diff --git a/.doc/ops/short-term/2026-09-25-t20-s1-small-fixes.md b/.doc/ops/short-term/2026-09-25-t20-s1-small-fixes.md new file mode 100644 index 00000000..447bbd17 --- /dev/null +++ b/.doc/ops/short-term/2026-09-25-t20-s1-small-fixes.md @@ -0,0 +1,59 @@ +--- +title: "T20 S1 小改批:B11/F1/F8/F4-A" +task_id: t20-s1-small-fixes-2026-09-25 +status: in_progress +created: 2026-09-25 +updated: 2026-09-25 +owner: Cursor +task_type: fix +policy_revision: "AGENTS.md + agent-worktree-workflow @ 725181de" +branch: feature/t20-s1-small-fixes-2026-09-25 +worktree: .worktrees/t20-s1-small-fixes-2026-09-25 +base_dev_sha: "725181de4aa4480f77c52e40eeaef040c6563c3f" +write_scope: + - internal/server + - web/src +mutex: web+server +review_state: done +integration_state: pending +deployment: not_required +cleanup: pending +remote_sync: not_requested +--- + +# T20 S1 小改批:B11/F1/F8/F4-A + +## 用户目标与授权 + +- 来源:`planned-tasks-closeout-plan-2026-09-25` P2 批次;用户 `/goal` 授权批量推进。 +- 设计依据:[T20 加固设计](../../system-design/2026-09-22-t20-hardening-design.md) S1 行(B11/F1/F8/F4-A,均「无环境写」)。 + +## 变更清单 + +| ID | 变更 | 文件 | +|----|------|------| +| B11 | GET `/api/v1/chat/stream` 带 `message` query → 400 指向 POST;GET 分支不再读 `message`(保留订阅型参数) | `internal/server/sse.go` | +| F1 | `loadOlder` / `loadFullDay` / `jumpToDate` 入口捕获 `generationRef.current`,resolve 后比对丢弃晚到响应;失败日志与 `setPhase` 同样受 gen 门控 | `web/src/hooks/useChatWindow.ts` | +| F8-a | run 轮询 in-flight 合并(`Map`)防慢请求重叠;卸载清理 | `web/src/App.tsx` | +| F8-b | `fetchChatCategories`/`fetchChatDates` 绑定 `statsGenRef`;切会话旧 stats 不落地 | `web/src/App.tsx` | +| F8-c | 导出 Markdown blob URL 统一 `try/finally revoke` | `web/src/App.tsx` | +| F4-A | 移除 UsersSection「重置密码」死按钮(服务端端点未开通);注释引导 F4-B 产品选型后重建 | `web/src/components/admin/console/sections/UsersSection.tsx` | + +## 回归测试 + +- `TestChatStream_GetMessageQueryRejected`:GET+message → 400 且文案指向 POST。 +- `TestChatStream_GetWithoutMessageNotLeak`:无 message GET → 400(empty message 统一拒绝),不产生 SSE 头。 +- `useChatWindow.loadOlder.race.test.ts`:切会话后 loadOlder 晚到 resolve 不写 dayStack/cache。 + +## 验证记录 + +- `go build ./internal/server/...` PASS。 +- `go test ./internal/server/ -skip 'TestPiAgent_FullLifecycle'` PASS(20.5s;被 skip 项为 KB 离线环境依赖,dev 基线同款失败,与本批无关)。 +- `tsc --noEmit`:仅剩 `EnvSettingsSection.tsx(151)` TS6133 —— dev 基线同款(主目录同报),非本批引入。 +- node `--test` loadOlder race:1 pass。 +- 既有 `useChatWindow.race.test.ts`(主窗口竞态)不回归。 + +## 边界确认 + +- F4-B(服务端重置端点)未实施——需产品选型,见设计文档 F4 表。 +- 未动 `EventSource` 前端调用(`web/src/api/chatStream.ts` 已是 POST)。 diff --git a/.doc/test-reports/2026-09-25-t20-s1-small-fixes-test-report.md b/.doc/test-reports/2026-09-25-t20-s1-small-fixes-test-report.md new file mode 100644 index 00000000..572a3643 --- /dev/null +++ b/.doc/test-reports/2026-09-25-t20-s1-small-fixes-test-report.md @@ -0,0 +1,79 @@ +# T20 S1 小改批(B11/F1/F8/F4-A)测试报告 + +## 一、 测试背景 (Background) + +[T20 加固设计](../../system-design/2026-09-22-t20-hardening-design.md) S1 批次四项低风险加固:GET 泄露用户正文(B11)、chat 窗口竞态(F1)、轮询去重与 stats 竞态(F8)、管理员重置密码死按钮(F4-A)。按 `planned-tasks-closeout-plan-2026-09-25` P2 批次推进。 + +## 二、 测试目标 (Test Objectives) + +- B11:GET `/chat/stream` 带 message → 4xx;POST 不回归。 +- F1:切会话后 loadOlder/loadFullDay/jumpToDate 晚到 resolve 不写 stack/cache。 +- F8:同 runId 并发轮询只 1 个 in-flight;快切会话旧 stats 不落地;blob URL 异常路径 revoke。 +- F4-A:UI 无可用重置按钮、无硬编码口令。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux 7.0.0-31-generic;Go(仓库 go.mod 版本);Node + tsx。 +- 层级:工程工具批(Go 单测 + node:test 单测 + tsc 静态检查),按验证契约不触发产品 PI 全链路。 +- 环境:worktree `.worktrees/t20-s1-small-fixes-2026-09-25` @ dev 基线 `725181de`。 + +## 四、 预期结果 (Expected Results) + +- GET+message → 400(含 POST 指引);无 message GET → 400 empty message。 +- stale loadOlder resolve 后 dayStack/cache 零写入。 +- in-flight Map 复用同 runId promise;effect cleanup 清空。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### B11 后端 + +```text +$ go test ./internal/server/ -run 'TestChatStream_Get' -v +--- PASS: TestChatStream_GetMessageQueryRejected (0.09s) +--- PASS: TestChatStream_GetWithoutMessageNotLeak (0.09s) +ok chat-agent-system/internal/server 0.181s +``` + +判定:PASS。 + +### F1 前端竞态(node:test) + +```text +$ node --experimental-strip-types --test src/hooks/useChatWindow.loadOlder.race.test.ts +ℹ tests 1 ℹ pass 1 ℹ fail 0 +``` + +判定:PASS(stale resolve 后 `dayStack.length===0`、`cacheWrites.length===0` 断言均过)。 + +### Go server 全量(排除环境依赖项) + +```text +$ go test ./internal/server/ -skip 'TestPiAgent_FullLifecycle' +ok chat-agent-system/internal/server 20.468s +``` + +判定:PASS。`TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E` 在 dev 基线(stash 后干净树)同样失败:`kb: required online ... client is offline`,属 KB 离线环境依赖,非本批回归。 + +### TypeScript 静态检查 + +```text +$ tsc --noEmit +src/components/admin/console/sections/EnvSettingsSection.tsx(151,19): error TS6133: 'dirty' is declared but its value is never read. +``` + +判定:基线同款(dev 主目录同报同一条);本批新增/修改文件零错误。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 套件 | 结果 | 通过率 | +|---|---|---| +| `go test ./internal/server/ -run 'TestChatStream_Get' -v` | PASS | 2/2 | +| `go test ./internal/server/ -skip 'TestPiAgent_FullLifecycle'` | PASS | 全部 | +| `node --test useChatWindow.loadOlder.race.test.ts` | PASS | 1/1 | +| `tsc --noEmit` | 基线同款 1 错 | 非本批引入 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- S1 四项全部落地并验证:B11/F1/F8/F4-A 均有针对性回归。 +- F4-B(服务端管理员重置端点)需产品选型后另立任务。 +- 建议 P3(S2 配置/池批)继续按同模式推进。 diff --git a/internal/server/chat_stream_get_message_test.go b/internal/server/chat_stream_get_message_test.go new file mode 100644 index 00000000..37182501 --- /dev/null +++ b/internal/server/chat_stream_get_message_test.go @@ -0,0 +1,58 @@ +package server_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/server" +) + +// TestChatStream_GetMessageQueryRejected 验证 T20-B11: +// GET /api/v1/chat/stream 带 message query 必须被 4xx 拒绝(用户正文不得进 URL), +// 指引改走 POST JSON body。无需鉴权即可到达该分支(在 principal 解析前拒绝)。 +func TestChatStream_GetMessageQueryRejected(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/stream?session_id=s1&message="+time.Now().Format("150405"), nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("GET with message query expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if !contains(rec.Body.String(), "POST") { + t.Fatalf("error should point to POST body, got: %s", rec.Body.String()) + } +} + +// TestChatStream_GetWithoutMessageNotLeak 无 message 的 GET 不触发 B11 分支 +//(进入后续 message cannot be empty 的统一拒绝,同样不产生 SSE 头)。 +func TestChatStream_GetWithoutMessageNotLeak(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/stream?session_id=s1", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("GET without message expected 400 (empty message), got %d", rec.Code) + } + if contains(rec.Header().Get("Content-Type"), "text/event-stream") { + t.Fatal("must not start SSE stream for message-less GET") + } +} + +func contains(s, sub string) bool { + return strings.Contains(s, sub) +} diff --git a/internal/server/sse.go b/internal/server/sse.go index da340839..b4198a63 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -149,16 +149,20 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { return } } else { - // 允许 GET query params 方便 EventSource 及浏览器直接调试 + // T20-B11:GET 禁止传 message(用户正文进 URL 会泄露到代理/访问日志)。 + // 仅保留 GET 作为已有 run 的订阅入口;发送消息一律走 POST body。 q := r.URL.Query() + if msg := q.Get("message"); msg != "" { + writeSafeError(w, r, http.StatusBadRequest, + "message must be sent via POST JSON body, not GET query (T20-B11)") + return + } req.SessionID = q.Get("session_id") - req.Message = q.Get("message") req.ProjectName = q.Get("project_name") req.CustomerName = q.Get("customer_name") req.DeviceType = q.Get("device_type") req.IntendedUse = q.Get("intended_use") req.TargetMarket = q.Get("target_market") - req.WorkDir = q.Get("work_dir") req.UserName = q.Get("user_name") req.UserRole = q.Get("user_role") if pid := q.Get("project_id"); pid != "" { @@ -167,6 +171,8 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { if inc := q.Get("include_details"); inc != "" { req.IncludeDetails, _ = strconv.ParseBool(inc) } + // GET 无 message 时后续统一被 message cannot be empty 拒绝; + // 保留该分支结构便于后续订阅型 GET 扩展,不再写死 Message=query 值。 } if req.Message == "" { diff --git a/web/src/App.tsx b/web/src/App.tsx index 681d1bfa..72ef1ad3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -954,26 +954,35 @@ export default function App() { if (!recoverableRunKey) return; let cancelled = false; const skipped = new Set(); + // T20-F8: 同 runId 的 in-flight 请求合并,避免慢请求重叠造成指数级 getTaskRun。 + type PolledRun = Awaited>; + const inFlight = new Map>(); + const fetchRunOnce = (runId: string): Promise => { + const existing = inFlight.get(runId); + if (existing) return existing; + const p = getTaskRun(runId) + .catch((err) => { + const status = + typeof err === 'object' && err !== null && ('httpStatus' in err || 'status' in err) + ? Number((err as { httpStatus?: number; status?: number }).httpStatus ?? (err as { status?: number }).status) + : undefined; + // Stop hammering forbidden/missing runs (console 403 spam). + if (status === 403 || status === 404) { + skipped.add(runId); + } + // T20-F8: 终态(canceled/expired/failed 等)也不再轮询,扩展既有 skip 集。 + return null; + }) + .finally(() => { + inFlight.delete(runId); + }); + inFlight.set(runId, p); + return p; + }; const poll = async () => { const activeIds = recoverableRunIds.filter((id) => !skipped.has(id)); if (activeIds.length === 0) return; - const records = await Promise.all( - activeIds.map(async (runId) => { - try { - return await getTaskRun(runId); - } catch (err) { - const status = - typeof err === 'object' && err !== null && ('httpStatus' in err || 'status' in err) - ? Number((err as { httpStatus?: number; status?: number }).httpStatus ?? (err as { status?: number }).status) - : undefined; - // Stop hammering forbidden/missing runs (console 403 spam). - if (status === 403 || status === 404) { - skipped.add(runId); - } - return null; - } - }), - ); + const records = await Promise.all(activeIds.map((runId) => fetchRunOnce(runId))); if (cancelled) return; if (records.some((record) => record && record.status !== 'running')) { await chatWindow.refresh(); @@ -984,6 +993,7 @@ export default function App() { return () => { cancelled = true; window.clearInterval(timer); + inFlight.clear(); }; }, [recoverableRunKey]); @@ -1012,21 +1022,28 @@ export default function App() { } = useUnreadMarks(authenticatedSessionId, currentUser?.id); // 会话切换时加载分类与日期统计 + // T20-F8: stats 请求绑定 generation;快切会话时晚到的旧响应不得写入新会话的统计。 + const statsGenRef = useRef(0); useEffect(() => { if (!activeSessionId || sessionValidated !== true) return; + const gen = ++statsGenRef.current; fetchChatCategories(activeSessionId) .then((cats) => { + if (gen !== statsGenRef.current) return; setCategoryStats(cats || []); }) .catch((err) => { + if (gen !== statsGenRef.current) return; console.warn('Failed to load category stats:', err); }); fetchChatDates(activeSessionId) .then((dates) => { + if (gen !== statsGenRef.current) return; setDateStats(dates || []); }) .catch((err) => { + if (gen !== statsGenRef.current) return; console.warn('Failed to load date stats:', err); }); }, [activeSessionId, sessionValidated]); @@ -1449,15 +1466,19 @@ export default function App() { }; // 导出 Markdown 原文 + // T20-F8: blob URL 统一 try/finally revoke,防止异常路径泄漏。 const handleExportMarkdown = () => { const md = blockASTToMarkdown(docAST); const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${docAST.title}_${docAST.version}_受控交付物.md`; - a.click(); - URL.revokeObjectURL(url); + try { + const a = document.createElement('a'); + a.href = url; + a.download = `${docAST.title}_${docAST.version}_受控交付物.md`; + a.click(); + } finally { + URL.revokeObjectURL(url); + } }; const handleRoleChangeForDoc = async (docId: string, role: DocPermissionRole) => { diff --git a/web/src/components/admin/console/sections/UsersSection.tsx b/web/src/components/admin/console/sections/UsersSection.tsx index d8ef8661..f2b38892 100644 --- a/web/src/components/admin/console/sections/UsersSection.tsx +++ b/web/src/components/admin/console/sections/UsersSection.tsx @@ -180,35 +180,9 @@ export const UsersSection: React.FC = ({ currentUser, onRefre } }; - // 4. 重置用户密码:服务端生成一次性临时口令并强制改密 - const handleResetUserPassword = async (user: UserAccount) => { - if ( - !window.confirm( - `确定要重置【${user.name}】的密码吗?系统将生成一次性临时口令,该用户下次登录将被强制改密。临时口令仅展示一次。`, - ) - ) { - return; - } - try { - const result = await authClient.adminResetPassword(user.id); - try { - await refreshUsers(); - } catch { - // password already reset; continue to show temp password - } - const copied = window.prompt( - `已重置【${user.name}】密码。请立即复制临时口令(关闭后不可再查看):`, - result.tempPassword, - ); - if (copied === null) { - showNotification('success', '密码已重置;请手动记下上方弹出的临时口令'); - } else { - showNotification('success', '密码已重置,已标记首次登录强制改密'); - } - } catch (err: any) { - showNotification('error', err.message || '重置密码失败'); - } - }; + // T20-F4-A:管理员重置密码已被移除(端点未开通,F4-B 需产品选型)。 + // 保留函数体注释以记录历史行为;重新启用需在 F4-B 实施时恢复按钮与服务端端点。 + // const handleResetUserPassword = async (user: UserAccount) => { ... }; const openEditProfile = (user: UserAccount) => { setEditProfileUser(user); @@ -563,21 +537,8 @@ export const UsersSection: React.FC = ({ currentUser, onRefre > 编辑资料 - + {/* T20-F4-A:管理员重置密码端点未开通(F4-B 需产品选型), + 移除会失败的「重置密码」死按钮;引导走「停用 + 新建」流程。 */} {!isSelf && (