From cccb76b72b9d84fc6a2a14c76dbe01ce850fba43 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 17 Apr 2026 19:26:20 +0800 Subject: [PATCH] feat(supply-api): make withdraw readiness depend on sms wiring --- supply-api/README.md | 1 + supply-api/internal/app/runtime.go | 33 ++++- supply-api/internal/app/runtime_test.go | 129 ++++++++++++++++-- supply-api/internal/config/config.go | 58 +++++++- supply-api/internal/domain/settlement.go | 11 +- supply-api/internal/httpapi/supply_api.go | 2 +- .../internal/httpapi/supply_api_test.go | 3 + 7 files changed, 222 insertions(+), 15 deletions(-) diff --git a/supply-api/README.md b/supply-api/README.md index b69fee3c..33bda1dd 100644 --- a/supply-api/README.md +++ b/supply-api/README.md @@ -9,6 +9,7 @@ - 数据库不可用时,开发模式下仍保留部分内存降级路径;当前仓库不把这种模式视为生产可用状态。 - 告警 API 在 PostgreSQL 可用时走数据库仓储;数据库不可用时才显式回退内存实现。 - IAM 路由默认不进入对外交付面;只有 `server.iam_enabled=true` 且 runtime 具备数据库依赖时才注册 `/api/v1/iam/*`。 +- 提现接口路径存在不代表能力已上线。只有 `settlement.withdraw_enabled=true`、`sms.*` 配置齐备,且 runtime 显式注入了真实 `SMSVerifier` 时,提现才会从关闭态切到可用态。 - 依赖幂等仓储的写接口在中间件缺失时会返回 `503 SUP_HTTP_5031`,不再静默切回内联逻辑。 - Outbox processor 与补偿 worker 仅在数据库可用时启动。 diff --git a/supply-api/internal/app/runtime.go b/supply-api/internal/app/runtime.go index c5bd70c9..9a8ec322 100644 --- a/supply-api/internal/app/runtime.go +++ b/supply-api/internal/app/runtime.go @@ -83,6 +83,7 @@ type runtimeStartupViews struct { type runtimeFactory struct { newDB func(ctx context.Context, cfg config.DatabaseConfig) (*repository.DB, error) newRedisCache func(cfg config.RedisConfig) (*cache.RedisCache, error) + newSMSVerifier func(cfg config.SMSConfig) (domain.SMSVerifier, error) } type runtimeBuildInputs struct { @@ -149,6 +150,7 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt if err != nil { return nil, err } + factory = withDefaultRuntimeFactory(factory) resources, err := initializeRuntimeExternalResources(inputs, factory) if err != nil { @@ -157,7 +159,7 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt storeBundle := buildStoreBundle(resources.db, inputs.logger) securityBundle := buildSecurityBundle(inputs.env, inputs.cfg, inputs.logger, storeBundle.auditStore, resources.redisCache, storeBundle.tokenStatusRepo) - apiBundle, err := buildAPIBundle(inputs.env, inputs.cfg, inputs.now, inputs.tuning, inputs.logger, inputs.isProd, resources.db, storeBundle) + apiBundle, err := buildAPIBundle(inputs.env, inputs.cfg, inputs.now, inputs.tuning, inputs.logger, inputs.isProd, factory, resources.db, storeBundle) if err != nil { return nil, err } @@ -209,6 +211,11 @@ func withDefaultRuntimeFactory(factory runtimeFactory) runtimeFactory { if factory.newRedisCache == nil { factory.newRedisCache = cache.NewRedisCache } + if factory.newSMSVerifier == nil { + factory.newSMSVerifier = func(config.SMSConfig) (domain.SMSVerifier, error) { + return nil, nil + } + } return factory } @@ -366,6 +373,7 @@ func buildAPIBundle( tuning runtimeTuning, logger logging.Logger, isProd bool, + factory runtimeFactory, db *repository.DB, storeBundle runtimeStoreBundle, ) (runtimeAPIBundle, error) { @@ -374,6 +382,7 @@ func buildAPIBundle( accountService := domain.NewAccountService(storeBundle.accountStore, storeBundle.auditStore) packageService := domain.NewPackageService(storeBundle.packageStore, storeBundle.accountStore, storeBundle.auditStore) settlementService := domain.NewSettlementService(storeBundle.settlementStore, storeBundle.earningStore, storeBundle.auditStore) + withdrawEnabled := false earningService := domain.NewEarningService(storeBundle.earningStore) var iamAPI routeRegistrar @@ -395,6 +404,26 @@ func buildAPIBundle( rateLimitConfig.Enabled = env != "dev" logger.Info("限流中间件已初始化", nil) + if cfg.Settlement.WithdrawEnabled { + if !cfg.SMS.IsReadyForWithdraw() { + logger.Warn("提现能力未启用:SMS is not ready", nil) + } else { + smsVerifier, err := factory.newSMSVerifier(cfg.SMS) + if err != nil { + return runtimeAPIBundle{}, fmt.Errorf("failed to initialize SMS verifier: %w", err) + } + if smsVerifier == nil { + logger.Warn("提现能力未启用:SMS verifier is not wired", nil) + } else { + settlementService = domain.NewSettlementServiceWithSMS(storeBundle.settlementStore, storeBundle.earningStore, storeBundle.auditStore, smsVerifier) + withdrawEnabled = true + logger.Info("提现能力已启用(SMS verifier wired)", nil) + } + } + } else { + logger.Info("提现能力未启用(settlement.withdraw_enabled=false)", nil) + } + if cfg.Server.IAMEnabled { if db == nil { return runtimeAPIBundle{}, errors.New("iam requires database-backed runtime") @@ -424,7 +453,7 @@ func buildAPIBundle( if err != nil { return runtimeAPIBundle{}, fmt.Errorf("failed to initialize supply api: %w", err) } - supplyAPI.SetWithdrawEnabled(cfg.Settlement.WithdrawEnabled) + supplyAPI.SetWithdrawEnabled(withdrawEnabled) alertAPI, err := httpapi.NewAlertAPI(storeBundle.alertService) if err != nil { diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index f9cef65f..d73b5472 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -408,6 +410,95 @@ func TestBuildRuntime_EnablesIAMRoutesWhenConfigured(t *testing.T) { } } +func TestBuildRuntime_KeepsWithdrawDisabledWithoutSMSVerifier(t *testing.T) { + cfg := testRuntimeConfig() + cfg.Settlement.WithdrawEnabled = true + cfg.SMS.Enabled = true + + runtime, err := buildRuntimeWithFactory(RuntimeOptions{ + Env: "dev", + Config: cfg, + Logger: testLogger{}, + InitContext: context.Background(), + Now: func() time.Time { + return time.Unix(1712800000, 0).UTC() + }, + }, runtimeFactory{ + newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) { + return nil, errors.New("db down") + }, + newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) { + return nil, nil + }, + }) + if err != nil { + t.Fatalf("expected runtime build to succeed with withdraw forced closed, got %v", err) + } + + srv, err := runtime.BuildServer() + if err != nil { + t.Fatalf("expected server build to succeed, got %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/settlements/withdraw", strings.NewReader(`{"withdraw_amount":1000,"payment_method":"bank","payment_account":"13800138000","sms_code":"123456"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected withdraw to stay disabled, got=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "SMS is not ready") { + t.Fatalf("expected disabled response to mention SMS readiness, got %s", rec.Body.String()) + } +} + +func TestBuildRuntime_EnablesWithdrawWhenSMSVerifierIsWired(t *testing.T) { + cfg := testRuntimeConfig() + cfg.Settlement.WithdrawEnabled = true + cfg.SMS.Enabled = true + + runtime, err := buildRuntimeWithFactory(RuntimeOptions{ + Env: "dev", + Config: cfg, + Logger: testLogger{}, + InitContext: context.Background(), + Now: func() time.Time { + return time.Unix(1712800000, 0).UTC() + }, + }, runtimeFactory{ + newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) { + return nil, errors.New("db down") + }, + newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) { + return nil, nil + }, + newSMSVerifier: func(config.SMSConfig) (domain.SMSVerifier, error) { + return &mockSMSVerifierForRuntime{verifyResult: true}, nil + }, + }) + if err != nil { + t.Fatalf("expected runtime build to succeed, got %v", err) + } + + srv, err := runtime.BuildServer() + if err != nil { + t.Fatalf("expected server build to succeed, got %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/settlements/withdraw", strings.NewReader(`{"withdraw_amount":1000,"payment_method":"bank","payment_account":"13800138000","sms_code":"123456"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected request to stop at idempotency gate, got=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "idempotency middleware is required") { + t.Fatalf("expected withdraw gate to move past SMS readiness, got %s", rec.Body.String()) + } +} + func TestBuildRuntime_NormalizesServerConfigDefaults(t *testing.T) { cfg := testRuntimeConfig() cfg.Server = config.ServerConfig{} @@ -1091,13 +1182,13 @@ func testRuntimeConfig() *config.Config { ConnMaxLifetime: time.Minute, ConnMaxIdleTime: time.Minute, }, - Redis: config.RedisConfig{ - Host: "127.0.0.1", - Port: 6379, - Password: "", - DB: 0, - PoolSize: 2, - }, + Redis: config.RedisConfig{ + Host: "127.0.0.1", + Port: 6379, + Password: "", + DB: 0, + PoolSize: 2, + }, Token: config.TokenConfig{ SecretKey: "runtime-test-secret", Algorithm: "HS256", @@ -1105,13 +1196,35 @@ func testRuntimeConfig() *config.Config { RevocationCacheTTL: 10 * time.Second, }, Settlement: config.SettlementConfig{ - WithdrawEnabled: true, + WithdrawEnabled: false, + }, + SMS: config.SMSConfig{ + Provider: "tencent", + AppID: "test-app-id", + AppSecret: "test-app-secret", + SignName: "SupplyAPI", + TemplateCode: "SMS_123456", + Region: "ap-guangzhou", + CodeLength: 6, + CodeExpireMins: 5, }, } } type stubOutboxRepository struct{} +type mockSMSVerifierForRuntime struct { + verifyResult bool + verifyError error +} + +func (m *mockSMSVerifierForRuntime) Verify(context.Context, string, string) (bool, error) { + if m.verifyError != nil { + return false, m.verifyError + } + return m.verifyResult, nil +} + type stubRevocationSubscriber struct{} func (stubRevocationSubscriber) StartRevocationSubscriber(context.Context) error { diff --git a/supply-api/internal/config/config.go b/supply-api/internal/config/config.go index b2687ae6..ca584f4a 100644 --- a/supply-api/internal/config/config.go +++ b/supply-api/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Redis RedisConfig Token TokenConfig Settlement SettlementConfig + SMS SMSConfig Audit AuditConfig } @@ -70,6 +71,30 @@ type SettlementConfig struct { WithdrawEnabled bool } +// SMSConfig 短信验证码服务配置。 +type SMSConfig struct { + Enabled bool + Provider string + AppID string + AppSecret string + SignName string + TemplateCode string + Region string + Endpoint string + CodeLength int + CodeExpireMins int +} + +// IsReadyForWithdraw 返回提现能力所需的最小 SMS 配置是否齐备。 +func (c SMSConfig) IsReadyForWithdraw() bool { + return c.Enabled && + strings.TrimSpace(c.Provider) != "" && + strings.TrimSpace(c.AppID) != "" && + strings.TrimSpace(c.AppSecret) != "" && + strings.TrimSpace(c.SignName) != "" && + strings.TrimSpace(c.TemplateCode) != "" +} + // AuditConfig 审计配置 type AuditConfig struct { BufferSize int @@ -200,6 +225,18 @@ func load(env, configPath string) (*Config, error) { // Settlement配置 cfg.Settlement.WithdrawEnabled = v.GetBool("settlement.withdraw_enabled") + // SMS 配置 + cfg.SMS.Enabled = v.GetBool("sms.enabled") + cfg.SMS.Provider = v.GetString("sms.provider") + cfg.SMS.AppID = v.GetString("sms.app_id") + cfg.SMS.AppSecret = v.GetString("sms.app_secret") + cfg.SMS.SignName = v.GetString("sms.sign_name") + cfg.SMS.TemplateCode = v.GetString("sms.template_code") + cfg.SMS.Region = v.GetString("sms.region") + cfg.SMS.Endpoint = v.GetString("sms.endpoint") + cfg.SMS.CodeLength = v.GetInt("sms.code_length") + cfg.SMS.CodeExpireMins = v.GetInt("sms.code_expire_mins") + if err := validateForEnv(env, &cfg); err != nil { return nil, err } @@ -247,6 +284,13 @@ func setDefaults(v *viper.Viper) { // Settlement defaults v.SetDefault("settlement.withdraw_enabled", false) + // SMS defaults + v.SetDefault("sms.enabled", false) + v.SetDefault("sms.provider", "tencent") + v.SetDefault("sms.region", "ap-guangzhou") + v.SetDefault("sms.code_length", 6) + v.SetDefault("sms.code_expire_mins", 5) + // Audit defaults v.SetDefault("audit.buffer_size", 1000) v.SetDefault("audit.flush_interval", 5*time.Second) @@ -278,6 +322,16 @@ func bindEnvVars(v *viper.Viper) { _ = v.BindEnv("token.algorithm", "SUPPLY_TOKEN_ALGORITHM") _ = v.BindEnv("token.issuer", "SUPPLY_TOKEN_ISSUER") _ = v.BindEnv("settlement.withdraw_enabled", "SUPPLY_SETTLEMENT_WITHDRAW_ENABLED") + _ = v.BindEnv("sms.enabled", "SUPPLY_SMS_ENABLED") + _ = v.BindEnv("sms.provider", "SUPPLY_SMS_PROVIDER") + _ = v.BindEnv("sms.app_id", "SUPPLY_SMS_APP_ID") + _ = v.BindEnv("sms.app_secret", "SUPPLY_SMS_APP_SECRET") + _ = v.BindEnv("sms.sign_name", "SUPPLY_SMS_SIGN_NAME") + _ = v.BindEnv("sms.template_code", "SUPPLY_SMS_TEMPLATE_CODE") + _ = v.BindEnv("sms.region", "SUPPLY_SMS_REGION") + _ = v.BindEnv("sms.endpoint", "SUPPLY_SMS_ENDPOINT") + _ = v.BindEnv("sms.code_length", "SUPPLY_SMS_CODE_LENGTH") + _ = v.BindEnv("sms.code_expire_mins", "SUPPLY_SMS_CODE_EXPIRE_MINS") } // GetEnvInt 获取环境变量int值 @@ -310,8 +364,8 @@ func validateForEnv(env string, cfg *Config) error { if strings.TrimSpace(cfg.Token.Issuer) == "" { return fmt.Errorf("invalid prod config: token.issuer is required") } - if cfg.Settlement.WithdrawEnabled { - return fmt.Errorf("invalid prod config: settlement.withdraw_enabled cannot be true until SMS integration is production-ready") + if cfg.Settlement.WithdrawEnabled && !cfg.SMS.IsReadyForWithdraw() { + return fmt.Errorf("invalid prod config: settlement.withdraw_enabled requires SMS to be ready; SMS is not ready") } // P1-2: Reject HMAC algorithms (HS256/HS384/HS512) in production — only RSA is allowed diff --git a/supply-api/internal/domain/settlement.go b/supply-api/internal/domain/settlement.go index d81547dc..3e8a9038 100644 --- a/supply-api/internal/domain/settlement.go +++ b/supply-api/internal/domain/settlement.go @@ -192,7 +192,7 @@ func NewSettlementService(store SettlementStore, earningStore EarningStore, audi store: store, earningStore: earningStore, auditStore: auditStore, - smsVerifier: &DefaultSMSVerifier{}, // 默认使用硬编码验证码 + smsVerifier: resolveSMSVerifier(nil), } } @@ -202,10 +202,17 @@ func NewSettlementServiceWithSMS(store SettlementStore, earningStore EarningStor store: store, earningStore: earningStore, auditStore: auditStore, - smsVerifier: smsVerifier, + smsVerifier: resolveSMSVerifier(smsVerifier), } } +func resolveSMSVerifier(verifier SMSVerifier) SMSVerifier { + if verifier == nil { + return &DefaultSMSVerifier{} + } + return verifier +} + // emitAudit 安全记录审计日志(失败只记录错误,不影响主流程) func (s *settlementService) emitAudit(ctx context.Context, event audit.Event) { if err := s.auditStore.Emit(ctx, event); err != nil { diff --git a/supply-api/internal/httpapi/supply_api.go b/supply-api/internal/httpapi/supply_api.go index c6a7eb78..ce504343 100644 --- a/supply-api/internal/httpapi/supply_api.go +++ b/supply-api/internal/httpapi/supply_api.go @@ -763,7 +763,7 @@ func (a *SupplyAPI) handleWithdraw(w http.ResponseWriter, r *http.Request) { return } if !a.withdrawEnabled { - writeError(w, http.StatusServiceUnavailable, CodeFeatureDisabled, "withdraw is disabled until SMS verification is integrated") + writeError(w, http.StatusServiceUnavailable, CodeFeatureDisabled, "withdraw is disabled because SMS is not ready") return } if !a.requireIdempotencyMiddleware(w) { diff --git a/supply-api/internal/httpapi/supply_api_test.go b/supply-api/internal/httpapi/supply_api_test.go index 512b7886..b3adfac3 100644 --- a/supply-api/internal/httpapi/supply_api_test.go +++ b/supply-api/internal/httpapi/supply_api_test.go @@ -1441,6 +1441,9 @@ func TestSupplyAPI_WithdrawDisabled_ReturnsServiceUnavailable(t *testing.T) { if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected status 503, got %d body=%s", w.Code, w.Body.String()) } + if !strings.Contains(w.Body.String(), "SMS is not ready") { + t.Fatalf("expected disabled response to mention SMS readiness, got %s", w.Body.String()) + } } func TestSupplyAPI_EndToEnd_BillingSummary(t *testing.T) {