package smsdispatch import ( "context" "errors" "testing" "time" "hfb_sys/backend/internal/database" smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/sms" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" ) type fakeProvider struct { sent []smsprovider.TemplateRequest err error } func (p *fakeProvider) SendLoginCode(ctx context.Context, phone string, code string) error { return nil } func (p *fakeProvider) SendTemplate(ctx context.Context, req smsprovider.TemplateRequest) error { p.sent = append(p.sent, req) return p.err } type fakeConfigReader struct { value string err error } func (r *fakeConfigReader) FindValue(ctx context.Context, key string) (string, error) { if r.err != nil { return "", r.err } if key != sms.ConfigKey { return "", gorm.ErrRecordNotFound } return r.value, nil } type fakeSiteResolver struct { provider smsprovider.Provider signName string templates map[string]string err error } func (r *fakeSiteResolver) ResolveBusinessSender(ctx context.Context, siteID uint64) (smsprovider.Provider, string, map[string]string, error) { return r.provider, r.signName, r.templates, r.err } func newDispatchTestDB(t *testing.T) *gorm.DB { t.Helper() db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db, &model.SmsMessage{}); err != nil { t.Fatalf("AutoMigrate() error = %v", err) } return db } func configValue(t *testing.T, nodes map[string]sms.NodeConfig) string { t.Helper() raw, err := sms.MarshalConfig(sms.Config{Enabled: true, Nodes: nodes}) if err != nil { t.Fatalf("MarshalConfig() error = %v", err) } return raw } func seedPending(t *testing.T, db *gorm.DB, row model.SmsMessage) model.SmsMessage { t.Helper() if row.Status == "" { row.Status = model.SmsStatusPending } if row.Phone == "" { row.Phone = "13800000000" } if err := db.Create(&row).Error; err != nil { t.Fatalf("创建待发送记录失败: %v", err) } return row } func loadRow(t *testing.T, db *gorm.DB, id uint64) model.SmsMessage { t.Helper() var row model.SmsMessage if err := db.First(&row, id).Error; err != nil { t.Fatalf("查询短信记录失败: %v", err) } return row } func TestDispatchUsesMainChannelWhenNoSite(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"}, })} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil) row := seedPending(t, db, model.SmsMessage{ UserID: 1, Phone: "13800000001", Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1001, }) handled, err := job.dispatch(context.Background()) if err != nil { t.Fatalf("dispatch() error = %v", err) } if handled != 1 { t.Fatalf("dispatch() handled = %d, want 1", handled) } if len(mainProvider.sent) != 1 { t.Fatalf("主站通道发送次数 = %d, want 1", len(mainProvider.sent)) } req := mainProvider.sent[0] if req.TemplateCode != "SMS_512540945" || req.SignName != "大锤商行" || req.Phone != "13800000001" { t.Fatalf("请求 = %+v", req) } got := loadRow(t, db, row.ID) if got.Status != model.SmsStatusSent || got.TemplateCode != "SMS_512540945" || got.SignName != "大锤商行" || got.SentAt == nil { t.Fatalf("落库记录 = %+v", got) } } func TestDispatchPrefersSiteChannelWhenTemplateConfigured(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} siteProvider := &fakeProvider{} siteID := uint64(9) reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_MAIN"}, })} resolver := &fakeSiteResolver{ provider: siteProvider, signName: "分站签名", templates: map[string]string{sms.NodeOrderHandoffAssist: "SMS_SITE"}, } job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, resolver) row := seedPending(t, db, model.SmsMessage{ UserID: 2, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1002, DistributionSiteID: &siteID, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(mainProvider.sent) != 0 { t.Fatalf("分站已配置模板时不应使用主站通道") } if len(siteProvider.sent) != 1 { t.Fatalf("分站通道发送次数 = %d, want 1", len(siteProvider.sent)) } if req := siteProvider.sent[0]; req.TemplateCode != "SMS_SITE" || req.SignName != "分站签名" { t.Fatalf("分站请求 = %+v", req) } if got := loadRow(t, db, row.ID); got.TemplateCode != "SMS_SITE" || got.SignName != "分站签名" { t.Fatalf("分站落库记录 = %+v", got) } } func TestDispatchFallsBackToMainWhenSiteTemplateMissing(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} siteProvider := &fakeProvider{} siteID := uint64(10) reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeListingReviewRejected: {Enabled: true, TemplateCode: "SMS_MAIN"}, })} resolver := &fakeSiteResolver{ provider: siteProvider, signName: "分站签名", templates: map[string]string{sms.NodeOrderHandoffAssist: "SMS_SITE"}, } job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, resolver) row := seedPending(t, db, model.SmsMessage{ UserID: 3, Node: sms.NodeListingReviewRejected, BizType: "listing", BizID: 1003, DistributionSiteID: &siteID, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(siteProvider.sent) != 0 { t.Fatalf("分站未配置该节点模板时不应走分站通道") } if len(mainProvider.sent) != 1 || mainProvider.sent[0].TemplateCode != "SMS_MAIN" { t.Fatalf("应回退主站模板,实际 = %+v", mainProvider.sent) } if got := loadRow(t, db, row.ID); got.Status != model.SmsStatusSent { t.Fatalf("status = %q, want sent", got.Status) } } func TestDispatchFallsBackToMainWhenSiteResolutionFails(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} siteID := uint64(11) reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_MAIN"}, })} resolver := &fakeSiteResolver{err: errors.New("解密失败")} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, resolver) seedPending(t, db, model.SmsMessage{ UserID: 4, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1004, DistributionSiteID: &siteID, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(mainProvider.sent) != 1 { t.Fatalf("分站解析失败应回退主站通道,实际发送 %d 次", len(mainProvider.sent)) } } func TestDispatchSkipsWhenTemplateMissing(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: ""}, })} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil) row := seedPending(t, db, model.SmsMessage{ UserID: 5, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1005, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(mainProvider.sent) != 0 { t.Fatalf("未配置模板时不应发送") } got := loadRow(t, db, row.ID) if got.Status != model.SmsStatusSkipped || got.SkipReason == "" { t.Fatalf("应标记 skipped 并写原因,实际 = %+v", got) } } func TestDispatchSkipsWhenNodeDisabled(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: false, TemplateCode: "SMS_512540945"}, })} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil) row := seedPending(t, db, model.SmsMessage{ UserID: 6, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1006, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(mainProvider.sent) != 0 { t.Fatalf("节点关闭时不应发送") } if got := loadRow(t, db, row.ID); got.Status != model.SmsStatusSkipped { t.Fatalf("status = %q, want skipped", got.Status) } } func TestDispatchRetriesThenFails(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{err: errors.New("渠道异常")} reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"}, })} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil) // 退避设为负值,便于在测试内连续重试。 // updated_at 是 DATETIME(0),写库时小数秒会向上取整到下一秒(如 .690 → 下一秒), // 若把退避设为 0,刚失败的记录会被取数条件 updated_at <= now-退避 误判为「仍在退避中」而取不到。 job.retryDelay = -2 * time.Second row := seedPending(t, db, model.SmsMessage{ UserID: 7, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1007, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("第一次 dispatch() error = %v", err) } got := loadRow(t, db, row.ID) if got.Status != model.SmsStatusPending || got.RetryCount != 1 || got.LastError == "" { t.Fatalf("首次失败应留待重试,实际 = %+v", got) } // 第二次达到 maxRetry(3) 前仍是 pending,第三次用尽后置为 failed。 if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("第二次 dispatch() error = %v", err) } if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("第三次 dispatch() error = %v", err) } got = loadRow(t, db, row.ID) if got.Status != model.SmsStatusFailed || got.RetryCount != job.maxRetry { t.Fatalf("重试用尽后应标记 failed,实际 = %+v", got) } if len(mainProvider.sent) != job.maxRetry { t.Fatalf("发送尝试次数 = %d, want %d", len(mainProvider.sent), job.maxRetry) } } func TestDispatchPassesTemplateParams(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"}, })} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil) seedPending(t, db, model.SmsMessage{ UserID: 8, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1008, TemplateParams: datatypes.JSON([]byte(`{"brand_name":"大锤商行"}`)), }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(mainProvider.sent) != 1 { t.Fatalf("发送次数 = %d, want 1", len(mainProvider.sent)) } if got := mainProvider.sent[0].Params["brand_name"]; got != "大锤商行" { t.Fatalf("params.brand_name = %q, want 大锤商行", got) } } func TestDecodeParamsInvalidJSONFallsBackToNil(t *testing.T) { if params := decodeParams(datatypes.JSON([]byte(`{oops`))); params != nil { t.Fatalf("非法 JSON 应回落 nil, got %+v", params) } if params := decodeParams(datatypes.JSON([]byte(`{}`))); params != nil { t.Fatalf("空对象应回落 nil, got %+v", params) } if params := decodeParams(nil); params != nil { t.Fatalf("nil 应回落 nil, got %+v", params) } } func TestDispatchIgnoresRecordsInRetryBackoff(t *testing.T) { db := newDispatchTestDB(t) mainProvider := &fakeProvider{} reader := &fakeConfigReader{value: configValue(t, map[string]sms.NodeConfig{ sms.NodeOrderHandoffAssist: {Enabled: true, TemplateCode: "SMS_512540945"}, })} job := New(db, nil, zap.NewNop(), mainProvider, "大锤商行", reader, nil) job.retryDelay = time.Hour seedPending(t, db, model.SmsMessage{ UserID: 12, Node: sms.NodeOrderHandoffAssist, BizType: "order", BizID: 1012, Status: model.SmsStatusPending, RetryCount: 1, }) if _, err := job.dispatch(context.Background()); err != nil { t.Fatalf("dispatch() error = %v", err) } if len(mainProvider.sent) != 0 { t.Fatalf("退避窗口内不应重试") } }