Files
pay/internal/channel/wechat.go
T
wangjia fd5f56f7de feat: 微信V3 + 支付宝手机站/UA自适应 + 业务对接(签名下单/webhook) + 通用多业务
- 微信支付 V3 Native 渠道 (wechat.go):Native下单/回调AES-GCM解密验签/查单
- 支付宝:手机网站支付 wap.pay + 按 UA 自适应(PC page.pay扫码 / 手机拉App);qr_pay_mode=2 完整扫码收银台
- 业务对接:下单接口扩展(biz_system/biz_ref/return_url)+ HMAC 签名鉴权;支付成功 webhook 主动推送业务方 + 60s 重试 + BizNotifyLog
- 通用多业务:config.biz 改 map,加业务只改配置(BIZ_<SYS>_SECRET/_CALLBACK_URL)
- seedPlans:四档真实套餐 + promo_first_month(¥1) + test_liandiao(0.01),均挂 biz_code;/products 暴露 biz_code
- 删除沙箱 pay.html;对接设计文档入 docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UQmqWmV67sXGLrb3U1XXn
2026-07-03 22:44:34 +08:00

153 lines
5.4 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 channel
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"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"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/util"
)
// wechatChannel 微信支付 V3 · Native(扫码)实现。
// PC 网站场景微信只提供 Native 扫码(无支付宝那种网页跳转收银台),所以:
// - PreCreate → Native 下单,返回 code_url(前端经 /qrcode 渲染成二维码)
// - PagePay → 不适用,返回错误引导改用扫码
// - VerifyNotify / Query → 回调解密验签 / 主动查单
type wechatChannel struct {
mchID string
appID string
client *core.Client
native *native.NativeApiService
handler *notify.Handler
}
func newWechat(m *model.Merchant) (Channel, error) {
if m.MchID == "" || m.WxAppID == "" || m.WxPrivateKey == "" || m.CertSerial == "" || m.APIv3Key == "" {
return nil, fmt.Errorf("商户 %s 的微信凭证不完整(mch_id/wx_app_id/cert_serial/wx_private_key/apiv3_key", m.Code)
}
mchPrivateKey, err := utils.LoadPrivateKey(m.WxPrivateKey)
if err != nil {
return nil, fmt.Errorf("加载微信商户私钥失败: %w", err)
}
// 初始化时自动下载并周期性更新微信支付平台证书(需公网可达微信 API)。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(
m.MchID, m.CertSerial, mchPrivateKey, m.APIv3Key,
))
if err != nil {
return nil, fmt.Errorf("初始化微信支付客户端失败: %w", err)
}
// 回调处理器:用平台证书验签 + APIv3 密钥解密。
certVisitor := downloader.MgrInstance().GetCertificateVisitor(m.MchID)
handler, err := notify.NewRSANotifyHandler(m.APIv3Key, verifiers.NewSHA256WithRSAVerifier(certVisitor))
if err != nil {
return nil, fmt.Errorf("初始化微信回调处理器失败: %w", err)
}
return &wechatChannel{
mchID: m.MchID,
appID: m.WxAppID,
client: client,
native: &native.NativeApiService{Client: client},
handler: handler,
}, nil
}
func (w *wechatChannel) Name() string { return "wechat" }
// PagePay 微信无网页跳转收银台(PC 网站走 Native 扫码),改用 PreCreate。
func (w *wechatChannel) PagePay(context.Context, CreateReq) (string, error) {
return "", errors.New("微信支付无网页跳转收银台,请用扫码下单(CreateQR / PreCreate")
}
// PreCreate Native 下单,返回 code_url 供渲染二维码。
func (w *wechatChannel) PreCreate(ctx context.Context, req CreateReq) (string, error) {
cents, err := util.AmountToCents(req.Amount)
if err != nil {
return "", fmt.Errorf("金额换算失败: %w", err)
}
resp, _, err := w.native.Prepay(ctx, native.PrepayRequest{
Appid: core.String(w.appID),
Mchid: core.String(w.mchID),
Description: core.String(req.Subject),
OutTradeNo: core.String(req.OutTradeNo),
NotifyUrl: core.String(req.NotifyURL),
Amount: &native.Amount{
Total: core.Int64(cents),
Currency: core.String("CNY"),
},
})
if err != nil {
return "", fmt.Errorf("微信 Native 下单失败: %w", err)
}
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
return "", errors.New("微信 Native 下单未返回 code_url")
}
return *resp.CodeUrl, nil
}
// VerifyNotify 解密验签微信异步回调,解析成统一结果。
func (w *wechatChannel) VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) {
tx := new(payments.Transaction)
if _, err := w.handler.ParseNotifyRequest(ctx, r, tx); err != nil {
return nil, fmt.Errorf("微信回调验签/解密失败: %w", err)
}
return txToNotifyResult(tx), nil
}
// Query 主动查单(out_trade_no 维度)。
func (w *wechatChannel) Query(ctx context.Context, outTradeNo string) (*QueryResult, error) {
tx, _, err := w.native.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(outTradeNo),
Mchid: core.String(w.mchID),
})
if err != nil {
// 交易不存在等:视为未找到,交由上层决定(与支付宝实现一致)。
return &QueryResult{Found: false}, nil
}
res := txToNotifyResult(tx)
return &QueryResult{
Found: true,
OutTradeNo: res.OutTradeNo,
TradeNo: res.TradeNo,
Amount: res.Amount,
Paid: res.Paid,
}, nil
}
// txToNotifyResult 把微信交易对象归一化成本服务的统一结构(金额分→元字符串)。
func txToNotifyResult(tx *payments.Transaction) *NotifyResult {
res := &NotifyResult{}
if tx.OutTradeNo != nil {
res.OutTradeNo = *tx.OutTradeNo
}
if tx.TransactionId != nil {
res.TradeNo = *tx.TransactionId
}
if tx.Amount != nil && tx.Amount.Total != nil {
res.Amount = util.CentsToAmount(*tx.Amount.Total)
}
if tx.Payer != nil && tx.Payer.Openid != nil {
res.BuyerLogonID = *tx.Payer.Openid
}
// 交易状态:SUCCESS 视为已支付(其余 NOTPAY/CLOSED/REFUND 等均非成功)。
res.Paid = tx.TradeState != nil && *tx.TradeState == "SUCCESS"
return res
}