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
+154
View File
@@ -0,0 +1,154 @@
// Package payment 是充值渠道适配层(设计见 PAYMENT_DESIGN.md §3/§5)。
// P5.1 的兑换码不走这里(无「待支付」态,核销即入账);本包面向真渠道:
// 下单出支付凭据 → 回调/查单确认 → 上层 MarkOrderPaid 幂等入账。
package payment
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// Wechat 微信支付 Native(扫码)适配器。凭据一律 env 注入:
//
// WECHAT_MCHID 商户号
// WECHAT_MCH_CERT_SERIAL 商户 API 证书序列号
// WECHAT_MCH_PRIVATE_KEY 商户 API 私钥文件路径(apiclient_key.pem
// WECHAT_APIV3_KEY APIv3 密钥(32 字节)
// WECHAT_APPID 关联的公众号/小程序/APP 的 appid
// WECHAT_NOTIFY_URL 支付回调地址(须公网 https,如 https://api.example.com/api/v1/billing/callback/wechat
//
// 任一缺失 → NewWechatFromEnv 返回 nil,渠道自动隐藏(半配置状态不许把下单路由搞出 5xx)。
// 本地开发收不到公网回调没关系:前端轮询的 GET /billing/orders/:id 会主动查单确认,
// 回调只是生产环境更快的到账通道,两条路都汇入同一个幂等入账闸。
type Wechat struct {
mchID string
appID string
notifyURL string
apiv3Key string
client *core.Client
svc native.NativeApiService
}
// NewWechatFromEnv 依据环境变量装配微信渠道;未配置(或配置不全/私钥读不了)返回 nil。
func NewWechatFromEnv(ctx context.Context) *Wechat {
mchID := os.Getenv("WECHAT_MCHID")
serial := os.Getenv("WECHAT_MCH_CERT_SERIAL")
keyPath := os.Getenv("WECHAT_MCH_PRIVATE_KEY")
apiv3 := os.Getenv("WECHAT_APIV3_KEY")
appID := os.Getenv("WECHAT_APPID")
notifyURL := os.Getenv("WECHAT_NOTIFY_URL")
if mchID == "" && serial == "" && keyPath == "" && apiv3 == "" {
return nil // 完全未配置:静默(大多数开发环境)
}
if mchID == "" || serial == "" || keyPath == "" || apiv3 == "" || appID == "" || notifyURL == "" {
log.Printf("[payment] 微信支付配置不全(MCHID/CERT_SERIAL/PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL 缺一不可),渠道保持隐藏")
return nil
}
priv, err := utils.LoadPrivateKeyWithPath(keyPath)
if err != nil {
log.Printf("[payment] 微信商户私钥加载失败(%s),渠道保持隐藏: %v", keyPath, err)
return nil
}
client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(mchID, serial, priv, apiv3))
if err != nil {
log.Printf("[payment] 微信支付客户端初始化失败,渠道保持隐藏: %v", err)
return nil
}
log.Printf("[payment] 微信支付 Native 渠道已启用 (mchid=%s)", mchID)
return &Wechat{
mchID: mchID, appID: appID, notifyURL: notifyURL, apiv3Key: apiv3,
client: client, svc: native.NativeApiService{Client: client},
}
}
// CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。
func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (string, error) {
resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{
Appid: core.String(w.appID),
Mchid: core.String(w.mchID),
Description: core.String(description),
OutTradeNo: core.String(orderID),
NotifyUrl: core.String(w.notifyURL),
Amount: &native.Amount{Total: core.Int64(amountFen), Currency: core.String("CNY")},
})
if err != nil {
return "", err
}
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
return "", errors.New("微信未返回 code_url")
}
return *resp.CodeUrl, nil
}
// QueryResult 查单/回调解析后的统一结果。
type QueryResult struct {
OrderID string // out_trade_no
ChannelTxn string // transaction_id
Paid bool // TradeState == SUCCESS
Closed bool // CLOSED/REVOKED/PAYERROR 等终态失败
AmountFen int64 // 用户实付(分);回调/查单都带,供金额核对
}
func fromTransaction(t *payments.Transaction) QueryResult {
r := QueryResult{}
if t.OutTradeNo != nil {
r.OrderID = *t.OutTradeNo
}
if t.TransactionId != nil {
r.ChannelTxn = *t.TransactionId
}
if t.Amount != nil && t.Amount.PayerTotal != nil {
r.AmountFen = *t.Amount.PayerTotal
} else if t.Amount != nil && t.Amount.Total != nil {
r.AmountFen = *t.Amount.Total
}
if t.TradeState != nil {
switch *t.TradeState {
case "SUCCESS":
r.Paid = true
case "CLOSED", "REVOKED", "PAYERROR":
r.Closed = true
}
}
return r
}
// QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。
func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (QueryResult, error) {
t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(orderID),
Mchid: core.String(w.mchID),
})
if err != nil {
return QueryResult{}, err
}
return fromTransaction(t), nil
}
// VerifyCallback 验签 + 解密支付回调(APIv3:平台证书验签、AES-GCM 解密资源)。
// 验签失败一律拒绝——回调路由是公开的,签名是唯一的门。
func (w *Wechat) VerifyCallback(req *http.Request) (QueryResult, error) {
certVisitor := downloader.MgrInstance().GetCertificateVisitor(w.mchID)
h, err := notify.NewRSANotifyHandler(w.apiv3Key, verifiers.NewSHA256WithRSAVerifier(certVisitor))
if err != nil {
return QueryResult{}, fmt.Errorf("回调处理器初始化失败: %w", err)
}
txn := new(payments.Transaction)
if _, err := h.ParseNotifyRequest(req.Context(), req, txn); err != nil {
return QueryResult{}, err
}
return fromTransaction(txn), nil
}