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:
parent
ccba6f470e
commit
8eeb196515
@ -412,6 +412,26 @@ async def delete_project(project_id, who=None, agent_id=None, confirm=False):
|
|||||||
for sql in indirect:
|
for sql in indirect:
|
||||||
await sor.sqlExe(sql, {"pid": project_id})
|
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)
|
# 3. 删除直接关联表(project_id / tenant_id)
|
||||||
direct = [
|
direct = [
|
||||||
"DELETE FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
"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_deploy_envs WHERE project_id=${pid}$",
|
||||||
"DELETE FROM sd_features 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_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 pipeline_tasks WHERE tenant_id=${pid}$",
|
||||||
"DELETE FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
"DELETE FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$",
|
||||||
"DELETE FROM sd_iterations WHERE project_id=${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)
|
logger.info("delete_project: %s done, backup=%s", project_id, backup_msg)
|
||||||
return True, f"项目已删除,归档备份在 {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)}"
|
||||||
|
|||||||
@ -188,6 +188,7 @@ SDL_TOOLS = [
|
|||||||
ToolDefinition(name="resume_project", description="恢复项目推进(paused→active)", parameters={"project_id": "项目ID"}, category="project"),
|
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="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="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="list_iterations", description="查看当前项目迭代列表", parameters={"status": "按状态筛选(可选)"}, category="iteration"),
|
||||||
ToolDefinition(name="create_iteration", description="创建迭代(planning)", parameters={"iteration_name": "迭代名称", "iteration_type": "迭代类型(可选)", "scope": "迭代范围(可选)"}, 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}"
|
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 ──
|
# ── 迭代 handler ──
|
||||||
|
|
||||||
async def _h_list_iterations(sor, p, ctx):
|
async def _h_list_iterations(sor, p, ctx):
|
||||||
@ -1755,6 +1764,7 @@ SDL_HANDLERS = {
|
|||||||
"resume_project": _h_resume_project,
|
"resume_project": _h_resume_project,
|
||||||
"backup_project": _h_backup_project,
|
"backup_project": _h_backup_project,
|
||||||
"delete_project": _h_delete_project,
|
"delete_project": _h_delete_project,
|
||||||
|
"cleanup_orphans": _h_cleanup_orphans,
|
||||||
# ── 迭代 ──
|
# ── 迭代 ──
|
||||||
"list_iterations": _h_list_iterations,
|
"list_iterations": _h_list_iterations,
|
||||||
"create_iteration": _h_create_iteration,
|
"create_iteration": _h_create_iteration,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user