diff --git a/.doc/audit/2026-09-17-system-health-check.md b/.doc/audit/2026-09-17-system-health-check.md new file mode 100644 index 00000000..6d42da41 --- /dev/null +++ b/.doc/audit/2026-09-17-system-health-check.md @@ -0,0 +1,180 @@ +--- +title: "系统全面体检:任务收口、缺陷与未启用功能审计" +status: done +created: 2026-09-17 +updated: 2026-09-17 +severity: high +--- + +# 系统全面体检(2026-09-17) + +> 范围:① 规划未实现 / 需变更 / 实施中任务;② 现存与潜在 bug;③ 已实现但未启用或未测试的功能。 +> 方法:文档 SSOT 盘点(CURRENT / FEATURE-MATRIX / gap-matrix / short-term / long-term / backlog / ADR / handoff)+ Go 全量 vet/build/test + 前端 test/build + 特性开关与环境配置交叉核对。只读审计,未实施任何修改。 +> 基线验证:`go vet ./...` 与 `go build ./...` 全绿;`go test -short -count=1 ./...` 62 包全 ok;`web` npm test(380+32)与 build 通过。 + +--- + +## 一、任务状态盘点 + +### 1.1 实施中(in_progress) + +| # | 任务 | 位置 | 剩余工作 | +|---|------|------|----------| +| T1 | partial/deferred 全量收口总控 | `.doc/ops/short-term/2026-09-16-partial-deferred-completion.md`(owner Cline) | W5(SWBOOT-10 真实现 + 真实 PI + UI 二次验收)、W6(FAV shadow→live 放量)、W7(D7 按 ADR 整改,待合规签字)、逐行升 done | +| T2 | Control Center 个人中控台 | `.doc/ops/short-term/2026-09-17-control-center-server.md`(owner Kimi) | 后端 P0–P6 已签;**唯一剩余:前端 SPA 七页人工浏览器走查** | +| T3 | 纯软件项目自举 W5b | `.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md` | SWBOOT-10 受控执行器(真 patch/merge)未启动,需独立安全评审,当前 fail-closed stub | + +### 1.2 已规划未开始 + +| # | 任务 | 位置 | +|---|------|------| +| T4 | 首次加载性能 U1/U3–U7 | `.doc/remediation/2026-09-17-initial-load-performance-plan.md`(仅 U2 done:主 JS 传输 2.82 MB→~624 KB) | +| T5 | 选择性模块解耦 M4–M6 | `.doc/ops/long-term/2026-09-14-selective-module-decoupling.md`(planned,owner=TBD) | +| T6 | 产品 backlog:B5 报价复核、A5/A6 小队降级与 trace、A7 中文注释迁移、D5 KB 按环境隔离评估 | `.doc/backlog/chat-agent-p6-backlog.md`(均无任务文件) | +| T7 | `TestReconcile_LegacyRepair_PG` 工程测试债 | gap-matrix DOC-CONTENT-CANONICAL-HASH-DRIFT 行(需隔离 PG,本机无) | +| T8 | IAM 收尾开放项:410 退役产品拍板、R13 全量清单勾选 | handoff `2026-09-16-phase3-leftover-closeout.md` | + +### 1.3 部分完成(partial,剩余关闭条件) + +| 行 | 剩余关闭条件 | +|----|--------------| +| 纯软件项目自举 | SWBOOT-10 非 stub(W5b)+ 真实 PI 全链路 + UI 二次验收 | +| FAV | W6 shadow 已合;live 未放量(staging→artifact→publication 各 ≥7 天观察窗) | +| Control Center | 前端人工走查 PASS 后升 done | +| 首次加载性能 | U1/U3–U7 | +| 文档可靠性试点 | M1 结论「停止扩面」;M2(U5–U12)明确禁止开工,真实 PI/浏览器验收未勾 | + +### 1.4 deferred 待决策 + +- **D7 对话数据治理**:ADR `2026-09-17-d7-chat-data-retention.md` 仍 `pending-compliance-signoff`;W7 盘点未发现违规;签字前禁止物理删除。 +- **`SEMANTIC_SUMMARY_REPLACE`**:决策默认不启用;启用须另开授权 + fail-closed 灰度。 +- **`MULTI_INTENT_PLAN_TIER` 升 confirm**:W4 证据闭环,升级需独立授权;永不默认 full。 + +### 1.5 文档漂移 / 矛盾(文档债) + +| # | 漂移 | 涉及文件 | +|---|------|----------| +| D-1 | PU8 四份契约文件仍 `status: in_progress`,但 U9–U12 已升 done、报告已归档 | `.doc/ops/long-term/2026-09-16-pu8-u9/u10/u11/u12-*.md` | +| D-2 | ADR modular-monolith-decoupling 仍「提议,等待用户确认」,但 Phase 1/2 已落地并被当作已确认结论引用 | `.doc/adr/2026-09-14-modular-monolith-decoupling.md` | +| D-3 | audit/INDEX.md 与 remediation/INDEX.md 末行称首次加载性能「方案未实施」,与 U2 已验收矛盾 | 两个 INDEX.md(**本审计已顺手修正为准确表述**) | +| D-4 | handoff `2026-09-15-conversation-intent-dual-tab.md` 的「未完成」已被后续 PASS 覆盖但未回标 | 该 handoff | +| D-5 | backlog D3/D7 条目与 W2 done、W7/ADR 草案并存,backlog 未同步 | `.doc/backlog/chat-agent-p6-backlog.md` | +| D-6 | PROJECT.md §9「剩余 blocker」表述与 gap-matrix「门禁已签、production_ready=yes」口径冲突 | `PROJECT.md` | + +--- + +## 二、Bug 清单 + +### 2.1 确认 bug(有明确证据) + +**后端** + +| ID | 描述 | 证据 | 严重度 | +|----|------|------|--------| +| B-1 | **SSE 聊天流被全局 WriteTimeout=120s 强杀**:`server.go:350` 的 WriteTimeout 是绝对期限;`handleChatStream`(`sse.go:129`)从不调用 `extendSSEWriteDeadline`、无心跳,该机制只接到了 run-events 订阅端点。任何 >120s 的聊天回合(长 PI 推演常见)必断流 | `internal/server/server.go:350`、`internal/server/sse.go:46-64,129` | high | +| B-2 | **`POST /tasks/run/stream` 同样无 deadline 延长/心跳**,且断线后无法续订:`sse.go:390` 写死 `Subscribe(created.RunID, 0)`,不接受 `after_seq` | `internal/server/sse.go:305-424,390` | high | +| B-3 | `handleRunTaskStream` **静默吞 JSON 解析错误**:`_ = json.NewDecoder(r.Body).Decode(&req)`,畸形 body 以空请求走默认 `TaskKey="intake"`,可能误启动 intake 任务 | `internal/server/sse.go:315,362-364` | medium | +| B-4 | `handleRunTaskStream` **Flush 200 SSE 头之后才做会失败的操作**(CreateAndStart/Subscribe),失败时往 SSE 流里写 JSON 错误体;同文件 chat 路径明确「授权必须在 Flush 前」,task 路径未对齐 | `internal/server/sse.go:355-360,383-393` | medium | + +**前端** + +| ID | 描述 | 证据 | 严重度 | +|----|------|------|--------| +| B-5 | **会话窗口缓存 key 不一致**:读/初次写带 `userId` 前缀,但 `loadOlder`/`loadFullDay`/`jumpToDate`/`loadDayForAnchor`/`appendLiveMessage` 等 7 处后续写入不带前缀 → 刷新/切会话恢复到旧窗口;且 LRU 驱逐只用无前缀 key,带前缀缓存永不驱逐,localStorage 只增不减 | `web/src/hooks/useChatWindow.ts:84,100,136,235,271,315,365,416,439,471`、`web/src/data/storage.ts:164-176` | high | +| B-6 | **mount 时 IndexedDB 恢复用陈旧快照整体覆盖 deliverables**:捕获 mount 闭包、非函数式 set,resolve 前新增的 deliverable 被静默丢弃(旁边已有 `deliverablesRef` 未用) | `web/src/App.tsx:326-336` | medium | +| B-7 | `useTaskRunStream` 返回**伪 ref**(每渲染新建对象、快照值非 live),且全仓库无消费方——误导性 API | `web/src/hooks/useTaskRunStream.ts:96` | low | + +### 2.2 高风险隐患 + +| ID | 描述 | 证据 | +|----|------|------| +| R-1 | **聊天会话无归属校验**:`getOrCreateSession` 仅凭客户端传入 `session_id` 返回既有内存会话,随后直接覆盖 `sess.UserID`;非项目会话(ProjectID==0)无任何授权关卡,内存 Messages/Facts 可进入他人 Prompt 与 SSE。缓解:session ID 为纳秒时间戳不易猜 | `internal/server/server.go:394-415`、`handlers.go:181`、`sse.go:187`、`stage_access_helpers.go:251-253` | +| R-2 | **18 个非测试文件 `rows.Next()` 循环后未查 `rows.Err()`**,中途断连静默截断结果集(ctxmgr 历史/窗口/搜索、docgov engine、freedoc、storage/cas、controlcenter tasks、`cmd/document-content-reconcile` 漏检会让 reconcile 漏报文档) | 如 `internal/ctxmgr/chat_history.go`、`internal/docgov/engine.go`、`cmd/document-content-reconcile/main.go:82` | +| R-3 | 事件 dispatcher `Handlers` map **写持锁、读不持锁**(当前仅启动期注册则良性,动态注册即 race) | `internal/events/worker.go:87,154,234` | +| R-4 | `memTurns` 全局幂等表只增不删且跨实例共享,长期运行内存缓增、测试间可能串扰 | `internal/ctxmgr/chat_turns.go:169` | +| R-5 | `internal/docrevision/pg_store.go:68-208` 十余处 `_ = json.Unmarshal(...)`,账本 JSONB 损坏时静默零值化 | 同左 | +| R-6 | 后台 ticker goroutine 无关停通道(通知提醒、vault Drain 错误被丢弃、bizdev 心跳用 `context.Background()`),`Shutdown` 未覆盖 | `notification_reminder_loop.go:15`、`vault.go:63`、`bizdev/service.go:682` | +| R-7 | SSE 重连对 401/404 无 fail-fast:任何错误一律退避重连 40 次(~3 分钟)期间 UI 一直 thinking;`streamChat` 对畸形 data 帧直接抛错中断整条流 | `web/src/api/taskRuns.ts:108-140`、`api/chatStream.ts:122` | +| R-8 | 组件卸载/登出不 abort 任务流,在途 SSE 继续写 localStorage | `web/src/App.tsx:595,2804` | + +### 2.3 环境 / 配置缺陷 + +| ID | 描述 | 证据 | 严重度 | +|----|------|------|--------| +| E-1 | **main 环境 app `ENV=prod` 与 events `ENV=main` 不一致**:事件信封以 `Environment="prod"` 写入,而 `event_consumer_modes` 只种子 dev/test/main 三行 → app 侧 `WriterLive("prod")` 恒 false(in-process 直连不退出),events worker 按 'main' 判定 → **main 存在双写或 durable 管道空转风险**(取决于 main 行 mode)。dev/test 两服务 ENV 一致,无此问题 | `docker-compose.main.yml:90,160`、`internal/app/docflow/commands.go:276`、`goose/00040:55-59`、`internal/events/dispatcher.go:308-313` | **critical(main 专属)** | +| E-2 | **main schema 落后两版(47 vs dev 49)**:00048 给 projects 加 13 列,当前 HEAD 代码直接 SELECT/INSERT 这些列;00049 建 system_settings。若把 dev HEAD 镜像升 main 而不先跑迁移,项目工作流查询/系统设置 API 直接 SQL 报错。**main 晋升顺序必须 events(跑迁移)先于 app** | `goose/00048`、`goose/00049`、`internal/projectworkflow/store.go:76,113` | high(晋升窗口) | +| E-3 | `MULTI_INTENT_PLAN_TIER` 未在任何 compose environment 声明(configs/.env 设了也传不进容器);test/main 连 U9–U12 flag 都未声明 → 容器内锁死 legacy,授权升级时需先改 compose | `docker-compose.*.yml` | medium | +| E-4 | `CURSOR_RUNNER_URL` 三环境 compose 都注入但代码零读取;cursor-agent 容器是 alpine 占位,cursor-cli 后端容器部署不可达 | `docker-compose.*.yml` | low | +| E-5 | `ENABLE_DEV_IMPORT` 不在任何 compose/.env.example;且判断把 `cfg.Env==""` 也算 dev——配置缺失时导入门禁自动放开 | `internal/server/chat_window_api.go:171` | medium | +| E-6 | `SEED_DEMO_USERS` 仅 test compose 显式 false;dev 依赖默认播种(demo 密码 123456),main 靠代码 `Env!="dev/test"` 兜底,主 compose 未显式关闭 | `docker-compose.test.yml:91`、`internal/auth/bootstrap.go:67-116` | low | +| E-7 | `INTENT_V2_DECISION_TIER` 非法值回退 `query`(激进),与 `MULTI_INTENT_PLAN_TIER` 回退 `legacy`(保守)策略不一致 | `internal/intent/flags.go:47-64` | low | +| E-8 | `publisher_gate_test.go:16` 读 `FAV_PUBLISHER_ENABLE`(少 D),与正式变量名不符,该测试门永远走默认分支 | `internal/artifactvault/publisher_gate_test.go:16` | low | +| E-9 | main compose 的 system-db initdb 只挂 00001_baseline.sql,与 dev/test 不一致;新建卷时初始脚本漂移(依赖 events 启动迁移补齐) | `docker-compose.main.yml:23` | low | + +--- + +## 三、已实现但未启用 / 未测试的功能 + +### 3.1 特性开关封存(实现完整,全环境 off) + +| 功能 | 实现位置 | 状态与门禁 | +|------|----------|-----------| +| FAV 事件订阅者 | `internal/app/fav/subscriber.go` | 三重硬门禁:cmd/events 刻意不注册、registry fail-closed、SetMode 拒绝非 off;仅 ObserveShadow 可达 | +| FAV 发布器 | `internal/server/vault.go:18-67` | `FAV_PUBLISHER_ENABLED=false` 三环境显式;且 VAULT_* 三变量 compose 全未声明,容器内即使开 flag 也会静默 return(E-类:实际不可误开,属设计内封存) | +| FAV 降级读 | `internal/docquery/service.go:70` | `FAV_DEGRADED_READ=false` 红线保持 | +| 多意图 PlanTier shadow/auto_read/confirm | `internal/app/intentflow/turn.go` | 容器内锁死 legacy(见 E-3) | +| U9–U12 扩面 | `intentflow/expansion.go`、`mux.go` | 全 false;dev compose 已声明可调,test/main 未声明 | +| 意图 V2 shadow/decision | `internal/intent/shadow_v2.go`、`v2_decision.go` | 三环境显式 false | +| 语义摘要 shadow | `internal/ctxmgr/semantic_summary.go:216` | 默认 off,且仅 AGENT_BACKEND=pi-agent 时挂载 | +| PI change-report 叙事 | `internal/docrevision/narrative.go:68` | 默认 off,仅确定性 fallback(不得冒充完整 PI) | +| artifact-vault CLI | `cmd/artifact-vault/main.go` | 未部署 stub | +| eventmode cutover CLI | `cmd/eventmode/main.go` | 硬门禁仅 dev 可用(`internal/events/cutover.go:13`) | + +### 3.2 死代码 / 机制先行无消费方 + +| # | 项 | 位置 | +|---|----|------| +| X-1 | `SEMANTIC_FALLBACK_ENABLED` 死 flag:无任何业务消费方,仅 sysadmin 展示;三环境 compose 都在设一个没用的变量 | `internal/intent/flags.go:43`、`internal/app/app.go:285` | +| X-2 | `SEMANTIC_SUMMARY_REPLACE` 读取后 `_=` 显式丢弃,刻意永久未接线 | `internal/ctxmgr/semantic_summary.go:257` | +| X-3 | goose 00048 三张新表 `project_source_bindings`/`repo_path_allowlist`/`project_type_workflows` 无代码消费方(swboot 目录硬编码内存版,RepoAllowlist 为 stub) | `internal/swboot/catalog.go:92,118`、`repo_allowlist.go` | +| X-4 | `system_settings` 白名单 6 键全部无业务消费方——可写不生效,纯机制先行 | `internal/sysadmin/settings.go:57-66` | +| X-5 | 前端死组件:`DocumentNotificationList.tsx`(含已读回写,功能完整无挂载点)、`WorkflowPrerequisiteBanner.tsx`、`user/SettingsModal.tsx`(已被 UserSettingsModal 取代);死 API:`api/projectWorkflow.ts:71 activateWorkflow`;`data/storage.ts:141 saveMessages` deprecated no-op | web/src 各文件 | +| X-6 | `useTaskRunStream` 伪 ref(同 B-7) | — | + +### 3.3 测试盲区 + +| # | 盲区 | 说明 | +|---|------|------| +| S-1 | `TestReconcile_LegacyRepair_PG` 从未执行 | 要求隔离 PG 且硬拒绝 5433/5434/5435;canonical-hash 修复链路零自动化验证(gap-matrix 已记测试债) | +| S-2 | 两个「永久跳过」 | `taskstream_test.go:189`(功能已删,测试成死代码应删除);`plan_owner_test.go:125`(重定向跳过) | +| S-3 | 无测试的 cmd 入口 | `cmd/server`、`cmd/events`、`cmd/control-center`、`cmd/artifact-vault`、`cmd/dataset`、`cmd/eventmode`、`cmd/recategorize`、`cmd/document-revision-backfill`(仅 bizdev-cli、document-content-reconcile 有);`internal/controlcenter/{config,store,tasks,webui}`、`internal/observability`、`internal/workflow` 无测试文件 | +| S-4 | **E2E 默认全不跑**:web/e2e 9 个 spec 全部被环境门禁(`RUN_UI_ACCEPTANCE=1`/`DOCREV_E2E_BASE_URL`/`INTENT_DUAL_TAB=1`),默认 `playwright test` 实跑 0 用例;`test:ui-acceptance` script 只覆盖 1 个 spec,其余 8 个无 script 入口 | web/e2e/、web/package.json:11 | +| S-5 | 409 信封处理只覆盖部分消费方:projectMembers、freeformDoc、createTaskRun、chatSessions upsert 等 409 只能裸报错(与 FEATURE-MATRIX hardening backlog 一致) | `web/src/api/projectMembers.ts`、`freeformDoc.ts:177`、`taskRuns.ts`、`chatSessions.ts` | +| S-6 | 前端 bundle 大 chunk 未分割(mermaid/katex/xlsx/docx 各成 >500 kB chunk,已有 precompress 缓解) | vite build 警告 | + +--- + +## 四、结论与优先级 + +**总体判断**:发布门禁与 FEATURE-MATRIX 的 `done/partial/deferred` 口径与代码实况基本一致,未发现「stub 冒充验收」类违规;质量门禁(vet/test/build)全绿。主要风险集中在三处: + +1. **E-1(main ENV 不一致 → 双写/空转)是唯一 critical 级发现**,且只在 main 环境存在, dev/test 的「healthy」不能证明 main 事件链路正确。 +2. **SSE 120s 断流(B-1/B-2)直接影响长任务可用性**,是用户可感知的现存 bug。 +3. **封存面(FAV/多意图 tier/U9–U12/语义摘要/PI 叙事)实现完整但容器内不可调或硬门禁封存**,红线达标,但授权放量时会发现 compose 层缺失(E-3)与若干死接线(X-1/X-2)。 + +详细整改顺序见 [remediation 方案](../remediation/2026-09-17-system-health-remediation-plan.md)。 + +## 关联整改 + +- Remediation: [2026-09-17-system-health-remediation-plan.md](../remediation/2026-09-17-system-health-remediation-plan.md) + +## 证据 + +- `go vet ./...` / `go build ./...` / `go test -short -count=1 ./...`(62 包 ok)/ `cd web && npm test && npm run build`:2026-09-17 全绿。 +- 文中所有 `文件:行号` 引用均为 2026-09-17 dev 分支工作区实测。 + +## 开放问题 + +- E-1 的修复取向(app 改 `ENV=main` vs 事件信封/consumer mode 键改用部署名)需架构决策。 +- D7 合规签字、`MULTI_INTENT_PLAN_TIER` 升 confirm、FAV live 放量均需用户显式授权,不在本整改自动范围内。 +- configs/.env.dev/.env.test/.env.main 实际文件未读取(含密钥);若偏离 example 模板,E-3/E-4/E-6 结论需复核。 diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index d5d83e91..e8200a2d 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -473,4 +473,5 @@ Day 4-5: Phase C 完善与发布准备(20h) *本索引最后更新于 2026-09-12 15:05 UTC* -- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-audit.md)(三入口重启首访;方案未实施)。 +- 2026-09-17:[系统全面体检](2026-09-17-system-health-check.md)(任务收口 / 缺陷 / 未启用功能;配套 [整改方案](../remediation/2026-09-17-system-health-remediation-plan.md) Wave0–7)。 +- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-audit.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 357e8d1b..1346012d 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,8 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-18-wave0-p0-bug-fixes | fix/wave0-p0-sse-chat-cache-bugs | - | chat + web | in_progress | +| Kimi | 2026-09-17-system-health-check(只读审计,未实施) | - | - | audit | done | | Cursor | 2026-09-17-docgov-modification-proposal-http | feature/docgov-modification-proposal-http | - | docgov | done | | Cursor | 2026-09-17-document-author-only-closeout | feature/document-author-only-plan | - | document-duty-policy | done | | Cursor | 2026-09-17-rdev-template-depth-s1-continued | feature/rdev-template-depth-s1-continued | .worktrees/feature-rdev-template-depth-s1-continued | rdev-templates | done | diff --git a/.doc/ops/short-term/2026-09-18-wave0-p0-bug-fixes.md b/.doc/ops/short-term/2026-09-18-wave0-p0-bug-fixes.md new file mode 100644 index 00000000..2b14fca8 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave0-p0-bug-fixes.md @@ -0,0 +1,54 @@ +--- +title: "Wave 0:P0 确认 bug 修复(SSE/缓存/run-stream)" +status: in_progress +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor +branch: fix/wave0-p0-sse-chat-cache-bugs +worktree: "-" +mutex: chat + web +--- + +# Wave 0:P0 确认 bug 修复(SSE/缓存/run-stream) + +## 目标 + +修复体检确认的 P0 bug:SSE 120s 断流(B-1/B-2)、run/stream 解析与时序(B-3/B-4)、前端会话窗口缓存 key 不一致(B-5)、IndexedDB 恢复覆盖(B-6)。 + +## 范围 + +- `internal/server/sse.go`:chat/run stream 心跳 + WriteDeadline;Decode 400;CreateAndStart/Subscribe 前置 Flush +- `web/src/hooks/useChatWindow.ts` + `web/src/data/storage.ts`:缓存 key / LRU 一致 +- `web/src/App.tsx` + `web/src/data/fileStore.ts`:恢复合并 +- 对应单测与测试报告 + +## 非目标 + +- E-1(main ENV 不一致)及 Wave 1–7 其余项 +- FAV / MULTI_INTENT_PLAN_TIER / SEMANTIC_SUMMARY_REPLACE 放量 + +## 步骤 + +1. [x] 从 dev 开 `fix/wave0-p0-sse-chat-cache-bugs`,CURRENT 认领 +2. [ ] B-1/B-2 心跳与 deadline +3. [ ] B-3/B-4 run/stream +4. [ ] B-5 缓存 key +5. [ ] B-6 恢复合并 +6. [ ] 单测 + 全量回归 + 合入 dev 冒烟 + +## 验收标准 + +- [ ] `go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build` 全绿 +- [ ] 畸形 body → 400;Flush 前失败非 200 SSE +- [ ] 缓存 key / LRU 单测 PASS;恢复合并不丢新增 deliverable +- [ ] dev rebuild + health/ready;真实 >120s 聊天回合不断流(可行时) + +## 相关链接 + +- Audit: [../../audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Remediation: [../../remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Test report: (待归档) + +## 证据 / 备注 + +(实施中) diff --git a/.doc/remediation/2026-09-17-system-health-remediation-plan.md b/.doc/remediation/2026-09-17-system-health-remediation-plan.md new file mode 100644 index 00000000..0ef2e5b5 --- /dev/null +++ b/.doc/remediation/2026-09-17-system-health-remediation-plan.md @@ -0,0 +1,55 @@ +--- +title: "系统体检整改与完善实施方案(2026-09-17)" +status: planned +created: 2026-09-17 +updated: 2026-09-17 +--- + +# 系统体检整改与完善实施方案 + +> 配套审计:[../audit/2026-09-17-system-health-check.md](../audit/2026-09-17-system-health-check.md)(编号 B-x 确认 bug、R-x 隐患、E-x 环境缺陷、T-x 任务、X-x 死代码、S-x 测试盲区、D-x 文档漂移)。 +> 本方案只列计划与验收,**不含实施**。所有波次遵守 AGENTS.md §6:从 dev 开 `fix/*`/`feature/*`,合回 dev 占集成槽位;三大主干禁止直接 commit。 + +## 背景 + +体检确认质量门禁全绿、发布门禁口径属实;但发现 1 项 critical 环境缺陷(main ENV 不一致,E-1)、2 项 high 级 SSE 断流 bug(B-1/B-2)、1 项 high 级前端缓存 bug(B-5)、一批「机制先行无消费方」与测试盲区。本方案按风险优先分波次收口。 + +## 目标 + +1. main 环境事件链路语义唯一、无双写/空转风险(E-1)。 +2. SSE 长任务不再被 120s WriteTimeout 强杀(B-1/B-2),错误语义对齐(B-3/B-4)。 +3. 前端会话窗口缓存一致、恢复不丢数据(B-5/B-6)。 +4. 封存功能的灰度入口在 compose 层就绪(E-3),死配置/死代码有明确处置(E-4、X-*)。 +5. 测试债与 E2E 门禁入口收敛(S-1/S-2/S-4)。 +6. 文档 SSOT 无漂移(D-1~D-6)。 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| **Wave 0:P0 确认 bug** | B-1/B-2:`handleChatStream` 与 `handleRunTaskStream` 接 `extendSSEWriteDeadline` + 心跳(复用 sse.go:52-64 既有机制);run/stream 支持 `after_seq` 续订。B-3:Decode 失败返回 400。B-4:CreateAndStart/Subscribe 前置到 Flush 之前(对齐 chat 路径)。B-5:useChatWindow 全部写入统一带 `userId` 前缀(或统一不带),LRU 驱逐 key 规则一致。B-6:`restoreFileRawToDeliverables` 改函数式更新/用 `deliverablesRef` 合并 | 单测:chat/run 流 >120s 不断(可用注入假时钟或短 WriteTimeout 测试);畸形 body 400;Flush 前失败返回非 200。前端 vitest 覆盖缓存 key 与恢复合并。`go test -short ./...` + `npm test` 全绿 + dev 容器冒烟(真实 >120s 聊天回合复测) | +| **Wave 1:main 环境修正(critical)** | E-1:统一 main 环境 app/events 的 ENV 语义(推荐 app 改 `ENV=main`,同时核对 `ResolveBackendInEnv` 等对 `prod` 的既有依赖;若保留 `prod` 则事件信封键与 `event_consumer_modes` 种子需对齐——二选一,先出 1 页决策)。E-2:main 晋升 checklist 固化「events 先升跑迁移、app 后升」,schema 47→49。E-5:`Env==""` 不再视同 dev | main 容器重建后:事件 producer/consumer mode 一致核验(写入一条事件观察 dispatcher 行为无双写);`health` schema_version=49;项目工作流查询与 `/api/v1/admin/system/settings` 读写在 main 冒烟 PASS | +| **Wave 2:配置与灰度入口对齐** | E-3:`MULTI_INTENT_PLAN_TIER` 与 U9–U12 flag 显式写入三环境 compose(值保持 legacy/false,只补声明);A-4 类(SEMANTIC_SUMMARY_SHADOW、PI_CHANGE_REPORT_NARRATIVE)同步补 test/main 声明。E-4:`CURSOR_RUNNER_URL` 与 cursor-agent 占位容器从 compose 移除或标注。E-6:main compose 显式 `SEED_DEMO_USERS=false`。E-7:`INTENT_V2_DECISION_TIER` 非法值回退改 `none`(保守对齐)。E-9:main initdb 脚本与 dev/test 对齐评估 | 三环境 compose `config` 渲染 diff 复核;容器内 `env` 抽检 flag 可见;`go test -short` 全绿;不改变任何默认行为(纯声明对齐,验证 dev/test/main 冒烟无回归) | +| **Wave 3:测试债与盲区** | S-1:申请隔离 PG(docker 单发 5436),执行 `TestReconcile_LegacyRepair_PG` 一次并归档报告,关闭 gap-matrix 测试债。S-2:删除 `taskstream_test.go:189` 死跳过;`plan_owner_test.go:125` 补说明或修复。S-4:统一 E2E 入口——`web/package.json` 为 9 个 spec 各配 script 或统一 flag 约定(消除 `DOCREV_E2E_BASE_URL` 与 `RUN_UI_ACCEPTANCE` 双口径);README/e2e 注释同步。S-5:409 信封复用推广到 projectMembers/freeformDoc/taskRuns/chatSessions(复用 projectSettingsError 解析器) | 隔离 PG 测试报告归档 `.doc/test-reports/`;`playwright test --list` 输出与门禁矩阵一致;409 负例单测覆盖 4 个 API 封装 | +| **Wave 4:隐患加固(可与 Wave 0–3 并行认领,mutex 不冲突时)** | R-1:`getOrCreateSession` 增 owner 绑定校验(session 创建时记录 owner,后续请求校验,不匹配 403)。R-2:18 处 `rows.Next()` 循环补 `rows.Err()` 检查。R-3:dispatcher Handlers 读路径加 RLock(或注释冻结「仅启动期注册」并加 archtest 约束)。R-4:`memTurns` 加 TTL/容量淘汰。R-5:pg_store JSONB 反序列化错误改为显式报错。R-6:后台 ticker 纳入 Shutdown;vault Drain 错误上报告警。R-7/R-8:SSE 重连 401/404 fail-fast、逐帧容错;登出 abort 在途流 | R-1 跨用户会话负例测试 PASS(补 PG + 内存双实现);R-2 `go vet`+单测;R-7 前端单测模拟 401 立即抛出 | +| **Wave 5:死代码与机制收口(需逐项决策,不擅自删)** | X-1:`SEMANTIC_FALLBACK_ENABLED` 删除或接线(先决策语义兜底是否还需要)。X-2:`SEMANTIC_SUMMARY_REPLACE` 维持未接线但补注释说明为刻意封存(与红线一致)。X-3:00048 三表消费方接线评估(并入 W5b SWBOOT-10 或标记 deferred)。X-4:system_settings 6 键逐一接消费方或从白名单移除(系统管理台二期范围)。X-5:前端死组件/死 API 删除(DocumentNotificationList、WorkflowPrerequisiteBanner、SettingsModal、activateWorkflow、saveMessages)或明确保留理由 | 每项有决策记录(ADR 或 short-term 决议段);删除后 `npm run build`/`go build` 全绿 | +| **Wave 6:文档漂移修复** | D-1:PU8 四份契约文件状态改 done 并归档。D-2:modular-monolith ADR 状态更新为 accepted。D-3:两个 INDEX 末行已在本审计修正(复核即可)。D-4:handoff 回标已被覆盖。D-5:backlog D3/D7 同步最新状态。D-6:PROJECT.md §9「剩余 blocker」段与 gap-matrix 对齐或删除 | 漂移清单逐项回链;`.doc/audit/INDEX.md`、`remediation/INDEX.md` 与 FEATURE-MATRIX 三方口径一致 | +| **Wave 7:既有 roadmap 收口(不改优先级,仅确认编排)** | T1/W5:SWBOOT-10 安全评审后实施(保持 fail-closed 直至授权)。T1/W6:FAV live 放量 staging→artifact→publication,每级 ≥7 天观察窗,`FAV_DEGRADED_READ=false` 不动。T1/W7:D7 待合规签字后按 ADR 整改。T2:Control Center 前端 SPA 七页人工走查。T4:首次加载性能 U1/U3–U7。T5/T6/T8:backlog 类按收益触发 | 各项沿用其原有关闭条件(见 FEATURE-MATRIX 剩余工作列);FAV/D7/tier 升级保持「需用户显式授权」红线 | + +## 验收标准 + +- [ ] Wave 0–2 完成后:`go vet ./...`、`go test -short ./...`、`cd web && npm test && npm run build` 全绿;dev 容器 rebuild + health/ready PASS +- [ ] main 环境:ENV 语义统一核验记录 + schema 49 + 事件链路无双写证据(Wave 1) +- [ ] SSE 长回合(>120s)真实 PI 复测不断流(Wave 0) +- [ ] `TestReconcile_LegacyRepair_PG` 报告归档,gap-matrix 测试债行关闭(Wave 3) +- [ ] `playwright test --list` 与门禁文档一致(Wave 3) +- [ ] 跨用户会话挂靠负例测试入库(Wave 4) +- [ ] X-1~X-5 每项有决策记录(Wave 5) +- [ ] D-1~D-6 全部回链关闭(Wave 6) +- [ ] 不触碰红线:FAV_DEGRADED_READ=false、MULTI_INTENT_PLAN_TIER 默认 legacy、SEMANTIC_SUMMARY_REPLACE 不接线、项目 143 只读、禁止整支 merge FAV + +## 相关链接 + +- Audit: [../audit/2026-09-17-system-health-check.md](../audit/2026-09-17-system-health-check.md) +- gap-matrix: [./gap-matrix.md](./gap-matrix.md)(本方案不改动发布门禁结论;E-1 若核验确认 main 事件链路异常,再评估是否升 blocker) +- 既有收口总控:[./2026-09-16-partial-deferred-completion-plan.md](./2026-09-16-partial-deferred-completion-plan.md) diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index a18fd4bc..0ad4cb5c 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -2,6 +2,7 @@ | 文档 | 说明 | |------|------| +| [2026-09-17-system-health-remediation-plan.md](./2026-09-17-system-health-remediation-plan.md) | 系统体检整改:Wave0–7(SSE/缓存 P0 → main ENV → 配置对齐 → 测试债 → 隐患 → 死代码决策 → 文档漂移 → roadmap);配套 [审计](../audit/2026-09-17-system-health-check.md) | | [2026-09-17-document-author-only-plan.md](./2026-09-17-document-author-only-plan.md) | 编制人兼任审核/批准:两级授权、`duty_policy=author_only`、U1–U6 实现已收口;默认 off;变更提案 HTTP 另立([audit](../audit/2026-09-17-docgov-modification-proposal-http-gap.md)) | | [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT | | [2026-09-16-partial-deferred-completion-plan.md](./2026-09-16-partial-deferred-completion-plan.md) | FEATURE-MATRIX 6 partial + 1 deferred 全量收口:通信机制确认(模块化单体,不新增 RPC 层)、7 项关闭条件、W1–W7 波次;§7 四项决策已确认(2026-09-17) | @@ -33,4 +34,4 @@ 审计入口:[../audit/INDEX.md](../audit/INDEX.md) -- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-plan.md)(三入口重启首访;方案未实施)。 +- 2026-09-17:[首次加载性能诊断与改进](2026-09-17-initial-load-performance-plan.md)(三入口重启首访;U2 已验收,主 JS ~624 KB;U1/U3–U7 待实施)。 diff --git a/internal/server/sse.go b/internal/server/sse.go index 16d5daa8..c51730af 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -204,6 +204,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { s.writeCORS(w, r) w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 flusher.Flush() + extendSSEWriteDeadline(w) ctx := stream.WithRequestID(r.Context(), requestID) @@ -228,14 +229,37 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { } } - // 3. 构建线程安全的 SSE 监听分发闭包 + // 3. 构建线程安全的 SSE 监听分发闭包;心跳与业务写共用 writeMu,避免与 WriteTimeout 竞态 var writeMu sync.Mutex listener := func(ev stream.StreamEvent) { writeMu.Lock() defer writeMu.Unlock() + extendSSEWriteDeadline(w) writeSSE(w, flusher, ev) } + heartbeatDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(sseHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-heartbeatDone: + return + case <-r.Context().Done(): + return + case <-ticker.C: + writeMu.Lock() + err := writeSSEHeartbeat(w, flusher) + writeMu.Unlock() + if err != nil { + return + } + } + } + }() + defer close(heartbeatDone) + clientTurnID := req.ClientTurnID if clientTurnID == "" { clientTurnID = r.Header.Get("Idempotency-Key") @@ -312,7 +336,10 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { var req taskStreamRequest includeDetails := false if r.Method == http.MethodPost { - _ = json.NewDecoder(r.Body).Decode(&req) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } } else { q := r.URL.Query() req.TaskKey = q.Get("task_key") @@ -349,22 +376,15 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } } - // 请求追踪 ID:与 chat stream 对齐,注入 ctx 并回写响应头 - requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) - w.Header().Set("X-Request-Id", requestID) - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - s.writeCORS(w, r) - w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 - flusher.Flush() - if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } req.UserID = user.ID req.WorkDir = "" + // 请求追踪 ID:与 chat stream 对齐;CreateAndStart/Subscribe 必须在 Flush 之前, + // 失败时返回普通 JSON 错误,避免污染已声明的 text/event-stream 连接。 + requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) runID := fmt.Sprintf("run_%d", time.Now().UnixNano()) record := &taskrun.RunRecord{ RunID: runID, @@ -394,9 +414,19 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } defer s.taskRuns.Unsubscribe(created.RunID, ch) + w.Header().Set("X-Request-Id", requestID) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + s.writeCORS(w, r) + w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 + flusher.Flush() + extendSSEWriteDeadline(w) + writeStored := func(stored taskrun.StoredEvent) { _ = (transport.Encoder{W: w}).WriteWithID(stored.Seq, stored.Event) flusher.Flush() + extendSSEWriteDeadline(w) } for _, stored := range replay { @@ -406,11 +436,18 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { } } + heartbeat := time.NewTicker(sseHeartbeatInterval) + defer heartbeat.Stop() + for { select { case <-r.Context().Done(): // SSE 断线仅停止推送,不取消后台 worker return + case <-heartbeat.C: + if err := writeSSEHeartbeat(w, flusher); err != nil { + return + } case stored, ok := <-ch: if !ok { return diff --git a/internal/server/sse_wave0_test.go b/internal/server/sse_wave0_test.go new file mode 100644 index 00000000..ff59e36b --- /dev/null +++ b/internal/server/sse_wave0_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/taskrun" +) + +// failInsertStore:Available=true 但 InsertRun 恒失败,用于验证 Flush 前 CreateAndStart 失败路径。 +type failInsertStore struct{} + +func (failInsertStore) Available() bool { return true } +func (failInsertStore) InsertRun(context.Context, *taskrun.RunRecord) error { + return errors.New("injected insert failure") +} +func (failInsertStore) UpdateRun(context.Context, *taskrun.RunRecord) error { return nil } +func (failInsertStore) GetByIdempotencyKey(context.Context, string) (*taskrun.RunRecord, error) { + return nil, nil +} +func (failInsertStore) AppendEvent(context.Context, string, int64, stream.StreamEvent) error { + return nil +} +func (failInsertStore) GetRun(context.Context, string) (*taskrun.RunRecord, error) { + return nil, errors.New("not found") +} +func (failInsertStore) ListEvents(context.Context, string, int64) ([]taskrun.StoredEvent, error) { + return nil, nil +} +func (failInsertStore) AcquireLease(context.Context, string, string, time.Duration) error { + return nil +} +func (failInsertStore) HeartbeatLease(context.Context, string, string, time.Duration) error { + return nil +} +func (failInsertStore) ReleaseLease(context.Context, string, string) error { return nil } +func (failInsertStore) ResetForResume(context.Context, string, int) error { return nil } +func (failInsertStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, nil } + +func TestRunTaskStream_InvalidJSON_Returns400(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run/stream", bytes.NewReader([]byte(`{not-json`))) + withBearerAuth(req, token) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400 for malformed body, got %d body=%s", w.Code, w.Body.String()) + } + ct := w.Header().Get("Content-Type") + if strings.Contains(ct, "text/event-stream") { + t.Fatalf("malformed body must not open SSE; Content-Type=%q", ct) + } +} + +func TestRunTaskStream_CreateAndStartFails_BeforeSSEFlush(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + + mgr := taskrun.NewManager(nil, nil) + mgr.SetStore(failInsertStore{}) + srv.taskRuns = mgr + + body, _ := json.Marshal(map[string]any{ + "task_key": "intake", + "project_name": "wave0-fail-start", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run/stream", bytes.NewReader(body)) + withBearerAuth(req, token) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + + if w.Code < 400 { + t.Fatalf("want error status before SSE flush, got %d body=%s", w.Code, w.Body.String()) + } + ct := w.Header().Get("Content-Type") + if strings.Contains(ct, "text/event-stream") { + t.Fatalf("CreateAndStart failure must not declare SSE; Content-Type=%q body=%s", ct, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "start task run failed") && !strings.Contains(w.Body.String(), "injected insert") { + t.Fatalf("expected start failure message, got %s", w.Body.String()) + } +} + +func TestSSEHeartbeat_HelpersSurviveShortWriteTimeout(t *testing.T) { + // 复用既有心跳机制:短 WriteTimeout 下流时长 > timeout 仍可读完(已有机制的回归钉)。 + mux := http.NewServeMux() + mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "no flush", 500) + return + } + w.Header().Set("Content-Type", "text/event-stream") + flusher.Flush() + extendSSEWriteDeadline(w) + tick := time.NewTicker(60 * time.Millisecond) + defer tick.Stop() + end := time.After(700 * time.Millisecond) + for { + select { + case <-end: + _, _ = fmt.Fprintf(w, "event: done\ndata: {\"type\":\"done\"}\n\n") + flusher.Flush() + return + case <-tick.C: + if err := writeSSEHeartbeat(w, flusher); err != nil { + return + } + case <-r.Context().Done(): + return + } + } + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + httpSrv := &http.Server{ + Handler: mux, + WriteTimeout: 200 * time.Millisecond, + } + go func() { _ = httpSrv.Serve(ln) }() + t.Cleanup(func() { _ = httpSrv.Close() }) + + resp, err := http.Get("http://" + ln.Addr().String() + "/sse") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !strings.Contains(string(raw), "event: done") { + t.Fatalf("expected done event after heartbeats; body=%q", string(raw)) + } +} + +func TestRunTaskStream_ValidBody_OpensSSEWithHeartbeatPath(t *testing.T) { + cfg := &config.Config{ + Env: "test", + AgentBackend: "stub", + DataDir: t.TempDir(), + KbDbPort: 5432, + AdminUsername: "admin", + AdminPassword: "admin-test-pass", + } + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + }) + + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + token := testBearerToken(t, ts.URL, cfg) + + body, _ := json.Marshal(map[string]any{ + "task_key": "intake", + "project_name": "wave0-sse-ok", + "customer": "北京水木东方", + }) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run/stream", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("want 200 SSE, got %d %s", resp.StatusCode, string(raw)) + } + if !strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { + t.Fatalf("Content-Type=%q", resp.Header.Get("Content-Type")) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 34f13329..3df7ab6c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -230,7 +230,7 @@ import { useUnreadMarks } from './hooks/useUnreadMarks'; import { CategoryDock } from './components/chat/CategoryDock'; import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow'; import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench'; -import { saveFile, restoreFileRawToDeliverables } from './data/fileStore'; +import { saveFile, restoreFileRawToDeliverables, applyRestoredFileRaw } from './data/fileStore'; import { uploadProjectAsset } from './api/projectAssets'; import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns'; @@ -333,14 +333,13 @@ export default function App() { }, [deliverables]); // ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题) - // 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch。 + // 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch(函数式合并,不覆盖期间新增)。 useEffect(() => { let cancelled = false; - restoreFileRawToDeliverables(deliverables).then((restored) => { - if (!cancelled && restored !== deliverables) { - // 只有真正有内容被恢复时才更新 state(避免无意义的 re-render) - setDeliverables(restored); - } + const snapshot = deliverablesRef.current; + restoreFileRawToDeliverables(snapshot).then((restored) => { + if (cancelled || restored === snapshot) return; + setDeliverables((prev) => applyRestoredFileRaw(prev, restored)); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/web/src/data/fileStore.test.ts b/web/src/data/fileStore.test.ts new file mode 100644 index 00000000..c02d092c --- /dev/null +++ b/web/src/data/fileStore.test.ts @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyRestoredFileRaw } from './fileStore'; +import type { DeliverableItem } from '../types/workbench'; + +function baseItem(overrides: Partial & { docId: string }): DeliverableItem { + return { + projectId: 1, + taskKey: 'intake', + title: '上传材料', + stage: 'V0.1', + category: 'private', + creatorId: 1, + creatorName: '测试', + isFixedWorkflow: false, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: '测试', + reviewer: '', + approver: '', + updatedAt: '2026-09-18', + sourceType: 'uploaded_file', + ...overrides, + }; +} + +test('applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目', () => { + const blobA = new Blob(['a'], { type: 'text/plain' }); + const blobB = new Blob(['b'], { type: 'text/plain' }); + + const mountSnapshot: DeliverableItem[] = [ + baseItem({ + docId: 'doc-a', + fileMeta: { + originalName: 'a.txt', + size: '1B', + mimeType: 'text/plain', + extension: 'txt', + uploadedAt: '2026-09-18', + }, + }), + ]; + + const restored = [ + { + ...mountSnapshot[0], + fileMeta: { + ...mountSnapshot[0].fileMeta!, + fileRaw: blobA, + }, + }, + ]; + + // resolve 前用户又新增了 doc-b,且 doc-a 仍缺 fileRaw + const prev: DeliverableItem[] = [ + ...mountSnapshot, + baseItem({ + docId: 'doc-b', + title: '新上传', + fileMeta: { + originalName: 'b.txt', + size: '1B', + mimeType: 'text/plain', + extension: 'txt', + fileRaw: blobB, + uploadedAt: '2026-09-18', + }, + }), + ]; + + const merged = applyRestoredFileRaw(prev, restored); + assert.equal(merged.length, 2, 'must keep newly added deliverable'); + assert.equal(merged[0].docId, 'doc-a'); + assert.equal(merged[0].fileMeta?.fileRaw, blobA); + assert.equal(merged[1].docId, 'doc-b'); + assert.equal(merged[1].fileMeta?.fileRaw, blobB); +}); + +test('applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用', () => { + const prev = [ + baseItem({ + docId: 'doc-x', + fileMeta: { + originalName: 'x.txt', + size: '1B', + mimeType: 'text/plain', + extension: 'txt', + uploadedAt: '2026-09-18', + }, + }), + ]; + const out = applyRestoredFileRaw(prev, prev); + assert.equal(out, prev); +}); diff --git a/web/src/data/fileStore.ts b/web/src/data/fileStore.ts index c9471b92..9a085874 100644 --- a/web/src/data/fileStore.ts +++ b/web/src/data/fileStore.ts @@ -181,3 +181,37 @@ export async function restoreFileRawToDeliverables( }; }); } + +/** + * 将 restore 快照中的 fileRaw 合并进当前 deliverables(函数式更新用)。 + * 以 prev 为权威列表,仅按 docId 补丁缺失的 fileRaw;不丢弃 restore 期间新增的条目。 + */ +export function applyRestoredFileRaw( + prev: DeliverableItem[], + restored: DeliverableItem[] +): DeliverableItem[] { + const restoredRawByDocId = new Map(); + for (const d of restored) { + const raw = d.fileMeta?.fileRaw; + if (raw) { + restoredRawByDocId.set(d.docId, raw); + } + } + if (restoredRawByDocId.size === 0) return prev; + + let changed = false; + const next = prev.map((d) => { + if (!d.fileMeta || d.fileMeta.fileRaw) return d; + const blob = restoredRawByDocId.get(d.docId); + if (!blob) return d; + changed = true; + return { + ...d, + fileMeta: { + ...d.fileMeta, + fileRaw: blob, + }, + }; + }); + return changed ? next : prev; +} diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 3759020b..85babdf9 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -161,14 +161,14 @@ function getDraftCacheKey(sessionId: string): string { return `${DRAFT_CACHE_PREFIX}${sessionId}`; } -function updateWindowLRU(sessionId: string): void { +function updateWindowLRU(cacheKey: string): void { const current = safeGetItem(WINDOW_LRU_KEY, []); - const updated = [sessionId, ...current.filter((id) => id !== sessionId)]; + const updated = [cacheKey, ...current.filter((id) => id !== cacheKey)]; if (updated.length > MAX_CACHED_WINDOWS) { const evicted = updated.slice(MAX_CACHED_WINDOWS); - evicted.forEach((id) => { + evicted.forEach((key) => { if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.removeItem(getWindowCacheKey(id)); + window.localStorage.removeItem(key); } }); } @@ -180,8 +180,9 @@ export function loadSessionWindowCache(sessionId: string, userId?: number): Chat } export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[], userId?: number): void { - safeSetItem(getWindowCacheKey(sessionId, userId), buckets); - updateWindowLRU(sessionId); + const cacheKey = getWindowCacheKey(sessionId, userId); + safeSetItem(cacheKey, buckets); + updateWindowLRU(cacheKey); } export function clearSessionWindowCache(sessionId: string, userId?: number): void { diff --git a/web/src/data/storage.windowCache.test.ts b/web/src/data/storage.windowCache.test.ts new file mode 100644 index 00000000..688eea75 --- /dev/null +++ b/web/src/data/storage.windowCache.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + saveSessionWindowCache, + loadSessionWindowCache, + clearSessionWindowCache, +} from './storage'; +import type { ChatDayBucket } from '../types/workbench'; + +function installMemoryLocalStorage(): { store: Map; restore: () => void } { + const store = new Map(); + const originalWindow = (globalThis as { window?: unknown }).window; + const localStorage = { + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + removeItem(key: string) { + store.delete(key); + }, + }; + (globalThis as { window: unknown }).window = { localStorage }; + return { + store, + restore: () => { + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window: unknown }).window = originalWindow; + } + }, + }; +} + +function sampleBucket(dayKey: string): ChatDayBucket { + return { + dayKey, + count: 1, + loadedCount: 1, + truncated: false, + firstMessageId: 1, + lastMessageId: 1, + messages: [ + { + id: `msg-${dayKey}`, + sessionId: 's', + role: 'user', + content: 'hi', + timestamp: '12:00', + }, + ], + }; +} + +test('window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目', () => { + const { store, restore } = installMemoryLocalStorage(); + try { + const userId = 42; + // MAX_CACHED_WINDOWS = 3;写入 4 个不同 session 应驱逐最早的带前缀 key + const sessions = ['sess-a', 'sess-b', 'sess-c', 'sess-d']; + for (const sid of sessions) { + saveSessionWindowCache(sid, [sampleBucket(`2026-09-18-${sid}`)], userId); + } + + const prefixed = [...store.keys()].filter((k) => k.startsWith('smdf_chat_window_u')); + assert.equal(prefixed.length, 3, `expected 3 prefixed window keys, got ${prefixed.join(',')}`); + assert.equal( + loadSessionWindowCache('sess-a', userId), + null, + 'oldest sess-a with user prefix must be evicted' + ); + assert.ok(loadSessionWindowCache('sess-b', userId)); + assert.ok(loadSessionWindowCache('sess-c', userId)); + assert.ok(loadSessionWindowCache('sess-d', userId)); + + // 无前缀 key 不应被误认为存活缓存 + assert.equal(store.has('smdf_chat_window_sess-a'), false); + assert.equal(store.has('smdf_chat_window_sess-d'), false); + + clearSessionWindowCache('sess-d', userId); + assert.equal(loadSessionWindowCache('sess-d', userId), null); + } finally { + restore(); + } +}); + +test('window cache: 读写同一 userId 前缀,跨用户不串读', () => { + const { restore } = installMemoryLocalStorage(); + try { + saveSessionWindowCache('sess-shared', [sampleBucket('2026-09-18')], 1); + saveSessionWindowCache('sess-shared', [sampleBucket('2026-09-19')], 2); + const u1 = loadSessionWindowCache('sess-shared', 1); + const u2 = loadSessionWindowCache('sess-shared', 2); + assert.equal(u1?.[0]?.dayKey, '2026-09-18'); + assert.equal(u2?.[0]?.dayKey, '2026-09-19'); + } finally { + restore(); + } +}); diff --git a/web/src/hooks/useChatWindow.cacheKey.test.ts b/web/src/hooks/useChatWindow.cacheKey.test.ts new file mode 100644 index 00000000..808c91c2 --- /dev/null +++ b/web/src/hooks/useChatWindow.cacheKey.test.ts @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +test('useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(here, 'useChatWindow.ts'), 'utf8'); + const calls = [...src.matchAll(/saveSessionWindowCache\(([^)]*)\)/g)]; + assert.ok(calls.length >= 8, `expected >=8 saveSessionWindowCache calls, got ${calls.length}`); + for (const m of calls) { + const args = m[1]; + assert.match( + args, + /\buserId\b/, + `saveSessionWindowCache call must include userId: saveSessionWindowCache(${args})` + ); + } +}); diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 47170dbb..3252a9f8 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -232,7 +232,7 @@ export function useChatWindow({ if (removed) totalMsgs -= removed.loadedCount; } - saveSessionWindowCache(sessionId, protectedStack); + saveSessionWindowCache(sessionId, protectedStack, userId); return protectedStack; }); @@ -247,7 +247,7 @@ export function useChatWindow({ setPhase('ready'); } }, - [hasMoreBefore, phase, oldestLoadedDay, sessionId] + [hasMoreBefore, phase, oldestLoadedDay, sessionId, userId] ); // 加载当日全量消息 @@ -268,14 +268,14 @@ export function useChatWindow({ } return b; }); - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; }); } catch (err) { console.error('[useChatWindow] loadFullDay failed', err); } }, - [sessionId] + [sessionId, userId] ); // 日期跳转: 聚焦指定日期 @@ -312,7 +312,7 @@ export function useChatWindow({ if (next.length > MAX_WINDOW_DAYS_STACK) { next.splice(0, next.length - MAX_WINDOW_DAYS_STACK); } - saveSessionWindowCache(sessionId, next); + saveSessionWindowCache(sessionId, next, userId); return next; }); @@ -329,7 +329,7 @@ export function useChatWindow({ console.warn(`[useChatWindow] jumpToDate failed for ${dayKey}:`, err); } }, - [sessionId, dayStack] + [sessionId, userId, dayStack] ); // 跨日锚点拉取: 根据 messageId 拉取所属日分片并合并进窗口 @@ -362,7 +362,7 @@ export function useChatWindow({ if (next.length > MAX_WINDOW_DAYS_STACK) { next.splice(0, next.length - MAX_WINDOW_DAYS_STACK); } - saveSessionWindowCache(sessionId, next); + saveSessionWindowCache(sessionId, next, userId); return next; }); return true; @@ -372,7 +372,7 @@ export function useChatWindow({ } return false; }, - [sessionId, dayStack] + [sessionId, userId, dayStack] ); // 追加新消息 (SSE 或用户发送) @@ -413,11 +413,11 @@ export function useChatWindow({ updated.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1)); } - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; }); }, - [sessionId] + [sessionId, userId] ); // 原地更新指定消息 @@ -436,13 +436,13 @@ export function useChatWindow({ return bucket; }); if (changed) { - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; } return prev; }); }, - [sessionId] + [sessionId, userId] ); // 将流式临时 ID 替换为服务端落库真实 serverId @@ -468,13 +468,13 @@ export function useChatWindow({ return bucket; }); if (changed) { - saveSessionWindowCache(sessionId, updated); + saveSessionWindowCache(sessionId, updated, userId); return updated; } return prev; }); }, - [sessionId] + [sessionId, userId] ); // 将 liveMessages (来自组件层实时状态,如刚发送的 user 消息或 streaming 中的 assistant 气泡)