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 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),
]

View File

@ -105,7 +105,23 @@ async def get_session_project_id(sor, uid, session_id=''):
session_id 非空时优先读 pipeline_session_settingsweb 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=''):