fix(workspace): 悬空项目引用防护——解析层校验+自愈清理+删除时清指针

根因:删除项目后不清理 pipeline_session_settings/pipeline_agent_settings 里的
当前项目指针;解析逻辑(会话级优先)又不校验项目是否存在,悬空引用遮蔽有效
的全局设置,导致工作空间报「请先在会话中切换项目」(实测复现:孤儿项目
0x0EISBKAOsvHzW09dIOS 卡死会话级解析)。

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

View File

@ -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 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}$",
# 会话/全局「当前项目」指针:清空而非删行(行里还有 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}$", "DELETE FROM sd_projects WHERE id=${pid}$",
] ]
for sql in direct: for sql in direct:
@ -495,6 +500,10 @@ _ORPHAN_CLEANUPS = [
("sd_features(无任务)", "sd_features", "task_id", "pipeline_tasks", False), ("sd_features(无任务)", "sd_features", "task_id", "pipeline_tasks", False),
("sd_iterations(无任务)", "sd_iterations", "task_id", "pipeline_tasks", False), ("sd_iterations(无任务)", "sd_iterations", "task_id", "pipeline_tasks", False),
("sd_conversations(无任务)", "sd_conversations", "task_id", "pipeline_tasks", True), ("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),
] ]

View File

@ -105,7 +105,23 @@ async def get_session_project_id(sor, uid, session_id=''):
session_id 非空时优先读 pipeline_session_settings(web 多 tab 各自项目上下文), session_id 非空时优先读 pipeline_session_settings(web 多 tab 各自项目上下文),
无记录或表不存在时回退全局 pipeline_agent_settings.current_project_id。 无记录或表不存在时回退全局 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: if session_id:
try: try:
recs = await sor.sqlExe( recs = await sor.sqlExe(
@ -115,14 +131,37 @@ async def get_session_project_id(sor, uid, session_id=''):
if recs: if recs:
pid = getattr(recs[0], 'current_project_id', '') or '' pid = getattr(recs[0], 'current_project_id', '') or ''
if pid: 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: except Exception:
pass pass
try: try:
recs = await sor.sqlExe( recs = await sor.sqlExe(
"SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", "SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$",
{"u": uid}) {"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: except Exception:
return '' return ''
@ -130,33 +169,37 @@ async def get_session_project_id(sor, uid, session_id=''):
async def get_session_context(sor, uid, session_id=''): async def get_session_context(sor, uid, session_id=''):
"""按会话解析 (project_id, iteration_id)。per-session 优先,回退 per-user 全局。 """按会话解析 (project_id, iteration_id)。per-session 优先,回退 per-user 全局。
多 tab 隔离:session_id 非空时优先读 pipeline_session_settings(每个 user+session 一条), 项目 id 统一走 get_session_project_id(含悬空引用防护:指向已删除项目的
无记录回退 pipeline_agent_settings.current_project_id/current_iteration_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: if session_id:
try: try:
recs = await sor.sqlExe( recs = await sor.sqlExe(
"SELECT current_project_id, current_iteration_id FROM pipeline_session_settings " "SELECT current_project_id, current_iteration_id FROM pipeline_session_settings "
"WHERE user_id=${u}$ AND session_id=${s}$", "WHERE user_id=${u}$ AND session_id=${s}$",
{"u": uid, "s": session_id}) {"u": uid, "s": session_id})
if recs: if recs and (getattr(recs[0], 'current_project_id', '') or '') == pid:
pid = getattr(recs[0], 'current_project_id', '') or ''
iid = getattr(recs[0], 'current_iteration_id', '') or '' iid = getattr(recs[0], 'current_iteration_id', '') or ''
if pid or iid:
return pid, iid
except Exception: except Exception:
pass pass
try: if not iid:
recs = await sor.sqlExe( try:
"SELECT current_project_id, current_iteration_id FROM pipeline_agent_settings " recs = await sor.sqlExe(
"WHERE user_id=${u}$", "SELECT current_project_id, current_iteration_id FROM pipeline_agent_settings "
{"u": uid}) "WHERE user_id=${u}$",
if recs: {"u": uid})
return (getattr(recs[0], 'current_project_id', '') or '', if recs and (getattr(recs[0], 'current_project_id', '') or '') == pid:
getattr(recs[0], 'current_iteration_id', '') or '') iid = getattr(recs[0], 'current_iteration_id', '') or ''
except Exception: except Exception:
pass pass
return '', '' return pid, iid
async def get_workspace_dir(sor, uid, session_id=''): async def get_workspace_dir(sor, uid, session_id=''):