mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix(subscription): prevent duplicate qb downloads
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">设备限制 & 删号规则</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">
|
||||
|
||||
Reference in New Issue
Block a user