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
This commit is contained in:
@@ -32,6 +32,23 @@ func newAlipay(m *model.Merchant) (Channel, error) {
|
||||
func (a *alipayChannel) Name() string { return "alipay" }
|
||||
|
||||
func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error) {
|
||||
// 手机浏览器:手机网站支付 wap.pay,H5 收银台可直接拉起支付宝 App 付款。
|
||||
if req.IsMobile {
|
||||
var p = alipay.TradeWapPay{}
|
||||
p.OutTradeNo = req.OutTradeNo
|
||||
p.Subject = req.Subject
|
||||
p.TotalAmount = req.Amount
|
||||
p.ProductCode = "QUICK_WAP_WAY"
|
||||
p.NotifyURL = req.NotifyURL
|
||||
p.ReturnURL = req.ReturnURL
|
||||
u, err := a.client.TradeWapPay(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("支付宝手机网站下单失败: %w", err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// PC 浏览器:电脑网站支付 page.pay。
|
||||
var p = alipay.TradePagePay{}
|
||||
p.OutTradeNo = req.OutTradeNo
|
||||
p.Subject = req.Subject
|
||||
@@ -39,6 +56,9 @@ func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error
|
||||
p.ProductCode = "FAST_INSTANT_TRADE_PAY"
|
||||
p.NotifyURL = req.NotifyURL
|
||||
p.ReturnURL = req.ReturnURL
|
||||
// qr_pay_mode=2 跳转模式:跳到支付宝完整扫码收银台(含收款方/金额/订单详情),
|
||||
// 而非默认「登录付款」页;区别于 4(光秃秃的嵌入式二维码)。
|
||||
p.QRPayMode = "2"
|
||||
u, err := a.client.TradePagePay(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("支付宝下单失败: %w", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ type CreateReq struct {
|
||||
Amount string // 元,两位小数
|
||||
ReturnURL string
|
||||
NotifyURL string
|
||||
IsMobile bool // 客户端是否手机浏览器:支付宝据此走手机网站支付(wap.pay 拉起 App) 而非电脑网站支付(page.pay 扫码)
|
||||
}
|
||||
|
||||
// NotifyResult 异步通知验签解析后的统一结果。
|
||||
|
||||
@@ -43,6 +43,19 @@ func (r *Registry) AlipayByAppID(appID string) (Channel, *model.Merchant, error)
|
||||
return ch, &m, err
|
||||
}
|
||||
|
||||
// FirstWechat 取一个启用中的微信商户及其渠道,用于回调解密验签。
|
||||
// 微信 V3 回调正文加密,需先用商户凭证(平台证书 + APIv3 密钥)解密才能拿到 out_trade_no;
|
||||
// 同一公司主体共用一个 mch_id/apiv3_key/证书,故取任一启用的微信商户即可解密,
|
||||
// 解密后再按 out_trade_no 定位到订单真正归属的商户入账。
|
||||
func (r *Registry) FirstWechat() (Channel, *model.Merchant, error) {
|
||||
var m model.Merchant
|
||||
if err := r.db.First(&m, "channel = ? AND enabled = ?", "wechat", true).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("没有启用的微信商户: %w", err)
|
||||
}
|
||||
ch, err := r.get(&m)
|
||||
return ch, &m, err
|
||||
}
|
||||
|
||||
func (r *Registry) get(m *model.Merchant) (Channel, error) {
|
||||
r.mu.RLock()
|
||||
if ch, ok := r.cache[m.ID]; ok {
|
||||
|
||||
+132
-12
@@ -3,30 +3,150 @@ 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"
|
||||
)
|
||||
|
||||
// ErrNotImplemented 渠道尚未实现。
|
||||
var ErrNotImplemented = errors.New("微信支付渠道尚未实现:微信无可用沙箱,待真实商户号下来后补 V3 实现")
|
||||
// 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
|
||||
}
|
||||
|
||||
// wechatChannel 占位实现。接口已就绪,补齐 V3 下单/回调/查单即可启用。
|
||||
type wechatChannel struct{ m *model.Merchant }
|
||||
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)
|
||||
}
|
||||
|
||||
func newWechat(m *model.Merchant) (Channel, error) { return &wechatChannel{m: m}, nil }
|
||||
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 "", ErrNotImplemented
|
||||
return "", errors.New("微信支付无网页跳转收银台,请用扫码下单(CreateQR / PreCreate)")
|
||||
}
|
||||
func (w *wechatChannel) PreCreate(context.Context, CreateReq) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
|
||||
// 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
|
||||
}
|
||||
func (w *wechatChannel) VerifyNotify(context.Context, *http.Request) (*NotifyResult, error) {
|
||||
return nil, ErrNotImplemented
|
||||
|
||||
// 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
|
||||
}
|
||||
func (w *wechatChannel) Query(context.Context, string) (*QueryResult, error) {
|
||||
return nil, ErrNotImplemented
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user