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:
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user