diff --git a/pipeline_service/project_capability.py b/pipeline_service/project_capability.py index 0b5a42d..98d1673 100644 --- a/pipeline_service/project_capability.py +++ b/pipeline_service/project_capability.py @@ -463,62 +463,50 @@ async def delete_project(project_id, who=None, agent_id=None, confirm=False): # ── 孤儿清扫 ──────────────────────────────────────────────────────── # -# 历史缺陷与手工测试会留下「指向已删项目/迭代/任务的孤儿行」。本函数逐表清扫: -# 孤儿判据 = 外键列非空,但其指向的主键在父表中已不存在。全部幂等,可反复执行。 +# 历史缺陷与手工测试会留下「指向已删项目/迭代/任务的孤儿行」。本函数逐表清扫。 # -# 列 collation 不一(sd_projects.id 是 unicode_ci,sd_project_repos/pipeline_deliverables/ -# audit_log 的引用列是 general_ci),跨表比较处显式 COLLATE utf8mb4_unicode_ci 规避 1267。 +# 孤儿判据 = 引用列【非空】且指向的主键在父表中已不存在。 +# 注意:空串 '' 与 NULL 都表示「未挂载」(合法),不是孤儿——若不加 `<> ''` 守卫, +# `NOT IN` 会把空串当成孤儿误删(实测 sd_conversations 32 条 / sd_features 23 条 iteration_id='' 被误判)。 +# +# 列 collation 不一(sd_projects.id / sd_iterations.id / pipeline_tasks.id 是 unicode_ci; +# sd_project_repos / pipeline_deliverables / sd_conversations 的引用列是 general_ci), +# 跨表比较处显式 COLLATE utf8mb4_unicode_ci 规避 1267。 +# (label, 表, 引用列, 父表, 是否需 COLLATE) _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)"), + ("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), + ("sd_conversations(无迭代)", "sd_conversations", "iteration_id", "sd_iterations", True), + ("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), + ("sd_conversations(无任务)", "sd_conversations", "task_id", "pipeline_tasks", 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: @@ -548,7 +536,8 @@ async def cleanup_orphans(confirm=False, dry_run=False): async with db.sqlorContext(dbname) as sor: results = [] total = 0 - for label, cnt_sql, del_sql in _ORPHAN_CLEANUPS: + 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", {})