711 lines
34 KiB
Python
711 lines
34 KiB
Python
"""项目能力 — sd_projects 的状态机语义化迁移(通用、产线无关)。
|
||
|
||
定位:项目 CRUD 走 xls2ui 生成的端点;本模块只做「生命周期状态流转」——
|
||
每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。
|
||
|
||
项目的生命周期/流转规则在 project skill 里(LLM 读 skill 判断合法性);本模块只固化操作原语。
|
||
|
||
角色规范:role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}。
|
||
|
||
scope 约定:sd_projects 是顶层容器,用 id 唯一标识(CAS WHERE 带 id 即可),
|
||
org_id 用于 list 过滤(租户隔离);audit 的 tenant_id 用 project_id(项目即最外层容器)。
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import json
|
||
import re
|
||
import tarfile
|
||
import tempfile
|
||
import shutil
|
||
from datetime import datetime
|
||
from sqlor.dbpools import DBPools
|
||
from appPublic.uniqueID import getID
|
||
from .audit import record_audit
|
||
|
||
DBNAME = "pipeline"
|
||
logger = logging.getLogger("pipeline.project_capability")
|
||
|
||
TABLE = "sd_projects"
|
||
|
||
# 项目状态(SDLC 默认,状态机语义见 project skill)
|
||
S_DRAFT = "draft" # 草稿(已创建,未启动)
|
||
S_ACTIVE = "active" # 进行中
|
||
S_PAUSED = "paused" # 已暂停(暂停推进,PM 不再推进项目,除非恢复)
|
||
S_COMPLETED = "completed" # 已完成
|
||
S_ARCHIVED = "archived" # 已归档
|
||
|
||
# 「进行中」状态集合(跨产线状态兼容,2026-09-01):
|
||
# SDLC 产线用 active;投标/商机等产线用 in_progress。
|
||
# pause/resume 必须对两者都生效,否则非 SDLC 产线项目无法暂停。
|
||
S_ACTIVE_LIKE = {S_ACTIVE, "in_progress"}
|
||
|
||
|
||
def _get_db():
|
||
db = DBPools()
|
||
if not db.databases:
|
||
from appPublic.jsonConfig import getConfig
|
||
config = getConfig()
|
||
if config.databases:
|
||
db.databases = config.databases
|
||
return db, DBNAME
|
||
|
||
|
||
def _normalize_role(role):
|
||
"""角色规范:agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 保留原样。"""
|
||
role = (role or "").strip()
|
||
if not role:
|
||
return ""
|
||
if "." in role:
|
||
return role
|
||
return f"agent.{role}"
|
||
|
||
|
||
async def _transition(project_id, from_state, to_state, action,
|
||
who=None, agent_id=None, detail=None):
|
||
"""CAS 状态迁移 + 审计。返回 (ok, message)。"""
|
||
if not project_id:
|
||
return False, "缺少 project_id"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
await sor.sqlExe(
|
||
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
|
||
"WHERE id=${pid}$ AND status=${from}$",
|
||
{"to": to_state, "pid": project_id, "from": from_state})
|
||
recs = await sor.R(TABLE, {'id': project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "项目不存在"
|
||
cur = getattr(recs[0], 'status', '')
|
||
if cur != to_state:
|
||
return False, f"状态迁移失败(CAS): 期望 from={from_state} 实际 status={cur}"
|
||
await record_audit(project_id, TABLE, project_id, action,
|
||
from_state=from_state, to_state=to_state,
|
||
who=who, agent_id=agent_id, detail=detail, sor=sor)
|
||
return True, to_state
|
||
|
||
|
||
async def create_project(name, project_type="general", description="",
|
||
org_id="0", created_by="", who=None, agent_id=None,
|
||
pipeline_id="", default_model=""):
|
||
"""创建项目:新建记录,status=draft。返回 (ok, project_id_or_message)。
|
||
|
||
pipeline_id 必须与建目录用的 space 一致(工作空间解析 space=pipeline_id,
|
||
不一致会导致「上传落盘目录 ≠ agent 工作目录」错位)。缺省 'general' 表示
|
||
通用会话项目,不挂任何产线。
|
||
"""
|
||
if not name or not name.strip():
|
||
return False, "缺少项目名称"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
pid = getID()
|
||
space = pipeline_id or 'general'
|
||
# 创建时定死项目目录并写库(统一机制:解析只读库不推导,
|
||
# 保证上传落盘=agent工作目录=工作控件显示目录)
|
||
from .workspace import alloc_project_dir
|
||
ws_path, dname = await alloc_project_dir(
|
||
sor, org_id or '0', space, name.strip(), pid)
|
||
await sor.C(TABLE, {
|
||
'id': pid,
|
||
'name': name.strip(),
|
||
'description': description or '',
|
||
'project_type': project_type or 'general',
|
||
'status': S_DRAFT,
|
||
'org_id': org_id or '0',
|
||
'created_by': created_by or '',
|
||
'workspace_dir': ws_path,
|
||
'directory_name': dname,
|
||
'pipeline_id': space,
|
||
'default_model': default_model or '',
|
||
})
|
||
await record_audit(pid, TABLE, pid, 'create',
|
||
to_state=S_DRAFT, who=_normalize_role(who),
|
||
agent_id=agent_id, sor=sor)
|
||
logger.info("create_project: %s name=%s", pid, name.strip())
|
||
return True, pid
|
||
|
||
|
||
async def check_project_owner(project_id, user_id, sor=None):
|
||
"""校验 user_id 是否为项目 owner(sd_projects.created_by)。
|
||
|
||
独立开 context(供 pipeline-task 等 dspy 直接调用);传入 sor 时复用它(供
|
||
pipeline-sdlc dspy 在已有 get_sor_context 内调用,避免嵌套 context 的 MDL 锁)。
|
||
返回 (True, '') 或 (False, 错误信息)。
|
||
"""
|
||
if not project_id:
|
||
return False, "缺少 project_id"
|
||
if not user_id:
|
||
return False, "未登录"
|
||
|
||
async def _check(_sor):
|
||
recs = await _sor.sqlExe(
|
||
"SELECT created_by, name, org_id FROM sd_projects WHERE id=${pid}$",
|
||
{"pid": project_id})
|
||
await _sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "项目不存在"
|
||
owner = getattr(recs[0], 'created_by', '') or ''
|
||
if owner == user_id:
|
||
return True, ''
|
||
# owner 降级(2026-09-01 修复):项目由 agent 创建(created_by='agent.*')时
|
||
# 没有任何人类是字面 owner,owner 门禁会永久拒绝,导致用户无法管理自己产线里
|
||
# agent 建的项目。此时降级为「同机构人类」:项目 org_id == 请求用户的 orgid 即放行。
|
||
if owner.startswith("agent."):
|
||
proj_org = str(getattr(recs[0], 'org_id', '') or '')
|
||
u = await _sor.sqlExe(
|
||
"SELECT orgid FROM users WHERE id=${uid}$", {"uid": user_id})
|
||
await _sor.sqlExe("COMMIT", {})
|
||
if u:
|
||
user_org = str(getattr(u[0], 'orgid', '') or '')
|
||
if user_org and user_org == proj_org:
|
||
return True, ''
|
||
return False, "项目由 agent 创建,仅同机构成员可操作"
|
||
return False, "仅项目 owner 可执行此操作"
|
||
|
||
if sor is not None:
|
||
return await _check(sor)
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as _s:
|
||
return await _check(_s)
|
||
|
||
|
||
async def check_task_owner(task_id, user_id):
|
||
"""校验 user_id 是否为 task 所属 SDLC 项目的 owner。
|
||
|
||
SDLC 任务 tenant_id=project_id,须校验 owner;通用引擎任务 tenant_id=org_id(非
|
||
项目 id),放行返回 (True, '')。独立开 context(供 pipeline-task dspy 调用)。
|
||
"""
|
||
if not task_id:
|
||
return False, "缺少 task_id"
|
||
if not user_id:
|
||
return False, "未登录"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT tenant_id FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "任务不存在"
|
||
tenant = getattr(recs[0], 'tenant_id', '') or ''
|
||
p = await sor.sqlExe(
|
||
"SELECT id FROM sd_projects WHERE id=${pid}$", {"pid": tenant})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not p:
|
||
# 通用引擎任务(tenant_id 非 SDLC 项目),无 owner 概念,放行
|
||
return True, ''
|
||
return await check_project_owner(tenant, user_id, sor)
|
||
|
||
|
||
async def check_tenant_owner(tenant_id, user_id):
|
||
"""校验 user_id 是否为 tenant_id 所属 SDLC 项目的 owner。非项目 tenant 放行。
|
||
|
||
独立开 context(供 pipeline-task 的 task_submit 等 dspy 调用)。
|
||
"""
|
||
if not user_id:
|
||
return False, "未登录"
|
||
if not tenant_id:
|
||
return True, ''
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
p = await sor.sqlExe(
|
||
"SELECT id FROM sd_projects WHERE id=${pid}$", {"pid": tenant_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not p:
|
||
return True, ''
|
||
return await check_project_owner(tenant_id, user_id, sor)
|
||
|
||
|
||
async def start_project(project_id, who=None, agent_id=None):
|
||
"""启动项目:draft → active(也兼容 archived → active 重新激活)。"""
|
||
ok, msg = await _transition(project_id, S_DRAFT, S_ACTIVE, 'start',
|
||
who=who, agent_id=agent_id)
|
||
if ok:
|
||
return ok, msg
|
||
return await _transition(project_id, S_ARCHIVED, S_ACTIVE, 'start',
|
||
who=who, agent_id=agent_id)
|
||
|
||
|
||
async def _clear_project_todos(sor, project_id):
|
||
"""删除项目全部待办:人类任务 + 冒泡问题(待办角标/列表的两个来源)。
|
||
关联链:project_id 直挂 / iteration_id 经迭代 / task_id 经任务 / tenant_id(questions)。
|
||
调用方保证在 sd_iterations / pipeline_tasks 删除之前执行(子查询依赖)。
|
||
"""
|
||
if not project_id:
|
||
return
|
||
for sql in [
|
||
"DELETE FROM pipeline_human_tasks WHERE project_id=${pid}$",
|
||
"DELETE FROM pipeline_human_tasks WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
"DELETE FROM pipeline_human_tasks WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$)",
|
||
"DELETE FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
|
||
"DELETE FROM pipeline_agent_questions WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$)",
|
||
]:
|
||
try:
|
||
await sor.sqlExe(sql, {"pid": project_id})
|
||
except Exception as e:
|
||
logger.warning("clear_project_todos failed: %s err=%s", sql, e)
|
||
await sor.sqlExe("COMMIT", {})
|
||
logger.info("clear_project_todos: project=%s done", project_id)
|
||
|
||
|
||
async def complete_project(project_id, who=None, agent_id=None):
|
||
"""完成项目:active → completed。"""
|
||
return await _transition(project_id, S_ACTIVE, S_COMPLETED, 'complete',
|
||
who=who, agent_id=agent_id)
|
||
|
||
|
||
async def archive_project(project_id, who=None, agent_id=None):
|
||
"""归档项目:completed → archived(也兼容 active → archived 强制归档)。
|
||
归档成功后清空项目全部待办(2026-09-01)。"""
|
||
ok, msg = await _transition(project_id, S_COMPLETED, S_ARCHIVED, 'archive',
|
||
who=who, agent_id=agent_id)
|
||
if not ok:
|
||
ok, msg = await _transition(project_id, S_ACTIVE, S_ARCHIVED, 'archive',
|
||
who=who, agent_id=agent_id)
|
||
if ok:
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
await _clear_project_todos(sor, project_id)
|
||
return ok, msg
|
||
|
||
|
||
async def reopen_project(project_id, who=None, agent_id=None):
|
||
"""重新打开:archived → active。"""
|
||
return await _transition(project_id, S_ARCHIVED, S_ACTIVE, 'reopen',
|
||
who=who, agent_id=agent_id)
|
||
|
||
|
||
async def pause_project(project_id, who=None, agent_id=None):
|
||
"""暂停推进:进行中 → paused。暂停后 PM 不再推进项目(不认领审核任务、不创建后续任务),
|
||
直到 resume_project 恢复。仅用户明确指令「暂停推进」时才调用——默认必须推进,无需指令。
|
||
|
||
跨产线状态兼容(2026-09-01 修复):进行中状态不硬编码 active——
|
||
SDLC 产线是 active,投标/商机等产线是 in_progress,两者都可暂停。
|
||
暂停前的原状态记入审计 detail(prev_state=xxx),恢复时据此还原。
|
||
"""
|
||
if not project_id:
|
||
return False, "缺少 project_id"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.R(TABLE, {'id': project_id})
|
||
if not recs:
|
||
await sor.sqlExe("COMMIT", {})
|
||
return False, "项目不存在"
|
||
cur = getattr(recs[0], 'status', '') or ''
|
||
if cur not in S_ACTIVE_LIKE:
|
||
await sor.sqlExe("COMMIT", {})
|
||
return False, f"无法暂停:项目状态 {cur}(仅 {sorted(S_ACTIVE_LIKE)} 可暂停)"
|
||
await sor.sqlExe(
|
||
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
|
||
"WHERE id=${pid}$ AND status=${from}$",
|
||
{"to": S_PAUSED, "pid": project_id, "from": cur})
|
||
recs2 = await sor.R(TABLE, {'id': project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if getattr(recs2[0], 'status', '') != S_PAUSED:
|
||
return False, f"状态迁移失败(CAS): 期望 to={S_PAUSED} 实际 status={getattr(recs2[0], 'status', '')}"
|
||
# 原状态记入审计(恢复时还原的依据)
|
||
await record_audit(project_id, TABLE, project_id, 'pause',
|
||
from_state=cur, to_state=S_PAUSED,
|
||
who=_normalize_role(who), agent_id=agent_id,
|
||
detail=f"prev_state={cur}", sor=sor)
|
||
# 2026-09-15 修复:暂停不再删除待办。暂停语义是「可逆停摆」,删 pending 人类
|
||
# 任务/冒泡问题是破坏性动作——实测(首医项目)pause 删掉待办后 resume,
|
||
# human_task_qc 的检查对象消失 → qc_reject 计轮死循环 → 项目再次 failed 暂停。
|
||
# 待办清理只保留给 archive/delete(项目终态,遗留待办确属无意义)。
|
||
return True, S_PAUSED
|
||
|
||
|
||
async def resume_project(project_id, who=None, agent_id=None):
|
||
"""恢复推进:paused → 原状态(优先还原暂停前的状态,跨产线兼容)。
|
||
|
||
SDLC 项目还原为 active;投标/商机项目若暂停前是 in_progress 则还原为
|
||
in_progress(2026-09-01:原实现硬编码还原 active,会把投标项目
|
||
改成产线状态机里不存在的状态)。查不到暂停审计时兜底还原 active。
|
||
"""
|
||
if not project_id:
|
||
return False, "缺少 project_id"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.R(TABLE, {'id': project_id})
|
||
if not recs:
|
||
await sor.sqlExe("COMMIT", {})
|
||
return False, "项目不存在"
|
||
cur = getattr(recs[0], 'status', '') or ''
|
||
if cur != S_PAUSED:
|
||
await sor.sqlExe("COMMIT", {})
|
||
return False, f"无法恢复:项目状态 {cur}(仅 {S_PAUSED} 可恢复)"
|
||
# 查最近一次暂停审计,还原暂停前状态
|
||
restore_to = S_ACTIVE
|
||
audits = await sor.sqlExe(
|
||
"SELECT detail FROM audit_log WHERE tenant_id=${pid}$ AND entity=${e}$ "
|
||
"AND action='pause' ORDER BY created_at DESC LIMIT 1",
|
||
{"pid": project_id, "e": TABLE})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if audits:
|
||
d = getattr(audits[0], 'detail', '') or ''
|
||
if d.startswith("prev_state="):
|
||
prev = d[len("prev_state="):].strip()
|
||
if prev in S_ACTIVE_LIKE:
|
||
restore_to = prev
|
||
await sor.sqlExe(
|
||
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
|
||
"WHERE id=${pid}$ AND status=${from}$",
|
||
{"to": restore_to, "pid": project_id, "from": S_PAUSED})
|
||
recs2 = await sor.R(TABLE, {'id': project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if getattr(recs2[0], 'status', '') != restore_to:
|
||
return False, f"状态迁移失败(CAS): 期望 to={restore_to} 实际 status={getattr(recs2[0], 'status', '')}"
|
||
await record_audit(project_id, TABLE, project_id, 'resume',
|
||
from_state=S_PAUSED, to_state=restore_to,
|
||
who=_normalize_role(who), agent_id=agent_id, sor=sor)
|
||
return True, restore_to
|
||
|
||
|
||
async def set_project_state(project_id, from_state, to_state,
|
||
who=None, agent_id=None, detail=None):
|
||
"""通用 CAS 状态迁移兜底(跨产线自定义状态机用)。"""
|
||
return await _transition(project_id, from_state, to_state, 'set_state',
|
||
who=who, agent_id=agent_id, detail=detail)
|
||
|
||
|
||
async def list_projects(org_id=None, status=None, pipeline_id=None, limit=50) -> list:
|
||
"""列出项目(可按组织/状态/产线过滤;org_id 空则列全部)。
|
||
|
||
产线隔离:传 pipeline_id 时只列该产线的项目(投标/商机等产线会话
|
||
不串到开发产线项目)。不传则不按产线过滤。
|
||
"""
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
conditions = ["1=1"]
|
||
params = {}
|
||
if org_id:
|
||
conditions.append("org_id=${org}$")
|
||
params["org"] = org_id
|
||
if status:
|
||
conditions.append("status=${status}$")
|
||
params["status"] = status
|
||
if pipeline_id:
|
||
conditions.append("pipeline_id=${pl}$")
|
||
params["pl"] = pipeline_id
|
||
where = " AND ".join(conditions)
|
||
try:
|
||
limit = int(limit)
|
||
except (TypeError, ValueError):
|
||
limit = 50
|
||
sql = (f"SELECT * FROM {TABLE} WHERE {where} "
|
||
f"ORDER BY created_at DESC LIMIT {limit}")
|
||
recs = await sor.sqlExe(sql, params)
|
||
# 释放 SELECT 元数据锁
|
||
await sor.sqlExe("COMMIT", {})
|
||
result = []
|
||
for rec in (recs or []):
|
||
result.append(_rec_to_dict(rec))
|
||
return result
|
||
|
||
|
||
def _rec_to_dict(rec):
|
||
"""把 sqlor 记录对象转成 dict(sqlor 行是 DictObject,必须 dict(rec) 取列)。"""
|
||
if isinstance(rec, dict):
|
||
return dict(rec)
|
||
try:
|
||
return dict(rec)
|
||
except (TypeError, ValueError):
|
||
if hasattr(rec, 'to_dict'):
|
||
try:
|
||
return rec.to_dict()
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
async def _dump_project_records(sor, project_id):
|
||
"""导出项目及关联表全部记录(归档备份 JSON 用)。"""
|
||
dump = {}
|
||
# 直接关联(project_id / tenant_id)
|
||
direct = {
|
||
'sd_projects': "SELECT * FROM sd_projects WHERE id=${pid}$",
|
||
'sd_iterations': "SELECT * FROM sd_iterations WHERE project_id=${pid}$",
|
||
'pipeline_tasks': "SELECT * FROM pipeline_tasks WHERE tenant_id=${pid}$",
|
||
'pipeline_deliverables': "SELECT * FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
'pipeline_project_agents': "SELECT * FROM pipeline_project_agents WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
'pipeline_agent_questions': "SELECT * FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
|
||
'sd_deploy_envs': "SELECT * FROM sd_deploy_envs WHERE project_id=${pid}$",
|
||
'sd_features': "SELECT * FROM sd_features WHERE project_id=${pid}$",
|
||
'sd_project_repos': "SELECT * FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
}
|
||
for tbl, sql in direct.items():
|
||
try:
|
||
recs = await sor.sqlExe(sql, {"pid": project_id})
|
||
dump[tbl] = [_rec_to_dict(r) for r in (recs or [])]
|
||
except Exception as e:
|
||
dump[tbl] = {"_error": f"{type(e).__name__}: {str(e)[:200]}"}
|
||
|
||
# 间接关联(iteration_id 通过子查询;pipeline_conversations 的 iteration_id 存的是 project_id)
|
||
indirect = {
|
||
'sd_bugs': "SELECT * FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
'sd_test_plans': "SELECT * FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
'sd_test_cases': "SELECT * FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
|
||
'pipeline_conversations': "SELECT * FROM pipeline_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci=${pid}$ OR iteration_id COLLATE utf8mb4_unicode_ci IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
'audit_log': "SELECT * FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$ ORDER BY created_at ASC",
|
||
}
|
||
for tbl, sql in indirect.items():
|
||
try:
|
||
recs = await sor.sqlExe(sql, {"pid": project_id})
|
||
dump[tbl] = [_rec_to_dict(r) for r in (recs or [])]
|
||
except Exception as e:
|
||
dump[tbl] = {"_error": f"{type(e).__name__}: {str(e)[:200]}"}
|
||
|
||
return dump
|
||
|
||
|
||
async def backup_project(project_id, who=None, agent_id=None):
|
||
"""归档备份:打包项目工作目录 + 导出数据库记录 → tgz 到「机构工作目录/_archive/」。
|
||
|
||
返回 (ok, tgz_path_or_message)。删除项目前必须调用,保证可回退。
|
||
"""
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.R(TABLE, {'id': project_id})
|
||
if not recs:
|
||
await sor.sqlExe("COMMIT", {})
|
||
return False, "项目不存在"
|
||
p = recs[0]
|
||
pname = getattr(p, 'name', '') or project_id
|
||
org_id = getattr(p, 'org_id', '0') or '0'
|
||
ws = getattr(p, 'workspace_dir', '') or ''
|
||
status = getattr(p, 'status', '')
|
||
|
||
from .workspace import get_workspace_base
|
||
workspace_base = await get_workspace_base(sor)
|
||
if not (ws or '').startswith('/'):
|
||
ws = os.path.join(workspace_base, str(org_id), pname)
|
||
|
||
# 机构工作目录下的备份归档目录
|
||
archive_dir = os.path.join(workspace_base, str(org_id), '_archive')
|
||
os.makedirs(archive_dir, exist_ok=True)
|
||
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
safe_name = re.sub(r'[^\w\u4e00-\u9fff.-]', '_', pname)
|
||
tgz_path = os.path.join(archive_dir, f"{safe_name}_{timestamp}.tgz")
|
||
|
||
# 导出数据库记录
|
||
dump = await _dump_project_records(sor, project_id)
|
||
|
||
try:
|
||
with tarfile.open(tgz_path, 'w:gz') as tar:
|
||
# 数据库记录 JSON
|
||
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False, encoding='utf-8') as f:
|
||
json.dump(dump, f, ensure_ascii=False, default=str)
|
||
tmp_json = f.name
|
||
tar.add(tmp_json, arcname='project_data.json')
|
||
os.unlink(tmp_json)
|
||
# 工作目录文件(若存在)
|
||
if ws and os.path.isdir(ws):
|
||
tar.add(ws, arcname='workspace')
|
||
except Exception as e:
|
||
logger.error("backup_project tar failed: %s err=%s", project_id, e)
|
||
return False, f"打包失败: {type(e).__name__}: {str(e)[:200]}"
|
||
|
||
await record_audit(project_id, TABLE, project_id, 'backup',
|
||
to_state=status, who=_normalize_role(who),
|
||
agent_id=agent_id, detail=tgz_path, sor=sor)
|
||
logger.info("backup_project: %s -> %s", project_id, tgz_path)
|
||
return True, tgz_path
|
||
|
||
|
||
async def delete_project(project_id, who=None, agent_id=None, confirm=False):
|
||
"""删除项目:先归档备份,再删除关联表记录 + sd_projects + 工作目录。
|
||
|
||
confirm 必须为 True(删除前二次确认)。备份失败则中止删除。
|
||
返回 (ok, message)。
|
||
"""
|
||
if not confirm:
|
||
return False, "删除项目需二次确认(confirm=true),请先确认再执行"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
# 1. 归档备份(失败则中止删除)
|
||
ok, backup_msg = await backup_project(project_id, who=who, agent_id=agent_id)
|
||
if not ok:
|
||
return False, f"归档备份失败,已中止删除:{backup_msg}"
|
||
|
||
# 读工作目录(删除数据库后清理物理文件)
|
||
recs = await sor.R(TABLE, {'id': project_id})
|
||
if not recs:
|
||
await sor.sqlExe("COMMIT", {})
|
||
return False, "项目不存在"
|
||
p = recs[0]
|
||
pname = getattr(p, 'name', '') or project_id
|
||
org_id = getattr(p, 'org_id', '0') or '0'
|
||
ws = getattr(p, 'workspace_dir', '') or ''
|
||
from .workspace import get_workspace_base
|
||
workspace_base = await get_workspace_base(sor)
|
||
if not (ws or '').startswith('/'):
|
||
ws = os.path.join(workspace_base, str(org_id), pname)
|
||
|
||
# 2. 删除间接关联表(依赖迭代)
|
||
indirect = [
|
||
"DELETE FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
|
||
"DELETE FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
"DELETE FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
"DELETE FROM pipeline_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci=${pid}$ OR iteration_id COLLATE utf8mb4_unicode_ci IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
|
||
]
|
||
for sql in indirect:
|
||
await sor.sqlExe(sql, {"pid": project_id})
|
||
|
||
# 2a. 清空项目全部待办(2026-09-01):project_id 直挂 + iteration/task 关联。
|
||
# 必须在 sd_iterations / pipeline_tasks 删除之前(子查询依赖)。
|
||
await _clear_project_todos(sor, project_id)
|
||
|
||
# 2b. 任务关联表:先取本项目的任务 id,再删 task_id 指向它们的行
|
||
# (pipeline_deliverables 等同时有 project_id 和 task_id 两列,仅按 project_id
|
||
# 删会漏掉「项目删除时 task 已先被删、project_id 已失效」的残留)
|
||
tid_rows = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$", {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
task_ids = [getattr(r, 'id', '') for r in (tid_rows or []) if getattr(r, 'id', '')]
|
||
if task_ids:
|
||
tid_list = ",".join("'" + t.replace("'", "") + "'" for t in task_ids)
|
||
for sql in [
|
||
"DELETE FROM pipeline_deliverables WHERE task_id COLLATE utf8mb4_unicode_ci IN (" + tid_list + ")",
|
||
"DELETE FROM pipeline_artifacts WHERE task_id IN (" + tid_list + ")",
|
||
"DELETE FROM pipeline_task_steps WHERE task_id IN (" + tid_list + ")",
|
||
"DELETE FROM pipeline_human_tasks WHERE task_id IN (" + tid_list + ")",
|
||
]:
|
||
try:
|
||
await sor.sqlExe(sql, {})
|
||
except Exception as e:
|
||
logger.warning("delete_project task-linked cleanup failed: %s err=%s", sql, e)
|
||
|
||
# 3. 删除直接关联表(project_id / tenant_id)
|
||
direct = [
|
||
"DELETE FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
"DELETE FROM pipeline_project_agents WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
"DELETE FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
|
||
"DELETE FROM sd_deploy_envs WHERE project_id=${pid}$",
|
||
"DELETE FROM sd_features WHERE project_id=${pid}$",
|
||
"DELETE FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
"DELETE FROM sd_project_role_models WHERE project_id=${pid}$",
|
||
"DELETE FROM pipeline_tasks WHERE tenant_id=${pid}$",
|
||
"DELETE FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||
"DELETE FROM sd_iterations WHERE project_id=${pid}$",
|
||
# 会话/全局「当前项目」指针:清空而非删行(行里还有 iteration/llm 等其他设置)。
|
||
# 不清理会产生悬空引用:下次打开工作空间/菜单时按会话解析仍命中已删项目,
|
||
# 报「请先在会话中切换项目」——这正是本 bug 的源头。
|
||
"UPDATE pipeline_session_settings SET current_project_id='' WHERE current_project_id=${pid}$",
|
||
"UPDATE pipeline_agent_settings SET current_project_id='' WHERE current_project_id=${pid}$",
|
||
"DELETE FROM sd_projects WHERE id=${pid}$",
|
||
]
|
||
for sql in direct:
|
||
await sor.sqlExe(sql, {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
|
||
# 4. 删除工作目录(物理文件,已备份)
|
||
if ws and os.path.isdir(ws):
|
||
try:
|
||
shutil.rmtree(ws, ignore_errors=True)
|
||
except Exception as e:
|
||
logger.warning("delete_project rmtree failed: %s err=%s", ws, e)
|
||
|
||
logger.info("delete_project: %s done, backup=%s", project_id, backup_msg)
|
||
return True, f"项目已删除,归档备份在 {backup_msg}"
|
||
|
||
|
||
# ── 孤儿清扫 ────────────────────────────────────────────────────────
|
||
#
|
||
# 历史缺陷与手工测试会留下「指向已删项目/迭代/任务的孤儿行」。本函数逐表清扫。
|
||
#
|
||
# 孤儿判据 = 引用列【非空】且指向的主键在父表中已不存在。
|
||
# 注意:空串 '' 与 NULL 都表示「未挂载」(合法),不是孤儿——若不加 `<> ''` 守卫,
|
||
# `NOT IN` 会把空串当成孤儿误删(实测 sd_features 23 条 iteration_id='' 被误判)。
|
||
#
|
||
# 列 collation 不一(sd_projects.id / sd_iterations.id / pipeline_tasks.id 是 unicode_ci;
|
||
# sd_project_repos / pipeline_deliverables 的引用列是 general_ci),
|
||
# 跨表比较处显式 COLLATE utf8mb4_unicode_ci 规避 1267。
|
||
|
||
# (label, 表, 引用列, 父表, 是否需 COLLATE)
|
||
_ORPHAN_CLEANUPS = [
|
||
("sd_project_repos(无项目)", "sd_project_repos", "project_id", "sd_projects", True),
|
||
("sd_features(无项目)", "sd_features", "project_id", "sd_projects", False),
|
||
("sd_project_role_models(无项目)", "sd_project_role_models", "project_id", "sd_projects", False),
|
||
("sd_deploy_envs(无项目)", "sd_deploy_envs", "project_id", "sd_projects", False),
|
||
("pipeline_deliverables(无项目)", "pipeline_deliverables", "project_id", "sd_projects", True),
|
||
("sd_iterations(无项目)", "sd_iterations", "project_id", "sd_projects", False),
|
||
("sd_features(无迭代)", "sd_features", "iteration_id", "sd_iterations", False),
|
||
("sd_bugs(无迭代)", "sd_bugs", "iteration_id", "sd_iterations", False),
|
||
("sd_test_plans(无迭代)", "sd_test_plans", "iteration_id", "sd_iterations", False),
|
||
("pipeline_deliverables(无迭代)", "pipeline_deliverables", "iteration_id", "sd_iterations", True),
|
||
("sd_test_cases(无计划)", "sd_test_cases", "plan_id", "sd_test_plans", False),
|
||
("pipeline_deliverables(无任务)", "pipeline_deliverables", "task_id", "pipeline_tasks", True),
|
||
("pipeline_artifacts(无任务)", "pipeline_artifacts", "task_id", "pipeline_tasks", False),
|
||
("pipeline_task_steps(无任务)", "pipeline_task_steps", "task_id", "pipeline_tasks", False),
|
||
("pipeline_human_tasks(无任务)", "pipeline_human_tasks", "task_id", "pipeline_tasks", False),
|
||
("pipeline_agent_questions(无任务)", "pipeline_agent_questions", "task_id", "pipeline_tasks", False),
|
||
("sd_features(无任务)", "sd_features", "task_id", "pipeline_tasks", False),
|
||
("sd_iterations(无任务)", "sd_iterations", "task_id", "pipeline_tasks", False),
|
||
# 「当前项目」指针:指向已删项目的会话/全局设置记录。解析层已能自愈回退,
|
||
# 这里清扫是存量脏数据的批量兜底(历史 CRUD 删除不清理指针留下的)。
|
||
("pipeline_session_settings(无项目)", "pipeline_session_settings", "current_project_id", "sd_projects", True),
|
||
("pipeline_agent_settings(无项目)", "pipeline_agent_settings", "current_project_id", "sd_projects", True),
|
||
]
|
||
|
||
|
||
def _orphan_sql(label, table, col, parent, use_collate):
|
||
"""生成 (计数SQL, 删除SQL)。守卫:引用列非空(<> '' 排除 NULL 与空串)。"""
|
||
lhs = col + " COLLATE utf8mb4_unicode_ci" if use_collate else col
|
||
guard = f"{lhs} <> '' AND {lhs} NOT IN (SELECT id FROM {parent})"
|
||
cnt = f"SELECT COUNT(*) AS rcnt FROM {table} WHERE {guard}"
|
||
dlt = f"DELETE FROM {table} WHERE {guard}"
|
||
return cnt, dlt
|
||
|
||
|
||
def _count_rows(recs):
|
||
"""从 SELECT COUNT(*) AS rcnt 结果取整数;兼容 DictObject/dict/list。"""
|
||
if not recs:
|
||
return 0
|
||
r = recs[0]
|
||
if isinstance(r, dict):
|
||
v = r.get('rcnt') or r.get('count') or r.get('COUNT(*)')
|
||
return int(v or 0)
|
||
if isinstance(r, (list, tuple)):
|
||
return int(r[0] if r and r[0] is not None else 0)
|
||
v = getattr(r, 'rcnt', None)
|
||
if v is None:
|
||
v = getattr(r, 'count', None)
|
||
return int(v or 0)
|
||
|
||
|
||
async def cleanup_orphans(confirm=False, dry_run=False):
|
||
"""清扫指向已删项目/迭代/任务的孤儿行(幂等,可反复执行)。
|
||
|
||
confirm 必须为 True。dry_run=True 时只统计不删除。
|
||
逐表执行,单表失败不中断其余表。
|
||
返回 (ok, message),message 含每表行数汇总。
|
||
"""
|
||
if not confirm:
|
||
return False, "孤儿清扫需二次确认(confirm=true)"
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
results = []
|
||
total = 0
|
||
for label, table, col, parent, use_collate in _ORPHAN_CLEANUPS:
|
||
cnt_sql, del_sql = _orphan_sql(label, table, col, parent, use_collate)
|
||
try:
|
||
recs = await sor.sqlExe(cnt_sql, {})
|
||
await sor.sqlExe("COMMIT", {})
|
||
n = _count_rows(recs)
|
||
except Exception as e:
|
||
results.append(f"{label}: 跳过({type(e).__name__})")
|
||
continue
|
||
if n and not dry_run:
|
||
try:
|
||
await sor.sqlExe(del_sql, {})
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception as e:
|
||
results.append(f"{label}: 删除失败({type(e).__name__})")
|
||
continue
|
||
total += n
|
||
results.append(f"{label}: {n}")
|
||
verb = "发现" if dry_run else "清除"
|
||
logger.info("cleanup_orphans(%s): %s %d 行孤儿记录。明细: %s",
|
||
"dry_run" if dry_run else "执行", verb, total, "; ".join(results))
|
||
return True, f"孤儿清扫完成,{verb} {total} 行。明细:{';'.join(results)}"
|