feat(contract,gateway): B4 枚举权威表 — GET /api/v1/meta/enums + 未知值回落约定
- 新增 internal/contract/enums 叶子包:platform/mood/mode 三表 + UnknownFallback="display-raw";每次调用返回新切片,无包级可变全局 - GET /api/v1/meta/enums(anyAuth 只读、纯投影、无 store/无错误路径) - 反漂移 4 处:真构造内置适配器断言 Name() 与表双向相等、真工厂 buildAccountAdapter 逐项可建、mood 对齐 emotion 常量 + 默认转移表、mode 对齐前端 MODES(跨语言,文件缺失则 skip) - Docs/api-client §1.1 枚举权威表 + 行15 + v0.38;api-reference §3.7 + 行12
This commit is contained in:
1 parent
a828edacc5
commit
22e8398110
8 files changed
+617
No files matched your search
@@ -41,11 +41,77 @@
|
||||
| 13 | `/api/v1/upload` | POST | JWT(owner/admin) | ✅ | **图片上传**(B1):multipart/form-data,字段 `file`(raster image,5MB 上限)→ `201 {data:{url,size,mime,name}}`;`url` 为**同源访问地址**(`<host>/api/v1/files/<name>`,可直接作 `chat.image`)。校验:`MaxBytesReader` 防超大 → `DetectContentType` 嗅探(**不信客户端 Content-Type**)→ 白名单 png/jpeg/gif/webp/bmp(**拒 SVG/HTML**);文件名用 **crypto/rand 128-bit 随机名**(原始文件名丢弃),存 `<dataDir>/uploads/`(0700)。错误码:缺文件 `400 bad_request`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`、未授权 `401/403` |
|
||||
| 14 | `/api/v1/files/{name}` | GET | 无需(能力URL) | ✅ | **上传文件访问**(B1):从 `<dataDir>/uploads/` 读取,同名端口服务。`name` 仅接受「32 位小写 hex + 白名单扩展名」;**路径穿越全形态拒绝**(`..`/`%2f`/`%2e%2e` 等)。返回 `Content-Type`/`Content-Length`/`Cache-Control: private,max-age=86400,immutable`/`X-Content-Type-Options: nosniff`(支持 Range)。**设计取舍**:GET 不鉴权——该 URL 是**128-bit 随机能力地址**(`chat.image`/`<img>` 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 |
|
||||
|
||||
| 15 | `/api/v1/meta/enums` | GET | JWT(任意角色) | ✅ | **共享枚举权威表**(B4):`{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`——`platform`/`mood`/`mode` 的**唯一事实来源**,客户端**不必硬编码**即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 `GET /sessions` 同级(owner/admin/viewer 均可读;无 token 回 `401`)。详见 §1.1 |
|
||||
|
||||
> **语音 REST 源(`/api/v1/audio/*`,均 owner-only + 审计)**:原生/移动端用它上传/获取音频,**绕开 WS 4KB 帧限**。
|
||||
> - `POST /api/v1/audio/transcribe`:body `{audio:"<base64>", format?:"wav", deviceId?}` → `200 {data:{text}}`。`audio` 为 base64(或 data: URI);`format` 为编码(`wav`/`mp3`/`ogg`,缺省 `wav`);空 audio 回 `400`;未注入语音服务 `500 voice_unavailable`、识别失败 `500 stt_failed`。
|
||||
> - `POST /api/v1/audio/speech`:body `{text, voice?, format?, instruction?}` → `200` 原始音频字节(`Content-Type: audio/<format>`),**非 JSON envelope**。`voice`/`format` 目前遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT` 配置(后续支持按请求覆盖);`instruction` 为**可选** per-request 情绪/语气指令(如 `"语气开朗俏皮,像女朋友撒娇"`),只对**复刻/设计音色**生效——**系统音色 `longxiaochun_v3` 被服务端忽略,永不发送自由指令**(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端 `VOICE_TTS_INSTRUCTION`。空 text 回 `400`;未注入语音服务 `500 voice_unavailable`、合成失败 `500 tts_failed`、静默空音频 `500 tts_empty`。
|
||||
> - `POST /api/v1/audio/voice-clone`:body `{url:"<公网参考音频URL>", prefix?:"cyrene"}` → `200 {data:{voiceId}}`。`url` 为**公网可访问的参考音频 URL**(create_voice **只收 `input.url`,不收 base64**),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;`prefix` 为音色名前缀(**≤10 字母/数字**,缺省 `cyrene`)。返回的 `voiceId` 填到 `VOICE_TTS_VOICE_ID` 即可用该复刻音色合成(`target_model` 固定为 `VOICE_TTS_MODEL`,须与合成模型一致)。**URL 不写审计/日志(红线,绝不打日志)**。空 url 回 `400`;未注入语音服务 `500 voice_unavailable`;建音色失败 `500 voice_clone_failed`。**⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。**
|
||||
|
||||
### 1.1 枚举权威表(`GET /api/v1/meta/enums`,B4)
|
||||
|
||||
> **背景**:`platform` / `mood` / `mode` 此前只在文档里给示例(`web`/`telegram`/…;`calm`;`normal`),客户端只能自建映射表——来源角标与情绪标签因此会**随客户端漂移**。本端点发布**权威枚举表 + 未知值回落约定**:Web / Android / IM bridge **共用同一份**,客户端不必硬编码。
|
||||
|
||||
- **鉴权**:`Authorization: Bearer <JWT>`,**任意已认证角色可读**(`owner`/`admin`/`viewer`),与 `GET /api/v1/sessions` 同级——只读、无敏感信息。无 token / 令牌无效回 `401`。无其它错误码(纯内存投影,无存储、无 501)。
|
||||
- **响应**:`200`
|
||||
```json
|
||||
{"data":{
|
||||
"platforms":[{"id":"web","label":"网页"}, ...],
|
||||
"moods":[{"id":"calm","label":"平静"}, ...],
|
||||
"modes":[{"id":"chat","label":"对话"}, ...],
|
||||
"unknown":"display-raw"
|
||||
}}
|
||||
```
|
||||
每项固定为 `{"id":"<枚举 id>","label":"<默认中文标签>"}`。`label` 是**默认值**,自带 i18n 的客户端可覆盖显示;`id` 才是必须比对的值。
|
||||
- **`unknown`(未知值回落约定,机器可读)**:恒为 `"display-raw"`。语义见下。
|
||||
|
||||
**platform(消息来源平台,`{id:label}` 逐值)**
|
||||
|
||||
| id | label | 说明 |
|
||||
|----|-------|------|
|
||||
| `web` | 网页 | Web 主会话来源(`chat_messages.platform` 默认值) |
|
||||
| `kook` | KOOK | KOOK 适配器 |
|
||||
| `qq-obv11` | QQ | OneBot v11(QQ)适配器 |
|
||||
| `weixin-ilink` | 微信 | 微信 iLink 适配器 |
|
||||
| `telegram` | Telegram | Telegram Bot API 适配器 |
|
||||
| `webhook` | Webhook | 通用 Webhook 适配器 |
|
||||
|
||||
**mood(情绪,`{id:label}` 逐值)**
|
||||
|
||||
| id | label |
|
||||
|----|-------|
|
||||
| `calm` | 平静 |
|
||||
| `happy` | 开心 |
|
||||
| `excited` | 兴奋 |
|
||||
| `sad` | 低落 |
|
||||
| `angry` | 生气 |
|
||||
|
||||
> ⚠️ 情绪状态机**可扩展**(persona 可注册更多状态),因此上表**不是封闭集合**——未知 mood 必须按回落约定原样显示。
|
||||
|
||||
**mode(行为/呈现模式,`{id:label}` 逐值)**
|
||||
|
||||
| id | label |
|
||||
|----|-------|
|
||||
| `chat` | 对话 |
|
||||
| `code` | 代码 |
|
||||
| `home` | 家居 |
|
||||
| `work` | 工作 |
|
||||
| `docs` | 文档 |
|
||||
| `voice` | 语音 |
|
||||
| `vision` | 视觉 |
|
||||
| `agents` | 智能体 |
|
||||
| `settings` | 设置 |
|
||||
|
||||
**未知值回落约定(红线)**
|
||||
|
||||
- 客户端遇到**不在表中**的取值(`platform` / `mood` / `mode` 任一):**原样显示该原始字符串**——**不丢弃、不报错、不替换占位符、不猜测**。
|
||||
- **服务端新增枚举取值是非破坏性变更**(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;**不要**因为读到未知值而清空角标、抛异常或中断渲染。
|
||||
- 判定**只比对 `id`**(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。
|
||||
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
|
||||
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
|
||||
|
||||
> **契约版本 `v0.38`**(新增端点,向后兼容)。
|
||||
|
||||
## 2. REST 端点(管理,`/api/v1/admin/*`)
|
||||
|
||||
> 仅供管理面板与授权客户端;RBAC:`owner`/`admin`/`viewer`(viewer 只读、写 403);**写操作全程审计**。
|
||||
@@ -261,6 +327,7 @@
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。
|
||||
- `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。
|
||||
- `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。
|
||||
- `v0.35`(**可观测性收尾:后端日志 tail 端点**,对客户端**新增一个 admin 只读端点**):`GET /api/v1/admin/observability/logs?level=&limit=`(owner/admin)→ `200 {data:[{time,level,source,msg}]}`,**最新在前**。数据源为 logger 新增的**有界环形缓冲**(`internal/logger`,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经 `SetLogBuffer` 注入;缓冲未注入回 `501 not_implemented`(不伪造空 tail)。`time` 为 **RFC3339 UTC(秒精度)**,与 `trace.startedAt`/`audit.when` 一致;`level ∈ debug|info|warn|error`(大小写不敏感)为**最低等级过滤**,空 = 全部,未知值 `400 invalid_level`;`limit` 缺省 `200`、上限 `1000`。`source` 取 `logger.WithSource` 的 `source=`(`Scoped` 记录回落 `module/sub`),无来源为空串。详见 §2「可观测性契约」。
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
| 9 | PATCH | `/api/v1/sessions/{id}` | owner\|admin | 200 | 会话改名(A4) + 置顶(`pinned`, D5) body `{title?,pinned?}` | 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 |
|
||||
|
||||
### 1.3 管理后台(`/api/v1/admin/*`)
|
||||
|
||||
@@ -364,6 +365,40 @@ curl -s -X DELETE http://<host>:8080/api/v1/sessions/web:0123abcd... \
|
||||
|
||||
---
|
||||
|
||||
### 3.7 GET /api/v1/meta/enums
|
||||
- **鉴权**:`Authorize()`(**任意已认证角色**:owner/admin/viewer)——只读、无敏感信息,与 `GET /api/v1/sessions`(§3.2)同级。无 token 回 `401`。
|
||||
- **用途**:客户端共享枚举权威表(Android 协同 B4,design/07 §6)。`platform`(消息来源平台)/ `mood`(情绪)/ `mode`(行为·呈现模式)此前只在文档里给示例,客户端各自硬编码映射导致来源角标与情绪标签漂移;本端点把三张表做成**机器可读的单一事实来源**,Web / Android / IM bridge 共用。
|
||||
- **响应**:`200`
|
||||
```json
|
||||
{"data":{
|
||||
"platforms":[{"id":"web","label":"网页"},{"id":"kook","label":"KOOK"},{"id":"qq-obv11","label":"QQ"},{"id":"weixin-ilink","label":"微信"},{"id":"telegram","label":"Telegram"},{"id":"webhook","label":"Webhook"}],
|
||||
"moods":[{"id":"calm","label":"平静"},{"id":"happy","label":"开心"},{"id":"excited","label":"兴奋"},{"id":"sad","label":"低落"},{"id":"angry","label":"生气"}],
|
||||
"modes":[{"id":"chat","label":"对话"},{"id":"code","label":"代码"},{"id":"home","label":"家居"},{"id":"work","label":"工作"},{"id":"docs","label":"文档"},{"id":"voice","label":"语音"},{"id":"vision","label":"视觉"},{"id":"agents","label":"智能体"},{"id":"settings","label":"设置"}],
|
||||
"unknown":"display-raw"
|
||||
}}
|
||||
```
|
||||
- **字段**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `platforms` | `[]{id,label}` | 来源平台:各内置适配器 `Name()`(`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`)+ Web 主会话来源 `web`(`history.PlatformWeb`) |
|
||||
| `moods` | `[]{id,label}` | 情绪:`internal/persona/emotion` 内置状态。状态机**可扩展**(persona 可经 `AddTransition` 注册更多状态),此表**非封闭集合** |
|
||||
| `modes` | `[]{id,label}` | 九模式(design/07 §1);mode 只驱动呈现,不构成能力边界 |
|
||||
| `unknown` | string | 恒 `"display-raw"`:未知值回落约定(机器可读) |
|
||||
| `label` | string | **默认中文标签**,客户端可直接显示、也可按 i18n 覆盖;客户端比对应以 `id` 为准(小写、大小写敏感) |
|
||||
- **错误**:仅 `401`(未认证)。无存储依赖,不会 `501`/`500`。
|
||||
- **备注**:
|
||||
- **未知值回落约定(红线)**:客户端遇不在表中的取值**原样显示原始字符串**——**不丢弃、不报错、不替换占位符**;**服务端新增取值是非破坏性变更**(forward-compatible),旧客户端无需升级,也不得因未知值清空角标或抛错。
|
||||
- **单一事实来源在 Go 侧**:`internal/contract/enums`(`Platforms()`/`Moods()`/`Modes()`/`UnknownFallback`),网关只做投影(`internal/services/gateway/meta.go`)。
|
||||
- **反漂移**:`internal/contract/enums` 的测试断言三张表与「适配器 `Name()`」「`emotion` 内置状态」「前端 `types.ts` 的 `MODES`」一致;`cmd/cyrene` 另断言表内每个平台都能被渠道工厂构建——漏登记或幽灵条目即 FAIL。
|
||||
- Web 侧镜像:`frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 未知值返回原值),顶栏 / 侧栏 / 消息气泡的心情标签已改用。
|
||||
- curl:
|
||||
```bash
|
||||
curl -s http://<host>:8080/api/v1/meta/enums -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
|
||||
# => {"data":{"platforms":[...],"moods":[...],"modes":[...],"unknown":"display-raw"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理后台 · 读端点(`owner|admin`)
|
||||
|
||||
> 以下端点 `鉴权 = Authorize(owner, admin)`(除特别标注 owner)。所有管理**读**不审计;管理**写**(§5)全程审计(who/what/when/result,见 §4.15)。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
// Wiring-level anti-drift test for the shared client enum table (design/07 §6).
|
||||
// The enums package test pins the platform table to the adapters' Name() values;
|
||||
// this one pins it to the composition root's factory — every platform the table
|
||||
// publishes must be one buildAccountAdapter actually knows how to build, so a
|
||||
// phantom entry (a platform listed for clients but never wired up) fails here.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// TestChannelFactoryBuildsEnumeratedPlatforms builds every adapter the published
|
||||
// platform table lists (other than the Web main-session source, which is not an
|
||||
// adapter) through the real factory and asserts the built adapter's Name()
|
||||
// resolves back to that platform. Adapter construction never dials out, so the
|
||||
// minimal configs below are enough.
|
||||
func TestChannelFactoryBuildsEnumeratedPlatforms(t *testing.T) {
|
||||
t.Parallel()
|
||||
log := logger.New(logger.Options{Out: io.Discard})
|
||||
minimalConfig := map[string]map[string]any{
|
||||
"kook": {"bot_token": "test"},
|
||||
"telegram": {"bot_token": "test"},
|
||||
}
|
||||
|
||||
for _, p := range enums.Platforms() {
|
||||
if p.ID == history.PlatformWeb {
|
||||
continue
|
||||
}
|
||||
acct := config.ChannelAccount{Config: minimalConfig[p.ID]}
|
||||
built, err := buildAccountAdapter(p.ID, acct, log)
|
||||
if err != nil {
|
||||
t.Errorf("platform %q is published to clients but the factory cannot build it: %v", p.ID, err)
|
||||
continue
|
||||
}
|
||||
if got := barePlatform(built.Name()); got != p.ID {
|
||||
t.Errorf("buildAccountAdapter(%q).Name() = %q, want %q", p.ID, got, p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package enums is the single Go-side source of truth for the shared client
|
||||
// enumerations published at GET /api/v1/meta/enums (Android collaboration B4,
|
||||
// design/07 §6). It carries three tables — platform (a message's source
|
||||
// platform), mood (a persona's emotion) and mode (the presentation mode a
|
||||
// message was produced in) — so every client maps ids to labels from the same
|
||||
// place instead of hardcoding its own, which is what made source badges and mood
|
||||
// labels drift per client.
|
||||
//
|
||||
// Adding a value is a non-breaking contract change. A value absent from a table
|
||||
// must be rendered raw by the client — never dropped, never an error — and that
|
||||
// policy is published alongside the tables (UnknownFallback). The anti-drift
|
||||
// tests in this package pin each table to its real source of truth: the platform
|
||||
// table to the built-in adapters' Name() values plus the Web main-session source
|
||||
// (internal/runtime/history), the mood table to internal/persona/emotion's
|
||||
// built-in states, and the mode table to the nine modes design/07 §1 defines
|
||||
// (mirrored by frontend/web/src/lib/types.ts MODES).
|
||||
package enums
|
||||
|
||||
// Item is one enumeration entry: the machine-readable ID a client compares
|
||||
// against, plus the default Simplified-Chinese label it may display directly.
|
||||
// The label is a default, not a contract: a client with i18n may override it.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// UnknownFallback is the machine-readable fallback policy a client applies to a
|
||||
// value that is not in the published tables: display the value exactly as
|
||||
// received — no label substitution, no drop, no error. It is served in the same
|
||||
// response as the tables so a client reads the policy and the data together.
|
||||
const UnknownFallback = "display-raw"
|
||||
|
||||
// Platforms returns the authoritative message-source platforms: the Web main
|
||||
// session plus every built-in IM adapter, keyed by the adapter's Name() value
|
||||
// (kook, qq-obv11, weixin-ilink, telegram, webhook).
|
||||
//
|
||||
// Each call builds a fresh slice, so a caller may sort or otherwise mutate its
|
||||
// copy without affecting another — the table itself is never package-level
|
||||
// mutable state.
|
||||
func Platforms() []Item {
|
||||
return []Item{
|
||||
{ID: "web", Label: "网页"},
|
||||
{ID: "kook", Label: "KOOK"},
|
||||
{ID: "qq-obv11", Label: "QQ"},
|
||||
{ID: "weixin-ilink", Label: "微信"},
|
||||
{ID: "telegram", Label: "Telegram"},
|
||||
{ID: "webhook", Label: "Webhook"},
|
||||
}
|
||||
}
|
||||
|
||||
// Moods returns the authoritative persona-emotion values, mirroring
|
||||
// internal/persona/emotion's built-in states (calm/happy/excited/sad/angry). The
|
||||
// emotion state machine is extensible — a persona may register further states via
|
||||
// AddTransition — so a client must treat an unlisted mood as display-raw rather
|
||||
// than assuming this table is closed.
|
||||
func Moods() []Item {
|
||||
return []Item{
|
||||
{ID: "calm", Label: "平静"},
|
||||
{ID: "happy", Label: "开心"},
|
||||
{ID: "excited", Label: "兴奋"},
|
||||
{ID: "sad", Label: "低落"},
|
||||
{ID: "angry", Label: "生气"},
|
||||
}
|
||||
}
|
||||
|
||||
// Modes returns the authoritative behavior/presentation modes (design/01 §3,
|
||||
// design/07 §1). A mode drives how a message is rendered, never what the persona
|
||||
// is able to do: it is a presentation dimension, not a capability boundary.
|
||||
func Modes() []Item {
|
||||
return []Item{
|
||||
{ID: "chat", Label: "对话"},
|
||||
{ID: "code", Label: "代码"},
|
||||
{ID: "home", Label: "家居"},
|
||||
{ID: "work", Label: "工作"},
|
||||
{ID: "docs", Label: "文档"},
|
||||
{ID: "voice", Label: "语音"},
|
||||
{ID: "vision", Label: "视觉"},
|
||||
{ID: "agents", Label: "智能体"},
|
||||
{ID: "settings", Label: "设置"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package enums_test
|
||||
|
||||
// Anti-drift tests for the shared client enum tables (design/07 §6, requirement
|
||||
// 4): each table is pinned to the real code that defines its values, so a value
|
||||
// added to (or renamed in) an adapter, the emotion state machine or the frontend
|
||||
// mode union without registering it here fails the build rather than silently
|
||||
// reaching clients as an unmapped string.
|
||||
//
|
||||
// The platform test builds the real built-in adapters and asserts the table and
|
||||
// their Name() values are the same set — not merely that the table holds some
|
||||
// strings. The adapters are constructed with empty configs, which never dials
|
||||
// out (Connect does); see cmd/cyrene's factory test for the wiring-level anchor.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||||
)
|
||||
|
||||
// ids maps a table to its id set.
|
||||
func ids(items []enums.Item) map[string]bool {
|
||||
out := make(map[string]bool, len(items))
|
||||
for _, it := range items {
|
||||
out[it.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of a set in a stable order for diffs.
|
||||
func sortedKeys(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// adapterPlatformNames returns the Name() of every built-in IM adapter plus the
|
||||
// Web main-session source. It is the authority the platform table mirrors: if an
|
||||
// adapter is renamed and the table is not, the two sets diverge and the test
|
||||
// fails.
|
||||
func adapterPlatformNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
weixin, err := weixin_ilink.New(weixin_ilink.Config{}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build weixin-ilink adapter: %v", err)
|
||||
}
|
||||
return []string{
|
||||
history.PlatformWeb,
|
||||
kook.New(kook.Config{}).Name(),
|
||||
qq_obv11.NewAdapter(qq_obv11.Config{}).Name(),
|
||||
weixin.Name(),
|
||||
telegram.New(telegram.Config{}).Name(),
|
||||
webhook.New().Name(),
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformTableMatchesAdapterNames asserts the published platform table and
|
||||
// the adapters the app actually builds are the same set, in both directions: an
|
||||
// unregistered adapter (missing entry) and a phantom entry (a platform no
|
||||
// adapter provides) both fail.
|
||||
func TestPlatformTableMatchesAdapterNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := make(map[string]bool)
|
||||
for _, name := range adapterPlatformNames(t) {
|
||||
want[name] = true
|
||||
}
|
||||
got := ids(enums.Platforms())
|
||||
for _, name := range sortedKeys(want) {
|
||||
if !got[name] {
|
||||
t.Errorf("adapter Name() %q is missing from enums.Platforms()", name)
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(got) {
|
||||
if !want[id] {
|
||||
t.Errorf("enums.Platforms() lists %q, which no built-in adapter provides", id)
|
||||
}
|
||||
}
|
||||
if len(want) != len(got) {
|
||||
t.Fatalf("platform count = %d, want %d (table=%v adapters=%v)", len(got), len(want), sortedKeys(got), sortedKeys(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoodTableMatchesEmotionBuiltins asserts the mood table is exactly the
|
||||
// emotion state machine's built-in states, referencing the constants rather than
|
||||
// re-typing the strings, and that every state the default transition table
|
||||
// mentions is therefore labeled (a transition to an unlabeled state would reach
|
||||
// clients as a display-raw string).
|
||||
func TestMoodTableMatchesEmotionBuiltins(t *testing.T) {
|
||||
t.Parallel()
|
||||
builtins := []emotion.EmotionState{emotion.Calm, emotion.Happy, emotion.Excited, emotion.Sad, emotion.Angry}
|
||||
want := make(map[string]bool, len(builtins))
|
||||
for _, s := range builtins {
|
||||
want[string(s)] = true
|
||||
}
|
||||
got := ids(enums.Moods())
|
||||
if !slices.Equal(sortedKeys(got), sortedKeys(want)) {
|
||||
t.Fatalf("mood table ids = %v, want the emotion built-ins %v", sortedKeys(got), sortedKeys(want))
|
||||
}
|
||||
for _, entry := range enums.Moods() {
|
||||
if entry.Label == "" {
|
||||
t.Errorf("mood %q has no label", entry.ID)
|
||||
}
|
||||
}
|
||||
for from, tos := range emotion.DefaultTransitions() {
|
||||
if !got[string(from)] {
|
||||
t.Errorf("default transition source %q is not in the mood table", from)
|
||||
}
|
||||
for _, to := range tos {
|
||||
if !got[string(to)] {
|
||||
t.Errorf("default transition target %q is not in the mood table", to)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeTableMatchesFrontendModes asserts the mode table matches the frontend's
|
||||
// Mode union / MODES array (frontend/web/src/lib/types.ts), the only code-level
|
||||
// definition of the nine modes. This is the cross-language drift guard: adding a
|
||||
// mode on either side without the other fails here.
|
||||
func TestModeTableMatchesFrontendModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
const typesPath = "../../../frontend/web/src/lib/types.ts"
|
||||
raw, err := os.ReadFile(typesPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
t.Skipf("%s not present (backend-only checkout)", typesPath)
|
||||
}
|
||||
t.Fatalf("read %s: %v", typesPath, err)
|
||||
}
|
||||
block := regexp.MustCompile(`(?s)export const MODES\s*:[^=]*=\s*\[(.*?)\]`).FindSubmatch(raw)
|
||||
if block == nil {
|
||||
t.Fatalf("MODES array not found in %s", typesPath)
|
||||
}
|
||||
quoted := regexp.MustCompile(`"([^"]+)"`).FindAllStringSubmatch(string(block[1]), -1)
|
||||
frontend := make([]string, 0, len(quoted))
|
||||
for _, m := range quoted {
|
||||
frontend = append(frontend, m[1])
|
||||
}
|
||||
backend := make([]string, 0, len(enums.Modes()))
|
||||
for _, m := range enums.Modes() {
|
||||
backend = append(backend, m.ID)
|
||||
}
|
||||
if !slices.Equal(backend, frontend) {
|
||||
t.Fatalf("mode table = %v, want the frontend MODES %v", backend, frontend)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablesAreWellFormed guards the published shape itself: ids are unique and
|
||||
// non-empty and every entry carries a default label, so a client can always
|
||||
// render something for a known value.
|
||||
func TestTablesAreWellFormed(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, table := range []struct {
|
||||
name string
|
||||
items []enums.Item
|
||||
}{
|
||||
{name: "platforms", items: enums.Platforms()},
|
||||
{name: "moods", items: enums.Moods()},
|
||||
{name: "modes", items: enums.Modes()},
|
||||
} {
|
||||
if len(table.items) == 0 {
|
||||
t.Fatalf("%s is empty", table.name)
|
||||
}
|
||||
seen := make(map[string]bool, len(table.items))
|
||||
for _, it := range table.items {
|
||||
if it.ID == "" || it.Label == "" {
|
||||
t.Errorf("%s entry %+v must have both id and label", table.name, it)
|
||||
}
|
||||
if seen[it.ID] {
|
||||
t.Errorf("%s id %q is duplicated", table.name, it.ID)
|
||||
}
|
||||
seen[it.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablesAreCopies asserts the accessors hand out independent slices, so a
|
||||
// caller sorting its copy cannot reorder the authority table for everyone else.
|
||||
func TestTablesAreCopies(t *testing.T) {
|
||||
t.Parallel()
|
||||
first := enums.Platforms()
|
||||
first[0] = enums.Item{ID: "mutated", Label: "mutated"}
|
||||
second := enums.Platforms()
|
||||
if second[0].ID != "web" {
|
||||
t.Fatalf("Platforms() shared state: second call starts with %q", second[0].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
|
||||
)
|
||||
|
||||
// This file owns the read-only client metadata surface: GET /api/v1/meta/enums,
|
||||
// which publishes the authoritative platform/mood/mode tables (Android
|
||||
// collaboration B4, design/07 §6). Clients (Web, Android, an IM bridge) read the
|
||||
// tables instead of hardcoding their own id → label mapping, so a source badge or
|
||||
// a mood label cannot drift per client. The tables themselves live in
|
||||
// internal/contract/enums; this handler only serves them, so there is one source
|
||||
// of truth rather than a copy per consumer.
|
||||
|
||||
// metaEnums is the GET /api/v1/meta/enums payload. `unknown` carries the
|
||||
// machine-readable fallback policy (enums.UnknownFallback) so a client reads the
|
||||
// tables and the rule for an unlisted value from the same response.
|
||||
type metaEnums struct {
|
||||
Platforms []enums.Item `json:"platforms"`
|
||||
Moods []enums.Item `json:"moods"`
|
||||
Modes []enums.Item `json:"modes"`
|
||||
Unknown string `json:"unknown"`
|
||||
}
|
||||
|
||||
// handleListEnums serves the shared client enum tables. It is a pure projection
|
||||
// of the Go-side authority (no store, no error path) and contains nothing
|
||||
// sensitive, so it is gated like GET /api/v1/sessions: any authenticated role may
|
||||
// read it.
|
||||
//
|
||||
// Adding a value to a table is a non-breaking change for existing clients, which
|
||||
// must display an unlisted value raw (see enums.UnknownFallback) rather than
|
||||
// dropping it or erroring.
|
||||
func (s *Server) handleListEnums(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, metaEnums{
|
||||
Platforms: enums.Platforms(),
|
||||
Moods: enums.Moods(),
|
||||
Modes: enums.Modes(),
|
||||
Unknown: enums.UnknownFallback,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
)
|
||||
|
||||
// These tests cover the shared client enum endpoint (Android collaboration B4,
|
||||
// design/07 §6): the wire shape of GET /api/v1/meta/enums, the values it
|
||||
// publishes, and the fact that it is readable by any authenticated role. They
|
||||
// assert the JSON contract with literals rather than by comparing against
|
||||
// internal/contract/enums, so the client-facing shape is pinned independently of
|
||||
// the Go table (the table's own drift tests live in that package).
|
||||
|
||||
// enumEntry is the wire shape of one published enum item.
|
||||
type enumEntry struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// metaEnumsWire decodes GET /api/v1/meta/enums and asserts the envelope and the
|
||||
// per-item shape, returning the ids of each table for value assertions.
|
||||
func metaEnumsWire(t *testing.T, ts *httptest.Server, tok string) (platforms, moods, modes []string, unknown string) {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/meta/enums", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
env := decodeBody(t, body)
|
||||
if len(env) != 1 {
|
||||
t.Fatalf("envelope keys = %v, want only data", env)
|
||||
}
|
||||
var data struct {
|
||||
Platforms []enumEntry `json:"platforms"`
|
||||
Moods []enumEntry `json:"moods"`
|
||||
Modes []enumEntry `json:"modes"`
|
||||
Unknown string `json:"unknown"`
|
||||
}
|
||||
if err := json.Unmarshal(env["data"], &data); err != nil {
|
||||
t.Fatalf("decode data: %v (body=%s)", err, body)
|
||||
}
|
||||
for _, table := range []struct {
|
||||
name string
|
||||
entries []enumEntry
|
||||
}{{"platforms", data.Platforms}, {"moods", data.Moods}, {"modes", data.Modes}} {
|
||||
if len(table.entries) == 0 {
|
||||
t.Fatalf("%s is empty (body=%s)", table.name, body)
|
||||
}
|
||||
for _, e := range table.entries {
|
||||
if e.ID == "" || e.Label == "" {
|
||||
t.Errorf("%s entry %+v must carry both id and label", table.name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entryIDs(data.Platforms), entryIDs(data.Moods), entryIDs(data.Modes), data.Unknown
|
||||
}
|
||||
|
||||
func entryIDs(items []enumEntry) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMetaEnumsShape pins the published contract: the three tables name the real
|
||||
// platform/mood/mode values and the response carries the unknown-value fallback
|
||||
// policy.
|
||||
func TestMetaEnumsShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
platforms, moods, modes, unknown := metaEnumsWire(t, ts, tok)
|
||||
|
||||
wantPlatforms := []string{"web", "kook", "qq-obv11", "weixin-ilink", "telegram", "webhook"}
|
||||
if !sameStringSet(platforms, wantPlatforms) {
|
||||
t.Errorf("platforms = %v, want %v", platforms, wantPlatforms)
|
||||
}
|
||||
wantMoods := []string{"calm", "happy", "excited", "sad", "angry"}
|
||||
if !sameStringSet(moods, wantMoods) {
|
||||
t.Errorf("moods = %v, want %v", moods, wantMoods)
|
||||
}
|
||||
wantModes := []string{"chat", "code", "home", "work", "docs", "voice", "vision", "agents", "settings"}
|
||||
if !sameStringSet(modes, wantModes) {
|
||||
t.Errorf("modes = %v, want %v", modes, wantModes)
|
||||
}
|
||||
if unknown != "display-raw" {
|
||||
t.Errorf("unknown = %q, want display-raw", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaEnumsAnyAuthenticatedRole asserts the endpoint is a read-only,
|
||||
// non-sensitive table: every role may read it, and anonymous callers get 401
|
||||
// rather than the data.
|
||||
func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
url := ts.URL + "/api/v1/meta/enums"
|
||||
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
|
||||
tok := signAny(t, jwt, "acct", role)
|
||||
resp, body := doReq(t, http.MethodGet, url, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("role %s status = %d, want 200 (body=%s)", role, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, url, nil, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// sameStringSet reports whether got and want hold the same values, ignoring order.
|
||||
func sameStringSet(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]int, len(got))
|
||||
for _, v := range got {
|
||||
seen[v]++
|
||||
}
|
||||
for _, v := range want {
|
||||
seen[v]--
|
||||
}
|
||||
for _, n := range seen {
|
||||
if n != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -58,6 +58,12 @@ func (s *Server) registerUserRoutes(
|
||||
mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat)))
|
||||
mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions)))
|
||||
mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas)))
|
||||
// Shared client enum tables (Android collaboration B4, design/07 §6): the
|
||||
// authority every client maps ids to labels from, so a source badge or a mood
|
||||
// label cannot drift per client. A read-only projection of
|
||||
// internal/contract/enums with nothing sensitive in it, so any authenticated
|
||||
// role may read it (same gate as /sessions above).
|
||||
mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums)))
|
||||
|
||||
mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe)))
|
||||
mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech)))
|
||||
|
||||
Reference in new issue
Block a user