feat(admin): 积分包支持就地生成付款码,验证支付链路

管理端「支付 · 配置 → 积分包」每行加「生成付款码」:下单 → 二维码 → 盯到终态,
用来确认「渠道配置 → 下单 → 扫码 → 回调/查单 → 幂等入账 → 积分到账」在当前
环境真的通。

刻意走用户面的 /api/v1/billing/*,不做 admin 专用旁路 —— 造条测试专线的话,
验过了也不能说明线上用户那条路通。租户由服务端按登录用户解析,无需带租户头。

代价是这会真实扣款,所以:
- 弹窗顶部显著提示是真单、会真扣钱,并指向「订单与对账」的退款入口;
- 已下架的包按钮禁用(服务端本就会拒,别让人白扫)。

弹窗展示订单号/状态/轮询次数/到账积分,出问题时能直接判断卡在哪一环,
比只说一句"成功/失败"有用。轮询策略与 sundynix-web 支付弹窗保持一致:
递归 setTimeout、切后台暂停、失败指数退避、倒计时以服务端 expires_at 为准。

依赖:admin 新增 qrcode(懒加载进 PaymentConfigPage 分块,不进主包)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 12:52:24 +08:00
parent 70b0419387
commit 2c9ecc833e
5 changed files with 581 additions and 5 deletions
+32
View File
@@ -301,6 +301,38 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
return { diffs: d.diffs ?? [], ok: !!d.ok };
}
// ---- 支付联调:走「用户面」的真实下单/查单接口 ----
// 刻意不做 admin 专用旁路:验的就是用户真实走的那条路(下单 → 渠道 → 回调/查单 → 入账),
// 造条测试专线的话验过了也不代表线上通。因此这两个打的是 /api/v1/billing/*(非 /admin/*),
// 租户由服务端按当前登录用户解析,无需带租户头。
export interface TestOrder {
order_id: string;
code_url: string;
amount_fen: number;
expires_at: string;
}
export async function createPayOrder(packId: string): Promise<TestOrder> {
const res = guard(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ pack_id: packId }),
}),
);
const d = (await res.json().catch(() => ({}))) as Partial<TestOrder> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `下单失败: ${res.status}`);
return d as TestOrder;
}
// warn:已付但金额与订单不符 —— 服务端不入账、挂起人工核对,订单会一直停在 pending。
export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder; warn?: string }> {
const res = guard(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { order?: PayOrder; warn?: string; error?: string };
if (!res.ok) throw new Error(d.error ?? `查询失败: ${res.status}`);
return { order: d.order as PayOrder, warn: d.warn };
}
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {