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
+34
View File
@@ -256,6 +256,40 @@ export interface TopupOrder {
created_at: string;
}
export interface Pack {
id: string;
name: string;
credits_micro: number;
price_fen: number;
}
// billingPacks 在售积分包 + 可用渠道(服务端配了微信 env 才会出现 "wechat")。
export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[] }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/packs`, { headers: bearer() }));
if (!res.ok) return { packs: [], channels: [] };
const d = (await res.json()) as { packs?: Pack[]; channels?: string[] };
return { packs: d.packs ?? [], channels: d.channels ?? [] };
}
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)。
export async function createWechatOrder(packId: string): Promise<{ order_id: string; code_url: string; amount_fen: number }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ pack_id: packId }),
}),
);
return jsonOrThrow(res, "下单失败");
}
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
export async function orderStatus(orderId: string): Promise<TopupOrder> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() }));
const d = await jsonOrThrow<{ order: TopupOrder }>(res, "查询失败");
return d.order;
}
// redeemCode 核销兑换码,返回入账后的余额。
export async function redeemCode(code: string): Promise<{ balance_micro: number }> {
const res = guard401(