diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index dc93fcae..efee4cc0 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -471,11 +471,12 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) { } func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService { + var legacyKey []byte key, err := payment.ProvideEncryptionKey(h.cfg) - if err != nil { - return service.NewPaymentResumeService(nil) + if err == nil { + legacyKey = []byte(key) } - return service.NewPaymentResumeService([]byte(key)) + return service.NewLegacyAwarePaymentResumeService(legacyKey) } type completeWeChatOAuthRequest struct { diff --git a/backend/internal/handler/auth_wechat_oauth_test.go b/backend/internal/handler/auth_wechat_oauth_test.go index d303bd42..7cf114c1 100644 --- a/backend/internal/handler/auth_wechat_oauth_test.go +++ b/backend/internal/handler/auth_wechat_oauth_test.go @@ -378,6 +378,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback")) defer client.Close() handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + handler.cfg.Totp.EncryptionKeyConfigured = true recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) @@ -415,6 +416,67 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) require.Equal(t, "/purchase?from=wechat", claims.RedirectTo) } +func TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedKeysConfigured(t *testing.T) { + originalAccessTokenURL := wechatOAuthAccessTokenURL + t.Cleanup(func() { + wechatOAuthAccessTokenURL = originalAccessTokenURL + }) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/sns/oauth2/access_token") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"wechat-access","openid":"openid-mixed-key","scope":"snsapi_base"}`)) + return + } + http.NotFound(w, r) + })) + defer upstream.Close() + wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token" + + handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback")) + defer client.Close() + + legacyKeyHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + explicitSigningKey := "explicit-payment-resume-signing-key" + t.Setenv("PAYMENT_RESUME_SIGNING_KEY", explicitSigningKey) + handler.cfg.Totp.EncryptionKey = legacyKeyHex + handler.cfg.Totp.EncryptionKeyConfigured = true + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/payment/callback?code=wechat-code&state=state-mixed", nil) + req.Host = "api.example.com" + req.AddCookie(encodedCookie(wechatPaymentOAuthStateName, "state-mixed")) + req.AddCookie(encodedCookie(wechatPaymentOAuthRedirect, "/purchase?from=wechat")) + req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, `{"payment_type":"wxpay","amount":"18.8","order_type":"subscription","plan_id":9}`)) + req.AddCookie(encodedCookie(wechatPaymentOAuthScope, "snsapi_base")) + c.Request = req + + handler.WeChatPaymentOAuthCallback(c) + + require.Equal(t, http.StatusFound, recorder.Code) + location := recorder.Header().Get("Location") + parsed, err := url.Parse(location) + require.NoError(t, err) + fragment, err := url.ParseQuery(parsed.Fragment) + require.NoError(t, err) + + token := fragment.Get("wechat_resume_token") + require.NotEmpty(t, token) + + claims, err := service.NewPaymentResumeService([]byte(explicitSigningKey)).ParseWeChatPaymentResumeToken(token) + require.NoError(t, err) + require.Equal(t, "openid-mixed-key", claims.OpenID) + require.Equal(t, payment.TypeWxpay, claims.PaymentType) + require.Equal(t, "18.8", claims.Amount) + require.Equal(t, payment.OrderTypeSubscription, claims.OrderType) + require.EqualValues(t, 9, claims.PlanID) + require.Equal(t, "/purchase?from=wechat", claims.RedirectTo) + + _, err = service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")).ParseWeChatPaymentResumeToken(token) + require.Error(t, err) +} + func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *testing.T) { testCases := []struct { name string diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 4b334513..9927a265 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -204,8 +204,8 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ if err == nil { return resp, nil } - if strings.Contains(err.Error(), wxpayErrNoAuth) { - return nil, fmt.Errorf("wxpay h5 payments are not authorized for this merchant: %w", err) + if wxpayShouldFallbackToNative(err) { + return w.prepayNativeFallback(ctx, client, req, notifyURL, totalFen) } return nil, err case wxpayModeNative: @@ -292,6 +292,23 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil } +func (w *Wxpay) prepayNativeFallback(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) { + resp, err := w.prepayNative(ctx, c, req, notifyURL, totalFen) + if err != nil { + return nil, fmt.Errorf("wxpay native fallback after NO_AUTH: %w", err) + } + nativeURL := strings.TrimSpace(resp.PayURL) + if nativeURL == "" { + nativeURL = strings.TrimSpace(resp.QRCode) + } + if nativeURL == "" { + return resp, nil + } + resp.PayURL = nativeURL + resp.QRCode = nativeURL + return resp, nil +} + func buildWxpayH5Info(config map[string]string) *h5.H5Info { tp := wxpayH5Type info := &h5.H5Info{Type: &tp} @@ -304,6 +321,10 @@ func buildWxpayH5Info(config map[string]string) *h5.H5Info { return info } +func wxpayShouldFallbackToNative(err error) bool { + return err != nil && strings.Contains(err.Error(), wxpayErrNoAuth) +} + func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) { if strings.TrimSpace(req.OpenID) != "" { return wxpayModeJSAPI, nil diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index ebbd9d34..a5a406f9 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -8,6 +8,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "net/url" "strings" "testing" @@ -641,3 +642,68 @@ func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) { t.Fatalf("pay_url = %q, want redirect_url query appended", resp.PayURL) } } + +func TestCreatePaymentMobileH5FallsBackToNativeOnNoAuth(t *testing.T) { + origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment + origNativePrepay := wxpayNativePrepay + origH5Prepay := wxpayH5Prepay + t.Cleanup(func() { + wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay + wxpayNativePrepay = origNativePrepay + wxpayH5Prepay = origH5Prepay + }) + + jsapiCalls := 0 + nativeCalls := 0 + h5Calls := 0 + wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) { + jsapiCalls++ + return &jsapi.PrepayWithRequestPaymentResponse{}, nil, nil + } + wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) { + h5Calls++ + return nil, nil, errors.New("NO_AUTH") + } + wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) { + nativeCalls++ + return &native.PrepayResponse{ + CodeUrl: core.String("weixin://wxpay/bizpayurl?pr=fallback-native"), + }, nil, nil + } + + provider := &Wxpay{ + config: map[string]string{ + "appId": "wx123", + "mchId": "mch123", + }, + coreClient: &core.Client{}, + } + + resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{ + OrderID: "sub2_100", + Amount: "66.88", + PaymentType: payment.TypeWxpay, + Subject: "Balance Recharge", + NotifyURL: "https://merchant.example/payment/notify", + ClientIP: "203.0.113.10", + IsMobile: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if jsapiCalls != 0 { + t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls) + } + if h5Calls != 1 { + t.Fatalf("h5 prepay calls = %d, want 1", h5Calls) + } + if nativeCalls != 1 { + t.Fatalf("native prepay calls = %d, want 1", nativeCalls) + } + if resp.PayURL != "weixin://wxpay/bizpayurl?pr=fallback-native" { + t.Fatalf("pay_url = %q, want native fallback url", resp.PayURL) + } + if resp.QRCode != "weixin://wxpay/bizpayurl?pr=fallback-native" { + t.Fatalf("qr_code = %q, want native fallback url", resp.QRCode) + } +} diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go index d2f89b06..ff05e559 100644 --- a/backend/internal/service/payment_config_providers.go +++ b/backend/internal/service/payment_config_providers.go @@ -116,6 +116,17 @@ var providerSensitiveConfigFields = map[string]map[string]struct{}{ payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, } +// providerPendingOrderProtectedConfigFields lists config keys that cannot be +// changed while the instance has in-progress orders. This includes secrets plus +// all provider identity fields that are snapshotted into orders or used by +// webhook/refund verification. +var providerPendingOrderProtectedConfigFields = map[string]map[string]struct{}{ + payment.TypeEasyPay: {"pkey": {}, "pid": {}}, + payment.TypeAlipay: {"privatekey": {}, "publickey": {}, "alipaypublickey": {}, "appid": {}}, + payment.TypeWxpay: {"privatekey": {}, "apiv3key": {}, "publickey": {}, "appid": {}, "mpappid": {}, "mchid": {}, "publickeyid": {}, "certserial": {}}, + payment.TypeStripe: {"secretkey": {}, "webhooksecret": {}}, +} + func isSensitiveProviderConfigField(providerKey, fieldName string) bool { fields, ok := providerSensitiveConfigFields[providerKey] if !ok { @@ -125,6 +136,28 @@ func isSensitiveProviderConfigField(providerKey, fieldName string) bool { return found } +func hasPendingOrderProtectedConfigChange(providerKey string, currentConfig, nextConfig map[string]string) bool { + fields, ok := providerPendingOrderProtectedConfigFields[providerKey] + if !ok { + return false + } + for fieldName := range fields { + if providerConfigFieldValue(currentConfig, fieldName) != providerConfigFieldValue(nextConfig, fieldName) { + return true + } + } + return false +} + +func providerConfigFieldValue(config map[string]string, fieldName string) string { + for key, value := range config { + if strings.EqualFold(key, fieldName) { + return value + } + } + return "" +} + func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) { return s.entClient.PaymentOrder.Query(). Where( @@ -190,6 +223,18 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in if err != nil { return nil, fmt.Errorf("load provider instance: %w", err) } + var pendingOrderCount *int + getPendingOrderCount := func() (int, error) { + if pendingOrderCount != nil { + return *pendingOrderCount, nil + } + count, err := s.countPendingOrders(ctx, id) + if err != nil { + return 0, fmt.Errorf("check pending orders: %w", err) + } + pendingOrderCount = &count + return count, nil + } nextEnabled := current.Enabled if req.Enabled != nil { nextEnabled = *req.Enabled @@ -201,18 +246,20 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil { return nil, err } + var mergedConfig map[string]string if req.Config != nil { - hasSensitive := false - for k, v := range req.Config { - if v != "" && isSensitiveProviderConfigField(current.ProviderKey, k) { - hasSensitive = true - break - } + currentConfig, err := s.decryptConfig(current.Config) + if err != nil { + return nil, fmt.Errorf("decrypt existing config: %w", err) } - if hasSensitive { - count, err := s.countPendingOrders(ctx, id) + mergedConfig, err = s.mergeConfig(ctx, id, req.Config) + if err != nil { + return nil, err + } + if hasPendingOrderProtectedConfigChange(current.ProviderKey, currentConfig, mergedConfig) { + count, err := getPendingOrderCount() if err != nil { - return nil, fmt.Errorf("check pending orders: %w", err) + return nil, err } if count > 0 { return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders"). @@ -221,9 +268,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in } } if req.Enabled != nil && !*req.Enabled { - count, err := s.countPendingOrders(ctx, id) + count, err := getPendingOrderCount() if err != nil { - return nil, fmt.Errorf("check pending orders: %w", err) + return nil, err } if count > 0 { return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders"). @@ -237,13 +284,6 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in if req.Enabled != nil { finalEnabled = *req.Enabled } - var mergedConfig map[string]string - if req.Config != nil { - mergedConfig, err = s.mergeConfig(ctx, id, req.Config) - if err != nil { - return nil, err - } - } if finalEnabled { configToValidate := mergedConfig if configToValidate == nil { @@ -269,9 +309,9 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in } if req.SupportedTypes != nil { // Check pending orders before removing payment types - count, err := s.countPendingOrders(ctx, id) + count, err := getPendingOrderCount() if err != nil { - return nil, fmt.Errorf("check pending orders: %w", err) + return nil, err } if count > 0 { // Load current instance to compare types diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go index 51d5c7b6..e0d2908a 100644 --- a/backend/internal/service/payment_config_providers_test.go +++ b/backend/internal/service/payment_config_providers_test.go @@ -8,8 +8,13 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "strconv" "testing" + "time" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -315,10 +320,263 @@ func TestUpdateProviderInstancePersistsEnabledAndSupportedTypes(t *testing.T) { require.Equal(t, "alipay,wxpay", saved.SupportedTypes) } +func TestUpdateProviderInstanceRejectsProtectedConfigChangesWhilePendingOrders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + providerKey string + createConfig func(*testing.T) map[string]string + supportedType []string + updateConfig map[string]string + fieldName string + wantValue string + }{ + { + name: "wxpay appId", + providerKey: payment.TypeWxpay, + createConfig: validWxpayProviderConfig, + supportedType: []string{payment.TypeWxpay}, + updateConfig: map[string]string{"appId": "wx-app-updated"}, + fieldName: "appId", + wantValue: "wx-app-test", + }, + { + name: "wxpay mpAppId", + providerKey: payment.TypeWxpay, + createConfig: validWxpayProviderConfigWithJSAPIAppID, + supportedType: []string{payment.TypeWxpay}, + updateConfig: map[string]string{"mpAppId": "wx-mp-app-updated"}, + fieldName: "mpAppId", + wantValue: "wx-mp-app-test", + }, + { + name: "wxpay mchId", + providerKey: payment.TypeWxpay, + createConfig: validWxpayProviderConfig, + supportedType: []string{payment.TypeWxpay}, + updateConfig: map[string]string{"mchId": "mch-updated"}, + fieldName: "mchId", + wantValue: "mch-test", + }, + { + name: "wxpay publicKeyId", + providerKey: payment.TypeWxpay, + createConfig: validWxpayProviderConfig, + supportedType: []string{payment.TypeWxpay}, + updateConfig: map[string]string{"publicKeyId": "public-key-id-updated"}, + fieldName: "publicKeyId", + wantValue: "public-key-id-test", + }, + { + name: "wxpay certSerial", + providerKey: payment.TypeWxpay, + createConfig: validWxpayProviderConfig, + supportedType: []string{payment.TypeWxpay}, + updateConfig: map[string]string{"certSerial": "cert-serial-updated"}, + fieldName: "certSerial", + wantValue: "cert-serial-test", + }, + { + name: "alipay appId", + providerKey: payment.TypeAlipay, + createConfig: validAlipayProviderConfig, + supportedType: []string{payment.TypeAlipay}, + updateConfig: map[string]string{"appId": "alipay-app-updated"}, + fieldName: "appId", + wantValue: "alipay-app-test", + }, + { + name: "easypay pid", + providerKey: payment.TypeEasyPay, + createConfig: validEasyPayProviderConfig, + supportedType: []string{payment.TypeAlipay}, + updateConfig: map[string]string{"pid": "pid-updated"}, + fieldName: "pid", + wantValue: "pid-test", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := &PaymentConfigService{ + entClient: client, + encryptionKey: []byte("0123456789abcdef0123456789abcdef"), + } + + instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{ + ProviderKey: tc.providerKey, + Name: "protected-config-instance", + Config: tc.createConfig(t), + SupportedTypes: tc.supportedType, + Enabled: true, + }) + require.NoError(t, err) + + createPendingProviderConfigOrder(t, ctx, client, instance) + + updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{ + Config: tc.updateConfig, + }) + require.Nil(t, updated) + require.Error(t, err) + require.Equal(t, "PENDING_ORDERS", infraerrors.Reason(err)) + + saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID) + require.NoError(t, err) + cfg, err := svc.decryptConfig(saved.Config) + require.NoError(t, err) + require.Equal(t, tc.wantValue, cfg[tc.fieldName]) + }) + } +} + +func TestUpdateProviderInstanceAllowsSafeConfigChangesWhilePendingOrders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + providerKey string + createConfig func(*testing.T) map[string]string + supportedType []string + updateConfig map[string]string + fieldName string + wantValue string + }{ + { + name: "wxpay notifyUrl", + providerKey: payment.TypeWxpay, + createConfig: validWxpayProviderConfig, + supportedType: []string{payment.TypeWxpay}, + updateConfig: map[string]string{"notifyUrl": "https://merchant.example.com/wxpay/notify-v2"}, + fieldName: "notifyUrl", + wantValue: "https://merchant.example.com/wxpay/notify-v2", + }, + { + name: "alipay same appId", + providerKey: payment.TypeAlipay, + createConfig: validAlipayProviderConfig, + supportedType: []string{payment.TypeAlipay}, + updateConfig: map[string]string{"appId": "alipay-app-test"}, + fieldName: "appId", + wantValue: "alipay-app-test", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := &PaymentConfigService{ + entClient: client, + encryptionKey: []byte("0123456789abcdef0123456789abcdef"), + } + + instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{ + ProviderKey: tc.providerKey, + Name: "safe-config-instance", + Config: tc.createConfig(t), + SupportedTypes: tc.supportedType, + Enabled: true, + }) + require.NoError(t, err) + + createPendingProviderConfigOrder(t, ctx, client, instance) + + updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{ + Config: tc.updateConfig, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID) + require.NoError(t, err) + cfg, err := svc.decryptConfig(saved.Config) + require.NoError(t, err) + require.Equal(t, tc.wantValue, cfg[tc.fieldName]) + }) + } +} + +func createPendingProviderConfigOrder(t *testing.T, ctx context.Context, client *dbent.Client, instance *dbent.PaymentProviderInstance) { + t.Helper() + + user, err := client.User.Create(). + SetEmail("provider-config-pending@example.com"). + SetPasswordHash("hash"). + SetUsername("provider-config-pending-user"). + Save(ctx) + require.NoError(t, err) + + instanceID := strconv.FormatInt(instance.ID, 10) + _, err = client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("PENDING-PROVIDER-CONFIG-" + instanceID). + SetOutTradeNo("sub2_pending_provider_config_" + instanceID). + SetPaymentType(providerPendingOrderPaymentType(instance.ProviderKey)). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + SetProviderInstanceID(instanceID). + SetProviderKey(instance.ProviderKey). + Save(ctx) + require.NoError(t, err) +} + +func providerPendingOrderPaymentType(providerKey string) string { + switch providerKey { + case payment.TypeWxpay: + return payment.TypeWxpay + case payment.TypeAlipay: + return payment.TypeAlipay + default: + return payment.TypeAlipay + } +} + func boolPtrValue(v bool) *bool { return &v } +func validAlipayProviderConfig(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "appId": "alipay-app-test", + "privateKey": "alipay-private-key-test", + "notifyUrl": "https://merchant.example.com/alipay/notify", + "returnUrl": "https://merchant.example.com/alipay/return", + } +} + +func validEasyPayProviderConfig(t *testing.T) map[string]string { + t.Helper() + + return map[string]string{ + "pid": "pid-test", + "pkey": "pkey-test", + "apiBase": "https://pay.example.com", + "notifyUrl": "https://merchant.example.com/easypay/notify", + "returnUrl": "https://merchant.example.com/easypay/return", + } +} + func validWxpayProviderConfig(t *testing.T) map[string]string { t.Helper() @@ -340,3 +598,11 @@ func validWxpayProviderConfig(t *testing.T) map[string]string { "certSerial": "cert-serial-test", } } + +func validWxpayProviderConfigWithJSAPIAppID(t *testing.T) map[string]string { + t.Helper() + + cfg := validWxpayProviderConfig(t) + cfg["mpAppId"] = "wx-mp-app-test" + return cfg +} diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 59a2221e..7e0adc2d 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -387,6 +387,45 @@ func TestPaymentServiceParseWeChatPaymentResumeTokenAcceptsLegacyEncryptionKeyDu } } +func TestNewConfiguredPaymentResumeServicePrefersExplicitSigningKeyAndKeepsLegacyVerificationFallback(t *testing.T) { + t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "explicit-payment-resume-signing-key") + + legacyKey := []byte("0123456789abcdef0123456789abcdef") + svc := newLegacyAwarePaymentResumeService(legacyKey) + + explicitToken, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{ + OpenID: "openid-explicit-key", + PaymentType: payment.TypeWxpay, + }) + if err != nil { + t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err) + } + + explicitClaims, err := NewPaymentResumeService([]byte("explicit-payment-resume-signing-key")).ParseWeChatPaymentResumeToken(explicitToken) + if err != nil { + t.Fatalf("ParseWeChatPaymentResumeToken returned error: %v", err) + } + if explicitClaims.OpenID != "openid-explicit-key" { + t.Fatalf("openid = %q, want %q", explicitClaims.OpenID, "openid-explicit-key") + } + + legacyToken, err := NewPaymentResumeService(legacyKey).CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{ + OpenID: "openid-legacy-key", + PaymentType: payment.TypeWxpay, + }) + if err != nil { + t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err) + } + + legacyClaims, err := svc.ParseWeChatPaymentResumeToken(legacyToken) + if err != nil { + t.Fatalf("ParseWeChatPaymentResumeToken returned error: %v", err) + } + if legacyClaims.OpenID != "openid-legacy-key" { + t.Fatalf("openid = %q, want %q", legacyClaims.OpenID, "openid-legacy-key") + } +} + func TestNormalizeVisibleMethodSource(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 159f97d3..d39d2b19 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -268,8 +268,16 @@ func (s *PaymentService) paymentResume() *PaymentResumeService { return psNewPaymentResumeService(s.configService) } +func NewLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService { + return newLegacyAwarePaymentResumeService(legacyKey) +} + func psNewPaymentResumeService(configService *PaymentConfigService) *PaymentResumeService { - signingKey, verifyFallbacks := psResumeSigningKeys(configService) + return newLegacyAwarePaymentResumeService(psResumeLegacyVerificationKey(configService)) +} + +func newLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService { + signingKey, verifyFallbacks := resolvePaymentResumeSigningKeys(legacyKey) return NewPaymentResumeService(signingKey, verifyFallbacks...) } @@ -279,8 +287,18 @@ func psResumeSigningKey(configService *PaymentConfigService) []byte { } func psResumeSigningKeys(configService *PaymentConfigService) ([]byte, [][]byte) { + return resolvePaymentResumeSigningKeys(psResumeLegacyVerificationKey(configService)) +} + +func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte { + if configService == nil { + return nil + } + return configService.encryptionKey +} + +func resolvePaymentResumeSigningKeys(legacyKey []byte) ([]byte, [][]byte) { signingKey := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv)) - legacyKey := psResumeLegacyVerificationKey(configService) if len(signingKey) == 0 { if len(legacyKey) == 0 { return nil, nil @@ -293,13 +311,6 @@ func psResumeSigningKeys(configService *PaymentConfigService) ([]byte, [][]byte) return signingKey, [][]byte{legacyKey} } -func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte { - if configService == nil { - return nil - } - return configService.encryptionKey -} - func parsePaymentResumeSigningKey(raw string) []byte { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/backend/migrations/120_enforce_payment_orders_out_trade_no_unique_notx.sql b/backend/migrations/120_enforce_payment_orders_out_trade_no_unique_notx.sql index 00836698..638d8622 100644 --- a/backend/migrations/120_enforce_payment_orders_out_trade_no_unique_notx.sql +++ b/backend/migrations/120_enforce_payment_orders_out_trade_no_unique_notx.sql @@ -1,4 +1,6 @@ -- Build the payment order uniqueness guarantee online. +-- The migration runner performs an explicit duplicate out_trade_no precheck and +-- drops any stale invalid paymentorder_out_trade_no_unique index before retrying. -- Create the new partial unique index concurrently first so writes keep flowing, -- then remove the legacy index name once the replacement is ready. CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index dcb0bb9c..6a95d335 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -63,6 +63,8 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) { require.NoError(t, err) followupSQL := string(followupContent) + require.Contains(t, followupSQL, "explicit duplicate out_trade_no precheck") + require.Contains(t, followupSQL, "stale invalid paymentorder_out_trade_no_unique index") require.Contains(t, followupSQL, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique") require.NotContains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique") require.Contains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no") @@ -76,6 +78,18 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) { require.Contains(t, alignmentSQL, "RENAME TO paymentorder_out_trade_no") } +func TestMigration110SeedsAuthSourceSignupGrantsDisabledByDefault(t *testing.T) { + content, err := FS.ReadFile("110_pending_auth_and_provider_default_grants.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "('auth_source_default_email_grant_on_signup', 'false')") + require.Contains(t, sql, "('auth_source_default_linuxdo_grant_on_signup', 'false')") + require.Contains(t, sql, "('auth_source_default_oidc_grant_on_signup', 'false')") + require.Contains(t, sql, "('auth_source_default_wechat_grant_on_signup', 'false')") + require.NotContains(t, sql, "('auth_source_default_email_grant_on_signup', 'true')") +} + func TestMigration122ScrubsPendingOAuthCompletionTokensAtRest(t *testing.T) { content, err := FS.ReadFile("122_pending_auth_completion_token_cleanup.sql") require.NoError(t, err) @@ -94,7 +108,10 @@ func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T) require.NoError(t, err) sql := string(content) - require.Contains(t, sql, "Intentionally left as a no-op") - require.NotContains(t, sql, "UPDATE settings") - require.NotContains(t, sql, "value = 'false'") + require.Contains(t, sql, "110_pending_auth_and_provider_default_grants.sql") + require.Contains(t, sql, "schema_migrations") + require.Contains(t, sql, "updated_at") + require.Contains(t, sql, "'_grant_on_signup'") + require.Contains(t, sql, "value = 'false'") + require.Contains(t, sql, "auth_identity_migration_reports") } diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index b75d75df..f8ed37d9 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -291,6 +291,7 @@ onMounted(async () => { const routeOrderId = Number(readRouteQueryString('order_id')) || 0 let outTradeNo = readRouteQueryString('out_trade_no') let orderId = 0 + let resumeTokenLookupFailed = false const restored = restoreRecoverySnapshot({ resumeToken, @@ -312,24 +313,17 @@ onMounted(async () => { orderId = resolvedOrder.id } } else if (routeOrderId > 0) { + resumeTokenLookupFailed = true orderId = routeOrderId + } else { + resumeTokenLookupFailed = true } } else if (routeOrderId > 0) { orderId = routeOrderId } const hasLegacyFallbackContext = readRouteQueryString('trade_status').trim() !== '' - const shouldUsePublicOutTradeNo = !resumeToken && outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0) - - if (!order.value && shouldUsePublicOutTradeNo) { - const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo) - if (legacyOrder) { - order.value = legacyOrder - if (!orderId) { - orderId = legacyOrder.id - } - } - } + const shouldUsePublicOutTradeNo = outTradeNo !== '' && (hasLegacyFallbackContext || routeOrderId > 0 || orderId > 0) if (!order.value && orderId && (!resumeToken || routeOrderId > 0)) { try { @@ -339,7 +333,17 @@ onMounted(async () => { } } - if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) { + if (!order.value && shouldUsePublicOutTradeNo && (!resumeToken || resumeTokenLookupFailed)) { + const legacyOrder = await resolveOrderFromOutTradeNo(outTradeNo) + if (legacyOrder) { + order.value = legacyOrder + if (!orderId) { + orderId = legacyOrder.id + } + } + } + + if (!order.value && !orderId && outTradeNo && hasLegacyFallbackContext) { returnInfo.value = { outTradeNo, money: String(route.query.money || ''), @@ -350,17 +354,24 @@ onMounted(async () => { const refreshOrder = async (): Promise => { if (resumeToken) { - return await resolveOrderFromResumeToken(resumeToken) + const resolvedOrder = await resolveOrderFromResumeToken(resumeToken) + if (resolvedOrder) { + return resolvedOrder + } + } + + if (orderId) { + try { + return await paymentStore.pollOrderStatus(orderId) + } catch (_err: unknown) { + // Fall through to legacy public verification when order polling is unavailable. + } } if (shouldUsePublicOutTradeNo) { return await resolveOrderFromOutTradeNo(outTradeNo) } - if (orderId) { - return await paymentStore.pollOrderStatus(orderId) - } - return null } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 05d70512..1040d3f6 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -740,18 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n return } if (decision.kind === 'wechat_jsapi' && decision.jsapi) { - const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record) - const errMsg = String(jsapiResult.err_msg || '').toLowerCase() - if (errMsg.includes('cancel')) { - appStore.showInfo(t('payment.qr.cancelled')) + try { + const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record) + const errMsg = String(jsapiResult.err_msg || '').toLowerCase() + if (errMsg.includes('cancel')) { + appStore.showInfo(t('payment.qr.cancelled')) + resetPayment() + } else if (errMsg && !errMsg.includes('ok')) { + applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod) + resetPayment() + } else { + const resultState = { ...decision.paymentState } + resetPayment() + await redirectToPaymentResult(resultState) + } + } catch (err: unknown) { resetPayment() - } else if (errMsg && !errMsg.includes('ok')) { - applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod) - resetPayment() - } else { - const resultState = { ...decision.paymentState } - resetPayment() - await redirectToPaymentResult(resultState) + throw err } return } diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 81a7ccf0..556cf390 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -255,14 +255,21 @@ describe('PaymentResultView', () => { expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) - it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => { + it('falls back to public out_trade_no verification when resume_token recovery fails in legacy return flows', async () => { routeState.query = { resume_token: 'resume-fail', out_trade_no: 'legacy-should-not-run', trade_status: 'TRADE_SUCCESS', } resolveOrderPublicByResumeToken.mockRejectedValueOnce(new Error('resume failed')) - mount(PaymentResultView, { + verifyOrderPublic.mockResolvedValueOnce({ + data: { + ...orderFactory('PAID'), + out_trade_no: 'legacy-should-not-run', + }, + }) + + const wrapper = mount(PaymentResultView, { global: { stubs: { OrderStatusBadge: true, @@ -273,7 +280,9 @@ describe('PaymentResultView', () => { await flushPromises() expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-fail') - expect(verifyOrderPublic).not.toHaveBeenCalled() + expect(verifyOrderPublic).toHaveBeenCalledWith('legacy-should-not-run') + expect(pollOrderStatus).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('payment.result.success') }) it('ignores a stale global recovery snapshot when legacy return markers do not identify the order', async () => { diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index 2b81a085..d2683161 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -252,6 +252,33 @@ describe('PaymentView WeChat JSAPI flow', () => { expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) + it('clears stale recovery state when JSAPI never becomes available', async () => { + vi.useFakeTimers() + createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-missing-bridge')) + ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined + + const wrapper = shallowMount(PaymentView, { + global: { + stubs: { + Teleport: true, + Transition: false, + }, + }, + }) + + await flushPromises() + await vi.advanceTimersByTimeAsync(4000) + await flushPromises() + await flushPromises() + + expect(showError).toHaveBeenCalledWith( + 'payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint', + ) + expect(routerPush).not.toHaveBeenCalled() + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() + expect(wrapper.html()).not.toContain('payment-status-panel-stub') + }) + it('clears a stale recovery snapshot before handling wechat resume callback params', async () => { createOrder.mockRejectedValueOnce(new Error('resume failed')) window.localStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify({