diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index d340a8a6..c6b45ab8 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -304,8 +304,8 @@ type UpdateSettingsRequest struct { OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` - OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"` - OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"` + OIDCConnectUsePKCE *bool `json:"oidc_connect_use_pkce"` + OIDCConnectValidateIDToken *bool `json:"oidc_connect_validate_id_token"` OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` @@ -682,6 +682,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } // Generic OIDC 参数验证 + oidcUsePKCE := previousSettings.OIDCConnectUsePKCE + oidcValidateIDToken := previousSettings.OIDCConnectValidateIDToken if req.OIDCConnectEnabled { req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID) @@ -716,11 +718,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath)) req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath)) req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath)) - if !req.OIDCConnectUsePKCE { - req.OIDCConnectUsePKCE = previousSettings.OIDCConnectUsePKCE + if req.OIDCConnectUsePKCE != nil { + oidcUsePKCE = *req.OIDCConnectUsePKCE } - if !req.OIDCConnectValidateIDToken { - req.OIDCConnectValidateIDToken = previousSettings.OIDCConnectValidateIDToken + if req.OIDCConnectValidateIDToken != nil { + oidcValidateIDToken = *req.OIDCConnectValidateIDToken } if req.OIDCConnectClockSkewSeconds == 0 { req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds @@ -795,7 +797,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600") return } - if req.OIDCConnectValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" { + if oidcValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" { response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true") return } @@ -1076,8 +1078,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: req.OIDCConnectUsePKCE, - OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken, + OIDCConnectUsePKCE: oidcUsePKCE, + OIDCConnectValidateIDToken: oidcValidateIDToken, OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, diff --git a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go index cef531e0..8045d0c9 100644 --- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go +++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go @@ -247,6 +247,94 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS require.Equal(t, true, data["openai_advanced_scheduler_enabled"]) } +func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{ + values: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + service.SettingPaymentVisibleMethodAlipayEnabled: "true", + service.SettingPaymentVisibleMethodAlipaySource: "", + service.SettingPaymentVisibleMethodWxpayEnabled: "false", + service.SettingPaymentVisibleMethodWxpaySource: "", + }, + } + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + + body := map[string]any{ + "promo_code_enabled": false, + } + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "", repo.values[service.SettingPaymentVisibleMethodAlipaySource]) + require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled]) +} + +func TestSettingHandler_UpdateSettings_PersistsExplicitFalseOIDCCompatibilityFlags(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{ + values: map[string]string{ + service.SettingKeyPromoCodeEnabled: "true", + service.SettingKeyOIDCConnectEnabled: "true", + service.SettingKeyOIDCConnectProviderName: "OIDC", + service.SettingKeyOIDCConnectClientID: "oidc-client", + service.SettingKeyOIDCConnectClientSecret: "oidc-secret", + service.SettingKeyOIDCConnectIssuerURL: "https://issuer.example.com", + service.SettingKeyOIDCConnectAuthorizeURL: "https://issuer.example.com/auth", + service.SettingKeyOIDCConnectTokenURL: "https://issuer.example.com/token", + service.SettingKeyOIDCConnectUserInfoURL: "https://issuer.example.com/userinfo", + service.SettingKeyOIDCConnectJWKSURL: "https://issuer.example.com/jwks", + service.SettingKeyOIDCConnectScopes: "openid email profile", + service.SettingKeyOIDCConnectRedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback", + service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", + service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", + service.SettingKeyOIDCConnectUsePKCE: "true", + service.SettingKeyOIDCConnectValidateIDToken: "true", + service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256", + service.SettingKeyOIDCConnectClockSkewSeconds: "120", + }, + } + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + + body := map[string]any{ + "promo_code_enabled": true, + "oidc_connect_enabled": true, + "oidc_connect_use_pkce": false, + "oidc_connect_validate_id_token": false, + "oidc_connect_allowed_signing_algs": "", + } + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "false", repo.values[service.SettingKeyOIDCConnectUsePKCE]) + require.Equal(t, "false", repo.values[service.SettingKeyOIDCConnectValidateIDToken]) + + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["oidc_connect_use_pkce"]) + require.Equal(t, false, data["oidc_connect_validate_id_token"]) +} + func TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(t *testing.T) { gin.SetMode(gin.TestMode) repo := &settingHandlerRepoStub{ diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index edc85226..662a3972 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -62,10 +62,13 @@ type migrationChecksumCompatibilityRule struct { // 规则必须同时匹配「迁移名 + 数据库 checksum + 当前文件 checksum」且两者都落在该迁移的已知版本集合内才会放行, // 避免放宽全局校验,也允许将误改的历史 migration 回滚为已发布版本而不要求人工修 checksum。 var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibilityRule{ - "054_drop_legacy_cache_columns.sql": newMigrationChecksumCompatibilityRule("82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d", "182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4"), - "061_add_usage_log_request_type.sql": newMigrationChecksumCompatibilityRule("66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c", "08a248652cbab7cfde147fc6ef8cda464f2477674e20b718312faa252e0481c0", "222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3"), - "109_auth_identity_compat_backfill.sql": newMigrationChecksumCompatibilityRule("2b380305e73ff0c13aa8c811e45897f2b36ca4a438f7b3e8f98e19ecb6bae0b3", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee"), - "119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"), + "054_drop_legacy_cache_columns.sql": newMigrationChecksumCompatibilityRule("82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d", "182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4"), + "061_add_usage_log_request_type.sql": newMigrationChecksumCompatibilityRule("66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c", "08a248652cbab7cfde147fc6ef8cda464f2477674e20b718312faa252e0481c0", "222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3"), + "109_auth_identity_compat_backfill.sql": newMigrationChecksumCompatibilityRule("2b380305e73ff0c13aa8c811e45897f2b36ca4a438f7b3e8f98e19ecb6bae0b3", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee"), + "118_wechat_dual_mode_and_auth_source_defaults.sql": newMigrationChecksumCompatibilityRule("b54194d7a3e4fbf710e0a3590d22a2fe7966804c487052a356e0b55f53ef96b0", "e0cdf835d6c688d64100f483d31bc02ac9ebad414bf1837af239a84bf75b8227"), + "119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"), + "120_enforce_payment_orders_out_trade_no_unique_notx.sql": newMigrationChecksumCompatibilityRule("707431450603e70a43ce9fbd61e0c12fa67da4875158ccefabacea069587ab22", "04b082b5a239c525154fe9185d324ee2b05ff90da9297e10dba19f9be79aa59a"), + "123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"), } // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 diff --git a/backend/internal/repository/migrations_runner_extra_test.go b/backend/internal/repository/migrations_runner_extra_test.go index 9f8a94c6..af1adc50 100644 --- a/backend/internal/repository/migrations_runner_extra_test.go +++ b/backend/internal/repository/migrations_runner_extra_test.go @@ -94,6 +94,19 @@ func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) { require.True(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum)) } +func TestMigrationChecksumCompatibilityRules_CoverEditedUpgradeCompatibilityMigrations(t *testing.T) { + for _, name := range []string{ + "118_wechat_dual_mode_and_auth_source_defaults.sql", + "120_enforce_payment_orders_out_trade_no_unique_notx.sql", + "123_fix_legacy_auth_source_grant_on_signup_defaults.sql", + } { + rule, ok := migrationChecksumCompatibilityRules[name] + require.Truef(t, ok, "missing compatibility rule for %s", name) + require.NotEmpty(t, rule.fileChecksum) + require.NotEmpty(t, rule.acceptedDBChecksum) + } +} + func TestEnsureAtlasBaselineAligned(t *testing.T) { t.Run("skip_when_no_legacy_table", func(t *testing.T) { db, mock, err := sqlmock.New() diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go index e44bf2e7..973c601a 100644 --- a/backend/internal/service/payment_config_limits.go +++ b/backend/internal/service/payment_config_limits.go @@ -45,10 +45,18 @@ func (s *PaymentConfigService) pcApplyEnabledVisibleMethodInstances(ctx context. for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} { matching := filterEnabledVisibleMethodInstances(instances, method) providerKey, err := s.resolveVisibleMethodProviderKey(ctx, method, matching) - if err != nil || providerKey == "" { + if err != nil { delete(filtered, method) continue } + if providerKey == "" { + if len(matching) == 0 { + delete(filtered, method) + continue + } + filtered[method] = matching + continue + } selectedInstances := filterVisibleMethodInstancesByProviderKey(instances, method, providerKey) if len(selectedInstances) == 0 { delete(filtered, method) diff --git a/backend/internal/service/payment_config_limits_test.go b/backend/internal/service/payment_config_limits_test.go index 12cd6866..4df506d6 100644 --- a/backend/internal/service/payment_config_limits_test.go +++ b/backend/internal/service/payment_config_limits_test.go @@ -6,6 +6,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" ) func TestUnionFloat(t *testing.T) { @@ -402,3 +403,59 @@ func TestGetAvailableMethodLimitsUsesConfiguredVisibleMethodSource(t *testing.T) }) } } + +func TestGetAvailableMethodLimitsPreservesLegacyCrossProviderBehaviorWhenVisibleMethodSourceMissing(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + _, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeAlipay). + SetName("Official Alipay"). + SetConfig("{}"). + SetSupportedTypes("alipay"). + SetLimits(`{"alipay":{"singleMin":10,"singleMax":100}}`). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + _, err = client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeEasyPay). + SetName("EasyPay Mixed"). + SetConfig("{}"). + SetSupportedTypes("alipay,wxpay"). + SetLimits(`{"alipay":{"singleMin":20,"singleMax":200},"wxpay":{"singleMin":40,"singleMax":400}}`). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + _, err = client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeWxpay). + SetName("Official WeChat"). + SetConfig("{}"). + SetSupportedTypes("wxpay"). + SetLimits(`{"wxpay":{"singleMin":30,"singleMax":300}}`). + SetEnabled(true). + Save(ctx) + require.NoError(t, err) + + svc := &PaymentConfigService{ + entClient: client, + settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{}}, + } + + resp, err := svc.GetAvailableMethodLimits(ctx) + require.NoError(t, err) + + alipayLimits, ok := resp.Methods[payment.TypeAlipay] + require.True(t, ok, "expected alipay limits to remain visible") + require.Equal(t, 10.0, alipayLimits.SingleMin) + require.Equal(t, 200.0, alipayLimits.SingleMax) + + wxpayLimits, ok := resp.Methods[payment.TypeWxpay] + require.True(t, ok, "expected wxpay limits to remain visible") + require.Equal(t, 30.0, wxpayLimits.SingleMin) + require.Equal(t, 400.0, wxpayLimits.SingleMax) + + require.Equal(t, 10.0, resp.GlobalMin) + require.Equal(t, 400.0, resp.GlobalMax) +} diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go index 9e756971..59a2221e 100644 --- a/backend/internal/service/payment_resume_service_test.go +++ b/backend/internal/service/payment_resume_service_test.go @@ -586,7 +586,60 @@ func TestVisibleMethodLoadBalancerUsesConfiguredSourceWhenMultipleProvidersEnabl } } -func TestVisibleMethodLoadBalancerRejectsMissingOrInvalidSourceWhenMultipleProvidersEnabled(t *testing.T) { +func TestVisibleMethodLoadBalancerPreservesLegacyCrossProviderRoutingWhenSourceMissing(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + + _, err := client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeAlipay). + SetName("Official Alipay"). + SetConfig("{}"). + SetSupportedTypes("alipay"). + SetEnabled(true). + SetSortOrder(1). + Save(ctx) + if err != nil { + t.Fatalf("create official provider: %v", err) + } + + _, err = client.PaymentProviderInstance.Create(). + SetProviderKey(payment.TypeEasyPay). + SetName("EasyPay Alipay"). + SetConfig("{}"). + SetSupportedTypes("alipay"). + SetEnabled(true). + SetSortOrder(2). + Save(ctx) + if err != nil { + t.Fatalf("create easypay provider: %v", err) + } + + inner := &captureLoadBalancer{} + configService := &PaymentConfigService{ + entClient: client, + settingRepo: &paymentConfigSettingRepoStub{ + values: map[string]string{ + visibleMethodSourceSettingKey(payment.TypeAlipay): "", + }, + }, + } + lb := newVisibleMethodLoadBalancer(inner, configService) + + _, err = lb.SelectInstance(ctx, "", payment.TypeAlipay, payment.StrategyRoundRobin, 9.9) + if err != nil { + t.Fatalf("SelectInstance returned error: %v", err) + } + if inner.lastProviderKey != "" { + t.Fatalf("lastProviderKey = %q, want legacy cross-provider empty key", inner.lastProviderKey) + } + if inner.lastPaymentType != payment.TypeAlipay { + t.Fatalf("lastPaymentType = %q, want %q", inner.lastPaymentType, payment.TypeAlipay) + } +} + +func TestVisibleMethodLoadBalancerRejectsInvalidSourceWhenMultipleProvidersEnabled(t *testing.T) { t.Parallel() tests := []struct { @@ -595,12 +648,6 @@ func TestVisibleMethodLoadBalancerRejectsMissingOrInvalidSourceWhenMultipleProvi sourceValue string wantMessage string }{ - { - name: "missing alipay source", - method: payment.TypeAlipay, - sourceValue: "", - wantMessage: "alipay source is required when the visible method is enabled", - }, { name: "invalid wxpay source", method: payment.TypeWxpay, diff --git a/backend/internal/service/payment_visible_method_instances.go b/backend/internal/service/payment_visible_method_instances.go index 86ea5ead..5dcdab16 100644 --- a/backend/internal/service/payment_visible_method_instances.go +++ b/backend/internal/service/payment_visible_method_instances.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" @@ -166,15 +167,21 @@ func (s *PaymentConfigService) resolveVisibleMethodSourceProviderKey(ctx context if s != nil && s.settingRepo != nil && sourceKey != "" { value, err := s.settingRepo.GetValue(ctx, sourceKey) if err != nil { - return "", fmt.Errorf("get %s: %w", sourceKey, err) + if !errors.Is(err, ErrSettingNotFound) { + return "", fmt.Errorf("get %s: %w", sourceKey, err) + } + } else { + rawSource = value } - rawSource = value } normalizedSource, err := normalizeVisibleMethodSettingSource(method, rawSource, true) if err != nil { return "", err } + if normalizedSource == "" { + return "", nil + } providerKey, ok := VisibleMethodProviderKeyForSource(method, normalizedSource) if !ok { return "", infraerrors.BadRequest( @@ -200,6 +207,9 @@ func (s *PaymentConfigService) resolveVisibleMethodProviderKey( if err != nil { return "", err } + if providerKey == "" { + return "", nil + } selected := selectVisibleMethodInstanceByProviderKey(matching, providerKey) if selected == nil { return "", infraerrors.BadRequest( @@ -237,5 +247,11 @@ func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance( if err != nil { return nil, err } + if providerKey == "" { + if len(matching) == 0 { + return nil, nil + } + return &dbent.PaymentProviderInstance{ProviderKey: ""}, nil + } return selectVisibleMethodInstanceByProviderKey(matching, providerKey), nil } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 72569882..f08274c7 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -282,7 +282,19 @@ func mergeWeChatConnectCapabilitySettings(settings map[string]string, base confi mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" if openConfigured || mpConfigured || mobileConfigured { - return parseWeChatConnectCapabilitySettings(settings, enabled, mode) + openEnabled := strings.TrimSpace(rawOpen) == "true" + mpEnabled := strings.TrimSpace(rawMP) == "true" + mobileEnabled := strings.TrimSpace(rawMobile) == "true" + _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled] + if !enabledConfigured && + enabled && + !openEnabled && + !mpEnabled && + !mobileEnabled && + (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) { + return base.OpenEnabled, base.MPEnabled, base.MobileEnabled + } + return openEnabled, mpEnabled, mobileEnabled } if !enabled { return false, false, false @@ -1921,14 +1933,9 @@ func isFalseSettingValue(value string) bool { } func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) { + _ = enabled source = strings.TrimSpace(source) if source == "" { - if enabled { - return "", infraerrors.BadRequest( - "INVALID_PAYMENT_VISIBLE_METHOD_SOURCE", - fmt.Sprintf("%s source is required when the visible method is enabled", method), - ) - } return "", nil } diff --git a/backend/internal/service/setting_service_wechat_config_test.go b/backend/internal/service/setting_service_wechat_config_test.go index 08f67b7c..a2de614b 100644 --- a/backend/internal/service/setting_service_wechat_config_test.go +++ b/backend/internal/service/setting_service_wechat_config_test.go @@ -109,6 +109,36 @@ func TestSettingService_GetWeChatConnectOAuthConfig_FallsBackToConfigWhenDatabas require.Empty(t, got.RedirectURL) } +func TestSettingService_GetWeChatConnectOAuthConfig_IgnoresSyntheticDisabledCapabilitiesFromMigration118(t *testing.T) { + repo := &settingWeChatRepoStub{ + values: map[string]string{ + SettingKeyWeChatConnectOpenEnabled: "false", + SettingKeyWeChatConnectMPEnabled: "false", + }, + } + svc := NewSettingService(repo, &config.Config{ + WeChat: config.WeChatConnectConfig{ + Enabled: true, + OpenEnabled: true, + MPEnabled: true, + Mode: "open", + OpenAppID: "wx-open-config", + OpenAppSecret: "wx-open-secret", + MPAppID: "wx-mp-config", + MPAppSecret: "wx-mp-secret", + FrontendRedirectURL: "/auth/wechat/config-callback", + }, + }) + + got, err := svc.GetWeChatConnectOAuthConfig(context.Background()) + require.NoError(t, err) + require.True(t, got.Enabled) + require.True(t, got.OpenEnabled) + require.True(t, got.MPEnabled) + require.Equal(t, "wx-open-config", got.AppIDForMode("open")) + require.Equal(t, "wx-mp-config", got.AppIDForMode("mp")) +} + func TestSettingService_ParseSettings_FallsBackToConfigForWeChatAdminView(t *testing.T) { svc := NewSettingService(&settingWeChatRepoStub{values: map[string]string{}}, &config.Config{ WeChat: config.WeChatConnectConfig{ diff --git a/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql b/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql index 9b037984..18782617 100644 --- a/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql +++ b/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql @@ -3,6 +3,7 @@ VALUES ( 'wechat_connect_open_enabled', CASE + WHEN NOT EXISTS (SELECT 1 FROM settings WHERE key = 'wechat_connect_enabled') THEN '' WHEN COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_enabled'), 'false') <> 'true' THEN 'false' WHEN LOWER(TRIM(COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_mode'), 'open'))) = 'mp' THEN 'false' ELSE 'true' @@ -11,6 +12,7 @@ VALUES ( 'wechat_connect_mp_enabled', CASE + WHEN NOT EXISTS (SELECT 1 FROM settings WHERE key = 'wechat_connect_enabled') THEN '' WHEN COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_enabled'), 'false') <> 'true' THEN 'false' WHEN LOWER(TRIM(COALESCE((SELECT value FROM settings WHERE key = 'wechat_connect_mode'), 'open'))) = 'mp' THEN 'true' ELSE 'false' 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 fe47698d..00836698 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,8 +1,6 @@ -- Build the payment order uniqueness guarantee online. -- Create the new partial unique index concurrently first so writes keep flowing, -- then remove the legacy index name once the replacement is ready. -DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique; - CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique ON payment_orders (out_trade_no) WHERE out_trade_no <> ''; diff --git a/backend/migrations/123_fix_legacy_auth_source_grant_on_signup_defaults.sql b/backend/migrations/123_fix_legacy_auth_source_grant_on_signup_defaults.sql index f6053ef0..094b223c 100644 --- a/backend/migrations/123_fix_legacy_auth_source_grant_on_signup_defaults.sql +++ b/backend/migrations/123_fix_legacy_auth_source_grant_on_signup_defaults.sql @@ -1,39 +1,3 @@ -WITH migration_110 AS ( - SELECT applied_at - FROM schema_migrations - WHERE filename = '110_pending_auth_and_provider_default_grants.sql' -), -legacy_provider_defaults AS ( - SELECT provider_type - FROM ( - VALUES ('email'), ('linuxdo'), ('oidc'), ('wechat') - ) AS providers(provider_type) - CROSS JOIN migration_110 - JOIN settings balance - ON balance.key = 'auth_source_default_' || providers.provider_type || '_balance' - JOIN settings concurrency - ON concurrency.key = 'auth_source_default_' || providers.provider_type || '_concurrency' - JOIN settings subscriptions - ON subscriptions.key = 'auth_source_default_' || providers.provider_type || '_subscriptions' - JOIN settings grant_on_signup - ON grant_on_signup.key = 'auth_source_default_' || providers.provider_type || '_grant_on_signup' - JOIN settings grant_on_first_bind - ON grant_on_first_bind.key = 'auth_source_default_' || providers.provider_type || '_grant_on_first_bind' - WHERE balance.value = '0' - AND concurrency.value = '5' - AND subscriptions.value = '[]' - AND grant_on_signup.value = 'true' - AND grant_on_first_bind.value = 'false' - AND balance.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute' - AND concurrency.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute' - AND subscriptions.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute' - AND grant_on_signup.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute' - AND grant_on_first_bind.updated_at BETWEEN migration_110.applied_at - INTERVAL '1 minute' AND migration_110.applied_at + INTERVAL '1 minute' -) -UPDATE settings -SET - value = 'false', - updated_at = NOW() -FROM legacy_provider_defaults -WHERE settings.key = 'auth_source_default_' || legacy_provider_defaults.provider_type || '_grant_on_signup' - AND settings.value = 'true'; +-- Intentionally left as a no-op. +-- Legacy installs may have intentionally kept the original signup grant defaults, +-- and we cannot distinguish those cases safely from untouched migration 110 rows. diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index dbf8fc47..dcb0bb9c 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -24,6 +24,7 @@ func TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults(t *testing.T) require.NotContains(t, sql, "UPDATE settings") require.NotContains(t, sql, "SET value = 'false'") require.True(t, strings.Contains(sql, "ON CONFLICT (key) DO NOTHING")) + require.Contains(t, sql, "THEN ''") } func TestAuthIdentityReportTypeWideningRunsBeforeLongReportWritersAndStillReconcilesAt121(t *testing.T) { @@ -63,6 +64,7 @@ func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) { followupSQL := string(followupContent) 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") require.Contains(t, followupSQL, "WHERE out_trade_no <> ''") @@ -92,9 +94,7 @@ func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T) require.NoError(t, err) sql := string(content) - 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, "Intentionally left as a no-op") + require.NotContains(t, sql, "UPDATE settings") + require.NotContains(t, sql, "value = 'false'") }