efd185b779
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>
155 lines
5.9 KiB
Go
155 lines
5.9 KiB
Go
// 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
|
||
}
|