From 335a7fa14bc0caea68189bcd563cbb6ac4a08c55 Mon Sep 17 00:00:00 2001
From: ShukeBta <272197458+ShukeBta@users.noreply.github.com>
Date: Fri, 26 Jun 2026 14:25:27 +0800
Subject: [PATCH] refactor device service helpers
---
internal/service/device_cleanup.go | 158 ++++++++++++++
internal/service/device_listing.go | 197 +++++++++++++++++
internal/service/device_service.go | 333 -----------------------------
3 files changed, 355 insertions(+), 333 deletions(-)
create mode 100644 internal/service/device_cleanup.go
create mode 100644 internal/service/device_listing.go
diff --git a/internal/service/device_cleanup.go b/internal/service/device_cleanup.go
new file mode 100644
index 0000000..684426d
--- /dev/null
+++ b/internal/service/device_cleanup.go
@@ -0,0 +1,158 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+// SweepInactiveUsers is kept for compatibility with the existing scheduler; it
+// now delegates to the custom account-cleanup policy.
+func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) {
+ return s.SweepAccountCleanup(ctx)
+}
+
+// SweepAccountCleanup runs the admin-defined account cleanup policy once.
+// Users are kept when they satisfy any enabled keep rule. Users that do not
+// meet any enabled rule are deleted.
+func (s *DeviceService) SweepAccountCleanup(ctx context.Context) (int, error) {
+ cfg := loadBotConfig(ctx, s.repo)
+ if !cfg.AccountCleanupEnabled {
+ return 0, nil
+ }
+ candidates, err := s.accountCleanupCandidates(ctx, cfg)
+ if err != nil {
+ return 0, err
+ }
+ removed := 0
+ for _, candidate := range candidates {
+ s.notify(ctx, candidate.UserID, fmt.Sprintf("⛔️ 账号 %s 未满足保号规则,已被清理。\n规则结果:%s\n如需恢复请联系管理员。", candidate.Username, candidate.Details))
+ s.log.Warn("account cleanup: deleting account", zap.String("user", candidate.Username), zap.String("details", candidate.Details))
+ _ = s.repo.UserDevice.DeleteByUser(ctx, candidate.UserID)
+ if err := s.repo.User.Delete(ctx, candidate.UserID); err == nil {
+ removed++
+ }
+ }
+ return removed, nil
+}
+
+type accountCleanupCandidate struct {
+ UserID string
+ Username string
+ Details string
+}
+
+func (s *DeviceService) PreviewAccountCleanup(ctx context.Context) ([]accountCleanupCandidate, error) {
+ cfg := loadBotConfig(ctx, s.repo)
+ if !cfg.AccountCleanupEnabled {
+ return nil, nil
+ }
+ return s.accountCleanupCandidates(ctx, cfg)
+}
+
+func (s *DeviceService) accountCleanupCandidates(ctx context.Context, cfg botConfig) ([]accountCleanupCandidate, error) {
+ users, err := s.repo.User.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ candidates := make([]accountCleanupCandidate, 0)
+ for i := range users {
+ u := &users[i]
+ if s.isProtected(ctx, u) || !u.IsActive {
+ continue
+ }
+ keep, details := s.userMatchesCleanupPolicy(ctx, u, cfg)
+ if keep {
+ continue
+ }
+ candidates = append(candidates, accountCleanupCandidate{
+ UserID: u.ID,
+ Username: u.Username,
+ Details: details,
+ })
+ }
+ return candidates, nil
+}
+
+// randomWindowDays returns a random integer in [min,max]. The window is
+// intentionally non-fixed per the operator's requirement ("随机触发").
+func randomWindowDays(min, max int) int {
+ if min < 1 {
+ min = 1
+ }
+ if max < min {
+ max = min
+ }
+ if max == min {
+ return min
+ }
+ return min + secureRandomIntn(max-min+1)
+}
+
+func (s *DeviceService) userMatchesCleanupPolicy(ctx context.Context, u *model.User, cfg botConfig) (bool, string) {
+ rules := make([]accountCleanupRule, 0, len(cfg.AccountCleanupRules))
+ for _, r := range cfg.AccountCleanupRules {
+ if r.Enabled {
+ rules = append(rules, r)
+ }
+ }
+ if len(rules) == 0 {
+ return true, "无启用规则,跳过"
+ }
+ matches := 0
+ parts := make([]string, 0, len(rules))
+ for _, r := range rules {
+ ok, detail := s.userMatchesCleanupRule(ctx, u, r)
+ if ok {
+ matches++
+ parts = append(parts, "✅ "+detail)
+ } else {
+ parts = append(parts, "❌ "+detail)
+ }
+ }
+ required := 1
+ return matches >= required, fmt.Sprintf("满足 %d/%d 条,需要 %d 条;%s", matches, len(rules), required, strings.Join(parts, ";"))
+}
+
+func (s *DeviceService) userMatchesCleanupRule(ctx context.Context, u *model.User, r accountCleanupRule) (bool, string) {
+ switch r.Type {
+ case "watch_hours":
+ windowDays := randomWindowDays(r.WindowDaysMin, r.WindowDaysMax)
+ since := time.Now().Add(-time.Duration(windowDays) * 24 * time.Hour)
+ watched, _ := s.repo.UserDevice.WatchedMillisSince(ctx, u.ID, since)
+ hours := float64(watched) / 3600000
+ return hours >= r.MinHours, fmt.Sprintf("%s:近 %d 天观看 %.1f/%.1f 小时", r.Name, windowDays, hours, r.MinHours)
+ case "recent_login":
+ days := r.WindowDaysMax
+ if days < 1 {
+ days = r.WindowDaysMin
+ }
+ cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
+ ok := u.LastLoginAt != nil && u.LastLoginAt.After(cutoff)
+ if !ok && s.sessions != nil {
+ ok = s.sessions.UserRecentlyActive(ctx, u.ID, time.Duration(days)*24*time.Hour)
+ }
+ return ok, fmt.Sprintf("%s:%d 天内登录", r.Name, days)
+ case "signin_streak":
+ rec, _ := s.repo.SignIn.Get(ctx, u.ID)
+ streak := 0
+ if rec != nil {
+ streak = rec.StreakDays
+ }
+ return streak >= r.MinCount, fmt.Sprintf("%s:连续签到 %d/%d 天", r.Name, streak, r.MinCount)
+ case "account_age_grace":
+ days := r.MinCount
+ if days < 1 {
+ days = r.WindowDaysMax
+ }
+ ok := u.CreatedAt.After(time.Now().Add(-time.Duration(days) * 24 * time.Hour))
+ return ok, fmt.Sprintf("%s:新账号 %d 天宽限", r.Name, days)
+ default:
+ return false, r.Name + ":未知规则"
+ }
+}
diff --git a/internal/service/device_listing.go b/internal/service/device_listing.go
new file mode 100644
index 0000000..35f4adc
--- /dev/null
+++ b/internal/service/device_listing.go
@@ -0,0 +1,197 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+// KickDevice marks a device as kicked so the next request from it is rejected
+// (the client must log in again). Returns the affected device for messaging.
+func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) error {
+ d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
+ if err != nil {
+ return err
+ }
+ if d == nil {
+ return fmt.Errorf("device not found")
+ }
+ if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
+ return s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
+ }
+ return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
+}
+
+// KickAllDevices marks all devices for a user as kicked.
+func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
+ return s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
+}
+
+// ListDevices returns the device sessions for a user.
+func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
+ rows, err := s.repo.UserDevice.ListByUser(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ rows = collapseUserDeviceRows(rows)
+ if s.sessions == nil {
+ return rows, nil
+ }
+ now := s.sessions.now()
+ byDevice := make(map[string]int, len(rows))
+ byTerminal := make(map[string]int, len(rows))
+ for i := range rows {
+ byDevice[rows[i].DeviceID] = i
+ byTerminal[userDeviceTerminalKey(rows[i])] = i
+ }
+ for _, sess := range s.sessions.ListByUser(ctx, userID) {
+ online := sess.LastActivityAt.After(now.Add(-realtimeSessionOnlineTTL))
+ idx, ok := byDevice[sess.DeviceID]
+ if !ok {
+ idx, ok = byTerminal[sessionDeviceKey(sess)]
+ }
+ if ok {
+ if sess.LastActivityAt.After(rows[idx].LastSeenAt) {
+ rows[idx].LastSeenAt = sess.LastActivityAt
+ }
+ if sess.DeviceName != "" {
+ rows[idx].DeviceName = sess.DeviceName
+ }
+ if sess.Client != "" {
+ rows[idx].Client = sess.Client
+ }
+ if sess.RemoteEndPoint != "" {
+ rows[idx].LastIP = sess.RemoteEndPoint
+ }
+ if sess.LastPlaybackAt != nil {
+ rows[idx].LastPlayAt = sess.LastPlaybackAt
+ }
+ rows[idx].Realtime = true
+ rows[idx].Online = online
+ rows[idx].Playing = sess.IsPlaying && online
+ continue
+ }
+ row := model.UserDevice{
+ UserID: userID,
+ DeviceID: sess.DeviceID,
+ DeviceName: sess.DeviceName,
+ Client: sess.Client,
+ Fingerprint: fingerprint(sess.Client, sess.DeviceName),
+ LastIP: sess.RemoteEndPoint,
+ FirstSeenAt: sess.LastActivityAt,
+ LastSeenAt: sess.LastActivityAt,
+ LastPlayAt: sess.LastPlaybackAt,
+ Realtime: true,
+ Online: online,
+ Playing: sess.IsPlaying && online,
+ }
+ row.ID = "rt:" + sess.ID
+ rows = append(rows, row)
+ byDevice[row.DeviceID] = len(rows) - 1
+ byTerminal[userDeviceTerminalKey(row)] = len(rows) - 1
+ }
+ sort.SliceStable(rows, func(i, j int) bool {
+ return rows[i].LastSeenAt.After(rows[j].LastSeenAt)
+ })
+ return rows, nil
+}
+
+func collapseUserDeviceRows(rows []model.UserDevice) []model.UserDevice {
+ if len(rows) < 2 {
+ return rows
+ }
+ out := make([]model.UserDevice, 0, len(rows))
+ byTerminal := map[string]int{}
+ for _, row := range rows {
+ key := userDeviceTerminalKey(row)
+ if idx, ok := byTerminal[key]; ok {
+ out[idx] = mergeUserDeviceRows(out[idx], row)
+ continue
+ }
+ byTerminal[key] = len(out)
+ out = append(out, row)
+ }
+ return out
+}
+
+func mergeUserDeviceRows(a, b model.UserDevice) model.UserDevice {
+ if b.LastSeenAt.After(a.LastSeenAt) {
+ a.ID = b.ID
+ a.DeviceID = b.DeviceID
+ a.DeviceName = b.DeviceName
+ a.Client = b.Client
+ a.LastIP = b.LastIP
+ a.FirstSeenAt = earlierTime(a.FirstSeenAt, b.FirstSeenAt)
+ a.LastSeenAt = b.LastSeenAt
+ a.LastPlayAt = latestOptionalTime(a.LastPlayAt, b.LastPlayAt)
+ a.Fingerprint = firstNonEmptyString(b.Fingerprint, a.Fingerprint)
+ a.Kicked = b.Kicked
+ return a
+ }
+ a.FirstSeenAt = earlierTime(a.FirstSeenAt, b.FirstSeenAt)
+ a.LastPlayAt = latestOptionalTime(a.LastPlayAt, b.LastPlayAt)
+ a.Fingerprint = firstNonEmptyString(a.Fingerprint, b.Fingerprint)
+ return a
+}
+
+func userDeviceTerminalKey(row model.UserDevice) string {
+ if key := strings.TrimSpace(row.Fingerprint); key != "" {
+ return key
+ }
+ if strings.TrimSpace(row.DeviceName) != "" {
+ return "fp-" + fingerprint(row.Client, row.DeviceName)
+ }
+ if id := strings.TrimSpace(row.DeviceID); id != "" {
+ return id
+ }
+ return "unknown"
+}
+
+func earlierTime(a, b time.Time) time.Time {
+ if a.IsZero() || (!b.IsZero() && b.Before(a)) {
+ return b
+ }
+ return a
+}
+
+func latestOptionalTime(a, b *time.Time) *time.Time {
+ if a == nil {
+ return b
+ }
+ if b != nil && b.After(*a) {
+ return b
+ }
+ return a
+}
+
+// IsDeviceKicked reports whether a (user, device) pair was kicked and should be
+// forced to re-authenticate.
+func (s *DeviceService) IsDeviceKicked(ctx context.Context, userID, deviceID string) bool {
+ return s.IsTerminalKicked(ctx, userID, deviceID, "", "")
+}
+
+// IsTerminalKicked reports whether a request belongs to a kicked terminal.
+func (s *DeviceService) IsTerminalKicked(ctx context.Context, userID, deviceID, deviceName, client string) bool {
+ if userID == "" {
+ return false
+ }
+ if strings.TrimSpace(deviceID) != "" {
+ d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
+ if err == nil && d != nil {
+ return d.Kicked
+ }
+ }
+ fp := ""
+ if strings.TrimSpace(deviceName) != "" {
+ fp = fingerprint(client, deviceName)
+ }
+ if fp == "" {
+ return false
+ }
+ d, err := s.repo.UserDevice.FindByFingerprint(ctx, userID, fp)
+ return err == nil && d != nil && d.Kicked
+}
diff --git a/internal/service/device_service.go b/internal/service/device_service.go
index 3a56241..708eca7 100644
--- a/internal/service/device_service.go
+++ b/internal/service/device_service.go
@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
- "sort"
"strings"
"time"
@@ -260,260 +259,6 @@ func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason str
s.log.Warn("device policy: disabled account", zap.String("user", u.Username), zap.String("reason", reason))
}
-// SweepInactiveUsers is kept for compatibility with the existing scheduler; it
-// now delegates to the custom account-cleanup policy.
-func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) {
- return s.SweepAccountCleanup(ctx)
-}
-
-// SweepAccountCleanup runs the admin-defined account cleanup policy once.
-// Users are kept when they satisfy any enabled keep rule. Users that do not
-// meet any enabled rule are deleted.
-func (s *DeviceService) SweepAccountCleanup(ctx context.Context) (int, error) {
- cfg := loadBotConfig(ctx, s.repo)
- if !cfg.AccountCleanupEnabled {
- return 0, nil
- }
- candidates, err := s.accountCleanupCandidates(ctx, cfg)
- if err != nil {
- return 0, err
- }
- removed := 0
- for _, candidate := range candidates {
- s.notify(ctx, candidate.UserID, fmt.Sprintf("⛔️ 账号 %s 未满足保号规则,已被清理。\n规则结果:%s\n如需恢复请联系管理员。", candidate.Username, candidate.Details))
- s.log.Warn("account cleanup: deleting account", zap.String("user", candidate.Username), zap.String("details", candidate.Details))
- _ = s.repo.UserDevice.DeleteByUser(ctx, candidate.UserID)
- if err := s.repo.User.Delete(ctx, candidate.UserID); err == nil {
- removed++
- }
- }
- return removed, nil
-}
-
-type accountCleanupCandidate struct {
- UserID string
- Username string
- Details string
-}
-
-func (s *DeviceService) PreviewAccountCleanup(ctx context.Context) ([]accountCleanupCandidate, error) {
- cfg := loadBotConfig(ctx, s.repo)
- if !cfg.AccountCleanupEnabled {
- return nil, nil
- }
- return s.accountCleanupCandidates(ctx, cfg)
-}
-
-func (s *DeviceService) accountCleanupCandidates(ctx context.Context, cfg botConfig) ([]accountCleanupCandidate, error) {
- users, err := s.repo.User.List(ctx)
- if err != nil {
- return nil, err
- }
- candidates := make([]accountCleanupCandidate, 0)
- for i := range users {
- u := &users[i]
- if s.isProtected(ctx, u) || !u.IsActive {
- continue
- }
- keep, details := s.userMatchesCleanupPolicy(ctx, u, cfg)
- if keep {
- continue
- }
- candidates = append(candidates, accountCleanupCandidate{
- UserID: u.ID,
- Username: u.Username,
- Details: details,
- })
- }
- return candidates, nil
-}
-
-// KickDevice marks a device as kicked so the next request from it is rejected
-// (the client must log in again). Returns the affected device for messaging.
-func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) error {
- d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
- if err != nil {
- return err
- }
- if d == nil {
- return fmt.Errorf("device not found")
- }
- if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
- return s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
- }
- return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
-}
-
-// KickAllDevices marks all devices for a user as kicked.
-func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
- return s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
-}
-
-// ListDevices returns the device sessions for a user.
-func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
- rows, err := s.repo.UserDevice.ListByUser(ctx, userID)
- if err != nil {
- return nil, err
- }
- rows = collapseUserDeviceRows(rows)
- if s.sessions == nil {
- return rows, nil
- }
- now := s.sessions.now()
- byDevice := make(map[string]int, len(rows))
- byTerminal := make(map[string]int, len(rows))
- for i := range rows {
- byDevice[rows[i].DeviceID] = i
- byTerminal[userDeviceTerminalKey(rows[i])] = i
- }
- for _, sess := range s.sessions.ListByUser(ctx, userID) {
- online := sess.LastActivityAt.After(now.Add(-realtimeSessionOnlineTTL))
- idx, ok := byDevice[sess.DeviceID]
- if !ok {
- idx, ok = byTerminal[sessionDeviceKey(sess)]
- }
- if ok {
- if sess.LastActivityAt.After(rows[idx].LastSeenAt) {
- rows[idx].LastSeenAt = sess.LastActivityAt
- }
- if sess.DeviceName != "" {
- rows[idx].DeviceName = sess.DeviceName
- }
- if sess.Client != "" {
- rows[idx].Client = sess.Client
- }
- if sess.RemoteEndPoint != "" {
- rows[idx].LastIP = sess.RemoteEndPoint
- }
- if sess.LastPlaybackAt != nil {
- rows[idx].LastPlayAt = sess.LastPlaybackAt
- }
- rows[idx].Realtime = true
- rows[idx].Online = online
- rows[idx].Playing = sess.IsPlaying && online
- continue
- }
- row := model.UserDevice{
- UserID: userID,
- DeviceID: sess.DeviceID,
- DeviceName: sess.DeviceName,
- Client: sess.Client,
- Fingerprint: fingerprint(sess.Client, sess.DeviceName),
- LastIP: sess.RemoteEndPoint,
- FirstSeenAt: sess.LastActivityAt,
- LastSeenAt: sess.LastActivityAt,
- LastPlayAt: sess.LastPlaybackAt,
- Realtime: true,
- Online: online,
- Playing: sess.IsPlaying && online,
- }
- row.ID = "rt:" + sess.ID
- rows = append(rows, row)
- byDevice[row.DeviceID] = len(rows) - 1
- byTerminal[userDeviceTerminalKey(row)] = len(rows) - 1
- }
- sort.SliceStable(rows, func(i, j int) bool {
- return rows[i].LastSeenAt.After(rows[j].LastSeenAt)
- })
- return rows, nil
-}
-
-func collapseUserDeviceRows(rows []model.UserDevice) []model.UserDevice {
- if len(rows) < 2 {
- return rows
- }
- out := make([]model.UserDevice, 0, len(rows))
- byTerminal := map[string]int{}
- for _, row := range rows {
- key := userDeviceTerminalKey(row)
- if idx, ok := byTerminal[key]; ok {
- out[idx] = mergeUserDeviceRows(out[idx], row)
- continue
- }
- byTerminal[key] = len(out)
- out = append(out, row)
- }
- return out
-}
-
-func mergeUserDeviceRows(a, b model.UserDevice) model.UserDevice {
- if b.LastSeenAt.After(a.LastSeenAt) {
- a.ID = b.ID
- a.DeviceID = b.DeviceID
- a.DeviceName = b.DeviceName
- a.Client = b.Client
- a.LastIP = b.LastIP
- a.FirstSeenAt = earlierTime(a.FirstSeenAt, b.FirstSeenAt)
- a.LastSeenAt = b.LastSeenAt
- a.LastPlayAt = latestOptionalTime(a.LastPlayAt, b.LastPlayAt)
- a.Fingerprint = firstNonEmptyString(b.Fingerprint, a.Fingerprint)
- a.Kicked = b.Kicked
- return a
- }
- a.FirstSeenAt = earlierTime(a.FirstSeenAt, b.FirstSeenAt)
- a.LastPlayAt = latestOptionalTime(a.LastPlayAt, b.LastPlayAt)
- a.Fingerprint = firstNonEmptyString(a.Fingerprint, b.Fingerprint)
- return a
-}
-
-func userDeviceTerminalKey(row model.UserDevice) string {
- if key := strings.TrimSpace(row.Fingerprint); key != "" {
- return key
- }
- if strings.TrimSpace(row.DeviceName) != "" {
- return "fp-" + fingerprint(row.Client, row.DeviceName)
- }
- if id := strings.TrimSpace(row.DeviceID); id != "" {
- return id
- }
- return "unknown"
-}
-
-func earlierTime(a, b time.Time) time.Time {
- if a.IsZero() || (!b.IsZero() && b.Before(a)) {
- return b
- }
- return a
-}
-
-func latestOptionalTime(a, b *time.Time) *time.Time {
- if a == nil {
- return b
- }
- if b != nil && b.After(*a) {
- return b
- }
- return a
-}
-
-// IsDeviceKicked reports whether a (user, device) pair was kicked and should be
-// forced to re-authenticate.
-func (s *DeviceService) IsDeviceKicked(ctx context.Context, userID, deviceID string) bool {
- return s.IsTerminalKicked(ctx, userID, deviceID, "", "")
-}
-
-// IsTerminalKicked reports whether a request belongs to a kicked terminal.
-func (s *DeviceService) IsTerminalKicked(ctx context.Context, userID, deviceID, deviceName, client string) bool {
- if userID == "" {
- return false
- }
- if strings.TrimSpace(deviceID) != "" {
- d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
- if err == nil && d != nil {
- return d.Kicked
- }
- }
- fp := ""
- if strings.TrimSpace(deviceName) != "" {
- fp = fingerprint(client, deviceName)
- }
- if fp == "" {
- return false
- }
- d, err := s.repo.UserDevice.FindByFingerprint(ctx, userID, fp)
- return err == nil && d != nil && d.Kicked
-}
-
func (s *DeviceService) UserRecentlyActive(ctx context.Context, userID string, within time.Duration) bool {
return s.sessions != nil && s.sessions.UserRecentlyActive(ctx, userID, within)
}
@@ -531,84 +276,6 @@ func (s *DeviceService) notify(ctx context.Context, userID, text string) {
}
}
-// randomWindowDays returns a random integer in [min,max]. The window is
-// intentionally non-fixed per the operator's requirement ("随机触发").
-func randomWindowDays(min, max int) int {
- if min < 1 {
- min = 1
- }
- if max < min {
- max = min
- }
- if max == min {
- return min
- }
- return min + secureRandomIntn(max-min+1)
-}
-
-func (s *DeviceService) userMatchesCleanupPolicy(ctx context.Context, u *model.User, cfg botConfig) (bool, string) {
- rules := make([]accountCleanupRule, 0, len(cfg.AccountCleanupRules))
- for _, r := range cfg.AccountCleanupRules {
- if r.Enabled {
- rules = append(rules, r)
- }
- }
- if len(rules) == 0 {
- return true, "无启用规则,跳过"
- }
- matches := 0
- parts := make([]string, 0, len(rules))
- for _, r := range rules {
- ok, detail := s.userMatchesCleanupRule(ctx, u, r)
- if ok {
- matches++
- parts = append(parts, "✅ "+detail)
- } else {
- parts = append(parts, "❌ "+detail)
- }
- }
- required := 1
- return matches >= required, fmt.Sprintf("满足 %d/%d 条,需要 %d 条;%s", matches, len(rules), required, strings.Join(parts, ";"))
-}
-
-func (s *DeviceService) userMatchesCleanupRule(ctx context.Context, u *model.User, r accountCleanupRule) (bool, string) {
- switch r.Type {
- case "watch_hours":
- windowDays := randomWindowDays(r.WindowDaysMin, r.WindowDaysMax)
- since := time.Now().Add(-time.Duration(windowDays) * 24 * time.Hour)
- watched, _ := s.repo.UserDevice.WatchedMillisSince(ctx, u.ID, since)
- hours := float64(watched) / 3600000
- return hours >= r.MinHours, fmt.Sprintf("%s:近 %d 天观看 %.1f/%.1f 小时", r.Name, windowDays, hours, r.MinHours)
- case "recent_login":
- days := r.WindowDaysMax
- if days < 1 {
- days = r.WindowDaysMin
- }
- cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
- ok := u.LastLoginAt != nil && u.LastLoginAt.After(cutoff)
- if !ok && s.sessions != nil {
- ok = s.sessions.UserRecentlyActive(ctx, u.ID, time.Duration(days)*24*time.Hour)
- }
- return ok, fmt.Sprintf("%s:%d 天内登录", r.Name, days)
- case "signin_streak":
- rec, _ := s.repo.SignIn.Get(ctx, u.ID)
- streak := 0
- if rec != nil {
- streak = rec.StreakDays
- }
- return streak >= r.MinCount, fmt.Sprintf("%s:连续签到 %d/%d 天", r.Name, streak, r.MinCount)
- case "account_age_grace":
- days := r.MinCount
- if days < 1 {
- days = r.WindowDaysMax
- }
- ok := u.CreatedAt.After(time.Now().Add(-time.Duration(days) * 24 * time.Hour))
- return ok, fmt.Sprintf("%s:新账号 %d 天宽限", r.Name, days)
- default:
- return false, r.Name + ":未知规则"
- }
-}
-
func deviceLabel(name, client string) string {
name = strings.TrimSpace(name)
client = strings.TrimSpace(client)