pipeline-service/pipeline_service/project_capability.py
ymq 98b90d4f21 fix(workspace): 悬空项目引用防护——解析层校验+自愈清理+删除时清指针
根因:删除项目后不清理 pipeline_session_settings/pipeline_agent_settings 里的
当前项目指针;解析逻辑(会话级优先)又不校验项目是否存在,悬空引用遮蔽有效
的全局设置,导致工作空间报「请先在会话中切换项目」(实测复现:孤儿项目
0x0EISBKAOsvHzW09dIOS 卡死会话级解析)。

修复(根治,三层):
1. get_session_project_id 加存在性校验:悬空记录自愈清理并回退全局,
   查询异常保守放行不误伤
2. get_session_context 收敛为复用前者,消除重复实现的语义漂移
3. delete_project 删项目时清空两张设置表的指针;孤儿清扫清单纳入这两表
2026-08-27 17:44:37 +08:00

570 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""项目能力 — 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" # 已归档
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="web_app", description="",
org_id="0", created_by="", who=None, agent_id=None):
"""创建项目新建记录status=draft。返回 (ok, project_id_or_message)。"""
if not name or not name.strip():
return False, "缺少项目名称"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid = getID()
await sor.C(TABLE, {
'id': pid,
'name': name.strip(),
'description': description or '',
'project_type': project_type or 'web_app',
'status': S_DRAFT,
'org_id': org_id or '0',
'created_by': created_by 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 是否为项目 ownersd_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 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, ''
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 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 强制归档)。"""
ok, msg = await _transition(project_id, S_COMPLETED, S_ARCHIVED, 'archive',
who=who, agent_id=agent_id)
if ok:
return ok, msg
return await _transition(project_id, S_ACTIVE, S_ARCHIVED, 'archive',
who=who, agent_id=agent_id)
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):
"""暂停推进active → paused。暂停后 PM 不再推进项目(不认领审核任务、不创建后续任务),
直到 resume_project 恢复。仅用户明确指令「暂停推进」时才调用——默认必须推进,无需指令。"""
return await _transition(project_id, S_ACTIVE, S_PAUSED, 'pause',
who=who, agent_id=agent_id)
async def resume_project(project_id, who=None, agent_id=None):
"""恢复推进paused → active。"""
return await _transition(project_id, S_PAUSED, S_ACTIVE, 'resume',
who=who, agent_id=agent_id)
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, limit=50) -> list:
"""列出项目(可按组织/状态过滤org_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
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 记录对象转成 dictsqlor 行是 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}$))",
'sd_conversations': "SELECT * FROM sd_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci 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 sd_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci 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})
# 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_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 = [
("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),
# 「当前项目」指针:指向已删项目的会话/全局设置记录。解析层已能自愈回退,
# 这里清扫是存量脏数据的批量兜底(历史 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)}"