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

179 lines
5.0 KiB
Go

// 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))
}
}