diff --git a/internal/handler/active_user.go b/internal/handler/active_user.go new file mode 100644 index 0000000..19dd9e4 --- /dev/null +++ b/internal/handler/active_user.go @@ -0,0 +1,57 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func activeUserRequired(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid, _ := c.Get(middleware.CtxUserID) + userID, _ := uid.(string) + if userID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "missing user"}) + return + } + u, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil || u == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "user not found"}) + return + } + if !u.IsActive { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40302, "message": "user account is disabled"}) + return + } + if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40303, "message": "user account has expired"}) + return + } + c.Next() + } +} + +func activeEmbyUserRequired(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid, _ := c.Get(middleware.CtxUserID) + userID, _ := uid.(string) + u, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if userID == "" || err != nil || u == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"}) + return + } + if !u.IsActive { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40302, "Message": "User account is disabled"}) + return + } + if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40303, "Message": "User account has expired"}) + return + } + c.Next() + } +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 3dee9d5..290fe1c 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -67,6 +67,10 @@ type adminResetPasswordReq struct { Password string `json:"password" binding:"required,min=6"` } +type adminUpdateUserStatusReq struct { + IsActive bool `json:"is_active"` +} + func updateUserHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req adminUpdateUserReq @@ -151,6 +155,46 @@ func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc { } } +func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminUpdateUserStatusReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + userID := c.Param("id") + if !req.IsActive { + if firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } else if firstAdmin != nil && firstAdmin.ID == userID { + c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be disabled"}) + return + } + } + updates := map[string]any{"is_active": req.IsActive} + if req.IsActive { + updates["share_warnings"] = 0 + updates["last_share_warn_at"] = nil + } + if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if req.IsActive { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) + } else { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) + } + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, updated) + } +} + func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) if err != nil || firstAdmin == nil { diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go index a1a8ff9..0a69025 100644 --- a/internal/handler/downloads.go +++ b/internal/handler/downloads.go @@ -3,6 +3,7 @@ package handler import ( "context" + "errors" "net/http" "net/url" "strings" @@ -73,6 +74,10 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc { }, fallbackTitle, "") t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta) if err != nil { + if errors.Is(err, service.ErrDownloadAlreadyExists) { + c.JSON(http.StatusOK, t) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 56143ed..9985e9c 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -779,7 +779,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc)) // 鉴权后端点 - auth := grp.Group("", middleware.EmbyAuthRequired(jwtSecret)) + auth := grp.Group("", middleware.EmbyAuthRequired(jwtSecret), activeEmbyUserRequired(svc)) auth.GET("/Users/Me", embyMeHandler(svc)) auth.GET("/Users", embyListUsersHandler(svc)) auth.GET("/Users/:userId", embyGetUserByIDHandler(svc)) diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 187aa2c..744bd9c 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -92,10 +92,20 @@ func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) { if err != nil { t.Fatalf("open db: %v", err) } - if err := db.AutoMigrate(&model.Library{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.Library{}); err != nil { t.Fatalf("migrate: %v", err) } repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } for _, lib := range []model.Library{ {Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}, {Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}, @@ -201,10 +211,20 @@ func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) { if err != nil { t.Fatalf("open db: %v", err) } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil { t.Fatalf("migrate: %v", err) } repos := repository.New(db) + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "tester", + PasswordHash: "x", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatalf("create user: %v", err) + } lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true} if err := repos.Library.Create(t.Context(), &lib); err != nil { t.Fatalf("create library: %v", err) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 09dff24..146978b 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -50,6 +50,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // Authenticated endpoints. authed := api.Group("/") authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) + authed.Use(activeUserRequired(svc)) { authed.GET("/me", meHandler(svc)) authed.PATCH("/me", updateProfileHandler(svc)) @@ -298,6 +299,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C admin.POST("/users", createUserHandler(svc)) admin.PATCH("/users/:id", updateUserHandler(svc)) admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc)) + admin.PATCH("/users/:id/status", updateUserStatusHandler(svc)) admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc)) admin.DELETE("/users/:id", deleteUserHandler(svc)) admin.GET("/settings", listSettingsHandler(svc)) diff --git a/internal/handler/refresh_handler.go b/internal/handler/refresh_handler.go index b0c1a80..b2cd360 100644 --- a/internal/handler/refresh_handler.go +++ b/internal/handler/refresh_handler.go @@ -47,6 +47,10 @@ func (h *RefreshHandler) RefreshToken(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"code": 40102, "message": "refresh token expired", "data": nil}) case service.ErrTokenRevoked: c.JSON(http.StatusUnauthorized, gin.H{"code": 40103, "message": "refresh token revoked", "data": nil}) + case service.ErrUserInactive: + c.JSON(http.StatusForbidden, gin.H{"code": 40302, "message": "user account is disabled", "data": nil}) + case service.ErrUserExpired: + c.JSON(http.StatusForbidden, gin.H{"code": 40303, "message": "user account has expired", "data": nil}) default: c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil}) } @@ -54,7 +58,7 @@ func (h *RefreshHandler) RefreshToken(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "code": 0, + "code": 0, "message": "ok", "data": gin.H{ "token": tokens.AccessToken, diff --git a/internal/model/model.go b/internal/model/model.go index 4bae1a7..90edbba 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -48,9 +48,9 @@ type User struct { // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. ExpiredAt *time.Time `json:"expired_at,omitempty"` - // ShareWarnings counts anti-account-sharing warnings (too many concurrent - // playbacks / logged-in clients / device-fingerprint mismatches). Once it - // exceeds the configured threshold a re-offence deletes the account. + // ShareWarnings counts anti-account-sharing warnings, mainly device + // fingerprint mismatches. Once it exceeds the configured threshold a + // re-offence disables the account until an admin re-enables it. ShareWarnings int `gorm:"default:0" json:"share_warnings"` LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"` IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"` diff --git a/internal/repository/bot_repo.go b/internal/repository/bot_repo.go index ba6f838..5ffdadc 100644 --- a/internal/repository/bot_repo.go +++ b/internal/repository/bot_repo.go @@ -164,6 +164,12 @@ func (r *UserDeviceRepository) SetKicked(ctx context.Context, id string, kicked Update("kicked", kicked).Error } +// SetKickedByUser marks every device for a user as kicked/un-kicked. +func (r *UserDeviceRepository) SetKickedByUser(ctx context.Context, userID string, kicked bool) error { + return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("user_id = ?", userID). + Update("kicked", kicked).Error +} + // WatchedMillisSince approximates the total watched milliseconds for a user // since `since`, using the last known playback position per media. Playback // history keeps one row per (user, media), so this is an activity proxy rather diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go index 9e9808d..f84515a 100644 --- a/internal/service/bot_features_test.go +++ b/internal/service/bot_features_test.go @@ -247,14 +247,17 @@ func TestProtectedAdminNeverViolated(t *testing.T) { } _ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true") cfg := loadBotConfig(ctx, repos) - // 多次违规也不应删除/警告管理员 + // 多次违规也不应删除/警告/禁用管理员 for i := 0; i < 5; i++ { - dev.registerViolation(ctx, admin.ID, "test", cfg) + dev.registerFingerprintWarning(ctx, admin.ID, "test", cfg) } got, _ := repos.User.FindByID(ctx, admin.ID) if got == nil { t.Fatal("admin must never be auto-deleted") } + if !got.IsActive { + t.Fatal("admin must never be auto-disabled") + } if got.ShareWarnings != 0 { t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings) } diff --git a/internal/service/bot_settings.go b/internal/service/bot_settings.go index 5133af7..3631b9a 100644 --- a/internal/service/bot_settings.go +++ b/internal/service/bot_settings.go @@ -2,7 +2,9 @@ package service import ( "context" + "encoding/json" "strconv" + "strings" "github.com/ShukeBta/MediaStationGo/internal/repository" ) @@ -29,6 +31,13 @@ const ( SettingInactiveWindowMin = "device.inactive_window_days_min" // 随机窗口下限(天) SettingInactiveWindowMax = "device.inactive_window_days_max" // 随机窗口上限(天) SettingInactiveGraceDays = "device.inactive_grace_days" // 新号宽限期(天) + + // 自定义删号/保号规则。规则默认关闭;开启后按 KeepMode 计算用户 + // 是否满足足够的保号条件,未满足才会删号。 + SettingAccountCleanupEnabled = "device.account_cleanup_enabled" + SettingAccountCleanupKeepMode = "device.account_cleanup_keep_mode" // any / all / count + SettingAccountCleanupRequiredCount = "device.account_cleanup_required_count" // keep_mode=count 时需要满足几条 + SettingAccountCleanupRules = "device.account_cleanup_rules" // JSON []accountCleanupRule ) // botConfig 是设备管控的已解析配置(含默认值)。 @@ -45,6 +54,31 @@ type botConfig struct { InactiveWindowMin int InactiveWindowMax int InactiveGraceDays int + + AccountCleanupEnabled bool + AccountCleanupKeepMode string + AccountCleanupRequiredCount int + AccountCleanupRules []accountCleanupRule +} + +// accountCleanupRule is one admin-defined "保号" condition. A user is deleted +// only when the cleanup policy is enabled and the user does not satisfy the +// configured combination of enabled keep rules. +// +// Supported types: +// - watch_hours: watched hours in a random [min,max] day window >= MinHours +// - recent_login: LastLoginAt is within WindowDaysMax days +// - signin_streak: current sign-in streak >= MinCount +// - account_age_grace: account age <= MinCount days (new-user grace) +type accountCleanupRule struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + WindowDaysMin int `json:"window_days_min,omitempty"` + WindowDaysMax int `json:"window_days_max,omitempty"` + MinHours float64 `json:"min_hours,omitempty"` + MinCount int `json:"min_count,omitempty"` } // defaultBotConfig returns the safe defaults requested by the operator. @@ -62,6 +96,21 @@ func defaultBotConfig() botConfig { InactiveWindowMin: 3, InactiveWindowMax: 5, InactiveGraceDays: 7, + + AccountCleanupEnabled: false, + AccountCleanupKeepMode: "any", + AccountCleanupRequiredCount: 1, + AccountCleanupRules: []accountCleanupRule{ + { + ID: "watch_3_5d_6h", + Name: "3~5 天观看满 6 小时", + Type: "watch_hours", + Enabled: true, + WindowDaysMin: 3, + WindowDaysMax: 5, + MinHours: 6, + }, + }, } } @@ -87,9 +136,21 @@ func loadBotConfig(ctx context.Context, repo *repository.Container) botConfig { cfg.InactiveWindowMin = parseIntSettingDefault(get(SettingInactiveWindowMin), cfg.InactiveWindowMin) cfg.InactiveWindowMax = parseIntSettingDefault(get(SettingInactiveWindowMax), cfg.InactiveWindowMax) cfg.InactiveGraceDays = parseIntSettingDefault(get(SettingInactiveGraceDays), cfg.InactiveGraceDays) + cfg.AccountCleanupEnabled = parseBoolSetting(get(SettingAccountCleanupEnabled), cfg.AccountCleanupEnabled) + cfg.AccountCleanupKeepMode = normalizeCleanupKeepMode(get(SettingAccountCleanupKeepMode), cfg.AccountCleanupKeepMode) + cfg.AccountCleanupRequiredCount = parseIntSettingDefault(get(SettingAccountCleanupRequiredCount), cfg.AccountCleanupRequiredCount) + if raw := strings.TrimSpace(get(SettingAccountCleanupRules)); raw != "" { + var rules []accountCleanupRule + if err := json.Unmarshal([]byte(raw), &rules); err == nil && len(rules) > 0 { + cfg.AccountCleanupRules = normalizeCleanupRules(rules) + } + } if cfg.InactiveWindowMax < cfg.InactiveWindowMin { cfg.InactiveWindowMax = cfg.InactiveWindowMin } + if cfg.AccountCleanupRequiredCount < 1 { + cfg.AccountCleanupRequiredCount = 1 + } return cfg } @@ -101,3 +162,47 @@ func parseIntSettingDefault(value string, fallback int) int { } return n } + +func normalizeCleanupKeepMode(value, fallback string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "any", "all", "count": + return strings.ToLower(strings.TrimSpace(value)) + default: + if fallback == "" { + return "any" + } + return fallback + } +} + +func normalizeCleanupRules(rules []accountCleanupRule) []accountCleanupRule { + out := make([]accountCleanupRule, 0, len(rules)) + for _, r := range rules { + r.Type = strings.ToLower(strings.TrimSpace(r.Type)) + r.ID = strings.TrimSpace(r.ID) + r.Name = strings.TrimSpace(r.Name) + if r.ID == "" { + r.ID = r.Type + } + if r.Name == "" { + r.Name = r.ID + } + if r.WindowDaysMin < 1 { + r.WindowDaysMin = 1 + } + if r.WindowDaysMax < r.WindowDaysMin { + r.WindowDaysMax = r.WindowDaysMin + } + if r.MinCount < 0 { + r.MinCount = 0 + } + if r.MinHours < 0 { + r.MinHours = 0 + } + switch r.Type { + case "watch_hours", "recent_login", "signin_streak", "account_age_grace": + out = append(out, r) + } + } + return out +} diff --git a/internal/service/device_service.go b/internal/service/device_service.go index 13123e1..c769409 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -18,16 +18,15 @@ import ( // DeviceService implements device/session tracking and the two decoupled // enforcement policies requested by the operator: // -// ① 防共享 (anti-sharing, warning-based): too many concurrent playbacks, -// too many logged-in clients, or a device-fingerprint mismatch each emit a -// warning. After the configured number of warnings, a re-offence deletes -// the account. These three sub-rules share one per-user warning counter. -// ② 不活跃清理 (inactivity cleanup, independent toggle): watching less than -// the configured hours over a random 3–5 day window deletes the account. +// ① 防共享: too many concurrent playbacks / logged-in clients disables the +// account immediately; fingerprint mismatch is warning-based and disables +// the account after the configured warning threshold. +// ② 自定义删号/保号规则: admins define one or more keep rules; a sweep deletes +// accounts that do not satisfy the configured any/all/count rule set. // -// Safeguards (always enforced): admin / protected accounts are never auto -// disabled or deleted; a Telegram notification is sent before any destructive -// action; every threshold is configurable and both policies default to OFF. +// Safeguards: admin / protected accounts are never auto disabled or deleted; +// a Telegram notification is sent before a destructive action; every policy +// defaults to OFF. type DeviceService struct { log *zap.Logger repo *repository.Container @@ -116,12 +115,12 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic } if mismatch { - s.registerViolation(ctx, userID, fmt.Sprintf("设备指纹变更(设备:%s)", deviceLabel(deviceName, client)), cfg) + s.registerFingerprintWarning(ctx, userID, fmt.Sprintf("设备指纹变更(设备:%s)", deviceLabel(deviceName, client)), cfg) return } since := now.Add(-time.Duration(cfg.ClientActiveDays) * 24 * time.Hour) if n, err := s.repo.UserDevice.CountActiveClients(ctx, userID, since); err == nil && int(n) > cfg.MaxLoggedClients { - s.registerViolation(ctx, userID, fmt.Sprintf("同时登录客户端 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients), cfg) + s.disableForPolicy(ctx, userID, fmt.Sprintf("同时登录客户端 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients)) } } @@ -160,15 +159,14 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de } since := now.Add(-time.Duration(cfg.PlayWindowSeconds) * time.Second) if n, err := s.repo.UserDevice.CountConcurrentPlaying(ctx, userID, since); err == nil && int(n) > cfg.MaxConcurrentPlay { - s.registerViolation(ctx, userID, fmt.Sprintf("同时播放 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay), cfg) + s.disableForPolicy(ctx, userID, fmt.Sprintf("同时播放设备 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay)) } } -// registerViolation increments the shared anti-share warning counter for a -// user, notifies them, and deletes the account once warnings exceed the -// threshold. Protected accounts are never auto-deleted. Violations are -// debounced so a single burst counts at most once per minute. -func (s *DeviceService) registerViolation(ctx context.Context, userID, reason string, cfg botConfig) { +// registerFingerprintWarning increments the fingerprint warning counter for a +// user and disables the account once warnings exceed the threshold. Violations +// are debounced so a single burst counts at most once per minute. +func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID, reason string, cfg botConfig) { u, err := s.repo.User.FindByID(ctx, userID) if err != nil || u == nil { return @@ -184,12 +182,9 @@ func (s *DeviceService) registerViolation(ctx context.Context, userID, reason st warnings := u.ShareWarnings + 1 if warnings > cfg.WarnThreshold { - // Exhausted warnings → delete (notify first). - s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 %s 因多次触发防共享规则(%s)已被删除。如有疑问请联系管理员。", u.Username, reason)) - s.log.Warn("anti-share: deleting account after warnings", + s.disableForPolicy(ctx, userID, fmt.Sprintf("多次设备指纹异常:%s", reason)) + s.log.Warn("anti-share: disabling account after fingerprint warnings", zap.String("user", u.Username), zap.Int("warnings", u.ShareWarnings), zap.String("reason", reason)) - _ = s.repo.UserDevice.DeleteByUser(ctx, userID) - _ = s.repo.User.Delete(ctx, userID) return } _ = s.repo.User.UpdateFields(ctx, userID, map[string]any{ @@ -197,24 +192,43 @@ func (s *DeviceService) registerViolation(ctx context.Context, userID, reason st "last_share_warn_at": &now, }) left := cfg.WarnThreshold + 1 - warnings - s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 %s 触发防共享规则:%s\n这是第 %d 次警告,再违规 %d 次将删除账号。请使用 Bot 的「我的设备」一键踢下线多余设备。", u.Username, reason, warnings, left)) + s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 %s 触发设备指纹警告:%s\n这是第 %d 次警告,再异常 %d 次将禁用账号。请使用 Bot 的「我的设备」踢下线异常设备。", u.Username, reason, warnings, left)) s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason)) } -// SweepInactiveUsers runs the inactivity cleanup policy once. It picks a random -// window in [min,max] days, then deletes non-protected users (past their grace -// period) who watched less than the configured hours in that window. Returns -// the number of accounts removed. No-op when the policy is disabled. +func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason string) { + u, err := s.repo.User.FindByID(ctx, userID) + if err != nil || u == nil || !u.IsActive { + return + } + if s.isProtected(ctx, u) { + s.log.Info("device policy: skipping protected account", zap.String("user", u.Username), zap.String("reason", reason)) + return + } + now := time.Now() + _ = s.repo.User.UpdateFields(ctx, userID, map[string]any{ + "is_active": false, + "last_share_warn_at": &now, + }) + _ = s.repo.UserDevice.SetKickedByUser(ctx, userID, true) + s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 %s 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「我的设备」踢下线多余设备后再申请恢复。", u.Username, reason)) + 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 enough enabled keep rules according to +// keep_mode: any / all / count. Users that do not meet the policy are deleted. +func (s *DeviceService) SweepAccountCleanup(ctx context.Context) (int, error) { cfg := loadBotConfig(ctx, s.repo) - if !cfg.InactiveEnabled { + if !cfg.AccountCleanupEnabled { return 0, nil } - windowDays := randomWindowDays(cfg.InactiveWindowMin, cfg.InactiveWindowMax) - since := time.Now().Add(-time.Duration(windowDays) * 24 * time.Hour) - graceCutoff := time.Now().Add(-time.Duration(cfg.InactiveGraceDays) * 24 * time.Hour) - minMs := int64(cfg.InactiveMinHours) * 3600 * 1000 - users, err := s.repo.User.List(ctx) if err != nil { return 0, err @@ -225,19 +239,12 @@ func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) { if s.isProtected(ctx, u) || !u.IsActive { continue } - if u.CreatedAt.After(graceCutoff) { - continue // still within new-account grace period - } - watched, err := s.repo.UserDevice.WatchedMillisSince(ctx, u.ID, since) - if err != nil { + keep, details := s.userMatchesCleanupPolicy(ctx, u, cfg) + if keep { continue } - if watched >= minMs { - continue // active enough → keep - } - s.notify(ctx, u.ID, fmt.Sprintf("⛔️ 账号 %s 因近 %d 天观看时长不足 %d 小时(不活跃)已被清理。如需恢复请联系管理员。", u.Username, windowDays, cfg.InactiveMinHours)) - s.log.Warn("inactivity: deleting inactive account", - zap.String("user", u.Username), zap.Int("window_days", windowDays), zap.Int64("watched_ms", watched)) + s.notify(ctx, u.ID, fmt.Sprintf("⛔️ 账号 %s 未满足保号规则,已被清理。\n规则结果:%s\n如需恢复请联系管理员。", u.Username, details)) + s.log.Warn("account cleanup: deleting account", zap.String("user", u.Username), zap.String("details", details)) _ = s.repo.UserDevice.DeleteByUser(ctx, u.ID) if err := s.repo.User.Delete(ctx, u.ID); err == nil { removed++ @@ -259,6 +266,11 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) 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) { return s.repo.UserDevice.ListByUser(ctx, userID) @@ -295,6 +307,76 @@ func randomWindowDays(min, max int) int { return min + rand.Intn(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 + switch cfg.AccountCleanupKeepMode { + case "all": + required = len(rules) + case "count": + required = cfg.AccountCleanupRequiredCount + if required > len(rules) { + required = len(rules) + } + default: + 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 + } + ok := u.LastLoginAt != nil && u.LastLoginAt.After(time.Now().Add(-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) diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 88f9fbd..78c73d6 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -50,6 +50,11 @@ type DownloadService struct { var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) +// ErrDownloadAlreadyExists tells callers that the requested resource is already +// tracked locally or present in qBittorrent. Subscriptions treat this as a +// successful dedup hit, not as a retryable enqueue failure. +var ErrDownloadAlreadyExists = errors.New("download already exists") + // DownloadTaskMeta carries public display metadata for a download. It is // deliberately separate from the private torrent URL so API responses never // need to expose tracker tokens. @@ -181,6 +186,21 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt if savePath == "" { savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.savepath") } + title := strings.TrimSpace(meta.Title) + if title == "" { + title = publicDownloadTitle(urlStr) + meta.Title = title + } + if existing, ok := d.findExistingDownloadTask(ctx, title); ok { + return existing, ErrDownloadAlreadyExists + } + if d.torrentExistsByIdentity(ctx, title) { + task, err := d.createTask(ctx, userID, urlStr, savePath, meta) + if err != nil { + return nil, err + } + return task, ErrDownloadAlreadyExists + } var siteFetchErr error if d.site != nil { if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == nil { @@ -204,6 +224,70 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt return d.createTask(ctx, userID, urlStr, savePath, meta) } +func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string) (*model.DownloadTask, bool) { + key := downloadTaskIdentityKey(title) + if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { + return nil, false + } + rows, err := d.repo.Download.List(ctx) + if err != nil { + return nil, false + } + for i := range rows { + if !downloadTaskBlocksReadd(rows[i].Status) { + continue + } + if downloadTaskIdentityKey(rows[i].Title) == key { + return &rows[i], true + } + } + return nil, false +} + +func downloadTaskBlocksReadd(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "failed", "error", "deleted", "removed", "canceled", "cancelled": + return false + default: + return true + } +} + +func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title string) bool { + query := downloadTaskIdentityKey(title) + if query == "" { + return false + } + live, err := d.qb.List(ctx, "") + if err != nil { + return false + } + for _, torrent := range live { + current := downloadTaskIdentityKey(torrent.Name) + if current == "" { + continue + } + if current == query || strings.Contains(current, query) || strings.Contains(query, current) { + return true + } + } + return false +} + +func downloadTaskIdentityKey(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return "" + } + var b strings.Builder + for _, r := range name { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { title := strings.TrimSpace(meta.Title) if title == "" { @@ -484,10 +568,13 @@ func (d *DownloadService) poll(ctx context.Context) { if err != nil { continue } + rows, _ := d.repo.Download.List(ctx) + taskByKey := tasksByIdentity(rows) // Detect completed downloads and trigger organize for _, t := range live { hash := t.Hash complete := t.Progress >= 1.0 + d.syncDownloadTaskProgress(ctx, t, taskByKey) if complete && !d.prevStates[hash] { // Just completed: trigger organize go d.onTorrentComplete(ctx, hash, t.SavePath) @@ -498,6 +585,55 @@ func (d *DownloadService) poll(ctx context.Context) { } } +func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask) { + if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrent.Name) == "" { + return + } + matched, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey) + if !ok { + return + } + status := torrent.State + if torrent.Progress >= 1 { + status = "completed" + } + if strings.TrimSpace(status) == "" { + status = matched.Status + } + updates := map[string]any{"progress": torrent.Progress} + if status != "" { + updates["status"] = status + } + _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", matched.ID).Updates(updates).Error +} + +func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask { + out := make(map[string]model.DownloadTask, len(rows)) + for _, row := range rows { + key := downloadTaskIdentityKey(row.Title) + if key != "" { + out[key] = row + } + } + return out +} + +func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { + key := downloadTaskIdentityKey(title) + if key == "" { + return model.DownloadTask{}, false + } + if row, ok := taskByKey[key]; ok { + return row, true + } + for currentKey, row := range taskByKey { + if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) { + return row, true + } + } + return model.DownloadTask{}, false +} + // onTorrentComplete handles a torrent that just finished downloading. // It tries to find the associated Media record and trigger organize. func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, savePath string) { diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index 60c4a97..e2cca52 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -2,10 +2,19 @@ package service import ( "encoding/json" + "errors" + "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) { @@ -41,3 +50,57 @@ func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) { t.Fatalf("publicDownloadTitle = %q, want %q", got, "测试影片") } } + +func TestAddDownloadWithMetaSkipsExistingTaskBeforeQBAdd(t *testing.T) { + var addCalls int32 + qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/auth/login": + _, _ = w.Write([]byte("Ok.")) + case "/api/v2/torrents/info": + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/torrents/add": + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.DownloadTask{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + existing := &model.DownloadTask{ + UserID: "u1", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old&passkey=old", + Title: "Some Show S01E01 1080p", + SavePath: "/downloads/tv", + Status: "completed", + Progress: 1, + } + if err := repos.Download.Create(t.Context(), existing); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "https://pt.example/download?id=new&passkey=new", "/downloads/tv", DownloadTaskMeta{ + Title: "Some Show S01E01 1080p", + }) + if !errors.Is(err, ErrDownloadAlreadyExists) { + t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) + } + if task == nil || task.ID != existing.ID { + t.Fatalf("task = %#v, want existing task %#v", task, existing) + } + if got := atomic.LoadInt32(&addCalls); got != 0 { + t.Fatalf("qb add calls = %d, want 0", got) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index f7d9ab5..a1bee5a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -239,16 +239,15 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) - // 不活跃清理巡检:随机 3~5 天窗口、默认关闭,由管理员在 Bot 设备策略开启。 - // 每天触发一次评估(窗口天数随机,不固定)。 + // 账号删号/保号规则巡检:默认关闭,由管理员在 Bot 或运维工具开启。 + // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { go c.runInactivitySweeper(c.stopCtx) } } -// runInactivitySweeper periodically runs the inactivity-cleanup policy. The -// policy itself is a no-op unless an admin enabled it; this just provides the -// daily trigger with a non-fixed random window (handled inside the sweep). +// runInactivitySweeper periodically runs the account-cleanup policy. Kept with +// the historical name to avoid churn in callers. func (c *Container) runInactivitySweeper(ctx context.Context) { ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() @@ -257,10 +256,10 @@ func (c *Container) runInactivitySweeper(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if n, err := c.Device.SweepInactiveUsers(ctx); err != nil { - c.Log.Warn("inactivity sweep failed", zap.Error(err)) + if n, err := c.Device.SweepAccountCleanup(ctx); err != nil { + c.Log.Warn("account cleanup sweep failed", zap.Error(err)) } else if n > 0 { - c.Log.Info("inactivity sweep removed accounts", zap.Int("count", n)) + c.Log.Info("account cleanup sweep removed accounts", zap.Int("count", n)) } } } diff --git a/internal/service/subscription.go b/internal/service/subscription.go index e90989b..058e983 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -214,10 +214,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio queued := 0 for _, item := range feed.Channel.Items { - guid := item.GUID - if guid == "" { - guid = item.Link - } + guid := stableRSSItemGUID(item.Title, item.GUID, item.Link, item.Enclosure.URL) if _, ok := seenSet[guid]; ok { continue } @@ -253,6 +250,11 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio BackdropURL: sub.BackdropURL, Overview: sub.Overview, }); err != nil { + if errors.Is(err, ErrDownloadAlreadyExists) { + seen = append(seen, guid) + seenSet[guid] = struct{}{} + continue + } s.log.Warn("subscription enqueue failed", zap.String("title", item.Title), zap.String("media_type", mediaType), @@ -338,6 +340,11 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs BackdropURL: sub.BackdropURL, Overview: sub.Overview, }); err != nil { + if errors.Is(err, ErrDownloadAlreadyExists) { + seen = append(seen, candidate.GUID) + seenSet[candidate.GUID] = struct{}{} + continue + } lastEnqueueErr = err s.log.Warn("site-search subscription enqueue failed", zap.String("subscription", sub.Name), @@ -389,7 +396,7 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, if download == "" { continue } - guid := download + guid := stableSiteSearchGUID(item, download) if _, ok := seenSet[guid]; ok { continue } @@ -472,6 +479,68 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, return selected } +func stableRSSItemGUID(title, guid, link, enclosureURL string) string { + parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))} + for _, raw := range []string{guid, enclosureURL, link} { + if key := stableDownloadURLKey(raw); key != "" { + parts = append(parts, key) + return strings.Join(parts, "|") + } + if raw = strings.TrimSpace(raw); raw != "" { + parts = append(parts, strings.ToLower(raw)) + return strings.Join(parts, "|") + } + } + return strings.Join(parts, "|") +} + +func stableSiteSearchGUID(item SearchResult, download string) string { + parts := []string{ + "site", + strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))), + strings.ToLower(strings.TrimSpace(item.Category)), + strings.ToLower(strings.TrimSpace(item.Title)), + fmt.Sprintf("%d", item.Size), + } + if key := stableDownloadURLKey(download); key != "" { + parts = append(parts, key) + } + return strings.Join(parts, "|") +} + +func stableDownloadURLKey(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return strings.ToLower(raw) + } + if strings.EqualFold(u.Scheme, "magnet") { + xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt"))) + if xt != "" { + return "magnet:" + xt + } + return strings.ToLower(raw) + } + if u.Host == "" { + return strings.ToLower(raw) + } + q := u.Query() + kept := make([]string, 0, 4) + for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} { + if value := strings.TrimSpace(q.Get(key)); value != "" { + kept = append(kept, key+"="+strings.ToLower(value)) + } + } + base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/")) + if len(kept) > 0 { + return base + "?" + strings.Join(kept, "&") + } + return base +} + // defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效, // 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample" diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 58dcef1..a0856e5 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -3,6 +3,7 @@ package service import ( "os" "path/filepath" + "strings" "testing" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -101,6 +102,23 @@ func TestSiteSearchKeywordCanUseIMDB(t *testing.T) { } } +func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) { + item := SearchResult{ + SiteID: "mteam", + Title: "Some Show S01E01 1080p", + Category: "TV", + Size: 1024, + } + first := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=old") + second := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=new") + if first != second { + t.Fatalf("stableSiteSearchGUID changed with token: %q != %q", first, second) + } + if strings.Contains(first, "passkey") || strings.Contains(first, "old") || strings.Contains(first, "new") { + t.Fatalf("stableSiteSearchGUID leaked private token: %q", first) + } +} + func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) { sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3} results := []SearchResult{ diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index 0847f0c..f228083 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -320,6 +320,9 @@ func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessa if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil { return telegramCommandReply{Text: "修改失败:" + err.Error()} } + if s.device != nil { + _ = s.device.KickAllDevices(ctx, user.ID) + } return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"} } @@ -519,9 +522,17 @@ func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, un return telegramCommandReply{Text: reason} } } - if err := s.repo.User.UpdateFields(ctx, userID, map[string]any{"is_active": unban}); err != nil { + updates := map[string]any{"is_active": unban} + if unban { + updates["share_warnings"] = 0 + updates["last_share_warn_at"] = nil + } + if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil { return telegramCommandReply{Text: "操作失败:" + err.Error()} } + if unban { + _ = s.repo.UserDevice.SetKickedByUser(ctx, userID, false) + } return s.replyUserActions(ctx, userID) } @@ -572,14 +583,14 @@ func (s *TelegramBotService) protectReason(ctx context.Context, userID string) s func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply { cfg := loadBotConfig(ctx, s.repo) text := fmt.Sprintf( - "设备策略\n\n① 防共享(警告制):%s\n 并发播放上限 %d / 登录客户端上限 %d / 警告 %d 次后删号\n\n② 不活跃清理:%s\n 随机 %d~%d 天窗口观看 < %d 小时则删号(新号 %d 天宽限)\n\n两套策略默认关闭、互不干扰;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。", + "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② 自定义删号规则:%s\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n策略默认关闭;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。", onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold, - onOff(cfg.InactiveEnabled), cfg.InactiveWindowMin, cfg.InactiveWindowMax, cfg.InactiveMinHours, cfg.InactiveGraceDays) + onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), cfg.AccountCleanupRequiredCount, countEnabledCleanupRules(cfg.AccountCleanupRules)) return telegramCommandReply{ Text: text, Buttons: [][]telegramInlineButton{ {{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}}, - {{Text: toggleLabel("不活跃清理", cfg.InactiveEnabled), Data: "dp_toggle:inactive"}}, + {{Text: toggleLabel("删号规则", cfg.AccountCleanupEnabled), Data: "dp_toggle:cleanup"}}, {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, }, } @@ -590,8 +601,8 @@ func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which switch which { case "antishare": _ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled)) - case "inactive": - _ = s.repo.Setting.Set(ctx, SettingInactiveEnabled, strconv.FormatBool(!cfg.InactiveEnabled)) + case "cleanup": + _ = s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, strconv.FormatBool(!cfg.AccountCleanupEnabled)) } return s.replyDevicePolicy(ctx) } @@ -606,3 +617,24 @@ func toggleLabel(name string, enabled bool) string { } return "开启" + name } + +func cleanupModeLabel(mode string) string { + switch mode { + case "all": + return "满足全部规则" + case "count": + return "满足指定数量" + default: + return "满足任意一条" + } +} + +func countEnabledCleanupRules(rules []accountCleanupRule) int { + n := 0 + for _, r := range rules { + if r.Enabled { + n++ + } + } + return n +} diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index dd33492..33c5665 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -56,8 +56,8 @@ type TokenPair struct { // TokenService 错误定义。 var ( ErrInvalidRefreshToken = errors.New("invalid refresh token") - ErrTokenExpired = errors.New("token expired") - ErrTokenRevoked = errors.New("token revoked") + ErrTokenExpired = errors.New("token expired") + ErrTokenRevoked = errors.New("token revoked") ) // IssuePair 为用户签发新的令牌对。 @@ -150,6 +150,12 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token if user == nil { return nil, ErrInvalidRefreshToken } + if !user.IsActive { + return nil, ErrUserInactive + } + if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) { + return nil, ErrUserExpired + } // 撤销旧的 Refresh Token if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil { diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index c8ef449..19468e4 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -13,6 +13,9 @@ export const adminAPI = { resetUserPassword: (id: string, password: string) => api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data), + setUserStatus: (id: string, isActive: boolean) => + api.patch(`/admin/users/${id}/status`, { is_active: isActive }).then((r) => r.data), + deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data), listSettings: () => api.get('/admin/settings').then((r) => r.data), diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index ec4ef40..e6c1c04 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react' +import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react' import { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' @@ -245,6 +245,34 @@ function UsersPanel() { } } + const toggleStatus = async (u: User) => { + const next = !u.is_active + if (!next && u.is_protected) { + toast.error('受保护管理员不可禁用') + return + } + if ( + !next && + !(await confirmAction({ + title: '禁用用户', + message: `禁用「${u.username}」后,Web 与第三方客户端已有登录也会失效。`, + confirmText: '禁用', + })) + ) { + return + } + try { + await adminAPI.setUserStatus(u.id, next) + toast.success(next ? '用户已解禁' : '用户已禁用') + await refresh() + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '操作失败' + toast.error(msg) + } + } + return (
@@ -289,6 +317,7 @@ function UsersPanel() { 用户名 角色 + 状态 权限说明 最近登录 操作 @@ -312,6 +341,9 @@ function UsersPanel() { )} {u.role === 'admin' ? '管理员' : '观看用户'} + + {u.is_active ? '正常' : '已禁用'} + {u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'} @@ -349,6 +381,19 @@ function UsersPanel() { > +
) @@ -559,6 +560,221 @@ function NotifyPanel() { ) } +type CleanupRule = { + id: string + name: string + type: 'watch_hours' | 'recent_login' | 'signin_streak' | 'account_age_grace' + enabled: boolean + window_days_min?: number + window_days_max?: number + min_hours?: number + min_count?: number +} + +const cleanupKeys = { + enabled: 'device.account_cleanup_enabled', + mode: 'device.account_cleanup_keep_mode', + required: 'device.account_cleanup_required_count', + rules: 'device.account_cleanup_rules', + antishare: 'device.antishare_enabled', + maxPlay: 'device.max_concurrent_play', + maxClients: 'device.max_logged_clients', + warnThreshold: 'device.warn_threshold', +} + +function defaultCleanupRules(): CleanupRule[] { + return [ + { + id: 'watch_3_5d_6h', + name: '3~5 天观看满 6 小时', + type: 'watch_hours', + enabled: true, + window_days_min: 3, + window_days_max: 5, + min_hours: 6, + }, + ] +} + +function AccountCleanupPanel() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [enabled, setEnabled] = useState(false) + const [antiShare, setAntiShare] = useState(false) + const [maxPlay, setMaxPlay] = useState(3) + const [maxClients, setMaxClients] = useState(3) + const [warnThreshold, setWarnThreshold] = useState(2) + const [mode, setMode] = useState<'any' | 'all' | 'count'>('any') + const [required, setRequired] = useState(1) + const [rules, setRules] = useState(defaultCleanupRules()) + + useEffect(() => { + adminAPI + .listSettings() + .then((settings) => { + const byKey: Record = {} + for (const s of settings) byKey[s.key] = s.value + setEnabled(isOn(byKey[cleanupKeys.enabled])) + setAntiShare(isOn(byKey[cleanupKeys.antishare])) + setMode((['any', 'all', 'count'].includes(byKey[cleanupKeys.mode]) ? byKey[cleanupKeys.mode] : 'any') as 'any' | 'all' | 'count') + setRequired(Number(byKey[cleanupKeys.required] || 1)) + setMaxPlay(Number(byKey[cleanupKeys.maxPlay] || 3)) + setMaxClients(Number(byKey[cleanupKeys.maxClients] || 3)) + setWarnThreshold(Number(byKey[cleanupKeys.warnThreshold] || 2)) + if (byKey[cleanupKeys.rules]) { + try { + const parsed = JSON.parse(byKey[cleanupKeys.rules]) as CleanupRule[] + if (Array.isArray(parsed) && parsed.length > 0) setRules(parsed) + } catch { + // keep defaults + } + } + }) + .finally(() => setLoading(false)) + }, []) + + const updateRule = (id: string, patch: Partial) => + setRules((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))) + + const addRule = () => + setRules((prev) => [ + ...prev, + { + id: `rule_${Date.now()}`, + name: '新保号规则', + type: 'watch_hours', + enabled: true, + window_days_min: 3, + window_days_max: 5, + min_hours: 6, + }, + ]) + + const save = async () => { + setSaving(true) + try { + await adminAPI.updateSetting(cleanupKeys.antishare, antiShare ? 'true' : 'false') + await adminAPI.updateSetting(cleanupKeys.maxPlay, String(Math.max(1, maxPlay))) + await adminAPI.updateSetting(cleanupKeys.maxClients, String(Math.max(1, maxClients))) + await adminAPI.updateSetting(cleanupKeys.warnThreshold, String(Math.max(1, warnThreshold))) + await adminAPI.updateSetting(cleanupKeys.enabled, enabled ? 'true' : 'false') + await adminAPI.updateSetting(cleanupKeys.mode, mode) + await adminAPI.updateSetting(cleanupKeys.required, String(Math.max(1, required))) + await adminAPI.updateSetting(cleanupKeys.rules, JSON.stringify(rules)) + toast.success('设备策略与删号规则已保存') + } catch { + toast.error('保存失败') + } finally { + setSaving(false) + } + } + + return ( +
+
+ +

设备限制 & 删号规则

+
+

+ 设备限制用于防共享:同一用户同时播放超过上限会被禁用,管理员可在用户管理或 Bot 中解禁。删号规则为“保号规则”:用户满足足够条件则保留,否则巡检时删除。 +

+ {loading ? ( +
读取配置中…
+ ) : ( + <> +
+ + + + +
+
+ + + + +
+
+ {rules.map((rule) => ( +
+ + updateRule(rule.id, { name: e.target.value })} /> + + updateRule(rule.id, { window_days_min: v })} /> + updateRule(rule.id, { window_days_max: v })} /> +
+ {rule.type === 'watch_hours' ? ( + updateRule(rule.id, { min_hours: v })} /> + ) : ( + updateRule(rule.id, { min_count: v })} /> + )} + +
+
+ ))} +
+ + + )} +
+ ) +} + +function NumberField({ + label, + value, + onChange, + disabled, +}: { + label: string + value: number + onChange: (value: number) => void + disabled?: boolean +}) { + return ( + + ) +} + function Field({ label, children }: { label: string; children: React.ReactNode }) { return (