fix(gateway)+docs(api): F5 区分「字段缺失/值为空白」+ E4/E5 契约文档治理
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- F5:PATCH /sessions/{id} 的 400 文案区分两类——都缺 → at least one of title or pinned is required;
  存在但空白 → title must not be blank(此前空白也回 title is required,误导排查方向)。
  「空串不能清空标题」的既有语义保持不变(用例显式钉住),新增 history_write_test.go 4 段断言
- E4:/auth/logout 的两种真实响应形状(单撤 {revoked} / 全撤 {revoked,revokedCount})+ 错误码;
  /auth/refresh 补 role;登录响应与一次性接入段补 refreshToken(两份文档一致)
- E5:api-reference §3.2 补 pinned/unreadCount/lastMessage/personaId 与准确排序规则
  (pinned DESC, (type='main') DESC, created_at ASC ⇒ 置顶的次级会话可排在主会话之前);
  新增 §3.6a PATCH /sessions/{id}(请求体/错误码/部分更新/title 不可清空)与 §3.6b POST /sessions/{id}/read;
  端点表补 read 行
- api-client 删掉「主会话恒在前」的错误断言(两处),PATCH 错误码文案同步
- DevDocs/02-development-constraints.md:上传上限 50MB → **硬 5 MiB**(常量、无配置项)
This commit is contained in:
AskaEth committed 2026-10-04 14:13:27 +08:00
1 parent 5fb0cf5e19
commit 20d452a51a
4 files changed
+198 -20

No files matched your search

+7 -7
View File
@@ -24,17 +24,17 @@
| # | 端点 | 方法 | 认证 | 状态 | 说明 |
|---|------|------|------|:---:|------|
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`(access 24h;**新增 `refreshToken`**——不透明随机串,仅哈希落库,`AUTH_REFRESH_TTL` 默认 720h,用于静默续期;role=owner) |
| 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,refreshToken?}}`(新 access + 新 refresh,**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 |
| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 |
| 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,role,refreshToken?}}`(新 access + 新 refresh;**`role` 恒返回** = 该 refresh grant 记录的角色 `owner|admin|viewer`),**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 |
| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等)→ `{data:{revoked:<bool>}}`;带 `Authorization: Bearer <jwt>` 且无 body → 撤**该账号全部** grant(全设备登出)→ `{data:{revoked:<bool>,revokedCount:<n>}}`。**两种响应形状不同**——单撤**没有** `revokedCount`;两者都不给回 `400 bad_request`。refresh 相关操作成功才审计 |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;**v0.53 语义修正**)——**主模型能不能收图看生效组的 `multimodal`**(`models.json` 组键,面板可保存、热更新);`LLM_VISION_MODEL`/`LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**,**与主模型能否收图无关**(裁决 14.4.1);主模型不收图时由视觉·OCR 工具的**文本描述**替代(图像不占上下文,只有工具结果占);`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `<p>` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`**——`pinned=true` 最前,其次是未置顶的主会话,最后是未置顶子会话(按 `createdAt` 升序);⚠️ **置顶的次级会话会排在主会话之前**,客户端定位主会话请按 `id=="web"` / `type=="main"`,**不要假定 index 0 就是主会话**;次级 `web:<uuid>`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";**会话角色只在创建时写入、之后不可变**,见 §1 行 8 / design/22 §8-1);`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` |
| 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) |
| 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:<id>}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId` 出现即 `400`**(v0.46,design/22 §8-1:**会话角色在创建时确定、之后不可变,要换角色请新开会话**)——**不是静默忽略、也不是 200 空操作**:**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request`,错误消息点名 `personaId`,且该检查**先于**会话存在性检查(**未知会话 + `personaId` 也回 `400`**,不是 `404`;连 `title` 同传也整体 `400`、不改任何字段)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**;都缺回 `400 {"code":"bad_request","message":"at least one of title or pinned is required"}`——说明「缺字段」)。`title` trim、≤80 rune:**存在但为空/仅空白回 `400 "title must not be blank"`**(**不是** `title is required`——字段在、只是值非法)、超长回 `400 "title is too long (max 80 characters)"`;**空串不能清空标题**(不回落自动命名,故客户端不提供「恢复自动命名」入口);`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId` 出现即 `400`**(v0.46,design/22 §8-1:**会话角色在创建时确定、之后不可变,要换角色请新开会话**)——**不是静默忽略、也不是 200 空操作**:**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request`,错误消息点名 `personaId`,且该检查**先于**会话存在性检查(**未知会话 + `personaId` 也回 `400`**,不是 `404`;连 `title` 同传也整体 `400`、不改任何字段)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 |
| 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
| 9 | `/api/v1/personas` | GET | JWT | ✅ | **角色目录**(任意已认证角色可读)→ `{data:[PersonaSummary...]}`,每项 `{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}`。**`mood`(v0.43)** = 该角色**当前情绪状态**(角色级,取值见 `GET /meta/enums` 的 `moods`;`""` = 该角色暂无被跟踪的情绪,**不伪造默认值**)。**语义:情绪跟随角色,与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**,避免刷新后短暂空白(随后以 WS `mood` 帧为准)。**不含 admin 专属的 `thinking` 旋钮**(用户侧不暴露模型调用参数)。 |
| 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"<base64>",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) |
@@ -449,12 +449,12 @@
## 4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)
### 4.1 一次性接入流程(三步走)
1. **拿令牌**:`POST /api/v1/auth/login` `{username,password}` → `{data:{token,expiresAt,role}}`(JWT,默认 24h,`role` ∈ `owner|admin|viewer`)。
1. **拿令牌**:`POST /api/v1/auth/login` `{username,password}` → `{data:{token,expiresAt,role,refreshToken}}`(JWT,默认 24h,`role` ∈ `owner|admin|viewer`;`refreshToken` 为 B2 不透明长期凭据,用于静默续期,未接线 refresh 存储时**省略该键**)。
2. **声明版本(可选/advisory)**:可带 `X-API-Version: 1` + `Authorization: Bearer <token>`;WS 握手用 `ws://<host>:<port>/ws/chat?token=<token>`(或 `Authorization: Bearer`)。**`X-API-Version` 是**advisory**惯例头——服务端当前不校验、不据此协商版本,带不带行为不变**;`viewer` 只能读会话/人格元数据;读写会话消息、管理写操作需 `owner`/`admin`(**WS 上 `viewer` 也只收 7 个状态机信号帧:既收不到 `history` 回放,也发不出任何上行指令**,见 §3 顶部两把角色门,v0.48 + v0.49)。
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
### 4.2 会话与消息用法(聊天)
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次是未置顶的主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;⚠️ **置顶的次级会话可以排在主会话之前**,别把列表第一项当主会话(按 `id=="web"` / `type=="main"` 定位);次级 `web:<uuid>` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。
- **新建时选角(v0.42,v0.46 收口)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色(省略/空 = 跟随全局默认角色)。**会话角色在创建时确定、之后不可变**(design/22 §8-1):**没有"会话内换角色"这回事**——`PATCH /api/v1/sessions/{id}` 一旦收到 `personaId` 键就回 `400`(见 §1 行 8),**要换角色请新开会话**。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。
- **角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4)**:一个回合到底以哪个角色答话,**自上而下**取第一个命中的:
1. **请求级覆盖** —— 仍**未实现**(design/22 §2 裁决规则 1,留待后续批次);
@@ -463,7 +463,7 @@
4. **全局默认角色** `owner.yaml:defaultPersona` —— 兜底(`''` 始终 = 当前默认角色)。
**群聊 / 陌生人隔离会话强制默认角色**(§7-4,更保守):这两类会话**跳过** 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是**只读**。**Web 主会话与 IM 主人私聊共享同一角色**(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
- **模型上下文里的「谁说的」(v0.44,design/22 §7-5)**:一回合的上下文由该会话**最近 20 条**持久化消息(`chat_messages`,含 `persona_id`)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);**每条 assistant 历史消息按其说话角色标注**,模型收到形如 `[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。` 的独立 system 标注,以及一条声明当前角色的 `[会话角色] …` 段。**正文永不被改写**:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。**对客户端透明**(无新字段、无新端点)。
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)和/或 `{pinned?:bool}`(至少一个字段)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。**body 里出现 `personaId` 键一律 `400`**(会话角色不可变,v0.46 / design/22 §8-1)。
- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune,空白标题回 `400 "title must not be blank"`——**空串不能清空标题**/恢复自动命名)和/或 `{pinned?:bool}`(至少一个字段;都缺回 `400`)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。**body 里出现 `personaId` 键一律 `400`**(会话角色不可变,v0.46 / design/22 §8-1)。
- **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。
- **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=`。
- **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。
+59 -9
View File
@@ -72,10 +72,11 @@
| 6 | GET | `/api/v1/sessions/{id}/messages` | owner\|admin | 200 | 会话消息热分页 | M16 |
| 7 | POST | `/api/v1/sessions` | owner\|admin | 201 | 新建次级会话 | M16 |
| 8 | DELETE | `/api/v1/sessions/{id}` | owner\|admin | 200 | 删除次级会话 | M16 |
| 9 | PATCH | `/api/v1/sessions/{id}` | owner\|admin | 200 | 会话改名(A4) + 置顶(`pinned`, D5) body `{title?,pinned?}` | M16 |
| 9 | PATCH | `/api/v1/sessions/{id}` | owner\|admin | 200 | 会话改名(A4) + 置顶(`pinned`, D5) body `{title?,pinned?}`(部分更新,只回显变更字段;见 §3.6a) | M16 |
| 10 | PATCH | `/api/v1/messages/{id}` | owner\|admin | 200 | 编辑单条消息(D3) body `{content}` | M16 |
| 11 | DELETE | `/api/v1/messages/{id}` | owner\|admin | 200 | 删除单条消息(D3) | M16 |
| 12 | GET | `/api/v1/meta/enums` | JWT | 200 | 客户端共享枚举权威表(B4):`platform`/`mood`/`mode` + `unknown` 回落约定 | M8 |
| 13 | POST | `/api/v1/sessions/{id}/read` | owner\|admin | 200 | 推进已读游标(D4) body `{before?}`(缺省 = 现在)→ `{sessionId,readAt}`(见 §3.6b) | M16 |
### 1.3 管理后台(`/api/v1/admin/*`)
@@ -178,7 +179,7 @@ curl -s http://<host>:8080/api/v1/health
| `refreshToken` | string | **不透明 256-bit 随机串**(B2)——服务端**仅存其 SHA-256 哈希**,明文只在响应出现一次;用于 `POST /api/v1/auth/refresh` 静默续期,`AUTH_REFRESH_TTL` 默认 `720h` |
- **错误**:`400` `{"error":{"code":"bad_request","message":"invalid request body"}}`(JSON 解析失败);`401` `{"error":{"code":"unauthorized","message":"invalid username or password"}}`;`500` `{"error":{"code":"login_failed",...}}`(签名失败)。
- **备注**:登录响应**以代码为准** = `{token, expiresAt, role}`(**不是** `{token,jwt}`)。旧 `api-client.md` §1 表内一行写 `{token,jwt}` 为**历史笔误**,以本档为准。curl:
- **备注**:登录响应**以代码为准** = `{token, expiresAt, role, refreshToken?}`(**不是** `{token,jwt}`)。`refreshToken` 是 B2 新增的非透明长期凭据(未接线 refresh 存储时**该键省略**,此时形状与 B2 之前一致);旧 `api-client.md` §1 表内一行写 `{token,jwt}` 为**历史笔误**,以本档为准。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/auth/login \
@@ -190,14 +191,16 @@ curl -s -X POST http://<host>:8080/api/v1/auth/login \
### 2.3 POST /api/v1/auth/refresh(B2)
- **鉴权**:公开,body 带 `refreshToken`。
- **请求 Body**:`{refreshToken:"<opaque>"}`。
- **响应**:`200` `{"data":{"token":"<新JWT>","expiresAt":<unix秒>,"refreshToken":"<新opaque?>"}}`——默认**单次使用旋转**:旧 refresh 立即作废并签发新的;`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh(`refreshToken` 省略)。
- **响应**:`200` `{"data":{"token":"<新JWT>","expiresAt":<unix秒>,"role":"<owner|admin|viewer>","refreshToken":"<新opaque?>"}}`(`role` 恒返回,取自该 refresh grant 记录的角色)——默认**单次使用旋转**:旧 refresh 立即作废并签发新的;`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh(`refreshToken` 省略)。
- **错误**:`400 bad_request`(JSON 解析失败 / 空 token);`401` `{"error":{"code":"invalid_grant",...}}`(未知/已撤销/已过期,统一口径,防枚举);仅成功才审计。
- **curl**:`curl -s -X POST http://<host>:8080/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":"<opaque>"}'`
### 2.4 POST /api/v1/auth/logout(B2)
- **鉴权**:公开,两种方式二选一——body `{refreshToken}` 撤**单个** grant(幂等);或无 body 但带 `Authorization: Bearer <jwt>` 撤**该账号全部** grant(全设备登出)。
- **响应**:`200` `{"data":{"ok":true,"revokedCount":<n>}}`(单撤 `revokedCount` 为 0/1;全撤为该账号 refresh 行数)。
- **错误**:不存在的 token 为幂等 no-op(仍 `200`);非法 body `400`。
- **响应**(**两种形状,别混**):
- body 撤单个:`200` `{"data":{"revoked":<bool>}}`(幂等——未知/已吊销的 token 得 `revoked:false` 而**不是**错误)。
- Bearer 全撤:`200` `{"data":{"revoked":<bool>,"revokedCount":<n>}}`(`revokedCount` = 本次删除的该账号 refresh 行数,`revoked` = `revokedCount > 0`)。
- **错误**:不存在的 token 为幂等 no-op(仍 `200`);非法 body `400`;`refreshToken` 与 `Authorization` **都没给**回 `400` `{"error":{"code":"bad_request","message":"refreshToken or Authorization bearer token is required"}}`;Bearer 无效/过期回 `401` `{"error":{"code":"invalid_grant",...}}`;撤销失败回 `500 logout_failed`。
---
@@ -253,14 +256,21 @@ curl -s -X POST http://<host>:8080/api/v1/chat \
**A) 持久化会话**(`history != nil`,主 + 子):
```json
{"data":[
{"id":"web","type":"main","title":"","createdAt":1753...},
{"id":"web:<uuid>","type":"sub","title":"临时小事","createdAt":1753...}
{"id":"web","type":"main","title":"主控台","createdAt":1753...,"pinned":false,"unreadCount":2,
"lastMessage":{"role":"assistant","content":"好的,我在。","createdAt":1753...},"personaId":"xilian"},
{"id":"web:<uuid>","type":"sub","title":"临时小事","createdAt":1753...,"pinned":true,"unreadCount":0}
]}
```
- `id`:主会话恒 `"web"`;次级为 `web:<uuid>`。
- `type`:`"main"` | `"sub"`。
- `title`:可选(`omitempty`)。
- `title`:可选(`omitempty`;首条用户消息到达时自动命名,无标题时缺该键)。
- `createdAt`:int64(Unix 毫秒)。
- `pinned`:bool,**恒返回**(D5 置顶标志;只是排序元数据,客户端无需第二次调用即可排序)。
- `unreadCount`:int,**恒返回**(D4;= 该会话中 `createdAt >` 已读游标的消息条数,`0` = 无未读)。它与 `lastMessage` 由**同一条 SQL** 派生,故查询数不随会话数增长(无 N+1)。
- `lastMessage`:可选(`omitempty`);最后一条消息的纯展示预览 `{role("user"|"assistant"), content, createdAt}`——`content` 已折叠空白并截断到 **60 rune** 加省略号;**无消息的会话缺该键**。
- `personaId`:可选(`omitempty`,v0.42);该会话绑定的角色 id(design/22 §2/§4.5)。**缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像。
**排序(以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`)**:置顶会话最前 → 未置顶的**主会话** → 未置顶的**子会话**(按 `createdAt` 升序)。⚠️ **置顶的次级会话会排在主会话之前**,因此客户端**不要假定「index 0 就是主会话」**;要定位主会话请按 `id == "web"`(或 `type == "main"`)查找。
**B) 渠道会话回退**(`history == nil`):
```json
@@ -367,7 +377,7 @@ curl -s -X POST http://<host>:8080/api/v1/sessions \
- **错误**:
- `403` `{"error":{"code":"forbidden","message":"the main session cannot be deleted"}}`(`history.ErrImmutableMain`,主会话 `web` 不可删)。
- `400`(id 缺失);`501`(无 HistoryStore);`500` `{"error":{"code":"session_failed",...}}`。
- **备注**:删除次级会话及其全部消息。curl:
- **备注**:删除次级会话及其全部消息。**未知会话 id 也回 `200`(幂等)**——删除只是影响 0 行,不报 `404`(删除目标已不在时「已删除」依然成立)。curl:
```bash
curl -s -X DELETE http://<host>:8080/api/v1/sessions/web:0123abcd... \
@@ -375,6 +385,46 @@ curl -s -X DELETE http://<host>:8080/api/v1/sessions/web:0123abcd... \
# => {"data":{"deleted":"web:0123abcd..."}}
```
### 3.6a PATCH /api/v1/sessions/{id}
- **鉴权**:`Authorize(owner, admin)`(历史写;viewer → `403`)。
- **Path**:`{id}` = 会话 id。主会话 `web` **可以**改名/置顶,只是不可删。
- **请求 Body**:`{"title"?: string, "pinned"?: bool}`——**两个字段都缺失时回 `400`**(不支持「空 PATCH」):
- `title`:会话显示名。服务端 `TrimSpace` 后校验:**空白(`""` / 全空白)→ `400 title must not be blank`**;**超过 80 rune → `400 title is too long (max 80 characters)`**。**`""` 不能清空标题**——「`title` 存在但为空白」与「`title` 缺键」语义不同,后者才是「不改标题」;因此客户端**不提供「恢复自动命名」入口**(要换名只能给非空白标题)。
- `pinned`:bool,置顶 / 取消置顶(D5)。
- `personaId`:**不是可设置字段**——请求体里**出现该键(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)即 `400`**,错误消息点名 `personaId`;该检查在会话存在性检查**之前**执行,因此**未知会话 + `personaId` 回 `400`(不是 `404`)**,且与 `title` 同传时整体 `400`、**一个字段都不落库**(design/22 §8-1:会话角色创建时固定、之后不可变;v0.46)。
- **语义**:**部分更新**——只改请求中给出的字段;响应**只回显本次变更的字段**(title-only → `{id,title}`;pin-only → `{id,pinned}`;两者同给 → 三个键都在)。
- **响应**:`200`:
```json
{"data":{"id":"web:<uuid>","title":"新名字"}}
```
- **错误**:
- `400` `{"error":{"code":"bad_request","message":"at least one of title or pinned is required"}}`(`title` 与 `pinned` 都缺)。
- `400` `{"error":{"code":"bad_request","message":"title must not be blank"}}`(`title` 存在但 trim 后为空白)/ `"title is too long (max 80 characters)"`;`personaId` 出现即 `400`。
- `404` `{"error":{"code":"not_found","message":"session \"<id>\" not found"}}`。
- `501` `not_implemented`(未注入 HistoryStore);`500` `{"error":{"code":"session_failed",...}}`(改名/置顶失败)。
- **curl**:
```bash
curl -s -X PATCH http://<host>:8080/api/v1/sessions/web:0123abcd... \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pinned":true}'
# => {"data":{"id":"web:0123abcd...","pinned":true}}
```
### 3.6b POST /api/v1/sessions/{id}/read
- **鉴权**:`Authorize(owner, admin)`(历史写;viewer → `403`)。
- **请求 Body(可选)**:`{"before"?: int64}`——毫秒 Unix 时间戳。**缺省 / 空 body / `{}` = 现在**(「我看到的都算已读」);给值 = 把游标推进到该时刻。
- **语义**:`createdAt <= readAt` 的消息算已读(`GET /sessions` 的 `unreadCount` 只数 `createdAt > readAt` 的)。**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(无 per-device 游标);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),因此乱序/并发/重试请求幂等安全,绝不会把已读退回未读。
- **响应**:`200` `{"data":{"sessionId":"<id>","readAt":<推进后的游标毫秒>}}`。
- **错误**:`before` 为 `0`/负数 → `400 "before must be a positive millisecond timestamp"`;`before` 非数字或 body 非 JSON 对象 → `400 "invalid request body"`(**空对象 `{}` 合法** = 现在);未知会话(含不在 `chat_sessions` 里的隔离会话 `sess-xxx`)→ `404`;未注入 HistoryStore → `501 not_implemented`;推进失败 → `500 session_failed`。
- **副作用**:成功后向下行广播 `session_read`(见 api-client §3.9),让同账号其它端**免刷新**清红点;**失败路径不广播**。
- **升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读。
- **curl**:
```bash
curl -s -X POST http://<host>:8080/api/v1/sessions/web/read \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"before":1753000000000}'
# => {"data":{"sessionId":"web","readAt":1753000000000}}
```
---
### 3.7 GET /api/v1/meta/enums
+9 -4
View File
@@ -42,8 +42,13 @@ const maxSessionTitleRunes = 80
// handlePatchSession 更新持久化会话的显示元数据 —— 其标题
// (A4)与置顶标志(D5)。二者都是元数据:与 DELETE 不同,它们
// 允许作用于固定的主会话。两个字段都不携带的请求返回 400,
// 空/仅空白或过长的标题亦然,未知会话 id(照常 404/500)。
// 允许作用于固定的主会话。未知会话 id 照常 404/500。
//
// 两种 400 刻意用不同文案区分「缺字段」与「字段在、值非法」:
// title 与 pinned 都没给 → 「至少需要 title 或 pinned」;
// 给了 title 但 trim 后为空白 → 「title 不得为空白」;过长 → 明确的上限提示。
// title 的空串/仅空白**没有**清空语义(不回落自动命名),
// 因而客户端不提供「恢复自动命名」入口;要换名只能给非空白标题。
//
// 携带 personaId 键的请求体对任何取值都返回 400,且该检查
// 在会话被查找之前执行:会话角色在创建时固定(design/22
@@ -74,14 +79,14 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
return
}
if req.Title == nil && req.Pinned == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title or pinned is required"))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "at least one of title or pinned is required"))
return
}
resp := map[string]any{"id": id}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is required"))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title must not be blank"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
@@ -0,0 +1,123 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件覆盖 PATCH /api/v1/sessions/{id} 的两个 400 文案之别:
// 「字段缺失」(title 与 pinned 都没给)与「字段在、值非法」
// (title 存在但 trim 后为空白)必须给出不同的说明——否则
// 空白标题读起来像「缺字段」,客户端会去补一个本就在的键。
// 同时钉住客户端依赖的既有语义:空串/仅空白**不能**清空标题
// (因此客户端不提供「恢复自动命名」入口)。
// patchSessionError 解出 PATCH /sessions/{id} 失败响应的
// {code,message},使断言针对客户端看到的文案本身。
func patchSessionError(t *testing.T, body []byte) (string, string) {
t.Helper()
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
return eobj.Code, eobj.Message
}
// sessionTitle 读回列表里某会话的标题,用于断言标题未被改写。
func sessionTitle(t *testing.T, ts *httptest.Server, tok, id string) string {
t.Helper()
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list sessions status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []struct {
ID string `json:"id"`
Title string `json:"title"`
}
decodeDataSlice(t, body, &sessions)
for _, s := range sessions {
if s.ID == id {
return s.Title
}
}
t.Fatalf("session %s missing from list", id)
return ""
}
// TestPatchSessionDistinguishesMissingFromBlank 断言两类 400 的文案互不相同
// 且各自说到点上:都缺字段时说明「至少需要 title 或 pinned」,
// 只有 title 且为空白时说明「title 不得为空白」(不是
// 读起来像缺字段的 "title is required")。
func TestPatchSessionDistinguishesMissingFromBlank(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/sessions/" + history.MainSessionID
// ① 两个字段都没给:文案必须点名两个可给的字段。
resp, body := doReq(t, http.MethodPatch, base, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty body status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
code, missingMsg := patchSessionError(t, body)
if code != "bad_request" {
t.Fatalf("empty body code = %q, want bad_request (body=%s)", code, body)
}
if !strings.Contains(missingMsg, "title") || !strings.Contains(missingMsg, "pinned") {
t.Fatalf("empty body message = %q, want it to name both title and pinned", missingMsg)
}
t.Logf("字段缺失 → %s", body)
// ② title 在但为空白:文案必须说「不得为空白」,且与 ① 不同。
for _, blank := range []string{"", " ", " \t\n "} {
resp, body = doReq(t, http.MethodPatch, base, map[string]any{"title": blank}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("blank title %q status = %d, want 400 (body=%s)", blank, resp.StatusCode, body)
}
code, blankMsg := patchSessionError(t, body)
if code != "bad_request" {
t.Fatalf("blank title %q code = %q, want bad_request (body=%s)", blank, code, body)
}
if blankMsg != "title must not be blank" {
t.Fatalf("blank title %q message = %q, want %q", blank, blankMsg, "title must not be blank")
}
if blankMsg == missingMsg {
t.Fatalf("blank and missing share message %q, want the two cases distinguishable", blankMsg)
}
if strings.Contains(blankMsg, "is required") {
t.Fatalf("blank title message = %q, want it not to read as a missing field", blankMsg)
}
t.Logf("字段在但空白 → %s", body)
}
// ③ 既存语义不变:空白 PATCH 不改标题(标题无法清空)。
resp, body = doReq(t, http.MethodPatch, base, map[string]any{"title": " 保留我 "}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := sessionTitle(t, ts, tok, history.MainSessionID); got != "保留我" {
t.Fatalf("title after rename = %q, want 保留我", got)
}
resp, body = doReq(t, http.MethodPatch, base, map[string]any{"title": ""}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("clear attempt status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if got := sessionTitle(t, ts, tok, history.MainSessionID); got != "保留我" {
t.Fatalf("title after clear attempt = %q, want it unchanged at 保留我", got)
}
// ④ 只给 pinned(无 title)仍然可用:区分文案不得收紧原有契约。
resp, body = doReq(t, http.MethodPatch, base, map[string]any{"pinned": true}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("pin-only status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
}