// Package smsdispatch 负责把业务事务里入队的节点短信真正发出去。 // // 设计要点: // - 业务侧只在事务内写 sms_messages(pending),发送完全异步,短信失败不影响业务; // - 通道选择与登录验证码一致:分站启用独立短信且配置了该节点模板时走分站通道与分站签名, // 否则回退主站通道与主站模板; // - 失败按 retryDelay 退避重试,超过 maxRetry 标记 failed,便于运营排查。 package smsdispatch import ( "context" "encoding/json" "fmt" "strings" "time" smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/sms" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" ) const lockKey = "hfb:job:smsdispatch:lock" // SiteSenderResolver 解析分销站点的独立短信通道,由 distribution.SMSResolver 实现。 type SiteSenderResolver interface { ResolveBusinessSender(ctx context.Context, siteID uint64) (smsprovider.Provider, string, map[string]string, error) } type Job struct { db *gorm.DB redis *redis.Client logger *zap.Logger repo *sms.Repository configs sms.ConfigReader provider smsprovider.Provider mainSignName string siteResolver SiteSenderResolver interval time.Duration batchSize int maxRetry int retryDelay time.Duration instanceID string } func New( db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, provider smsprovider.Provider, mainSignName string, configs sms.ConfigReader, siteResolver SiteSenderResolver, ) *Job { return &Job{ db: db, redis: redisClient, logger: logger, repo: sms.NewRepository(db), configs: configs, provider: provider, mainSignName: strings.TrimSpace(mainSignName), siteResolver: siteResolver, interval: time.Minute, batchSize: 100, maxRetry: 3, retryDelay: 5 * time.Minute, instanceID: newInstanceID(), } } func (j *Job) Start(ctx context.Context) { if j == nil || j.db == nil { return } go j.loop(ctx) } func (j *Job) loop(ctx context.Context) { j.run(ctx) ticker := time.NewTicker(j.interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: j.run(ctx) } } } func (j *Job) run(ctx context.Context) { release, ok := j.acquireLock(ctx) if !ok { return } defer release() sent, err := j.dispatch(ctx) if err != nil { j.logger.Warn("节点短信派发失败", zap.Error(err)) return } if sent > 0 { j.logger.Info("节点短信派发完成", zap.Int("handled", sent)) } } func (j *Job) dispatch(ctx context.Context) (int, error) { cfg, err := sms.LoadConfig(ctx, j.configs) if err != nil { return 0, err } rows, err := j.repo.ListPending(ctx, j.batchSize, time.Now().Add(-j.retryDelay)) if err != nil { return 0, err } handled := 0 for _, row := range rows { if err := ctx.Err(); err != nil { return handled, err } if err := j.sendOne(ctx, cfg, row); err != nil { j.logger.Warn("节点短信处理失败", zap.Uint64("sms_message_id", row.ID), zap.String("node", row.Node), zap.String("phone", maskPhone(row.Phone)), zap.Error(err), ) continue } handled++ } return handled, nil } func (j *Job) sendOne(ctx context.Context, cfg sms.Config, row model.SmsMessage) error { node := strings.TrimSpace(row.Node) if !sms.IsKnownNode(node) { return j.repo.MarkSkipped(ctx, row.ID, "未登记的短信节点") } if strings.TrimSpace(row.Phone) == "" { return j.repo.MarkSkipped(ctx, row.ID, "接收人手机号为空") } if !cfg.Enabled { return j.repo.MarkSkipped(ctx, row.ID, "节点短信总开关未开启") } nodeCfg, ok := cfg.Node(node) if !ok || !nodeCfg.Enabled { return j.repo.MarkSkipped(ctx, row.ID, "该节点短信未启用") } mainTemplateCode := strings.TrimSpace(nodeCfg.TemplateCode) if mainTemplateCode == "" { return j.repo.MarkSkipped(ctx, row.ID, "主站未配置该节点模板 CODE") } provider, signName, templateCode := j.resolveSender(ctx, row, node, mainTemplateCode) if provider == nil { return j.repo.MarkSkipped(ctx, row.ID, "短信通道不可用") } req := smsprovider.TemplateRequest{ Phone: strings.TrimSpace(row.Phone), SignName: signName, TemplateCode: templateCode, Params: decodeParams(row.TemplateParams), } if err := provider.SendTemplate(ctx, req); err != nil { return j.handleSendFailure(ctx, row, err) } return j.repo.MarkSent(ctx, row.ID, templateCode, signName, time.Now()) } // resolveSender 决定使用分站通道还是主站通道:分站启用独立短信且配置了该节点模板时优先分站。 func (j *Job) resolveSender(ctx context.Context, row model.SmsMessage, node string, mainTemplateCode string) (smsprovider.Provider, string, string) { if row.DistributionSiteID != nil && *row.DistributionSiteID > 0 && j.siteResolver != nil { siteProvider, siteSignName, siteTemplates, err := j.siteResolver.ResolveBusinessSender(ctx, *row.DistributionSiteID) if err != nil { j.logger.Warn("解析分站短信通道失败,回退主站通道", zap.Uint64("site_id", *row.DistributionSiteID), zap.Error(err), ) } else if siteProvider != nil { if code := strings.TrimSpace(siteTemplates[node]); code != "" { return siteProvider, siteSignName, code } } } if j.provider == nil { return nil, "", "" } return j.provider, j.mainSignName, mainTemplateCode } func (j *Job) handleSendFailure(ctx context.Context, row model.SmsMessage, sendErr error) error { retryCount := row.RetryCount + 1 if retryCount >= j.maxRetry { if err := j.repo.MarkFailed(ctx, row.ID, retryCount, sendErr.Error()); err != nil { return err } j.logger.Warn("节点短信重试次数用尽", zap.Uint64("sms_message_id", row.ID), zap.String("node", row.Node), zap.Int("retry_count", retryCount), zap.Error(sendErr), ) return nil } return j.repo.MarkRetry(ctx, row.ID, retryCount, sendErr.Error()) } func decodeParams(raw datatypes.JSON) map[string]string { if len(raw) == 0 { return nil } var params map[string]string if err := json.Unmarshal(raw, ¶ms); err != nil { return nil } if len(params) == 0 { return nil } return params } // acquireLock 通过 Redis 分布式锁确保同一时刻只有一个实例派发。 // 未配置 Redis 时直接执行;Redis 出错时降级执行(重复发送由状态与重试次数兜底)。 func (j *Job) acquireLock(ctx context.Context) (func(), bool) { if j.redis == nil { return func() {}, true } ok, err := j.redis.SetNX(ctx, lockKey, j.instanceID, 2*j.interval).Result() if err != nil { j.logger.Warn("节点短信任务获取锁失败,将以单实例模式执行", zap.Error(err)) return func() {}, true } if !ok { return nil, false } return j.releaseLock, true } func (j *Job) releaseLock() { if j.redis == nil { return } relCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`) if err := script.Run(relCtx, j.redis, []string{lockKey}, j.instanceID).Err(); err != nil { j.logger.Warn("节点短信任务释放锁失败", zap.Error(err)) } } func newInstanceID() string { return fmt.Sprintf("smsdispatch-%d", time.Now().UnixNano()) } func maskPhone(phone string) string { phone = strings.TrimSpace(phone) if len(phone) < 7 { return phone } return phone[:3] + "****" + phone[len(phone)-4:] }