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

+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)))