package wechat import ( "context" "crypto/md5" "encoding/xml" "fmt" "io" "net/http" "sort" "strings" "time" "github.com/break/junhong_cmp_fiber/pkg/errors" "go.uber.org/zap" ) const wechatPayV2UnifiedOrderURL = "https://api.mch.weixin.qq.com/pay/unifiedorder" // PaymentV2Service 微信支付 v2 服务 // 适用于仅配置 APIv2Key 的商户(无需 v3 证书序列号) type PaymentV2Service struct { appID string mchID string apiKey string notifyURL string logger *zap.Logger } // NewPaymentV2Service 创建微信支付 v2 服务 func NewPaymentV2Service(appID, mchID, apiKey, notifyURL string, logger *zap.Logger) *PaymentV2Service { return &PaymentV2Service{ appID: appID, mchID: mchID, apiKey: apiKey, notifyURL: notifyURL, logger: logger, } } // v2UnifiedOrderRequest 统一下单请求(v2 XML 格式) type v2UnifiedOrderRequest struct { XMLName xml.Name `xml:"xml"` AppID string `xml:"appid"` MchID string `xml:"mch_id"` NonceStr string `xml:"nonce_str"` Sign string `xml:"sign"` Body string `xml:"body"` OutTradeNo string `xml:"out_trade_no"` TotalFee int `xml:"total_fee"` SpbillIP string `xml:"spbill_create_ip"` NotifyURL string `xml:"notify_url"` TradeType string `xml:"trade_type"` OpenID string `xml:"openid"` } // v2UnifiedOrderResponse 统一下单响应(v2 XML 格式) type v2UnifiedOrderResponse struct { XMLName xml.Name `xml:"xml"` ReturnCode string `xml:"return_code"` ReturnMsg string `xml:"return_msg"` ResultCode string `xml:"result_code"` ErrCode string `xml:"err_code"` ErrCodeDes string `xml:"err_code_des"` PrepayID string `xml:"prepay_id"` } // CreateJSAPIOrder 创建 v2 JSAPI 支付订单 func (s *PaymentV2Service) CreateJSAPIOrder(ctx context.Context, orderNo, description, openID string, amount int) (*JSAPIPayResult, error) { if orderNo == "" || openID == "" || amount <= 0 { return nil, errors.New(errors.CodeInvalidParam, "订单号、OpenID 和金额不能为空") } nonceStr := v2GenerateNonceStr() params := map[string]string{ "appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, "body": description, "out_trade_no": orderNo, "total_fee": fmt.Sprintf("%d", amount), "spbill_create_ip": "127.0.0.1", "notify_url": s.notifyURL, "trade_type": "JSAPI", "openid": openID, } params["sign"] = v2SignMD5(params, s.apiKey) reqBody := &v2UnifiedOrderRequest{ AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], Sign: params["sign"], Body: params["body"], OutTradeNo: params["out_trade_no"], TotalFee: amount, SpbillIP: params["spbill_create_ip"], NotifyURL: params["notify_url"], TradeType: params["trade_type"], OpenID: params["openid"], } xmlBytes, err := xml.Marshal(reqBody) if err != nil { return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "序列化统一下单请求失败") } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, wechatPayV2UnifiedOrderURL, strings.NewReader(string(xmlBytes))) if err != nil { return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建统一下单请求失败") } httpReq.Header.Set("Content-Type", "application/xml") httpResp, err := http.DefaultClient.Do(httpReq) if err != nil { s.logger.Error("调用微信 v2 统一下单接口失败", zap.String("order_no", orderNo), zap.Error(err)) return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "调用统一下单接口失败") } defer httpResp.Body.Close() respBytes, err := io.ReadAll(httpResp.Body) if err != nil { return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "读取统一下单响应失败") } var resp v2UnifiedOrderResponse if err = xml.Unmarshal(respBytes, &resp); err != nil { return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "解析统一下单响应失败") } if resp.ReturnCode != "SUCCESS" { s.logger.Error("微信 v2 统一下单通信失败", zap.String("order_no", orderNo), zap.String("return_msg", resp.ReturnMsg), ) return nil, errors.New(errors.CodeWechatPayFailed, "统一下单通信失败: "+resp.ReturnMsg) } if resp.ResultCode != "SUCCESS" { s.logger.Error("微信 v2 统一下单业务失败", zap.String("order_no", orderNo), zap.String("err_code", resp.ErrCode), zap.String("err_code_des", resp.ErrCodeDes), ) return nil, errors.New(errors.CodeWechatPayFailed, "统一下单业务失败: "+resp.ErrCodeDes) } payConfig := s.buildJSAPIPayConfig(resp.PrepayID, nonceStr) s.logger.Info("创建 v2 JSAPI 支付订单成功", zap.String("order_no", orderNo), zap.String("prepay_id", resp.PrepayID), ) return &JSAPIPayResult{ PrepayID: resp.PrepayID, PayConfig: payConfig, }, nil } // buildJSAPIPayConfig 构建 JSAPI 唤起支付所需的签名参数 func (s *PaymentV2Service) buildJSAPIPayConfig(prepayID, nonceStr string) map[string]string { timestamp := fmt.Sprintf("%d", time.Now().Unix()) params := map[string]string{ "appId": s.appID, "timeStamp": timestamp, "nonceStr": nonceStr, "package": "prepay_id=" + prepayID, "signType": "MD5", } params["paySign"] = v2SignMD5(params, s.apiKey) return params } // v2SignMD5 对参数按字典序排序后拼接 key,计算 MD5 大写签名 func v2SignMD5(params map[string]string, apiKey string) string { keys := make([]string, 0, len(params)) for k := range params { keys = append(keys, k) } sort.Strings(keys) var sb strings.Builder for _, k := range keys { v := params[k] if v == "" { continue } sb.WriteString(k) sb.WriteString("=") sb.WriteString(v) sb.WriteString("&") } sb.WriteString("key=") sb.WriteString(apiKey) hash := md5.Sum([]byte(sb.String())) return strings.ToUpper(fmt.Sprintf("%x", hash)) } func v2GenerateNonceStr() string { return fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))) } // v2CallbackNotify v2 支付回调通知 XML 结构 // 所有字段均为 string,避免 CDATA 整数字段的类型转换问题 type v2CallbackNotify struct { XMLName xml.Name `xml:"xml"` AppID string `xml:"appid"` BankType string `xml:"bank_type"` CashFee string `xml:"cash_fee"` FeeType string `xml:"fee_type"` IsSubscribe string `xml:"is_subscribe"` MchID string `xml:"mch_id"` NonceStr string `xml:"nonce_str"` OpenID string `xml:"openid"` OutTradeNo string `xml:"out_trade_no"` ResultCode string `xml:"result_code"` ReturnCode string `xml:"return_code"` Sign string `xml:"sign"` TimeEnd string `xml:"time_end"` TotalFee string `xml:"total_fee"` TradeType string `xml:"trade_type"` TransactionID string `xml:"transaction_id"` } // PeekOrderNo 从 v2 回调 XML 中解析订单号(不验签) // 用于在验签前确定使用哪套支付配置 func PeekOrderNo(body []byte) (string, error) { var notify v2CallbackNotify if err := xml.Unmarshal(body, ¬ify); err != nil { return "", fmt.Errorf("解析回调 XML 失败: %w", err) } return notify.OutTradeNo, nil } // V2CallbackResult v2 回调解析结果 type V2CallbackResult struct { OutTradeNo string TransactionID string // TradeState 为 "SUCCESS" 表示支付成功(return_code 和 result_code 均为 SUCCESS) TradeState string TotalFee string OpenID string } // VerifyCallback 解析并验证 v2 回调 XML,返回解析结果 func (s *PaymentV2Service) VerifyCallback(body []byte) (*V2CallbackResult, error) { var notify v2CallbackNotify if err := xml.Unmarshal(body, ¬ify); err != nil { return nil, fmt.Errorf("解析回调 XML 失败: %w", err) } // 构建待签名参数(排除 sign 字段,v2SignMD5 内部自动跳过空值) params := map[string]string{ "appid": notify.AppID, "bank_type": notify.BankType, "cash_fee": notify.CashFee, "fee_type": notify.FeeType, "is_subscribe": notify.IsSubscribe, "mch_id": notify.MchID, "nonce_str": notify.NonceStr, "openid": notify.OpenID, "out_trade_no": notify.OutTradeNo, "result_code": notify.ResultCode, "return_code": notify.ReturnCode, "time_end": notify.TimeEnd, "total_fee": notify.TotalFee, "trade_type": notify.TradeType, "transaction_id": notify.TransactionID, } expected := v2SignMD5(params, s.apiKey) if !strings.EqualFold(notify.Sign, expected) { s.logger.Error("v2 回调签名验证失败", zap.String("received", notify.Sign), zap.String("expected", expected), zap.String("mch_id", notify.MchID), ) return nil, fmt.Errorf("签名验证失败") } // return_code 和 result_code 均为 SUCCESS 才算支付成功 tradeState := "" if notify.ReturnCode == "SUCCESS" && notify.ResultCode == "SUCCESS" { tradeState = "SUCCESS" } return &V2CallbackResult{ OutTradeNo: notify.OutTradeNo, TransactionID: notify.TransactionID, TradeState: tradeState, TotalFee: notify.TotalFee, OpenID: notify.OpenID, }, nil }