Files
backend/internal/service/device_cleanup_worker.go
lafay 322aa9eebb
All checks were successful
Build Backend / build (push) Successful in 2m18s
Build Backend / build-docker (push) Successful in 1m19s
feat(worker): add device registration_id cleanup worker
Add DeviceCleanupWorker to periodically clean up stale device tokens that haven't
been used within the retention period. This prevents accumulation of invalid
registration_ids and maintains data hygiene.

- New DeviceCleanupConfig with enabled, retention_days (3), and interval_minutes (360)
- New repository methods DeleteStaleDevices and DeleteOldestActiveDevice
- Updated RegisterDevice to evict oldest active device when reaching limit
- Reduced MaxDevicesPerUser from 10 to 3 per user
- Worker integrated into app startup/shutdown lifecycle
2026-06-18 20:36:25 +08:00

194 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"sync"
"time"
"with_you/internal/config"
"with_you/internal/repository"
redislib "github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// DeviceCleanupWorker 设备 registration_id 清理 worker
// 周期性清理超过保留期未使用的设备 token按 last_used_at 判断)。
// 多实例部署时通过 Redis 分布式锁保证仅主实例执行清理。
type DeviceCleanupWorker struct {
cfg *config.Config
deviceRepo repository.DeviceTokenRepository
rdb *redislib.Client // 用于分布式锁(可为 nil降级为本地运行
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
}
const (
// deviceCleanupLockKey Redis 分布式锁键
deviceCleanupLockKey = "device_cleanup:leader_lock"
// deviceCleanupLockTTL 锁持有时间(略大于扫描间隔,确保单实例持有)
deviceCleanupLockTTL = 7 * time.Hour
// deviceCleanupLockRetryInterval 竞争锁的重试间隔
deviceCleanupLockRetryInterval = 1 * time.Minute
)
// NewDeviceCleanupWorker 创建设备清理 worker
func NewDeviceCleanupWorker(
cfg *config.Config,
deviceRepo repository.DeviceTokenRepository,
rdb *redislib.Client,
) *DeviceCleanupWorker {
return &DeviceCleanupWorker{
cfg: cfg,
deviceRepo: deviceRepo,
rdb: rdb,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// tryAcquireLock 尝试获取分布式锁,返回是否成功
func (w *DeviceCleanupWorker) tryAcquireLock(ctx context.Context) bool {
if w.rdb == nil {
return true // 无 Redis 时降级为本地运行
}
ok, err := w.rdb.SetNX(ctx, deviceCleanupLockKey, "1", deviceCleanupLockTTL).Result()
if err != nil {
zap.L().Warn("device cleanup: failed to acquire leader lock, running locally", zap.Error(err))
return true
}
return ok
}
// keepLockAlive 周期性续期分布式锁
func (w *DeviceCleanupWorker) keepLockAlive(ctx context.Context) {
if w.rdb == nil {
return
}
ticker := time.NewTicker(deviceCleanupLockTTL / 2)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.stopCh:
return
case <-ticker.C:
w.rdb.Expire(ctx, deviceCleanupLockKey, deviceCleanupLockTTL)
}
}
}
// releaseLock 释放分布式锁
func (w *DeviceCleanupWorker) releaseLock() {
if w.rdb == nil {
return
}
w.rdb.Del(context.Background(), deviceCleanupLockKey)
}
// Start 启动清理 worker
func (w *DeviceCleanupWorker) Start(ctx context.Context) {
if w == nil || w.cfg == nil || !w.cfg.DeviceCleanup.Enabled {
close(w.doneCh)
return
}
if w.deviceRepo == nil {
zap.L().Warn("device cleanup: missing dependencies, worker disabled")
close(w.doneCh)
return
}
// 扫描间隔,默认 6 小时,最小 5 分钟
interval := time.Duration(w.cfg.DeviceCleanup.IntervalMinutes) * time.Minute
if interval < 5*time.Minute {
interval = 6 * time.Hour
}
go func() {
defer close(w.doneCh)
isLeader := false
lockCtx, lockCancel := context.WithCancel(ctx)
for {
select {
case <-w.stopCh:
if isLeader {
w.releaseLock()
}
lockCancel()
return
default:
}
acquired := w.tryAcquireLock(ctx)
if acquired {
if !isLeader {
zap.L().Info("device cleanup: acquired leader lock, becoming primary")
isLeader = true
go w.keepLockAlive(lockCtx)
// 主实例启动后立即执行一次清理
w.runOnce(ctx)
ticker := time.NewTicker(interval)
for {
select {
case <-w.stopCh:
ticker.Stop()
lockCancel()
w.releaseLock()
return
case <-ticker.C:
w.runOnce(ctx)
}
}
}
} else {
zap.L().Debug("device cleanup: another instance holds leader lock, retrying later")
}
select {
case <-w.stopCh:
lockCancel()
return
case <-time.After(deviceCleanupLockRetryInterval):
}
}
}()
}
// Stop 停止 worker
func (w *DeviceCleanupWorker) Stop() {
if w == nil {
return
}
w.stopOnce.Do(func() {
close(w.stopCh)
})
<-w.doneCh
w.releaseLock()
}
// runOnce 执行一次清理扫描
func (w *DeviceCleanupWorker) runOnce(ctx context.Context) {
cfg := w.cfg.DeviceCleanup
retentionDays := cfg.RetentionDays
if retentionDays <= 0 {
retentionDays = 3
}
before := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
if err := w.deviceRepo.DeleteStaleDevices(before); err != nil {
zap.L().Warn("device cleanup: delete stale devices failed", zap.Error(err))
return
}
zap.L().Info("device cleanup sweep done",
zap.Int("retention_days", retentionDays),
zap.Time("before", before),
)
}