package payment import ( "bytes" "context" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "net/http" "net/url" "strings" "time" "kra/internal/biz" "github.com/go-pay/gopay" gopayWechatV3 "github.com/go-pay/gopay/wechat/v3" ) type wechatV3Adapter struct{} func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("微信支付 v3 下单请求为空") } client, err := newWechatV3Client(c, "create_url") if err != nil { return nil, err } bm := gopay.BodyMap{ "appid": firstAny(c, "app_id", "appid"), "mchid": firstAny(c, "merchant_id", "mch_id", "mchid"), "description": req.Subject, "out_trade_no": req.TradeNo, "notify_url": req.NotifyURL, } bm.SetBodyMap("amount", func(amount gopay.BodyMap) { amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency)) }) mergeGoPayExtras(bm, req.Extra, "appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer", "trade_type", "pay_type", "method", "openid", "open_id", "auth_code", "authcode", "barcode", ) tradeType := normalizeWechatV3TradeType(firstAny(req.Extra, "trade_type", "pay_type", "method")) if tradeType == "" { tradeType = normalizeWechatV3TradeType(firstAny(c, "trade_type", "pay_type", "method")) } switch tradeType { case "", "jsapi", "mini", "miniprogram", "mini_program", "applet": if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" { bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("openid", openID) }) } rsp, callErr := client.V3TransactionJsapi(ctx, bm) if callErr != nil { return nil, callErr } if rsp == nil { return nil, errors.New("微信支付 v3 下单响应为空") } return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), tradeType, req.TradeNo, rsp) case "app": rsp, callErr := client.V3TransactionApp(ctx, bm) if callErr != nil { return nil, callErr } if rsp == nil { return nil, errors.New("微信支付 v3 下单响应为空") } return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), "app", req.TradeNo, rsp) case "native", "qr", "qrcode": rsp, callErr := client.V3TransactionNative(ctx, bm) if callErr != nil { return nil, callErr } if rsp == nil { return nil, errors.New("微信支付 v3 下单响应为空") } return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error) case "h5", "mweb": rsp, callErr := client.V3TransactionH5(ctx, bm) if callErr != nil { return nil, callErr } if rsp == nil { return nil, errors.New("微信支付 v3 下单响应为空") } return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error) case "micropay", "micro_pay", "codepay", "code_pay", "barcode", "barcode_pay", "facepay", "face_pay": authCode := strings.TrimSpace(firstAny(req.Extra, "auth_code", "authcode", "barcode")) if authCode == "" { return nil, errors.New("微信支付 v3 付款码支付缺少 auth_code") } bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("auth_code", authCode) }) rsp, callErr := client.V3TransactionCodePay(ctx, bm) if callErr != nil { return nil, callErr } result, err := wechatV3CodePayResult(req.TradeNo, rsp) if err != nil { return nil, err } if result.Amount > 0 && result.Amount != req.Amount { return nil, errors.New("微信支付 v3 付款码响应金额不匹配") } if result.Currency != "" && req.Currency != "" && !strings.EqualFold(result.Currency, req.Currency) { return nil, errors.New("微信支付 v3 付款码响应币种不匹配") } if rsp.Response.Appid != "" && rsp.Response.Appid != firstAny(c, "app_id", "appid") { return nil, errors.New("微信支付 v3 付款码响应 appid 不匹配") } if rsp.Response.Mchid != "" && rsp.Response.Mchid != firstAny(c, "merchant_id", "mch_id", "mchid") { return nil, errors.New("微信支付 v3 付款码响应商户号不匹配") } return result, nil default: return nil, fmt.Errorf("微信支付 v3 不支持交易类型 %q", tradeType) } } func normalizeWechatV3TradeType(value string) string { return strings.NewReplacer(".", "", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) } func wechatV3CodePayResult(tradeNo string, rsp *gopayWechatV3.CodePayRsp) (*biz.PaymentResult, error) { if rsp == nil { return nil, errors.New("微信支付 v3 付款码响应为空") } if rsp.Code != gopayWechatV3.Success { return nil, wechatV3APIError("付款码支付", rsp.Code, rsp.ErrResponse, rsp.Error) } if rsp.Response == nil { return nil, errors.New("微信支付 v3 付款码响应缺少订单") } order := rsp.Response if returnedTradeNo := strings.TrimSpace(order.OutTradeNo); returnedTradeNo == "" { return nil, errors.New("微信支付 v3 付款码响应缺少 out_trade_no") } else if returnedTradeNo != tradeNo { return nil, errors.New("微信支付 v3 付款码响应的商户订单号不匹配") } result := &biz.PaymentResult{ Provider: biz.PaymentWechatV3, Status: normalizePaymentStatus(order.TradeState, "pending"), TradeNo: tradeNo, ProviderTradeNo: strings.TrimSpace(order.TransactionId), Payload: wechatV3ResponsePayload(rsp.SignInfo, order), } if order.Amount != nil { result.Amount = int64(order.Amount.Total) result.Currency = strings.ToUpper(order.Amount.Currency) result.PayerCurrency = strings.ToUpper(order.Amount.PayerCurrency) if result.PayerCurrency == "" { result.PayerCurrency = result.Currency } // GoPay's Amount.PayerTotal is an int, so a missing JSON field and an // explicit zero are otherwise indistinguishable. Inspect the signed // response body before marking the amount breakdown as authoritative. payloadObject := jsonObject(wechatV3ResponsePayload(rsp.SignInfo, order)) if amountObject, ok := valueAtPath(payloadObject, "amount").(map[string]any); ok { if payerTotal, payerOK, payerErr := parseJSONInteger(amountObject, "payer_total"); payerErr != nil { return nil, fmt.Errorf("解析微信支付 v3 付款码 amount.payer_total: %w", payerErr) } else if payerOK && payerTotal >= 0 && payerTotal <= result.Amount { result.PayerPaidAmount = payerTotal result.CashPaidAmount = payerTotal result.DiscountAmount = result.Amount - payerTotal result.AmountBreakdownKnown = true } } } if result.Status == "success" { if result.ProviderTradeNo == "" { return nil, errors.New("微信支付 v3 付款码响应缺少 transaction_id") } if result.Amount <= 0 || result.Currency == "" { return nil, errors.New("微信支付 v3 付款码响应缺少有效金额") } } return result, nil } func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { return nil, errors.New("微信支付 v3 查单缺少 out_trade_no") } client, err := newWechatV3Client(c, "query_url") if err != nil { return nil, err } rsp, err := client.V3TransactionQueryOrder(ctx, gopayWechatV3.OutTradeNo, tradeNo) if err != nil { return nil, err } if rsp == nil { return nil, errors.New("微信支付 v3 查单响应为空") } if rsp.Code != gopayWechatV3.Success { return nil, wechatV3APIError("查单", rsp.Code, rsp.ErrResponse, rsp.Error) } if rsp.Response == nil { return nil, errors.New("微信支付 v3 查单响应缺少订单") } order := rsp.Response payload := wechatV3ResponsePayload(rsp.SignInfo, order) result := &biz.PaymentResult{ Provider: biz.PaymentWechatV3, Status: normalizePaymentStatus(order.TradeState, "pending"), TradeNo: order.OutTradeNo, ProviderTradeNo: order.TransactionId, Payload: payload, } if result.TradeNo == "" { result.TradeNo = tradeNo } else if result.TradeNo != tradeNo { return nil, errors.New("微信支付 v3 查询响应的 out_trade_no 不匹配") } if result.Status != "success" { return result, nil } if result.ProviderTradeNo == "" { return nil, errors.New("微信支付 v3 查询响应缺少 transaction_id") } if order.Amount == nil || order.Amount.Total <= 0 { return nil, errors.New("微信支付 v3 查询响应缺少 amount.total") } result.Amount = int64(order.Amount.Total) result.Currency = strings.ToUpper(order.Amount.Currency) if result.Currency == "" { return nil, errors.New("微信支付 v3 查询响应缺少 amount.currency") } if err := populateWechatV3Breakdown(result, jsonObject(payload)); err != nil { return nil, err } return result, nil } func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("微信支付 v3 退款请求为空") } tradeNo := strings.TrimSpace(req.TradeNo) if tradeNo == "" { return nil, errors.New("微信支付 v3 退款请求缺少 out_trade_no") } refundNo := strings.TrimSpace(req.RefundNo) if refundNo == "" { return nil, errors.New("微信支付 v3 退款请求缺少 out_refund_no") } if req.Amount <= 0 || req.TotalAmount <= 0 || req.Amount > req.TotalAmount { return nil, errors.New("微信支付 v3 退款金额无效") } client, err := newWechatV3Client(c, "refund_url") if err != nil { return nil, err } bm := gopay.BodyMap{"out_trade_no": tradeNo, "out_refund_no": refundNo} bm.SetBodyMap("amount", func(values gopay.BodyMap) { values.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency)) }) mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount") rsp, err := client.V3Refund(ctx, bm) if err != nil { return nil, err } if rsp == nil { return nil, errors.New("微信支付 v3 退款响应为空") } if rsp.Code != gopayWechatV3.Success { return nil, wechatV3APIError("退款", rsp.Code, rsp.ErrResponse, rsp.Error) } if rsp.Response == nil { return nil, errors.New("微信支付 v3 退款响应缺少退款单") } refund := rsp.Response refundID, err := validateWechatV3RefundIdentity(refund, tradeNo, refundNo) if err != nil { return nil, err } result := &biz.PaymentResult{ Provider: biz.PaymentWechatV3, Status: normalizeRefundState(refund.Status), TradeNo: tradeNo, ProviderTradeNo: refundID, Payload: wechatV3ResponsePayload(rsp.SignInfo, refund), } if refund.Amount == nil { return nil, errors.New("微信支付 v3 退款响应缺少金额") } result.Amount = int64(refund.Amount.Refund) result.Currency = strings.ToUpper(strings.TrimSpace(refund.Amount.Currency)) if result.Amount != req.Amount { return nil, errors.New("微信支付 v3 退款响应金额不匹配") } if result.Currency == "" { return nil, errors.New("微信支付 v3 退款响应缺少币种") } if req.Currency != "" && !strings.EqualFold(result.Currency, req.Currency) { return nil, errors.New("微信支付 v3 退款响应币种不匹配") } return result, nil } func validateWechatV3RefundIdentity(refund *gopayWechatV3.RefundOrderResponse, tradeNo, refundNo string) (string, error) { if refund == nil { return "", errors.New("微信支付 v3 退款响应缺少退款单") } tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { return "", errors.New("微信支付 v3 退款请求缺少 out_trade_no") } refundNo = strings.TrimSpace(refundNo) if refundNo == "" { return "", errors.New("微信支付 v3 退款请求缺少 out_refund_no") } returnedTradeNo := strings.TrimSpace(refund.OutTradeNo) if returnedTradeNo == "" { return "", errors.New("微信支付 v3 退款响应缺少 out_trade_no") } if returnedTradeNo != tradeNo { return "", errors.New("微信支付 v3 退款响应的 out_trade_no 不匹配") } returnedRefundNo := strings.TrimSpace(refund.OutRefundNo) if returnedRefundNo == "" { return "", errors.New("微信支付 v3 退款响应缺少 out_refund_no") } if returnedRefundNo != refundNo { return "", errors.New("微信支付 v3 退款响应的 out_refund_no 不匹配") } refundID := strings.TrimSpace(refund.RefundId) if refundID == "" { return "", errors.New("微信支付 v3 退款响应缺少 refund_id") } return refundID, nil } func (a *wechatV3Adapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { if callback == nil { return nil, errors.New("微信支付 v3 回调为空") } apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key") if apiV3Key == "" { return nil, errors.New("微信支付 v3 缺少 api_v3_key") } client, err := newWechatV3Client(c, "") if err != nil { return nil, err } request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://callback.local", bytes.NewReader(callback.Body)) if err != nil { return nil, err } for key, value := range callback.Headers { request.Header.Set(key, value) } notify, err := gopayWechatV3.V3ParseNotify(request) if err != nil { return nil, err } if notify.SignInfo == nil { return nil, errors.New("微信支付 v3 回调缺少签名信息") } platformKeys := client.WxPublicKeyMap() if _, exists := platformKeys[notify.SignInfo.HeaderSerial]; !exists { for serial, key := range platformKeys { if strings.EqualFold(serial, notify.SignInfo.HeaderSerial) { platformKeys[notify.SignInfo.HeaderSerial] = key break } } } if err = notify.VerifySignByPKMap(platformKeys); err != nil { return nil, err } pay, err := notify.DecryptPayCipherText(apiV3Key) if err != nil { return nil, err } if callback.Query == nil { callback.Query = map[string]string{} } callback.Query["event_id"] = notify.Id status := "pending" if pay.TradeState == gopayWechatV3.TradeStateSuccess { status = "success" } merchantID := firstAny(c, "merchant_id", "mch_id", "mchid") if pay.Mchid != "" && pay.Mchid != merchantID { return nil, errors.New("微信支付 v3 回调商户号不匹配") } appID := firstAny(c, "app_id", "appid") if pay.Appid != "" && pay.Appid != appID { return nil, errors.New("微信支付 v3 回调 appid 不匹配") } plain, _ := json.Marshal(pay) return &biz.PaymentResult{ Provider: biz.PaymentWechatV3, Status: status, TradeNo: pay.OutTradeNo, ProviderTradeNo: pay.TransactionId, Payload: plain, }, nil } func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.ClientV3, error) { merchantID := firstAny(c, "merchant_id", "mch_id", "mchid") serialNo := firstAny(c, "serial_no", "merchant_serial_no", "certificate_serial_no") apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key") privateKey := firstAny(c, "private_key", "merchant_private_key") if merchantID == "" || serialNo == "" || apiV3Key == "" || privateKey == "" { return nil, errors.New("微信支付 v3 缺少 merchant_id、serial_no、api_v3_key 或 private_key") } client, err := gopayWechatV3.NewClientV3(merchantID, serialNo, apiV3Key, privateKey) if err != nil { return nil, err } platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert") if platformCert == "" { return nil, errors.New("微信支付 v3 缺少 platform_cert") } platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no") if platformSerial == "" { platformSerial, err = wechatV3PlatformSerial(platformCert) if err != nil { return nil, err } } if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil { return nil, err } client.SetBodySize(8) client.GetHttpClient().SetTimeout(20 * time.Second) if endpointKey != "" { if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" { parsed, parseErr := url.Parse(endpoint) if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" { return nil, fmt.Errorf("微信支付 v3 %s 配置无效", endpointKey) } if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" { return nil, fmt.Errorf("微信支付 v3 %s 只能配置 scheme 和 host", endpointKey) } httpClient := client.GetHttpClient() httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed}) return client, nil } } if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" { parsed, parseErr := url.Parse(baseURL) if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" { return nil, errors.New("微信支付 v3 base_url 配置无效") } client.SetProxyHost(baseURL) } return client, nil } type wechatV3EndpointTransport struct { base http.RoundTripper endpoint *url.URL } func (t *wechatV3EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) { base := t.base if base == nil { base = http.DefaultTransport } clone := request.Clone(request.Context()) target := *request.URL target.Scheme = t.endpoint.Scheme target.Host = t.endpoint.Host target.User = t.endpoint.User clone.URL = &target clone.Host = "" return base.RoundTrip(clone) } func wechatV3PlatformSerial(certificate string) (string, error) { block, _ := pem.Decode([]byte(certificate)) if block == nil || block.Type != "CERTIFICATE" { return "", errors.New("微信支付 v3 platform_cert 格式错误") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return "", fmt.Errorf("解析微信支付 v3 platform_cert: %w", err) } return strings.ToUpper(cert.SerialNumber.Text(16)), nil } func wechatV3CreateResult(tradeNo string, code int, signInfo *gopayWechatV3.SignInfo, response any, errResponse gopayWechatV3.ErrResponse, rawError string) (*biz.PaymentResult, error) { if code != gopayWechatV3.Success { return nil, wechatV3APIError("下单", code, errResponse, rawError) } if response == nil { return nil, errors.New("微信支付 v3 下单响应缺少预支付信息") } return &biz.PaymentResult{ Provider: biz.PaymentWechatV3, Status: "created", TradeNo: tradeNo, Payload: wechatV3ResponsePayload(signInfo, response), }, nil } func wechatV3PrepayCreateResult(client *gopayWechatV3.ClientV3, appID, tradeType, tradeNo string, rsp *gopayWechatV3.PrepayRsp) (*biz.PaymentResult, error) { if rsp == nil { return nil, errors.New("微信支付 v3 下单响应为空") } result, err := wechatV3CreateResult(tradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error) if err != nil { return nil, err } if rsp.Response == nil || strings.TrimSpace(rsp.Response.PrepayId) == "" { return nil, errors.New("微信支付 v3 下单响应缺少 prepay_id") } var payParams any switch strings.ToLower(strings.TrimSpace(tradeType)) { case "app": payParams, err = client.PaySignOfApp(appID, rsp.Response.PrepayId) case "mini", "miniprogram", "mini_program", "applet": payParams, err = client.PaySignOfApplet(appID, rsp.Response.PrepayId) default: payParams, err = client.PaySignOfJSAPI(appID, rsp.Response.PrepayId) } if err != nil { return nil, fmt.Errorf("生成微信支付 v3 客户端调起参数: %w", err) } result.Payload = mustJSON(payParams) return result, nil } func wechatV3APIError(operation string, code int, response gopayWechatV3.ErrResponse, raw string) error { message := strings.TrimSpace(response.Message) if message == "" { message = strings.TrimSpace(raw) } if message == "" { return fmt.Errorf("微信支付 v3 %s HTTP %d", operation, code) } return fmt.Errorf("微信支付 v3 %s HTTP %d: %s", operation, code, message) } func wechatV3ResponsePayload(signInfo *gopayWechatV3.SignInfo, response any) []byte { if signInfo != nil && strings.TrimSpace(signInfo.SignBody) != "" { return []byte(signInfo.SignBody) } payload, _ := json.Marshal(response) return payload } func populateWechatV3Breakdown(result *biz.PaymentResult, object map[string]any) error { if result == nil || result.Status != "success" { return nil } amount, ok := valueAtPath(object, "amount").(map[string]any) if !ok { return nil } payerTotal, payerOK, err := parseJSONInteger(amount, "payer_total") if err != nil { return fmt.Errorf("解析微信支付 v3 amount.payer_total: %w", err) } if !payerOK { return nil } if payerTotal < 0 || payerTotal > result.Amount { return errors.New("微信支付 v3 用户实付金额无效") } result.PayerPaidAmount = payerTotal result.CashPaidAmount = payerTotal result.DiscountAmount = result.Amount - payerTotal if settlement, ok, settlementErr := parseJSONInteger(amount, "settlement_amount"); settlementErr != nil { return fmt.Errorf("解析微信支付 v3 amount.settlement_amount: %w", settlementErr) } else if ok { result.SettlementAmount = settlement } // promotion_detail contains the authoritative promotion breakdown, but its // funding fields vary by promotion type. We only expose the aggregate // discount here; the original JSON remains in Payload for audit. if result.DiscountAmount < 0 { return errors.New("微信支付 v3 优惠金额无效") } result.AmountBreakdownKnown = true return nil } func parseJSONInteger(object map[string]any, key string) (int64, bool, error) { raw, exists := object[key] if !exists || raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || fmt.Sprint(raw) == "" { return 0, false, nil } amount, err := parseIntegerAmount(fmt.Sprint(raw)) return amount, true, err } func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) { if len(key) != 32 { return nil, errors.New("微信支付 v3 api_v3_key 必须为 32 字节") } return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key) }