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

13 KiB
Raw Blame History

任务书 · 聊天消息服务端权威化(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. 消息写入是客户端全量上传。 persistSessionpage.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_responsefrontend/supabase/migrations/20260808030000_consultation_stream_recovery.sql)把完整 assistant 消息(含 thinkingText / thinkingSections / techniqueTruth / 双 receipt,见 frontend/src/app/api/consult/route.ts:529-568completeResponseappend 进 chat_sessions.messages;随后客户端在 page.tsx:3742 再全量 PATCH 覆盖一次。两个标签页同开一个会话时,整数组 last-write-wins,另一个标签页的消息直接被抹掉。
  4. 上下文可伪造,部分回答白嫖。 /api/consulthistory 由客户端提供(page.tsx:3517slice(-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。

范围定性(三个已核实的事实,决定了刀口很小)

  • 没有任何"带消息创建会话"的路径。 createSessionpage.tsx:426-442)建会话时 messages: [];校正会话 mergepage.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-553page.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 definerset search_pathpg_advisory_xact_lockuser+request 粒度)、按 request_id 幂等、revoke all from public/anon/authenticatedgrant execute to service_roleadmin_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=0skipped=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 提示;两者都要在浅色/深色两套主题下检查。

让步顺序:数据不损坏 > 功能与测试不回归 > 可验证的改进 > 代码整洁。

开工前置

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.tsfrontend/src/app/api/sessions/[id]/route.ts(现状只有 PATCH/DELETE,无 GET
  • frontend/src/lib/chat-session-write-contract.ts
  • frontend/src/app/api/consult/route.tscompleteResponse 与 reserve 段
  • frontend/supabase/migrations/20260808030000_consultation_stream_recovery.sqlreserve_consultation_usage / complete_consultation_response 的模式样板)
  • frontend/src/app/page.tsxpersistSession(2202)及全部调用点:2234(重命名)、2317create)、2915(校正 merge)、3191(中止)、3454(发问前)、3683(截断失败)、3742(完成)、3776/3789(失败恢复)

任务分解

任务 0(P0,门槛)· 列表/详情拆分

  • GET /api/sessionsselect 去掉 messages,只回元数据(id、title、theme、model_id、session_type、rectification_case_id、chart 绑定三件、updated_at)。
  • frontend/src/app/api/sessions/[id]/route.ts 新增 GET:返回单会话完整 messages(鉴权同 PATCHuser_id 过滤 + RLS)。
  • 前端:启动只拉列表;切换会话时拉详情并展示加载态(用现有 inline-spinner 风格,两套主题验证);已加载的会话内存缓存,正在 streaming 的会话不重复拉。
  • 验收:首屏 /api/sessions 响应不含任何消息文本;用多会话账户实测拆分前后 payload 字节数,写进 PROGRESS。

任务 1P0)· 用户提问服务端落库

  • 新 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 字符的会话发问 → 得到明确提示、余额不扣、会话未被写入半条消息。

任务 2P1)· 上下文服务端读取

  • consult 路由忽略请求体 history,改为从数据库读该会话最近 12 条(role/texttext 截 4,000 字符,与现契约同口径)。
  • 请求 schema 的 history 字段按决策记录 4 保留 optional。
  • 验收:构造携带伪造 history 的请求,用测试断言证明伪造内容不进入模型上下文。

任务 3P1)· 收回客户端 messages 写权

  • persistSession 改为元数据 patch:新增 chatSessionMetadataPatchSchematitle/theme/model_id/chart 绑定三件),updated_at 改由服务端 now() 写,不再信客户端时钟。
  • 按决策记录 2/3 删除 3191、3683、3742、3776、3789 的消息持久化调用;完成态的本地 UI 消息保持现有渲染(客户端本地已有同内容,刷新后走任务 0 的详情接口拿服务端权威版本)。
  • PATCH 路由按红线 3 接受并忽略 messages,打观测日志(用户 id 哈希 + 数组长度即可,不落消息内容)。
  • POST /api/sessionscreate):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 通过;测试满足红线 6npm 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 兼容层 —— 留到观测确认无量后的下一轮。