Files
sundynix-agentix/sundynix-gateway/internal/payment/wechat.go
T
Blizzard efd185b779 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>
2026-07-17 10:39:24 +08:00

155 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}