Files
Cyrene_v2/internal/services/gateway/routes.go
T
AskaEth 22e8398110 feat(contract,gateway): B4 枚举权威表 — GET /api/v1/meta/enums + 未知值回落约定
- 新增 internal/contract/enums 叶子包:platform/mood/mode 三表 + UnknownFallback="display-raw";每次调用返回新切片,无包级可变全局
- GET /api/v1/meta/enums(anyAuth 只读、纯投影、无 store/无错误路径)
- 反漂移 4 处:真构造内置适配器断言 Name() 与表双向相等、真工厂 buildAccountAdapter 逐项可建、mood 对齐 emotion 常量 + 默认转移表、mode 对齐前端 MODES(跨语言,文件缺失则 skip)
- Docs/api-client §1.1 枚举权威表 + 行15 + v0.38;api-reference §3.7 + 行12
2026-10-03 16:55:22 +08:00

207 lines
13 KiB
Go

package gateway
import (
"net/http"
"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.
// Handler returns the fully assembled http.Handler: the ServeMux routing
// REST + WS + admin, wrapped in recovery, CORS and request logging.
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 §九).
anyAuth := s.auth.Authorize()
ownerAuth := s.auth.Authorize(auth.RoleOwner)
writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
s.registerPublicRoutes(mux)
s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth)
s.registerHistoryRoutes(mux, writeAuth)
s.registerAdminRoutes(mux, ownerAuth, writeAuth)
s.mountWebhooks(mux)
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.
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 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.
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)))
// Shared client enum tables (Android collaboration B4, design/07 §6): the
// authority every client maps ids to labels from, so a source badge or a mood
// label cannot drift per client. A read-only projection of
// internal/contract/enums with nothing sensitive in it, so any authenticated
// role may read it (same gate as /sessions above).
mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums)))
mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe)))
mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech)))
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 / <img> cannot carry a
// bearer token. Both ride the one gateway port (single-binary red line).
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.
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.
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.
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: reads require owner or admin, the
// audited policy write requires owner, and every other write is reserved for the
// owner (RBAC red line). Each write handler persists to the real backing store or
// answers honestly with a clear "M10 / not-wired" error rather than fabricating a
// success.
func (s *Server) registerAdminRoutes(
mux *http.ServeMux,
ownerAuth func(http.Handler) http.Handler,
writeAuth func(http.Handler) http.Handler,
) {
// Admin reads: owner/admin.
mux.Handle("GET /api/v1/admin/personas", writeAuth(http.HandlerFunc(s.adminListPersonas)))
mux.Handle("GET /api/v1/admin/sessions", writeAuth(http.HandlerFunc(s.adminListSessions)))
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))
mux.Handle("GET /api/v1/admin/plugins", writeAuth(http.HandlerFunc(s.adminListPlugins)))
mux.Handle("GET /api/v1/admin/plugins/installed", writeAuth(http.HandlerFunc(s.adminListInstalledPlugins)))
mux.Handle("GET /api/v1/admin/channels", writeAuth(http.HandlerFunc(s.adminListChannels)))
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/iot", writeAuth(http.HandlerFunc(s.adminListIoT)))
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
mux.Handle("GET /api/v1/admin/automations", writeAuth(http.HandlerFunc(s.adminListAutomations)))
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/rag", writeAuth(http.HandlerFunc(s.adminListRag)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
mux.Handle("GET /api/v1/admin/observability/metrics", writeAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
mux.Handle("GET /api/v1/admin/observability/trace", writeAuth(http.HandlerFunc(s.adminObservabilityTrace)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
// Backend log tail: the logger's own bounded ring buffer, injected by the
// composition root (501 until then). Newest first, `?level=`/`?limit=` filtered.
mux.Handle("GET /api/v1/admin/observability/logs", writeAuth(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.
mux.Handle("GET /api/v1/admin/notifications", writeAuth(http.HandlerFunc(s.adminListNotifications)))
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
// Admin writes: owner-only, audited.
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)))
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 mounts externally-supplied inbound callbacks (e.g. the webhook
// channel) without gateway auth; each handler enforces its own
// signature/authorization.
func (s *Server) mountWebhooks(mux *http.ServeMux) {
for path, hdl := range s.webhooks {
if path == "" {
continue
}
mux.Handle(path, hdl)
}
}
// 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.
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
}