增加支付功能
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user