Files
Jyotisha/TASK-chat-message-authority-20260901.md
T
Jesse_Chen 87fdd5648a
Independent Staging Quality Gate / validate (push) Successful in 10m33s
Independent Staging Quality Gate / publish (push) Successful in 11m47s
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
2026-09-01 10:11:53 +00:00

117 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 聊天消息服务端权威化(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(重命名)、2317create)、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/texttext 截 4,000 字符,与现契约同口径)。
- 请求 schema 的 `history` 字段按决策记录 4 保留 optional。
- 验收:构造携带伪造 `history` 的请求,用测试断言证明伪造内容不进入模型上下文。
### 任务 3P1)· 收回客户端 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。
### 任务 4P2)· 顺手刀
- `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 兼容层** —— 留到观测确认无量后的下一轮。