fix(delete_project)+feat(cleanup_orphans): 删除项目补 task_id 级联(sd_deliverables/artifacts/task_steps/human_tasks)+sd_project_role_models;新增孤儿清扫能力(指向已删项目/迭代/任务的残留,dry_run可先统计)

This commit is contained in:
ymq 2026-08-26 15:17:48 +08:00
parent ccba6f470e
commit 8eeb196515
2 changed files with 141 additions and 0 deletions

View File

@ -412,6 +412,26 @@ async def delete_project(project_id, who=None, agent_id=None, confirm=False):
for sql in indirect:
await sor.sqlExe(sql, {"pid": 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}$",
@ -420,6 +440,7 @@ async def delete_project(project_id, who=None, agent_id=None, confirm=False):
"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}$",
@ -438,3 +459,113 @@ async def delete_project(project_id, who=None, agent_id=None, confirm=False):
logger.info("delete_project: %s done, backup=%s", project_id, backup_msg)
return True, f"项目已删除,归档备份在 {backup_msg}"
# ── 孤儿清扫 ────────────────────────────────────────────────────────
#
# 历史缺陷与手工测试会留下「指向已删项目/迭代/任务的孤儿行」。本函数逐表清扫:
# 孤儿判据 = 外键列非空,但其指向的主键在父表中已不存在。全部幂等,可反复执行。
#
# 列 collation 不一sd_projects.id 是 unicode_cisd_project_repos/pipeline_deliverables/
# audit_log 的引用列是 general_ci跨表比较处显式 COLLATE utf8mb4_unicode_ci 规避 1267。
_ORPHAN_CLEANUPS = [
# (label, 计数SQL, 删除SQL) — 计数用 SELECT COUNT(*),删除用对应 DELETE
("sd_project_repos(无项目)",
"SELECT COUNT(*) AS rcnt FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM sd_projects)",
"DELETE FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM sd_projects)"),
("sd_features(无项目)",
"SELECT COUNT(*) AS rcnt FROM sd_features WHERE project_id NOT IN (SELECT id FROM sd_projects)",
"DELETE FROM sd_features WHERE project_id NOT IN (SELECT id FROM sd_projects)"),
("sd_project_role_models(无项目)",
"SELECT COUNT(*) AS rcnt FROM sd_project_role_models WHERE project_id NOT IN (SELECT id FROM sd_projects)",
"DELETE FROM sd_project_role_models WHERE project_id NOT IN (SELECT id FROM sd_projects)"),
("pipeline_deliverables(无项目)",
"SELECT COUNT(*) AS rcnt FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM sd_projects)",
"DELETE FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM sd_projects)"),
("pipeline_deliverables(无任务)",
"SELECT COUNT(*) AS rcnt FROM pipeline_deliverables WHERE task_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM pipeline_tasks)",
"DELETE FROM pipeline_deliverables WHERE task_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM pipeline_tasks)"),
("pipeline_artifacts(无任务)",
"SELECT COUNT(*) AS rcnt FROM pipeline_artifacts WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)",
"DELETE FROM pipeline_artifacts WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)"),
("pipeline_task_steps(无任务)",
"SELECT COUNT(*) AS rcnt FROM pipeline_task_steps WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)",
"DELETE FROM pipeline_task_steps WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)"),
("pipeline_human_tasks(无任务)",
"SELECT COUNT(*) AS rcnt FROM pipeline_human_tasks WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)",
"DELETE FROM pipeline_human_tasks WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)"),
("pipeline_agent_questions(无任务)",
"SELECT COUNT(*) AS rcnt FROM pipeline_agent_questions WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)",
"DELETE FROM pipeline_agent_questions WHERE task_id NOT IN (SELECT id FROM pipeline_tasks)"),
("sd_features(无迭代)",
"SELECT COUNT(*) AS rcnt FROM sd_features WHERE iteration_id NOT IN (SELECT id FROM sd_iterations)",
"DELETE FROM sd_features WHERE iteration_id NOT IN (SELECT id FROM sd_iterations)"),
("sd_bugs(无迭代)",
"SELECT COUNT(*) AS rcnt FROM sd_bugs WHERE iteration_id NOT IN (SELECT id FROM sd_iterations)",
"DELETE FROM sd_bugs WHERE iteration_id NOT IN (SELECT id FROM sd_iterations)"),
("sd_test_plans(无迭代)",
"SELECT COUNT(*) AS rcnt FROM sd_test_plans WHERE iteration_id NOT IN (SELECT id FROM sd_iterations)",
"DELETE FROM sd_test_plans WHERE iteration_id NOT IN (SELECT id FROM sd_iterations)"),
("sd_test_cases(无计划)",
"SELECT COUNT(*) AS rcnt FROM sd_test_cases WHERE plan_id NOT IN (SELECT id FROM sd_test_plans)",
"DELETE FROM sd_test_cases WHERE plan_id NOT IN (SELECT id FROM sd_test_plans)"),
("sd_conversations(无迭代)",
"SELECT COUNT(*) AS rcnt FROM sd_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM sd_iterations)",
"DELETE FROM sd_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci NOT IN (SELECT id FROM sd_iterations)"),
("sd_iterations(无项目)",
"SELECT COUNT(*) AS rcnt FROM sd_iterations WHERE project_id NOT IN (SELECT id FROM sd_projects)",
"DELETE FROM sd_iterations WHERE project_id NOT IN (SELECT id FROM sd_projects)"),
]
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 必须为 Truedry_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, cnt_sql, del_sql in _ORPHAN_CLEANUPS:
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)}"

View File

@ -188,6 +188,7 @@ SDL_TOOLS = [
ToolDefinition(name="resume_project", description="恢复项目推进(paused→active)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="backup_project", description="归档备份项目(打包工作目录+数据记录为tgz到机构工作目录_archive/)", parameters={"project_id": "项目ID"}, category="project"),
ToolDefinition(name="delete_project", description="删除项目(需二次确认confirm=true删除前自动归档备份tgz)", parameters={"project_id": "项目ID", "confirm": "二次确认必须为true"}, category="project"),
ToolDefinition(name="cleanup_orphans", description="清扫指向已删项目/迭代/任务的孤儿残留记录(需confirm=truedry_run=true只统计不删)", parameters={"confirm": "二次确认必须为true", "dry_run": "true=只统计不删除(可选)"}, category="admin"),
# ── 迭代 ──
ToolDefinition(name="list_iterations", description="查看当前项目迭代列表", parameters={"status": "按状态筛选(可选)"}, category="iteration"),
ToolDefinition(name="create_iteration", description="创建迭代(planning)", parameters={"iteration_name": "迭代名称", "iteration_type": "迭代类型(可选)", "scope": "迭代范围(可选)"}, category="iteration"),
@ -1226,6 +1227,14 @@ async def _h_delete_project(sor, p, ctx):
return f"OK: {msg}" if ok else f"ERROR: {msg}"
async def _h_cleanup_orphans(sor, p, ctx):
confirm = (p.get("confirm", "") or "").strip().lower()
dry_run = (p.get("dry_run", "") or "").strip().lower() == "true"
from .project_capability import cleanup_orphans
ok, msg = await cleanup_orphans(confirm=(confirm == "true"), dry_run=dry_run)
return f"OK: {msg}" if ok else f"ERROR: {msg}"
# ── 迭代 handler ──
async def _h_list_iterations(sor, p, ctx):
@ -1755,6 +1764,7 @@ SDL_HANDLERS = {
"resume_project": _h_resume_project,
"backup_project": _h_backup_project,
"delete_project": _h_delete_project,
"cleanup_orphans": _h_cleanup_orphans,
# ── 迭代 ──
"list_iterations": _h_list_iterations,
"create_iteration": _h_create_iteration,