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

+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