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 }> {
@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { createPayOrder, payOrderStatus, type CreditPack, type PayOrder } from "../api";
// 支付联调:在管理端就地下一笔**真单**并盯到终态,用来验证「渠道配置 → 下单 → 扫码付款
// → 回调/查单 → 入账」整条链路在当前环境真的通。
//
// 刻意走用户面的 /api/v1/billing/* 而非 admin 旁路:造条测试专线的话,验过了也不能
// 说明线上用户那条路通。代价是这会真扣钱 —— 所以 UI 上必须讲清楚,并给出退款去处。
//
// 轮询策略与 sundynix-web 的支付弹窗一致:递归 setTimeout(不让慢请求摞起来)、
// 切后台暂停(扫完码要去微信 App)、失败指数退避、倒计时以服务端 expires_at 为准。
const POLL_BASE_MS = 2500;
const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
type Phase = "creating" | "waiting" | "paid" | "expired";
export function PayProbeDialog({ pack, onClose }: { pack: CreditPack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [warn, setWarn] = useState("");
const [phase, setPhase] = useState<Phase>("creating");
const [order, setOrder] = useState<PayOrder | null>(null);
const [left, setLeft] = useState(0);
const [polls, setPolls] = useState(0); // 轮询次数:链路是否在动,一眼可见
const orderRef = useRef("");
const expiresRef = useRef(0);
useEffect(() => {
let alive = true;
let timer: number | null = null;
let failures = 0;
const stop = () => {
if (timer) window.clearTimeout(timer);
timer = null;
};
const schedule = (ms: number) => {
stop();
timer = window.setTimeout(() => void tick(), ms);
};
const tick = async () => {
if (!alive) return;
if (document.hidden) return schedule(POLL_BASE_MS);
try {
const { order: o, warn: w } = await payOrderStatus(orderRef.current);
if (!alive) return;
failures = 0;
setPolls((n) => n + 1);
setOrder(o);
setWarn(w ?? "");
if (o.status === "paid") {
setPhase("paid");
stop();
return;
}
if (o.status === "expired" || o.status === "failed") {
setPhase("expired");
stop();
return;
}
schedule(POLL_BASE_MS);
} catch (e) {
if (!alive) return;
failures += 1;
setErr((e as Error).message);
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
}
};
const onVisible = () => {
if (!document.hidden && alive && orderRef.current) schedule(0);
};
document.addEventListener("visibilitychange", onVisible);
void (async () => {
try {
const o = await createPayOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime();
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 }));
setPhase("waiting");
schedule(POLL_BASE_MS);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
stop();
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
useEffect(() => {
if (phase !== "waiting") return;
const t = window.setInterval(() => {
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
setLeft(s);
if (s === 0) setPhase("expired");
}, 1000);
return () => window.clearInterval(t);
}, [phase]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/40 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl">
<div className="flex items-baseline justify-between">
<h4 className="text-sm font-semibold text-gray-800"> · {pack.name}</h4>
<button onClick={() => onClose(phase === "paid")} className="text-xs text-gray-400 hover:text-gray-600">
</button>
</div>
<p className="mt-1 rounded-md bg-amber-50 px-2.5 py-1.5 text-[11px] leading-relaxed text-amber-700">
<b></b> · 退
</p>
<div className="mt-4 flex flex-col items-center gap-3">
{err && !qr ? (
<p className="py-10 text-center text-xs text-rose-500">{err}</p>
) : phase === "creating" ? (
<p className="py-16 text-xs text-gray-400"></p>
) : (
<>
<div className="relative rounded-lg border border-gray-100 bg-white p-2">
<img src={qr} alt="微信支付二维码" width={220} height={220} />
{(phase === "expired" || phase === "paid") && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-white/85 text-sm font-medium text-gray-700">
{phase === "paid" ? "✅ 已入账" : "二维码已失效"}
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-gray-800">¥{(pack.price_fen / 100).toFixed(2)}</div>
{phase === "waiting" && <div className="mt-0.5 text-[11px] tabular-nums text-gray-400"> {mmss(left)} </div>}
</div>
</>
)}
{warn && (
<p className="w-full rounded-md bg-amber-50 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-700">{warn}</p>
)}
{/* 链路证据:这些字段就是判断「哪一环通了」的依据,比一句“成功”有用 */}
{orderRef.current && (
<dl className="w-full space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
<Row k="订单号" v={<span className="font-mono">{orderRef.current}</span>} />
<Row
k="订单状态"
v={
<span
className={
phase === "paid" ? "font-medium text-emerald-600" : phase === "expired" ? "text-gray-400" : "text-amber-600"
}
>
{order?.status ?? "pending"}
{phase === "waiting" && " · 等待扫码支付"}
</span>
}
/>
<Row k="轮询次数" v={<span className="tabular-nums">{polls}</span>} />
{order?.credits_micro != null && phase === "paid" && (
<Row k="到账积分" v={<span className="tabular-nums text-emerald-600">{(order.credits_micro / 1e6).toLocaleString()}</span>} />
)}
{err && <Row k="最近错误" v={<span className="text-rose-500">{err}</span>} />}
</dl>
)}
{phase === "paid" && (
<p className="text-center text-[11px] leading-relaxed text-emerald-600">
/
</p>
)}
{phase === "waiting" && (
<p className="text-center text-[11px] leading-relaxed text-gray-400">
</p>
)}
</div>
</div>
</div>
);
}
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-gray-400">{k}</dt>
<dd className="truncate text-gray-700">{v}</dd>
</div>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { PayProbeDialog } from "./PayProbeDialog";
import { adminPacks, savePack, genRedeemCodes, listRedeemCodes, getWechatPay, saveWechatPay, type CreditPack, type RedeemCodeRow, type WechatPayConfig } from "../api";
// 充值渠道(P5.1,设计见 PAYMENT_DESIGN.md):
@@ -250,6 +251,7 @@ function RedeemBlock() {
// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ----
function PacksBlock() {
const [rows, setRows] = useState<CreditPack[]>([]);
const [probe, setProbe] = useState<CreditPack | null>(null); // 正在联调的积分包
const [name, setName] = useState("");
const [creditsIn, setCreditsIn] = useState("");
const [yuan, setYuan] = useState("");
@@ -326,7 +328,8 @@ function PacksBlock() {
<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 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>
@@ -335,22 +338,39 @@ function PacksBlock() {
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(p.credits_micro)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{(p.price_fen / 100).toFixed(2)}</td>
<td className="py-2 text-right">
<td className="py-2 pr-3 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>
<td className="py-2 text-right">
{/* 就地下真单验链路。下架的包下单会被服务端拒,按钮也就没意义 */}
<button onClick={() => setProbe(p)} disabled={!p.active} title={p.active ? "生成付款码,验证支付链路" : "已下架的包不可下单"}
className="rounded border border-violet-200 px-2 py-0.5 text-[11px] text-violet-600 hover:bg-violet-50 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300 disabled:hover:bg-transparent">
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">线</td>
<td colSpan={5} className="py-6 text-center text-xs text-gray-400">线</td>
</tr>
)}
</tbody>
</table>
</div>
{probe && (
<PayProbeDialog
pack={probe}
onClose={() => {
setProbe(null);
void load(); // 联调单也是真单,回来刷一次列表
}}
/>
)}
</div>
);
}