Files
hfb_sys/backend/internal/jobs/smsdispatch/job.go
T

275 lines
7.5 KiB
Go

// 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, &params); 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:]
}