Files
2026-09-15 17:28:08 +08:00

372 lines
12 KiB
Go
Raw Permalink 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
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"server/models"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
"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/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
// WechatChannel 微信支付(JSAPI / Native 扫码)
//
// 渠道参数(config_json):
//
// mch_id 商户号
// appid 公众号 / 小程序 APPID
// cert_serial_no 商户 API 证书序列号
// api_v3_key APIv3 密钥
// pub_key_id 微信支付公钥 ID(PUB_KEY_ID_ 开头;公钥模式必填,与 pub_key 成对)
// pub_key 微信支付公钥 PEM 内容(公钥模式必填)
//
// 证书(cert_json):
//
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
type WechatChannel struct{}
// connectTestOutTradeNo 测试连接用的探测单号:查询不存在的订单,返回 404 即视为鉴权链路通过
const connectTestOutTradeNo = "CONNECT_TEST_NO_0000000000000001"
func (c *WechatChannel) Code() string { return ChannelWechat }
func (c *WechatChannel) Name() string { return "微信支付" }
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
//
// 初始化模式自动选择:
// - 配置了「微信支付公钥」(pub_key_id + pub_key)时走公钥模式:
// 2024 年后新开通商户默认使用公钥,平台证书下载接口对其停用(403 NOT_ENOUGH);
// - 否则回退平台证书模式(自动下载并轮换平台证书,老商户适用)。
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
mchID := cfg.Get("mch_id")
serial := cfg.Get("cert_serial_no")
apiV3Key := cfg.Get("api_v3_key")
if mchID == "" || serial == "" || apiV3Key == "" {
return nil, "", fmt.Errorf("微信支付参数不完整:需要 mch_id / cert_serial_no / api_v3_key")
}
keyPath := cfg.CertPaths["key_path"]
if keyPath == "" {
return nil, "", fmt.Errorf("微信支付缺少商户私钥:请先上传 apiclient_key.pem")
}
pem, err := os.ReadFile(keyPath)
if err != nil {
return nil, "", fmt.Errorf("读取商户私钥失败: %w", err)
}
privateKey, err := utils.LoadPrivateKey(string(pem))
if err != nil {
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
}
pubKeyID := cfg.Get("pub_key_id")
pubKeyPEM := cfg.Get("pub_key")
if pubKeyID != "" && pubKeyPEM != "" {
pubKey, perr := utils.LoadPublicKey(pubKeyPEM)
if perr != nil {
return nil, "", fmt.Errorf("解析微信支付公钥失败: %w", perr)
}
client, cerr := core.NewClient(context.Background(),
option.WithMerchantCredential(mchID, serial, privateKey),
option.WithWechatPayPublicKeyAuthCipher(mchID, serial, privateKey, pubKeyID, pubKey),
)
if cerr != nil {
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", cerr)
}
return client, mchID, nil
}
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(mchID, serial, privateKey),
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
)
if err != nil {
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", err)
}
return client, mchID, nil
}
func (c *WechatChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
appID := cfg.Get("appid")
if appID == "" {
return nil, fmt.Errorf("微信支付参数不完整:需要 appid")
}
notifyURL := cfg.CallbackURL
amount := &native.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")}
// 公众号内支付
if strings.EqualFold(opt.PayType, PayTypeJSAPI) {
if opt.OpenID == "" {
return nil, fmt.Errorf("微信 JSAPI 支付需要 OpenID")
}
svc := jsapi.JsapiApiService{Client: client}
resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(mchID),
Description: core.String(order.Subject),
OutTradeNo: core.String(order.PayNo),
NotifyUrl: core.String(notifyURL),
Amount: &jsapi.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")},
Payer: &jsapi.Payer{Openid: core.String(opt.OpenID)},
})
if err != nil {
return nil, fmt.Errorf("微信 JSAPI 下单失败: %w", err)
}
if resp == nil || resp.PrepayId == nil {
return nil, fmt.Errorf("微信 JSAPI 下单未返回 prepay_id")
}
// 按微信规范自行生成 JSAPI 调起参数(timeStamp/nonceStr/paySign)
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce, _ := randomNonce(16)
message := appID + "\n" + ts + "\n" + nonce + "\n" + "prepay_id=" + *resp.PrepayId + "\n"
sign, err := client.Sign(ctx, message)
if err != nil {
return nil, fmt.Errorf("生成 JSAPI 签名失败: %w", err)
}
return &PayParams{
Channel: ChannelWechat,
PayType: PayTypeJSAPI,
JSAPI: map[string]string{
"appId": appID,
"timeStamp": ts,
"nonceStr": nonce,
"package": "prepay_id=" + *resp.PrepayId,
"signType": "RSA",
"paySign": sign.Signature,
},
}, nil
}
// 默认:Native 扫码(PC 收银台)
req := native.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(mchID),
Description: core.String(order.Subject),
OutTradeNo: core.String(order.PayNo),
NotifyUrl: core.String(notifyURL),
Amount: amount,
}
if order.ExpireAt != nil {
req.TimeExpire = order.ExpireAt
}
svc := native.NativeApiService{Client: client}
resp, _, err := svc.Prepay(ctx, req)
if err != nil {
return nil, fmt.Errorf("微信 Native 下单失败: %w", err)
}
if resp == nil || resp.CodeUrl == nil {
return nil, fmt.Errorf("微信 Native 下单未返回 code_url")
}
return &PayParams{Channel: ChannelWechat, PayType: PayTypeQR, CodeURL: *resp.CodeUrl}, nil
}
// normalizeWechatState 微信交易状态 -> 归一化状态
func normalizeWechatState(state string) string {
switch state {
case "SUCCESS":
return StateSuccess
case "NOTPAY", "USERPAYING":
return StatePending
case "CLOSED", "REVOKED":
return StateClosed
case "PAYERROR":
return StateFailed
case "REFUND":
return StateRefunded
default:
return StatePending
}
}
func (c *WechatChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return nil, err
}
url := consts.WechatPayAPIServer + "/v3/pay/transactions/out-trade-no/" + order.PayNo + "?mchid=" + mchID
result, err := client.Get(ctx, url)
if err != nil {
return nil, fmt.Errorf("微信查询订单失败: %w", err)
}
body, err := io.ReadAll(result.Response.Body)
if err != nil {
return nil, fmt.Errorf("读取微信查询响应失败: %w", err)
}
tx := &payments.Transaction{}
if err := json.Unmarshal(body, tx); err != nil {
return nil, fmt.Errorf("解析微信查询响应失败: %w", err)
}
state := &ChannelState{Raw: string(body)}
if tx.TransactionId != nil {
state.ChannelTradeNo = *tx.TransactionId
}
state.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
if tx.Amount != nil && tx.Amount.Total != nil {
state.Amount = *tx.Amount.Total
}
if tx.SuccessTime != nil {
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
state.PaidAt = &t
}
}
return state, nil
}
func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
mchID := cfg.Get("mch_id")
apiV3Key := cfg.Get("api_v3_key")
// 验签器按初始化模式选择:公钥模式用微信支付公钥,平台证书模式用下载器维护的证书访问器
var verifier auth.Verifier
pubKeyID := cfg.Get("pub_key_id")
pubKeyPEM := cfg.Get("pub_key")
if pubKeyID != "" && pubKeyPEM != "" {
pubKey, perr := utils.LoadPublicKey(pubKeyPEM)
if perr != nil {
return nil, fmt.Errorf("解析微信支付公钥失败: %w", perr)
}
verifier = verifiers.NewSHA256WithRSAPubkeyVerifier(pubKeyID, *pubKey)
} else {
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
verifier = verifiers.NewSHA256WithRSAVerifier(visitor)
}
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifier)
if err != nil {
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
}
tx := &payments.Transaction{}
req, err := handler.ParseNotifyRequest(ctx, r, tx)
if err != nil {
return nil, fmt.Errorf("微信通知验签/解密失败: %w", err)
}
result := &NotifyResult{
EventType: req.EventType,
EventID: req.ID,
AckBody: `{"code":"SUCCESS","message":"成功"}`,
Raw: req.Resource.Plaintext,
}
if tx.OutTradeNo != nil {
result.OutTradeNo = *tx.OutTradeNo
result.PayNo = *tx.OutTradeNo
}
if tx.TransactionId != nil {
result.ChannelTradeNo = *tx.TransactionId
}
if tx.Amount != nil && tx.Amount.Total != nil {
result.Amount = *tx.Amount.Total
}
if tx.SuccessTime != nil {
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
_ = t
}
}
result.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
result.Paid = result.TradeState == StateSuccess
return result, nil
}
func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
client, _, err := c.buildClient(cfg)
if err != nil {
return "", err
}
body := map[string]any{
"out_trade_no": order.PayNo,
"out_refund_no": refundNo,
"reason": reason,
"notify_url": "", // 退回结果以主动查询为准;如需退回回调,配置后填入
"amount": map[string]any{
"refund": amount,
"total": order.Amount,
"currency": "CNY",
},
}
result, err := client.Post(ctx, consts.WechatPayAPIServer+"/v3/refund/domestic/refunds", body)
if err != nil {
return "", fmt.Errorf("微信退回请求失败: %w", err)
}
respBody, _ := io.ReadAll(result.Response.Body)
var resp struct {
RefundID string `json:"refund_id"`
Status string `json:"status"`
}
_ = json.Unmarshal(respBody, &resp)
if resp.Status != "" && resp.Status != "SUCCESS" && resp.Status != "PROCESSING" {
return resp.RefundID, fmt.Errorf("微信退回未受理,状态: %s", resp.Status)
}
return resp.RefundID, nil
}
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, mchID, err := c.buildClient(cfg)
if err != nil {
return "", err
}
// 公钥模式:/v3/certificates 已对其停用,改查一笔不存在的订单验证鉴权(纯读,不产生交易)
if cfg.Get("pub_key_id") != "" && cfg.Get("pub_key") != "" {
_, qerr := client.Get(ctx,
consts.WechatPayAPIServer+"/v3/pay/transactions/out-trade-no/"+connectTestOutTradeNo+"?mchid="+mchID)
if qerr == nil {
return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil
}
var apiErr *core.APIError
if errors.As(qerr, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
// 订单不存在属预期结果:说明请求签名与应答验签链路均已通过
return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil
}
return "", fmt.Errorf("微信凭证校验失败: %w", qerr)
}
// 平台证书模式:平台证书接口只做签名校验,不产生任何交易
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
if err != nil {
return "", fmt.Errorf("微信凭证校验失败: %w", err)
}
body, _ := io.ReadAll(result.Response.Body)
return fmt.Sprintf("连接成功:商户证书与 APIv3 密钥校验通过(平台证书响应 %d 字节)", len(body)), nil
}
/* ---------------- 小工具 ---------------- */
func valueOrEmpty(v *string) string {
if v == nil {
return ""
}
return *v
}
// randomNonce 生成 n 字节随机数的十六进制串
func randomNonce(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}