From 597665f3c8e64982ba5c8eda410940aed152b102 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 19 Jun 2026 11:25:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=AE=A1=E4=BB=B7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=88=E6=8C=89=E6=A8=A1=E5=9E=8B=C2=B7=E5=88=86?= =?UTF-8?q?=E8=BE=93=E5=85=A5/=E8=BE=93=E5=87=BA=E5=8D=95=E4=BB=B7?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=20=E8=AE=A1=E8=B4=B9=E6=AF=94?= =?UTF-8?q?=E7=8E=87=E9=85=8D=E7=BD=AE=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计费需 token↔真钱比率,配置归管理端。本次落地"按模型·分输入/输出"粒度: 后端(gateway): - store.Pricing 模型(BaseModel + model_id 唯一 + input_per_1k/output_per_1k + currency), AutoMigrate 建 sundynix_pricing;ListPricing/UpsertPricing(OnConflict model_id 覆盖)。 - admin handler:GET /admin/pricing 列表、PUT /admin/pricing 设置(校验非负,币种默认 CNY), 挂在 RequireAdmin 组下。 前端(admin): - api:listPricing/savePricing(带 Bearer)。 - PricingPage:列出所有已登记模型(chat+embedding),每行可编辑 输入/输出每1K单价 + 币种,逐行保存。 - routes 新增「计价」页(配置组)。 实测:PUT→ok;GET 返回正确行;重复 PUT 同 model_id 仍 1 行且值更新(upsert 生效);表自动迁移。 前端 tsc 干净。下一步可做用量计量 × 单价折算(真正计费)。 Co-Authored-By: Claude Opus 4.8 --- PROGRESS.md | 3 +- sundynix-admin/src/api.ts | 22 ++++ sundynix-admin/src/pages/PricingPage.tsx | 144 +++++++++++++++++++++ sundynix-admin/src/routes.tsx | 8 ++ sundynix-gateway/internal/handler/admin.go | 42 ++++++ sundynix-gateway/internal/router/router.go | 2 + sundynix-gateway/internal/store/model.go | 33 +++++ sundynix-gateway/internal/store/pgsql.go | 2 +- 8 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 sundynix-admin/src/pages/PricingPage.tsx diff --git a/PROGRESS.md b/PROGRESS.md index 0b64e56..167a945 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -34,7 +34,8 @@ - [x] 可观测性:Prometheus /metrics(请求数/耗时/在途,路由模板低基数)· 结构化 JSON 访问日志 + X-Request-ID · /healthz(存活) + /readyz(就绪) 探针 - [x] Harness **输入**护栏(拦提示词注入 + 超大体,纯逻辑 `internal/guardrail` + 单测 + 实跑验证) - [x] Harness **输出**护栏(dispatcher 发射层逐片脱敏疑似密钥/令牌 sk-/AKIA/JWT/Bearer + 轨迹标记 + 单测) -- [ ] 🟡 商业化与计费模块(占位,仅统计任务数) +- [x] 计价配置(按模型·分输入/输出每1K单价+币种;admin 计价页 + /admin/pricing 端点 + sundynix_pricing 表) +- [ ] 🟡 计费/商业化:用量计量×单价折算 + 配额(计价配置已完成,计量待做) ## 第 3 层 · MESSAGE BUS(NATS 零拷贝骨干网) diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index cced335..e3e86d9 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -120,6 +120,28 @@ export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: return (await res.json()) as { ok: boolean; message: string }; } +// ---- 计价(token↔真钱,按模型分输入/输出)---- +export interface Pricing { + model_id: string; + input_per_1k: number; + output_per_1k: number; + currency: string; +} + +export async function listPricing(): Promise { + const res = guard(await fetch(`${ADMIN}/pricing`, { headers: authHeaders() })); + if (!res.ok) throw new Error(`list pricing failed: ${res.status}`); + return ((await res.json()) as { pricing: Pricing[] }).pricing ?? []; +} + +export async function savePricing(p: Pricing): Promise { + const res = guard(await fetch(`${ADMIN}/pricing`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) })); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(d.error ?? `save pricing failed: ${res.status}`); + } +} + // gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。 export async function gatewayOnline(): Promise { try { diff --git a/sundynix-admin/src/pages/PricingPage.tsx b/sundynix-admin/src/pages/PricingPage.tsx new file mode 100644 index 0000000..efede7a --- /dev/null +++ b/sundynix-admin/src/pages/PricingPage.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { listModels, listPricing, savePricing, type Model, type Pricing } from "../api"; + +// 每个模型一行的本地编辑态。 +interface Row { + model: Model; + inPer1k: string; + outPer1k: string; + currency: string; + dirty: boolean; + saving: boolean; + msg: string; +} + +// 计价配置:为每个已登记模型设「输入 / 输出 每 1K token 单价」+ 币种(token↔真钱)。供计费折算。 +export function PricingPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(""); + + const load = async () => { + setLoading(true); + setErr(""); + try { + const [chat, emb, pricing] = await Promise.all([listModels("chat"), listModels("embedding"), listPricing()]); + const byID = new Map(pricing.map((p) => [p.model_id, p])); + const mk = (m: Model): Row => { + const p = byID.get(m.id); + return { + model: m, + inPer1k: p ? String(p.input_per_1k) : "", + outPer1k: p ? String(p.output_per_1k) : "", + currency: p?.currency || "CNY", + dirty: false, + saving: false, + msg: "", + }; + }; + setRows([...chat.map(mk), ...emb.map(mk)]); + } catch (e) { + setErr((e as Error).message); + } finally { + setLoading(false); + } + }; + useEffect(() => { + void load(); + }, []); + + const patch = (id: string, p: Partial) => setRows((rs) => rs.map((r) => (r.model.id === id ? { ...r, ...p, dirty: true, msg: "" } : r))); + + const save = async (r: Row) => { + setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: true, msg: "" } : x))); + try { + await savePricing({ + model_id: r.model.id, + input_per_1k: Number(r.inPer1k) || 0, + output_per_1k: Number(r.outPer1k) || 0, + currency: r.currency || "CNY", + }); + setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, dirty: false, msg: "✓ 已保存" } : x))); + } catch (e) { + setRows((rs) => rs.map((x) => (x.model.id === r.model.id ? { ...x, saving: false, msg: (e as Error).message } : x))); + } + }; + + if (loading) return
加载中…
; + if (err) return
{err}
; + + return ( +
+

为每个已登记模型设置 token↔真钱单价(每 1K token)。计费按用量 × 单价折算。

+ {rows.length === 0 ? ( +
还没有登记模型,先到「模型」页添加。
+ ) : ( + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
模型类型输入 / 1K输出 / 1K币种
+
{r.model.model}
+
{r.model.provider}
+
{r.model.kind} + patch(r.model.id, { inPer1k: e.target.value })} + placeholder="0" + /> + + patch(r.model.id, { outPer1k: e.target.value })} + placeholder="0" + /> + + + + + {r.msg && {r.msg}} +
+ )} +
+ ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index 832015e..e062986 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -5,6 +5,7 @@ import { Soon } from "./components/Soon"; // 新增页面 = 在此加一条;real 页面用 lazy 懒加载(代码分割)。 const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage }))); const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage }))); +const PricingPage = lazy(() => import("./pages/PricingPage").then((m) => ({ default: m.PricingPage }))); export interface RouteDef { path: string; @@ -29,6 +30,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "/pricing", + label: "计价", + group: "配置", + ready: true, + element: , + }, { path: "/tenants", label: "租户", diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index 01c77ae..c21b313 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -42,6 +42,48 @@ func (h *Handler) ListModels(c *gin.Context) { 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, "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"` + 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 { + 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.Currency); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + // SaveModel: POST /api/v1/admin/models —— 新增/更新一条模型配置。 func (h *Handler) SaveModel(c *gin.Context) { var b modelBody diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 55dac0b..3402eb4 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -75,6 +75,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.POST("/models/:id/active", h.SetActiveModel) admin.DELETE("/models/:id", h.DeleteModel) admin.POST("/models/test", h.TestModel) + admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱) + admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 } } return r diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go index cbb4063..472d0a8 100644 --- a/sundynix-gateway/internal/store/model.go +++ b/sundynix-gateway/internal/store/model.go @@ -216,6 +216,39 @@ func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink, return rows, err } +// Pricing 是某模型的计价配置(token↔真钱):按模型分输入/输出单价(每 1K token)。 +// 表名 sundynix_pricing。ModelID 关联 sundynix_model.id,唯一。 +type Pricing struct { + BaseModel + ModelID string `gorm:"size:24;uniqueIndex"` // 关联 sundynix_model.id + InputPer1K float64 `gorm:"column:input_per_1k"` // 每 1K 输入 token 单价 + OutputPer1K float64 `gorm:"column:output_per_1k"` // 每 1K 输出 token 单价 + Currency string `gorm:"size:8"` // 币种(CNY / USD…) +} + +func (Pricing) TableName() string { return "sundynix_pricing" } + +// ListPricing 列出全部计价配置。 +func (p *Postgres) ListPricing(ctx context.Context) ([]Pricing, error) { + if p.db == nil { + return nil, nil + } + var rows []Pricing + err := p.db.WithContext(ctx).Find(&rows).Error + return rows, err +} + +// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种)。 +func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K float64, currency string) error { + if p.db == nil { + return errStoreDisabled + } + return p.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "model_id"}}, + DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "currency": currency, "updated_at": time.Now()}), + }).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, Currency: currency}).Error +} + // LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。 // 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。 type LLMModel struct { diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 2ed9a27..0e4c751 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -39,7 +39,7 @@ func OpenPostgres(dsn string) *Postgres { migrateLegacyIntIDs(db) migrateDocLinkToID(db) - if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}); err != nil { + if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}); err != nil { log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) return &Postgres{} }