Files
sundynix-agentix/sundynix-gateway/internal/handler/admin.go
T
Blizzard 466959d3c3 fix: 静默吞错横扫 —— 数据落库/配置广播/审计失败不再无声
把检索链路那次的做法推到同类位置。判据是"丢了这个 error 的代价",只改代价
高的,不动刻意的 fire-and-forget。

改(丢了就是数据永久丢失或行为不可解释):
  - task_handler: 任务**输出**与**执行轨迹**的收尾落库。这是唯一的持久副本
    (Redis 流 10min TTL),失败则永远无法复盘,界面上只显示"这次运行没有
    轨迹"。轨迹的 json.Marshal 失败分支同样是静默跳过,一并补上。
  - admin.broadcastActive: 模型配置热更新广播。失败 = dispatcher/mcp-go 仍用
    旧配置,症状是"控制台改了模型却不生效",而操作者这边一切正常——正是今天
    排查半天的那类问题。
  - middleware/audit: 审计留痕写入。不阻断主流程是对的(业务已完成),但静默
    失败意味着敏感操作没有记录,且没人知道记录缺了,这是合规缺口。

不改(确认过是合理的):
  - dispatcher 的 PublishToken/PublishExec:往前端推流,丢一帧是 UI 瑕疵,
    且按 token 打日志会刷屏;
  - 计量回写 PublishUsage:本来就检查 error 并打日志,钱的路径是干净的。

另修一处误导文案:任务下钻的轨迹空态原本把原因说死为"早于该功能上线",
现在"落库失败"也是已知原因,文案改为并列并指向日志里的 [task] 告警。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:49:13 +08:00

651 lines
23 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 handler
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-shared/prompts"
"github.com/sundynix/sundynix-shared/secrets"
)
// AuditList: GET /api/v1/admin/audit?limit=&offset= —— 敏感操作审计流(倒序,供运维溯源)。
func (h *Handler) AuditList(c *gin.Context) {
limit, offset := 50, 0
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
// 筛选下沉到 SQL:此前是前端在当前页 50 条里过滤,翻页外的记录搜不到,
// 对审计来说等于给出错误结论。
f := store.AuditFilter{
Action: c.Query("action"),
Path: c.Query("path"),
Q: strings.TrimSpace(c.Query("q")),
}
rows, err := h.db.ListAudit(c.Request.Context(), limit, offset, f)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(rows))
for _, a := range rows {
out = append(out, gin.H{
"id": a.ID, "actor": a.Actor, "action": a.Action, "route": a.Route,
"path": a.Path, "status": a.Status, "ip": a.IP, "detail": a.Detail, "at": a.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"logs": out})
}
// GuardrailEvents: GET /api/v1/admin/guardrail-events?limit=&offset= —— 护栏命中安全事件流(倒序)。
func (h *Handler) GuardrailEvents(c *gin.Context) {
limit, offset := 50, 0
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := c.Query("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
rows, err := h.db.ListGuardrailEvents(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(rows))
for _, e := range rows {
out = append(out, gin.H{
"id": e.ID, "actor": e.Actor, "kind": e.Kind, "reason": e.Reason,
"signals": e.Signals, "method": e.Method, "path": e.Path, "ip": e.IP, "at": e.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"events": out})
}
// AdminOverview: GET /api/v1/admin/overview —— 管理端系统级聚合(控制塔口径)。
// 区别于 stats/overview(桌面端个人工作台):这里一律系统级——全平台用户/任务/评测/
// 模型配置态/提示词控制面态/服务健康。Task/Eval 表无 owner 即全量;用户/KB/Doc 走全局计数。
func (h *Handler) AdminOverview(c *gin.Context) {
// 系统级口径:显式跨租户(否则受租户表 KB/Doc/Task/Eval 会被插件按 admin 自己的租户过滤)。
ctx := store.WithoutTenant(c.Request.Context())
ov := h.db.StatsOverview(ctx, "") // owner="" → 跳过个人 KB 口径,仅取全局任务/评测
users, kbs, docs := h.db.SystemCounts(ctx)
// 模型配置态:主模型 + 备用链数 + 各 kind 数量。
chat, _ := h.db.ListModels(ctx, "chat")
emb, _ := h.db.ListModels(ctx, "embedding")
activeChat, activeEmb, fallbacks := "", "", 0
for _, m := range chat {
if m.Active {
activeChat = m.Provider + "/" + m.Model
} else {
fallbacks++
}
}
for _, m := range emb {
if m.Active {
activeEmb = m.Provider + "/" + m.Model
}
}
// 提示词控制面态:受管 key 总数(代码内置)vs 已激活热覆盖数。
promptRows, _ := h.db.ListPrompts(ctx)
overrides := map[string]struct{}{}
for _, pr := range promptRows {
if pr.Active {
overrides[pr.Key] = struct{}{}
}
}
// 服务健康(与 Health / StatsOverview 同口径:本地可判 + milvus/neo4j 经 mcp-go)。
services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false}
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if res, err := h.bus.CallTool(cctx, contract.ToolSubjectGo("health"), &contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK {
var sub map[string]bool
if json.Unmarshal([]byte(res.Content), &sub) == nil {
services["milvus"], services["neo4j"] = sub["milvus"], sub["neo4j"]
}
}
// 模型运行时健康态:Ping dispatcher 心跳取每模型 failover/熔断态(在线/熔断中/半开)。
// 权威配置来自 DB(上面 chat/emb),运行时态只有 dispatcher 知道 → 二者互补。
modelHealth := []gin.H{}
dctx, dcancel := context.WithTimeout(ctx, 2*time.Second) // 独立超时,不与 mcp-go 探活共用 cctx(避免被挤掉)
defer dcancel()
if data, err := h.bus.Ping(dctx, contract.SubjectHealthDispatcher); err == nil && len(data) > 0 {
var dh struct {
Models []struct {
Provider string `json:"provider"`
Model string `json:"model"`
Role string `json:"role"`
State string `json:"state"`
Fails int `json:"fails"`
} `json:"models"`
}
if json.Unmarshal(data, &dh) == nil {
for _, m := range dh.Models {
modelHealth = append(modelHealth, gin.H{
"provider": m.Provider, "model": m.Model, "role": m.Role, "state": m.State, "fails": m.Fails,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"users": users, "kb_count": kbs, "kb_docs": docs,
"tasks_today": ov.TasksToday, "tasks_total": ov.TasksTotal,
"status_count": ov.StatusCount, "task_trend": ov.TaskTrend,
"eval_avg": ov.EvalAvg, "faithful_avg": ov.FaithfulAvg, "eval_count": ov.EvalCount,
"models": gin.H{
"chat_count": len(chat), "embedding_count": len(emb),
"active_chat": activeChat, "active_embedding": activeEmb, "fallbacks": fallbacks,
"health": modelHealth, // 运行时每模型 failover/熔断态
},
"prompts": gin.H{"managed": len(prompts.Known), "overrides": len(overrides)},
"services": services,
"checked_at": time.Now().Format(time.RFC3339),
})
}
// maskPrefix 是脱敏展示用的占位前缀;前端把列表里的脱敏 key 原样回传即视为「未改动」。
const maskPrefix = "••••"
// existingModelKey 取某 id 模型库内存储的 api_key(密文,未解密);不存在返回空。
func (h *Handler) existingModelKey(ctx context.Context, id string) string {
if id == "" {
return ""
}
rows, _ := h.db.ListModels(ctx, "")
for _, m := range rows {
if m.ID == id {
return m.APIKey
}
}
return ""
}
// 控制面(运维管理):LLM 模型配置 CRUD + 测试连接 + 变更广播。
// 表 sundynix_model 由 Gateway 持有;Dispatcher 经 NATS 取激活配置。
type modelBody struct {
ID string `json:"id"`
Kind string `json:"kind"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
// ListModels: GET /api/v1/admin/models?kind=chat|embedding —— 列出模型(api_key 脱敏)。
func (h *Handler) ListModels(c *gin.Context) {
rows, err := h.db.ListModels(c.Request.Context(), c.Query("kind"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(rows))
for _, m := range rows {
plain, _ := secrets.Decrypt(m.APIKey) // 库内为密文,脱敏前先还原以展示真实尾 4 位
out = append(out, gin.H{
"id": m.ID, "kind": m.Kind, "provider": m.Provider, "base_url": m.BaseURL,
"model": m.Model, "active": m.Active, "api_key": mask(plain),
})
}
c.JSON(http.StatusOK, gin.H{"models": out})
}
// ListPricing: GET /api/v1/admin/pricing —— 列出各模型的计价配置(token↔真钱)。
func (h *Handler) ListPricing(c *gin.Context) {
rows, err := h.db.ListPricing(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(rows))
for _, p := range rows {
out = append(out, gin.H{
"model_id": p.ModelID, "input_per_1k": p.InputPer1K, "output_per_1k": p.OutputPer1K,
"credit_weight": p.CreditWeight, "currency": p.Currency,
})
}
c.JSON(http.StatusOK, gin.H{"pricing": out})
}
// SavePricing: PUT /api/v1/admin/pricing —— 设置某模型的输入/输出单价(每 1K token)+ 积分权重。
func (h *Handler) SavePricing(c *gin.Context) {
var b struct {
ModelID string `json:"model_id"`
InputPer1K float64 `json:"input_per_1k"`
OutputPer1K float64 `json:"output_per_1k"`
CreditWeight float64 `json:"credit_weight"`
Currency string `json:"currency"`
}
if err := c.ShouldBindJSON(&b); err != nil || b.ModelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "model_id required"})
return
}
if b.InputPer1K < 0 || b.OutputPer1K < 0 || b.CreditWeight < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "单价/权重不能为负"})
return
}
if b.Currency == "" {
b.Currency = "CNY"
}
if err := h.db.UpsertPricing(c.Request.Context(), b.ModelID, b.InputPer1K, b.OutputPer1K, b.CreditWeight, b.Currency); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// BillingConfig: GET /api/v1/admin/billing-config —— 全局计费规则(token→积分汇率 + 硬拦截开关)。
func (h *Handler) BillingConfig(c *gin.Context) {
ctx := c.Request.Context()
c.JSON(http.StatusOK, gin.H{
"tokens_per_credit": h.db.GetSetting(ctx, store.SettingTokensPerCredit), // 空串=未设,前端回退默认
"credit_enforce": h.db.CreditEnforceEnabled(ctx), // 余额≤0 是否拒绝新任务
})
}
// SaveBillingConfig: PUT /api/v1/admin/billing-config —— 设 token→积分汇率(>0)+ 硬拦截开关。
func (h *Handler) SaveBillingConfig(c *gin.Context) {
var b struct {
TokensPerCredit float64 `json:"tokens_per_credit"`
CreditEnforce bool `json:"credit_enforce"`
}
if err := c.ShouldBindJSON(&b); err != nil || b.TokensPerCredit <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "tokens_per_credit 必须 > 0"})
return
}
ctx := c.Request.Context()
if err := h.db.SetSetting(ctx, store.SettingTokensPerCredit, strconv.FormatFloat(b.TokensPerCredit, 'f', -1, 64)); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
enforce := "off"
if b.CreditEnforce {
enforce = "on"
}
if err := h.db.SetSetting(ctx, store.SettingCreditEnforce, enforce); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// AdminTenants: GET /api/v1/admin/tenants —— 全平台租户目录(含成员数 + 余额)。
func (h *Handler) AdminTenants(c *gin.Context) {
rows, err := h.db.ListTenants(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tenants": rows})
}
// AdminCreateTenant: POST /api/v1/admin/tenants {name, slug, owner_email?} —— 新建租户(可选指定 owner)。
func (h *Handler) AdminCreateTenant(c *gin.Context) {
var b struct {
Name string `json:"name"`
Slug string `json:"slug"`
Plan string `json:"plan"`
OwnerEmail string `json:"owner_email"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" || strings.TrimSpace(b.Slug) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 和 slug 必填"})
return
}
ctx := c.Request.Context()
t, err := h.db.CreateTenant(ctx, b.Name, b.Slug, b.Plan)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if em := strings.TrimSpace(b.OwnerEmail); em != "" {
if _, err := h.db.AddMemberByEmail(ctx, t.ID, em, store.RoleOwner); err != nil {
c.JSON(http.StatusOK, gin.H{"tenant": t, "warn": "租户已建,但指定 owner 失败:" + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"tenant": t})
}
// AdminSetSharedBilling: PUT /api/v1/admin/tenants/:id/shared-billing {shared_billing} —— 设共享计费开关。
func (h *Handler) AdminSetSharedBilling(c *gin.Context) {
var b struct {
SharedBilling bool `json:"shared_billing"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad body"})
return
}
if err := h.db.SetSharedBilling(c.Request.Context(), c.Param("id"), b.SharedBilling); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// AdminSetTenantPlan: PUT /api/v1/admin/tenants/:id/plan {plan} —— 改方案等级。
func (h *Handler) AdminSetTenantPlan(c *gin.Context) {
var b struct {
Plan string `json:"plan"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Plan) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "plan 必填"})
return
}
if err := h.db.SetTenantPlan(c.Request.Context(), c.Param("id"), b.Plan); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// AdminSetTenantStatus: PUT /api/v1/admin/tenants/:id/status {status} —— 改租户状态(暂停/恢复)。
func (h *Handler) AdminSetTenantStatus(c *gin.Context) {
var b struct {
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Status) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "status 必填"})
return
}
if b.Status != "active" && b.Status != "suspended" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 status,仅支持 active/suspended"})
return
}
if err := h.db.SetTenantStatus(c.Request.Context(), c.Param("id"), b.Status); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。
func (h *Handler) AdminMembers(c *gin.Context) {
rows, err := h.db.ListMembers(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"members": rows})
}
// AdminAddMember: POST /api/v1/admin/tenants/:id/members {email, role} —— 按邮箱加成员。
func (h *Handler) AdminAddMember(c *gin.Context) {
var b struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Email) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"})
return
}
if b.Role == "" {
b.Role = store.RoleMember
}
m, err := h.db.AddMemberByEmail(c.Request.Context(), c.Param("id"), strings.TrimSpace(b.Email), b.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"member": m})
}
// AdminSetMemberRole: PUT /api/v1/admin/tenants/:id/members/:uid {role} —— 改成员角色。
func (h *Handler) AdminSetMemberRole(c *gin.Context) {
var b struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&b); err != nil || b.Role == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "role 必填"})
return
}
if err := h.db.SetMemberRole(c.Request.Context(), c.Param("id"), c.Param("uid"), b.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// AdminRemoveMember: DELETE /api/v1/admin/tenants/:id/members/:uid —— 软移除成员。
func (h *Handler) AdminRemoveMember(c *gin.Context) {
if err := h.db.RemoveMember(c.Request.Context(), c.Param("id"), c.Param("uid")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// GrantCredits: POST /api/v1/admin/credits/grant —— 给租户充值/发放积分(记账本 + 增余额)。
func (h *Handler) GrantCredits(c *gin.Context) {
var b struct {
TenantID string `json:"tenant_id"`
Credits float64 `json:"credits"` // 单位:积分(正=充值/发放,负=扣减/校正)
Memo string `json:"memo"`
}
if err := c.ShouldBindJSON(&b); err != nil || b.TenantID == "" || b.Credits == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id 必填、credits 不能为 0"})
return
}
ctx := c.Request.Context()
kind := store.LedgerGrant
if b.Credits < 0 {
kind = store.LedgerAdjust // 负数记为人工校正
}
if err := h.db.GrantCredits(ctx, b.TenantID, kind, int64(b.Credits*1e6), "", b.Memo); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "balance_micro": h.db.TenantBalance(ctx, b.TenantID)})
}
// SaveModel: POST /api/v1/admin/models —— 新增/更新一条模型配置。
func (h *Handler) SaveModel(c *gin.Context) {
var b modelBody
if err := c.ShouldBindJSON(&b); err != nil || b.BaseURL == "" || b.Model == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider/base_url/model required"})
return
}
provider := b.Provider
if provider == "" {
provider = "openai-compatible"
}
kind := b.Kind
if kind == "" {
kind = contract.ConfigKindChat
}
// api_key 处理:空或脱敏占位 => 沿用库内既有密文(更新时未改 key);否则视为新明文,加密落库。
apiKey := b.APIKey
if apiKey == "" || strings.HasPrefix(apiKey, maskPrefix) {
apiKey = h.existingModelKey(c.Request.Context(), b.ID) // 已是密文,原样保留(不存在则空)
} else {
enc, err := secrets.Encrypt(apiKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt api_key: " + err.Error()})
return
}
apiKey = enc
}
m := &store.LLMModel{BaseModel: store.BaseModel{ID: b.ID}, Kind: kind, Provider: provider, BaseURL: b.BaseURL, APIKey: apiKey, Model: b.Model}
if err := h.db.SaveModel(c.Request.Context(), m); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
h.broadcastActive(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"id": m.ID})
}
// SetActiveModel: POST /api/v1/admin/models/:id/active —— 设为激活并广播。
func (h *Handler) SetActiveModel(c *gin.Context) {
id := c.Param("id")
if err := h.db.SetActiveModel(c.Request.Context(), id); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
h.broadcastActive(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"status": "ok", "active": id})
}
// DeleteModel: DELETE /api/v1/admin/models/:id
func (h *Handler) DeleteModel(c *gin.Context) {
id := c.Param("id")
if err := h.db.DeleteModel(c.Request.Context(), id); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
h.broadcastActive(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// TestModel: POST /api/v1/admin/models/test —— 探测 OpenAI 兼容端点连通性。
func (h *Handler) TestModel(c *gin.Context) {
var b modelBody
if err := c.ShouldBindJSON(&b); err != nil || b.BaseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required"})
return
}
// 若未带 key(或回传脱敏占位),用库里的真实 key。
key := b.APIKey
if key == "" || strings.HasPrefix(key, maskPrefix) {
key = h.existingModelKey(c.Request.Context(), b.ID)
}
// key 此刻可能是库内密文,也可能是用户新填的明文;Decrypt 对无前缀明文透传,两种都还原成可用明文。
if plain, err := secrets.Decrypt(key); err == nil {
key = plain
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
var req *http.Request
if b.Kind == contract.ConfigKindEmbedding {
// embedding 端点多无 /models,发一个最小 /embeddings 探测。
payload, _ := json.Marshal(map[string]any{"model": b.Model, "input": []string{"ping"}})
req, _ = http.NewRequestWithContext(ctx, http.MethodPost, b.BaseURL+"/embeddings", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
} else {
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, b.BaseURL+"/models", nil)
}
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()})
return
}
defer resp.Body.Close()
c.JSON(http.StatusOK, gin.H{"ok": resp.StatusCode < 400, "message": "HTTP " + resp.Status})
}
// broadcastActive 重新广播各 kind 当前激活配置,触发对应消费方热更新。
// chat 配置带 Fallbacks(其它已登记 chat 模型作备用),dispatcher 据此重建 failover 链。
func (h *Handler) broadcastActive(ctx context.Context) {
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
// 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效",
// 而改配置的人这边一切正常。必须留痕,否则只能靠猜。
if err := h.bus.PublishConfigUpdated(kind, cfg); err != nil {
log.Printf("[admin] ⚠️ %s 配置广播失败: %vdispatcher/mcp-go 仍在用旧配置,需重启或重试保存)", kind, err)
}
}
}
}
func mask(s string) string {
if len(s) <= 4 {
if s == "" {
return ""
}
return "••••"
}
return "••••" + s[len(s)-4:]
}
// AdminUsage: GET /api/v1/admin/usage?tenant=&from=&to= —— 系统级用量/积分/成本口径。
// 无 tenant → 全平台按天 SUM + 各租户排行;有 tenant → 该租户按天趋势 + 当前余额。
// from/to 为 YYYYMMDD,缺省近 30 天。走 WithoutTenant(跨租户,与 admin/overview 同口径)。
func (h *Handler) AdminUsage(c *gin.Context) {
ctx := store.WithoutTenant(c.Request.Context())
now := time.Now()
from := c.Query("from")
if from == "" {
from = now.AddDate(0, 0, -29).Format("20060102")
}
to := c.Query("to")
if to == "" {
to = now.Format("20060102")
}
tenant := c.Query("tenant")
trend := h.db.UsageTrend(ctx, tenant, from, to)
var totTok, totCredits, totCost, totTasks int64
for _, d := range trend {
totTok += d.TotalTok
totCredits += d.CreditsMicro
totCost += d.CostMicros
totTasks += d.TaskCount
}
resp := gin.H{
"from": from, "to": to, "tenant": tenant, "trend": trend,
"totals": gin.H{"total_tok": totTok, "credits_micro": totCredits, "cost_micros": totCost, "task_count": totTasks},
}
if tenant != "" {
resp["balance_micro"] = h.db.TenantBalance(ctx, tenant)
} else {
resp["tenants"] = h.db.UsageByTenant(ctx, from, to, 20) // 全平台:各租户用量排行(含余额)
}
c.JSON(http.StatusOK, resp)
}
// AdminDatasources: GET /api/v1/admin/datasources —— 数据源清单(全平台知识库 + 文档数/字数)+ 平台计数。
func (h *Handler) AdminDatasources(c *gin.Context) {
ctx := store.WithoutTenant(c.Request.Context())
users, kbs, docs := h.db.SystemCounts(ctx)
c.JSON(http.StatusOK, gin.H{
"counts": gin.H{"users": users, "kbs": kbs, "docs": docs},
"datasources": h.db.AllDatasources(ctx),
})
}
// AdminEvals: GET /api/v1/admin/evals?days= —— 自动评测观测(趋势 + 计数 + 错题本)。全平台口径。
// 数据来自 sundynix_eval(评测经 JetStream eval 流持久落库);此前该页纯 mock。
func (h *Handler) AdminEvals(c *gin.Context) {
ctx := store.WithoutTenant(c.Request.Context())
now := time.Now()
days := 14
if d, err := strconv.Atoi(c.Query("days")); err == nil && d > 0 && d <= 90 {
days = d
}
from := now.AddDate(0, 0, -(days - 1)).Format("20060102")
to := now.Format("20060102")
c.JSON(http.StatusOK, gin.H{
"from": from,
"to": to,
"trend": h.db.EvalTrend(ctx, from, to),
"summary": h.db.EvalSummaryFor(ctx, from, to),
"poor": h.db.PoorEvals(ctx, 30),
})
}