feat(supply-api): make withdraw readiness depend on sms wiring

This commit is contained in:
Your Name
2026-04-17 19:26:20 +08:00
parent 9bb1d6ce3e
commit cccb76b72b
7 changed files with 222 additions and 15 deletions

View File

@@ -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 仅在数据库可用时启动。

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) {