Files
Cyrene_v2/internal/services/gateway/admin_query.go
T
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
2026-10-05 10:37:56 +08:00

468 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// adminListPersonas 列出人格摘要(管理读)。它是管理侧结构:
// 角色级 mood 加上面板可编辑的 `thinking` 开关。
func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectPersonas(true))
}
// 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)
return
}
writeJSON(w, http.StatusOK, s.collectSessions(r.Context()))
}
// adminListMemories 列出记忆条目(管理读)。
func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.collectMemories(r.Context()))
}
// adminListPlugins 列出所有已注册能力(管理读)。
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
out := make([]PluginSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List("") {
out = append(out, PluginSummary{
Name: cap.Name,
Kind: string(cap.Kind),
Description: cap.Description,
AllowInGroup: cap.Policy.AllowInGroup,
AllowInStranger: cap.Policy.AllowInStranger,
})
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListChannels 列出已注册的渠道适配器(管理读)。当接线了
// 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏,
// 确保原始 token 绝不抵达客户端。
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
accountsByPlatform := s.maskedChannelAccounts()
botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
platform, acctID := splitAdapterName(ch.Name())
// channels.json 是「存在哪些账号」的唯一事实来源。已注册的
// 适配器只有在其账号仍处于配置状态时才浮现,因此已删除的
// 账号会从列表中消失。没有配置存储时(测试),每个已注册
// 适配器原样列出。
if s.channels != nil {
accts, ok := accountsByPlatform[platform]
if !ok || !platformHasAccountID(accts, acctID) {
continue
}
}
// 按平台聚合:裸适配器("kook")与多账号适配器
// ("kook:fc2048"、"kook:conf1")共用一个平台行,
// 因此账号列表不会按注册表中的每个适配器重复成独立行。
if s.hasChannelSummary(out, platform) {
continue
}
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()}
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
// 而不是送出一个编造的值。
if ra, ok := asRescanAware(cap.Value); ok {
if required, reason := ra.RescanRequired(); required {
sum.NeedsRescan = true
sum.LastError = reason
}
}
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
sum.Accounts = enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform])
}
out = append(out, sum)
}
}
// 有已配置账号但没有已注册适配器的平台(例如账号刚刚保存)
// 依然会浮现,以便其配置可见。
for platform, accts := range accountsByPlatform {
if len(accts) == 0 || s.hasChannelSummary(out, platform) {
continue
}
out = append(out, ChannelSummary{Name: platform, Accounts: accts})
}
writeJSON(w, http.StatusOK, out)
}
// maskedChannelAccounts 返回按平台生效的已配置账号,
// 已脱敏(不含原始 token)。渠道配置存储未接线时为空。
func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
if s.channels == nil {
return map[string][]AdminChannelAccount{}
}
out := make(map[string][]AdminChannelAccount, 8)
for platform, accts := range s.channels.Load() {
list := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
if !configuredAccount(a) {
continue
}
account := adminChannelAccountOf(runtimeconfig.Masked(a))
account.Capabilities = platformAdminOps(platform)
list = append(list, account)
}
if len(list) > 0 {
out[platform] = list
}
}
return out
}
// platformAdminOps 返回该平台适配器暴露的账号级管理操作,
// 使面板只渲染账号真正具备的能力。没有专用适配器
// 表面的平台返回 nil(省略)。
func platformAdminOps(platform string) []string {
if channelBasePlatform(platform) == "kook" {
return []string{"friends", "user_chats", "channels", "guilds", "message_test"}
}
return nil
}
// hasChannelSummary 报告 out 中是否已含该平台的摘要
// (以免有已注册适配器的平台被重复)。
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
for _, c := range out {
if channelBasePlatform(c.Name) == platform {
return true
}
}
return false
}
// platformHasAccountID 报告某平台已配置的账号中是否仍包含
// 适配器所绑定的账号。裸适配器(kook)绑定到该平台的
// 默认账号(空 id);多账号适配器(kook:conf0)绑定到
// 恰好那个账号 id。不匹配意味着适配器已陈旧 —— 它的
// 账号已从 channels.json 删除 —— 因此不得出现在列表中。
func platformHasAccountID(accts []AdminChannelAccount, id string) bool {
for _, a := range accts {
if a.ID == id {
return true
}
}
return false
}
// botInfo 是已连接机器人的实际解析身份(kook 在 Connect 时从
// /api/v3/user/me 获取)。它通过适配器的 Config() 浮现,
// 使管理列表能展示机器人是谁而无需持久化。
type botInfo struct {
id string
username string
nickname string
}
// empty 报告机器人身份是否没有任何部分解析成功。
func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" }
// 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 {
return out
}
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(interface{ Config() map[string]any })
if !ok {
continue
}
info := botIdentityOf(ch.Config())
if info.empty() {
continue
}
platform, acct := splitAdapterName(cap.Name)
if out[platform] == nil {
out[platform] = make(map[string]botInfo)
}
out[platform][acct] = info
}
return out
}
// botIdentityOf 从适配器配置 map 中读取可选的机器人身份字段。
// 这些键是非敏感的(bot_id / bot_username / bot_nickname),因此
// 绝不会被 runtimeconfig.Masked 脱敏。
func botIdentityOf(cfg map[string]any) botInfo {
if cfg == nil {
return botInfo{}
}
var b botInfo
b.id, _ = cfg["bot_id"].(string)
b.username, _ = cfg["bot_username"].(string)
b.nickname, _ = cfg["bot_nickname"].(string)
return b
}
// splitAdapterName 把形如 "kook:conf0" 的(可能多账号的)适配器名
// 拆成 ("kook", "conf0");裸名得到空账号 id。
func splitAdapterName(name string) (platform, acct string) {
if base, tail, ok := strings.Cut(name, ":"); ok {
return base, tail
}
return name, ""
}
// enrichAccountsWithBot 把实时机器人身份合并进平台中匹配的账号。
// 账号按 id 与其实时适配器配对,因此多账号平台会展示
// 各自机器人的身份。没有实时机器人的账号(尚未连接)
// 保持其已存字段不变。
func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) []AdminChannelAccount {
if len(by) == 0 {
return accts
}
out := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
if info, ok := by[a.ID]; ok && !info.empty() {
a.BotID = info.id
a.BotUsername = info.username
a.BotNickname = info.nickname
}
out = append(out, a)
}
return out
}
// adminListIoT 列出 IoT 设备(管理读)。
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
out := make([]iotDeviceSummary, 0)
if s.iot != nil {
devices, err := s.iot.List(r.Context())
if err != nil {
s.log.Error("list iot", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_failed", "failed to list devices"))
return
}
for _, d := range devices {
out = append(out, iotDeviceSummary{ID: d.ID, Name: d.Name, Type: d.Type, State: d.State, Online: d.Online})
}
}
writeJSON(w, http.StatusOK, out)
}
// adminGetLlm 返回从实时 provider 栈读取的 LLM provider/model
// 配置(models.json,回退到 .env)。API key 绝不返回 ——
// 线上结构中不含 key 字段 —— 因此该 handler
// 不泄露任何敏感信息。除分组本身外还下发**表单选项的取值域**
// (reasoningKinds / personaProviderOptions),使面板的下拉框与后端校验
// 永远同一份来源。
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
groups := s.currentLlmGroups()
providers := make([]AdminLlmProvider, 0, len(groups))
for i, g := range groups {
providers = append(providers, adminLlmProviderDTO(g, providerDisplayName(g), i == 0))
}
writeJSON(w, http.StatusOK, AdminLlmConfig{
Providers: providers,
ReasoningKinds: runtimeconfig.ReasoningKinds(),
PersonaProviderOptions: runtimeconfig.ProviderNames(),
})
}
// currentLlmGroups 返回要展示的 provider 分组:接线了实时栈时
// 用它的分组,否则用由环境变量合成的单一分组。
func (s *Server) currentLlmGroups() []runtimeconfig.Group {
if s.llm != nil {
return s.llm.Groups()
}
return runtimeconfig.GroupFromEnv(s.cfg.LLM)
}
// providerDisplayName 返回 provider 分组的友好标签:两种已知协议
// 用厂商名,其余用分组 id。
func providerDisplayName(g runtimeconfig.Group) string {
switch g.Provider {
case runtimeconfig.ProviderOpenAI:
return "OpenAI"
case runtimeconfig.ProviderAnthropic:
return "Anthropic"
default:
return g.ID
}
}
// adminListAutomations 列出自动化与提醒(管理读)。
func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) {
out := make([]AdminAutomationRecord, 0)
if s.automations != nil {
for _, a := range s.automations.List() {
rec := AdminAutomationRecord{
ID: a.ID,
Name: a.Name,
Kind: a.Kind,
Cron: a.Cron,
Enabled: a.Enabled,
}
if !a.LastRun.IsZero() {
rec.LastRun = a.LastRun.Format(time.RFC3339)
}
if !a.NextRun.IsZero() {
rec.NextRun = a.NextRun.Format(time.RFC3339)
}
out = append(out, rec)
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListUsers 列出账号与 API key(管理读)。未接线 UserStore 时
// 它只报告那一个已配置的管理账号,而不是编造
// 一列虚拟用户。
func (s *Server) adminListUsers(w http.ResponseWriter, r *http.Request) {
out := []AdminUserRecord{{
ID: "admin",
Username: s.cfg.Auth.AdminUser,
Role: string(auth.RoleOwner),
}}
if s.users != nil {
for _, u := range s.users.List() {
rec := AdminUserRecord{ID: u.ID, Username: u.Username, Role: string(u.Role)}
if u.CreatedAt != nil {
rec.CreatedAt = u.CreatedAt.Format(time.RFC3339)
}
out = append(out, rec)
}
}
writeJSON(w, http.StatusOK, out)
}
// adminListBackthink 列出最近的后台思考记录(管理读)。
func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
out := make([]AdminBackthinkItem, 0)
if s.backthink == nil {
writeJSON(w, http.StatusOK, out)
return
}
records, err := s.backthink.List(r.Context(), backthinkListLimit)
if err != nil {
s.log.Error("list backthink", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "backthink_failed", "failed to list backthink records"))
return
}
for _, rec := range records {
out = append(out, AdminBackthinkItem{
ID: strconv.FormatInt(rec.ID, 10),
Trigger: rec.Trigger,
Conclusion: rec.Conclusion,
ShouldNotify: rec.ShouldPush,
Pushed: rec.PushResult.Pushed,
Route: string(rec.PushResult.Route),
Detail: rec.PushResult.Detail,
Bypassed: rec.PushResult.Bypassed,
At: rec.At.UnixMilli(),
})
}
writeJSON(w, http.StatusOK, out)
}
// adminListRag 列出知识库文档,并在带 ?query= 时
// 一并返回匹配的命中。未接线 RagStore 时它退化为
// 空列表(向后兼容 —— 绝不返回 500)。
func (s *Server) adminListRag(w http.ResponseWriter, r *http.Request) {
out := AdminRagResponse{Documents: make([]AdminRagDoc, 0)}
if s.rag == nil {
writeJSON(w, http.StatusOK, out)
return
}
docs, err := s.rag.ListDocuments(r.Context())
if err != nil {
s.log.Error("list rag", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_failed", "failed to list knowledge documents"))
return
}
out.Documents = ragDocsFrom(docs)
if query := strings.TrimSpace(r.URL.Query().Get("query")); query != "" {
hits, herr := s.rag.Search(r.Context(), query)
if herr != nil {
s.log.Error("search rag", "err", herr)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
return
}
out.Hits = ragHitsFrom(hits)
}
writeJSON(w, http.StatusOK, out)
}
// adminSearchRag 执行一次知识库查询并返回匹配的命中。
func (s *Server) adminSearchRag(w http.ResponseWriter, r *http.Request) {
if s.rag == nil {
s.auditClaims(r, "rag.query", "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
return
}
var req ragQueryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Query) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
return
}
hits, err := s.rag.Search(r.Context(), req.Query)
if err != nil {
s.log.Error("search rag", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_search_failed", "failed to search knowledge base"))
return
}
s.auditClaims(r, "rag.query", "ok")
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "hits": ragHitsFrom(hits)})
}
// ragDocsFrom 把知识文档映射为其管理侧线上结构。
func ragDocsFrom(docs []knowledge.Doc) []AdminRagDoc {
out := make([]AdminRagDoc, 0, len(docs))
for _, d := range docs {
out = append(out, AdminRagDoc{ID: d.ID, Name: d.Name, Desc: d.Desc, Chunks: d.Chunks, Embedded: d.Embedded})
}
return out
}
// ragHitsFrom 把知识命中映射为其管理侧线上结构。
func ragHitsFrom(hits []knowledge.Hit) []AdminRagHit {
out := make([]AdminRagHit, 0, len(hits))
for _, h := range hits {
out = append(out, AdminRagHit{
DocID: h.DocID,
DocName: h.DocName,
Chapter: h.Chapter,
Text: h.Text,
Score: h.Score,
Semantic: h.Semantic,
})
}
return out
}