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
|
# Build artifacts
|
||||||
/server
|
/server
|
||||||
|
*.exe
|
||||||
*.tar
|
*.tar
|
||||||
|
|
||||||
# Runtime files
|
# Runtime files
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type App struct {
|
|||||||
PushService service.PushService
|
PushService service.PushService
|
||||||
HotRankWorker *service.HotRankWorker
|
HotRankWorker *service.HotRankWorker
|
||||||
FileCleanupWorker *service.FileCleanupWorker
|
FileCleanupWorker *service.FileCleanupWorker
|
||||||
|
DeviceCleanupWorker *service.DeviceCleanupWorker
|
||||||
GRPCServer *runner.Server
|
GRPCServer *runner.Server
|
||||||
Server *http.Server
|
Server *http.Server
|
||||||
Publisher ws.MessagePublisher
|
Publisher ws.MessagePublisher
|
||||||
@@ -40,6 +41,7 @@ func NewApp(
|
|||||||
pushService service.PushService,
|
pushService service.PushService,
|
||||||
hotRankWorker *service.HotRankWorker,
|
hotRankWorker *service.HotRankWorker,
|
||||||
fileCleanupWorker *service.FileCleanupWorker,
|
fileCleanupWorker *service.FileCleanupWorker,
|
||||||
|
deviceCleanupWorker *service.DeviceCleanupWorker,
|
||||||
grpcServer *runner.Server,
|
grpcServer *runner.Server,
|
||||||
publisher ws.MessagePublisher,
|
publisher ws.MessagePublisher,
|
||||||
taskDispatcher runner.TaskDispatcher,
|
taskDispatcher runner.TaskDispatcher,
|
||||||
@@ -53,6 +55,7 @@ func NewApp(
|
|||||||
PushService: pushService,
|
PushService: pushService,
|
||||||
HotRankWorker: hotRankWorker,
|
HotRankWorker: hotRankWorker,
|
||||||
FileCleanupWorker: fileCleanupWorker,
|
FileCleanupWorker: fileCleanupWorker,
|
||||||
|
DeviceCleanupWorker: deviceCleanupWorker,
|
||||||
GRPCServer: grpcServer,
|
GRPCServer: grpcServer,
|
||||||
Publisher: publisher,
|
Publisher: publisher,
|
||||||
TaskDispatcher: taskDispatcher,
|
TaskDispatcher: taskDispatcher,
|
||||||
@@ -84,6 +87,11 @@ func (a *App) Start(ctx context.Context) error {
|
|||||||
a.FileCleanupWorker.Start(ctx)
|
a.FileCleanupWorker.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1.2 设备 registration_id 清理(清理不活跃设备 token)
|
||||||
|
if a.DeviceCleanupWorker != nil {
|
||||||
|
a.DeviceCleanupWorker.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
||||||
if a.PushWorker != nil {
|
if a.PushWorker != nil {
|
||||||
a.PushWorker.Start(ctx)
|
a.PushWorker.Start(ctx)
|
||||||
@@ -169,6 +177,11 @@ func (a *App) Stop(ctx context.Context) error {
|
|||||||
a.FileCleanupWorker.Stop()
|
a.FileCleanupWorker.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.DeviceCleanupWorker != nil {
|
||||||
|
a.Logger.Info("Stopping device cleanup worker")
|
||||||
|
a.DeviceCleanupWorker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// 5.1 停止 PushWorker
|
// 5.1 停止 PushWorker
|
||||||
if a.PushWorker != nil {
|
if a.PushWorker != nil {
|
||||||
a.Logger.Info("Stopping push worker (stream)")
|
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)
|
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)
|
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
|
||||||
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
|
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
|
||||||
|
deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client)
|
||||||
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
||||||
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
||||||
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
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
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ upload:
|
|||||||
- image/gif
|
- image/gif
|
||||||
- image/webp
|
- 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:
|
sensitive:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
Upload UploadConfig `mapstructure:"upload"`
|
||||||
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||||
|
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
|
||||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
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.retention_days", 7)
|
||||||
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
||||||
viper.SetDefault("file_cleanup.batch_size", 100)
|
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.endpoint", "")
|
||||||
viper.SetDefault("s3.access_key", "")
|
viper.SetDefault("s3.access_key", "")
|
||||||
viper.SetDefault("s3.secret_key", "")
|
viper.SetDefault("s3.secret_key", "")
|
||||||
|
|||||||
@@ -69,3 +69,11 @@ type FileCleanupConfig struct {
|
|||||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||||
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
|
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)
|
GetDeviceCountByUserID(userID int64) (int64, error)
|
||||||
GetActiveDeviceCountByUserID(userID int64) (int64, error)
|
GetActiveDeviceCountByUserID(userID int64) (int64, error)
|
||||||
DeleteInactiveDevices(before time.Time) 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仓储实现
|
// 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).
|
return r.db.Where("is_active = ? AND last_used_at < ?", false, before).
|
||||||
Delete(&model.DeviceToken{}).Error
|
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 推送队列大小
|
||||||
PushQueueSize = 1000
|
PushQueueSize = 1000
|
||||||
// MaxDevicesPerUser 每个用户最大设备数
|
// MaxDevicesPerUser 每个用户最大设备数
|
||||||
MaxDevicesPerUser = 10
|
MaxDevicesPerUser = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChatMessageSender 聊天消息发送者信息
|
// ChatMessageSender 聊天消息发送者信息
|
||||||
@@ -386,12 +386,9 @@ func (s *pushServiceImpl) pushViaJPushBatch(devices []*model.DeviceToken, params
|
|||||||
// 处理同设备多账号场景:registration_id(pushToken)是设备级标识,
|
// 处理同设备多账号场景:registration_id(pushToken)是设备级标识,
|
||||||
// 同一物理设备登录新账号时 pushToken 不变,需先失效其他用户对该 pushToken 的关联,
|
// 同一物理设备登录新账号时 pushToken 不变,需先失效其他用户对该 pushToken 的关联,
|
||||||
// 避免给一个账号推送时同设备的其他账号也收到通知。
|
// 避免给一个账号推送时同设备的其他账号也收到通知。
|
||||||
|
// 每个用户最多保留 MaxDevicesPerUser 个活跃设备:注册后统一收敛到限额以内,
|
||||||
|
// 既能处理达上限淘汰,也能收敛历史超额数据(如限额从 10 调整为 3 后的存量清理)。
|
||||||
func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, deviceID string, deviceType model.DeviceType, pushToken string) error {
|
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 的关联(防止跨账号误推)
|
// 失效其他用户/设备对同一 pushToken 的关联(防止跨账号误推)
|
||||||
if pushToken != "" {
|
if pushToken != "" {
|
||||||
if dErr := s.deviceRepo.DeactivateByPushToken(pushToken); dErr != nil {
|
if dErr := s.deviceRepo.DeactivateByPushToken(pushToken); dErr != nil {
|
||||||
@@ -413,7 +410,28 @@ func (s *pushServiceImpl) RegisterDevice(ctx context.Context, userID string, dev
|
|||||||
}
|
}
|
||||||
deviceToken.UpdateLastUsed()
|
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 注销设备
|
// UnregisterDevice 注销设备
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ var ServiceSet = wire.NewSet(
|
|||||||
ProvideQRCodeLoginService,
|
ProvideQRCodeLoginService,
|
||||||
ProvideHotRankWorker,
|
ProvideHotRankWorker,
|
||||||
ProvideFileCleanupWorker,
|
ProvideFileCleanupWorker,
|
||||||
|
ProvideDeviceCleanupWorker,
|
||||||
ProvideMaterialService,
|
ProvideMaterialService,
|
||||||
ProvideCallService,
|
ProvideCallService,
|
||||||
ProvideLiveKitService,
|
ProvideLiveKitService,
|
||||||
@@ -312,6 +313,19 @@ func ProvideFileCleanupWorker(
|
|||||||
return service.NewFileCleanupWorker(cfg, uploadedRepo, s3Client, rdb)
|
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 提供用户活跃统计服务
|
// ProvideUserActivityService 提供用户活跃统计服务
|
||||||
func ProvideUserActivityService(repo repository.UserActivityRepository) service.UserActivityService {
|
func ProvideUserActivityService(repo repository.UserActivityRepository) service.UserActivityService {
|
||||||
return service.NewUserActivityService(repo)
|
return service.NewUserActivityService(repo)
|
||||||
|
|||||||
Reference in New Issue
Block a user