From 8eeb196515261a440b8825c0a240a4f473fafd29 Mon Sep 17 00:00:00 2001 From: ymq Date: Wed, 26 Aug 2026 15:17:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(delete=5Fproject)+feat(cleanup=5Forphans):?= =?UTF-8?q?=20=E5=88=A0=E9=99=A4=E9=A1=B9=E7=9B=AE=E8=A1=A5=20task=5Fid=20?= =?UTF-8?q?=E7=BA=A7=E8=81=94(sd=5Fdeliverables/artifacts/task=5Fsteps/hum?= =?UTF-8?q?an=5Ftasks)+sd=5Fproject=5Frole=5Fmodels=EF=BC=9B=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=AD=A4=E5=84=BF=E6=B8=85=E6=89=AB=E8=83=BD=E5=8A=9B?= =?UTF-8?q?(=E6=8C=87=E5=90=91=E5=B7=B2=E5=88=A0=E9=A1=B9=E7=9B=AE/?= =?UTF-8?q?=E8=BF=AD=E4=BB=A3/=E4=BB=BB=E5=8A=A1=E7=9A=84=E6=AE=8B?= =?UTF-8?q?=E7=95=99=EF=BC=8Cdry=5Frun=E5=8F=AF=E5=85=88=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_service/project_capability.py | 131 +++++++++++++++++++++++++ pipeline_service/sdlc_ability.py | 10 ++ 2 files changed, 141 insertions(+) diff --git a/pipeline_service/project_capability.py b/pipeline_service/project_capability.py index 5255c0e..0b5a42d 100644 --- a/pipeline_service/project_capability.py +++ b/pipeline_service/project_capability.py @@ -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_ci,sd_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 必须为 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, 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)}" diff --git a/pipeline_service/sdlc_ability.py b/pipeline_service/sdlc_ability.py index 581cc24..7b7103b 100644 --- a/pipeline_service/sdlc_ability.py +++ b/pipeline_service/sdlc_ability.py @@ -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=true,dry_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,