feat(supply-api): make withdraw readiness depend on sms wiring
This commit is contained in:
@@ -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 仅在数据库可用时启动。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user