diff --git a/README.md b/README.md index be325de..cfdaea8 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ SKIP_CHECKS=1 git push - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。 - 后台资金流水已接入,支持按用户、订单和业务类型查询。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 +- 节点短信已支持审核未通过、被租用需辅助登录、上架未成交降价建议三个节点,事务内只入队、由后台任务异步发送;主站模板在「系统配置 → 节点短信配置」维护,分站可在「短信配置」里配置自己的模板,未配置的节点回退主站。详见 [`docs/节点短信提醒方案.md`](docs/节点短信提醒方案.md)。 - 订单群聊已支持支付成功后自动创建,租客、号主和客服可在移动端消息页进入会话。 - 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理。 - 系统配置已支持默认配置初始化和后台编辑,更新会写入审计日志。 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index c97c1a5..dc5a6fb 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -14,13 +14,17 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/jobs/distributionsettlement" "hfb_sys/backend/internal/jobs/fileuploadcleanup" + "hfb_sys/backend/internal/jobs/listingstale" "hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/jobs/refundretry" + "hfb_sys/backend/internal/jobs/smsdispatch" "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/modules/adminauth" + "hfb_sys/backend/internal/modules/distribution" "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/payment" "hfb_sys/backend/internal/modules/paymentconfig" + "hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/router" "go.uber.org/zap" @@ -112,6 +116,7 @@ func main() { if paymentRepo != nil { refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx) } + startSMSJobs(jobCtx, cfg, deps, logger) } server := &http.Server{ Addr: cfg.AppAddr, @@ -178,3 +183,32 @@ func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *z } return paymentconfig.NewRepository(db, encryptor) } + +// startSMSJobs 启动节点短信派发与「上架未成交」扫描任务。 +// 分站短信密钥需要解密,因此这里单独构造一份带业务字段加密器的分销仓库。 +func startSMSJobs(ctx context.Context, cfg config.Config, deps router.Dependencies, logger *zap.Logger) { + if deps.DB == nil { + return + } + configRepo := systemconfig.NewRepository(deps.DB) + if err := configRepo.EnsureDefaults(ctx); err != nil { + logger.Warn("系统配置默认值初始化失败,节点短信将按默认关闭处理", zap.Error(err)) + } + + distributionRepo := distribution.NewRepository(deps.DB, deps.Redis) + distributionRepo.SetEncryptor(router.NewFieldEncryptor(cfg, logger)) + distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv)) + resolver := distribution.NewSMSResolver(distributionRepo, logger) + + smsdispatch.New( + deps.DB, + deps.Redis, + logger, + router.NewSMSProvider(cfg, logger), + cfg.SMS.AliyunSignName, + configRepo, + resolver, + ).Start(ctx) + + listingstale.New(deps.DB, deps.Redis, logger, configRepo).Start(ctx) +} diff --git a/backend/internal/integrations/sms/aliyun.go b/backend/internal/integrations/sms/aliyun.go index f9d8503..56faa76 100644 --- a/backend/internal/integrations/sms/aliyun.go +++ b/backend/internal/integrations/sms/aliyun.go @@ -57,18 +57,45 @@ func NewAliyunProvider(cfg AliyunConfig, log *zap.Logger) (*AliyunProvider, erro } func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code string) error { - params, err := json.Marshal(map[string]string{"code": code}) - if err != nil { - return err + return p.SendTemplate(ctx, TemplateRequest{ + Phone: phone, + TemplateCode: p.loginTemplateCode, + Params: map[string]string{"code": code}, + }) +} + +// SendTemplate 发送已报备的模板短信。req.SignName 为空时回退到 Provider 默认签名。 +// 模板无变量时按阿里云要求传空 JSON,避免拼接文案导致模板不匹配。 +func (p *AliyunProvider) SendTemplate(ctx context.Context, req TemplateRequest) error { + phone := strings.TrimSpace(req.Phone) + if phone == "" { + return ErrProviderConfigInvalid + } + signName := strings.TrimSpace(req.SignName) + if signName == "" { + signName = p.signName + } + templateCode := strings.TrimSpace(req.TemplateCode) + if templateCode == "" { + return ErrProviderConfigInvalid } - req := &dysms.SendSmsRequest{} - req.SetPhoneNumbers(phone) - req.SetSignName(p.signName) - req.SetTemplateCode(p.loginTemplateCode) - req.SetTemplateParam(string(params)) + params := "{}" + if len(req.Params) > 0 { + raw, err := json.Marshal(req.Params) + if err != nil { + return err + } + params = string(raw) + } - resp, err := p.client.SendSmsWithContext(ctx, req, &dara.RuntimeOptions{}) + apiReq := &dysms.SendSmsRequest{} + apiReq.SetPhoneNumbers(phone) + apiReq.SetSignName(signName) + apiReq.SetTemplateCode(templateCode) + apiReq.SetTemplateParam(params) + + resp, err := p.client.SendSmsWithContext(ctx, apiReq, &dara.RuntimeOptions{}) if err != nil { return fmt.Errorf("aliyun sms send failed: %w", err) } @@ -90,6 +117,7 @@ func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code s p.log.Debug( "短信发送成功", zap.String("phone", maskPhone(phone)), + zap.String("template_code", templateCode), zap.String("provider_request_id", dara.StringValue(resp.Body.RequestId)), ) } diff --git a/backend/internal/integrations/sms/mock.go b/backend/internal/integrations/sms/mock.go index 43241fa..8a55bb7 100644 --- a/backend/internal/integrations/sms/mock.go +++ b/backend/internal/integrations/sms/mock.go @@ -23,3 +23,17 @@ func (p *MockProvider) SendLoginCode(ctx context.Context, phone string, code str } return nil } + +func (p *MockProvider) SendTemplate(ctx context.Context, req TemplateRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if p.log != nil { + p.log.Info("本地模板短信(未真实发送)", + zap.String("phone", maskPhone(req.Phone)), + zap.String("template_code", req.TemplateCode), + zap.Any("params", req.Params), + ) + } + return nil +} diff --git a/backend/internal/integrations/sms/provider.go b/backend/internal/integrations/sms/provider.go index 7fd1fef..5de4b22 100644 --- a/backend/internal/integrations/sms/provider.go +++ b/backend/internal/integrations/sms/provider.go @@ -11,8 +11,19 @@ var ( ErrProviderRateLimited = errors.New("sms provider rate limited") ) +// TemplateRequest 描述一条模板短信。SignName 留空时使用 Provider 自身配置的默认签名。 +type TemplateRequest struct { + Phone string + SignName string + TemplateCode string + Params map[string]string +} + type Provider interface { SendLoginCode(ctx context.Context, phone string, code string) error + // SendTemplate 发送模板短信,用于登录验证码以外的业务节点触达。 + // 阿里云要求模板必须提前报备审核,因此调用方只传模板 CODE 与变量,不拼接文案。 + SendTemplate(ctx context.Context, req TemplateRequest) error } type ProviderError struct { diff --git a/backend/internal/integrations/sms/unavailable.go b/backend/internal/integrations/sms/unavailable.go index 904f5bf..61ea798 100644 --- a/backend/internal/integrations/sms/unavailable.go +++ b/backend/internal/integrations/sms/unavailable.go @@ -19,3 +19,7 @@ func (p *UnavailableProvider) SendLoginCode(ctx context.Context, phone string, c } return ErrProviderConfigInvalid } + +func (p *UnavailableProvider) SendTemplate(ctx context.Context, req TemplateRequest) error { + return p.SendLoginCode(ctx, req.Phone, "") +} diff --git a/backend/internal/jobs/listingstale/job.go b/backend/internal/jobs/listingstale/job.go new file mode 100644 index 0000000..416e0fc --- /dev/null +++ b/backend/internal/jobs/listingstale/job.go @@ -0,0 +1,178 @@ +// Package listingstale 扫描「上架满 N 天仍未成交」的账号,为号主入队一条降价建议短信。 +// +// 只处理「刚跨过阈值」的账号(阈值 ~ 阈值+候选窗口),原因有两个: +// 1. 直接扫描所有 published_at <= now-N 天的账号,功能首次上线会把历史上长期滞销的 +// 账号一次性全部触发,造成短信轰炸与费用失控; +// 2. 配合 sms_messages 的 (节点, 业务对象, 接收人) 唯一索引,每个账号最多提醒一次。 +package listingstale + +import ( + "context" + "fmt" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/sms" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" +) + +const ( + lockKey = "hfb:job:listingstale:lock" + // candidateWindow 候选窗口,保证每个账号在跨越阈值后仍能被扫到一次。 + candidateWindow = 24 * time.Hour + // defaultBatchSize 单轮最多处理的账号数;同一窗口内候选超过该值时由后续轮次补齐。 + defaultBatchSize = 200 +) + +type Job struct { + db *gorm.DB + redis *redis.Client + logger *zap.Logger + configs sms.ConfigReader + interval time.Duration + batchSize int + instanceID string +} + +func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, configs sms.ConfigReader) *Job { + return &Job{ + db: db, + redis: redisClient, + logger: logger, + configs: configs, + interval: time.Hour, + batchSize: defaultBatchSize, + instanceID: fmt.Sprintf("listingstale-%d", time.Now().UnixNano()), + } +} + +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() + + enqueued, err := j.RunOnce(ctx) + if err != nil { + j.logger.Warn("上架未成交降价提醒扫描失败", zap.Error(err)) + return + } + if enqueued > 0 { + j.logger.Info("上架未成交降价提醒已入队", zap.Int("count", enqueued)) + } +} + +// RunOnce 执行一次扫描并返回本次新入队的短信数量。 +func (j *Job) RunOnce(ctx context.Context) (int, error) { + cfg, err := sms.LoadConfig(ctx, j.configs) + if err != nil { + return 0, err + } + if !cfg.NodeActive(sms.NodeListingStalePriceSuggestion) { + return 0, nil + } + + staleDays := cfg.StaleDaysFor(sms.NodeListingStalePriceSuggestion) + now := time.Now() + threshold := now.AddDate(0, 0, -staleDays) + windowStart := threshold.Add(-candidateWindow) + + var rows []model.RentalListing + // NOT EXISTS 排除已提醒过的账号,否则已入队的记录会一直占满 LIMIT 名额: + // 候选窗口有 24 小时,任务每小时跑一次,若窗口内候选超过 batchSize, + // 每次都会查回同一批「已提醒」的账号,排在后面的账号会在窗口结束后被永久漏发。 + err = j.db.WithContext(ctx). + Select("id", "owner_id", "distribution_site_id", "published_at"). + Where("status = ? AND in_transaction = ?", "published", false). + Where("published_at IS NOT NULL AND published_at <= ? AND published_at > ?", threshold, windowStart). + Where(`NOT EXISTS ( + SELECT 1 FROM sms_messages sm + WHERE sm.node = ? AND sm.biz_type = ? AND sm.biz_id = rental_listings.id + )`, sms.NodeListingStalePriceSuggestion, "listing"). + Order("id ASC"). + Limit(j.batchSize). + Find(&rows).Error + if err != nil { + return 0, err + } + + enqueued := 0 + for _, row := range rows { + if err := ctx.Err(); err != nil { + return enqueued, err + } + if row.OwnerID == 0 { + continue + } + inserted, err := sms.Enqueue(j.db.WithContext(ctx), sms.Entry{ + UserID: row.OwnerID, + Node: sms.NodeListingStalePriceSuggestion, + BizType: "listing", + BizID: row.ID, + DistributionSiteID: row.DistributionSiteID, + }) + if err != nil { + j.logger.Warn("上架未成交降价提醒入队失败", + zap.Uint64("listing_id", row.ID), + zap.Error(err), + ) + continue + } + if inserted { + enqueued++ + } + } + return enqueued, nil +} + +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)) + } +} diff --git a/backend/internal/jobs/listingstale/job_test.go b/backend/internal/jobs/listingstale/job_test.go new file mode 100644 index 0000000..9a7d49b --- /dev/null +++ b/backend/internal/jobs/listingstale/job_test.go @@ -0,0 +1,235 @@ +package listingstale + +import ( + "context" + "testing" + "time" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/sms" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +type fakeConfigReader struct { + value string +} + +func (r *fakeConfigReader) FindValue(ctx context.Context, key string) (string, error) { + if key != sms.ConfigKey { + return "", gorm.ErrRecordNotFound + } + return r.value, nil +} + +func staleConfig(t *testing.T, enabled bool, staleDays int) string { + t.Helper() + raw, err := sms.MarshalConfig(sms.Config{ + Enabled: true, + Nodes: map[string]sms.NodeConfig{ + sms.NodeListingStalePriceSuggestion: { + Enabled: enabled, + TemplateCode: "SMS_512620985", + StaleDays: staleDays, + }, + }, + }) + if err != nil { + t.Fatalf("MarshalConfig() error = %v", err) + } + return raw +} + +func newStaleTestDB(t *testing.T) *gorm.DB { + t.Helper() + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, &model.SmsMessage{}, &model.RentalListing{}, &model.User{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + return db +} + +func seedListing(t *testing.T, db *gorm.DB, listing model.RentalListing) model.RentalListing { + t.Helper() + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + return listing +} + +func TestRunOnceEnqueuesOnlyListingsJustCrossingThreshold(t *testing.T) { + db := newStaleTestDB(t) + ctx := context.Background() + + owner := model.User{Phone: "13900000001", Nickname: "号主"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + now := time.Now() + justStale := now.Add(-3*24*time.Hour - 6*time.Hour) // 刚跨过 3 天阈值,窗口内 + longStale := now.Add(-10 * 24 * time.Hour) // 窗口外,功能上线前的历史滞销账号 + fresh := now.Add(-24 * time.Hour) // 还没到阈值 + staleAt := now.Add(-3*24*time.Hour - 6*time.Hour) + + // 命中:上架满 3 天、仍在架、未成交。 + hit := seedListing(t, db, model.RentalListing{ + ListingNo: "L-HIT", OwnerID: owner.ID, Status: "published", PublishedAt: &justStale, + }) + // 窗口外:上架过久,避免功能上线时批量轰炸。 + seedListing(t, db, model.RentalListing{ + ListingNo: "L-OLD", OwnerID: owner.ID, Status: "published", PublishedAt: &longStale, + }) + // 未到阈值。 + seedListing(t, db, model.RentalListing{ + ListingNo: "L-FRESH", OwnerID: owner.ID, Status: "published", PublishedAt: &fresh, + }) + // 已成交(在租)。 + seedListing(t, db, model.RentalListing{ + ListingNo: "L-RENTED", OwnerID: owner.ID, Status: "rented", PublishedAt: &staleAt, + }) + // 交易中。 + seedListing(t, db, model.RentalListing{ + ListingNo: "L-INCHARGE", OwnerID: owner.ID, Status: "published", InTransaction: true, PublishedAt: &staleAt, + }) + + job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, true, 3)}) + + count, err := job.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce() error = %v", err) + } + if count != 1 { + t.Fatalf("RunOnce() = %d, want 1", count) + } + + var rows []model.SmsMessage + if err := db.Where("node = ?", sms.NodeListingStalePriceSuggestion).Find(&rows).Error; err != nil { + t.Fatalf("查询短信记录失败: %v", err) + } + if len(rows) != 1 { + t.Fatalf("短信记录数 = %d, want 1", len(rows)) + } + if rows[0].BizID != hit.ID || rows[0].BizType != "listing" { + t.Fatalf("业务对象 = %s/%d, want listing/%d", rows[0].BizType, rows[0].BizID, hit.ID) + } + if rows[0].UserID != owner.ID || rows[0].Phone != owner.Phone { + t.Fatalf("接收人 = %d/%s, want %d/%s", rows[0].UserID, rows[0].Phone, owner.ID, owner.Phone) + } + + // 再次执行应因唯一索引去重而不重复入队。 + count, err = job.RunOnce(ctx) + if err != nil { + t.Fatalf("重复 RunOnce() error = %v", err) + } + if count != 0 { + t.Fatalf("重复 RunOnce() = %d, want 0(同一账号只提醒一次)", count) + } + var total int64 + if err := db.Model(&model.SmsMessage{}).Count(&total).Error; err != nil { + t.Fatalf("统计失败: %v", err) + } + if total != 1 { + t.Fatalf("短信记录总数 = %d, want 1", total) + } +} + +// 同一候选窗口内的账号数超过批次上限时,已提醒过的账号不能继续占用批次名额, +// 否则排在后面的账号会在窗口结束后被永久漏发。 +func TestRunOnceDoesNotStarveListingsBeyondBatchSize(t *testing.T) { + db := newStaleTestDB(t) + ctx := context.Background() + + owner := model.User{Phone: "13900000004"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + staleAt := time.Now().Add(-3*24*time.Hour - 6*time.Hour) + expected := map[uint64]bool{} + for _, no := range []string{"L-BATCH-1", "L-BATCH-2", "L-BATCH-3"} { + listing := seedListing(t, db, model.RentalListing{ + ListingNo: no, OwnerID: owner.ID, Status: "published", PublishedAt: &staleAt, + }) + expected[listing.ID] = true + } + + job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, true, 3)}) + job.batchSize = 1 + + for round := 1; round <= len(expected); round++ { + count, err := job.RunOnce(ctx) + if err != nil { + t.Fatalf("第 %d 轮 RunOnce() error = %v", round, err) + } + if count != 1 { + t.Fatalf("第 %d 轮 RunOnce() = %d, want 1(已提醒的账号不应占满批次名额)", round, count) + } + } + + var rows []model.SmsMessage + if err := db.Where("node = ?", sms.NodeListingStalePriceSuggestion).Find(&rows).Error; err != nil { + t.Fatalf("查询短信记录失败: %v", err) + } + if len(rows) != len(expected) { + t.Fatalf("短信记录数 = %d, want %d", len(rows), len(expected)) + } + for _, row := range rows { + if !expected[row.BizID] { + t.Fatalf("出现未预期的业务对象 %d", row.BizID) + } + delete(expected, row.BizID) + } + if len(expected) != 0 { + t.Fatalf("仍有账号未被提醒: %v", expected) + } +} + +func TestRunOnceSkipsWhenNodeInactive(t *testing.T) { + db := newStaleTestDB(t) + ctx := context.Background() + + owner := model.User{Phone: "13900000002"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + staleAt := time.Now().Add(-3*24*time.Hour - 6*time.Hour) + seedListing(t, db, model.RentalListing{ + ListingNo: "L-OFF", OwnerID: owner.ID, Status: "published", PublishedAt: &staleAt, + }) + + job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, false, 3)}) + count, err := job.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce() error = %v", err) + } + if count != 0 { + t.Fatalf("节点关闭时 RunOnce() = %d, want 0", count) + } +} + +func TestRunOnceRespectsConfiguredStaleDays(t *testing.T) { + db := newStaleTestDB(t) + ctx := context.Background() + + owner := model.User{Phone: "13900000003"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + // 上架 3.5 天:阈值为 3 天时命中,阈值调成 5 天则不应命中。 + staleAt := time.Now().Add(-3*24*time.Hour - 12*time.Hour) + seedListing(t, db, model.RentalListing{ + ListingNo: "L-DAYS", OwnerID: owner.ID, Status: "published", PublishedAt: &staleAt, + }) + + job := New(db, nil, zap.NewNop(), &fakeConfigReader{value: staleConfig(t, true, 5)}) + count, err := job.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce() error = %v", err) + } + if count != 0 { + t.Fatalf("阈值 5 天时 RunOnce() = %d, want 0", count) + } +} diff --git a/backend/internal/jobs/smsdispatch/job.go b/backend/internal/jobs/smsdispatch/job.go new file mode 100644 index 0000000..d510687 --- /dev/null +++ b/backend/internal/jobs/smsdispatch/job.go @@ -0,0 +1,274 @@ +// 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:] +} diff --git a/backend/internal/jobs/smsdispatch/job_test.go b/backend/internal/jobs/smsdispatch/job_test.go new file mode 100644 index 0000000..e5ab184 --- /dev/null +++ b/backend/internal/jobs/smsdispatch/job_test.go @@ -0,0 +1,397 @@ +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) + // 退避设为 0,便于在测试内连续重试。 + job.retryDelay = 0 + + 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("退避窗口内不应重试") + } +} diff --git a/backend/internal/model/sms_message.go b/backend/internal/model/sms_message.go new file mode 100644 index 0000000..a5075df --- /dev/null +++ b/backend/internal/model/sms_message.go @@ -0,0 +1,41 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// SmsMessage 是节点短信发送队列记录。 +// 业务事务内只写入 pending,真正的发送由后台任务完成, +// 因此短信通道异常不会影响业务提交。 +type SmsMessage struct { + ID uint64 `gorm:"primaryKey" json:"id"` + UserID uint64 `gorm:"not null;default:0" json:"user_id"` + Phone string `gorm:"size:32;not null;default:''" json:"phone"` + Node string `gorm:"size:64;not null" json:"node"` + BizType string `gorm:"size:32;not null;default:''" json:"biz_type"` + BizID uint64 `gorm:"not null;default:0" json:"biz_id"` + DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"` + TemplateCode string `gorm:"size:64;not null;default:''" json:"template_code"` + TemplateParams datatypes.JSON `json:"template_params"` + SignName string `gorm:"size:64;not null;default:''" json:"sign_name"` + Status string `gorm:"size:16;not null;default:'pending'" json:"status"` + SkipReason string `gorm:"size:128;not null;default:''" json:"skip_reason"` + RetryCount int `gorm:"not null;default:0" json:"retry_count"` + LastError string `gorm:"size:512;not null;default:''" json:"last_error"` + SentAt *time.Time `json:"sent_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (SmsMessage) TableName() string { + return "sms_messages" +} + +const ( + SmsStatusPending = "pending" + SmsStatusSent = "sent" + SmsStatusFailed = "failed" + SmsStatusSkipped = "skipped" +) diff --git a/backend/internal/modules/auth/sms_resolver_test.go b/backend/internal/modules/auth/sms_resolver_test.go index 0af99fe..9ce6775 100644 --- a/backend/internal/modules/auth/sms_resolver_test.go +++ b/backend/internal/modules/auth/sms_resolver_test.go @@ -20,6 +20,11 @@ func (p *recordProvider) SendLoginCode(ctx context.Context, phone string, code s return nil } +func (p *recordProvider) SendTemplate(ctx context.Context, req smsprovider.TemplateRequest) error { + p.sentPhones = append(p.sentPhones, req.Phone) + return nil +} + type mockResolver struct { customProvider smsprovider.Provider calledSites []string diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index f1f0d0b..db6546a 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -597,14 +597,15 @@ type DistributionTrendPoint struct { // 分销站点独立短信配置结构体 type SiteSMSConfigDTO struct { - Enabled bool `json:"enabled"` - Provider string `json:"provider"` - AccessKeyID string `json:"access_key_id"` - MaskedSecret string `json:"masked_secret"` - HasSecret bool `json:"has_secret"` - Endpoint string `json:"endpoint"` - SignName string `json:"sign_name"` - LoginTemplateCode string `json:"login_template_code"` + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + AccessKeyID string `json:"access_key_id"` + MaskedSecret string `json:"masked_secret"` + HasSecret bool `json:"has_secret"` + Endpoint string `json:"endpoint"` + SignName string `json:"sign_name"` + LoginTemplateCode string `json:"login_template_code"` + BusinessTemplates map[string]string `json:"business_templates"` } type UpdateSiteSMSConfigRequest struct { @@ -615,6 +616,8 @@ type UpdateSiteSMSConfigRequest struct { Endpoint *string `json:"endpoint"` SignName *string `json:"sign_name"` LoginTemplateCode *string `json:"login_template_code"` + // BusinessTemplates 传入时整体覆盖站点节点模板映射,传空对象即清空。 + BusinessTemplates *map[string]string `json:"business_templates"` } type TestSiteSMSConfigRequest struct { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 91400cb..c6d2376 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -1045,6 +1045,10 @@ func (r *Repository) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID ui if req.LoginTemplateCode != nil { cfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode) } + if req.BusinessTemplates != nil { + // 整体覆盖语义:后台按节点逐一提交,空对象表示清空并回退主站模板。 + cfg.BusinessTemplates = normalizeBusinessTemplates(*req.BusinessTemplates) + } // 完整性校验:必须属于合法 provider 白名单,且开启时必须提供完整凭据 providerType, err := r.validateSMSProvider(cfg.Provider) diff --git a/backend/internal/modules/distribution/sms_business_templates_test.go b/backend/internal/modules/distribution/sms_business_templates_test.go new file mode 100644 index 0000000..1361737 --- /dev/null +++ b/backend/internal/modules/distribution/sms_business_templates_test.go @@ -0,0 +1,230 @@ +package distribution + +import ( + "context" + "testing" + + "hfb_sys/backend/internal/model" + smsnode "hfb_sys/backend/internal/modules/sms" + + "go.uber.org/zap" +) + +func TestParseSiteSMSConfigNormalizesBusinessTemplates(t *testing.T) { + cfg, err := ParseSiteSMSConfig(`{ + "enabled": true, + "sign_name": "分站签名", + "business_templates": { + "order_handoff_assist": " SMS_512540945 ", + "listing_review_rejected": " ", + "unknown_node": "SMS_BAD", + "": "SMS_EMPTY" + } + }`) + if err != nil { + t.Fatalf("ParseSiteSMSConfig() error = %v", err) + } + if len(cfg.BusinessTemplates) != 1 { + t.Fatalf("business_templates = %+v, want 仅保留已登记且非空的模板", cfg.BusinessTemplates) + } + if got := cfg.BusinessTemplates[smsnode.NodeOrderHandoffAssist]; got != "SMS_512540945" { + t.Fatalf("order_handoff_assist = %q, want SMS_512540945", got) + } + if _, ok := cfg.BusinessTemplates["unknown_node"]; ok { + t.Fatal("未登记节点不应保留") + } +} + +func TestUpdateSiteSMSConfigPersistsBusinessTemplates(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db, nil) + ctx := context.Background() + + site := model.DistributionSite{ + SiteCode: "site_sms_node", + SiteName: "节点短信站", + Domain: "smsnode.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + templates := map[string]string{ + smsnode.NodeOrderHandoffAssist: "SMS_512540945", + smsnode.NodeListingReviewRejected: "SMS_512520993", + "unknown_node": "SMS_BAD", + } + updated, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{ + BusinessTemplates: &templates, + }) + if err != nil { + t.Fatalf("UpdateSiteSMSConfig() error = %v", err) + } + if len(updated.BusinessTemplates) != 2 { + t.Fatalf("business_templates = %+v, want 2 项(过滤未登记节点)", updated.BusinessTemplates) + } + + // 未传 BusinessTemplates 时不能被清空。 + newSign := "分站签名" + if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{SignName: &newSign}); err != nil { + t.Fatalf("UpdateSiteSMSConfig() error = %v", err) + } + reloaded, err := repo.GetSiteSMSConfig(ctx, 0, site.ID) + if err != nil { + t.Fatalf("GetSiteSMSConfig() error = %v", err) + } + if reloaded.BusinessTemplates[smsnode.NodeOrderHandoffAssist] != "SMS_512540945" { + t.Fatalf("未传该字段时模板应保留,实际 = %+v", reloaded.BusinessTemplates) + } + + // 传空对象表示清空,节点回退主站模板。 + empty := map[string]string{} + cleared, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{BusinessTemplates: &empty}) + if err != nil { + t.Fatalf("UpdateSiteSMSConfig() error = %v", err) + } + if len(cleared.BusinessTemplates) != 0 { + t.Fatalf("business_templates 应被清空, 实际 = %+v", cleared.BusinessTemplates) + } +} + +// 后台改了站点短信凭据后,长期运行的任务必须能自动重建 Provider, +// 否则更换 AccessKey 要重启进程才会生效。 +func TestResolveBusinessSenderRebuildsProviderAfterCredentialChange(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db, nil) + repo.SetAllowMockSMS(true) + ctx := context.Background() + + site := model.DistributionSite{ + SiteCode: "site_sms_rotate", + SiteName: "凭据轮换站", + Domain: "smsrotate.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + enabled := true + provider := "mock" + sign := "【分站签名】" + oldKey := "LTAI_OLD_KEY" + if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{ + Enabled: &enabled, + Provider: &provider, + SignName: &sign, + AccessKeyID: &oldKey, + }); err != nil { + t.Fatalf("UpdateSiteSMSConfig() error = %v", err) + } + + resolver := NewSMSResolver(repo, zap.NewNop()) + first, _, _, err := resolver.ResolveBusinessSender(ctx, site.ID) + if err != nil { + t.Fatalf("ResolveBusinessSender() error = %v", err) + } + if first == nil { + t.Fatal("站点已启用独立短信时应返回通道") + } + + // 凭据未变化时应命中缓存,返回同一实例。 + again, _, _, err := resolver.ResolveBusinessSender(ctx, site.ID) + if err != nil { + t.Fatalf("ResolveBusinessSender() error = %v", err) + } + if again != first { + t.Fatal("凭据未变化时应复用缓存 Provider") + } + + newKey := "LTAI_NEW_KEY" + if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{ + AccessKeyID: &newKey, + }); err != nil { + t.Fatalf("UpdateSiteSMSConfig() error = %v", err) + } + + rebuilt, _, _, err := resolver.ResolveBusinessSender(ctx, site.ID) + if err != nil { + t.Fatalf("ResolveBusinessSender() error = %v", err) + } + if rebuilt == nil { + t.Fatal("凭据轮换后仍应返回可用通道") + } + if rebuilt == first { + t.Fatal("凭据轮换后应重建 Provider,而不是继续复用旧缓存") + } +} + +func TestResolveBusinessSenderReturnsSiteTemplates(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db, nil) + repo.SetAllowMockSMS(true) + ctx := context.Background() + + site := model.DistributionSite{ + SiteCode: "site_sms_sender", + SiteName: "节点短信通道站", + Domain: "smssender.com", + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + enabled := true + provider := "mock" + sign := "【分站签名】" + templates := map[string]string{smsnode.NodeOrderHandoffAssist: "SMS_SITE"} + if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{ + Enabled: &enabled, + Provider: &provider, + SignName: &sign, + BusinessTemplates: &templates, + }); err != nil { + t.Fatalf("UpdateSiteSMSConfig() error = %v", err) + } + + resolver := NewSMSResolver(repo, zap.NewNop()) + sender, signName, siteTemplates, err := resolver.ResolveBusinessSender(ctx, site.ID) + if err != nil { + t.Fatalf("ResolveBusinessSender() error = %v", err) + } + if sender == nil { + t.Fatal("站点已启用独立短信时应返回通道") + } + if signName != sign { + t.Fatalf("signName = %q, want %q", signName, sign) + } + if siteTemplates[smsnode.NodeOrderHandoffAssist] != "SMS_SITE" { + t.Fatalf("站点模板 = %+v", siteTemplates) + } + + // 站点未启用独立短信时返回 nil,调用方回退主站通道。 + other := model.DistributionSite{ + SiteCode: "site_sms_off", + SiteName: "未启用短信站", + Domain: "smsoff.com", + Status: "active", + } + if err := db.Create(&other).Error; err != nil { + t.Fatal(err) + } + disabledSender, _, _, err := resolver.ResolveBusinessSender(ctx, other.ID) + if err != nil { + t.Fatalf("ResolveBusinessSender() error = %v", err) + } + if disabledSender != nil { + t.Fatal("站点未启用时应返回 nil 以便回退主站") + } + + // 站点不存在(或非 active)时同样返回 nil,不报错。 + missingSender, _, _, err := resolver.ResolveBusinessSender(ctx, 999999) + if err != nil { + t.Fatalf("ResolveBusinessSender() error = %v", err) + } + if missingSender != nil { + t.Fatal("站点不存在时应返回 nil") + } +} diff --git a/backend/internal/modules/distribution/sms_resolver.go b/backend/internal/modules/distribution/sms_resolver.go index 2521e02..aa2b823 100644 --- a/backend/internal/modules/distribution/sms_resolver.go +++ b/backend/internal/modules/distribution/sms_resolver.go @@ -3,6 +3,8 @@ package distribution import ( "context" "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -13,6 +15,7 @@ import ( smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/model" + smsnode "hfb_sys/backend/internal/modules/sms" "go.uber.org/zap" "gorm.io/gorm" @@ -33,6 +36,30 @@ type SiteSMSConfig struct { Endpoint string `json:"endpoint,omitempty"` SignName string `json:"sign_name"` LoginTemplateCode string `json:"login_template_code"` + // BusinessTemplates 站点自有的节点短信模板 CODE,key 为 sms 模块登记的节点标识。 + // 站点启用独立短信后,这里配置了模板的节点走站点通道与站点签名; + // 未配置的节点回退主站通道与主站模板。 + BusinessTemplates map[string]string `json:"business_templates,omitempty"` +} + +// normalizeBusinessTemplates 只保留已登记节点且模板 CODE 非空的配置项。 +func normalizeBusinessTemplates(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for code, template := range in { + code = strings.TrimSpace(code) + template = strings.TrimSpace(template) + if code == "" || template == "" || !smsnode.IsKnownNode(code) { + continue + } + out[code] = template + } + if len(out) == 0 { + return nil + } + return out } // MaskSecret 对敏感密钥进行掩码脱敏 @@ -62,6 +89,7 @@ func (c *SiteSMSConfig) ToDTO() *SiteSMSConfigDTO { Endpoint: c.Endpoint, SignName: c.SignName, LoginTemplateCode: c.LoginTemplateCode, + BusinessTemplates: normalizeBusinessTemplates(c.BusinessTemplates), } } @@ -75,6 +103,7 @@ func ParseSiteSMSConfig(raw string) (*SiteSMSConfig, error) { if err := json.Unmarshal([]byte(raw), &cfg); err != nil { return nil, err } + cfg.BusinessTemplates = normalizeBusinessTemplates(cfg.BusinessTemplates) return &cfg, nil } @@ -115,12 +144,35 @@ func BuildProvider(cfg SiteSMSConfig, logger *zap.Logger) (smsprovider.Provider, } } +// cachedSMSProvider 是站点 Provider 的缓存项。 +// fingerprint 取自站点凭据与关键字段,凭据轮换后指纹变化即自动重建, +// 避免「后台改了 AccessKey,长期运行的任务仍在用旧缓存」。 +type cachedSMSProvider struct { + provider smsprovider.Provider + fingerprint string +} + +// cacheFingerprint 用凭据密文与关键字段生成缓存指纹。 +// 密文即每次保存都会变化,因此重复保存同一份配置最多只是重建一次 Provider,不影响正确性。 +func (c *SiteSMSConfig) cacheFingerprint() string { + raw := strings.Join([]string{ + strings.TrimSpace(c.Provider), + strings.TrimSpace(c.AccessKeyID), + strings.TrimSpace(c.AccessKeySecret), + strings.TrimSpace(c.Endpoint), + strings.TrimSpace(c.SignName), + strings.TrimSpace(c.LoginTemplateCode), + }, "|") + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + // SMSResolver 管理分销站点的 SMS Provider 实例与缓存 type SMSResolver struct { repo *Repository logger *zap.Logger mu sync.RWMutex - providers map[uint64]smsprovider.Provider + providers map[uint64]cachedSMSProvider testCooldown sync.Map } @@ -128,7 +180,7 @@ func NewSMSResolver(repo *Repository, logger *zap.Logger) *SMSResolver { return &SMSResolver{ repo: repo, logger: logger, - providers: make(map[uint64]smsprovider.Provider), + providers: make(map[uint64]cachedSMSProvider), } } @@ -139,6 +191,23 @@ func (r *SMSResolver) InvalidateCache(siteID uint64) { delete(r.providers, siteID) } +// cached 返回指纹一致的缓存 Provider,指纹不一致(凭据已轮换)时返回 nil 由调用方重建。 +func (r *SMSResolver) cached(siteID uint64, fingerprint string) smsprovider.Provider { + r.mu.RLock() + entry, ok := r.providers[siteID] + r.mu.RUnlock() + if !ok || entry.provider == nil || entry.fingerprint != fingerprint { + return nil + } + return entry.provider +} + +func (r *SMSResolver) store(siteID uint64, fingerprint string, provider smsprovider.Provider) { + r.mu.Lock() + r.providers[siteID] = cachedSMSProvider{provider: provider, fingerprint: fingerprint} + r.mu.Unlock() +} + // ResolveSMSProvider 根据 siteCode 或 domain 解析分销站点专属的 SMS Provider。 // 若分销站点未启用自定义短信或配置不完整,则返回 nil,调用方将优雅降级为主站默认 Provider。 func (r *SMSResolver) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) { @@ -168,12 +237,10 @@ func (r *SMSResolver) ResolveSMSProvider(ctx context.Context, siteCode, domain s return nil, nil } - // 检查内存缓存 - r.mu.RLock() - p, ok := r.providers[site.ID] - r.mu.RUnlock() - if ok && p != nil { - return p, nil + // 检查内存缓存;凭据已轮换(指纹不一致)时视为未命中,重建 Provider + fingerprint := cfg.cacheFingerprint() + if cached := r.cached(site.ID, fingerprint); cached != nil { + return cached, nil } // 解密 AccessKeySecret;解密失败说明密钥轮换或密文损坏,回退主站并留下告警 @@ -203,13 +270,70 @@ func (r *SMSResolver) ResolveSMSProvider(ctx context.Context, siteCode, domain s return nil, nil } - r.mu.Lock() - r.providers[site.ID] = provider - r.mu.Unlock() + r.store(site.ID, fingerprint, provider) return provider, nil } +// ResolveBusinessSender 返回启用独立短信的分站通道,用于业务节点短信。 +// 返回值为站点 Provider、站点签名与该站点的节点模板映射; +// 站点未启用、配置破损或密钥解密失败时返回 nil Provider,调用方回退主站通道。 +func (r *SMSResolver) ResolveBusinessSender(ctx context.Context, siteID uint64) (smsprovider.Provider, string, map[string]string, error) { + if r == nil || r.repo == nil || siteID == 0 { + return nil, "", nil, nil + } + + site, err := r.repo.FindActiveSiteByID(ctx, siteID) + if err != nil { + if errors.Is(err, ErrSiteNotFound) { + return nil, "", nil, nil + } + return nil, "", nil, err + } + if site == nil || strings.TrimSpace(site.SMSConfigJSON) == "" { + return nil, "", nil, nil + } + + cfg, err := ParseSiteSMSConfig(site.SMSConfigJSON) + if err != nil || cfg == nil || !cfg.Enabled { + return nil, "", nil, nil + } + + fingerprint := cfg.cacheFingerprint() + if cached := r.cached(site.ID, fingerprint); cached != nil { + return cached, strings.TrimSpace(cfg.SignName), cfg.BusinessTemplates, nil + } + + plainSecret, decErr := r.repo.decryptSecret(cfg.AccessKeySecret) + if decErr != nil { + if r.logger != nil { + r.logger.Warn("解密分销站点短信密钥失败,节点短信将回退主站通道", + zap.Uint64("site_id", site.ID), + zap.String("site_code", site.SiteCode), + zap.Error(decErr), + ) + } + return nil, "", cfg.BusinessTemplates, nil + } + cfg.AccessKeySecret = plainSecret + + provider, buildErr := BuildProvider(*cfg, r.logger) + if buildErr != nil || provider == nil { + if r.logger != nil { + r.logger.Warn("分销站点自定义短信配置无效,节点短信将回退主站通道", + zap.Uint64("site_id", site.ID), + zap.String("site_code", site.SiteCode), + zap.Error(buildErr), + ) + } + return nil, "", cfg.BusinessTemplates, nil + } + + r.store(site.ID, fingerprint, provider) + + return provider, strings.TrimSpace(cfg.SignName), cfg.BusinessTemplates, nil +} + // TestSendSMS 使用给定的短信配置,向指定手机号发送一条测试验证码以检验连通性 func (r *SMSResolver) TestSendSMS(ctx context.Context, siteID uint64, cfg SiteSMSConfig, phone string) error { phone = strings.TrimSpace(phone) @@ -303,3 +427,22 @@ func (r *Repository) FindActiveSiteByCodeOrDomain(ctx context.Context, code, dom } return &site, nil } + +// FindActiveSiteByID 按主键查询状态为 active 的分销站点实体。 +func (r *Repository) FindActiveSiteByID(ctx context.Context, siteID uint64) (*model.DistributionSite, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + if siteID == 0 { + return nil, ErrSiteNotFound + } + + var site model.DistributionSite + if err := r.db.WithContext(ctx).Where("id = ? AND status = 'active'", siteID).First(&site).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + return &site, nil +} diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index c848724..167ab71 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + smsnode "hfb_sys/backend/internal/modules/sms" "gorm.io/datatypes" "gorm.io/gorm" @@ -389,6 +390,16 @@ func (r *Repository) Reject(ctx context.Context, listingID uint64, req ReviewReq }); err != nil { return err } + // 节点短信:审核未通过提醒号主修改后重新上架。只入队,由后台任务异步发送。 + if _, err := smsnode.Enqueue(tx, smsnode.Entry{ + UserID: listing.OwnerID, + Node: smsnode.NodeListingReviewRejected, + BizType: "listing", + BizID: listingID, + DistributionSiteID: listing.DistributionSiteID, + }); err != nil { + return err + } dto = toDTO(*account, *listing) return nil }) diff --git a/backend/internal/modules/listing/sms_node_test.go b/backend/internal/modules/listing/sms_node_test.go new file mode 100644 index 0000000..0270028 --- /dev/null +++ b/backend/internal/modules/listing/sms_node_test.go @@ -0,0 +1,79 @@ +package listing + +import ( + "context" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + smsnode "hfb_sys/backend/internal/modules/sms" +) + +// 审核未通过时必须给号主入队一条短信(只落库,发送交给后台任务)。 +func TestRejectEnqueuesOwnerSms(t *testing.T) { + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, &model.SmsMessage{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + repo := NewRepository(db, nil) + + owner := model.User{Phone: "13700009001", Nickname: "号主"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + siteID := uint64(31) + account := model.GameAccount{ + OwnerID: owner.ID, + Status: "draft", + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "审核未通过账号", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + listing := model.RentalListing{ + ListingNo: "LST-REJECT-SMS-001", + AccountID: account.ID, + OwnerID: owner.ID, + Status: "pending_review", + ReviewStatus: "pending", + DistributionSiteID: &siteID, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + + if _, err := repo.Reject(context.Background(), listing.ID, ReviewRequest{Reason: "截图不清晰"}); err != nil { + t.Fatalf("Reject() error = %v", err) + } + + var row model.SmsMessage + if err := db.Where("node = ? AND biz_type = ? AND biz_id = ?", + smsnode.NodeListingReviewRejected, "listing", listing.ID).First(&row).Error; err != nil { + t.Fatalf("应给号主入队审核未通过短信, 查询失败: %v", err) + } + if row.UserID != owner.ID || row.Phone != owner.Phone { + t.Fatalf("收件人 = %d/%s, want %d/%s", row.UserID, row.Phone, owner.ID, owner.Phone) + } + if row.Status != model.SmsStatusPending { + t.Fatalf("status = %q, want %q", row.Status, model.SmsStatusPending) + } + if row.DistributionSiteID == nil || *row.DistributionSiteID != siteID { + t.Fatalf("distribution_site_id = %v, want %d", row.DistributionSiteID, siteID) + } + + // 后台重复点「拒绝」不应重复入队。 + if _, err := repo.Reject(context.Background(), listing.ID, ReviewRequest{Reason: "截图不清晰"}); err != nil { + t.Fatalf("重复 Reject() error = %v", err) + } + var count int64 + if err := db.Model(&model.SmsMessage{}). + Where("node = ? AND biz_id = ?", smsnode.NodeListingReviewRejected, listing.ID). + Count(&count).Error; err != nil { + t.Fatalf("统计失败: %v", err) + } + if count != 1 { + t.Fatalf("短信记录数 = %d, want 1(同一账号只发一次)", count) + } +} diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 66d3230..7520254 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -14,6 +14,7 @@ import ( listingpkg "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/rentergrowth" + smsnode "hfb_sys/backend/internal/modules/sms" "hfb_sys/backend/internal/processlog" "gorm.io/datatypes" @@ -406,6 +407,17 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint ); err != nil { return 0, err } + // 节点短信:账号被租用、需要号主辅助登录,提醒及时处理避免被强制结束订单。 + // 代管订单由客服提交交接说明,不触达号主,因此只在这个分支入队。 + if _, err := smsnode.Enqueue(tx, smsnode.Entry{ + UserID: order.OwnerID, + Node: smsnode.NodeOrderHandoffAssist, + BizType: "order", + BizID: orderID, + DistributionSiteID: order.DistributionSiteID, + }); err != nil { + return 0, err + } } if err := tx.Save(order).Error; err != nil { return 0, err diff --git a/backend/internal/modules/order/sms_node_test.go b/backend/internal/modules/order/sms_node_test.go new file mode 100644 index 0000000..9906b1f --- /dev/null +++ b/backend/internal/modules/order/sms_node_test.go @@ -0,0 +1,116 @@ +package order + +import ( + "testing" + + "hfb_sys/backend/internal/model" + smsnode "hfb_sys/backend/internal/modules/sms" + + "gorm.io/gorm" +) + +func seedHandoffAssistOrder(t *testing.T, db *gorm.DB, handoffMode string) (model.User, model.RentalOrder) { + t.Helper() + + owner := model.User{Phone: "13800009001"} + renter := model.User{Phone: "13900009001"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("创建租客失败: %v", err) + } + + account := model.GameAccount{ + OwnerID: owner.ID, + Status: accountStatusPublished, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "节点短信测试账号", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + + listing := model.RentalListing{ + ListingNo: "LST-SMS-NODE-001", + OwnerID: owner.ID, + AccountID: account.ID, + Status: listingStatusPublished, + ReviewStatus: listingReviewStatusApproved, + InTransaction: true, + PriceCent: 1000, + } + if handoffMode != "" { + listing.HandoffMode = handoffMode + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建商品失败: %v", err) + } + + order := model.RentalOrder{ + OrderNo: "ORD-SMS-NODE-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + RentAmountCent: 1000, + Status: orderStatusPendingPayment, + HandoffStatus: handoffStatusNone, + } + if handoffMode != "" { + // 代管判定读的是订单自己的交接模式,仅在商品上标记不会生效。 + order.HandoffMode = handoffMode + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + return owner, order +} + +// 支付成功进入待交接时,必须给号主入队一条「需要辅助登录」短信(仅落库,不在此处发送)。 +func TestConfirmPaidEnqueuesHandoffAssistSms(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + owner, order := seedHandoffAssistOrder(t, db, "") + + if _, err := repo.ConfirmPaidFromChannelTx(db, order.ID); err != nil { + t.Fatalf("ConfirmPaidFromChannelTx() error = %v", err) + } + + var row model.SmsMessage + if err := db.Where("node = ? AND biz_type = ? AND biz_id = ?", + smsnode.NodeOrderHandoffAssist, "order", order.ID).First(&row).Error; err != nil { + t.Fatalf("应给号主入队辅助登录短信, 查询失败: %v", err) + } + if row.UserID != owner.ID { + t.Fatalf("user_id = %d, want %d(收件人应是号主)", row.UserID, owner.ID) + } + if row.Phone != owner.Phone { + t.Fatalf("phone = %q, want %q", row.Phone, owner.Phone) + } + if row.Status != model.SmsStatusPending { + t.Fatalf("status = %q, want %q", row.Status, model.SmsStatusPending) + } +} + +// 代管订单由客服提交交接说明,不触达号主,因此不应产生号主短信。 +func TestConfirmPaidSkipsSmsForPlatformManagedOrder(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + _, order := seedHandoffAssistOrder(t, db, handoffModePlatform) + + if _, err := repo.ConfirmPaidFromChannelTx(db, order.ID); err != nil { + t.Fatalf("ConfirmPaidFromChannelTx() error = %v", err) + } + + var count int64 + if err := db.Model(&model.SmsMessage{}). + Where("node = ? AND biz_id = ?", smsnode.NodeOrderHandoffAssist, order.ID). + Count(&count).Error; err != nil { + t.Fatalf("统计短信记录失败: %v", err) + } + if count != 0 { + t.Fatalf("代管订单不应给号主发短信, 实际 %d 条", count) + } +} diff --git a/backend/internal/modules/sms/config.go b/backend/internal/modules/sms/config.go new file mode 100644 index 0000000..a3533d0 --- /dev/null +++ b/backend/internal/modules/sms/config.go @@ -0,0 +1,148 @@ +package sms + +import ( + "context" + "encoding/json" + "strings" +) + +// ConfigKey 主站节点短信配置项,与分销站点的 business_templates 同构。 +const ConfigKey = "sms.business_templates" + +// DefaultStaleDays 上架未成交提醒的默认天数阈值。 +const DefaultStaleDays = 3 + +// ConfigReader 读取系统配置文本,由 systemconfig.Repository 实现。 +type ConfigReader interface { + FindValue(ctx context.Context, key string) (string, error) +} + +// LoadConfig 从系统配置读取并解析节点短信配置。 +func LoadConfig(ctx context.Context, reader ConfigReader) (Config, error) { + if reader == nil { + return DefaultConfig(), nil + } + value, err := reader.FindValue(ctx, ConfigKey) + if err != nil { + return Config{}, err + } + return ParseConfigValue(value) +} + +// NodeConfig 是单个节点的开关与模板 CODE。 +type NodeConfig struct { + Enabled bool `json:"enabled"` + TemplateCode string `json:"template_code"` + // StaleDays 仅上架未成交提醒使用:上架满多少天仍未成交才提醒,0 表示取默认值。 + StaleDays int `json:"stale_days,omitempty"` +} + +// Config 是主站节点短信配置(系统配置 sms.business_templates 的 JSON 结构)。 +type Config struct { + Enabled bool `json:"enabled"` + Nodes map[string]NodeConfig `json:"nodes"` +} + +// DefaultConfig 返回默认配置:总开关关闭(避免误发产生短信费用), +// 三个节点默认各自开启,模板 CODE 留空由运营在后台填写。 +func DefaultConfig() Config { + nodes := make(map[string]NodeConfig, len(nodeMetas)) + for _, meta := range nodeMetas { + node := NodeConfig{Enabled: true} + if meta.SupportsStaleDays { + node.StaleDays = DefaultStaleDays + } + nodes[meta.Code] = node + } + return Config{Enabled: false, Nodes: nodes} +} + +// DefaultConfigValue 返回默认配置的 JSON 文本。 +func DefaultConfigValue() string { + raw, err := MarshalConfig(DefaultConfig()) + if err != nil { + return `{"enabled":false,"nodes":{}}` + } + return raw +} + +// ParseConfigValue 解析系统配置里的 JSON 文本,缺失字段回落到默认值。 +func ParseConfigValue(value string) (Config, error) { + cfg := DefaultConfig() + value = strings.TrimSpace(value) + if value == "" { + return cfg, nil + } + var raw struct { + Enabled *bool `json:"enabled"` + Nodes map[string]NodeConfig `json:"nodes"` + } + if err := json.Unmarshal([]byte(value), &raw); err != nil { + return Config{}, err + } + if raw.Enabled != nil { + cfg.Enabled = *raw.Enabled + } + if raw.Nodes != nil { + // 以已登记节点为基准合并,避免后台配置里出现未知节点。 + for code, node := range raw.Nodes { + if !IsKnownNode(code) { + continue + } + node.TemplateCode = strings.TrimSpace(node.TemplateCode) + if node.StaleDays < 0 { + node.StaleDays = 0 + } + cfg.Nodes[code] = node + } + } + return cfg, nil +} + +// MarshalConfig 规范化并序列化配置。 +func MarshalConfig(cfg Config) (string, error) { + normalized := DefaultConfig() + normalized.Enabled = cfg.Enabled + for code, node := range cfg.Nodes { + if !IsKnownNode(code) { + continue + } + node.TemplateCode = strings.TrimSpace(node.TemplateCode) + if node.StaleDays < 0 { + node.StaleDays = 0 + } + normalized.Nodes[code] = node + } + raw, err := json.Marshal(normalized) + if err != nil { + return "", err + } + return string(raw), nil +} + +// Node 返回指定节点的配置。 +func (c Config) Node(code string) (NodeConfig, bool) { + node, ok := c.Nodes[code] + return node, ok +} + +// NodeActive 判断节点是否可发送:总开关、节点开关与模板 CODE 都必须就绪。 +func (c Config) NodeActive(code string) bool { + if !c.Enabled { + return false + } + node, ok := c.Node(code) + if !ok || !node.Enabled { + return false + } + return strings.TrimSpace(node.TemplateCode) != "" +} + +// StaleDaysFor 返回上架未成交提醒的天数阈值,未配置时取默认值。 +func (c Config) StaleDaysFor(code string) int { + node, ok := c.Node(code) + if !ok || node.StaleDays <= 0 { + return DefaultStaleDays + } + return node.StaleDays +} diff --git a/backend/internal/modules/sms/config_test.go b/backend/internal/modules/sms/config_test.go new file mode 100644 index 0000000..7107540 --- /dev/null +++ b/backend/internal/modules/sms/config_test.go @@ -0,0 +1,150 @@ +package sms + +import ( + "strings" + "testing" +) + +func TestDefaultConfigCoversRegisteredNodes(t *testing.T) { + cfg := DefaultConfig() + if cfg.Enabled { + t.Fatal("默认配置的总开关必须关闭,避免未配置模板就产生短信费用") + } + for _, meta := range NodeMetas() { + node, ok := cfg.Node(meta.Code) + if !ok { + t.Fatalf("默认配置缺少节点 %s", meta.Code) + } + if !node.Enabled { + t.Fatalf("默认配置节点 %s 应默认启用", meta.Code) + } + if node.TemplateCode != "" { + t.Fatalf("默认配置节点 %s 的模板 CODE 应为空,实际 %q", meta.Code, node.TemplateCode) + } + } + if days := cfg.StaleDaysFor(NodeListingStalePriceSuggestion); days != DefaultStaleDays { + t.Fatalf("StaleDaysFor() = %d, want %d", days, DefaultStaleDays) + } +} + +func TestParseConfigValueMergesAndFiltersUnknownNodes(t *testing.T) { + cfg, err := ParseConfigValue(`{ + "enabled": true, + "nodes": { + "order_handoff_assist": {"enabled": true, "template_code": " SMS_512540945 "}, + "listing_stale_price_suggestion": {"enabled": false, "template_code": "SMS_1", "stale_days": 5}, + "not_registered_node": {"enabled": true, "template_code": "SMS_BAD"} + } + }`) + if err != nil { + t.Fatalf("ParseConfigValue() error = %v", err) + } + if !cfg.Enabled { + t.Fatal("ParseConfigValue() 应保留总开关") + } + if _, ok := cfg.Node("not_registered_node"); ok { + t.Fatal("ParseConfigValue() 不应保留未登记节点") + } + node, ok := cfg.Node(NodeOrderHandoffAssist) + if !ok || node.TemplateCode != "SMS_512540945" { + t.Fatalf("handoff node = %+v, want template trimmed to SMS_512540945", node) + } + // 未在 JSON 中出现的已登记节点应保留默认值。 + rejected, ok := cfg.Node(NodeListingReviewRejected) + if !ok || !rejected.Enabled { + t.Fatalf("未配置节点应保留默认值, got %+v ok=%v", rejected, ok) + } + stale, _ := cfg.Node(NodeListingStalePriceSuggestion) + if stale.StaleDays != 5 { + t.Fatalf("stale_days = %d, want 5", stale.StaleDays) + } +} + +func TestParseConfigValueEmptyAndInvalid(t *testing.T) { + cfg, err := ParseConfigValue(" ") + if err != nil { + t.Fatalf("空配置不应报错: %v", err) + } + if cfg.Enabled { + t.Fatal("空配置应回落到默认(总开关关闭)") + } + + if _, err := ParseConfigValue("{not json}"); err == nil { + t.Fatal("非法 JSON 应返回错误") + } +} + +func TestMarshalConfigNormalizes(t *testing.T) { + raw, err := MarshalConfig(Config{ + Enabled: true, + Nodes: map[string]NodeConfig{ + NodeListingReviewRejected: {Enabled: true, TemplateCode: " SMS_512520993 ", StaleDays: -3}, + "unknown_node": {Enabled: true, TemplateCode: "SMS_X"}, + NodeListingStalePriceSuggestion: {Enabled: true, TemplateCode: "SMS_512620985", StaleDays: 0}, + }, + }) + if err != nil { + t.Fatalf("MarshalConfig() error = %v", err) + } + if strings.Contains(raw, "unknown_node") { + t.Fatalf("MarshalConfig() 不应输出未登记节点: %s", raw) + } + parsed, err := ParseConfigValue(raw) + if err != nil { + t.Fatalf("ParseConfigValue() error = %v", err) + } + rejected, _ := parsed.Node(NodeListingReviewRejected) + if rejected.TemplateCode != "SMS_512520993" { + t.Fatalf("template_code = %q, want SMS_512520993", rejected.TemplateCode) + } + if rejected.StaleDays != 0 { + t.Fatalf("负的 stale_days 应被归零, got %d", rejected.StaleDays) + } + if days := parsed.StaleDaysFor(NodeListingStalePriceSuggestion); days != DefaultStaleDays { + t.Fatalf("stale_days=0 时应回落默认值 %d, got %d", DefaultStaleDays, days) + } +} + +func TestConfigNodeActive(t *testing.T) { + base := Config{ + Enabled: true, + Nodes: map[string]NodeConfig{ + NodeListingReviewRejected: {Enabled: true, TemplateCode: "SMS_512520993"}, + NodeOrderHandoffAssist: {Enabled: false, TemplateCode: "SMS_512540945"}, + NodeListingStalePriceSuggestion: {Enabled: true, TemplateCode: ""}, + }, + } + if !base.NodeActive(NodeListingReviewRejected) { + t.Fatal("总开关与节点开关都开且模板非空时应可发送") + } + if base.NodeActive(NodeOrderHandoffAssist) { + t.Fatal("节点关闭时不应发送") + } + if base.NodeActive(NodeListingStalePriceSuggestion) { + t.Fatal("模板 CODE 为空时不应发送") + } + if base.NodeActive("unknown_node") { + t.Fatal("未登记节点不应发送") + } + + disabled := base + disabled.Enabled = false + if disabled.NodeActive(NodeListingReviewRejected) { + t.Fatal("总开关关闭时不应发送") + } +} + +func TestIsKnownNode(t *testing.T) { + for _, code := range []string{ + NodeListingReviewRejected, + NodeOrderHandoffAssist, + NodeListingStalePriceSuggestion, + } { + if !IsKnownNode(code) { + t.Fatalf("IsKnownNode(%q) = false, want true", code) + } + } + if IsKnownNode("") || IsKnownNode("nope") { + t.Fatal("未登记节点应为 false") + } +} diff --git a/backend/internal/modules/sms/nodes.go b/backend/internal/modules/sms/nodes.go new file mode 100644 index 0000000..bb1d9cc --- /dev/null +++ b/backend/internal/modules/sms/nodes.go @@ -0,0 +1,78 @@ +package sms + +// 节点标识:与系统配置 sms.business_templates 中 nodes 的 key 一一对应, +// 也与分销站点短信配置 business_templates 的 key 一一对应。 +const ( + // NodeListingReviewRejected 账号审核未通过,提醒号主修改后重新上架。 + NodeListingReviewRejected = "listing_review_rejected" + // NodeOrderHandoffAssist 账号被租用需要号主辅助登录,提醒号主及时处理。 + NodeOrderHandoffAssist = "order_handoff_assist" + // NodeListingStalePriceSuggestion 账号上架多日仍未成交,建议号主调整比例。 + NodeListingStalePriceSuggestion = "listing_stale_price_suggestion" +) + +// NodeMeta 描述一个节点短信,用于后台配置页展示与文案预览。 +type NodeMeta struct { + Code string `json:"code"` + Name string `json:"name"` + Recipient string `json:"recipient"` + Description string `json:"description"` + // TemplateCode 是该节点在阿里云报备的模板 CODE, + // 这里只是后台首次配置时的参考值,真实值以系统配置/站点配置为准。 + TemplateCode string `json:"template_code"` + // Sample 是报备模板的参考文案,仅供后台预览;真实发送以供应商模板为准。 + Sample string `json:"sample"` + // SupportsStaleDays 标记该节点是否需要「上架天数」阈值(仅闲置降价提醒使用)。 + SupportsStaleDays bool `json:"supports_stale_days"` +} + +var nodeMetas = []NodeMeta{ + { + Code: NodeListingReviewRejected, + Name: "账号审核未通过", + Recipient: "号主", + Description: "号主上架的账号被后台审核拒绝时,提醒登录网站修改后重新上架。", + TemplateCode: "SMS_512520993", + Sample: "板板,您在我们商行上架的账号审核未通过,请登录网站进行修改重新上架", + }, + { + Code: NodeOrderHandoffAssist, + Name: "账号被租用需辅助登录", + Recipient: "号主", + Description: "订单支付成功进入待交接后,提醒号主及时辅助登录,避免订单被强制结束。", + TemplateCode: "SMS_512540945", + Sample: "板板,您的订单已有人下单,下单方需要辅助登录,请及时留意平台信息!长时间未辅助平台会强制结束订单", + }, + { + Code: NodeListingStalePriceSuggestion, + Name: "上架多日未成交降价建议", + Recipient: "号主", + Description: "账号上架满指定天数仍未被租用,建议号主适当调整比例,加快出租。", + TemplateCode: "SMS_512620985", + Sample: "板板,您的三角洲账号上架三天还未成功出售,建议您比例适当让一点,比例越高,出租越快、匹配越顺。具体调多少按自己心理价位定", + SupportsStaleDays: true, + }, +} + +// NodeMetas 返回全部节点短信定义(副本,调用方不可修改内部数据)。 +func NodeMetas() []NodeMeta { + out := make([]NodeMeta, len(nodeMetas)) + copy(out, nodeMetas) + return out +} + +// NodeMetaOf 返回指定节点的定义。 +func NodeMetaOf(code string) (NodeMeta, bool) { + for _, meta := range nodeMetas { + if meta.Code == code { + return meta, true + } + } + return NodeMeta{}, false +} + +// IsKnownNode 判断节点标识是否在已登记的节点清单内。 +func IsKnownNode(code string) bool { + _, ok := NodeMetaOf(code) + return ok +} diff --git a/backend/internal/modules/sms/repository.go b/backend/internal/modules/sms/repository.go new file mode 100644 index 0000000..935268e --- /dev/null +++ b/backend/internal/modules/sms/repository.go @@ -0,0 +1,167 @@ +package sms + +import ( + "context" + "encoding/json" + "strings" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// Entry 描述一条待发送的节点短信。 +// 去重口径为 (Node, BizType, BizID, UserID): +// - 审核未通过:同一账号只发一次; +// - 被租用需辅助:每个订单发一次,同一账号被反复租用会分别提醒; +// - 上架未成交:同一账号只发一次。 +type Entry struct { + UserID uint64 + Phone string + Node string + BizType string + BizID uint64 + DistributionSiteID *uint64 + Params map[string]string +} + +// Enqueue 在业务事务内写入待发送记录,返回是否真正插入了新记录。 +// 与站内信 notification.Append 相同的用法:只落库不发送, +// 短信通道异常不会影响业务提交;重复入队由唯一索引静默忽略(返回 false, nil)。 +func Enqueue(tx *gorm.DB, entry Entry) (bool, error) { + if tx == nil { + return false, nil + } + node := strings.TrimSpace(entry.Node) + if node == "" || entry.UserID == 0 || !IsKnownNode(node) { + return false, nil + } + + phone := strings.TrimSpace(entry.Phone) + if phone == "" { + var user model.User + if err := tx.Select("id", "phone").First(&user, entry.UserID).Error; err == nil { + phone = strings.TrimSpace(user.Phone) + } + } + + var params datatypes.JSON + if len(entry.Params) > 0 { + raw, err := json.Marshal(entry.Params) + if err != nil { + return false, err + } + params = datatypes.JSON(raw) + } + + row := model.SmsMessage{ + UserID: entry.UserID, + Phone: phone, + Node: node, + BizType: strings.TrimSpace(entry.BizType), + BizID: entry.BizID, + DistributionSiteID: entry.DistributionSiteID, + TemplateParams: params, + Status: model.SmsStatusPending, + } + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// ListPending 拉取待发送记录。重试过的记录要等到 retryAfter 之后才再次尝试,避免密集重试。 +func (r *Repository) ListPending(ctx context.Context, limit int, retryAfter time.Time) ([]model.SmsMessage, error) { + if r == nil || r.db == nil { + return nil, gorm.ErrInvalidDB + } + if limit <= 0 { + limit = 100 + } + var rows []model.SmsMessage + err := r.db.WithContext(ctx). + Where("status = ? AND (retry_count = 0 OR updated_at <= ?)", model.SmsStatusPending, retryAfter). + Order("id ASC"). + Limit(limit). + Find(&rows).Error + return rows, err +} + +// MarkSent 记录发送成功,并回写实际使用的模板 CODE 与签名。 +func (r *Repository) MarkSent(ctx context.Context, id uint64, templateCode string, signName string, sentAt time.Time) error { + return r.update(ctx, id, map[string]any{ + "status": model.SmsStatusSent, + "template_code": strings.TrimSpace(templateCode), + "sign_name": strings.TrimSpace(signName), + "last_error": "", + "skip_reason": "", + "sent_at": sentAt, + }) +} + +// MarkRetry 记录一次失败并留在待发送状态,由下一轮任务重试。 +func (r *Repository) MarkRetry(ctx context.Context, id uint64, retryCount int, lastError string) error { + return r.update(ctx, id, map[string]any{ + "status": model.SmsStatusPending, + "retry_count": retryCount, + "last_error": truncateError(lastError), + }) +} + +// MarkFailed 重试次数用尽后标记为失败。 +func (r *Repository) MarkFailed(ctx context.Context, id uint64, retryCount int, lastError string) error { + return r.update(ctx, id, map[string]any{ + "status": model.SmsStatusFailed, + "retry_count": retryCount, + "last_error": truncateError(lastError), + }) +} + +// MarkSkipped 记录因配置缺失(总开关关闭、模板未配置等)未发送。 +func (r *Repository) MarkSkipped(ctx context.Context, id uint64, reason string) error { + return r.update(ctx, id, map[string]any{ + "status": model.SmsStatusSkipped, + "skip_reason": truncate(reason, 128), + }) +} + +func (r *Repository) update(ctx context.Context, id uint64, fields map[string]any) error { + if r == nil || r.db == nil { + return gorm.ErrInvalidDB + } + if id == 0 { + return nil + } + return r.db.WithContext(ctx). + Model(&model.SmsMessage{}). + Where("id = ?", id). + Updates(fields).Error +} + +func truncateError(value string) string { + return truncate(value, 512) +} + +func truncate(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 { + return value + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} diff --git a/backend/internal/modules/sms/repository_test.go b/backend/internal/modules/sms/repository_test.go new file mode 100644 index 0000000..725e475 --- /dev/null +++ b/backend/internal/modules/sms/repository_test.go @@ -0,0 +1,199 @@ +package sms + +import ( + "context" + "testing" + "time" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +func newSmsTestDB(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 TestEnqueueResolvesPhoneAndDeduplicates(t *testing.T) { + db := newSmsTestDB(t) + ctx := context.Background() + + user := model.User{Phone: "13800001111", Nickname: "号主"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + entry := Entry{ + UserID: user.ID, + Node: NodeListingReviewRejected, + BizType: "listing", + BizID: 9001, + } + if inserted, err := Enqueue(db.WithContext(ctx), entry); err != nil { + t.Fatalf("Enqueue() error = %v", err) + } else if !inserted { + t.Fatal("Enqueue() 首次入队应插入新记录") + } + // 同一业务对象重复入队应被唯一索引静默忽略。 + if inserted, err := Enqueue(db.WithContext(ctx), entry); err != nil { + t.Fatalf("Enqueue() 重复调用 error = %v", err) + } else if inserted { + t.Fatal("Enqueue() 重复入队不应插入新记录") + } + + var rows []model.SmsMessage + if err := db.Where("node = ? AND biz_id = ?", NodeListingReviewRejected, 9001).Find(&rows).Error; err != nil { + t.Fatalf("查询短信记录失败: %v", err) + } + if len(rows) != 1 { + t.Fatalf("记录数 = %d, want 1(唯一索引应保证幂等)", len(rows)) + } + if rows[0].Phone != user.Phone { + t.Fatalf("phone = %q, want %q(应从用户表补齐手机号)", rows[0].Phone, user.Phone) + } + if rows[0].Status != model.SmsStatusPending { + t.Fatalf("status = %q, want %q", rows[0].Status, model.SmsStatusPending) + } +} + +func TestEnqueueIgnoresUnknownNodeAndMissingUser(t *testing.T) { + db := newSmsTestDB(t) + ctx := context.Background() + + if _, err := Enqueue(db.WithContext(ctx), Entry{UserID: 1, Node: "not_registered", BizType: "listing", BizID: 1}); err != nil { + t.Fatalf("未登记节点不应报错: %v", err) + } + if _, err := Enqueue(db.WithContext(ctx), Entry{UserID: 0, Node: NodeOrderHandoffAssist, BizType: "order", BizID: 1}); err != nil { + t.Fatalf("缺少用户不应报错: %v", err) + } + + var count int64 + if err := db.Model(&model.SmsMessage{}).Count(&count).Error; err != nil { + t.Fatalf("统计失败: %v", err) + } + if count != 0 { + t.Fatalf("记录数 = %d, want 0", count) + } +} + +func TestRepositoryStatusTransitions(t *testing.T) { + db := newSmsTestDB(t) + ctx := context.Background() + repo := NewRepository(db) + + row := model.SmsMessage{ + UserID: 11, + Phone: "13800002222", + Node: NodeOrderHandoffAssist, + BizType: "order", + BizID: 7001, + Status: model.SmsStatusPending, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("创建记录失败: %v", err) + } + + // 首次拉取应命中,重试退避窗口内的记录应被排除。 + pending, err := repo.ListPending(ctx, 10, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("ListPending() error = %v", err) + } + if len(pending) != 1 { + t.Fatalf("ListPending() = %d 条, want 1", len(pending)) + } + + if err := repo.MarkRetry(ctx, row.ID, 1, "渠道限流"); err != nil { + t.Fatalf("MarkRetry() error = %v", err) + } + var retried model.SmsMessage + if err := db.First(&retried, row.ID).Error; err != nil { + t.Fatalf("查询失败: %v", err) + } + if retried.Status != model.SmsStatusPending || retried.RetryCount != 1 || retried.LastError != "渠道限流" { + t.Fatalf("重试后记录 = %+v", retried) + } + // 退避窗口内不应再次被拉取。 + pending, err = repo.ListPending(ctx, 10, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("ListPending() error = %v", err) + } + if len(pending) != 0 { + t.Fatalf("退避窗口内 ListPending() = %d 条, want 0", len(pending)) + } + // 退避窗口外应重新进入待发送队列。 + pending, err = repo.ListPending(ctx, 10, time.Now().Add(time.Hour)) + if err != nil { + t.Fatalf("ListPending() error = %v", err) + } + if len(pending) != 1 { + t.Fatalf("退避窗口外 ListPending() = %d 条, want 1", len(pending)) + } + + sentAt := time.Now() + if err := repo.MarkSent(ctx, row.ID, "SMS_512540945", "大锤商行", sentAt); err != nil { + t.Fatalf("MarkSent() error = %v", err) + } + var sent model.SmsMessage + if err := db.First(&sent, row.ID).Error; err != nil { + t.Fatalf("查询失败: %v", err) + } + if sent.Status != model.SmsStatusSent || sent.TemplateCode != "SMS_512540945" || sent.SignName != "大锤商行" || sent.SentAt == nil { + t.Fatalf("发送成功后记录 = %+v", sent) + } + + if err := repo.MarkSkipped(ctx, row.ID, "节点短信总开关未开启"); err != nil { + t.Fatalf("MarkSkipped() error = %v", err) + } + var skipped model.SmsMessage + if err := db.First(&skipped, row.ID).Error; err != nil { + t.Fatalf("查询失败: %v", err) + } + if skipped.Status != model.SmsStatusSkipped || skipped.SkipReason != "节点短信总开关未开启" { + t.Fatalf("跳过记录 = %+v", skipped) + } + + if err := repo.MarkFailed(ctx, row.ID, 3, "渠道异常"); err != nil { + t.Fatalf("MarkFailed() error = %v", err) + } + var failed model.SmsMessage + if err := db.First(&failed, row.ID).Error; err != nil { + t.Fatalf("查询失败: %v", err) + } + if failed.Status != model.SmsStatusFailed || failed.RetryCount != 3 { + t.Fatalf("失败记录 = %+v", failed) + } +} + +func TestEnqueueKeepsSiteID(t *testing.T) { + db := newSmsTestDB(t) + ctx := context.Background() + siteID := uint64(77) + + user := model.User{Phone: "13800003333"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + if _, err := Enqueue(db.WithContext(ctx), Entry{ + UserID: user.ID, + Node: NodeListingStalePriceSuggestion, + BizType: "listing", + BizID: 5001, + DistributionSiteID: &siteID, + }); err != nil { + t.Fatalf("Enqueue() error = %v", err) + } + + var row model.SmsMessage + if err := db.Where("biz_id = ?", 5001).First(&row).Error; err != nil { + t.Fatalf("查询失败: %v", err) + } + if row.DistributionSiteID == nil || *row.DistributionSiteID != siteID { + t.Fatalf("distribution_site_id = %v, want %d", row.DistributionSiteID, siteID) + } +} diff --git a/backend/internal/modules/systemconfig/distribution.go b/backend/internal/modules/systemconfig/distribution.go index c06fe21..e465ad8 100644 --- a/backend/internal/modules/systemconfig/distribution.go +++ b/backend/internal/modules/systemconfig/distribution.go @@ -3,6 +3,8 @@ package systemconfig import ( "strconv" "strings" + + smsnode "hfb_sys/backend/internal/modules/sms" ) // DistributionSettlementHoldDaysKey 是「新建分销站点默认结算冷却天数」的系统配置键。 @@ -29,6 +31,11 @@ func validateConfigValue(key, value string) error { switch key { case DistributionSettlementHoldDaysKey: return validateDistributionSettlementHoldDays(value) + case smsnode.ConfigKey: + if _, err := smsnode.ParseConfigValue(value); err != nil { + return ErrInvalidConfig + } + return nil default: return nil } diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 0dff82b..492c704 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -10,6 +10,7 @@ import ( "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/rentergrowth" + smsnode "hfb_sys/backend/internal/modules/sms" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -41,6 +42,7 @@ var defaultConfigs = []defaultConfig{ {Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"}, {Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"}, {Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则(积分门槛、租金折扣、等级免押额度)"}, + {Key: smsnode.ConfigKey, Value: smsnode.DefaultConfigValue(), Description: "节点短信模板配置(总开关、各业务节点模板 CODE)"}, {Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"}, {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"}, @@ -87,6 +89,7 @@ var adminVisibleConfigKeys = []string{ "order.return_overdue_grace_minutes", "profile.post_rental_notice", "renter.growth_level_rules", + "sms.business_templates", } func NewRepository(db *gorm.DB) *Repository { diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index d6549f8..165cbb0 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -6,6 +6,7 @@ import ( "strings" "hfb_sys/backend/internal/modules/rentergrowth" + smsnode "hfb_sys/backend/internal/modules/sms" ) func (s *Service) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) { @@ -36,6 +37,17 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up } req.Value = value } + if key == smsnode.ConfigKey { + cfg, err := smsnode.ParseConfigValue(req.Value) + if err != nil { + return nil, ErrInvalidConfig + } + value, err := smsnode.MarshalConfig(cfg) + if err != nil { + return nil, ErrInvalidConfig + } + req.Value = value + } return s.repo.Update(ctx, actorID, key, req, meta) } diff --git a/backend/internal/router/providers.go b/backend/internal/router/providers.go index e81f0f5..b6253d3 100644 --- a/backend/internal/router/providers.go +++ b/backend/internal/router/providers.go @@ -11,7 +11,8 @@ import ( "go.uber.org/zap" ) -func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { +// NewSMSProvider 构造主站短信通道,供路由装配与后台任务(节点短信派发)共用。 +func NewSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { case "aliyun": provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ @@ -31,6 +32,47 @@ func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provid } } +// NewFieldEncryptor 构造业务字段加密器(实名、收款账号、分站短信密钥共用)。 +// 主密钥与 legacy 回退并存,保证密钥轮换期间仍能解出旧密文。 +func NewFieldEncryptor(cfg config.Config, logger *zap.Logger) crypto.Encryptor { + var fieldEncryptor crypto.Encryptor + primary := cfg.FieldEncryptionKey + legacy := cfg.FieldEncryptionLegacyKey + switch { + case primary != "": + encryptor, err := crypto.NewFieldEncryptor(primary, legacy) + if err != nil { + if config.IsProductionEnv(cfg.AppEnv) { + logger.Fatal("业务字段加密密钥无效", zap.Error(err)) + } + logger.Warn("业务字段加密密钥无效,已回退旧密钥", zap.Error(err)) + // primary 非法时退回 legacy 单密钥(若有),否则 MockEncryptor + if legacy != "" { + if e, eErr := crypto.NewFieldEncryptor(legacy); eErr == nil { + fieldEncryptor = e + } + } + } else { + fieldEncryptor = encryptor + } + case legacy != "": + // 开发态零配置:用 legacy 单密钥,能解历史存量密文。 + encryptor, err := crypto.NewFieldEncryptor(legacy) + if err == nil { + fieldEncryptor = encryptor + logger.Debug("开发环境使用旧业务字段加密密钥") + } + } + if fieldEncryptor == nil { + if config.IsProductionEnv(cfg.AppEnv) { + logger.Fatal("业务字段加密密钥未设置") + } + fieldEncryptor = &crypto.MockEncryptor{} + logger.Debug("开发环境使用模拟业务字段加密器") + } + return fieldEncryptor +} + func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { case "cloudmarket", "aliyun_cloudmarket": diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 1512809..0668d71 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -42,7 +42,6 @@ import ( "hfb_sys/backend/internal/modules/withdrawal" _ "hfb_sys/backend/docs" // Swagger 文档 - "hfb_sys/backend/pkg/crypto" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" @@ -76,51 +75,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { engine.Use(middleware.AdminAwareRateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis, jwtManager)) } - // 业务字段加密器(实名/收款账号):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。 + // 业务字段加密器(实名/收款账号/分站短信密钥):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。 // 生产环境必须配置 FIELD_ENCRYPTION_KEY(ValidateProductionSecurity 已校验)。 - // 开发/测试缺 FIELD_ENCRYPTION_KEY 时用 legacy 密钥构造单密钥加密器, - // 保证零配置兼容历史硬编码密钥加密的存量密文(非生产 legacy 默认填历史硬编码值)。 - var fieldEncryptor crypto.Encryptor - primary := cfg.FieldEncryptionKey - legacy := cfg.FieldEncryptionLegacyKey - switch { - case primary != "": - encryptor, err := crypto.NewFieldEncryptor(primary, legacy) - if err != nil { - if config.IsProductionEnv(cfg.AppEnv) { - logger.Fatal("业务字段加密密钥无效", zap.Error(err)) - } - logger.Warn("业务字段加密密钥无效,已回退旧密钥", zap.Error(err)) - // primary 非法时退回 legacy 单密钥(若有),否则 MockEncryptor - if legacy != "" { - if e, eErr := crypto.NewFieldEncryptor(legacy); eErr == nil { - fieldEncryptor = e - } - } - } else { - fieldEncryptor = encryptor - } - case legacy != "": - // 开发态零配置:用 legacy 单密钥,能解历史存量密文。 - encryptor, err := crypto.NewFieldEncryptor(legacy) - if err == nil { - fieldEncryptor = encryptor - logger.Debug("开发环境使用旧业务字段加密密钥") - } - } - if fieldEncryptor == nil { - if config.IsProductionEnv(cfg.AppEnv) { - logger.Fatal("业务字段加密密钥未设置") - } - fieldEncryptor = &crypto.MockEncryptor{} - logger.Debug("开发环境使用模拟业务字段加密器") - } + fieldEncryptor := NewFieldEncryptor(cfg, logger) var userRepo *auth.UserRepository if deps.DB != nil { userRepo = auth.NewUserRepository(deps.DB) } - smsProvider := newSMSProvider(cfg, logger) + smsProvider := NewSMSProvider(cfg, logger) authService := auth.NewService(userRepo, deps.Redis, jwtManager, smsProvider, logger) authHandler := auth.NewHandler(authService) var adminAuthRepo *adminauth.Repository diff --git a/backend/migrations/000088_sms_messages.sql b/backend/migrations/000088_sms_messages.sql new file mode 100644 index 0000000..08ef895 --- /dev/null +++ b/backend/migrations/000088_sms_messages.sql @@ -0,0 +1,33 @@ +-- +goose Up + +-- 节点短信:业务节点触发触达的短信发送队列。 +-- 业务事务内只写 pending 记录(与站内信 notification.Append 同点), +-- 由后台任务 smsdispatch 异步发送,短信失败不回滚业务、也不阻塞接口。 +-- 唯一索引保证「同一业务对象 + 同一节点 + 同一接收人」只发一次, +-- 同时充当节点级幂等去重(上架三天未成交提醒不会重复轰炸号主)。 +CREATE TABLE IF NOT EXISTS sms_messages ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '接收用户 ID', + phone VARCHAR(32) NOT NULL DEFAULT '' COMMENT '接收手机号快照', + node VARCHAR(64) NOT NULL COMMENT '业务节点标识,如 listing_review_rejected', + biz_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型: listing/order/pickup 等', + biz_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '业务对象 ID', + distribution_site_id BIGINT UNSIGNED NULL COMMENT '归属分销站点 ID,NULL 表示主站', + template_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '实际使用的模板 CODE 快照', + template_params JSON NULL COMMENT '模板变量快照', + sign_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '实际使用的短信签名快照', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/sent/failed/skipped', + skip_reason VARCHAR(128) NOT NULL DEFAULT '' COMMENT '未发送原因(节点未启用/未配置模板等)', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次发送失败原因', + sent_at DATETIME NULL COMMENT '发送成功时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_sms_messages_node_biz_user (node, biz_type, biz_id, user_id), + KEY idx_sms_messages_status_id (status, id), + KEY idx_sms_messages_site_id (distribution_site_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='节点短信发送队列'; + +-- +goose Down + +DROP TABLE IF EXISTS sms_messages; diff --git a/docs/租客成长等级与主动短信触达方案.md b/docs/租客成长等级与主动短信触达方案.md index 07cbbcd..ac9332f 100644 --- a/docs/租客成长等级与主动短信触达方案.md +++ b/docs/租客成长等级与主动短信触达方案.md @@ -141,6 +141,10 @@ amountCent := row.RentAmountCent + row.DepositAmountCent ## 二、主动短信触达 +> 本节是「订单完成后主动触达」的早期设计。当前已落地的多节点短信能力(审核未通过、被租用需辅助登录、上架未成交降价建议)见 +> [`节点短信提醒方案.md`](./节点短信提醒方案.md):同样采用「事务内入队 + 后台任务异步发送 + 分站优先主站回退」, +> 并已实现 `sms_messages` 队列表、唯一索引去重、重试与节点级配置,可作为本节后续实现的载体。 + ### 业务目标 用户完成一笔租号订单后,平台可以主动发送短信,告知用户本月已经累计完成多少笔租号订单,以及距离领取奖励还差多少笔。 diff --git a/docs/节点短信提醒方案.md b/docs/节点短信提醒方案.md new file mode 100644 index 0000000..6b84fe6 --- /dev/null +++ b/docs/节点短信提醒方案.md @@ -0,0 +1,128 @@ +# 节点短信提醒方案 + +## 一、目标 + +平台在关键业务节点主动给号主发短信,减少「审核被拒不知道」「有人下单没辅助登录导致订单被强制结束」「账号长期卖不出去」这三类损耗。 + +阿里云要求短信文案必须提前报备审核,**代码里不拼接文案、不存文案快照**:业务侧只决定「发给谁 + 用哪个模板 CODE」,文案完全由报备的模板决定。 + +## 二、已接入节点 + +| 节点标识 | 场景 | 收件人 | 触发位置 | +| --- | --- | --- | --- | +| `listing_review_rejected` | 账号审核未通过,提醒登录网站修改后重新上架 | 号主 | `listing/review.go` `Reject()` | +| `order_handoff_assist` | 订单支付成功进入待交接,提醒号主及时辅助登录 | 号主 | `order/lifecycle.go` `ConfirmPaidFromChannelTx()` 的非代管分支 | +| `listing_stale_price_suggestion` | 账号上架满 N 天仍未被租用,建议调整比例 | 号主 | `jobs/listingstale` 定时扫描 | + +模板 CODE(主站可在后台自行更换): + +- `listing_review_rejected`:`SMS_512520993` +- `order_handoff_assist`:`SMS_512540945` +- `listing_stale_price_suggestion`:`SMS_512620985` + +> 代管订单(`handoff_mode=platform`)由客服提交交接说明,不触达号主,因此不会产生 `order_handoff_assist` 短信。 + +## 三、整体链路 + +```mermaid +flowchart LR + A["业务事务内
sms.Enqueue()"] --> B["sms_messages
status=pending"] + B --> C["jobs/smsdispatch
每分钟轮询"] + C --> D{"总开关 / 节点开关
模板 CODE 是否就绪"} + D -->|未就绪| E["status=skipped
记录 skip_reason"] + D -->|就绪| F{"订单/商品是否属于分站"} + F -->|分站已配该节点模板| G["分站通道 + 分站签名"] + F -->|未配置| H["主站通道 + 主站签名"] + G --> I["Provider.SendTemplate"] + H --> I + I --> J["sent / 重试 / failed"] +``` + +设计要点: + +- **只入队不发送**:入队与业务数据在同一个事务里提交(与站内信 `notification.Append` 同点),短信通道异常不影响下单、审核等主流程,也不拖慢接口。 +- **幂等去重**:`sms_messages` 上的唯一索引 `(node, biz_type, biz_id, user_id)` 保证同一业务对象同一节点只发一次;上架未成交提醒因此天然不会重复轰炸号主。 +- **失败重试**:失败按 5 分钟退避重试,最多 3 次,用尽后置为 `failed` 并记录 `last_error`。 +- **分站优先、主站回退**:订单/商品带 `distribution_site_id` 时先尝试分站独立短信;分站未启用、密钥解密失败或没配该节点模板,都回退主站通道与主站模板。 + +## 四、主站配置 + +系统配置项 `sms.business_templates`(后台「系统配置 → 节点短信配置」弹窗维护): + +```json +{ + "enabled": false, + "nodes": { + "listing_review_rejected": { "enabled": true, "template_code": "" }, + "order_handoff_assist": { "enabled": true, "template_code": "" }, + "listing_stale_price_suggestion": { "enabled": true, "template_code": "", "stale_days": 3 } + } +} +``` + +- `enabled`:总开关,默认 `false`,避免未配置模板就产生短信费用。 +- `nodes[].enabled`:单节点开关。 +- `nodes[].template_code`:阿里云报备模板 CODE,**留空即该节点不发短信**。 +- `nodes[].stale_days`:仅上架未成交提醒使用,默认 3 天。 +- 主站发送使用的签名沿用环境变量 `ALIYUN_SMS_SIGN_NAME`,凭证沿用 `ALIYUN_ACCESS_KEY_ID/SECRET`。 + +## 五、分站配置 + +分销站点短信配置(`distribution_sites.sms_config_json`,「分销站点 → 短信配置」弹窗)新增 `business_templates` 字段: + +```json +{ + "enabled": true, + "provider": "aliyun", + "access_key_id": "LTAI...", + "access_key_secret": "(加密存储)", + "sign_name": "【分站签名】", + "login_template_code": "SMS_xxx", + "business_templates": { + "order_handoff_assist": "SMS_YYYY" + } +} +``` + +- 站点启用独立短信后,`business_templates` 里配置了模板的节点走**分站凭证 + 分站签名 + 分站模板**,短信费用计入分站额度。 +- 未配置的节点自动回退主站通道与主站模板。 +- 提交时该字段为整体覆盖语义;传空对象即清空,全部回退主站。 +- 服务端只保留已登记节点且模板非空的配置项,写错节点名不会静默生效。 + +### 凭据轮换 + +站点 Provider 按「站点 ID + 凭据指纹」缓存,指纹取自 provider / AccessKeyID / 密钥密文 / endpoint / 签名 / 登录模板码。后台改了任一项(尤其是更换 AccessKey)后指纹变化,**运行中的任务会自动重建 Provider**,不需要重启进程;API 侧保存配置时也会主动清一次缓存。 + +## 六、数据库 + +迁移 `000088_sms_messages.sql` 新增 `sms_messages` 队列表: + +- `status`:`pending` / `sent` / `failed` / `skipped` +- `skip_reason`:因总开关关闭、节点停用、模板缺失、通道不可用等原因未发送 +- `retry_count` / `last_error` / `sent_at` / `template_code` / `sign_name`:排障用快照 +- 唯一索引 `uk_sms_messages_node_biz_user`:节点级幂等 +- 索引 `idx_sms_messages_status_id`:派发任务扫描待发送 +- 索引 `idx_sms_messages_site_id`:按分站排查 + +排查方式:直接查 `sms_messages`(`status='skipped'` 看 `skip_reason`,`status='failed'` 看 `last_error`)。 + +## 七、上架未成交提醒的扫描口径 + +`jobs/listingstale` 每小时扫描一次,命中条件: + +- `rental_listings.status = 'published'` 且 `in_transaction = 0`(在架且未成交) +- `published_at` 落在 `[now - stale_days - 24h, now - stale_days]` 窗口内 +- `sms_messages` 中不存在同节点的该账号记录(`NOT EXISTS` 排除已提醒) + +只处理「刚跨过阈值」的账号,是为了避免功能首次上线时把历史上所有长期滞销账号一次性全部触发,造成短信轰炸与费用失控。配合唯一索引,每个账号最多提醒一次。 + +单轮最多处理 200 个账号(`defaultBatchSize`)。这里的 `NOT EXISTS` 不只是省一次插入:候选窗口有 24 小时、任务每小时跑一次,如果不排除已提醒账号,窗口内候选超过 200 个时每次都会查回同一批「已提醒」的账号并占满批次名额,排在后面的账号会在 24 小时窗口结束后被永久漏发。 + +## 八、测试范围 + +- 配置解析:默认值、未知节点过滤、模板 CODE 去空格、负数天数归零、`NodeActive` 判定 +- 队列:手机号自动补齐、重复入队幂等、状态流转、重试退避 +- 派发:主站通道、分站优先、分站未配模板回退主站、分站解析失败回退主站、模板缺失跳过、节点停用跳过、重试用尽置 failed +- 业务接入:审核拒绝给号主入队(含分站归属)、支付成功给号主入队、代管订单不入队 +- 定时扫描:只命中刚跨阈值的在架未成交账号、重复执行不重复入队、阈值可配置 +- 回归用例:候选数超过批次上限时不会漏发后续账号;站点凭据轮换后自动重建 Provider diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 6b46c71..48fcc82 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -335,6 +335,8 @@ export interface DistributionSiteSMSConfig { endpoint: string sign_name: string login_template_code: string + /** 站点自有的节点短信模板 CODE(key 为节点标识),未配置的节点回退主站模板。 */ + business_templates: Record } export interface UpdateDistributionSiteSMSConfigPayload { @@ -345,6 +347,8 @@ export interface UpdateDistributionSiteSMSConfigPayload { endpoint?: string sign_name?: string login_template_code?: string + /** 整体覆盖站点节点模板映射,传空对象即清空并回退主站模板。 */ + business_templates?: Record } export interface TestDistributionSiteSMSConfigPayload { diff --git a/frontend/src/features/admin/api/smsNodeTemplates.ts b/frontend/src/features/admin/api/smsNodeTemplates.ts new file mode 100644 index 0000000..2e106c9 --- /dev/null +++ b/frontend/src/features/admin/api/smsNodeTemplates.ts @@ -0,0 +1,116 @@ +/** + * 节点短信模板配置:与后端 internal/modules/sms/nodes.go 登记一致。 + * 阿里云要求模板提前报备审核,因此这里只维护模板 CODE,文案由平台模板决定。 + */ +export type SmsNodeCode = + | 'listing_review_rejected' + | 'order_handoff_assist' + | 'listing_stale_price_suggestion' + +export interface SmsNodeMeta { + code: SmsNodeCode + name: string + recipient: string + description: string + /** 报备模板的参考 CODE,仅作提示,真实值以配置为准。 */ + templateCode: string + /** 报备模板的参考文案,仅供识别节点,实际文案由平台模板决定。 */ + sample: string + supportsStaleDays: boolean +} + +export const smsNodeMetas: SmsNodeMeta[] = [ + { + code: 'listing_review_rejected', + name: '账号审核未通过', + recipient: '号主', + description: '号主上架的账号被后台审核拒绝时,提醒登录网站修改后重新上架。', + templateCode: 'SMS_512520993', + sample: '板板,您在我们商行上架的账号审核未通过,请登录网站进行修改重新上架', + supportsStaleDays: false, + }, + { + code: 'order_handoff_assist', + name: '账号被租用需辅助登录', + recipient: '号主', + description: '订单支付成功进入待交接后,提醒号主及时辅助登录,避免订单被强制结束。', + templateCode: 'SMS_512540945', + sample: + '板板,您的订单已有人下单,下单方需要辅助登录,请及时留意平台信息!长时间未辅助平台会强制结束订单', + supportsStaleDays: false, + }, + { + code: 'listing_stale_price_suggestion', + name: '上架多日未成交降价建议', + recipient: '号主', + description: '账号上架满指定天数仍未被租用,建议号主适当调整比例,加快出租。', + templateCode: 'SMS_512620985', + sample: + '板板,您的三角洲账号上架三天还未成功出售,建议您比例适当让一点,比例越高,出租越快、匹配越顺。具体调多少按自己心理价位定', + supportsStaleDays: true, + }, +] + +export interface SmsNodeConfig { + enabled: boolean + template_code: string + /** 仅「上架多日未成交降价建议」使用:上架满多少天仍未成交才提醒。 */ + stale_days?: number +} + +export interface SmsBusinessTemplatesConfig { + enabled: boolean + nodes: Record +} + +export const defaultSmsBusinessTemplatesConfig: SmsBusinessTemplatesConfig = { + // 与后端默认值一致:总开关关闭,避免未配置模板就产生短信费用。 + enabled: false, + nodes: { + listing_review_rejected: { enabled: true, template_code: '' }, + order_handoff_assist: { enabled: true, template_code: '' }, + listing_stale_price_suggestion: { enabled: true, template_code: '', stale_days: 3 }, + }, +} + +export function parseSmsBusinessTemplatesConfig(raw: string): SmsBusinessTemplatesConfig { + const parsed = parseRawConfig(raw) + const nodes: Record = {} + for (const meta of smsNodeMetas) { + const fallback = defaultSmsBusinessTemplatesConfig.nodes[meta.code] + const incoming = parsed.nodes?.[meta.code] + const node: SmsNodeConfig = { + enabled: + typeof incoming?.enabled === 'boolean' ? incoming.enabled : (fallback?.enabled ?? true), + template_code: + typeof incoming?.template_code === 'string' ? incoming.template_code.trim() : '', + } + if (meta.supportsStaleDays) { + node.stale_days = positiveInteger(incoming?.stale_days, fallback?.stale_days ?? 3) + } + nodes[meta.code] = node + } + return { + enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : false, + nodes, + } +} + +export function cloneSmsBusinessTemplatesConfig( + config: SmsBusinessTemplatesConfig +): SmsBusinessTemplatesConfig { + return JSON.parse(JSON.stringify(config)) as SmsBusinessTemplatesConfig +} + +function parseRawConfig(raw: string): Partial { + try { + return JSON.parse(raw) as Partial + } catch { + return {} + } +} + +function positiveInteger(value: unknown, fallback: number) { + const number = Number(value) + return Number.isInteger(number) && number > 0 ? number : fallback +} diff --git a/frontend/src/features/admin/components/SmsNodeTemplatesDialog.vue b/frontend/src/features/admin/components/SmsNodeTemplatesDialog.vue new file mode 100644 index 0000000..3264694 --- /dev/null +++ b/frontend/src/features/admin/components/SmsNodeTemplatesDialog.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 84ade9f..5f49b03 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -33,6 +33,7 @@ import { } from '@/features/admin/api/adminDistribution' import { formatDateTime } from '@/shared/utils/time' import { readError } from '@/shared/utils/error' +import { smsNodeMetas } from '@/features/admin/api/smsNodeTemplates' import { distributionAdminBaseUrl, distributionAdminLoginUrl, @@ -143,6 +144,7 @@ const smsForm = reactive({ masked_secret: '', has_secret: false, endpoint: '', + business_templates: {} as Record, }) function handleOpenSettlement(row: DistributionSite) { @@ -208,6 +210,10 @@ async function handleOpenSMS(row: DistributionSite) { smsForm.masked_secret = cfg.masked_secret || '' smsForm.has_secret = cfg.has_secret smsForm.endpoint = cfg.endpoint || '' + // 逐节点初始化,未配置的节点留空表示回退主站模板。 + smsForm.business_templates = Object.fromEntries( + smsNodeMetas.map(meta => [meta.code, cfg.business_templates?.[meta.code] || '']) + ) } catch (err) { ElMessage.error(readError(err, '读取分站短信配置失败')) } finally { @@ -215,6 +221,15 @@ async function handleOpenSMS(row: DistributionSite) { } } +function trimmedBusinessTemplates() { + const out: Record = {} + for (const meta of smsNodeMetas) { + const code = (smsForm.business_templates[meta.code] || '').trim() + if (code) out[meta.code] = code + } + return out +} + async function handleSMSSubmit() { if (smsForm.enabled) { if (!smsForm.sign_name.trim()) { @@ -244,6 +259,7 @@ async function handleSMSSubmit() { access_key_id: string access_key_secret?: string endpoint?: string + business_templates: Record } = { enabled: smsForm.enabled, provider: smsForm.provider || 'aliyun', @@ -251,6 +267,7 @@ async function handleSMSSubmit() { login_template_code: smsForm.login_template_code.trim(), access_key_id: smsForm.access_key_id.trim(), endpoint: smsForm.endpoint.trim(), + business_templates: trimmedBusinessTemplates(), } if (smsSecretInput.value.trim()) { payload.access_key_secret = smsSecretInput.value.trim() @@ -1082,6 +1099,14 @@ onMounted(() => { + 节点短信模板(可选) + + +
{{ meta.description }}
+
@@ -1344,4 +1369,12 @@ onMounted(() => { color: #6b7280; font-size: 13px; } + +.sms-node-hint { + width: 100%; + margin-top: 4px; + color: #9ca3af; + font-size: 12px; + line-height: 1.5; +} diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 9273f7c..9750d02 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -19,6 +19,10 @@ import { import type { OrderAgreements } from '@/features/orders/api/orders' import { fetchSystemConfigs, type SystemConfig } from '@/features/admin/api/systemConfigs' import { parseRenterGrowthConfig } from '@/features/admin/api/renterGrowth' +import { + parseSmsBusinessTemplatesConfig, + smsNodeMetas, +} from '@/features/admin/api/smsNodeTemplates' import { formatDateTime } from '@/shared/utils/time' import { safeParseJSON } from '@/shared/utils/json' import { formatSystemConfigSelectValue } from '@/shared/utils/systemConfigOptions' @@ -40,6 +44,7 @@ import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue' import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue' import GeneralConfigDialog from '../components/GeneralConfigDialog.vue' import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue' +import SmsNodeTemplatesDialog from '../components/SmsNodeTemplatesDialog.vue' import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue' import { fetchAutoWelcomeMessage, @@ -86,6 +91,7 @@ const agreementsVisible = ref(false) const postRentalNoticeVisible = ref(false) const generalVisible = ref(false) const renterGrowthVisible = ref(false) +const smsNodeTemplatesVisible = ref(false) const publishConfig = computed( () => configs.value.find(item => item.key === 'listing.publish_options') || null @@ -99,6 +105,9 @@ const listingPublishCooldownConfig = computed( const renterGrowthConfig = computed( () => configs.value.find(item => item.key === 'renter.growth_level_rules') || null ) +const smsNodeTemplatesConfig = computed( + () => configs.value.find(item => item.key === 'sms.business_templates') || null +) const homeAnnouncementsConfig = computed( () => configs.value.find(item => item.key === 'mobile.home_announcements') || null ) @@ -167,7 +176,8 @@ const regularConfigs = computed(() => item.key !== 'integration.paddle_ocr_token' && item.key !== 'integration.paddle_ocr_job_url' && item.key !== 'integration.paddle_ocr_model' && - item.key !== 'renter.growth_level_rules' + item.key !== 'renter.growth_level_rules' && + item.key !== 'sms.business_templates' ) ) @@ -227,6 +237,24 @@ const renterGrowthStats = computed(() => { } }) +const smsNodeStats = computed(() => { + const config = parseSmsBusinessTemplatesConfig(smsNodeTemplatesConfig.value?.value || '') + const configured = smsNodeMetas.filter( + meta => config.nodes[meta.code]?.template_code && config.nodes[meta.code]?.enabled + ) + return { + enabled: config.enabled, + configuredCount: configured.length, + totalCount: smsNodeMetas.length, + nodes: smsNodeMetas, + statusOf: (code: string) => { + const node = config.nodes[code] + if (!node?.template_code) return '未配置' + return node.enabled ? '已配置' : '已停用' + }, + } +}) + const agreementStats = computed(() => { const agreements = parseOrderAgreements(orderAgreementsConfig.value?.value || '') return { @@ -295,6 +323,8 @@ function openEdit(row: SystemConfig) { postRentalNoticeVisible.value = true } else if (row.key === 'renter.growth_level_rules') { renterGrowthVisible.value = true + } else if (row.key === 'sms.business_templates') { + smsNodeTemplatesVisible.value = true } else if (row.key === 'chat.renter_retention_days_after_order_end') { generalVisible.value = true } else { @@ -592,6 +622,36 @@ function formatConfigValue(row: SystemConfig) {
+
+
+
+

Node SMS

+

节点短信

+ 审核未通过、被租用需辅助登录、上架多日未成交等节点给号主发短信;只填阿里云已报备的模板 + CODE,文案由平台模板决定。分站可在「分销站点 → 短信配置」里配置自己的模板。 +
+ + 编辑节点短信 + +
+
+
+ {{ smsNodeStats.enabled ? '已启用' : '已停用' }} + 总开关 +
+
+ {{ smsNodeStats.configuredCount }} / {{ smsNodeStats.totalCount }} + 已配置节点 +
+
+ {{ smsNodeStats.statusOf(meta.code) }} + {{ meta.name }} +
+
+
+
+ +