"""项目能力 — 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 是否为项目 owner(sd_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 记录对象转成 dict(sqlor 行是 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)}"