diff --git a/pipeline_service/project_capability.py b/pipeline_service/project_capability.py index 98d1673..d1f2174 100644 --- a/pipeline_service/project_capability.py +++ b/pipeline_service/project_capability.py @@ -444,6 +444,11 @@ async def delete_project(project_id, who=None, agent_id=None, confirm=False): "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: @@ -495,6 +500,10 @@ _ORPHAN_CLEANUPS = [ ("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), ] diff --git a/pipeline_service/workspace.py b/pipeline_service/workspace.py index 5fb1f77..8090e92 100644 --- a/pipeline_service/workspace.py +++ b/pipeline_service/workspace.py @@ -105,7 +105,23 @@ async def get_session_project_id(sor, uid, session_id=''): session_id 非空时优先读 pipeline_session_settings(web 多 tab 各自项目上下文), 无记录或表不存在时回退全局 pipeline_agent_settings.current_project_id。 + + 存在性校验(悬空引用防护):记录指向的项目若已从 sd_projects 删除, + 视为无效——顺手清掉死记录(自愈,避免每次请求重复命中),继续回退全局; + 全局也无效则返回 ''。若不清理,删除项目后该会话的所有项目相关入口 + (工作空间/任务/菜单)都会被死引用遮蔽,报「请先在会话中切换项目」。 """ + async def _valid(pid): + """pid 对应项目存在则原样返回;不存在返回 '';查询异常保守放行(不因校验误伤功能)。""" + if not pid: + return '' + try: + recs = await sor.sqlExe( + "SELECT 1 FROM sd_projects WHERE id=${p}$ LIMIT 1", {"p": pid}) + return pid if recs else '' + except Exception: + return pid + if session_id: try: recs = await sor.sqlExe( @@ -115,14 +131,37 @@ async def get_session_project_id(sor, uid, session_id=''): if recs: pid = getattr(recs[0], 'current_project_id', '') or '' if pid: - return pid + vpid = await _valid(pid) + if vpid: + return vpid + # 悬空引用:清掉死记录,回退全局 + try: + await sor.sqlExe( + "DELETE FROM pipeline_session_settings " + "WHERE user_id=${u}$ AND session_id=${s}$", + {"u": uid, "s": session_id}) + await sor.sqlExe("COMMIT", {}) + except Exception: + pass except Exception: pass try: recs = await sor.sqlExe( "SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", {"u": uid}) - return getattr(recs[0], 'current_project_id', '') if recs else '' + pid = getattr(recs[0], 'current_project_id', '') if recs else '' + if pid: + vpid = await _valid(pid) + if vpid: + return vpid + try: + await sor.sqlExe( + "UPDATE pipeline_agent_settings SET current_project_id='' " + "WHERE user_id=${u}$", {"u": uid}) + await sor.sqlExe("COMMIT", {}) + except Exception: + pass + return '' except Exception: return '' @@ -130,33 +169,37 @@ async def get_session_project_id(sor, uid, session_id=''): async def get_session_context(sor, uid, session_id=''): """按会话解析 (project_id, iteration_id)。per-session 优先,回退 per-user 全局。 - 多 tab 隔离:session_id 非空时优先读 pipeline_session_settings(每个 user+session 一条), - 无记录回退 pipeline_agent_settings.current_project_id/current_iteration_id。 + 项目 id 统一走 get_session_project_id(含悬空引用防护:指向已删除项目的 + 记录视为无效并自愈清理)。禁止在本函数里重复实现解析逻辑——两份实现 + 语义漂移正是「会话有项目、工作空间说没有」这类 bug 的根因。 + iteration_id 只从提供有效 pid 的同一条记录取,避免 pid 与 iid 来自不同 + 记录造成错配。 """ + pid = await get_session_project_id(sor, uid, session_id) + if not pid: + return '', '' + iid = '' if session_id: try: recs = await sor.sqlExe( "SELECT current_project_id, current_iteration_id FROM pipeline_session_settings " "WHERE user_id=${u}$ AND session_id=${s}$", {"u": uid, "s": session_id}) - if recs: - pid = getattr(recs[0], 'current_project_id', '') or '' + if recs and (getattr(recs[0], 'current_project_id', '') or '') == pid: iid = getattr(recs[0], 'current_iteration_id', '') or '' - if pid or iid: - return pid, iid except Exception: pass - try: - recs = await sor.sqlExe( - "SELECT current_project_id, current_iteration_id FROM pipeline_agent_settings " - "WHERE user_id=${u}$", - {"u": uid}) - if recs: - return (getattr(recs[0], 'current_project_id', '') or '', - getattr(recs[0], 'current_iteration_id', '') or '') - except Exception: - pass - return '', '' + if not iid: + try: + recs = await sor.sqlExe( + "SELECT current_project_id, current_iteration_id FROM pipeline_agent_settings " + "WHERE user_id=${u}$", + {"u": uid}) + if recs and (getattr(recs[0], 'current_project_id', '') or '') == pid: + iid = getattr(recs[0], 'current_iteration_id', '') or '' + except Exception: + pass + return pid, iid async def get_workspace_dir(sor, uid, session_id=''):