87fdd5648a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
13 KiB
13 KiB
任务书 · 聊天消息服务端权威化(2026-09-01)
基线:origin/staging @ 26ea3f06(开工时以 origin/staging 最新为准)。本轮与任何同期改 frontend/src/app/page.tsx 的轮次不得并行。
这份任务书来自一次全栈架构审计。产品"交互不好"的最大结构性根源不在样式层,而在聊天记录的持久化模型:客户端权威、整包上传。本轮收回这个写权。先读完「范围定性」和「决策记录」再动手 —— 刀口比看起来小得多,不要自行扩大。
为什么要做(事故实证)
下面所有行号只是线索,按符号/选择器定位,origin/staging 上可能有偏移。
- 会话列表全量返回所有消息。
frontend/src/app/api/sessions/route.ts的 GETselect包含messages,无分页、无上限 —— 每次进首页,把该用户所有会话的完整聊天记录一次性拉下来。单会话契约上限 20 万字符(见下),老用户几十个会话时首屏 payload 达 MB 级。客户端读取时还要把每条截到 12,000 字符(page.tsx:908附近stored.text.slice(0, 12000)),说明这个问题已经被打过一次补丁而不是治过。 - 消息写入是客户端全量上传。
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。 - 双写且客户端胜,多标签页丢消息。 服务端在结算时已经通过
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,另一个标签页的消息直接被抹掉。 - 上下文可伪造,部分回答白嫖。
/api/consult的history由客户端提供(page.tsx:3517的slice(-12),schema 上限 20 条 × 4,000 字符),用户可伪造"assistant 说过的话"注入模型上下文。另外中止(page.tsx:3191)和 run.failed 有部分文本(page.tsx:3683)时由客户端持久化部分回答 —— 这两种情形额度已退款,现状等于退款请求还能免费保存部分回答。 - 顺手的浪费。
frontend/src/app/api/rectification/agent/route.ts:241select 了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)
以下四条推翻或改变既有语义,均已获产品确认,执行方不得以"改变现状"为由拒改:
- 消息的唯一权威写入方是服务端。 客户端对
messages的一切写入权收回。这会改变chat-session-write-contract的既有语义及锁它的合同测试 —— 属于"断言锁住的正是本轮要修的缺陷"的例外情形,按红线 4 的程序修改。 - 中止与失败的部分回答不再持久化。 仅保留在当前页面内存,刷新即失,沿用现有提示文案。理由:这两种情形额度已退款,持久化等于免费保存部分回答;为它单开服务端写路径会扩大攻击面。
- 流失败时用户提问的语义改变:reserve 成功即落库、保留在会话里,失败提示后可直接重试(新 requestId);不再"放回输入框"。reserve 失败(余额不足、session_full 等)则不落库、不出现在会话里,此时问题仍放回输入框。
history字段服务端不再读取,上下文从数据库取。请求 schema 中该字段保留 optional 以兼容线上旧 bundle,本轮不删字段,下一轮再删。
硬红线
- 不改表结构,不写数据迁移。 新增数据库对象只允许 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存在则条件授权)。 - 状态与并发控制在 Postgres 函数里,不得把幂等/加锁逻辑搬进应用层(与
personal_report_jobs的既有规矩一致)。 - PATCH 兼容窗口。 服务端收到含
messages的旧全量写,必须接受并忽略messages字段(不报错、不落库、打一条观测日志)。禁止直接改成拒绝 —— 线上旧 bundle 没有强制刷新机制(stale-client-recovery只救 chunk 加载失败,救不了契约不兼容),直接拒绝会让存量用户每轮聊天报错。 - 不得修改既有测试断言 —— 例外仅限锁住"客户端可写 messages / 服务端信任客户端 history"这一缺陷本身的断言;须在断言上方注释原值与原因,并在 PROGRESS 单列。
- 推 staging 前必须
./node_modules/.bin/tsc --noEmit通过。不要用npx tsc,本仓库环境下会装到空包tsc@2.0.4。 - 测试数不得低于基线且
fail=0、skipped=0(有 Docker 的环境)。2026-09-01 验收环境实测基线为 738 通过、fail=0;无 Docker 时另有既有缺口(数据库/部署类失败 + skipped),必须逐条比对失败清单确认没有新增。本轮动了 SQL 函数,npm run test:db必须真跑;没有 Docker 就登记BLOCKED.md停下,不允许"本地跑不了所以跳过"。 - 不得改
.gitea/workflows/**。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。 - 本轮唯一允许的 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.ts与frontend/src/app/api/sessions/[id]/route.ts(现状只有 PATCH/DELETE,无 GET)frontend/src/lib/chat-session-write-contract.tsfrontend/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口径,便于两周后确认无量、下一轮拆除兼容层。
总验收
./node_modules/.bin/tsc --noEmit通过;测试满足红线 6,npm run test:db真跑。- 任务 0/1/3 的三个实测用例(payload 对比、session_full、双标签页)逐条附数据。
- 旧契约兼容:手工用旧格式(含
messages的全量 PATCH)打一发,返回 200 且数据库消息未被覆盖。 - 浅色/深色两套主题下检查新增的加载态与提示。
明确不做(本轮红线外,不要顺手做)
- 不做会话 URL 化(
/c/[sessionId])—— 下一轮任务书,依赖本轮任务 0 的详情接口。 - 不把 messages normalize 成独立表 —— 当前规模下 jsonb append 足够;先消灭客户端写权,若后续观测到行膨胀/写放大再立项。
- 不动校正会话链路(case 表的 turn RPC 体系保持原样)。
- 不删
history字段、不拆 PATCH 兼容层 —— 留到观测确认无量后的下一轮。