317 lines
10 KiB
Go
317 lines
10 KiB
Go
package payment
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"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/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 密钥
|
||
//
|
||
// 证书(cert_json):
|
||
//
|
||
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
|
||
type WechatChannel struct{}
|
||
|
||
func (c *WechatChannel) Code() string { return ChannelWechat }
|
||
func (c *WechatChannel) Name() string { return "微信支付" }
|
||
|
||
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
|
||
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)
|
||
}
|
||
|
||
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")
|
||
|
||
// 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签
|
||
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
|
||
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
|
||
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, _, err := c.buildClient(cfg)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 平台证书接口只做签名校验,不产生任何交易
|
||
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
|
||
}
|