From 0928c5de3b380d5f44a42a50a9605794f42c9b6b Mon Sep 17 00:00:00 2001 From: 18848298290-del <237243727+18848298290-del@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:58:07 +0800 Subject: [PATCH 1/3] fix(payment): harden EasyPay callbacks and refund parsing --- backend/internal/payment/httpapi/webhook.go | 38 +- backend/internal/payment/provider/easypay.go | 336 ++++++++++++------ .../internal/payment/provider/easypay_test.go | 234 ++++++++++-- backend/internal/payment/types.go | 11 + docs/domains/payments_and_entitlements.md | 6 +- 5 files changed, 485 insertions(+), 140 deletions(-) diff --git a/backend/internal/payment/httpapi/webhook.go b/backend/internal/payment/httpapi/webhook.go index ad86efecf..f29716731 100644 --- a/backend/internal/payment/httpapi/webhook.go +++ b/backend/internal/payment/httpapi/webhook.go @@ -124,8 +124,8 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) { // error wins even for an irrelevant nil notification. A missing provider is // acknowledged except official WeChat; other lookup failures return 500. // Failed verification returns 400 after bounded diagnostics. Verified missing -// orders are acknowledged, while other notification-processing failures return -// 500 for provider retry. +// orders are acknowledged, while confirmation or other processing failures +// remain retryable. func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) { var rawBody string if c.Request.Method == http.MethodGet { @@ -177,7 +177,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) headers[strings.ToLower(k)] = c.GetHeader(k) } - resolvedProviderKey, notification, err := WebhookVerifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers) + verifiedProvider, notification, err := webhookVerifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers) if err != nil { truncatedBody := rawBody // Limit failure diagnostics to a byte prefix plus suffix. This bounds logged @@ -191,6 +191,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) c.String(http.StatusBadRequest, "verify failed") return } + resolvedProviderKey := verifiedProvider.ProviderKey() // The accepted candidate can classify the event as irrelevant. Acknowledge // that nil notification immediately so it does not enter order handling or @@ -200,6 +201,20 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) return } + if confirmer, ok := verifiedProvider.(payment.NotificationConfirmer); ok && + notification.Status == payment.NotificationStatusSuccess { + if err := confirmer.ConfirmNotification(c.Request.Context(), notification); err != nil { + slog.Error("[Payment Webhook] upstream confirmation failed", + "provider", resolvedProviderKey, + "outTradeNo", notification.OrderID, + "tradeNo", notification.TradeNo, + "error", err, + ) + c.String(http.StatusBadGateway, "confirm failed") + return + } + } + if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil { // A verified notification can name no local order. Acknowledge that // classified absence so retries do not repeatedly request fulfillment for an @@ -304,6 +319,17 @@ func WebhookExtractStripeOutTradeNo(rawBody string) string { // resolved key for acknowledgement and notification validation, so ordering // can determine which merchant accepts an event. func WebhookVerifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) { + provider, notification, err := webhookVerifyNotificationWithProviders(ctx, providers, rawBody, headers) + if err != nil { + return "", nil, err + } + return provider.ProviderKey(), notification, nil +} + +// webhookVerifyNotificationWithProviders retains the exact provider instance +// that accepted the callback so optional upstream confirmation uses the same +// merchant configuration before fulfillment. +func webhookVerifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *payment.PaymentNotification, error) { var lastErr error for _, provider := range providers { if provider == nil { @@ -317,12 +343,12 @@ func WebhookVerifyNotificationWithProviders(ctx context.Context, providers []pay lastErr = err continue } - return provider.ProviderKey(), notification, nil + return provider, notification, nil } if lastErr != nil { - return "", nil, lastErr + return nil, nil, lastErr } - return "", nil, fmt.Errorf("no webhook provider could verify notification") + return nil, nil, fmt.Errorf("no webhook provider could verify notification") } // WebhookWriteSuccessResponse writes the accepted callback acknowledgement for diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 3d4a53473..4af945ecd 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -6,9 +6,11 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "maps" + "math" "net/http" "net/url" "sort" @@ -24,11 +26,10 @@ import ( // translates payment and refund responses. // EasyPay hosted checkout signs a browser URL, while API checkout posts the // same merchant identity and retrieves payment references. Callback processing -// validates one value per accepted field before checking its signature; a -// successful signature still leaves order association and amount confirmation -// to the payment core. +// accepts a bounded V1 field set, rejects ambiguous signed values and confirms +// successful notifications through an upstream order query before fulfillment. // -// Query responses support top-level and nested variants, with pointer presence +// Query responses support top-level and nested variants, with field presence // deciding which representation wins even when the winning value is empty. // Refund submission tries the merchant order label before the upstream trade // label and advances only for an order-not-found diagnostic. HTTP status @@ -43,12 +44,25 @@ const ( easypayHTTPTimeout = 10 * time.Second // default HTTP request timeout maxEasypayResponseSize = 1 << 20 // response prefix in bytes maxEasypayErrorSummary = 512 // diagnostic prefix in bytes + easypayAmountTolerance = 0.005 // half-cent confirmation margin tradeStatusSuccess = "TRADE_SUCCESS" // paid textual trade state signTypeMD5 = "MD5" // outbound signing type label paymentModePopup = "popup" // exact hosted checkout selector deviceMobile = "mobile" // mobile request device label ) +var ( + // easyPayNotifyAllowedParams contains genuine asynchronous notification + // fields across common V1 implementations. Checkout-only fields stay excluded + // because their reusable signatures can otherwise be spliced into callbacks. + easyPayNotifyAllowedParams = map[string]struct{}{ + "pid": {}, "trade_no": {}, "out_trade_no": {}, "type": {}, + "name": {}, "money": {}, "trade_status": {}, "param": {}, + "api_trade_no": {}, "addtime": {}, "endtime": {}, "buyer": {}, + "timestamp": {}, "sign": {}, "sign_type": {}, + } +) + // EasyPay sends signed checkout, query and refund requests for one configured // aggregation instance. type EasyPay struct { @@ -68,41 +82,40 @@ type easyPayCustomMethod struct { DisplayName string `json:"displayName"` // configured display text } -// easyPayQueryData holds optional nested query fields considered after their -// top-level counterparts. +// easyPayQueryData holds optional query fields shared by top-level and nested +// response layouts. type easyPayQueryData struct { - // TradeStatus wins over numeric Status when top-level TradeStatus is absent, - // including an unrecognized nested string. + // TradeStatus wins over numeric Status when present, including an + // unrecognized or empty string. TradeStatus *string `json:"trade_status"` - // Status supplies a nested numeric state only after both textual states and - // top-level Status are absent. - Status *int `json:"status"` - // Money supplies amount text when top-level Money is absent; an empty - // top-level string blocks this value. - Money *string `json:"money"` - // TradeNo replaces the requested reference only when top-level TradeNo is - // absent and this nested string is nonempty. - TradeNo *string `json:"trade_no"` + // Status retains numeric or textual JSON for lenient PHP response decoding. + Status json.RawMessage `json:"status"` + // Money retains textual or numeric JSON before amount parsing. + Money json.RawMessage `json:"money"` + // TradeNo retains the exact upstream payment reference when supplied. + TradeNo json.RawMessage `json:"trade_no"` + // OutTradeNo echoes the merchant order identity when supplied by the + // upstream response. + OutTradeNo json.RawMessage `json:"out_trade_no"` } // easyPayQueryResponse represents both supported JSON layouts while retaining // presence for precedence decisions. type easyPayQueryResponse struct { - Code int `json:"code"` // unused query response code - Msg string `json:"msg"` // unused query diagnostic text - // TradeStatus has first status precedence whenever present; an unrecognized - // or empty value keeps the result pending. - TradeStatus *string `json:"trade_status"` - // Status is considered after both textual states are absent, and presence - // blocks nested numeric Status. - Status *int `json:"status"` - // Money has amount precedence whenever present, including empty text that - // later parses as zero. - Money *string `json:"money"` - // TradeNo presence blocks nested TradeNo; a nonempty value replaces the - // fallback reference supplied by QueryOrder. - TradeNo *string `json:"trade_no"` - Data easyPayQueryData `json:"data"` // nested query response fields + easyPayQueryData + Code any `json:"code"` // numeric or textual response code + Msg string `json:"msg"` // query failure diagnostic text + Data easyPayQueryData `json:"data"` // nested query response fields +} + +// easyPayQueryResult retains the normalized upstream state and its exact +// returned amount and trade reference. An absent trade number stays empty so +// callback confirmation can distinguish it from the merchant-order fallback +// exposed by QueryOrder. +type easyPayQueryResult struct { + status string // normalized provider order state + money string // exact returned amount text + tradeNo string // exact returned upstream trade reference } // easyPayRefundAttempt pairs a request form with the reference returned on its @@ -423,34 +436,56 @@ func (e *EasyPay) upstreamPaymentType(paymentType string) string { return paymentType } -// QueryOrder posts an order lookup using tradeNo as the merchant out_trade_no -// label and ctx for HTTP cancellation. Its parser checks HTTP status and -// retains the requested label when the selected response reference is empty. +// QueryOrder looks up a merchant order and exposes the normalized paid state, +// amount and upstream reference. When the response omits trade_no, the +// requested merchant label remains the public fallback. func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { + result, err := e.queryOrder(ctx, tradeNo) + if err != nil { + return nil, err + } + responseTradeNo := tradeNo + if result.tradeNo != "" { + responseTradeNo = result.tradeNo + } + amount, _ := strconv.ParseFloat(result.money, 64) + return &payment.QueryOrderResponse{ + TradeNo: responseTradeNo, + Status: result.status, + Amount: amount, + Metadata: e.MerchantIdentityMetadata(), + }, nil +} + +// queryOrder sends lookup parameters in both the query and form body because +// V1-compatible PHP deployments differ between reading GET and POST. It +// strips credential-bearing request URLs from transport errors before parsing +// the upstream response. +func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQueryResult, error) { params := map[string]string{ "act": "order", "pid": e.config["pid"], - "key": e.config["pkey"], "out_trade_no": tradeNo, + "key": e.config["pkey"], "out_trade_no": outTradeNo, } - body, statusCode, err := e.postRaw(ctx, e.apiBase()+"/api.php", params) + query := url.Values{} + for key, value := range params { + query.Set(key, value) + } + body, statusCode, err := e.postRaw(ctx, e.apiBase()+"/api.php?"+query.Encode(), params) if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } return nil, fmt.Errorf("easypay query: %w", err) } - return parseEasyPayQueryResponse(statusCode, body, tradeNo, e.MerchantIdentityMetadata()) + return parseEasyPayQueryResponse(statusCode, body, outTradeNo) } // parseEasyPayQueryResponse accepts successful HTTP JSON objects and selects // status, money and trade identity from optional top-level or nested fields. -// Status precedence is top-level textual, nested textual, top-level numeric, -// then nested numeric; a present unrecognized value prevents later -// alternatives. -// -// Money and trade references prefer top-level presence separately. Empty -// selected money or invalid syntax produces zero, while range overflow can -// produce infinity despite a discarded parse error. Empty selected trade text -// keeps fallbackTradeNo. The supplied metadata map is returned directly, and -// Code or Msg does not determine query acceptance; the payment core confirms -// any reported paid amount afterward. -func parseEasyPayQueryResponse(statusCode int, body []byte, fallbackTradeNo string, metadata map[string]string) (*payment.QueryOrderResponse, error) { +// An explicit failure code or a different echoed merchant order rejects the +// response before paid-looking status fields are considered. +func parseEasyPayQueryResponse(statusCode int, body []byte, outTradeNo string) (*easyPayQueryResult, error) { if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("easypay query HTTP %d", statusCode) } @@ -472,6 +507,17 @@ func parseEasyPayQueryResponse(statusCode int, body []byte, fallbackTradeNo stri if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("easypay query non-JSON response (HTTP %d)", statusCode) } + if resp.Code != nil && !easyPayResponseCodeIsSuccess(resp.Code) { + msg := strings.TrimSpace(resp.Msg) + if msg == "" { + msg = summarizeEasyPayResponse(body) + } + return nil, fmt.Errorf("easypay query failed: %s", msg) + } + echoed := easyPayJSONScalar(pickEasyPayJSON(resp.OutTradeNo, resp.Data.OutTradeNo)) + if echoed != "" && echoed != strings.TrimSpace(outTradeNo) { + return nil, fmt.Errorf("easypay query out_trade_no mismatch: expected %s, got %s", strings.TrimSpace(outTradeNo), echoed) + } // Choose by field presence in descending precedence; a present unknown state // deliberately prevents another response layout from claiming paid. status := payment.ProviderStatusPending @@ -483,47 +529,83 @@ func parseEasyPayQueryResponse(statusCode int, body []byte, fallbackTradeNo stri if *resp.Data.TradeStatus == tradeStatusSuccess { status = payment.ProviderStatusPaid } - } else if resp.Status != nil { - if *resp.Status == easypayStatusPaid { + } else if raw := pickEasyPayJSON(resp.Status, resp.Data.Status); raw != nil { + if easyPayJSONScalar(raw) == strconv.Itoa(easypayStatusPaid) { status = payment.ProviderStatusPaid } - } else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid { - status = payment.ProviderStatusPaid - } - - // Select money and trade references independently of status; a present empty - // top-level value blocks its nested alternative. - money := "" - if resp.Money != nil { - money = *resp.Money - } else if resp.Data.Money != nil { - money = *resp.Data.Money - } - responseTradeNo := fallbackTradeNo - if resp.TradeNo != nil { - if *resp.TradeNo != "" { - responseTradeNo = *resp.TradeNo - } - } else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" { - responseTradeNo = *resp.Data.TradeNo } - // Discard the numeric parse error here; syntax failures yield zero and - // overflow can yield infinity, both reaching core amount confirmation. - amount, _ := strconv.ParseFloat(money, 64) - return &payment.QueryOrderResponse{ - TradeNo: responseTradeNo, - Status: status, - Amount: amount, - Metadata: metadata, + return &easyPayQueryResult{ + status: status, + money: easyPayJSONScalar(pickEasyPayJSON(resp.Money, resp.Data.Money)), + tradeNo: easyPayJSONScalar( + pickEasyPayJSON(resp.TradeNo, resp.Data.TradeNo), + ), }, nil } +// ConfirmNotification checks a verified success callback against the +// upstream order before local fulfillment. Paid state, amount and any returned +// upstream trade number must agree with the signed callback. +func (e *EasyPay) ConfirmNotification(ctx context.Context, notification *payment.PaymentNotification) error { + if notification == nil || notification.Status != payment.NotificationStatusSuccess { + return nil + } + result, err := e.queryOrder(ctx, notification.OrderID) + if err != nil { + return fmt.Errorf("easypay confirm: %w", err) + } + if result.status != payment.ProviderStatusPaid { + return fmt.Errorf("easypay confirm: upstream reports order %s as not paid", notification.OrderID) + } + queriedAmount, err := strconv.ParseFloat(result.money, 64) + if err != nil || !payment.IsValidProviderAmount(queriedAmount) || + math.Abs(queriedAmount-notification.Amount) > easypayAmountTolerance { + return fmt.Errorf("easypay confirm: amount mismatch: notified %v, upstream %q", notification.Amount, result.money) + } + if result.tradeNo != "" { + if notification.TradeNo != "" && notification.TradeNo != result.tradeNo { + return fmt.Errorf("easypay confirm: trade_no mismatch: notified %s, upstream %s", notification.TradeNo, result.tradeNo) + } + notification.TradeNo = result.tradeNo + } + return nil +} + +// easyPayJSONScalar decodes a JSON string or number into trimmed text. Other +// JSON types, absent fields and null return empty text. +func easyPayJSONScalar(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return strings.TrimSpace(text) + } + var number json.Number + if err := json.Unmarshal(raw, &number); err == nil { + return number.String() + } + return "" +} + +// pickEasyPayJSON returns a present non-null top-level field before its nested +// equivalent. When neither exists it returns nil. +func pickEasyPayJSON(top, nested json.RawMessage) json.RawMessage { + if len(top) > 0 && string(top) != "null" { + return top + } + if len(nested) > 0 && string(nested) != "null" { + return nested + } + return nil +} + // VerifyNotification validates EasyPay form structure, merchant identity, // positive finite money and the computed signature before returning a callback -// observation. It accepts a fixed key set with exactly one value per key and -// requires delimiter-free structured fields, while name and param retain free -// text. +// observation. It accepts a bounded V1 notification field set with exactly one +// value per key. All fields except the merchant-defined product name reject +// delimiters that could make one signed value represent multiple parameters. // // Signing sorts nonempty parameters and excludes sign and sign_type. Signature // text must equal the computed lowercase MD5 hex exactly; sign_type is @@ -540,9 +622,7 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st // selection and signature input could describe different callback data. params := make(map[string]string, len(values)) for key, entries := range values { - switch key { - case "pid", "trade_no", "out_trade_no", "type", "name", "money", "trade_status", "param", "sign", "sign_type": - default: + if _, allowed := easyPayNotifyAllowedParams[key]; !allowed { return nil, fmt.Errorf("unexpected notify param: %s", key) } // Reject duplicate values before extracting the first entry, keeping the @@ -550,10 +630,15 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st if len(entries) != 1 { return nil, fmt.Errorf("duplicate notify param: %s", key) } - params[key] = entries[0] + value := entries[0] + if key != "name" && + (strings.ContainsAny(value, "&=\x00\r\n") || strings.TrimSpace(value) != value) { + return nil, fmt.Errorf("invalid notify param: %s", key) + } + params[key] = value } - // Constrain identifiers, type, money and state before raw pair concatenation; - // name and param remain free text in this wire format. + // Constrain required identifiers, type, money and state before raw pair + // concatenation. Product name remains the only free-text field. for _, key := range []string{"pid", "trade_no", "out_trade_no", "type", "money", "trade_status"} { value := params[key] if value == "" { @@ -677,10 +762,11 @@ func isEasyPayRefundOrderNotFound(err error) bool { strings.Contains(lower, "not exist") } -// parseEasyPayRefundResponse requires successful HTTP JSON containing a -// response code recognized as success. Failures use a bounded body summary -// unless a nonblank JSON message exists, in which case that trimmed message is -// returned at its supplied length. +// parseEasyPayRefundResponse accepts successful HTTP responses in the JSON and +// text forms returned by EasyPay V1 implementations. JSON code one is accepted +// directly; code zero needs an explicit success message, and non-JSON text must +// contain an unambiguous refund-success result. Other responses use a bounded +// body summary unless JSON supplies a nonblank diagnostic message. func parseEasyPayRefundResponse(status int, body []byte) error { summary := summarizeEasyPayResponse(body) if status < http.StatusOK || status >= http.StatusMultipleChoices { @@ -698,16 +784,20 @@ func parseEasyPayRefundResponse(status int, body []byte) error { return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary) } - var resp struct { - Code any `json:"code"` // numeric or textual response code - // Msg replaces the bounded body summary whenever its trimmed text is - // nonempty, so supplied JSON diagnostics retain their length. - Msg string `json:"msg"` - } - if err := json.Unmarshal(body, &resp); err != nil { - return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary) - } - if !easyPayResponseCodeIsSuccess(resp.Code) { + if strings.HasPrefix(trimmed, "{") { + var resp struct { + Code any `json:"code"` // numeric or textual response code + // Msg supplies the V1 refund result or failure diagnostic. Code zero uses + // this text to distinguish a successful refund from a rejected request. + Msg string `json:"msg"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary) + } + if easyPayResponseCodeIsSuccess(resp.Code) || + (easyPayResponseCodeIsZero(resp.Code) && easyPayRefundTextIsSuccess(resp.Msg)) { + return nil + } msg := strings.TrimSpace(resp.Msg) if msg == "" { // Use the bounded body diagnostic when JSON contains no usable message, @@ -716,18 +806,18 @@ func parseEasyPayRefundResponse(status int, body []byte) error { } return fmt.Errorf("easypay refund failed (HTTP %d): %s", status, msg) } - return nil + if easyPayRefundTextIsSuccess(trimmed) { + return nil + } + return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary) } -// easyPayResponseCodeIsSuccess accepts a decoded float whose integer -// conversion equals one or trimmed integer text equal to one. Other types -// fail, and fractional float codes can pass because conversion truncates them. +// easyPayResponseCodeIsSuccess reports whether JSON code is exactly numeric or +// textual one. Fractional numbers and other decoded types are rejected. func easyPayResponseCodeIsSuccess(code any) bool { switch v := code.(type) { case float64: - // Integer conversion truncates fractional numeric response codes before - // comparison. - return int(v) == easypayCodeSuccess + return v == easypayCodeSuccess case string: n, err := strconv.Atoi(strings.TrimSpace(v)) return err == nil && n == easypayCodeSuccess @@ -736,6 +826,36 @@ func easyPayResponseCodeIsSuccess(code any) bool { } } +// easyPayResponseCodeIsZero reports whether JSON code is exactly numeric or +// textual zero. The refund parser also requires a success message for this V1 +// response variant. +func easyPayResponseCodeIsZero(code any) bool { + switch v := code.(type) { + case float64: + return v == 0 + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + return err == nil && n == 0 + default: + return false + } +} + +// easyPayRefundTextIsSuccess recognizes explicit V1 refund-success text after +// trimming whitespace and an optional UTF-8 byte-order mark. Exact English +// acknowledgements and Chinese refund-success prefixes are accepted; unrelated +// text containing the word success remains a failure diagnostic. +func easyPayRefundTextIsSuccess(value string) bool { + normalized := strings.TrimSpace(strings.TrimPrefix(value, "\ufeff")) + switch strings.ToLower(normalized) { + case "1", "ok", "success": + return true + } + return strings.HasPrefix(normalized, "退款成功") || + strings.HasPrefix(normalized, "退款已成功") || + strings.HasPrefix(normalized, "已退款") +} + // summarizeEasyPayResponse collapses body whitespace and uses an empty marker // for absent text. Long summaries keep a 512-byte prefix shortened to valid // UTF-8, then add an ellipsis; the text is not credential-filtered. diff --git a/backend/internal/payment/provider/easypay_test.go b/backend/internal/payment/provider/easypay_test.go index fb14bcd72..5b0df8193 100644 --- a/backend/internal/payment/provider/easypay_test.go +++ b/backend/internal/payment/provider/easypay_test.go @@ -12,14 +12,14 @@ import ( "github.com/TokenFlux/TokenRouter/internal/payment" ) -// EasyPay callback fixtures sign form values locally and distinguish accepted -// notification keys from checkout parameters, duplicate values and -// delimiter-bearing structured fields. Query fixtures exercise status -// precedence across JSON layouts and keep requested merchant labels when -// upstream references are absent. +// EasyPay callback fixtures sign form values locally and distinguish required +// notification fields from signed V1 extensions, duplicate values and unknown +// parameter names. Query fixtures exercise status precedence across JSON +// layouts and keep requested merchant labels when upstream references are +// absent. // -// Loopback refund fixtures capture alternative identifier forms in order, -// returning missing-order text before a successful trade-reference attempt. +// Loopback refund fixtures capture alternative identifier forms in order and +// exercise JSON or text success responses used by V1-compatible platforms. // URL fixtures separate slash-prefixed resolution from absolute links or // opaque QR text, including protocol-relative references that change host. // Signing tests cover parameter order and excluded fields independently of @@ -28,8 +28,8 @@ import ( // TestEasyPayNotificationRejectsCheckoutParameters generates a signed // hosted-checkout query and submits it as callback form text. It expects -// rejection because checkout parameter names do not constitute an accepted -// notification payload. +// rejection because the checkout form lacks required callback identity and +// status fields. func TestEasyPayNotificationRejectsCheckoutParameters(t *testing.T) { t.Parallel() @@ -56,10 +56,41 @@ func TestEasyPayNotificationRejectsCheckoutParameters(t *testing.T) { } } +// TestEasyPayNotificationRejectsForgedCheckoutSignature reproduces the +// checkout-signature replay from the upstream security report. A return URL +// contains a success field that is promoted into the callback, but checkout +// fields remain outside the accepted notification set. +func TestEasyPayNotificationRejectsForgedCheckoutSignature(t *testing.T) { + t.Parallel() + + e := &EasyPay{config: map[string]string{ + "pid": "1000", "pkey": "test-merchant-secret", + "notifyUrl": "https://site.example.com/api/v1/payment/webhook/easypay", + }} + returnURL := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS" + createParams := map[string]string{ + "pid": "1000", "type": "alipay", "out_trade_no": "ORDER123", + "notify_url": e.config["notifyUrl"], "return_url": returnURL, + "name": "balance recharge", "money": "650.00", + } + sign := easyPaySign(createParams, e.config["pkey"]) + + callback := url.Values{ + "pid": {"1000"}, "type": {"alipay"}, "out_trade_no": {"ORDER123"}, + "notify_url": {e.config["notifyUrl"]}, "name": {"balance recharge"}, + "money": {"650.00"}, + "return_url": {"https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success"}, + } + raw := callback.Encode() + "&trade_status=TRADE_SUCCESS&sign=" + sign + "&sign_type=MD5" + if notification, err := e.VerifyNotification(context.Background(), raw, nil); err == nil || notification != nil { + t.Fatalf("forged checkout signature accepted: notification=%+v, err=%v", notification, err) + } +} + // TestEasyPayNotificationParameters assembles standard, custom-type and -// free-text forms with optional extras before signing, then adds any duplicate -// values. It checks accepted observations, rejects extra or duplicated keys -// and rejects amount changes after signing. +// free-text forms with V1 extension fields before signing, then adds any +// duplicate values. It checks accepted observations, rejects duplicates and +// rejects amount changes after signing. func TestEasyPayNotificationParameters(t *testing.T) { t.Parallel() @@ -82,14 +113,18 @@ func TestEasyPayNotificationParameters(t *testing.T) { {name: "standard notification"}, {name: "wechat notification", payType: "wxpay"}, {name: "custom payment type", payType: "usdt_trc20"}, - {name: "optional param", extraKey: "param", extra: "merchant=value&extra=中文"}, + {name: "ambiguous optional param", extraKey: "param", extra: "merchant=value&extra=中文", wantError: true}, {name: "empty optional param", extraKey: "param"}, + {name: "API trade number", extraKey: "api_trade_no", extra: "API123"}, + {name: "payment creation time", extraKey: "addtime", extra: "2026-10-23 17:59:00"}, + {name: "payment completion time", extraKey: "endtime", extra: "2026-10-23 18:00:00"}, + {name: "buyer identifier", extraKey: "buyer", extra: "buyer@example.com"}, + {name: "timestamp", extraKey: "timestamp", extra: "1792759200"}, {name: "return URL", extraKey: "return_url", extra: "https://site.example.com/payment/result", wantError: true}, {name: "notify URL", extraKey: "notify_url", extra: "https://site.example.com/notify", wantError: true}, {name: "device", extraKey: "device", extra: "mobile", wantError: true}, - {name: "unknown field", extraKey: "unknown", extra: "value", wantError: true}, - {name: "empty unknown field", extraKey: "unknown", wantError: true}, - {name: "empty return URL", extraKey: "return_url", wantError: true}, + {name: "unknown signed field", extraKey: "provider_extension", extra: "value", wantError: true}, + {name: "empty unknown field", extraKey: "provider_extension", wantError: true}, {name: "duplicate status", duplicate: "trade_status", wantError: true}, {name: "duplicate order", duplicate: "out_trade_no", wantError: true}, {name: "duplicate signature", duplicate: "sign", wantError: true}, @@ -149,6 +184,35 @@ func TestEasyPayNotificationParameters(t *testing.T) { } } +// TestEasyPayNotificationRejectsUnknownParameterNames supplies callback forms +// with names outside the bounded V1 notification set. The verifier must reject +// each name before signature acceptance can interpret the callback. +func TestEasyPayNotificationRejectsUnknownParameterNames(t *testing.T) { + t.Parallel() + + e := &EasyPay{config: map[string]string{"pid": "1000", "pkey": "test-merchant-secret"}} + for _, tt := range []struct { + name string // unsafe parameter-name case + key string // submitted extension key + }{ + {name: "space", key: "bad key"}, + {name: "ampersand", key: "bad&key"}, + {name: "equals", key: "bad=key"}, + {name: "NUL", key: "bad\x00key"}, + {name: "empty", key: ""}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + values := easyPayTestNotificationValues() + values[tt.key] = []string{"value"} + notification, err := e.VerifyNotification(context.Background(), values.Encode(), nil) + if err == nil || notification != nil || !strings.Contains(err.Error(), "unexpected notify param") { + t.Fatalf("unsafe parameter name accepted: notification=%+v, err=%v", notification, err) + } + }) + } +} + // TestEasyPayNotificationRejectsInvalidFields mutates unsigned structured // fields with missing, delimiter, control-byte or whitespace values and // invalid merchant/type/money observations. It expects exact field-validation @@ -215,6 +279,7 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { wantStatus string // expected core query state wantTradeNo string // expected selected trade reference wantAmount float64 // expected reported numeric money + wantError bool // expected query rejection flag }{ { name: "top level trade success is paid", @@ -259,10 +324,16 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { wantAmount: 3.21, }, { - name: "query failure with missing status is pending", - body: `{"code":0,"msg":"订单不存在"}`, - wantStatus: payment.ProviderStatusPending, - wantTradeNo: orderID, + name: "query failure code is rejected", + body: `{"code":0,"msg":"订单不存在"}`, + wantError: true, + }, + { + name: "string scalar response is compatible", + body: `{"code":"1","out_trade_no":"order-123","trade_no":"gateway-123","status":"1","money":"3.21"}`, + wantStatus: payment.ProviderStatusPaid, + wantTradeNo: "gateway-123", + wantAmount: 3.21, }, { name: "missing fields are pending", @@ -277,6 +348,7 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { t.Parallel() var gotForm url.Values + var gotQuery url.Values server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Serve the fixed response for client parser assertions; handler Write // errors are discarded while callers check channel results or diagnostics. @@ -299,6 +371,7 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { for key, values := range r.PostForm { gotForm[key] = append([]string(nil), values...) } + gotQuery = r.URL.Query() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(tt.body)) })) @@ -308,6 +381,12 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { provider := newTestEasyPay(t, server.URL) resp, err := provider.QueryOrder(context.Background(), orderID) + if tt.wantError { + if err == nil { + t.Fatalf("QueryOrder response = %+v, want error", resp) + } + return + } if err != nil { t.Fatalf("QueryOrder returned error: %v", err) } @@ -329,6 +408,9 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { if got := gotForm.Get(key); got != want { t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm) } + if got := gotQuery.Get(key); got != want { + t.Fatalf("query[%s] = %q, want %q (query=%v)", key, got, want, gotQuery) + } } }) } @@ -371,6 +453,18 @@ func TestEasyPayQueryOrderRejectsUnsafeResponses(t *testing.T) { body: "", wantErr: "easypay query empty response (HTTP 200)", }, + { + name: "failure code with paid status", + statusCode: http.StatusOK, + body: `{"code":-1,"msg":"签名错误","status":1,"money":"10.00"}`, + wantErr: "easypay query failed: 签名错误", + }, + { + name: "foreign order echo", + statusCode: http.StatusOK, + body: `{"code":1,"out_trade_no":"different-order","status":1,"money":"10.00"}`, + wantErr: "easypay query out_trade_no mismatch", + }, } for _, tt := range tests { @@ -398,6 +492,71 @@ func TestEasyPayQueryOrderRejectsUnsafeResponses(t *testing.T) { } } +// TestEasyPayConfirmNotification checks that a signed success observation is +// still rejected unless the upstream order query reports the same paid amount +// and trade reference. This is the final guard against reusable or spliced +// checkout signatures. +func TestEasyPayConfirmNotification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string // named confirmation case + body string // served order-query response + wantError string // expected confirmation error fragment + }{ + { + name: "matching paid order", + body: `{"code":1,"out_trade_no":"ORDER123","trade_no":"UPSTREAM123","money":"10.20","status":1}`, + }, + { + name: "upstream order pending", + body: `{"code":1,"out_trade_no":"ORDER123","trade_no":"UPSTREAM123","money":"10.20","status":0}`, + wantError: "not paid", + }, + { + name: "amount mismatch", + body: `{"code":1,"out_trade_no":"ORDER123","trade_no":"UPSTREAM123","money":"0.01","status":1}`, + wantError: "amount mismatch", + }, + { + name: "trade number mismatch", + body: `{"code":1,"out_trade_no":"ORDER123","trade_no":"OTHER","money":"10.20","status":1}`, + wantError: "trade_no mismatch", + }, + { + name: "failure response with paid status", + body: `{"code":0,"msg":"订单不存在","money":"10.20","status":1}`, + wantError: "订单不存在", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + provider := newTestEasyPay(t, server.URL) + notification := &payment.PaymentNotification{ + TradeNo: "UPSTREAM123", OrderID: "ORDER123", Amount: 10.20, + Status: payment.NotificationStatusSuccess, + } + err := provider.ConfirmNotification(context.Background(), notification) + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("ConfirmNotification error = %v, want containing %q", err, tt.wantError) + } + return + } + if err != nil { + t.Fatalf("ConfirmNotification returned error: %v", err) + } + }) + } +} + // TestNormalizeEasyPayAPIBase supplies trailing slashes and recognized // operation suffixes, including an operation query. It expects the same // normalized origin text for the listed complete URL fixtures. @@ -554,9 +713,35 @@ func TestEasyPayRefundRetriesWithTradeNoWhenOutTradeNoNotFound(t *testing.T) { } } -// TestEasyPayRefundResponseErrors serves HTML, invalid JSON, HTTP failure and -// empty refund responses. It expects diagnostics containing the corresponding -// status and bounded response summary for those short supplied bodies. +// TestEasyPayRefundResponseCompatibility serves the JSON and plain-text success +// variants used by V1-compatible refund endpoints. Each accepted body must +// complete the adapter call without a parser error. +func TestEasyPayRefundResponseCompatibility(t *testing.T) { + t.Parallel() + + for _, body := range []string{ + `{"code":1,"msg":"ok"}`, + `{"code":"1","msg":"success"}`, + `{"code":0,"msg":"退款成功!退款金额¥10.20"}`, + `{"code":"0","msg":"退款已成功"}`, + "退款成功!退款金额¥10.20", + "\ufeff 已退款:10.20元 ", + "success", + "1", + } { + body := body + t.Run(body, func(t *testing.T) { + t.Parallel() + if err := parseEasyPayRefundResponse(http.StatusOK, []byte(body)); err != nil { + t.Fatalf("parseEasyPayRefundResponse(%q): %v", body, err) + } + }) + } +} + +// TestEasyPayRefundResponseErrors serves HTML, invalid JSON, ambiguous text, +// rejected JSON, HTTP failure and empty responses. It expects diagnostics with +// the corresponding status and bounded summary for each short supplied body. func TestEasyPayRefundResponseErrors(t *testing.T) { t.Parallel() @@ -568,6 +753,9 @@ func TestEasyPayRefundResponseErrors(t *testing.T) { }{ {name: "html response", statusCode: http.StatusOK, body: "bad config", want: "non-JSON response (HTTP 200): bad config"}, {name: "non json response", statusCode: http.StatusOK, body: "not json", want: "non-JSON response (HTTP 200): not json"}, + {name: "ambiguous success text", statusCode: http.StatusOK, body: "原订单未支付成功", want: "non-JSON response (HTTP 200): 原订单未支付成功"}, + {name: "zero failure code", statusCode: http.StatusOK, body: `{"code":0,"msg":"退款失败"}`, want: "refund failed (HTTP 200): 退款失败"}, + {name: "fractional success code", statusCode: http.StatusOK, body: `{"code":1.9,"msg":"invalid code"}`, want: "refund failed (HTTP 200): invalid code"}, {name: "non 2xx response", statusCode: http.StatusBadGateway, body: "bad gateway", want: "HTTP 502: bad gateway"}, {name: "empty response", statusCode: http.StatusOK, body: "", want: "empty response (HTTP 200): "}, } diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 3a7d88d51..ccb7bd222 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -361,6 +361,17 @@ type RefundQueryProvider interface { QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error) } +// NotificationConfirmer adds upstream verification for gateways whose signed +// callbacks are not sufficient payment proof by themselves. Webhook handling +// invokes it on the same provider instance that accepted the callback before +// local fulfillment. +type NotificationConfirmer interface { + Provider // base payment and identity operations + // ConfirmNotification checks a verified success observation against upstream + // state and may fill provider-owned references on the notification. + ConfirmNotification(ctx context.Context, notification *PaymentNotification) error +} + // CancelableProvider adds provider-side closing for a still-pending payment // reference. type CancelableProvider interface { diff --git a/docs/domains/payments_and_entitlements.md b/docs/domains/payments_and_entitlements.md index c0e5406fc..acde0c5c9 100644 --- a/docs/domains/payments_and_entitlements.md +++ b/docs/domains/payments_and_entitlements.md @@ -97,13 +97,13 @@ Webhook 路由不使用用户 JWT,所以提供商验签、订单绑定和金 4. 核对通知金额是有限的正数,并且与订单的 `pay_amount` 在币种容差内一致,然后才进入 `PAID`。 5. 无关的事件,按提供商的要求返回成功响应;验签或处理失败时返回失败,让提供商重试。本地确实不存在的订单,可以确认接收以停止无意义的重试,同时记录告警。 -易支付的 MD5 协议直接拼接参数值,浏览器中的下单 URL 带有签名。`EasyPay.VerifyNotification` 在验签前检查字段白名单:`pid`、`trade_no`、`out_trade_no`、`type`、`name`、`money`、`trade_status`、`param`、`sign`、`sign_type`。出现白名单外的字段或重复字段时拒绝通知,空值字段也参与检查。 +易支付的 MD5 协议直接拼接参数值,浏览器中的下单 URL 带有签名。`EasyPay.VerifyNotification` 只接受标准异步通知字段以及 V1 常见扩展字段 `api_trade_no`、`addtime`、`endtime`、`buyer`、`timestamp`,每个参数只能出现一次。下单专用的 `notify_url`、`return_url`、`device`、`clientip`、`cid` 和其他未知字段一律拒绝,避免重放下单签名伪造通知。 -字段名白名单需要配合字段内容校验:`pid`、`trade_no`、`out_trade_no`、`type`、`money`、`trade_status` 都需要非空,含 `&`、`=`、NUL、回车、换行或首尾空白时拒绝。`type` 使用和自定义支付方式配置相同的格式,接受小写字母、数字、下划线和连字符。`pid` 需要匹配验签实例,金额需要是有限的正数。商品名称和可选 `param` 支持自由文本。所有参数按协议解码一次后验签,入账前再与订单快照核对商户和金额。 +字段集合需要配合内容校验:`pid`、`trade_no`、`out_trade_no`、`type`、`money`、`trade_status` 都需要非空。除商品名称外,字段值含 `&`、`=`、NUL、回车、换行或首尾空白时拒绝,防止一个签名值被拆成多个参数;`param` 也按结构化字段处理。`type` 使用和自定义支付方式配置相同的格式,接受小写字母、数字、下划线和连字符。`pid` 需要匹配验签实例,金额需要是有限的正数。 下单时,`CanonicalizeReturnURL` 校验用户提供的结果页地址,拒绝 URL 用户信息(userinfo)以及主机名中的 `&`、`=`。它使用校验后的 scheme、host 和固定 `/payment/result` 路径重建 URL。服务端随后生成 `order_id`、`out_trade_no`、`resume_token` 和 `status` 参数。结果页参数用于找回订单和展示页面,付款确认依赖回调验签或上游查单。 -回调是主要的信号。主动 verify、查单和后台 reconcile 用于漏掉回调、弹窗支付或进程中断后的恢复,它们和回调调用同一套状态迁移和履约函数,加余额只发生在履约函数里。易支付兼容平台携带白名单外的通知字段时,回调会被拒绝,订单可通过主动查单确认付款。 +回调是主要的信号。主动 verify、查单和后台 reconcile 用于漏掉回调、弹窗支付或进程中断后的恢复,它们和回调调用同一套状态迁移和履约函数,加余额只发生在履约函数里。易支付的成功回调即使验签通过,也必须使用同一个商户实例主动查单,确认上游状态已支付、金额一致且返回的 `trade_no` 不冲突后才能履约;确认失败时 webhook 返回可重试错误,订单保持未支付状态。 ## 履约与幂等 From 5a6e16501a66c9844bc7856b6759d056e9ddf3c0 Mon Sep 17 00:00:00 2001 From: 18848298290-del <237243727+18848298290-del@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:59:51 +0800 Subject: [PATCH 2/3] fix(payment): preserve EasyPay probe compatibility --- backend/internal/payment/provider/easypay.go | 37 +++++++++++-------- .../internal/payment/provider/easypay_test.go | 14 ++----- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 4af945ecd..e0ccaf521 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -51,17 +51,15 @@ const ( deviceMobile = "mobile" // mobile request device label ) -var ( - // easyPayNotifyAllowedParams contains genuine asynchronous notification - // fields across common V1 implementations. Checkout-only fields stay excluded - // because their reusable signatures can otherwise be spliced into callbacks. - easyPayNotifyAllowedParams = map[string]struct{}{ - "pid": {}, "trade_no": {}, "out_trade_no": {}, "type": {}, - "name": {}, "money": {}, "trade_status": {}, "param": {}, - "api_trade_no": {}, "addtime": {}, "endtime": {}, "buyer": {}, - "timestamp": {}, "sign": {}, "sign_type": {}, - } -) +// easyPayNotifyAllowedParams contains genuine asynchronous notification fields +// across common V1 implementations. Checkout-only fields stay excluded because +// their reusable signatures can otherwise be spliced into callbacks. +var easyPayNotifyAllowedParams = map[string]struct{}{ + "pid": {}, "trade_no": {}, "out_trade_no": {}, "type": {}, + "name": {}, "money": {}, "trade_status": {}, "param": {}, + "api_trade_no": {}, "addtime": {}, "endtime": {}, "buyer": {}, + "timestamp": {}, "sign": {}, "sign_type": {}, +} // EasyPay sends signed checkout, query and refund requests for one configured // aggregation instance. @@ -116,6 +114,7 @@ type easyPayQueryResult struct { status string // normalized provider order state money string // exact returned amount text tradeNo string // exact returned upstream trade reference + failure string // explicit upstream failure diagnostic, if any } // easyPayRefundAttempt pairs a request form with the reference returned on its @@ -472,8 +471,7 @@ func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQu } body, statusCode, err := e.postRaw(ctx, e.apiBase()+"/api.php?"+query.Encode(), params) if err != nil { - var urlErr *url.Error - if errors.As(err, &urlErr) { + if urlErr, ok := errors.AsType[*url.Error](err); ok { err = urlErr.Err } return nil, fmt.Errorf("easypay query: %w", err) @@ -483,8 +481,8 @@ func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQu // parseEasyPayQueryResponse accepts successful HTTP JSON objects and selects // status, money and trade identity from optional top-level or nested fields. -// An explicit failure code or a different echoed merchant order rejects the -// response before paid-looking status fields are considered. +// An explicit failure code becomes a pending observation with a retained +// diagnostic, while a different echoed merchant order rejects the response. func parseEasyPayQueryResponse(statusCode int, body []byte, outTradeNo string) (*easyPayQueryResult, error) { if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("easypay query HTTP %d", statusCode) @@ -512,7 +510,11 @@ func parseEasyPayQueryResponse(statusCode int, body []byte, outTradeNo string) ( if msg == "" { msg = summarizeEasyPayResponse(body) } - return nil, fmt.Errorf("easypay query failed: %s", msg) + // QueryOrder is also used as a reachability probe with a fresh random + // order number. Preserve explicit upstream failures as pending observations + // there, while ConfirmNotification rejects the retained diagnostic before + // fulfillment. + return &easyPayQueryResult{status: payment.ProviderStatusPending, failure: msg}, nil } echoed := easyPayJSONScalar(pickEasyPayJSON(resp.OutTradeNo, resp.Data.OutTradeNo)) if echoed != "" && echoed != strings.TrimSpace(outTradeNo) { @@ -555,6 +557,9 @@ func (e *EasyPay) ConfirmNotification(ctx context.Context, notification *payment if err != nil { return fmt.Errorf("easypay confirm: %w", err) } + if result.failure != "" { + return fmt.Errorf("easypay confirm: query failed: %s", result.failure) + } if result.status != payment.ProviderStatusPaid { return fmt.Errorf("easypay confirm: upstream reports order %s as not paid", notification.OrderID) } diff --git a/backend/internal/payment/provider/easypay_test.go b/backend/internal/payment/provider/easypay_test.go index 5b0df8193..008bd3635 100644 --- a/backend/internal/payment/provider/easypay_test.go +++ b/backend/internal/payment/provider/easypay_test.go @@ -324,9 +324,10 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) { wantAmount: 3.21, }, { - name: "query failure code is rejected", - body: `{"code":0,"msg":"订单不存在"}`, - wantError: true, + name: "query failure code remains pending", + body: `{"code":0,"msg":"订单不存在","status":1,"money":"3.21"}`, + wantStatus: payment.ProviderStatusPending, + wantTradeNo: orderID, }, { name: "string scalar response is compatible", @@ -453,12 +454,6 @@ func TestEasyPayQueryOrderRejectsUnsafeResponses(t *testing.T) { body: "", wantErr: "easypay query empty response (HTTP 200)", }, - { - name: "failure code with paid status", - statusCode: http.StatusOK, - body: `{"code":-1,"msg":"签名错误","status":1,"money":"10.00"}`, - wantErr: "easypay query failed: 签名错误", - }, { name: "foreign order echo", statusCode: http.StatusOK, @@ -729,7 +724,6 @@ func TestEasyPayRefundResponseCompatibility(t *testing.T) { "success", "1", } { - body := body t.Run(body, func(t *testing.T) { t.Parallel() if err := parseEasyPayRefundResponse(http.StatusOK, []byte(body)); err != nil { From 39cadd5a5bdfac9c601a58d82c02972d1983664b Mon Sep 17 00:00:00 2001 From: 18848298290-del <237243727+18848298290-del@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:48:51 +0800 Subject: [PATCH 3/3] fix(payment): handle indeterminate EasyPay outcomes --- backend/internal/payment/httpapi/webhook.go | 36 ++++- .../internal/payment/httpapi/webhook_test.go | 137 ++++++++++++++++++ backend/internal/payment/provider/easypay.go | 120 +++++++++------ .../internal/payment/provider/easypay_test.go | 115 ++++++++++++--- backend/internal/payment/types.go | 11 +- docs/domains/payments_and_entitlements.md | 2 + 6 files changed, 349 insertions(+), 72 deletions(-) create mode 100644 backend/internal/payment/httpapi/webhook_test.go diff --git a/backend/internal/payment/httpapi/webhook.go b/backend/internal/payment/httpapi/webhook.go index f29716731..6d7e972db 100644 --- a/backend/internal/payment/httpapi/webhook.go +++ b/backend/internal/payment/httpapi/webhook.go @@ -124,8 +124,8 @@ func (h *PaymentWebhookHandler) AirwallexWebhook(c *gin.Context) { // error wins even for an irrelevant nil notification. A missing provider is // acknowledged except official WeChat; other lookup failures return 500. // Failed verification returns 400 after bounded diagnostics. Verified missing -// orders are acknowledged, while confirmation or other processing failures -// remain retryable. +// orders are acknowledged before optional upstream confirmation, while real +// lookup, confirmation, or other processing failures remain retryable. func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) { var rawBody string if c.Request.Method == http.MethodGet { @@ -203,6 +203,29 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) if confirmer, ok := verifiedProvider.(payment.NotificationConfirmer); ok && notification.Status == payment.NotificationStatusSuccess { + // Preserve the existing missing-order acknowledgement before a gateway + // query. Obsolete verified callbacks have no local state to fulfill, while + // database failures remain retryable and existing orders still require the + // same verified merchant instance to confirm upstream payment. + if _, err := h.paymentService.FindPaymentNotificationOrder(c.Request.Context(), notification.OrderID); err != nil { + if errors.Is(err, payment.ErrOrderNotFound) { + slog.Warn("[Payment Webhook] unknown order before confirmation, acking to stop retries", + "provider", resolvedProviderKey, + "outTradeNo", notification.OrderID, + "tradeNo", notification.TradeNo, + ) + WebhookWriteSuccessResponse(c, resolvedProviderKey) + return + } + slog.Error("[Payment Webhook] pre-confirmation order lookup failed", + "provider", resolvedProviderKey, + "outTradeNo", notification.OrderID, + "tradeNo", notification.TradeNo, + "error", err, + ) + c.String(http.StatusInternalServerError, "order lookup failed") + return + } if err := confirmer.ConfirmNotification(c.Request.Context(), notification); err != nil { slog.Error("[Payment Webhook] upstream confirmation failed", "provider", resolvedProviderKey, @@ -326,9 +349,12 @@ func WebhookVerifyNotificationWithProviders(ctx context.Context, providers []pay return provider.ProviderKey(), notification, nil } -// webhookVerifyNotificationWithProviders retains the exact provider instance -// that accepted the callback so optional upstream confirmation uses the same -// merchant configuration before fulfillment. +// webhookVerifyNotificationWithProviders is the handler-only variant that +// retains the exact provider instance accepting the callback. Call it after +// candidate lookup and before any local order mutation. A nil notification is +// a verified irrelevant event; otherwise the caller may use optional provider +// capabilities on this same merchant instance before fulfillment. Candidates +// are tried in order, and the last verification error wins when none accepts. func webhookVerifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *payment.PaymentNotification, error) { var lastErr error for _, provider := range providers { diff --git a/backend/internal/payment/httpapi/webhook_test.go b/backend/internal/payment/httpapi/webhook_test.go new file mode 100644 index 000000000..bbf61e086 --- /dev/null +++ b/backend/internal/payment/httpapi/webhook_test.go @@ -0,0 +1,137 @@ +package httpapi + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/TokenFlux/TokenRouter/internal/payment" +) + +var errWebhookOrderNotFound = errors.New("webhook order not found") + +// webhookOrderStore supplies only the provider fallback and notification-order +// reads reached by these handler tests. Other promoted store operations would +// panic if callback handling unexpectedly continued into fulfillment. +type webhookOrderStore struct { + payment.BindingStore + payment.FulfillmentStore + order *payment.Order + err error +} + +// webhookConfirmer verifies one fixed success notification and records whether +// handler ordering reached upstream confirmation. +type webhookConfirmer struct { + payment.Provider + notification *payment.PaymentNotification + confirmErr error + confirmCalls int +} + +func TestEasyPayNotifyChecksLocalOrderBeforeConfirmation(t *testing.T) { + tests := []struct { + name string + order *payment.Order + orderErr error + confirmErr error + wantCode int + wantBody string + wantConfirms int + }{ + { + name: "missing order is acknowledged", + orderErr: errWebhookOrderNotFound, + confirmErr: errors.New("confirmation must not run"), + wantCode: http.StatusOK, + wantBody: "success", + wantConfirms: 0, + }, + { + name: "database failure remains retryable", + orderErr: errors.New("database unavailable"), + confirmErr: errors.New("confirmation must not run"), + wantCode: http.StatusInternalServerError, + wantBody: "order lookup failed", + wantConfirms: 0, + }, + { + name: "existing order still requires confirmation", + order: &payment.Order{ID: 42, OutTradeNo: "order-42"}, + confirmErr: errors.New("upstream unavailable"), + wantCode: http.StatusBadGateway, + wantBody: "confirm failed", + wantConfirms: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &webhookOrderStore{order: tt.order, err: tt.orderErr} + provider := &webhookConfirmer{ + notification: &payment.PaymentNotification{ + OrderID: "order-42", + Status: payment.NotificationStatusSuccess, + }, + confirmErr: tt.confirmErr, + } + registry := payment.NewRegistry() + registry.Register(provider) + bindings := payment.NewProviderBindings(store, registry, nil, payment.BindingRuntime{}, true) + fulfillment := payment.NewFulfillment(store, bindings, registry, nil, nil, payment.FulfillmentRuntime{}) + runtime := &payment.Runtime{ + OrderLifecycle: payment.NewOrderLifecycle(fulfillment, nil, nil), + ProviderBindings: bindings, + } + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/payment/webhook/easypay", nil) + + NewPaymentWebhookHandler(runtime).EasyPayNotify(ctx) + + require.Equal(t, tt.wantCode, recorder.Code) + require.Equal(t, tt.wantBody, recorder.Body.String()) + require.Equal(t, tt.wantConfirms, provider.confirmCalls) + }) + } +} + +func (s *webhookOrderStore) OrderByTradeNumber(context.Context, string) (*payment.Order, error) { + return s.order, s.err +} + +func (*webhookOrderStore) CountEnabledInstances(context.Context, string) (int, error) { + return 0, nil +} + +func (*webhookOrderStore) IsNotFound(err error) bool { + return errors.Is(err, errWebhookOrderNotFound) +} + +func (*webhookConfirmer) Name() string { return "webhook confirmer" } + +func (*webhookConfirmer) ProviderKey() string { return payment.TypeEasyPay } + +func (*webhookConfirmer) SupportedTypes() []payment.PaymentType { + return []payment.PaymentType{payment.TypeEasyPay} +} + +func (p *webhookConfirmer) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) { + if p.notification == nil { + return nil, fmt.Errorf("test notification missing") + } + copy := *p.notification + return ©, nil +} + +func (p *webhookConfirmer) ConfirmNotification(context.Context, *payment.PaymentNotification) error { + p.confirmCalls++ + return p.confirmErr +} diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index e0ccaf521..63fe4114a 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -13,6 +13,7 @@ import ( "math" "net/http" "net/url" + "regexp" "sort" "strconv" "strings" @@ -61,6 +62,11 @@ var easyPayNotifyAllowedParams = map[string]struct{}{ "timestamp": {}, "sign": {}, "sign_type": {}, } +// easyPayRefundSuccessTextPattern is the complete plain-text success format +// observed from V1-compatible refund endpoints. Captured money is compared +// with the amount submitted for the current attempt before success is exposed. +var easyPayRefundSuccessTextPattern = regexp.MustCompile(`^退款成功!退款金额¥([0-9]+(?:\.[0-9]{1,2})?)$`) + // EasyPay sends signed checkout, query and refund requests for one configured // aggregation instance. type EasyPay struct { @@ -546,9 +552,12 @@ func parseEasyPayQueryResponse(statusCode int, body []byte, outTradeNo string) ( }, nil } -// ConfirmNotification checks a verified success callback against the -// upstream order before local fulfillment. Paid state, amount and any returned -// upstream trade number must agree with the signed callback. +// ConfirmNotification is called for a verified EasyPay success notification +// after webhook handling establishes that its local order exists. It queries +// the same merchant configuration that verified the callback; paid state, +// amount and any returned upstream trade number must agree. On success it may +// fill the notification's TradeNo before local fulfillment, while every error +// leaves the local order unchanged and asks the gateway to retry the callback. func (e *EasyPay) ConfirmNotification(ctx context.Context, notification *payment.PaymentNotification) error { if notification == nil || notification.Status != payment.NotificationStatusSuccess { return nil @@ -682,8 +691,9 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st // Refund submits available merchant-order and upstream-trade forms in that // order, returning the identity of the successful attempt. A transport error -// stops submission immediately; a parsed order-not-found diagnostic permits -// the next form, while other parsed failures return at once. +// stops submission immediately with pending progress because request outcome +// is unknown; a parsed order-not-found diagnostic permits the next form, while +// other explicit failures return at once. // // Each form carries the requested amount but omits the workflow OperationID. // The alternative is another order lookup representation, so callers should @@ -699,9 +709,13 @@ func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payme for i, attempt := range attempts { body, status, err := e.postRaw(ctx, e.apiBase()+"/api.php?act=refund", attempt.params) if err != nil { - return nil, fmt.Errorf("easypay refund request: %w", err) + return &payment.RefundResponse{ + RefundID: attempt.refundID, + Status: payment.ProviderStatusPending, + }, fmt.Errorf("easypay refund request outcome unknown: %w", err) } - if err := parseEasyPayRefundResponse(status, body); err != nil { + refundStatus, err := parseEasyPayRefundResponse(status, body, req.Amount) + if err != nil { if firstErr == nil { firstErr = err } @@ -712,7 +726,7 @@ func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payme } return nil, err } - return &payment.RefundResponse{RefundID: attempt.refundID, Status: payment.ProviderStatusSuccess}, nil + return &payment.RefundResponse{RefundID: attempt.refundID, Status: refundStatus}, nil } return nil, firstErr } @@ -767,54 +781,61 @@ func isEasyPayRefundOrderNotFound(err error) bool { strings.Contains(lower, "not exist") } -// parseEasyPayRefundResponse accepts successful HTTP responses in the JSON and -// text forms returned by EasyPay V1 implementations. JSON code one is accepted -// directly; code zero needs an explicit success message, and non-JSON text must -// contain an unambiguous refund-success result. Other responses use a bounded -// body summary unless JSON supplies a nonblank diagnostic message. -func parseEasyPayRefundResponse(status int, body []byte) error { +// parseEasyPayRefundResponse classifies one submitted refund response as +// success, pending manual verification, or explicit failure. JSON code one and +// exact plain acknowledgements establish success. The complete Chinese success +// format must report the submitted amount; other successful HTTP responses are +// pending because they do not prove that this attempt completed. Transport and +// non-2xx outcomes are also pending at the adapter boundary; documented JSON +// code zero responses are explicit failures. +func parseEasyPayRefundResponse(status int, body []byte, expectedAmount string) (string, error) { summary := summarizeEasyPayResponse(body) if status < http.StatusOK || status >= http.StatusMultipleChoices { - return fmt.Errorf("easypay refund HTTP %d: %s", status, summary) + return payment.ProviderStatusPending, nil } trimmed := strings.TrimSpace(string(body)) if trimmed == "" { - return fmt.Errorf("easypay refund empty response (HTTP %d): %s", status, summary) + return payment.ProviderStatusPending, nil } lower := strings.ToLower(trimmed) if strings.HasPrefix(lower, "bad config"}, + {name: "non json response", body: "not json"}, + {name: "ambiguous success text", body: "原订单未支付成功"}, + {name: "previous refund diagnostic", body: "已退款,请勿重复退款"}, + {name: "fractional JSON code", body: `{"code":1.9,"msg":"invalid code"}`}, + {name: "empty response", body: ""}, + {name: "plain amount mismatch", body: "退款成功!退款金额¥10.20"}, + {name: "JSON amount mismatch", body: `{"code":1,"msg":"退款成功!退款金额¥10.20"}`}, + {name: "upstream bad gateway", statusCode: http.StatusBadGateway, body: "bad gateway"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var attempts int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + if tt.statusCode != 0 { + w.WriteHeader(tt.statusCode) + } + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + provider := newTestEasyPay(t, server.URL) + resp, err := provider.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "trade-123", + OrderID: "out-456", + Amount: "1.50", + }) + if err != nil { + t.Fatalf("Refund returned error: %v", err) + } + if resp == nil || resp.Status != payment.ProviderStatusPending || resp.RefundID != "out-456" { + t.Fatalf("Refund response = %+v, want pending with merchant refund id", resp) + } + if attempts != 1 { + t.Fatalf("refund attempts = %d, want 1 for indeterminate response", attempts) + } + }) + } +} + +// TestEasyPayRefundTransportErrorPreservesPendingProgress closes a loopback +// endpoint before submission. The adapter must retain the attempted merchant +// identity with pending state so the workflow can compensate and reconcile it. +func TestEasyPayRefundTransportErrorPreservesPendingProgress(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + serverURL := server.URL + server.Close() + + provider := newTestEasyPay(t, serverURL) + resp, err := provider.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "trade-123", + OrderID: "out-456", + Amount: "1.50", + }) + if err == nil { + t.Fatal("Refund returned nil error") + } + if resp == nil || resp.Status != payment.ProviderStatusPending || resp.RefundID != "out-456" { + t.Fatalf("Refund response = %+v, want pending with attempted merchant refund id", resp) + } +} + +// TestEasyPayRefundResponseErrors serves explicit JSON and HTTP failures. The +// adapter must return their diagnostics instead of converting them to success +// or pending progress. func TestEasyPayRefundResponseErrors(t *testing.T) { t.Parallel() tests := []struct { - name string // named refund rejection case + name string // named explicit failure case statusCode int // served HTTP status code body string // served response body text want string // expected refund diagnostic fragment }{ - {name: "html response", statusCode: http.StatusOK, body: "bad config", want: "non-JSON response (HTTP 200): bad config"}, - {name: "non json response", statusCode: http.StatusOK, body: "not json", want: "non-JSON response (HTTP 200): not json"}, - {name: "ambiguous success text", statusCode: http.StatusOK, body: "原订单未支付成功", want: "non-JSON response (HTTP 200): 原订单未支付成功"}, {name: "zero failure code", statusCode: http.StatusOK, body: `{"code":0,"msg":"退款失败"}`, want: "refund failed (HTTP 200): 退款失败"}, - {name: "fractional success code", statusCode: http.StatusOK, body: `{"code":1.9,"msg":"invalid code"}`, want: "refund failed (HTTP 200): invalid code"}, - {name: "non 2xx response", statusCode: http.StatusBadGateway, body: "bad gateway", want: "HTTP 502: bad gateway"}, - {name: "empty response", statusCode: http.StatusOK, body: "", want: "empty response (HTTP 200): "}, + {name: "success-looking failure diagnostic", statusCode: http.StatusOK, body: `{"code":0,"msg":"已退款金额超过订单金额,本次退款失败"}`, want: "refund failed (HTTP 200): 已退款金额超过订单金额,本次退款失败"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // Serve the fixed response for client parser assertions; handler Write - // errors are discarded while callers check channel results or diagnostics. w.WriteHeader(tt.statusCode) _, _ = w.Write([]byte(tt.body)) })) - // Close the loopback listener when the fixture returns, releasing its HTTP - // resources after the request and response assertions finish. defer server.Close() provider := newTestEasyPay(t, server.URL) diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index ccb7bd222..566804748 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -363,12 +363,15 @@ type RefundQueryProvider interface { // NotificationConfirmer adds upstream verification for gateways whose signed // callbacks are not sufficient payment proof by themselves. Webhook handling -// invokes it on the same provider instance that accepted the callback before -// local fulfillment. +// invokes it only for a verified success notification after confirming that +// its local order exists, and uses the same provider instance that accepted +// the callback. Confirmation errors are retryable and stop fulfillment. type NotificationConfirmer interface { Provider // base payment and identity operations - // ConfirmNotification checks a verified success observation against upstream - // state and may fill provider-owned references on the notification. + // ConfirmNotification queries upstream state for the verified notification. + // Implementations must reject unpaid, mismatched, or indeterminate results; + // they may replace provider-owned references such as TradeNo on the supplied + // notification before the caller enters local fulfillment. ConfirmNotification(ctx context.Context, notification *PaymentNotification) error } diff --git a/docs/domains/payments_and_entitlements.md b/docs/domains/payments_and_entitlements.md index acde0c5c9..f520016ae 100644 --- a/docs/domains/payments_and_entitlements.md +++ b/docs/domains/payments_and_entitlements.md @@ -129,6 +129,8 @@ Webhook 路由不使用用户 JWT,所以提供商验签、订单绑定和金 管理员的退款查单入口也接受带有效恢复记录的 `REFUNDING` 订单:它查询已经发生的渠道操作,并幂等地完成退款流程,渠道上的退款只发起过那一次。旧的 `REFUND_PENDING` 字段继续兼容。恢复资料缺失、损坏或互相矛盾,或者渠道不支持查单、结果不明确时,要求人工核实;系统不会假设扣减为零并自动完成。普通支付过程的审计尽力写入;退款正确性依赖的记录必须持久化。`(order_id, action)` 唯一索引保持不变:同一动作的后续尝试更新当前版本,旧的详情原样归档到 `history`,并记录操作标识和时间,旧的退款记录不会被删除。旧 pending 记录里明确写了 `deductBalance=false` 时,这个值优先;没有这个字段和写了 `false` 是两回事。 +易支付退款只把 JSON `code=1`、完整的纯文本 `success`、`ok`、`1`,以及金额与本次请求一致的 `退款成功!退款金额¥X` 认定为立即成功。JSON `code=0` 是明确失败;成功 HTTP 中的其他文本、矛盾金额、非 2xx 和传输中断都不能证明本次操作结果,适配器返回 pending,使工作流补偿本地权益并保留恢复记录。易支付 V1 没有可依赖的退款查询能力时,管理员必须根据渠道记录人工核实,不能再次提交同一笔退款。 + 回退到不认识准备记录的旧版本之前,先处理或登记所有未完成的退款;删除这些记录后盲目重试,会丢失退款的进度。 余额退款在非强制模式下发现余额不足时,返回 `require_force`,管理员明确确认后才能继续。强制退款也只能原子扣除当时可用的非负余额,实际扣减的金额写入结果和审计。订阅相关的退款,需要识别由 `source_order_id` 发放的订阅,以及它们处于 active 还是 pending。强制退款是管理员明确发起的操作,需要记录理由;普通的错误恢复走上面的查单和补偿流程。第一次尝试返回 `require_force` 时,管理端保持退款弹窗打开,显示服务端的警告;管理员勾选强制确认后,才带上 `force` 重试;关闭或重新打开弹窗时,清除上一次的确认状态。