diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index 9927a265..e1b337fc 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -200,14 +200,7 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ case wxpayModeJSAPI: return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen) case wxpayModeH5: - resp, err := w.prepayH5(ctx, client, req, notifyURL, totalFen) - if err == nil { - return resp, nil - } - if wxpayShouldFallbackToNative(err) { - return w.prepayNativeFallback(ctx, client, req, notifyURL, totalFen) - } - return nil, err + return w.prepayH5(ctx, client, req, notifyURL, totalFen) case wxpayModeNative: return w.prepayNative(ctx, client, req, notifyURL, totalFen) default: @@ -292,23 +285,6 @@ 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} @@ -321,10 +297,6 @@ 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 a5a406f9..e8ac5e54 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -643,7 +643,7 @@ func TestCreatePaymentMobileH5IncludesConfiguredSceneInfo(t *testing.T) { } } -func TestCreatePaymentMobileH5FallsBackToNativeOnNoAuth(t *testing.T) { +func TestCreatePaymentMobileH5ReturnsNoAuthErrorWithoutNativeFallback(t *testing.T) { origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment origNativePrepay := wxpayNativePrepay origH5Prepay := wxpayH5Prepay @@ -688,8 +688,8 @@ func TestCreatePaymentMobileH5FallsBackToNativeOnNoAuth(t *testing.T) { ClientIP: "203.0.113.10", IsMobile: true, }) - if err != nil { - t.Fatalf("unexpected error: %v", err) + if err == nil { + t.Fatal("expected no-auth error, got nil") } if jsapiCalls != 0 { t.Fatalf("jsapi prepay calls = %d, want 0", jsapiCalls) @@ -697,13 +697,13 @@ func TestCreatePaymentMobileH5FallsBackToNativeOnNoAuth(t *testing.T) { 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 nativeCalls != 0 { + t.Fatalf("native prepay calls = %d, want 0", nativeCalls) } - if resp.PayURL != "weixin://wxpay/bizpayurl?pr=fallback-native" { - t.Fatalf("pay_url = %q, want native fallback url", resp.PayURL) + if resp != nil { + t.Fatalf("expected nil response, got %+v", resp) } - if resp.QRCode != "weixin://wxpay/bizpayurl?pr=fallback-native" { - t.Fatalf("qr_code = %q, want native fallback url", resp.QRCode) + if !strings.Contains(err.Error(), "NO_AUTH") { + t.Fatalf("error = %v, want NO_AUTH", err) } } diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index be4a4cc5..6dbb9fbd 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -66,10 +66,12 @@ type migrationChecksumCompatibilityRule struct { 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"), + "109_auth_identity_compat_backfill.sql": newMigrationChecksumCompatibilityRule("0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee"), + "110_pending_auth_and_provider_default_grants.sql": newMigrationChecksumCompatibilityRule("32cf87ee787b1bb36b5c691367c96eee37518fa3eed6f3322cf68795e3745279", "e3d1f433be2b564cfbdc549adf98fce13c5c7b363ebc20fd05b765d0563b0925"), + "112_add_payment_order_provider_key_snapshot.sql": newMigrationChecksumCompatibilityRule("b75f8f56d39455682787696a3d92ad25b055444ca328fb7fca9a460a15d68d99", "ffd3e8a2c9295fa9cbefefd629a78268877e5b51bc970a82d9b3f46ec4ebd15e"), "115_auth_identity_legacy_external_backfill.sql": newMigrationChecksumCompatibilityRule("022aadd97bb53e755f0cf7a3a957e0cb1a1353b0c39ec4de3234acd2871fd04f", "4cf39e508be9fd1a5aa41610cbbebeb80385c9adda45bf78a706de9db4f1385f"), "116_auth_identity_legacy_external_safety_reports.sql": newMigrationChecksumCompatibilityRule("07edb09fa8d04ffb172b0621e3c22f4d1757d20a24ae267b3b36b087ab72d488", "f7757bd929ac67ffb08ce69fa4cf20fad39dbff9d5a5085fb2adabb7607e5877"), - "118_wechat_dual_mode_and_auth_source_defaults.sql": newMigrationChecksumCompatibilityRule("b54194d7a3e4fbf710e0a3590d22a2fe7966804c487052a356e0b55f53ef96b0", "e0cdf835d6c688d64100f483d31bc02ac9ebad414bf1837af239a84bf75b8227"), + "118_wechat_dual_mode_and_auth_source_defaults.sql": newMigrationChecksumCompatibilityRule("b54194d7a3e4fbf710e0a3590d22a2fe7966804c487052a356e0b55f53ef96b0", "e0cdf835d6c688d64100f483d31bc02ac9ebad414bf1837af239a84bf75b8227", "a38243ca0a72c3a01c0a92b7986423054d6133c0399441f853b99802852720fb"), "119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"), "120_enforce_payment_orders_out_trade_no_unique_notx.sql": newMigrationChecksumCompatibilityRule("34aadc0db59a4e390f92a12b73bd74642d9724f33124f73638ae00089ea5e074", "e77921f79d539bc24575cb9c16cbe566d2b23ce816190343d0a7568f6a3fcf61", "707431450603e70a43ce9fbd61e0c12fa67da4875158ccefabacea069587ab22", "04b082b5a239c525154fe9185d324ee2b05ff90da9297e10dba19f9be79aa59a"), "123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"), diff --git a/backend/internal/repository/migrations_runner_checksum_test.go b/backend/internal/repository/migrations_runner_checksum_test.go index 57647093..1fcb3be1 100644 --- a/backend/internal/repository/migrations_runner_checksum_test.go +++ b/backend/internal/repository/migrations_runner_checksum_test.go @@ -55,8 +55,17 @@ func TestIsMigrationChecksumCompatible(t *testing.T) { t.Run("109历史checksum可兼容", func(t *testing.T) { ok := isMigrationChecksumCompatible( "109_auth_identity_compat_backfill.sql", - "2b380305e73ff0c13aa8c811e45897f2b36ca4a438f7b3e8f98e19ecb6bae0b3", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee", + "0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace", + ) + require.True(t, ok) + }) + + t.Run("109当前checksum可兼容历史checksum", func(t *testing.T) { + ok := isMigrationChecksumCompatible( + "109_auth_identity_compat_backfill.sql", + "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee", + "0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace", ) require.True(t, ok) }) @@ -64,8 +73,26 @@ func TestIsMigrationChecksumCompatible(t *testing.T) { t.Run("109回滚到历史文件后仍兼容已应用的新checksum", func(t *testing.T) { ok := isMigrationChecksumCompatible( "109_auth_identity_compat_backfill.sql", + "0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace", "551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee", - "2b380305e73ff0c13aa8c811e45897f2b36ca4a438f7b3e8f98e19ecb6bae0b3", + ) + require.True(t, ok) + }) + + t.Run("110历史checksum可兼容", func(t *testing.T) { + ok := isMigrationChecksumCompatible( + "110_pending_auth_and_provider_default_grants.sql", + "e3d1f433be2b564cfbdc549adf98fce13c5c7b363ebc20fd05b765d0563b0925", + "32cf87ee787b1bb36b5c691367c96eee37518fa3eed6f3322cf68795e3745279", + ) + require.True(t, ok) + }) + + t.Run("112历史checksum可兼容", func(t *testing.T) { + ok := isMigrationChecksumCompatible( + "112_add_payment_order_provider_key_snapshot.sql", + "ffd3e8a2c9295fa9cbefefd629a78268877e5b51bc970a82d9b3f46ec4ebd15e", + "b75f8f56d39455682787696a3d92ad25b055444ca328fb7fca9a460a15d68d99", ) require.True(t, ok) }) @@ -97,6 +124,20 @@ func TestIsMigrationChecksumCompatible(t *testing.T) { require.True(t, ok) }) + t.Run("118多个历史checksum都可兼容当前版本", func(t *testing.T) { + for _, dbChecksum := range []string{ + "a38243ca0a72c3a01c0a92b7986423054d6133c0399441f853b99802852720fb", + "e0cdf835d6c688d64100f483d31bc02ac9ebad414bf1837af239a84bf75b8227", + } { + ok := isMigrationChecksumCompatible( + "118_wechat_dual_mode_and_auth_source_defaults.sql", + dbChecksum, + "b54194d7a3e4fbf710e0a3590d22a2fe7966804c487052a356e0b55f53ef96b0", + ) + require.True(t, ok) + } + }) + t.Run("120多个历史checksum都可兼容新的notx修复版本", func(t *testing.T) { for _, dbChecksum := range []string{ "e77921f79d539bc24575cb9c16cbe566d2b23ce816190343d0a7568f6a3fcf61", diff --git a/backend/internal/repository/migrations_runner_extra_test.go b/backend/internal/repository/migrations_runner_extra_test.go index a8bc15bc..5d67665e 100644 --- a/backend/internal/repository/migrations_runner_extra_test.go +++ b/backend/internal/repository/migrations_runner_extra_test.go @@ -96,6 +96,9 @@ func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) { func TestMigrationChecksumCompatibilityRules_CoverEditedUpgradeCompatibilityMigrations(t *testing.T) { for _, name := range []string{ + "109_auth_identity_compat_backfill.sql", + "110_pending_auth_and_provider_default_grants.sql", + "112_add_payment_order_provider_key_snapshot.sql", "115_auth_identity_legacy_external_backfill.sql", "116_auth_identity_legacy_external_safety_reports.sql", "118_wechat_dual_mode_and_auth_source_defaults.sql", diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index f14dc55d..b627ced4 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -158,7 +158,11 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s "queryRef": queryRef, }) slog.Warn("query upstream returned invalid paid amount", "orderID", o.ID, "queryRef", queryRef, "paid", resp.Amount) - return "" + retriedResp, retryOK := requeryPaidOrderOnce(ctx, prov, queryRef) + if !retryOK { + return "" + } + resp = retriedResp } notificationTradeNo := o.PaymentTradeNo if upstreamTradeNo := strings.TrimSpace(resp.TradeNo); paymentOrderShouldPersistUpstreamTradeNo(queryRef, upstreamTradeNo, notificationTradeNo) { @@ -184,6 +188,21 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s return "" } +func requeryPaidOrderOnce(ctx context.Context, prov payment.Provider, queryRef string) (*payment.QueryOrderResponse, bool) { + if prov == nil || strings.TrimSpace(queryRef) == "" { + return nil, false + } + resp, err := prov.QueryOrder(ctx, queryRef) + if err != nil { + slog.Warn("query upstream retry failed", "queryRef", queryRef, "error", err) + return nil, false + } + if resp == nil || resp.Status != payment.ProviderStatusPaid || !isValidProviderAmount(resp.Amount) { + return nil, false + } + return resp, true +} + func paymentOrderQueryReference(order *dbent.PaymentOrder, prov payment.Provider) string { if order == nil { return "" diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index cabdb445..8dfd2e7e 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -21,6 +21,8 @@ import ( type paymentOrderLifecycleQueryProvider struct { lastQueryTradeNo string + queryCalls int + responses []*payment.QueryOrderResponse resp *payment.QueryOrderResponse } @@ -48,6 +50,14 @@ func (p *paymentOrderLifecycleQueryProvider) CreatePayment(context.Context, paym func (p *paymentOrderLifecycleQueryProvider) QueryOrder(_ context.Context, tradeNo string) (*payment.QueryOrderResponse, error) { p.lastQueryTradeNo = tradeNo + p.queryCalls++ + if len(p.responses) > 0 { + resp := p.responses[0] + if len(p.responses) > 1 { + p.responses = p.responses[1:] + } + return resp, nil + } return p.resp, nil } @@ -234,6 +244,103 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) { require.Equal(t, user.ID, redeemRepo.useCalls[0].userID) } +func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("checkpaid-retry@example.com"). + SetPasswordHash("hash"). + SetUsername("checkpaid-retry-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("CHECKPAID-UPSTREAM-RETRY"). + SetOutTradeNo("sub2_checkpaid_retry_zero_amount"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + userRepo := &mockUserRepo{ + getByIDUser: &User{ + ID: user.ID, + Email: user.Email, + Username: user.Username, + Balance: 0, + }, + } + userRepo.updateBalanceFn = func(ctx context.Context, id int64, amount float64) error { + require.Equal(t, user.ID, id) + if userRepo.getByIDUser != nil { + userRepo.getByIDUser.Balance += amount + } + return nil + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 1, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUnused, + }, + }, + } + redeemService := NewRedeemService( + redeemRepo, + userRepo, + nil, + nil, + nil, + client, + nil, + ) + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + responses: []*payment.QueryOrderResponse{ + { + TradeNo: "upstream-trade-zero", + Status: payment.ProviderStatusPaid, + Amount: 0, + }, + { + TradeNo: "upstream-trade-retry", + Status: payment.ProviderStatusPaid, + Amount: 88, + }, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + redeemService: redeemService, + userRepo: userRepo, + providersLoaded: true, + } + + got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID) + require.NoError(t, err) + require.Equal(t, 2, provider.queryCalls) + require.Equal(t, OrderStatusCompleted, got.Status) + require.Equal(t, "upstream-trade-retry", got.PaymentTradeNo) +} + func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/backend/migrations/124_backfill_legacy_oidc_security_flags.sql b/backend/migrations/124_backfill_legacy_oidc_security_flags.sql new file mode 100644 index 00000000..e68bb11a --- /dev/null +++ b/backend/migrations/124_backfill_legacy_oidc_security_flags.sql @@ -0,0 +1,32 @@ +-- Preserve legacy OIDC behavior for upgraded installs that predate the +-- introduction of secure PKCE/id_token defaults. Fresh installs continue to +-- inherit runtime defaults when these rows are absent. + +WITH legacy_oidc_install AS ( + SELECT 1 + FROM settings + WHERE key IN ( + 'oidc_connect_enabled', + 'oidc_connect_client_id', + 'oidc_connect_authorize_url', + 'oidc_connect_token_url', + 'oidc_connect_issuer_url', + 'oidc_connect_userinfo_url', + 'oidc_connect_frontend_redirect_url' + ) + LIMIT 1 +) +INSERT INTO settings (key, value) +SELECT defaults.key, 'false' +FROM legacy_oidc_install +CROSS JOIN ( + VALUES + ('oidc_connect_use_pkce'), + ('oidc_connect_validate_id_token') +) AS defaults(key) +WHERE NOT EXISTS ( + SELECT 1 + FROM settings existing + WHERE existing.key = defaults.key +) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index 6a95d335..798ae0fe 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -115,3 +115,15 @@ func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T) require.Contains(t, sql, "value = 'false'") require.Contains(t, sql, "auth_identity_migration_reports") } + +func TestMigration124BackfillsLegacyOIDCSecurityFlagsSafely(t *testing.T) { + content, err := FS.ReadFile("124_backfill_legacy_oidc_security_flags.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "oidc_connect_use_pkce") + require.Contains(t, sql, "oidc_connect_validate_id_token") + require.Contains(t, sql, "ON CONFLICT (key) DO NOTHING") + require.Contains(t, sql, "oidc_connect_enabled") + require.Contains(t, sql, "'false'") +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 358f6a31..dfc363b5 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -841,7 +841,7 @@ linuxdo_connect: frontend_redirect_url: "/auth/linuxdo/callback" token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none # 注意:当 token_auth_method=none(public client)时,必须启用 PKCE - use_pkce: false + use_pkce: true userinfo_email_path: "" userinfo_id_path: "" userinfo_username_path: ""