feat(admin): 数据源页转 RAG 运维台 + 支付/模型菜单重组 + 概览升级为仪表盘

后端:
- 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路
  (vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者
  自己的 space,跨租户排障就没法做了)
- KB 清单补 space_id(检索键是 <space_id>/<name>,缺它前端拼不出 key)

前端:
- 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断,
  召回不准时能直接定位是向量/分词/图谱哪一环挂了
- 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下;
  导航支持二级菜单(NavParent 命中子路由自动展开)
- SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一
- 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection),
  去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据)
- 全局隐藏滚动条(保留滚动)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 11:17:54 +08:00
parent ac4fd00e28
commit 79b110afd0
16 changed files with 577 additions and 237 deletions
+42
View File
@@ -553,6 +553,48 @@ func (h *Handler) KbSearch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"hits": hits})
}
// AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键**
// 检索任意租户的知识库(kb 形如 "<space_id>/<name>",由 /admin/datasources 清单给出)。
//
// 与用户侧 KbSearch 的关键差别:这里**不做 scopedKB 改写** —— 用户侧会把库名套上调用者
// 自己的 space_id,那样 admin 只能搜到自己空间的库,试验台就废了。跨租户是本端点的目的
// (运维排障:线上召回不准时,定位是 embedding、切块还是融合的问题)。
//
// mode: 空=生产混合(含 rerank)vector/fulltext/graph/hybrid=单路/纯融合(不 rerank)
// 用于逐路对比看是哪一路没召回。
func (h *Handler) AdminKbSearch(c *gin.Context) {
var body struct {
KB string `json:"kb"` // 完整作用域键 "<space_id>/<name>"
Q string `json:"q"`
TopK int `json:"topK"`
Mode string `json:"mode"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Q == "" || body.KB == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"})
return
}
args := map[string]any{"kb": body.KB, "q": body.Q}
if body.TopK > 0 {
args["topK"] = body.TopK
}
if body.Mode != "" {
args["mode"] = body.Mode
}
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_search"),
&contract.ToolCall{Tool: "kb_search", Args: args})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !res.OK {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
return
}
var hits []map[string]any
_ = json.Unmarshal([]byte(res.Content), &hits)
c.JSON(http.StatusOK, gin.H{"hits": hits})
}
// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graphNeo4j)。
func (h *Handler) KbGraph(c *gin.Context) {
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"),
@@ -166,6 +166,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比)
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
@@ -7,8 +7,11 @@ import "context"
// DatasourceKB 是一条知识库的清单行(含文档数与总字数、租户名)。
type DatasourceKB struct {
ID string `json:"id"`
Name string `json:"name"`
ID string `json:"id"`
Name string `json:"name"`
// SpaceID 是检索作用域键的前半段:向量/全文/图谱三库里的库名是 "<space_id>/<name>"
// (见 handler.scopedKB)。admin 检索试验台要按这个完整键定位库,故必须带出来。
SpaceID string `json:"space_id"`
Kind string `json:"kind"`
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
@@ -24,12 +27,12 @@ func (p *Postgres) AllDatasources(ctx context.Context) []DatasourceKB {
}
var out []DatasourceKB
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_kb k").
Select("k.id, k.name, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+
Select("k.id, k.name, k.space_id, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+
"count(d.id) as doc_count, coalesce(sum(d.size),0) as total_words").
Joins("left join sundynix_doc d on d.kb = k.name and d.space_id = k.space_id and d.deleted_at is null").
Joins("left join sundynix_tenant t on t.id = k.tenant_id").
Where("k.deleted_at is null").
Group("k.id, k.name, k.kind, k.tenant_id, t.name, k.owner").
Group("k.id, k.name, k.space_id, k.kind, k.tenant_id, t.name, k.owner").
Order("doc_count desc, k.created_at desc").
Scan(&out)
return out