From 074ffc4ebf153c931934d26c3d5c558274ea9d63 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 22:28:25 +0800 Subject: [PATCH] =?UTF-8?q?docs(gateway):=20gateway=20=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=AD=E6=96=87?= =?UTF-8?q?=EF=BC=88=E9=80=90=E8=A1=8C=201:1=EF=BC=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=9B=B6=E5=AD=97=E8=8A=82=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。 --- internal/services/gateway/admin.go | 216 +++++++------- .../gateway/admin_channel_persona_test.go | 72 ++--- .../gateway/admin_channels_platform.go | 66 ++--- .../gateway/admin_channels_platform_test.go | 18 +- .../services/gateway/admin_channels_rescan.go | 46 +-- .../gateway/admin_channels_rescan_test.go | 42 +-- .../services/gateway/admin_channels_weixin.go | 116 ++++---- .../gateway/admin_channels_weixin_test.go | 54 ++-- .../services/gateway/admin_honest501_test.go | 8 +- internal/services/gateway/admin_plugins.go | 70 ++--- .../services/gateway/admin_plugins_test.go | 12 +- .../services/gateway/admin_rag_delete_test.go | 12 +- internal/services/gateway/admin_system.go | 90 +++--- .../services/gateway/admin_system_test.go | 46 +-- .../gateway/admin_write_channel_config.go | 158 +++++----- .../admin_write_channel_config_test.go | 56 ++-- .../gateway/admin_write_iot_automations.go | 40 +-- .../gateway/admin_write_llm_channels.go | 106 +++---- .../gateway/admin_write_memory_rag.go | 38 +-- .../admin_write_persona_thinking_test.go | 28 +- .../gateway/admin_write_personas_users.go | 96 +++---- .../admin_write_plugins_memory_sessions.go | 34 +-- internal/services/gateway/admin_write_test.go | 48 ++-- internal/services/gateway/audio.go | 46 +-- internal/services/gateway/audio_test.go | 4 +- internal/services/gateway/audit.go | 28 +- internal/services/gateway/auth_refresh.go | 50 ++-- .../services/gateway/auth_refresh_test.go | 20 +- internal/services/gateway/contracts.go | 242 ++++++++-------- internal/services/gateway/deps.go | 134 ++++----- internal/services/gateway/findchannel_test.go | 16 +- .../services/gateway/flow_update_ws_test.go | 62 ++-- internal/services/gateway/gateway_test.go | 38 +-- internal/services/gateway/history.go | 160 +++++------ .../services/gateway/history_after_test.go | 32 +-- .../services/gateway/history_handler_test.go | 90 +++--- .../gateway/history_lastmessage_test.go | 14 +- .../services/gateway/history_messages_test.go | 54 ++-- .../services/gateway/history_persona_test.go | 116 ++++---- internal/services/gateway/history_write.go | 114 ++++---- internal/services/gateway/meta.go | 34 +-- internal/services/gateway/meta_test.go | 32 +-- internal/services/gateway/middleware.go | 28 +- internal/services/gateway/notifications.go | 142 ++++----- .../services/gateway/notifications_test.go | 74 ++--- internal/services/gateway/observability.go | 50 ++-- .../services/gateway/observability_audit.go | 48 ++-- .../services/gateway/observability_logs.go | 40 +-- .../gateway/observability_logs_test.go | 18 +- .../services/gateway/observability_metrics.go | 54 ++-- .../gateway/observability_metrics_test.go | 58 ++-- .../services/gateway/observability_trace.go | 42 +-- internal/services/gateway/persona_env.go | 118 ++++---- .../services/gateway/persona_read_test.go | 44 +-- internal/services/gateway/proactive.go | 34 +-- internal/services/gateway/proactive_test.go | 20 +- internal/services/gateway/rbac_viewer_test.go | 76 ++--- internal/services/gateway/rest.go | 170 +++++------ internal/services/gateway/routes.go | 152 +++++----- .../gateway/session_attribution_test.go | 40 +-- .../services/gateway/session_orchestrator.go | 272 +++++++++--------- .../gateway/session_orchestrator_test.go | 158 +++++----- internal/services/gateway/session_persist.go | 78 ++--- .../services/gateway/session_read_test.go | 56 ++-- internal/services/gateway/session_tail.go | 24 +- .../services/gateway/session_transcript.go | 100 +++---- internal/services/gateway/static.go | 22 +- internal/services/gateway/upload.go | 126 ++++---- internal/services/gateway/upload_test.go | 38 +-- internal/services/gateway/voice.go | 102 +++---- internal/services/gateway/ws_bridge.go | 48 ++-- internal/services/gateway/ws_hub_conn.go | 32 +-- internal/services/gateway/ws_hub_origin.go | 36 +-- 73 files changed, 2529 insertions(+), 2529 deletions(-) diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index 89666ae..44761c7 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -17,11 +17,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) -// backthinkListLimit is the number of most-recent think records the admin -// panel asks for when no limit is specified. +// backthinkListLimit 是未指定 limit 时管理面板索取的 +// 最近 think 记录条数。 const backthinkListLimit = 50 -// PluginSummary is the wire shape of a capability/plugin in the admin list. +// PluginSummary 是管理列表中一个能力/插件的线上结构。 type PluginSummary struct { Name string `json:"name"` Kind string `json:"kind"` @@ -30,14 +30,14 @@ type PluginSummary struct { AllowInStranger bool `json:"allowInStranger"` } -// ChannelSummary is the wire shape of a registered channel adapter. Accounts -// carries that platform's configured accounts (masked, no raw tokens) so the -// panel can show and edit the account config; it is omitted when s.channels is -// not wired or the platform has no configured account. +// ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts +// 承载该平台已配置的账号(已脱敏,不含原始 token),使 +// 面板能展示并编辑账号配置;当 s.channels 未接线 +// 或该平台没有已配置账号时省略。 // -// NeedsRescan/LastError (v0.39) report that the adapter needs the owner to -// re-authorize it (see rescanAware in admin_channels_rescan.go); both are omitted -// unless the adapter really asked for it. +// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权 +// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器 +// 确实要求时才出现,否则一律省略。 type ChannelSummary struct { Name string `json:"name"` Connected bool `json:"connected"` @@ -46,15 +46,15 @@ type ChannelSummary struct { Accounts []AdminChannelAccount `json:"accounts,omitempty"` } -// policyRequest is the POST /api/v1/admin/plugins/policy body. +// policyRequest 是 POST /api/v1/admin/plugins/policy 的请求体。 type policyRequest struct { Name string `json:"name"` AllowInGroup bool `json:"allowInGroup"` AllowInStranger bool `json:"allowInStranger"` } -// AdminLlmProvider is the wire shape of one LLM provider in the admin config. -// The API key is deliberately absent: it is never returned to the client. +// AdminLlmProvider 是管理配置中一个 LLM provider 的线上结构。 +// API key 刻意缺位:它绝不返回给客户端。 type AdminLlmProvider struct { ID string `json:"id"` Name string `json:"name"` @@ -64,20 +64,20 @@ type AdminLlmProvider struct { Multimodal bool `json:"multimodal"` Enabled bool `json:"enabled"` Fallback string `json:"fallback,omitempty"` - // MaxTokens is the group's own token budget (0 = unset; the runtime then - // falls back to LLM_FALLBACK_MAX_TOKENS on the last fallback step, then the - // adapter default). + // MaxTokens 是该分组自身的 token 预算(0 = 未设置;此时运行时 + // 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS, + // 再回退到适配器默认值)。 MaxTokens int `json:"maxTokens,omitempty"` } -// AdminLlmConfig is the wire shape of GET /api/v1/admin/llm. +// AdminLlmConfig 是 GET /api/v1/admin/llm 的线上结构。 type AdminLlmConfig struct { Providers []AdminLlmProvider `json:"providers"` } -// AdminAutomationRecord is the wire shape of one automation/reminder in the -// admin list. Cron carries the cron expression for the cron kind; lastRun and -// nextRun are omitted when the value is unknown. +// AdminAutomationRecord 是管理列表中一条自动化/提醒的线上结构。 +// 对 cron 类型,Cron 承载 cron 表达式;lastRun 与 +// nextRun 在值未知时省略。 type AdminAutomationRecord struct { ID string `json:"id"` Name string `json:"name"` @@ -88,7 +88,7 @@ type AdminAutomationRecord struct { NextRun string `json:"nextRun,omitempty"` } -// AdminUserRecord is the wire shape of one account/API key in the admin list. +// AdminUserRecord 是管理列表中一个账号/API key 的线上结构。 type AdminUserRecord struct { ID string `json:"id"` Username string `json:"username"` @@ -96,8 +96,8 @@ type AdminUserRecord struct { CreatedAt string `json:"createdAt,omitempty"` } -// AdminBackthinkItem is the wire shape of one background-think record in the -// admin monitor list. +// AdminBackthinkItem 是管理监控列表中一条后台思考记录的 +// 线上结构。 type AdminBackthinkItem struct { ID string `json:"id"` Trigger string `json:"trigger"` @@ -107,17 +107,17 @@ type AdminBackthinkItem struct { At int64 `json:"at"` } -// adminListPersonas lists persona summaries (admin read). It is the admin shape: -// the role-level mood plus the `thinking` knobs the panel edits. +// adminListPersonas 列出人格摘要(管理读)。它是管理侧结构: +// 角色级 mood 加上面板可编辑的 `thinking` 开关。 func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectPersonas(true)) } -// adminListSessions lists the persistent chat-history sessions (main + sub) when a -// history store is wired, otherwise falls back to the channel-session view. It is -// the admin counterpart of handleListSessions: the panel shows the real main and -// secondary sessions (design/16 §2) rather than stale channel-session rows, and -// the fallback keeps the pre-design/16 contract for servers built without history. +// adminListSessions 在接线了 history store 时列出持久化聊天历史 +// 会话(main + sub),否则回退到渠道会话视图。它是 +// handleListSessions 的管理侧对应物:面板展示真实的 main 与 +// 次级会话(design/16 §2),而不是陈旧的渠道会话行; +// 回退路径为未接 history 的服务器保留 design/16 之前的契约。 func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) { if s.history != nil { s.handleHistorySessions(w, r) @@ -126,12 +126,12 @@ func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectSessions(r.Context())) } -// adminListMemories lists memory entries (admin read). +// adminListMemories 列出记忆条目(管理读)。 func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectMemories(r.Context())) } -// adminListPlugins lists every registered capability (admin read). +// adminListPlugins 列出所有已注册能力(管理读)。 func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { out := make([]PluginSummary, 0) if s.caps != nil { @@ -148,9 +148,9 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// adminListChannels lists the registered channel adapters (admin read). When the -// channel config store is wired it also attaches each platform's configured -// accounts, masked so a raw token never reaches the client. +// adminListChannels 列出已注册的渠道适配器(管理读)。当接线了 +// 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏, +// 确保原始 token 绝不抵达客户端。 func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { accountsByPlatform := s.maskedChannelAccounts() botByPlatform := s.liveBotIdentity() @@ -162,26 +162,26 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { continue } platform, acctID := splitAdapterName(ch.Name()) - // channels.json is the source of truth for which accounts exist. A - // registered adapter surfaces only while its account is still - // configured, so a deleted account disappears from the list. Without a - // config store (tests) every registered adapter is listed unchanged. + // channels.json 是「存在哪些账号」的唯一事实来源。已注册的 + // 适配器只有在其账号仍处于配置状态时才浮现,因此已删除的 + // 账号会从列表中消失。没有配置存储时(测试),每个已注册 + // 适配器原样列出。 if s.channels != nil { accts, ok := accountsByPlatform[platform] if !ok || !platformHasAccountID(accts, acctID) { continue } } - // Aggregate by platform: a bare adapter ("kook") and the multi-account - // ones ("kook:fc2048", "kook:conf1") are ONE platform row, so the account - // list is not duplicated into a separate row per in-registry adapter. + // 按平台聚合:裸适配器("kook")与多账号适配器 + // ("kook:fc2048"、"kook:conf1")共用一个平台行, + // 因此账号列表不会按注册表中的每个适配器重复成独立行。 if s.hasChannelSummary(out, platform) { continue } sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} - // Optional rescan capability (weixin-iLink rejected session, v0.39): reported - // only when the adapter really asks for a re-scan, so a healthy adapter - // keeps the fields omitted rather than shipping a fabricated value. + // 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器 + // 确实要求重新扫码时才报告,因此健康的适配器保持字段省略, + // 而不是送出一个编造的值。 if ra, ok := asRescanAware(cap.Value); ok { if required, reason := ra.RescanRequired(); required { sum.NeedsRescan = true @@ -194,8 +194,8 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { out = append(out, sum) } } - // A platform that has configured accounts but no registered adapter (e.g. the - // account was just saved) still surfaces, so its config is visible. + // 有已配置账号但没有已注册适配器的平台(例如账号刚刚保存) + // 依然会浮现,以便其配置可见。 for platform, accts := range accountsByPlatform { if len(accts) == 0 || s.hasChannelSummary(out, platform) { continue @@ -205,8 +205,8 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// maskedChannelAccounts returns the effective per-platform configured accounts, -// masked (no raw tokens). It is empty when the channel config store is not wired. +// maskedChannelAccounts 返回按平台生效的已配置账号, +// 已脱敏(不含原始 token)。渠道配置存储未接线时为空。 func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount { if s.channels == nil { return map[string][]AdminChannelAccount{} @@ -229,9 +229,9 @@ func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount { return out } -// platformAdminOps returns the account-level admin operations the platform's -// adapter exposes, so the panel renders only the capabilities the account really -// has. Platforms without a specialized adapter surface return nil (omitted). +// platformAdminOps 返回该平台适配器暴露的账号级管理操作, +// 使面板只渲染账号真正具备的能力。没有专用适配器 +// 表面的平台返回 nil(省略)。 func platformAdminOps(platform string) []string { if channelBasePlatform(platform) == "kook" { return []string{"friends", "user_chats", "channels", "guilds", "message_test"} @@ -239,8 +239,8 @@ func platformAdminOps(platform string) []string { return nil } -// hasChannelSummary reports whether out already contains a summary for platform -// (so a platform with a registered adapter is not duplicated). +// hasChannelSummary 报告 out 中是否已含该平台的摘要 +// (以免有已注册适配器的平台被重复)。 func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool { for _, c := range out { if channelBasePlatform(c.Name) == platform { @@ -250,11 +250,11 @@ func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool { return false } -// platformHasAccountID reports whether the configured accounts of a platform still -// include the account an adapter is bound to. A bare adapter (kook) binds to that -// platform's default account (empty id); a multi-account adapter (kook:conf0) -// binds to exactly that account id. A mismatch means the adapter is stale — its -// account was deleted from channels.json — so it must not surface in the list. +// platformHasAccountID 报告某平台已配置的账号中是否仍包含 +// 适配器所绑定的账号。裸适配器(kook)绑定到该平台的 +// 默认账号(空 id);多账号适配器(kook:conf0)绑定到 +// 恰好那个账号 id。不匹配意味着适配器已陈旧 —— 它的 +// 账号已从 channels.json 删除 —— 因此不得出现在列表中。 func platformHasAccountID(accts []AdminChannelAccount, id string) bool { for _, a := range accts { if a.ID == id { @@ -264,22 +264,22 @@ func platformHasAccountID(accts []AdminChannelAccount, id string) bool { return false } -// botInfo is the live, connected bot's resolved identity (kook fetches it from -// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so -// the admin list can show who the bot is without persisting it. +// botInfo 是已连接机器人的实际解析身份(kook 在 Connect 时从 +// /api/v3/user/me 获取)。它通过适配器的 Config() 浮现, +// 使管理列表能展示机器人是谁而无需持久化。 type botInfo struct { id string username string nickname string } -// empty reports whether no part of the bot identity resolved. +// empty 报告机器人身份是否没有任何部分解析成功。 func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" } -// liveBotIdentity collects the bot identity each registered channel adapter -// reports, keyed by bare platform then account id (a bare adapter name yields an -// empty account id). Adapters that do not expose bot identity (telegram, -// webhook, ...) contribute nothing, so other platforms are unaffected. +// liveBotIdentity 收集每个已注册渠道适配器报告的机器人身份, +// 以裸平台名再按账号 id 为键(裸适配器名得到空账号 id)。 +// 不暴露机器人身份的适配器(telegram、webhook……)不贡献 +// 任何内容,因此其他平台不受影响。 func (s *Server) liveBotIdentity() map[string]map[string]botInfo { out := make(map[string]map[string]botInfo) if s.caps == nil { @@ -303,9 +303,9 @@ func (s *Server) liveBotIdentity() map[string]map[string]botInfo { return out } -// botIdentityOf reads the optional bot-identity fields off an adapter config map. -// The keys are non-sensitive (bot_id / bot_username / bot_nickname) so they are -// never masked by runtimeconfig.Masked. +// botIdentityOf 从适配器配置 map 中读取可选的机器人身份字段。 +// 这些键是非敏感的(bot_id / bot_username / bot_nickname),因此 +// 绝不会被 runtimeconfig.Masked 脱敏。 func botIdentityOf(cfg map[string]any) botInfo { if cfg == nil { return botInfo{} @@ -317,8 +317,8 @@ func botIdentityOf(cfg map[string]any) botInfo { return b } -// splitAdapterName splits a (possibly multi-account) adapter name like -// "kook:conf0" into ("kook", "conf0"); a bare name yields an empty account id. +// splitAdapterName 把形如 "kook:conf0" 的(可能多账号的)适配器名 +// 拆成 ("kook", "conf0");裸名得到空账号 id。 func splitAdapterName(name string) (platform, acct string) { if base, tail, ok := strings.Cut(name, ":"); ok { return base, tail @@ -326,10 +326,10 @@ func splitAdapterName(name string) (platform, acct string) { return name, "" } -// enrichAccountsWithBot merges the live bot identity into the matching account(s) -// of a platform. An account is matched to its live adapter by id, so a -// multi-account platform shows each bot's own identity. Accounts without a live -// bot (not connected yet) keep their stored fields unchanged. +// enrichAccountsWithBot 把实时机器人身份合并进平台中匹配的账号。 +// 账号按 id 与其实时适配器配对,因此多账号平台会展示 +// 各自机器人的身份。没有实时机器人的账号(尚未连接) +// 保持其已存字段不变。 func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) []AdminChannelAccount { if len(by) == 0 { return accts @@ -346,7 +346,7 @@ func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) [ return out } -// adminListIoT lists IoT devices (admin read). +// adminListIoT 列出 IoT 设备(管理读)。 func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) { out := make([]iotDeviceSummary, 0) if s.iot != nil { @@ -363,10 +363,10 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// adminGetLlm returns the LLM provider/model configuration read from the live -// provider stack (models.json, falling back to .env). API keys are never -// returned — the wire shape carries no key field — so the handler leaks nothing -// sensitive. +// adminGetLlm 返回从实时 provider 栈读取的 LLM provider/model +// 配置(models.json,回退到 .env)。API key 绝不返回 —— +// 线上结构中不含 key 字段 —— 因此该 handler +// 不泄露任何敏感信息。 func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) { groups := s.currentLlmGroups() providers := make([]AdminLlmProvider, 0, len(groups)) @@ -386,8 +386,8 @@ func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers}) } -// currentLlmGroups returns the provider groups to display: the live stack's -// groups when one is wired, otherwise the env-synthesized single group. +// currentLlmGroups 返回要展示的 provider 分组:接线了实时栈时 +// 用它的分组,否则用由环境变量合成的单一分组。 func (s *Server) currentLlmGroups() []runtimeconfig.Group { if s.llm != nil { return s.llm.Groups() @@ -395,8 +395,8 @@ func (s *Server) currentLlmGroups() []runtimeconfig.Group { return runtimeconfig.GroupFromEnv(s.cfg.LLM) } -// providerDisplayName returns a friendly label for a provider group, using the -// vendor name for the two known protocols and the group id otherwise. +// providerDisplayName 返回 provider 分组的友好标签:两种已知协议 +// 用厂商名,其余用分组 id。 func providerDisplayName(g runtimeconfig.Group) string { switch g.Provider { case runtimeconfig.ProviderOpenAI: @@ -408,7 +408,7 @@ func providerDisplayName(g runtimeconfig.Group) string { } } -// adminListAutomations lists automations and reminders (admin read). +// adminListAutomations 列出自动化与提醒(管理读)。 func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) { out := make([]AdminAutomationRecord, 0) if s.automations != nil { @@ -432,9 +432,9 @@ func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// adminListUsers lists accounts and API keys (admin read). Without a wired -// UserStore it reports the single configured admin account rather than -// fabricating a column of virtual users. +// adminListUsers 列出账号与 API key(管理读)。未接线 UserStore 时 +// 它只报告那一个已配置的管理账号,而不是编造 +// 一列虚拟用户。 func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) { out := []AdminUserRecord{{ ID: "admin", @@ -453,7 +453,7 @@ func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// adminListBackthink lists the most recent background-think records (admin read). +// adminListBackthink 列出最近的后台思考记录(管理读)。 func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) { out := make([]AdminBackthinkItem, 0) if s.backthink == nil { @@ -479,8 +479,8 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// AdminRagDoc is the wire shape of one knowledge-base document (project-index -// entry) in the admin RAG list. +// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引 +// 条目)的线上结构。 type AdminRagDoc struct { ID string `json:"id"` Name string `json:"name"` @@ -489,7 +489,7 @@ type AdminRagDoc struct { Embedded bool `json:"embedded"` } -// AdminRagHit is the wire shape of one RAG retrieval hit. +// AdminRagHit 是一条 RAG 检索命中的线上结构。 type AdminRagHit struct { DocID string `json:"docId"` DocName string `json:"docName"` @@ -499,22 +499,22 @@ type AdminRagHit struct { Semantic bool `json:"semantic"` } -// AdminRagResponse is the wire shape of GET /api/v1/admin/rag. Hits is present -// only when a query parameter was supplied, so callers distinguish "no search -// requested" from "searched and found nothing". +// AdminRagResponse 是 GET /api/v1/admin/rag 的线上结构。Hits 仅 +// 在提供了查询参数时出现,因此调用方能区分「未请求搜索」 +// 与「搜索了但没找到」。 type AdminRagResponse struct { Documents []AdminRagDoc `json:"documents"` Hits []AdminRagHit `json:"hits,omitempty"` } -// ragQueryRequest is the POST /api/v1/admin/rag/query body. +// ragQueryRequest 是 POST /api/v1/admin/rag/query 的请求体。 type ragQueryRequest struct { Query string `json:"query"` } -// adminListRag lists the knowledge-base documents and, when ?query= is present, -// returns the matching hits too. Without a wired RagStore it degrades to an empty -// list (backward compatible — never a 500). +// adminListRag 列出知识库文档,并在带 ?query= 时 +// 一并返回匹配的命中。未接线 RagStore 时它退化为 +// 空列表(向后兼容 —— 绝不返回 500)。 func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) { out := AdminRagResponse{Documents: make([]AdminRagDoc, 0)} if s.rag == nil { @@ -540,7 +540,7 @@ func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// adminSearchRag runs a knowledge-base query and returns the matching hits. +// adminSearchRag 执行一次知识库查询并返回匹配的命中。 func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) { if s.rag == nil { s.auditClaims(r, "rag.query", "error") @@ -566,7 +566,7 @@ func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "hits": ragHitsFrom(hits)}) } -// ragDocsFrom maps knowledge docs to their admin wire shape. +// ragDocsFrom 把知识文档映射为其管理侧线上结构。 func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc { out := make([]AdminRagDoc, 0, len(docs)) for _, d := range docs { @@ -575,7 +575,7 @@ func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc { return out } -// ragHitsFrom maps knowledge hits to their admin wire shape. +// ragHitsFrom 把知识命中映射为其管理侧线上结构。 func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit { out := make([]AdminRagHit, 0, len(hits)) for _, h := range hits { @@ -591,8 +591,8 @@ func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit { return out } -// adminSetPolicy upserts the reachability markers for a capability. It is the -// audited admin write example (owner-only, enforced by the route registration). +// adminSetPolicy 更新插入某个能力的可达性标记。它是 +// 带审计的管理写示例(仅 owner,由路由注册强制)。 func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) { var req policyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -622,7 +622,7 @@ func (s *Server) adminSetPolicy(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "allowInGroup": req.AllowInGroup, "allowInStranger": req.AllowInStranger}) } -// collectSessions returns a non-nil session slice from the configured store. +// collectSessions 从已配置的存储返回非 nil 的会话切片。 func (s *Server) collectSessions(ctx context.Context) []SessionSummary { out := make([]SessionSummary, 0) if s.sessions == nil { @@ -639,7 +639,7 @@ func (s *Server) collectSessions(ctx context.Context) []SessionSummary { return out } -// collectMemories returns a non-nil memory slice from the configured store. +// collectMemories 从已配置的存储返回非 nil 的记忆切片。 func (s *Server) collectMemories(ctx context.Context) []MemorySummary { out := make([]MemorySummary, 0) if s.memories == nil { @@ -656,7 +656,7 @@ func (s *Server) collectMemories(ctx context.Context) []MemorySummary { return out } -// iotDeviceSummary is the wire shape of an IoT device. +// iotDeviceSummary 是一个 IoT 设备的线上结构。 type iotDeviceSummary struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/services/gateway/admin_channel_persona_test.go b/internal/services/gateway/admin_channel_persona_test.go index dd1988a..5b1d082 100644 --- a/internal/services/gateway/admin_channel_persona_test.go +++ b/internal/services/gateway/admin_channel_persona_test.go @@ -16,25 +16,25 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) -// This file covers the admin half of the channel-level role binding (design/22 §2 -// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the -// existing read-modify-write + bridge.Reload path, an unknown role is refused -// instead of binding a role that cannot answer, and the list endpoint reports the -// binding back. +// 本文件覆盖渠道级角色绑定的管理侧一半(design/22 §2 +// 方案 b / §4.4):PUT /admin/channels/{platform}/config 通过 +// 既有的 read-modify-write + bridge.Reload 路径接受 personaId, +// 未知角色会被拒绝,而不是绑定一个无法应答的角色; +// 列表端点会回报该绑定。 // -// personaId is three-state on the wire, because the panel sends the key only when -// the owner actually touched the role select: +// personaId 在线上是三态的,因为面板仅在主人 +// 真正触碰过角色选择时才发送该键: // -// key absent → keep the stored binding -// explicit "" → clear the binding -// non-empty id → bind that role (unknown id → 400) +// 键缺失 → 保留已存绑定 +// 显式 "" → 清除绑定 +// 非空 id → 绑定该角色(未知 id → 400) // -// The absent-key case is the one that silently dropped bindings, so it is asserted -// with a literal JSON body: a map[string]any would still make the assertion pass if -// the DTO had picked up a value from somewhere else. +// 「键缺失」这一情形正是静默丢弃绑定的那个,因此用字面量 +// JSON 请求体断言:若 DTO 从别处取到了值,map[string]any +// 仍会让断言通过。 -// putChannelConfigRaw posts a literal JSON body so a test can pin the exact wire -// shape (notably: no personaId key at all). +// putChannelConfigRaw 发送字面量 JSON 请求体,使测试能钉死确切的 +// 线上结构(尤其是:完全没有 personaId 键)。 func putChannelConfigRaw(t *testing.T, ts *httptest.Server, platform, raw, token string) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(http.MethodPut, @@ -59,7 +59,7 @@ func putChannelConfigRaw(t *testing.T, ts *httptest.Server, platform, raw, token return resp, b } -// storedPersonaID reads the binding channels.json holds for one account. +// storedPersonaID 读取 channels.json 为某个账号保存的绑定。 func storedPersonaID(t *testing.T, path, platform, accountID string) string { t.Helper() accounts, warn := runtimeconfig.LoadChannels(path, nil) @@ -75,8 +75,8 @@ func storedPersonaID(t *testing.T, path, platform, accountID string) string { return "" } -// listedPersonaID is the personaId GET /admin/channels reports for one account, i.e. -// what the panel reads back to render the role select. +// listedPersonaID 是 GET /admin/channels 为某个账号报告的 personaId, +// 即面板读回用于渲染角色选择的值。 func listedPersonaID(t *testing.T, ts *httptest.Server, token, platform, accountID string) (string, []byte) { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token) @@ -107,9 +107,9 @@ func listedPersonaID(t *testing.T, ts *httptest.Server, token, platform, account return "", body } -// TestAdminSaveChannelConfigPersonaIDBindsAndEchoes binds a channel role and -// asserts it reached channels.json, that the list endpoint echoes it back for the -// panel to render, and that the hot-reload path ran. +// TestAdminSaveChannelConfigPersonaIDBindsAndEchoes 绑定一个渠道角色, +// 断言它到达了 channels.json、列表端点回显它供面板渲染, +// 并且热重载路径被执行。 func TestAdminSaveChannelConfigPersonaIDBindsAndEchoes(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") @@ -136,9 +136,9 @@ func TestAdminSaveChannelConfigPersonaIDBindsAndEchoes(t *testing.T) { t.Logf("GET /api/v1/admin/channels echoes the binding: %s", listBody) } -// TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted is the regression for the -// silent data loss: the panel omits personaId unless the owner touched the role -// select, so saving another field must leave the stored binding alone. +// TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted 是静默数据 +// 丢失的回归:除非主人触碰过角色选择,面板会省略 personaId, +// 因此保存另一个字段必须不碰已存绑定。 func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") @@ -148,8 +148,8 @@ func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // The wire body carries the new token and no personaId key at all — exactly - // what an untouched role select produces. + // 线上请求体携带新 token,且完全没有 personaId 键 —— + // 正是一个未被触碰的角色选择所产生的形态。 _, body := putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"new-tok","groupMsgPerMin":5}`, tok) @@ -164,16 +164,16 @@ func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) { t.Fatalf("GET /admin/channels personaId = %q, want yuki (body=%s)", echoed, listBody) } - // An explicit JSON null is the same as an absent key: the DTO cannot tell them - // apart, and neither means "clear". + // 显式的 JSON null 与键缺失相同:DTO 无法区分二者, + // 二者都不表示「清除」。 putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"newer-tok","personaId":null}`, tok) if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" { t.Fatalf("null personaId cleared the binding: personaId = %q, want yuki", got) } } -// TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty asserts that clearing -// the binding still works, but only when the panel says so with an explicit "". +// TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty 断言清除 +// 绑定仍然可行,但仅在面板以显式 "" 表示时才如此。 func TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") @@ -193,9 +193,9 @@ func TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty(t *testing.T) { } } -// TestAdminSaveChannelConfigPersonaIDKeptPerAccount pins the read-modify-write to -// the account being saved: an omitted personaId on one account must inherit that -// account's binding, not a sibling's. +// TestAdminSaveChannelConfigPersonaIDKeptPerAccount 把 read-modify-write +// 钉在正在保存的账号上:某账号省略 personaId 时必须继承该 +// 账号自己的绑定,而不是兄弟账号的。 func TestAdminSaveChannelConfigPersonaIDKeptPerAccount(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") @@ -218,9 +218,9 @@ func TestAdminSaveChannelConfigPersonaIDKeptPerAccount(t *testing.T) { } } -// TestAdminSaveChannelConfigRejectsUnknownPersonaID keeps the save fail-closed: a -// channel bound to a role the library does not know would silently answer as the -// default role, so it is refused and nothing is written or reloaded. +// TestAdminSaveChannelConfigRejectsUnknownPersonaID 保持保存 fail-closed: +// 绑定到一个库不认识的角色时,渠道会静默地以默认角色应答, +// 因此该绑定被拒绝,不写入也不重载。 func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") diff --git a/internal/services/gateway/admin_channels_platform.go b/internal/services/gateway/admin_channels_platform.go index a625ff5..12bee5c 100644 --- a/internal/services/gateway/admin_channels_platform.go +++ b/internal/services/gateway/admin_channels_platform.go @@ -1,12 +1,12 @@ package gateway -// This file adds the per-platform adapter panel endpoints (design/17 §2), owner -// only and audited. They expose a platform's real account data (friends, -// channels, guilds, private chats) via the adapter's REST surface and a single -// controlled "test send" write. Only KOOK is wired; every other platform answers -// an honest 501 rather than fabricated data (a project red line). The bot token -// is never read or logged — the adapter owns the token and surfaces only its -// normalized REST results. +// 本文件新增按平台的适配器面板端点(design/17 §2),仅 owner +// 且带审计。它们通过适配器的 REST 表面暴露某平台的真实账号 +// 数据(好友、频道、服务器、私聊),外加一个受控的 +// 「测试发送」写操作。目前只接线了 KOOK;其他平台一律 +// 返回诚实的 501 而不是编造数据(项目红线)。机器人 token +// 绝不读取或记录 —— 适配器持有 token,只浮现其 +// 归一化后的 REST 结果。 import ( "context" @@ -21,9 +21,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook" ) -// kookAdmin is the subset of the KOOK adapter's admin REST surface the gateway -// panel endpoints invoke. It is implemented by *kook.Adapter and reached by -// unwrapping the registration wrapper. +// kookAdmin 是网关面板端点所调用的 KOOK 适配器管理 REST 表面的 +// 子集。它由 *kook.Adapter 实现,通过拆开注册包装器 +// 抵达。 type kookAdmin interface { ListFriends(ctx context.Context, typ string) (kook.FriendList, error) HandleFriendRequest(ctx context.Context, id int64, accept bool) error @@ -37,7 +37,7 @@ type kookAdmin interface { SendTestMessage(ctx context.Context, targetID, content string) (kook.TestMessageResult, error) } -// adminKookView is the wire shape of GET /api/v1/admin/channels/{platform}/view. +// adminKookView 是 GET /api/v1/admin/channels/{platform}/view 的线上结构。 type adminKookView struct { Platform string `json:"platform"` AccountID string `json:"accountId,omitempty"` @@ -49,9 +49,9 @@ type adminKookView struct { Errors map[string]string `json:"errors,omitempty"` } -// kookAccountFor resolves the *kook.Adapter registered for a platform/account. -// It is nil for a non-kook platform, an unknown account, or a wrapped adapter -// that does not expose the admin surface. +// kookAccountFor 解析为某平台/账号注册的 *kook.Adapter。 +// 对非 kook 平台、未知账号,或未暴露管理表面的 +// 包装适配器,它返回 nil。 func kookAccountFor(caps CapabilityStore, platform, accountID string) (kookAdmin, bool) { if caps == nil { return nil, false @@ -75,11 +75,11 @@ func kookAccountFor(caps CapabilityStore, platform, accountID string) (kookAdmin return nil, false } -// asKookAdmin unwraps a registered channel value to its inner KOOK adapter. The -// registration wraps the adapter in cmd/cyrene's namedAdapter (an unexported -// struct embedding adapter.PlatformAdapter); the embedded field is exported, so -// reflection reads it and type-asserts to the KOOK surface. A direct *kook.Adapter -// value (tests) is also accepted. +// asKookAdmin 把已注册的渠道值拆到其内层 KOOK 适配器。 +// 注册时会把适配器包进 cmd/cyrene 的 namedAdapter(一个内嵌 +// adapter.PlatformAdapter 的未导出结构体);被内嵌的字段是导出的, +// 因此反射可读取它并类型断言到 KOOK 表面。直接的 +// *kook.Adapter 值(测试)也被接受。 func asKookAdmin(v any) (kookAdmin, bool) { if k, ok := v.(kookAdmin); ok { return k, true @@ -104,9 +104,9 @@ func asKookAdmin(v any) (kookAdmin, bool) { return nil, false } -// adminChannelPlatformView aggregates a KOOK account's friends, private chats and -// guilds (with the first guild's channels). Each sub-fetch is best-effort; a -// failing one is recorded in errors rather than aborting the whole view. +// adminChannelPlatformView 聚合某 KOOK 账号的好友、私聊与 +// 服务器(含首个服务器的频道)。每次子拉取都是尽力而为; +// 失败的那次记入 errors,而不中止整个视图。 func (s *Server) adminChannelPlatformView(w http.ResponseWriter, r *http.Request) { platform := r.PathValue("platform") accountID := r.URL.Query().Get("accountId") @@ -136,8 +136,8 @@ func (s *Server) buildKookView(ctx context.Context, k kookAdmin, platform, accou } else { view.Guilds = guilds if len(guilds) > 0 { - // Aggregate the first guild's channels and members so the panel has a - // concrete server to render; deeper servers are a separate fetch. + // 聚合首个服务器的频道与成员,使面板有具体的服务器 + // 可渲染;更深的服务器是另一次拉取。 if channels, err := k.ListGuildChannels(ctx, guilds[0].ID, -1); err != nil { view.Errors["channels"] = err.Error() } else { @@ -153,8 +153,8 @@ func (s *Server) buildKookView(ctx context.Context, k kookAdmin, platform, accou return view } -// adminChannelPlatformFriends returns a KOOK account's friends/requests/blocked -// list, filtered by the optional ?type= and ?accountId= params. +// adminChannelPlatformFriends 返回某 KOOK 账号的好友/请求/黑名单 +// 列表,按可选的 ?type= 与 ?accountId= 参数过滤。 func (s *Server) adminChannelPlatformFriends(w http.ResponseWriter, r *http.Request) { platform := r.PathValue("platform") accountID := r.URL.Query().Get("accountId") @@ -173,13 +173,13 @@ func (s *Server) adminChannelPlatformFriends(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, fl) } -// kookFriendHandleRequest is the POST /admin/channels/{platform}/friend/handle body. +// kookFriendHandleRequest 是 POST /admin/channels/{platform}/friend/handle 的请求体。 type kookFriendHandleRequest struct { ID int64 `json:"id"` Accept bool `json:"accept"` } -// adminChannelFriendHandle accepts or rejects a KOOK friend request (owner write). +// adminChannelFriendHandle 接受或拒绝一个 KOOK 好友请求(owner 写)。 func (s *Server) adminChannelFriendHandle(w http.ResponseWriter, r *http.Request) { platform := r.PathValue("platform") accountID := r.URL.Query().Get("accountId") @@ -207,14 +207,14 @@ func (s *Server) adminChannelFriendHandle(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, map[string]any{"handled": true, "id": req.ID, "accept": req.Accept}) } -// kookMessageTestRequest is the POST /admin/channels/{platform}/message/test body. +// kookMessageTestRequest 是 POST /admin/channels/{platform}/message/test 的请求体。 type kookMessageTestRequest struct { TargetID string `json:"targetId"` Content string `json:"content"` } -// adminChannelMessageTest sends one test message to a KOOK target channel (owner -// write), so the panel can confirm the bot can reach the channel. +// adminChannelMessageTest 向 KOOK 目标频道发送一条测试消息(owner +// 写),使面板能确认机器人可以抵达该频道。 func (s *Server) adminChannelMessageTest(w http.ResponseWriter, r *http.Request) { platform := r.PathValue("platform") accountID := r.URL.Query().Get("accountId") @@ -248,8 +248,8 @@ func (s *Server) adminChannelMessageTest(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, res) } -// kookTarget resolves the KOOK adapter for a platform/account, writing an honest -// error and reporting found=false when it cannot. +// kookTarget 解析某平台/账号的 KOOK 适配器;无法解析时 +// 写出诚实的错误并报告 found=false。 func (s *Server) kookTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (kookAdmin, bool) { platform = strings.TrimSpace(platform) if platform != "kook" { diff --git a/internal/services/gateway/admin_channels_platform_test.go b/internal/services/gateway/admin_channels_platform_test.go index bb028fa..fe9b6d7 100644 --- a/internal/services/gateway/admin_channels_platform_test.go +++ b/internal/services/gateway/admin_channels_platform_test.go @@ -1,9 +1,9 @@ package gateway -// Exercises the per-platform KOOK adapter panel endpoints (design/17 §2). The -// adapter is registered through a wrapper that embeds adapter.PlatformAdapter — -// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection -// unwrap the gateway uses to reach the concrete *kook.Adapter. +// 演练按平台的 KOOK 适配器面板端点(design/17 §2)。适配器 +// 经由一个内嵌 adapter.PlatformAdapter 的包装器注册 —— 仿照 +// cmd/cyrene 的 namedAdapter —— 因此这些测试也覆盖网关用来 +// 抵达具体 *kook.Adapter 的反射拆包。 import ( "encoding/json" @@ -23,8 +23,8 @@ func gatewayKookLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// gatewayKookWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter -// does, so the gateway's reflection-based unwrap (asKookAdmin) is exercised. +// gatewayKookWrap 按 cmd/cyrene 的 namedAdapter 的方式内嵌 +// adapter.PlatformAdapter,从而演练网关基于反射的拆包(asKookAdmin)。 type gatewayKookWrap struct { adapter.PlatformAdapter name string @@ -32,7 +32,7 @@ type gatewayKookWrap struct { func (w *gatewayKookWrap) Name() string { return w.name } -// gatewayKookServer answers the KOOK REST surface a panel view touches. +// gatewayKookServer 应答面板视图会触及的 KOOK REST 表面。 func gatewayKookServer(t *testing.T) *httptest.Server { t.Helper() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -71,8 +71,8 @@ func gatewayKookServer(t *testing.T) *httptest.Server { return srv } -// newKookGateway wires a registry whose kook channel wraps a real adapter pointed -// at an httptest KOOK server, and returns the owner JWT. +// newKookGateway 接线一个注册表,其 kook 渠道包装一个指向 httptest +// KOOK 服务器的真实适配器,并返回 owner JWT。 func newKookGateway(t *testing.T, name string) (string, string) { t.Helper() kookSrv := gatewayKookServer(t) diff --git a/internal/services/gateway/admin_channels_rescan.go b/internal/services/gateway/admin_channels_rescan.go index c8a6e06..e385434 100644 --- a/internal/services/gateway/admin_channels_rescan.go +++ b/internal/services/gateway/admin_channels_rescan.go @@ -2,32 +2,32 @@ package gateway import "reflect" -// This file holds the optional "the adapter needs the owner to re-authorize it" -// capability surfaced by GET /api/v1/admin/channels (v0.39). It lives next to — -// not inside — the channel summary itself because the summary list is one of -// admin.go's larger handlers and the unwrap helper is reusable. +// 本文件承载「适配器需要主人重新授权」这一可选能力, +// 由 GET /api/v1/admin/channels 浮现(v0.39)。它位于渠道摘要 +// 本身之旁 —— 而非其内 —— 因为摘要列表是 admin.go 中较大的 +// handler 之一,而拆包辅助函数可复用。 -// rescanAware is the optional capability a channel adapter exposes when its -// session needs owner re-authorization that a reconnect cannot provide. -// *weixin_ilink.Adapter implements it (RescanRequired): iLink answering -// errcode=-14 means the bot token is dead and the owner must scan a fresh QR. The -// core adapter.PlatformAdapter contract is deliberately NOT extended, so adapters -// that do not need the capability stay untouched. +// rescanAware 是渠道适配器在会话需要主人重新授权、而重连 +// 无法提供该授权时所暴露的可选能力。 +// *weixin_ilink.Adapter 实现了它(RescanRequired):iLink 应答 +// errcode=-14 意味着机器人 token 已失效,主人必须扫新的二维码。 +// 核心的 adapter.PlatformAdapter 契约被刻意不扩展,因此不需要 +// 该能力的适配器不受任何改动。 // -// A value is only ever reported when the adapter really produced it — a platform -// without the capability keeps the fields omitted rather than shipping a guess. +// 只有在适配器确实产出某值时才会报告它 —— 没有该能力的 +// 平台保持字段省略,而不是送出一个猜测。 type rescanAware interface { - // RescanRequired reports whether a new scan-code login is required, and why - // (reason is "" when it is not). + // RescanRequired 报告是否需要一次新的扫码登录,以及原因 + // (不需要时 reason 为 "")。 RescanRequired() (required bool, reason string) } -// asRescanAware reports whether a registered channel value exposes the rescan -// capability. The registration wraps an adapter in cmd/cyrene's namedAdapter — an -// unexported struct embedding adapter.PlatformAdapter — which promotes only the -// interface's methods, so the concrete adapter's extra capability is reachable -// only by unwrapping the embedded field. A direct adapter value (tests, -// single-account registrations) is accepted as-is. +// asRescanAware 报告已注册的渠道值是否暴露 rescan 能力。 +// 注册时会把适配器包进 cmd/cyrene 的 namedAdapter —— 一个内嵌 +// adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的 +// 方法,因此具体适配器多出的能力只能通过拆开被内嵌字段 +// 才能抵达。直接的适配器值(测试、单账号注册) +// 按原样接受。 func asRescanAware(v any) (rescanAware, bool) { if ra, ok := v.(rescanAware); ok { return ra, true @@ -40,9 +40,9 @@ func asRescanAware(v any) (rescanAware, bool) { return ra, ok } -// unwrapNamedAdapter returns the adapter a registration wrapper embeds in its -// exported PlatformAdapter field. asKookAdmin and asWeixinLoginAdmin perform the -// same unwrap for their own capability subsets and could share this helper. +// unwrapNamedAdapter 返回注册包装器内嵌在其导出 PlatformAdapter +// 字段中的适配器。asKookAdmin 与 asWeixinLoginAdmin 为各自的 +// 能力子集做同样的拆包,本可共用此辅助函数。 func unwrapNamedAdapter(v any) (any, bool) { rv := reflect.ValueOf(v) if !rv.IsValid() { diff --git a/internal/services/gateway/admin_channels_rescan_test.go b/internal/services/gateway/admin_channels_rescan_test.go index 332b405..65355a1 100644 --- a/internal/services/gateway/admin_channels_rescan_test.go +++ b/internal/services/gateway/admin_channels_rescan_test.go @@ -16,9 +16,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) -// rescanStubAdapter is a channel adapter that also exposes the optional rescan -// capability, exactly as *weixin_ilink.Adapter does once iLink answers -// errcode=-14 (session rejected, re-scan required). +// rescanStubAdapter 是一个渠道适配器,它同时暴露可选的 rescan +// 能力,正如 *weixin_ilink.Adapter 在 iLink 应答 errcode=-14 +// (会话被拒,需要重新扫码)之后那样。 type rescanStubAdapter struct { name string connected bool @@ -47,9 +47,9 @@ func (s *rescanStubAdapter) ReadMessages(ctx context.Context, _ func(adapter.Uni } func (s *rescanStubAdapter) RescanRequired() (bool, string) { return s.rescan, s.reason } -// namedRescanAdapter mirrors cmd/cyrene's namedAdapter: an unexported wrapper -// embedding adapter.PlatformAdapter, which promotes only the interface's methods. -// The gateway therefore has to unwrap it to reach the adapter's rescan capability. +// namedRescanAdapter 仿照 cmd/cyrene 的 namedAdapter:一个内嵌 +// adapter.PlatformAdapter 的未导出包装器,它只提升接口的方法。 +// 因此网关必须拆开它才能抵达适配器的 rescan 能力。 type namedRescanAdapter struct{ adapter.PlatformAdapter } var ( @@ -57,15 +57,15 @@ var ( _ sdk.Channel = namedRescanAdapter{} ) -// TestAdminChannelsReportsNeedsRescan asserts the panel can tell "needs a fresh -// scan" apart from plain "not connected": the rejected adapter's row carries -// needsRescan + the reason, and a healthy adapter omits both rather than shipping -// a fabricated value. +// TestAdminChannelsReportsNeedsRescan 断言面板能把「需要重新扫码」 +// 与单纯的「未连接」区分开:被拒适配器的行携带 +// needsRescan + 原因,而健康适配器两者都省略, +// 而不是送出一个编造的值。 func TestAdminChannelsReportsNeedsRescan(t *testing.T) { t.Parallel() const reason = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)" caps := capreg.New() - // Registered through the wrapper, the way the composition root registers it. + // 经包装器注册,与组合根注册它的方式一致。 if err := caps.Register(capreg.Capability{ Name: "weixin-ilink", Kind: capreg.KindChannel, @@ -112,8 +112,8 @@ func TestAdminChannelsReportsNeedsRescan(t *testing.T) { t.Errorf("telegram = %+v, want a connected row without rescan fields", tg) } - // The healthy channel must OMIT the fields (omitempty), not send defaults: the - // panel distinguishes "adapter did not report" from "adapter reported false". + // 健康渠道必须省略这些字段(omitempty),而不是发送默认值: + // 面板要区分「适配器未报告」与「适配器报告了 false」。 var raw []map[string]json.RawMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil { t.Fatalf("decode raw items: %v (body=%s)", err, body) @@ -129,15 +129,15 @@ func TestAdminChannelsReportsNeedsRescan(t *testing.T) { t.Error("telegram row carries lastError although there is none") } } - // The reason is informational text, never a credential. + // 原因是提示性文本,绝不是凭据。 if strings.Contains(string(body), `"bot_token"`) { t.Errorf("channel list leaked a bot_token field: %s", body) } } -// TestAdminChannelOpenRejectedSessionReturns409 asserts the open endpoint answers -// honestly for a session a reconnect cannot fix: 409 session_rejected with the -// re-scan instruction, never a 200 that would make the panel show "connected". +// TestAdminChannelOpenRejectedSessionReturns409 断言 open 端点对重连 +// 修不好的会话诚实作答:409 session_rejected 附带重新扫码 +// 指引,绝不返回会让面板显示「已连接」的 200。 func TestAdminChannelOpenRejectedSessionReturns409(t *testing.T) { t.Parallel() caps := capreg.New() @@ -175,10 +175,10 @@ func TestAdminChannelOpenRejectedSessionReturns409(t *testing.T) { } } -// TestAdminChannelsRescanFromRealIlinkAdapter closes the loop across the layers: -// a REAL *weixin_ilink.Adapter that iLink answered errcode=-14 for, registered the -// way the composition root registers it (wrapped), must surface needsRescan + -// lastError in the admin channel list. +// TestAdminChannelsRescanFromRealIlinkAdapter 跨层闭环:一个真正被 +// iLink 应答 errcode=-14 的 *weixin_ilink.Adapter,按组合根 +// 注册它的方式(包装后)注册,必须在管理渠道列表中 +// 浮现 needsRescan + lastError。 func TestAdminChannelsRescanFromRealIlinkAdapter(t *testing.T) { t.Parallel() ilog := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/services/gateway/admin_channels_weixin.go b/internal/services/gateway/admin_channels_weixin.go index 0dfc280..bccdda4 100644 --- a/internal/services/gateway/admin_channels_weixin.go +++ b/internal/services/gateway/admin_channels_weixin.go @@ -1,14 +1,14 @@ package gateway -// This file adds the weixin-iLink scan-login admin endpoints (design/08 §4), -// owner only and audited. The panel triggers a QR bind (login-qr), then polls the -// bind status (login-status); on confirmation the handler persists the newly -// obtained bot_token / bot_id into channels.json and hot-reloads the platform so -// the bridge connects the now-configured adapter — no restart required. +// 本文件新增 weixin-iLink 扫码登录管理端点(design/08 §4), +// 仅 owner 且带审计。面板先触发二维码绑定(login-qr),再轮询 +// 绑定状态(login-status);确认后 handler 把新获得的 +// bot_token / bot_id 持久化进 channels.json 并热重载该平台, +// 使 bridge 连接到这个刚配置好的适配器 —— 无需重启。 // -// The adapter owns the token: it is never logged, and it is returned to the panel -// only masked (**** + trailing 4). Identity stays on the adapter's actor metadata, -// never a prompt. +// token 归适配器所有:绝不记录日志,且返回给面板时 +// 只做脱敏(**** + 末 4 位)。身份始终落在适配器的 actor 元数据上, +// 绝不是提示词。 import ( "context" @@ -28,26 +28,26 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) -// weixinLoginAdmin is the subset of the weixin-ilink adapter's scan-login surface -// the gateway panel endpoints invoke. It is implemented by *weixin_ilink.Adapter -// and reached by unwrapping the registration wrapper (see asWeixinLoginAdmin). +// weixinLoginAdmin 是网关面板端点所调用的 weixin-ilink 适配器扫码 +// 登录表面的子集。它由 *weixin_ilink.Adapter 实现, +// 通过拆开注册包装器抵达(见 asWeixinLoginAdmin)。 type weixinLoginAdmin interface { StartLogin(ctx context.Context) (weixin_ilink.LoginQR, error) LoginStatus(ctx context.Context) (status, detail string) Config() map[string]any } -// weixinLoginQRResponse is the wire shape of GET /admin/channels/{platform}/login-qr. -// qrUrl is the WeChat QR image URL (qrcode_img_content) and qrCode is the raw scan -// identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the -// liteapp URL is an HTML page that cannot be fetched as an image. +// weixinLoginQRResponse 是 GET /admin/channels/{platform}/login-qr 的线上 +// 结构。qrUrl 是微信二维码图片 URL(qrcode_img_content),qrCode 是原始扫码 +// 标识(qrcode);前端从 qrUrl 渲染出可扫的二维码,因为 +// liteapp URL 是一个 HTML 页面,无法当作图片拉取。 type weixinLoginQRResponse struct { QrUrl string `json:"qrUrl,omitempty"` QrCode string `json:"qrCode,omitempty"` Status string `json:"status"` } -// weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status. +// weixinLoginStatusResponse 是 GET /admin/channels/{platform}/login-status 的线上结构。 type weixinLoginStatusResponse struct { Status string `json:"status"` BotID string `json:"botId,omitempty"` @@ -55,9 +55,9 @@ type weixinLoginStatusResponse struct { Err string `json:"err,omitempty"` } -// adminWeixinLoginQR triggers (or returns the in-progress) QR code for a -// weixin-ilink account. It is the panel's "扫码登录" entry point; it never blocks -// on the user's scan — LoginStatus is polled separately. +// adminWeixinLoginQR 为 weixin-ilink 账号触发(或返回进行中的)二维码。 +// 它是面板的「扫码登录」入口;绝不会阻塞于用户的 +// 扫码 —— LoginStatus 由另行轮询获得。 func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) { platform := r.PathValue("platform") accountID := r.URL.Query().Get("accountId") @@ -86,9 +86,9 @@ func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) { }) } -// adminWeixinLoginStatus reports the scan-login state. On confirmation it also -// persists the freshly obtained token to channels.json and hot-reloads the -// platform, so the bridge connects the now-configured adapter. +// adminWeixinLoginStatus 报告扫码登录状态。确认时它还会 +// 把新获得的 token 持久化进 channels.json 并热重载该平台, +// 使 bridge 连接到这个刚配置好的适配器。 func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) { platform := r.PathValue("platform") accountID := r.URL.Query().Get("accountId") @@ -119,15 +119,15 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, resp) } -// persistWeixinConfirmed writes the login-obtained credentials into the account's -// channels.json config (merging onto the user's curated base_url/cdn fields and -// preserving its allow/deny/limit lists) and hot-reloads the platform so the -// bridge connects the now-configured adapter. When ownerID is non-empty it also -// registers the scanner's bound ilink_user_id into the owner whitelist -// (owner.yaml) so the bridge recognizes it as the owner without a manual edit. -// The whitelist registration is best-effort: a failure is logged but does not -// fail the already-persisted channels.json, since the identity red line never -// blocks a successful bind. +// persistWeixinConfirmed 把登录获得的凭据写入该账号的 +// channels.json 配置(合并到用户精心维护的 base_url/cdn 字段之上, +// 并保留其 allow/deny/limit 列表),再热重载该平台, +// 使 bridge 连接到这个刚配置好的适配器。ownerID 非空时它还会 +// 把扫码者绑定的 ilink_user_id 登记进主人白名单 +// (owner.yaml),使 bridge 无需手工编辑就认出它是主人。 +// 白名单登记是尽力而为:失败会记日志,但不会让 +// 已持久化的 channels.json 失败,因为身份红线绝不 +// 阻塞一次成功的绑定。 func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID, token, botID, ownerID string) error { if s.channels == nil { return fmt.Errorf("weixin login: channel config store not injected") @@ -154,9 +154,9 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID if ownerID != "" { cfg["owner_id"] = ownerID } - // Ensure the binding user (the scanner) lands in the channel's allow list, so - // the bridge recognises it as the owner and replies instead of silencing it as - // a stranger (actor_role=unknown / env=stranger). + // 确保绑定用户(扫码者)落入该渠道的允许列表, + // 使 bridge 认出它是主人并回复,而不是把它当作 + // 陌生人(actor_role=unknown / env=stranger)静默掉。 allowUsers := append([]string(nil), base.AllowUsers...) if ownerID != "" { found := false @@ -191,10 +191,10 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID if err := s.channels.Reload(platform); err != nil { return err } - // Stamp the scanner (the bound owner) into the owner whitelist so the bridge - // recognizes it structurally. Best-effort: never fail an already-successful - // bind because a whitelist write hiccupped — the operator is alerted by a log - // line instead. + // 把扫码者(被绑定的主人)盖进主人白名单,使 bridge + // 从结构上认出它。尽力而为:绝不因为白名单写入打嗝 + // 就让已经成功的绑定失败 —— 改为用一行日志 + // 提醒运维。 if ownerID != "" && s.ownerRegistrar != nil { if err := s.ownerRegistrar.RegisterChannelOwner(ctx, platform, ownerID); err != nil { s.log.Error("weixin login owner register", "platform", platform, "err", err) @@ -203,17 +203,17 @@ func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID return nil } -// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the -// platform/account. It is nil for an unknown account or a wrapped adapter that -// does not expose the login surface. +// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。 +// 对未知账号,或未暴露登录表面的包装适配器, +// 它返回 nil。 func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) { if caps == nil { return nil, false } - // A weixin adapter may be registered with the bare "weixin-ilink" name (single - // account) rather than "weixin-ilink:", so first try a composite match, and - // fall back to a bare weixin adapter when the caller names a specific account but - // no composite name carries it (the panel passes the config account id). + // weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号), + // 而非 "weixin-ilink:",因此先尝试复合匹配, + // 当调用方指定了具体账号、但没有复合名承载它时,回退到 + // 裸 weixin 适配器(面板传的就是配置里的账号 id)。 var bare weixinLoginAdmin var bareFound bool for _, cap := range caps.List(capreg.KindChannel) { @@ -242,11 +242,11 @@ func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinL return nil, false } -// asWeixinLoginAdmin unwraps a registered channel value to its inner weixin-ilink -// adapter, mirroring asKookAdmin: the registration wraps the adapter in cmd/cyrene's -// namedAdapter (an unexported struct embedding adapter.PlatformAdapter); reflection -// reads the embedded field and type-asserts to the login surface. A direct -// *weixin_ilink.Adapter value (tests) is also accepted. +// asWeixinLoginAdmin 把已注册的渠道值拆到其内层 weixin-ilink +// 适配器,仿照 asKookAdmin:注册时会把适配器包进 cmd/cyrene 的 +// namedAdapter(一个内嵌 adapter.PlatformAdapter 的未导出结构体); +// 反射读取被内嵌字段并类型断言到登录表面。直接的 +// *weixin_ilink.Adapter 值(测试)也被接受。 func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) { if w, ok := v.(weixinLoginAdmin); ok { return w, true @@ -271,8 +271,8 @@ func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) { return nil, false } -// weixinTarget resolves the weixin-ilink adapter for a platform/account, writing -// an honest error and reporting found=false when it cannot. +// weixinTarget 解析某平台/账号的 weixin-ilink 适配器;无法解析时 +// 写出诚实的错误并报告 found=false。 func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (weixinLoginAdmin, bool) { platform = strings.TrimSpace(platform) if channelBasePlatform(platform) != "weixin-ilink" { @@ -290,8 +290,8 @@ func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, plat return wx, true } -// maskToken redacts a secret to "****" + the trailing 4 characters, matching the -// project's maskToken so a token never lands verbatim in a wire response. +// maskToken 把秘密脱敏成 "****" + 末 4 个字符,与项目的 +// maskToken 一致,使 token 绝不原样落在线上响应里。 func maskToken(s string) string { if len(s) <= 4 { return "****" @@ -299,10 +299,10 @@ func maskToken(s string) string { return "****" + s[len(s)-4:] } -// qrImageDataURL turns a QR image's raw content into an embeddable data URL. When -// the content is an http(s) URL it fetches and base64-encodes it (best effort); on -// any failure it returns "" so the caller keeps qrUrl. A content already carrying a -// data: prefix is passed through; anything else is treated as raw base64. +// qrImageDataURL 把二维码图片的原始内容转成可内嵌的 data URL。 +// 内容是 http(s) URL 时,它拉取并 base64 编码(尽力而为); +// 任何失败都返回 "",使调用方保留 qrUrl。已带 +// data: 前缀的内容原样透传;其他一律当作原始 base64。 func qrImageDataURL(ctx context.Context, raw string) string { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/services/gateway/admin_channels_weixin_test.go b/internal/services/gateway/admin_channels_weixin_test.go index 2f3363b..189c40d 100644 --- a/internal/services/gateway/admin_channels_weixin_test.go +++ b/internal/services/gateway/admin_channels_weixin_test.go @@ -1,9 +1,9 @@ package gateway -// Exercises the weixin-iLink scan-login admin endpoints (design/08 §4). The -// adapter is registered through a wrapper that embeds adapter.PlatformAdapter — -// mirroring cmd/cyrene's namedAdapter — so these tests also cover the reflection -// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter. +// 演练 weixin-iLink 扫码登录管理端点(design/08 §4)。适配器 +// 经由一个内嵌 adapter.PlatformAdapter 的包装器注册 —— 仿照 +// 仿照 cmd/cyrene 的 namedAdapter —— 因此这些测试也覆盖网关 +// 用来抵达具体 *weixin_ilink.Adapter 的反射拆包。 import ( "context" @@ -25,8 +25,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) -// gatewayWeixinWrap embeds adapter.PlatformAdapter the way cmd/cyrene's namedAdapter -// does, so the gateway's reflection-based unwrap (asWeixinLoginAdmin) is exercised. +// gatewayWeixinWrap 按 cmd/cyrene 的 namedAdapter 的方式内嵌 +// adapter.PlatformAdapter,从而演练网关基于反射的拆包(asWeixinLoginAdmin)。 type gatewayWeixinWrap struct { adapter.PlatformAdapter name string @@ -34,7 +34,7 @@ type gatewayWeixinWrap struct { func (w *gatewayWeixinWrap) Name() string { return w.name } -// fakeWeixinChannels is a minimal ChannelConfigStore for the persist-on-confirm path. +// fakeWeixinChannels 是供「确认即持久化」路径用的最小 ChannelConfigStore。 type fakeWeixinChannels struct { path string data map[string][]config.ChannelAccount @@ -50,8 +50,8 @@ func (f *fakeWeixinChannels) Reload(platform string) error { return nil } -// weixinLoginServer answers the scan-login surface: a fixed QR and an immediate -// confirmed status (so the background poll confirms within a poll interval). +// weixinLoginServer 应答扫码登录表面:固定的二维码与立即 +// 确认的状态(使后台轮询在一个轮询周期内确认)。 func weixinLoginServer(t *testing.T) *httptest.Server { t.Helper() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -75,8 +75,8 @@ func weixinLoginServer(t *testing.T) *httptest.Server { return srv } -// newWeixinGateway wires a registry whose weixin channel wraps a real adapter, and -// returns the httptest URL + an owner JWT. channels may be nil (no persist test). +// newWeixinGateway 接线一个注册表,其 weixin 渠道包装真实适配器, +// 并返回 httptest URL + owner JWT。channels 可为 nil(不做持久化测试)。 func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (string, string) { t.Helper() wsrv := weixinLoginServer(t) @@ -101,8 +101,8 @@ func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (s return ts.URL, tok } -// newWeixinGatewayPresetToken builds a gateway whose weixin adapter already holds a -// token, so login endpoints report confirmed without any network. +// newWeixinGatewayPresetToken 构建一个网关,其 weixin 适配器已持有 +// token,因此登录端点无需任何网络就报告已确认。 func newWeixinGatewayPresetToken(t *testing.T, name string) (string, string) { t.Helper() wa, err := weixin_ilink.New( @@ -122,8 +122,8 @@ func newWeixinGatewayPresetToken(t *testing.T, name string) (string, string) { return ts.URL, tok } -// weixinLoginWaitingServer answers the QR but keeps the status "wait" (never -// confirms), so the panel polls a login that is still in progress. +// weixinLoginWaitingServer 应答二维码但状态一直是 "wait"(永不 +// 确认),因此面板轮询的是一个仍在进行中的登录。 func weixinLoginWaitingServer(t *testing.T) *httptest.Server { t.Helper() h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -144,7 +144,7 @@ func weixinLoginWaitingServer(t *testing.T) *httptest.Server { return srv } -// newWeixinGatewayWaiting wires a weixin adapter whose status poll never confirms. +// newWeixinGatewayWaiting 接线一个 weixin 适配器,其状态轮询永不确认。 func newWeixinGatewayWaiting(t *testing.T, name string) (string, string) { t.Helper() wsrv := weixinLoginWaitingServer(t) @@ -229,7 +229,7 @@ func TestAdminWeixinLoginStatus_ConfirmPersists(t *testing.T) { } base, tok := newWeixinGateway(t, "weixin-ilink:conf0", chs) - // Trigger the QR, then poll until the background login confirms and persists. + // 触发二维码,然后轮询直到后台登录确认并持久化。 if resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0", nil, tok); resp.StatusCode != http.StatusOK { t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body) } @@ -333,12 +333,12 @@ func TestAdminWeixinLogin_NonWeixinNotImplemented(t *testing.T) { } } -// ownerRegCall records one RegisterChannelOwner invocation. +// ownerRegCall 记录一次 RegisterChannelOwner 调用。 type ownerRegCall struct { platform, accountID string } -// captureOwnerRegistrar records whitelist registrations for the weixin confirm path. +// captureOwnerRegistrar 记录 weixin 确认路径的白名单登记。 type captureOwnerRegistrar struct { calls []ownerRegCall } @@ -348,10 +348,10 @@ func (c *captureOwnerRegistrar) RegisterChannelOwner(_ context.Context, platform return nil } -// TestPersistWeixinConfirmed_RegistersOwner verifies that once a scan-login -// confirmation persists the channels.json credentials, the bound owner id is -// handed to the OwnerRegistrar as (weixin-ilink, ilink_user_id), and that an empty -// owner id skips the registration entirely. +// TestPersistWeixinConfirmed_RegistersOwner 验证:一旦扫码登录确认 +// 把凭据持久化进 channels.json,被绑定的主人 id 就会以 +// (weixin-ilink, ilink_user_id) 交给 OwnerRegistrar,而空 +// 主人 id 会完全跳过该登记。 func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") @@ -375,8 +375,8 @@ func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) { t.Fatalf("registrar calls = %+v, want [weixin-ilink/o-1]", reg.calls) } - // An empty owner id (a confirmed bind that yields no bound user) must not - // register anything. + // 空的主人 id(一次确认了但没有产出绑定用户的绑定)绝不 + // 登记任何内容。 if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-y", "bot-y", ""); err != nil { t.Fatalf("persistWeixinConfirmed(no owner): %v", err) } @@ -385,8 +385,8 @@ func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) { } } -// TestNew_WiresOwnerRegistrar verifies the composition point: Deps.OwnerRegistrar -// is threaded onto the Server so the weixin confirm flow has a live registrar. +// TestNew_WiresOwnerRegistrar 验证组合点:Deps.OwnerRegistrar +// 被串到 Server 上,使 weixin 确认流程有活的 registrar。 func TestNew_WiresOwnerRegistrar(t *testing.T) { t.Parallel() reg := &captureOwnerRegistrar{} diff --git a/internal/services/gateway/admin_honest501_test.go b/internal/services/gateway/admin_honest501_test.go index aa7ab6a..4b6003d 100644 --- a/internal/services/gateway/admin_honest501_test.go +++ b/internal/services/gateway/admin_honest501_test.go @@ -8,10 +8,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) -// TestAdminMissingStoreWritesHonest501 locks the B5 red line for the endpoints -// whose backing store does not exist yet: they must answer 501 not_implemented -// with an explanatory message, never a fabricated success. User creation has no -// user table/login to persist into, and IoT has no scenario/rule model or store. +// TestAdminMissingStoreWritesHonest501 为尚无后端存储的端点锁定 B5 +// 红线:它们必须返回 501 not_implemented 并附说明性 +// 消息,绝不编造成功。创建用户没有可持久化的用户表/登录, +// 而 IoT 没有场景/规则模型或存储。 func TestAdminMissingStoreWritesHonest501(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) diff --git a/internal/services/gateway/admin_plugins.go b/internal/services/gateway/admin_plugins.go index 185c098..a17d646 100644 --- a/internal/services/gateway/admin_plugins.go +++ b/internal/services/gateway/admin_plugins.go @@ -1,13 +1,13 @@ package gateway -// Admin plugin install/uninstall and the installed-plugin listing. A runtime -// "plugin" in v1 is an external MCP server: install connects it, hot-registers -// its tools into the capability registry (so they are injected into the next -// model request without a restart), persists its config to the gitignored -// plugins.json, and holds the live server for a later refresh/uninstall -// (design/09 插件生态 §1). Installing a compiled Go plugin is impossible in a -// single static binary, so it is not offered. Writes are owner-only and audited; -// an API key is persisted but never returned or logged verbatim. +// 管理侧插件安装/卸载与已安装插件列表。v1 中的运行时 +// 「插件」是一个外部 MCP server:安装即连接它、把它的 +// 工具热注册进能力注册表(从而无需重启就注入下一次 +// 模型请求),把其配置持久化到被 gitignore 的 +// plugins.json,并持有活着的 server 以便之后刷新/卸载 +// (design/09 插件生态 §1)。在单静态二进制中安装编译好的 Go 插件 +// 不可能,因此不提供该方式。写操作仅 owner 且带审计; +// API key 会被持久化,但绝不返回,也绝不原样记日志。 import ( "context" @@ -24,28 +24,28 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) -// pluginInstallTimeout bounds the connect+discover handshake the install request -// waits for. The plugin manager applies its own per-server timeout inside it. +// pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手 +// 时长。插件管理器在其中施加自己的每 server 超时。 const pluginInstallTimeout = 20 * time.Second -// pluginActionRequest is the POST /api/v1/admin/plugins/uninstall body. +// pluginActionRequest 是 POST /api/v1/admin/plugins/uninstall 的请求体。 type pluginActionRequest struct { Name string `json:"name"` Kind string `json:"kind"` } -// pluginInstallRequest is the POST /api/v1/admin/plugins/install body. Config -// carries the MCP server connection fields; the top-level name/kind are kept -// separate so the request reads like the other admin writes. +// pluginInstallRequest 是 POST /api/v1/admin/plugins/install 的请求体。Config +// 承载 MCP server 连接字段;顶层的 name/kind 被单独 +// 保留,使该请求读起来与其他管理写一致。 type pluginInstallRequest struct { Kind string `json:"kind"` Name string `json:"name"` Config pluginInstallConfig `json:"config"` } -// pluginInstallConfig is the MCP server connection block. Endpoint is the URL -// for http/sse; url is accepted as an alias. APIKey/Env are persisted but never -// echoed back. +// pluginInstallConfig 是 MCP server 连接块。Endpoint 是 http/sse +// 的 URL;url 作为别名被接受。APIKey/Env 会被持久化,但绝不 +// 回显。 type pluginInstallConfig struct { Transport string `json:"transport"` Endpoint string `json:"endpoint"` @@ -56,8 +56,8 @@ type pluginInstallConfig struct { Env map[string]string `json:"env"` } -// AdminInstalledPlugin is the wire shape of one installed plugin in the admin -// list. The API key and any sensitive env values are masked. +// AdminInstalledPlugin 是管理列表中一个已安装插件的线上 +// 结构。API key 与任何敏感 env 值都会脱敏。 type AdminInstalledPlugin struct { ID string `json:"id"` Name string `json:"name"` @@ -69,16 +69,16 @@ type AdminInstalledPlugin struct { APIKey string `json:"apiKey,omitempty"` Env map[string]string `json:"env,omitempty"` CreatedAt string `json:"createdAt,omitempty"` - // Live reports whether the MCP server is connected and its tools registered. + // Live 报告 MCP server 是否已连接、其工具是否已注册。 Live bool `json:"live"` - // ToolCount is the number of tools the live server registered. + // ToolCount 是活着的 server 已注册的工具数。 ToolCount int `json:"toolCount"` } -// adminPluginInstall installs (or refreshes) an MCP server plugin: it validates -// the request, connects and discovers the server's tools, then persists the -// config. A connect/discover/register failure returns 502 and persists nothing, -// so a plugin is never half-installed. +// adminPluginInstall 安装(或刷新)一个 MCP server 插件:它校验 +// 请求、连接并发现该 server 的工具,然后持久化配置。 +// connect/discover/register 失败会返回 502 且不持久化任何东西, +// 因此插件绝不会处于半安装状态。 func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) { var req pluginInstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -145,10 +145,10 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) { }) } -// adminPluginUninstall uninstalls a plugin. An installed MCP server is closed and -// its tools removed through the plugin manager, then its config is deleted from -// plugins.json; any other name falls back to removing the capability from the -// registry, preserving the pre-existing behavior. +// adminPluginUninstall 卸载一个插件。已安装的 MCP server 会被关闭、 +// 其工具经插件管理器移除,随后其配置从 plugins.json 删除; +// 其他任何名字回退为把该能力从注册表移除, +// 保持既有行为。 func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { var req pluginActionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -194,9 +194,9 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true}) } -// adminListInstalledPlugins lists the runtime-installed plugins with their -// masked config and per-server tool count. It is a read endpoint (owner/admin) -// and never returns an API key or a sensitive env value verbatim. +// adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置 +// 与每 server 工具数。它是读端点(owner/admin), +// 绝不原样返回 API key 或敏感 env 值。 func (s *Server) adminListInstalledPlugins(w http.ResponseWriter, r *http.Request) { out := make([]AdminInstalledPlugin, 0) if s.plugins != nil { @@ -207,7 +207,7 @@ func (s *Server) adminListInstalledPlugins(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, out) } -// adminInstalledPluginOf maps an installed plugin to its masked wire shape. +// adminInstalledPluginOf 把已安装插件映射为其脱敏后的线上结构。 func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin { cfg := runtimeconfig.MaskedPlugin(it.Config) return AdminInstalledPlugin{ @@ -226,8 +226,8 @@ func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin { } } -// firstNonEmpty returns the first non-blank value, so an install body may send -// either endpoint or url for the server address. +// firstNonEmpty 返回首个非空值,因此安装请求体可用 endpoint +// 或 url 任一字段给出 server 地址。 func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { diff --git a/internal/services/gateway/admin_plugins_test.go b/internal/services/gateway/admin_plugins_test.go index adf2d55..6ad32fe 100644 --- a/internal/services/gateway/admin_plugins_test.go +++ b/internal/services/gateway/admin_plugins_test.go @@ -19,9 +19,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) -// fakeMCPHandler is a minimal Streamable HTTP MCP server for the plugin tests. -// wantAuth, when set, requires the bearer header; tools is mutated atomically so -// a refresh test can change the tool list. +// fakeMCPHandler 是供插件测试用的最小 Streamable HTTP MCP server。 +// wantAuth 设置后要求 bearer 头;tools 以原子方式变更, +// 使刷新测试能改变工具列表。 type fakeMCPHandler struct { wantAuth string tools []string @@ -69,8 +69,8 @@ func (f *fakeMCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(resp) } -// pluginTestEnv is a gateway wired with a real plugin manager over a temp -// plugins.json. +// pluginTestEnv 是一个网关,接线了以临时 plugins.json 为 +// 后端的真实插件管理器。 type pluginTestEnv struct { ts *httptest.Server caps *capreg.Registry @@ -108,7 +108,7 @@ func newPluginTestEnv(t *testing.T, mcp *fakeMCPHandler) *pluginTestEnv { return &pluginTestEnv{ts: ts, caps: caps, path: path, token: tok, mcpURL: mcpURL} } -// newFakeMCPHandler returns a fake server plus a setter for its tool list. +// newFakeMCPHandler 返回一个假 server 以及其工具列表的 setter。 func newFakeMCPHandler(wantAuth string, tools ...string) *fakeMCPHandler { return &fakeMCPHandler{wantAuth: wantAuth, tools: tools} } diff --git a/internal/services/gateway/admin_rag_delete_test.go b/internal/services/gateway/admin_rag_delete_test.go index 0586599..2213766 100644 --- a/internal/services/gateway/admin_rag_delete_test.go +++ b/internal/services/gateway/admin_rag_delete_test.go @@ -10,9 +10,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" ) -// TestAdminRagDeleteRemovesDocument proves the delete write path is real: the -// indexed document (project entry + its chapters) is gone from the list and the -// search, and deleting it again answers 404 rather than a fabricated success. +// TestAdminRagDeleteRemovesDocument 证明删除写路径是真的:已索引 +// 的文档(项目条目 + 其章节)从列表与搜索中消失, +// 再次删除它返回 404 而不是编造的成功。 func TestAdminRagDeleteRemovesDocument(t *testing.T) { t.Parallel() ctx := context.Background() @@ -67,8 +67,8 @@ func TestAdminRagDeleteRemovesDocument(t *testing.T) { wantErrorShape(t, body2) } -// TestAdminRagDeleteUnwired501 asserts an unconfigured RAG service still answers -// honestly instead of pretending the document was removed. +// TestAdminRagDeleteUnwired501 断言未配置的 RAG 服务仍然 +// 诚实作答,而不是假装文档已被移除。 func TestAdminRagDeleteUnwired501(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -82,7 +82,7 @@ func TestAdminRagDeleteUnwired501(t *testing.T) { wantErrorShape(t, body) } -// TestAdminRagDeleteRequiresOwner asserts the write route stays owner-gated. +// TestAdminRagDeleteRequiresOwner 断言该写路由仍然受 owner 门控。 func TestAdminRagDeleteRequiresOwner(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: newRagStoreForGateway(t)}) diff --git a/internal/services/gateway/admin_system.go b/internal/services/gateway/admin_system.go index 79cbc01..0155955 100644 --- a/internal/services/gateway/admin_system.go +++ b/internal/services/gateway/admin_system.go @@ -17,13 +17,13 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) -// System management admin writes (owner-only, audited; design/11 §4 敏感操作代审 + -// design/10 §3 module 14 系统设置). Every handler audits a system.* action and never -// echoes a JWT secret or a database path back to the client. +// 系统管理写操作(仅 owner,带审计;design/11 §4 敏感操作代审 + +// design/10 §3 module 14 系统设置)。每个 handler 都会审计一个 system.* 动作, +// 绝不把 JWT 秘密或数据库路径回显给客户端。 -// adminRevokeAllKeys revokes every currently registered API key. It is the -// `system.revoke_keys` admin write; the table is emptied so no prior key can -// authenticate afterward. +// adminRevokeAllKeys 吊销当前已注册的每一个 API key。它是 +// `system.revoke_keys` 管理写;该表被清空,因此此前任何 key +// 之后都无法认证。 func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) { keys, ok := s.users.(*auth.APIKey) if !ok { @@ -36,15 +36,15 @@ func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"revoked": n}) } -// rotateJWTRequest is the POST /api/v1/admin/system/rotate-jwt body. The new -// secret is accepted from the request so the owner can supply a fresh value; it -// is never echoed back or logged. +// rotateJWTRequest 是 POST /api/v1/admin/system/rotate-jwt 的请求体。新 +// 秘密从请求中接受,使主人能提供新值;它绝不 +// 被回显或被记日志。 type rotateJWTRequest struct { NewSecret string `json:"newSecret"` } -// adminRotateJWT replaces the JWT signing secret, invalidating every token issued -// under the old secret. It is the `system.rotate_jwt` admin write. +// adminRotateJWT 替换 JWT 签名秘密,使在旧秘密下签发的所有 +// token 失效。它是 `system.rotate_jwt` 管理写。 func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) { var req rotateJWTRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -71,10 +71,10 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret")) return } - // Credential rotation must invalidate every long-lived grant too, not only the - // access tokens signed under the old secret. Refresh tokens are opaque and - // secret-independent, so they are deleted wholesale — the minimal, exact - // global invalidation in this architecture (no token_version column needed). + // 凭据轮换也必须让每一个长期授权失效,而不只是旧秘密下 + // 签发的访问令牌。refresh token 是不透明的、与秘密无关, + // 因此被整体删除 —— 这是本架构中最小且精确的 + // 全局失效手段(无需 token_version 列)。 var refreshRevoked int64 if s.refresh != nil { n, rerr := s.refresh.RevokeAll(r.Context()) @@ -95,9 +95,9 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) { }) } -// adminBackup snapshots the SQLite database into the data directory using -// `VACUUM INTO` (which includes committed WAL content), returning the backup path -// and size. It is the `system.backup` admin write. +// adminBackup 用 `VACUUM INTO` 把 SQLite 数据库快照进数据目录 +// (该语句包含已提交的 WAL 内容),返回备份路径 +// 与大小。它是 `system.backup` 管理写。 func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(s.dbPath) == "" { s.auditClaims(r, "system.backup", "error") @@ -115,17 +115,17 @@ func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"path": path, "size": size}) } -// restoreRequest is the POST /api/v1/admin/system/restore body. Path is the -// backup file to restore, expected to live inside the data directory. +// restoreRequest 是 POST /api/v1/admin/system/restore 的请求体。Path 是 +// 要恢复的备份文件,预期位于数据目录内。 type restoreRequest struct { Path string `json:"path"` } -// adminRestore validates a backup path (must be inside the data directory — a -// path-traversal guard), snapshots the current database first, then replaces the -// live DB file. Because the running process holds the SQLite file handle open, the -// replace takes effect on restart; it is reported as such. It is the -// `system.restore` admin write. +// adminRestore 校验备份路径(必须位于数据目录内 —— 这是 +// 路径穿越防护),先为当前数据库做快照,然后替换 +// 活的 DB 文件。由于运行中的进程仍持有 SQLite 文件句柄, +// 替换在重启后生效;响应中如实说明。它是 +// `system.restore` 管理写。 func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) { var req restoreRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -154,8 +154,8 @@ func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "备份文件不存在")) return } - // Snapshot the current database before touching it, so an accidental restore - // of the wrong file is recoverable. + // 在动它之前先为当前数据库做快照,这样误恢复 + // 错文件时仍可挽回。 curPath, _, err := s.backupDatabase(r.Context()) if err != nil { s.auditClaims(r, "system.restore", "error") @@ -177,10 +177,10 @@ func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) { }) } -// restorePathWithinDataDir resolves src to an absolute path and reports whether it -// lives inside the data directory (the parent of s.dbPath). It is the path- -// traversal guard for the restore endpoint: only paths under the data dir are -// accepted. +// restorePathWithinDataDir 把 src 解析为绝对路径并报告它是否 +// 位于数据目录(s.dbPath 的父目录)内。它是恢复端点的 +// 路径穿越防护:只接受数据目录之下的 +// 路径。 func (s *Server) restorePathWithinDataDir(src string) (string, bool) { dataDir, err := filepath.Abs(filepath.Dir(s.dbPath)) if err != nil { @@ -199,11 +199,11 @@ func (s *Server) restorePathWithinDataDir(src string) (string, bool) { return absSrc, true } -// backupDatabase snapshots the live SQLite database into -// /backup-.db via `VACUUM INTO`, which includes every committed WAL -// transaction, and returns the new file's path and size. Opening a second -// connection to the file-backed database is safe: SQLite serializes access, and -// busy_timeout handles a momentarily locked source. +// backupDatabase 经 `VACUUM INTO` 把活的 SQLite 数据库快照到 +// /backup-.db,该语句包含每一个已提交的 WAL +// 事务,并返回新文件的路径与大小。对基于文件的数据库再开 +// 一个连接是安全的:SQLite 会串行化访问,而 +// busy_timeout 能应对源被短暂锁定。 func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) { dir := filepath.Dir(s.dbPath) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -215,8 +215,8 @@ func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) { return "", 0, fmt.Errorf("open db for backup: %w", err) } defer conn.Close() - // VACUUM INTO takes a string literal, not a bound parameter; escape the - // generated filename (safe characters, but escape defensively). + // VACUUM INTO 接受字符串字面量,而不是绑定参数;对生成的 + // 文件名做转义(字符本身安全,但防御性地转义)。 query := fmt.Sprintf("VACUUM INTO '%s'", escapeSQLString(dest)) if _, err := conn.ExecContext(ctx, query); err != nil { return "", 0, fmt.Errorf("vacuum into: %w", err) @@ -228,11 +228,11 @@ func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) { return dest, info.Size(), nil } -// replaceDatabase atomically replaces the live DB file at dest with a copy of src, -// writing to a temp file first so a failure never leaves dest truncated. On Linux -// a running SQLite connection keeps its open handle to the old inode, so the new -// data becomes visible only after the process restarts — which the restore -// endpoint reports. +// replaceDatabase 用 src 的副本原子替换 dest 处的活 DB 文件, +// 先写临时文件,因此失败绝不留下被截断的 dest。在 Linux 上 +// 运行中的 SQLite 连接仍持有旧 inode 的句柄,因此新数据 +// 只有在进程重启后才可见 —— 恢复端点会如实 +// 报告这一点。 func replaceDatabase(src, dest string) error { in, err := os.Open(src) if err != nil { @@ -260,8 +260,8 @@ func replaceDatabase(src, dest string) error { return nil } -// escapeSQLString doubles any single quote so a filename can be safely embedded -// in a SQL string literal for VACUUM INTO. +// escapeSQLString 把每个单引号翻倍,使文件名能安全地嵌入 +// 供 VACUUM INTO 使用的 SQL 字符串字面量。 func escapeSQLString(s string) string { return strings.ReplaceAll(s, "'", "''") } diff --git a/internal/services/gateway/admin_system_test.go b/internal/services/gateway/admin_system_test.go index 58cd858..d89cc9a 100644 --- a/internal/services/gateway/admin_system_test.go +++ b/internal/services/gateway/admin_system_test.go @@ -14,9 +14,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) -// newFileDB creates a real SQLite database file at dir/, migrates it, and -// closes the handle, returning the file path. The gateway backup/restore -// endpoints open their own connections, so the handle is not held open here. +// newFileDB 在 dir/ 创建真实 SQLite 数据库文件、迁移它、 +// 然后关闭句柄,返回文件路径。网关的备份/恢复端点 +// 会自己打开连接,因此这里不保持句柄打开。 func newFileDB(t *testing.T, dir, name string) string { t.Helper() path := filepath.Join(dir, name) @@ -35,7 +35,7 @@ func newFileDB(t *testing.T, dir, name string) string { return path } -// --- revoke-keys ------------------------------------------------------------- +// --- system.revoke_keys(吊销全部密钥) ------------------------------------- func TestAdminRevokeAllKeys(t *testing.T) { t.Parallel() @@ -68,7 +68,7 @@ func TestAdminRevokeAllKeys(t *testing.T) { func TestAdminRevokeAllKeysUnwired(t *testing.T) { t.Parallel() - _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Users store + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 Users store tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok) if resp.StatusCode != http.StatusInternalServerError { @@ -77,13 +77,13 @@ func TestAdminRevokeAllKeysUnwired(t *testing.T) { wantErrorShape(t, body) } -// --- rotate-jwt -------------------------------------------------------------- +// --- system.rotate_jwt(轮换 JWT) ----------------------------------------- func TestAdminRotateJWT(t *testing.T) { t.Parallel() jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) _, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt}) - // The route is owner-only; verify an agent with the current secret passes. + // 该路由仅 owner;验证持有当前秘密的 agent 能通过。 tok := signAny(t, jwt, "acct", auth.RoleOwner) newSecret := strings.Repeat("Z", 40) @@ -102,11 +102,11 @@ func TestAdminRotateJWT(t *testing.T) { if !res.Rotated || res.Note == "" { t.Fatalf("res = %+v, want rotated with note", res) } - // The pre-rotation token no longer verifies. + // 轮换前的 token 不再通过校验。 if _, err := jwt.Verify(tok); err == nil { t.Fatal("pre-rotation token still verifies after RotateSecret") } - // The new secret is never echoed back in the body, nor is a short one accepted. + // 新秘密绝不在响应体里回显,过短的也不会被接受。 if strings.Contains(string(body), newSecret) { t.Fatalf("new secret leaked in response body: %s", body) } @@ -124,7 +124,7 @@ func TestAdminRotateJWTShortSecret(t *testing.T) { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) - // The previous secret stays intact after a rejected rotation. + // 被拒绝的轮换之后,原秘密保持不变。 if _, err := jwt.Verify(tok); err != nil { t.Fatalf("token invalid after rejected rotation: %v", err) } @@ -143,7 +143,7 @@ func TestAdminRotateJWTMissingSecret(t *testing.T) { } } -// --- backup ------------------------------------------------------------------ +// --- system.backup(备份) -------------------------------------------------- func TestAdminBackupProducesFile(t *testing.T) { t.Parallel() @@ -176,7 +176,7 @@ func TestAdminBackupProducesFile(t *testing.T) { func TestAdminBackupUnwired(t *testing.T) { t.Parallel() - _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 DatabasePath tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok) if resp.StatusCode != http.StatusInternalServerError { @@ -185,7 +185,7 @@ func TestAdminBackupUnwired(t *testing.T) { wantErrorShape(t, body) } -// --- restore ----------------------------------------------------------------- +// --- system.restore(恢复) ------------------------------------------------- func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) { t.Parallel() @@ -193,9 +193,9 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) { dbPath := newFileDB(t, dir, "cyrene.db") backupSrc := newFileDB(t, dir, "backup.db") - // Make the backup source clearly different from the live db by appending a - // marker. Restore only copies bytes and never opens the source, so the marker - // survives into the replaced live file and proves the swap happened. + // 通过追加一个标记,让备份源与活的 db 明显不同。 + // 恢复只复制字节、绝不打开源,因此该标记会 + // 存活进被替换的活文件,证明替换确实发生过。 srcFile, err := os.OpenFile(backupSrc, os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { t.Fatalf("open backup source: %v", err) @@ -214,7 +214,7 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // No pre-existing backups yet. + // 此时尚无既有备份。 before, _ := filepath.Glob(filepath.Join(dir, "backup-*.db")) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": backupSrc}, tok) @@ -232,13 +232,13 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) { if !res.Restored || res.Note == "" { t.Fatalf("res = %+v, want restored with note", res) } - // The current DB was snapshotted before the replace (a new backup appeared). + // 当前 DB 在替换前已被快照(出现了一个新备份)。 after, _ := filepath.Glob(filepath.Join(dir, "backup-*.db")) if len(after) != len(before)+1 { t.Fatalf("backup count before=%d after=%d, want one more pre-restore snapshot", len(before), len(after)) } - // The live DB was replaced with the backup source's bytes, so it now carries - // the marker and differs from its pre-restore content. + // 活 DB 已被替换为备份源的字节,因此它现在携带 + // 该标记,与恢复前的内容不同。 liveAfter, err := os.ReadFile(dbPath) if err != nil { t.Fatalf("read live db after: %v", err) @@ -253,8 +253,8 @@ func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) { func TestAdminRestoreRejectsTraversal(t *testing.T) { t.Parallel() - dir := t.TempDir() // data dir - outside := t.TempDir() // a sibling directory, outside the data dir + dir := t.TempDir() // 数据目录 + outside := t.TempDir() // 兄弟目录,位于数据目录之外 dbPath := newFileDB(t, dir, "cyrene.db") outsideBackup := newFileDB(t, outside, "outside.db") @@ -287,7 +287,7 @@ func TestAdminRestoreRejectsMissingFile(t *testing.T) { func TestAdminRestoreUnwired(t *testing.T) { t.Parallel() - _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无 DatabasePath tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": "x"}, tok) diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index b5677e6..cd31336 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -1,16 +1,16 @@ package gateway -// Admin channel-configuration writes (owner-only, audited). The panel saves a -// platform's account config (token/address/rate limit/black-or-white lists) to -// channels.json and hot-reloads that platform's adapters — no restart required -// (design/04 §3.1). The GET /admin/channels read path masks every token so a -// secret is never returned to the client; this write path persists the real -// token to the gitignored file and triggers the bridge reload. +// 管理渠道配置写操作(仅 owner,带审计)。面板把某平台的 +// 账号配置(token/地址/速率限制/黑或白名单)保存到 +// channels.json 并热重载该平台的适配器 —— 无需重启 +// (design/04 §3.1)。GET /admin/channels 读路径会对每个 token +// 脱敏,因此秘密绝不返回给客户端;这条写路径把真实 +// token 持久化进被 gitignore 的文件并触发 bridge 重载。 // -// The request carries a generic config map (platform-specific keys such as -// bot_token / base_url / remote_url / endpointPath) plus convenience top-level -// fields for the common token/address fields, so the panel can send either the -// config object or the shortcut fields. +// 请求携带一个通用 config map(平台特有键,如 +// bot_token / base_url / remote_url / endpointPath),外加针对常见 +// token/地址字段的顶层便捷字段,因此面板既可发送 +// config 对象,也可发送快捷字段。 import ( "encoding/json" @@ -22,9 +22,9 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) -// AdminChannelAccount is the wire shape of one configured channel account in the -// admin list. Config values are masked (no raw token) so nothing sensitive is -// returned to the client. +// AdminChannelAccount 是管理列表中一个已配置渠道账号的线上 +// 结构。Config 的值已脱敏(不含原始 token),因此没有敏感 +// 内容返回给客户端。 type AdminChannelAccount struct { ID string `json:"id"` Config map[string]any `json:"config,omitempty"` @@ -35,29 +35,29 @@ type AdminChannelAccount struct { GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"` MsgPerMin int `json:"msgPerMin,omitempty"` MaxMessages int `json:"maxMessages,omitempty"` - // PersonaID is the role this account answers as (design/22 §2 方案 b), omitted - // when the account binds none. It is a persona id, not a secret. + // PersonaID 是该账号应答所用的角色(design/22 §2 方案 b),账号 + // 未绑定任何角色时省略。它是人格 id,不是秘密。 PersonaID string `json:"personaId,omitempty"` - // BotID/BotUsername/BotNickname are the connected bot's resolved identity - // (kook fetches it from /api/v3/user/me on connect). They come from the live - // adapter, not the persisted channels.json config, and are non-sensitive (the - // bot's public handle/display name), so the panel may show them. + // BotID/BotUsername/BotNickname 是已连接机器人的实际解析身份 + // (kook 在 connect 时从 /api/v3/user/me 获取)。它们来自活的 + // 适配器,而不是持久化的 channels.json 配置,且非敏感(机器人 + // 的公开 handle/显示名),因此面板可以展示它们。 BotID string `json:"botId,omitempty"` BotUsername string `json:"botUsername,omitempty"` BotNickname string `json:"botNickname,omitempty"` - // Capabilities lists the account-level admin operations the platform's - // adapter exposes (friends/channels/user-chats/message-test) so the panel - // renders the right per-account cards. It is omitted for platforms without a - // specialized adapter surface. + // Capabilities 列出该平台适配器暴露的账号级管理操作 + // (friends/channels/user-chats/message-test),使面板 + // 渲染正确的每账号卡片。没有专用适配器表面的平台 + // 会省略它。 Capabilities []string `json:"capabilities,omitempty"` } -// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body. -// Config holds platform-specific keys (bot_token, base_url, remote_url, -// endpointPath, ...); the top-level convenience fields are merged into it for the -// common token/address fields. AllowGroups/DenyGroups/AllowUsers/DenyUsers are the -// black-or-white lists (design/16 §4.6 §6); the *_PerMin/MaxMessages fields are -// the per-account flow limits (design/12 §6 策略三/四). +// channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。 +// Config 保存平台特有键(bot_token、base_url、remote_url、 +// endpointPath……);顶层便捷字段会为常见 token/地址字段 +// 合并进去。AllowGroups/DenyGroups/AllowUsers/DenyUsers 是 +// 黑或白名单(design/16 §4.6 §6);*_PerMin/MaxMessages 字段是 +// 每账号的流量限制(design/12 §6 策略三/四)。 type channelConfigRequest struct { ID string `json:"id"` Token string `json:"token"` @@ -75,22 +75,22 @@ type channelConfigRequest struct { GroupMsgPerMin int `json:"groupMsgPerMin"` MsgPerMin int `json:"msgPerMin"` MaxMessages int `json:"maxMessages"` - // PersonaID is the role this account answers as (design/22 §2 方案 b). It is a - // pointer because the wire contract is three-state and a plain string cannot - // tell an absent key from an explicit "": absent keeps the stored binding, - // "" clears it, a non-empty id binds it. An unknown id is refused with 400 so a - // save can never bind a role that cannot answer. + // PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是 + // 指针,因为线上契约是三态的,而普通 string 无法 + // 区分键缺失与显式 "":缺失保留已存绑定, + // "" 清除它,非空 id 绑定它。未知 id 以 400 拒绝,因此 + // 保存绝不可能绑定一个无法应答的角色。 PersonaID *string `json:"personaId"` } -// adminSaveChannelConfig persists one account's config for a platform into -// channels.json (Upsert by id) and hot-reloads that platform's adapters. The -// response carries the channels.json path and the account id so the panel can -// address the saved account (for a later edit/delete). +// adminSaveChannelConfig 把某平台一个账号的配置持久化进 +// channels.json(按 id Upsert)并热重载该平台的适配器。 +// 响应携带 channels.json 路径与账号 id,使面板能 +// 定位已保存的账号(供之后编辑/删除)。 // -// personaId is three-state (see channelPersonaBinding): the panel only sends the -// key when the owner touched the role select, so an absent key must keep the -// stored binding rather than silently unbind the account. +// personaId 是三态的(见 channelPersonaBinding):面板仅在主人 +// 触碰过角色选择时才发送该键,因此键缺失必须保留 +// 已存绑定,而不是静默解绑该账号。 func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSpace(r.PathValue("platform")) if platform == "" { @@ -108,11 +108,11 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入")) return } - // A channel-level role must exist in the persona library, or the account would - // be bound to a role that cannot answer (design/22 §2 方案 b). Only a value the - // panel actually sent is validated; an absent key (nil) inherits the stored - // binding, which was validated when it was written. An explicit "" is always - // accepted: it means "clear the channel binding". + // 渠道级角色必须存在于人格库中,否则账号会被绑定到 + // 一个无法应答的角色(design/22 §2 方案 b)。只校验面板确实 + // 发送的值;键缺失(nil)继承已存绑定,该绑定在 + // 写入时就已校验。显式的 "" 一律接受: + // 它表示「清除渠道绑定」。 if req.PersonaID != nil { if !s.requireKnownPersona(w, strings.TrimSpace(*req.PersonaID)) { s.auditClaims(r, "channel.save", "error") @@ -120,10 +120,10 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) } } cfg := channelConfigFromRequest(req) - // weixin-ilink may be saved with an empty config: the panel creates the account - // first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/ - // owner_id via login-status (design/08 §4). Requiring a token here would block that - // flow, so exempt it from the "at least one field" gate. + // weixin-ilink 可以带空配置保存:面板先创建账号, + // 随后用户扫码(get_bot_qrcode),它经 login-status 填入 + // bot_token/bot_id/owner_id(design/08 §4)。此处要求 token 会阻塞该 + // 流程,因此把它从「至少一个字段」的门禁中豁免。 if len(cfg) == 0 && platform != "weixin-ilink" { s.auditClaims(r, "channel.save", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段")) @@ -163,8 +163,8 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) }) } -// adminDeleteChannelAccount removes one account (by the id query param) from a -// platform in channels.json and hot-reloads that platform so the adapter stops. +// adminDeleteChannelAccount 按 id 查询参数从某平台在 channels.json +// 中移除一个账号,并热重载该平台使适配器停止。 func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSpace(r.PathValue("platform")) id := strings.TrimSpace(r.URL.Query().Get("id")) @@ -206,10 +206,10 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques }) } -// channelConfigFromRequest merges the request's convenience fields into the -// platform-specific config map, preferring keys the panel already set explicitly. -// It never injects a key the panel left out (an empty token is left absent so the -// adapter's zero value, not an empty-string token, is used). +// channelConfigFromRequest 把请求的便捷字段合并进平台特有 config +// map,优先采用面板已显式设置的键。它绝不注入面板 +// 未给出的键(空 token 保持缺位,这样用的是 +// 适配器的零值,而不是空字符串 token)。 func channelConfigFromRequest(req channelConfigRequest) map[string]any { cfg := make(map[string]any, len(req.Config)+8) for k, v := range req.Config { @@ -253,17 +253,17 @@ func channelConfigFromRequest(req channelConfigRequest) map[string]any { return cfg } -// channelPersonaBinding resolves the three-state personaId contract of -// PUT /admin/channels/{platform}/config: +// channelPersonaBinding 解析 PUT /admin/channels/{platform}/config 的 +// 三态 personaId 契约: // -// - the key is absent (nil) → keep the account's stored binding; -// - the key is an explicit "" → clear the binding (an explicit unbind); -// - the key is a non-empty id → bind that role. +// - 键缺失(nil) → 保留该账号已存的绑定; +// - 键是显式 "" → 清除绑定(显式解绑); +// - 键是非空 id → 绑定该角色。 // -// The panel omits the key unless the owner touched the role select, so resolving -// an absent key from the stored account is what keeps a plain config edit from -// silently dropping a binding. A save that creates a new account (no stored -// account to inherit from) resolves to "no binding". +// 除非主人触碰过角色选择,面板会省略该键,因此从已存 +// 账号解析缺失键,正是让一次普通的配置编辑不会 +// 静默丢弃绑定的原因。创建新账号的保存(没有已存 +// 账号可继承)解析为「无绑定」。 func channelPersonaBinding(sent *string, stored config.ChannelAccount) string { if sent == nil { return strings.TrimSpace(stored.PersonaID) @@ -271,10 +271,10 @@ func channelPersonaBinding(sent *string, stored config.ChannelAccount) string { return strings.TrimSpace(*sent) } -// existingChannelAccount returns the stored account a save would replace (same -// platform, same id) so the write can inherit the fields the panel did not send. -// An empty id means the save creates a new account, so there is nothing to -// inherit. +// existingChannelAccount 返回一次保存将要替换的已存账号(同 +// 平台、同 id),使写操作能继承面板未发送的字段。 +// 空 id 意味着该保存创建新账号,因此没有 +// 可继承的内容。 func existingChannelAccount(list []config.ChannelAccount, id string) config.ChannelAccount { id = strings.TrimSpace(id) if id == "" { @@ -288,9 +288,9 @@ func existingChannelAccount(list []config.ChannelAccount, id string) config.Chan return config.ChannelAccount{} } -// savedAccountID returns the account id that a save produced — the caller-supplied -// one when present, otherwise the id runtimeconfig.Upsert generated (which it -// appends last for a brand-new account). +// savedAccountID 返回一次保存产出的账号 id —— 调用方提供时 +// 用它的,否则用 runtimeconfig.Upsert 生成的 id(对全新账号 +// 它会追加在末尾)。 func savedAccountID(list []config.ChannelAccount, supplied string) string { if supplied != "" { return supplied @@ -301,7 +301,7 @@ func savedAccountID(list []config.ChannelAccount, supplied string) string { return list[len(list)-1].ID } -// adminChannelAccountOf maps a (masked) account to its admin wire shape. +// adminChannelAccountOf 把一个(已脱敏的)账号映射为其管理侧线上结构。 func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount { return AdminChannelAccount{ ID: acct.ID, @@ -317,9 +317,9 @@ func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount { } } -// channelBasePlatform returns the bare platform of a (possibly multi-account) -// adapter name like "telegram:conf0" → "telegram", so config lookup groups every -// account of a platform together. +// channelBasePlatform 返回形如 "telegram:conf0" → "telegram" 的 +// (可能多账号的)适配器名的裸平台,使配置查找把某平台的 +// 所有账号归到一组。 func channelBasePlatform(name string) string { if base, _, ok := strings.Cut(name, ":"); ok { return base @@ -327,9 +327,9 @@ func channelBasePlatform(name string) string { return name } -// configuredAccount reports whether an account carries a meaningful config (a -// panel-created id or a real token/address), so the admin list skips the blank -// placeholder accounts the env fallback synthesizes for unconfigured platforms. +// configuredAccount 报告某账号是否携带有效配置(面板创建的 +// id 或真实 token/地址),使管理列表跳过环境变量回退 +// 为未配置平台合成的空白占位账号。 func configuredAccount(a config.ChannelAccount) bool { if strings.TrimSpace(a.ID) != "" { return true diff --git a/internal/services/gateway/admin_write_channel_config_test.go b/internal/services/gateway/admin_write_channel_config_test.go index b4d881a..d49b821 100644 --- a/internal/services/gateway/admin_write_channel_config_test.go +++ b/internal/services/gateway/admin_write_channel_config_test.go @@ -16,8 +16,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) -// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a -// real temp channels.json path, recording Reload calls. +// stubChannelConfigStore 是一个 ChannelConfigStore,后端是内存 map 与 +// 真实临时 channels.json 路径,并记录 Reload 调用。 type stubChannelConfigStore struct { path string accounts map[string][]config.ChannelAccount @@ -37,9 +37,9 @@ func (s *stubChannelConfigStore) Reload(platform string) error { return s.reloadErr } -// stubNamedChannel is an sdk.Channel with a caller-chosen adapter name, so a test -// can register a multi-account adapter (kook:3fc8) distinct from its bare-platorm -// sibling and observe which entries survive a config change. +// stubNamedChannel 是一个 sdk.Channel,适配器名由调用方选定, +// 使测试能注册一个多账号适配器(kook:3fc8),与它的裸平台 +// 兄弟区分开,并观察配置变更后哪些条目存活。 type stubNamedChannel struct { name string } @@ -80,7 +80,7 @@ func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) { t.Fatalf("save res = %+v", res) } - // The account landed in channels.json with the real token and the black-list. + // 该账号带着真实 token 与黑名单落到了 channels.json。 accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn != nil { t.Fatalf("load channels warn = %v", warn) @@ -96,12 +96,12 @@ func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) { t.Fatalf("groupMsgPerMin = %d, want 3", kook[0].GroupMsgPerMin) } - // Reload was triggered for the saved platform. + // 已为保存的平台触发了 Reload。 if len(store.reloaded) != 1 || store.reloaded[0] != "kook" { t.Fatalf("reloaded = %v, want [kook]", store.reloaded) } - // GET /admin/channels reflects the account and never leaks the raw token. + // GET /admin/channels 反映了该账号,且绝不泄露原始 token。 gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) if gResp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody) @@ -124,7 +124,7 @@ func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) { if resp.StatusCode != http.StatusBadRequest { t.Fatalf("blank config status = %d, want 400 (body=%s)", resp.StatusCode, body) } - // No reload should have been triggered. + // 不应触发任何重载。 if len(store.reloaded) != 0 { t.Fatalf("reloaded = %v, want none", store.reloaded) } @@ -132,7 +132,7 @@ func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) { func TestAdminSaveChannelConfigUnwired500(t *testing.T) { t.Parallel() - _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no channel config store + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无渠道配置存储 tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config", map[string]any{"token": "x"}, tok) @@ -157,7 +157,7 @@ func TestAdminDeleteChannelConfig(t *testing.T) { if len(store.reloaded) != 1 || store.reloaded[0] != "kook" { t.Fatalf("reloaded = %v, want [kook]", store.reloaded) } - // The file is rewritten without the account; the platform key is dropped. + // 文件被重写且不含该账号;平台键被删除。 accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn == nil { t.Fatalf("expected a warn after deleting the last account (empty map is invalid)") @@ -167,9 +167,9 @@ func TestAdminDeleteChannelConfig(t *testing.T) { } } -// adminChannelsList is a small read helper that issues GET /admin/channels and -// decodes the data array, returning the raw body too so a caller can assert that a -// secret never leaked. It fails the test on any transport/parse error. +// adminChannelsList 是一个小的读辅助函数:发起 GET /admin/channels, +// 解码 data 数组,同时返回原始响应体,使调用方能断言 +// 秘密从未泄露。任何传输/解析错误都会让测试失败。 func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]ChannelSummary, []byte) { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token) @@ -183,10 +183,10 @@ func adminChannelsList(t *testing.T, ts *httptest.Server, token string) ([]Chann return items, body } -// TestAdminDeleteChannelAccountRemovesFromList is the regression for the -// stale-adapter bug: deleting an account removes it from channels.json, but the -// adapter stays registered in caps. The list must still drop it (channels.json is -// the source of truth), so a deleted account never reappears on refresh. +// TestAdminDeleteChannelAccountRemovesFromList 是陈旧适配器 bug 的 +// 回归:删除账号会把它从 channels.json 移除,但适配器 +// 仍注册在 caps 中。列表必须仍然丢弃它(channels.json 是 +// 唯一事实来源),因此已删除账号绝不在刷新时复现。 func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) { t.Parallel() caps := capreg.New() @@ -202,7 +202,7 @@ func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Before the delete the account shows up (aggregated into a single "kook" row). + // 删除前该账号会出现(聚合进单个 "kook" 行)。 items, _ := adminChannelsList(t, ts, tok) if len(items) != 1 || items[0].Name != "kook" || len(items[0].Accounts) != 1 || items[0].Accounts[0].ID != "3fc8" { t.Fatalf("before delete items = %+v, want one kook row with account 3fc8", items) @@ -216,16 +216,16 @@ func TestAdminDeleteChannelAccountRemovesFromList(t *testing.T) { t.Fatalf("reloaded = %v, want [kook]", store.reloaded) } - // The stale adapter remains in caps (the bridge reload does not touch the - // registry), yet the deleted account must not reappear in the list. + // 陈旧适配器仍留在 caps 中(bridge 重载不触碰注册表), + // 但已删除的账号不得在列表中复现。 if items, _ := adminChannelsList(t, ts, tok); len(items) != 0 { t.Fatalf("after delete items = %+v, want none (deleted account must vanish)", items) } } -// TestAdminDeleteChannelAccountKeepsSibling verifies that deleting one account of a -// multi-account platform drops only that account: the sibling adapter and its -// account remain, and the deleted one disappears. +// TestAdminDeleteChannelAccountKeepsSibling 验证删除多账号平台的 +// 一个账号只丢弃该账号:兄弟适配器及其 +// 账号保留,被删除的那个消失。 func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) { t.Parallel() caps := capreg.New() @@ -260,10 +260,10 @@ func TestAdminDeleteChannelAccountKeepsSibling(t *testing.T) { } } -// TestAdminSaveChannelConfigListMirrorsChannelsJSON verifies the save path: after -// persisting an account the list reflects it, while a stale caps adapter for a -// different account id (here kook:oldid, never in channels.json) is dropped. The -// account's token is never leaked. +// TestAdminSaveChannelConfigListMirrorsChannelsJSON 验证保存路径: +// 持久化账号后列表会反映它,而针对不同账号 id(这里是 +// kook:oldid,从未出现在 channels.json)的陈旧 caps 适配器被丢弃。该 +// 账号的 token 从不泄露。 func TestAdminSaveChannelConfigListMirrorsChannelsJSON(t *testing.T) { t.Parallel() caps := capreg.New() diff --git a/internal/services/gateway/admin_write_iot_automations.go b/internal/services/gateway/admin_write_iot_automations.go index f787691..66c8b36 100644 --- a/internal/services/gateway/admin_write_iot_automations.go +++ b/internal/services/gateway/admin_write_iot_automations.go @@ -11,19 +11,19 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations" ) -// IoT device-control and automation-rule admin writes (owner-only, audited). -// Device control performs a real control on the owning provider; the others -// answer honestly when no scenario/rule store is wired. +// IoT 设备控制与自动化规则管理写操作(仅 owner,带审计)。 +// 设备控制会在所属 provider 上执行真实控制;其余在没有 +// 接线场景/规则存储时诚实作答。 -// iotControlRequest is the POST /api/v1/admin/iot/{id}/control body. +// iotControlRequest 是 POST /api/v1/admin/iot/{id}/control 的请求体。 type iotControlRequest struct { Action string `json:"action"` Params map[string]any `json:"params"` } -// adminIoTControl sends a control instruction to a device through the IoT -// manager, which applies it on the owning provider and publishes a device_update -// event. A real action only when the deployment has a device provider. +// adminIoTControl 经 IoT 管理器向设备发送控制指令,管理器 +// 在所属 provider 上施加它并发布 device_update +// 事件。只有部署了设备 provider 时才是真实动作。 func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req iotControlRequest @@ -56,10 +56,10 @@ func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, iotDeviceSummary{ID: dev.ID, Name: dev.Name, Type: dev.Type, State: dev.State, Online: dev.Online}) } -// adminIoTScenario answers honestly: the IoT subsystem owns device -// list/control/status only (the Provider interface has no scenario model or -// store), so there is nothing to persist a scenario into. It reports the missing -// store rather than fabricating one. +// adminIoTScenario 诚实作答:IoT 子系统只管设备 +// 列表/控制/状态(Provider 接口没有场景模型或 +// 存储),因此没有可持久化场景的地方。它报告缺失的 +// 存储,而不是编造一个。 func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "iot.scenario", "error") writeError(w, &apperr.AppError{ @@ -69,9 +69,9 @@ func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) { }) } -// adminIoTRule answers honestly: no IoT automation-rule model or store exists -// (the generic automations service runs cron/event rules, not device-state -// conditions with device actions), so a rule cannot be persisted or executed. +// adminIoTRule 诚实作答:不存在 IoT 自动化规则模型或存储 +// (通用 automations 服务运行 cron/event 规则,而不是带设备动作的 +// 设备状态条件),因此规则无法被持久化或执行。 func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "iot.rule", "error") writeError(w, &apperr.AppError{ @@ -81,7 +81,7 @@ func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) { }) } -// mapIotError maps iot sentinel errors to HTTP errors. +// mapIotError 把 iot 哨兵错误映射为 HTTP 错误。 func mapIotError(err error) *apperr.AppError { switch { case errors.Is(err, iot.ErrDeviceNotFound): @@ -97,15 +97,15 @@ func mapIotError(err error) *apperr.AppError { } } -// automationCreateRequest is the POST /api/v1/admin/automations body. +// automationCreateRequest 是 POST /api/v1/admin/automations 的请求体。 type automationCreateRequest struct { Name string `json:"name"` Kind string `json:"kind"` // cron | event Expression string `json:"expression"` } -// adminCreateAutomation registers a real cron/event rule whose action records its -// fire. Invalid cron expressions or malformed rules are answered honestly. +// adminCreateAutomation 注册一条真实的 cron/event 规则,其动作会记录 +// 触发。无效的 cron 表达式或畸形的规则会被诚实作答。 func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) { var req automationCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -141,7 +141,7 @@ func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name, "kind": req.Kind}) } -// adminRunAutomation manually triggers a registered rule's action once. +// adminRunAutomation 手动触发一次已注册规则的动作。 func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") auto, ok := s.automations.(*automations.Service) @@ -164,7 +164,7 @@ func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"id": id, "output": out}) } -// adminDeleteAutomation removes a registered rule (cron or event) by id. +// adminDeleteAutomation 按 id 移除一条已注册规则(cron 或 event)。 func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") auto, ok := s.automations.(*automations.Service) diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index 7a682b8..e61a14d 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -21,27 +21,27 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink" ) -// LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM -// test performs a real model call; provider "add" records a process-lifetime -// entry. Channel open/close drive the real adapter lifecycle; the identity test -// is the structural actor × whitelist rule, never prompt text. +// LLM provider 与渠道生命周期的管理写操作(仅 owner,带审计)。LLM +// 测试会发起真实模型调用;provider「add」记录一条 +// 进程生命周期内的条目。渠道开/关驱动真实适配器生命周期;身份 +// 测试走结构化的 actor × 白名单规则,绝不是提示词文本。 const ( - // ownerProfilePath is the owner whitelist loaded for the structural identity - // test. It mirrors the path wire.go uses for owner recognition. + // ownerProfilePath 是为结构化身份测试加载的主人白名单。 + // 它与 wire.go 用于主人识别的路径一致。 ownerProfilePath = "configs/users/owner.yaml" - // llmTestTimeout bounds a single LLM probe request. + // llmTestTimeout 限定单次 LLM 探测请求。 llmTestTimeout = 30 * time.Second - // llmProbePrompt is the minimal prompt used to verify a model responds. + // llmProbePrompt 是用于验证模型能应答的最小提示词。 llmProbePrompt = "Reply with the single word: OK" ) -// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a -// provider group. Every field is a pointer so the handler can tell "absent" -// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit -// from the panel must never wipe a field it did not touch. Name is a client-side -// display label (not persisted — the models.json schema carries id/provider/ -// baseUrl/apiKey/model/fallback/multimodal/maxTokens). +// llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新 +// provider 分组。每个字段都是指针,使 handler 能区分「缺失」 +// (保留已存值)与「显式零值」(覆盖):来自面板的局部 +// 编辑绝不清除它未触碰的字段。Name 是客户端侧 +// 显示标签(不持久化 —— models.json 结构含 id/provider/ +// baseUrl/apiKey/model/fallback/multimodal/maxTokens)。 type llmProviderRequest struct { ID *string `json:"id"` Name *string `json:"name"` @@ -51,16 +51,16 @@ type llmProviderRequest struct { Model *string `json:"model"` Multimodal *bool `json:"multimodal"` Fallback *string `json:"fallback"` - // MaxTokens is the group's own token budget. Absent preserves the stored - // budget (0 = unset for a new group). Negative is rejected. + // MaxTokens 是该分组自身的 token 预算。缺失则保留已存 + // 预算(新分组 0 = 未设置)。负值被拒绝。 MaxTokens *int `json:"maxTokens"` } -// adminAddLlmProvider saves a provider group to models.json (read-modify-write, -// promoting it to the active group) and hot-reloads the running LLM stack so the -// new provider/model takes effect without a restart (design/04 §3.1). A field the -// client omits keeps its stored value, so a partial edit never resets a budget or -// key. The API key is persisted to the gitignored file but never returned/logged. +// adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write, +// 并将其提升为活动分组),再热重载运行中的 LLM 栈,使 +// 新的 provider/model 无需重启即生效(design/04 §3.1)。客户端省略的 +// 字段保持其已存值,因此局部编辑绝不复位预算或 +// key。API key 被持久化进被 gitignore 的文件,但绝不返回/记日志。 func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) { var req llmProviderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -83,8 +83,8 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err)) return } - // Promote the saved group to the active (index 0) slot, then persist and - // hot-reload so Current() reflects the new provider/model immediately. + // 把已保存的分组提升到活动(下标 0)槽位,然后持久化并 + // 热重载,使 Current() 立即反映新的 provider/model。 groups := runtimeconfig.Activate(s.llm.Groups(), group) if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil { s.auditClaims(r, "llm.addProvider", "error") @@ -117,9 +117,9 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) { }) } -// mergeLlmProvider applies a partial provider patch over the stored groups: a -// field present in the request wins, an absent field keeps the stored value (or -// its default for a new group). It returns a validated group. +// mergeLlmProvider 把局部 provider 补丁施加到已存分组之上: +// 请求中出现的字段胜出,缺失字段保留已存值(新分组 +// 则用其默认值)。它返回经过校验的分组。 func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) { id := "" if req.ID != nil { @@ -166,7 +166,7 @@ func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (run return g, nil } -// llmTestRequest is the POST /api/v1/admin/llm/test body. +// llmTestRequest 是 POST /api/v1/admin/llm/test 的请求体。 type llmTestRequest struct { Provider string `json:"provider"` BaseURL string `json:"baseUrl"` @@ -175,8 +175,8 @@ type llmTestRequest struct { Prompt string `json:"prompt"` } -// adminTestLlm performs a real completion against the supplied provider/model -// endpoint and reports the result — a genuine round-trip, not a placeholder. +// adminTestLlm 针对提供的 provider/model 端点执行一次真实补全 +// 并报告结果 —— 真实的往返,而不是占位。 func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) { var req llmTestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -217,12 +217,12 @@ func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens}) } -// channelActionRequest is the POST /api/v1/admin/channels/open|close body. +// channelActionRequest 是 POST /api/v1/admin/channels/open|close 的请求体。 type channelActionRequest struct { Name string `json:"name"` } -// adminChannelOpen connects a registered channel adapter (real lifecycle call). +// adminChannelOpen 连接一个已注册渠道适配器(真实的生命周期调用)。 func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { name, ok := s.channelName(r, w, "channel.open") if !ok { @@ -236,17 +236,17 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { } if err := ch.Connect(r.Context()); err != nil { s.auditClaims(r, "channel.open", "error") - // A state a plain reconnect cannot fix must not read as a generic failure: - // the weixin-iLink bot token was rejected by iLink (errcode=-14), so the - // owner needs a fresh scan-code login. Answer 409 with that instruction - // instead of pretending the channel opened (the panel marks it connected). + // 单纯重连修不好的状态不得读作通用失败: + // weixin-iLink 机器人 token 被 iLink 拒绝(errcode=-14),因此 + // 主人需要一次新的扫码登录。用该指引返回 409, + // 而不是假装渠道已打开(面板会把它标记为已连接)。 if errors.Is(err, weixin_ilink.ErrSessionRejected) { writeError(w, apperr.Wrap(apperr.ErrConflict, "session_rejected", "该渠道会话已被 iLink 拒绝(errcode=-14):请用「扫码登录」重新绑定后再连接")) return } - // weixin-iLink with no token yet must not hang nor read as a credential - // error: the panel routes the owner to the out-of-band 扫码登录 flow. + // 尚无 token 的 weixin-iLink 既不能挂起,也不能读作凭据 + // 错误:面板会把主人导向带外的扫码登录流程。 if errors.Is(err, weixin_ilink.ErrNeedsLogin) { writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接")) return @@ -259,7 +259,7 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true}) } -// adminChannelClose disconnects a registered channel adapter (real lifecycle call). +// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。 func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) { name, ok := s.channelName(r, w, "channel.close") if !ok { @@ -281,17 +281,17 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false}) } -// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel -// account id plus the conversation shape, resolved by the structural actor rule. +// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道 +// 账号 id 加上对话形态,由结构化 actor 规则解析。 type channelTestRequest struct { Platform string `json:"platform"` AccountID string `json:"accountId"` IsGroup bool `json:"isGroup"` } -// adminChannelTest runs the structural identity rule (actor metadata × owner -// whitelist), never prompt text. It loads the owner whitelist from disk so a -// mismatch degrades to the conservative default (member/stranger). +// adminChannelTest 运行结构化身份规则(actor 元数据 × 主人 +// 白名单),绝不是提示词文本。它从磁盘加载主人白名单, +// 因此不匹配时退化为保守默认(member/stranger)。 func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) { var req channelTestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -327,8 +327,8 @@ func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) { }) } -// channelName decodes the {name} body for open/close routes, writing a 400 on -// failure. It reports uniqueness of the decode via ok. +// channelName 为 open/close 路由解码 {name} 请求体,失败时写出 400。 +// 它通过 ok 报告解码的唯一性。 func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) { var req channelActionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -343,11 +343,11 @@ func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) return req.Name, true } -// findChannel returns the registered channel adapter named name, if any. An exact -// adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an -// exact name still resolves to that platform's first registered account, so the -// panel/CLI "connect kook" reaches the token-bearing account even when it is named -// "kook:conf0" (the empty-token bare placeholder is never registered). +// findChannel 返回名为 name 的已注册渠道适配器(若有)。精确的 +// 适配器名("kook:conf0")胜出;非精确名的裸平台名("kook") +// 仍会解析到该平台首个已注册账号,因此面板/CLI 的 +// "connect kook" 能抵达携带 token 的账号,即使它名为 +// "kook:conf0"(空 token 的裸占位符从不注册)。 func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) { if caps == nil { return nil, false @@ -371,8 +371,8 @@ func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) { return nil, false } -// loadOwnerProfile loads the owner whitelist from disk, returning nil on any -// failure so the identity test degrades to the conservative deny. +// loadOwnerProfile 从磁盘加载主人白名单,任何失败都返回 +// nil,因此身份测试退化为保守的拒绝。 func loadOwnerProfile() *user.OwnerProfile { p, err := user.LoadOwnerProfile(ownerProfilePath) if err != nil { @@ -381,7 +381,7 @@ func loadOwnerProfile() *user.OwnerProfile { return p } -// describeIdentity renders the structural verdict for the identity test. +// describeIdentity 为身份测试渲染结构化判定结果。 func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string { switch { case a.IsGroupChat(): diff --git a/internal/services/gateway/admin_write_memory_rag.go b/internal/services/gateway/admin_write_memory_rag.go index f68141b..67b6002 100644 --- a/internal/services/gateway/admin_write_memory_rag.go +++ b/internal/services/gateway/admin_write_memory_rag.go @@ -11,22 +11,22 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" ) -// Admin memory + knowledge-base write endpoints (owner-only, audited). The memory -// add writes a real item into the memory store (owner-private, safe), and the RAG -// index imports a document into the project index so it becomes searchable. Both -// degrade to a clear 501 when their backing store is not wired. +// 管理侧记忆 + 知识库写端点(仅 owner,带审计)。添加记忆 +// 会把真实条目写入记忆存储(owner 私有,安全),而 RAG 索引 +// 把文档导入项目索引使其可被检索。二者在各自 +// 后端存储未接线时都退化为明确的 501。 -// memoryAddRequest is the POST /api/v1/admin/memories body. +// memoryAddRequest 是 POST /api/v1/admin/memories 的请求体。 type memoryAddRequest struct { PersonaID string `json:"personaId"` Content string `json:"content"` Scope string `json:"scope"` } -// adminAddMemory writes a single memory item for the owner. It is the manual -// "添加记忆" path: the item is scoped to the persona (default), so it behaves like -// the "记住 X" path the loop already writes. Scope may be omitted (default -// persona) or one of user|persona|project; an invalid non-empty scope is rejected. +// adminAddMemory 为主人写入单条记忆。它是手动的 +// 「添加记忆」路径:条目默认作用于 persona,因此行为与 +// loop 已在写的「记住 X」路径一致。Scope 可省略(默认 +// persona),或为 user|persona|project 之一;非法非空 scope 会被拒绝。 func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) { var req memoryAddRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -70,7 +70,7 @@ func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]any{"id": stored.ID, "scope": string(stored.Scope), "text": stored.Content}) } -// isValidMemoryScope reports whether raw is one of the defined memory scopes. +// isValidMemoryScope 报告 raw 是否是已定义的记忆作用域之一。 func isValidMemoryScope(raw string) bool { switch memory.Scope(strings.TrimSpace(raw)) { case memory.ScopeUser, memory.ScopePersona, memory.ScopeProject: @@ -80,16 +80,16 @@ func isValidMemoryScope(raw string) bool { } } -// ragIndexRequest is the POST /api/v1/admin/rag body. +// ragIndexRequest 是 POST /api/v1/admin/rag 的请求体。 type ragIndexRequest struct { Name string `json:"name"` Desc string `json:"desc"` Content string `json:"content"` } -// adminIndexRag imports one knowledge document: it hands the name/description and -// content to the knowledge store, which chunks it and indexes it into the project -// index so the document becomes keyword/searchable. It is the admin "导入文档" path. +// adminIndexRag 导入一份知识文档:它把 name/description 与 +// content 交给知识存储,后者切分它并索引进项目 +// 索引,使文档可被关键词检索。它是管理侧的「导入文档」路径。 func (s *Server) adminIndexRag(w http.ResponseWriter, r *http.Request) { var req ragIndexRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -127,15 +127,15 @@ func (s *Server) adminIndexRag(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]any{"name": name}) } -// ragDeleteRequest is the POST /api/v1/admin/rag/delete body. +// ragDeleteRequest 是 POST /api/v1/admin/rag/delete 的请求体。 type ragDeleteRequest struct { ID string `json:"id"` } -// adminDeleteRag removes a knowledge document by id: the store deletes the -// project-index entry and cascades its chapters. A missing document is answered -// with 404, a store that cannot delete with a clear 501, and any other failure -// with 500 — never a fabricated success. The route is owner-gated and audited. +// adminDeleteRag 按 id 移除一份知识文档:存储删除 +// 项目索引条目并级联其章节。文档缺失时返回 404, +// 存储无法删除时返回明确的 501,其他失败 +// 返回 500 —— 绝不编造成功。该路由受 owner 门控且带审计。 func (s *Server) adminDeleteRag(w http.ResponseWriter, r *http.Request) { if s.rag == nil { s.auditClaims(r, "rag.delete", "error") diff --git a/internal/services/gateway/admin_write_persona_thinking_test.go b/internal/services/gateway/admin_write_persona_thinking_test.go index 91d7f06..f5a1a9c 100644 --- a/internal/services/gateway/admin_write_persona_thinking_test.go +++ b/internal/services/gateway/admin_write_persona_thinking_test.go @@ -9,11 +9,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" ) -// TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML pins the admin persona -// write path for the optional `thinking` block: the hand-written DTO maps into -// the loader struct, yaml.Marshal emits the documented keys, and the reload of -// that YAML yields the same values — i.e. the field really round-trips instead of -// being dropped by an un-whitelisted DTO. +// TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML 钉住管理侧人格 +// 写路径对可选 `thinking` 块的处理:手写 DTO 映射进 +// loader 结构体,yaml.Marshal 输出文档化的键,重载 +// 该 YAML 得到相同的值 —— 即该字段真的往返成功, +// 而不是被未列入白名单的 DTO 丢弃。 func TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML(t *testing.T) { t.Parallel() @@ -42,9 +42,9 @@ func TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML(t *testing.T) { } } -// TestPersonaUpsertRequest_ThinkingInvalidRejected asserts an invalid thinking -// block is rejected through the same validation the loader uses, so the admin API -// cannot persist a mode the runtime would refuse to load. +// TestPersonaUpsertRequest_ThinkingInvalidRejected 断言非法的 thinking +// 块会经由 loader 所用的同一套校验被拒绝,因此管理 API +// 无法持久化运行时拒绝加载的模式。 func TestPersonaUpsertRequest_ThinkingInvalidRejected(t *testing.T) { t.Parallel() @@ -60,9 +60,9 @@ func TestPersonaUpsertRequest_ThinkingInvalidRejected(t *testing.T) { } } -// TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey asserts a request without a -// thinking block produces none in the YAML, so no existing persona file gains a -// noise section. +// TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey 断言不带 thinking +// 块的请求在 YAML 中不产出它,因此既有人格文件不会多出 +// 一段噪声。 func TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey(t *testing.T) { t.Parallel() @@ -75,9 +75,9 @@ func TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey(t *testing.T) { } } -// TestApplyThinkingEdit_PreservesOnOmission is the non-destructive update rule: a -// client that omits `thinking` keeps the stored block, an explicit block replaces -// it, and an all-default block clears it. +// TestApplyThinkingEdit_PreservesOnOmission 是非破坏性更新规则: +// 省略 `thinking` 的客户端保留已存块,显式给出块则替换 +// 它,全默认值的块清除它。 func TestApplyThinkingEdit_PreservesOnOmission(t *testing.T) { t.Parallel() diff --git a/internal/services/gateway/admin_write_personas_users.go b/internal/services/gateway/admin_write_personas_users.go index 92282fb..32ff784 100644 --- a/internal/services/gateway/admin_write_personas_users.go +++ b/internal/services/gateway/admin_write_personas_users.go @@ -16,14 +16,14 @@ import ( personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" ) -// persona-to-identity and API-key admin writes (owner-only, audited). Persona -// writes register into the persona registry (real runtime effect) and persist a -// YAML file so they survive a restart; API keys are minted/revoked in the -// auth.APIKey store. +// 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格 +// 写操作注册进人格注册表(真实的运行时效果),并持久化 +// 一个 YAML 文件使其在重启后存活;API key 在 +// auth.APIKey 存储中铸造/吊销。 const personaDir = "configs/personas" -// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body. +// personaUpsertRequest 是 POST/PUT /api/v1/admin/personas 的请求体。 type personaUpsertRequest struct { ID string `json:"id"` Name string `json:"name"` @@ -38,14 +38,14 @@ type personaUpsertRequest struct { Modes []string `json:"modes"` Mode string `json:"mode"` MemoryNS string `json:"memoryNamespace"` - // Thinking is the optional reasoning/presentation block (design/01 §2.1). It - // is persisted into the persona YAML as-is; omitting it on an update keeps the - // stored block (see adminUpdatePersona). + // Thinking 是可选的推理/呈现块(design/01 §2.1)。它 + // 原样持久化进人格 YAML;更新时省略它会保留 + // 已存块(见 adminUpdatePersona)。 Thinking *personaThinkingRequest `json:"thinking,omitempty"` } -// personaThinkingRequest is the wire form of the persona `thinking` section. The -// pointers/naming mirror the YAML so a panel can round-trip it verbatim. +// personaThinkingRequest 是人格 `thinking` 段的线上形式。 +// 其指针/命名与 YAML 一致,使面板能原样往返。 type personaThinkingRequest struct { BudgetTokens int `json:"budgetTokens,omitempty"` Effort string `json:"effort,omitempty"` @@ -53,8 +53,8 @@ type personaThinkingRequest struct { Mode string `json:"mode,omitempty"` } -// adminCreatePersona registers a new persona in the registry (real runtime -// effect) and persists it to the persona YAML library so it survives a restart. +// adminCreatePersona 在注册表中注册一个新人格(真实的运行时 +// 效果),并持久化进人格 YAML 库使其在重启后存活。 func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) { var req personaUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -103,8 +103,8 @@ func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, s.personaSummaryOf(p, characterFromRequest(req))) } -// adminUpdatePersona edits an existing persona, persisting the YAML and -// replacing the in-memory persona/character. +// adminUpdatePersona 编辑既有人格,持久化 YAML 并 +// 替换内存中的人格/角色。 func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req personaUpsertRequest @@ -135,10 +135,10 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err)) return } - // A client that does not know the optional `thinking` block (an older panel, a - // script) omits it; keep the stored one instead of silently wiping a - // hand-edited config. Sending the object replaces it, and sending it with all - // fields default clears it. + // 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它; + // 应保留已存的那个,而不是静默清掉手工编辑的 + // 配置。发送该对象则替换它,全字段为默认值 + // 时清除它。 p.Thinking = applyThinkingEdit(req.Thinking, existingPersona(reg, id)) if err := savePersonaYAML(p); err != nil { s.auditClaims(r, "persona.update", "error") @@ -160,11 +160,11 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.personaSummaryOf(p, characterFromRequest(req))) } -// adminCreateUser answers honestly: there is no persistent user table and no -// user-authenticated login to back one (the deployment is a single admin -// account from .env plus runtime API keys; design/11 is single-user first), so -// user creation would be a row nobody could log in with. It reports the missing -// store instead of fabricating a user. +// adminCreateUser 诚实作答:既没有持久化用户表,也没有 +// 可供支撑的用户认证登录(部署形态是来自 .env 的单个管理 +// 账号加运行时 API key;design/11 是单用户优先),因此 +// 创建用户会是一行没人能登录的记录。它报告缺失的 +// 存储,而不是编造一个用户。 func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "user.create", "error") writeError(w, &apperr.AppError{ @@ -174,14 +174,14 @@ func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) { }) } -// apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body. +// apiKeyIssueRequest 是 POST /api/v1/admin/users/apikey 的请求体。 type apiKeyIssueRequest struct { Role string `json:"role"` } -// adminIssueApiKey mints a real API key bound to a role and returns the plaintext -// once (the only time it is visible). The id in the response is the masked form -// the admin list shows, so it can be used to revoke the key later. +// adminIssueApiKey 铸造一个绑定到某角色的真实 API key,并返回一次 +// 明文(唯一可见的一次)。响应中的 id 是管理列表所展示的 +// 脱敏形式,因此之后可用它吊销该 key。 func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) { var req apiKeyIssueRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -210,8 +210,8 @@ func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)}) } -// adminRevokeApiKey revokes a key by its masked id (the identifier the admin list -// returns). It reports not-found when no key carries that mask. +// adminRevokeApiKey 按脱敏 id(管理列表返回的标识)吊销一个 +// key。没有 key 携带该掩码时报告 not-found。 func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") keys, ok := s.users.(*auth.APIKey) @@ -229,8 +229,8 @@ func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true}) } -// personaFromRequest builds a loader.Persona from the upsert request, filling a -// derived system prompt/characterRef when omitted so a minimal create validates. +// personaFromRequest 从 upsert 请求构建 loader.Persona,在省略时 +// 填入派生的 system prompt/characterRef,使最小创建也能通过校验。 func personaFromRequest(req personaUpsertRequest) *loader.Persona { systemPrompt := strings.TrimSpace(req.SystemPrompt) if systemPrompt == "" { @@ -255,8 +255,8 @@ func personaFromRequest(req personaUpsertRequest) *loader.Persona { } } -// thinkingFromRequest maps the wire `thinking` block onto the persona field; a -// missing block yields the zero value, which yaml.Marshal omits entirely. +// thinkingFromRequest 把线上 `thinking` 块映射到人格字段; +// 块缺失得到零值,yaml.Marshal 会完全省略它。 func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams { if req == nil { return loader.ThinkingParams{} @@ -269,7 +269,7 @@ func thinkingFromRequest(req *personaThinkingRequest) loader.ThinkingParams { } } -// existingPersona returns the stored persona for id, or nil when it is absent. +// existingPersona 返回 id 对应的已存人格,不存在时为 nil。 func existingPersona(reg *personareg.Registry, id string) *loader.Persona { if p, ok := reg.Persona(id); ok { return p @@ -277,10 +277,10 @@ func existingPersona(reg *personareg.Registry, id string) *loader.Persona { return nil } -// applyThinkingEdit returns the `thinking` block an update should persist: the -// request's own block when it carries one, otherwise the stored persona's block — -// so a client that does not know the optional field can never wipe it. An update -// that sends the object replaces the block outright (all-default fields clear it). +// applyThinkingEdit 返回更新应持久化的 `thinking` 块:请求自带 +// 块时用请求的,否则用已存人格的块 —— +// 因此不认识该可选字段的客户端绝不可能清掉它。发送 +// 该对象的更新会直接替换整块(全默认字段则清除它)。 func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) loader.ThinkingParams { if req != nil { return thinkingFromRequest(req) @@ -291,7 +291,7 @@ func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) lo return loader.ThinkingParams{} } -// characterFromRequest builds the registry.Character bound to the persona. +// characterFromRequest 构建绑定到该人格的 registry.Character。 func characterFromRequest(req personaUpsertRequest) *personareg.Character { modes := append([]string(nil), req.Modes...) if len(modes) == 0 && req.Mode != "" { @@ -300,10 +300,10 @@ func characterFromRequest(req personaUpsertRequest) *personareg.Character { return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes} } -// personaSummaryOf maps a persona+character to its wire summary. It is the same -// shape the admin list returns — the role-level mood plus the `thinking` block — -// so a write response and a subsequent read describe the persona identically -// (a client can trust either for backfill). +// personaSummaryOf 把人格+角色映射为其线上摘要。它与 +// 管理列表返回的形状相同 —— 角色级 mood 加上 `thinking` 块 —— +// 因此写响应与随后的读对人格的描述完全一致 +// (客户端可信任任一方用于回填)。 func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary { return PersonaSummary{ ID: p.ID, @@ -317,9 +317,9 @@ func (s *Server) personaSummaryOf(p *loader.Persona, c *personareg.Character) Pe } } -// savePersonaYAML persists p to the persona library as .yaml so it survives -// a restart (wire.go loads this directory). The id is sanitized so user input -// cannot traverse out of the library directory. +// savePersonaYAML 把 p 以 .yaml 持久化进人格库,使其在 +// 重启后存活(wire.go 加载该目录)。id 会被净化,使用户 +// 输入无法穿越出库目录。 func savePersonaYAML(p *loader.Persona) error { data, err := yaml.Marshal(p) if err != nil { @@ -335,8 +335,8 @@ func savePersonaYAML(p *loader.Persona) error { return nil } -// sanitizeID keeps only URL/path-safe characters so a persona id cannot escape -// the persona directory. +// sanitizeID 只保留 URL/路径安全字符,使人格 id 不能 +// 逃出人格目录。 func sanitizeID(id string) string { var b strings.Builder for _, r := range id { diff --git a/internal/services/gateway/admin_write_plugins_memory_sessions.go b/internal/services/gateway/admin_write_plugins_memory_sessions.go index 6b0498d..28393b7 100644 --- a/internal/services/gateway/admin_write_plugins_memory_sessions.go +++ b/internal/services/gateway/admin_write_plugins_memory_sessions.go @@ -11,18 +11,18 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" ) -// Memory retrieval and session export admin writes (owner-only, audited). Memory -// search runs a real retrieval; session export returns the session metadata. The -// plugin install/uninstall writes live in admin_plugins.go. +// 记忆检索与会话导出的管理写操作(仅 owner,带审计)。记忆 +// 搜索执行真实检索;会话导出返回会话元数据。 +// 插件安装/卸载写操作位于 admin_plugins.go。 -// memorySearchRequest is the POST /api/v1/admin/memories/search body. +// memorySearchRequest 是 POST /api/v1/admin/memories/search 的请求体。 type memorySearchRequest struct { Query string `json:"query"` PersonaID string `json:"personaId"` TopK int `json:"topK"` } -// memorySearchHit is one admin memory retrieval hit. +// memorySearchHit 是一条管理侧记忆检索命中。 type memorySearchHit struct { ID string `json:"id"` Scope string `json:"scope"` @@ -30,10 +30,10 @@ type memorySearchHit struct { Score float64 `json:"score"` } -// adminMemorySearch runs a real retrieval over the memory store. The retrieval -// ranks by keyword relevance × decay; the semantic/vector path requires the -// composition root to inject the vector index, so this answers on the keyword -// path and reports semantic=false honestly. +// adminMemorySearch 在记忆存储上执行真实检索。检索按 +// 关键词相关度 × 衰减排序;语义/向量路径需要 +// 组合根注入向量索引,因此这里走关键词路径 +// 并诚实报告 semantic=false。 func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) { var req memorySearchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -53,10 +53,10 @@ func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) { } personaID := strings.TrimSpace(req.PersonaID) if personaID == "" { - // Fall back to the library's default role rather than a hardcoded id, so the - // admin search follows a renamed/replaced default (design/22 §4.5). No default - // registered means there is nothing to search: answer honestly instead of - // querying an id that cannot match. + // 回退到库的默认角色,而不是硬编码 id,使 + // 管理搜索跟随被改名/替换的默认角色(design/22 §4.5)。没有注册 + // 默认角色意味着无可搜索:诚实作答,而不是 + // 查询一个不可能匹配的 id。 personaID = s.defaultPersonaID() if personaID == "" { s.auditClaims(r, "memory.search", "error") @@ -83,9 +83,9 @@ func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "personaId": personaID, "semantic": false, "hits": hits}) } -// adminExportSession exports a session as JSON. Without a conversation-message -// store the export carries the session identity metadata that exists today; a -// full transcript export is deferred to M10. +// adminExportSession 把一个会话导出为 JSON。没有对话消息 +// 存储时,导出只携带当下存在的会话身份元数据; +// 完整转录导出推迟到 M10。 func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") sess := s.findSession(r.Context(), id) @@ -98,7 +98,7 @@ func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"session": summaryOfSession(*sess)}) } -// findSession returns the session whose SessionID equals id, if known. +// findSession 返回 SessionID 等于 id 的会话(若已知)。 func (s *Server) findSession(ctx context.Context, id string) *session.Session { if s.sessions == nil { return nil diff --git a/internal/services/gateway/admin_write_test.go b/internal/services/gateway/admin_write_test.go index e6af6b1..753f697 100644 --- a/internal/services/gateway/admin_write_test.go +++ b/internal/services/gateway/admin_write_test.go @@ -25,9 +25,9 @@ func gwTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// newMemoryStoreForGateway opens an isolated in-memory SQLite DB and builds a -// real memory.Store over it so the owner write path (which type-asserts to -// *memory.Store) exercises the real Add. +// newMemoryStoreForGateway 打开隔离的内存 SQLite DB,并在其上构建 +// 真实的 memory.Store,使 owner 写路径(它类型断言到 +// *memory.Store)演练真实的 Add。 func newMemoryStoreForGateway(t *testing.T) *memory.Store { t.Helper() ctx := context.Background() @@ -97,7 +97,7 @@ func TestAdminAddMemoryWritesAndLists(t *testing.T) { t.Fatalf("added = %+v, want persona/她喜欢喝冰美式", added) } - // The freshly added item is now listed. + // 刚添加的条目现在已被列出。 listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok) if listResp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody) @@ -117,13 +117,13 @@ func TestAdminAddMemoryRejectsBadInput(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Blank content. + // 空白内容。 resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"personaId": "cyrene", "content": " "}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("blank content status = %d, want 400", resp.StatusCode) } - // Invalid scope. + // 非法 scope。 resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"personaId": "cyrene", "content": "x", "scope": "bogus"}, tok) if resp2.StatusCode != http.StatusBadRequest { @@ -133,7 +133,7 @@ func TestAdminAddMemoryRejectsBadInput(t *testing.T) { func TestAdminAddMemoryUnwired501(t *testing.T) { t.Parallel() - _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no memory store + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 无记忆存储 tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", map[string]any{"content": "x"}, tok) @@ -155,7 +155,7 @@ func TestAdminIndexRagImportsAndLists(t *testing.T) { t.Fatalf("index status = %d, want 201 (body=%s)", resp.StatusCode, body) } - // The document now shows up in the list (imported + chunked). + // 该文档现在出现在列表中(已导入 + 已切分)。 listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok) if listResp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody) @@ -168,7 +168,7 @@ func TestAdminIndexRagImportsAndLists(t *testing.T) { t.Fatalf("documents = %+v, want one 内部知识库.md with 2 chunks", res.Documents) } - // It is immediately queryable (keyword/line path). + // 它立即可查询(关键词/行路径)。 qResp, qBody := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "评审流程"}, tok) if qResp.StatusCode != http.StatusOK { @@ -214,9 +214,9 @@ func TestAdminIndexRagUnwired501(t *testing.T) { } } -// newLlmRuntimeForGateway builds a hot-swappable RuntimeLLM pointing at a fresh -// temp models.json, seeded by the env config. It is the real stack the admin -// write endpoint persists to and reloads. +// newLlmRuntimeForGateway 构建一个可热切换的 RuntimeLLM,指向全新的 +// 临时 models.json,由环境配置播种。它就是管理写端点 +// 持久化并重载的真实栈。 func newLlmRuntimeForGateway(t *testing.T, modelsPath string, env config.LLMConfig) *model.RuntimeLLM { t.Helper() return model.NewRuntimeLLM(modelsPath, env, gwTestLogger()) @@ -247,7 +247,7 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { t.Fatalf("add res = %+v", res) } - // models.json now holds the saved group (promoted to index 0 = active). + // models.json 现在持有已保存的分组(提升到下标 0 = 活动)。 groups, warn := runtimeconfig.LoadModels(modelsPath, env) if warn != nil { t.Fatalf("load models warn = %v", warn) @@ -256,12 +256,12 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { t.Fatalf("file groups = %+v, want deepseek first with sk-ds and maxTokens 1234", groups) } - // The running stack reflects the new active provider/model (hot reload). + // 运行中的栈反映了新的活动 provider/model(热重载)。 if runtime.Provider() != "openai" || runtime.Model() != "deepseek-flash" { t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-flash", runtime.Provider(), runtime.Model()) } - // GET /admin/llm reflects the saved group and never leaks any API key. + // GET /admin/llm 反映已保存的分组,且绝不泄露任何 API key。 gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) if gResp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200", gResp.StatusCode) @@ -286,7 +286,7 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Saving an anthropic group makes it the active provider/model. + // 保存 anthropic 分组会使其成为活动的 provider/model。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"provider": "anthropic", "baseUrl": "https://api.anthropic.com", "apiKey": "sk-ant", "model": "claude-sonnet-4"}, tok) if resp.StatusCode != http.StatusCreated { @@ -295,7 +295,7 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) { if runtime.Provider() != "anthropic" || runtime.Model() != "claude-sonnet-4" { t.Fatalf("runtime Provider=%q Model=%q, want anthropic/claude-sonnet-4", runtime.Provider(), runtime.Model()) } - // The saved group is active (index 0) and anthropic. + // 已保存的分组是活动的(下标 0)且为 anthropic。 if runtime.Group().Provider != "anthropic" { t.Fatalf("active group = %+v, want anthropic", runtime.Group()) } @@ -309,16 +309,16 @@ func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Seed a fully-specified group. + // 播种一个完整指定的分组。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-flash", "multimodal": true, "fallback": "deepseek-v4-flash", "maxTokens": 1234}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body) } - // Partial edit: only the model is given. Every omitted field must keep its - // stored value — in particular maxTokens (a full replace reset it to 0) and - // apiKey (a full replace wiped it). + // 局部编辑:只给出 model。每个省略的字段都必须保持其 + // 已存值 —— 尤其是 maxTokens(整体替换会把它重置为 0)与 + // apiKey(整体替换会抹掉它)。 resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"id": "deepseek", "model": "deepseek-v4-pro"}, tok) if resp2.StatusCode != http.StatusCreated { @@ -352,13 +352,13 @@ func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Missing model. + // 缺少 model。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"baseUrl": "https://x"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("missing model status = %d, want 400 (body=%s)", resp.StatusCode, body) } - // Invalid provider. + // 非法 provider。 resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"provider": "bogus", "baseUrl": "https://x", "model": "m"}, tok) if resp2.StatusCode != http.StatusBadRequest { @@ -368,7 +368,7 @@ func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) { func TestAdminAddLlmProviderUnwired501(t *testing.T) { t.Parallel() - _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no LLM stack wired + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 未接线 LLM 栈 tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", map[string]any{"baseUrl": "https://x", "model": "m"}, tok) diff --git a/internal/services/gateway/audio.go b/internal/services/gateway/audio.go index 43c6a66..58e38f2 100644 --- a/internal/services/gateway/audio.go +++ b/internal/services/gateway/audio.go @@ -10,23 +10,23 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) -// maxAudioRequestBytes caps the JSON body a /api/v1/audio/* request may carry so -// a huge base64 audio payload cannot exhaust memory. +// maxAudioRequestBytes 限定 /api/v1/audio/* 请求可携带的 JSON 体大小, +// 使巨大的 base64 音频载荷无法耗尽内存。 const maxAudioRequestBytes = 32 << 20 -// transcribeRequest is the POST /api/v1/audio/transcribe body. Audio is a base64 -// (or data: URI) blob; Format is its encoding ("wav"/"mp3"/"ogg"). +// transcribeRequest 是 POST /api/v1/audio/transcribe 的请求体。Audio 是 base64 +// (或 data: URI)数据块;Format 是其编码("wav"/"mp3"/"ogg")。 type transcribeRequest struct { Audio string `json:"audio"` Format string `json:"format"` DeviceID string `json:"deviceId,omitempty"` } -// speechRequest is the POST /api/v1/audio/speech body. Voice/format are accepted -// for forward compatibility; synthesis currently follows the server's -// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT config. Instruction is an optional -// per-request emotion/tone override for a cloned voice (声音复刻); a system voice -// (longxiaochun_v3) ignores it and never receives a free-form instruction. +// speechRequest 是 POST /api/v1/audio/speech 的请求体。Voice/format 为前向 +// 兼容而被接受;合成目前遵循服务端的 +// VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT 配置。Instruction 是可选的 +// 每请求情绪/语气覆盖,用于克隆音色(声音复刻);系统音色 +// (longxiaochun_v3)忽略它,绝不接收自由形式的指令。 type speechRequest struct { Text string `json:"text"` Voice string `json:"voice,omitempty"` @@ -34,9 +34,9 @@ type speechRequest struct { Instruction string `json:"instruction,omitempty"` } -// handleTranscribe runs STT on the uploaded audio and returns the recognized -// text. It is the REST source for native clients, bypassing the 4KB WebSocket -// frame limit that would truncate a base64 audio blob. +// handleTranscribe 对上传的音频执行 STT 并返回识别出的 +// 文本。它是原生客户端的 REST 来源,绕过 4KB WebSocket +// 帧上限(该上限会截断 base64 音频块)。 func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes) var req transcribeRequest @@ -76,8 +76,8 @@ func (s *Server) handleTranscribe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// handleSpeech synthesizes speech for the given text and writes the raw audio -// bytes (audio/) back — a binary response, not a JSON envelope. +// handleSpeech 为给定文本合成语音并写回原始音频 +// 字节(audio/)—— 二进制响应,不是 JSON 信封。 func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes) var req speechRequest @@ -120,18 +120,18 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(audi.Data) } -// voiceCloneRequest is the POST /api/v1/audio/voice-clone body. URL is the public -// reference audio URL; prefix is the optional voice-name prefix (≤10 alnum chars). -// Neither is logged, and the URL is not persisted (owner + audit only, red line). +// voiceCloneRequest 是 POST /api/v1/audio/voice-clone 的请求体。URL 是公开的 +// 参考音频 URL;prefix 是可选音色名前缀(≤10 个字母数字字符)。 +// 二者都不记日志,URL 也不持久化(仅 owner + 审计,红线)。 type voiceCloneRequest struct { URL string `json:"url"` Prefix string `json:"prefix,omitempty"` } -// handleVoiceClone creates a custom TTS voice from a public reference audio URL -// via the Bailian voice-enrollment API (声音复刻 create_voice) and returns the new -// voice_id. Owner-only + audited; the reference URL is never written to the audit -// trail or the log (red line). +// handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开 +// 参考音频 URL 创建自定义 TTS 音色,并返回新的 +// voice_id。仅 owner + 带审计;参考 URL 绝不写入审计 +// 记录或日志(红线)。 func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxAudioRequestBytes) var req voiceCloneRequest @@ -153,7 +153,7 @@ func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) { voiceID, err := s.voice.CloneVoice(r.Context(), req.URL, req.Prefix) if err != nil { s.auditClaims(r, "audio.voice-clone", "error") - // Do not log the cause: a provider error body may echo the reference URL. + // 不要记录原因:provider 的错误体可能回显参考 URL。 s.log.Warn("voice clone failed") writeError(w, apperr.Wrap(apperr.ErrInternal, "voice_clone_failed", "voice clone failed")) return @@ -162,7 +162,7 @@ func (s *Server) handleVoiceClone(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"voiceId": voiceID}) } -// audioMime maps an audio format to its Content-Type for the speech response. +// audioMime 把音频格式映射为其用于语音响应的 Content-Type。 func audioMime(format string) string { switch strings.ToLower(format) { case "mp3", "mpeg": diff --git a/internal/services/gateway/audio_test.go b/internal/services/gateway/audio_test.go index 50edabd..44f01ab 100644 --- a/internal/services/gateway/audio_test.go +++ b/internal/services/gateway/audio_test.go @@ -11,8 +11,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) -// audioStubVoice is a VoiceService returning fixed STT/TTS results for the -// REST /api/v1/audio/* handler tests. +// audioStubVoice 是一个 VoiceService,为 REST /api/v1/audio/* handler +// 测试返回固定的 STT/TTS 结果。 type audioStubVoice struct { sttText string sttErr error diff --git a/internal/services/gateway/audit.go b/internal/services/gateway/audit.go index db1b755..2663b82 100644 --- a/internal/services/gateway/audit.go +++ b/internal/services/gateway/audit.go @@ -8,10 +8,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) -// auditClaims records an admin write with the caller identity read from the -// request context (populated by auth.Authorize). It is the who/what/when/result -// audit trail required for admin operations; the "主代理代审" user-facing note is -// the frontend's job, not the gateway's. +// auditClaims 记录一次管理写操作,调用方身份从请求上下文读取 +// (由 auth.Authorize 填充)。它是管理操作所需的 +// 谁/什么/何时/结果审计链;「主代理代审」这一面向用户的提示是 +// 前端的职责,不是网关的。 func (s *Server) auditClaims(r *http.Request, what, result string) { who := "anonymous" if claims, ok := auth.ClaimsFromContext(r.Context()); ok { @@ -20,20 +20,20 @@ func (s *Server) auditClaims(r *http.Request, what, result string) { s.audit(r.Context(), who, what, result) } -// AuditEvent records an external (non-HTTP) audit event with an explicit caller -// identity, so programmatic sinks outside the request path (for example the -// channel bridge's blacklist rejection hook, design/16 §4.6 §6) can append to the -// same who/what/when/result trail. who is a free-form actor label ("channel" or -// "bridge:telegram"); what is the operation and result its outcome. It is safe for -// concurrent use. +// AuditEvent 记录一次带显式调用方身份的外部(非 HTTP)审计事件, +// 使请求路径之外的程序化写入方(例如渠道 bridge 的 +// 黑名单拒绝钩子,design/16 §4.6 §6)能追加到同一 +// 谁/什么/何时/结果链。who 是自由形式的 actor 标签("channel" 或 +// "bridge:telegram");what 是操作,result 是其结果。它对 +// 并发使用安全。 func (s *Server) AuditEvent(who, what, result string) { s.audit(context.Background(), who, what, result) } -// audit emits one structured audit record on the gateway logger and persists it to -// the configured AuditStore (falling back to an in-memory store when none is -// injected). A persist failure is logged but never blocks the admin write, so a -// broken audit store cannot take down the audit trail's primary job. +// audit 在网关 logger 上发出一条结构化审计记录,并把它持久化到 +// 已配置的 AuditStore(未注入时回退到内存存储)。 +// 持久化失败会记日志,但绝不阻塞管理写,因此 +// 坏掉的审计存储无法拖垮审计链的首要职责。 func (s *Server) audit(ctx context.Context, who, what, result string) { s.log.Info("admin audit", "who", who, diff --git a/internal/services/gateway/auth_refresh.go b/internal/services/gateway/auth_refresh.go index fde10a7..f30af9e 100644 --- a/internal/services/gateway/auth_refresh.go +++ b/internal/services/gateway/auth_refresh.go @@ -12,17 +12,17 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) -// defaultRefreshTTL is the refresh-token lifetime used when cfg.Auth.RefreshTTL -// is unset (an undecoded config in tests), matching the AUTH_REFRESH_TTL default. +// defaultRefreshTTL 是 cfg.Auth.RefreshTTL 未设置时(测试中未解码的 +// 配置)使用的 refresh token 生命周期,与 AUTH_REFRESH_TTL 默认值一致。 const defaultRefreshTTL = 720 * time.Hour -// refreshRequest is the POST /api/v1/auth/refresh body. +// refreshRequest 是 POST /api/v1/auth/refresh 的请求体。 type refreshRequest struct { RefreshToken string `json:"refreshToken"` } -// refreshResponse is the refresh success payload: a fresh access token plus the -// rotated refresh token (omitted when rotation is disabled by config). +// refreshResponse 是刷新成功的载荷:新的访问令牌加上 +// 轮换后的 refresh token(配置禁用轮换时省略)。 type refreshResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expiresAt"` @@ -30,13 +30,13 @@ type refreshResponse struct { RefreshToken string `json:"refreshToken,omitempty"` } -// logoutRequest is the POST /api/v1/auth/logout body. +// logoutRequest 是 POST /api/v1/auth/logout 的请求体。 type logoutRequest struct { RefreshToken string `json:"refreshToken"` } -// refreshTTL returns the configured refresh-token lifetime, falling back to -// defaultRefreshTTL when the config was not decoded (zero value). +// refreshTTL 返回已配置的 refresh token 生命周期,配置未解码 +// (零值)时回退到 defaultRefreshTTL。 func (s *Server) refreshTTL() time.Duration { if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 { return s.cfg.Auth.RefreshTTL @@ -44,16 +44,16 @@ func (s *Server) refreshTTL() time.Duration { return defaultRefreshTTL } -// refreshRotate reports whether single-use rotation is enabled. Rotation is the -// default; only an explicit AUTH_REFRESH_ROTATE_DISABLED=true opts out. +// refreshRotate 报告是否启用一次性轮换。轮换是 +// 默认;只有显式 AUTH_REFRESH_ROTATE_DISABLED=true 才选择退出。 func (s *Server) refreshRotate() bool { return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled } -// handleRefresh exchanges a valid opaque refresh token for a new access token, -// rotating the refresh token by default. It is public (the refresh token is the -// credential) and returns 401 invalid_grant for an unknown, revoked, or expired -// token. A successful rotation is audited; the token itself is never logged. +// handleRefresh 用有效的非透明 refresh token 换取新的访问令牌, +// 默认轮换 refresh token。它是公开的(refresh token 就是 +// 凭据),对未知、已吊销或已过期的 token 返回 401 invalid_grant。 +// 成功的轮换会被审计;token 本身绝不记日志。 func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { var req refreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -92,18 +92,18 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { }) } -// handleLogout revokes the refresh token in the body. It is public and -// idempotent: an unknown or already revoked token yields {"revoked": false} with -// 200 rather than an error, so a client can always clear its local state. +// handleLogout 吊销请求体中的 refresh token。它公开且 +// 幂等:未知或已吊销的 token 返回 {"revoked": false} 与 +// 200 而不是错误,因此客户端总能清理其本地状态。 // -// The body may carry either a refreshToken (revokes that one grant) or nothing, -// in which case the Authorization Bearer access token identifies the account and -// every refresh grant for it is revoked (logout on all devices). A request with -// neither is a 400. +// 请求体可以携带 refreshToken(只吊销那一份授权),也可以不携带, +// 此时 Authorization Bearer 访问令牌标识账号,该账号的 +// 每一份 refresh 授权都被吊销(全设备登出)。两者都 +// 没有的请求是 400。 func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { var req logoutRequest - // An empty body is valid: it means "no refreshToken", and the account is - // identified by the bearer access token below. + // 空请求体是合法的:它意味着「无 refreshToken」,账号由 + // 下面的 bearer 访问令牌标识。 if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return @@ -141,8 +141,8 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"revoked": n > 0, "revokedCount": n}) } -// bearerFromRequest extracts the Bearer credential from the Authorization -// header, returning "" when the header is absent or malformed. +// bearerFromRequest 从 Authorization 头提取 Bearer 凭据, +// 头缺失或畸形时返回 ""。 func bearerFromRequest(r *http.Request) string { const prefix = "Bearer " h := r.Header.Get("Authorization") diff --git a/internal/services/gateway/auth_refresh_test.go b/internal/services/gateway/auth_refresh_test.go index b1a64a5..0e76e2d 100644 --- a/internal/services/gateway/auth_refresh_test.go +++ b/internal/services/gateway/auth_refresh_test.go @@ -17,9 +17,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) -// newRefreshEnv builds a gateway backed by a real JWT service and a real -// SQLite-backed refresh store, returning the store and DB so a test can assert -// what was persisted. The DB is isolated in memory per test. +// newRefreshEnv 构建一个网关,后端是真实 JWT 服务与真实的 +// SQLite refresh 存储,并返回该存储与 DB,使测试能断言 +// 持久化了什么。DB 每个测试都隔离在内存中。 func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) { t.Helper() ctx := context.Background() @@ -52,7 +52,7 @@ func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) { return &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}, refresh, sqlDB } -// decodeData unmarshals the {data: ...} envelope payload into v. +// decodeData 把 {data: ...} 信封载荷反序列化进 v。 func decodeData(t *testing.T, body []byte, v any) { t.Helper() env := decodeBody(t, body) @@ -65,7 +65,7 @@ func decodeData(t *testing.T, body []byte, v any) { } } -// loginRefresh performs an admin login and returns the access and refresh tokens. +// loginRefresh 执行一次管理登录并返回访问令牌与 refresh token。 func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) { t.Helper() resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login", @@ -89,7 +89,7 @@ func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) { return data.Token, data.RefreshToken } -// postRefresh calls /auth/refresh with token and returns the status and body. +// postRefresh 用 token 调用 /auth/refresh 并返回状态与响应体。 func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) { t.Helper() resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/refresh", @@ -97,7 +97,7 @@ func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) return resp.StatusCode, body } -// wantInvalidGrant asserts a 401 with the machine-readable invalid_grant code. +// wantInvalidGrant 断言 401 与机器可读的 invalid_grant 码。 func wantInvalidGrant(t *testing.T, status int, body []byte) { t.Helper() if status != http.StatusUnauthorized { @@ -165,11 +165,11 @@ func TestRefreshRotatesAccessAndRefreshTokens(t *testing.T) { t.Fatalf("refreshed claims = %+v, want admin/owner", claims) } - // The presented token is single-use: replay must fail. + // 出示的 token 是一次性的:重放必须失败。 status, body = postRefresh(t, env.ts, oldRefresh) wantInvalidGrant(t, status, body) - // The rotated token works. + // 轮换后的 token 有效。 if status, body = postRefresh(t, env.ts, data.RefreshToken); status != http.StatusOK { t.Fatalf("rotate replacement status = %d, want 200: %s", status, body) } @@ -229,7 +229,7 @@ func TestLogoutRevokesRefreshToken(t *testing.T) { t.Fatalf("refresh after logout status = %d, want 401: %s", status, body) } - // Logout is idempotent: a second call reports revoked=false, not an error. + // 登出是幂等的:第二次调用报告 revoked=false,而不是错误。 resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", map[string]string{"refreshToken": refreshToken}, "") if resp.StatusCode != http.StatusOK { diff --git a/internal/services/gateway/contracts.go b/internal/services/gateway/contracts.go index 3e50b03..1468dcf 100644 --- a/internal/services/gateway/contracts.go +++ b/internal/services/gateway/contracts.go @@ -24,225 +24,225 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) -// This file declares the dependency contracts the gateway consumes. Every one is -// a narrow interface satisfied by a concrete backend the composition root injects -// (see Deps in deps.go), so the gateway never reaches for a concrete -// implementation and tests can substitute a stub. +// 本文件声明网关所消费的依赖契约。每一个都是窄接口, +// 由组合根注入的具体后端满足(见 deps.go 中的 +// Deps),因此网关绝不直接取用具体实现, +// 测试也能替换成桩。 -// Authenticator abstracts the credential + RBAC service. *auth.JWTService is the -// production implementation; it is an interface so tests substitute a mock. +// Authenticator 抽象凭据 + RBAC 服务。*auth.JWTService 是 +// 生产实现;它是接口,因此测试可替换为 mock。 type Authenticator interface { - // Sign issues a token for sub with the given role and lifetime. + // Sign 为 sub 签发一个带给定角色与生命周期的 token。 Sign(sub string, role auth.Role, ttl time.Duration) (string, error) - // Verify validates a token and returns its claims. + // Verify 校验一个 token 并返回其 claims。 Verify(token string) (*auth.Claims, error) - // Authorize returns an HTTP middleware enforcing at least one required role. + // Authorize 返回一个 HTTP 中间件,强制至少一个所需角色。 Authorize(required ...auth.Role) func(http.Handler) http.Handler } -// CapabilityStore abstracts the global capability registry used to enumerate -// plugins and channels in the admin API. *capability.Registry is the production -// implementation. +// CapabilityStore 抽象管理 API 中用于枚举插件与渠道的全局 +// 能力注册表。*capability.Registry 是生产 +// 实现。 type CapabilityStore interface { - // List returns every capability whose Kind matches, or all when kind is - // empty. + // List 返回 Kind 匹配的每一个能力,kind 为空时 + // 返回全部。 List(kind capreg.Kind) []capreg.Capability - // SetPolicy replaces the reachability markers for the named capability. + // SetPolicy 替换指定能力的可达性标记。 SetPolicy(name string, p capreg.ToolPolicy) error } -// Chatter runs a single agent turn. *loop.Loop is the production implementation. +// Chatter 运行单次 agent 回合。*loop.Loop 是生产实现。 type Chatter interface { - // Run drives one turn for sessionID and returns the final answer. + // Run 为 sessionID 驱动一个回合并返回最终答案。 Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) } -// SessionStore lists known channel sessions. *session.SessionManager is the -// production implementation. +// SessionStore 列出已知的渠道会话。*session.SessionManager 是 +// 生产实现。 type SessionStore interface { List(ctx context.Context) ([]session.Session, error) } -// PersonaStore lists registered characters (each bound one-to-one to a persona). -// *persona/registry.Registry is the production implementation. The gateway maps -// each Character to its wire summary; the interface deliberately returns the -// domain type so the wire shape stays out of the store contract. +// PersonaStore 列出已注册角色(每个与一个人格一一绑定)。 +// *persona/registry.Registry 是生产实现。网关把 +// 每个 Character 映射为其线上摘要;该接口刻意返回 +// 领域类型,使线上结构不进入存储契约。 type PersonaStore interface { List() []personareg.Character - // Persona returns the persona with the given id (to read its visual assets). - // The production registry implements it; the wire summary reads Avatar / - // ChatBackground from it so each role's visuals stay role-scoped. + // Persona 返回给定 id 的人格(用于读取其视觉资产)。 + // 生产注册表实现了它;线上摘要从它读取 Avatar / + // ChatBackground,使每个角色的视觉保持角色作用域。 Persona(id string) (*loader.Persona, bool) } -// PersonaMoodStore is the *optional* capability of a persona store that also owns -// the per-persona emotion state (design/01 §2: emotion follows the persona). The -// production *personareg.Registry implements it; the gateway reads a role's mood -// through an assertion on this narrow interface so a store that only lists -// personas (a test double, a future read-only catalog) keeps satisfying -// PersonaStore without owning an emotion machine. When a store does not implement -// it, a role's mood simply reads as "" rather than being invented. +// PersonaMoodStore 是人格存储的*可选*能力,该存储同时持有 +// 每人格的情绪状态(design/01 §2:情绪跟随人格)。 +// 生产实现 *personareg.Registry 实现了它;网关通过对这个窄 +// 接口做断言来读取某角色的 mood,因此只列出人格的存储 +// (测试替身、未来的只读目录)无需持有情绪机 +// 就能继续满足 PersonaStore。当存储未实现它时, +// 某角色的 mood 只读作 "",而不是被凭空造出。 type PersonaMoodStore interface { Emotions() *emotion.Manager } -// MemoryStore lists memory entries. *runtime/memory.Store is the production -// implementation. The gateway maps each MemoryItem to its wire summary. +// MemoryStore 列出记忆条目。*runtime/memory.Store 是生产 +// 实现。网关把每个 MemoryItem 映射为其线上摘要。 type MemoryStore interface { List(ctx context.Context) ([]memory.MemoryItem, error) } -// HistoryStore is the persistent chat-history store (design/16). *runtime/history.Store -// is the production implementation. When nil the session/message endpoints degrade: list -// sessions falls back to the channel-session view, and the create/messages/delete -// endpoints answer with a clear 501. +// HistoryStore 是持久化聊天历史存储(design/16)。*runtime/history.Store +// 是生产实现。为 nil 时会话/消息端点退化:列出 +// 会话回退到渠道会话视图,而创建/取消息/删除 +// 端点返回明确的 501。 type HistoryStore interface { Append(ctx context.Context, msg history.Message) error - // AppendFull persists msg like Append but returns the complete stored message - // (with the assigned id/timestamp) so the orchestrator can broadcast it for - // cross-end realtime sync (design/16 §4.2). + // AppendFull 像 Append 一样持久化 msg,但返回完整的已存消息 + // (含分配的 id/时间戳),使编排器能广播它以进行 + // 跨端实时同步(design/16 §4.2)。 AppendFull(ctx context.Context, msg history.Message) (history.Message, error) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error) - // ListAfter returns up to limit messages strictly newer than after in - // oldest-first order, for forward incremental catch-up after a reconnect - // (the "before" cursor can only walk backwards). platform filters as in List. + // ListAfter 按最旧优先返回至多 limit 条严格新于 after 的消息, + // 用于重连后的前向增量追赶("before" 游标只能 + // 向后走)。platform 的过滤同 List。 ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]history.Message, error) Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error) ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error) DeleteSession(ctx context.Context, sessionID string) error - // Search returns session-scoped content matches (newest first) for the - // ?q= message-search path. An empty query yields an empty slice. + // Search 为 ?q= 消息搜索路径返回会话作用域的内容匹配 + // (最新优先)。空查询得到空切片。 Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]history.Message, error) - // UpdateMessage edits a single message's content and returns the updated - // message. An unknown id returns history.ErrMessageNotFound. + // UpdateMessage 编辑单条消息的内容并返回更新后的 + // 消息。未知 id 返回 history.ErrMessageNotFound。 UpdateMessage(ctx context.Context, id, content string) (history.Message, error) - // DeleteMessage removes a single message. An unknown id returns - // history.ErrMessageNotFound. + // DeleteMessage 移除单条消息。未知 id 返回 + // history.ErrMessageNotFound。 DeleteMessage(ctx context.Context, id string) error - // RenameSession sets a session's display title (main or sub). An unknown id - // returns history.ErrSessionNotFound. + // RenameSession 设置会话的显示标题(main 或 sub)。未知 id + // 返回 history.ErrSessionNotFound。 RenameSession(ctx context.Context, sessionID, title string) error - // SetPinned pins/unpins a session (main or sub). An unknown id returns - // history.ErrSessionNotFound. + // SetPinned 置顶/取消置顶一个会话(main 或 sub)。未知 id 返回 + // history.ErrSessionNotFound。 SetPinned(ctx context.Context, sessionID string, pinned bool) error NewSubSession(ctx context.Context, ownerID string) (string, error) - // MarkSessionRead advances a session's read cursor (D4) to before and returns - // the resulting cursor as a Unix millisecond timestamp; a zero before means - // "now". The cursor only moves forward. An unknown id returns - // history.ErrSessionNotFound. + // MarkSessionRead 把会话的读游标(D4)推进到 before 并返回 + // 结果游标作为 Unix 毫秒时间戳;before 为零意味着 + // 「现在」。游标只向前移动。未知 id 返回 + // history.ErrSessionNotFound。 MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error) ListSessions(ctx context.Context) ([]history.SessionMeta, error) } -// IoTStore lists IoT devices. *iot.Manager is the production implementation. +// IoTStore 列出 IoT 设备。*iot.Manager 是生产实现。 type IoTStore interface { List(ctx context.Context) ([]iot.Device, error) } -// SubagentAuditor audits a sub-agent's conversation context for the subagent_ask -// WS route. *orchestration.Scheduler is the production implementation. +// SubagentAuditor 为 subagent_ask WS 路由审计子代理的对话 +// 上下文。*orchestration.Scheduler 是生产实现。 type SubagentAuditor interface { AuditContext(ctx context.Context, id string, mode orchestration.AuditMode, n int, query string) (*orchestration.AuditResult, error) } -// TaskOutputStore queries a task's output for the task_output_query WS route. -// *task.Manager is the production implementation. +// TaskOutputStore 为 task_output_query WS 路由查询任务输出。 +// *task.Manager 是生产实现。 type TaskOutputStore interface { TaskOutput(ctx context.Context, id string) (task.OutputEvent, error) } -// AutomationStore enumerates the automations and reminders for the admin list. -// *automations.Service is the production implementation. The gateway maps each -// record to its wire shape. +// AutomationStore 为管理列表枚举自动化与提醒。 +// *automations.Service 是生产实现。网关把每条 +// 记录映射为其线上结构。 type AutomationStore interface { List() []automations.AutomationRecord } -// UserStore enumerates the accounts and API keys visible to the admin panel. -// The production implementation reads the configured admin account plus the -// auth.APIKey table. The gateway maps each summary to its wire shape. +// UserStore 枚举管理面板可见的账号与 API key。 +// 生产实现读取已配置的管理账号加上 +// auth.APIKey 表。网关把每个摘要映射为其线上结构。 type UserStore interface { List() []auth.UserSummary } -// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the -// production implementation). It is the credential behind the refreshToken -// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only -// hashes are persisted; the plaintext token crosses this interface exactly once -// per issue/rotate. Optional: when nil login omits refreshToken and the refresh -// and logout endpoints report a clear not-configured error. +// RefreshGrantStore 管理非透明 refresh token(auth.RefreshStore 是 +// 生产实现)。它是登录与 POST /auth/refresh、/auth/logout 端点 +// 所返回 refreshToken 背后的凭据。只持久化哈希; +// 明文 token 每次签发/轮换恰好穿过该接口一次。 +// 可选:为 nil 时登录省略 refreshToken, +// refresh 与 logout 端点返回明确的 not-configured 错误。 type RefreshGrantStore interface { - // Issue mints a refresh token for userID/role and returns its plaintext - // (visible once) plus its expiry. + // Issue 为 userID/role 铸造一个 refresh token,返回其明文 + // (仅可见一次)及其过期时间。 Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error) - // Rotate validates plaintext and, when opts.Rotate is set, revokes it and - // returns a replacement. It returns auth.ErrInvalidGrant for an unknown, - // revoked, or expired token. + // Rotate 校验 plaintext,并在 opts.Rotate 设置时吊销它并 + // 返回替代品。对未知、已吊销或已过期的 token 返回 + // auth.ErrInvalidGrant 错误。 Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error) - // Revoke marks the token matching plaintext revoked, returning whether a - // live token was found. It is idempotent. + // Revoke 把匹配 plaintext 的 token 标记为已吊销,并返回是否 + // 找到了存活的 token。它是幂等的。 Revoke(ctx context.Context, plaintext string) (bool, error) - // RevokeAll deletes every refresh token (global invalidation on secret - // rotation) and returns how many were removed. + // RevokeAll 删除每一个 refresh token(秘密轮换时的全局 + // 失效)并返回移除了多少个。 RevokeAll(ctx context.Context) (int64, error) - // RevokeByUser deletes every refresh token for userID (logout by access - // token, where the individual grant is unknown) and returns how many were - // removed. + // RevokeByUser 删除 userID 的每一个 refresh token(按访问 + // 令牌登出,此时不知道具体是哪一份授权)并返回 + // 移除了多少个。 RevokeByUser(ctx context.Context, userID string) (int64, error) } -// BackthinkStore enumerates the persisted background-think records for the -// M9/M10 monitor panel. *backthink.Backthink is the production implementation. +// BackthinkStore 为 M9/M10 监控面板枚举持久化的后台思考 +// 记录。*backthink.Backthink 是生产实现。 type BackthinkStore interface { List(ctx context.Context, limit int) ([]backthink.Record, error) } -// ChannelConfigStore is the runtime-editable channel-adapter configuration -// (channels.json, design/04 §3.1). It is implemented by the composition root's -// channel store: Load returns the effective per-platform accounts (file → env -// fallback), Path reports the channels.json path, and Reload hot-replaces a -// platform's running adapters after a panel save (Bridge.Reload). Load returns -// account configs with real tokens so the owner write path can persist them; the -// admin read path masks them before returning to the client. +// ChannelConfigStore 是运行时可编辑的渠道适配器配置 +// (channels.json,design/04 §3.1)。它由组合根的渠道存储 +// 实现:Load 返回按平台生效的账号(文件 → 环境变量 +// 回退),Path 报告 channels.json 路径,Reload 在面板保存后 +// 热替换某平台运行中的适配器(Bridge.Reload)。Load 返回 +// 带真实 token 的账号配置,使 owner 写路径能持久化它们; +// 管理读路径在返回客户端前对它们脱敏。 type ChannelConfigStore interface { Path() string - // Load returns the effective per-platform accounts (file → env fallback), - // including the secret tokens needed to persist them. + // Load 返回按平台生效的账号(文件 → 环境变量回退), + // 含持久化它们所需的秘密 token。 Load() map[string][]config.ChannelAccount - // Reload rebuilds and hot-replaces the running adapters for platform. + // Reload 重建并热替换该平台运行中的适配器。 Reload(platform string) error } -// OwnerRegistrar registers a channel account id into the owner whitelist -// (configs/users/owner.yaml). It is the structural identity red line: a scan-login -// confirmation stamps the scanner's bound ilink_user_id into the whitelist so the -// channel bridge recognizes it as the owner via actor metadata, never a prompt. -// The composition root implements it (cmd/cyrene's ownerRegistrar) to persist the -// whitelist and hot-update the live owner profile. Registration is best-effort: -// a failure is logged by the caller but does not fail the already-applied -// channels.json persist. +// OwnerRegistrar 把一个渠道账号 id 登记进主人白名单 +// (configs/users/owner.yaml)。它是结构化的身份红线:扫码登录 +// 确认会把扫码者绑定的 ilink_user_id 盖进白名单,使 +// 渠道 bridge 经 actor 元数据认出它是主人,绝不是提示词。 +// 组合根实现了它(cmd/cyrene 的 ownerRegistrar)以持久化 +// 白名单并热更新活的 owner profile。登记是尽力而为: +// 失败由调用方记日志,但不会让已经施加的 +// channels.json 持久化失败。 type OwnerRegistrar interface { RegisterChannelOwner(ctx context.Context, platform, accountID string) error } -// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8). -// *runtime/knowledge.Store is the production implementation: it lists/searches -// the project index, imports a document (chunk + index) and deletes one -// (project entry + cascaded chapters). An implementation that cannot write -// reports a clear error (knowledge.ErrWriteUnsupported) rather than a fabricated -// success; the handlers map a missing document to 404. +// RagStore 支撑管理侧知识库 RAG 模块(design/10 §3.12 module 8)。 +// *runtime/knowledge.Store 是生产实现:它列出/搜索 +// 项目索引、导入文档(切分 + 索引)、删除文档 +// (项目条目 + 级联章节)。不能写入的实现 +// 会报告明确的错误(knowledge.ErrWriteUnsupported),而不是编造 +// 成功;handler 把文档缺失映射为 404。 type RagStore interface { - // ListDocuments enumerates the knowledge-base documents (project-index - // entries). + // ListDocuments 枚举知识库文档(项目索引 + // 条目)。 ListDocuments(ctx context.Context) ([]knowledge.Doc, error) - // Search answers a knowledge-base query (line-level hierarchy + optional - // semantic/vector ranking). + // Search 应答一次知识库查询(行级层级 + 可选 + // 语义/向量排序)。 Search(ctx context.Context, query string) ([]knowledge.Hit, error) - // IndexDocument uploads/chunks/embeds a document into the project index. + // IndexDocument 把一份文档上传/切分/嵌入进项目索引。 IndexDocument(ctx context.Context, doc knowledge.Doc) error - // DeleteDocument removes a document (and its chapters) by id, returning - // knowledge.ErrDocumentNotFound when no such document exists. + // DeleteDocument 按 id 移除一份文档(及其章节),在 + // 不存在该文档时返回 knowledge.ErrDocumentNotFound。 DeleteDocument(ctx context.Context, id string) error } diff --git a/internal/services/gateway/deps.go b/internal/services/gateway/deps.go index 77a579c..d229dcc 100644 --- a/internal/services/gateway/deps.go +++ b/internal/services/gateway/deps.go @@ -10,99 +10,99 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// Deps carries the gateway's injected dependencies. Every field is nil-tolerant -// except Auth, which is required (authentication backs login and every RBAC -// decision). Registry/Loop are satisfied by the concrete *capability.Registry and -// *loop.Loop; Sessions/Personas/Memories are satisfied by the concrete session -// manager, persona registry and memory store, and the handlers return [] only -// when a dependency is left nil. +// Deps 承载网关被注入的依赖。除 Auth 外每个字段都容忍 +// nil,Auth 是必需的(认证支撑登录与每一个 RBAC +// 决策)。Registry/Loop 由具体的 *capability.Registry 与 +// *loop.Loop 满足;Sessions/Personas/Memories 由具体的会话 +// 管理器、人格注册表与记忆存储满足,只有当依赖留为 +// nil 时 handler 才返回 []。 type Deps struct { - // Log is the base logger. Nil uses a discard logger. + // Log 是基础 logger。为 nil 时使用丢弃型 logger。 Log *slog.Logger - // Auth is the JWT/RBAC service. Required. + // Auth 是 JWT/RBAC 服务。必需。 Auth Authenticator - // Bus is the in-process event bus the WS hub subscribes to. Optional. + // Bus 是 WS hub 所订阅的进程内事件总线。可选。 Bus bus.Bus - // Registry enumerates capabilities/plugins/channels. Optional. + // Registry 枚举能力/插件/渠道。可选。 Registry CapabilityStore - // Loop runs chat turns. Optional (chat returns an error when unset). + // Loop 运行聊天回合。可选(未设置时 chat 返回错误)。 Loop Chatter - // Session lists channel sessions. Optional. + // Session 列出渠道会话。可选。 Session SessionStore - // Personas lists personas. Optional. + // Personas 列出人格。可选。 Personas PersonaStore - // Memories lists memory entries. Optional. + // Memories 列出记忆条目。可选。 Memories MemoryStore - // History is the persistent chat-history store (design/16). Optional; when nil the - // session/message endpoints degrade (list falls back to channel sessions, others 501). + // History 是持久化聊天历史存储(design/16)。可选;为 nil 时 + // 会话/消息端点退化(列出回退到渠道会话,其余 501)。 History HistoryStore - // IoT lists IoT devices. Optional. + // IoT 列出 IoT 设备。可选。 IoT IoTStore - // Scheduler audits sub-agent context for the subagent_ask WS route. - // Optional (subagent_ask returns a clear error when unset). + // Scheduler 为 subagent_ask WS 路由审计子代理上下文。 + // 可选(未设置时 subagent_ask 返回明确的错误)。 Scheduler SubagentAuditor - // Tasks queries task output for the task_output_query WS route. - // Optional (task_output_query returns a clear error when unset). + // Tasks 为 task_output_query WS 路由查询任务输出。 + // 可选(未设置时 task_output_query 返回明确的错误)。 Tasks TaskOutputStore - // Automations lists automations and reminders. Optional. + // Automations 列出自动化与提醒。可选。 Automations AutomationStore - // Users lists accounts and API keys. Optional (falls back to the configured - // single admin account). + // Users 列出账号与 API key。可选(回退到已配置的 + // 单个管理账号)。 Users UserStore - // Refresh is the opaque refresh-token store. Optional: when nil login omits - // refreshToken and /auth/refresh + /auth/logout answer not-configured. + // Refresh 是非透明 refresh token 存储。可选:为 nil 时登录省略 + // refreshToken,/auth/refresh + /auth/logout 返回 not-configured。 Refresh RefreshGrantStore - // Backthink lists background-think records. Optional. + // Backthink 列出后台思考记录。可选。 Backthink BackthinkStore - // RAG backs the admin knowledge-base RAG module. Optional; list/search - // return empty when unset, and write endpoints report a clear 501. + // RAG 支撑管理侧知识库 RAG 模块。可选;未设置时列出/搜索 + // 返回空,写端点报告明确的 501。 RAG RagStore - // LLM is the hot-swappable provider stack (models.json, design/04 §3.1). - // Optional: when nil the admin /llm endpoints fall back to the env config so - // pre-existing gateway tests keep working. + // LLM 是可热切换的 provider 栈(models.json,design/04 §3.1)。 + // 可选:为 nil 时管理 /llm 端点回退到环境配置,使 + // 既有的网关测试继续可用。 LLM *model.RuntimeLLM - // Channels is the runtime-editable channel-adapter config (channels.json). - // Optional: when nil the admin /channels/config endpoints return a clear - // not-configured error, matching how the LLM stack behaves when unset. + // Channels 是运行时可编辑的渠道适配器配置(channels.json)。 + // 可选:为 nil 时管理 /channels/config 端点返回明确的 + // not-configured 错误,与 LLM 栈未设置时的行为一致。 Channels ChannelConfigStore - // Plugins is the runtime-installable external tool provider manager - // (plugins.json + live MCP servers). Optional: when nil install answers a - // clear not-implemented error and uninstall falls back to a plain capability - // removal, so pre-existing gateway tests keep working. + // Plugins 是运行时可安装的外部工具 provider 管理器 + // (plugins.json + 活的 MCP server)。可选:为 nil 时安装返回 + // 明确的 not-implemented 错误,卸载回退为普通的能力 + // 移除,使既有的网关测试继续可用。 Plugins *pluginmgr.Manager - // OwnerRegistrar stamps a scan-login's bound owner id into the owner - // whitelist (configs/users/owner.yaml) and hot-updates the live profile. - // Optional: when nil the weixin confirm flow still persists channels.json but - // skips the whitelist registration (best-effort by design). + // OwnerRegistrar 把扫码登录绑定的主人 id 盖进主人 + // 白名单(configs/users/owner.yaml)并热更新活的 profile。 + // 可选:为 nil 时 weixin 确认流程仍会持久化 channels.json,但 + // 跳过白名单登记(按设计即为尽力而为)。 OwnerRegistrar OwnerRegistrar - // AuditStore persists the admin audit trail (observability → audit). When nil - // the gateway degrades to an in-memory store so the endpoint still returns - // data; the composition root injects NewSQLiteAuditStore over a.db for real - // persistence. Optional. + // AuditStore 持久化管理审计链(observability → audit)。为 nil 时 + // 网关退化为内存存储,使端点仍能返回 + // 数据;组合根注入基于 a.db 的 NewSQLiteAuditStore 以获得真实 + // 持久化。可选。 AuditStore AuditStore - // DatabasePath is the resolved path of the SQLite database file the - // composition root actually opened (the value derived by resolveSQLitePath, - // which may differ from cfg.DB.SQLitePath when the data dir default applies). - // It backs the admin backup/restore endpoints. Optional; when empty those - // endpoints answer honestly with a clear not-configured error. + // DatabasePath 是组合根实际打开的 SQLite 数据库文件解析后 + // 路径(由 resolveSQLitePath 推导的值,当数据目录默认值 + // 生效时可能与 cfg.DB.SQLitePath 不同)。它支撑 + // 管理备份/恢复端点。可选;为空时那些端点诚实作答, + // 返回明确的 not-configured 错误。 DatabasePath string - // DataDir is the resolved data directory (the value resolveDataDir returns, - // which may differ from cfg.DataDir when the OS default applies). It backs - // the image-upload store (/uploads) and its static route. Optional; - // when empty the upload handlers fall back to cfg.DataDir. + // DataDir 是解析后的数据目录(resolveDataDir 返回的值,当操作系统 + // 默认值生效时可能与 cfg.DataDir 不同)。它支撑 + // 图片上传存储(/uploads)及其静态路由。可选; + // 为空时上传 handler 回退到 cfg.DataDir。 DataDir string - // Streaming marks the injected Loop as streaming-capable (its OnStream is - // wired to publish ws.stream_chunk). When set the hub skips its own single - // final stream_chunk emission, since deltas already stream through OnStream. + // Streaming 标记注入的 Loop 具备流式能力(其 OnStream + // 已接线为发布 ws.stream_chunk)。设置后 hub 会跳过它自己的 + // 单次最终 stream_chunk 发送,因为增量已通过 OnStream 流出。 Streaming bool - // Frontend is the SPA filesystem served for all non-API, non-WS routes. - // When nil the gateway serves no static assets (API and WS only). The - // embedded dist (internal/webui) or an external dist directory is used. + // Frontend 是为所有非 API、非 WS 路由提供的 SPA 文件系统。 + // 为 nil 时网关不提供任何静态资产(只有 API 与 WS)。 + // 使用内嵌的 dist(internal/webui)或外部 dist 目录。 Frontend fs.FS - // Webhooks are externally-mounted inbound HTTP handlers (endpoint path → - // handler), typically from channel adapters that receive webhook callbacks - // (e.g. webhook). Each is mounted on the ServeMux without gateway auth; the - // handler itself enforces its own signature/authorization (webhook HMAC). - // Optional. + // Webhooks 是外部挂载的入站 HTTP handler(端点路径 → + // handler),通常来自接收 webhook 回调的渠道适配器 + // (例如 webhook)。每个都挂到 ServeMux 上且不经网关认证; + // handler 自身强制其签名/授权(webhook HMAC)。 + // 可选。 Webhooks map[string]http.Handler } diff --git a/internal/services/gateway/findchannel_test.go b/internal/services/gateway/findchannel_test.go index 23a65ab..e97d279 100644 --- a/internal/services/gateway/findchannel_test.go +++ b/internal/services/gateway/findchannel_test.go @@ -7,7 +7,7 @@ import ( capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" ) -// testChannel is a bare sdk.Channel used to exercise findChannel resolution. +// testChannel 是一个裸 sdk.Channel,用于演练 findChannel 的解析。 type testChannel struct{ name string } func (t testChannel) Name() string { return t.name } @@ -18,7 +18,7 @@ func (t testChannel) Disconnect(context.Context) error { return nil } func (t testChannel) IsConnected() bool { return false } func (t testChannel) HealthCheck(context.Context) error { return nil } -// TestFindChannelExactMatch prefers an exact adapter name over a bare-name match. +// TestFindChannelExactMatch 优先精确适配器名而非裸名匹配。 func TestFindChannelExactMatch(t *testing.T) { reg := capreg.New() _ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}}) @@ -33,10 +33,10 @@ func TestFindChannelExactMatch(t *testing.T) { } } -// TestFindChannelBarePlatformResolvesToTokenAccount verifies a bare platform name -// ("kook") that is not an exact adapter name resolves to that platform's first -// registered account (the token-bearing one, since the empty-token bare account -// is never registered). +// TestFindChannelBarePlatformResolvesToTokenAccount 验证非精确适配器名的 +// 裸平台名("kook")会解析到该平台首个已注册账号 +// (即携带 token 的那个,因为空 token 的裸账号 +// 从不注册)。 func TestFindChannelBarePlatformResolvesToTokenAccount(t *testing.T) { reg := capreg.New() _ = reg.Register(capreg.Capability{Name: "kook:3fc8", Kind: capreg.KindChannel, Value: testChannel{name: "kook:3fc8"}}) @@ -46,7 +46,7 @@ func TestFindChannelBarePlatformResolvesToTokenAccount(t *testing.T) { } } -// TestFindChannelUnknownOrBlank rejects a name that matches no channel. +// TestFindChannelUnknownOrBlank 拒绝不匹配任何渠道的名字。 func TestFindChannelUnknownOrBlank(t *testing.T) { reg := capreg.New() _ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}}) @@ -56,7 +56,7 @@ func TestFindChannelUnknownOrBlank(t *testing.T) { if _, ok := findChannel(reg, ""); ok { t.Fatal("blank name should not be found") } - // Skipped: non-channel capabilities must not match. + // 已跳过:非渠道能力不得匹配。 _ = reg.Register(capreg.Capability{Name: "some-tool", Kind: capreg.KindTool, Value: testChannel{name: "some-tool"}}) if _, ok := findChannel(reg, "some-tool"); ok { t.Fatal("non-channel capability must not match findChannel") diff --git a/internal/services/gateway/flow_update_ws_test.go b/internal/services/gateway/flow_update_ws_test.go index dbfb721..057be21 100644 --- a/internal/services/gateway/flow_update_ws_test.go +++ b/internal/services/gateway/flow_update_ws_test.go @@ -14,15 +14,15 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// These tests cover the only leg of the design/19 flow chain that had no test: -// the producer's bus event → hub relay → outbound WS frame. The loop side is -// pinned by internal/runtime/loop/flow_test.go; this pins the gateway relay AND -// the wire field names the frontend's parseGraph reads, so a rename on either side -// fails here instead of silently blanking the 全链路流程 page. +// 这些测试覆盖 design/19 流程链中此前唯一没有测试的一段: +// 生产者的 bus 事件 → hub 中继 → 下行 WS 帧。loop 一侧由 +// internal/runtime/loop/flow_test.go 钉住;这里钉住网关中继以及 +// 前端 parseGraph 所读的线上字段名,因此任一侧改名都会 +// 在这里失败,而不是静默让全链路流程页面变空。 -// TestFlowGraphUpdateRelayedToWS publishes a real trace.RunGraph snapshot on the -// producer subject (ws.graph_update) and asserts the client receives a -// `graph_update` frame carrying the snapshot unchanged. +// TestFlowGraphUpdateRelayedToWS 在生产主题(ws.graph_update)上 +// 发布真实的 trace.RunGraph 快照,并断言客户端收到一个 +// 原样携带该快照的 `graph_update` 帧。 func TestFlowGraphUpdateRelayedToWS(t *testing.T) { env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) @@ -35,8 +35,8 @@ func TestFlowGraphUpdateRelayedToWS(t *testing.T) { defer conn.Close() _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - // Round-trip a mode_switch first so the connection is registered before the - // publish (same guard as TestWSUpgradeAndBroadcast). + // 先往返一次 mode_switch,使连接在发布前已注册 + // (与 TestWSUpgradeAndBroadcast 相同的保护)。 if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil { t.Fatalf("write mode_switch: %v", err) } @@ -61,7 +61,7 @@ func TestFlowGraphUpdateRelayedToWS(t *testing.T) { t.Fatalf("frame type = %q, want graph_update (frame=%s)", frame.Type, raw) } - // The relayed payload must be the snapshot verbatim — same JSON, not re-shaped. + // 中继的载荷必须是快照原文 —— 同样的 JSON,未被重塑。 want, err := json.Marshal(snap) if err != nil { t.Fatalf("marshal snapshot: %v", err) @@ -71,10 +71,10 @@ func TestFlowGraphUpdateRelayedToWS(t *testing.T) { } } -// TestFlowGraphUpdateWireFields pins the JSON key names on every level of the -// snapshot against the fields the frontend reader (parseGraph in lib/wsParse.ts) -// requires. It is deliberately an exact key-set comparison: adding, renaming or -// dropping a field the frontend reads must fail here. +// TestFlowGraphUpdateWireFields 把快照每一层的 JSON 键名钉到 +// 前端读取器(lib/wsParse.ts 中的 parseGraph)所需的字段上。 +// 它刻意做精确键集比较:新增、改名或丢弃 +// 前端所读的字段必须在这里失败。 func TestFlowGraphUpdateWireFields(t *testing.T) { raw, err := json.Marshal(sampleFlowSnapshot()) if err != nil { @@ -93,7 +93,7 @@ func TestFlowGraphUpdateWireFields(t *testing.T) { assertGraphMeta(t, raw) } -// assertGraphKeys pins the top-level FlowGraph keys read by parseGraph/Flow.tsx. +// assertGraphKeys 钉住 parseGraph/Flow.tsx 所读的 FlowGraph 顶层键。 func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) { t.Helper() want := []string{"id", "nodes", "edges", "sessionId", "startedAt", "endedAt"} @@ -102,9 +102,9 @@ func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) { } } -// assertNodeKeys checks every emitted node key is one the frontend reads, that each -// node carries the always-read id/label, and that the node holding props+log has -// the complete set (which pins the omitempty tags too). +// assertNodeKeys 检查每个发出的节点键都是前端会读的、每个 +// 节点都携带始终读取的 id/label,以及持有 props+log 的节点具备 +// 完整的键集(这也钉住了 omitempty 标签)。 func assertNodeKeys(t *testing.T, nodes []map[string]json.RawMessage) { t.Helper() want := []string{"id", "label", "kind", "status", "startedAt", "endedAt", "props", "log"} @@ -131,7 +131,7 @@ func assertNodeKeys(t *testing.T, nodes []map[string]json.RawMessage) { } } -// assertEdgeKeys pins the FlowEdge shape (parseGraph reads from/to). +// assertEdgeKeys 钉住 FlowEdge 形状(parseGraph 读取 from/to)。 func assertEdgeKeys(t *testing.T, edges []map[string]json.RawMessage) { t.Helper() if len(edges) == 0 { @@ -144,7 +144,7 @@ func assertEdgeKeys(t *testing.T, edges []map[string]json.RawMessage) { } } -// assertLogKeys pins a node's log entry shape (parseGraph reads time/type/props). +// assertLogKeys 钉住节点的 log 条目形状(parseGraph 读取 time/type/props)。 func assertLogKeys(t *testing.T, nodes []map[string]json.RawMessage) { t.Helper() var logged []map[string]json.RawMessage @@ -167,8 +167,8 @@ func assertLogKeys(t *testing.T, nodes []map[string]json.RawMessage) { } } -// assertGraphMeta pins the envelope values the frontend renders (graph id/session -// pair) and that timestamps arrive as RFC3339Nano (what Date.parse expects). +// assertGraphMeta 钉住前端渲染的信封值(graph id/session +// 对),以及时间戳以 RFC3339Nano 抵达(Date.parse 所期望的格式)。 func assertGraphMeta(t *testing.T, raw []byte) { t.Helper() var meta struct { @@ -187,9 +187,9 @@ func assertGraphMeta(t *testing.T, raw []byte) { } } -// sampleFlowSnapshot builds a real producer-side snapshot (trace.RunGraph, the same -// type the loop's FlowPublisher publishes) whose every optional wire field is -// populated, so the key-set assertions above are meaningful. +// sampleFlowSnapshot 构建真实的生产者侧快照(trace.RunGraph,与 +// loop 的 FlowPublisher 发布的类型相同),其每个可选线上字段 +// 都被填充,使上面的键集断言有意义。 func sampleFlowSnapshot() *trace.FlowGraph { base := time.Date(2026, 10, 3, 21, 0, 0, 0, time.UTC) g := trace.NewRunGraph("web") @@ -222,7 +222,7 @@ func sampleFlowSnapshot() *trace.FlowGraph { return g.Snapshot() } -// decodeObjects decodes a raw JSON array of objects, failing on a malformed shape. +// decodeObjects 解码原始 JSON 对象数组,形状畸形时失败。 func decodeObjects(t *testing.T, raw json.RawMessage, what string) []map[string]json.RawMessage { t.Helper() var out []map[string]json.RawMessage @@ -235,7 +235,7 @@ func decodeObjects(t *testing.T, raw json.RawMessage, what string) []map[string] return out } -// readRawWS reads one text frame and returns its raw bytes. +// readRawWS 读取一个文本帧并返回其原始字节。 func readRawWS(t *testing.T, conn *websocket.Conn) []byte { t.Helper() _, msg, err := conn.ReadMessage() @@ -245,7 +245,7 @@ func readRawWS(t *testing.T, conn *websocket.Conn) []byte { return msg } -// sortedKeys returns the object's JSON keys in sorted order. +// sortedKeys 按排序后的顺序返回该对象的 JSON 键。 func sortedKeys(obj map[string]json.RawMessage) []string { out := make([]string, 0, len(obj)) for k := range obj { @@ -255,7 +255,7 @@ func sortedKeys(obj map[string]json.RawMessage) []string { return out } -// equalKeys reports whether got has exactly the members of want (both sorted). +// equalKeys 报告 got 是否恰好拥有 want 的全部成员(二者均已排序)。 func equalKeys(got []string, want []string) bool { want = append([]string(nil), want...) sort.Strings(want) @@ -270,7 +270,7 @@ func equalKeys(got []string, want []string) bool { return true } -// containsKey reports whether the (sorted) key list holds k. +// containsKey 报告(已排序的)键列表中是否含有 k。 func containsKey(keys []string, k string) bool { for _, key := range keys { if key == k { diff --git a/internal/services/gateway/gateway_test.go b/internal/services/gateway/gateway_test.go index 4bd062d..e40e2f6 100644 --- a/internal/services/gateway/gateway_test.go +++ b/internal/services/gateway/gateway_test.go @@ -23,7 +23,7 @@ import ( const testSecret = "0123456789abcdef0123456789abcdef" -// stubRunner is a Chatter whose Run returns a fixed result. +// stubRunner 是一个 Chatter,其 Run 返回固定结果。 type stubRunner struct { res *loop.Result err error @@ -33,7 +33,7 @@ func (s *stubRunner) Run(ctx context.Context, sessionID string, messages []model return s.res, s.err } -// stubTool is a minimal sdk.Tool for capability-registry tests. +// stubTool 是供能力注册表测试用的最小 sdk.Tool。 type stubTool struct{} func (stubTool) Name() string { return "echo" } @@ -41,7 +41,7 @@ func (stubTool) Description() string { return "ec func (stubTool) Parameters() (map[string]any, error) { return nil, nil } func (stubTool) Execute(ctx context.Context, args any) (any, error) { return args, nil } -// testEnv bundles everything a test needs. +// testEnv 打包了测试所需的一切。 type testEnv struct { srv *Server ts *httptest.Server @@ -50,8 +50,8 @@ type testEnv struct { caps *capreg.Registry } -// newEnv builds a Server backed by a real JWT service and in-process bus, plus a -// httptest server. runner may be nil (chat returns 500). +// newEnv 构建一个 Server,后端是真实 JWT 服务与进程内 bus, +// 外加一个 httptest 服务器。runner 可为 nil(chat 返回 500)。 func newEnv(t *testing.T, runner Chatter, withCaps bool) *testEnv { t.Helper() jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) @@ -87,7 +87,7 @@ func (e *testEnv) token(t *testing.T, role auth.Role) string { return tok } -// doReq performs an HTTP request and returns the response plus the raw body. +// doReq 执行一次 HTTP 请求并返回响应与原始响应体。 func doReq(t *testing.T, method, url string, body any, token string) (*http.Response, []byte) { t.Helper() var rd io.Reader @@ -118,7 +118,7 @@ func doReq(t *testing.T, method, url string, body any, token string) (*http.Resp return resp, bodyBytes } -// decodeBody decodes a response body into a generic map. +// decodeBody 把响应体解码进一个通用 map。 func decodeBody(t *testing.T, b []byte) map[string]json.RawMessage { t.Helper() var m map[string]json.RawMessage @@ -128,7 +128,7 @@ func decodeBody(t *testing.T, b []byte) map[string]json.RawMessage { return m } -// numOrZero returns the numeric value of a JSON number in the data envelope. +// numOrZero 返回 data 信封中某个 JSON 数字的数值。 func wantErrorShape(t *testing.T, b []byte) { t.Helper() env := decodeBody(t, b) @@ -291,9 +291,9 @@ func TestRBACForbiddenAndUnauthorized(t *testing.T) { {name: "chat no token", url: "/api/v1/chat", token: "", want: http.StatusUnauthorized}, {name: "chat viewer", url: "/api/v1/chat", token: viewer, want: http.StatusForbidden}, {name: "admin no token", url: "/api/v1/admin/plugins", token: "", want: http.StatusUnauthorized}, - // A viewer reads the monitoring surface (/admin/plugins) but not the - // restricted reads (/admin/memories) — gateway/rbac_viewer_test.go - // enumerates both sets route by route. + // viewer 可以读监控表面(/admin/plugins),但不能读 + // 受限读(/admin/memories)—— gateway/rbac_viewer_test.go + // 逐路由枚举了这两个集合。 {name: "admin plugin viewer ok", url: "/api/v1/admin/plugins", token: viewer, want: http.StatusOK}, {name: "admin memories viewer forbidden", url: "/api/v1/admin/memories", token: viewer, want: http.StatusForbidden}, {name: "admin owner ok", url: "/api/v1/admin/plugins", token: owner, want: http.StatusOK}, @@ -330,13 +330,13 @@ func TestAdminSetPolicyAndAudit(t *testing.T) { t.Fatalf("policy data = %s, want allowInGroup true", data) } - // Unknown capability → 404. + // 未知能力 → 404。 resp2, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "nope"}, owner) if resp2.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp2.StatusCode) } - // Viewer is forbidden from the write. + // viewer 被禁止执行该写操作。 viewer := env.token(t, auth.RoleViewer) resp3, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, viewer) if resp3.StatusCode != http.StatusForbidden { @@ -370,8 +370,8 @@ func TestWSUpgradeAndBroadcast(t *testing.T) { defer conn.Close() _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - // Round-trip a mode_switch first: the relay re-broadcasts it, which - // guarantees the connection is registered before we trigger a broadcast. + // 先往返一次 mode_switch:中继会重新广播它,这 + // 保证连接在我们触发广播前已注册。 if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil { t.Fatalf("write mode_switch: %v", err) } @@ -379,7 +379,7 @@ func TestWSUpgradeAndBroadcast(t *testing.T) { t.Fatalf("first frame type = %q, want mode_switch (frame=%+v)", got.Type, got) } - // Publish an outbound event; the relay must forward it to the client. + // 发布一个下行事件;中继必须把它转发给客户端。 if err := env.bus.Publish(context.Background(), "ws.thinking", map[string]any{"text": "hmm"}); err != nil { t.Fatalf("publish: %v", err) } @@ -390,7 +390,7 @@ func TestWSUpgradeAndBroadcast(t *testing.T) { func TestWsEventSessionIDField(t *testing.T) { t.Parallel() - // When set, sessionId is marshalled so the client can filter by session. + // 设置时 sessionId 会被序列化,使客户端能按会话过滤。 set, err := json.Marshal(wsEvent{Type: "stream_end", SessionID: "web", Data: map[string]any{"stopReason": "stop"}}) if err != nil { t.Fatalf("marshal set: %v", err) @@ -398,7 +398,7 @@ func TestWsEventSessionIDField(t *testing.T) { if !strings.Contains(string(set), `"sessionId":"web"`) { t.Fatalf("set frame = %s, want sessionId web", set) } - // When empty, omitempty drops it so existing clients/tests see no change. + // 为空时 omitempty 会丢弃它,因此既有客户端/测试看不到变化。 unset, err := json.Marshal(wsEvent{Type: "stream_end", Data: map[string]any{"stopReason": "stop"}}) if err != nil { t.Fatalf("marshal unset: %v", err) @@ -415,7 +415,7 @@ func TestWsEventSessionIDField(t *testing.T) { } } -// readWS reads one text frame and decodes it into a wsEvent. +// readWS 读取一个文本帧并解码进 wsEvent。 func readWS(t *testing.T, conn *websocket.Conn) wsEvent { t.Helper() _, msg, err := conn.ReadMessage() diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index 49e7927..e332160 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -13,46 +13,46 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// historySessionSummary is the wire shape of one persistent session (main or sub) in the -// GET /api/v1/sessions response. +// historySessionSummary 是 GET /api/v1/sessions 响应中一个持久化会话 +// (main 或 sub)的线上结构。 type historySessionSummary struct { ID string `json:"id"` Type string `json:"type"` // "main" | "sub" Title string `json:"title,omitempty"` CreatedAt int64 `json:"createdAt"` - // Pinned floats the session to the top of the list (D5). It is order metadata - // only and is always present so a client can render/sort without a second call. + // Pinned 把会话浮到列表顶部(D5)。它只是排序元数据, + // 且始终存在,使客户端无需第二次调用就能渲染/排序。 Pinned bool `json:"pinned"` - // LastMessage is a display-only preview of the session's newest message, so the - // client can render a conversation row (e.g. the Android session list) without a - // per-session request. It is omitted for a session with no messages. Content is - // already truncated by the store. + // LastMessage 是会话最新消息的纯展示预览,使客户端 + // 无需逐会话请求就能渲染对话行(例如 Android 会话列表)。 + // 没有消息的会话会省略它。内容 + // 已由存储截断。 LastMessage *historyLastMessage `json:"lastMessage,omitempty"` - // UnreadCount is the number of messages newer than the session's read cursor - // (D4), derived in the same list query. It is always returned (0 when nothing - // is unread) so a client can render/clear the badge without a second call. The - // cursor is session-scoped and single-owner: reading on any one end clears the - // badge for every end of the account. + // UnreadCount 是比会话读游标(D4)更新的消息数, + // 在同一次列表查询中推导。它始终返回(无未读时为 0), + // 使客户端无需第二次调用就能渲染/清除角标。该 + // 游标是会话作用域且单归属:在任一端已读会为 + // 该账号的每一端清除角标。 UnreadCount int `json:"unreadCount"` - // PersonaID is the role this session belongs to (design/22 §2/§4.5). It is the - // single source of truth for which role answers in the conversation, and the - // client uses it to render the role's name/avatar for the active session. - // Omitted when empty, and empty means "inherit the global default role", so an - // old client that ignores the field keeps showing the default role's visuals. + // PersonaID 是该会话所属的角色(design/22 §2/§4.5)。它是 + // 「对话中由哪个角色应答」的唯一事实来源,客户端 + // 用它为活动会话渲染角色的名字/头像。 + // 为空时省略,而空意味着「继承全局默认角色」,因此 + // 忽略该字段的旧客户端仍展示默认角色的视觉。 PersonaID string `json:"personaId,omitempty"` } -// historyLastMessage is the wire shape of the last-message preview in a session summary. +// historyLastMessage 是会话摘要中最后消息预览的线上结构。 type historyLastMessage struct { Role string `json:"role"` // "user" | "assistant" Content string `json:"content"` CreatedAt int64 `json:"createdAt"` } -// historyMessage is the wire shape of one persisted chat message in the -// GET /api/v1/sessions/{id}/messages response. Mode is carried so the frontend renders -// the bubble without guessing (design/16 §5.1); thinking is stored and returned to the -// owner/admin (the messages route is owner/admin-only, so it never reaches a viewer). +// historyMessage 是 GET /api/v1/sessions/{id}/messages 响应中一条持久化聊天 +// 消息的线上结构。Mode 被携带,使前端无需猜测就渲染 +// 气泡(design/16 §5.1);thinking 会被存储并返回给 +// owner/admin(消息路由仅 owner/admin,因此绝不触达 viewer)。 type historyMessage struct { ID string `json:"id"` SessionID string `json:"sessionId"` @@ -64,25 +64,25 @@ type historyMessage struct { Thinking string `json:"thinking,omitempty"` Platform string `json:"platform,omitempty"` DeviceID string `json:"deviceId,omitempty"` - // SenderName is the human-readable sender display name of a third-party - // message (group/stranger, design/16 §4.6). It is blank for the owner's - // private chat on the main session, so empty means "labeled as the owner". + // SenderName 是第三方消息(群聊/陌生人,design/16 §4.6) + // 给人看的发送者显示名。对主会话中主人的私聊它为 + // 空,因此空意味着「标记为主人」。 SenderName string `json:"senderName,omitempty"` - // AdapterID is the adapter (platform + account) registration name of the IM - // source that produced the message (e.g. "kook:3bef..."). It is blank for - // Web-originated messages. + // AdapterID 是产出该消息的 IM 来源适配器(平台 + 账号) + // 注册名(例如 "kook:3bef...")。对 Web 来源的消息 + // 为空。 AdapterID string `json:"adapterId,omitempty"` - // PersonaID is the role that SPOKE this message (design/22 §7-5). A session may - // switch roles while keeping its context, so the authoring role is stored and - // returned per message and the client renders the bubble with that role's - // name/avatar. Omitted when empty, and empty means "the global default role" — - // the pre-multi-character rows read exactly as before. + // PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以 + // 在保留上下文的同时切换角色,因此作者角色按消息 + // 存储并返回,客户端用该角色的名字/头像渲染 + // 气泡。为空时省略,而空意味着「全局默认角色」—— + // 多角色之前的行读起来与以往完全一致。 PersonaID string `json:"personaId,omitempty"` CreatedAt int64 `json:"createdAt"` } -// handleHistorySessions returns the main session first, then every sub session (oldest -// first). It is the chat-history session list for the frontend (design/16 §2.2). +// handleHistorySessions 先返回主会话,再返回每个子会话(最旧 +// 优先)。它是前端的聊天历史会话列表(design/16 §2.2)。 func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) { meta, err := s.history.ListSessions(r.Context()) if err != nil { @@ -106,8 +106,8 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// toHistoryLastMessage maps a stored last-message preview to its wire shape, returning -// nil for a session that has no messages (so the field is omitted from the JSON). +// toHistoryLastMessage 把已存的最后消息预览映射为其线上结构, +// 对没有消息的会话返回 nil(因此该字段从 JSON 中省略)。 func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage { if m == nil { return nil @@ -115,21 +115,21 @@ func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage { return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt} } -// createSubSessionRequest is the POST /api/v1/sessions body. The body itself is -// optional (a client that just wants a plain session sends none), and personaId is -// the optional role the new session is bound to (design/22 §4.5). It is the -// "seed the new session's role" half of the design's precedence: an explicit role -// here becomes the session-level binding (the single source of truth), while -// omitting it leaves persona_id = '' so the session inherits the global default. +// createSubSessionRequest 是 POST /api/v1/sessions 的请求体。请求体本身 +// 可选(只想要普通会话的客户端不发送任何内容),personaId 是 +// 新会话可选绑定的角色(design/22 §4.5)。它是设计的 +// 优先级中「为新会话播种角色」的那一半:此处显式给出的角色 +// 成为会话级绑定(唯一事实来源),而省略它则 +// 保持 persona_id = '',使会话继承全局默认。 type createSubSessionRequest struct { PersonaID string `json:"personaId"` } -// handleCreateSubSession creates a new sub session and returns its sessionId. It is a -// user-side write, so it is owner/admin-gated like /api/v1/chat. An optional -// {"personaId":"..."} body binds the new session to that role; an unknown id is -// rejected with 400 rather than creating a session that would silently answer as -// the default role. +// handleCreateSubSession 创建一个新的子会话并返回其 sessionId。它是 +// 用户侧写操作,因此与 /api/v1/chat 一样受 owner/admin 门控。可选的 +// {"personaId":"..."} 请求体把新会话绑定到该角色;未知 id 会 +// 以 400 拒绝,而不是创建一个会静默以 +// 默认角色应答的会话。 func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -165,22 +165,22 @@ func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusCreated, resp) } -// handleListMessages returns a page of messages for a session with cursor pagination. -// It is the hot-paging history endpoint (design/16 §2.2, §5.1). Two mutually exclusive -// cursors are supported: -// - before (millisecond createdAt): newest-first page of messages strictly older than -// before — the default, used to page backwards through history; -// - after (millisecond createdAt): oldest-first page of messages strictly newer than -// after — forward incremental catch-up, so a client that reconnected can pull -// everything it missed from the newest createdAt it already has, without the -// one-page limit of a backward walk. +// handleListMessages 用游标分页返回某会话的一页消息。 +// 它是热分页历史端点(design/16 §2.2、§5.1)。支持两个互斥的 +// 游标: +// - before(毫秒 createdAt):返回严格早于 before 的消息,最新优先 +// —— 默认方向,用于向后翻历史; +// - after(毫秒 createdAt):返回严格新于 after 的消息,最旧优先 +// —— 前向增量追赶,使已重连的客户端能拉取 +// 从其已有的最新 createdAt 起错过的全部内容,而不受 +// 向后翻的单页上限限制。 // -// limit defaults to 50 (clamped to 200) for both directions. platform filters the page -// by source platform in either direction. An optional q switches the page to a -// session-scoped content search (newest first, still honoring before/limit); q is -// mutually exclusive with after, and an empty q is ignored so plain paging is -// unchanged. Supplying both before and after (both > 0) answers 400, as does a -// non-numeric/negative after or an invalid limit/before. +// limit 两个方向都默认 50(上限 200)。platform 在任一方向按 +// 来源平台过滤该页。可选的 q 把该页切换为 +// 会话作用域的内容搜索(最新优先,仍遵守 before/limit);q 与 +// after 互斥,空 q 被忽略,因此普通分页 +// 不变。同时给出 before 与 after(二者都 > 0)返回 400, +// after 非数字/为负或 limit/before 非法时亦然。 func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -248,9 +248,9 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// parseAfterCursor reads the non-negative "after" query param (a millisecond createdAt -// cursor). An absent/empty param yields (0, true) — no forward bound. A non-numeric or -// negative value yields (0, false) so the caller answers 400. +// parseAfterCursor 读取非负的 "after" 查询参数(毫秒 createdAt +// 游标)。参数缺失/为空得到 (0, true) —— 无前向边界。非数字或 +// 负值得到 (0, false),使调用方返回 400。 func parseAfterCursor(r *http.Request) (int64, bool) { raw := r.URL.Query().Get("after") if raw == "" { @@ -263,10 +263,10 @@ func parseAfterCursor(r *http.Request) (int64, bool) { return v, true } -// handleChannelPlatformLog returns up to limit messages for an IM platform across its -// isolated (group/stranger) sessions, newest first, for the admin adapter log page -// (design/16 §4.6 §4). It excludes the fixed main web session, so only the platform's -// non-owner message stream is surfaced; the owner-private mirror stays out of this view. +// handleChannelPlatformLog 为某个 IM 平台跨其隔离(群聊/陌生人) +// 会话返回至多 limit 条消息,最新优先,供管理适配器日志页 +// (design/16 §4.6 §4)。它排除固定的主 Web 会话,因此只浮现该平台的 +// 非主人消息流;owner 私有镜像不进入该视图。 func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -309,13 +309,13 @@ func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, out) } -// handleChannelMessages returns up to limit messages for the admin adapter log page, -// newest first, across one of three granularities (design/16 §4.6 §4): a single -// adapter config (?adapter=kook:3bef...), a whole platform (?platform=kook), or the -// full IM adapter overview (neither, so adapter_id != ''). before/limit reuse the -// standard cursor pagination. The owner-private mirror on the main web session is -// NOT excluded here because it carries the adapter_id; each granularity filters on -// adapter_id / platform directly. +// handleChannelMessages 为管理适配器日志页返回至多 limit 条消息, +// 最新优先,覆盖三种粒度之一(design/16 §4.6 §4):单个 +// 适配器配置(?adapter=kook:3bef...)、整个平台(?platform=kook),或 +// 全部 IM 适配器总览(二者都不给,即 adapter_id != '')。before/limit 复用 +// 标准游标分页。主 Web 会话上的 owner 私有镜像在此 +// 不被排除,因为它携带 adapter_id;每种粒度直接按 +// adapter_id / platform 过滤。 func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -387,7 +387,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) } -// toHistoryMessage maps a stored history.Message to its wire shape. +// toHistoryMessage 把已存的 history.Message 映射为其线上结构。 func toHistoryMessage(m history.Message) historyMessage { return historyMessage{ ID: m.ID, @@ -407,5 +407,5 @@ func toHistoryMessage(m history.Message) historyMessage { } } -// historyDefaultLimit is the page size used when the client omits the limit query param. +// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。 const historyDefaultLimit = 50 diff --git a/internal/services/gateway/history_after_test.go b/internal/services/gateway/history_after_test.go index 534608d..ad4c592 100644 --- a/internal/services/gateway/history_after_test.go +++ b/internal/services/gateway/history_after_test.go @@ -9,9 +9,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// TestHistoryListMessagesAfterCursor covers the forward cursor (A5): after returns the -// strictly-newer messages oldest-first, before keeps its existing newest-first behavior, -// and the two cursors are mutually exclusive. +// TestHistoryListMessagesAfterCursor 覆盖前向游标(A5):after 返回 +// 严格更新的消息且最旧优先,before 保持既有的最新优先行为, +// 两个游标互斥。 func TestHistoryListMessagesAfterCursor(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -24,7 +24,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) { tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/messages" - // after=2000 → strictly newer, oldest first. + // after=2000 → 严格更新,最旧优先。 resp, body := doReq(t, http.MethodGet, base+"?after=2000", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("after status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -35,7 +35,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) { t.Fatalf("after=2000 = %+v, want ascending [3000 4000]", forward) } - // after at the newest message → empty page, still 200. + // after 设为最新消息 → 空页,仍为 200。 resp, body = doReq(t, http.MethodGet, base+"?after=4000", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("after newest status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -46,7 +46,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) { t.Fatalf("after=4000 = %+v, want empty", empty) } - // before behavior is unchanged: newest first, strictly older. + // before 行为不变:最新优先,严格更旧。 resp, body = doReq(t, http.MethodGet, base+"?before=3000&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("before status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -57,7 +57,7 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) { t.Fatalf("before=3000 = %+v, want descending [2000 1000]", backward) } - // after=0 is not a forward bound → falls back to the default newest-first page. + // after=0 不是前向边界 → 回退到默认的最新优先页。 resp, body = doReq(t, http.MethodGet, base+"?after=0&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("after=0 status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -69,8 +69,8 @@ func TestHistoryListMessagesAfterCursor(t *testing.T) { } } -// TestHistoryListMessagesAfterRejectsBadInput covers the 400s: both cursors together, -// and a negative or non-numeric after. +// TestHistoryListMessagesAfterRejectsBadInput 覆盖那些 400:两个游标同时 +// 给出,以及 after 为负或非数字。 func TestHistoryListMessagesAfterRejectsBadInput(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) @@ -78,9 +78,9 @@ func TestHistoryListMessagesAfterRejectsBadInput(t *testing.T) { base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/messages" for _, q := range []string{ - "?after=1000&before=3000", // both cursors - "?after=-1", // negative - "?after=abc", // non-numeric + "?after=1000&before=3000", // 两个游标都给 + "?after=-1", // 负数 + "?after=abc", // 非数字 } { resp, body := doReq(t, http.MethodGet, base+q, nil, tok) if resp.StatusCode != http.StatusBadRequest { @@ -89,8 +89,8 @@ func TestHistoryListMessagesAfterRejectsBadInput(t *testing.T) { } } -// TestHistoryListMessagesAfterPlatformFilter asserts the platform filter composes with -// the forward cursor. +// TestHistoryListMessagesAfterPlatformFilter 断言平台过滤与前向游标 +// 可以组合。 func TestHistoryListMessagesAfterPlatformFilter(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -119,8 +119,8 @@ func TestHistoryListMessagesAfterPlatformFilter(t *testing.T) { } } -// TestHistoryListMessagesAfterLimitClamp asserts the forward page is capped at 200 like -// the backward one, and that advancing the returned cursor pages forward without a gap. +// TestHistoryListMessagesAfterLimitClamp 断言前向页与后向一样上限 200, +// 且推进返回的游标能无缝隙地向前翻页。 func TestHistoryListMessagesAfterLimitClamp(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) diff --git a/internal/services/gateway/history_handler_test.go b/internal/services/gateway/history_handler_test.go index 2b65203..23b837a 100644 --- a/internal/services/gateway/history_handler_test.go +++ b/internal/services/gateway/history_handler_test.go @@ -16,13 +16,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// testLogger returns a logger that writes nowhere, keeping test output clean. +// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。 func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// newHistoryEnv builds a Server wired with a real in-memory history store, returning the -// httptest server, the JWT service and the underlying history store. +// newHistoryEnv 构建一个接线了真实内存 history 存储的 Server,返回 +// httptest 服务器、JWT 服务与底层 history 存储。 func newHistoryEnv(t *testing.T) (*Server, *httptest.Server, *auth.JWTService, *history.Store) { t.Helper() ctx := context.Background() @@ -39,7 +39,7 @@ func newHistoryEnv(t *testing.T) (*Server, *httptest.Server, *auth.JWTService, * return srv, ts, jwt, st } -// decodeDataSlice decodes the {data:[...]} envelope into the given slice target. +// decodeDataSlice 把 {data:[...]} 信封解码进给定的切片目标。 func decodeDataSlice(t *testing.T, body []byte, out any) { t.Helper() if err := json.Unmarshal(decodeBody(t, body)["data"], out); err != nil { @@ -52,7 +52,7 @@ func TestHistoryCreateAndListSessions(t *testing.T) { _, ts, jwt, _ := newHistoryEnv(t) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Fresh store always lists the main session. + // 全新存储总会列出主会话。 resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -63,7 +63,7 @@ func TestHistoryCreateAndListSessions(t *testing.T) { t.Fatalf("initial sessions = %+v, want [main web]", initial) } - // Create a sub session. + // 创建一个子会话。 resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body) @@ -78,7 +78,7 @@ func TestHistoryCreateAndListSessions(t *testing.T) { t.Fatalf("sessionId = %q, want prefix web:", created.SessionID) } - // Now main first, then the sub. + // 现在 main 在前,sub 在后。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -97,7 +97,7 @@ func TestHistoryListMessagesPagination(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) ctx := context.Background() - // Seed the main session with messages at ascending ms timestamps. + // 用毫秒时间戳递增的消息播种主会话。 for _, ts := range []int64{1000, 2000, 3000, 4000} { if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: ts}); err != nil { t.Fatalf("seed %d: %v", ts, err) @@ -105,7 +105,7 @@ func TestHistoryListMessagesPagination(t *testing.T) { } tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Newest page of 2. + // 最新优先的一页,2 条。 resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("first page status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -116,7 +116,7 @@ func TestHistoryListMessagesPagination(t *testing.T) { t.Fatalf("first page = %+v, want [4000 3000]", page) } - // Cursor before the page's oldest (3000) → older page [2000 1000]. + // 游标设在该页最旧(3000)之前 → 更旧的一页 [2000 1000]。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?before=3000&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("older page status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -127,7 +127,7 @@ func TestHistoryListMessagesPagination(t *testing.T) { t.Fatalf("older page = %+v, want [2000 1000]", older) } - // Invalid limit/before → 400. + // 非法 limit/before → 400。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=abc", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("invalid limit status = %d, want 400 (body=%s)", resp.StatusCode, body) @@ -146,7 +146,7 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) { } tok := signAny(t, jwt, "acct", auth.RoleOwner) - // No platform → the full main session (both lanes). + // 不带 platform → 完整的主会话(两条通道)。 resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -157,7 +157,7 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) { t.Fatalf("all = %d, want 2", len(all)) } - // ?platform=telegram → only the telegram lane, and the DTO carries platform. + // ?platform=telegram → 只有 telegram 通道,且 DTO 携带 platform。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?limit=10&platform=telegram", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -169,10 +169,10 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) { } } -// handleChannelMessagesGranularity exercises the admin adapter log endpoint at each -// granularity: a single adapter config (?adapter=), a whole platform (?platform=), -// and the full IM adapter overview (neither). It asserts adapterId is carried on the -// DTO so the panel can label the source adapter config. +// handleChannelMessagesGranularity 在每种粒度上演练管理适配器日志 +// 端点:单个适配器配置(?adapter=)、整个平台(?platform=)、 +// 以及全部 IM 适配器总览(二者都不给)。它断言 DTO 上携带 +// adapterId,使面板能标注来源适配器配置。 func TestHistoryChannelMessagesGranularity(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -189,8 +189,8 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) { } tok := signAny(t, jwt, "acct", auth.RoleOwner) - // ?adapter=kook:3bef → only that adapter config, both the owner mirror and the - // group message, newest first, and the DTO carries adapterId. + // ?adapter=kook:3bef → 只有该适配器配置,包含 owner 镜像与 + // 群消息,最新优先,且 DTO 携带 adapterId。 resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?adapter=kook:3bef", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("adapter status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -207,8 +207,8 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) { t.Fatalf("adapter[1] = %+v, want kook-owner on main session", byAdapter[1]) } - // ?platform=kook → the platform lane across isolated sessions (owner-private - // mirror excluded, matching handleChannelPlatformLog semantics). + // ?platform=kook → 跨隔离会话的平台通道(owner 私有 + // 镜像被排除,与 handleChannelPlatformLog 语义一致)。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?platform=kook", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -219,7 +219,7 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) { t.Fatalf("platform page = %+v, want [kook-group]", byPlatform) } - // No filter → the full IM adapter overview (adapter_id != ''), web excluded. + // 无过滤 → 全部 IM 适配器总览(adapter_id != ''),web 被排除。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -233,7 +233,7 @@ func TestHistoryChannelMessagesGranularity(t *testing.T) { t.Fatalf("all order = %+v, want [tg-group kook-group kook-owner]", all) } - // Invalid before → 400. + // 非法 before → 400。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?before=abc", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("invalid before status = %d, want 400 (body=%s)", resp.StatusCode, body) @@ -246,7 +246,7 @@ func TestHistoryDeleteSubSession(t *testing.T) { ctx := context.Background() tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Create a sub and seed a message. + // 创建一个子会话并播种一条消息。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body) @@ -261,12 +261,12 @@ func TestHistoryDeleteSubSession(t *testing.T) { t.Fatalf("seed sub: %v", err) } - // Delete it. + // 删除它。 resp, _ = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+created.SessionID, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200", resp.StatusCode) } - // It is gone from the list. + // 它已从列表中消失。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200", resp.StatusCode) @@ -277,7 +277,7 @@ func TestHistoryDeleteSubSession(t *testing.T) { t.Fatalf("sessions after delete = %+v, want [main]", sessions) } - // Main session is immutable → 403. + // 主会话不可变 → 403。 resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+history.MainSessionID, nil, tok) if resp.StatusCode != http.StatusForbidden { t.Fatalf("delete main status = %d, want 403 (body=%s)", resp.StatusCode, body) @@ -285,14 +285,14 @@ func TestHistoryDeleteSubSession(t *testing.T) { wantErrorShape(t, body) } -// TestHistoryRenameSession covers PATCH /api/v1/sessions/{id}: renaming a sub and -// the fixed main session, the input bounds, RBAC and an unknown id. +// TestHistoryRenameSession 覆盖 PATCH /api/v1/sessions/{id}:重命名子会话 +// 与固定的主会话、输入边界、RBAC 以及未知 id。 func TestHistoryRenameSession(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Create a sub session to rename. + // 创建一个待重命名的子会话。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body) @@ -304,7 +304,7 @@ func TestHistoryRenameSession(t *testing.T) { t.Fatalf("decode create data: %v", err) } - // Rename it and assert the response envelope plus the persisted list. + // 重命名它并断言响应信封与持久化后的列表。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": " 我的会话 "}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -338,7 +338,7 @@ func TestHistoryRenameSession(t *testing.T) { t.Fatalf("renamed sub %s missing from %+v", created.SessionID, sessions) } - // The fixed main session may be renamed even though it cannot be deleted. + // 固定的主会话即使不能删除也可以被重命名。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+history.MainSessionID, map[string]any{"title": "主控台"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("rename main status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -354,7 +354,7 @@ func TestHistoryRenameSession(t *testing.T) { t.Fatalf("main rename data = %+v, want id web title 主控台", mainRenamed) } - // Empty / whitespace-only / over-long titles are 400. + // 空 / 仅空白 / 过长的标题都是 400。 for _, bad := range []string{"", " ", strings.Repeat("字", maxSessionTitleRunes+1)} { resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": bad}, tok) if resp.StatusCode != http.StatusBadRequest { @@ -363,20 +363,20 @@ func TestHistoryRenameSession(t *testing.T) { wantErrorShape(t, body) } - // An unknown session id is 404. + // 未知会话 id 是 404。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"title": "x"}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown rename status = %d, want 404 (body=%s)", resp.StatusCode, body) } - // A title of exactly the 80-rune bound is accepted. + // 恰好为 80 个 rune 边界的标题被接受。 exact := strings.Repeat("字", maxSessionTitleRunes) resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": exact}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("80-rune rename status = %d, want 200 (body=%s)", resp.StatusCode, body) } - // A viewer cannot rename (owner/admin write). + // viewer 不能重命名(owner/admin 写)。 viewer := signAny(t, jwt, "acct", auth.RoleViewer) resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "nope"}, viewer) if resp.StatusCode != http.StatusForbidden { @@ -384,17 +384,17 @@ func TestHistoryRenameSession(t *testing.T) { } } -// TestPersistUserMessageNamesSession exercises the Web persistence path end to end: -// persistUserMessages (the orchestrator hook the HTTP/WS boundary calls) writes the -// first user message through the real store, which names the session. A later user -// message leaves the title alone. +// TestPersistUserMessageNamesSession 端到端演练 Web 持久化路径: +// persistUserMessages(HTTP/WS 边界所调用的编排器钩子)经真实 +// 存储写入首条用户消息,从而命名该会话。之后的用户 +// 消息不再改标题。 func TestPersistUserMessageNamesSession(t *testing.T) { t.Parallel() _, _, _, st := newHistoryEnv(t) o := &Orchestrator{history: st, log: testLogger()} ctx := context.Background() - // A sub session starts nameless; the first user message names it. + // 子会话起始无名;首条用户消息为它命名。 sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) @@ -416,7 +416,7 @@ func TestPersistUserMessageNamesSession(t *testing.T) { t.Fatalf("sub title = %q, want 帮我规划一次旅行", gotSub) } - // The main session is named from its first Web user message too. + // 主会话同样由它的首条 Web 用户消息命名。 o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat", "cyrene") metas, err = st.ListSessions(ctx) if err != nil { @@ -438,7 +438,7 @@ func TestHistoryNotWired(t *testing.T) { _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Messages/create/delete answer a clear 501 when no history store is wired. + // 未接线 history 存储时,取消息/创建/删除返回明确的 501。 for _, tc := range []struct { method, url string want int @@ -463,7 +463,7 @@ func TestHistoryRBAC(t *testing.T) { viewer := signAny(t, jwt, "acct", auth.RoleViewer) owner := signAny(t, jwt, "acct", auth.RoleOwner) - // Viewer can list session metadata (readAuth) but not read/write history content. + // viewer 可以列出会话元数据(readAuth),但不能读写历史内容。 resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, viewer) if resp.StatusCode != http.StatusOK { t.Fatalf("viewer list sessions status = %d, want 200", resp.StatusCode) @@ -476,7 +476,7 @@ func TestHistoryRBAC(t *testing.T) { if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer create status = %d, want 403 (body=%s)", resp.StatusCode, body) } - // Owner can read messages. + // owner 可以读取消息。 resp, _ = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/web/messages?limit=2", nil, owner) if resp.StatusCode != http.StatusOK { t.Fatalf("owner messages status = %d, want 200", resp.StatusCode) diff --git a/internal/services/gateway/history_lastmessage_test.go b/internal/services/gateway/history_lastmessage_test.go index 3cde0f6..a643947 100644 --- a/internal/services/gateway/history_lastmessage_test.go +++ b/internal/services/gateway/history_lastmessage_test.go @@ -9,10 +9,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// TestHistorySessionsLastMessage asserts the GET /api/v1/sessions wire contract for the -// last-message preview (B3): every session carries lastMessage {role,content,createdAt} -// with the newest message's whitespace-collapsed content, and a message-less session -// omits the field. +// TestHistorySessionsLastMessage 断言 GET /api/v1/sessions 中最后消息预览的 +// 线上契约(B3):每个会话都携带 lastMessage {role,content,createdAt}, +// 其 content 是折叠空白后的最新消息内容,而没有消息的会话 +// 省略该字段。 func TestHistorySessionsLastMessage(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -33,8 +33,8 @@ func TestHistorySessionsLastMessage(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } - // Decode into a JSON-tagged shape so the wire contract (key names) is asserted, - // not just the in-package struct. + // 解码进带 JSON 标签的形状,因此断言的是线上契约(键名), + // 而不只是包内结构体。 var sessions []struct { ID string `json:"id"` LastMessage *struct { @@ -51,7 +51,7 @@ func TestHistorySessionsLastMessage(t *testing.T) { if main == nil || main.Role != history.RoleAssistant || main.Content != "在的 请说" || main.CreatedAt != 2000 { t.Fatalf("main lastMessage = %+v, want assistant/在的 请说/2000", main) } - // The empty sub session omits lastMessage entirely. + // 空的子会话完全省略 lastMessage。 if sessions[1].ID != sub { t.Fatalf("sessions[1].id = %q, want %q", sessions[1].ID, sub) } diff --git a/internal/services/gateway/history_messages_test.go b/internal/services/gateway/history_messages_test.go index 82c440d..21092e5 100644 --- a/internal/services/gateway/history_messages_test.go +++ b/internal/services/gateway/history_messages_test.go @@ -11,8 +11,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// seedFullMessage appends m through the real store and returns the stored message -// (with its generated id), so HTTP tests can address a specific message. +// seedFullMessage 经真实存储追加 m 并返回已存消息 +// (含其生成的 id),使 HTTP 测试能定位到具体某条消息。 func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history.Message { t.Helper() got, err := st.AppendFull(context.Background(), m) @@ -22,8 +22,8 @@ func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history return got } -// TestHistorySearchMessagesHTTP covers D2: session-scoped ?q= search hits, paging, -// the empty-q fallback, input validation and isolation from another session. +// TestHistorySearchMessagesHTTP 覆盖 D2:会话作用域的 ?q= 搜索命中、分页、 +// 空 q 回退、输入校验以及与其他会话的隔离。 func TestHistorySearchMessagesHTTP(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -39,7 +39,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { t.Fatalf("seed %q: %v", m.Content, err) } } - // A matching message in another (sub) session must never surface in the main search. + // 另一个(子)会话中的匹配消息绝不得出现在主会话搜索中。 sub, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("new sub: %v", err) @@ -49,7 +49,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { } tok := signAny(t, jwt, "acct", auth.RoleOwner) - // Newest-first page of 2 matching hits. + // 最新优先的一页,2 条匹配命中。 resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("search status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -63,7 +63,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { t.Fatalf("search leaked session %q", page[0].SessionID) } - // before cursor pages to the older hits. + // before 游标翻到更旧的命中。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&before=3000&limit=2", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("search older status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -74,7 +74,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { t.Fatalf("search older = %+v, want [2000 1000]", older) } - // The sub session's search sees only its own hit. + // 子会话的搜索只看到自己的命中。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages?q=天气", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("sub search status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -85,7 +85,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { t.Fatalf("sub search = %+v, want only the sub hit", subHits) } - // An empty q falls back to plain paging (all messages). + // 空 q 回退到普通分页(全部消息)。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=&limit=10", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("empty q status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -96,7 +96,7 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { t.Fatalf("empty q = %d messages, want 5", len(all)) } - // q is mutually exclusive with after, and invalid limit still 400. + // q 与 after 互斥,且非法 limit 仍是 400。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&after=1000", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("q+after status = %d, want 400 (body=%s)", resp.StatusCode, body) @@ -107,8 +107,8 @@ func TestHistorySearchMessagesHTTP(t *testing.T) { } } -// TestHistoryPatchAndDeleteMessageHTTP covers D3: editing and deleting one message, -// the input bounds, 404 and RBAC. +// TestHistoryPatchAndDeleteMessageHTTP 覆盖 D3:编辑与删除单条消息、 +// 输入边界、404 与 RBAC。 func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -116,7 +116,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/messages/" + msg.ID - // Edit the content; role/timestamp/session are preserved and echoed back. + // 编辑 content;role/时间戳/会话保持不变并回显。 resp, body := doReq(t, http.MethodPatch, base, map[string]any{"content": "改后的正文"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -129,7 +129,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { t.Fatalf("updated = %+v, want content edited with role/ts preserved", updated) } - // The edit is persisted. + // 该编辑已被持久化。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -140,7 +140,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { t.Fatalf("listed = %+v, want the edited content", listed) } - // Invalid edits: empty/whitespace, missing field, over-long, unknown id. + // 非法编辑:空/仅空白、字段缺失、过长、未知 id。 for _, tc := range []struct { name string body any @@ -162,7 +162,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { t.Fatalf("patch unknown status = %d, want 404 (body=%s)", resp.StatusCode, body) } - // Delete the message, then deleting again is 404. + // 删除该消息,再次删除则是 404。 resp, body = doReq(t, http.MethodDelete, base, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -181,7 +181,7 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { t.Fatalf("delete again status = %d, want 404 (body=%s)", resp.StatusCode, body) } - // A viewer cannot edit or delete (owner/admin write). + // viewer 不能编辑或删除(owner/admin 写)。 other := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: 2000}) viewer := signAny(t, jwt, "acct", auth.RoleViewer) for _, method := range []string{http.MethodPatch, http.MethodDelete} { @@ -192,8 +192,8 @@ func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) { } } -// TestHistoryMessagesNotWiredHTTP asserts the D2/D3 endpoints answer a clear 501 when -// no HistoryStore is injected. +// TestHistoryMessagesNotWiredHTTP 断言未注入 HistoryStore 时 D2/D3 端点 +// 返回明确的 501。 func TestHistoryMessagesNotWiredHTTP(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -215,8 +215,8 @@ func TestHistoryMessagesNotWiredHTTP(t *testing.T) { } } -// TestHistoryPatchSessionPinnedHTTP covers D5: pin/unpin changes GET /sessions order, -// the response echoes only the changed field, and the A4 title PATCH still works. +// TestHistoryPatchSessionPinnedHTTP 覆盖 D5:置顶/取消置顶改变 GET /sessions 顺序, +// 响应只回显被改动的字段,且 A4 的标题 PATCH 仍然可用。 func TestHistoryPatchSessionPinnedHTTP(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) @@ -265,8 +265,8 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) { assertOrder([]string{history.MainSessionID, first, second}) - // Pin the newest sub: it floats above the main session, and the response echoes - // only the pinned field. + // 置顶最新的子会话:它会浮到主会话之上,且响应只回显 + // 被置顶的字段。 resp, body := doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("pin status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -284,7 +284,7 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) { } assertOrder([]string{second, history.MainSessionID, first}) - // GET /sessions reports the pin flag. + // GET /sessions 报告置顶标志。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -295,7 +295,7 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) { t.Fatalf("sessions[0] = %+v, want pinned sub %s", sessions[0], second) } - // A title PATCH still works and keeps its {id,title} response shape. + // 标题 PATCH 仍然可用,并保持其 {id,title} 响应形状。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"title": " 置顶会话 "}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("title patch status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -311,14 +311,14 @@ func TestHistoryPatchSessionPinnedHTTP(t *testing.T) { t.Fatalf("title patch = %+v, want 置顶会话", renamed) } - // Unpin restores the baseline order. + // 取消置顶恢复基线顺序。 resp, _ = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": false}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("unpin status = %d, want 200", resp.StatusCode) } assertOrder([]string{history.MainSessionID, first, second}) - // Neither field → 400; unknown id → 404; viewer → 403. + // 两个字段都不给 → 400;未知 id → 404;viewer → 403。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body) diff --git a/internal/services/gateway/history_persona_test.go b/internal/services/gateway/history_persona_test.go index 5e4abf5..ed3349b 100644 --- a/internal/services/gateway/history_persona_test.go +++ b/internal/services/gateway/history_persona_test.go @@ -17,15 +17,15 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file covers the gateway half of design/22 (多角色并存 + 按会话选角): the REST -// contract for binding a session to a role, and the resolver that turns a -// sessionID into the loop environment the turn runs under. The chain asserted end to +// 本文件覆盖 design/22(多角色并存 + 按会话选角)的网关侧一半:把 +// 会话绑定到角色的 REST 契约,以及把 sessionID 转成该回合 +// 所运行的 loop 环境的解析器。端到端断言的链条是: // end is: sessionID → chat_sessions.persona_id → turnEnvResolver → loop.SessionEnv → -// per-role agent (system prompt), so a regression at any hop shows up as the wrong -// role answering. +// 按角色的 agent(system prompt),因此任一环节回归都会表现为 +// 错误的角色在应答。 -// personaAdapter records every request the loop built, so a test can assert which -// role's system prompt actually reached the model. +// personaAdapter 记录 loop 构建的每一个请求,使测试能断言 +// 究竟是哪个角色的 system prompt 抵达了模型。 type personaAdapter struct { reqs []model.Request } @@ -41,8 +41,8 @@ func (p *personaAdapter) ChatStream(ctx context.Context, req model.Request, _ fu return p.Chat(ctx, req, model.Options{}) } -// personaTestRegistry registers two roles (one default) so the gateway can validate -// personaId values and resolve the default role. +// personaTestRegistry 注册两个角色(其中一个是默认),使网关能校验 +// personaId 值并解析默认角色。 func personaTestRegistry(t *testing.T) *personareg.Registry { t.Helper() reg := personareg.New() @@ -63,7 +63,7 @@ func personaTestRegistry(t *testing.T) *personareg.Registry { return reg } -// personaTestStore opens an in-memory chat-history store. +// personaTestStore 打开一个内存聊天历史存储。 func personaTestStore(t *testing.T) *history.Store { t.Helper() ctx := context.Background() @@ -79,8 +79,8 @@ func personaTestStore(t *testing.T) *history.Store { return st } -// gatewayAgentFor builds the per-role agent the composition root would build: the -// same adapter, a role-specific instruction string. +// gatewayAgentFor 构建组合根会构建的按角色 agent:同一个 +// 适配器,角色专属的指令字符串。 func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent.Agent { t.Helper() ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil)) @@ -90,12 +90,12 @@ func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent return ag } -// TestChatTurnAnswersAsTheBoundRole is design/22 acceptance 3 seen from the process -// boundary: a sub session created and bound to role Y answers as Y (the first system -// message is Y's prompt and the persisted messages carry Y). Its role is fixed at -// creation (§8-1), so the PATCH that used to unbind it is refused and the session -// keeps answering as Y; the only way to get the default role is a session created -// without a binding. +// TestChatTurnAnswersAsTheBoundRole 是从进程边界看 design/22 验收 3: +// 一个创建并绑定到角色 Y 的子会话以 Y 应答(首条 system +// 消息是 Y 的提示词,且持久化的消息携带 Y)。其角色在 +// 创建时即固定(§8-1),因此此前用于解绑的 PATCH 被拒绝,会话 +// 继续以 Y 应答;要得到默认角色,唯一的办法是创建 +// 一个不带绑定的会话。 func TestChatTurnAnswersAsTheBoundRole(t *testing.T) { t.Parallel() ctx := context.Background() @@ -128,8 +128,8 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) { t.Fatalf("bound session prompt = %q, want role Y's prompt", got) } - // The orchestrator files both the user turn and the reply under the turn's role, - // which is what makes the stored transcript readable role-by-role (design/22 §7-5). + // 编排器把用户回合与回复都归到该回合的角色名下, + // 这正是让已存转录能按角色阅读的原因(design/22 §7-5)。 msgs, err := st.List(ctx, sub, 0, 10, "") if err != nil { t.Fatalf("list messages: %v", err) @@ -143,8 +143,8 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) { } } - // Unbinding is refused (design/22 §8-1): a session's role is decided when it is - // created, so this PATCH answers 400 and the very next turn still answers as Y. + // 解绑被拒绝(design/22 §8-1):会话的角色在创建时 + // 决定,因此该 PATCH 返回 400,紧接着的下一个回合仍以 Y 应答。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+sub, map[string]any{"personaId": ""}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unbind status = %d, want 400 (a session's role is immutable; body=%s)", resp.StatusCode, body) @@ -157,8 +157,8 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) { t.Fatalf("prompt after the refused unbind = %q, want role Y's prompt", got) } - // The default role is reached by a session created WITHOUT a binding — the only - // way to change role now. + // 默认角色要通过创建时不带绑定的会话才能抵达 —— 这是 + // 目前改变角色的唯一方式。 unbound, err := st.NewSubSession(ctx, "owner") if err != nil { t.Fatalf("NewSubSession: %v", err) @@ -172,7 +172,7 @@ func TestChatTurnAnswersAsTheBoundRole(t *testing.T) { } } -// firstSystemText returns the text of the first system message of req. +// firstSystemText 返回 req 中首条 system 消息的文本。 func firstSystemText(req model.Request) string { for _, m := range req.Messages { if m.Role != model.RoleSystem { @@ -187,12 +187,12 @@ func firstSystemText(req model.Request) string { return "" } -// TestSessionPersonaHTTPContract covers the REST contract of design/22 §4.5 as -// amended by §8-1: create with a personaId binds it, the session list reports it, an -// unknown personaId on create is rejected (400) instead of binding a role that cannot -// answer, a PATCH with neither title nor pinned is a 400, and PATCH REFUSES the -// personaId key for every value — a session's role is fixed at creation, so there is -// no supported rebind and the refusal can never be mistaken for a silent no-op. +// TestSessionPersonaHTTPContract 覆盖经 §8-1 修订后的 design/22 §4.5 的 +// REST 契约:带 personaId 创建即绑定它、会话列表报告它、 +// 创建时的未知 personaId 被拒绝(400)而不是绑定一个无法 +// 应答的角色、既无 title 也无 pinned 的 PATCH 是 400,且 PATCH 对 +// personaId 键的任何取值一律拒绝 —— 会话角色在创建时固定, +// 因此没有受支持的重绑,该拒绝绝不会被误认为静默无操作。 func TestSessionPersonaHTTPContract(t *testing.T) { t.Parallel() st := personaTestStore(t) @@ -215,7 +215,7 @@ func TestSessionPersonaHTTPContract(t *testing.T) { t.Fatalf("create echoed personaId = %q, want yuki", created.PersonaID) } - // The session list surfaces the binding (the client renders the active role from it). + // 会话列表浮现该绑定(客户端据此渲染活动角色)。 var sessions []historySessionSummary _, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) decodeDataSlice(t, body, &sessions) @@ -229,23 +229,23 @@ func TestSessionPersonaHTTPContract(t *testing.T) { t.Fatalf("listed persona_id = %q, want yuki (sessions=%+v)", found, sessions) } - // An unknown role on create is refused rather than silently ignored. + // 创建时的未知角色被拒绝,而不是被静默忽略。 resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "nobody"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("create with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body) } - // A body with none of the supported fields stays a 400. + // 不含任何受支持字段的请求体仍是 400。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body) } - // PATCH refuses the personaId key for EVERY value (design/22 §8-1): the role is - // fixed when the session is created, so a rebind — to another role, to the bound - // role, to "", or to null — is not a supported operation. Each refusal must name - // personaId (so a client can tell it from "no field given") and must leave the - // stored binding exactly as it was. + // PATCH 对 personaId 键的任何取值都拒绝(design/22 §8-1):角色在 + // 会话创建时固定,因此重绑 —— 绑到另一个角色、绑到已绑 + // 角色、绑到 ""、或绑到 null —— 都不是受支持的操作。每次拒绝都必须点明 + // personaId(使客户端能与「未给出字段」区分),并且必须让 + // 已存绑定保持原样。 for _, bad := range []any{"xilian", "yuki", "nobody", "", " ", nil} { resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": bad}, tok) if resp.StatusCode != http.StatusBadRequest { @@ -259,8 +259,8 @@ func TestSessionPersonaHTTPContract(t *testing.T) { } } - // The refusal beats a legal field in the same body: a client cannot smuggle a - // rebind alongside a rename, and the rename must not land either. + // 该拒绝压过同一请求体中的合法字段:客户端不能借改名 + // 夹带重绑,而改名也绝不能生效。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "偷偷改名", "personaId": "xilian"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("rename+personaId status = %d, want 400 (body=%s)", resp.StatusCode, body) @@ -278,12 +278,12 @@ func TestSessionPersonaHTTPContract(t *testing.T) { } } - // The personaId check runs BEFORE the session lookup: an unknown session carrying - // the key answers 400 (immutable role), not 404 — while a title-only patch for the - // very same unknown id is still the store's 404, which is what proves the 400 above - // comes from the persona rule rather than from a missing session. An empty body is - // the third corner of the same table: 400 for a missing field, again without ever - // looking the session up. + // personaId 检查在会话查找之前执行:携带该键的未知会话 + // 返回 400(角色不可变),而不是 404 —— 而针对同一个未知 id + // 的纯标题补丁仍是存储的 404,这正是证明上面的 400 + // 来自人格规则而非会话缺失的依据。空请求体是 + // 同一张表的第三个角:字段缺失返回 400,同样 + // 完全不查找会话。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"personaId": "xilian"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unknown session + personaId status = %d, want 400 (body=%s)", resp.StatusCode, body) @@ -297,7 +297,7 @@ func TestSessionPersonaHTTPContract(t *testing.T) { t.Fatalf("unknown session + title status = %d, want 404 (body=%s)", resp.StatusCode, body) } - // title/pinned are untouched by the refusal. + // title/pinned 不受该拒绝影响。 resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"title": "新名字"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("title-only patch status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -307,8 +307,8 @@ func TestSessionPersonaHTTPContract(t *testing.T) { } } -// personaBindingOf reads a session's bound role straight from the store, so the test -// asserts the persisted state rather than only the response echo. +// personaBindingOf 直接从存储读取会话的绑定角色,因此测试 +// 断言的是持久化状态,而不只是响应回显。 func personaBindingOf(t *testing.T, st *history.Store, sessionID string) string { t.Helper() personaID, err := st.SessionPersona(context.Background(), sessionID) @@ -318,10 +318,10 @@ func personaBindingOf(t *testing.T, st *history.Store, sessionID string) string return personaID } -// TestTurnEnvResolver covers the resolver itself (design/22 §4.5): the session's -// binding wins, an unknown/unbound session yields "" (so the loop falls back to the -// global default), and the identity/memory fields stay the owner-full ones — the -// role never changes what a turn is allowed to do. +// TestTurnEnvResolver 覆盖解析器本身(design/22 §4.5):会话的 +// 绑定胜出,未知/未绑定的会话得到 ""(使 loop 回退到 +// 全局默认),且身份/记忆字段保持 owner 全量 —— +// 角色绝不改变一个回合被允许做什么。 func TestTurnEnvResolver(t *testing.T) { t.Parallel() ctx := context.Background() @@ -352,7 +352,7 @@ func TestTurnEnvResolver(t *testing.T) { t.Fatalf("unknown session env persona = %q, want '' (inherit the default)", got) } - // A gateway without a history store cannot resolve a binding and must not fail. + // 没有 history 存储的网关无法解析绑定,也绝不能失败。 bare := turnEnvResolver{log: testLogger()} if got := bare.owner(ctx, "web").PersonaID; got != "" { t.Fatalf("no-store env persona = %q, want ''", got) @@ -362,9 +362,9 @@ func TestTurnEnvResolver(t *testing.T) { } } -// TestDefaultPersonaIDFromRegistry locks the admin fallback design/22 §4.5 asks for: -// the default role is read from the library's default character instead of a -// hardcoded "cyrene", and an unwired registry reports none. +// TestDefaultPersonaIDFromRegistry 锁定 design/22 §4.5 要求的管理回退: +// 默认角色从库的默认角色读取,而不是硬编码的 +// "cyrene",且未接线的注册表报告无默认。 func TestDefaultPersonaIDFromRegistry(t *testing.T) { t.Parallel() srv, _, _ := buildEnv(t, cfgDefault(), Deps{Personas: personaTestRegistry(t)}) diff --git a/internal/services/gateway/history_write.go b/internal/services/gateway/history_write.go index eeda246..b9d1cef 100644 --- a/internal/services/gateway/history_write.go +++ b/internal/services/gateway/history_write.go @@ -12,47 +12,47 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// This file owns the session/message write endpoints added for the Android -// collaboration batch ③a: PATCH /sessions/{id} (A4 rename + D5 pin), PATCH -// /messages/{id} (D3 edit) and DELETE /messages/{id} (D3 delete). All are owner/admin -// history writes; the reads and DTOs live in history.go. The D3 edit/delete paths also -// broadcast their outcome over the WS hub (message_edited / message_deleted, see -// ws_hub.go) so the account's other browsers stay in sync without a manual re-read. +// 本文件拥有为 Android 协作批次 ③a 新增的会话/消息写端点: +// PATCH /sessions/{id}(A4 改名 + D5 置顶)、PATCH +// /messages/{id}(D3 编辑)与 DELETE /messages/{id}(D3 删除)。它们都是 owner/admin +// 历史写;读与 DTO 位于 history.go。D3 的编辑/删除路径还会 +// 经 WS hub 广播其结果(message_edited / message_deleted,见 +// ws_hub.go),使该账号的其他浏览器无需手动重读即保持同步。 -// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. Both fields are -// pointers so an absent field is distinguishable from an explicit zero value: -// title renames the session (A4), pinned sets/unets the pin (D5), and at least one -// must be present. +// patchSessionRequest 是 PATCH /api/v1/sessions/{id} 的请求体。两个字段都是 +// 指针,因此字段缺失与显式零值可区分:title 重命名该 +// 会话(A4),pinned 设置/取消置顶(D5),且至少一个 +// 必须出现。 // -// personaId is deliberately NOT a settable field: a session's role is fixed when the -// session is created (design/22 §8-1), so the key is rejected outright instead of -// being ignored. It stays in the struct as the raw JSON so the handler can tell -// "the key is absent" from "the key is present with any value" — `"yuki"`, `""` and -// `null` are all refused, and only a body without the key is accepted. +// personaId 刻意不是可设置字段:会话角色在会话创建时 +// 固定(design/22 §8-1),因此该键会被直接拒绝,而不是 +// 被忽略。它以原始 JSON 留在结构体中,使 handler 能区分 +// 「键缺失」与「键存在且为任意值」—— `"yuki"`、`""` 与 +// `null` 一律被拒,只有不含该键的请求体才被接受。 type patchSessionRequest struct { Title *string `json:"title"` Pinned *bool `json:"pinned"` PersonaID json.RawMessage `json:"personaId"` } -// maxSessionTitleRunes bounds a client-supplied session title so a display row never -// overflows. It is a display-only bound: identity and permissions never derive from -// the title (project red line). +// maxSessionTitleRunes 限定客户端提供的会话标题,使显示行绝不 +// 溢出。它只是显示边界:身份与权限绝不派生自 +// 标题(项目红线)。 const maxSessionTitleRunes = 80 -// handlePatchSession updates a persistent session's display metadata — its title -// (A4) and its pinned flag (D5). Both are metadata: unlike DELETE they are allowed -// on the fixed main session. A request carrying neither field answers 400, as do an -// empty/whitespace or over-long title and an unknown session id (404/500 as usual). +// handlePatchSession 更新持久化会话的显示元数据 —— 其标题 +// (A4)与置顶标志(D5)。二者都是元数据:与 DELETE 不同,它们 +// 允许作用于固定的主会话。两个字段都不携带的请求返回 400, +// 空/仅空白或过长的标题亦然,未知会话 id(照常 404/500)。 // -// A body carrying the personaId key answers 400 for EVERY value, and that check runs -// before the session is looked up: a session's role is fixed at creation (design/22 -// §8-1), so switching the role of an existing conversation — including clearing it — -// is not a supported operation; create a new session instead. Refusing explicitly -// (rather than ignoring the key) is what keeps a client from believing a rebind -// landed. It is owner/admin-gated like the other history writes. The response echoes -// only the fields that were changed, so a title-only PATCH keeps its pre-D5 -// {id,title} shape. +// 携带 personaId 键的请求体对任何取值都返回 400,且该检查 +// 在会话被查找之前执行:会话角色在创建时固定(design/22 +// §8-1),因此切换既有对话的角色 —— 包括清除它 —— +// 不是受支持的操作;请改为新建会话。显式拒绝 +// (而不是忽略该键)正是让客户端不会误以为重绑 +// 生效的原因。它与其他历史写一样受 owner/admin 门控。响应只 +// 回显被改动的字段,因此纯标题 PATCH 保持其 D5 之前的 +// {id,title} 形状。 func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -104,8 +104,8 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } -// writeSessionPatchError maps a RenameSession/SetPinned failure to its response: -// ErrSessionNotFound → 404, anything else → 500 (logged under op). +// writeSessionPatchError 把 RenameSession/SetPinned 的失败映射为其响应: +// ErrSessionNotFound → 404,其余 → 500(在 op 下记日志)。 func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op string) { if errors.Is(err, history.ErrSessionNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id)) @@ -115,21 +115,21 @@ func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to update session")) } -// patchMessageRequest is the PATCH /api/v1/messages/{id} body. Content is a pointer so -// an absent field is rejected rather than treated as an empty edit. +// patchMessageRequest 是 PATCH /api/v1/messages/{id} 的请求体。Content 是指针, +// 因此字段缺失会被拒绝,而不是被当作空编辑。 type patchMessageRequest struct { Content *string `json:"content"` } -// maxMessageContentRunes bounds an edited message body. It is a storage/display bound -// on the edit path only; inbound chat messages are not subject to it. +// maxMessageContentRunes 限定被编辑的消息体。它只是编辑路径上的 +// 存储/显示边界;入站聊天消息不受其约束。 const maxMessageContentRunes = 4000 -// handlePatchMessage edits a single message's content (Android collaboration batch ③a, -// D3). Only content changes — role/timestamp/platform stay as stored — and the response -// returns the updated message so the client can upsert it by id without a re-read. An -// empty/whitespace or over-long content answers 400, an unknown id answers 404, and a -// missing HistoryStore answers 501. It is owner/admin-gated. +// handlePatchMessage 编辑单条消息的内容(Android 协作批次 ③a, +// D3)。只有 content 会变 —— role/时间戳/platform 保持已存值 —— 响应 +// 返回更新后的消息,使客户端能按 id upsert 而无需重读。 +// 空/仅空白或过长的 content 返回 400,未知 id 返回 404, +// HistoryStore 缺失返回 501。它受 owner/admin 门控。 func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -168,20 +168,20 @@ func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrInternal, "message_failed", "failed to update message")) return } - // Broadcast the edit so every other browser of the account re-renders the same - // bubble without a refresh (the message_persisted realtime-sync contract, D3 edit - // path). Thinking is stripped from the frame to match the persisted/replay - // contract; the REST reply keeps it because the messages routes are owner/admin-only. + // 广播该编辑,使该账号的其他每个浏览器无需刷新就重渲染同一个 + // 气泡(message_persisted 实时同步契约,D3 编辑 + // 路径)。thinking 会从帧中剥掉,以匹配持久化/重放 + // 契约;REST 回复保留它,因为消息路由仅 owner/admin。 edited := toHistoryMessage(msg) edited.Thinking = "" s.hub.broadcastMessageEdited(edited) writeJSON(w, http.StatusOK, toHistoryMessage(msg)) } -// handleDeleteMessage removes a single message (D3). An unknown id answers 404, a -// missing HistoryStore 501. It is owner/admin-gated. A successful delete is -// broadcast as a `message_deleted` frame so every browser of the account drops the -// row without a refresh; the owning session is read before the row disappears. +// handleDeleteMessage 移除单条消息(D3)。未知 id 返回 404, +// HistoryStore 缺失返回 501。它受 owner/admin 门控。成功的删除会 +// 以 `message_deleted` 帧广播,使该账号的每个浏览器无需刷新就丢掉 +// 该行;所属会话在该行消失前被读取。 func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) { if s.history == nil { writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented}) @@ -206,18 +206,18 @@ func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) } -// messageLookup is the optional narrower view of HistoryStore used by DELETE to learn -// a message's owning session before it is removed (afterwards there is nothing left to -// ask). *history.Store implements it; a store that does not simply yields an unscoped -// frame, which the client resolves by the globally unique message id. +// messageLookup 是 HistoryStore 可选的更窄视图,DELETE 用它 +// 在移除前得知消息所属的会话(之后就没有可问的了)。 +// *history.Store 实现了它;未实现的存储只是产出一个无作用域 +// 的帧,客户端凭全局唯一的消息 id 解析它。 type messageLookup interface { GetMessage(ctx context.Context, id string) (history.Message, error) } -// messageSession returns the session id owning message id, or "" when the store cannot -// answer (no lookup surface, unknown id, or a read error). A blank session is safe for -// the broadcast: the message id is globally unique, so a client that falls back to an -// id-only filter can only ever touch that message's own conversation. +// messageSession 返回拥有 message id 的会话 id,存储无法作答时 +// 返回 ""(无查找表面、未知 id 或读错误)。空白会话 +// 对广播是安全的:消息 id 全局唯一,因此回退到 +// 纯 id 过滤的客户端只可能触及该消息自己的对话。 func (s *Server) messageSession(ctx context.Context, id string) string { lookup, ok := s.history.(messageLookup) if !ok { diff --git a/internal/services/gateway/meta.go b/internal/services/gateway/meta.go index 181a411..d292fc4 100644 --- a/internal/services/gateway/meta.go +++ b/internal/services/gateway/meta.go @@ -6,17 +6,17 @@ import ( "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. +// 本文件拥有只读的客户端元数据表面:GET /api/v1/meta/enums, +// 它发布权威的 platform/mood/mode 表(Android +// 协作 B4,design/07 §6)。客户端(Web、Android、IM bridge)读这些 +// 表,而不是各自硬编码 id → 标签映射,因此来源徽标或 +// 情绪标签不会逐客户端漂移。这些表本身位于 +// internal/contract/enums;本 handler 只负责提供它们,因此只有 +// 一个事实来源,而不是每个消费方一份副本。 -// 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. +// metaEnums 是 GET /api/v1/meta/enums 的载荷。`unknown` 携带 +// 机器可读的回退策略(enums.UnknownFallback),使客户端从同一 +// 响应中读到表以及对未列出取值的规则。 type metaEnums struct { Platforms []enums.Item `json:"platforms"` Moods []enums.Item `json:"moods"` @@ -24,14 +24,14 @@ type metaEnums struct { 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. +// handleListEnums 提供共享的客户端枚举表。它是 Go 侧权威的 +// 纯投影(无存储、无错误路径),不含任何 +// 敏感内容,因此像 GET /api/v1/sessions 一样门控:任何已认证角色都可以 +// 读取它。 // -// 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. +// 向表中新增取值对既有客户端是非破坏性变更,客户端 +// 必须原样展示未列出的取值(见 enums.UnknownFallback),而不是 +// 丢弃它或报错。 func (s *Server) handleListEnums(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, metaEnums{ Platforms: enums.Platforms(), diff --git a/internal/services/gateway/meta_test.go b/internal/services/gateway/meta_test.go index 9e76f31..fd37d36 100644 --- a/internal/services/gateway/meta_test.go +++ b/internal/services/gateway/meta_test.go @@ -9,21 +9,21 @@ import ( "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). +// 这些测试覆盖共享的客户端枚举端点(Android 协作 B4, +// design/07 §6):GET /api/v1/meta/enums 的线上结构、它发布的 +// 取值,以及任何已认证角色都可读这一点。它们 +// 用字面量断言 JSON 契约,而不是与 +// internal/contract/enums 对比,因此面向客户端的形状独立于 +// Go 表被钉住(表自身的漂移测试在该包内)。 -// enumEntry is the wire shape of one published enum item. +// enumEntry 是一个已发布枚举项的线上结构。 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. +// metaEnumsWire 解码 GET /api/v1/meta/enums 并断言信封与 +// 逐项形状,返回每个表的 id 供取值断言。 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) @@ -67,9 +67,9 @@ func entryIDs(items []enumEntry) []string { 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. +// TestMetaEnumsShape 钉住已发布的契约:三张表列出真实的 +// platform/mood/mode 取值,且响应携带未知取值回退 +// 策略。 func TestMetaEnumsShape(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -94,9 +94,9 @@ func TestMetaEnumsShape(t *testing.T) { } } -// 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. +// TestMetaEnumsAnyAuthenticatedRole 断言该端点是只读、非敏感的 +// 表:每个角色都可读它,匿名调用方得到 401 +// 而不是数据。 func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -117,7 +117,7 @@ func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) { wantErrorShape(t, body) } -// sameStringSet reports whether got and want hold the same values, ignoring order. +// sameStringSet 报告 got 与 want 是否持有相同取值(忽略顺序)。 func sameStringSet(got, want []string) bool { if len(got) != len(want) { return false diff --git a/internal/services/gateway/middleware.go b/internal/services/gateway/middleware.go index c1577de..fcd1dd3 100644 --- a/internal/services/gateway/middleware.go +++ b/internal/services/gateway/middleware.go @@ -10,9 +10,9 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) -// cors sets the Access-Control-* headers for origins in the configured allow -// list and short-circuits OPTIONS preflight requests. An empty origin (same-origin -// or non-browser client) is passed through untouched. +// cors 为已配置允许列表中的来源设置 Access-Control-* 头,并 +// 短路 OPTIONS 预检请求。空来源(同源或 +// 非浏览器客户端)原样放行。 func (s *Server) cors(next http.Handler) http.Handler { allowed, allowAll := s.allowedOrigins() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -36,8 +36,8 @@ func (s *Server) cors(next http.Handler) http.Handler { }) } -// allowedOrigins resolves the configured origin list into a lookup set plus an -// allow-all flag for the "*" wildcard. +// allowedOrigins 把已配置的来源列表解析成查找集合,外加针对 +// "*" 通配符的允许全部标志。 func (s *Server) allowedOrigins() (map[string]bool, bool) { list := s.cfg.AllowedOrigins if len(list) == 0 { @@ -55,8 +55,8 @@ func (s *Server) allowedOrigins() (map[string]bool, bool) { return out, allowAll } -// recover converts a panic in a downstream handler into a 500 {error:...} -// response instead of crashing the process, and logs the panic. +// recover 把下游 handler 中的 panic 转成 500 {error:...} +// 响应,而不是让进程崩溃,并记录该 panic。 func (s *Server) recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { @@ -69,7 +69,7 @@ func (s *Server) recover(next http.Handler) http.Handler { }) } -// logging records one structured line per request (method, path, status, dur). +// logging 为每个请求记录一行结构化日志(method、path、status、dur)。 func (s *Server) logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() @@ -86,21 +86,21 @@ func (s *Server) logging(next http.Handler) http.Handler { }) } -// statusRecorder captures the written status so the logging middleware can report -// it. It forwards the optional Hijacker/Flusher interfaces so the WebSocket -// upgrader (which requires http.Hijacker) and streaming handlers still work. +// statusRecorder 捕获写入的状态,使 logging 中间件能报告 +// 它。它转发可选的 Hijacker/Flusher 接口,因此 WebSocket +// 升级器(要求 http.Hijacker)与流式 handler 仍能工作。 type statusRecorder struct { http.ResponseWriter status int } -// WriteHeader records the status before delegating. +// WriteHeader 在委托之前记录状态。 func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } -// Hijack forwards to the underlying writer when it supports connection hijacking. +// Hijack 在底层 writer 支持连接劫持时转发给它。 func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { if hj, ok := r.ResponseWriter.(http.Hijacker); ok { return hj.Hijack() @@ -108,7 +108,7 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, fmt.Errorf("gateway: hijack unsupported by %T", r.ResponseWriter) } -// Flush forwards to the underlying writer when it supports flushing. +// Flush 在底层 writer 支持 flush 时转发给它。 func (r *statusRecorder) Flush() { if f, ok := r.ResponseWriter.(http.Flusher); ok { f.Flush() diff --git a/internal/services/gateway/notifications.go b/internal/services/gateway/notifications.go index 9ef8a54..04f3124 100644 --- a/internal/services/gateway/notifications.go +++ b/internal/services/gateway/notifications.go @@ -14,54 +14,54 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) -// Notification feed limits (design/10 §3 #16). Compile-time constants so no bare -// magic number appears in the run path (project rule §10). +// 通知 feed 的限制(design/10 §3 #16)。编译期常量,因此运行路径中 +// 不出现裸魔法数字(项目规范 §10)。 const ( defaultNotificationLimit = 50 maxNotificationLimit = 200 - // notificationFetchFactor widens the per-source read. The merged page must be - // the newest N of the union, which the per-source newest-N always contains, - // but with a `before` cursor one source can dominate the newest window and - // starve the other — the factor pays for that. + // notificationFetchFactor 加宽每来源的读取。合并页必须是并集的 + // 最新 N 条,而每来源的最新 N 条总是包含它, + // 但在带 `before` 游标时某一来源可能占据最新窗口并 + // 饿死另一来源 —— 该系数就是为此付出的代价。 notificationFetchFactor = 4 - // notificationBodyRunes caps a backthink summary in the feed; the full text - // stays available on GET /api/v1/admin/backthink. + // notificationBodyRunes 限定 feed 中后台思考摘要的长度;全文 + // 仍可在 GET /api/v1/admin/backthink 获取。 notificationBodyRunes = 200 ) -// The unified feed's families, used both as the item `type` and as the `?type=` -// filter. They are derived from the sources the composition root already has -// (design/10 §3 #16: 先聚合现有源为统一 feed,避免新建重模型). +// 统一 feed 的族,既用作条目的 `type`,也用作 `?type=` +// 过滤器。它们派生自组合根已有的来源 +// (design/10 §3 #16:先聚合现有源为统一 feed,避免新建重模型)。 const ( - // NotificationTypeSystem is a system/security operation: backup & restore, - // JWT rotation, API-key revocation, plugin install/uninstall, tool policy. + // NotificationTypeSystem 是系统/安全操作:备份与恢复、 + // JWT 轮换、API key 吊销、插件安装/卸载、工具策略。 NotificationTypeSystem = "system" - // NotificationTypeChannel is a channel lifecycle or connection event - // (channel.open / close / save / reject / login). + // NotificationTypeChannel 是渠道生命周期或连接事件 + // (channel.open / close / save / reject / login)。 NotificationTypeChannel = "channel" - // NotificationTypeBackthink is one proactive push the background thinker - // decided to send. + // NotificationTypeBackthink 是后台思考器决定发送的一次 + // 主动推送。 NotificationTypeBackthink = "backthink" - // NotificationTypeAudit is the remaining admin audit trail: personas, memory, - // users, RAG, sessions, uploads, audio. + // NotificationTypeAudit 是剩余的管理审计链:人格、记忆、 + // 用户、RAG、会话、上传、音频。 NotificationTypeAudit = "audit" ) -// Provenance labels carried on an item's `source` field. +// 条目 `source` 字段上携带的来源标签。 const ( notificationSourceAudit = "audit" notificationSourceBackthink = "backthink" - // notificationIDPrefixAudit / ...Backthink namespace the source's own record id - // so the merged feed has stable, collision-free, client-dedupable ids. + // notificationIDPrefixAudit / ...Backthink 为来源自身的记录 id 加命名空间, + // 使合并 feed 的 id 稳定、无冲突、客户端可去重。 notificationIDPrefixAudit = "audit:" notificationIDPrefixBackthink = "backthink:" ) -// AdminNotification is one item of the unified notification feed (design/10 §3 -// #16). It is a read-only projection of an existing record — audit rows and -// backthink records — never a new persisted model: `id` is ":<原生 id>", -// `title`/`body` are derived from the source record, and `createdAt` is epoch -// milliseconds. +// AdminNotification 是统一通知 feed 的一个条目(design/10 §3 +// #16)。它是既有记录 —— 审计行与后台思考记录 —— 的只读 +// 投影,绝不是新的持久化模型:`id` 是 ":<原生 id>", +// `title`/`body` 派生自来源记录,`createdAt` 是 epoch +// 毫秒。 type AdminNotification struct { ID string `json:"id"` Type string `json:"type"` @@ -71,13 +71,13 @@ type AdminNotification struct { CreatedAt int64 `json:"createdAt"` } -// adminListNotifications returns the unified notification feed (owner/admin -// read): the admin audit trail and the background thinker's proactive pushes, -// merged newest-first and filterable by family and by a `before` cursor. +// adminListNotifications 返回统一通知 feed(owner/admin +// 读):管理审计链与后台思考器的主动推送, +// 最新优先合并,可按族与 `before` 游标过滤。 // -// A source that is not injected contributes no items — the feed reports what -// actually exists instead of fabricating a family — so a deployment without the -// backthink service simply has no `backthink` items. +// 未注入的来源不贡献任何条目 —— feed 报告确实存在的 +// 内容,而不是编造一个族 —— 因此没有后台思考服务的 +// 部署就是没有 `backthink` 条目。 func (s *Server) adminListNotifications(w http.ResponseWriter, r *http.Request) { limit := parseNotificationLimit(r) typ := strings.TrimSpace(r.URL.Query().Get("type")) @@ -112,9 +112,9 @@ func (s *Server) adminListNotifications(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, out) } -// notificationItems reads both sources and returns their merge, newest first. A -// source failure is returned (never swallowed into a silently shorter feed), so -// the endpoint answers a clear error instead of pretending there was no event. +// notificationItems 读取两个来源并返回它们的合并结果,最新优先。 +// 来源失败会被返回(绝不被吞进静默变短的 feed),因此 +// 端点返回明确的错误,而不是假装没有事件。 func (s *Server) notificationItems(ctx context.Context, fetch int) ([]AdminNotification, error) { auditItems, err := s.notificationsFromAudit(ctx, fetch) if err != nil { @@ -129,17 +129,17 @@ func (s *Server) notificationItems(ctx context.Context, fetch int) ([]AdminNotif if items[i].CreatedAt != items[j].CreatedAt { return items[i].CreatedAt > items[j].CreatedAt } - // Descending id keeps the order deterministic for items sharing a - // millisecond (the cursor can split one millisecond across pages). + // id 降序为共享同一毫秒的条目保持确定性顺序 + // (游标可能把一毫秒拆到两页)。 return items[i].ID > items[j].ID }) return items, nil } -// notificationsFromAudit projects the admin audit trail (audit_logs, persisted -// by NewSQLiteAuditStore) into feed items. The fallback in-memory store holds -// only records really appended in this process, so an unwired store yields an -// empty family rather than fabricated entries. +// notificationsFromAudit 把管理审计链(audit_logs,由 +// NewSQLiteAuditStore 持久化)投影为 feed 条目。回退的内存存储 +// 只保留本进程真正追加过的记录,因此未接线的存储得到 +// 空族,而不是编造条目。 func (s *Server) notificationsFromAudit(ctx context.Context, fetch int) ([]AdminNotification, error) { if s.obs == nil || s.obs.audit == nil { return nil, nil @@ -162,10 +162,10 @@ func (s *Server) notificationsFromAudit(ctx context.Context, fetch int) ([]Admin return out, nil } -// notificationsFromBackthink projects the background thinker's records into feed -// items. Only records that decided to push are notifications (a plain thinking -// record is not an event the owner needs to be told about); each carries the -// delivery state so the feed never claims an undelivered push was sent. +// notificationsFromBackthink 把后台思考器的记录投影为 feed +// 条目。只有决定推送的记录才是通知(普通思考 +// 记录不是需要告知主人的事件);每条都携带 +// 投递状态,因此 feed 绝不声称未投递的推送已发出。 func (s *Server) notificationsFromBackthink(ctx context.Context, fetch int) ([]AdminNotification, error) { if s.backthink == nil { return nil, nil @@ -191,9 +191,9 @@ func (s *Server) notificationsFromBackthink(ctx context.Context, fetch int) ([]A return out, nil } -// notificationTypeOfAudit classifies an audit operation into a feed family: the -// channel lifecycle is `channel`, and the system/plugin/policy operations are -// `system`; everything else stays `audit`. +// notificationTypeOfAudit 把审计操作归类到 feed 族: +// 渠道生命周期是 `channel`,系统/插件/策略操作是 +// `system`;其余保持 `audit`。 func notificationTypeOfAudit(what string) string { switch { case strings.HasPrefix(what, "channel."): @@ -207,8 +207,8 @@ func notificationTypeOfAudit(what string) string { } } -// notificationAuditBody derives an audit item's body from its who/what/result -// trail, e.g. "owner:owner · ok". +// notificationAuditBody 从审计条目的 who/what/result +// 链推导其 body,例如 "owner:owner · ok"。 func notificationAuditBody(rec AuditRecord) string { parts := make([]string, 0, 2) if who := strings.TrimSpace(rec.Who); who != "" { @@ -220,9 +220,9 @@ func notificationAuditBody(rec AuditRecord) string { return strings.Join(parts, " · ") } -// notificationAuditAt parses an audit record's RFC3339 timestamp into epoch -// milliseconds. An unparseable timestamp yields 0: the item still appears (oldest -// first) instead of being dropped or stamped with a made-up time. +// notificationAuditAt 把审计记录的 RFC3339 时间戳解析为 epoch +// 毫秒。无法解析的时间戳得到 0:条目仍然出现(最旧 +// 优先),而不是被丢弃或被盖上一个编造的时间。 func notificationAuditAt(when string) int64 { ts, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(when)) if err != nil { @@ -231,9 +231,9 @@ func notificationAuditAt(when string) int64 { return ts.UnixMilli() } -// backthinkNotificationTitle labels one proactive push with its trigger and -// delivery state, so the feed distinguishes "sent" from "decided but not -// delivered" (route none / no online client / push failure). +// backthinkNotificationTitle 用触发原因与投递状态标注一条主动 +// 推送,使 feed 区分「已发送」与「已决定但未 +// 投递」(route none / 无在线客户端 / 推送失败)。 func backthinkNotificationTitle(rec backthink.Record) string { state := "未送达" if rec.PushResult.Pushed { @@ -245,8 +245,8 @@ func backthinkNotificationTitle(rec backthink.Record) string { return "主动推送(" + state + ")" } -// clipRunes truncates s to at most max runes, appending an ellipsis when it was -// cut. It never splits a multi-byte rune. +// clipRunes 把 s 截断到至多 max 个 rune,被截断时追加省略号。 +// 它绝不切开多字节 rune。 func clipRunes(s string, max int) string { if max <= 0 || utf8.RuneCountInString(s) <= max { return s @@ -255,9 +255,9 @@ func clipRunes(s string, max int) string { return string(runes[:max]) + "…" } -// parseNotificationLimit parses the ?limit= query param, clamping to -// [1, maxNotificationLimit] and selecting defaultNotificationLimit when absent or -// invalid. +// parseNotificationLimit 解析 ?limit= 查询参数,夹取到 +// [1, maxNotificationLimit],缺失或非法时选用 +// defaultNotificationLimit 作为默认值。 func parseNotificationLimit(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil || n <= 0 { @@ -269,11 +269,11 @@ func parseNotificationLimit(r *http.Request) int { return n } -// parseNotificationBefore parses the ?before= epoch-millisecond cursor. The -// cursor is inclusive (items with createdAt <= before are returned): the sources -// resolve to milliseconds, so a page boundary can fall inside one millisecond, -// and the client deduplicates by id. An absent or malformed cursor means "from -// the newest item". +// parseNotificationBefore 解析 ?before= epoch 毫秒游标。该 +// 游标是包含式的(返回 createdAt <= before 的条目):来源 +// 解析到毫秒,因此页边界可能落在一毫秒之内, +// 而客户端按 id 去重。游标缺失或畸形意味着「从 +// 最新条目开始」。 func parseNotificationBefore(r *http.Request) (int64, bool) { raw := strings.TrimSpace(r.URL.Query().Get("before")) if raw == "" { @@ -286,8 +286,8 @@ func parseNotificationBefore(r *http.Request) (int64, bool) { return ms, true } -// notificationFetchLimit widens a page request into the per-source read window, -// capped at maxObsLimit (the audit store's own ceiling) and never below the page. +// notificationFetchLimit 把页请求加宽为每来源读取窗口, +// 上限为 maxObsLimit(审计存储自身的上限),且绝不低于该页。 func notificationFetchLimit(limit int) int { fetch := limit * notificationFetchFactor if fetch > maxObsLimit { @@ -299,8 +299,8 @@ func notificationFetchLimit(limit int) int { return fetch } -// validNotificationType reports whether t names a feed family. An unknown family -// is rejected with 400 rather than answered with a silently empty feed. +// validNotificationType 报告 t 是否是一个 feed 族名。未知族 +// 以 400 拒绝,而不是以静默为空的 feed 作答。 func validNotificationType(t string) bool { switch t { case NotificationTypeSystem, NotificationTypeChannel, NotificationTypeBackthink, NotificationTypeAudit: diff --git a/internal/services/gateway/notifications_test.go b/internal/services/gateway/notifications_test.go index c03a12a..8b94ae1 100644 --- a/internal/services/gateway/notifications_test.go +++ b/internal/services/gateway/notifications_test.go @@ -14,9 +14,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink" ) -// stubAuditFeed is an AuditStore over scripted records (already newest first), -// so the feed tests control millisecond timestamps exactly — the in-memory -// fallback store only keeps second precision. +// stubAuditFeed 是一个基于脚本化记录的 AuditStore(已按最新优先), +// 使 feed 测试能精确控制毫秒时间戳 —— 内存 +// 回退存储只保留秒级精度。 type stubAuditFeed struct { records []AuditRecord err error @@ -34,7 +34,7 @@ func (s *stubAuditFeed) List(_ context.Context, limit int) ([]AuditRecord, error return append([]AuditRecord(nil), s.records[:limit]...), nil } -// stubBackthinkFeed is a BackthinkStore over scripted records (newest first). +// stubBackthinkFeed 是一个基于脚本化记录的 BackthinkStore(最新优先)。 type stubBackthinkFeed struct { records []backthink.Record err error @@ -50,15 +50,15 @@ func (s *stubBackthinkFeed) List(_ context.Context, limit int) ([]backthink.Reco return append([]backthink.Record(nil), s.records[:limit]...), nil } -// notificationMS renders epoch milliseconds as the RFC3339 timestamp an audit -// record carries. +// notificationMS 把 epoch 毫秒渲染成审计 +// 记录所携带的 RFC3339 时间戳。 func notificationMS(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339Nano) } -// notificationFeedFixture scripts both sources: channel + system + audit rows in -// the audit trail and one delivered / one undelivered / one non-push backthink -// record. +// notificationFeedFixture 脚本化两个来源:审计链中的 +// channel + system + audit 行,以及一条已投递 / 一条未投递 / 一条非推送的 +// 后台思考记录。 func notificationFeedFixture() (*stubAuditFeed, *stubBackthinkFeed) { audit := &stubAuditFeed{records: []AuditRecord{ {ID: 5, Who: "owner:owner", What: "channel.open", When: notificationMS(3000), Result: "ok"}, @@ -76,7 +76,7 @@ func notificationFeedFixture() (*stubAuditFeed, *stubBackthinkFeed) { return audit, bt } -// listNotifications calls the endpoint and decodes the feed. +// listNotifications 调用该端点并解码 feed。 func listNotifications(t *testing.T, ts *httptest.Server, tok, query string) (int, []AdminNotification, []byte) { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/notifications"+query, nil, tok) @@ -90,10 +90,10 @@ func listNotifications(t *testing.T, ts *httptest.Server, tok, query string) (in return resp.StatusCode, out, body } -// TestAdminListNotifications_MergesAuditAndBackthink proves the feed is the two -// existing sources merged newest-first with source-namespaced ids, the audit -// family classification, and that a backthink record which decided not to push is -// not an event. +// TestAdminListNotifications_MergesAuditAndBackthink 证明 feed 是两个 +// 既有来源按最新优先合并、id 带来源命名空间、审计 +// 族分类正确,以及决定不推送的后台思考记录 +// 不是事件。 func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) { t.Parallel() audit, bt := notificationFeedFixture() @@ -134,8 +134,8 @@ func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) { t.Fatalf("backthink title = %q, want the delivery state", got.Title) } } - // The undelivered push is older and its conclusion is long: both the body cap - // and the "未送达" label must show. + // 未投递的推送更旧且其结论很长:body 上限 + // 与「未送达」标签都必须体现。 for _, it := range items { if it.ID == "backthink:6" { if !strings.Contains(it.Title, "未送达") { @@ -146,7 +146,7 @@ func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) { } } } - // A thinking record that decided not to push never becomes a notification. + // 决定不推送的思考记录绝不成为通知。 for _, it := range items { if it.ID == "backthink:7" { t.Fatalf("non-push backthink record leaked into the feed: %+v", it) @@ -154,9 +154,9 @@ func TestAdminListNotifications_MergesAuditAndBackthink(t *testing.T) { } } -// TestAdminListNotifications_TypeFilter proves `?type=` selects one family and -// that a family without any source data answers an empty feed (never a fabricated -// row). +// TestAdminListNotifications_TypeFilter 证明 `?type=` 选中一个族, +// 且没有任何来源数据的族返回空 feed(绝不编造 +// 一行)。 func TestAdminListNotifications_TypeFilter(t *testing.T) { t.Parallel() audit, bt := notificationFeedFixture() @@ -187,7 +187,7 @@ func TestAdminListNotifications_TypeFilter(t *testing.T) { } } - // An empty source (no backthink service injected) yields an empty family. + // 空来源(未注入后台思考服务)得到空族。 _, auditOnly, jwtOnly := buildEnv(t, nil, Deps{AuditStore: audit}) tokAudit := signAny(t, jwtOnly, "acct", auth.RoleOwner) status, items, body := listNotifications(t, auditOnly, tokAudit, "?type=backthink") @@ -196,9 +196,9 @@ func TestAdminListNotifications_TypeFilter(t *testing.T) { } } -// TestAdminListNotifications_BeforeCursorPagination proves the `before` cursor: -// pages walk older, the cursor item itself is included (the sources resolve to -// milliseconds and the client dedupes by id), and `limit` caps a page. +// TestAdminListNotifications_BeforeCursorPagination 证明 `before` 游标: +// 翻页向更旧走,游标条目本身被包含(来源解析到 +// 毫秒,客户端按 id 去重),且 `limit` 限定一页。 func TestAdminListNotifications_BeforeCursorPagination(t *testing.T) { t.Parallel() audit, bt := notificationFeedFixture() @@ -229,16 +229,16 @@ func TestAdminListNotifications_BeforeCursorPagination(t *testing.T) { t.Fatalf("page3 = %+v, want [backthink:8 audit:3]", page3) } - // A cursor older than every item ends the walk with an empty page. + // 比所有条目都旧的游标以空页结束翻页。 status, tail, body := listNotifications(t, ts, tok, "?before=1") if status != http.StatusOK || len(tail) != 0 { t.Fatalf("tail = %+v / status %d, want empty / 200 (body=%s)", tail, status, body) } } -// TestAdminListNotifications_NoSourcesNoFabrication proves the honest empty feed: -// with neither source injected the endpoint returns an empty array, not invented -// notices. +// TestAdminListNotifications_NoSourcesNoFabrication 证明诚实的空 feed: +// 两个来源都未注入时端点返回空数组,而不是编造 +// 通知。 func TestAdminListNotifications_NoSourcesNoFabrication(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, nil, Deps{}) @@ -256,9 +256,9 @@ func TestAdminListNotifications_NoSourcesNoFabrication(t *testing.T) { } } -// TestAdminListNotifications_Errors proves the endpoint fails loudly: an unknown -// family is a 400 and a failing source is a 500 (never a silently shortened -// feed). A viewer reads the feed (read-only monitoring tier). +// TestAdminListNotifications_Errors 证明该端点响亮地失败:未知 +// 族是 400,来源失败是 500(绝不是静默变短的 +// feed)。viewer 可以读该 feed(只读监控层)。 func TestAdminListNotifications_Errors(t *testing.T) { t.Parallel() audit, bt := notificationFeedFixture() @@ -283,10 +283,10 @@ func TestAdminListNotifications_Errors(t *testing.T) { } } -// TestNotificationAuditAt_ParsesBothStoreFormats locks the timestamp contract with -// the two real audit stores: SQLiteAuditStore writes RFC3339Nano, the in-memory -// fallback writes RFC3339, and an unparseable value degrades to 0 (oldest) rather -// than being dropped or faked. +// TestNotificationAuditAt_ParsesBothStoreFormats 用两个真实审计存储锁定 +// 时间戳契约:SQLiteAuditStore 写 RFC3339Nano,内存 +// 回退写 RFC3339,无法解析的值退化到 0(最旧),而不是 +// 被丢弃或伪造。 func TestNotificationAuditAt_ParsesBothStoreFormats(t *testing.T) { t.Parallel() base := time.Date(2026, 9, 30, 10, 0, 0, 123456000, time.UTC) @@ -307,8 +307,8 @@ func TestNotificationAuditAt_ParsesBothStoreFormats(t *testing.T) { } } -// TestNotificationQueryParsing locks the query knobs: the limit defaults to 50 and -// clamps at 200, and an absent or malformed `before` means "from newest". +// TestNotificationQueryParsing 锁定查询旋钮:limit 默认 50, +// 上限 200,且 `before` 缺失或畸形意味着「从最新开始」。 func TestNotificationQueryParsing(t *testing.T) { t.Parallel() limitCases := []struct { diff --git a/internal/services/gateway/observability.go b/internal/services/gateway/observability.go index f7d1a2c..221ee8e 100644 --- a/internal/services/gateway/observability.go +++ b/internal/services/gateway/observability.go @@ -11,29 +11,29 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// Default list limits shared by the trace/audit endpoints. Compile-time constants -// so no magic number appears in the run path (project rule §10). +// trace/audit 端点共享的默认列表上限。编译期常量, +// 因此运行路径中不出现魔法数字(项目规范 §10)。 const ( defaultObsLimit = 50 maxObsLimit = 500 ) -// observability bundles the gateway's live admin instrumentation: the request -// metrics counter, the trace span ring buffer, the audit store and the -// process-wide log ring buffer. It is built in New and is safe for concurrent -// use. +// observability 打包网关的实时管理仪表:请求 +// 指标计数器、trace span 环形缓冲、审计存储与 +// 进程级日志环形缓冲。它在 New 中构建,且对并发 +// 使用安全。 type observability struct { metrics *MetricsCollector traceBuf *RingBuffer audit AuditStore - // logBuf is the logger's log ring buffer behind the admin log-tail endpoint. - // The composition root injects it via SetLogBuffer once the gateway exists; - // until then the endpoint answers 501 rather than an empty-but-fake tail. + // logBuf 是管理日志尾部端点背后的 logger 日志环形缓冲。 + // 组合根在网关存在后经 SetLogBuffer 注入它;在那之前 + // 该端点返回 501,而不是空但虚假的尾部。 logBuf atomic.Pointer[logger.RingBuffer] } -// newObservability builds the instrumentation, falling back to an in-memory audit -// store when no store is injected. +// newObservability 构建仪表,未注入存储时回退到内存审计 +// 存储。 func newObservability(deps Deps) *observability { obs := &observability{ metrics: newMetricsCollector(), @@ -47,10 +47,10 @@ func newObservability(deps Deps) *observability { return obs } -// ObserveSpan feeds a completed trace span into the gateway's ring buffer and the -// LLM/subagent metric counters. It is the gateway's trace injection point: the -// composition root wires the loop tracer here (e.g. through TraceSink) so the -// admin panel can query spans without the gateway touching wire.go. +// ObserveSpan 把一个已完成的 trace span 送入网关的环形缓冲与 +// LLM/子代理指标计数器。它是网关的 trace 注入点: +// 组合根把 loop tracer 接到这里(例如通过 TraceSink),使 +// 管理面板能查询 span 而网关无需触碰 wire.go。 func (s *Server) ObserveSpan(rec trace.SpanRecord) { if s.obs == nil { return @@ -59,10 +59,10 @@ func (s *Server) ObserveSpan(rec trace.SpanRecord) { s.obs.metrics.Observe(rec) } -// TraceSink returns a trace.Writer that appends completed spans to the gateway's -// trace ring buffer. Combine it with the existing JSONRecorder in the composition -// root (e.g. trace.New(MultiTraceWriter{...})) so spans both hit stderr and the -// admin panel. +// TraceSink 返回一个 trace.Writer,把已完成的 span 追加进网关的 +// trace 环形缓冲。在组合根把它与既有的 JSONRecorder 结合 +// (例如 trace.New(MultiTraceWriter{...})),使 span 既落 stderr 又进 +// 管理面板。 func (s *Server) TraceSink() trace.Writer { if s.obs == nil { return nil @@ -70,7 +70,7 @@ func (s *Server) TraceSink() trace.Writer { return traceSink{s.obs.traceBuf} } -// recordMetric counts one request for the observability panel. +// recordMetric 为可观测面板计一次请求。 func (s *Server) recordMetric(method, path string, status int, durMs int64) { if s.obs == nil { return @@ -78,7 +78,7 @@ func (s *Server) recordMetric(method, path string, status int, durMs int64) { s.obs.metrics.Record(method, path, status, durMs) } -// adminObservabilityMetrics returns the current gateway metrics (admin read). +// adminObservabilityMetrics 返回当前网关指标(管理读)。 func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Request) { if s.obs == nil { writeJSON(w, http.StatusOK, AdminMetrics{ @@ -89,7 +89,7 @@ func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, s.obs.metrics.Snapshot(time.Now())) } -// adminObservabilityTrace returns the most recent traced spans (admin read). +// adminObservabilityTrace 返回最近的已追踪 span(管理读)。 func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request) { if s.obs == nil { writeJSON(w, http.StatusOK, []AdminTraceSpan{}) @@ -103,7 +103,7 @@ func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, out) } -// adminObservabilityAudit returns the most recent audit entries (admin read). +// adminObservabilityAudit 返回最近的审计条目(管理读)。 func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request) { if s.obs == nil || s.obs.audit == nil { writeJSON(w, http.StatusOK, []AuditRecord{}) @@ -118,8 +118,8 @@ func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, records) } -// parseLimit parses the ?limit= query param, clamping to [1, maxObsLimit], and -// selects defaultObsLimit when absent or invalid. +// parseLimit 解析 ?limit= 查询参数,夹取到 [1, maxObsLimit], +// 缺失或非法时选用 defaultObsLimit。 func parseLimit(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil || n <= 0 { diff --git a/internal/services/gateway/observability_audit.go b/internal/services/gateway/observability_audit.go index 248195d..9e9267e 100644 --- a/internal/services/gateway/observability_audit.go +++ b/internal/services/gateway/observability_audit.go @@ -9,7 +9,7 @@ import ( "time" ) -// AuditRecord is one persisted admin audit entry (who/what/when/result). +// AuditRecord 是一条持久化的管理审计条目(who/what/when/result)。 type AuditRecord struct { ID int64 `json:"id"` Who string `json:"who"` @@ -18,33 +18,33 @@ type AuditRecord struct { Result string `json:"result"` } -// AuditStore persists the admin audit trail. Implementations must be safe for -// concurrent use. *SQLiteAuditStore is the production implementation (wired by the -// composition root over a.db); when none is injected the gateway falls back to an -// in-memory store so the endpoint always returns data. +// AuditStore 持久化管理审计链。实现必须对 +// 并发使用安全。*SQLiteAuditStore 是生产实现(由 +// 组合根接到 a.db 上);未注入任何实现时网关回退到 +// 内存存储,因此端点总能返回数据。 type AuditStore interface { - // Append persists one audit entry at ts. + // Append 在 ts 持久化一条审计条目。 Append(ctx context.Context, who, what, result string, ts time.Time) error - // List returns at most limit most-recent entries, newest first. A non-positive - // limit selects the default. + // List 返回至多 limit 条最近条目,最新优先。非正的 + // limit 选用默认值。 List(ctx context.Context, limit int) ([]AuditRecord, error) } -// memoryAuditStore is the nil-injection fallback: it holds the audit trail in -// process memory so the admin endpoint works before the composition root injects a -// SQLite store. It is concurrency-safe. +// memoryAuditStore 是未注入时的回退:它把审计链放在 +// 进程内存中,使管理端点在组合根注入 SQLite 存储 +// 之前也能工作。它对并发安全。 type memoryAuditStore struct { mu sync.Mutex records []AuditRecord seq int64 } -// newMemoryAuditStore builds an empty in-memory audit store. +// newMemoryAuditStore 构建一个空的内存审计存储。 func newMemoryAuditStore() *memoryAuditStore { return &memoryAuditStore{records: make([]AuditRecord, 0, 64)} } -// Append stores one entry with a monotonically increasing id. +// Append 用单调递增的 id 存一条条目。 func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error { if err := ctx.Err(); err != nil { return err @@ -58,7 +58,7 @@ func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string, return nil } -// List returns the most recent entries, newest first. +// List 返回最近的条目,最新优先。 func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) { if err := ctx.Err(); err != nil { return nil, err @@ -84,9 +84,9 @@ func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, return out, nil } -// audit DDL for a self-contained SQLite store (no migration bookkeeping), the -// same pattern channel/session and backthink use. recorded_at is the UTC RFC3339 -// wall-clock of the audited action. +// 自包含 SQLite 存储的审计 DDL(无需迁移记账),与 +// channel/session 与 backthink 所用模式相同。recorded_at 是被审计 +// 动作的 UTC RFC3339 墙钟时间。 const ( createAuditSQL = ` CREATE TABLE IF NOT EXISTS audit_logs ( @@ -102,16 +102,16 @@ CREATE TABLE IF NOT EXISTS audit_logs ( selectAuditSQL = `SELECT id, who, what, result, recorded_at FROM audit_logs ORDER BY id DESC LIMIT ?` ) -// SQLiteAuditStore persists the admin audit trail in SQLite. It is safe for -// concurrent use by virtue of the underlying *sql.DB. +// SQLiteAuditStore 把管理审计链持久化在 SQLite 中。凭借底层的 +// *sql.DB,它对并发使用安全。 type SQLiteAuditStore struct { db *sql.DB log *slog.Logger } -// NewSQLiteAuditStore builds a SQLite-backed audit store over db, guaranteeing the -// audit_logs table and index exist. It returns an error when db is nil or the DDL -// fails. The composition root wires this over the primary *sql.DB (a.db). +// NewSQLiteAuditStore 在 db 上构建 SQLite 审计存储,保证 +// audit_logs 表与索引存在。db 为 nil 或 DDL 失败时 +// 返回错误。组合根把它接到主 *sql.DB(a.db)上。 func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQLiteAuditStore, error) { if db == nil { return nil, fmt.Errorf("gateway: audit store: nil database") @@ -125,7 +125,7 @@ func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQ return &SQLiteAuditStore{db: db, log: sourcedLog(log)}, nil } -// Append persists one audit entry at ts. +// Append 在 ts 持久化一条审计条目。 func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error { if _, err := s.db.ExecContext(ctx, insertAuditSQL, who, what, result, ts.UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("gateway: insert audit: %w", err) @@ -133,7 +133,7 @@ func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string, return nil } -// List returns the most recent entries, newest first. +// List 返回最近的条目,最新优先。 func (s *SQLiteAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) { if limit <= 0 { limit = defaultObsLimit diff --git a/internal/services/gateway/observability_logs.go b/internal/services/gateway/observability_logs.go index 73b4ebf..94a898d 100644 --- a/internal/services/gateway/observability_logs.go +++ b/internal/services/gateway/observability_logs.go @@ -11,16 +11,16 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// Log-tail endpoint tuning. Compile-time constants (project rule §10). +// 日志尾部端点的调参。编译期常量(项目规范 §10)。 const ( defaultLogLimit = 200 maxLogLimit = 1000 ) -// AdminLogEntry is the wire shape of one entry in -// GET /api/v1/admin/observability/logs: {time,level,source,msg}. `time` is -// RFC3339 UTC (matching trace/audit); `level` ∈ debug|info|warn|error; `source` -// is the record's provenance tag, empty when the record carried none. +// AdminLogEntry 是 GET /api/v1/admin/observability/logs 中一个条目的 +// 线上结构:{time,level,source,msg}。`time` 是 +// RFC3339 UTC(与 trace/audit 一致);`level` ∈ debug|info|warn|error;`source` +// 是记录的来源标签,记录未携带时为空。 type AdminLogEntry struct { Time string `json:"time"` Level string `json:"level"` @@ -28,10 +28,10 @@ type AdminLogEntry struct { Msg string `json:"msg"` } -// SetLogBuffer injects the process-wide log ring buffer behind -// GET /api/v1/admin/observability/logs. The composition root calls it once the -// gateway exists (the logger is built before the gateway); until then the -// endpoint answers 501. It is safe to call concurrently. +// SetLogBuffer 注入 GET /api/v1/admin/observability/logs 背后的 +// 进程级日志环形缓冲。组合根在网关存在后调用它 +// (logger 先于网关构建);在那之前该 +// 端点返回 501。并发调用是安全的。 func (s *Server) SetLogBuffer(buf *logger.RingBuffer) { if s == nil || s.obs == nil { return @@ -39,7 +39,7 @@ func (s *Server) SetLogBuffer(buf *logger.RingBuffer) { s.obs.logBuf.Store(buf) } -// logBuffer returns the injected log ring buffer, or nil when none was injected. +// logBuffer 返回已注入的日志环形缓冲,未注入时为 nil。 func (s *Server) logBuffer() *logger.RingBuffer { if s == nil || s.obs == nil { return nil @@ -47,10 +47,10 @@ func (s *Server) logBuffer() *logger.RingBuffer { return s.obs.logBuf.Load() } -// adminObservabilityLogs returns the most recent backend log records, newest -// first, optionally filtered to a minimum level (admin read). It is the backend -// behind the panel's log tail: the records come from the logger's own ring -// buffer and nothing is re-rendered or fabricated. +// adminObservabilityLogs 返回最近的后端日志记录,最新 +// 优先,可选按最低级别过滤(管理读)。它是面板日志 +// 尾部背后的后端:记录来自 logger 自身的环形 +// 缓冲,没有任何东西被重新渲染或编造。 func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) { minLevel, filtered, ok := parseLogLevel(r.URL.Query().Get("level")) if !ok { @@ -85,7 +85,7 @@ func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, out) } -// adminLogOf maps a captured log entry to the camelCase admin wire shape. +// adminLogOf 把捕获的日志条目映射为驼峰式管理线上结构。 func adminLogOf(e logger.Entry) AdminLogEntry { return AdminLogEntry{ Time: e.Time.UTC().Format(time.RFC3339), @@ -95,9 +95,9 @@ func adminLogOf(e logger.Entry) AdminLogEntry { } } -// parseLogLevel resolves the ?level= filter. An empty value disables filtering -// (filtered=false); debug/info/warn/error select that level and above; anything -// else is rejected with ok=false so the handler answers 400. +// parseLogLevel 解析 ?level= 过滤器。空值关闭过滤 +// (filtered=false);debug/info/warn/error 选中该级别及以上;其他任何 +// 取值都以 ok=false 拒绝,使 handler 返回 400。 func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) { switch strings.ToLower(strings.TrimSpace(raw)) { case "": @@ -115,8 +115,8 @@ func parseLogLevel(raw string) (min slog.Level, filtered, ok bool) { } } -// parseLogLimit parses the ?limit= query param, clamping to [1, maxLogLimit] and -// selecting defaultLogLimit when absent or invalid. +// parseLogLimit 解析 ?limit= 查询参数,夹取到 [1, maxLogLimit] 并在 +// 缺失或非法时选用 defaultLogLimit。 func parseLogLimit(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("limit")) if err != nil || n <= 0 { diff --git a/internal/services/gateway/observability_logs_test.go b/internal/services/gateway/observability_logs_test.go index deabd2c..5505b0c 100644 --- a/internal/services/gateway/observability_logs_test.go +++ b/internal/services/gateway/observability_logs_test.go @@ -12,13 +12,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// logEnvelope decodes the {data:[...]} envelope of the log-tail endpoint. +// logEnvelope 解码日志尾部端点的 {data:[...]} 信封。 type logEnvelope struct { Data []AdminLogEntry `json:"data"` } -// logsServer builds a gateway with an injected log ring buffer already fed by a -// real logger, so the endpoint is exercised against the production capture path. +// logsServer 构建一个网关,注入了已由真实 logger 喂入的日志 +// 环形缓冲,因此该端点是针对生产捕获路径演练的。 func logsServer(t *testing.T) (*Server, string, string) { t.Helper() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -74,7 +74,7 @@ func TestAdminObservabilityLogs_LevelFilter(t *testing.T) { {"info", []string{"error", "warn", "info"}}, {"warn", []string{"error", "warn"}}, {"error", []string{"error"}}, - {"ERROR", []string{"error"}}, // case-insensitive + {"ERROR", []string{"error"}}, // 大小写不敏感 {"", []string{"error", "warn", "info"}}, } for _, tc := range cases { @@ -133,7 +133,7 @@ func TestAdminObservabilityLogs_LimitClamped(t *testing.T) { t.Fatalf("limit=2 data = %+v, want the two newest", env.Data) } - // An invalid/absent limit falls back to the default (all 3 here < 200). + // 非法/缺失的 limit 回退到默认值(这里全部 3 条 < 200)。 resp, body = doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=abc", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("bad limit status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -201,10 +201,10 @@ func TestAdminObservabilityLogs_EmptyBufferIs200Empty(t *testing.T) { } } -// TestAdminObservabilityLogs_ViewerReadsTail locks the viewer gate: the log tail -// is part of the read-only monitoring surface the viewer role exists for, so a -// viewer reads it exactly like an owner (the payload is log records, not -// credentials). +// TestAdminObservabilityLogs_ViewerReadsTail 锁定 viewer 门:日志尾部 +// 属于 viewer 角色存在的那个只读监控表面,因此 +// viewer 读它就像 owner 一样(载荷是日志记录,不是 +// 凭据)。 func TestAdminObservabilityLogs_ViewerReadsTail(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) diff --git a/internal/services/gateway/observability_metrics.go b/internal/services/gateway/observability_metrics.go index 0a560de..3813363 100644 --- a/internal/services/gateway/observability_metrics.go +++ b/internal/services/gateway/observability_metrics.go @@ -8,17 +8,17 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// Metrics collector tuning. Compile-time constants. +// 指标收集器调参。编译期常量。 const ( - // metricsWindow bounds how long request timestamps are retained for QPS. + // metricsWindow 限定请求时间戳为 QPS 保留多久。 metricsWindow = 2 * time.Second - // metricsQPSSpan is the sliding window over which QPS is measured. + // metricsQPSSpan 是测量 QPS 的滑动窗口。 metricsQPSSpan = time.Second ) -// AdminMetrics is the wire shape of GET /api/v1/admin/observability/metrics. -// LLM/Token cost is deliberately absent: the gateway does not track token usage or -// spend, and a fabricated number would violate the "不伪造指标" red line. +// AdminMetrics 是 GET /api/v1/admin/observability/metrics 的线上结构。 +// LLM/Token 成本刻意缺位:网关不跟踪 token 用量或 +// 花费,编造一个数字会违反「不伪造指标」红线。 type AdminMetrics struct { TotalRequests int64 `json:"totalRequests"` QPS float64 `json:"qps"` @@ -31,8 +31,8 @@ type AdminMetrics struct { SubagentEvents int64 `json:"subagentEvents"` } -// MetricsCollector accumulates request metrics and derived counters for the admin -// observability panel. It is concurrency-safe. +// MetricsCollector 为管理可观测面板累积请求指标与派生 +// 计数器。它对并发安全。 type MetricsCollector struct { mu sync.Mutex total int64 @@ -46,7 +46,7 @@ type MetricsCollector struct { subagent int64 } -// newMetricsCollector builds an empty metrics collector. +// newMetricsCollector 构建一个空指标收集器。 func newMetricsCollector() *MetricsCollector { return &MetricsCollector{ perMethod: make(map[string]int64), @@ -55,7 +55,7 @@ func newMetricsCollector() *MetricsCollector { } } -// Record counts one request with the given method, path, status and duration. +// Record 用给定的 method、path、status 与 duration 计一次请求。 func (m *MetricsCollector) Record(method, path string, status int, durMs int64) { m.mu.Lock() defer m.mu.Unlock() @@ -72,8 +72,8 @@ func (m *MetricsCollector) Record(method, path string, status int, durMs int64) m.prune(now) } -// Observe increments the LLM/subagent counters when a span of that kind is -// recorded. It is called by ObserveSpan so the counters reflect real work. +// Observe 在记录到该类 span 时递增 LLM/子代理计数器。 +// 它由 ObserveSpan 调用,使计数器反映真实工作。 func (m *MetricsCollector) Observe(rec trace.SpanRecord) { m.mu.Lock() defer m.mu.Unlock() @@ -85,8 +85,8 @@ func (m *MetricsCollector) Observe(rec trace.SpanRecord) { } } -// prune drops request times older than metricsWindow so recent stays small. The -// caller must hold m.mu. +// prune 丢弃早于 metricsWindow 的请求时间,使 recent 保持小。 +// 调用方必须持有 m.mu。 func (m *MetricsCollector) prune(now time.Time) { min := now.Add(-metricsWindow) i := 0 @@ -98,15 +98,15 @@ func (m *MetricsCollector) prune(now time.Time) { } } -// Snapshot returns a point-in-time copy of the metrics. qps counts the requests -// started within metricsQPSSpan of now. +// Snapshot 返回指标的时间点副本。qps 统计在 now 的 +// metricsQPSSpan 之内开始的请求。 // -// Every map in the result is a fresh copy, never the collector's own: the caller -// serializes the value AFTER this lock is released (the admin handler JSON-encodes -// it), so returning the live maps would let a concurrent Record mutate a map while -// the encoder iterates it — the Go runtime answers that with an unrecoverable -// fatal "concurrent map iteration and map write" that takes the whole process -// down (the 2026-10-03 21:00:28 crash on GET /api/v1/admin/observability/metrics). +// 结果中的每个 map 都是全新副本,绝不是收集器自己的:调用方 +// 在本锁释放之后才序列化该值(管理 handler 对它做 JSON +// 编码),因此返回活 map 会让并发的 Record 在编码器 +// 遍历它时改动该 map —— Go 运行时对此的回答是不可恢复的 +// 致命错误 "concurrent map iteration and map write",它会让整个进程 +// 倒下(2026-10-03 21:00:28 在 GET /api/v1/admin/observability/metrics 上的崩溃)。 func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics { m.mu.Lock() defer m.mu.Unlock() @@ -133,9 +133,9 @@ func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics { } } -// copyCounts returns a detached copy of a string-keyed counter map, so the -// snapshot can be encoded after the collector's lock is released. The caller must -// hold m.mu while copying (the copy iterates the live map). +// copyCounts 返回字符串键计数器 map 的脱离副本,使 +// 快照能在收集器锁释放后编码。调用方在复制时 +// 必须持有 m.mu(复制会遍历活 map)。 func copyCounts(src map[string]int64) map[string]int64 { out := make(map[string]int64, len(src)) for k, v := range src { @@ -144,8 +144,8 @@ func copyCounts(src map[string]int64) map[string]int64 { return out } -// copyStatusCounts renders the int-keyed status counter as the string-keyed wire -// map the AdminMetrics JSON shape declares. The caller must hold m.mu while copying. +// copyStatusCounts 把 int 键的状态计数器渲染为 AdminMetrics JSON +// 形状所声明的字符串键线上 map。调用方在复制时必须持有 m.mu。 func copyStatusCounts(src map[int]int64) map[string]int64 { out := make(map[string]int64, len(src)) for k, v := range src { diff --git a/internal/services/gateway/observability_metrics_test.go b/internal/services/gateway/observability_metrics_test.go index 08056c4..fdcd81a 100644 --- a/internal/services/gateway/observability_metrics_test.go +++ b/internal/services/gateway/observability_metrics_test.go @@ -12,17 +12,17 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) -// These tests pin the contract that broke in production (2026-10-03 21:00:28): -// MetricsCollector.Snapshot must hand back DETACHED counter maps, because the -// admin handler JSON-encodes the snapshot after the collector's lock is released. -// Returning the collector's own maps let a concurrent Record write a map while the -// encoder iterated it → unrecoverable -// "fatal error: concurrent map iteration and map write" → the whole backend died, -// and with it every admin page (the reported「全链路流程功能无效」). +// 这些测试钉住生产中坏掉的那个契约(2026-10-03 21:00:28): +// MetricsCollector.Snapshot 必须交回脱离的计数器 map,因为 +// 管理 handler 在收集器锁释放后对快照做 JSON 编码。 +// 返回收集器自己的 map 会让并发的 Record 在编码器遍历它时 +// 写该 map → 不可恢复的 +// "fatal error: concurrent map iteration and map write" → 整个后端死亡, +// 随之所有管理页面也死(上报的「全链路流程功能无效」)。 -// TestMetricsSnapshotDetachesMaps is the deterministic regression: a value read out -// of a snapshot must not change when the collector is counted afterwards, and -// writing into a snapshot must not reach the collector. +// TestMetricsSnapshotDetachesMaps 是确定性的回归:从快照读出的 +// 值不得在之后对收集器计数时改变,且写入 +// 快照不得触达收集器。 func TestMetricsSnapshotDetachesMaps(t *testing.T) { t.Parallel() m := newMetricsCollector() @@ -36,7 +36,7 @@ func TestMetricsSnapshotDetachesMaps(t *testing.T) { t.Fatalf("PerMethod[GET] = %d, want 1", got) } - // A count recorded after the snapshot must not appear in the older snapshot. + // 快照之后记录的计数不得出现在较旧的快照中。 m.Record(http.MethodPost, "/api/v1/b", http.StatusTeapot, 1) if _, ok := snap.PerPath["/api/v1/b"]; ok { t.Fatalf("snapshot shares the collector's perPath map: later path leaked into it (%v)", snap.PerPath) @@ -52,8 +52,8 @@ func TestMetricsSnapshotDetachesMaps(t *testing.T) { assertCollectorNotMutatedBySnapshot(t, m) } -// assertSnapshotWriteIsolation writes into the snapshot's maps and checks the -// collector is untouched afterwards. +// assertSnapshotWriteIsolation 写入快照的 map 并检查 +// 之后收集器未被触碰。 func assertSnapshotWriteIsolation(t *testing.T, m *MetricsCollector, snap AdminMetrics) { t.Helper() snap.PerPath["/api/v1/evil"] = 999 @@ -78,8 +78,8 @@ func assertSnapshotWriteIsolation(t *testing.T, m *MetricsCollector, snap AdminM } } -// assertCollectorNotMutatedBySnapshot re-reads a counter the collector recorded -// before the isolation writes to prove no counter value was disturbed. +// assertCollectorNotMutatedBySnapshot 重新读取收集器在隔离写入之前 +// 记录的一个计数器,以证明没有任何计数值被扰动。 func assertCollectorNotMutatedBySnapshot(t *testing.T, m *MetricsCollector) { t.Helper() final := m.Snapshot(time.Now()) @@ -91,11 +91,11 @@ func assertCollectorNotMutatedBySnapshot(t *testing.T, m *MetricsCollector) { } } -// TestMetricsSnapshotEncodedConcurrently runs the exact interleaving that crashed -// production: one goroutine counts requests (the logging middleware does this on -// every request) while another encodes snapshots (the admin handler does this -// outside the lock). Under -race a shared live map is reported as a data race; on -// an unpatched build it can also abort the process outright. +// TestMetricsSnapshotEncodedConcurrently 运行让生产崩溃的那个确切 +// 交错:一个 goroutine 计请求(logging 中间件对每个请求都 +// 这么做),另一个编码快照(管理 handler 在锁外 +// 这么做)。在 -race 下共享活 map 会被报告为数据竞争;在 +// 未打补丁的构建上它还可能直接把进程搞崩。 func TestMetricsSnapshotEncodedConcurrently(t *testing.T) { t.Parallel() m := newMetricsCollector() @@ -127,18 +127,18 @@ func TestMetricsSnapshotEncodedConcurrently(t *testing.T) { wg.Wait() } -// TestAdminMetricsEndpointConcurrentPoll drives the real HTTP path — the admin -// panel polls every module in parallel, so one request can be encoding the metrics -// JSON while another request's logging middleware records into the collector. This -// is the production crash reproduced end-to-end through the middleware + handler. +// TestAdminMetricsEndpointConcurrentPoll 驱动真实 HTTP 路径 —— 管理 +// 面板并行轮询每个模块,因此一个请求可能在编码指标 +// JSON,而另一个请求的 logging 中间件正在往收集器里记录。这 +// 是生产崩溃经中间件 + handler 端到端复现。 func TestAdminMetricsEndpointConcurrentPoll(t *testing.T) { env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) metricsURL := env.ts.URL + "/api/v1/admin/observability/metrics" healthURL := env.ts.URL + "/api/v1/health" - // Both jobs run concurrently: the panel's metrics reads race the load requests - // that keep recording into the collector. + // 两个任务并发运行:面板的指标读与持续往收集器 + // 记录的负载请求相互竞争。 for _, err := range runConcurrent( func() error { return driveRequests(healthURL, "", 4, 25) }, func() error { return driveRequests(metricsURL, tok, 6, 25) }, @@ -169,7 +169,7 @@ func TestAdminMetricsEndpointConcurrentPoll(t *testing.T) { } } -// runConcurrent runs every job in its own goroutine and returns the non-nil errors. +// runConcurrent 在各自 goroutine 中运行每个任务并返回非 nil 错误。 func runConcurrent(jobs ...func() error) []error { var wg sync.WaitGroup errs := make([]error, len(jobs)) @@ -184,8 +184,8 @@ func runConcurrent(jobs ...func() error) []error { return errs } -// driveRequests issues n GETs of url, workers of them in parallel, using token as -// a bearer credential when non-empty. It returns the first failure (status or I/O). +// driveRequests 对 url 发起 n 次 GET,其中 workers 个并行,token 非空 +// 时作为 bearer 凭据。它返回首个失败(状态或 I/O)。 func driveRequests(url, token string, workers, n int) error { var wg sync.WaitGroup fail := make(chan error, workers) diff --git a/internal/services/gateway/observability_trace.go b/internal/services/gateway/observability_trace.go index 0bb3e8d..a0d70a8 100644 --- a/internal/services/gateway/observability_trace.go +++ b/internal/services/gateway/observability_trace.go @@ -7,12 +7,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// defaultTraceBufCap is how many completed spans the ring buffer holds before the -// oldest is evicted. Compile-time constant. +// defaultTraceBufCap 是环形缓冲在逐出最旧 span 之前持有的已 +// 完成 span 数。编译期常量。 const defaultTraceBufCap = 512 -// AdminTraceSpan is the camelCase wire shape of one traced span. It decouples the -// frontend from the trace package's snake_case SpanRecord. +// AdminTraceSpan 是一个已追踪 span 的驼峰式线上结构。它把 +// 前端与 trace 包蛇形命名的 SpanRecord 解耦。 type AdminTraceSpan struct { ID string `json:"id"` ParentID string `json:"parentId,omitempty"` @@ -26,9 +26,9 @@ type AdminTraceSpan struct { Events []trace.Event `json:"events,omitempty"` } -// RingBuffer is a concurrency-safe bounded buffer of completed trace spans. The -// oldest span is evicted when the buffer is full, so the admin panel always sees -// the most recent work without unbounded growth. +// RingBuffer 是已完成的 trace span 的并发安全有界缓冲。缓冲 +// 满时逐出最旧 span,因此管理面板总能看到 +// 最近的工作而不会无界增长。 type RingBuffer struct { mu sync.RWMutex buf []trace.SpanRecord @@ -37,7 +37,7 @@ type RingBuffer struct { cap int } -// newRingBuffer builds a ring buffer holding up to cap spans (default when cap<=0). +// newRingBuffer 构建至多容纳 cap 个 span 的环形缓冲(cap<=0 时用默认值)。 func newRingBuffer(cap int) *RingBuffer { if cap <= 0 { cap = defaultTraceBufCap @@ -45,7 +45,7 @@ func newRingBuffer(cap int) *RingBuffer { return &RingBuffer{buf: make([]trace.SpanRecord, cap), cap: cap} } -// Append stores rec, evicting the oldest when the buffer is full. +// Append 存储 rec,缓冲满时逐出最旧的。 func (r *RingBuffer) Append(rec trace.SpanRecord) { r.mu.Lock() defer r.mu.Unlock() @@ -56,8 +56,8 @@ func (r *RingBuffer) Append(rec trace.SpanRecord) { } } -// Snapshot returns up to n most-recent spans, newest first. A non-positive n -// selects defaultObsLimit; n is clamped to maxObsLimit. +// Snapshot 返回至多 n 条最近 span,最新优先。非正的 n +// 选用 defaultObsLimit;n 被夹取到 maxObsLimit。 func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord { if n <= 0 { n = defaultObsLimit @@ -82,11 +82,11 @@ func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord { return out } -// traceSink adapts a *RingBuffer to trace.Writer so a completed span is appended -// to the gateway's trace buffer. +// traceSink 把 *RingBuffer 适配为 trace.Writer,使已完成的 span 被 +// 追加进网关的 trace 缓冲。 type traceSink struct{ b *RingBuffer } -// WriteSpan appends rec to the ring buffer. +// WriteSpan 把 rec 追加进环形缓冲。 func (s traceSink) WriteSpan(rec trace.SpanRecord) error { if s.b == nil { return nil @@ -95,16 +95,16 @@ func (s traceSink) WriteSpan(rec trace.SpanRecord) error { return nil } -// MultiTraceWriter fans a completed span out to several trace.Writers, so a tracer -// can record to both the gateway's ring buffer and a JSONL file in one pass. A nil -// Writer is skipped. It is the composition-root helper the main session uses to -// route the loop tracer's output into the gateway without dropping stderr. +// MultiTraceWriter 把已完成的 span 扇出到多个 trace.Writer,使一个 +// tracer 能一趟同时记录到网关的环形缓冲与一个 JSONL 文件。nil 的 +// Writer 被跳过。它是主会话用来把 loop tracer 的输出引入 +// 网关而不丢掉 stderr 的组合根辅助件。 type MultiTraceWriter struct { - // Writers are the sinks a span is written to. A nil Writer is skipped. + // Writers 是 span 被写入的汇聚点。nil 的 Writer 被跳过。 Writers []trace.Writer } -// WriteSpan writes rec to every configured Writer. +// WriteSpan 把 rec 写入每一个已配置的 Writer。 func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error { for _, w := range m.Writers { if w == nil { @@ -117,7 +117,7 @@ func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error { return nil } -// adminSpanOf maps a trace.SpanRecord to the camelCase admin wire shape. +// adminSpanOf 把 trace.SpanRecord 映射为驼峰式管理线上结构。 func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan { sp := AdminTraceSpan{ ID: rec.ID, diff --git a/internal/services/gateway/persona_env.go b/internal/services/gateway/persona_env.go index 9fd11bb..fd5423d 100644 --- a/internal/services/gateway/persona_env.go +++ b/internal/services/gateway/persona_env.go @@ -11,82 +11,82 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) -// errPersonaBindingUnsupported is returned by newSubSession when a caller asks for -// a role-bound session but the injected history store cannot bind roles. The -// handler maps it to 501 rather than pretending the binding landed. +// errPersonaBindingUnsupported 由 newSubSession 在调用方要求一个 +// 绑定角色的会话、但注入的 history 存储无法绑定角色时返回。 +// handler 把它映射为 501,而不是假装绑定已生效。 var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding") -// This file owns the gateway's turn-environment resolution (DevDocs/design/22 -// §2/§4.5): which role answers, expressed as the loop.SessionEnv the turn runs -// under. +// 本文件拥有网关的回合环境解析(DevDocs/design/22 +// §2/§4.5):由哪个角色应答,表达为该回合运行的 +// loop.SessionEnv 环境。 // -// Precedence (design/22 §2, top down): -// 1. request-level override — NOT implemented in this batch (batch 1 exposes the -// session-level binding; a per-request override is additive and unpersisted); -// 2. session-level binding — chat_sessions.persona_id, resolved here; -// 3. channel-level mapping — batch 2 (channels.json / channel_sessions); -// 4. global default — expressed as PersonaID == "", which the loop resolves to its -// configured default role (Options.PersonaID). That is what makes every -// pre-existing session behave exactly as before. +// 优先级(design/22 §2,自上而下): +// 1. 请求级覆盖 —— 本批次不实现(批次 1 暴露的是 +// 会话级绑定;按请求覆盖是加性且不持久化的); +// 2. 会话级绑定 —— chat_sessions.persona_id,在此解析; +// 3. 渠道级映射 —— 批次 2(channels.json / channel_sessions); +// 4. 全局默认 —— 表达为 PersonaID == "",由 loop 解析到其 +// 已配置的默认角色(Options.PersonaID)。这正是让每个 +// 既有会话的行为与以往完全一致的原因。 // -// The env's OTHER fields are untouched: ActorEnv and MemWritable still come from -// loop.OwnerEnv() for these owner-facing Web/voice paths. That is the identity red -// line — who may do what is decided by the channel actor metadata (here: a JWT -// owner), never by the role being played. Persona selection only changes the -// prompt, the memory namespace, the emotion machine and the thinking budget. +// env 的其他字段不受影响:对这些面向 owner 的 Web/语音路径, +// ActorEnv 与 MemWritable 仍来自 loop.OwnerEnv()。这就是身份红线 +// —— 谁可以做什么由 channel 的 actor 元数据决定(此处是一个 JWT +// owner),绝不是由所扮演的角色决定。人格选择只改变 +// 提示词、记忆命名空间、情绪机与思考预算。 -// sessionPersonaReader is the optional narrower view of HistoryStore used to read a -// session's bound role. *history.Store implements it; a store that does not (a test -// fake, or a store predating design/22) simply yields "" — the global default — so -// the gateway keeps working. +// sessionPersonaReader 是 HistoryStore 可选的更窄视图,用于读取 +// 会话的绑定角色。*history.Store 实现了它;未实现的存储(测试 +// 替身,或早于 design/22 的存储)只是得到 "" —— 全局默认 —— 因此 +// 网关继续工作。 type sessionPersonaReader interface { SessionPersona(ctx context.Context, sessionID string) (string, error) } -// sessionPersonaWriter is the optional narrower view of HistoryStore for writing a -// session's role binding. A session's role is decided at creation (design/22 §8-1), -// so no request path rebinds one any more — creation goes through subSessionCreator — -// and the gateway no longer type-asserts this interface. It stays next to the reader -// and the creator so the store's role-binding surface is documented in one place: -// *history.Store still implements SetSessionPersona (design/22 §4.1) and the -// admin/owner write path that would use it is a separate endpoint, not a PATCH. +// sessionPersonaWriter 是 HistoryStore 可选的更窄视图,用于写入 +// 会话的角色绑定。会话角色在创建时决定(design/22 §8-1), +// 因此不再有任何请求路径重绑 —— 创建走 subSessionCreator —— +// 而网关也不再对该接口做类型断言。它留在读取器 +// 与创建器旁边,使存储的角色绑定表面集中记录在一处: +// *history.Store 仍实现 SetSessionPersona(design/22 §4.1),而会用它的 +// 管理/owner 写路径是独立端点,不是 PATCH。 type sessionPersonaWriter interface { SetSessionPersona(ctx context.Context, sessionID, personaID string) error } -// subSessionCreator is the optional narrower view of HistoryStore used to create a -// session already bound to a role (POST /sessions {personaId}, design/22 §4.5). -// *history.Store implements it; a store without it can only create an unbound -// session, so a request that explicitly asks for a role is refused with 501. +// subSessionCreator 是 HistoryStore 可选的更窄视图,用于创建一个 +// 已绑定角色的会话(POST /sessions {personaId},design/22 §4.5)。 +// *history.Store 实现了它;没有它的存储只能创建未绑定的 +// 会话,因此显式要求角色的请求以 501 拒绝。 type subSessionCreator interface { NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error) } -// turnEnvResolver builds the loop session environment for one turn. It holds the -// two read-only dependencies the resolution needs, so both the HTTP/WS handlers -// and the hub resolve roles through the same code path (design/22 §4.5: one -// resolver, not a hardcoded loop.OwnerEnv() per entry point). +// turnEnvResolver 为一个回合构建 loop 会话环境。它持有 +// 解析所需的两个只读依赖,因此 HTTP/WS handler +// 与 hub 都经同一条代码路径解析角色(design/22 §4.5:一个 +// 解析器,而不是每个入口各硬编码一份 loop.OwnerEnv())。 type turnEnvResolver struct { history HistoryStore personas PersonaStore log *slog.Logger } -// owner returns the per-turn environment for one owner-facing turn of sessionID: -// the full-access owner env (identity unchanged) plus the session's bound role. -// A blank role means "inherit the global default"; the loop performs that final -// fallback, so the gateway never has to know the default role's id. +// owner 返回 sessionID 一个面向 owner 回合的每回合环境: +// 全访问 owner env(身份不变)加上该会话绑定的角色。 +// 空白角色意味着「继承全局默认」;loop 执行那最后一次 +// 回退,因此网关永远不必知道默认角色的 id。 func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv { env := loop.OwnerEnv() env.PersonaID = t.sessionPersona(ctx, sessionID) return env } -// sessionPersona returns the role bound to sessionID, or "" when the session -// inherits the global default. An unknown session, a store without the reader, or -// a read error all degrade to "" (the default) rather than failing the turn: a -// chat must never be blocked by a metadata lookup, and the fallback is the -// pre-multi-character behavior. +// sessionPersona 返回绑定到 sessionID 的角色,会话继承全局 +// 默认时返回 ""。未知会话、没有读取器的存储、或 +// 读错误都退化为 ""(默认),而不是让该回合失败: +// 聊天绝不能被一次元数据查找阻塞,而该回退正是 +// 多角色之前的行为。 func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) string { if t.history == nil || sessionID == "" { return "" @@ -105,10 +105,10 @@ func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) s return personaID } -// defaultPersonaID returns the persona id of the library's default character, or -// "" when no persona store is wired or no character is marked default. It is the -// gateway-side view of owner.yaml:defaultPersona used where a role id is needed -// without a session (the admin memory search fallback, design/22 §4.5). +// defaultPersonaID 返回库中默认角色的人格 id,未接线人格 +// 存储或没有角色被标记为默认时返回 ""。它是 +// owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时 +// 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。 func (s *Server) defaultPersonaID() string { if s.personas == nil { return "" @@ -121,10 +121,10 @@ func (s *Server) defaultPersonaID() string { return "" } -// requireKnownPersona rejects a personaId the registry does not know, answering -// 400 and returning false. An empty id is always allowed (it means "inherit the -// global default"), and a gateway without a persona store cannot validate, so the -// binding is accepted there and simply resolves as an unknown role later. +// requireKnownPersona 拒绝注册表不认识的 personaId,返回 +// 400 并返回 false。空 id 一律允许(它意味着「继承 +// 全局默认」),而没有 persona 存储的网关无法校验,因此 +// 那里接受该绑定,之后只是解析为未知角色。 func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bool { if personaID == "" || s.personas == nil { return true @@ -136,10 +136,10 @@ func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bo return true } -// newSubSession creates a sub session bound to personaID, using the role-aware -// creator when the store provides one. A store without it can only create an -// unbound session, so an explicit role request is refused (501) instead of -// silently dropping the binding. +// newSubSession 创建一个绑定到 personaID 的子会话,存储提供 +// 角色感知创建器时使用它。没有它的存储只能创建 +// 未绑定会话,因此显式的角色请求被拒绝(501),而不是 +// 静默丢弃该绑定。 func (s *Server) newSubSession(ctx context.Context, ownerID, personaID string) (string, error) { creator, ok := s.history.(subSessionCreator) if !ok { diff --git a/internal/services/gateway/persona_read_test.go b/internal/services/gateway/persona_read_test.go index d395305..d938751 100644 --- a/internal/services/gateway/persona_read_test.go +++ b/internal/services/gateway/persona_read_test.go @@ -12,9 +12,9 @@ import ( personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" ) -// moodRegistry builds a real persona registry (the production PersonaStore, which -// also owns each role's emotion machine) from the given personas, registering the -// matching character too. +// moodRegistry 从给定人格构建真实人格注册表(生产 +// PersonaStore,它同时持有每个角色的情绪机),并一并注册 +// 匹配的角色。 func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registry { t.Helper() reg := personareg.New() @@ -29,8 +29,8 @@ func moodRegistry(t *testing.T, personas ...*loader.Persona) *personareg.Registr return reg } -// personaList fetches a persona list endpoint and returns each row as a raw map, -// so the test can tell a missing key from a present null/false. +// personaList 拉取人格列表端点并把每行作为原始 map 返回, +// 使测试能区分键缺失与存在且为 null/false。 func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path string) []map[string]any { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+path, nil, mustSign(t, jwt)) @@ -44,17 +44,17 @@ func personaList(t *testing.T, ts *httptest.Server, jwt *auth.JWTService, path s return rows } -// mustSign mints an owner token for the read endpoints. +// mustSign 为这些读端点铸造一个 owner token。 func mustSign(t *testing.T, jwt *auth.JWTService) string { t.Helper() return signAny(t, jwt, "acct", auth.RoleOwner) } -// TestAdminListPersonasThinkingKeepShowToUserTriState pins the read-side contract -// of the panel's `thinking` block: an unconfigured persona carries NO `thinking` -// key (nil, not an all-zero object), an explicitly false `showToUser` stays -// `false`, and an unset one stays `null` — never flattened into false, which is -// the distinction the write side keeps with *bool. +// TestAdminListPersonasThinkingKeepShowToUserTriState 钉住面板 `thinking` +// 块的读侧契约:未配置的人格不携带 `thinking` +// 键(nil,而不是全零对象),显式为 false 的 `showToUser` 保持 +// `false`,未设置的保持 `null` —— 绝不被压平成 false,这正是 +// 写侧用 *bool 保持的区分。 func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) { t.Parallel() @@ -118,9 +118,9 @@ func TestAdminListPersonasThinkingKeepShowToUserTriState(t *testing.T) { } } -// TestUserListPersonasReportsRoleMoodOnly pins the user-side shape: the role-level -// mood rides along (the frontend seeds its badge from it), while the admin-only -// thinking knobs stay out of the user contract. +// TestUserListPersonasReportsRoleMoodOnly 钉住用户侧形状:角色级 +// mood 一并带上(前端从它给角标播种),而仅管理的 +// thinking 开关留在用户契约之外。 func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) { t.Parallel() @@ -150,10 +150,10 @@ func TestUserListPersonasReportsRoleMoodOnly(t *testing.T) { } } -// TestPersonaMoodFollowsTheRole is the "mood is role-level, not per-session" rule: -// it reads the live emotion machine, so a transition on one role's machine shows -// up for that role and leaves every other role untouched, with no session input -// anywhere in the path. +// TestPersonaMoodFollowsTheRole 是「mood 是角色级、不是每会话」规则: +// 它读取活的情绪机,因此一个角色机器上的转移会为该角色 +// 体现出来,且不触碰其他任何角色,路径中 +// 任何地方都没有会话输入。 func TestPersonaMoodFollowsTheRole(t *testing.T) { t.Parallel() @@ -175,7 +175,7 @@ func TestPersonaMoodFollowsTheRole(t *testing.T) { if x, y := readMoods(); x != "calm" || y != "calm" { t.Fatalf("initial moods = (%q, %q), want (calm, calm)", x, y) } - // Move only 昔涟's machine (the machine a settled turn's observer would move). + // 只移动昔涟的机器(一个已结算回合的观察者会移动的机器)。 if err := reg.Emotions().Transition("xilian", emotion.Happy); err != nil { t.Fatalf("Transition: %v", err) } @@ -184,9 +184,9 @@ func TestPersonaMoodFollowsTheRole(t *testing.T) { } } -// TestPersonaMoodEmptyWithoutEmotionStore is the honest degrade: a persona store -// that does not own emotion (it only lists personas) reports an empty mood rather -// than inventing one. +// TestPersonaMoodEmptyWithoutEmotionStore 是诚实的退化:不持有情绪 +// 的人格存储(它只列出人格)报告空 mood,而不是 +// 编造一个。 func TestPersonaMoodEmptyWithoutEmotionStore(t *testing.T) { t.Parallel() diff --git a/internal/services/gateway/proactive.go b/internal/services/gateway/proactive.go index e9e506c..47484f9 100644 --- a/internal/services/gateway/proactive.go +++ b/internal/services/gateway/proactive.go @@ -10,14 +10,14 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// wsProactiveMessage is the WS event type for an autonomous proactive push -// (design/12 §3): a message Cyrene decided to send without a recent user input. +// wsProactiveMessage 是自主主动推送的 WS 事件类型 +// (design/12 §3):Cyrene 在没有近期用户输入时决定发送的消息。 const wsProactiveMessage = "proactive_message" -// proactivePayload is the sanitized wire shape of a proactive message. It carries -// only the fields a client needs to render a bubble (content, platform, channel, -// sender label); it deliberately omits RawData / media bytes / tokens so a -// proactive push can never leak a credential or payload into the frontend. +// proactivePayload 是主动消息消毒后的线上结构。它只 +// 携带客户端渲染气泡所需的字段(content、platform、channel、 +// 发送者标签);它刻意省略 RawData / 媒体字节 / token,使 +// 主动推送绝不可能把凭据或载荷泄露进前端。 type proactivePayload struct { Platform string `json:"platform,omitempty"` ChannelID string `json:"channelId,omitempty"` @@ -29,12 +29,12 @@ type proactivePayload struct { MessageID string `json:"messageId,omitempty"` } -// SendProactive delivers msg to every live owner/admin connection and returns how -// many accepted it. A zero count means no owner/admin client is online, so the -// caller falls back to the IM bridge. Only owner/admin connections are targeted -// (red line: proactive is owner-only); a viewer or unauthenticated connection is -// never reached. It never blocks: a full send buffer drops the frame for that -// slow client rather than stalling the backthink push. +// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回 +// 有多少个接受了它。计数为零意味着没有 owner/admin 客户端在线, +// 因此调用方回退到 IM bridge。只有 owner/admin 连接会被作为 +// 目标(红线:主动推送仅 owner);viewer 或未认证连接 +// 绝不被触达。它绝不阻塞:发送缓冲满时对该 +// 慢客户端丢弃该帧,而不是卡住后台思考推送。 func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) { if err := ctx.Err(); err != nil { return 0, err @@ -59,13 +59,13 @@ func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (in return n, nil } -// Server.SendProactive satisfies the backthink ActiveClient seam by delegating to -// the hub, so the composition root can hand the gateway itself to the sink setter. +// Server.SendProactive 通过委托给 hub 满足后台思考的 ActiveClient +// 接缝,因此组合根能把网关自身交给汇聚点设置器。 func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) { return s.hub.SendProactive(ctx, msg) } -// proactivePayloadFrom projects a UnifiedMessage into the sanitized wire payload. +// proactivePayloadFrom 把 UnifiedMessage 投影为消毒后的线上载荷。 func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload { return proactivePayload{ Platform: msg.Platform, @@ -79,8 +79,8 @@ func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload { } } -// ownerAdminClaims reports whether claims authorize a proactive push (owner or -// admin). Nil claims (unauth / bare test connections) are never targeted. +// ownerAdminClaims 报告 claims 是否授权一次主动推送(owner 或 +// admin)。nil claims(未认证 / 裸测试连接)绝不被作为目标。 func ownerAdminClaims(claims *auth.Claims) bool { if claims == nil { return false diff --git a/internal/services/gateway/proactive_test.go b/internal/services/gateway/proactive_test.go index 00c9f25..3218100 100644 --- a/internal/services/gateway/proactive_test.go +++ b/internal/services/gateway/proactive_test.go @@ -8,9 +8,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// TestHub_SendProactive_OwnerAndAdmin verifies a proactive push reaches exactly -// the owner/admin connections and returns the delivered count; a viewer or -// unauthenticated (nil-claims) connection never receives it. +// TestHub_SendProactive_OwnerAndAdmin 验证主动推送恰好抵达 +// owner/admin 连接并返回投递计数;viewer 或未认证 +// (nil claims)连接绝不收到它。 func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) @@ -20,7 +20,7 @@ func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) { admin.claims = &auth.Claims{Role: auth.RoleAdmin} viewer := newFakeConn(hb, 8) viewer.claims = &auth.Claims{Role: auth.RoleViewer} - bare := newFakeConn(hb, 8) // claims nil: unauthenticated + bare := newFakeConn(hb, 8) // claims 为 nil:未认证 for _, c := range []*wsConn{owner, admin, viewer, bare} { hb.register(c) } @@ -69,8 +69,8 @@ func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) { } } -// TestHub_SendProactive_NoOnlineOwner verifies a zero count (no error) when no -// owner/admin connection is live, so the caller falls back to the IM bridge. +// TestHub_SendProactive_NoOnlineOwner 验证没有存活 owner/admin 连接时 +// 计数为零(无错误),使调用方回退到 IM bridge。 func TestHub_SendProactive_NoOnlineOwner(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) @@ -87,8 +87,8 @@ func TestHub_SendProactive_NoOnlineOwner(t *testing.T) { } } -// TestHub_SendProactive_CancelledContext verifies a cancelled context denies the -// push (fail closed) with a wrapped error. +// TestHub_SendProactive_CancelledContext 验证被取消的 context 拒绝该 +// 推送(fail closed)并返回被 wrap 的错误。 func TestHub_SendProactive_CancelledContext(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) @@ -100,8 +100,8 @@ func TestHub_SendProactive_CancelledContext(t *testing.T) { } } -// TestServer_SendProactive_DelegatesToHub verifies Server.SendProactive mirrors the -// hub so the composition root can hand the gateway itself to the sink setter. +// TestServer_SendProactive_DelegatesToHub 验证 Server.SendProactive 镜像 +// hub,使组合根能把网关自身交给汇聚点设置器。 func TestServer_SendProactive_DelegatesToHub(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) diff --git a/internal/services/gateway/rbac_viewer_test.go b/internal/services/gateway/rbac_viewer_test.go index acd263d..a296843 100644 --- a/internal/services/gateway/rbac_viewer_test.go +++ b/internal/services/gateway/rbac_viewer_test.go @@ -8,19 +8,19 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) -// rbacRoute is one admin route plus the method needed to reach it. The name is -// the subtest label, so a failure names the route without reading the path. +// rbacRoute 是一条管理路由加上抵达它所需的方法。name 是 +// 子测试标签,因此失败时不必读路径就能指明是哪条路由。 type rbacRoute struct { name string method string path string } -// viewerReadRoutes is the read-only monitoring tier: the exact admin route set -// wired to viewerAuth in gateway/routes.go (owner/admin/viewer). It must stay in -// lockstep with registerAdminRoutes — adding a route to viewerAuth without -// adding it here leaves it untested, and moving one out of viewerAuth makes the -// matching subtest fail (see the batch's mutation-verification records). +// viewerReadRoutes 是只读监控层:gateway/routes.go 中接到 viewerAuth +// 的确切管理路由集合(owner/admin/viewer)。它必须与 +// registerAdminRoutes 保持同步 —— 把一条路由加进 viewerAuth 而不 +// 加进这里会让它未被测试,而把一条移出 viewerAuth 会让对应 +// 子测试失败(见本批次的变异验证记录)。 var viewerReadRoutes = []rbacRoute{ {"personas", http.MethodGet, "/api/v1/admin/personas"}, {"sessions", http.MethodGet, "/api/v1/admin/sessions"}, @@ -36,10 +36,10 @@ var viewerReadRoutes = []rbacRoute{ {"notifications", http.MethodGet, "/api/v1/admin/notifications"}, } -// ownerAdminReadRoutes is the admin read tier that stays owner/admin: private -// content, other accounts' data and the audit trail. A viewer must get 403; an -// admin must pass the gate (the handler's own status is out of scope here, so -// only "not 401/403" is asserted for admin). +// ownerAdminReadRoutes 是仍保持 owner/admin 的管理读层:私有 +// 内容、其他账号的数据与审计链。viewer 必须得到 403; +// admin 必须通过该门(handler 自身的状态在此不在范围内,因此 +// 对 admin 只断言「不是 401/403」)。 var ownerAdminReadRoutes = []rbacRoute{ {"memories", http.MethodGet, "/api/v1/admin/memories"}, {"channels/messages", http.MethodGet, "/api/v1/admin/channels/messages"}, @@ -52,10 +52,10 @@ var ownerAdminReadRoutes = []rbacRoute{ {"rag/query", http.MethodPost, "/api/v1/admin/rag/query"}, } -// ownerOnlyRoutes is every route reserved for the owner, including the -// credential-acquiring channel endpoints (login-qr/login-status) and the -// state-mutating writes. An admin must be rejected too, so the list doubles as -// the owner>admin boundary test. +// ownerOnlyRoutes 是保留给 owner 的每一条路由,包括 +// 获取凭据的渠道端点(login-qr/login-status)与 +// 改变状态的写操作。admin 也必须被拒绝,因此该列表同时用作 +// owner>admin 边界测试。 var ownerOnlyRoutes = []rbacRoute{ {"channels/{platform}/login-qr", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-qr"}, {"channels/{platform}/login-status", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-status"}, @@ -94,9 +94,9 @@ var ownerOnlyRoutes = []rbacRoute{ {"system/restore", http.MethodPost, "/api/v1/admin/system/restore"}, } -// newRBACEnv builds the gateway with a real JWT service, a capability registry -// and an injected log ring buffer, so every viewer-readable route can answer -// 200 (the log tail would otherwise be an honest 501). +// newRBACEnv 用真实 JWT 服务、能力注册表 +// 与注入的日志环形缓冲构建网关,使每条 viewer 可读路由都能返回 +// 200(否则日志尾部会是诚实的 501)。 func newRBACEnv(t *testing.T) *testEnv { t.Helper() env := newEnv(t, nil, true) @@ -104,10 +104,10 @@ func newRBACEnv(t *testing.T) *testEnv { return env } -// rbacBody returns a request body valid enough that the RBAC gate — not a -// decoder error — decides the status. A failed decode still answers 4xx, which -// never turns a 403 into a false pass, but a valid body keeps the assertions -// honest about what each route expects. +// rbacBody 返回一个足够合法的请求体,使决定状态的是 RBAC 门 —— +// 而不是解码错误。解码失败仍返回 4xx,这绝不会把 +// 403 变成假通过,但合法请求体让断言对每条路由 +// 的预期保持诚实。 func rbacBody(rt rbacRoute) any { switch rt.name { case "rag/query": @@ -117,9 +117,9 @@ func rbacBody(rt rbacRoute) any { } } -// TestAdminRBAC_ViewerReadsMonitoringSurface locks the viewer tier: each of the -// twelve read-only monitoring routes answers 200 for viewer, admin and owner -// alike (owner>admin>viewer all satisfy viewerAuth). +// TestAdminRBAC_ViewerReadsMonitoringSurface 锁定 viewer 层:十二条只读 +// 监控路由对 viewer、admin 与 owner 都返回 200 +// (owner>admin>viewer 都满足 viewerAuth)。 func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) { t.Parallel() env := newRBACEnv(t) @@ -140,9 +140,9 @@ func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) { } } -// TestAdminRBAC_AnonymousIsUnauthorized proves every admin route (both tiers) -// fails closed with 401 — not 403 and never a silent success — when no bearer -// token is presented. +// TestAdminRBAC_AnonymousIsUnauthorized 证明未出示 bearer token 时每条 +// 管理路由(两层)都以 401 失败关闭 —— 不是 403,也绝不是 +// 静默成功。 func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) { t.Parallel() env := newRBACEnv(t) @@ -162,10 +162,10 @@ func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) { } } -// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes locks the exclusion list: a -// viewer is 403 on every admin read that carries private content, other -// accounts' data or audit records, and on every owner-only route (writes plus -// the credential-acquiring channel endpoints). +// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes 锁定排除列表:viewer 在 +// 每一条携带私有内容、其他账号数据或审计记录的 +// 管理读上都是 403,在每一条仅 owner 路由(写操作加上 +// 获取凭据的渠道端点)上亦然。 func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) { t.Parallel() env := newRBACEnv(t) @@ -185,10 +185,10 @@ func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) { } } -// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate separates the two upper -// tiers: an admin passes the gate on the owner/admin read routes (its handler -// may still answer 404/501 — only the gate's verdict is asserted) but is 403 on -// every owner-only route, proving admin never leaks into owner. +// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate 区分上面两层:admin +// 在 owner/admin 读路由上通过该门(其 handler 仍可能返回 +// 404/501 —— 只断言门的判定)但在每一条仅 owner 路由上 +// 是 403,证明 admin 绝不越入 owner。 func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) { t.Parallel() env := newRBACEnv(t) @@ -215,8 +215,8 @@ func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) { } } -// TestAdminRBAC_ViewerForbiddenOnWrites pins the two write routes named in the -// wiring brief so a future gate change cannot quietly hand a viewer a mutation. +// TestAdminRBAC_ViewerForbiddenOnWrites 钉住接线说明中点名的两条写 +// 路由,使今后的门的改动无法悄悄把变更能力交给 viewer。 func TestAdminRBAC_ViewerForbiddenOnWrites(t *testing.T) { t.Parallel() env := newRBACEnv(t) diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index 4b5c81e..41d1fd4 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -14,10 +14,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// tokenTTL is how long an issued JWT remains valid. +// tokenTTL 是已签发 JWT 的有效时长。 const tokenTTL = 24 * time.Hour -// PersonaSummary is the wire shape of a persona in REST/admin list responses. +// PersonaSummary 是 REST/管理列表响应中一个人格的线上结构。 type PersonaSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -26,53 +26,53 @@ type PersonaSummary struct { Mode string `json:"mode"` MemoryNS string `json:"memoryNamespace"` Avatar string `json:"avatar,omitempty"` - // ChatBackground holds per-orientation wallpaper; a role's own, never another's. + // ChatBackground 持有分方向的壁纸;是本角色自己的,绝不是别人的。 ChatBackground ChatBackgroundSummary `json:"chatBackground,omitempty"` - // Mood is the role's current emotion state (design/01 §2: emotion follows the - // role, never a session). "" means the role has no tracked emotion — an honest - // empty, never a fabricated default. Clients seed their mood badge from it so a - // refresh no longer flashes an empty mood. + // Mood 是该角色的当前情绪状态(design/01 §2:情绪跟随角色, + // 绝不是会话)。"" 意味着该角色没有受跟踪的情绪 —— 诚实的 + // 空值,绝不是编造的默认。客户端从它给 mood 角标播种,因此 + // 刷新不再闪出空 mood。 Mood string `json:"mood"` - // Thinking carries the persona's optional reasoning/presentation block - // (design/01 §2.1). It is nil — and so absent from the JSON — when the persona - // configures none, mirroring the write side's "an omitted block keeps the - // stored one". Admin list only: these are model-call knobs, not user-UI state - // (the user list takes Mood and leaves the knobs to the panel). + // Thinking 携带人格可选的推理/呈现块 + // (design/01 §2.1)。人格未配置时它是 nil —— 因而从 JSON 中缺席 —— + // 与写侧「省略的块保留 + // 已存块」。仅管理列表:这些是模型调用开关,不是用户 UI 状态 + // (用户列表取 Mood,把开关留给面板)。 Thinking *PersonaThinkingSummary `json:"thinking,omitempty"` } -// PersonaThinkingSummary mirrors a persona's `thinking` block on the wire -// (design/01 §2.1; provider mapping in design/04 §3.2). +// PersonaThinkingSummary 在线上镜像人格的 `thinking` 块 +// (design/01 §2.1;provider 映射见 design/04 §3.2)。 type PersonaThinkingSummary struct { - // BudgetTokens is the reasoning budget (Anthropic thinking.budget_tokens). + // BudgetTokens 是推理预算(Anthropic thinking.budget_tokens)。 BudgetTokens int `json:"budgetTokens"` - // Effort is the provider-specific reasoning strength (reasoning_effort). The - // model layer validates it against the active provider's whitelist and WARNs - // when it is dropped — this side just reports what is stored. + // Effort 是 provider 特有的推理强度(reasoning_effort)。 + // 模型层会按活动 provider 的白名单校验它,并在它被丢弃时 + // 打 WARN —— 这一侧只报告已存的内容。 Effort string `json:"effort"` - // ShowToUser is TRI-STATE and stays that way on the wire: null = not - // configured (thinking frames keep flowing — the pre-existing behavior), - // true/false = an explicit choice. A plain bool would flatten "unset" onto - // "off" and lose the distinction the write side keeps with *bool. + // ShowToUser 是三态的,且在线上保持如此:null = 未 + // 配置(thinking 帧继续流出 —— 既有行为), + // true/false = 显式选择。普通 bool 会把「未设置」压平到 + // 「关闭」上,丢掉写侧用 *bool 保持的区分。 ShowToUser *bool `json:"showToUser"` - // Mode is the explicit hard switch: default | on | off ("" = default). + // Mode 是显式的硬开关:default | on | off("" = default)。 Mode string `json:"mode"` } -// ChatBackgroundSummary mirrors the persona chatBackground on the wire. +// ChatBackgroundSummary 在线上镜像人格的 chatBackground。 type ChatBackgroundSummary struct { Landscape string `json:"landscape,omitempty"` Portrait string `json:"portrait,omitempty"` } -// MemorySummary is the wire shape of a memory entry in the admin list response. +// MemorySummary 是管理列表响应中一条记忆条目的线上结构。 type MemorySummary struct { ID string `json:"id"` Scope string `json:"scope"` Text string `json:"text"` } -// SessionSummary is the wire shape of a channel session in list responses. +// SessionSummary 是列表响应中一个渠道会话的线上结构。 type SessionSummary struct { Platform string `json:"platform"` ChannelID string `json:"channelId"` @@ -83,15 +83,15 @@ type SessionSummary struct { MemWrite bool `json:"memWritable"` } -// loginRequest is the POST /api/v1/auth/login body. +// loginRequest 是 POST /api/v1/auth/login 的请求体。 type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } -// loginResponse is the login success payload. RefreshToken is the opaque -// long-lived credential (POST /auth/refresh) added by B2; it is omitted when no -// refresh store is wired so the pre-B2 contract is unchanged. +// loginResponse 是登录成功的载荷。RefreshToken 是 B2 新增的非透明 +// 长期凭据(POST /auth/refresh);未接线 refresh 存储时被省略, +// 因此 B2 之前的契约不变。 type loginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expiresAt"` @@ -99,15 +99,15 @@ type loginResponse struct { RefreshToken string `json:"refreshToken,omitempty"` } -// chatRequest is the POST /api/v1/chat body. When Stream is true the gateway -// triggers streaming over the WebSocket channel and returns immediately. Image is -// an optional base64 data: URI or http(s) URL attached for the vision path; the -// loop routes it through the vision/OCR tool or the main model (design/06 §0). -// DeviceID is the client-produced identifier of the sending browser/device -// (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted as-is. -// Mode is the behavior mode the client sent the turn in (design/01 §3, design/16 -// §5.1); it is optional and persisted per message for bubble rendering, falling -// back to "chat" when omitted. +// chatRequest 是 POST /api/v1/chat 的请求体。Stream 为 true 时网关 +// 经 WebSocket 通道触发流式并立即返回。Image 是 +// 为视觉路径附带的可选 base64 data: URI 或 http(s) URL; +// loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。 +// DeviceID 是客户端产出的发送浏览器/设备标识 +// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。 +// Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16 +// §5.1);它可选,并按消息持久化以供气泡渲染,省略时 +// 回退到 "chat"。 type chatRequest struct { SessionID string `json:"sessionId"` Text string `json:"text"` @@ -117,19 +117,19 @@ type chatRequest struct { Stream bool `json:"stream"` } -// chatResponse is the synchronous chat reply payload. +// chatResponse 是同步聊天回复的载荷。 type chatResponse struct { Reply string `json:"reply"` Steps int `json:"steps"` StopReason string `json:"stopReason"` } -// handleHealth reports liveness. It is unauthenticated because it is a probe. +// handleHealth 报告存活。它不认证,因为它是探针。 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } -// handleLogin exchanges the admin username/password for a signed JWT. +// handleLogin 用管理用户名/密码换取已签名的 JWT。 func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -152,9 +152,9 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { ExpiresAt: time.Now().Add(tokenTTL).Unix(), Role: string(auth.RoleOwner), } - // Issue the long-lived refresh credential alongside the access token. A store - // failure fails the login rather than silently handing back a login without - // the advertised refresh token; the plaintext is never logged. + // 在签发访问令牌的同时签发长期 refresh 凭据。存储失败会让 + // 登录失败,而不是静默交回一个没有所声称 refresh token 的 + // 登录;明文绝不记日志。 if s.refresh != nil { refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL()) if rerr != nil { @@ -167,7 +167,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } -// handleChat runs one synchronous agent turn (or triggers streaming). +// handleChat 运行一次同步 agent 回合(或触发流式)。 func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { var req chatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -190,9 +190,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if req.SessionID == "" { req.SessionID = "web" } - // The Web main session is the owner's private conversation (JWT = owner), so - // it binds the full-access env: memory read+write, unchanged behavior. The role - // comes from the session's binding (design/22 §4.5). + // Web 主会话是主人的私密对话(JWT = owner),因此它绑定 + // 全访问 env:记忆读+写,行为不变。角色 + // 来自会话的绑定(design/22 §4.5)。 msgs := userChatMessages(req.Text, req.Image) env := s.envResolver.owner(r.Context(), req.SessionID) if s.hub.orchestrator != nil { @@ -205,8 +205,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } return } - // A busy session returns a non-blocking ack (design/15 §2.1) instead of - // blocking the request on a turn that may run for a while. + // 忙碌会话返回非阻塞 ack(design/15 §2.1),而不是 + // 让请求阻塞在一个可能跑一阵子的回合上。 if sr.Pending { writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth}) return @@ -219,8 +219,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason}) return } - // Legacy synchronous path when no orchestrator is wired (a hub built without - // one keeps the exact pre-design/15 reply contract). + // 未接线编排器时的旧同步路径(未接线的 hub + // 保持完全与 design/15 之前相同的回复契约)。 res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), req.SessionID, msgs) if err != nil { s.log.Error("chat run", "err", err, "session", req.SessionID) @@ -230,10 +230,10 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason}) } -// handleListSessions returns the persistent chat-history sessions (main + sub) when a -// history store is wired, otherwise falls back to the channel-session view. The fallback -// keeps the pre-design/16 contract: a server built without history still lists the known -// channel sessions (or [] when no store is wired). +// handleListSessions 在接线了 history 存储时返回持久化聊天历史会话 +// (main + sub),否则回退到渠道会话视图。回退保持 +// design/16 之前的契约:未接 history 的服务器仍列出已知的 +// 渠道会话(未接线任何存储时为 [])。 func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { if s.history != nil { s.handleHistorySessions(w, r) @@ -254,16 +254,16 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// handleListPersonas returns the personas (or [] when no store is wired). It is -// the user-side shape: the role-level mood rides along, the admin-only thinking -// knobs do not. +// handleListPersonas 返回人格(未接线存储时为 [])。它是 +// 用户侧形状:角色级 mood 一并带上,仅管理的 thinking +// 开关则不然。 func (s *Server) handleListPersonas(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.collectPersonas(false)) } -// collectPersonas returns a non-nil persona slice from the configured store. -// withThinking adds the admin-only `thinking` block; mood is always reported -// because the user UI renders the badge. +// collectPersonas 从已配置的存储返回非 nil 的人格切片。 +// withThinking 加上仅管理的 `thinking` 块;mood 总是报告, +// 因为用户 UI 要渲染角标。 func (s *Server) collectPersonas(withThinking bool) []PersonaSummary { out := make([]PersonaSummary, 0) if s.personas == nil { @@ -282,9 +282,9 @@ func (s *Server) collectPersonas(withThinking bool) []PersonaSummary { Mode: firstMode(c.Modes), Mood: s.moodOf(id), } - // Read this persona's own visual assets (avatar / chat background). Each - // role carries its own; a missing persona or unset visual yields empty, - // never another role's — the frontend falls back to a neutral default. + // 读取该人格自己的视觉资产(头像 / 聊天背景)。每个 + // 角色携带自己的;人格缺失或视觉未设置得到空, + // 绝不是别的角色的 —— 前端回退到中性默认。 if p, ok := s.personas.Persona(id); ok && p != nil { if p.Visual.Avatar != "" { sum.Avatar = p.Visual.Avatar @@ -302,10 +302,10 @@ func (s *Server) collectPersonas(withThinking bool) []PersonaSummary { return out } -// moodOf reports the role's current emotion state. Emotion is owned by the -// persona store (design/01 §2), read through the optional PersonaMoodStore -// capability; a store that does not track emotion, or a role with no machine, -// reports "" instead of a made-up mood. +// moodOf 报告该角色的当前情绪状态。情绪归 +// 人格存储所有(design/01 §2),经可选的 PersonaMoodStore +// 能力读取;不跟踪情绪的存储,或没有情绪机的角色, +// 报告 "" 而不是编造一个 mood。 func (s *Server) moodOf(id string) string { store, ok := s.personas.(PersonaMoodStore) if !ok { @@ -322,11 +322,11 @@ func (s *Server) moodOf(id string) string { return string(machine.Current()) } -// thinkingSummaryOf maps a stored thinking block onto the wire. It returns nil -// when the persona configures none — the key is then absent, so a client can tell -// "not configured" from an explicit value (the read-side mirror of the write-side -// rule that an omitted block keeps the stored one). include is false for the -// user-side list, which does not carry these knobs. +// thinkingSummaryOf 把已存 thinking 块映射到线上。人格未配置时 +// 它返回 nil —— 该键因而缺席,使客户端能把「未配置」 +// 与显式取值区分开(写侧「省略的块保留已存块」规则的 +// 读侧镜像)。用户侧列表的 include 为 false, +// 因为用户侧不携带这些开关。 func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSummary { if !include || !thinkingConfigured(t) { return nil @@ -339,13 +339,13 @@ func thinkingSummaryOf(t loader.ThinkingParams, include bool) *PersonaThinkingSu } } -// thinkingConfigured reports whether any thinking knob is set. An empty block -// must read as "not configured" rather than as an all-zero object. +// thinkingConfigured 报告是否有任何 thinking 开关被设置。空块 +// 必须读作「未配置」,而不是全零对象。 func thinkingConfigured(t loader.ThinkingParams) bool { return t.BudgetTokens != 0 || t.Effort != "" || t.ShowToUser != nil || t.Mode != "" } -// firstMode returns the first declared behavior mode, or "" when none are set. +// firstMode 返回首个声明的行为模式,未设置时返回 ""。 func firstMode(modes []string) string { if len(modes) == 0 { return "" @@ -353,9 +353,9 @@ func firstMode(modes []string) string { return modes[0] } -// userChatMessages builds the user turn for a chat / stream request. When image is -// present the turn becomes an image message (with optional text); otherwise a -// plain text message. An empty text with an image yields an image-only turn. +// userChatMessages 为 chat / stream 请求构建用户回合。image +// 存在时该回合成为图片消息(可带文本);否则是 +// 纯文本消息。空文本加图片得到仅图片的回合。 func userChatMessages(text, image string) []model.Message { if strings.TrimSpace(image) != "" { return []model.Message{model.ImageMessage(model.RoleUser, text, image)} @@ -363,7 +363,7 @@ func userChatMessages(text, image string) []model.Message { return []model.Message{model.TextMessage(model.RoleUser, text)} } -// summaryOfSession maps a session.Session to its wire shape. +// summaryOfSession 把 session.Session 映射为其线上结构。 func summaryOfSession(sess session.Session) SessionSummary { return SessionSummary{ Platform: sess.Platform, @@ -376,15 +376,15 @@ func summaryOfSession(sess session.Session) SessionSummary { } } -// writeJSON encodes v as the {data: v} envelope with the given status. +// writeJSON 把 v 编码为 {data: v} 信封并带上给定状态。 func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"data": v}) } -// writeError encodes err as the {error:{code,message}} envelope with the status -// resolved by apperr.HTTPStatus. +// writeError 把 err 编码为 {error:{code,message}} 信封,状态由 +// apperr.HTTPStatus 解析。 func writeError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(apperr.HTTPStatus(err)) diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 992e684..8249f0f 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -6,22 +6,22 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) -// This file owns the HTTP surface: the route table (REST /api/v1, the realtime -// WebSocket hub, /api/v1/admin/* and externally-mounted webhook callbacks) and -// the outer middleware chain. Route registration is split by audience so the -// RBAC gate guarding each group stays visible: public, authenticated user, -// private history, admin reads and owner-only writes. +// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时 +// WebSocket hub、/api/v1/admin/* 与外部挂载的 webhook 回调)以及 +// 外层中间件链。路由注册按受众切分,使守护每组 +// 的 RBAC 门保持可见:公开、已认证用户、 +// 私有历史、管理读与仅 owner 写。 -// Handler returns the fully assembled http.Handler: the ServeMux routing -// REST + WS + admin, wrapped in recovery, CORS and request logging. +// Handler 返回完整组装的 http.Handler:路由 REST + WS + 管理的 +// ServeMux,外面包上 recover、CORS 与请求日志。 func (s *Server) Handler() http.Handler { mux := http.NewServeMux() - // The RBAC middlewares are built once per role set and shared by every route - // that requires it (owner>admin>viewer, DevDocs/02 §九). viewerAuth is "every - // authenticated role": it names the read-only monitoring surface the viewer - // role exists for (module inventories, runtime metrics/traces, the log tail - // and the notification feed) — see registerAdminRoutes. + // RBAC 中间件按角色集各构建一次,并被每条需要它的路由 + // 共用(owner>admin>viewer,DevDocs/02 §九)。viewerAuth 是「每个 + // 已认证角色」:它指 viewer 角色存在的那个只读监控 + // 表面(模块清单、运行时指标/trace、日志尾部 + // 与通知 feed)—— 见 registerAdminRoutes。 anyAuth := s.auth.Authorize() ownerAuth := s.auth.Authorize(auth.RoleOwner) writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin) @@ -36,11 +36,11 @@ func (s *Server) Handler() http.Handler { return s.withMiddleware(mux) } -// registerPublicRoutes mounts the unauthenticated surface: liveness plus the -// credential endpoints. Refresh/logout are public by design — the opaque refresh -// token is itself the credential, so a client with an expired access token can -// still rotate or revoke it. Both fail closed with 401 invalid_grant on a bad -// token. +// registerPublicRoutes 挂载未认证表面:存活探针加上 +// 凭据端点。refresh/logout 按设计是公开的 —— 非透明 refresh +// token 本身就是凭据,因此访问令牌已过期的客户端仍能 +// 轮换或吊销它。二者在 token 非法时都以 401 invalid_grant +// 失败关闭。 func (s *Server) registerPublicRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/health", s.handleHealth) mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) @@ -48,11 +48,11 @@ func (s *Server) registerPublicRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) } -// registerUserRoutes mounts the authenticated user-facing surface. Chat is a -// write (owner/admin); reads accept any authenticated role so a viewer can still -// list personas/sessions. Audio STT/TTS and image upload are owner/admin-only (the -// owner's private assistant, audited) and are the REST surface native clients use -// to bypass the 4KB WebSocket frame limit. +// registerUserRoutes 挂载已认证的面向用户表面。Chat 是 +// 写(owner/admin);读接受任何已认证角色,因此 viewer 仍能 +// 列出人格/会话。音频 STT/TTS 与图片上传仅 owner/admin(主人的 +// 私密助手,带审计),它们是原生客户端用来 +// 绕过 4KB WebSocket 帧上限的 REST 表面。 func (s *Server) registerUserRoutes( mux *http.ServeMux, anyAuth func(http.Handler) http.Handler, @@ -62,74 +62,74 @@ 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). + // 共享的客户端枚举表(Android 协作 B4,design/07 §6): + // 每个客户端据此把 id 映射为标签的权威,因此来源徽标或 + // 情绪标签不会逐客户端漂移。它是 + // internal/contract/enums 的只读投影,不含任何敏感内容,因此任何已认证 + // 角色都可读它(上面的 /sessions 同一道门)。 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))) mux.Handle("POST /api/v1/audio/voice-clone", ownerAuth(http.HandlerFunc(s.handleVoiceClone))) - // Image upload (B1): a multipart POST that stores a raster image under the - // resolved data dir and returns a same-origin URL usable directly as - // chat.image. Upload is owner/admin + audited; the static route is a - // capability URL (unguessable name) because chat.image / cannot carry a - // bearer token. Both ride the one gateway port (single-binary red line). + // 图片上传(B1):一个 multipart POST,把光栅图片存到 + // 解析后的数据目录下,返回可直接用作 + // chat.image 的同源 URL。上传是 owner/admin + 带审计;静态路由是 + // 能力 URL(名字不可猜)因为 chat.image / 无法携带 + // bearer token。二者都走同一个网关端口(单二进制红线)。 mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload))) mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile) } -// registerHistoryRoutes mounts the persistent chat-history surface (design/16) -// plus the realtime endpoint. Session/model reads and writes are the owner's -// private history, so owner/admin only (chat uses the same roles); the message -// content (and thinking) is owner/admin-only per design/16 §6, while the session -// metadata in GET /sessions above stays readable by any authenticated role. -// Creating and deleting a sub session mutate persistent state. +// registerHistoryRoutes 挂载持久化聊天历史表面(design/16) +// 加上实时端点。会话/模型读与写是主人的 +// 私有历史,因此仅 owner/admin(chat 用同样的角色);消息 +// 内容(与 thinking)按 design/16 §6 仅 owner/admin,而上面 +// GET /sessions 中的会话元数据仍可被任何已认证角色读取。 +// 创建与删除子会话会改变持久化状态。 func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.Handler) http.Handler) { mux.Handle("GET /api/v1/sessions/{id}/messages", writeAuth(http.HandlerFunc(s.handleListMessages))) mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession))) mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession))) mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession))) - // Read cursor (D4 / Android collaboration B3): advancing it clears the session's - // unread badge on every end of the account, so it is an owner/admin history write - // like the other session mutations. It broadcasts a `session_read` frame. + // 读游标(D4 / Android 协作 B3):推进它会为该账号 + // 每一端清除该会话的未读角标,因此它和其他会话变更一样 + // 是 owner/admin 历史写。它会广播一个 `session_read` 帧。 mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead))) - // Single-message edit/delete (Android collaboration batch ③a, D3). A message is - // addressed by its own opaque id; both are owner/admin-gated history writes. + // 单条消息编辑/删除(Android 协作批次 ③a,D3)。消息由 + // 自身非透明 id 定位;二者都是受 owner/admin 门控的历史写。 mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage))) mux.Handle("DELETE /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handleDeleteMessage))) - // Realtime. + // 实时。 mux.HandleFunc("/ws/chat", s.handleWS) } -// registerAdminRoutes mounts the admin API. Three read tiers plus two write -// tiers, mirroring the role hierarchy (RBAC red line): +// registerAdminRoutes 挂载管理 API。三个读层加两个写 +// 层,镜像角色层级(RBAC 红线): // -// - viewerAuth (owner/admin/viewer): the read-only monitoring surface — module -// inventories, runtime metrics/traces, the backend log tail and the -// notification feed. Every payload here is a non-sensitive projection with no -// credential, no other account's data and no message body, which is what -// makes the viewer role a usable dashboard/logs/monitoring account. -// - writeAuth reads (owner/admin): everything a viewer must not see — memories, -// the LLM provider stack, accounts/API keys, the backthink monitor, the audit -// trail, channel message logs/transcripts, full session export. -// - writeAuth writes (owner/admin): the audited knowledge search. -// - ownerAuth (owner): the audited policy write, every credential-acquiring -// channel endpoint (login-qr/login-status) and every other mutation. +// - viewerAuth(owner/admin/viewer):只读监控表面 —— 模块 +// 清单、运行时指标/trace、后端日志尾部与 +// 通知 feed。这里每个载荷都是非敏感投影,不含 +// 凭据、不含其他账号的数据、不含消息体,这正是 +// 让 viewer 角色成为可用的看板/日志/监控账号的原因。 +// - writeAuth 读(owner/admin):viewer 不得看到的一切 —— 记忆、 +// LLM provider 栈、账号/API key、后台思考监控、审计 +// 链、渠道消息日志/转录、完整会话导出。 +// - writeAuth 写(owner/admin):带审计的知识搜索。 +// - ownerAuth(owner):带审计的策略写、每个获取凭据的 +// 渠道端点(login-qr/login-status)以及每一个其他变更。 // -// Each write handler persists to the real backing store or answers honestly with -// a clear "M10 / not-wired" error rather than fabricating a success. +// 每个写 handler 都会持久化到真实后端存储,或用明确的 +// "M10 / not-wired" 错误诚实作答,而不是编造成功。 func (s *Server) registerAdminRoutes( mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler, writeAuth func(http.Handler) http.Handler, viewerAuth func(http.Handler) http.Handler, ) { - // Admin reads open to viewer: the read-only monitoring/inventory surface. + // 对 viewer 开放的管理读:只读监控/清单表面。 mux.Handle("GET /api/v1/admin/personas", viewerAuth(http.HandlerFunc(s.adminListPersonas))) mux.Handle("GET /api/v1/admin/sessions", viewerAuth(http.HandlerFunc(s.adminListSessions))) mux.Handle("GET /api/v1/admin/plugins", viewerAuth(http.HandlerFunc(s.adminListPlugins))) @@ -137,21 +137,21 @@ func (s *Server) registerAdminRoutes( mux.Handle("GET /api/v1/admin/channels", viewerAuth(http.HandlerFunc(s.adminListChannels))) mux.Handle("GET /api/v1/admin/iot", viewerAuth(http.HandlerFunc(s.adminListIoT))) mux.Handle("GET /api/v1/admin/automations", viewerAuth(http.HandlerFunc(s.adminListAutomations))) - // Knowledge-base document list: names/descriptions plus chunk/embedding - // status. The `?query=` search rides along here and stays read-only. + // 知识库文档列表:名字/描述加上切分/嵌入 + // 状态。`?query=` 搜索在旁一并提供,且保持只读。 mux.Handle("GET /api/v1/admin/rag", viewerAuth(http.HandlerFunc(s.adminListRag))) mux.Handle("GET /api/v1/admin/observability/metrics", viewerAuth(http.HandlerFunc(s.adminObservabilityMetrics))) mux.Handle("GET /api/v1/admin/observability/trace", viewerAuth(http.HandlerFunc(s.adminObservabilityTrace))) - // Backend log tail: the logger's own bounded ring buffer, injected by the - // composition root (501 until then). Newest first, `?level=`/`?limit=` filtered. + // 后端日志尾部:logger 自带有界环形缓冲,由组合根注入 + // (在那之前 501)。最新优先,按 `?level=`/`?limit=` 过滤。 mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs))) - // Unified notification feed (design/10 §3 #16): a read-only projection of the - // audit trail + backthink pushes, so the panel's notification page needs no - // new persisted model. + // 统一通知 feed(design/10 §3 #16):审计链 + 后台思考推送的 + // 只读投影,因此面板的通知页不需要 + // 任何新的持久化模型。 mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications))) - // Admin reads reserved for owner/admin: private content, other accounts' - // data and the audit trail. Not open to viewer. + // 保留给 owner/admin 的管理读:私有内容、其他账号的 + // 数据与审计链。不对 viewer 开放。 mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories))) mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages))) mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog))) @@ -163,7 +163,7 @@ func (s *Server) registerAdminRoutes( mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag))) mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy))) - // Admin writes: owner-only, audited. + // 管理写:仅 owner,带审计。 mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona))) mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona))) mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser))) @@ -200,9 +200,9 @@ func (s *Server) registerAdminRoutes( mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore))) } -// mountWebhooks mounts externally-supplied inbound callbacks (e.g. the webhook -// channel) without gateway auth; each handler enforces its own -// signature/authorization. +// mountWebhooks 挂载外部提供的入站回调(例如 webhook +// 渠道)且不经网关认证;每个 handler 强制自己的 +// 签名/授权。 func (s *Server) mountWebhooks(mux *http.ServeMux) { for path, hdl := range s.webhooks { if path == "" { @@ -212,9 +212,9 @@ func (s *Server) mountWebhooks(mux *http.ServeMux) { } } -// withMiddleware wraps the routed handler in the outer layers, outermost first: -// the upload capability-URL guard, the SPA fallback (when a frontend is -// configured), panic recovery, CORS and request logging. +// withMiddleware 把已路由的 handler 包进外层,最外层在前: +// 上传能力 URL 防护、SPA 回退(当配置了前端时)、 +// panic recover、CORS 与请求日志。 func (s *Server) withMiddleware(mux http.Handler) http.Handler { var h http.Handler = mux h = guardUploadPaths(h) diff --git a/internal/services/gateway/session_attribution_test.go b/internal/services/gateway/session_attribution_test.go index 8141e05..ffd6b93 100644 --- a/internal/services/gateway/session_attribution_test.go +++ b/internal/services/gateway/session_attribution_test.go @@ -13,13 +13,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file covers the end-to-end half of design/22 §7-5: a session that switched -// roles must keep the conversation it had, and the model must be able to tell -// which utterances came from the earlier role. The chain asserted here is: -// chat_messages (per-message persona_id) → the orchestrator's seeded turn context -// → the loop's speaker attribution → the model.Request the adapter receives. +// 本文件覆盖 design/22 §7-5 的端到端一半:切换过角色的 +// 会话必须保留它曾有过的对话,且模型必须能分辨 +// 哪些话语来自更早的角色。这里断言的链条是: +// chat_messages(每消息 persona_id)→ 编排器播种的回合上下文 +// → loop 的说话者归属 → 适配器收到的 model.Request。 -// requestTexts returns the text of every message of req, in order. +// requestTexts 按顺序返回 req 每条消息的文本。 func requestTexts(req model.Request) []string { out := make([]string, 0, len(req.Messages)) for _, m := range req.Messages { @@ -28,7 +28,7 @@ func requestTexts(req model.Request) []string { return out } -// messageTextOf concatenates the text parts of m. +// messageTextOf 拼接 m 的文本部分。 func messageTextOf(m model.Message) string { var b strings.Builder for _, c := range m.Content { @@ -39,11 +39,11 @@ func messageTextOf(m model.Message) string { return b.String() } -// TestChatTurnCarriesSpeakerAttributionFromTranscript is design/22 §7-5 accepted -// from the process boundary: the session already holds turns spoken by role X, it -// is now bound to role Y, and one more chat turn arrives. The request must carry -// X's earlier utterance with its speaker attributed to X (not to Y), keep every -// stored body verbatim, and still open with Y's own instructions. +// TestChatTurnCarriesSpeakerAttributionFromTranscript 是从进程边界验收的 +// design/22 §7-5:该会话已持有角色 X 说过的话,它现在 +// 绑定到角色 Y,且又来了一个聊天回合。请求必须携带 +// X 更早的话语且其说话者归属到 X(不是 Y),原样保留每个 +// 已存消息体,并仍以 Y 自己的指令开头。 func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) { t.Parallel() ctx := context.Background() @@ -53,8 +53,8 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) { if err != nil { t.Fatalf("NewSubSessionFor: %v", err) } - // The conversation as it stood while the session spoke as X, with per-message - // persona ids exactly as the orchestrator persists them (design/22 §7-5). + // 该会话以 X 说话时的对话原貌,每消息 + // persona id 与编排器持久化的完全一致(design/22 §7-5)。 for _, m := range []history.Message{ {SessionID: sub, Role: history.RoleUser, Content: "你还记得吗", PersonaID: "xilian", CreatedAt: 1000}, {SessionID: sub, Role: history.RoleAssistant, Content: "我记住了,你喜欢冰美式", PersonaID: "xilian", CreatedAt: 1001}, @@ -86,13 +86,13 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) { } req := ad.reqs[len(ad.reqs)-1] - // The answering role still owns the head of the prompt. + // 应答角色仍然占据提示词的开头。 if got := firstSystemText(req); got != "你是由纪。" { t.Fatalf("first system = %q, want role Y's prompt", got) } - // X's earlier utterance reached the model, verbatim (the turn is not amnesiac - // across the role switch). + // X 更早的话语原样抵达了模型(该回合没有 + // 在角色切换处失忆)。 texts := requestTexts(req) xIdx := -1 for i, txt := range texts { @@ -111,7 +111,7 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) { t.Fatalf("speaker label = %q, want xilian (the role that actually spoke it)", req.Messages[xIdx].SenderName) } - // The speaker boundary names X and marks the stretch as not Y's own words. + // 说话者边界点明 X,并把那一段标记为不是 Y 自己的话。 note := "" for _, m := range req.Messages { if m.Role == model.RoleSystem && strings.Contains(messageTextOf(m), "「昔涟」(xilian)") { @@ -123,8 +123,8 @@ func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) { t.Fatalf("no speaker boundary for X found: %q", texts) } - // No stored body was rewritten, and the newly sent user text carries no system - // metadata (the user lane stays free text, design/11 §10). + // 没有任何已存消息体被改写,且新发送的用户文本不携带 system + // 元数据(用户通道保持自由文本,design/11 §10)。 for _, m := range req.Messages { if m.Role != model.RoleUser { continue diff --git a/internal/services/gateway/session_orchestrator.go b/internal/services/gateway/session_orchestrator.go index 5943287..2231908 100644 --- a/internal/services/gateway/session_orchestrator.go +++ b/internal/services/gateway/session_orchestrator.go @@ -12,39 +12,39 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file implements the session-message orchestrator (DevDocs/design/15). +// 本文件实现会话消息编排器(DevDocs/design/15)。 // -// It is deliberately NOT a task engine. design/04 §2 (task/subagent) decides -// whether a heavy job gets pushed to the background; this orchestrator decides -// how NEW user messages queue and resume while a session is busy. The two layers -// are orthogonal and compose: a session runs a task (04), a new message arrives -// mid-run (15), and the orchestrator holds it in a bounded pending queue and -// resumes it with the previous turn's context once the current turn finishes. +// 它刻意不是任务引擎。design/04 §2(task/subagent)决定 +// 一个重活是否被推到后台;本编排器决定 +// 会话忙碌时新的用户消息如何排队与续跑。两层 +// 正交且可组合:一个会话跑一个任务(04),新消息在 +// 运行中途到达(15),编排器把它握在有界待处理队列中, +// 并在当前回合结束后带着上一个回合的上下文续跑它。 // -// Non-blocking is a hard constraint: Submit/Interrupt never wait on a running -// turn. The queue is bounded per session and isolated across sessions, and no -// identity decision is made here — the orchestrator only orders intent/messages, -// identity stays in the channel actor metadata (project red line). +// 非阻塞是硬约束:Submit/Interrupt 绝不等待运行中的 +// 回合。队列按会话有界,且跨会话隔离,这里不做 +// 任何身份判定 —— 编排器只排序意图/消息, +// 身份始终留在 channel 的 actor 元数据中(项目红线)。 -// Mode is the per-session busy-state lane of the orchestrator. It is exposed so -// a loop/subagent hook can raise it to executing/subagent (an injection point -// the composition root may wire later), but the orchestrator itself tracks an -// abstract "busy" via mode != ModeIdle. +// Mode 是编排器的每会话忙碌状态通道。它被暴露出来, +// 使 loop/subagent 钩子能把它升到 executing/subagent(一个 +// 组合根之后可能接线的注入点),但编排器自身只用 +// mode != ModeIdle 跟踪抽象的「忙碌」。 type Mode string const ( - // ModeIdle means the session has no turn in flight and no resume pending. + // ModeIdle 意味着该会话没有在飞回合、也没有待处理续跑。 ModeIdle Mode = "idle" - // ModeThinking means a turn is in flight (the initial start state). + // ModeThinking 意味着有回合在飞(起始的初始状态)。 ModeThinking Mode = "thinking" - // ModeExecuting means a turn is executing tools/substeps. + // ModeExecuting 意味着回合正在执行工具/子步骤。 ModeExecuting Mode = "executing" - // ModeSubagent means a turn is delegated to a subagent (design/04 §2). + // ModeSubagent 意味着回合被委派给子代理(design/04 §2)。 ModeSubagent Mode = "subagent" ) -// State event types pushed over the WS channel to the frontend. The data map of -// each carries sessionId so a client can correlate per-session state. +// 经 WS 通道推给前端的状态事件类型。每个的 data map 都 +// 携带 sessionId,使客户端能按会话关联状态。 const ( EventSessionBusy = "session_busy" EventPendingAck = "pending_ack" @@ -55,85 +55,85 @@ const ( ) const ( - // defaultMaxPending bounds the per-session pending queue (design/15 §4). + // defaultMaxPending 限定每会话的待处理队列(design/15 §4)。 defaultMaxPending = 5 - // defaultMaxTail bounds the conversation tail kept for continuation, so the - // resumed context is incremental, not a full replay (design/15 §2.2, §4). + // defaultMaxTail 限定为续跑而保留的对话尾部,使 + // 续跑的上下文是增量的,而不是全量重放(design/15 §2.2、§4)。 defaultMaxTail = 20 - // defaultSessionID is used when a caller passes an empty session id. + // 调用方传入空会话 id 时使用 defaultSessionID。 defaultSessionID = "web" ) -// SubmitResult is the outcome of handing one user turn to the orchestrator. It -// is always returned without blocking: Accepted is false only when the turn was -// rejected (pending queue full, or an empty turn), never because a turn is busy. +// SubmitResult 是把一个用户回合交给编排器的结果。它 +// 总是不阻塞地返回:Accepted 为 false 仅在回合被 +// 拒绝时(待处理队列满,或空回合),绝不因为回合忙碌。 type SubmitResult struct { - // Accepted reports whether the turn was taken. A false value with - // PendingFull or Empty carries the reject reason; it never means "try later". + // Accepted 报告回合是否被接收。为 false 且带 + // PendingFull 或 Empty 时携带拒绝原因;它绝不意味着「稍后重试」。 Accepted bool - // Immediate reports that a turn started now because the session was idle. + // Immediate 报告因会话空闲而立即开始了一个回合。 Immediate bool - // Pending reports that the turn was queued behind a busy turn. + // Pending 报告该回合排在忙碌回合之后入队了。 Pending bool - // QueueDepth is the pending depth after this insertion (0 when Immediate). + // QueueDepth 是本次插入后的待处理深度(Immediate 时为 0)。 QueueDepth int - // PendingFull reports the turn was rejected because the queue is at capacity - // (design/15 §4: bounded pending, over-capacity is refused with a hint). + // PendingFull 报告回合被拒绝,因为队列已满 + // (design/15 §4:有界待处理,超容量时拒绝并给出提示)。 PendingFull bool - // Empty reports the turn was rejected because it carried no text or image. + // Empty 报告回合因既无文本也无图片而被拒绝。 Empty bool - // Err is set on the synchronous path when the run errored; the REST caller - // uses it to return a 5xx rather than a fabricated reply. + // Err 在同步路径上于运行出错时设置;REST 调用方 + // 用它返回 5xx,而不是编造回复。 Err error } -// TurnRunner executes one agent turn and returns its result. The gateway wires -// it to the loop plus the streaming broadcast, so the orchestrator stays agnostic -// of how a turn is physically driven (it never re-implements a loop engine). +// TurnRunner 执行一个 agent 回合并返回其结果。网关把它 +// 接到 loop 加上流式广播上,因此编排器对回合 +// 实际如何驱动保持不可知(它绝不重新实现 loop 引擎)。 type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) -// StateSink reports a per-session state transition (busy/pending/idle/interrupt) -// to the transport. The gateway wires it to the WS hub broadcast. +// StateSink 把每会话状态转移(busy/pending/idle/interrupt)报告给 +// 传输层。网关把它接到 WS hub 广播上。 type StateSink func(sessionID, typ string, data map[string]any) -// OrchestratorOptions configures an Orchestrator. Run and Log are required -// (Log may be a discard logger); Emit is optional (state events are dropped when -// nil). MaxPending/MaxTail fall back to the defaults when non-positive. +// OrchestratorOptions 配置 Orchestrator。Run 与 Log 必需 +// (Log 可以是丢弃型 logger);Emit 可选(为 nil 时状态事件被丢弃)。 +// MaxPending/MaxTail 非正时回退到默认值。 type OrchestratorOptions struct { Run TurnRunner Emit StateSink Log *slog.Logger MaxPending int MaxTail int - // History is the optional persistent chat-history store (design/16 §3.3). - // When nil every Append is skipped, so the orchestrator runs without storage - // (tests / a gateway built without a history store). + // History 是可选的持久化聊天历史存储(design/16 §3.3)。 + // 为 nil 时每次 Append 都被跳过,因此编排器无需存储也能运行 + // (测试 / 未接 history 存储构建的网关)。 History HistoryStore - // OnPersisted, when non-nil, is called with the complete stored message right - // after a user or assistant message is written, so the transport can broadcast - // a `message_persisted` event for cross-end realtime sync (design/16 §4.2). - // Dropped when nil (tests / a gateway built without a transport hook). + // OnPersisted 非 nil 时,会在用户或助手消息写入后立即 + // 带着完整已存消息被调用,使传输层能广播 + // 一个 `message_persisted` 事件用于跨端实时同步(design/16 §4.2)。 + // 为 nil 时丢弃(测试 / 未接传输钩子构建的网关)。 OnPersisted func(history.Message) } -// sessionState is the per-session message state (design/15 §2). Every mutation -// happens under the Orchestrator mutex; the actual loop run is done outside the -// lock so a slow model call never blocks Submit. +// sessionState 是每会话的消息状态(design/15 §2)。每次变更 +// 都在 Orchestrator 互斥锁下发生;实际的 loop 运行在锁外 +// 完成,因此慢的模型调用绝不阻塞 Submit。 type sessionState struct { mode Mode - gen uint64 // generation: bumped on interrupt and on a fresh top-level turn + gen uint64 // generation:在打断时以及新的顶层回合时递增 cancel context.CancelFunc pending []pendingMsg - tail []model.Message // bounded conversation tail for context continuation + tail []model.Message // 用于上下文续跑的有界对话尾部 } -// pendingMsg is one queued user turn. It carries its own env so a resume runs -// under the same memory-isolation boundary — and, since design/22, the same role -// (env.PersonaID) — the message arrived with, plus the client deviceID of the -// originating turn (design/16 §4.5) so the resumed assistant reply is tagged with -// the same client that sent the message, and the turn's behavior mode (design/16 -// §5.1) so the resumed reply is persisted under the same mode as the user message -// that started it. +// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑 +// 在该消息到达时所用的同一记忆隔离边界 —— 且自 design/22 起 +// 同一角色(env.PersonaID)—— 之下运行,并携带产出该回合的 +// 客户端 deviceID(design/16 §4.5),使续跑的助手回复被标上 +// 发送该消息的同一个客户端;以及该回合的行为模式(design/16 +// §5.1),使续跑回复与发起它的用户消息以同一模式 +// 被持久化。 type pendingMsg struct { messages []model.Message env loop.SessionEnv @@ -142,10 +142,10 @@ type pendingMsg struct { at time.Time } -// Orchestrator owns the per-session message state machine. It is concurrency-safe -// and non-blocking: Submit/Interrupt lock only to mutate state, then hand the -// actual run off to a goroutine. It is the single owner of "is this session busy" -// for the chat path; the loop/task/subagent engines are reused, not re-created. +// Orchestrator 拥有每会话的消息状态机。它对并发安全 +// 且非阻塞:Submit/Interrupt 只在变更状态时加锁,然后把 +// 实际运行交给一个 goroutine。它是 chat 路径上「该会话是否 +// 忙碌」的唯一归属者;loop/task/subagent 引擎被复用,而不是重建。 type Orchestrator struct { mu sync.Mutex states map[string]*sessionState @@ -158,10 +158,10 @@ type Orchestrator struct { maxTail int } -// NewOrchestrator builds an orchestrator from opts. Run is required: it is the -// turn-driver the orchestrator reuses (the loop, never a re-implemented engine). -// A nil Run makes every Submit reject rather than run, so a misconfiguration -// cannot silently leave a session busy. Tests inject a fake runner here. +// NewOrchestrator 从 opts 构建编排器。Run 必需:它是编排器 +// 复用的回合驱动器(loop,绝不是重新实现的引擎)。 +// Run 为 nil 会让每次 Submit 拒绝而非运行,因此错误配置 +// 不可能静默地让会话处于忙碌。测试在此注入假 runner。 func NewOrchestrator(opts OrchestratorOptions) *Orchestrator { maxPending := opts.MaxPending if maxPending <= 0 { @@ -186,27 +186,27 @@ func NewOrchestrator(opts OrchestratorOptions) *Orchestrator { } } -// Submit hands one user turn to the session. It never blocks: an idle session -// starts a turn on a background goroutine and returns Immediate; a busy session -// enqueues the turn into the bounded pending queue and returns Pending; a full -// queue refuses with PendingFull. Interrupt words are NOT auto-detected here — -// the caller (frontend/intent classifier) calls Interrupt when the user asks to -// stop, per design/15 §3 (interrupt is an intent, not an identity decision). +// Submit 把一个用户回合交给会话。它绝不阻塞:空闲会话在 +// 后台 goroutine 上启动回合并返回 Immediate;忙碌会话把 +// 回合入队到有界待处理队列并返回 Pending;队列 +// 满时以 PendingFull 拒绝。此处不自动检测打断词 —— +// 调用方(前端/意图分类器)在用户要求停止时调用 Interrupt, +// 依 design/15 §3(打断是一种意图,不是身份判定)。 func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult { sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false) return sr } -// SubmitSync is the synchronous variant used by the REST non-streaming path. An -// idle session runs the turn to completion and returns the Result; a busy session -// returns a Pending result without blocking so the caller can reply with a -// non-blocking ack (design/15 §2.1). It never waits on an in-flight turn. +// SubmitSync 是 REST 非流式路径所用的同步变体。空闲会话 +// 把回合跑完并返回 Result;忙碌会话不阻塞地返回 +// Pending 结果,使调用方能以非阻塞 ack 回复 +// (design/15 §2.1)。它绝不等待在飞回合。 func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) { return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true) } -// submitInternal is the shared Submit/SubmitSync body. sync selects the -// synchronous turn path (REST) vs the background goroutine path (WS). +// submitInternal 是 Submit/SubmitSync 共用的主体。sync 选择 +// 同步回合路径(REST)还是后台 goroutine 路径(WS)。 func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) { if o.run == nil { o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID) @@ -243,14 +243,14 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes st.gen++ gen := st.gen st.mode = ModeThinking - // A fresh turn continues the session's conversation: the persisted transcript - // is seeded in front of the just-sent messages (design/15 §2.2/§4, design/22 - // §7-5), and its assistant utterances keep the role that spoke them. The read - // happens here, before persistUserMessages below writes the new message, so the - // message just sent is never duplicated in the context. A store without history - // (or an empty session) leaves the turn on the caller's messages alone. It is a - // bounded indexed read (transcriptContextCount rows) under the state lock, which - // no running turn ever holds, so it cannot block a Submit behind a turn. + // 新回合延续该会话的对话:持久化转录被播种在 + // 刚发送的消息之前(design/15 §2.2/§4,design/22 + // §7-5),且其助手话语保持说话时的角色。该读取 + // 在此发生,在下面 persistUserMessages 写入新消息之前,因此刚 + // 发送的消息绝不在上下文中重复。没有历史(或空会话)的 + // 存储让该回合只使用调用方给出的消息。它是在状态锁下 + // 的有界索引读取(transcriptContextCount 行),而状态锁 + // 从不会被运行中的回合持有,因此它无法把 Submit 堵在回合后面。 turn := o.startTurnMessages(ctx, sessionID, messages) st.tail = o.boundedTail(turn) runCtx, cancel := context.WithCancel(ctx) @@ -258,8 +258,8 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes o.mu.Unlock() o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking}) - // Only the messages that arrived now are persisted: the seeded transcript is - // already durable, and re-appending it would duplicate the whole conversation. + // 只有此刻到达的消息会被持久化:已播种的转录 + // 已经持久,重新追加它会重复整个对话。 o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID) if sync { @@ -275,12 +275,12 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes return SubmitResult{Accepted: true, Immediate: true}, nil } -// goTurn runs a single turn on a background goroutine and, when it finishes, -// hands the outcome to completeTurn so the orchestrator can resume the queue. -// deviceID tags the persistent assistant reply with the client that sent the turn -// (design/16 §4.5), mode with the turn's behavior mode (design/16 §5.1) and env is -// the turn's resolved role/isolation environment (design/22 §4.5), so the reply is -// filed under the role that produced it. +// goTurn 在后台 goroutine 上运行单个回合,并在它结束时 +// 把结果交给 completeTurn,使编排器能续跑队列。 +// deviceID 用发送该回合的客户端标注持久化助手回复 +// (design/16 §4.5),mode 用该回合的行为模式(design/16 §5.1),而 env 是 +// 该回合解析出的角色/隔离环境(design/22 §4.5),因此回复被 +// 归到产出它的角色名下。 func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) { defer cancel() res, err := o.run(ctx, sessionID, messages, env) @@ -290,15 +290,15 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes _ = o.completeTurn(sessionID, gen, res, err, deviceID, mode, env.PersonaID) } -// completeTurn runs after a turn settles (either it finished, errored, or was -// canceled). It folds the assistant reply into the session's continuation tail, -// then pops the next pending turn and resumes it carrying that tail — so the next -// message "remembers" the prior context instead of starting from a blank slate -// (design/15 §2.2: incremental, not full replay). A stale generation (an -// interrupt or a newer top-level turn superseded this one) is a no-op. -// deviceID is the originating turn's client id, mode its behavior mode and -// personaID the role it spoke as, all used to tag the persisted reply -// (design/16 §4.5, §5.1; design/22 §7-5). +// completeTurn 在一个回合结算后运行(它完成、出错或被 +// 取消)。它把助手回复折进会话的续跑尾部, +// 然后弹出下一条待处理回合并带着该尾部续跑它 —— 因此下一条 +// 消息「记得」先前上下文,而不是从空白开始 +// (design/15 §2.2:增量,而不是全量重放)。陈旧的代次(一次 +// 打断或一个更新的顶层回合取代了本次)是空操作。 +// deviceID 是产出回合的客户端 id,mode 是其行为模式, +// personaID 是它说话时的角色,都用于标注持久化的回复 +// (design/16 §4.5、§5.1;design/22 §7-5)。 func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error { o.persistAssistantReply(sessionID, res, deviceID, mode, personaID) o.mu.Lock() @@ -337,18 +337,18 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu return nil } -// Interrupt cancels the in-flight turn, clears the pending queue and returns the -// session to idle (design/15 §3, the "停下/算了/换个话题" path). It is the only -// way a busy session resets without draining the queue; the running goroutine's -// completion becomes a stale generation and does not resume anything. It returns -// the number of pending turns that were dropped. +// Interrupt 取消在飞回合、清空待处理队列并把会话 +// 回到空闲(design/15 §3,「停下/算了/换个话题」路径)。它是忙碌 +// 会话不必排空队列就能复位的唯一方式;运行中 goroutine 的 +// 完成变成陈旧代次,不续跑任何东西。它返回 +// 被丢弃的待处理回合数。 func (o *Orchestrator) Interrupt(sessionID string) int { return o.interrupt(o.normalize(sessionID)) } -// InterruptAll cancels every in-flight session and clears all pending queues. It -// is used on gateway shutdown so no background turn outlives the hub. It returns -// the total number of dropped pending turns. +// InterruptAll 取消每一个在飞会话并清空所有待处理队列。它 +// 在网关关闭时使用,使没有后台回合比 hub 活得更久。它返回 +// 被丢弃的待处理回合总数。 func (o *Orchestrator) InterruptAll() int { o.mu.Lock() total := 0 @@ -381,10 +381,10 @@ func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState) int { return dropped } -// SetMode raises the reported busy-state lane of a session (thinking → -// executing → subagent). It is an injection point a loop/subagent hook can call; -// any non-idle mode keeps the session busy, and idle un-busies it. A nil session -// is created lazily so a hook may set a mode before the first message. +// SetMode 提升某会话所报告的忙碌状态通道(thinking → +// executing → subagent)。它是 loop/subagent 钩子可调用的注入点; +// 任何非空闲模式都让会话保持忙碌,而 idle 解除忙碌。为 nil 的会话 +// 被惰性创建,因此钩子可以在第一条消息之前设模式。 func (o *Orchestrator) SetMode(sessionID string, mode Mode) { sessionID = o.normalize(sessionID) o.mu.Lock() @@ -403,7 +403,7 @@ func (o *Orchestrator) SetMode(sessionID string, mode Mode) { o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": mode}) } -// Busy reports whether the session currently has a turn in flight. +// Busy 报告该会话当前是否有在飞回合。 func (o *Orchestrator) Busy(sessionID string) bool { o.mu.Lock() defer o.mu.Unlock() @@ -411,7 +411,7 @@ func (o *Orchestrator) Busy(sessionID string) bool { return st != nil && st.mode != ModeIdle } -// QueueDepth reports the number of pending turns for a session. +// QueueDepth 报告某会话的待处理回合数。 func (o *Orchestrator) QueueDepth(sessionID string) int { o.mu.Lock() defer o.mu.Unlock() @@ -422,7 +422,7 @@ func (o *Orchestrator) QueueDepth(sessionID string) int { return len(st.pending) } -// ModeOf reports the current busy-state lane of a session (idle when unknown). +// ModeOf 报告某会话当前的忙碌状态通道(未知时为 idle)。 func (o *Orchestrator) ModeOf(sessionID string) Mode { o.mu.Lock() defer o.mu.Unlock() @@ -433,8 +433,8 @@ func (o *Orchestrator) ModeOf(sessionID string) Mode { return st.mode } -// normalize maps an empty session id to the default so the state map is stable -// for callers that omit it (the Web main session). +// normalize 把空会话 id 映射为默认值,使状态 map 对省略它的 +// 调用方保持稳定(Web 主会话)。 func (o *Orchestrator) normalize(sessionID string) string { if sessionID == "" { return defaultSessionID @@ -442,33 +442,33 @@ func (o *Orchestrator) normalize(sessionID string) string { return sessionID } -// emitState forwards a state transition to the transport when one is wired. +// emitState 在接线了传输层时把状态转移转发给它。 func (o *Orchestrator) emitState(sessionID, typ string, data map[string]any) { if o.emit != nil { o.emit(sessionID, typ, data) } } -// notifyPersisted hands a just-persisted message to the transport hook when one is -// wired, so the gateway can broadcast `message_persisted` for cross-end sync. +// notifyPersisted 在接线了传输钩子时把刚持久化的消息交给它, +// 使网关能广播 `message_persisted` 以进行跨端同步。 func (o *Orchestrator) notifyPersisted(msg history.Message) { if o.onPersisted != nil { o.onPersisted(msg) } } -// deviceIDContextKey is the context key carrying a message's client deviceID -// (design/16 §4.5) from the HTTP/WS boundary into the orchestrator's persistence. +// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键 +// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。 type deviceIDContextKey struct{} -// withDeviceID returns a context carrying deviceID so the orchestrator can tag the -// persisted user message. An empty deviceID is a no-op (no tag is written). +// withDeviceID 返回携带 deviceID 的 context,使编排器能标注 +// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。 func withDeviceID(ctx context.Context, deviceID string) context.Context { return context.WithValue(ctx, deviceIDContextKey{}, deviceID) } -// deviceIDFrom returns the deviceID carried by ctx, or "" when none was set. It -// returns only well-formed non-empty values; anything else is treated as absent. +// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它 +// 只返回格式良好的非空值;其他一律视为缺失。 func deviceIDFrom(ctx context.Context) string { v, _ := ctx.Value(deviceIDContextKey{}).(string) return v diff --git a/internal/services/gateway/session_orchestrator_test.go b/internal/services/gateway/session_orchestrator_test.go index 3692645..10a0a3a 100644 --- a/internal/services/gateway/session_orchestrator_test.go +++ b/internal/services/gateway/session_orchestrator_test.go @@ -13,7 +13,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// callRecord is one invocation captured by the fake runner. +// callRecord 是假 runner 捕获的一次调用。 type callRecord struct { sessionID string messages []model.Message @@ -21,19 +21,19 @@ type callRecord struct { ctx context.Context } -// gatedRunner is a controllable TurnRunner. Each Run blocks until it consumes one -// ticket from tickets, or until ctx is canceled (the interrupt path). When it -// proceeds it records the call and returns a deterministic reply. A nil err -// produces a reply holding the *incoming* messages as text, so a test can assert -// exactly what context the resumed turn carried. +// gatedRunner 是一个可控 TurnRunner。每次 Run 都会阻塞,直到它 +// 从 tickets 消费一张票,或直到 ctx 被取消(打断路径)。当它 +// 继续时,它记录这次调用并返回确定性回复。err 为 nil +// 会产出一个把*传入*消息作为文本持有的回复,使测试能断言 +// 续跑回合究竟携带了什么上下文。 // -// Like the real loop runner, it never executes a turn whose context is already -// canceled, and it counts those canceled returns so a test can wait for an -// interrupted turn to actually stop (waitCanceled) before releasing the next -// ticket. Without that, a turn that was canceled *while parked in the select -// below* can still win the ticket case in the same scheduler step (both cases are -// ready) and be recorded as if it had run — a test-harness artifact, not an -// orchestrator ordering guarantee. +// 与真实的 loop runner 一样,它绝不执行 context 已被取消的 +// 回合,并统计那些被取消的返回,使测试能等待一个 +// 被中断的回合真正停下(waitCanceled),然后再释放下一张 +// 票。没有这一点,一个在下面 select 中驻留时被取消的回合 +// 仍可能在同一调度步骤中赢下 ticket 分支(两个分支都 +// 就绪)并被记录成仿佛已经运行 —— 这是测试夹具的产物,不是 +// 编排器的顺序保证。 type gatedRunner struct { mu sync.Mutex calls []callRecord @@ -68,8 +68,8 @@ func (g *gatedRunner) Run(ctx context.Context, sessionID string, messages []mode return &loop.Result{Text: fmt.Sprintf("reply-%d", reply), Steps: 1, StopReason: "stop"}, nil } -// waitCalls blocks until at least n calls are recorded and returns a copy of the -// first n, so a test can advance the gated chain deterministically. +// waitCalls 阻塞直到至少记录 n 次调用,并返回前 n 次的 +// 副本,使测试能确定性地推进受门控的链条。 func (g *gatedRunner) waitCalls(n int) []callRecord { deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { @@ -85,25 +85,25 @@ func (g *gatedRunner) waitCalls(n int) []callRecord { panic("timed out waiting for runner calls") } -// count returns the total recorded calls. +// count 返回已记录调用的总数。 func (g *gatedRunner) count() int { g.mu.Lock() defer g.mu.Unlock() return len(g.calls) } -// recordCanceled notes one invocation that returned because its context was -// canceled, i.e. a turn that never executed. +// recordCanceled 记下一次因其 context 被取消而返回的调用, +// 即一个从未执行的回合。 func (g *gatedRunner) recordCanceled() { g.mu.Lock() g.canceled++ g.mu.Unlock() } -// waitCanceled blocks until at least n invocations have stopped because their -// context was canceled, reporting false when that never happens within the -// deadline. It is the barrier a test uses between an Interrupt and the release of -// the next ticket: once it holds, no canceled turn can still consume a ticket. +// waitCanceled 阻塞直到至少 n 次调用因 context 被取消而 +// 停止,未能在期限内发生时报 false。它是测试在 Interrupt +// 与释放下一张票之间使用的屏障:它一旦成立,就没有 +// 被取消的回合还能消费一张票。 func (g *gatedRunner) waitCanceled(n int) bool { deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { @@ -118,10 +118,10 @@ func (g *gatedRunner) waitCanceled(n int) bool { return false } -// waitIdle blocks until the session reports idle with an empty pending queue, -// reporting false when it never settles within the deadline. It lets a test assert -// the settled state deterministically instead of sleeping, and turns "a dropped -// turn was resumed anyway" into a test failure rather than a silent pass. +// waitIdle 阻塞直到该会话报告空闲且待处理队列为空, +// 未能在期限内安定时报 false。它让测试能确定性地断言 +// 安定的状态而不是睡眠等待,并把「被丢弃的回合竟仍被 +// 续跑」变成测试失败,而不是静默通过。 func waitIdle(o *Orchestrator, sessionID string) bool { deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { @@ -133,13 +133,13 @@ func waitIdle(o *Orchestrator, sessionID string) bool { return false } -// eventRec is one state transition captured by the recording sink. +// eventRec 是记录型汇聚点捕获的一次状态转移。 type eventRec struct { sessionID, typ string data map[string]any } -// recordingSink captures every emitted state transition. +// recordingSink 捕获每一次发出的状态转移。 type recordingSink struct { mu sync.Mutex events []eventRec @@ -170,12 +170,12 @@ func (s *recordingSink) last() (string, bool) { return s.events[len(s.events)-1].typ, true } -// userMsg builds a text-only user message. +// userMsg 构建一条纯文本用户消息。 func userMsg(text string) model.Message { return model.TextMessage(model.RoleUser, text) } -// messageText returns the concatenated text of a message's text parts. +// messageText 返回消息文本部分拼接后的文本。 func messageText(m model.Message) string { var out string for _, c := range m.Content { @@ -186,8 +186,8 @@ func messageText(m model.Message) string { return out } -// seedOrchestrator builds an orchestrator with a gated runner and a recording -// sink, both returned so the test can drive the chain and assert state events. +// seedOrchestrator 构建一个带受门控 runner 与记录型汇聚点的 +// 编排器,二者都返回,使测试能驱动链条并断言状态事件。 func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordingSink) { sink := &recordingSink{} o := NewOrchestrator(OrchestratorOptions{ @@ -200,13 +200,13 @@ func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordi return o, sink } -// readOnlyEnv returns the group-chat / stranger read-only memory lane. +// readOnlyEnv 返回群聊 / 陌生人的只读记忆通道。 func readOnlyEnv() loop.SessionEnv { return loop.SessionEnv{MemWritable: false} } -// fakeHistory records every Append so tests assert the orchestrator persists the -// user stream and the assistant reply into the chat-history store. +// fakeHistory 记录每一次 Append,使测试能断言编排器把用户 +// 消息流与助手回复持久化进聊天历史存储。 type fakeHistory struct { mu sync.Mutex appends []history.Message @@ -286,9 +286,9 @@ func (f *fakeHistory) ListSessions(context.Context) ([]history.SessionMeta, erro return nil, nil } -// MarkSessionRead satisfies the HistoryStore contract for the read-cursor path (D4). -// The orchestrator never advances a cursor, so the fake only reports the requested -// cursor back without storing it. +// MarkSessionRead 为读游标路径(D4)满足 HistoryStore 契约。 +// 编排器绝不推进游标,因此该假实现只把请求的游标 +// 原样报告回去,不予存储。 func (f *fakeHistory) MarkSessionRead(_ context.Context, _ string, before time.Time) (int64, error) { return before.UnixMilli(), nil } @@ -326,7 +326,7 @@ func TestSubmitPersistsUserAndAssistant(t *testing.T) { if recs[1].SessionID != "web" || recs[1].Role != history.RoleAssistant || recs[1].Content == "" { t.Fatalf("assistant record = %+v", recs[1]) } - // No mode on the ctx → both fall back to "chat" (design/16 §5.1). + // ctx 上没有 mode → 二者都回退到 "chat"(design/16 §5.1)。 for i, rec := range recs { if rec.Mode != "chat" { t.Fatalf("record[%d] Mode = %q, want chat fallback", i, rec.Mode) @@ -344,9 +344,9 @@ func TestSubmitPersistsMessageMode(t *testing.T) { History: hist, }) run.tickets <- struct{}{} - // The HTTP/WS boundary threads the client's behavior mode through ctx - // (design/01 §3, design/16 §5.1); both the user turn and the assistant reply - // must be persisted under that mode. + // HTTP/WS 边界把客户端的行为模式经 ctx 串下来 + // (design/01 §3、design/16 §5.1);用户回合与助手回复 + // 都必须以该模式被持久化。 ctx := withMode(context.Background(), "code") sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("改代码")}, loop.OwnerEnv()) if !sr.Accepted || !sr.Immediate { @@ -377,8 +377,8 @@ func TestSubmitPersistsDeviceID(t *testing.T) { History: hist, }) run.tickets <- struct{}{} - // The HTTP/WS boundary threads the browser deviceID through ctx (design/16 §4.5); - // both the user turn and the assistant reply must be tagged with it. + // HTTP/WS 边界把浏览器 deviceID 经 ctx 串下来(design/16 §4.5); + // 用户回合与助手回复都必须被它标注。 ctx := withDeviceID(context.Background(), "web:chrome:a1b2c3") sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()) if !sr.Accepted || !sr.Immediate { @@ -441,7 +441,7 @@ func TestSubmitDoesNotPersistRejectedTurn(t *testing.T) { t.Parallel() hist := &fakeHistory{} o := NewOrchestrator(OrchestratorOptions{Run: newGatedRunner(0).Run, Log: slog.Default(), History: hist, MaxPending: 1}) - // Empty turn is rejected before any persistence. + // 空回合在任何持久化之前就被拒绝。 sr := o.Submit(context.Background(), "web", nil, loop.OwnerEnv()) if sr.Accepted || !sr.Empty { t.Fatalf("empty submit = %+v, want Empty reject", sr) @@ -475,12 +475,12 @@ func TestSubmitBusyPendingNotDropped(t *testing.T) { run := newGatedRunner(4) o, sink := seedOrchestrator(run, 5) - // Turn A starts and blocks (no ticket yet), so the session is busy. + // 回合 A 启动并阻塞(还没有票),因此会话处于忙碌。 a := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()) if !a.Immediate { t.Fatalf("A = %+v, want Immediate", a) } - // B and C arrive while A is in flight: they must be queued, not dropped. + // B 与 C 在 A 在飞时到达:它们必须被排队,而不是被丢弃。 b := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv()) c := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()) if !b.Accepted || !b.Pending || b.QueueDepth != 1 { @@ -493,8 +493,8 @@ func TestSubmitBusyPendingNotDropped(t *testing.T) { t.Fatalf("queue depth = %d, want 2", o.QueueDepth("s1")) } - // Release the chain: A, then B, then C. The emitted ack should include the - // pending depth, and the sink should have seen pending_ack events. + // 释放链条:A,然后 B,然后 C。发出的 ack 应包含 + // 待处理深度,且汇聚点应已看到 pending_ack 事件。 types := sink.types() foundAck := false for _, ty := range types { @@ -537,27 +537,27 @@ func TestSubmitBusyResumeCarriesPreviousContext(t *testing.T) { run.tickets <- struct{}{} // C got := run.waitCalls(3) - // A starts from a blank turn. + // A 从空白回合开始。 if len(got[0].messages) != 1 || messageText(got[0].messages[0]) != "A" { t.Fatalf("A messages = %v, want [A]", messageTextList(got[0].messages)) } - // B must resume carrying A's assistant reply folded into the tail. + // B 必须带着折进尾部的 A 助手回复续跑。 if !matchesText(got[1].messages, "A", "B") { t.Fatalf("B messages = %v, want B to resume carrying the prior tail", messageTextList(got[1].messages)) } - // C must resume carrying A's and B's assistant replies. + // C 必须带着 A 与 B 的助手回复续跑。 if !matchesText(got[2].messages, "A", "C") { t.Fatalf("C messages = %v, want C to resume carrying the prior tail", messageTextList(got[2].messages)) } - // B's env must be the one the message arrived with (read-only lane). + // B 的 env 必须是该消息到达时所用的那个(只读通道)。 if got[1].env.MemWritable { t.Fatal("B resumed with MemWritable=true, want the caller's read-only env") } } -// matchesText reports whether a resumed turn begins with the captured first user -// turn (so the prior context was carried over), ends with the expected new user -// turn, and contains at least one assistant reply in between. +// matchesText 报告一个续跑回合是否以捕获的首个用户 +// 回合开头(即先前上下文被带了过来)、以预期的新闻用户 +// 回合结尾,且中间至少含有一条助手回复。 func matchesText(msgs []model.Message, first, last string) bool { if len(msgs) < 3 { return false @@ -587,7 +587,7 @@ func messageTextList(msgs []model.Message) []string { func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) { t.Parallel() run := newGatedRunner(8) - o, _ := seedOrchestrator(run, 3) // tight queue + o, _ := seedOrchestrator(run, 3) // 紧凑队列 if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate { t.Fatalf("A = %+v, want Immediate", sr) @@ -598,7 +598,7 @@ func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) { t.Fatalf("pending %d = %+v, want Pending depth=%d", i, sr, i+1) } } - // Queue is at capacity (3), so the next message is refused, not dropped. + // 队列已达容量(3),因此下一条消息被拒绝,而不是被丢弃。 over := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv()) if over.Accepted || !over.PendingFull { t.Fatalf("overflow = %+v, want PendingFull reject", over) @@ -620,7 +620,7 @@ func TestInterruptCancelsTurnAndClearsPending(t *testing.T) { t.Fatalf("B = %+v, want Pending", sr) } - // Release no ticket: A is blocked in flight. Interrupt cancels it and clears B. + // 不释放任何票:A 阻塞在飞。Interrupt 取消它并清除 B。 dropped := o.Interrupt("s1") if dropped != 1 { t.Fatalf("dropped = %d, want 1", dropped) @@ -629,16 +629,16 @@ func TestInterruptCancelsTurnAndClearsPending(t *testing.T) { t.Fatalf("session not idle after interrupt: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1")) } - // The cancel must reach the in-flight turn (design/15 §3: interrupt is a - // graceful context cancel) and it must stop before any further turn is - // released: a canceled turn never executes, so it can never consume the ticket - // meant for the next one. + // 取消必须抵达在飞回合(design/15 §3:打断是优雅的 + // context 取消),且它必须在释放任何后续回合之前停下: + // 被取消的回合绝不执行,因此它绝不可能消费本该属于 + // 下一个回合的票。 if !run.waitCanceled(1) { t.Fatal("interrupted turn never observed its context cancellation") } - // A new message after the interrupt must start fresh (Immediate), and the - // canceled chain must not have resumed B. + // 打断之后的新消息必须全新开始(Immediate),且 + // 被取消的链条绝不续跑过 B。 if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Immediate { t.Fatalf("C = %+v, want Immediate after interrupt", sr) } @@ -651,18 +651,18 @@ func TestInterruptCancelsTurnAndClearsPending(t *testing.T) { t.Fatalf("only surviving call = %v, want the C turn", messageTextList(got[0].messages)) } - // C's chain settles with nothing left to run: had the interrupt failed to - // cancel A or to clear B, the surviving chain would have resumed a turn and the - // session would never go idle again. + // C 的链条安定下来且无待运行项:若打断未能取消 A + // 或未能清除 B,存活的链条本会续跑一个回合, + // 而该会话就再也不会回到空闲。 if !waitIdle(o, "s1") { t.Fatalf("session never settled idle: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1")) } if n := run.count(); n != 1 { t.Fatalf("runner calls = %d, want 1 (neither the canceled A nor the dropped B may execute)", n) } - // The canceled turn's completion is a no-op for session state (its generation is - // stale), so it must not emit the idle event of the turn now running: exactly - // one session_idle belongs to this scenario (C settling). + // 被取消回合的完成对会话状态是空操作(其代次已 + // 陈旧),因此它不得发出正在运行那个回合的 idle 事件: + // 本场景恰好有一个 session_idle(C 安定)。 idles := 0 for _, ty := range sink.types() { if ty == EventSessionIdle { @@ -705,8 +705,8 @@ func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) { t.Fatalf("unexpected submit result: %+v", sr) } } - // With maxPending=3: exactly 1 turn runs immediately and up to 3 queue behind - // it; the remaining senders are refused at capacity rather than dropped. + // maxPending=3 时:恰好 1 个回合立即运行,最多 3 个排在其后; + // 其余发送者在容量处被拒绝,而不是被丢弃。 if immediate != 1 { t.Fatalf("immediate = %d, want 1", immediate) } @@ -720,7 +720,7 @@ func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) { t.Fatalf("queue depth = %d, want 3", o.QueueDepth("s1")) } - // Drain the chain: 1 running + 3 queued = 4 turns. No panic / no lost message. + // 排空链条:1 个在跑 + 3 个排队 = 4 个回合。无 panic / 无消息丢失。 for i := 0; i < 4; i++ { run.tickets <- struct{}{} } @@ -750,8 +750,8 @@ func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) { run := newGatedRunner(2) o, _ := seedOrchestrator(run, 5) - // Idle → synchronous run returns the reply immediately. Push a ticket so the - // gated runner's blocking select resolves during the synchronous wait. + // 空闲 → 同步运行立即返回回复。推入一张票,使 + // 受门控 runner 的阻塞 select 在同步等待期间得以解决。 run.tickets <- struct{}{} sr, res := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv()) if !sr.Accepted || !sr.Immediate { @@ -761,8 +761,8 @@ func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) { t.Fatal("sync idle should return a result") } - // Hold a busy turn in flight via an async Submit (blocked, no ticket), then a - // sync submit must return a Pending ack without blocking and without a result. + // 经异步 Submit 握一个在飞的忙碌回合(阻塞,无票),随后 + // 同步 submit 必须不阻塞、无结果地返回 Pending ack。 if sr2 := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr2.Immediate { t.Fatalf("async A = %+v, want Immediate", sr2) } @@ -773,7 +773,7 @@ func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) { if res3 != nil { t.Fatal("sync busy should not return a result (must not block)") } - run.tickets <- struct{}{} // let A finish so the orchestrator is idle again + run.tickets <- struct{}{} // 让 A 结束,使编排器回到空闲 } func TestInterruptAllClearsEverySession(t *testing.T) { diff --git a/internal/services/gateway/session_persist.go b/internal/services/gateway/session_persist.go index 578c168..c0338f8 100644 --- a/internal/services/gateway/session_persist.go +++ b/internal/services/gateway/session_persist.go @@ -8,18 +8,18 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file owns the orchestrator's chat-history persistence hooks (design/16 -// §3.3): the user stream is written at submit time and the assistant reply when a -// turn settles. Both are silent no-ops when no history store is wired, so the -// orchestrator runs unchanged without a store (tests / an unwired gateway). +// 本文件拥有编排器的聊天历史持久化钩子(design/16 +// §3.3):用户消息流在提交时写入,助手回复在 +// 回合结算时写入。未接线 history 存储时二者都是静默空操作, +// 因此编排器无存储也能原样运行(测试 / 未接线的网关)。 -// modeChat is the behavior mode persisted when a turn carries none (design/01 §3, -// design/16 §5.1). It is also the only mode the IM paths ever write, because IM -// carries chat text alone. +// modeChat 是回合未携带行为模式时所持久化的模式(design/01 §3, +// design/16 §5.1)。它也是 IM 路径唯一会写入的模式,因为 IM +// 只携带聊天文本。 const modeChat = "chat" -// modeOrDefault returns mode, or modeChat when the caller supplied none, so a -// persisted message never carries an empty behavior mode (design/16 §5.1). +// modeOrDefault 返回 mode,调用方未提供时返回 modeChat,使 +// 持久化消息绝不携带空的行为模式(design/16 §5.1)。 func modeOrDefault(mode string) string { if mode == "" { return modeChat @@ -27,34 +27,34 @@ func modeOrDefault(mode string) string { return mode } -// messageModeContextKey is the context key carrying a turn's behavior mode -// (design/01 §3, design/16 §5.1) from the HTTP/WS boundary into the orchestrator's -// persistence, so each persisted message is tagged with the mode it was sent in. +// messageModeContextKey 是携带回合行为模式的 context 键 +// (design/01 §3、design/16 §5.1),从 HTTP/WS 边界引入编排器的 +// 持久化,使每条持久化消息被它发送时的模式标注。 type messageModeContextKey struct{} -// withMode returns a context carrying mode so the orchestrator can tag the -// persisted user message and assistant reply. An empty mode is a no-op (the -// persistence layer falls back to modeChat). +// withMode 返回携带 mode 的 context,使编排器能标注 +// 持久化的用户消息与助手回复。空 mode 是空操作 +// (持久化层回退到 modeChat)。 func withMode(ctx context.Context, mode string) context.Context { return context.WithValue(ctx, messageModeContextKey{}, mode) } -// modeFrom returns the behavior mode carried by ctx, or "" when none was set. +// modeFrom 返回 ctx 携带的行为模式,未设置时返回 ""。 func modeFrom(ctx context.Context) string { v, _ := ctx.Value(messageModeContextKey{}).(string) return v } -// persistUserMessages writes each RoleUser message of a submitted turn to the -// history store so the raw user stream is durable before the turn resolves, then -// notifies the transport so every browser of the same account can append it -// without a manual refresh (design/16 §3.3, §4.2). deviceID tags the message with -// the sending client (design/16 §4.5), mode is the behavior mode the turn was -// sent in (design/16 §5.1), falling back to modeChat when empty, and personaID is -// the role the turn speaks as (design/22 §7-5) — the role of the moment, so a -// session that later switches roles keeps an accurate "who said this" stream. It -// is a no-op when no history store is wired. Rejected turns (empty, pending-full) -// never reach it, so nothing vacuous is persisted. +// persistUserMessages 把一次已提交回合中每条 RoleUser 消息写入 +// 历史存储,使原始用户消息流在回合结算前就持久,随后 +// 通知传输层,使同一账号的每个浏览器都能追加它 +// 而无需手动刷新(design/16 §3.3、§4.2)。deviceID 用发送客户端标注 +// 消息(design/16 §4.5),mode 是该回合发送时的行为模式 +// (design/16 §5.1),为空时回退到 modeChat,而 personaID 是 +// 该回合说话时的角色(design/22 §7-5)—— 即当时的角色,因此 +// 之后切换角色的会话仍保有准确的「谁说了这个」流。未接线 +// history 存储时它是空操作。被拒绝的回合(空、待处理已满) +// 绝不触达它,因此不会持久化任何空内容。 func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) { if o.history == nil { return @@ -76,17 +76,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string } } -// persistAssistantReply writes the final assistant text of a settled turn to the -// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags -// the reply with the client that sent the turn (design/16 §4.5), mode is the -// behavior mode of that turn (design/16 §5.1), falling back to modeChat when -// empty, and personaID is the role that produced the reply (design/22 §7-5). Mood -// is the persona's mood for that turn (design/01 §2 情绪状态机), taken off the loop -// result, so the stored/DTO/`message_persisted` mood is real data rather than a -// permanently empty column; a turn with no mood tracking stores an empty mood. It -// is a no-op when no history store is wired or the turn produced no text -// (interrupted/errored turns leave res nil). The write runs under a fresh -// background context so a canceled turn context never aborts it. +// persistAssistantReply 把已结算回合的最终助手文本写入 +// 历史存储,然后通知传输层(design/16 §3.3、§4.2)。deviceID 用 +// 发送该回合的客户端标注回复(design/16 §4.5),mode 是 +// 该回合的行为模式(design/16 §5.1),为空时回退到 modeChat, +// 而 personaID 是产出该回复的角色(design/22 §7-5)。Mood +// 是该回合中人格的 mood(design/01 §2 情绪状态机),取自 loop +// 结果,因此已存/DTO/`message_persisted` 的 mood 是真实数据,而不是 +// 永远为空的列;不跟踪 mood 的回合存一个空 mood。它 +// 在未接线 history 存储,或该回合没有产出文本 +// 时是空操作(被中断/出错的回合保持 res 为 nil)。该写入在一个全新的 +// 后台 context 下运行,因此被取消的回合 context 绝不可能中断它。 func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode, personaID string) { if o.history == nil || res == nil || res.Text == "" { return @@ -99,8 +99,8 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, o.notifyPersisted(full) } -// messageTextImage returns the concatenated text and the first image URL of a -// message's content parts, for persistence into the chat_messages columns. +// messageTextImage 返回消息内容部分拼接后的文本与首个图片 +// URL,供持久化进 chat_messages 各列。 func messageTextImage(m model.Message) (text, image string) { for _, c := range m.Content { if c.Type == model.ContentTypeText { diff --git a/internal/services/gateway/session_read_test.go b/internal/services/gateway/session_read_test.go index 2600d98..3a36dea 100644 --- a/internal/services/gateway/session_read_test.go +++ b/internal/services/gateway/session_read_test.go @@ -11,13 +11,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// These tests cover the D4 read-cursor contract (Android collaboration B3): the -// unreadCount carried by GET /api/v1/sessions, the advance endpoint -// POST /api/v1/sessions/{id}/read, and the `session_read` frame that clears the badge -// on the account's other browsers. +// 这些测试覆盖 D4 读游标契约(Android 协作 B3): +// GET /api/v1/sessions 携带的 unreadCount、推进端点 +// POST /api/v1/sessions/{id}/read,以及为该账号其他浏览器清除角标的 +// `session_read` 帧。 -// unreadBySession decodes GET /api/v1/sessions into a map of session id → unreadCount via -// the wire key, so the test asserts the JSON contract rather than the in-package struct. +// unreadBySession 经线上键把 GET /api/v1/sessions 解码为 session id → unreadCount 的 +// map,因此测试断言的是 JSON 契约,而不是包内结构体。 func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]int { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) @@ -36,10 +36,10 @@ func unreadBySession(t *testing.T, ts *httptest.Server, tok string) map[string]i return out } -// TestSessionsUnreadCountAndMarkRead covers the happy path end to end: a session with -// unread messages reports the count, POST /read moves the cursor and answers -// {sessionId, readAt}, the list drops to the messages sent after the cursor, and a -// body-less POST (mark everything seen) clears the badge. +// TestSessionsUnreadCountAndMarkRead 端到端覆盖顺路径:有未读消息的 +// 会话报告计数,POST /read 推进游标并返回 +// {sessionId, readAt},列表降到游标之后发送的消息,而无请求体的 +// POST(全部标记为已见)清除角标。 func TestSessionsUnreadCountAndMarkRead(t *testing.T) { t.Parallel() _, ts, jwt, st := newHistoryEnv(t) @@ -61,12 +61,12 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) { tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/" + history.MainSessionID + "/read" - // Nothing read yet: the main session is fully unread, the sub session independently so. + // 尚未读过任何内容:主会话全未读,子会话各自独立地同样如此。 if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 3 || got[sub] != 1 { t.Fatalf("initial unread = %+v, want main 3 / sub 1", got) } - // Advance the main cursor to 2000: messages at or before it are read. + // 把主游标推进到 2000:等于或早于它的消息即已读。 resp, body := doReq(t, http.MethodPost, base, map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("mark read status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -86,12 +86,12 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) { t.Fatalf("read data = %+v, want sessionId %s readAt 2000", data, history.MainSessionID) } - // Only the later message stays unread, and the sub session is untouched. + // 只有较晚那条消息保持未读,且子会话未被触碰。 if got := unreadBySession(t, ts, tok); got[history.MainSessionID] != 1 || got[sub] != 1 { t.Fatalf("unread after cursor 2000 = %+v, want main 1 / sub 1", got) } - // No body = "mark everything I have seen read" (resolved to now server-side). + // 无请求体 = 「把我已见的一切标记为已读」(服务端解析为 now)。 resp, body = doReq(t, http.MethodPost, base, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("body-less mark status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -107,7 +107,7 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) { } markedNow := data.ReadAt - // The cursor only moves forward: re-posting a stale cursor must not re-open the badge. + // 游标只向前移动:重发陈旧游标不得重新打开角标。 resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("stale mark status = %d, want 200 (body=%s)", resp.StatusCode, body) @@ -123,9 +123,9 @@ func TestSessionsUnreadCountAndMarkRead(t *testing.T) { } } -// TestMarkSessionReadValidation covers the failure contract: a present but unusable -// `before` answers 400, an unknown session 404, a viewer 403 and a missing HistoryStore -// 501 — none of which may be mistaken for a successful read. +// TestMarkSessionReadValidation 覆盖失败契约:存在但不可用的 +// `before` 返回 400,未知会话 404,viewer 403,HistoryStore +// 缺失 501 —— 其中任何一个都不得被误认为读成功。 func TestMarkSessionReadValidation(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) @@ -150,34 +150,34 @@ func TestMarkSessionReadValidation(t *testing.T) { }) } - // An empty object means "now", not "no-op". + // 空对象意味着「现在」,而不是「空操作」。 resp, body := doReq(t, http.MethodPost, base, map[string]any{}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("empty object status = %d, want 200 (body=%s)", resp.StatusCode, body) } - // Unknown session → 404; the session id is not auto-created. + // 未知会话 → 404;会话 id 不会被自动创建。 resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 1000}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown session status = %d, want 404 (body=%s)", resp.StatusCode, body) } - // A viewer cannot write the cursor (owner/admin history write, like PATCH /sessions). + // viewer 不能写游标(owner/admin 历史写,与 PATCH /sessions 同)。 viewer := signAny(t, jwt, "acct", auth.RoleViewer) resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, viewer) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body) } - // And no token at all is 401, not a silent success. + // 而完全不带 token 是 401,不是静默成功。 resp, body = doReq(t, http.MethodPost, base, map[string]any{"before": 1000}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body) } } -// TestMarkSessionReadNotWired pins the honest degradation: with no HistoryStore injected -// the endpoint answers 501 not_implemented rather than pretending the cursor moved. +// TestMarkSessionReadNotWired 钉住诚实的退化:未注入 HistoryStore 时 +// 端点返回 501 not_implemented,而不是假装游标已移动。 func TestMarkSessionReadNotWired(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) @@ -190,9 +190,9 @@ func TestMarkSessionReadNotWired(t *testing.T) { wantErrorShape(t, body) } -// TestMarkSessionReadBroadcasts asserts the cross-end sync frame: a successful advance -// pushes exactly one `session_read` frame scoped to that session and carrying the new -// cursor, while a failed one (404) pushes nothing. +// TestMarkSessionReadBroadcasts 断言跨端同步帧:成功的推进恰好 +// 推送一个 `session_read` 帧,作用域为该会话并携带新的 +// 游标;而失败的推进(404)什么也不推。 func TestMarkSessionReadBroadcasts(t *testing.T) { t.Parallel() srv, ts, jwt, _ := newHistoryEnv(t) @@ -222,7 +222,7 @@ func TestMarkSessionReadBroadcasts(t *testing.T) { t.Fatal(err) } - // A failed advance must not tell other ends the badge was cleared. + // 失败的推进不得告诉其他端角标已被清除。 resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions/web:missing/read", map[string]any{"before": 2000}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("unknown status = %d, want 404 (body=%s)", resp.StatusCode, body) diff --git a/internal/services/gateway/session_tail.go b/internal/services/gateway/session_tail.go index 15e6f02..41c5fb1 100644 --- a/internal/services/gateway/session_tail.go +++ b/internal/services/gateway/session_tail.go @@ -2,15 +2,15 @@ package gateway import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" -// This file owns the conversation-tail helpers of the session orchestrator -// (design/15 §2.2/§4): the bounded window that continuation resumes from and the -// emptiness check that keeps a vacuous turn from marking a session busy. They are -// pure functions over []model.Message, kept apart from the state machine so the -// orchestrator file stays small. +// 本文件拥有会话编排器的对话尾部辅助件 +// (design/15 §2.2/§4):续跑所依据的有界窗口,以及 +// 防止空洞回合把会话标记为忙碌的空判定。它们是 +// 作用于 []model.Message 的纯函数,与状态机分开 +// 放置,使编排器文件保持小。 -// boundedTail returns a fresh copy of the last maxTail messages, so continuation -// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input -// slice or another state's tail. +// boundedTail 返回最后 maxTail 条消息的全新副本,使续跑 +// 保持增量且有界(design/15 §2.2、§4),且绝不与输入 +// 切片或另一个状态的尾部共享底层数组。 func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message { if len(msgs) <= o.maxTail { return append([]model.Message(nil), msgs...) @@ -20,8 +20,8 @@ func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message { return out } -// messagesEmpty reports whether a turn carries no usable text or image, so the -// orchestrator does not mark a session busy on a vacuous message. +// messagesEmpty 报告回合是否不携带可用文本或图片,使 +// 编排器不会因空洞消息把会话标记为忙碌。 func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool { for _, m := range msgs { for _, c := range m.Content { @@ -36,8 +36,8 @@ func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool { return true } -// concatMessages returns a new slice combining a and b (never reusing a or b -// backing arrays) so the continuation tail is safe to hand to a concurrent run. +// concatMessages 返回合并 a 与 b 的新切片(绝不复用 a 或 b +// 的底层数组),因此续跑尾部可以安全地交给并发运行。 func concatMessages(a []model.Message, b ...model.Message) []model.Message { out := make([]model.Message, 0, len(a)+len(b)) out = append(out, a...) diff --git a/internal/services/gateway/session_transcript.go b/internal/services/gateway/session_transcript.go index aea529a..a1ce2a9 100644 --- a/internal/services/gateway/session_transcript.go +++ b/internal/services/gateway/session_transcript.go @@ -7,47 +7,47 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file owns the reconstruction of a turn's conversation context from the -// persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02 -// §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent -// message alone, which left the model with no sight of the conversation it was -// continuing; the assistant utterances of earlier turns also carried no record of -// which role had spoken them, so a role switched into a session could read another -// role's words as its own. +// 本文件拥有从持久化转录重建一个回合对话上下文的工作 +// (design/15 §2.2/§4「上下文有界:只保留最近 N 条上下文」、design/02 +// §1 主会话承载完整上下文、design/22 §7-5)。一个回合此前只从刚 +// 发送的消息开始,使模型完全看不到它正在延续的 +// 对话;更早回合的助手话语也没有记录 +// 是谁说的,因此切换进会话的角色会把另一个 +// 角色的话读成自己的。 // -// The seeding fixes both halves: a fresh turn carries the session's most recent -// persisted messages, and every assistant message is labelled with the role that -// spoke it (model.Message.SenderName) so the loop's context assembly can attribute -// it (loop/attribution.go). Only labels travel on the messages — no body is -// rewritten. +// 播种修好了这两半:新回合携带该会话最近的 +// 持久化消息,且每条助手消息都被说话的角色标注 +// (model.Message.SenderName),使 loop 的上下文组装能归属它 +// (loop/attribution.go)。消息上只走标签 —— 没有任何消息体 +// 被改写。 // -// Scope note: the seeded window is the conversation, so the loop's per-step -// background memory extraction now reads those recent messages instead of the -// single newly sent one. Extracted items are still filed under the turn's role -// (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is -// deduplicated by the extractor, but a fact first stated while another role was -// active can now be picked up by the current role if it was never extracted at the -// time. Narrowing the extraction input to the current turn's messages is a -// separate decision and is left as a follow-up. +// 范围说明:被播种的窗口就是该对话,因此 loop 的逐步 +// 后台记忆抽取现在读的是这些近期消息,而不是 +// 单条刚发送的消息。抽取出的条目仍归到该回合的角色 +// 名下(loop.persistMemories,design/22 §4.2),且同一内容的重复抽取 +// 会被抽取器去重,但一个在另一角色活跃时首次说出的事实, +// 若当时从未被抽取,现在就可能被当前角色拾取。 +// 把抽取输入收窄为当前回合的消息是一个 +// 独立的决定,留作后续跟进。 -// transcriptContextCount bounds how many persisted messages a fresh turn carries -// into the model context. It mirrors the orchestrator's continuation tail bound -// (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the -// loop's own context budget still governs compaction beyond that. +// transcriptContextCount 限定一个新回合带入模型上下文的持久化 +// 消息数。它与编排器的续跑尾部上限 +// (defaultMaxTail)一致,使播种回合与续跑回合保持同样大小; +// 超出之后仍由 loop 自身的上下文预算治理压缩。 const transcriptContextCount = 20 -// transcriptReader is the narrow history view the seeding needs. *history.Store -// satisfies it; a store that does not (a test fake, or a gateway built without -// persistence) simply yields no seeded context, so the orchestrator keeps running -// exactly as before. +// transcriptReader 是播种所需的窄历史视图。*history.Store +// 满足它;未满足的存储(测试替身,或未接持久化 +// 构建的网关)只是得不到播种上下文,因此编排器 +// 完全照旧继续运行。 type transcriptReader interface { Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error) } -// startTurnMessages returns the conversation a fresh turn runs on: the session's -// seeded transcript followed by the messages the client just sent. When the -// transcript is unavailable the caller's messages are returned untouched, which is -// the pre-existing behavior. +// startTurnMessages 返回新回合运行其上的对话:该会话 +// 已播种的转录,后跟客户端刚发送的消息。转录 +// 不可用时,调用方的消息原样返回,这正是 +// 既有行为。 func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message { seed := o.sessionTranscript(ctx, sessionID) if len(seed) == 0 { @@ -56,11 +56,11 @@ func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, return concatMessages(seed, messages...) } -// sessionTranscript reads the session's most recent persisted messages and maps -// them to model messages (oldest first). A nil/unreadable store or a read error -// yields nil so a chat is never blocked by a metadata lookup, and a session with -// no history yields nothing — both degrade to the pre-existing "new message only" -// context. +// sessionTranscript 读取该会话最近的持久化消息并把它们 +// 映射为模型消息(最旧优先)。nil/不可读的存储或读错误 +// 都得到 nil,因此聊天绝不被元数据查找阻塞;没有 +// 历史的会话也什么都不产出 —— 二者都退化为既有的 +// 「只有新消息」上下文。 func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message { if o.history == nil || sessionID == "" { return nil @@ -81,12 +81,12 @@ func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) return out } -// transcriptMessage maps one persisted chat message onto the model message a turn -// continues from. An assistant message carries the role that spoke it (the stored -// persona_id of the turn) as its speaker label, so the context can attribute it to -// that role even after the session was switched; a non-assistant message keeps its -// stored sender label (the third-party display name of design/16 §4.6) and is never -// relabelled as a speaking role. +// transcriptMessage 把一条持久化聊天消息映射为回合延续所依据的 +// 模型消息。助手消息携带说话的角色(该回合已存的 +// persona_id)作为其说话者标签,因此即使会话被切换过, +// 上下文也能把它归属到那个角色;非助手消息保持其 +// 已存发送者标签(design/16 §4.6 的第三方显示名),绝不 +// 被重新标为说话角色。 func transcriptMessage(h history.Message) model.Message { role := historyRoleToModel(h.Role) msg := model.Message{ @@ -104,8 +104,8 @@ func transcriptMessage(h history.Message) model.Message { return msg } -// historyRoleToModel maps a persisted chat role to the model's role vocabulary, -// defaulting an unknown role to the user lane. +// historyRoleToModel 把已存聊天角色映射为模型的角色词汇, +// 未知角色默认归到用户通道。 func historyRoleToModel(role string) model.Role { switch role { case history.RoleAssistant: @@ -117,11 +117,11 @@ func historyRoleToModel(role string) model.Role { } } -// assistantTailMessage builds the continuation-tail entry for one settled turn's -// reply, labelled with the role that spoke it (design/22 §7-5): the tail is the -// in-memory twin of the seeded transcript, so a role switched in later must be able -// to tell an earlier role's reply apart from its own words on the very next queued -// turn. +// assistantTailMessage 为一个已结算回合的回复构建续跑尾部 +// 条目,并用说话的角色标注它(design/22 §7-5):该尾部是 +// 播种转录的内存孪生,因此之后切换进来的角色必须能 +// 在紧接着的那个排队回合上,把更早角色的回复与自己的话 +// 区分开。 func assistantTailMessage(text, personaID string) model.Message { msg := model.TextMessage(model.RoleAssistant, text) msg.SenderName = personaID diff --git a/internal/services/gateway/static.go b/internal/services/gateway/static.go index 6757c29..801e1e0 100644 --- a/internal/services/gateway/static.go +++ b/internal/services/gateway/static.go @@ -6,10 +6,10 @@ import ( "strings" ) -// spa wraps the API mux with the SPA file server. Requests under /api/ and /ws/ -// are served by the API handler; every other path is served from the configured -// frontend filesystem with a fallback to index.html so client-side routes (e.g. -// /chat) resolve in a single-page app. +// spa 用 SPA 文件服务器包裹 API mux。/api/ 与 /ws/ +// 之下的请求由 API handler 服务;其他所有路径都由已配置的 +// 前端文件系统服务,并回退到 index.html,使客户端路由(例如 +// /chat)能在单页应用中解析。 func (s *Server) spa(api http.Handler) http.Handler { fileServer := s.spaFileServer() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -21,9 +21,9 @@ func (s *Server) spa(api http.Handler) http.Handler { }) } -// isWebhookRoute reports whether path is a registered external webhook callback -// route. Such paths must reach the API mux (where the webhook handler is -// mounted) rather than the SPA file fallback, so inbound callbacks are served. +// isWebhookRoute 报告 path 是否是已注册的外部 webhook 回调 +// 路由。这类路径必须抵达 API mux(webhook handler 挂载之处) +// 而不是 SPA 文件回退,使入站回调得到服务。 func (s *Server) isWebhookRoute(p string) bool { if len(s.webhooks) == 0 { return false @@ -32,8 +32,8 @@ func (s *Server) isWebhookRoute(p string) bool { return ok } -// spaFileServer serves static assets, falling back to index.html for paths that -// are not real files in the embedded/external dist. +// spaFileServer 提供静态资产,对不是内嵌/外部 dist 中真实 +// 文件的路径回退到 index.html。 func (s *Server) spaFileServer() http.Handler { fsrv := http.FileServer(http.FS(s.frontend)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -51,8 +51,8 @@ func (s *Server) spaFileServer() http.Handler { }) } -// isAPIRoute reports whether path belongs to the REST/WS API surface the -// gateway owns, which the SPA handler must leave untouched. +// isAPIRoute 报告 path 是否属于网关拥有的 REST/WS API 表面, +// SPA handler 必须对之不作干预。 func isAPIRoute(p string) bool { return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/ws/") } diff --git a/internal/services/gateway/upload.go b/internal/services/gateway/upload.go index c769ad5..d9b94a1 100644 --- a/internal/services/gateway/upload.go +++ b/internal/services/gateway/upload.go @@ -17,9 +17,9 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) -// maxUploadBytes caps a single uploaded image at 5 MiB. The multipart envelope -// (boundaries, part headers) gets a little headroom on top of that; the decoded -// file itself is checked against this exact limit. +// maxUploadBytes 把单张上传图片限制在 5 MiB。multipart 信封 +// (边界、部件头)在其上多留一点余量;解码后的 +// 文件本身按这个确切上限校验。 const ( maxUploadBytes = 5 << 20 uploadMultipartSlack = 1 << 20 @@ -27,16 +27,16 @@ const ( uploadSniffBytes = 512 ) -// uploadFilePrefix is the same-origin static route that serves stored uploads -// (registered in Handler). It lives under /api/v1/ so the gateway mux — not the -// SPA fallback — owns it, and so it is part of the versioned client contract. +// uploadFilePrefix 是提供已存上传的同源静态路由 +// (在 Handler 中注册)。它位于 /api/v1/ 之下,因此归网关 mux 而非 +// SPA 回退所有,也因此属于版本化客户端契约。 const uploadFilePrefix = "/api/v1/files/" -// uploadResponse is the POST /api/v1/upload success payload. URL is an absolute -// http(s) URL built from the request authority so it can be handed straight to -// chat.image: the vision layer classifies only http(s):// as a URL and would -// otherwise treat a bare path as base64. Name is the server-generated storage -// name; the client's original filename is never echoed back or persisted. +// uploadResponse 是 POST /api/v1/upload 的成功载荷。URL 是由请求 +// 权威构建的绝对 http(s) URL,因此能直接交给 +// chat.image:视觉层只把 http(s):// 归类为 URL,否则会把 +// 裸路径当作 base64。Name 是服务端生成的存储 +// 名;客户端原始文件名绝不回显或持久化。 type uploadResponse struct { URL string `json:"url"` Size int64 `json:"size"` @@ -44,12 +44,12 @@ type uploadResponse struct { Name string `json:"name"` } -// handleUpload accepts a multipart/form-data request with a single `file` part, -// validates it is a raster image within the size limit, stores it under -// /uploads under a cryptographically random name, audits the write and -// returns the URL to attach as chat.image. It is owner/admin only (registered in -// Handler). The client's original filename is deliberately ignored: it is -// attacker-controlled and could carry a path separator or overwrite a file. +// handleUpload 接受带单个 `file` 部件的 multipart/form-data 请求, +// 校验它是在大小限制内的光栅图片,并以密码学随机的 +// 名字存到 /uploads 下,审计该写操作, +// 返回可作为 chat.image 附上的 URL。它仅 owner/admin(注册在 +// Handler 中)。客户端原始文件名被刻意忽略:它由 +// 攻击者控制,可能携带路径分隔符或覆盖文件。 func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+uploadMultipartSlack) if err := r.ParseMultipartForm(1 << 20); err != nil { @@ -104,12 +104,12 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { }) } -// guardUploadPaths rejects a static-upload request whose decoded path contains a -// parent-directory segment, before ServeMux can clean it and redirect the client -// to a non-upload path (which the SPA would then answer with index.html). The -// {name} route only ever matches one segment, so a `..` can only be an escape -// attempt; answering 404 here keeps every traversal shape uniformly rejected. -// Non-upload paths pass through untouched. +// guardUploadPaths 拒绝解码后路径含父目录段的静态上传 +// 请求,赶在 ServeMux 清理它并把客户端重定向到一个非上传 +// 路径(SPA 随后会用 index.html 应答)之前。{name} 路由 +// 只匹配一个段,因此 `..` 只可能是逃逸尝试;这里返回 404 +// 使每一种穿越形态都被统一拒绝。非上传路径 +// 原样放行。 func guardUploadPaths(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, uploadFilePrefix) && hasDotDotSegment(r.URL.Path) { @@ -120,7 +120,7 @@ func guardUploadPaths(next http.Handler) http.Handler { }) } -// hasDotDotSegment reports whether p has a literal ".." path segment. +// hasDotDotSegment 报告 p 是否有字面的 ".." 路径段。 func hasDotDotSegment(p string) bool { for _, seg := range strings.Split(p, "/") { if seg == ".." { @@ -130,11 +130,11 @@ func hasDotDotSegment(p string) bool { return false } -// handleUploadFile serves a stored upload from /uploads. It is -// unauthenticated by design: the returned URL is handed to chat.image and to -// tags, neither of which can attach a bearer token. The random 128-bit -// name is the capability; the handler accepts only a strict hex+known-extension -// name and never lets a request path escape the uploads directory. +// handleUploadFile 从 /uploads 提供一份已存上传。它 +// 按设计不认证:返回的 URL 会交给 chat.image 与 +// 标签,二者都无法附带 bearer token。随机的 128 位 +// 名字就是能力;handler 只接受严格的十六进制+已知扩展名 +// 名字,且绝不让请求路径逃出 uploads 目录。 func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !validUploadName(name) { @@ -173,8 +173,8 @@ func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, name, info.ModTime(), f) } -// writeUploadBodyError maps a multipart parse failure to its wire error: an -// over-limit body is 413, anything else (not multipart, malformed part) is 400. +// writeUploadBodyError 把 multipart 解析失败映射为其线上错误: +// 超限请求体是 413,其他(非 multipart、部件畸形)是 400。 func writeUploadBodyError(w http.ResponseWriter, err error) { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { @@ -184,9 +184,9 @@ func writeUploadBodyError(w http.ResponseWriter, err error) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid multipart body")) } -// sniffImageMIME detects the content type of the upload head with the stdlib -// sniffer (never the client-supplied Content-Type header, which is untrusted) -// and rewinds the part so the caller can copy it in full. +// sniffImageMIME 用标准库嗅探器检测上传头部的类型 +// (绝不用客户端提供的 Content-Type 头,它不可信), +// 并回绕部件,使调用方能完整复制它。 func sniffImageMIME(f multipart.File) (string, error) { buf := make([]byte, uploadSniffBytes) n, err := io.ReadFull(f, buf) @@ -199,9 +199,9 @@ func sniffImageMIME(f multipart.File) (string, error) { return http.DetectContentType(buf[:n]), nil } -// uploadExtForMIME maps a sniffed image MIME to the extension used on disk. The -// whitelist is deliberately raster-only: SVG (and HTML) would be served inline -// and could execute script, so they are not accepted. +// uploadExtForMIME 把嗅探出的图片 MIME 映射为磁盘上使用的扩展名。 +// 白名单刻意只含光栅:SVG(与 HTML)会被内联服务 +// 并可能执行脚本,因此不被接受。 func uploadExtForMIME(mime string) (string, bool) { switch mime { case "image/png": @@ -219,9 +219,9 @@ func uploadExtForMIME(mime string) (string, bool) { } } -// uploadMIMEForExt returns the Content-Type for a stored upload extension. It is -// the read-side twin of uploadExtForMIME: only whitelisted extensions resolve, -// which is what makes validUploadName strict. +// uploadMIMEForExt 返回已存上传扩展名对应的 Content-Type。它是 +// uploadExtForMIME 的读侧孪生:只有白名单内的扩展名能解析, +// 这正是 validUploadName 严格的原因。 func uploadMIMEForExt(ext string) (string, bool) { switch strings.ToLower(ext) { case ".png": @@ -239,10 +239,10 @@ func uploadMIMEForExt(ext string) (string, bool) { } } -// storeUpload writes f to /uploads/. and returns the -// generated name. It writes to a dot-prefixed temp file first and renames it -// into place, so a partial upload is never visible under a servable name. The -// directory is created 0700 and the file inherits the temp file's 0600 mode. +// storeUpload 把 f 写入 /uploads/. 并返回 +// 生成的名字。它先写一个点前缀的临时文件再把它重命名 +// 到位,因此部分上传绝不会以可服务的名字可见。 +// 目录以 0700 创建,文件继承临时文件的 0600 权限。 func (s *Server) storeUpload(f io.Reader, ext string) (string, error) { dir, err := s.uploadDir() if err != nil { @@ -276,10 +276,10 @@ func (s *Server) storeUpload(f io.Reader, ext string) (string, error) { return name, nil } -// uploadDir returns /uploads. The resolved data dir is injected by the -// composition root (Deps.DataDir, the same value resolveDataDir hands the DB and -// the runtime configs); the configured cfg.DataDir is the fallback so a Server -// built without the injection still resolves its uploads. +// uploadDir 返回 /uploads。解析后的数据目录由 +// 组合根注入(Deps.DataDir,即 resolveDataDir 交给 DB 与 +// 运行时配置的同一个值);已配置的 cfg.DataDir 是回退,使 +// 未注入构建的 Server 仍能解析其上传目录。 func (s *Server) uploadDir() (string, error) { base := strings.TrimSpace(s.dataDir) if base == "" { @@ -291,8 +291,8 @@ func (s *Server) uploadDir() (string, error) { return filepath.Join(base, "uploads"), nil } -// randomUploadName returns a 128-bit random hex name with ext appended. It is -// the only filename component ever used: the client's original name is dropped. +// randomUploadName 返回追加 ext 的 128 位随机十六进制名。它 +// 是唯一被使用的文件名组成部分:客户端原始名被丢弃。 func randomUploadName(ext string) (string, error) { buf := make([]byte, uploadNameBytes) if _, err := rand.Read(buf); err != nil { @@ -301,10 +301,10 @@ func randomUploadName(ext string) (string, error) { return hex.EncodeToString(buf) + ext, nil } -// validUploadName reports whether name is exactly a 32-lowercase-hex stem plus a -// whitelisted image extension. This is the path-traversal guard for the static -// route: separators, dots, dots-dots and any other shape are rejected before the -// filesystem is touched. +// validUploadName 报告 name 是否恰好是 32 位小写十六进制主名 +// 加上白名单内的图片扩展名。这是静态路由的路径穿越 +// 防护:分隔符、点、双点及任何其他形态都在 +// 触碰文件系统之前被拒绝。 func validUploadName(name string) bool { if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") { return false @@ -322,15 +322,15 @@ func validUploadName(name string) bool { return ok } -// isLowerHex reports whether c is an ASCII lowercase hex digit. +// isLowerHex 报告 c 是否是 ASCII 小写十六进制数字。 func isLowerHex(c rune) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') } -// uploadURL builds the absolute, same-origin URL for a stored upload. The scheme -// honours X-Forwarded-Proto (reverse proxy) and the authority is the request -// host, sanitized so a malformed Host header cannot smuggle path syntax into the -// returned URL; the configured listen address is the fallback. +// uploadURL 为一份已存上传构建绝对同源 URL。scheme +// 遵循 X-Forwarded-Proto(反向代理),权威是请求 +// host,并做净化,使畸形的 Host 头无法把路径语法夹带进 +// 返回的 URL;已配置的监听地址是回退。 func (s *Server) uploadURL(r *http.Request, name string) string { scheme := "http" if r.TLS != nil { @@ -346,8 +346,8 @@ func (s *Server) uploadURL(r *http.Request, name string) string { return scheme + "://" + host + uploadFilePrefix + name } -// forwardedProto returns the first X-Forwarded-Proto value when it is http or -// https, else "". +// forwardedProto 在首个 X-Forwarded-Proto 值为 http 或 +// https 时返回它,否则返回 ""。 func forwardedProto(r *http.Request) string { v := r.Header.Get("X-Forwarded-Proto") if v == "" { @@ -360,8 +360,8 @@ func forwardedProto(r *http.Request) string { return "" } -// sanitizeHost rejects a host value containing path, userinfo or whitespace -// syntax; an empty result makes uploadURL fall back to the configured address. +// sanitizeHost 拒绝含路径、userinfo 或空白语法的 host +// 值;结果为空会让 uploadURL 回退到已配置的地址。 func sanitizeHost(host string) string { host = strings.TrimSpace(host) if host == "" || strings.ContainsAny(host, "/\\@ \t") { diff --git a/internal/services/gateway/upload_test.go b/internal/services/gateway/upload_test.go index ddb6665..3fc7786 100644 --- a/internal/services/gateway/upload_test.go +++ b/internal/services/gateway/upload_test.go @@ -19,13 +19,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) -// testPNG is the smallest byte string the stdlib sniffer recognizes as -// image/png (the 8-byte signature plus padding); the handler only sniffs the -// head, it never decodes the image. +// testPNG 是标准库嗅探器能识别为 image/png 的最小字节串 +// (8 字节签名加填充);handler 只嗅探头部, +// 绝不解码该图片。 var testPNG = append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...) -// uploadTestEnv builds a Server whose data dir is a fresh temp dir and returns -// the httptest server, the JWT service and the resolved data dir. +// uploadTestEnv 构建一个 Server,其数据目录是全新的临时目录,返回 +// httptest 服务器、JWT 服务与解析后的数据目录。 func uploadTestEnv(t *testing.T) (*httptest.Server, *auth.JWTService, string) { t.Helper() cfg := cfgDefault() @@ -34,9 +34,9 @@ func uploadTestEnv(t *testing.T) (*httptest.Server, *auth.JWTService, string) { return ts, jwt, cfg.DataDir } -// postUpload sends a multipart/form-data POST to /api/v1/upload. field is the -// part name (normally "file"); filename is the client-supplied name, which the -// handler must ignore. +// postUpload 向 /api/v1/upload 发送 multipart/form-data POST。field 是 +// 部件名(通常为 "file");filename 是客户端提供的名字, +// handler 必须忽略它。 func postUpload(t *testing.T, ts *httptest.Server, token, field, filename string, content []byte) (*http.Response, []byte) { t.Helper() var buf bytes.Buffer @@ -71,7 +71,7 @@ func postUpload(t *testing.T, ts *httptest.Server, token, field, filename string return resp, body } -// uploadData is the decoded upload response. +// uploadData 是解码后的上传响应。 type uploadData struct { URL string `json:"url"` Size int64 `json:"size"` @@ -110,7 +110,7 @@ func TestUploadAndServeImage(t *testing.T) { if !strings.HasPrefix(data.URL, "http://") || !strings.Contains(data.URL, uploadFilePrefix) { t.Fatalf("url = %q, want absolute %s URL", data.URL, uploadFilePrefix) } - // The client's original filename must never reach disk or the response. + // 客户端原始文件名绝不得到达磁盘或响应。 if strings.Contains(data.Name, "evil") || strings.ContainsAny(data.Name, "/\\") { t.Fatalf("name = %q, want a server-generated hex name", data.Name) } @@ -121,8 +121,8 @@ func TestUploadAndServeImage(t *testing.T) { t.Fatalf("stored file: %v", err) } - // The static route is a capability URL: no bearer token, and the bytes and - // caching/type headers must come back intact. + // 静态路由是能力 URL:不带 bearer token,且字节与 + // 缓存/类型头必须原样返回。 get, err := ts.Client().Get(data.URL) if err != nil { t.Fatalf("get upload: %v", err) @@ -230,8 +230,8 @@ func TestUploadFileServedThroughSPA(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.DataDir = t.TempDir() - // A non-nil frontend enables the SPA wrapper in Handler; the /api/v1/files/ - // route must still reach the API mux instead of falling back to index.html. + // 非 nil 的 frontend 会在 Handler 中启用 SPA 包装; + // /api/v1/files/ 路由仍必须抵达 API mux,而不是回退到 index.html。 frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("spa")}} _, ts, jwt := buildEnv(t, cfg, Deps{Frontend: frontend}) tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour) @@ -259,13 +259,13 @@ func TestUploadFileRejectsTraversal(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.DataDir = t.TempDir() - // A non-nil frontend reproduces production routing: without the explicit - // guard, ServeMux would clean the raw `..` path and redirect to a path the SPA - // then answers with index.html. + // 非 nil 的 frontend 复现生产路由:没有显式 + // 防护时,ServeMux 会清理原始 `..` 路径并重定向到一个 SPA + // 随后用 index.html 应答的路径。 frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("spa")}} _, ts, _ := buildEnv(t, cfg, Deps{Frontend: frontend}) - // A file that really exists directly under the data dir (not in uploads/) - // must not be reachable through the static route. + // 直接存在于数据目录下(不在 uploads/ 中)的真实文件 + // 绝不得经静态路由可达。 if err := os.WriteFile(filepath.Join(cfg.DataDir, "secret.png"), testPNG, 0o600); err != nil { t.Fatalf("write decoy: %v", err) } diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index a43316d..d8ea193 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -12,38 +12,38 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) -// VoiceService runs speech-to-text and text-to-speech for the voice_input WS -// route. *voice.Chain (the degrading provider chain) and *voice.Service (the -// background worker pool) both satisfy it. It is nil-tolerant: the voice route -// replies with a clear error when no service is injected. +// VoiceService 为 voice_input WS 路由运行语音转文本与 +// 文本转语音。*voice.Chain(降级 provider 链)与 *voice.Service +// (后台工作池)都满足它。它容忍 nil:未注入服务时 +// voice 路由返回明确的错误。 type VoiceService interface { - // STT transcribes audio to text. + // STT 把音频转写为文本。 STT(ctx context.Context, audio voice.Audio) (string, error) - // TTS synthesizes audio from text. A per-request emotion/tone instruction may - // be threaded through ctx (voice.WithTTSInstruction) to override the configured - // VOICE_TTS_INSTRUCTION for one call. + // TTS 从文本合成音频。每请求的情绪/语气指令可以 + // 经 ctx(voice.WithTTSInstruction)串下来,以在单次调用中覆盖 + // 已配置的 VOICE_TTS_INSTRUCTION。 TTS(ctx context.Context, text string) (voice.Audio, error) - // CloneVoice creates a custom voice (声音复刻 create_voice) from a public - // reference audio URL and returns its voice_id. + // CloneVoice 从公开参考音频 URL 创建自定义音色(声音复刻 + // create_voice)并返回其 voice_id。 CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) } -// voiceProviderHeader is the response header carrying the provider rung on the -// binary /api/v1/audio/speech response, where a JSON signal field cannot be added. +// voiceProviderHeader 是二进制 /api/v1/audio/speech 响应上携带 provider +// 档位的响应头,因为那里无法添加 JSON 信号字段。 const voiceProviderHeader = "X-Voice-Provider" -// voiceProviderReporter is the optional extension of VoiceService: a service that -// can report which provider rung (cloud / local / edge-tts / espeak / silent) -// produced a result. *voice.Chain and *voice.Service implement it; a custom -// VoiceService that does not is still supported and falls back to the plain -// STT/TTS methods with no reported rung. +// voiceProviderReporter 是 VoiceService 的可选扩展:一个能报告 +// 结果是哪个 provider 档位(cloud / local / edge-tts / espeak / silent) +// 产出的服务。*voice.Chain 与 *voice.Service 实现了它;未实现的 +// 自定义 VoiceService 仍被支持,回退到朴素的 +// STT/TTS 方法且不报告档位。 type voiceProviderReporter interface { STTWithProvider(ctx context.Context, audio voice.Audio) (string, string, error) TTSWithProvider(ctx context.Context, text string) (voice.Audio, string, error) } -// sttWithProvider runs STT and returns the transcript plus the provider rung that -// served it ("" when the injected service cannot report one). +// sttWithProvider 运行 STT 并返回转写文本加上服务它的 +// provider 档位(注入的服务无法报告时为 "")。 func sttWithProvider(ctx context.Context, v VoiceService, audio voice.Audio) (string, string, error) { if r, ok := v.(voiceProviderReporter); ok { return r.STTWithProvider(ctx, audio) @@ -52,8 +52,8 @@ func sttWithProvider(ctx context.Context, v VoiceService, audio voice.Audio) (st return text, "", err } -// ttsWithProvider runs TTS and returns the audio plus the provider rung that -// served it. +// ttsWithProvider 运行 TTS 并返回音频加上服务它的 +// provider 档位。 func ttsWithProvider(ctx context.Context, v VoiceService, text string) (voice.Audio, string, error) { if r, ok := v.(voiceProviderReporter); ok { return r.TTSWithProvider(ctx, text) @@ -62,10 +62,10 @@ func ttsWithProvider(ctx context.Context, v VoiceService, text string) (voice.Au return audio, "", err } -// providerSignal returns the "provider" degraded signal for a voice result: the -// actual provider rung when the result is empty, "silent" when the rung is -// unknown, and "" (omit the field) when the result carries real text/audio. It -// only flags "no real speech/audio"; it is never a business error. +// providerSignal 为语音结果返回 "provider" 降级信号:结果为 +// 空时返回实际 provider 档位,档位未知时返回 "silent", +// 而结果携带真实文本/音频时返回 ""(省略该字段)。它 +// 只标记「没有真实语音/音频」;它绝不是业务错误。 func providerSignal(rung string, empty bool) string { if !empty { return "" @@ -76,11 +76,11 @@ func providerSignal(rung string, empty bool) string { return rung } -// voiceInputRequest is the decoded payload of a voice_input frame. Audio is a -// base64 (or data: URI) audio blob; Format is its encoding ("wav"/"mp3"/"ogg"); -// Text overrides STT when present (a client that already transcribed can skip -// recognition); SessionID defaults to "web"; DeviceID is the client-produced -// end identifier (design/16 §4.5) threaded into the persisted voice turn. +// voiceInputRequest 是 voice_input 帧解码后的载荷。Audio 是 +// base64(或 data: URI)音频块;Format 是其编码("wav"/"mp3"/"ogg"); +// Text 存在时覆盖 STT(已自行转写的客户端可跳过 +// 识别);SessionID 默认 "web";DeviceID 是客户端产出的 +// 端标识(design/16 §4.5),串进持久化的语音回合。 type voiceInputRequest struct { Audio string `json:"audio"` Format string `json:"format"` @@ -89,19 +89,19 @@ type voiceInputRequest struct { DeviceID string `json:"deviceId,omitempty"` } -// SetVoice injects the voice STT/TTS service used by the voice_input WS route. -// It is the gateway's exposed injection point so the composition root can wire a -// *voice.Chain or *voice.Service without touching Deps. A nil value disables the -// route (it replies with a clear error). +// SetVoice 注入 voice_input WS 路由所用的语音 STT/TTS 服务。 +// 它是网关暴露的注入点,使组合根能接线 *voice.Chain 或 +// *voice.Service 而无需触碰 Deps。nil 值会禁用该 +// 路由(它以明确错误应答)。 func (s *Server) SetVoice(v VoiceService) { s.voice = v s.hub.voice = v } -// handleVoiceInput routes one voice_input frame. Without an injected voice -// service it replies with a clear error (never a crash); otherwise it decodes the -// request and runs the STT→chat→TTS turn asynchronously so the read pump is never -// blocked by provider latency. +// handleVoiceInput 路由一个 voice_input 帧。未注入语音 +// 服务时它以明确错误应答(绝不崩溃);否则它解码 +// 请求并异步运行 STT→chat→TTS 回合,使读泵绝不被 +// provider 延迟阻塞。 func (c *wsConn) handleVoiceInput(in wsInbound) { if c.h.voice == nil { c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "voice unavailable"}}) @@ -116,13 +116,13 @@ func (c *wsConn) handleVoiceInput(in wsInbound) { go c.h.runVoiceTurn(c, req) } -// runVoiceTurn drives one voice turn: STT (or a supplied transcript) → agent chat -// → TTS, then replies to the requesting connection with a voice_output frame -// carrying the base64 audio plus the recognized transcript and assistant reply. +// runVoiceTurn 驱动一个语音回合:STT(或提供的转写)→ agent chat +// → TTS,然后向请求连接回复一个 voice_output 帧, +// 携带 base64 音频加上识别出的转写与助手回复。 func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { ctx := context.Background() - // Thread the client's deviceID so the persisted voice turn is tagged with the - // sending end (design/16 §4.5), mirroring ws_hub.go:240. + // 把客户端的 deviceID 串下来,使持久化的语音回合被发送端 + // 标注(design/16 §4.5),与 ws_hub.go:240 一致。 ctx = withDeviceID(ctx, req.DeviceID) if req.SessionID == "" { req.SessionID = "web" @@ -155,9 +155,9 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { h.voiceError(c, "chat service is not configured") return } - // The Web main session is the owner's private conversation, so it binds the - // full-access env: memory read+write, unchanged behavior. The role comes from the - // session's binding (design/22 §4.5). + // Web 主会话是主人的私密对话,因此它绑定 + // 全访问 env:记忆读+写,行为不变。角色来自该 + // 会话的绑定(design/22 §4.5)。 env := h.envResolver.owner(ctx, req.SessionID) res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)}) if err != nil { @@ -187,14 +187,14 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { c.enqueueJSON(wsEvent{Type: wsVoiceOutput, Data: data}) } -// voiceError replies with a typed error frame for a failed voice turn. +// voiceError 为失败的语音回合回复一个带类型的错误帧。 func (h *hub) voiceError(c *wsConn, message string) { c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": message}}) } -// decodeVoiceAudio turns a base64 (or data:) audio payload into voice.Audio. The -// format is taken from req when set, guessed from a data: URI mime otherwise, and -// defaulted to "wav". +// decodeVoiceAudio 把 base64(或 data:)音频载荷转成 voice.Audio。 +// 格式在 req 设置时取自 req,否则从 data: URI mime 猜测, +// 再默认到 "wav"。 func decodeVoiceAudio(src, format string) (voice.Audio, error) { payload := src if strings.HasPrefix(src, "data:") { @@ -218,7 +218,7 @@ func decodeVoiceAudio(src, format string) (voice.Audio, error) { return voice.Audio{Format: format, Data: data}, nil } -// formatFromMime guesses an audio format from a data: URI mime segment. +// formatFromMime 从 data: URI 的 mime 段猜测音频格式。 func formatFromMime(meta string) string { lower := strings.ToLower(meta) for _, f := range []string{"wav", "mp3", "ogg", "webm", "mpeg"} { diff --git a/internal/services/gateway/ws_bridge.go b/internal/services/gateway/ws_bridge.go index b4c1bff..5414e7f 100644 --- a/internal/services/gateway/ws_bridge.go +++ b/internal/services/gateway/ws_bridge.go @@ -9,9 +9,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm" ) -// eventBridge maps a producer bus subject to its outbound WS event type. The hub -// subscribes to these (and re-normalizes into ws.* types) so the frontend's -// existing consumers light up without the producers changing their subjects. +// eventBridge 把生产者 bus 主题映射为其下行 WS 事件类型。hub +// 订阅这些(并重新归一化为 ws.* 类型),使前端既有的 +// 消费方亮起来,而生产者无需改变其主题名。 var eventBridge = map[string]string{ "iot.device_update": "device_update", "task.output": "task_output", @@ -20,8 +20,8 @@ var eventBridge = map[string]string{ "reminder.trigger": "reminder", } -// bridgePatterns are the additional bus patterns the hub subscribes to on top of -// ws.> to capture producer events and normalize them for WS delivery. +// bridgePatterns 是 hub 在 ws.> 之外额外订阅的 bus 模式,用于 +// 捕获生产者事件并把它们归一化以供 WS 投递。 var bridgePatterns = []string{ "iot.>", "task.>", @@ -30,11 +30,11 @@ var bridgePatterns = []string{ "cyrene.agent.*.event", } -// normalizeEvent maps a bus event to an outbound wsEvent. A ws.* subject keeps -// its design type (the ws. prefix is stripped); a known producer subject is -// bridged to its ws.* type; a sub-agent progress event is shaped into -// ws.subagent_event. It returns ok=false for events that have no WS-visible -// representation (e.g. internal cyrene.agent ask/reply traffic). +// normalizeEvent 把 bus 事件映射为下行 wsEvent。ws.* 主题保持 +// 其设计类型(ws. 前缀被剥掉);已知的生产者主题被 +// 桥接为其 ws.* 类型;子代理进度事件被塑形为 +// ws.subagent_event。对没有 WS 可见表示的事件返回 ok=false +// (例如内部的 cyrene.agent ask/reply 流量)。 func normalizeEvent(e bus.Event) (wsEvent, bool) { if typ, ok := eventBridge[e.Subject]; ok { return wsEvent{Type: typ, Data: e.Payload}, true @@ -48,11 +48,11 @@ func normalizeEvent(e bus.Event) (wsEvent, bool) { return wsEvent{}, false } -// subagentProgressEvent maps an orchestration progress event — broadcast on -// cyrene.agent..event via comm.PublishEvent — into a ws.subagent_event -// frame. Only the five sub-agent progress names are surfaced; any other event -// broadcast on the same subject (ask/reply/addressed events, or foreign event -// names) is ignored so internal agent-to-agent traffic stays internal. +// subagentProgressEvent 把编排进度事件 —— 经 comm.PublishEvent 广播在 +// cyrene.agent..event 上 —— 映射为 ws.subagent_event +// 帧。只有五个子代理进度名会被浮现;同一主题上广播的 +// 其他任何事件(ask/reply/addressed 事件,或外来事件 +// 名)都被忽略,使内部 agent 间流量保持内部。 func subagentProgressEvent(e bus.Event) (wsEvent, bool) { if !isBroadcastAgentEvent(e.Subject) { return wsEvent{}, false @@ -80,11 +80,11 @@ func subagentProgressEvent(e bus.Event) (wsEvent, bool) { }}, true } -// streamEventData maps a streamed model.StreamKind to its downstream WS event -// type and builds a typed data payload. It is what turns the loop's structured -// events into the message.*/thinking.*/tool_call.*/tool_execution.* frames the -// frontend renders. Unknown kinds fall back to the legacy stream_chunk shape so -// an unrecognized event still reaches the client instead of being dropped. +// streamEventData 把流式的 model.StreamKind 映射为其下游 WS 事件 +// 类型并构建带类型的 data 载荷。它正是把 loop 的结构化 +// 事件变成前端所渲染的 message.*/thinking.*/tool_call.*/tool_execution.* +// 帧的东西。未知类型回退到旧的 stream_chunk 形状,使 +// 未识别的事件仍能抵达客户端而不是被丢弃。 func streamEventData(ev model.StreamEvent) (string, map[string]any) { switch ev.Kind { case model.StreamKindTextStart: @@ -112,10 +112,10 @@ func streamEventData(ev model.StreamEvent) (string, map[string]any) { } } -// isBroadcastAgentEvent reports whether subject has the exact 4-token broadcast -// shape cyrene.agent..event, which is how the scheduler publishes progress. -// It deliberately does not match addressed events (cyrene.agent...event) -// or the ask/reply subjects. +// isBroadcastAgentEvent 报告 subject 是否恰为 4 段广播形态 +// cyrene.agent..event,调度器正是这样发布进度的。 +// 它刻意不匹配寻址事件(cyrene.agent...event) +// 或 ask/reply 主题。 func isBroadcastAgentEvent(subject string) bool { parts := strings.Split(subject, ".") if len(parts) != 4 { diff --git a/internal/services/gateway/ws_hub_conn.go b/internal/services/gateway/ws_hub_conn.go index 026a767..dc9d66d 100644 --- a/internal/services/gateway/ws_hub_conn.go +++ b/internal/services/gateway/ws_hub_conn.go @@ -15,9 +15,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// wsConn wraps one WebSocket connection. Writes are serialized through the -// buffered send channel drained by a single writePump, so the gorilla conn is -// only ever touched by one goroutine at a time. +// wsConn 包装一个 WebSocket 连接。写入经缓冲的 send +// 通道串行化,由单个 writePump 排空,因此 gorilla conn +// 在任一时刻只被一个 goroutine 触碰。 type wsConn struct { conn *websocket.Conn send chan []byte @@ -27,9 +27,9 @@ type wsConn struct { once sync.Once } -// handleWS upgrades /ws/chat after authenticating the JWT from the query token or -// the Authorization header. A failed upgrade writes a 401 {error:...} before the -// handshake, so a browser/IM client sees a clean reject. +// handleWS 在校验来自查询 token 或 Authorization 头的 JWT 之后 +// 升级 /ws/chat。升级失败会在握手前写出 401 {error:...}, +// 使浏览器/IM 客户端看到干净的拒绝。 func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "websocket requires GET")) @@ -59,7 +59,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { go c.readPump() } -// readPump reads client frames until the connection closes, routing each by type. +// readPump 读取客户端帧直到连接关闭,按类型路由每个帧。 func (c *wsConn) readPump() { defer func() { c.h.unregister(c) @@ -87,8 +87,8 @@ func (c *wsConn) readPump() { } } -// writePump drains the send channel (and sends keepalive pings) until the -// connection closes. It is the only writer on the gorilla conn. +// writePump 排空 send 通道(并发送 keepalive ping)直到 +// 连接关闭。它是 gorilla conn 上唯一的写入者。 func (c *wsConn) writePump() { ticker := time.NewTicker(wsPingPeriod) defer func() { @@ -113,7 +113,7 @@ func (c *wsConn) writePump() { } } -// publishPublic publishes a bus event without any per-connection reply. +// publishPublic 发布一个 bus 事件,且不做任何按连接的回复。 func (c *wsConn) publishPublic(subject string, payload any) { if c.h.bus == nil { return @@ -121,8 +121,8 @@ func (c *wsConn) publishPublic(subject string, payload any) { _ = c.h.bus.Publish(context.Background(), subject, payload) } -// shutdown closes the connection exactly once. It tolerates a nil *websocket.Conn -// so tests that build a fake connection can rely on a safe teardown. +// shutdown 恰好关闭连接一次。它容忍 nil 的 *websocket.Conn, +// 因此构建假连接的测试可以依赖安全的清理。 func (c *wsConn) shutdown() { c.once.Do(func() { close(c.done) @@ -132,8 +132,8 @@ func (c *wsConn) shutdown() { }) } -// enqueue queues a serialized frame, dropping it when a slow client's buffer is -// full so the producer never blocks. +// enqueue 把序列化后的帧入队,慢客户端缓冲满时丢弃它, +// 使生产者绝不阻塞。 func (c *wsConn) enqueue(b []byte) { select { case c.send <- b: @@ -141,7 +141,7 @@ func (c *wsConn) enqueue(b []byte) { } } -// enqueueJSON serializes ev and queues it. +// enqueueJSON 序列化 ev 并把它入队。 func (c *wsConn) enqueueJSON(ev wsEvent) { b, err := json.Marshal(ev) if err != nil { @@ -151,7 +151,7 @@ func (c *wsConn) enqueueJSON(ev wsEvent) { c.enqueue(b) } -// wsToken extracts the JWT from the ?token= query or the Authorization header. +// wsToken 从 ?token= 查询或 Authorization 头中提取 JWT。 func wsToken(r *http.Request) string { if t := r.URL.Query().Get("token"); t != "" { return t diff --git a/internal/services/gateway/ws_hub_origin.go b/internal/services/gateway/ws_hub_origin.go index dde9df9..b736fdd 100644 --- a/internal/services/gateway/ws_hub_origin.go +++ b/internal/services/gateway/ws_hub_origin.go @@ -5,14 +5,14 @@ import ( "strings" ) -// originChecker allows same-origin, same-host, and whitelisted WebSocket origins, -// mirroring the CORS allow list. A "*" allows all. +// originChecker 允许同源、同 host 以及白名单内的 WebSocket 来源, +// 镜像 CORS 允许列表。"*" 允许全部。 // -// Same-host relaxation: when cyrene serves its own embedded frontend (production -// mode), the page origin is exactly the gateway's Host (e.g. http://localhost:10942), -// which is not in the dev-phase ALLOWED_ORIGINS list. We must let that through so a -// user who changes PORT does not need to edit .env for the WS to connect. Only truly -// cross-origin clients (external IM / dashboards on another origin) hit the allow-list. +// 同 host 放宽:cyrene 提供自己的内嵌前端时(生产 +// 模式),页面来源恰好是网关的 Host(例如 http://localhost:10942), +// 它不在开发期的 ALLOWED_ORIGINS 列表中。我们必须放行它,使 +// 改过 PORT 的用户不必为了 WS 能连上而编辑 .env。只有真正的 +// 跨源客户端(外部 IM / 另一来源上的看板)才会走允许列表。 func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) bool { return func(r *http.Request) bool { origin := r.Header.Get("Origin") @@ -25,16 +25,16 @@ func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) b if allowed[origin] { return true } - // Same-host: the Origin matches this request's Host (scheme://host:port == host[:port]). + // 同 host:Origin 与该请求的 Host 匹配(scheme://host:port == host[:port])。 return sameOriginAsHost(origin, r) } } -// sameOriginAsHost reports whether origin (a full URL origin string such as -// "http://localhost:10942") corresponds to this request's Host header. It compares -// the host[:port] portion with loopback equivalence: "localhost" and "127.0.0.1" -// (and the IPv6 form) are treated as the same host, so serving the embedded frontend -// on 127.0.0.1 while a client connects via localhost (or vice versa) still passes. +// sameOriginAsHost 报告 origin(完整的 URL 来源串,例如 +// "http://localhost:10942")是否对应本请求的 Host 头。它比较 +// host[:port] 部分并做回环等价:"localhost" 与 "127.0.0.1" +// (以及 IPv6 形式)被视为同一 host,因此在 127.0.0.1 上提供内嵌 +// 前端而客户端经 localhost 连接(或反之)仍然通过。 func sameOriginAsHost(origin string, r *http.Request) bool { if origin == "" || r == nil { return false @@ -49,21 +49,21 @@ func sameOriginAsHost(origin string, r *http.Request) bool { return hostEquivalent(after, r.Host) } -// hostEquivalent compares two host[:port] strings, treating empty-port variants, -// case, and loopback aliases as equal. It handles "localhost" vs "127.0.0.1" and -// bracketed IPv6 loopback "[::1]". +// hostEquivalent 比较两个 host[:port] 串,把空端口变体、 +// 大小写与回环别名都视为相等。它处理 "localhost" 与 "127.0.0.1" +// 以及带括号的 IPv6 回环 "[::1]"。 func hostEquivalent(a, b string) bool { na := normalizeHost(a) nb := normalizeHost(b) if na == nb { return true } - // Loopback aliases: localhost, 127.0.0.1, [::1] + // 回环别名:localhost、127.0.0.1、[::1] loop := map[string]bool{"localhost": true, "127.0.0.1": true, "[::1]": true, "::1": true, "127.0.0.2": true} return loop[na] && loop[nb] } -// normalizeHost strips the port (if present), lowercases, and trims brackets. +// normalizeHost 剥掉端口(若有)、转小写并去掉括号。 func normalizeHost(h string) string { h = strings.ToLower(strings.TrimSpace(h)) if i := strings.LastIndex(h, ":"); i >= 0 && !strings.Contains(h[i:], "]") {