feat(worker): add device registration_id cleanup worker
All checks were successful
Build Backend / build (push) Successful in 2m18s
Build Backend / build-docker (push) Successful in 1m19s

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
This commit is contained in:
lafay
2026-06-18 20:34:22 +08:00
parent cb85c62093
commit 322aa9eebb
10 changed files with 303 additions and 8 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Build artifacts
/server
*.exe
*.tar
# Runtime files

View File

@@ -24,6 +24,7 @@ type App struct {
PushService service.PushService
HotRankWorker *service.HotRankWorker
FileCleanupWorker *service.FileCleanupWorker
DeviceCleanupWorker *service.DeviceCleanupWorker
GRPCServer *runner.Server
Server *http.Server
Publisher ws.MessagePublisher
@@ -40,6 +41,7 @@ func NewApp(
pushService service.PushService,
hotRankWorker *service.HotRankWorker,
fileCleanupWorker *service.FileCleanupWorker,
deviceCleanupWorker *service.DeviceCleanupWorker,
grpcServer *runner.Server,
publisher ws.MessagePublisher,
taskDispatcher runner.TaskDispatcher,
@@ -53,6 +55,7 @@ func NewApp(
PushService: pushService,
HotRankWorker: hotRankWorker,
FileCleanupWorker: fileCleanupWorker,
DeviceCleanupWorker: deviceCleanupWorker,
GRPCServer: grpcServer,
Publisher: publisher,
TaskDispatcher: taskDispatcher,
@@ -84,6 +87,11 @@ func (a *App) Start(ctx context.Context) error {
a.FileCleanupWorker.Start(ctx)
}
// 2.1.2 设备 registration_id 清理(清理不活跃设备 token
if a.DeviceCleanupWorker != nil {
a.DeviceCleanupWorker.Start(ctx)
}
// 2.2 启动 PushWorkerRedis Stream 异步推送)
if a.PushWorker != nil {
a.PushWorker.Start(ctx)
@@ -169,6 +177,11 @@ func (a *App) Stop(ctx context.Context) error {
a.FileCleanupWorker.Stop()
}
if a.DeviceCleanupWorker != nil {
a.Logger.Info("Stopping device cleanup worker")
a.DeviceCleanupWorker.Stop()
}
// 5.1 停止 PushWorker
if a.PushWorker != nil {
a.Logger.Info("Stopping push worker (stream)")

View File

@@ -165,10 +165,11 @@ func InitializeApp() (*App, error) {
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client)
server := wire.ProvideGRPCServer(config, logger, runnerHub)
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
app := NewApp(config, db, router, pushService, hotRankWorker, fileCleanupWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
app := NewApp(config, db, router, pushService, hotRankWorker, fileCleanupWorker, deviceCleanupWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
return app, nil
}

View File

@@ -159,6 +159,14 @@ upload:
- image/gif
- image/webp
# 设备 registration_id 清理配置
# 周期性清理超过保留期未使用的设备 token避免无效 registration_id 累积
# 环境变量: APP_DEVICE_CLEANUP_ENABLED, APP_DEVICE_CLEANUP_RETENTION_DAYS, APP_DEVICE_CLEANUP_INTERVAL_MINUTES
device_cleanup:
enabled: true # 是否启用清理 worker
retention_days: 3 # 保留天数3 天不活跃即清理(按 last_used_at 判断)
interval_minutes: 360 # 扫描间隔(分钟),默认 6 小时
# 敏感词过滤配置
sensitive:
enabled: true

View File

@@ -20,6 +20,7 @@ type Config struct {
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Upload UploadConfig `mapstructure:"upload"`
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
OpenAI OpenAIConfig `mapstructure:"openai"`
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
Email EmailConfig `mapstructure:"email"`
@@ -115,6 +116,10 @@ func Load(configPath string) (*Config, error) {
viper.SetDefault("file_cleanup.retention_days", 7)
viper.SetDefault("file_cleanup.interval_minutes", 360)
viper.SetDefault("file_cleanup.batch_size", 100)
// 设备 registration_id 清理默认值3 天不活跃即清理)
viper.SetDefault("device_cleanup.enabled", true)
viper.SetDefault("device_cleanup.retention_days", 3)
viper.SetDefault("device_cleanup.interval_minutes", 360)
viper.SetDefault("s3.endpoint", "")
viper.SetDefault("s3.access_key", "")
viper.SetDefault("s3.secret_key", "")

View File

@@ -69,3 +69,11 @@ type FileCleanupConfig struct {
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 3606 小时)
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
}
// DeviceCleanupConfig 设备 registration_id 清理配置
// 周期性清理超过保留期未使用的设备 token避免无效 registration_id 累积
type DeviceCleanupConfig struct {
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker默认 true
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 33 天不活跃即清理)
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 3606 小时)
}

View File

@@ -31,6 +31,12 @@ type DeviceTokenRepository interface {
GetDeviceCountByUserID(userID int64) (int64, error)
GetActiveDeviceCountByUserID(userID int64) (int64, error)
DeleteInactiveDevices(before time.Time) error
// DeleteStaleDevices 删除超过指定时间未使用的设备(按 last_used_at 判断,不限 is_active
// 用于周期性清理长期不活跃的 registration_idlast_used_at 为 NULL 的设备不清理
DeleteStaleDevices(before time.Time) error
// TrimActiveDevices 将用户活跃设备数量收敛到 keep 以内,删除超额的最旧设备
// 返回实际删除的记录数。用于设备数限制(如限制从 10 改为 3 后的存量收敛)
TrimActiveDevices(userID string, keep int) (int64, error)
}
// deviceTokenRepository 设备Token仓储实现
@@ -200,3 +206,31 @@ func (r *deviceTokenRepository) DeleteInactiveDevices(before time.Time) error {
return r.db.Where("is_active = ? AND last_used_at < ?", false, before).
Delete(&model.DeviceToken{}).Error
}
// DeleteStaleDevices 删除超过指定时间未使用的设备(按 last_used_at 判断,不限 is_active
// last_used_at 为 NULL 的设备不清理(可能是刚注册尚未使用)
func (r *deviceTokenRepository) DeleteStaleDevices(before time.Time) error {
return r.db.Where("last_used_at IS NOT NULL AND last_used_at < ?", before).
Delete(&model.DeviceToken{}).Error
}
// TrimActiveDevices 将用户活跃设备数量收敛到 keep 以内,删除超额的最旧设备
// 按 last_used_at 降序保留最新的 keep 条活跃记录,删除其余活跃记录。
// keep <= 0 时不删除任何记录。返回实际删除的记录数。
// 用于设备数限制(新注册超限淘汰 + 历史存量超额收敛)。
func (r *deviceTokenRepository) TrimActiveDevices(userID string, keep int) (int64, error) {
if keep <= 0 {
return 0, nil
}
// 子查询:取该用户最新的 keep 条活跃记录 ID
subQuery := r.db.Model(&model.DeviceToken{}).
Select("id").
Where("user_id = ? AND is_active = ?", userID, true).
Order("last_used_at DESC NULLS LAST").
Limit(keep)
// 删除不在保留集合内的活跃记录
result := r.db.Where("user_id = ? AND is_active = ? AND id NOT IN (?)", userID, true, subQuery).
Delete(&model.DeviceToken{})
return result.RowsAffected, result.Error
}

View File

@@ -0,0 +1,193 @@
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),
)
}

View File

@@ -28,7 +28,7 @@ const (
// PushQueueSize 推送队列大小
PushQueueSize = 1000
// MaxDevicesPerUser 每个用户最大设备数
MaxDevicesPerUser = 10
MaxDevicesPerUser = 3
)
// ChatMessageSender 聊天消息发送者信息
@@ -386,12 +386,9 @@ func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, params
// 处理同设备多账号场景registration_idpushToken是设备级标识
// 同一物理设备登录新账号时 pushToken 不变,需先失效其他用户对该 pushToken 的关联,
// 避免给一个账号推送时同设备的其他账号也收到通知。
// 每个用户最多保留 MaxDevicesPerUser 个活跃设备:注册后统一收敛到限额以内,
// 既能处理达上限淘汰,也能收敛历史超额数据(如限额从 10 调整为 3 后的存量清理)。
func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, deviceID string, deviceType model.DeviceType, pushToken string) error {
existing, err := s.deviceRepo.GetByUserID(userID)
if err == nil && len(existing) >= MaxDevicesPerUser {
return fmt.Errorf("exceeded maximum device limit (%d)", MaxDevicesPerUser)
}
// 失效其他用户/设备对同一 pushToken 的关联(防止跨账号误推)
if pushToken != "" {
if dErr := s.deviceRepo.DeactivateByPushToken(pushToken); dErr != nil {
@@ -413,7 +410,28 @@ func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, dev
}
deviceToken.UpdateLastUsed()
return s.deviceRepo.Upsert(deviceToken)
if err := s.deviceRepo.Upsert(deviceToken); err != nil {
return fmt.Errorf("failed to upsert device: %w", err)
}
// 收敛活跃设备数到 MaxDevicesPerUser 以内(保留最近使用的,淘汰超额的最旧设备)
// 覆盖两种场景:① 新设备注册导致超限;② 历史存量超额(限额调整后逐步收敛)
if evicted, err := s.deviceRepo.TrimActiveDevices(userID, MaxDevicesPerUser); err != nil {
zap.L().Warn("trim active devices failed",
zap.String("userID", userID),
zap.Int("limit", MaxDevicesPerUser),
zap.Error(err),
)
} else if evicted > 0 {
zap.L().Info("evicted oldest devices for device limit",
zap.String("userID", userID),
zap.String("deviceID", deviceID),
zap.Int64("evicted", evicted),
zap.Int("limit", MaxDevicesPerUser),
)
}
return nil
}
// UnregisterDevice 注销设备

View File

@@ -61,6 +61,7 @@ var ServiceSet = wire.NewSet(
ProvideQRCodeLoginService,
ProvideHotRankWorker,
ProvideFileCleanupWorker,
ProvideDeviceCleanupWorker,
ProvideMaterialService,
ProvideCallService,
ProvideLiveKitService,
@@ -312,6 +313,19 @@ func ProvideFileCleanupWorker(
return service.NewFileCleanupWorker(cfg, uploadedRepo, s3Client, rdb)
}
// ProvideDeviceCleanupWorker 提供设备 registration_id 清理 worker
func ProvideDeviceCleanupWorker(
cfg *config.Config,
deviceRepo repository.DeviceTokenRepository,
redisClient *redis.Client,
) *service.DeviceCleanupWorker {
var rdb *redisPkg.Client
if redisClient != nil {
rdb = redisClient.GetClient()
}
return service.NewDeviceCleanupWorker(cfg, deviceRepo, rdb)
}
// ProvideUserActivityService 提供用户活跃统计服务
func ProvideUserActivityService(repo repository.UserActivityRepository) service.UserActivityService {
return service.NewUserActivityService(repo)