package payment import ( "bytes" "context" "crypto/rand" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "errors" "fmt" bizpayment "kra/internal/biz/payment" "kra/internal/paymentkit" "net/http" "net/url" "strings" "time" "github.com/go-pay/gopay" "github.com/go-pay/gopay/pkg/xhttp" gopayWechat "github.com/go-pay/gopay/wechat" ) type wechatV2Adapter struct{} type wechatV2EndpointTransport struct { endpoint *url.URL next http.RoundTripper } func (t *wechatV2EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) { clone := request.Clone(request.Context()) target := *t.endpoint clone.URL = &target clone.Host = target.Host clone.RequestURI = "" return t.next.RoundTrip(clone) } func wechatV2SignType(value string) string { if strings.EqualFold(strings.TrimSpace(value), gopayWechat.SignType_HMAC_SHA256) { return gopayWechat.SignType_HMAC_SHA256 } return gopayWechat.SignType_MD5 } func (a *wechatV2Adapter) Create(ctx context.Context, req *bizpayment.PaymentRequest, c map[string]any) (*bizpayment.PaymentResult, error) { if req == nil { return nil, errors.New("微信支付 v2 下单请求为空") } client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url")) if err != nil { return nil, err } tradeType, err := wechatV2CreateMethod(req.Extra, c) if err != nil { return nil, err } values := map[string]string{ "appid": client.AppId, "mch_id": client.MchId, "nonce_str": nonce(), "body": req.Subject, "out_trade_no": req.TradeNo, "total_fee": fmt.Sprint(req.Amount), "fee_type": strings.ToUpper(req.Currency), "spbill_create_ip": req.ClientIP, "notify_url": req.NotifyURL, "trade_type": tradeType, "sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")), } if values["spbill_create_ip"] == "" { values["spbill_create_ip"] = firstAny(c, "client_ip", "spbill_create_ip") } if values["spbill_create_ip"] == "" { values["spbill_create_ip"] = "127.0.0.1" } if tradeType == "MICROPAY" { return a.createMicropay(ctx, client, req, c, values) } mergeWechatV2Extras(values, req.Extra) values["trade_type"] = tradeType rsp, err := client.UnifiedOrder(ctx, toGoPayBodyMap(values)) if err != nil { return nil, err } resultValues, err := wechatV2ResponseValues(rsp) if err != nil { return nil, err } if err = validateWechatV2Response(resultValues); err != nil { return nil, err } if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil { return nil, err } return &bizpayment.PaymentResult{ Provider: bizpayment.PaymentWechatV2, Status: "created", TradeNo: req.TradeNo, Payload: wechatV2Payload(resultValues), }, nil } func (a *wechatV2Adapter) createMicropay(ctx context.Context, client *gopayWechat.Client, req *bizpayment.PaymentRequest, c map[string]any, values map[string]string) (*bizpayment.PaymentResult, error) { authCode := firstAny(req.Extra, "auth_code", "authcode", "barcode", "pay_code", "payment_code") if authCode == "" { authCode = firstAny(c, "auth_code", "authcode", "barcode", "pay_code", "payment_code") } if strings.TrimSpace(authCode) == "" { return nil, errors.New("微信支付 v2 付款码支付缺少 auth_code") } delete(values, "trade_type") delete(values, "notify_url") mergeWechatV2Extras(values, req.Extra) values["auth_code"] = strings.TrimSpace(authCode) rsp, err := client.Micropay(ctx, toGoPayBodyMap(values)) if err != nil { return nil, err } if rsp == nil { return nil, errors.New("微信支付 v2 付款码支付响应为空") } resultValues, err := wechatV2ResponseValues(rsp) if err != nil { return nil, err } if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil { return nil, err } if resultValues["return_code"] != "SUCCESS" { return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "return_msg", "err_code_des", "err_code")) } if resultValues["result_code"] != "SUCCESS" && !strings.EqualFold(resultValues["err_code"], "USERPAYING") { return nil, fmt.Errorf("微信支付 v2 付款码支付失败: %s", first(resultValues, "err_code_des", "return_msg", "err_code")) } if resultValues["out_trade_no"] == "" { return nil, errors.New("微信支付 v2 付款码支付响应缺少 out_trade_no") } if resultValues["out_trade_no"] != req.TradeNo { return nil, errors.New("微信支付 v2 付款码支付响应的 out_trade_no 不匹配") } if strings.EqualFold(resultValues["err_code"], "USERPAYING") { return &bizpayment.PaymentResult{Provider: bizpayment.PaymentWechatV2, Status: "pending", TradeNo: req.TradeNo, Payload: wechatV2Payload(resultValues)}, nil } providerTradeNo := strings.TrimSpace(resultValues["transaction_id"]) if providerTradeNo == "" { return nil, errors.New("微信支付 v2 付款码支付响应缺少 transaction_id") } result := &bizpayment.PaymentResult{ Provider: bizpayment.PaymentWechatV2, Status: "success", TradeNo: req.TradeNo, ProviderTradeNo: providerTradeNo, Payload: wechatV2Payload(resultValues), } amount := strings.TrimSpace(resultValues["total_fee"]) if amount == "" { return nil, errors.New("微信支付 v2 付款码支付响应缺少 total_fee") } result.Amount, err = parseIntegerAmount(amount) if err != nil { return nil, fmt.Errorf("解析微信支付 v2 付款码金额: %w", err) } if result.Amount != req.Amount { return nil, errors.New("微信支付 v2 付款码支付响应金额不匹配") } result.Currency = strings.ToUpper(resultValues["fee_type"]) requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency)) if result.Currency == "" { result.Currency = requestCurrency } else if requestCurrency != "" && result.Currency != requestCurrency { return nil, errors.New("微信支付 v2 付款码支付响应币种不匹配") } if err = populateWechatV2Breakdown(result, resultValues); err != nil { return nil, err } return result, nil } func wechatV2CreateMethod(extra, config map[string]any) (string, error) { value := firstAny(extra, "trade_type", "pay_type", "method", "pay_method", "channel") if value == "" { value = firstAny(config, "trade_type", "pay_type", "method", "pay_method", "channel") } normalized := paymentkit.NormalizePaymentMethod(value) switch normalized { case "", "jsapi", "js_api", "mini", "miniapp", "mini_program", "miniprogram", "applet": return gopayWechat.TradeType_JsApi, nil case "app", "app_pay": return gopayWechat.TradeType_App, nil case "native", "qr", "qrcode": return gopayWechat.TradeType_Native, nil case "h5", "h5_pay", "mweb", "wap", "wap_pay": return gopayWechat.TradeType_H5, nil case "micropay", "micro_pay", "barcode", "barcode_pay", "pay_code", "payment_code": return "MICROPAY", nil default: return "", fmt.Errorf("微信支付 v2 不支持的下单方式: %s", value) } } func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*bizpayment.PaymentResult, error) { tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { return nil, errors.New("微信支付 v2 查单缺少 out_trade_no") } client, err := newWechatV2Client(c, firstAny(c, "query_url", "order_query_url")) if err != nil { return nil, err } body := gopay.BodyMap{ "nonce_str": nonce(), "out_trade_no": tradeNo, "sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")), } _, response, err := client.QueryOrder(ctx, body) if err != nil { return nil, err } values := fromGoPayBodyMap(response) if err = validateWechatV2Response(values); err != nil { return nil, err } if err = verifyWechatV2Response(values, client.ApiKey, body.GetString("sign_type")); err != nil { return nil, err } result := &bizpayment.PaymentResult{ Provider: bizpayment.PaymentWechatV2, Status: normalizePaymentStatus(values["trade_state"], "pending"), TradeNo: first(values, "out_trade_no"), Payload: wechatV2Payload(values), } if result.TradeNo == "" { result.TradeNo = tradeNo } else if result.TradeNo != tradeNo { return nil, errors.New("微信支付 v2 查询响应的 out_trade_no 不匹配") } if result.Status != "success" { return result, nil } result.ProviderTradeNo = values["transaction_id"] if result.ProviderTradeNo == "" { return nil, errors.New("微信支付 v2 查询响应缺少 transaction_id") } result.Amount, err = parseIntegerAmount(values["total_fee"]) if err != nil { return nil, fmt.Errorf("解析微信支付 v2 订单金额: %w", err) } result.Currency = strings.ToUpper(values["fee_type"]) if result.Currency == "" { result.Currency = "CNY" } if err = populateWechatV2Breakdown(result, values); err != nil { return nil, err } return result, nil } func (a *wechatV2Adapter) Refund(ctx context.Context, req *bizpayment.PaymentRefundRequest, c map[string]any) (*bizpayment.PaymentResult, error) { if req == nil { return nil, errors.New("微信支付 v2 退款请求为空") } tradeNo := strings.TrimSpace(req.TradeNo) refundNo := strings.TrimSpace(req.RefundNo) if tradeNo == "" || refundNo == "" { return nil, errors.New("微信支付 v2 退款缺少 out_trade_no 或 out_refund_no") } if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount { return nil, errors.New("微信支付 v2 退款金额无效") } client, err := newWechatV2Client(c, firstAny(c, "refund_url", "refund_apply_url")) if err != nil { return nil, err } values := map[string]string{ "appid": client.AppId, "mch_id": client.MchId, "nonce_str": nonce(), "out_trade_no": tradeNo, "out_refund_no": refundNo, "total_fee": fmt.Sprint(req.TotalAmount), "refund_fee": fmt.Sprint(req.Amount), "sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")), } currency := strings.ToUpper(strings.TrimSpace(req.Currency)) if currency != "" { values["refund_fee_type"] = currency } rsp, response, err := client.Refund(ctx, toGoPayBodyMap(values)) if err != nil { return nil, err } resultValues := fromGoPayBodyMap(response) if rsp == nil { return nil, errors.New("微信支付 v2 退款响应为空") } if err = validateWechatV2Response(resultValues); err != nil { return nil, err } if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil { return nil, err } if err = validateWechatV2RefundIdentity(tradeNo, refundNo, rsp); err != nil { return nil, err } result := &bizpayment.PaymentResult{ Provider: bizpayment.PaymentWechatV2, Status: "created", TradeNo: tradeNo, ProviderTradeNo: strings.TrimSpace(rsp.RefundId), Currency: currency, Payload: wechatV2Payload(resultValues), } if rsp.RefundFee != "" { result.Amount, err = parseIntegerAmount(rsp.RefundFee) if err != nil { return nil, fmt.Errorf("解析微信支付 v2 退款金额: %w", err) } if result.Amount != req.Amount { return nil, errors.New("微信支付 v2 退款响应金额不匹配") } } else { result.Amount = req.Amount } if responseCurrency := strings.ToUpper(strings.TrimSpace(rsp.FeeType)); responseCurrency != "" { if currency != "" && responseCurrency != currency { return nil, errors.New("微信支付 v2 退款响应币种不匹配") } result.Currency = responseCurrency } return result, nil } func validateWechatV2RefundIdentity(tradeNo, refundNo string, rsp *gopayWechat.RefundResponse) error { if rsp == nil { return errors.New("微信支付 v2 退款响应为空") } if returnedTradeNo := strings.TrimSpace(rsp.OutTradeNo); returnedTradeNo != "" && returnedTradeNo != tradeNo { return errors.New("微信支付 v2 退款响应的 out_trade_no 不匹配") } if returnedRefundNo := strings.TrimSpace(rsp.OutRefundNo); returnedRefundNo == "" { return errors.New("微信支付 v2 退款响应缺少 out_refund_no") } else if returnedRefundNo != refundNo { return errors.New("微信支付 v2 退款响应的 out_refund_no 不匹配") } if strings.TrimSpace(rsp.RefundId) == "" { return errors.New("微信支付 v2 退款响应缺少 refund_id") } return nil } func (a *wechatV2Adapter) Callback(_ context.Context, callback *bizpayment.PaymentCallback, c map[string]any) (*bizpayment.PaymentResult, error) { if callback == nil || len(callback.Body) == 0 { return nil, errors.New("微信支付 v2 回调为空") } request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body)) if err != nil { return nil, err } body, err := gopayWechat.ParseNotifyToBodyMap(request) if err != nil { return nil, err } values := fromGoPayBodyMap(body) if err = verifyWechatV2(values, firstAny(c, "mch_key", "api_key", "merchant_key")); err != nil { return nil, err } if values["appid"] != firstAny(c, "app_id", "appid") || values["mch_id"] != firstAny(c, "merchant_id", "mch_id") { return nil, errors.New("微信支付 v2 回调商户配置不匹配") } status := "pending" if values["return_code"] == "SUCCESS" && values["result_code"] == "SUCCESS" && values["trade_state"] != "REFUND" { status = "success" } payload, _ := json.Marshal(values) return &bizpayment.PaymentResult{Provider: bizpayment.PaymentWechatV2, Status: status, TradeNo: first(values, "out_trade_no"), ProviderTradeNo: values["transaction_id"], Payload: payload}, nil } func newWechatV2Client(c map[string]any, endpoint string) (*gopayWechat.Client, error) { appID := firstAny(c, "app_id", "appid") merchantID := firstAny(c, "merchant_id", "mch_id") mchKey := firstAny(c, "mch_key", "api_key", "merchant_key") if appID == "" || merchantID == "" || mchKey == "" { return nil, errors.New("微信支付 v2 缺少 app_id、merchant_id 或 mch_key") } // The previous adapter sent the configured amount unchanged, including to // sandbox or test endpoints. GoPay's sandbox mode rewrites total_fee, so use // its production signing flow and redirect the high-level call when a custom // endpoint is configured. client := gopayWechat.NewClient(appID, merchantID, mchKey, true) regular := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(4) if err := setWechatV2Endpoint(regular, endpoint); err != nil { return nil, err } client.SetHttpClient(regular) tlsHTTPClient, err := paymentHTTPClient(c) if err != nil { return nil, err } tlsClient := xhttp.NewClient().SetBodySize(4) tlsClient.HttpClient = tlsHTTPClient if err = setWechatV2Endpoint(tlsClient, endpoint); err != nil { return nil, err } client.SetTLSHttpClient(tlsClient) return client, nil } func setWechatV2Endpoint(client *xhttp.Client, endpoint string) error { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { return nil } target, err := url.Parse(endpoint) if err != nil || target.Scheme == "" || target.Host == "" { return fmt.Errorf("微信支付 v2 接口地址无效: %s", endpoint) } next := client.HttpClient.Transport if next == nil { next = http.DefaultTransport } client.SetTransport(&wechatV2EndpointTransport{endpoint: target, next: next}) return nil } func wechatV2ResponseValues(value any) (map[string]string, error) { raw, err := json.Marshal(value) if err != nil { return nil, err } var body gopay.BodyMap if err = json.Unmarshal(raw, &body); err != nil { return nil, err } return fromGoPayBodyMap(body), nil } func validateWechatV2Response(values map[string]string) error { if strings.EqualFold(strings.TrimSpace(values["return_code"]), "SUCCESS") && strings.EqualFold(strings.TrimSpace(values["result_code"]), "SUCCESS") { return nil } message := first(values, "err_code_des", "return_msg", "err_code") if message == "" { message = "未知错误" } return fmt.Errorf("微信支付 v2 错误: %s", message) } func verifyWechatV2Response(values map[string]string, key, fallbackSignType string) error { if values["sign"] == "" { return errors.New("微信支付 v2 响应缺少签名") } signType := values["sign_type"] if signType == "" { signType = fallbackSignType } return verifyWechatV2WithSignType(values, key, signType) } func wechatV2Payload(values map[string]string) []byte { return []byte(gopayWechat.GenerateXml(toGoPayBodyMap(values))) } func populateWechatV2Breakdown(result *bizpayment.PaymentResult, values map[string]string) error { if result == nil || result.Status != "success" { return nil } // WeChat v2 reports total_fee/cash_fee/coupon_fee in the smallest currency // unit. cash_fee is the payer cash amount; coupon_fee is the total coupon // discount. v2 does not reliably identify coupon funding parties, so those // fields remain zero rather than being misclassified. cash, cashOK, err := parseWechatInteger(values, "cash_fee") if err != nil { return fmt.Errorf("解析微信支付 v2 cash_fee: %w", err) } if !cashOK { return nil } coupon, couponOK, err := parseWechatInteger(values, "coupon_fee") if err != nil { return fmt.Errorf("解析微信支付 v2 coupon_fee: %w", err) } if !couponOK { coupon = result.Amount - cash } if cash < 0 || coupon < 0 || cash > result.Amount || cash+coupon != result.Amount { return errors.New("微信支付 v2 总额、现金实付和优惠金额不守恒") } result.PayerPaidAmount = cash result.CashPaidAmount = cash result.DiscountAmount = coupon if settlement, ok, settlementErr := parseWechatInteger(values, "settlement_total_fee"); settlementErr != nil { return fmt.Errorf("解析微信支付 v2 settlement_total_fee: %w", settlementErr) } else if ok { result.SettlementAmount = settlement } // coupon_fee_n is a per-coupon detail in v2. Its aggregate still has no // authoritative merchant/provider funding split, so preserve the raw list // in Payload instead of assigning it to either party. _ = couponOK result.AmountBreakdownKnown = true return nil } func parseWechatInteger(values map[string]string, key string) (int64, bool, error) { value, exists := values[key] if !exists || strings.TrimSpace(value) == "" { return 0, false, nil } amount, err := parseIntegerAmount(value) return amount, true, err } func mergeWechatV2Extras(dst map[string]string, extra map[string]any) { protected := map[string]struct{}{ "appid": {}, "mch_id": {}, "nonce_str": {}, "body": {}, "out_trade_no": {}, "total_fee": {}, "fee_type": {}, "spbill_create_ip": {}, "notify_url": {}, "trade_type": {}, "auth_code": {}, "sign": {}, } for key, value := range stringMap(extra) { if _, exists := protected[key]; exists { continue } dst[key] = value } } func verifyWechatV2(values map[string]string, key string) error { return verifyWechatV2WithSignType(values, key, values["sign_type"]) } func verifyWechatV2WithSignType(values map[string]string, key, signType string) error { if key == "" { return errors.New("微信支付 v2 未配置 mch_key") } ok, err := gopayWechat.VerifySign(key, wechatV2SignType(signType), cloneGoPayBodyMap(toGoPayBodyMap(values))) if err != nil { return err } if !ok { return errors.New("微信支付 v2 回调签名校验失败") } return nil } func paymentHTTPClient(c map[string]any) (*http.Client, error) { certPEM := firstAny(c, "client_cert", "cert_pem", "apiclient_cert") keyPEM := firstAny(c, "client_key", "key_pem", "apiclient_key") if certPEM == "" && keyPEM == "" { return &http.Client{Timeout: 20 * time.Second}, nil } if certPEM == "" || keyPEM == "" { return nil, errors.New("微信支付 v2 client_cert 和 client_key 必须同时配置") } cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) if err != nil { return nil, err } var pool *x509.CertPool if ca := firstAny(c, "ca_cert", "root_cert"); ca != "" { pool = x509.NewCertPool() if !pool.AppendCertsFromPEM([]byte(ca)) { return nil, errors.New("微信支付 v2 ca_cert 格式错误") } } return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil } // nonce returns a compact request token. Clock-only values can collide when // several payment requests are prepared in the same scheduler tick. func nonce() string { var raw [8]byte if _, err := rand.Read(raw[:]); err == nil { return hex.EncodeToString(raw[:]) } return fmt.Sprintf("%d", time.Now().UnixNano()) } func stringMap(values map[string]any) map[string]string { result := map[string]string{} for key, value := range values { if text, ok := value.(string); ok { result[key] = text } } return result }