feat(sms): 新增节点短信提醒(审核未通过/被租用需辅助/上架未成交)
This commit is contained in:
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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("退避窗口内不应重试")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user