fix(subscription): prevent duplicate qb downloads

This commit is contained in:
ShukeBta
2026-06-07 10:05:49 +08:00
parent 22b64d3d47
commit d94330b30f
22 changed files with 990 additions and 75 deletions
+57
View File
@@ -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()
}
}
+44
View File
@@ -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 {
+5
View File
@@ -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
}
+1 -1
View File
@@ -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))
+22 -2
View File
@@ -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)
+2
View File
@@ -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))
+5 -1
View File
@@ -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,
+3 -3
View File
@@ -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"`
+6
View File
@@ -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
+5 -2
View File
@@ -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)
}
+105
View File
@@ -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
}
+126 -44
View File
@@ -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("⛔️ 账号 <b>%s</b> 因多次触发防共享规则(%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("⚠️ 账号 <b>%s</b> 触发防共享规则:%s\n这是第 <b>%d</b> 次警告,再违规 <b>%d</b> 次将删除账号。请使用 Bot 的「我的设备」一键踢下线多余设备。", u.Username, reason, warnings, left))
s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请使用 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("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%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("⛔️ 账号 <b>%s</b> 因近 %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("⛔️ 账号 <b>%s</b> 未满足保号规则,已被清理。\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)
+136
View File
@@ -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) {
+63
View File
@@ -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)
}
}
+7 -8
View File
@@ -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))
}
}
}
+74 -5
View File
@@ -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"
+18
View File
@@ -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{
+38 -6
View File
@@ -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(
"<b>设备策略</b>\n\n① 防共享(警告制):<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d / 警告 %d 次后删号\n\n② 不活跃清理:<b>%s</b>\n 随机 %d~%d 天窗口观看 < %d 小时则删号(新号 %d 天宽限)\n\n两套策略默认关闭、互不干扰;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。",
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② 自定义删号规则:<b>%s</b>\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
}
+8 -2
View File
@@ -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 {
+3
View File
@@ -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<User>(`/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<Setting[]>('/admin/settings').then((r) => r.data),
+46 -1
View File
@@ -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 (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
@@ -289,6 +317,7 @@ function UsersPanel() {
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>状态</th>
<th>权限说明</th>
<th>最近登录</th>
<th className="text-right">操作</th>
@@ -312,6 +341,9 @@ function UsersPanel() {
)}
</td>
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
{u.is_active ? '正常' : '已禁用'}
</td>
<td className="text-ink-50">
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
</td>
@@ -349,6 +381,19 @@ function UsersPanel() {
>
<KeyRound size={12} />
</button>
<button
className={
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
(u.is_active
? 'border-orange-400/40 text-orange-500 hover:bg-orange-400/10'
: 'border-green-400/40 text-green-500 hover:bg-green-400/10')
}
disabled={u.is_protected && u.is_active}
title={u.is_active ? '禁用用户' : '解禁用户'}
onClick={() => toggleStatus(u)}
>
{u.is_active ? <UserX size={12} /> : <UserCheck size={12} />}
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
disabled={u.is_protected}
+216
View File
@@ -43,6 +43,7 @@ export function ToolsPage() {
/>
<OrganizePanel />
<AccountCleanupPanel />
<NotifyPanel />
</div>
)
@@ -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<CleanupRule[]>(defaultCleanupRules())
useEffect(() => {
adminAPI
.listSettings()
.then((settings) => {
const byKey: Record<string, string> = {}
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<CleanupRule>) =>
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 (
<section className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Info size={18} className="text-red-400" />
<h2 className="font-display text-lg font-semibold text-ink-600">设备限制 &amp; 删号规则</h2>
</div>
<p className="text-xs text-sand-500">
设备限制用于防共享:同一用户同时播放超过上限会被禁用,管理员可在用户管理或 Bot 中解禁。删号规则为“保号规则”:用户满足足够条件则保留,否则巡检时删除。
</p>
{loading ? (
<div className="text-sm text-ink-50">读取配置中…</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-4">
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm text-ink-600">
<input type="checkbox" checked={antiShare} onChange={(e) => setAntiShare(e.target.checked)} />
开启设备限制
</label>
<NumberField label="同时播放上限" value={maxPlay} onChange={setMaxPlay} />
<NumberField label="登录设备上限" value={maxClients} onChange={setMaxClients} />
<NumberField label="指纹警告阈值" value={warnThreshold} onChange={setWarnThreshold} />
</div>
<div className="grid gap-3 rounded-xl border border-gray-200 bg-white/40 p-3 md:grid-cols-4">
<label className="flex items-center gap-2 text-sm text-ink-600">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
开启删号规则巡检
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">保号模式</span>
<select className="input-base w-full" value={mode} onChange={(e) => setMode(e.target.value as 'any' | 'all' | 'count')}>
<option value="any">满足任意一条规则</option>
<option value="all">满足全部规则</option>
<option value="count">满足指定数量规则</option>
</select>
</label>
<NumberField label="指定数量" value={required} onChange={setRequired} disabled={mode !== 'count'} />
<button type="button" className="neon-button self-end" onClick={addRule}>
添加规则
</button>
</div>
<div className="space-y-3">
{rules.map((rule) => (
<div key={rule.id} className="grid gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 md:grid-cols-6">
<label className="flex items-center gap-2 text-xs text-ink-600">
<input type="checkbox" checked={rule.enabled} onChange={(e) => updateRule(rule.id, { enabled: e.target.checked })} />
启用
</label>
<input className="input-base" value={rule.name} onChange={(e) => updateRule(rule.id, { name: e.target.value })} />
<select className="input-base" value={rule.type} onChange={(e) => updateRule(rule.id, { type: e.target.value as CleanupRule['type'] })}>
<option value="watch_hours">观看时长</option>
<option value="recent_login">最近登录</option>
<option value="signin_streak">连续签到</option>
<option value="account_age_grace">新号宽限</option>
</select>
<NumberField label="窗口下限/天" value={rule.window_days_min ?? 3} onChange={(v) => updateRule(rule.id, { window_days_min: v })} />
<NumberField label="窗口上限/天" value={rule.window_days_max ?? 5} onChange={(v) => updateRule(rule.id, { window_days_max: v })} />
<div className="flex gap-2">
{rule.type === 'watch_hours' ? (
<NumberField label="最低小时" value={rule.min_hours ?? 6} onChange={(v) => updateRule(rule.id, { min_hours: v })} />
) : (
<NumberField label="最低数量/天" value={rule.min_count ?? 1} onChange={(v) => updateRule(rule.id, { min_count: v })} />
)}
<button type="button" className="rounded-lg border border-red-400/40 px-2 text-xs text-red-400" onClick={() => setRules((prev) => prev.filter((r) => r.id !== rule.id))}>
删除
</button>
</div>
</div>
))}
</div>
<button type="button" disabled={saving} className="neon-button" onClick={save}>
{saving ? <Loader2 size={16} className="animate-spin" /> : null}
保存设备与删号规则
</button>
</>
)}
</section>
)
}
function NumberField({
label,
value,
onChange,
disabled,
}: {
label: string
value: number
onChange: (value: number) => void
disabled?: boolean
}) {
return (
<label className="space-y-1">
<span className="text-xs text-ink-50">{label}</span>
<input
type="number"
min={0}
step={label.includes('小时') ? 0.5 : 1}
disabled={disabled}
className="input-base w-full"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">