diff --git a/backend/internal/payment/httpapi/webhook.go b/backend/internal/payment/httpapi/webhook.go index ad86efecf..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 other notification-processing failures return -// 500 for provider retry. +// 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 { @@ -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,43 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) return } + 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, + "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 +342,20 @@ 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 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 { if provider == nil { @@ -317,12 +369,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/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 3d4a53473..63fe4114a 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -6,11 +6,14 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "maps" + "math" "net/http" "net/url" + "regexp" "sort" "strconv" "strings" @@ -24,11 +27,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 +45,28 @@ 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 ) +// 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": {}, +} + +// 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 { @@ -68,41 +86,41 @@ 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 + failure string // explicit upstream failure diagnostic, if any } // easyPayRefundAttempt pairs a request form with the reference returned on its @@ -423,34 +441,55 @@ 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, + } + query := url.Values{} + for key, value := range params { + query.Set(key, value) } - body, statusCode, err := e.postRaw(ctx, e.apiBase()+"/api.php", params) + body, statusCode, err := e.postRaw(ctx, e.apiBase()+"/api.php?"+query.Encode(), params) if err != nil { + if urlErr, ok := errors.AsType[*url.Error](err); ok { + 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 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) } @@ -472,6 +511,21 @@ 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) + } + // 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) { + 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 +537,89 @@ 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 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 + } + result, err := e.queryOrder(ctx, notification.OrderID) + 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) + } + 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 +636,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 +644,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 == "" { @@ -592,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 @@ -609,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 } @@ -622,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 } @@ -677,57 +781,69 @@ 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. -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: "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: "zero failure code", statusCode: http.StatusOK, body: `{"code":0,"msg":"退款失败"}`, want: "refund failed (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 3a7d88d51..566804748 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -361,6 +361,20 @@ 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 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 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 +} + // 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..f520016ae 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 返回可重试错误,订单保持未支付状态。 ## 履约与幂等 @@ -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` 重试;关闭或重新打开弹窗时,清除上一次的确认状态。