merge: candidate t20-s1-small-fixes-2026-09-25 into dev

This commit is contained in:
weichen
2026-09-25 04:25:31 +00:00
8 changed files with 323 additions and 72 deletions
@@ -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<runId, Promise>`)防慢请求重叠;卸载清理 | `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)。
@@ -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 配置/池批)继续按同模式推进。
@@ -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)
}
+9 -3
View File
@@ -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 == "" {
+43 -22
View File
@@ -954,26 +954,35 @@ export default function App() {
if (!recoverableRunKey) return;
let cancelled = false;
const skipped = new Set<string>();
// T20-F8: 同 runId 的 in-flight 请求合并,避免慢请求重叠造成指数级 getTaskRun。
type PolledRun = Awaited<ReturnType<typeof getTaskRun>>;
const inFlight = new Map<string, Promise<PolledRun | null>>();
const fetchRunOnce = (runId: string): Promise<PolledRun | null> => {
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) => {
@@ -180,35 +180,9 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ 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<UsersSectionProps> = ({ currentUser, onRefre
>
编辑资料
</button>
<button
type="button"
onClick={() => handleResetUserPassword(u)}
style={{
fontSize: '11px',
color: 'var(--text-muted)',
padding: '3px 6px',
borderRadius: '4px',
border: '1px solid var(--border)',
cursor: 'pointer',
}}
title="重置初始密码并强制下次改密"
>
重置密码
</button>
{/* T20-F4-A:管理员重置密码端点未开通(F4-B 需产品选型),
移除会失败的「重置密码」死按钮;引导走「停用 + 新建」流程。 */}
{!isSelf && (
<button
type="button"
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
/**
* T20-F1 回归:切会话后 loadOlder 晚到 resolve 不得写 dayStack / 缓存。
* 模拟与 useChatWindow.loadOlder 相同的 generation 守卫契约:
* 入口捕获 gen → await fetch → gen 比对失败即丢弃。
*/
test('loadOlder race: stale resolve ignored by generation', async () => {
type WindowRes = { dayKey: string; messages: { id: string }[] };
let generation = 0;
let dayStack: WindowRes[] = [];
const cacheWrites: string[] = [];
let deferred: { resolve: (v: WindowRes[]) => void } | null = null;
const fetchOlder = () =>
new Promise<WindowRes[]>((resolve) => {
deferred = { resolve };
});
// 与实现同构的 loadOlder:入口捕获 gen,resolve 后比对
const loadOlder = async (sessionId: string) => {
const gen = generation;
const res = await fetchOlder();
if (gen !== generation) return; // 会话已切换,丢弃晚到响应
dayStack = [...res, ...dayStack];
cacheWrites.push(sessionId);
};
// 会话 A 发起 loadOlder
generation = 1;
const olderA = loadOlder('session-A');
assert.equal(cacheWrites.length, 0);
// 切到会话 B(generation++,与 session effect 行为一致)
generation = 2;
// A 的晚到响应 resolve —— 不得写 stack / 缓存
deferred!.resolve([{ dayKey: '2026-09-01', messages: [{ id: 'msg-old-a' }] }]);
await olderA;
assert.equal(dayStack.length, 0, 'stale loadOlder must not write dayStack');
assert.equal(cacheWrites.length, 0, 'stale loadOlder must not write session cache');
// B 会话的 loadOlder 正常落地
const olderB = loadOlder('session-B');
deferred!.resolve([{ dayKey: '2026-09-02', messages: [{ id: 'msg-old-b' }] }]);
await olderB;
assert.equal(dayStack.length, 1);
assert.equal(dayStack[0].messages[0].id, 'msg-old-b');
assert.deepEqual(cacheWrites, ['session-B']);
});
+18 -3
View File
@@ -204,16 +204,19 @@ export function useChatWindow({
}, [loadInitialWindow]);
// 触顶加载更早日 (P3: 滚动位置由虚拟化器 scrollToIndex 恢复,无需 DOM 补偿)
// T20-F1: loadOlder 绑定 generation;切会话后晚到的 resolve 不得写 dayStack/缓存。
const loadOlder = useCallback(
async () => {
if (!hasMoreBefore || phase === 'loading_older' || !oldestLoadedDay || !sessionId) {
return;
}
const gen = generationRef.current;
setPhase('loading_older');
try {
const res = await fetchChatWindow(sessionId, 7, oldestLoadedDay);
if (gen !== generationRef.current) return; // 会话已切换,丢弃晚到响应
if (res.days.length > 0) {
setDayStack((prev) => {
// 合并并去重
@@ -242,20 +245,27 @@ export function useChatWindow({
setHasMoreBefore(false);
}
} catch (err) {
console.error('[useChatWindow] loadOlder failed', err);
if (gen === generationRef.current) {
console.error('[useChatWindow] loadOlder failed', err);
}
} finally {
setPhase('ready');
if (gen === generationRef.current) {
setPhase('ready');
}
}
},
[hasMoreBefore, phase, oldestLoadedDay, sessionId, userId]
);
// 加载当日全量消息
// T20-F1: loadFullDay 绑定 generation;切会话后晚到的 resolve 不得写 dayStack/缓存。
const loadFullDay = useCallback(
async (dayKey: string) => {
if (!sessionId || !dayKey) return;
const gen = generationRef.current;
try {
const res = await fetchDayMessages(sessionId, dayKey, { limit: 300 });
if (gen !== generationRef.current) return;
setDayStack((prev) => {
const updated = prev.map((b) => {
if (b.dayKey === dayKey) {
@@ -272,15 +282,19 @@ export function useChatWindow({
return updated;
});
} catch (err) {
console.error('[useChatWindow] loadFullDay failed', err);
if (gen === generationRef.current) {
console.error('[useChatWindow] loadFullDay failed', err);
}
}
},
[sessionId, userId]
);
// 日期跳转: 聚焦指定日期
// T20-F1: jumpToDate 的异步分支绑定 generation,晚到响应不写 dayStack。
const jumpToDate = useCallback(
async (dayKey: string) => {
const gen = generationRef.current;
const exists = dayStack.some((b) => b.dayKey === dayKey);
if (exists) {
if (typeof document !== 'undefined') {
@@ -294,6 +308,7 @@ export function useChatWindow({
try {
const res = await fetchDayMessages(sessionId, dayKey, { limit: 100 });
if (gen !== generationRef.current) return; // T20-F1: 会话已切换,丢弃晚到响应
if (res.messages.length > 0) {
const bucket: ChatDayBucket = {
dayKey: res.dayKey,