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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build artifacts
|
||||
/server
|
||||
*.exe
|
||||
*.tar
|
||||
|
||||
# Runtime files
|
||||
|
||||
@@ -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 启动 PushWorker(Redis 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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -69,3 +69,11 @@ type FileCleanupConfig struct {
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
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"` // 保留天数,默认 3(3 天不活跃即清理)
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
}
|
||||
|
||||
@@ -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_id,last_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
|
||||
}
|
||||
|
||||
193
internal/service/device_cleanup_worker.go
Normal file
193
internal/service/device_cleanup_worker.go
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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_id(pushToken)是设备级标识,
|
||||
// 同一物理设备登录新账号时 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 注销设备
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user