- 新增 GET /api/v1/admin/llm/catalog(owner|admin,?refresh=1 强制刷新):抓 models.dev api.json 并裁剪 (226 供应商 / 8392 模型 → 1.31 MB),写落盘快照 data/llm_catalog.json(无凭据,TTL 24h,gitignore) - 降级诚实:快照优先 → 在线刷新 → stale 快照兜底(标 stale+warning)→ 都不行回 503 catalog_unavailable, 绝不编造;抓取只发生在该端点内,**不进运行时热路径** - 前端:新增 llm/LlmCatalogPanel(在线目录 + 粘贴导入落同一份候选 → 一键带出 baseURL/model id/multimodal/ maxTokens);Llm.tsx 挂载面板、删掉硬编码的「主→备→本地」假状态、表单按 id#seq 重挂 - 诚实性:面板把「加载中 / 端点未实现 / 抓取失败或离线 / 响应结构不认识 / 目录为空」分成五种独立状态, 全程无 mock/sample 回退 - 复核:新增 9 条零外网用例全绿;**变异**(Stale:true→false / TS 只认 camelCase)各自红后已还原; Go 真产出与 TS 解析器双向实测 8392 模型能力位零差异 - 越界说明:routes.go 仅 +3 行(注册该路由);llm/LlmCatalogPanel.tsx 为拆出以守住 Llm.tsx ≤500 行
267 lines
16 KiB
Go
267 lines
16 KiB
Go
package gateway
|
||
|
||
import (
|
||
"net/http"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
)
|
||
|
||
// 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时
|
||
// WebSocket hub、/api/v1/admin/* 与外部挂载的 webhook 回调)以及
|
||
// 外层中间件链。路由注册按受众切分,使守护每组
|
||
// 的 RBAC 门保持可见:公开、已认证用户、
|
||
// 私有历史、管理读与仅 owner 写。
|
||
|
||
// Handler 返回完整组装的 http.Handler:路由 REST + WS + 管理的
|
||
// ServeMux,外面包上 recover、CORS 与请求日志。
|
||
func (s *Server) Handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
|
||
// RBAC 中间件按角色集各构建一次,并被每条需要它的路由
|
||
// 共用(owner>admin>viewer,DevDocs/02 §九)。viewerAuth 是「每个
|
||
// 已认证角色」:它指 viewer 角色存在的那个只读监控
|
||
// 表面(模块清单、运行时指标/trace、日志尾部
|
||
// 与通知 feed)—— 见 registerAdminRoutes。
|
||
anyAuth := s.authorize()
|
||
ownerAuth := s.authorize(auth.RoleOwner)
|
||
writeAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin)
|
||
viewerAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
|
||
|
||
s.registerPublicRoutes(mux)
|
||
s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth)
|
||
s.registerHistoryRoutes(mux, writeAuth)
|
||
s.registerAdminRoutes(mux, ownerAuth, writeAuth, viewerAuth)
|
||
s.mountWebhooks(mux)
|
||
|
||
return s.withMiddleware(mux)
|
||
}
|
||
|
||
// 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)
|
||
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
|
||
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
||
}
|
||
|
||
// 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,
|
||
ownerAuth func(http.Handler) http.Handler,
|
||
writeAuth func(http.Handler) http.Handler,
|
||
) {
|
||
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)))
|
||
// 共享的客户端枚举表(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)))
|
||
|
||
// 图片上传(B1):一个 multipart POST,把光栅图片存到
|
||
// 解析后的数据目录下,返回可直接用作
|
||
// chat.image 的同源 URL。上传是 owner/admin + 带审计;静态路由是
|
||
// 能力 URL(名字不可猜)因为 chat.image / <img> 无法携带
|
||
// bearer token。二者都走同一个网关端口(单二进制红线)。
|
||
mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload)))
|
||
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
|
||
}
|
||
|
||
// 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)))
|
||
// 读游标(D4 / Android 协作 B3):推进它会为该账号
|
||
// 每一端清除该会话的未读角标,因此它和其他会话变更一样
|
||
// 是 owner/admin 历史写。它会广播一个 `session_read` 帧。
|
||
mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead)))
|
||
// 单条消息编辑/删除(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)))
|
||
|
||
// 实时。
|
||
mux.HandleFunc("/ws/chat", s.handleWS)
|
||
}
|
||
|
||
// registerAdminRoutes 挂载管理 API。三个读层加两个写
|
||
// 层,镜像角色层级(RBAC 红线):
|
||
//
|
||
// - viewerAuth(owner/admin/viewer):只读监控表面 —— 模块
|
||
// 清单、运行时指标/trace、后端日志尾部与
|
||
// 通知 feed。这里每个载荷都是非敏感投影,不含
|
||
// 凭据、不含其他账号的数据、不含消息体,这正是
|
||
// 让 viewer 角色成为可用的看板/日志/监控账号的原因。
|
||
// - writeAuth 读(owner/admin):viewer 不得看到的一切 —— 记忆、
|
||
// LLM provider 栈、账号/API key、后台思考监控、审计
|
||
// 链、渠道消息日志/转录、完整会话导出。
|
||
// - writeAuth 写(owner/admin):带审计的知识搜索。
|
||
// - ownerAuth(owner):带审计的策略写、每个获取凭据的
|
||
// 渠道端点(login-qr/login-status)以及每一个其他变更。
|
||
//
|
||
// 管理写路由表见 registerAdminWriteRoutes。
|
||
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,
|
||
) {
|
||
// 对 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)))
|
||
mux.Handle("GET /api/v1/admin/plugins/installed", viewerAuth(http.HandlerFunc(s.adminListInstalledPlugins)))
|
||
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)))
|
||
// 知识库文档列表:名字/描述加上切分/嵌入
|
||
// 状态。`?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)))
|
||
// 后端日志尾部:logger 自带有界环形缓冲,由组合根注入
|
||
// (在那之前 501)。最新优先,按 `?level=`/`?limit=` 过滤。
|
||
mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs)))
|
||
// 统一通知 feed(design/10 §3 #16):审计链 + 后台思考推送的
|
||
// 只读投影,因此面板的通知页不需要
|
||
// 任何新的持久化模型。
|
||
mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications)))
|
||
|
||
// 保留给 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)))
|
||
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
|
||
// 模型目录候选(裁决 03.4.2):面板「添加提供商 / 添加模型」的
|
||
// baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底。
|
||
mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog)))
|
||
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
|
||
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
|
||
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
|
||
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)))
|
||
|
||
s.registerAdminWriteRoutes(mux, ownerAuth)
|
||
}
|
||
|
||
// registerAdminWriteRoutes 挂载管理写路由:仅 owner,带审计。每个 handler 都会
|
||
// 持久化到真实后端存储,或用明确的 "M10 / not-wired" 错误诚实作答,而不是编造成功。
|
||
func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler) {
|
||
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)))
|
||
// 主人档案(裁决 10.4.5):白名单 + 显示名/关系备注的真实读写。
|
||
// **仅 owner** —— 这份档案决定谁能进 owner 车道(design/11 §0 的身份红线),
|
||
// 因此既不给 viewer 读、也不给 admin 写。
|
||
mux.Handle("GET /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminGetOwnerProfile)))
|
||
mux.Handle("PUT /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminPutOwnerProfile)))
|
||
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
|
||
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
|
||
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
|
||
mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl)))
|
||
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
|
||
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
|
||
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
|
||
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
|
||
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
|
||
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
|
||
mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm)))
|
||
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
|
||
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
||
mux.Handle("GET /api/v1/admin/channels/{platform}/login-qr", ownerAuth(http.HandlerFunc(s.adminWeixinLoginQR)))
|
||
mux.Handle("GET /api/v1/admin/channels/{platform}/login-status", ownerAuth(http.HandlerFunc(s.adminWeixinLoginStatus)))
|
||
mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView)))
|
||
mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends)))
|
||
mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle)))
|
||
mux.Handle("POST /api/v1/admin/channels/{platform}/message/test", ownerAuth(http.HandlerFunc(s.adminChannelMessageTest)))
|
||
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
|
||
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
|
||
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
|
||
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
|
||
mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory)))
|
||
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
|
||
mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag)))
|
||
mux.Handle("POST /api/v1/admin/rag/delete", ownerAuth(http.HandlerFunc(s.adminDeleteRag)))
|
||
mux.Handle("POST /api/v1/admin/system/revoke-keys", ownerAuth(http.HandlerFunc(s.adminRevokeAllKeys)))
|
||
mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT)))
|
||
mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup)))
|
||
mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore)))
|
||
}
|
||
|
||
// mountWebhooks 挂载外部提供的入站回调(例如 webhook
|
||
// 渠道)且不经网关认证;每个 handler 强制自己的
|
||
// 签名/授权。
|
||
func (s *Server) mountWebhooks(mux *http.ServeMux) {
|
||
for path, hdl := range s.webhooks {
|
||
if path == "" {
|
||
continue
|
||
}
|
||
mux.Handle(path, hdl)
|
||
}
|
||
}
|
||
|
||
// authorize 返回要求给定角色的凭据门。它由 Bearer JWT 门加上 `X-API-Key`
|
||
// 分支组成(裁决 15.4.2):key 表已注入时两种凭据都收,否则只认 Bearer。
|
||
// 按裁决 15.4.3,任何有效 key 一律 owner 级(owner 满足一切角色要求),
|
||
// 未知/已吊销 key 一律 401 且不回落 Bearer(fail-closed,见 auth.WithAPIKey)。
|
||
func (s *Server) authorize(required ...auth.Role) func(http.Handler) http.Handler {
|
||
bearer := s.auth.Authorize(required...)
|
||
keys := s.apiKeyTable()
|
||
if keys == nil {
|
||
return bearer
|
||
}
|
||
return auth.WithAPIKey(keys, bearer, s.log, required...)
|
||
}
|
||
|
||
// apiKeyTable 返回注入的 API key 表;未注入(或注入的实现不是 key 表)时为
|
||
// nil,此时鉴权只认 Bearer —— 「表不在」等同空表,绝不因此放行任何 key。
|
||
func (s *Server) apiKeyTable() auth.KeyTable {
|
||
if s.users == nil {
|
||
return nil
|
||
}
|
||
table, ok := s.users.(auth.KeyTable)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return table
|
||
}
|
||
|
||
// withMiddleware 把已路由的 handler 包进外层,最外层在前:
|
||
// 上传能力 URL 防护、SPA 回退(当配置了前端时)、
|
||
// panic recover、CORS 与请求日志。
|
||
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
|
||
var h http.Handler = mux
|
||
h = guardUploadPaths(h)
|
||
if s.frontend != nil {
|
||
h = s.spa(h)
|
||
}
|
||
h = s.recover(h)
|
||
h = s.cors(h)
|
||
h = s.logging(h)
|
||
return h
|
||
}
|