主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」 此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答 (批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。 四条不可走样: - 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器 工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定, 配置无权翻转; - 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点 (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前; - 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复; - 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom), 被静默的消息进不了批。 实现落点: - lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记; - coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数; - coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车; - coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch; - runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键); - gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 + writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批); - 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。 M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条 按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。 回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build + 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
501 lines
32 KiB
Go
501 lines
32 KiB
Go
package gateway
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"io/fs"
|
||
"net/http"
|
||
"path"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件拥有 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)
|
||
|
||
// 文档存储(design/24 §3 / §6.1,切片 S1):与图片上传同一个数据目录根
|
||
// (<DataDir>/uploads/documents),但**写读两侧都要 owner/admin** —— 文档读侧
|
||
// 不需要被 <img>/provider 直接消费(摄取在服务端完成),因此不走图片那套
|
||
// 「随机名即凭据」的免鉴权读法,也不会放开内联服务 HTML/SVG 的口子。
|
||
mux.Handle("POST /api/v1/documents", writeAuth(http.HandlerFunc(s.handleDocumentUpload)))
|
||
mux.Handle("GET /api/v1/documents/{name}", writeAuth(http.HandlerFunc(s.handleDocumentFile)))
|
||
|
||
// 语音副本回放(design/24 §10.11;契约 v0.67 登记、本片落地):与文档读侧**同一
|
||
// 道门**(owner|admin,必须 Bearer)—— 语音是私密内容,刻意**不**走图片那套
|
||
// 「随机名即凭据」的免鉴权读法(客户端带 token 取字节 → `blob:` 播放)。音频副本
|
||
// 与文档共用 `<DataDir>/uploads/documents` 目录,但下发类型恒为 `audio/*` 且内联,
|
||
// 理由见 media.go。
|
||
mux.Handle("GET /api/v1/media/{name}", writeAuth(http.HandlerFunc(s.handleMediaFile)))
|
||
}
|
||
|
||
// 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)))
|
||
|
||
// 插件管理面板的**唯一契约面**(design/09 §5.2.1;切片 PLG-2):
|
||
// - GET /admin/panels 是**按调用者角色过滤后**的接入点快照,走 viewerAuth ——
|
||
// viewer 拿到的是已被裁掉写面的只读投影(§5.4.1),因此这条读门不越权;
|
||
// - 动作端点走 viewerAuth,是因为 table/log 的 **source 读来源**动作对 viewer
|
||
// 可见;**写**动作(form 的 submit、table 的 rowActions)由 handler 内再按
|
||
// owner|admin 收紧(callerHasWrite,见 admin_panels.go),与「写动作一律
|
||
// writeAuth」逐字对齐 —— 路由中间件只能有一道门,写面的门因此落在 handler 里。
|
||
mux.Handle("GET /api/v1/admin/panels", viewerAuth(http.HandlerFunc(s.adminListPanels)))
|
||
mux.Handle("POST /api/v1/admin/plugins/{id}/actions/{action}", viewerAuth(http.HandlerFunc(s.adminPluginAction)))
|
||
|
||
// 保留给 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)))
|
||
// 渠道事件查询面(design/26 §4.5;切片 E6,契约 **v0.67 已登记**):非消息事件的
|
||
// 只读投影 + 逐类计数(近似值)。与同族的 `/admin/channels/messages` 同门
|
||
// (owner|admin):事件行带群 id / 成员 id / 关联消息 id,属私有内容面,
|
||
// 不是给 viewer 的监控投影(契约 §7.4-12 登记「落地前不要假设 viewer 能读」)。
|
||
mux.Handle("GET /api/v1/admin/channels/events", writeAuth(http.HandlerFunc(s.adminChannelEventsList)))
|
||
mux.Handle("GET /api/v1/admin/channels/events/counters", writeAuth(http.HandlerFunc(s.adminChannelEventsCounters)))
|
||
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
|
||
// LLM 用量汇总(切片 U1,**v0.64**):只读、不审计,与同页的 /admin/llm
|
||
// 同门(owner|admin)—— 它含成本与模型/用途分解,不是给 viewer 的监控投影。
|
||
mux.Handle("GET /api/v1/admin/llm/usage", writeAuth(http.HandlerFunc(s.adminLlmUsage)))
|
||
// 模型目录候选(裁决 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)))
|
||
// 自排闸只读快照(切片 C2,**v0.82**;design/12 §N C.5 的读侧):把
|
||
// `$DATA_DIR/config/backthink.json` 的**进程生效值**(上限/窗口 + 代码默认值)
|
||
// 与「本窗口已用 N / 上限 M」(selfWakeGate 的只读镜像)下发给面板。
|
||
// 只读、不审计、不写库;与上面的列表端点同门同档(writeAuth = owner|admin)
|
||
// —— 它是她自排的成本口径与运行状态,不是给 viewer 的监控投影。
|
||
// ⚠️ 它**不是**写口:调这两个旋钮要改配置文件 + 重启(启动期读一次)。
|
||
mux.Handle("GET /api/v1/admin/backthink/self-wake", writeAuth(http.HandlerFunc(s.adminSelfWakeStatus)))
|
||
// 最近一轮链路图快照(**v0.71**):`graph_update` 只走实时帧、从不重放,
|
||
// 前端图桶也不持久化 ⇒ 「轮次结束后才打开的页面」此前只能看到空态。
|
||
// 只读、不审计;与同一张图的 WS 下行帧同档(只发 owner|admin,
|
||
// design/19 §3「flow 可视化仅 owner/admin」),因此不存在绕过帧门的旁路。
|
||
mux.Handle("GET /api/v1/admin/flow/graph", writeAuth(http.HandlerFunc(s.adminFlowGraph)))
|
||
// 多轮时间轴(切片 W1,**v0.80**;design/19 §7):把**所有轮次按时间正序串起来**
|
||
// (每轮含该轮每一路),`before` 游标向更早翻页,响应另带保留期口径供面板如实标注
|
||
// 「更早的轮次已过保留期」。与上面的最近一轮快照**同门同档**(writeAuth = owner|admin)、
|
||
// 同样只读不审计;老出口逐字不变,老客户端零影响。
|
||
mux.Handle("GET /api/v1/admin/flow/timeline", writeAuth(http.HandlerFunc(s.adminFlowTimeline)))
|
||
// 配置事实面(切片 P3,**v0.68**):记忆整理 7 键 + 安静时段两键的**进程生效值**
|
||
// 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页
|
||
// (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。
|
||
mux.Handle("GET /api/v1/admin/config-facts", writeAuth(http.HandlerFunc(s.adminConfigFacts)))
|
||
// 记忆整理「试算」(切片 MT1,**v0.75**):POST + writeAuth(owner|admin)。
|
||
// 它是**只读语义**——跑同一套整理规则把候选直接回给面板、一行都不写;因此
|
||
// 它不在 registerAdminWriteRoutes(那条路是 owner-only 的真实写入 + 审计)。
|
||
// 试算属于「算给我看」,不是「改给我看」。
|
||
//
|
||
// 切片 MT-A(**v0.76**)在同一端点上加了**加性**异步:`?async=1` ⇒ 202 + runId,
|
||
// 再用下面那条 GET 轮询;不带该参数时逐字走原同步路径。
|
||
mux.Handle("POST /api/v1/admin/memories/tidy/dry-run", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRun)))
|
||
// 异步试算的结果查询(切片 MT-A,**v0.76**):与 B14 **同门同档**(writeAuth =
|
||
// owner/admin)—— 它是同一次计算的另一种取法,绝不比同步入口更宽;同样只读
|
||
// 语义、同样**不审计**。结果只活在进程内存(TTL 30 分钟),进程重启后旧 runId
|
||
// 如实 404。
|
||
mux.Handle("GET /api/v1/admin/memories/tidy/dry-run/{runId}", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRunStatus)))
|
||
// 入库前审查(D3)的管理面(design/23 §15 D3-8「面板可查」+ §8「回滚」;切片 L45):
|
||
// - GET .../memories/reviews —— 审查留痕列表(谁被审 / 四态 / 依据 / 合并去向 /
|
||
// 是否降级 / 时间),**不含记忆正文**。只读、不审计,走 writeAuth(owner|admin):
|
||
// 留痕带 itemId/personaId/projectId/sessionId,属私有记忆面,与同族的
|
||
// GET /admin/memories 同档,**不给 viewer**(design/23 §11 的既有取向 + design/11 §7
|
||
// 对 viewer 的定义是「只读监控投影」)。
|
||
// - POST .../memories/{id}/revert —— 撤销一次软合并(无损可逆,行永不物理删除)。
|
||
// 它是**写**,因此落审计(动作名 memory.review.revert,见 admin_memory_review.go)。
|
||
mux.Handle("GET /api/v1/admin/memories/reviews", writeAuth(http.HandlerFunc(s.adminMemoryReviews)))
|
||
mux.Handle("POST /api/v1/admin/memories/{id}/revert", writeAuth(http.HandlerFunc(s.adminMemoryRevert)))
|
||
// 记忆整理的**应用路径 + 手动真跑**(切片 TIDY-1,design/23 §7/§8/§11;见
|
||
// admin_memory_tidy_apply.go)。六条端点补齐「整理真的能用」:此前只有
|
||
// `POST …/tidy/dry-run`(只读试算),候选落库后**没有任何执行面**,于是 77 条
|
||
// 全 pending、两天一条没应用。门与同族的 `GET /admin/memories` /
|
||
// `POST …/memories/{id}/revert` 同档(writeAuth = owner|admin):候选正文含隐私。
|
||
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert` / `memory.tidy.reject`;
|
||
// `…/tidy/run` 的审计由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
|
||
mux.Handle("GET /api/v1/admin/memories/candidates", writeAuth(http.HandlerFunc(s.adminTidyCandidatesList)))
|
||
mux.Handle("POST /api/v1/admin/memories/candidates/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidatesApplyBatch)))
|
||
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidateApply)))
|
||
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/revert", writeAuth(http.HandlerFunc(s.adminTidyCandidateRevert)))
|
||
// 批量拒绝(按动作,2026-10-07):给**没有执行语义**的候选一条干净出路
|
||
// (活库积压 501 条 link,永远应用不了,只占待办)。它只改候选状态、不碰记忆,
|
||
// 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。
|
||
mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject)))
|
||
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
|
||
// 记忆整理「面板调优」的读写(切片 MEMPANEL / M4;契约由塔登记):
|
||
// 读 .../memories/tidy/tuning 回显 `$DATA_DIR/config/memory-tidy.json` 里本卡片
|
||
// 管的 7 个旋钮 + 它们的取值规则(specs);写同一条路径是**三态补丁**
|
||
// (键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
|
||
// + 审计(memory.tidy.tuning)。两条都走 writeAuth(owner|admin)—— 与同族的
|
||
// GET /admin/memories、POST /admin/memories/{id}/revert 同档:它是私有记忆面的
|
||
// 运行期旋钮,不是给 viewer 的监控投影;也**不是** owner-only(写的是面板配置
|
||
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
|
||
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
|
||
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
|
||
// 渠道静默窗口合并(M23;见 admin_coalesce.go):读回 `$DATA_DIR/config/coalesce.json`
|
||
// 的窗口参数 + 取值规则 + 「主人私聊不受影响」这条只读事实;写是同一条路径上的
|
||
// **三态补丁**(键缺位 = 不动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
|
||
// + 审计(channels.coalesce)。两条都走 writeAuth(owner|admin)—— 与上面那条
|
||
// memory-tidy 调优同档:写的是面板配置文件,不是身份/凭据面。
|
||
mux.Handle("GET /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceRead)))
|
||
mux.Handle("PUT /api/v1/admin/channels/coalesce", writeAuth(http.HandlerFunc(s.adminCoalesceWrite)))
|
||
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)))
|
||
// 定时提醒(P0-22):cron/event 之外的第三种自动化,到点把
|
||
// reminder.trigger 发布到进程内 bus。它是真实写入(进 ReminderStore
|
||
// 的轮询集合),因此与其他自动化写同门同审计。
|
||
mux.Handle("POST /api/v1/admin/automations/reminders", ownerAuth(http.HandlerFunc(s.adminCreateReminder)))
|
||
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)))
|
||
// 「重置为 env 默认」(切片 R1,v0.62):把面板里 `.env` 声明过的键覆盖回 env 值,
|
||
// 组集合不增减。**仅 owner**——它是破坏性写(覆盖面板对这些键的修改),门与同页的
|
||
// `POST /admin/llm`、`/admin/llm/test` 一致,绝不比同族写端点更宽。写操作,带审计
|
||
// 动作 `llm.resetFromEnv`;破坏性另由请求体的 `confirm:true` 兜住。
|
||
mux.Handle("POST /api/v1/admin/llm/reset-from-env", ownerAuth(http.HandlerFunc(s.adminResetLlmFromEnv)))
|
||
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)))
|
||
// 存储状态**只读**查询(面板首页「存储」卡的数据源,v0.60):只读、不审计,
|
||
// 因此与四个动作端点同门(owner)但不带审计动作。
|
||
mux.Handle("GET /api/v1/admin/system/storage", ownerAuth(http.HandlerFunc(s.adminStorageStatus)))
|
||
}
|
||
|
||
// 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)
|
||
// 文档路径的同类防护(design/24 §3.2「路径穿越」):`..` 形态必须在 ServeMux
|
||
// 清理路径并重定向之前被拦下,否则穿越请求会变成一个非文档路径的重定向。
|
||
h = guardDocumentPaths(h)
|
||
// 语音副本的同类防护(design/24 §10.11):三条读侧路(files/documents/media)
|
||
// 各自的名字形态与错误码,因此各有一道门。
|
||
h = guardMediaPaths(h)
|
||
if s.frontend != nil {
|
||
h = s.spa(h)
|
||
// 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的
|
||
// ADMIN_PATH 挂载(默认 /admin),猜错的前缀 404 而不回落用户端聊天壳。
|
||
h = s.adminEntryHatch(h, s.cfg.Server.AdminPathPrefix())
|
||
}
|
||
h = s.recover(h)
|
||
h = s.cors(h)
|
||
h = s.logging(h)
|
||
return h
|
||
}
|
||
|
||
// --- 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2) ---------------------------
|
||
|
||
// adminEntryGlobal 是注入面板入口页的全局变量名:SPA 据此判定「本页由安全
|
||
// 入口送达」并取得面板前缀。它是前缀抵达前端的唯一通道,因此该串不硬编进
|
||
// 用户端 bundle(见 frontend/web/src/admin/router.ts)。
|
||
const adminEntryGlobal = "__CYRENE_ADMIN_ENTRY__"
|
||
|
||
// adminEntryHatch 把管理面板挂到配置的 ADMIN_PATH 前缀下:
|
||
// - 前缀自身与其下的深链(/<前缀>/<section>)送出注入了入口信息的
|
||
// index.html,使真实路径直达面板而不回落用户端聊天壳;
|
||
// - 形如面板入口但前缀不对的探测(例如前缀被改后仍被访问的 /admin)
|
||
// 直接 404,既不回落聊天壳也不泄露正确前缀;
|
||
// - 其余路径原样交给常规 SPA / API 处理。
|
||
func (s *Server) adminEntryHatch(next http.Handler, prefix string) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
p := r.URL.Path
|
||
if isAPIRoute(p) || s.isWebhookRoute(p) {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
switch {
|
||
case p == prefix || strings.HasPrefix(p, prefix+"/"):
|
||
if s.adminEntryRealFile(p) || !s.serveAdminEntry(w, prefix) {
|
||
next.ServeHTTP(w, r)
|
||
}
|
||
case isAdminShapedPath(p):
|
||
http.NotFound(w, r)
|
||
default:
|
||
next.ServeHTTP(w, r)
|
||
}
|
||
})
|
||
}
|
||
|
||
// adminEntryRealFile 报告 path 是否命中 dist 里的真实静态文件:前缀若恰好
|
||
// 撞上资源目录/文件名(例如把 ADMIN_PATH 配成 assets),文件服务器照常送
|
||
// 文件,绝不让面板入口页顶掉 SPA 的资源。
|
||
func (s *Server) adminEntryRealFile(p string) bool {
|
||
rel := strings.TrimPrefix(path.Clean(p), "/")
|
||
if rel == "" {
|
||
return false
|
||
}
|
||
info, err := fs.Stat(s.frontend, rel)
|
||
return err == nil && !info.IsDir()
|
||
}
|
||
|
||
// serveAdminEntry 送出管理面板入口页。返回 false 表示 index.html 读不出来
|
||
// (例如外置 dist 尚未构建),交由常规 SPA 处理。
|
||
func (s *Server) serveAdminEntry(w http.ResponseWriter, prefix string) bool {
|
||
raw, err := fs.ReadFile(s.frontend, "index.html")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
// 入口页按请求注入(前缀 / 站点根锚点),不能缓存。
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
_, _ = w.Write(adminEntryHTML(raw, prefix))
|
||
return true
|
||
}
|
||
|
||
// adminEntryHTML 在 `<head>` 之后注入入口信息与 `<base href="/">`:
|
||
// - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动
|
||
// 时即可读到面板前缀;
|
||
// - `<base href="/">` 把相对资源锚回站点根:面板入口可能是多段路径
|
||
// (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...`
|
||
// 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。
|
||
//
|
||
// 找不到 `<head>` 时注入到文档最前,同样先于任何资源引用。
|
||
func adminEntryHTML(indexHTML []byte, prefix string) []byte {
|
||
payload, err := json.Marshal(map[string]string{"prefix": prefix})
|
||
if err != nil {
|
||
// 字符串映射不会让 json.Marshal 失败;真出错时只保住 <base>。
|
||
payload = []byte(`{}`)
|
||
}
|
||
snippet := []byte(`<base href="/"><script>window.` + adminEntryGlobal + "=" + string(payload) + `;</script>`)
|
||
out := make([]byte, 0, len(indexHTML)+len(snippet))
|
||
if i := bytes.Index(indexHTML, []byte("<head>")); i >= 0 {
|
||
at := i + len("<head>")
|
||
out = append(out, indexHTML[:at]...)
|
||
out = append(out, snippet...)
|
||
return append(out, indexHTML[at:]...)
|
||
}
|
||
out = append(out, snippet...)
|
||
return append(out, indexHTML...)
|
||
}
|
||
|
||
// isAdminShapedPath 报告 path 是否形如一次管理面板入口尝试(路径里出现
|
||
// `admin` 段,如 /admin、/admin/x、/foo/admin):前缀被自定义后,这类猜测
|
||
// 一律 404,避免回落用户端聊天壳把探测结果变成「面板不存在」以外的信息。
|
||
func isAdminShapedPath(p string) bool {
|
||
for _, seg := range strings.Split(p, "/") {
|
||
if seg == config.DefaultAdminPath {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|