Files
sundynix-agentix/sundynix-admin/src/pages/SubscriptionPage.tsx
T
Blizzard f8f7359723 feat(admin): 订阅管理页 + 修「余额列为 NULL 导致充值永不到账」
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。

顺带修了个真 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 <noreply@anthropic.com>
2026-07-20 16:28:32 +08:00

242 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="space-y-5">
<div className="border-b border-gray-200 pb-4">
<h3 className="text-base font-semibold text-gray-800"> · </h3>
<p className="text-xs text-gray-400">
·
</p>
</div>
<PlansBlock />
<SubsBlock />
</div>
);
}
function PlansBlock() {
const [rows, setRows] = useState<SubPlan[]>([]);
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 (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"></span>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-gray-500">
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:专业版月付"
className="mt-1 block w-36 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
¥
<input value={price} onChange={(e) => setPrice(e.target.value)} inputMode="decimal" placeholder="99"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={duration} onChange={(e) => setDuration(e.target.value)} inputMode="numeric"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={refill} onChange={(e) => setRefill(e.target.value)} inputMode="numeric" placeholder="1000"
className="mt-1 block w-28 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={interval} onChange={(e) => setIntervalDays(e.target.value)} inputMode="numeric"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<button onClick={() => void add()} disabled={busy}
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "…" : "新增"}
</button>
</div>
{/* 配置后果预览:省得上线后才发现节奏配错 */}
{previewTimes > 0 && (
<p className="mt-2 text-[11px] text-gray-500">
<b className="text-violet-600">{previewTimes}</b> {" "}
<b className="text-violet-600">{previewTotal.toLocaleString("zh-CN")}</b>
{Number(duration) % Number(interval) !== 0 && (
<span className="text-amber-600">{Number(duration)} {Number(interval)} </span>
)}
</p>
)}
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
<div className="mt-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{yuan(p.price_fen)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{p.duration_days} </td>
<td className="py-2 pr-3 text-right text-[11px] tabular-nums text-gray-600">
{p.refill_interval_days} · {credits(p.refill_credits_micro)}
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">
{credits(p.refill_credits_micro * times(p))}
<span className="ml-1 text-[10px] text-gray-400">({times(p)} )</span>
</td>
<td className="py-2 text-right">
<button onClick={() => void toggle(p)}
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function SubsBlock() {
const [rows, setRows] = useState<SubRow[]>([]);
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 (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"></span>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const left = daysLeft(s.expires_at);
return (
<tr key={s.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{s.tenant_name || s.tenant_id}</td>
<td className="py-2 pr-3 text-gray-600">{s.plan_name || "—"}</td>
<td className="py-2 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${s.status === "active" ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-500"}`}>
{s.status === "active" ? "生效中" : "已过期"}
</span>
</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-600">{s.refill_seq} </td>
<td className="py-2 text-right text-[11px] tabular-nums">
<span className="text-gray-500">{new Date(s.expires_at).toLocaleDateString("zh-CN")}</span>
{s.status === "active" && (
// 快到期的要显眼:到期即失效,没有自动续费兜底
<span className={`ml-1.5 ${left <= 3 ? "font-medium text-rose-600" : left <= 7 ? "text-amber-600" : "text-gray-400"}`}>
{left}
</span>
)}
</td>
</tr>
);
})}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
);
}