From f8f73597237b1078c5e87b330e8e2857006363e8 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 16:28:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=AE=A2=E9=98=85=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=20+=20=E4=BF=AE=E3=80=8C=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E5=88=97=E4=B8=BA=20NULL=20=E5=AF=BC=E8=87=B4=E5=85=85?= =?UTF-8?q?=E5=80=BC=E6=B0=B8=E4=B8=8D=E5=88=B0=E8=B4=A6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期 发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在 配的时候就看见后果,而不是上线后才发现只发了一次。 顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招): credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是 「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**: 分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的, 不是订阅引入的问题。 三处修: - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈; - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立); - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate 尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。 回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给 模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。 真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额 2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。 Co-Authored-By: Claude Opus 4.8 --- sundynix-admin/src/api.ts | 53 ++++ sundynix-admin/src/pages/SubscriptionPage.tsx | 241 ++++++++++++++++++ sundynix-admin/src/routes.tsx | 9 + sundynix-gateway/internal/store/credit.go | 4 +- sundynix-gateway/internal/store/models.go | 24 +- sundynix-gateway/internal/store/payment.go | 6 +- sundynix-gateway/internal/store/pgsql.go | 10 + .../internal/store/subscription_test.go | 22 ++ 8 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 sundynix-admin/src/pages/SubscriptionPage.tsx diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 1bed299..c7466d9 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -333,6 +333,59 @@ export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder return { order: d.order as PayOrder, warn: d.warn }; } +// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)---- +export interface SubPlan { + id: string; + name: string; + price_fen: number; + duration_days: number; // 一个周期多少天 + refill_credits_micro: number; // 每次发放的积分 + refill_interval_days: number; // 每几天发一次 + active: boolean; + sort: number; +} + +export interface SubRow { + id: string; + tenant_id: string; + tenant_name: string; + plan_name: string; + status: string; // active / expired + started_at: string; + expires_at: string; + refill_seq: number; // 已发放次数 +} + +export async function adminSubPlans(): Promise { + const res = guard(await fetch(`${ADMIN}/sub-plans`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as { plans?: SubPlan[]; error?: string }; + if (!res.ok) throw new Error(d.error ?? `sub plans failed: ${res.status}`); + return d.plans ?? []; +} + +// refill_credits 以「积分」为单位(面向人),服务端转 micro。 +export async function saveSubPlan(p: { + id?: string; + name: string; + price_fen: number; + duration_days: number; + refill_credits: number; + refill_interval_days: number; + active: boolean; + sort: number; +}): Promise { + const res = guard(await fetch(`${ADMIN}/sub-plans`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) })); + const d = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`); +} + +export async function adminSubscriptions(): Promise { + const res = guard(await fetch(`${ADMIN}/subscriptions`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as { subscriptions?: SubRow[]; error?: string }; + if (!res.ok) throw new Error(d.error ?? `subscriptions failed: ${res.status}`); + return d.subscriptions ?? []; +} + // 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。 // status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。 export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> { diff --git a/sundynix-admin/src/pages/SubscriptionPage.tsx b/sundynix-admin/src/pages/SubscriptionPage.tsx new file mode 100644 index 0000000..36cc391 --- /dev/null +++ b/sundynix-admin/src/pages/SubscriptionPage.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from "react"; +import { adminSubPlans, saveSubPlan, adminSubscriptions, type SubPlan, type SubRow } from "../api"; + +// 支付 · 订阅:套餐配置(价格/时长/发放节奏)+ 全平台订阅观测。 +// +// 语义要在界面上讲清楚,否则配错了很难发现: +// - 手动购买制,**不自动续费**(微信 Native 无代扣能力),到期即失效; +// - 期内每 N 天发一次积分,发放是**累加**(不清零用户已有积分); +// - 因此一个周期实际发放次数 = floor(时长 / 间隔),配的时候直接算给人看。 +const MICRO = 1_000_000; +const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); +const yuan = (fen: number) => (fen / 100).toFixed(2); + +export function SubscriptionPage() { + return ( +
+
+

支付 · 订阅

+

+ 订阅套餐定价与发放节奏 · 全平台订阅状态。手动购买制,到期即失效,不自动续费 +

+
+ + +
+ ); +} + +function PlansBlock() { + const [rows, setRows] = useState([]); + const [name, setName] = useState(""); + const [price, setPrice] = useState(""); + const [duration, setDuration] = useState("30"); + const [refill, setRefill] = useState(""); + const [interval, setIntervalDays] = useState("7"); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const load = () => adminSubPlans().then(setRows).catch((e) => setErr((e as Error).message)); + useEffect(() => { + void load(); + }, []); + + const add = async () => { + const p = Number(price), d = Number(duration), r = Number(refill), i = Number(interval); + if (!name.trim() || !p || !d || !r || !i || busy) return; + setBusy(true); + setErr(""); + try { + await saveSubPlan({ + name: name.trim(), price_fen: Math.round(p * 100), duration_days: d, + refill_credits: r, refill_interval_days: i, active: true, sort: 0, + }); + setName(""); setPrice(""); setRefill(""); + await load(); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + + const toggle = async (p: SubPlan) => { + try { + await saveSubPlan({ + id: p.id, name: p.name, price_fen: p.price_fen, duration_days: p.duration_days, + refill_credits: p.refill_credits_micro / MICRO, refill_interval_days: p.refill_interval_days, + active: !p.active, sort: p.sort, + }); + await load(); + } catch (e) { + setErr((e as Error).message); + } + }; + + // 一个周期发几次:让人在配的时候就看见后果,而不是上线后才发现只发了一次。 + const times = (p: { duration_days: number; refill_interval_days: number }) => + p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0; + + const previewTimes = times({ duration_days: Number(duration) || 0, refill_interval_days: Number(interval) || 0 }); + const previewTotal = previewTimes * (Number(refill) || 0); + + return ( +
+
+

订阅套餐

+ 积分按周期发放且累加,不清零用户已有积分 +
+ +
+ + + + + + +
+ + {/* 配置后果预览:省得上线后才发现节奏配错 */} + {previewTimes > 0 && ( +

+ 一个周期发 {previewTimes} 次,合计{" "} + {previewTotal.toLocaleString("zh-CN")} 积分 + {Number(duration) % Number(interval) !== 0 && ( + ({Number(duration)} 不能被 {Number(interval)} 整除,最后一次发放后到期前会有空档) + )} +

+ )} + {err &&

{err}

} + +
+ + + + + + + + + + + + + {rows.map((p) => ( + + + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
名称售价时长发放节奏周期总积分状态
{p.name}¥{yuan(p.price_fen)}{p.duration_days} 天 + 每 {p.refill_interval_days} 天 · {credits(p.refill_credits_micro)} + + {credits(p.refill_credits_micro * times(p))} + ({times(p)} 次) + + +
还没有订阅套餐
+
+
+ ); +} + +function SubsBlock() { + const [rows, setRows] = useState([]); + const [err, setErr] = useState(""); + + useEffect(() => { + adminSubscriptions().then(setRows).catch((e) => setErr((e as Error).message)); + }, []); + + const now = Date.now(); + const daysLeft = (iso: string) => Math.ceil((new Date(iso).getTime() - now) / 86400000); + + return ( +
+
+

全平台订阅

+ 到期即失效,不自动续费;剩余天数少的排在前面 +
+ {err &&

{err}

} + + + + + + + + + + + + {rows.map((s) => { + const left = daysLeft(s.expires_at); + return ( + + + + + + + + ); + })} + {rows.length === 0 && ( + + + + )} + +
租户套餐状态已发放到期
{s.tenant_name || s.tenant_id}{s.plan_name || "—"} + + {s.status === "active" ? "生效中" : "已过期"} + + {s.refill_seq} 次 + {new Date(s.expires_at).toLocaleDateString("zh-CN")} + {s.status === "active" && ( + // 快到期的要显眼:到期即失效,没有自动续费兜底 + + 剩 {left} 天 + + )} +
还没有人订阅
+
+ ); +} 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-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 e3054e3..8465672 100644 --- a/sundynix-gateway/internal/store/payment.go +++ b/sundynix-gateway/internal/store/payment.go @@ -188,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 @@ -253,7 +253,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string 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 } } @@ -315,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 9687d4e..2c31230 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -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_test.go b/sundynix-gateway/internal/store/subscription_test.go index 4a2fa59..5ada32e 100644 --- a/sundynix-gateway/internal/store/subscription_test.go +++ b/sundynix-gateway/internal/store/subscription_test.go @@ -246,3 +246,25 @@ func TestSubscription_ActivatedByOrderPayment(t *testing.T) { 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") +}