feat(billing): 支付 P5.2 —— 微信支付 Native 渠道(扫码充值)

wechatpay-go v0.2.21。凭据全 env 注入(WECHAT_MCHID/MCH_CERT_SERIAL/
MCH_PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL),缺一渠道即隐藏——半配置/假凭据
只打日志不拖垮 gateway(用假私钥实测过降级)。

- internal/payment:Native 下单出 code_url、APIv3 回调验签解密、主动查单,
  三者统一收敛为 QueryResult。
- 下单 POST /billing/orders {pack_id}(≥member+审计):金额/积分按在售包服务端
  锁定进订单行,不信任客户端;渠道下单失败即作废,不留付不了的 pending。
- 到账两条路汇入同一个 MarkOrderPaid 幂等闸(CAS+唯一索引双闸,同 P5.1):
  ①公开回调路由(验签是唯一的门;金额与订单不符不入账);②前端轮询的
  GET /billing/orders/:id 在 pending 时顺路主动查单——本地/内网收不到公网
  回调也能确认到账,回调只是生产更快的通道。pending 超 30 分钟置 expired。
- Web 面:在售包卡片(渠道亮才出现)→扫码弹窗(qrcode 画 code_url,二维码底色
  固定纯白——暗色主题下低对比码扫不出来)→2.5s 轮询→到账 toast+刷余额。

验证:go/tsc/vitest 全绿;无凭据+假凭据两种降级 live 四连
(channels 只剩 redeem/下单 400 引导兑换码/回调 503/兑换码闭环不受影响)。
⚠️ 真通道(prepay→扫码→回调/查单→入账)需真实商户号,未 live——用户配好
env 后用小额包实测,建议先配 ¥0.01 测试包走一单。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-17 10:39:24 +08:00
parent 929bbf334b
commit efd185b779
11 changed files with 921 additions and 46 deletions
+122 -5
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { Coins, ReceiptText, Ticket } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { myUsage, redeemCode, billingOrders, fmtCredits, type MyUsage, type TopupOrder } from "../api";
import { Badge, Button, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui";
import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, type MyUsage, type TopupOrder, type Pack } from "../api";
import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui";
// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。
// 微信扫码支付在 P5.2 挂上(渠道适配器已留位),此前不做假支付入口。
@@ -14,10 +15,19 @@ export function Usage() {
const [orders, setOrders] = useState<TopupOrder[]>([]);
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [packs, setPacks] = useState<Pack[]>([]);
const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮
const [paying, setPaying] = useState<Pack | null>(null); // 正在扫码支付的包
const load = useCallback(() => {
myUsage(days).then(setU).catch(() => {});
billingOrders().then(setOrders).catch(() => {});
billingPacks()
.then((r) => {
setPacks(r.packs);
setWechatOn(r.channels.includes("wechat"));
})
.catch(() => {});
}, [days]);
useEffect(load, [load, ctx?.tenant?.id]);
@@ -65,6 +75,26 @@ export function Usage() {
</div>
{canTopup ? (
<div className="mt-4 border-t border-line pt-4">
{/* 微信扫码:服务端配了商户号且有在售包才出现 */}
{wechatOn && packs.length > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<QrCode className="h-3.5 w-3.5" />
</div>
<div className="flex flex-wrap gap-2">
{packs.map((p) => (
<button key={p.id} onClick={() => setPaying(p)}
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
<div className="text-sm font-medium text-slate-200">{p.name}</div>
<div className="mt-0.5 text-xs text-slate-500">
<span className="tabular-nums text-brand-400">{fmtCredits(p.credits_micro)}</span> ·
<span className="ml-1 tabular-nums">¥{(p.price_fen / 100).toFixed(2)}</span>
</div>
</button>
))}
</div>
</div>
)}
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<Ticket className="h-3.5 w-3.5" />
</div>
@@ -79,7 +109,9 @@ export function Usage() {
<Button variant="primary" onClick={redeem} disabled={busy || !code.trim()}>
{busy ? "入账中…" : "兑换"}
</Button>
<span className="text-[11px] text-slate-600">线</span>
<span className="text-[11px] text-slate-600">
{wechatOn ? "兑换码请向平台获取。" : "微信扫码支付即将上线;兑换码请向平台获取。"}
</span>
</div>
</div>
) : (
@@ -153,6 +185,91 @@ export function Usage() {
</Table>
</Panel>
)}
{paying && (
<PayDialog
pack={paying}
onClose={(paid) => {
setPaying(null);
if (paid) {
toast.push("success", "支付成功,积分已入账");
load();
refresh();
}
}}
/>
)}
</div>
);
}
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态(2.5s;
// 服务端 pending 时会顺路主动查单,收不到公网回调的环境也能确认到账)。
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
const orderRef = useRef("");
useEffect(() => {
let alive = true;
let timer: number | null = null;
(async () => {
try {
const o = await createWechatOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
setState("waiting");
timer = window.setInterval(async () => {
try {
const s = await orderStatus(orderRef.current);
if (!alive) return;
if (s.status === "paid") {
setState("paid");
if (timer) window.clearInterval(timer);
window.setTimeout(() => onClose(true), 800);
} else if (s.status === "expired" || s.status === "failed") {
setState("expired");
if (timer) window.clearInterval(timer);
}
} catch {
/* 单次轮询失败忽略,下个 tick 再试 */
}
}, 2500);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
return (
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
<div className="flex flex-col items-center gap-3 py-2">
{err ? (
<p className="text-xs text-danger">{err}</p>
) : state === "creating" ? (
<p className="py-16 text-xs text-slate-500"></p>
) : (
<>
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
<div className="rounded-lg bg-white p-2">
<img src={qr} alt="微信支付二维码" width={240} height={240} />
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
<div className="mt-1 text-xs text-slate-500">
{state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
</div>
</div>
</>
)}
</div>
</Dialog>
);
}