package payment import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "kra/app/system/internal/biz" "github.com/go-pay/gopay" gopayAlipay "github.com/go-pay/gopay/alipay" "github.com/go-pay/gopay/pkg/xhttp" ) type alipayAdapter struct{} type alipayClient struct { client *gopayAlipay.Client gateway *url.URL response bytes.Buffer } type alipayTransport struct { base http.RoundTripper gateway *url.URL response *bytes.Buffer } type alipayResponseBody struct { io.Reader io.Closer } func (a *alipayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("支付宝下单参数为空") } amount, err := formatDecimalAmount(req.Amount, 100) if err != nil { return nil, err } bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "total_amount": amount, "subject": req.Subject} mergeMap(bm, req.Extra, "out_trade_no", "total_amount", "subject", "notify_url", "return_url", "method", "pay_method", "trade_type", "channel", ) sdk, err := newAlipayClient(c) if err != nil { return nil, err } if req.NotifyURL != "" { sdk.client.SetNotifyUrl(req.NotifyURL) } if req.ReturnURL != "" { sdk.client.SetReturnUrl(req.ReturnURL) } method, err := alipayCreateMethod(req.Extra, c) if err != nil { return nil, err } switch method { case "alipay.trade.pay": rsp, callErr := sdk.client.TradePay(ctx, bm) if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { return nil, err } response := rsp.Response if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != req.TradeNo { return nil, errors.New("支付宝付款码支付响应的 out_trade_no 不匹配") } status := "" switch response.Code { case "10000": status = "success" case "10003": status = "pending" default: return nil, fmt.Errorf("支付宝付款码支付失败: %s", firstNonEmptyAlipay(response.SubMsg, response.Msg, response.SubCode, response.Code)) } if strings.TrimSpace(response.OutTradeNo) == "" { return nil, errors.New("支付宝付款码支付响应缺少 out_trade_no") } if status == "success" && strings.TrimSpace(response.TradeNo) == "" { return nil, errors.New("支付宝付款码支付成功响应缺少 trade_no") } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: status, TradeNo: req.TradeNo, ProviderTradeNo: strings.TrimSpace(response.TradeNo), Payload: sdk.payload(rsp), }, nil case "alipay.trade.precreate": rsp, callErr := sdk.client.TradePrecreate(ctx, bm) if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { return nil, err } response := rsp.Response queryID := strings.TrimSpace(response.OutTradeNo) if queryID == "" { queryID = req.TradeNo } else if queryID != req.TradeNo { return nil, errors.New("支付宝预创建响应的 out_trade_no 不匹配") } if strings.TrimSpace(response.QrCode) == "" { return nil, errors.New("支付宝预创建响应缺少 qr_code") } // Precreate has no Alipay trade number yet. Keep the QR code in Payload // and query later by the merchant out_trade_no. return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, QueryID: queryID, Payload: sdk.payload(rsp), }, nil case "alipay.trade.app.pay": orderString, callErr := sdk.client.TradeAppPay(ctx, bm) if callErr != nil { return nil, callErr } if strings.TrimSpace(orderString) == "" { return nil, errors.New("支付宝 APP 支付参数为空") } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, Payload: mustMarshalAlipayPayload(map[string]string{"order_string": orderString}), }, nil case "alipay.trade.page.pay": payURL, callErr := sdk.client.TradePagePay(ctx, bm) if callErr != nil { return nil, callErr } if strings.TrimSpace(payURL) == "" { return nil, errors.New("支付宝电脑网站支付 URL 为空") } payURL, err = sdk.rewriteGeneratedURL(payURL) if err != nil { return nil, err } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}), }, nil case "alipay.trade.wap.pay": payURL, callErr := sdk.client.TradeWapPay(ctx, bm) if callErr != nil { return nil, callErr } if strings.TrimSpace(payURL) == "" { return nil, errors.New("支付宝手机网站支付 URL 为空") } payURL, err = sdk.rewriteGeneratedURL(payURL) if err != nil { return nil, err } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}), }, nil default: rsp, callErr := sdk.client.TradeCreate(ctx, bm) if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { return nil, err } response := rsp.Response if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != req.TradeNo { return nil, errors.New("支付宝创建响应的 out_trade_no 不匹配") } providerTradeNo := strings.TrimSpace(response.TradeNo) if providerTradeNo == "" { return nil, errors.New("支付宝创建响应缺少 trade_no") } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp), }, nil } } func (a *alipayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) { tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { return nil, errors.New("支付宝查单缺少 out_trade_no") } sdk, err := newAlipayClient(c) if err != nil { return nil, err } const method = "alipay.trade.query" rsp, callErr := sdk.client.TradeQuery(ctx, gopay.BodyMap{"out_trade_no": tradeNo}) if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { return nil, err } response := rsp.Response result := &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: normalizePaymentStatus(response.TradeStatus, "pending"), TradeNo: strings.TrimSpace(response.OutTradeNo), ProviderTradeNo: strings.TrimSpace(response.TradeNo), Payload: sdk.payload(rsp), } if result.TradeNo == "" { result.TradeNo = tradeNo } else if result.TradeNo != tradeNo { return nil, errors.New("支付宝查询响应的 out_trade_no 不匹配") } if result.Status != "success" { return result, nil } if result.ProviderTradeNo == "" { return nil, errors.New("支付宝查询响应缺少 trade_no") } result.Amount, err = parseDecimalAmount(response.TotalAmount, 100) if err != nil { return nil, fmt.Errorf("解析支付宝订单金额: %w", err) } result.Currency = strings.ToUpper(firstNonEmptyAlipay(response.TransCurrency, response.PayCurrency)) if result.Currency == "" { result.Currency = "CNY" } values, err := alipayResponseValues(response) if err != nil { return nil, err } if err = populateAlipayBreakdown(result, values); err != nil { return nil, err } return result, nil } func (a *alipayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) { if req == nil { return nil, errors.New("支付宝退款请求为空") } tradeNo := strings.TrimSpace(req.TradeNo) refundNo := strings.TrimSpace(req.RefundNo) if tradeNo == "" || refundNo == "" { return nil, errors.New("支付宝退款缺少 out_trade_no 或 out_request_no") } formatted, err := formatDecimalAmount(req.Amount, 100) if err != nil { return nil, err } sdk, err := newAlipayClient(c) if err != nil { return nil, err } bm := gopay.BodyMap{"out_trade_no": tradeNo, "out_request_no": refundNo, "refund_amount": formatted} mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_request_no", "refund_amount") const method = "alipay.trade.refund" rsp, callErr := sdk.client.TradeRefund(ctx, bm) if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil { return nil, err } response := rsp.Response return alipayRefundResult(req, response, sdk.payload(rsp)) } func alipayRefundResult(req *biz.PaymentRefundRequest, response *gopayAlipay.TradeRefund, payload []byte) (*biz.PaymentResult, error) { if req == nil || response == nil { return nil, errors.New("支付宝退款响应为空") } tradeNo := strings.TrimSpace(req.TradeNo) returnedTradeNo := strings.TrimSpace(response.OutTradeNo) if returnedTradeNo == "" { return nil, errors.New("支付宝退款响应缺少 out_trade_no") } if returnedTradeNo != tradeNo { return nil, errors.New("支付宝退款响应的 out_trade_no 不匹配") } providerTradeNo := strings.TrimSpace(response.TradeNo) if providerTradeNo == "" { return nil, errors.New("支付宝退款响应缺少 trade_no") } if expected := strings.TrimSpace(req.ProviderTradeNo); expected != "" && providerTradeNo != expected { return nil, errors.New("支付宝退款响应的 trade_no 不匹配") } refundFee := strings.TrimSpace(response.RefundFee) if refundFee == "" { return nil, errors.New("支付宝退款响应缺少 refund_fee") } amount, err := parseDecimalAmount(refundFee, 100) if err != nil { return nil, fmt.Errorf("解析支付宝退款金额: %w", err) } if amount != req.Amount { return nil, errors.New("支付宝退款响应金额不匹配") } currency := strings.ToUpper(strings.TrimSpace(response.RefundCurrency)) requestCurrency := strings.ToUpper(strings.TrimSpace(req.Currency)) if currency == "" { currency = requestCurrency } else if requestCurrency != "" && currency != requestCurrency { return nil, errors.New("支付宝退款响应币种不匹配") } if currency == "" { currency = "CNY" } return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: "created", TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, Amount: amount, Currency: currency, Payload: payload, }, nil } func (a *alipayAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) { if callback == nil || len(callback.Body) == 0 { return nil, errors.New("支付宝回调为空") } fields := callbackFields(callback) if err := verifyAlipay(fields, c); err != nil { return nil, err } if fields["app_id"] != text(c, "app_id") { return nil, errors.New("支付宝回调 app_id 不匹配") } status := "pending" if fields["trade_status"] == "TRADE_SUCCESS" || fields["trade_status"] == "TRADE_FINISHED" { status = "success" } payload, _ := json.Marshal(fields) return &biz.PaymentResult{ Provider: biz.PaymentAlipay, Status: status, TradeNo: first(fields, "out_trade_no"), ProviderTradeNo: first(fields, "trade_no"), Payload: payload, }, nil } func newAlipayClient(c map[string]any) (*alipayClient, error) { appID := strings.TrimSpace(text(c, "app_id")) privateKey := strings.TrimSpace(text(c, "private_key")) if appID == "" || privateKey == "" { return nil, errors.New("支付宝缺少 app_id 或 private_key") } production := !strings.EqualFold(text(c, "environment"), "sandbox") client, err := gopayAlipay.NewClient(appID, goPayAlipayKey(privateKey, false), production) if err != nil { return nil, err } if charset := strings.TrimSpace(text(c, "charset")); charset != "" { client.SetCharset(charset) } if signType := strings.TrimSpace(text(c, "sign_type")); signType != "" { client.SetSignType(signType) } if token := strings.TrimSpace(text(c, "app_auth_token")); token != "" { client.SetAppAuthToken(token) } if location := strings.TrimSpace(text(c, "location")); location != "" { client.SetLocation(location) } sdk := &alipayClient{client: client} if endpoint := strings.TrimSpace(text(c, "gateway_url")); endpoint != "" { sdk.gateway, err = url.Parse(endpoint) if err != nil || sdk.gateway.Scheme == "" || sdk.gateway.Host == "" { return nil, fmt.Errorf("支付宝 gateway_url 无效: %s", endpoint) } } httpClient := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(8) httpClient.SetTransport(&alipayTransport{ base: httpClient.HttpClient.Transport, gateway: sdk.gateway, response: &sdk.response, }) client.SetHttpClient(httpClient) return sdk, nil } func (c *alipayClient) finish(method string, hasResponse bool, callErr error, config map[string]any) error { if !hasResponse { if callErr != nil { return callErr } return fmt.Errorf("支付宝 %s 响应为空", method) } if c.response.Len() == 0 { return fmt.Errorf("支付宝 %s 响应原文为空", method) } if err := verifyAlipayResponse(c.response.Bytes(), method, config); err != nil { return err } return callErr } func (c *alipayClient) payload(fallback any) []byte { if c.response.Len() > 0 { return append([]byte(nil), c.response.Bytes()...) } return mustMarshalAlipayPayload(fallback) } func (c *alipayClient) rewriteGeneratedURL(value string) (string, error) { if c.gateway == nil { return value, nil } source, err := url.Parse(value) if err != nil { return "", fmt.Errorf("解析支付宝支付 URL: %w", err) } return alipayTargetURL(source, c.gateway).String(), nil } func (t *alipayTransport) RoundTrip(req *http.Request) (*http.Response, error) { if t.base == nil { t.base = http.DefaultTransport } target := req if t.gateway != nil { clone := req.Clone(req.Context()) clone.URL = alipayTargetURL(req.URL, t.gateway) clone.Host = "" target = clone } if t.response != nil { t.response.Reset() } resp, err := t.base.RoundTrip(target) if err != nil || resp == nil || resp.Body == nil || t.response == nil { return resp, err } resp.Body = &alipayResponseBody{Reader: io.TeeReader(resp.Body, t.response), Closer: resp.Body} return resp, nil } func alipayTargetURL(source, gateway *url.URL) *url.URL { target := *gateway switch { case target.RawQuery == "": target.RawQuery = source.RawQuery case source.RawQuery != "": target.RawQuery += "&" + source.RawQuery } return &target } func alipayCreateMethod(extra, config map[string]any) (string, error) { value := firstAny(extra, "method", "pay_method", "trade_type", "channel") if value == "" { value = firstAny(config, "method", "pay_method", "trade_type", "channel") } normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) switch normalized { case "", "create", "trade_create", "alipay_trade_create", "jsapi", "miniapp", "mini_program": return "alipay.trade.create", nil case "pay", "trade_pay", "alipay_trade_pay", "barcode", "barcode_pay", "micropay", "face_to_face": return "alipay.trade.pay", nil case "precreate", "trade_precreate", "alipay_trade_precreate", "native", "qr", "qrcode", "scan": return "alipay.trade.precreate", nil case "app", "app_pay", "trade_app_pay", "alipay_trade_app_pay": return "alipay.trade.app.pay", nil case "page", "page_pay", "trade_page_pay", "alipay_trade_page_pay", "web", "pc", "desktop": return "alipay.trade.page.pay", nil case "wap", "wap_pay", "trade_wap_pay", "alipay_trade_wap_pay", "h5", "mobile": return "alipay.trade.wap.pay", nil default: return "", fmt.Errorf("支付宝不支持的下单方式: %s", value) } } func alipayResponseValues(value any) (map[string]any, error) { raw, err := json.Marshal(value) if err != nil { return nil, err } result := map[string]any{} if err = json.Unmarshal(raw, &result); err != nil { return nil, err } return result, nil } func mustMarshalAlipayPayload(value any) []byte { raw, _ := json.Marshal(value) return raw } func firstNonEmptyAlipay(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" } func populateAlipayBreakdown(result *biz.PaymentResult, response map[string]any) error { if result == nil || result.Status != "success" { return nil } // Alipay returns these values in major currency units. Keep all arithmetic // integer-only and only mark the breakdown complete when the core fields are // present and internally consistent. total, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(response["total_amount"])), 100) if err != nil { return fmt.Errorf("解析支付宝 total_amount: %w", err) } result.Amount = total payer, payerOK, err := parseAlipayDecimal(response, "buyer_pay_amount") if err != nil { return err } if !payerOK { payer, payerOK, err = parseAlipayDecimal(response, "receipt_amount") if err != nil { return err } } point, pointOK, err := parseAlipayDecimal(response, "point_amount") if err != nil { return err } if !payerOK { return nil } if pointOK && point > payer { return errors.New("支付宝 point_amount 大于用户实付金额") } result.PayerPaidAmount = payer result.PointPaidAmount = point result.CashPaidAmount = payer - point result.DiscountAmount = total - payer if result.DiscountAmount < 0 { return errors.New("支付宝用户实付金额超过订单总额") } // receipt_amount is the merchant receipt amount. It is not always present; // when it is, use it as settlement amount. Alipay's detailed voucher list // is retained in Payload for audit and is intentionally not guessed here. if receipt, ok, receiptErr := parseAlipayDecimal(response, "receipt_amount"); receiptErr != nil { return receiptErr } else if ok { result.SettlementAmount = receipt } result.AmountBreakdownKnown = true return nil } func parseAlipayDecimal(values map[string]any, key string) (int64, bool, error) { value, exists := values[key] if !exists || strings.TrimSpace(fmt.Sprint(value)) == "" || fmt.Sprint(value) == "" { return 0, false, nil } amount, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(value)), 100) return amount, true, err } func verifyAlipayResponse(raw []byte, method string, c map[string]any) error { publicKeyValue := text(c, "public_key") if publicKeyValue == "" { return errors.New("支付宝缺少 public_key") } var envelope map[string]json.RawMessage if err := json.Unmarshal(raw, &envelope); err != nil { return err } responseKey := strings.ReplaceAll(method, ".", "_") + "_response" responseRaw := envelope[responseKey] if len(responseRaw) == 0 { return errors.New("支付宝响应缺少 " + responseKey) } var sign string if err := json.Unmarshal(envelope["sign"], &sign); err != nil || sign == "" { return errors.New("支付宝响应缺少 sign") } ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicKeyValue, true), string(responseRaw), sign) if err != nil { return err } if !ok { return errors.New("支付宝响应签名校验失败") } return nil } func alipaySignContent(v url.Values) string { keys := make([]string, 0, len(v)) for key := range v { if key != "sign" && v.Get(key) != "" { keys = append(keys, key) } } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, key+"="+v.Get(key)) } return strings.Join(parts, "&") } func verifyAlipay(fields map[string]string, c map[string]any) error { if text(c, "app_id") == "" || text(c, "public_key") == "" || fields["sign"] == "" { return errors.New("支付宝回调验签配置或 sign 缺失") } values := url.Values{} for key, value := range fields { values.Set(key, value) } body, err := gopayAlipay.ParseNotifyByURLValues(values) if err != nil { return err } ok, err := gopayAlipay.VerifySign(goPayAlipayKey(text(c, "public_key"), true), cloneGoPayBodyMap(body)) if err != nil { return err } if !ok { return errors.New("支付宝回调签名校验失败") } return nil } func mergeMap(dst map[string]any, src map[string]any, protected ...string) { protectedKeys := make(map[string]struct{}, len(protected)) for _, key := range protected { protectedKeys[key] = struct{}{} } for key, value := range src { if _, exists := protectedKeys[key]; exists { continue } dst[key] = value } }