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:
AskaEth committed 2026-10-03 16:55:22 +08:00
1 parent a828edacc5
commit 22e8398110
8 files changed
+617

No files matched your search

+67
View File
@@ -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「可观测性契约」。
+35
View File
@@ -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)。
+46
View File
@@ -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)
}
}
}
+81
View File
@@ -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: "设置"},
}
}
+202
View File
@@ -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)
}
}
+42
View File
@@ -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,
})
}
+138
View File
@@ -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
}
+6
View File
@@ -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)))