setDrill(t)}
+ className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40"
+ title="点击查看执行轨迹 / 输出 / 评测"
+ >
| {new Date(t.at).toLocaleString("zh-CN")} |
{t.topic || {t.task_id.slice(0, 8)}…}
+
+ {drill && setDrill(null)} />}
);
}
diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx
index 8bcb26a..1b36aaf 100644
--- a/sundynix-admin/src/routes.tsx
+++ b/sundynix-admin/src/routes.tsx
@@ -1,3 +1,4 @@
+import { SubscriptionPage } from "./pages/SubscriptionPage";
import { lazy, type ReactNode } from "react";
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
@@ -98,6 +99,14 @@ export const routes: RouteDef[] = [
ready: true,
element: ,
},
+ {
+ path: "payment/subscription",
+ label: "订阅",
+ group: "运维",
+ parent: "支付",
+ ready: true,
+ element: ,
+ },
{
path: "payment/orders",
label: "订单与对账",
diff --git a/sundynix-admin/src/site/components/layout/site-header.tsx b/sundynix-admin/src/site/components/layout/site-header.tsx
index dcaa2c5..38efef7 100644
--- a/sundynix-admin/src/site/components/layout/site-header.tsx
+++ b/sundynix-admin/src/site/components/layout/site-header.tsx
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
{ label: '产品', to: '/', end: true },
{ label: '功能', to: '/#features' },
{ label: '架构', to: '/#architecture' },
+ { label: '定价', to: '/pricing' },
]
export function SiteHeader() {
diff --git a/sundynix-admin/src/site/pages/pricing.tsx b/sundynix-admin/src/site/pages/pricing.tsx
new file mode 100644
index 0000000..29cd909
--- /dev/null
+++ b/sundynix-admin/src/site/pages/pricing.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useState } from 'react'
+import { GATEWAY } from '../../api'
+
+// 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**——
+// 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。
+//
+// 公开可见(/api/v1/pricing 不挂鉴权):让人在登录前就看得到价格,
+// 这是购买转化的前提,也是官网存在的意义。
+
+const MICRO = 1_000_000
+const credits = (m: number) => (m / MICRO).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
+const yuan = (fen: number) => (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: fen % 100 === 0 ? 0 : 2 })
+
+interface SubPlan {
+ id: string
+ name: string
+ price_fen: number
+ duration_days: number
+ refill_credits_micro: number
+ refill_interval_days: number
+}
+
+interface Pack {
+ id: string
+ name: string
+ credits_micro: number
+ price_fen: number
+}
+
+// 结账在 Web 面完成(那里有登录态、租户上下文与支付轮询)。官网只负责"看价 → 去买",
+// 避免同一套支付流程在两处各实现一遍。
+const CHECKOUT = '/usage'
+
+export default function PricingPage() {
+ const [plans, setPlans] = useState([])
+ const [packs, setPacks] = useState([])
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
+
+ useEffect(() => {
+ // GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
+ fetch(`${GATEWAY}/api/v1/pricing`)
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
+ .then((d: { plans?: SubPlan[]; packs?: Pack[] }) => {
+ setPlans(d.plans ?? [])
+ setPacks(d.packs ?? [])
+ setState('ready')
+ })
+ .catch(() => setState('error'))
+ }, [])
+
+ return (
+
+
+
+ {state === 'loading' && 正在获取价格… }
+ {state === 'error' && (
+
+ 价格暂时取不到,请稍后再试,或直接
+
+ 前往账单页
+
+ 。
+
+ )}
+
+ {state === 'ready' && (
+ <>
+ {plans.length > 0 && (
+
+ 订阅
+
+ 购买一个周期,期内按节奏发放积分。到期即结束,不会自动续费、不会自动扣款。
+
+
+ {plans.map((p) => {
+ const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0
+ return (
+
+ {p.name}
+
+ ¥{yuan(p.price_fen)}
+ / {p.duration_days} 天
+
+
+ -
+ 每 {p.refill_interval_days} 天发放{' '}
+ {credits(p.refill_credits_micro)} 积分
+
+ -
+ 周期内共 {times} 次,合计{' '}
+ {credits(p.refill_credits_micro * times)} 积分
+
+ - 积分累加,用不完不清零
+
+
+ 购买
+
+
+ )
+ })}
+
+
+ )}
+
+ {packs.length > 0 && (
+
+ 积分包
+ 一次性购买,用完再买,适合用量不规律的场景。
+
+ {packs.map((p) => (
+
+ {p.name}
+ ¥{yuan(p.price_fen)}
+ {credits(p.credits_micro)} 积分
+
+ 购买 →
+
+
+ ))}
+
+
+ )}
+
+ {plans.length === 0 && packs.length === 0 && (
+ 价格方案即将上线。
+ )}
+
+
+ 支付由微信支付提供。购买需要先登录;企业采购与私有化部署请通过页脚联系方式与我们联系。
+
+ >
+ )}
+
+ )
+}
diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts
index 836c318..27d4e72 100644
--- a/sundynix-desktop/frontend/src/lib/api.ts
+++ b/sundynix-desktop/frontend/src/lib/api.ts
@@ -150,6 +150,31 @@ export interface MyTenant {
members: number;
}
+// ---- 订阅(只读)----
+// 桌面端**不做**购买流程:购买入口在 Web 面(一个功能一个入口)。这里只读状态,
+// 因为"还剩几天到期"必须在用户干活的地方看得见——到期即失效且没有任何续费通知。
+export interface MySubscription {
+ id: string;
+ plan_id: string;
+ status: string;
+ expires_at: string;
+ refill_seq: number;
+}
+
+export interface MySubPlan {
+ id: string;
+ name: string;
+ refill_credits_micro: number;
+ refill_interval_days: number;
+}
+
+export async function mySubscription(): Promise<{ subscription: MySubscription | null; plan: MySubPlan | null }> {
+ const res = await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() });
+ if (!res.ok) return { subscription: null, plan: null };
+ const d = (await res.json()) as { subscription?: MySubscription | null; plan?: MySubPlan | null };
+ return { subscription: d.subscription ?? null, plan: d.plan ?? null };
+}
+
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
if (!res.ok) return { tenants: [], active: "" };
diff --git a/sundynix-desktop/frontend/src/views/UsageView.tsx b/sundynix-desktop/frontend/src/views/UsageView.tsx
index 0278ac0..38dea6a 100644
--- a/sundynix-desktop/frontend/src/views/UsageView.tsx
+++ b/sundynix-desktop/frontend/src/views/UsageView.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Coins, RefreshCw, Wallet, Cpu, Receipt, Activity } from "lucide-react";
-import { myUsage, type MyUsage, type UsageDay } from "../lib/api";
+import { myUsage, GATEWAY, type MyUsage, type UsageDay, mySubscription, type MySubscription, type MySubPlan } from "../lib/api";
+import { openExternal } from "../lib/version";
import { Card, Panel, EmptyState, cn } from "../ui";
// 桌面端「用量」= 用户自己租户的计量观测:积分余额 + 消耗趋势 + 最近消耗。
@@ -34,11 +35,20 @@ export function UsageView() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState("");
+ const [sub, setSub] = useState(null);
+ const [subPlan, setSubPlan] = useState(null);
const load = async () => {
setRefreshing(true);
try {
setData(await myUsage(days));
+ // 订阅只读展示;失败不影响用量页主体(没订阅也是常态)
+ mySubscription()
+ .then((r) => {
+ setSub(r.subscription);
+ setSubPlan(r.plan);
+ })
+ .catch(() => {});
setErr("");
} catch (e) {
setErr((e as Error).message);
@@ -114,6 +124,10 @@ export function UsageView() {
+ {/* 订阅状态:到期即失效、无自动续费、无扣款通知 —— 必须在用户干活的地方看得见。
+ 购买/续订不在这里做(入口在 Web 面账单页),避免同一功能两个入口。 */}
+ {sub && subPlan && }
+
{/* 消耗趋势 */}
@@ -182,3 +196,31 @@ function TrendBars({ series }: { series: UsageDay[] }) {
);
}
+
+// SubBar 订阅状态条。只读:桌面端不做购买流程,续订跳 Web 面账单页。
+function SubBar({ sub, plan }: { sub: MySubscription; plan: MySubPlan }) {
+ const left = Math.ceil((new Date(sub.expires_at).getTime() - Date.now()) / 86400000);
+ const tone = left <= 3 ? "text-danger" : left <= 7 ? "text-warn" : "text-slate-400";
+ return (
+
+
+ 订阅中 · {plan.name}
+
+ 每 {plan.refill_interval_days} 天发放 {credits(plan.refill_credits_micro)} 积分 · 已发放 {sub.refill_seq} 次 ·
+ 到期后不再发放
+
+
+
+
+ {new Date(sub.expires_at).toLocaleDateString("zh-CN")} 到期(剩 {left} 天)
+
+
+
+
+ );
+}
diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go
index 2900fc2..52f3b8a 100644
--- a/sundynix-gateway/internal/handler/admin.go
+++ b/sundynix-gateway/internal/handler/admin.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "log"
"net/http"
"strconv"
"strings"
@@ -30,7 +31,14 @@ func (h *Handler) AuditList(c *gin.Context) {
offset = n
}
}
- rows, err := h.db.ListAudit(c.Request.Context(), limit, offset)
+ // 筛选下沉到 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
@@ -375,7 +383,6 @@ func (h *Handler) AdminSetTenantStatus(c *gin.Context) {
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"))
@@ -557,7 +564,11 @@ func (h *Handler) TestModel(c *gin.Context) {
func (h *Handler) broadcastActive(ctx context.Context) {
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
- _ = h.bus.PublishConfigUpdated(kind, cfg)
+ // 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效",
+ // 而改配置的人这边一切正常。必须留痕,否则只能靠猜。
+ if err := h.bus.PublishConfigUpdated(kind, cfg); err != nil {
+ log.Printf("[admin] ⚠️ %s 配置广播失败: %v(dispatcher/mcp-go 仍在用旧配置,需重启或重试保存)", kind, err)
+ }
}
}
}
diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go
index c1a14fc..0a8ecd2 100644
--- a/sundynix-gateway/internal/handler/billing_pay.go
+++ b/sundynix-gateway/internal/handler/billing_pay.go
@@ -2,6 +2,7 @@ package handler
import (
"context"
+ "encoding/json"
"net/http"
"strconv"
"strings"
@@ -38,10 +39,11 @@ const orderTTL = 30 * time.Minute
func (h *Handler) BillingCreateOrder(c *gin.Context) {
var b struct {
PackID string `json:"pack_id"`
+ PlanID string `json:"plan_id"` // 传它=买订阅周期;与 pack_id 二选一
Channel string `json:"channel"`
}
- if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"})
+ if err := c.ShouldBindJSON(&b); err != nil || (strings.TrimSpace(b.PackID) == "" && strings.TrimSpace(b.PlanID) == "") {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 或 plan_id 必填"})
return
}
channel := strings.TrimSpace(b.Channel)
@@ -60,21 +62,40 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"})
return
}
- pk, err := h.db.GetPack(ctx, b.PackID)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
- return
- }
- o := &store.PaymentOrder{
- TenantID: billing, UserID: uid, PackID: pk.ID,
- AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
- Channel: channel, Status: store.OrderPending,
+ // 订阅单与积分包单走同一条支付链路:只有订单内容不同,下单/回调/查单/掉单补偿全复用。
+ var o *store.PaymentOrder
+ var desc string
+ if pid := strings.TrimSpace(b.PlanID); pid != "" {
+ pl := h.db.GetSubPlan(ctx, pid)
+ if pl == nil || !pl.Active {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "订阅套餐不存在或已下架"})
+ return
+ }
+ // 订阅单 credits_micro 恒为 0:积分不在付款时一次给,而是订阅期内按周期发放。
+ o = &store.PaymentOrder{
+ TenantID: billing, UserID: uid, Kind: store.OrderKindSub, PlanID: pl.ID,
+ AmountFen: pl.PriceFen, CreditsMicro: 0,
+ Channel: channel, Status: store.OrderPending,
+ }
+ desc = "sundynix 订阅 · " + pl.Name
+ } else {
+ pk, err := h.db.GetPack(ctx, b.PackID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"})
+ return
+ }
+ o = &store.PaymentOrder{
+ TenantID: billing, UserID: uid, PackID: pk.ID, Kind: store.OrderKindPack,
+ AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro,
+ Channel: channel, Status: store.OrderPending,
+ }
+ desc = "sundynix 积分充值 · " + pk.Name
}
if err := h.db.CreateOrder(ctx, o); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
- intent, err := ch.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen)
+ intent, err := ch.CreatePay(ctx, o.ID, desc, o.AmountFen)
if err != nil {
// 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。
_ = h.db.ExpireOrder(ctx, o.ID)
@@ -332,6 +353,25 @@ func (h *Handler) AdminTasks(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
}
+// AdminTaskDetail: GET /api/v1/admin/tasks/:id —— 任务下钻(跨租户)。
+// 不复用用户面的 /tasks/:id/replay:那条走请求 ctx,受租户插件过滤,管理员看别的租户
+// 的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
+func (h *Handler) AdminTaskDetail(c *gin.Context) {
+ d := h.db.TaskDetail(c.Request.Context(), c.Param("id"))
+ if d == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
+ return
+ }
+ // trace 是收尾时落库的事件数组原文;空串是正常情况(任务未跑完/早于该功能上线)。
+ exec := []json.RawMessage{}
+ if d.Trace != "" {
+ if err := json.Unmarshal([]byte(d.Trace), &exec); err != nil {
+ exec = []json.RawMessage{} // 脏数据不该让整个下钻 500
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"task": d, "exec": exec})
+}
+
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
func (h *Handler) AdminSpaces(c *gin.Context) {
limit := 200
diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go
index 0aadc7f..2755ae5 100644
--- a/sundynix-gateway/internal/handler/kb.go
+++ b/sundynix-gateway/internal/handler/kb.go
@@ -18,9 +18,9 @@ import (
"github.com/gin-gonic/gin"
- "github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store"
+ "github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-shared/contract"
)
@@ -573,7 +573,10 @@ func (h *Handler) AdminKbSearch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"})
return
}
- args := map[string]any{"kb": body.KB, "q": body.Q}
+ // diag=true:让 mcp-go 连每一路的诊断一起回(ok/empty/disabled/error)。
+ // 试验台要回答的是"这一路为什么空"——只有命中数回答不了:没配置、报错、
+ // 确实没匹配,三者在结果上都是空数组。
+ args := map[string]any{"kb": body.KB, "q": body.Q, "diag": true}
if body.TopK > 0 {
args["topK"] = body.TopK
}
@@ -590,9 +593,19 @@ func (h *Handler) AdminKbSearch(c *gin.Context) {
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})
+ // 带 diag 时 mcp-go 回的是 {hits, routes} 对象;老版本回裸数组,做兼容降级
+ // (少了诊断而已,不该让整个试验台打不开)。
+ var out struct {
+ Hits []map[string]any `json:"hits"`
+ Routes []map[string]any `json:"routes"`
+ }
+ if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
+ var hits []map[string]any
+ _ = json.Unmarshal([]byte(res.Content), &hits)
+ c.JSON(http.StatusOK, gin.H{"hits": hits})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"hits": out.Hits, "routes": out.Routes})
}
// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graph,Neo4j)。
diff --git a/sundynix-gateway/internal/handler/status_handler.go b/sundynix-gateway/internal/handler/status_handler.go
index 3e75ba6..8cc4a97 100644
--- a/sundynix-gateway/internal/handler/status_handler.go
+++ b/sundynix-gateway/internal/handler/status_handler.go
@@ -56,6 +56,7 @@ func (h *Handler) AdminStatus(c *gin.Context) {
wg sync.WaitGroup
milvus, neo4j bool // mcp-go health
+ ftDisk bool // 全文索引是否落盘持久(false=退内存兜底,重启清零)
goUp bool // mcp-go 在线
goTools []toolInfo // mcp-go 注册工具
goLatency int // mcp-go 探针耗时
@@ -81,6 +82,7 @@ func (h *Handler) AdminStatus(c *gin.Context) {
var sub map[string]bool
if json.Unmarshal([]byte(res.Content), &sub) == nil {
milvus, neo4j = sub["milvus"], sub["neo4j"]
+ ftDisk = sub["fulltext_disk"]
}
}
}()
@@ -138,6 +140,9 @@ func (h *Handler) AdminStatus(c *gin.Context) {
{Name: "milvus", Up: milvus},
{Name: "neo4j", Up: neo4j},
{Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob,126)
+ // 全文索引:mcp-go 本地 bleve,是唯一不在 128 集中存储上的检索路,
+ // 也是唯一会"静默降级"的一路(退内存后重启清零,检索只是变差不报错)。
+ {Name: "全文索引", Up: goUp && ftDisk, Detail: fulltextDetail(goUp, ftDisk)},
},
Services: []statusItem{
{Name: "gateway", Up: true, Detail: "在线"},
@@ -207,3 +212,16 @@ func humanDuration(s int) string {
}
return fmt.Sprintf("%dh%dm", m/60, m%60)
}
+
+// fulltextDetail 说明全文(bleve)索引的持久化状态。退内存兜底时必须讲清后果——
+// 否则一盏灰灯没人知道意味着"重启就没了"。
+func fulltextDetail(goUp, disk bool) string {
+ switch {
+ case !goUp:
+ return "mcp-go 离线,无法判定"
+ case disk:
+ return "落盘持久"
+ default:
+ return "内存兜底 · 重启即清零(检查 BLEVE_PATH 与挂载卷权限)"
+ }
+}
diff --git a/sundynix-gateway/internal/handler/subscription.go b/sundynix-gateway/internal/handler/subscription.go
new file mode 100644
index 0000000..c28cc45
--- /dev/null
+++ b/sundynix-gateway/internal/handler/subscription.go
@@ -0,0 +1,95 @@
+package handler
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/sundynix/sundynix-gateway/internal/store"
+)
+
+// 订阅 API。用户面只有「看套餐 / 看我的订阅」,下单复用现有 /billing/orders
+// (多传 plan_id 即可),因为支付链路、幂等、掉单补偿都已经在那条路上验过了,
+// 没必要为订阅再造一条支付路径。
+
+// ---- 用户面 ----
+
+// BillingSubPlans: GET /api/v1/billing/sub-plans —— 在售订阅套餐。
+func (h *Handler) BillingSubPlans(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"plans": h.db.ListSubPlans(c.Request.Context(), true)})
+}
+
+// MySubscription: GET /api/v1/billing/subscription —— 我的当前订阅(无则 null)。
+func (h *Handler) MySubscription(c *gin.Context) {
+ ctx := c.Request.Context()
+ billing := h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c))
+ if billing == "" {
+ c.JSON(http.StatusOK, gin.H{"subscription": nil})
+ return
+ }
+ sub := h.db.ActiveSubscription(ctx, billing)
+ if sub == nil {
+ c.JSON(http.StatusOK, gin.H{"subscription": nil})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"subscription": sub, "plan": h.db.GetSubPlan(ctx, sub.PlanID)})
+}
+
+// PublicPricing: GET /api/v1/pricing —— **公开**(无需登录):官网定价页用。
+// 官网要在用户登录前就能看到价格,所以这条不挂鉴权;只暴露在售项,且不含任何
+// 内部字段(成本、权重、租户信息一概不出去)。
+func (h *Handler) PublicPricing(c *gin.Context) {
+ ctx := c.Request.Context()
+ packs, _ := h.db.ActivePacks(ctx)
+ c.JSON(http.StatusOK, gin.H{
+ "plans": h.db.ListSubPlans(ctx, true),
+ "packs": packs,
+ })
+}
+
+// ---- 管理端 ----
+
+// AdminSubPlans: GET /api/v1/admin/sub-plans —— 全部套餐(含下架)。
+func (h *Handler) AdminSubPlans(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"plans": h.db.ListSubPlans(c.Request.Context(), false)})
+}
+
+// AdminSaveSubPlan: PUT /api/v1/admin/sub-plans —— 新增/改套餐(id 空=新增)。
+// 积分以「积分」为单位收(面向人),服务端转 micro。
+func (h *Handler) AdminSaveSubPlan(c *gin.Context) {
+ var b struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ PriceFen int64 `json:"price_fen"`
+ DurationDays int `json:"duration_days"`
+ RefillCredits float64 `json:"refill_credits"`
+ RefillInterval int `json:"refill_interval_days"`
+ Active bool `json:"active"`
+ Sort int `json:"sort"`
+ }
+ if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
+ return
+ }
+ pl := &store.SubscriptionPlan{
+ BaseModel: store.BaseModel{ID: b.ID},
+ Name: strings.TrimSpace(b.Name),
+ PriceFen: b.PriceFen,
+ DurationDays: b.DurationDays,
+ RefillCreditsMicro: int64(b.RefillCredits * 1e6),
+ RefillIntervalDays: b.RefillInterval,
+ Active: b.Active,
+ Sort: b.Sort,
+ }
+ if err := h.db.SaveSubPlan(c.Request.Context(), pl); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"id": pl.ID})
+}
+
+// AdminSubscriptions: GET /api/v1/admin/subscriptions —— 全平台订阅观测。
+func (h *Handler) AdminSubscriptions(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"subscriptions": h.db.AllSubscriptions(c.Request.Context(), 200)})
+}
diff --git a/sundynix-gateway/internal/handler/subscription_tick.go b/sundynix-gateway/internal/handler/subscription_tick.go
new file mode 100644
index 0000000..cbe1edf
--- /dev/null
+++ b/sundynix-gateway/internal/handler/subscription_tick.go
@@ -0,0 +1,58 @@
+package handler
+
+import (
+ "context"
+ "log"
+ "time"
+)
+
+// 订阅推进定时器:周期扫 active 订阅 → 该发的发、该过期的置过期。
+// 与掉单补偿(payment_reconcile.go)同一范式:定时器只是"兜底触发器",
+// 真正的语义与幂等都在 store.TickSubscription 里,两处不会漂移。
+//
+// 为什么需要它:订阅是"有效期内每 N 天发一次积分",没有用户请求来驱动这个节拍。
+// 进程停机期间欠下的发放,由 TickSubscription 的补发逻辑一次性补齐。
+const subTickInterval = 10 * time.Minute
+
+// StartSubscriptionTicker 随进程生命周期运行;多实例并发也安全(发放靠 ledger 唯一索引幂等)。
+func (h *Handler) StartSubscriptionTicker(ctx context.Context) {
+ go func() {
+ t := time.NewTicker(subTickInterval)
+ defer t.Stop()
+ h.tickSubscriptions(ctx) // 启动即跑一次,把停机期间欠的补上
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ h.tickSubscriptions(ctx)
+ }
+ }
+ }()
+ log.Printf("[sub] 订阅推进定时器已启动(每 %s 扫一次)", subTickInterval)
+}
+
+func (h *Handler) tickSubscriptions(ctx context.Context) {
+ subs := h.db.DueSubscriptions(ctx, 200)
+ if len(subs) == 0 {
+ return
+ }
+ now := time.Now()
+ var granted, expired int
+ for i := range subs {
+ g, exp, err := h.db.TickSubscription(ctx, &subs[i], now)
+ if err != nil {
+ // 单条失败不影响其它订阅;下一轮会重试(幂等,不会重复发)
+ log.Printf("[sub] ⚠️ 推进订阅 %s 失败: %v", subs[i].ID, err)
+ continue
+ }
+ granted += g
+ if exp {
+ expired++
+ }
+ }
+ // 只在有变化时记一行,避免空转刷屏
+ if granted+expired > 0 {
+ log.Printf("[sub] 推进:发放 %d 笔、过期 %d 条(本轮 %d 条订阅)", granted, expired, len(subs))
+ }
+}
diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go
index b532b69..013eca3 100644
--- a/sundynix-gateway/internal/handler/task_handler.go
+++ b/sundynix-gateway/internal/handler/task_handler.go
@@ -16,11 +16,11 @@ import (
"github.com/gin-contrib/sse"
"github.com/gin-gonic/gin"
- "github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/dsl"
"github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/payment"
"github.com/sundynix/sundynix-gateway/internal/store"
+ "github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-shared/contract"
)
@@ -139,7 +139,11 @@ func (h *Handler) startTokenRecorder(taskID string) {
func() {
_ = h.cache.StreamAppend(ctx, store.ChannelToken, taskID, "done", "")
if out.Len() > 0 {
- _ = h.db.SaveTaskOutput(context.Background(), taskID, out.String())
+ // 失败必须出声:这是**唯一**的持久副本(Redis 流 10min 后就没了),
+ // 丢了就再也复盘不了,界面上只会显示"这次运行没有输出"。
+ if err := h.db.SaveTaskOutput(context.Background(), taskID, out.String()); err != nil {
+ log.Printf("[task] ⚠️ 输出落库失败 task=%s len=%d: %v(该次运行将无法复盘)", taskID, out.Len(), err)
+ }
}
cancel()
},
@@ -170,8 +174,12 @@ func (h *Handler) startExecRecorder(taskID string) {
func() {
_ = h.cache.StreamAppend(ctx, store.ChannelExec, taskID, "done", "")
if len(evs) > 0 {
- if b, err := json.Marshal(evs); err == nil {
- _ = h.db.SaveTaskTrace(context.Background(), taskID, string(b))
+ // 同上:轨迹只有这一份持久副本,静默失败会表现为"这次运行没有轨迹"。
+ b, err := json.Marshal(evs)
+ if err != nil {
+ log.Printf("[task] ⚠️ 轨迹序列化失败 task=%s events=%d: %v(轨迹将丢失)", taskID, len(evs), err)
+ } else if err := h.db.SaveTaskTrace(context.Background(), taskID, string(b)); err != nil {
+ log.Printf("[task] ⚠️ 轨迹落库失败 task=%s events=%d: %v(该次运行将无法复盘)", taskID, len(evs), err)
}
}
cancel()
diff --git a/sundynix-gateway/internal/middleware/audit.go b/sundynix-gateway/internal/middleware/audit.go
index 8eab759..67f826f 100644
--- a/sundynix-gateway/internal/middleware/audit.go
+++ b/sundynix-gateway/internal/middleware/audit.go
@@ -2,6 +2,7 @@ package middleware
import (
"context"
+ "log"
"time"
"github.com/gin-gonic/gin"
@@ -35,6 +36,10 @@ func Audit(db *store.Postgres) gin.HandlerFunc {
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
- _ = db.AppendAudit(ctx, entry)
+ // best-effort:审计失败不阻断主流程(业务已经做完了),但必须留痕 ——
+ // 静默失败意味着敏感操作没有记录,而没人知道记录缺了。
+ if err := db.AppendAudit(ctx, entry); err != nil {
+ log.Printf("[audit] ⚠️ 审计留痕写入失败 actor=%s %s %s: %v", entry.Actor, entry.Action, entry.Path, err)
+ }
}
}
diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go
index 74d0841..f858cf4 100644
--- a/sundynix-gateway/internal/router/router.go
+++ b/sundynix-gateway/internal/router/router.go
@@ -7,18 +7,19 @@ import (
"log"
"net/http"
"os"
+ "path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
- "github.com/sundynix/sundynix-shared/blob"
"github.com/sundynix/sundynix-gateway/internal/handler"
"github.com/sundynix/sundynix-gateway/internal/middleware"
"github.com/sundynix/sundynix-gateway/internal/nats"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-gateway/internal/webui"
+ "github.com/sundynix/sundynix-shared/blob"
)
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
@@ -28,18 +29,19 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
r.Use(otelgin.Middleware("sundynix-gateway")) // OTel: HTTP server span(链路根 + 提取上游 traceparent)
r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
- r.Use(cors()) // 桌面端/浏览器跨源访问
- r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
- r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
- r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
- r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
- r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event)
+ r.Use(cors()) // 桌面端/浏览器跨源访问
+ r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
+ r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
+ r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
+ r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
+ r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event)
h := handler.New(db, cache, bus, blobStore)
// 微信支付渠道装配:DB 配置优先(admin 控制面热重载)→ env 兜底 → 隐藏。失败不阻断启动。
h.InitWechat(context.Background())
// 掉单补偿定时器:周期扫 pending 微信单确认到账(用户扫完码关页面也能补入账)。
h.StartReconcile(context.Background())
+ h.StartSubscriptionTicker(context.Background()) // 订阅按周期发放积分 + 到期置失效
// 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
@@ -49,73 +51,76 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
api := r.Group("/api/v1")
{
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
- api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
- api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
- api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
- api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
- api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
- api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
- api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
- api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址)
- api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx)
+ api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
+ api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
+ api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
+ api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
+ api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
+ api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
+ api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
+ api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
+ api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址)
+ api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx)
api.POST("/billing/callback/:channel", h.PaymentCallback) // 支付回调(渠道服务器带不了 Bearer;渠道验签是唯一的门)
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
p := api.Group("", middleware.RequireAuth())
{
p.POST("/tasks", middleware.RequireTenantRole(db, store.RoleMember), h.SubmitTask) // 提交任务(烧租户积分):viewer 只读拦下
- p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
- p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
- p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
- p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
- p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
- p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
+ p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
+ p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
+ p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户)
+ p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换)
+ p.POST("/me/tenants", h.CreateMyTenant) // 自助建组织(创建者即 owner,建完切入)
+ p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户
// 租户成员自助管理(薄 Web 面):作用于活跃租户,看 ≥viewer、写 ≥admin + 审计。
p.GET("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleViewer), h.TenantMembers)
p.POST("/tenants/current/members", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantAddMember)
p.PUT("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantSetMemberRole)
p.DELETE("/tenants/current/members/:uid", middleware.RequireTenantRole(db, store.RoleAdmin), middleware.Audit(db), h.TenantRemoveMember)
- p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
- p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
- p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
- p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
- p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
- p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区)
+ p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗)
+ p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
+ p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
+ p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
+ p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
+ p.GET("/kb/list", h.KbList) // 当前空间的知识库列表(共享工作区)
p.POST("/kb/create", middleware.RequireSpaceRole(db, store.RoleMember), h.KbCreate) // 新建知识库:空间只读 viewer 拦下
p.POST("/kb/ingest", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngest) // 文本入库:viewer 拦下
p.POST("/kb/ingest_file", middleware.RequireSpaceRole(db, store.RoleMember), h.KbIngestFile) // 文件入库:viewer 拦下
- p.POST("/kb/search", h.KbSearch) // 检索台(读,全员)
- p.GET("/kb/vault", h.KbVault) // 文库列表
- p.GET("/kb/doc", h.KbDoc) // 取单篇文档
+ p.POST("/kb/search", h.KbSearch) // 检索台(读,全员)
+ p.GET("/kb/vault", h.KbVault) // 文库列表
+ p.GET("/kb/doc", h.KbDoc) // 取单篇文档
p.DELETE("/kb/doc", middleware.RequireSpaceRole(db, store.RoleMember), h.KbDeleteDoc) // 级联删文档:viewer 拦下
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)
- p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
- p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
- p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
- p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
- p.GET("/kb/links", h.KbLinks) // 某库双链
- p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
- p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
- p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
+ p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
+ p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
+ p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
+ p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
+ p.GET("/kb/links", h.KbLinks) // 某库双链
+ p.POST("/kb/note", middleware.RequireSpaceRole(db, store.RoleMember), h.KbSaveNote) // 新建/编辑笔记:viewer 拦下
+ p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
+ p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人)
p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下
p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler)
// 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3)
- p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
- p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
- p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
- p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
- p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
- p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin)
- p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
- p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
- p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
+ p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
+ p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
+ p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
+ p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
+ p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
+ p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin)
+ p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
+ p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
+ p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下
p.GET("/billing", h.Billing)
// 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。
p.GET("/billing/packs", h.BillingPacks)
+ p.GET("/billing/sub-plans", h.BillingSubPlans) // 在售订阅套餐
+ p.GET("/billing/subscription", h.MySubscription) // 我的订阅(到期时间/发放次数)
p.GET("/billing/orders", h.BillingOrders)
p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认)
p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem)
@@ -133,9 +138,9 @@ 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) // 设置某模型输入/输出单价 + 积分权重
- admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关)
+ admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重)
+ admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重
+ admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关)
admin.PUT("/billing-config", h.SaveBillingConfig)
admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分
// 支付配置面(P5.1/P5.2):兑换码生成/查看 + 积分包配置 + 微信支付配置(DB 热生效)
@@ -145,31 +150,35 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.PUT("/packs", h.AdminSavePack)
admin.GET("/payment/wechat", h.AdminGetWechatPay)
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
- admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
- admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
+ admin.GET("/sub-plans", h.AdminSubPlans) // 订阅套餐(含下架)
+ admin.PUT("/sub-plans", h.AdminSaveSubPlan) // 配价格/时长/发放节奏
+ admin.GET("/subscriptions", h.AdminSubscriptions) // 全平台订阅观测
+ admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
+ admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计)
// 多租户成员管理(平台运维口径)
- admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
- admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
+ admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
+ admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关
admin.PUT("/tenants/:id/plan", h.AdminSetTenantPlan) // 方案等级
admin.PUT("/tenants/:id/status", h.AdminSetTenantStatus) // 租户状态
- admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
- admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
- admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
+ admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表
+ admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员
+ admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色
admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员
- admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
- admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
- admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
- admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
- 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) // 护栏命中安全事件流(倒序,翻页)
+ admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
+ admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
+ admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
+ admin.GET("/tasks/:id", h.AdminTaskDetail) // 任务下钻:DSL/输出/轨迹/评测(跨租户,走 WithoutTenant)
+ admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
+ 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) // 护栏命中安全事件流(倒序,翻页)
}
}
@@ -187,6 +196,20 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
c.JSON(http.StatusNotFound, gin.H{"error": "接口不存在"})
return
}
+ // 微信域名校验文件(MP_verify_xxx.txt):配置 JS安全域名/网页授权域名时微信要求
+ // 能从域名根目录直接访问。文件放 WECHAT_VERIFY_DIR(容器里挂宿主机目录),
+ // 不进镜像也不进 git。
+ // 放在 NoRoute 里而不是注册 /:mpfile —— 后者会匹配**所有单段路径**,
+ // 把官网的 /pricing、/download 全变成 404(实测踩过)。
+ if dir := os.Getenv("WECHAT_VERIFY_DIR"); dir != "" {
+ name := strings.TrimPrefix(p, "/")
+ // 只放行这一种文件名,且不含路径分隔符,避免变成任意文件下载口子
+ if strings.HasPrefix(name, "MP_verify_") && strings.HasSuffix(name, ".txt") &&
+ !strings.ContainsAny(name, `/\`) && !strings.Contains(name, "..") {
+ c.File(filepath.Join(dir, name))
+ return
+ }
+ }
if rel := strings.TrimPrefix(p, "/"); rel != "" {
if _, statErr := fs.Stat(adminDist, rel); statErr == nil {
adminServer.ServeHTTP(c.Writer, c.Request) // /assets/* 等真实文件
diff --git a/sundynix-gateway/internal/store/audit.go b/sundynix-gateway/internal/store/audit.go
index c85766d..ee011a3 100644
--- a/sundynix-gateway/internal/store/audit.go
+++ b/sundynix-gateway/internal/store/audit.go
@@ -1,6 +1,9 @@
package store
-import "context"
+import (
+ "context"
+ "strings"
+)
// AppendAudit 追加一条审计留痕(best-effort:审计失败不应影响主流程,调用方忽略返回)。
func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error {
@@ -10,14 +13,47 @@ func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error {
return p.db.WithContext(ctx).Create(a).Error
}
-// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页)。
-func (p *Postgres) ListAudit(ctx context.Context, limit, offset int) ([]AuditLog, error) {
+// AuditFilter 是审计流的服务端筛选条件。筛选必须落到 SQL:审计的用途是查证,
+// 若只在“当前页”里筛,搜不到就等于给出“没有这条记录”的错误结论。
+type AuditFilter struct {
+ Action string // HTTP 方法,精确匹配
+ Path string // 路径前缀
+ Q string // 跨 actor / ip / detail / path 的模糊匹配
+}
+
+// escapeLike 转义 LIKE 的通配符,让用户输入的 % 和 _ 按字面量匹配
+// (否则搜 "100%" 会退化成匹配任意串)。
+// 配套的 SQL 必须显式写 ESCAPE '\\':Postgres 默认就拿反斜杠当转义符,但 SQLite 不写
+// ESCAPE 就压根没有转义符——依赖隐式默认会在换库/单测时静默失效。
+func escapeLike(s string) string {
+ return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
+}
+
+// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页,筛选走 SQL)。
+func (p *Postgres) ListAudit(ctx context.Context, limit, offset int, f AuditFilter) ([]AuditLog, error) {
if p.db == nil {
return nil, errStoreDisabled
}
limit, offset = clampPage(limit, offset)
+ q := p.db.WithContext(ctx).Model(&AuditLog{})
+ if f.Action != "" {
+ q = q.Where("action = ?", f.Action)
+ }
+ if f.Path != "" {
+ q = q.Where(`path LIKE ? ESCAPE '\'`, escapeLike(f.Path)+"%")
+ }
+ if f.Q != "" {
+ like := "%" + strings.ToLower(escapeLike(f.Q)) + "%"
+ // 用 LOWER()+LIKE 而非 Postgres 专有的 ILIKE:语义一样,但 SQLite 也支持,
+ // 于是这段能被内存库单测覆盖(审计搜索量小,放弃索引可忽略)。
+ // 括号不能省:gorm 把每个 Where 以 AND 拼接,裸的 OR 串会让优先级变成
+ // `action = ? AND actor LIKE ? OR ip LIKE ? ...`,前面的条件直接失效。
+ q = q.Where(`(LOWER(actor) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\' `+
+ `OR LOWER(detail) LIKE ? ESCAPE '\' OR LOWER(path) LIKE ? ESCAPE '\')`,
+ like, like, like, like)
+ }
var out []AuditLog
- err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error
+ err := q.Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error
return out, err
}
diff --git a/sundynix-gateway/internal/store/audit_filter_test.go b/sundynix-gateway/internal/store/audit_filter_test.go
new file mode 100644
index 0000000..2e7d279
--- /dev/null
+++ b/sundynix-gateway/internal/store/audit_filter_test.go
@@ -0,0 +1,107 @@
+package store
+
+import (
+ "context"
+ "testing"
+)
+
+// 审计筛选必须落到 SQL。此前是前端在“当前页 50 条”里过滤,翻页外的记录搜不到 ——
+// 审计的用途就是查证,“搜不到”会被当成“没发生过”,所以这里把筛选语义固化成回归测试。
+
+func seedAudit(t *testing.T, p *Postgres, rows []AuditLog) {
+ t.Helper()
+ ctx := context.Background()
+ for i := range rows {
+ if rows[i].ID == "" {
+ rows[i].ID = rows[i].Actor + rows[i].Path + rows[i].IP + itoa(i)
+ }
+ if err := p.AppendAudit(ctx, &rows[i]); err != nil {
+ t.Fatalf("写审计失败: %v", err)
+ }
+ }
+}
+
+func itoa(i int) string { return string(rune('a' + i)) }
+
+func TestListAudit_FilterAction(t *testing.T) {
+ p := newTestStore(t)
+ seedAudit(t, p, []AuditLog{
+ {Actor: "u1", Action: "POST", Path: "/api/v1/admin/models", IP: "10.0.0.1"},
+ {Actor: "u2", Action: "DELETE", Path: "/api/v1/admin/models/9", IP: "10.0.0.2"},
+ })
+
+ got, err := ListAuditT(p, AuditFilter{Action: "DELETE"})
+ if err != nil {
+ t.Fatalf("查询失败: %v", err)
+ }
+ if len(got) != 1 || got[0].Actor != "u2" {
+ t.Fatalf("按方法筛应只剩 u2 那条,得 %d 条 %+v", len(got), got)
+ }
+}
+
+// 最关键的一条:q 的 OR 组必须带括号。少了括号时 SQL 会变成
+// `action = 'DELETE' AND actor LIKE .. OR ip LIKE .. OR ..`,
+// AND 优先级高于 OR → 只要 ip/detail/path 命中,action 条件就被绕过。
+func TestListAudit_ActionAndQueryAreConjunctive(t *testing.T) {
+ p := newTestStore(t)
+ seedAudit(t, p, []AuditLog{
+ {Actor: "alice", Action: "DELETE", Path: "/x", IP: "10.0.0.7"},
+ {Actor: "bob", Action: "GET", Path: "/y", IP: "10.0.0.7"}, // 同 IP 但方法不符
+ })
+
+ got, err := ListAuditT(p, AuditFilter{Action: "DELETE", Q: "10.0.0.7"})
+ if err != nil {
+ t.Fatalf("查询失败: %v", err)
+ }
+ if len(got) != 1 || got[0].Actor != "alice" {
+ t.Fatalf("方法与关键词应同时生效(AND),得 %d 条 %+v —— 括号可能丢了", len(got), got)
+ }
+}
+
+func TestListAudit_QueryMatchesAcrossFields(t *testing.T) {
+ p := newTestStore(t)
+ seedAudit(t, p, []AuditLog{
+ {Actor: "u1", Action: "POST", Path: "/tenants", IP: "1.1.1.1", Detail: "移除成员 zhang"},
+ {Actor: "u2", Action: "POST", Path: "/models", IP: "2.2.2.2", Detail: "保存模型"},
+ })
+
+ for _, tc := range []struct{ name, q, want string }{
+ {"命中 detail", "zhang", "u1"},
+ {"命中 ip", "2.2.2.2", "u2"},
+ {"命中 path", "/tenants", "u1"},
+ {"命中 actor", "u2", "u2"},
+ {"大小写不敏感", "ZHANG", "u1"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ListAuditT(p, AuditFilter{Q: tc.q})
+ if err != nil {
+ t.Fatalf("查询失败: %v", err)
+ }
+ if len(got) != 1 || got[0].Actor != tc.want {
+ t.Fatalf("q=%q 应只命中 %s,得 %d 条 %+v", tc.q, tc.want, len(got), got)
+ }
+ })
+ }
+}
+
+// 用户输入里的 % / _ 必须按字面量匹配,否则搜 "100%" 会退化成匹配任意串。
+func TestListAudit_QueryEscapesWildcards(t *testing.T) {
+ p := newTestStore(t)
+ seedAudit(t, p, []AuditLog{
+ {Actor: "u1", Action: "POST", Path: "/a", IP: "1.1.1.1", Detail: "折扣 100% 生效"},
+ {Actor: "u2", Action: "POST", Path: "/b", IP: "2.2.2.2", Detail: "无关记录"},
+ })
+
+ got, err := ListAuditT(p, AuditFilter{Q: "100%"})
+ if err != nil {
+ t.Fatalf("查询失败: %v", err)
+ }
+ if len(got) != 1 || got[0].Actor != "u1" {
+ t.Fatalf("%% 应按字面量匹配,得 %d 条 %+v", len(got), got)
+ }
+}
+
+// ListAuditT 是测试用的薄封装,省去每处都写 ctx/limit/offset。
+func ListAuditT(p *Postgres, f AuditFilter) ([]AuditLog, error) {
+ return p.ListAudit(context.Background(), 50, 0, f)
+}
diff --git a/sundynix-gateway/internal/store/credit.go b/sundynix-gateway/internal/store/credit.go
index 7432264..d11223a 100644
--- a/sundynix-gateway/internal/store/credit.go
+++ b/sundynix-gateway/internal/store/credit.go
@@ -57,7 +57,7 @@ func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64)
return err
}
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
- UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", creditsMicro)).Error
+ UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", creditsMicro)).Error
}
// upsertRollup 在事务内累加 租户/天 聚合。
@@ -165,6 +165,6 @@ func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, cred
return err
}
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
- UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", creditsMicro)).Error
+ UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", creditsMicro)).Error
})
}
diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go
index 15d4817..39aaf10 100644
--- a/sundynix-gateway/internal/store/models.go
+++ b/sundynix-gateway/internal/store/models.go
@@ -10,9 +10,9 @@ type User struct {
BaseModel
Email string `gorm:"uniqueIndex;size:255"`
Name string `gorm:"size:64"`
- PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
- ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
- ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
+ PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
+ ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
+ ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
}
// Task 是一次提交的 Agent 编排任务(DSL)。
@@ -35,8 +35,8 @@ func (Task) isTenantScoped() {}
// Eval 是一次任务的自动化评测结果(dispatcher 评完经 NATS 回写,每任务一条,按 task_id upsert)。
type Eval struct {
BaseModel
- TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
- Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
+ TenantID string `gorm:"size:64;index"` // 多租户作用域(SaveEval 从对应 task 复制)
+ Owner string `gorm:"size:64;index"` // 提交者 user.id(从对应 task 复制)
TaskID string `gorm:"uniqueIndex;size:64"`
Overall float64 // 综合分 [0,1]
Rule float64 // 规则分
@@ -85,12 +85,14 @@ func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" }
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
type Tenant struct {
BaseModel
- Name string `gorm:"size:128"`
- Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-)
- Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
- Status string `gorm:"size:16;default:active"` // active / suspended
- CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
- SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
+ Name string `gorm:"size:128"`
+ Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-)
+ Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
+ Status string `gorm:"size:16;default:active"` // active / suspended
+ // 只给 default,**不加 not null**:存量库里这一列有历史 NULL 行,AutoMigrate 若尝试
+ // SET NOT NULL 会直接失败(且它在回填之前跑)。空值由入账处的 coalesce + 启动回填兜住。
+ CreditBalanceMicro int64 `gorm:"column:credit_balance_micro;default:0"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
+ SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
}
func (Tenant) TableName() string { return "sundynix_tenant" }
diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go
index f373f93..8465672 100644
--- a/sundynix-gateway/internal/store/payment.go
+++ b/sundynix-gateway/internal/store/payment.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"errors"
+ "log"
"strings"
"time"
@@ -19,6 +20,12 @@ import (
// - CreditPack / RedeemCode 是平台级配置与凭证,不属于任何租户。
// 订单状态机:pending → paid | failed | expired;paid →(人工)refunded。
+const (
+ // 订单类型:一次性积分包 vs 订阅周期
+ OrderKindPack = "pack"
+ OrderKindSub = "sub"
+)
+
const (
OrderPending = "pending"
OrderPaid = "paid"
@@ -49,9 +56,11 @@ func (CreditPack) TableName() string { return "sundynix_credit_pack" }
// 兑换码入账也写一行(channel=redeem、amount_fen=0、即时 paid),全部充值一个查法。
type PaymentOrder struct {
BaseModel
- TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
- UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
- PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
+ TenantID string `gorm:"size:64;index" json:"tenant_id"` // 计费租户(下单时解析并锁定)
+ UserID string `gorm:"size:64;index" json:"user_id"` // 操作人(审计)
+ PackID string `gorm:"size:24" json:"pack_id"` // redeem 渠道为空
+ Kind string `gorm:"size:16;default:pack" json:"kind"` // pack=积分包(一次性) / sub=订阅
+ PlanID string `gorm:"size:24" json:"plan_id"` // kind=sub 时的订阅套餐
AmountFen int64 `gorm:"column:amount_fen" json:"amount_fen"`
CreditsMicro int64 `gorm:"column:credits_micro" json:"credits_micro"`
Channel string `gorm:"size:16;index" json:"channel"`
@@ -179,7 +188,7 @@ func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", tenantID).
- UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", rc.CreditsMicro)).Error; err != nil {
+ UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", rc.CreditsMicro)).Error; err != nil {
return err
}
order = o
@@ -219,6 +228,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
return false, errStoreDisabled
}
changed := false
+ var paid *PaymentOrder
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
now := time.Now()
res := tx.Model(&PaymentOrder{}).
@@ -234,19 +244,35 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
return err
}
- if err := tx.Create(&CreditLedger{
- TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
- }).Error; err != nil {
- return err
- }
- if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
- UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
- return err
+ paid = &o
+ // 订阅单自身不带积分(积分由订阅按周期发放),跳过零额分录避免账本噪声。
+ if o.CreditsMicro != 0 {
+ if err := tx.Create(&CreditLedger{
+ TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel,
+ }).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
+ UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", o.CreditsMicro)).Error; err != nil {
+ return err
+ }
}
changed = true
return nil
})
- return changed, err
+ if err != nil {
+ return changed, err
+ }
+ // 订阅开通放在这里、而不是各调用方:回调与掉单补偿两条路都经过 MarkOrderPaid,
+ // 放在这一处才没人能漏掉。ActivateSubscription 按 orderID 幂等,重复调用无害。
+ if paid != nil && paid.Kind == OrderKindSub && paid.PlanID != "" {
+ if _, aerr := p.ActivateSubscription(ctx, paid.TenantID, paid.PlanID, paid.ID); aerr != nil {
+ // 钱已收、订单已 paid:这里失败**不能**回滚订单(否则用户付了钱订单还回到 pending,
+ // 补偿定时器会再入账一次)。出声即可,可人工或下次回调补开通。
+ log.Printf("[payment] ⚠️ 订单 %s 已入账但订阅开通失败: %v(需人工补开通)", paid.ID, aerr)
+ }
+ }
+ return changed, nil
}
// RefundOrder 人工退款(PAYMENT_DESIGN §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
@@ -289,7 +315,7 @@ func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, mem
return err
}
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
- UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil {
+ UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", o.CreditsMicro)).Error; err != nil {
return err
}
changed = true
diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go
index cf4dac6..2c31230 100644
--- a/sundynix-gateway/internal/store/pgsql.go
+++ b/sundynix-gateway/internal/store/pgsql.go
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
migrateLegacyIntIDs(db)
migrateDocLinkToID(db)
- if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}); err != nil {
+ if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{}
}
@@ -80,6 +80,16 @@ func OpenPostgres(dsn string) *Postgres {
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
log.Printf("[store] 账本 adjust/ref 唯一索引创建失败(重复退款兜底闸缺位): %v", err)
}
+ // 回填历史 NULL 余额。credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
+ // 而入账用的是 `余额 + N` —— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**
+ // (分录照写、余额不动),且不报错。代码侧已改 coalesce 自愈,这里把存量一次修平,
+ // 让「余额 = SUM(ledger)」这条对账不变量重新成立。
+ if err := db.Exec(`UPDATE sundynix_tenant SET credit_balance_micro = COALESCE(
+ (SELECT SUM(credits_micro) FROM sundynix_credit_ledger l WHERE l.tenant_id = sundynix_tenant.id), 0)
+ WHERE credit_balance_micro IS NULL`).Error; err != nil {
+ log.Printf("[store] 历史 NULL 余额回填失败: %v", err)
+ }
+
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
return &Postgres{db: db}
diff --git a/sundynix-gateway/internal/store/subscription.go b/sundynix-gateway/internal/store/subscription.go
new file mode 100644
index 0000000..a231c15
--- /dev/null
+++ b/sundynix-gateway/internal/store/subscription.go
@@ -0,0 +1,299 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// 订阅(手动购买制)。刻意**不做自动续费**:微信 Native 扫码支付没有代扣能力,
+// 真自动续费要走「委托代扣」——另一套产品与资质。这里的语义是:
+//
+// 用户扫码买一个订阅周期 → 有效期内每 N 天发一次积分 → 到期即失效,要续得再买一次。
+//
+// N(间隔天数)与每次发多少积分都在套餐里配,后台可改。
+//
+// **发放语义是「累加」而非「重置」**:每次刷新写一条 grant 分录、余额累加,
+// 用不完的会留着,也绝不会清掉用户自己充值的积分。重置型(月度配额清零)会让
+// 「余额 = SUM(ledger)」这条对账不变量变复杂,且有误删用户已付费积分的风险,
+// 故不采用。
+
+// SubscriptionPlan 订阅套餐(价格 / 时长 / 发放节奏,全部后台可配)。
+type SubscriptionPlan struct {
+ BaseModel
+ Name string `gorm:"size:64" json:"name"`
+ PriceFen int64 `gorm:"column:price_fen" json:"price_fen"` // 售价(分)
+ DurationDays int `gorm:"column:duration_days" json:"duration_days"` // 一个订阅周期多少天
+ RefillCreditsMicro int64 `gorm:"column:refill_credits_micro" json:"refill_credits_micro"` // 每次发放的积分 ×10⁻⁶
+ RefillIntervalDays int `gorm:"column:refill_interval_days" json:"refill_interval_days"` // 每几天发一次
+ Active bool `json:"active"`
+ Sort int `json:"sort"`
+}
+
+func (SubscriptionPlan) TableName() string { return "sundynix_sub_plan" }
+
+// Subscription 一次已购订阅。到期即 expired,不自动续。
+type Subscription struct {
+ BaseModel
+ TenantID string `gorm:"size:64;index" json:"tenant_id"`
+ PlanID string `gorm:"size:24;index" json:"plan_id"`
+ OrderID string `gorm:"size:24" json:"order_id"` // 来源支付订单(人工发放为空)
+ Status string `gorm:"size:16;index" json:"status"`
+ StartedAt time.Time `json:"started_at"`
+ ExpiresAt time.Time `gorm:"index" json:"expires_at"`
+ RefillSeq int `gorm:"column:refill_seq" json:"refill_seq"` // 已发放次数;兼作幂等序号
+ LastRefillAt *time.Time `json:"last_refill_at"`
+}
+
+func (Subscription) TableName() string { return "sundynix_subscription" }
+func (Subscription) isTenantScoped() {} // 用户面只看得到自己租户的订阅;系统级扫描须 WithoutTenant
+
+const (
+ SubActive = "active"
+ SubExpired = "expired"
+)
+
+// refillRef 是一次发放的幂等键,落到 credit_ledger.ref。
+// credit_ledger 上 (kind='grant', ref) 的唯一索引是最终闸门:定时器重跑、多实例并发、
+// 手动补发,撞到同一序号都只会成功一次。
+func refillRef(subID string, seq int) string { return fmt.Sprintf("sub:%s:%d", subID, seq) }
+
+// isDupKey 判断是否唯一索引冲突(= 这一笔已经发过了,幂等成功而非失败)。
+func isDupKey(err error) bool {
+ if err == nil {
+ return false
+ }
+ s := strings.ToLower(err.Error())
+ return strings.Contains(s, "duplicate key") || strings.Contains(s, "unique constraint") ||
+ strings.Contains(s, "unique violation") || strings.Contains(s, "constraint failed")
+}
+
+// ---- 套餐配置 ----
+
+func (p *Postgres) ListSubPlans(ctx context.Context, onlyActive bool) []SubscriptionPlan {
+ if p.db == nil {
+ return nil
+ }
+ q := p.db.WithContext(WithoutTenant(ctx)).Order("sort asc, price_fen asc")
+ if onlyActive {
+ q = q.Where("active = ?", true)
+ }
+ var out []SubscriptionPlan
+ q.Find(&out)
+ return out
+}
+
+func (p *Postgres) GetSubPlan(ctx context.Context, id string) *SubscriptionPlan {
+ if p.db == nil || id == "" {
+ return nil
+ }
+ var pl SubscriptionPlan
+ if err := p.db.WithContext(WithoutTenant(ctx)).First(&pl, "id = ?", id).Error; err != nil {
+ return nil
+ }
+ return &pl
+}
+
+// SaveSubPlan 新增或更新套餐(id 空=新增)。
+func (p *Postgres) SaveSubPlan(ctx context.Context, pl *SubscriptionPlan) error {
+ if p.db == nil {
+ return errStoreDisabled
+ }
+ if pl.DurationDays <= 0 {
+ return errors.New("订阅时长必须大于 0 天")
+ }
+ if pl.RefillIntervalDays <= 0 {
+ return errors.New("发放间隔必须大于 0 天")
+ }
+ // 间隔比时长还长 = 一个周期内一次都发不到第二回,多半是配错了,直接拦下。
+ if pl.RefillIntervalDays > pl.DurationDays {
+ return errors.New("发放间隔不能大于订阅时长")
+ }
+ return p.db.WithContext(WithoutTenant(ctx)).Save(pl).Error
+}
+
+// ---- 订阅生命周期 ----
+
+// ActivateSubscription 支付成功后开通/续期,并立即发放第一笔积分。
+// 已有生效中的订阅则**顺延**到期时间(而不是新建一条),避免同租户多条 active 互相打架。
+// 幂等:同一 orderID 只会开通一次。
+func (p *Postgres) ActivateSubscription(ctx context.Context, tenantID, planID, orderID string) (*Subscription, error) {
+ if p.db == nil {
+ return nil, errStoreDisabled
+ }
+ pl := p.GetSubPlan(ctx, planID)
+ if pl == nil {
+ return nil, errors.New("订阅套餐不存在")
+ }
+ ctx = WithoutTenant(ctx) // 系统级:为目标租户开通,调用方可能是 admin
+ now := time.Now()
+
+ var sub *Subscription
+ err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // 幂等闸:同一订单已开通过就直接返回,不重复延期
+ if orderID != "" {
+ var exist Subscription
+ if err := tx.First(&exist, "order_id = ?", orderID).Error; err == nil {
+ sub = &exist
+ return nil
+ }
+ }
+ var cur Subscription
+ err := tx.Where("tenant_id = ? AND status = ?", tenantID, SubActive).
+ Order("expires_at desc").First(&cur).Error
+ switch {
+ case err == nil: // 续期:在原到期时间上顺延
+ cur.ExpiresAt = cur.ExpiresAt.AddDate(0, 0, pl.DurationDays)
+ cur.PlanID, cur.OrderID = pl.ID, orderID
+ if err := tx.Save(&cur).Error; err != nil {
+ return err
+ }
+ sub = &cur
+ return nil
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ s := &Subscription{
+ TenantID: tenantID, PlanID: pl.ID, OrderID: orderID, Status: SubActive,
+ StartedAt: now, ExpiresAt: now.AddDate(0, 0, pl.DurationDays),
+ }
+ if err := tx.Create(s).Error; err != nil {
+ return err
+ }
+ sub = s
+ return nil
+ default:
+ return err
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+ // 首笔发放走与定时器**同一套排期判断**(而不是无条件发一笔):否则同一订单重复
+ // 开通时(回调重推、查单与回调赛跑)会各发一笔,序号递增绕过幂等索引 —— 白送钱。
+ // 排期判断天然幂等:seq 已发过则下一笔的到期时间在未来,不会发。
+ // 放在事务外:发放失败不该导致"已付款却没开通",定时器下一轮会补上。
+ if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
+ return sub, nil // 开通已成功,发放失败交给定时器补
+ }
+ return sub, nil
+}
+
+// refillOnce 发放一次积分并推进序号。返回 granted=false 表示这一笔已发过(幂等)。
+func (p *Postgres) refillOnce(ctx context.Context, sub *Subscription, pl *SubscriptionPlan, now time.Time) (bool, error) {
+ if pl.RefillCreditsMicro <= 0 {
+ return false, nil
+ }
+ seq := sub.RefillSeq + 1
+ err := p.GrantCredits(ctx, sub.TenantID, LedgerGrant, pl.RefillCreditsMicro,
+ refillRef(sub.ID, seq), "订阅发放 "+pl.Name)
+ if err != nil {
+ if isDupKey(err) {
+ return false, nil // 已发过:幂等成功
+ }
+ return false, err
+ }
+ sub.RefillSeq = seq
+ sub.LastRefillAt = &now
+ return true, p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
+ Where("id = ?", sub.ID).
+ Updates(map[string]any{"refill_seq": seq, "last_refill_at": now}).Error
+}
+
+// DueSubscriptions 取到期需处理的订阅(系统级,跨租户)。
+func (p *Postgres) DueSubscriptions(ctx context.Context, limit int) []Subscription {
+ if p.db == nil {
+ return nil
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 200
+ }
+ var out []Subscription
+ p.db.WithContext(WithoutTenant(ctx)).
+ Where("status = ?", SubActive).Order("expires_at asc").Limit(limit).Find(&out)
+ return out
+}
+
+// TickSubscription 推进一条订阅:先看是否到期,再看是否该发放。
+// 返回 (发放笔数, 是否刚过期)。定时器与手动触发共用这一份逻辑,避免两处行为漂移。
+func (p *Postgres) TickSubscription(ctx context.Context, sub *Subscription, now time.Time) (int, bool, error) {
+ pl := p.GetSubPlan(ctx, sub.PlanID)
+ if pl == nil {
+ return 0, false, errors.New("订阅套餐已不存在: " + sub.PlanID)
+ }
+ granted := 0
+ // 补发:进程停机/定时器漏跑期间欠下的次数要一次性补齐,而不是只发最近一次。
+ // 上限用到期时间卡住——过期之后的周期一律不发。
+ for {
+ due := sub.StartedAt.AddDate(0, 0, pl.RefillIntervalDays*(sub.RefillSeq))
+ if due.After(now) || !due.Before(sub.ExpiresAt) {
+ break
+ }
+ ok, err := p.refillOnce(ctx, sub, pl, now)
+ if err != nil {
+ return granted, false, err
+ }
+ if ok {
+ granted++
+ }
+ if sub.RefillSeq > 1000 { // 防呆:配置异常(间隔 0)时不至于死循环
+ break
+ }
+ }
+ if now.After(sub.ExpiresAt) {
+ if err := p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
+ Where("id = ? AND status = ?", sub.ID, SubActive).
+ Update("status", SubExpired).Error; err != nil {
+ return granted, false, err
+ }
+ return granted, true, nil
+ }
+ return granted, false, nil
+}
+
+// ActiveSubscription 取某租户当前生效的订阅(用户面:账单页展示到期时间)。
+func (p *Postgres) ActiveSubscription(ctx context.Context, tenantID string) *Subscription {
+ if p.db == nil || tenantID == "" {
+ return nil
+ }
+ var s Subscription
+ if err := p.db.WithContext(WithoutTenant(ctx)).
+ Where("tenant_id = ? AND status = ?", tenantID, SubActive).
+ Order("expires_at desc").First(&s).Error; err != nil {
+ return nil
+ }
+ return &s
+}
+
+// AdminSubRow 是管理端订阅观测一行:订阅 + 租户名 + 套餐名。
+type AdminSubRow struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ TenantName string `json:"tenant_name"`
+ PlanName string `json:"plan_name"`
+ Status string `json:"status"`
+ StartedAt time.Time `json:"started_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ RefillSeq int `json:"refill_seq"`
+}
+
+// AllSubscriptions 全平台订阅(管理端观测,跨租户)。
+func (p *Postgres) AllSubscriptions(ctx context.Context, limit int) []AdminSubRow {
+ if p.db == nil {
+ return nil
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 200
+ }
+ var out []AdminSubRow
+ p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_subscription as s").
+ Select("s.id, s.tenant_id, s.status, s.started_at, s.expires_at, s.refill_seq, " +
+ "coalesce(t.name,'') as tenant_name, coalesce(pl.name,'') as plan_name").
+ Joins("left join sundynix_tenant t on t.id = s.tenant_id").
+ Joins("left join sundynix_sub_plan pl on pl.id = s.plan_id").
+ Where("s.deleted_at is null").
+ Order("s.expires_at desc").Limit(limit).Scan(&out)
+ return out
+}
diff --git a/sundynix-gateway/internal/store/subscription_test.go b/sundynix-gateway/internal/store/subscription_test.go
new file mode 100644
index 0000000..5ada32e
--- /dev/null
+++ b/sundynix-gateway/internal/store/subscription_test.go
@@ -0,0 +1,270 @@
+package store
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+// 订阅是涉及钱的路径:发多了是白送,发少了是欠付费用户的。这组测试钉死三件事——
+// 幂等(重跑不重复发)、补发(漏跑要补齐)、到期边界(过期后一分不发)。
+
+func seedPlan(t *testing.T, p *Postgres, durationDays, intervalDays int, credits int64) *SubscriptionPlan {
+ t.Helper()
+ pl := &SubscriptionPlan{
+ Name: "测试套餐", PriceFen: 9900, DurationDays: durationDays,
+ RefillCreditsMicro: credits, RefillIntervalDays: intervalDays, Active: true,
+ }
+ if err := p.SaveSubPlan(context.Background(), pl); err != nil {
+ t.Fatalf("建套餐失败: %v", err)
+ }
+ return pl
+}
+
+func balance(t *testing.T, p *Postgres, tenantID string) int64 {
+ t.Helper()
+ return p.TenantBalance(WithoutTenant(context.Background()), tenantID)
+}
+
+func TestSubscription_ActivateGrantsFirstRefill(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 30, 7, 100_000_000)
+
+ sub, err := p.ActivateSubscription(context.Background(), "t1", pl.ID, "order-1")
+ if err != nil {
+ t.Fatalf("开通失败: %v", err)
+ }
+ if sub.Status != SubActive {
+ t.Fatalf("应为 active,得 %q", sub.Status)
+ }
+ if got := balance(t, p, "t1"); got != 100_000_000 {
+ t.Fatalf("开通即应发第一笔,余额应 100e6,得 %d", got)
+ }
+ assertBalanceInvariant(t, p, "t1")
+}
+
+// 同一订单重复开通(回调重推 / 查单与回调赛跑)不能重复延期、不能重复发放。
+func TestSubscription_ActivateIsIdempotentPerOrder(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 30, 7, 100_000_000)
+ ctx := context.Background()
+
+ s1, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !s1.ExpiresAt.Equal(s2.ExpiresAt) {
+ t.Fatalf("同一订单重复开通不该延期:%v → %v", s1.ExpiresAt, s2.ExpiresAt)
+ }
+ if got := balance(t, p, "t1"); got != 100_000_000 {
+ t.Fatalf("重复开通不该重复发放,余额应仍为 100e6,得 %d", got)
+ }
+ assertBalanceInvariant(t, p, "t1")
+}
+
+// 续订(不同订单)应在原到期时间上顺延,而不是新建第二条 active。
+func TestSubscription_RenewExtendsInsteadOfDuplicating(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 30, 7, 100_000_000)
+ ctx := context.Background()
+
+ s1, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
+ s2, err := p.ActivateSubscription(ctx, "t1", pl.ID, "order-2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s1.ID != s2.ID {
+ t.Fatalf("续订应复用同一条订阅,得两条:%s / %s", s1.ID, s2.ID)
+ }
+ want := s1.ExpiresAt.AddDate(0, 0, 30)
+ if !s2.ExpiresAt.Equal(want) {
+ t.Fatalf("续订应顺延 30 天:want %v got %v", want, s2.ExpiresAt)
+ }
+ var n int64
+ p.db.WithContext(WithoutTenant(ctx)).Model(&Subscription{}).
+ Where("tenant_id = ? AND status = ?", "t1", SubActive).Count(&n)
+ if n != 1 {
+ t.Fatalf("同租户不应出现多条 active 订阅,得 %d 条", n)
+ }
+}
+
+// 定时器漏跑(进程停机数周)后要把欠下的次数一次补齐,而不是只补最近一次。
+func TestSubscription_TickBackfillsMissedRefills(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 30, 7, 100_000_000) // 30 天订阅,每 7 天发一次
+ ctx := context.Background()
+
+ sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1") // 已发第 1 笔
+ // 快进 22 天:第 7/14/21 天各应发一次,共补 3 笔
+ now := sub.StartedAt.AddDate(0, 0, 22)
+ granted, expired, err := p.TickSubscription(ctx, sub, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if expired {
+ t.Fatal("22 天时不该过期(周期 30 天)")
+ }
+ if granted != 3 {
+ t.Fatalf("应补发 3 笔(第 7/14/21 天),得 %d", granted)
+ }
+ if got := balance(t, p, "t1"); got != 400_000_000 {
+ t.Fatalf("首笔 + 补 3 笔 = 400e6,得 %d", got)
+ }
+ assertBalanceInvariant(t, p, "t1")
+}
+
+// 同一时刻重复 tick(多实例并发 / 定时器重叠)不能重复发放。
+func TestSubscription_TickIsIdempotent(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 30, 7, 100_000_000)
+ ctx := context.Background()
+
+ sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
+ now := sub.StartedAt.AddDate(0, 0, 8)
+
+ if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
+ t.Fatal(err)
+ }
+ before := balance(t, p, "t1")
+ // 再 tick 两次,余额不能变
+ for i := 0; i < 2; i++ {
+ if _, _, err := p.TickSubscription(ctx, sub, now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if after := balance(t, p, "t1"); after != before {
+ t.Fatalf("重复 tick 不该重复发放:%d → %d", before, after)
+ }
+ assertBalanceInvariant(t, p, "t1")
+}
+
+// 过期后一分不发,且状态置 expired(到期即失效,无自动续费)。
+func TestSubscription_ExpiresAndStopsGranting(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 14, 7, 100_000_000) // 14 天,7 天一发 → 最多发第 1、第 7 天两笔
+ ctx := context.Background()
+
+ sub, _ := p.ActivateSubscription(ctx, "t1", pl.ID, "order-1")
+ now := sub.StartedAt.AddDate(0, 0, 100) // 远超到期
+ granted, expired, err := p.TickSubscription(ctx, sub, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !expired {
+ t.Fatal("早该过期了")
+ }
+ // 到期时间点(第 14 天)之后的周期不发:第 7 天那笔算,第 14 天正好等于到期不算
+ if granted != 1 {
+ t.Fatalf("过期前只应补第 7 天那一笔,得 %d 笔", granted)
+ }
+ if got := balance(t, p, "t1"); got != 200_000_000 {
+ t.Fatalf("首笔 + 第 7 天 = 200e6,得 %d", got)
+ }
+
+ var s Subscription
+ p.db.WithContext(WithoutTenant(ctx)).First(&s, "id = ?", sub.ID)
+ if s.Status != SubExpired {
+ t.Fatalf("状态应为 expired,得 %q", s.Status)
+ }
+ assertBalanceInvariant(t, p, "t1")
+}
+
+// 配置校验:间隔比时长长 = 一个周期只发得到首笔,多半是配错了,直接拦。
+func TestSubPlan_RejectsBadConfig(t *testing.T) {
+ p := newTestStore(t)
+ ctx := context.Background()
+ for _, tc := range []struct {
+ name string
+ duration, ivl int
+ wantErrSubstring string
+ }{
+ {"时长为 0", 0, 7, "订阅时长"},
+ {"间隔为 0", 30, 0, "发放间隔"},
+ {"间隔大于时长", 7, 30, "不能大于"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := p.SaveSubPlan(ctx, &SubscriptionPlan{
+ Name: "x", DurationDays: tc.duration, RefillIntervalDays: tc.ivl, RefillCreditsMicro: 1,
+ })
+ if err == nil {
+ t.Fatal("应被拒绝")
+ }
+ })
+ }
+}
+
+var _ = time.Now
+
+// 订阅单经支付回调入账后必须真的开通订阅。这是"钱收了但订阅没生效"的高危点,
+// 而且开通逻辑刻意放在 MarkOrderPaid 里(回调与掉单补偿两条路共用),这里一并钉死。
+func TestSubscription_ActivatedByOrderPayment(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ pl := seedPlan(t, p, 30, 7, 100_000_000)
+ ctx := context.Background()
+
+ o := &PaymentOrder{
+ TenantID: "t1", UserID: "u1", Kind: OrderKindSub, PlanID: pl.ID,
+ AmountFen: pl.PriceFen, CreditsMicro: 0, // 订阅单自身不带积分
+ Channel: ChannelWechat, Status: OrderPending,
+ }
+ if err := p.CreateOrder(ctx, o); err != nil {
+ t.Fatal(err)
+ }
+ changed, err := p.MarkOrderPaid(ctx, o.ID, "txn-1")
+ if err != nil || !changed {
+ t.Fatalf("入账应成功: changed=%v err=%v", changed, err)
+ }
+
+ sub := p.ActiveSubscription(ctx, "t1")
+ if sub == nil {
+ t.Fatal("付款后应已开通订阅")
+ }
+ if sub.OrderID != o.ID {
+ t.Fatalf("订阅应关联来源订单 %s,得 %s", o.ID, sub.OrderID)
+ }
+ if got := balance(t, p, "t1"); got != 100_000_000 {
+ t.Fatalf("开通即发首笔,余额应 100e6,得 %d", got)
+ }
+ assertBalanceInvariant(t, p, "t1")
+
+ // 回调重复推送:不能重复开通、不能重复发放
+ if _, err := p.MarkOrderPaid(ctx, o.ID, "txn-1"); err != nil {
+ t.Fatal(err)
+ }
+ if got := balance(t, p, "t1"); got != 100_000_000 {
+ t.Fatalf("重复回调不该重复发放,得 %d", got)
+ }
+}
+
+// 余额列为 NULL 时入账必须仍然生效。
+// 真实事故:credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
+// 而入账语句是「余额 + N」——SQL 里 NULL + N = NULL,于是这些租户**充值永远不到账**,
+// 分录照写、余额不动、且不报错。本地库 42 个租户里有 11 个处于此状态。
+func TestGrantCredits_SurvivesNullBalance(t *testing.T) {
+ p := newTestStore(t)
+ ctx := context.Background()
+ seedTenant(t, p, "t-null")
+ // 造出"历史租户":把余额置回 NULL
+ if err := p.db.WithContext(WithoutTenant(ctx)).Exec(
+ "UPDATE sundynix_tenant SET credit_balance_micro = NULL WHERE id = ?", "t-null").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := p.GrantCredits(ctx, "t-null", LedgerGrant, 50_000_000, "ref-null", "充值"); err != nil {
+ t.Fatalf("入账失败: %v", err)
+ }
+ if got := balance(t, p, "t-null"); got != 50_000_000 {
+ t.Fatalf("NULL 余额的租户充值后应为 50e6,得 %d —— coalesce 丢了?", got)
+ }
+ assertBalanceInvariant(t, p, "t-null")
+}
diff --git a/sundynix-gateway/internal/store/task_detail_test.go b/sundynix-gateway/internal/store/task_detail_test.go
new file mode 100644
index 0000000..66a6cef
--- /dev/null
+++ b/sundynix-gateway/internal/store/task_detail_test.go
@@ -0,0 +1,105 @@
+package store
+
+import (
+ "context"
+ "testing"
+)
+
+// 管理端任务下钻必须跨租户读。Task/Eval 都在租户插件作用域内,若用请求 ctx 直接查,
+// 管理员看别的租户的任务不会报错,而是**静默返回空**——UI 上表现为"这任务没产出",
+// 比报错难查得多。这组测试把"跨租户可读"钉死。
+
+func seedTask(t *testing.T, p *Postgres, tenantID, taskID string) {
+ t.Helper()
+ task := &Task{
+ BaseModel: BaseModel{ID: taskID},
+ TenantID: tenantID, Owner: "u-" + tenantID, TaskID: taskID,
+ Status: "done", Output: "最终输出内容",
+ Trace: `[{"node":"n1","type":"llm","msg":"跑了一步"}]`,
+ Graph: `{"topic":"测试主题"}`,
+ }
+ // 显式 WithoutTenant 建数据:插件会按 ctx 覆写 tenant_id,不绕开就种不进指定租户。
+ if err := p.db.WithContext(WithoutTenant(context.Background())).Create(task).Error; err != nil {
+ t.Fatalf("建任务失败: %v", err)
+ }
+}
+
+// 前提验证:先证明租户过滤在测试环境里真的生效(用租户作用域的读去查别人的任务应查不到)。
+// 否则下面"跨租户能读到"的断言可能只是因为插件压根没装,属于假过。
+func TestTaskDetail_TenantScopeIsActuallyOn(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ seedTask(t, p, "t1", "task_a")
+
+ var got Task
+ err := p.db.WithContext(WithTenant(context.Background(), "t2")).
+ Where("task_id = ?", "task_a").First(&got).Error
+ if err == nil {
+ t.Fatal("租户过滤没生效:t2 的作用域竟能读到 t1 的任务,本测试文件的其余断言都不可信")
+ }
+}
+
+func TestTaskDetail_ReadsAcrossTenants(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ seedTask(t, p, "t1", "task_a")
+
+ // 以「另一个租户」的 ctx 调用——模拟管理员本人属于 t2、却要看 t1 的任务。
+ d := p.TaskDetail(WithTenant(context.Background(), "t2"), "task_a")
+ if d == nil {
+ t.Fatal("跨租户下钻取不到任务(TaskDetail 少了 WithoutTenant?)")
+ }
+ if d.Output != "最终输出内容" {
+ t.Fatalf("输出应完整读出,得 %q", d.Output)
+ }
+ if d.Trace == "" {
+ t.Fatal("轨迹不该为空")
+ }
+ if d.Topic != "测试主题" {
+ t.Fatalf("topic 应从 graph 里取出,得 %q", d.Topic)
+ }
+}
+
+func TestTaskDetail_MissingReturnsNil(t *testing.T) {
+ p := newTestStore(t)
+ if d := p.TaskDetail(context.Background(), "task_不存在"); d != nil {
+ t.Fatalf("不存在的任务应返回 nil,得 %+v", d)
+ }
+}
+
+func TestTaskDetail_EvalAttachedWhenPresent(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ seedTask(t, p, "t1", "task_a")
+
+ ctx := WithoutTenant(context.Background())
+ if err := p.db.WithContext(ctx).Create(&Eval{
+ BaseModel: BaseModel{ID: "ev1"}, TenantID: "t1", TaskID: "task_a",
+ Overall: 0.82, Level: "ok", Reason: "还行", Sources: 3,
+ }).Error; err != nil {
+ t.Fatalf("建评测失败: %v", err)
+ }
+
+ d := p.TaskDetail(WithTenant(context.Background(), "t2"), "task_a")
+ if d == nil || d.Eval == nil {
+ t.Fatal("有评测时应带出评测明细")
+ }
+ if d.Eval.Level != "ok" || d.Eval.Sources != 3 {
+ t.Fatalf("评测字段没读对: %+v", d.Eval)
+ }
+}
+
+// 没有评测的任务,eval 应为 nil 而不是零值——否则前端会把 0 分当成"评了 0 分"。
+func TestTaskDetail_NoEvalIsNil(t *testing.T) {
+ p := newTestStore(t)
+ seedTenant(t, p, "t1")
+ seedTask(t, p, "t1", "task_a")
+
+ d := p.TaskDetail(context.Background(), "task_a")
+ if d == nil {
+ t.Fatal("应能取到任务")
+ }
+ if d.Eval != nil {
+ t.Fatalf("无评测时 eval 应为 nil,得 %+v", d.Eval)
+ }
+}
diff --git a/sundynix-gateway/internal/store/task_query.go b/sundynix-gateway/internal/store/task_query.go
index 1cf77f4..3bf63d5 100644
--- a/sundynix-gateway/internal/store/task_query.go
+++ b/sundynix-gateway/internal/store/task_query.go
@@ -73,3 +73,60 @@ func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
}
return out
}
+
+// AdminTaskDetail 是管理端任务下钻:基本信息 + 持久化的输出/轨迹 + 评测。
+// 轨迹与输出取自 sundynix_task 的收尾落库列(Redis 流只有 10min TTL,历史任务只能靠它们)。
+type AdminTaskDetail struct {
+ AdminTaskRow
+ Graph string `json:"graph"` // 提交时的 DSL 原文
+ Output string `json:"output"` // 最终模型输出
+ Trace string `json:"trace"` // 执行轨迹事件 JSON 数组(原文透传,前端解析)
+ // gorm:"-":这是查完再单独填的组合字段,不是关联;不标记的话 Scan 会当成关系报错。
+ Eval *AdminEval `gorm:"-" json:"eval"` // 无评测时为 null
+}
+
+// AdminEval 是下钻里的评测明细(比列表页的 level/overall 多出评语与命中项)。
+type AdminEval struct {
+ Overall float64 `json:"overall"`
+ Rule float64 `json:"rule"`
+ LLM float64 `json:"llm"`
+ Faithful float64 `json:"faithful"`
+ Level string `json:"level"`
+ Flags string `json:"flags"`
+ Reason string `json:"reason"`
+ Sources int `json:"sources"`
+ Corrected bool `json:"corrected"`
+}
+
+// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
+// 必须 WithoutTenant:Task/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
+// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
+func (p *Postgres) TaskDetail(ctx context.Context, taskID string) *AdminTaskDetail {
+ if p.db == nil || taskID == "" {
+ return nil
+ }
+ ctx = WithoutTenant(ctx)
+ var d AdminTaskDetail
+ err := p.db.WithContext(ctx).Table("sundynix_task as t").
+ Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, "+
+ "t.output, t.trace, coalesce(cast(t.graph as text),'') as graph, "+
+ "coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, "+
+ "coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
+ "coalesce(t.graph->>'topic','') as topic").
+ Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
+ Joins("left join sundynix_user u on u.id = t.owner").
+ Joins("left join sundynix_eval e on e.task_id = t.task_id").
+ Where("t.task_id = ? and t.deleted_at is null", taskID).
+ Scan(&d).Error
+ if err != nil || d.TaskID == "" {
+ return nil
+ }
+ var e Eval
+ if p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error == nil {
+ d.Eval = &AdminEval{
+ Overall: e.Overall, Rule: e.Rule, LLM: e.LLM, Faithful: e.Faithful,
+ Level: e.Level, Flags: e.Flags, Reason: e.Reason, Sources: e.Sources, Corrected: e.Corrected,
+ }
+ }
+ return &d
+}
diff --git a/sundynix-gateway/internal/store/testdb_test.go b/sundynix-gateway/internal/store/testdb_test.go
index 45fc5c8..62c63c5 100644
--- a/sundynix-gateway/internal/store/testdb_test.go
+++ b/sundynix-gateway/internal/store/testdb_test.go
@@ -7,6 +7,7 @@ import (
"github.com/glebarez/sqlite" // 纯 Go sqlite(无 CGO):DB 背书的单测在 CI ubuntu 无 Postgres 服务时也能跑
"gorm.io/gorm"
"gorm.io/gorm/logger"
+ "gorm.io/gorm/schema"
)
// newTestStore 起一个内存 sqlite,迁移同款模型 + 建那道支付幂等兜底的部分唯一索引 +
@@ -15,7 +16,12 @@ import (
func newTestStore(t *testing.T) *Postgres {
t.Helper()
// 静音 gorm 日志:计费路径故意查 pricing/setting 取不到时回退默认,属预期空查询,别刷屏。
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ // 必须与 OpenPostgres 用同一套命名策略:多数模型有显式 TableName() 碰巧对得上,
+ // 但 Task 这类没有的会退化成 "tasks",于是写裸 SQL 的查询在测试里查无此表。
+ NamingStrategy: schema.NamingStrategy{TablePrefix: "sundynix_", SingularTable: true},
+ })
if err != nil {
t.Fatalf("打开内存 sqlite 失败: %v", err)
}
@@ -28,6 +34,7 @@ func newTestStore(t *testing.T) *Postgres {
if err := db.AutoMigrate(
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
+ &AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
&KB{}, // 租户作用域模型,验证隔离插件
); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
diff --git a/sundynix-mcp-go/Dockerfile b/sundynix-mcp-go/Dockerfile
index 95b285a..f630075 100644
--- a/sundynix-mcp-go/Dockerfile
+++ b/sundynix-mcp-go/Dockerfile
@@ -16,7 +16,12 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/mcp-go ./cmd/serve
# alpine 运行(内网 runner 连不上 gcr.io;二进制 CGO_ENABLED=0 全静态,ca-certs 供 TLS)。
FROM alpine:3.20
RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories \
- && apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app
+ && apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app \
+ # /data 存 bleve 全文索引:必须由 app 拥有(进程非 root),且 compose 要挂持久卷。
+ # 没有它的话 BLEVE_PATH 默认相对 CWD(=/),uid 10001 在 / 下建不了目录 → 静默退回
+ # 内存索引,全文路每次重启清零,而混合检索只会少一路召回、不报错。
+ && mkdir -p /data && chown app:app /data
USER app
+WORKDIR /data
COPY --from=build /out/mcp-go /mcp-go
ENTRYPOINT ["/mcp-go"]
diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go
index f6854a5..b193507 100644
--- a/sundynix-mcp-go/internal/mcp/gateway.go
+++ b/sundynix-mcp-go/internal/mcp/gateway.go
@@ -347,11 +347,24 @@ func (g *Gateway) kbSearch(ctx context.Context, call *contract.ToolCall) *contra
if v, ok := call.Args["topK"].(float64); ok && v > 0 {
topK = int(v)
}
- if !g.rag.Ready() {
- return &contract.ToolResult{OK: true, Content: "[]"}
- }
+ // 这里**不能**用 g.rag.Ready() 当总闸。Ready() 要求 embedding 已配置 + Milvus 已连,
+ // 但全文(bleve)与图谱(Neo4j)两路根本不依赖它们;一刀切返回 "[]" 会让"embedding 没配好"
+ // 表现为"整个知识库搜不到东西",且没有任何错误信息。改为让 searchPaths 逐路判定,
+ // 各路自己上报 disabled/error(见 rag.RouteDiag)。
// mode 空=生产混合检索(含 rerank);显式 vector/fulltext/graph/hybrid=评测用单路/纯融合(不 rerank)。
mode, _ := call.Args["mode"].(string)
+
+ // diag=true(仅检索试验台会传):返回 {hits, routes} 对象,带每一路的
+ // ok/empty/disabled/error 诊断。不传时保持裸数组,生产调用方不受影响。
+ if d, _ := call.Args["diag"].(bool); d {
+ hits, diags := g.rag.SearchByModeDiag(ctx, kb, q, topK, mode)
+ if hits == nil {
+ hits = []rag.Hit{}
+ }
+ data, _ := json.Marshal(map[string]any{"hits": hits, "routes": diags})
+ return &contract.ToolResult{OK: true, Content: string(data)}
+ }
+
var hits []rag.Hit
var err error
if mode == "" {
diff --git a/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go b/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go
new file mode 100644
index 0000000..24a17e6
--- /dev/null
+++ b/sundynix-mcp-go/internal/mcp/kb_search_diag_test.go
@@ -0,0 +1,94 @@
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/sundynix/sundynix-shared/contract"
+
+ "github.com/sundynix/sundynix-mcp-go/internal/rag"
+)
+
+// kb_search 的两种返回形状必须泾渭分明:
+// - 不带 diag(生产调用:dispatcher、Agent 工具链)→ 裸 JSON 数组,契约不能变;
+// - 带 diag(只有检索试验台会传)→ {hits, routes} 对象,带每一路的诊断。
+//
+// 把它钉住是因为这层是"改可观测性顺手改坏生产"的高风险位置:diag 分支若写成无条件
+// 生效,所有调用方拿到的就从数组变成了对象,工具链会静默解析失败。
+
+// newFulltextOnlyGateway 造一个只有全文路可用的 Gateway:无 embedding、无 Milvus、无 Neo4j。
+// 这正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。
+func newFulltextOnlyGateway(t *testing.T) *Gateway {
+ t.Helper()
+ t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve")
+ e := rag.Open(context.Background(), rag.Config{}) // 不给 Milvus/Neo4j/embedding
+ return &Gateway{rag: e}
+}
+
+func TestKbSearch_ProductionShapeIsBareArray(t *testing.T) {
+ g := newFulltextOnlyGateway(t)
+ res := g.kbSearch(context.Background(), &contract.ToolCall{
+ Tool: "kb_search",
+ Args: map[string]any{"kb": "k1", "q": "星间链路"},
+ })
+ if !res.OK {
+ t.Fatalf("不应失败: %s", res.Error)
+ }
+ var arr []map[string]any
+ if err := json.Unmarshal([]byte(res.Content), &arr); err != nil {
+ t.Fatalf("生产调用必须返回裸数组(工具链据此解析),得 %q: %v", res.Content, err)
+ }
+}
+
+func TestKbSearch_DiagShapeCarriesRoutes(t *testing.T) {
+ g := newFulltextOnlyGateway(t)
+ res := g.kbSearch(context.Background(), &contract.ToolCall{
+ Tool: "kb_search",
+ Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true, "mode": "hybrid"},
+ })
+ if !res.OK {
+ t.Fatalf("不应失败: %s", res.Error)
+ }
+ var out struct {
+ Hits []map[string]any `json:"hits"`
+ Routes []rag.RouteDiag `json:"routes"`
+ }
+ if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
+ t.Fatalf("diag 调用应返回 {hits,routes} 对象,得 %q: %v", res.Content, err)
+ }
+ if len(out.Routes) != 3 {
+ t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %d 条: %+v", len(out.Routes), out.Routes)
+ }
+ for _, r := range out.Routes {
+ if r.Status == "" {
+ t.Fatalf("每一路都必须有 status,否则界面仍无法解释这个空: %+v", r)
+ }
+ // 没配置的路必须给出原因——这正是当初排查半天的痛点。
+ if r.Status == "disabled" && r.Note == "" {
+ t.Fatalf("%s 路 disabled 却没写原因", r.Name)
+ }
+ }
+}
+
+// 未配置 embedding/Milvus 时不能整体短路返回空:以前用 rag.Ready() 当总闸,
+// 连不依赖它们的全文/图谱路一起毙掉,表现为"知识库什么都搜不到"且不报错。
+func TestKbSearch_NotShortCircuitedByReadiness(t *testing.T) {
+ g := newFulltextOnlyGateway(t)
+ if g.rag.Ready() {
+ t.Fatal("前提不成立:该引擎本应 not ready")
+ }
+ res := g.kbSearch(context.Background(), &contract.ToolCall{
+ Tool: "kb_search",
+ Args: map[string]any{"kb": "k1", "q": "星间链路", "diag": true},
+ })
+ var out struct {
+ Routes []rag.RouteDiag `json:"routes"`
+ }
+ if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
+ t.Fatalf("not ready 时也应正常走到各路诊断,得 %q", res.Content)
+ }
+ if len(out.Routes) == 0 {
+ t.Fatal("not ready 时被总闸短路了(返回空数组而非逐路诊断)")
+ }
+}
diff --git a/sundynix-mcp-go/internal/rag/bleve.go b/sundynix-mcp-go/internal/rag/bleve.go
index b9a660f..ab8952c 100644
--- a/sundynix-mcp-go/internal/rag/bleve.go
+++ b/sundynix-mcp-go/internal/rag/bleve.go
@@ -19,6 +19,9 @@ import (
// 落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。
type bleveStore struct {
idx bleve.Index
+ // persistent=false 表示退了内存兜底:功能在但重启即清零。这是**静默降级**——
+ // 混合检索只会少一路召回、不报错,所以必须上报出去(health → 控制台),否则没人发现。
+ persistent bool
}
// bleveMapping:text 字段用 cjk 分词器(bigram,能切中文,否则默认标准分词器把整段中文当一个
@@ -62,7 +65,7 @@ func openBleve() *bleveStore {
return memBleve()
}
log.Printf("[rag] bleve 全文索引落盘就绪: %s", path)
- return &bleveStore{idx: idx}
+ return &bleveStore{idx: idx, persistent: true}
}
// memBleve 内存兜底:落盘不可用时退回内存索引(功能在、重启会丢),好过全文路全哑。
@@ -123,9 +126,10 @@ func (b *bleveStore) deleteDoc(kb, doc string) error {
}
// search 全文检索(可按 kb 过滤),返回 BM25 排序的命中。
-func (b *bleveStore) search(kb, q string, topK int) []Hit {
+// 错误如实返回:以前吞掉错误只回 nil,全文路挂了看起来就只是"没召回"。
+func (b *bleveStore) search(kb, q string, topK int) ([]Hit, error) {
if !b.ready() || q == "" {
- return nil
+ return nil, nil
}
mq := bleve.NewMatchQuery(q)
mq.SetField("text")
@@ -140,7 +144,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
req.Fields = []string{"text"}
res, err := b.idx.Search(req)
if err != nil {
- return nil
+ return nil, err
}
var hits []Hit
for _, h := range res.Hits {
@@ -149,7 +153,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
hits = append(hits, Hit{Text: text, Score: float32(h.Score)})
}
}
- return hits
+ return hits, nil
}
func fnvHash(s string) uint64 {
diff --git a/sundynix-mcp-go/internal/rag/cjk_test.go b/sundynix-mcp-go/internal/rag/cjk_test.go
index 783e295..8b046e1 100644
--- a/sundynix-mcp-go/internal/rag/cjk_test.go
+++ b/sundynix-mcp-go/internal/rag/cjk_test.go
@@ -18,7 +18,10 @@ func TestBleve_ChineseSearch(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
- hits := b.search(kb, "星云一号的总设计师是谁", 5)
+ hits, err := b.search(kb, "星云一号的总设计师是谁", 5)
+ if err != nil {
+ t.Fatalf("全文检索报错: %v", err)
+ }
if len(hits) == 0 {
t.Fatal("中文全文检索应命中(CJK 分词),got 0 —— 分词器回归了?")
}
@@ -56,7 +59,7 @@ func TestBleve_PersistsAcrossReopen(t *testing.T) {
b2 := openBleve() // 模拟重启:同路径重开
defer b2.close()
- if hits := b2.search("kp", "龙渊号探测器", 5); len(hits) == 0 {
+ if hits, err := b2.search("kp", "龙渊号探测器", 5); err != nil || len(hits) == 0 {
t.Fatal("重开后应仍能检索到(落盘持久),got 0 —— 退回内存了?")
}
}
diff --git a/sundynix-mcp-go/internal/rag/degrade_test.go b/sundynix-mcp-go/internal/rag/degrade_test.go
new file mode 100644
index 0000000..3a152fa
--- /dev/null
+++ b/sundynix-mcp-go/internal/rag/degrade_test.go
@@ -0,0 +1,108 @@
+package rag
+
+import (
+ "context"
+ "testing"
+)
+
+// 这组测试钉死「一路挂了不能拖垮全部」以及「三种空必须分得清」。
+//
+// 背景:embedding 未配置时,kb_search 与 Search() 都用 Ready() 当总闸直接返回空——
+// 而全文(bleve)、图谱(Neo4j)两路根本不依赖 embedding/Milvus。结果是"模型配置没下发"
+// 表现为"整个知识库什么都搜不到",且无任何错误信息,排查时毫无线索。
+
+// newFulltextOnlyEngine 造一个只有全文路可用的引擎:无 embedding、无 Milvus、无 Neo4j,
+// 正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。
+func newFulltextOnlyEngine(t *testing.T) *Engine {
+ t.Helper()
+ t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve")
+ e := &Engine{bleve: openBleve(), graph: &graphStore{}}
+ t.Cleanup(func() { e.bleve.close() })
+ if !e.bleve.ready() {
+ t.Skip("bleve 不可用")
+ }
+ if err := e.bleve.index("k1", "d1", []string{
+ "星云一号卫星于2023年由长征七号发射,项目总设计师是李明华。",
+ "北斗增强终端解决了星间链路的可靠性难题。",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return e
+}
+
+// 没有 embedding/Milvus 时,全文路仍须照常召回——不能被总闸一刀切。
+func TestSearch_FulltextSurvivesWithoutEmbedding(t *testing.T) {
+ e := newFulltextOnlyEngine(t)
+ if e.Ready() {
+ t.Fatal("前提不成立:该引擎本应是 not ready(无 embedding/Milvus)")
+ }
+
+ hits, err := e.Search(context.Background(), "k1", "星间链路可靠性", 5)
+ if err != nil {
+ t.Fatalf("检索报错: %v", err)
+ }
+ if len(hits) == 0 {
+ t.Fatal("embedding 未配置时全文路仍应有召回;返回空说明总闸回归了")
+ }
+}
+
+// 诊断必须区分三种"空":没配置 / 报错 / 确实没匹配。
+func TestSearchByModeDiag_DistinguishesEmptyKinds(t *testing.T) {
+ e := newFulltextOnlyEngine(t)
+ ctx := context.Background()
+
+ _, diags := e.SearchByModeDiag(ctx, "k1", "星间链路可靠性", 5, "hybrid")
+ byName := map[string]RouteDiag{}
+ for _, d := range diags {
+ byName[d.Name] = d
+ }
+ if len(byName) != 3 {
+ t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %v", diags)
+ }
+
+ // 向量路:没配置 → disabled(不是 empty,更不是静默消失)
+ if got := byName["vector"].Status; got != "disabled" {
+ t.Fatalf("无 embedding 时向量路应为 disabled,得 %q", got)
+ }
+ if byName["vector"].Note == "" {
+ t.Fatal("disabled 必须给出原因,否则界面上仍是一个无法解释的空")
+ }
+ // 图谱路:Neo4j 未连 → disabled
+ if got := byName["graph"].Status; got != "disabled" {
+ t.Fatalf("无 Neo4j 时图谱路应为 disabled,得 %q", got)
+ }
+ // 全文路:有命中 → ok
+ if got := byName["fulltext"].Status; got != "ok" {
+ t.Fatalf("全文路应为 ok,得 %q(hits=%d err=%q)", got, byName["fulltext"].Hits, byName["fulltext"].Error)
+ }
+
+ // 换一个库里没有的词:全文路应是 empty(确实没匹配),而不是 disabled/error
+ _, diags2 := e.SearchByModeDiag(ctx, "k1", "完全不相关的查询内容xyz", 5, "fulltext")
+ for _, d := range diags2 {
+ if d.Name == "fulltext" && d.Status != "empty" {
+ t.Fatalf("无匹配时全文路应为 empty,得 %q", d.Status)
+ }
+ }
+}
+
+// 内存兜底(索引不持久)必须在诊断里说出来:命中数正常但数据随时会没,
+// 只看 hits 数量完全看不出来。
+func TestDiag_ReportsNonPersistentIndex(t *testing.T) {
+ e := &Engine{bleve: memBleve(), graph: &graphStore{}}
+ defer e.bleve.close()
+ if err := e.bleve.index("k1", "d1", []string{"星间链路可靠性难题"}); err != nil {
+ t.Fatal(err)
+ }
+ _, diags := e.SearchByModeDiag(context.Background(), "k1", "星间链路", 5, "fulltext")
+ for _, d := range diags {
+ if d.Name != "fulltext" {
+ continue
+ }
+ if d.Status != "ok" {
+ t.Fatalf("内存索引也应能召回,得 %q", d.Status)
+ }
+ if d.Note == "" {
+ t.Fatal("内存兜底必须在 note 里点明(重启即清零),否则运维看不出这是降级态")
+ }
+ }
+}
diff --git a/sundynix-mcp-go/internal/rag/graph.go b/sundynix-mcp-go/internal/rag/graph.go
index 28718ed..4a9cc2a 100644
--- a/sundynix-mcp-go/internal/rag/graph.go
+++ b/sundynix-mcp-go/internal/rag/graph.go
@@ -104,9 +104,10 @@ func (g *graphStore) deleteByFile(ctx context.Context, kb, fileID string) error
}
// search 图谱召回:找查询里提到的实体,返回其相连三元组(文本化)。
-func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []Hit {
+// 错误如实返回:Neo4j 不可用/查询失败以前一律回 nil,与"图谱里没有相关实体"无法区分。
+func (g *graphStore) search(ctx context.Context, kb, query string, limit int) ([]Hit, error) {
if !g.ready() || query == "" {
- return nil
+ return nil, nil
}
// 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串
// —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $q CONTAINS 会漏)。
@@ -118,7 +119,7 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
map[string]any{"kb": kb, "q": query, "ngrams": queryNgrams(query), "k": limit},
neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
if err != nil {
- return nil
+ return nil, err
}
var hits []Hit
for _, rec := range res.Records {
@@ -127,7 +128,7 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
o, _ := rec.Get("o")
hits = append(hits, Hit{Text: fmt.Sprintf("%v —%v→ %v", s, p, o), Score: 1})
}
- return hits
+ return hits, nil
}
// queryNgrams 生成查询的字符 n-gram(长度 2..8,去重并截断)——用于和图谱实体名做双向子串匹配,
diff --git a/sundynix-mcp-go/internal/rag/milvus.go b/sundynix-mcp-go/internal/rag/milvus.go
index 70ba642..8b67c18 100644
--- a/sundynix-mcp-go/internal/rag/milvus.go
+++ b/sundynix-mcp-go/internal/rag/milvus.go
@@ -221,8 +221,9 @@ func (m *milvusStore) search(ctx context.Context, kb string, qvec []float32, top
results, err = do()
}
if err != nil {
- // 集合尚未就绪/无法重建 → 降级空结果(不阻断混合检索其它路)。
- return nil, nil
+ // 如实上抛:调用方负责"不阻断其它路",但必须知道这一路是**失败**而非"没召回"。
+ // 以前这里回 nil,nil,向量路挂掉与检索无果彻底无法区分。
+ return nil, err
}
var hits []Hit
for _, r := range results {
diff --git a/sundynix-mcp-go/internal/rag/rag.go b/sundynix-mcp-go/internal/rag/rag.go
index 1da1f40..37cb44c 100644
--- a/sundynix-mcp-go/internal/rag/rag.go
+++ b/sundynix-mcp-go/internal/rag/rag.go
@@ -10,6 +10,7 @@ import (
"os"
"strconv"
"sync"
+ "time"
"github.com/sundynix/sundynix-shared/contract"
)
@@ -155,6 +156,9 @@ func (e *Engine) Status() map[string]bool {
"milvus": e.mv != nil,
"neo4j": e.graph.ready(),
"embedding": e.embed().ready(),
+ // 全文路是否落盘持久。false = 退了内存兜底,重启清零 —— 不上报的话这种降级
+ // 只表现为"召回变差",看不出故障(曾因此静默坏了很久)。
+ "fulltext_disk": e.bleve != nil && e.bleve.persistent,
}
}
@@ -230,17 +234,17 @@ func itoa(n int) string {
return string(b)
}
-// Search 混合检索:Milvus(向量) + Bleve(全文) → RRF 融合 → 可选 rerank → topK。降级时返回空。
+// Search 混合检索:Milvus(向量) + Bleve(全文) + Neo4j(图谱) → RRF 融合 → 可选 rerank → topK。
+// 注意这里**不再**用 Ready() 当总闸:Ready() 只代表"向量路可用",而全文/图谱两路不依赖
+// embedding 与 Milvus。以前一刀切返回空,导致 embedding 配置缺失时整个知识库像是"什么都搜不到",
+// 且无任何错误信息。现在各路独立判定,任一路可用就仍有召回(见 searchPaths 的 RouteDiag)。
func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit, error) {
- if !e.Ready() {
- return nil, nil
- }
if topK <= 0 {
topK = 5
}
fanout := topK * 3
- vecHits, ftHits, graphHits := e.searchPaths(ctx, kb, query, fanout)
+ vecHits, ftHits, graphHits, _ := e.searchPaths(ctx, kb, query, fanout)
// RRF 融合(三路,按文本去重)
cand := rrf([][]Hit{vecHits, ftHits, graphHits}, fanout)
log.Printf("[rag] hybrid: 向量=%d 全文=%d 图谱=%d → 融合=%d", len(vecHits), len(ftHits), len(graphHits), len(cand))
@@ -283,26 +287,129 @@ func (e *Engine) DeleteDoc(ctx context.Context, kb, fileID string) error {
return nil
}
-// searchPaths 跑三路召回,返回各路命中(供混合融合与离线评测按单路对比)。
-func (e *Engine) searchPaths(ctx context.Context, kb, query string, fanout int) (vec, ft, graph []Hit) {
- if vecs, err := e.embed().Embed(ctx, []string{query}); err == nil && len(vecs) > 0 {
- vec, _ = e.mv.search(ctx, kb, vecs[0], fanout)
+// RouteDiag 是一路召回的诊断。存在的理由:三路里任何一路挂掉都**不会报错**,
+// 只表现为召回变差——"这一路没配置"、"这一路报错了"、"这一路确实没匹配"
+// 在结果上完全一样(都是空数组),运维无从分辨。检索试验台据此告诉人是哪一环坏了。
+type RouteDiag struct {
+ Name string `json:"name"` // vector | fulltext | graph
+ Status string `json:"status"` // ok | empty | disabled | error
+ Hits int `json:"hits"` //
+ MS int64 `json:"ms"` // 该路耗时
+ Error string `json:"error,omitempty"` // status=error 时的原文
+ Note string `json:"note,omitempty"` // 给人看的解释
+}
+
+func diagOf(name string, hits []Hit, err error, disabled bool, note string, started time.Time) RouteDiag {
+ d := RouteDiag{Name: name, Hits: len(hits), MS: time.Since(started).Milliseconds(), Note: note}
+ switch {
+ case disabled:
+ d.Status = "disabled"
+ case err != nil:
+ d.Status, d.Error = "error", err.Error()
+ case len(hits) == 0:
+ d.Status = "empty"
+ default:
+ d.Status = "ok"
+ }
+ return d
+}
+
+// searchPaths 跑三路召回,返回各路命中 + 各路诊断(供混合融合、离线评测与检索试验台)。
+// 任何一路失败都不阻断其它路,但失败会被如实记录并打日志——绝不静默当成"没召回"。
+func (e *Engine) searchPaths(ctx context.Context, kb, query string, fanout int) (vec, ft, graph []Hit, diags []RouteDiag) {
+ // ── 向量路:embedding 与 Milvus 任一环节失败都要区分出来 ──
+ t := time.Now()
+ var vErr error
+ var vNote string
+ vDisabled := !e.embed().ready() || e.mv == nil
+ if vDisabled {
+ vNote = "embedding 未配置或 Milvus 未连接"
+ } else {
+ vecs, err := e.embed().Embed(ctx, []string{query})
+ switch {
+ case err != nil:
+ vErr, vNote = fmt.Errorf("embedding: %w", err), "查询向量化失败,向量路本次无贡献"
+ case len(vecs) == 0:
+ vErr, vNote = errors.New("embedding 返回空向量"), "向量化返回空结果"
+ default:
+ vec, vErr = e.mv.search(ctx, kb, vecs[0], fanout)
+ if vErr != nil {
+ vNote = "Milvus 检索失败"
+ } else if len(vec) == 0 {
+ vNote = "该知识库在 Milvus 中没有向量(未入库或集合被重建过)"
+ }
+ }
+ }
+ diags = append(diags, diagOf("vector", vec, vErr, vDisabled, vNote, t))
+
+ // ── 全文路 ──
+ t = time.Now()
+ ftDisabled := !e.bleve.ready()
+ var ftErr error
+ ftNote := ""
+ if ftDisabled {
+ ftNote = "全文索引未就绪"
+ } else {
+ ft, ftErr = e.bleve.search(kb, query, fanout)
+ if !e.bleve.persistent {
+ ftNote = "索引为内存兜底(重启已清零,历史文档需重新入库)"
+ }
+ }
+ diags = append(diags, diagOf("fulltext", ft, ftErr, ftDisabled, ftNote, t))
+
+ // ── 图谱路 ──
+ t = time.Now()
+ gDisabled := !e.graph.ready()
+ var gErr error
+ gNote := ""
+ if gDisabled {
+ gNote = "Neo4j 未连接或未配置"
+ } else {
+ graph, gErr = e.graph.search(ctx, kb, query, fanout)
+ }
+ diags = append(diags, diagOf("graph", graph, gErr, gDisabled, gNote, t))
+
+ for _, d := range diags {
+ if d.Status == "error" {
+ log.Printf("[rag] ⚠️ %s 路检索失败 kb=%s: %s", d.Name, kb, d.Error)
+ }
}
- ft = e.bleve.search(kb, query, fanout)
- graph = e.graph.search(ctx, kb, query, fanout)
return
}
+// SearchByModeDiag 与 SearchByMode 同源,额外返回各路诊断(检索试验台用)。
+// 试验台要回答的是"为什么这一路是空的",光有命中数回答不了。
+func (e *Engine) SearchByModeDiag(ctx context.Context, kb, query string, topK int, mode string) ([]Hit, []RouteDiag) {
+ if topK <= 0 {
+ topK = 5
+ }
+ fanout := topK * 3
+ vec, ft, graph, diags := e.searchPaths(ctx, kb, query, fanout)
+ var hits []Hit
+ switch mode {
+ case "vector":
+ hits = vec
+ case "fulltext":
+ hits = ft
+ case "graph":
+ hits = graph
+ default:
+ hits = rrf([][]Hit{vec, ft, graph}, fanout)
+ }
+ if len(hits) > topK {
+ hits = hits[:topK]
+ }
+ return hits, diags
+}
+
// SearchByMode 按指定模式返回 topK(评测用,纯检索不 rerank,便于公平对比)。
// mode: vector|fulltext|graph|hybrid(RRF)。
func (e *Engine) SearchByMode(ctx context.Context, kb, query string, topK int, mode string) []Hit {
- if !e.Ready() || topK <= 0 {
- if topK <= 0 {
- topK = 5
- }
+ if topK <= 0 {
+ topK = 5
}
fanout := topK * 3
- vec, ft, graph := e.searchPaths(ctx, kb, query, fanout)
+ vec, ft, graph, _ := e.searchPaths(ctx, kb, query, fanout)
var hits []Hit
switch mode {
case "vector":
diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts
index 7b50adb..32d6039 100644
--- a/sundynix-web/src/api.ts
+++ b/sundynix-web/src/api.ts
@@ -274,18 +274,53 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
export async function createWechatOrder(
- packId: string,
+ target: { packId: string } | { planId: string },
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
+ // 积分包与订阅走同一条支付链路(下单/回调/查单/掉单补偿全复用),只是订单内容不同。
+ const body = "packId" in target ? { pack_id: target.packId } : { plan_id: target.planId };
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
- body: JSON.stringify({ pack_id: packId }),
+ body: JSON.stringify(body),
}),
);
return jsonOrThrow(res, "下单失败");
}
+// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
+export interface SubPlan {
+ id: string;
+ name: string;
+ price_fen: number;
+ duration_days: number;
+ refill_credits_micro: number;
+ refill_interval_days: number;
+}
+
+export interface MySub {
+ id: string;
+ plan_id: string;
+ status: string;
+ started_at: string;
+ expires_at: string;
+ refill_seq: number;
+}
+
+export async function subPlans(): Promise {
+ const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/sub-plans`, { headers: bearer() }));
+ if (!res.ok) return [];
+ return ((await res.json()) as { plans?: SubPlan[] }).plans ?? [];
+}
+
+// 我的订阅(无则 null)。到期即失效、不自动续费,所以到期时间要显眼地告诉用户。
+export async function mySubscription(): Promise<{ subscription: MySub | null; plan: SubPlan | null }> {
+ const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() }));
+ if (!res.ok) return { subscription: null, plan: null };
+ const d = (await res.json()) as { subscription?: MySub | null; plan?: SubPlan | null };
+ return { subscription: d.subscription ?? null, plan: d.plan ?? null };
+}
+
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
diff --git a/sundynix-web/src/pages/Usage.tsx b/sundynix-web/src/pages/Usage.tsx
index a3db9d8..61ac463 100644
--- a/sundynix-web/src/pages/Usage.tsx
+++ b/sundynix-web/src/pages/Usage.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react";
import { useTenant } from "../shell/AppShell";
-import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, type MyUsage, type TopupOrder, type Pack } from "../api";
+import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, subPlans, mySubscription, type MyUsage, type TopupOrder, type Pack, type SubPlan, type MySub } from "../api";
import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui";
// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。
@@ -17,7 +17,10 @@ export function Usage() {
const [busy, setBusy] = useState(false);
const [packs, setPacks] = useState([]);
const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮
- const [paying, setPaying] = useState(null); // 正在扫码支付的包
+ const [paying, setPaying] = useState(null); // 正在扫码支付的商品(积分包或订阅)
+ const [plans, setPlans] = useState([]);
+ const [sub, setSub] = useState(null);
+ const [subPlan, setSubPlan] = useState(null);
const load = useCallback(() => {
myUsage(days).then(setU).catch(() => {});
@@ -28,6 +31,13 @@ export function Usage() {
setWechatOn(r.channels.includes("wechat"));
})
.catch(() => {});
+ subPlans().then(setPlans).catch(() => {});
+ mySubscription()
+ .then((r) => {
+ setSub(r.subscription);
+ setSubPlan(r.plan);
+ })
+ .catch(() => {});
}, [days]);
useEffect(load, [load, ctx?.tenant?.id]);
@@ -76,6 +86,55 @@ export function Usage() {
{canTopup ? (
{/* 微信扫码:服务端配了商户号且有在售包才出现 */}
+ {/* 订阅:到期即失效、不自动续费,所以"还剩几天"必须显眼——用户不会收到扣款提醒 */}
+ {sub && subPlan && (
+
+
+
+ 订阅中 · {subPlan.name}
+
+
+
+
+ 每 {subPlan.refill_interval_days} 天发放{" "}
+ {fmtCredits(subPlan.refill_credits_micro)} 积分 ·
+ 已发放 {sub.refill_seq} 次 ·
+ 到期后不再发放,需重新购买
+
+
+ )}
+
+ {wechatOn && plans.length > 0 && canTopup && (
+
+
+ {sub ? "续订(在当前到期时间上顺延)" : "订阅"}
+
+
+ {plans.map((pl) => {
+ const times = pl.refill_interval_days > 0 ? Math.floor(pl.duration_days / pl.refill_interval_days) : 0;
+ return (
+
+ );
+ })}
+
+
+ )}
+
{wechatOn && packs.length > 0 && (
@@ -83,7 +142,11 @@ export function Usage() {
{packs.map((p) => (
- |