179 lines
5.0 KiB
Go
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))
|
|
}
|
|
}
|