docs(chat): add server-authoritative message task brief
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# 任务书 · 聊天消息服务端权威化(2026-09-01)
|
||||
|
||||
基线:`origin/staging` @ `26ea3f06`(开工时以 `origin/staging` 最新为准)。本轮与任何同期改 `frontend/src/app/page.tsx` 的轮次**不得并行**。
|
||||
|
||||
这份任务书来自一次全栈架构审计。产品"交互不好"的最大结构性根源不在样式层,而在聊天记录的持久化模型:**客户端权威、整包上传**。本轮收回这个写权。**先读完「范围定性」和「决策记录」再动手** —— 刀口比看起来小得多,不要自行扩大。
|
||||
|
||||
---
|
||||
|
||||
## 为什么要做(事故实证)
|
||||
|
||||
下面所有行号只是线索,按符号/选择器定位,`origin/staging` 上可能有偏移。
|
||||
|
||||
1. **会话列表全量返回所有消息。** `frontend/src/app/api/sessions/route.ts` 的 GET `select` 包含 `messages`,无分页、无上限 —— 每次进首页,把该用户**所有会话的完整聊天记录**一次性拉下来。单会话契约上限 20 万字符(见下),老用户几十个会话时首屏 payload 达 MB 级。客户端读取时还要把每条截到 12,000 字符(`page.tsx:908` 附近 `stored.text.slice(0, 12000)`),说明这个问题已经被打过一次补丁而不是治过。
|
||||
2. **消息写入是客户端全量上传。** `persistSession`(`page.tsx:2202`)把整个 `messages` 数组 PATCH 到 `/api/sessions/[id]`。契约上限:200 条 / 单条 16,000 字符 / 总计 200,000 字符 / body 500,000 字符(`frontend/src/lib/chat-session-write-contract.ts:11-14`)。会话逼近上限后**每次保存都会失败**,用户看到的是"问题保存失败,问题已放回输入框"(`page.tsx:3465` 附近)。会话越长越容易坏,这是结构性的,不是偶发 bug。
|
||||
3. **双写且客户端胜,多标签页丢消息。** 服务端在结算时已经通过 `complete_consultation_response`(`frontend/supabase/migrations/20260808030000_consultation_stream_recovery.sql`)把完整 assistant 消息(含 `thinkingText` / `thinkingSections` / `techniqueTruth` / 双 receipt,见 `frontend/src/app/api/consult/route.ts:529-568` 的 `completeResponse`)append 进 `chat_sessions.messages`;随后客户端在 `page.tsx:3742` 再全量 PATCH **覆盖**一次。两个标签页同开一个会话时,整数组 last-write-wins,另一个标签页的消息直接被抹掉。
|
||||
4. **上下文可伪造,部分回答白嫖。** `/api/consult` 的 `history` 由客户端提供(`page.tsx:3517` 的 `slice(-12)`,schema 上限 20 条 × 4,000 字符),用户可伪造"assistant 说过的话"注入模型上下文。另外中止(`page.tsx:3191`)和 run.failed 有部分文本(`page.tsx:3683`)时由**客户端**持久化部分回答 —— 这两种情形额度已退款,现状等于退款请求还能免费保存部分回答。
|
||||
5. **顺手的浪费。** `frontend/src/app/api/rectification/agent/route.ts:241` select 了 `messages` 列但通篇从未使用(用到的只有 `session_type` / `agentic_rectification_case_id` / `model_id` / `model_config_version`),每个校正回合白拉最多 500KB。
|
||||
|
||||
## 范围定性(三个已核实的事实,决定了刀口很小)
|
||||
|
||||
- **没有任何"带消息创建会话"的路径。** `createSession`(`page.tsx:426-442`)建会话时 `messages: []`;校正会话 merge(`page.tsx:2908` 附近)也是空数组。
|
||||
- **校正会话的权威转写不在 `chat_sessions.messages`。** 它在 case 表里,由 `append_agentic_rectification_turn` 等 RPC 服务端写入(`frontend/src/lib/rectification-agentic/v9/agent-run.ts:268`)。
|
||||
- **服务端 append 的 assistant 消息字段已与客户端持久化的字段完全一致**(对照 `consult/route.ts:545-553` 与 `page.tsx:2209-2217`)。
|
||||
|
||||
结论:本轮只需要(a)把"用户提问"也改为服务端落库,(b)砍掉客户端全部 `messages` 写路径,(c)列表/详情拆分。**不改表结构,不写数据迁移,存量 jsonb 原样保留。** 明确不做的事见文末。
|
||||
|
||||
## 决策记录(产品授权,2026-09-01)
|
||||
|
||||
以下四条推翻或改变既有语义,均已获产品确认,执行方不得以"改变现状"为由拒改:
|
||||
|
||||
1. **消息的唯一权威写入方是服务端。** 客户端对 `messages` 的一切写入权收回。这会改变 `chat-session-write-contract` 的既有语义及锁它的合同测试 —— 属于"断言锁住的正是本轮要修的缺陷"的例外情形,按红线 4 的程序修改。
|
||||
2. **中止与失败的部分回答不再持久化。** 仅保留在当前页面内存,刷新即失,沿用现有提示文案。理由:这两种情形额度已退款,持久化等于免费保存部分回答;为它单开服务端写路径会扩大攻击面。
|
||||
3. **流失败时用户提问的语义改变**:reserve 成功即落库、保留在会话里,失败提示后可直接重试(新 requestId);不再"放回输入框"。reserve 失败(余额不足、session_full 等)则不落库、不出现在会话里,此时问题仍放回输入框。
|
||||
4. **`history` 字段服务端不再读取**,上下文从数据库取。请求 schema 中该字段保留 optional 以兼容线上旧 bundle,本轮不删字段,下一轮再删。
|
||||
|
||||
## 硬红线
|
||||
|
||||
1. **不改表结构,不写数据迁移。** 新增数据库对象只允许 SQL 函数,且必须照 `complete_consultation_response` 的既有模式:`security definer`、`set search_path`、`pg_advisory_xact_lock`(user+request 粒度)、按 `request_id` 幂等、`revoke all from public/anon/authenticated`、`grant execute to service_role`(`admin_runtime` 存在则条件授权)。
|
||||
2. **状态与并发控制在 Postgres 函数里**,不得把幂等/加锁逻辑搬进应用层(与 `personal_report_jobs` 的既有规矩一致)。
|
||||
3. **PATCH 兼容窗口。** 服务端收到含 `messages` 的旧全量写,必须**接受并忽略 `messages` 字段**(不报错、不落库、打一条观测日志)。禁止直接改成拒绝 —— 线上旧 bundle 没有强制刷新机制(`stale-client-recovery` 只救 chunk 加载失败,救不了契约不兼容),直接拒绝会让存量用户每轮聊天报错。
|
||||
4. **不得修改既有测试断言** —— 例外仅限锁住"客户端可写 messages / 服务端信任客户端 history"这一缺陷本身的断言;须在断言上方注释原值与原因,并在 PROGRESS 单列。
|
||||
5. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。
|
||||
6. 测试数不得低于基线且 `fail=0`、`skipped=0`(有 Docker 的环境)。2026-09-01 验收环境实测基线为 **738 通过、fail=0**;无 Docker 时另有既有缺口(数据库/部署类失败 + skipped),**必须逐条比对失败清单确认没有新增**。**本轮动了 SQL 函数,`npm run test:db` 必须真跑;没有 Docker 就登记 `BLOCKED.md` 停下,不允许"本地跑不了所以跳过"。**
|
||||
7. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
|
||||
8. 本轮唯一允许的 UI 改动是任务 0 的会话加载态与任务 1 的 `session_full` 提示;两者都要在浅色/深色两套主题下检查。
|
||||
|
||||
让步顺序:数据不损坏 > 功能与测试不回归 > 可验证的改进 > 代码整洁。
|
||||
|
||||
## 开工前置
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/chat-authority-20260901 \
|
||||
../.worktrees/chat-authority-20260901 origin/staging
|
||||
```
|
||||
|
||||
基线必须是 `origin/staging`,不是任何本地 ref。读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`(Next.js 版本与训练数据不同,写代码前先看 `node_modules/next/dist/docs/`)。改前在 `docs/BUG_HISTORY.md` 检索同类记录。
|
||||
|
||||
**先读这几个文件再动手**,本任务书的判断都基于它们:
|
||||
|
||||
- `frontend/src/app/api/sessions/route.ts` 与 `frontend/src/app/api/sessions/[id]/route.ts`(现状只有 PATCH/DELETE,无 GET)
|
||||
- `frontend/src/lib/chat-session-write-contract.ts`
|
||||
- `frontend/src/app/api/consult/route.ts` 的 `completeResponse` 与 reserve 段
|
||||
- `frontend/supabase/migrations/20260808030000_consultation_stream_recovery.sql`(`reserve_consultation_usage` / `complete_consultation_response` 的模式样板)
|
||||
- `frontend/src/app/page.tsx` 的 `persistSession`(2202)及全部调用点:2234(重命名)、2317(create)、2915(校正 merge)、3191(中止)、3454(发问前)、3683(截断失败)、3742(完成)、3776/3789(失败恢复)
|
||||
|
||||
---
|
||||
|
||||
## 任务分解
|
||||
|
||||
### 任务 0(P0,门槛)· 列表/详情拆分
|
||||
|
||||
- `GET /api/sessions`:`select` 去掉 `messages`,只回元数据(id、title、theme、model_id、session_type、rectification_case_id、chart 绑定三件、updated_at)。
|
||||
- `frontend/src/app/api/sessions/[id]/route.ts` 新增 GET:返回单会话完整 `messages`(鉴权同 PATCH:`user_id` 过滤 + RLS)。
|
||||
- 前端:启动只拉列表;切换会话时拉详情并展示加载态(用现有 `inline-spinner` 风格,两套主题验证);已加载的会话内存缓存,正在 streaming 的会话不重复拉。
|
||||
- 验收:首屏 `/api/sessions` 响应不含任何消息文本;用多会话账户实测拆分前后 payload 字节数,写进 PROGRESS。
|
||||
|
||||
### 任务 1(P0)· 用户提问服务端落库
|
||||
|
||||
- 新 SQL 函数 `append_consultation_question(p_user_id uuid, p_request_id text, p_session_id uuid, p_question_message jsonb)`,按红线 1 的模式:校验会话归属且 `session_type = 'consultation'`、校验消息为 `role='user'` 且 text 非空、同 `request_id` 重复调用幂等(不重复 append);append 后若消息数超 200 或总字符超 200,000,**不落库**并返回 `error_code = 'session_full'`(不是静默截断)。
|
||||
- consult 路由:reserve 成功后调用该函数;拿到 `session_full` 时释放预扣(走现有 `cancel_consultation_credit` 链路)并向客户端返回带该 code 的 4xx。
|
||||
- 前端:发问前不再 `persistSession`;收到 `session_full` 显示"这段对话已写满,开个新对话继续吧",并提供一键开新对话(把当前问题带进新会话的输入框)。
|
||||
- 验收:构造逼近 200,000 字符的会话发问 → 得到明确提示、余额不扣、会话未被写入半条消息。
|
||||
|
||||
### 任务 2(P1)· 上下文服务端读取
|
||||
|
||||
- consult 路由忽略请求体 `history`,改为从数据库读该会话最近 12 条(role/text,text 截 4,000 字符,与现契约同口径)。
|
||||
- 请求 schema 的 `history` 字段按决策记录 4 保留 optional。
|
||||
- 验收:构造携带伪造 `history` 的请求,用测试断言证明伪造内容不进入模型上下文。
|
||||
|
||||
### 任务 3(P1)· 收回客户端 messages 写权
|
||||
|
||||
- `persistSession` 改为元数据 patch:新增 `chatSessionMetadataPatchSchema`(title/theme/model_id/chart 绑定三件),`updated_at` 改由服务端 `now()` 写,不再信客户端时钟。
|
||||
- 按决策记录 2/3 删除 3191、3683、3742、3776、3789 的消息持久化调用;完成态的本地 UI 消息保持现有渲染(客户端本地已有同内容,刷新后走任务 0 的详情接口拿服务端权威版本)。
|
||||
- PATCH 路由按红线 3 接受并忽略 `messages`,打观测日志(用户 id 哈希 + 数组长度即可,不落消息内容)。
|
||||
- `POST /api/sessions`(create):`messages` 收紧为只接受空数组(`max(0)`),堵住经 create 伪造历史的口子。
|
||||
- 验收:**双标签页同一会话交替各发一问,两边分别刷新后四条消息完整无丢失** —— 这是现状必挂、改后必过的核心用例,实测过程写进 PROGRESS。
|
||||
|
||||
### 任务 4(P2)· 顺手刀
|
||||
|
||||
- `frontend/src/app/api/rectification/agent/route.ts:241` 的 select 去掉 `messages` 列。
|
||||
- PATCH 兼容层的观测日志接入现有 `agent-observability` 口径,便于两周后确认无量、下一轮拆除兼容层。
|
||||
|
||||
## 总验收
|
||||
|
||||
1. `./node_modules/.bin/tsc --noEmit` 通过;测试满足红线 6,`npm run test:db` 真跑。
|
||||
2. 任务 0/1/3 的三个实测用例(payload 对比、session_full、双标签页)逐条附数据。
|
||||
3. 旧契约兼容:手工用旧格式(含 `messages` 的全量 PATCH)打一发,返回 200 且数据库消息未被覆盖。
|
||||
4. 浅色/深色两套主题下检查新增的加载态与提示。
|
||||
|
||||
## 明确不做(本轮红线外,不要顺手做)
|
||||
|
||||
- **不做会话 URL 化**(`/c/[sessionId]`)—— 下一轮任务书,依赖本轮任务 0 的详情接口。
|
||||
- **不把 messages normalize 成独立表** —— 当前规模下 jsonb append 足够;先消灭客户端写权,若后续观测到行膨胀/写放大再立项。
|
||||
- **不动校正会话链路**(case 表的 turn RPC 体系保持原样)。
|
||||
- **不删 `history` 字段、不拆 PATCH 兼容层** —— 留到观测确认无量后的下一轮。
|
||||
Reference in New Issue
Block a user