From 0934f737d5c46a0451844c161b4c6e69bd2050d9 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Tue, 21 Apr 2026 13:35:54 +0800 Subject: [PATCH] fix: snapshot merchant identity for alipay and easypay --- backend/internal/payment/provider/alipay.go | 44 ++++++++--- .../internal/payment/provider/alipay_test.go | 15 ++++ backend/internal/payment/provider/easypay.go | 28 ++++++- .../payment/provider/easypay_sign_test.go | 15 ++++ .../internal/payment/provider/wxpay_test.go | 2 +- backend/internal/payment/types.go | 6 ++ .../internal/service/payment_fulfillment.go | 42 +--------- .../service/payment_fulfillment_test.go | 34 ++++++++ backend/internal/service/payment_order.go | 10 +++ .../payment_order_provider_snapshot.go | 78 +++++++++++++++++++ .../payment_order_provider_snapshot_test.go | 34 ++++++++ backend/internal/service/payment_refund.go | 6 ++ .../internal/service/payment_refund_test.go | 69 ++++++++++++++++ 13 files changed, 328 insertions(+), 55 deletions(-) diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index 4f87e5a7..0604883a 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -91,6 +91,17 @@ func (a *Alipay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeAlipay} } +func (a *Alipay) MerchantIdentityMetadata() map[string]string { + if a == nil { + return nil + } + appID := strings.TrimSpace(a.config["appId"]) + if appID == "" { + return nil + } + return map[string]string{"app_id": appID} +} + // CreatePayment creates an Alipay payment page URL. func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { client, err := a.getClient() @@ -181,10 +192,11 @@ func (a *Alipay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query } return &payment.QueryOrderResponse{ - TradeNo: result.TradeNo, - Status: status, - Amount: amount, - PaidAt: result.SendPayDate, + TradeNo: result.TradeNo, + Status: status, + Amount: amount, + PaidAt: result.SendPayDate, + Metadata: a.MerchantIdentityMetadata(), }, nil } @@ -215,12 +227,21 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s return nil, fmt.Errorf("alipay parse notification amount %q: %w", notification.TotalAmount, err) } + metadata := a.MerchantIdentityMetadata() + if appID := strings.TrimSpace(notification.AppId); appID != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["app_id"] = appID + } + return &payment.PaymentNotification{ - TradeNo: notification.TradeNo, - OrderID: notification.OutTradeNo, - Amount: amount, - Status: status, - RawData: rawBody, + TradeNo: notification.TradeNo, + OrderID: notification.OutTradeNo, + Amount: amount, + Status: status, + RawData: rawBody, + Metadata: metadata, }, nil } @@ -283,6 +304,7 @@ func isTradeNotExist(err error) bool { // Ensure interface compliance. var ( - _ payment.Provider = (*Alipay)(nil) - _ payment.CancelableProvider = (*Alipay)(nil) + _ payment.Provider = (*Alipay)(nil) + _ payment.CancelableProvider = (*Alipay)(nil) + _ payment.MerchantIdentityProvider = (*Alipay)(nil) ) diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go index 6cc4246c..b25c05bd 100644 --- a/backend/internal/payment/provider/alipay_test.go +++ b/backend/internal/payment/provider/alipay_test.go @@ -243,3 +243,18 @@ func TestCreateTradeUsesWapPayForMobile(t *testing.T) { t.Fatalf("qr_code = %q, want empty", resp.QRCode) } } + +func TestAlipayMerchantIdentityMetadata(t *testing.T) { + t.Parallel() + + provider := &Alipay{ + config: map[string]string{ + "appId": "2021001234567890", + }, + } + + metadata := provider.MerchantIdentityMetadata() + if metadata["app_id"] != "2021001234567890" { + t.Fatalf("app_id = %q, want %q", metadata["app_id"], "2021001234567890") + } +} diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index e33a567d..37bd38b2 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -59,6 +59,17 @@ func (e *EasyPay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay} } +func (e *EasyPay) MerchantIdentityMetadata() map[string]string { + if e == nil { + return nil + } + pid := strings.TrimSpace(e.config["pid"]) + if pid == "" { + return nil + } + return map[string]string{"pid": pid} +} + func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { // Payment mode determined by instance config, not payment type. // "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php). @@ -178,7 +189,12 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer status = payment.ProviderStatusPaid } amount, _ := strconv.ParseFloat(resp.Money, 64) - return &payment.QueryOrderResponse{TradeNo: tradeNo, Status: status, Amount: amount}, nil + return &payment.QueryOrderResponse{ + TradeNo: tradeNo, + Status: status, + Amount: amount, + Metadata: e.MerchantIdentityMetadata(), + }, nil } func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) { @@ -203,9 +219,17 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st status = payment.ProviderStatusSuccess } amount, _ := strconv.ParseFloat(params["money"], 64) + + metadata := e.MerchantIdentityMetadata() + if pid := strings.TrimSpace(params["pid"]); pid != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["pid"] = pid + } return &payment.PaymentNotification{ TradeNo: params["trade_no"], OrderID: params["out_trade_no"], - Amount: amount, Status: status, RawData: rawBody, + Amount: amount, Status: status, RawData: rawBody, Metadata: metadata, }, nil } diff --git a/backend/internal/payment/provider/easypay_sign_test.go b/backend/internal/payment/provider/easypay_sign_test.go index 146a6fa1..8328d294 100644 --- a/backend/internal/payment/provider/easypay_sign_test.go +++ b/backend/internal/payment/provider/easypay_sign_test.go @@ -178,3 +178,18 @@ func TestEasyPayVerifySignWrongSignValue(t *testing.T) { t.Fatal("easyPayVerifySign should return false for an incorrect sign value") } } + +func TestEasyPayMerchantIdentityMetadata(t *testing.T) { + t.Parallel() + + provider := &EasyPay{ + config: map[string]string{ + "pid": "1001", + }, + } + + metadata := provider.MerchantIdentityMetadata() + if metadata["pid"] != "1001" { + t.Fatalf("pid = %q, want %q", metadata["pid"], "1001") + } +} diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index b3f4f648..0d79b1b0 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -110,7 +110,7 @@ func TestBuildWxpayTransactionMetadata(t *testing.T) { Appid: strPtr("wx-app-id"), Mchid: strPtr("mch-id"), TradeState: strPtr(wxpayTradeStateSuccess), - Amount: &payments.Amount{ + Amount: &payments.TransactionAmount{ Currency: strPtr(wxpayCurrency), }, } diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 29abf82b..e7ac6727 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -214,3 +214,9 @@ type CancelableProvider interface { // CancelPayment cancels/expires a pending payment on the upstream platform. CancelPayment(ctx context.Context, tradeNo string) error } + +// MerchantIdentityProvider exposes the current non-sensitive merchant identity +// derived from provider configuration for snapshot consistency checks. +type MerchantIdentityProvider interface { + MerchantIdentityMetadata() map[string]string +} diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 423ed80f..904960ee 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -96,47 +96,7 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo } func validateProviderNotificationMetadata(order *dbent.PaymentOrder, providerKey string, metadata map[string]string) error { - if order == nil || len(metadata) == 0 || !strings.EqualFold(strings.TrimSpace(providerKey), payment.TypeWxpay) { - return nil - } - - snapshot := psOrderProviderSnapshot(order) - if snapshot == nil { - return nil - } - - if expected := strings.TrimSpace(snapshot.MerchantAppID); expected != "" { - actual := strings.TrimSpace(metadata["appid"]) - if actual == "" { - return fmt.Errorf("wxpay notification missing appid") - } - if !strings.EqualFold(expected, actual) { - return fmt.Errorf("wxpay appid mismatch: expected %s, got %s", expected, actual) - } - } - if expected := strings.TrimSpace(snapshot.MerchantID); expected != "" { - actual := strings.TrimSpace(metadata["mchid"]) - if actual == "" { - return fmt.Errorf("wxpay notification missing mchid") - } - if !strings.EqualFold(expected, actual) { - return fmt.Errorf("wxpay mchid mismatch: expected %s, got %s", expected, actual) - } - } - if expected := strings.TrimSpace(snapshot.Currency); expected != "" { - actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) - if actual == "" { - return fmt.Errorf("wxpay notification missing currency") - } - if !strings.EqualFold(expected, actual) { - return fmt.Errorf("wxpay currency mismatch: expected %s, got %s", expected, actual) - } - } - if actual := strings.TrimSpace(metadata["trade_state"]); actual != "" && !strings.EqualFold(actual, "SUCCESS") { - return fmt.Errorf("wxpay trade_state mismatch: expected SUCCESS, got %s", actual) - } - - return nil + return validateProviderSnapshotMetadata(order, providerKey, metadata) } func expectedNotificationProviderKey(registry *payment.Registry, orderPaymentType string, orderProviderKey string, instanceProviderKey string) string { diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index d70f8946..6aed19f8 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -321,3 +321,37 @@ func TestParseLegacyPaymentOrderID(t *testing.T) { _, ok = parseLegacyPaymentOrderID("sub2_42", errors.New("db down")) assert.False(t, ok) } + +func TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch(t *testing.T) { + t.Parallel() + + order := &dbent.PaymentOrder{ + PaymentType: payment.TypeAlipay, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + "merchant_app_id": "alipay-app-expected", + }, + } + + err := validateProviderNotificationMetadata(order, payment.TypeAlipay, map[string]string{ + "app_id": "alipay-app-other", + }) + assert.ErrorContains(t, err, "alipay app_id mismatch") +} + +func TestValidateProviderNotificationMetadataRejectsEasyPaySnapshotMismatch(t *testing.T) { + t.Parallel() + + order := &dbent.PaymentOrder{ + PaymentType: payment.TypeAlipay, + ProviderSnapshot: map[string]any{ + "schema_version": 2, + "merchant_id": "pid-expected", + }, + } + + err := validateProviderNotificationMetadata(order, payment.TypeEasyPay, map[string]string{ + "pid": "pid-other", + }) + assert.ErrorContains(t, err, "easypay pid mismatch") +} diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 6ee490a8..254af5fe 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -240,6 +240,16 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat } snapshot["currency"] = "CNY" } + if providerKey == payment.TypeAlipay { + if merchantAppID := strings.TrimSpace(sel.Config["appId"]); merchantAppID != "" { + snapshot["merchant_app_id"] = merchantAppID + } + } + if providerKey == payment.TypeEasyPay { + if merchantID := strings.TrimSpace(sel.Config["pid"]); merchantID != "" { + snapshot["merchant_id"] = merchantID + } + } if len(snapshot) == 1 { return nil diff --git a/backend/internal/service/payment_order_provider_snapshot.go b/backend/internal/service/payment_order_provider_snapshot.go index 31a790c7..bb60f9e2 100644 --- a/backend/internal/service/payment_order_provider_snapshot.go +++ b/backend/internal/service/payment_order_provider_snapshot.go @@ -125,3 +125,81 @@ func expectedNotificationProviderKeyForOrder(registry *payment.Registry, order * return expectedNotificationProviderKey(registry, order.PaymentType, orderProviderKey, instanceProviderKey) } + +func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey string, metadata map[string]string) error { + if order == nil || len(metadata) == 0 { + return nil + } + + snapshot := psOrderProviderSnapshot(order) + if snapshot == nil { + return nil + } + + switch strings.TrimSpace(providerKey) { + case payment.TypeWxpay: + if expected := strings.TrimSpace(snapshot.MerchantAppID); expected != "" { + actual := strings.TrimSpace(metadata["appid"]) + if actual == "" { + return fmt.Errorf("wxpay notification missing appid") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("wxpay appid mismatch: expected %s, got %s", expected, actual) + } + } + if expected := strings.TrimSpace(snapshot.MerchantID); expected != "" { + actual := strings.TrimSpace(metadata["mchid"]) + if actual == "" { + return fmt.Errorf("wxpay notification missing mchid") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("wxpay mchid mismatch: expected %s, got %s", expected, actual) + } + } + if expected := strings.TrimSpace(snapshot.Currency); expected != "" { + actual := strings.ToUpper(strings.TrimSpace(metadata["currency"])) + if actual == "" { + return fmt.Errorf("wxpay notification missing currency") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("wxpay currency mismatch: expected %s, got %s", expected, actual) + } + } + if actual := strings.TrimSpace(metadata["trade_state"]); actual != "" && !strings.EqualFold(actual, "SUCCESS") { + return fmt.Errorf("wxpay trade_state mismatch: expected SUCCESS, got %s", actual) + } + case payment.TypeAlipay: + if expected := strings.TrimSpace(snapshot.MerchantAppID); expected != "" { + actual := strings.TrimSpace(metadata["app_id"]) + if actual == "" { + return fmt.Errorf("alipay app_id missing") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("alipay app_id mismatch: expected %s, got %s", expected, actual) + } + } + case payment.TypeEasyPay: + if expected := strings.TrimSpace(snapshot.MerchantID); expected != "" { + actual := strings.TrimSpace(metadata["pid"]) + if actual == "" { + return fmt.Errorf("easypay pid missing") + } + if !strings.EqualFold(expected, actual) { + return fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual) + } + } + } + + return nil +} + +func providerMerchantIdentityMetadata(prov payment.Provider) map[string]string { + if prov == nil { + return nil + } + reporter, ok := prov.(payment.MerchantIdentityProvider) + if !ok { + return nil + } + return reporter.MerchantIdentityMetadata() +} diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go index bc6666a8..efa013b5 100644 --- a/backend/internal/service/payment_order_provider_snapshot_test.go +++ b/backend/internal/service/payment_order_provider_snapshot_test.go @@ -130,6 +130,40 @@ func TestBuildPaymentOrderProviderSnapshot_UsesWxpayJSAPIAppIDForOpenIDOrders(t require.Equal(t, "CNY", snapshot["currency"]) } +func TestBuildPaymentOrderProviderSnapshot_IncludesAlipayMerchantIdentity(t *testing.T) { + t.Parallel() + + snapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "21", + ProviderKey: payment.TypeAlipay, + Config: map[string]string{ + "appId": "alipay-app-21", + "privateKey": "secret", + }, + PaymentMode: "redirect", + }, CreateOrderRequest{}) + + require.Equal(t, "alipay-app-21", snapshot["merchant_app_id"]) + require.NotContains(t, snapshot, "privateKey") +} + +func TestBuildPaymentOrderProviderSnapshot_IncludesEasyPayMerchantIdentity(t *testing.T) { + t.Parallel() + + snapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{ + InstanceID: "66", + ProviderKey: payment.TypeEasyPay, + Config: map[string]string{ + "pid": "easypay-merchant-66", + "pkey": "secret", + }, + PaymentMode: "popup", + }, CreateOrderRequest{PaymentType: payment.TypeAlipay}) + + require.Equal(t, "easypay-merchant-66", snapshot["merchant_id"]) + require.NotContains(t, snapshot, "pkey") +} + func valueOrEmpty(v *string) string { if v == nil { return "" diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index 6883056c..7521878c 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -333,6 +333,12 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { if err != nil { return fmt.Errorf("get refund provider: %w", err) } + if err := validateProviderSnapshotMetadata(p.Order, prov.ProviderKey(), providerMerchantIdentityMetadata(prov)); err != nil { + s.writeAuditLog(ctx, p.Order.ID, "REFUND_PROVIDER_METADATA_MISMATCH", "admin", map[string]any{ + "detail": err.Error(), + }) + return err + } _, err = prov.Refund(ctx, payment.RefundRequest{ TradeNo: p.Order.PaymentTradeNo, OrderID: p.Order.OutTradeNo, diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index 95104618..ca5b62cb 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "strconv" "testing" "time" @@ -115,3 +116,71 @@ func TestPrepareRefundRejectsLegacyGuessedProviderInstance(t *testing.T) { require.Error(t, err) require.Equal(t, "REFUND_DISABLED", infraerrors.Reason(err)) } + +func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + user, err := client.User.Create(). + SetEmail("refund-snapshot-mismatch@example.com"). + SetPasswordHash("hash"). + SetUsername("refund-snapshot-mismatch-user"). + Save(ctx) + require.NoError(t, err) + + inst, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeAlipay). + SetName("alipay-refund-mismatch-instance"). + SetConfig(encryptWebhookProviderConfig(t, map[string]string{ + "appId": "runtime-alipay-app", + "privateKey": "runtime-private-key", + })). + SetSupportedTypes("alipay"). + SetEnabled(true). + SetRefundEnabled(true). + Save(ctx) + require.NoError(t, err) + + instID := strconv.FormatInt(inst.ID, 10) + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("REFUND-SNAPSHOT-MISMATCH-ORDER"). + SetOutTradeNo("sub2_refund_snapshot_mismatch_order"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo("trade-refund-snapshot-mismatch"). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusCompleted). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderInstanceID(instID). + SetProviderKey(payment.TypeAlipay). + SetProviderSnapshot(map[string]any{ + "schema_version": 2, + "provider_instance_id": instID, + "provider_key": payment.TypeAlipay, + "merchant_app_id": "expected-alipay-app", + }). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{ + entClient: client, + loadBalancer: newWebhookProviderTestLoadBalancer(client), + } + + err = svc.gwRefund(ctx, &RefundPlan{ + OrderID: order.ID, + Order: order, + RefundAmount: order.Amount, + GatewayAmount: order.Amount, + Reason: "snapshot mismatch", + }) + require.ErrorContains(t, err, "alipay app_id mismatch") +}