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>
This commit is contained in:
Blizzard
2026-07-20 16:28:32 +08:00
parent 80d4aaf4ba
commit f8f7359723
8 changed files with 353 additions and 16 deletions
+53
View File
@@ -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<SubPlan[]> {
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<void> {
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<SubRow[]> {
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 }> {
@@ -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 (
<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>
);
}
+9
View File
@@ -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: <PaymentConfigPage />,
},
{
path: "payment/subscription",
label: "订阅",
group: "运维",
parent: "支付",
ready: true,
element: <SubscriptionPage />,
},
{
path: "payment/orders",
label: "订单与对账",