基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
关联 issue:#951
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。ec5af33b 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
packages/core/query-client.ts:7—staleTime: Infinity,cache 永不主动过期packages/core/query-client.ts:9—refetchOnWindowFocus: false,tab 重新获得焦点也不 refetch- 依赖 WS 推送
issue:created/issue:updated事件 invalidate cache
但 WS 层存在一个不对称:
- 服务端:
server/internal/realtime/hub.go:83-96, 420-475有 54s ping / 60s pongWait,会清理死连接 - 客户端:
packages/core/api/ws-client.ts(142 行全貌)完全没有心跳检测,只靠onclose事件触发重连
浏览器原生 WebSocket API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
- 浏览器
readyState仍是OPEN onclose不触发ws-client.ts:70-73的 3 秒重连逻辑不跑packages/core/realtime/use-realtime-sync.ts:462-487的onReconnect全量 invalidate 不跑- 期间的 WS 事件进黑洞
- cache 保持旧快照
浏览器 DevTools 里的 "Block request URL" 不行 —— 那会触发 onclose,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
方法 A(推荐,最接近真实场景):macOS 用 pfctl 丢包
# 假设后端在 8080
sudo pfctl -E
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
# 观察:
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
# - Network 里 WS 连接仍显示 Pending / 101
# 用另一个账号/CLI 创建一个 issue
# 回到原客户端: 列表不更新
# 登出再登入: 列表恢复完整
sudo pfctl -d # 解除方法 B(不动网络):临时修改代码,在 packages/core/api/ws-client.ts:52 的 onmessage 处理器里加一行 return; 在前面,吞掉所有入站消息。效果等价于半开。
在 ws-client.ts 加客户端侧的心跳检测:记录 lastMessageTime,定时器检查若超过 N 秒没收到任何消息就主动 ws.close(),触发现有重连逻辑。
- 优点:从根本上解决半开问题
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新
lastMessageTime;服务端改 + 客户端改
在 packages/core/platform/core-provider.tsx 加 visibilitychange 监听,tab 重新可见时强制 queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })(及其他关键 key)。
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
- 短期上 B,立刻止血
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
- 可选加
refetchOnWindowFocus: true或把staleTime改成一个有限值(比如 5 min),作为第三层保险
| 方案 | 文件 | 改动规模 |
|---|---|---|
| B | packages/core/platform/core-provider.tsx |
~10 行 |
| A 客户端 | packages/core/api/ws-client.ts |
~30 行 |
| A 服务端 | server/internal/realtime/hub.go |
加 app-level heartbeat message |
修完之后:
- 跑方法 A 复现流程,确认数据不再丢失
- 加 e2e 测试:模拟
document.dispatchEvent(new Event('visibilitychange'))+ 验证 issue list 被 refetch
关联 issue:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
注意:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
当前 workspace 身份完全靠 X-Workspace-ID HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 /issues、/issues/:id 这种 workspace-agnostic 的。
数据库和 API 已经支持 slug:
server/migrations/001_init.up.sql:15-23— workspace 表有slug TEXT UNIQUE NOT NULLserver/pkg/db/queries/workspace.sql:11-13— 有GetWorkspaceBySlug查询packages/core/types/workspace.ts:8-19— Workspace 类型里有 slug 字段
但前端路由和导航层没用它:
- Web 路由:
apps/web/app/(dashboard)/下 25 个 route file 都是 workspace-implicit - Desktop 路由:
apps/desktop/src/renderer/src/routes.tsx:71-143同样 - Navigation 适配器
apps/web/platform/navigation.tsx直接透传router.push,没有任何 workspace 前缀逻辑
workspace 切换只靠 sidebar UI(packages/views/layout/app-sidebar.tsx:284-286):
if (ws.id !== workspace?.id) {
push("/issues"); // 硬跳 /issues(workspace-implicit!)
switchWorkspace(ws); // 然后改 store
}这种设计使得:
- 手机端因为没 sidebar UI,也没 URL 层切换入口,完全切不了 workspace(MUL-509)
- 把
/issues/xxx链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列) - 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
- MUL-723:登录 → 观察地址栏,没有任何 workspace 标识
- MUL-43:
- 加入两个 workspace A 和 B
- 在 A 中打开某个 issue
/issues/abc123 - 切到 B,URL 不变 → 访问失败 / 显示错数据
- MUL-509:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
所有路径加上 workspace slug 前缀。例如 /issues/abc123 → /ws/my-team/issues/abc123。
要改的地方:
- Web 路由目录结构:
apps/web/app/(dashboard)/下全部搬到apps/web/app/(dashboard)/ws/[slug]/...(~25 个文件) - Desktop 路由:
apps/desktop/src/renderer/src/routes.tsx:71-143给所有路径加/ws/:slug前缀 - Navigation 适配器:
apps/web/platform/navigation.tsx—push(path)内部前置/ws/${workspace.slug},pathname读取时去掉前缀apps/desktop/src/renderer/src/platform/navigation.tsx— 同上
- Sidebar 切换逻辑:
packages/views/layout/app-sidebar.tsx:284-286改成push('/ws/${ws.slug}/issues')(或依赖适配器自动加前缀就不用改) - 服务端中间件:
server/internal/middleware/workspace.go:41-46增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
预计改动:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
不改也能工作的部分:
packages/core/api/client.ts— 仍旧走 header,不用改- 所有
packages/views/下的组件 —— 它们用useNavigation().push()抽象,适配器层处理前缀就行
风险:
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
- E2E 测试需要更新所有 URL 断言
URL 形如 /issues?ws=my-team。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
在 switchWorkspace 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。不推荐。
| 模块 | 文件数 | 备注 |
|---|---|---|
| Web routes | ~25 | 目录搬迁 |
| Desktop routes | 1 | 路径前缀 |
| Navigation adapters | 2 | 前缀逻辑 |
| Server middleware | 1-2 | slug → ID 解析 |
| 组件(不用改) | 30-40 | 用 useNavigation 的不受影响 |
| E2E tests | 20-30 | URL 断言更新 |
关联 issue:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
同上,这两个编号建议交接时核对症状。
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 createWorkspaceAwareStorage(key 后缀加 wsId 自动隔离),但 useNavigationStore 是个例外:它持久化了 lastPath,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
packages/core/navigation/store.ts:15-31:
export const useNavigationStore = create<NavigationState>()(
persist(
(set) => ({
lastPath: "/issues",
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
partialize: (state) => ({ lastPath: state.lastPath }),
}
)
);
// ← 没有调 registerForWorkspaceRehydration对比:其他 store 都是正确的:
| Store | 是否 workspace-aware | 是否注册 rehydration |
|---|---|---|
| useNavigationStore | ❌ | ❌ |
| useIssuesScopeStore | ✅ | ✅ |
| useIssueDraftStore | ✅ | ✅ |
| useRecentIssuesStore | ✅ | ✅ |
| useIssueViewStore | ✅ | ✅ |
| myIssuesViewStore | ✅ | ✅ |
| useChatStore | ✅(手动用 wsKey) | ✅ |
另外 packages/core/platform/storage-cleanup.ts:10-19 的 WORKSPACE_SCOPED_KEYS 列表里也漏了 multica_navigation。
现有的 workaround:packages/views/layout/app-sidebar.tsx:285 切 workspace 时硬跳到 /issues,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
- 在 workspace A 中打开一个具体 issue
/issues/abc123 - 切到 workspace B
- 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到
/issues/abc123,但那个 issue 不属于 B,导致 404 或错误
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
三处改动:
packages/core/navigation/store.ts:28—— 把createPersistStorage(defaultStorage)改成createWorkspaceAwareStorage(defaultStorage)- 同文件在末尾加:
registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate()); packages/core/platform/storage-cleanup.ts:10-19的WORKSPACE_SCOPED_KEYS数组里加"multica_navigation"
可选:清理 packages/views/layout/app-sidebar.tsx:285 的 push("/issues") workaround(改完之后不再需要)。
| 文件 | 改动 |
|---|---|
packages/core/navigation/store.ts |
改 storage 类型、加 rehydration 注册(~3 行) |
packages/core/platform/storage-cleanup.ts |
数组加一行 |
packages/core/platform/workspace-storage.test.ts |
加 rehydration 的单测 |
packages/views/layout/app-sidebar.tsx(可选) |
移除硬跳 workaround |
风险:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
关联 issue:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 onSuccess 和各处 UI 回调里,没有统一抽象。几个具体 bug:
根因:两个 onSuccess 回调同时跑,顺序不确定。
packages/core/workspace/mutations.ts:7-21的useCreateWorkspace.onSuccess里调了switchWorkspace(newWs)—— 同步改 Zustand,/issues路由开始用新 workspace 渲染packages/views/modals/create-workspace.tsx:68-70的 UIonSuccess里调了router.push("/onboarding")—— 异步 schedule 导航
于是:/issues 先渲染(闪一下)→ 导航到 /onboarding。
修复:把 switchWorkspace 从 mutation 里拿出来,让 UI 层主导。在 create-workspace.tsx 的 onSuccess 里先 switchWorkspace 再 push,保证同一个微任务里完成。
文件:packages/core/workspace/mutations.ts、packages/views/modals/create-workspace.tsx、可能 packages/views/onboarding/step-workspace.tsx
核查结果:packages/views/settings/components/workspace-tab.tsx:102-119, 236-255 已经有 AlertDialog 确认了。
真实问题:删除成功后没有导航,用户停在 /settings,而当前 workspace 已经是删除后系统挑的另一个。
修复:在 handleDeleteWorkspace 的 onConfirm 成功分支里加 push("/issues")。
文件:packages/views/settings/components/workspace-tab.tsx(加一行)
核查结果:有两条路径:
- ✅
/invite/:id独立页(packages/views/invite/invite-page.tsx:32-52)是正确的:accept → switchWorkspace → push("/issues") - ❌ Sidebar 下拉里的 "Join" 按钮(
packages/views/layout/app-sidebar.tsx:203-209, 321-324)是错的:只 invalidate cache,不切也不跳
修复(推荐 Option 2):Sidebar 的 "Join" 改成跳转到 /invite/:id 页面,不再就地接受。单一入口、单一行为。
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
{inv.workspace_name}
</DropdownMenuItem>文件:packages/views/layout/app-sidebar.tsx(~10 行)
| Issue | 步骤 |
|---|---|
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 /issues 再跳 /onboarding |
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 /settings 页面(BUG: 没有自动跳走) |
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切) |
抽一个 useWorkspaceLifecycle hook 统一管这些副作用。Agent 报告里有完整设计,文件:packages/core/workspace/hooks.ts(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
| Issue | 文件 | 改动规模 |
|---|---|---|
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
| MUL-728 | workspace-tab.tsx | ~1 行 |
| MUL-820 | app-sidebar.tsx | ~10 行 |
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|---|---|---|---|---|
| 1. WS 半开 + 陈旧 cache | #951 | P0 | Option B ~10 行;Option C ~1-2 天 | 低 |
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁) | 中(影响面大、e2e 要改) |
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
- 立刻做:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
- 同步开始:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
- 规划立项:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
- 次要修补:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
- Issue 编号核对:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
- MUL-728 实际状态:确认对话框已经存在,真实缺的是"删除后跳走"。
- MUL-820 实际状态:
/invite/:id页面路径工作正常,只是 sidebar 下拉按钮坏了。
packages/core/query-client.ts:7-10 # staleTime: Infinity
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
server/internal/middleware/workspace.go:41-46 # wsId resolution
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在