mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 07:46: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"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminUpdateUserStatusReq struct {
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
|
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req adminUpdateUserReq
|
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 {
|
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||||
if err != nil || firstAdmin == nil {
|
if err != nil || firstAdmin == nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -73,6 +74,10 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}, fallbackTitle, "")
|
}, fallbackTitle, "")
|
||||||
t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta)
|
t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrDownloadAlreadyExists) {
|
||||||
|
c.JSON(http.StatusOK, t)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
|||||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
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/Me", embyMeHandler(svc))
|
||||||
auth.GET("/Users", embyListUsersHandler(svc))
|
auth.GET("/Users", embyListUsersHandler(svc))
|
||||||
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
|
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
|
||||||
|
|||||||
@@ -92,10 +92,20 @@ func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open db: %v", err)
|
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)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
repos := repository.New(db)
|
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{
|
for _, lib := range []model.Library{
|
||||||
{Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true},
|
{Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true},
|
||||||
{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true},
|
{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true},
|
||||||
@@ -201,10 +211,20 @@ func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open db: %v", err)
|
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)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
repos := repository.New(db)
|
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}
|
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
t.Fatalf("create library: %v", err)
|
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.
|
// Authenticated endpoints.
|
||||||
authed := api.Group("/")
|
authed := api.Group("/")
|
||||||
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||||
|
authed.Use(activeUserRequired(svc))
|
||||||
{
|
{
|
||||||
authed.GET("/me", meHandler(svc))
|
authed.GET("/me", meHandler(svc))
|
||||||
authed.PATCH("/me", updateProfileHandler(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.POST("/users", createUserHandler(svc))
|
||||||
admin.PATCH("/users/:id", updateUserHandler(svc))
|
admin.PATCH("/users/:id", updateUserHandler(svc))
|
||||||
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
|
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
|
||||||
|
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
|
||||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||||
admin.GET("/settings", listSettingsHandler(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})
|
c.JSON(http.StatusUnauthorized, gin.H{"code": 40102, "message": "refresh token expired", "data": nil})
|
||||||
case service.ErrTokenRevoked:
|
case service.ErrTokenRevoked:
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 40103, "message": "refresh token revoked", "data": nil})
|
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:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"token": tokens.AccessToken,
|
"token": tokens.AccessToken,
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ type User struct {
|
|||||||
// expires. When set and in the past, the account is treated as expired
|
// expires. When set and in the past, the account is treated as expired
|
||||||
// (login blocked) until an admin or a redemption code renews it.
|
// (login blocked) until an admin or a redemption code renews it.
|
||||||
ExpiredAt *time.Time `json:"expired_at,omitempty"`
|
ExpiredAt *time.Time `json:"expired_at,omitempty"`
|
||||||
// ShareWarnings counts anti-account-sharing warnings (too many concurrent
|
// ShareWarnings counts anti-account-sharing warnings, mainly device
|
||||||
// playbacks / logged-in clients / device-fingerprint mismatches). Once it
|
// fingerprint mismatches. Once it exceeds the configured threshold a
|
||||||
// exceeds the configured threshold a re-offence deletes the account.
|
// re-offence disables the account until an admin re-enables it.
|
||||||
ShareWarnings int `gorm:"default:0" json:"share_warnings"`
|
ShareWarnings int `gorm:"default:0" json:"share_warnings"`
|
||||||
LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"`
|
LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"`
|
||||||
IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,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
|
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
|
// WatchedMillisSince approximates the total watched milliseconds for a user
|
||||||
// since `since`, using the last known playback position per media. Playback
|
// 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
|
// 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")
|
_ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true")
|
||||||
cfg := loadBotConfig(ctx, repos)
|
cfg := loadBotConfig(ctx, repos)
|
||||||
// 多次违规也不应删除/警告管理员
|
// 多次违规也不应删除/警告/禁用管理员
|
||||||
for i := 0; i < 5; i++ {
|
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)
|
got, _ := repos.User.FindByID(ctx, admin.ID)
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatal("admin must never be auto-deleted")
|
t.Fatal("admin must never be auto-deleted")
|
||||||
}
|
}
|
||||||
|
if !got.IsActive {
|
||||||
|
t.Fatal("admin must never be auto-disabled")
|
||||||
|
}
|
||||||
if got.ShareWarnings != 0 {
|
if got.ShareWarnings != 0 {
|
||||||
t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
|
t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -29,6 +31,13 @@ const (
|
|||||||
SettingInactiveWindowMin = "device.inactive_window_days_min" // 随机窗口下限(天)
|
SettingInactiveWindowMin = "device.inactive_window_days_min" // 随机窗口下限(天)
|
||||||
SettingInactiveWindowMax = "device.inactive_window_days_max" // 随机窗口上限(天)
|
SettingInactiveWindowMax = "device.inactive_window_days_max" // 随机窗口上限(天)
|
||||||
SettingInactiveGraceDays = "device.inactive_grace_days" // 新号宽限期(天)
|
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 是设备管控的已解析配置(含默认值)。
|
// botConfig 是设备管控的已解析配置(含默认值)。
|
||||||
@@ -45,6 +54,31 @@ type botConfig struct {
|
|||||||
InactiveWindowMin int
|
InactiveWindowMin int
|
||||||
InactiveWindowMax int
|
InactiveWindowMax int
|
||||||
InactiveGraceDays 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.
|
// defaultBotConfig returns the safe defaults requested by the operator.
|
||||||
@@ -62,6 +96,21 @@ func defaultBotConfig() botConfig {
|
|||||||
InactiveWindowMin: 3,
|
InactiveWindowMin: 3,
|
||||||
InactiveWindowMax: 5,
|
InactiveWindowMax: 5,
|
||||||
InactiveGraceDays: 7,
|
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.InactiveWindowMin = parseIntSettingDefault(get(SettingInactiveWindowMin), cfg.InactiveWindowMin)
|
||||||
cfg.InactiveWindowMax = parseIntSettingDefault(get(SettingInactiveWindowMax), cfg.InactiveWindowMax)
|
cfg.InactiveWindowMax = parseIntSettingDefault(get(SettingInactiveWindowMax), cfg.InactiveWindowMax)
|
||||||
cfg.InactiveGraceDays = parseIntSettingDefault(get(SettingInactiveGraceDays), cfg.InactiveGraceDays)
|
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 {
|
if cfg.InactiveWindowMax < cfg.InactiveWindowMin {
|
||||||
cfg.InactiveWindowMax = cfg.InactiveWindowMin
|
cfg.InactiveWindowMax = cfg.InactiveWindowMin
|
||||||
}
|
}
|
||||||
|
if cfg.AccountCleanupRequiredCount < 1 {
|
||||||
|
cfg.AccountCleanupRequiredCount = 1
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,3 +162,47 @@ func parseIntSettingDefault(value string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return n
|
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
|
// DeviceService implements device/session tracking and the two decoupled
|
||||||
// enforcement policies requested by the operator:
|
// enforcement policies requested by the operator:
|
||||||
//
|
//
|
||||||
// ① 防共享 (anti-sharing, warning-based): too many concurrent playbacks,
|
// ① 防共享: too many concurrent playbacks / logged-in clients disables the
|
||||||
// too many logged-in clients, or a device-fingerprint mismatch each emit a
|
// account immediately; fingerprint mismatch is warning-based and disables
|
||||||
// warning. After the configured number of warnings, a re-offence deletes
|
// the account after the configured warning threshold.
|
||||||
// the account. These three sub-rules share one per-user warning counter.
|
// ② 自定义删号/保号规则: admins define one or more keep rules; a sweep deletes
|
||||||
// ② 不活跃清理 (inactivity cleanup, independent toggle): watching less than
|
// accounts that do not satisfy the configured any/all/count rule set.
|
||||||
// the configured hours over a random 3–5 day window deletes the account.
|
|
||||||
//
|
//
|
||||||
// Safeguards (always enforced): admin / protected accounts are never auto
|
// Safeguards: admin / protected accounts are never auto disabled or deleted;
|
||||||
// disabled or deleted; a Telegram notification is sent before any destructive
|
// a Telegram notification is sent before a destructive action; every policy
|
||||||
// action; every threshold is configurable and both policies default to OFF.
|
// defaults to OFF.
|
||||||
type DeviceService struct {
|
type DeviceService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
@@ -116,12 +115,12 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic
|
|||||||
}
|
}
|
||||||
|
|
||||||
if mismatch {
|
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
|
return
|
||||||
}
|
}
|
||||||
since := now.Add(-time.Duration(cfg.ClientActiveDays) * 24 * time.Hour)
|
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 {
|
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)
|
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 {
|
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
|
// registerFingerprintWarning increments the fingerprint warning counter for a
|
||||||
// user, notifies them, and deletes the account once warnings exceed the
|
// user and disables the account once warnings exceed the threshold. Violations
|
||||||
// threshold. Protected accounts are never auto-deleted. Violations are
|
// are debounced so a single burst counts at most once per minute.
|
||||||
// debounced so a single burst counts at most once per minute.
|
func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID, reason string, cfg botConfig) {
|
||||||
func (s *DeviceService) registerViolation(ctx context.Context, userID, reason string, cfg botConfig) {
|
|
||||||
u, err := s.repo.User.FindByID(ctx, userID)
|
u, err := s.repo.User.FindByID(ctx, userID)
|
||||||
if err != nil || u == nil {
|
if err != nil || u == nil {
|
||||||
return
|
return
|
||||||
@@ -184,12 +182,9 @@ func (s *DeviceService) registerViolation(ctx context.Context, userID, reason st
|
|||||||
|
|
||||||
warnings := u.ShareWarnings + 1
|
warnings := u.ShareWarnings + 1
|
||||||
if warnings > cfg.WarnThreshold {
|
if warnings > cfg.WarnThreshold {
|
||||||
// Exhausted warnings → delete (notify first).
|
s.disableForPolicy(ctx, userID, fmt.Sprintf("多次设备指纹异常:%s", reason))
|
||||||
s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因多次触发防共享规则(%s)已被删除。如有疑问请联系管理员。", u.Username, reason))
|
s.log.Warn("anti-share: disabling account after fingerprint warnings",
|
||||||
s.log.Warn("anti-share: deleting account after warnings",
|
|
||||||
zap.String("user", u.Username), zap.Int("warnings", u.ShareWarnings), zap.String("reason", reason))
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = s.repo.User.UpdateFields(ctx, userID, map[string]any{
|
_ = 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,
|
"last_share_warn_at": &now,
|
||||||
})
|
})
|
||||||
left := cfg.WarnThreshold + 1 - warnings
|
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))
|
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
|
func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason string) {
|
||||||
// window in [min,max] days, then deletes non-protected users (past their grace
|
u, err := s.repo.User.FindByID(ctx, userID)
|
||||||
// period) who watched less than the configured hours in that window. Returns
|
if err != nil || u == nil || !u.IsActive {
|
||||||
// the number of accounts removed. No-op when the policy is disabled.
|
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) {
|
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)
|
cfg := loadBotConfig(ctx, s.repo)
|
||||||
if !cfg.InactiveEnabled {
|
if !cfg.AccountCleanupEnabled {
|
||||||
return 0, nil
|
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)
|
users, err := s.repo.User.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -225,19 +239,12 @@ func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) {
|
|||||||
if s.isProtected(ctx, u) || !u.IsActive {
|
if s.isProtected(ctx, u) || !u.IsActive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if u.CreatedAt.After(graceCutoff) {
|
keep, details := s.userMatchesCleanupPolicy(ctx, u, cfg)
|
||||||
continue // still within new-account grace period
|
if keep {
|
||||||
}
|
|
||||||
watched, err := s.repo.UserDevice.WatchedMillisSince(ctx, u.ID, since)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if watched >= minMs {
|
s.notify(ctx, u.ID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 未满足保号规则,已被清理。\n规则结果:%s\n如需恢复请联系管理员。", u.Username, details))
|
||||||
continue // active enough → keep
|
s.log.Warn("account cleanup: deleting account", zap.String("user", u.Username), zap.String("details", details))
|
||||||
}
|
|
||||||
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.repo.UserDevice.DeleteByUser(ctx, u.ID)
|
_ = s.repo.UserDevice.DeleteByUser(ctx, u.ID)
|
||||||
if err := s.repo.User.Delete(ctx, u.ID); err == nil {
|
if err := s.repo.User.Delete(ctx, u.ID); err == nil {
|
||||||
removed++
|
removed++
|
||||||
@@ -259,6 +266,11 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
|
|||||||
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
|
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KickAllDevices marks all devices for a user as kicked.
|
||||||
|
func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
|
||||||
|
return s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
// ListDevices returns the device sessions for a user.
|
// ListDevices returns the device sessions for a user.
|
||||||
func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
|
func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
|
||||||
return s.repo.UserDevice.ListByUser(ctx, userID)
|
return s.repo.UserDevice.ListByUser(ctx, userID)
|
||||||
@@ -295,6 +307,76 @@ func randomWindowDays(min, max int) int {
|
|||||||
return min + rand.Intn(max-min+1)
|
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 {
|
func deviceLabel(name, client string) string {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
client = strings.TrimSpace(client)
|
client = strings.TrimSpace(client)
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ type DownloadService struct {
|
|||||||
|
|
||||||
var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`)
|
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
|
// DownloadTaskMeta carries public display metadata for a download. It is
|
||||||
// deliberately separate from the private torrent URL so API responses never
|
// deliberately separate from the private torrent URL so API responses never
|
||||||
// need to expose tracker tokens.
|
// need to expose tracker tokens.
|
||||||
@@ -181,6 +186,21 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
|
|||||||
if savePath == "" {
|
if savePath == "" {
|
||||||
savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.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
|
var siteFetchErr error
|
||||||
if d.site != nil {
|
if d.site != nil {
|
||||||
if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == 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)
|
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) {
|
func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) {
|
||||||
title := strings.TrimSpace(meta.Title)
|
title := strings.TrimSpace(meta.Title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -484,10 +568,13 @@ func (d *DownloadService) poll(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
rows, _ := d.repo.Download.List(ctx)
|
||||||
|
taskByKey := tasksByIdentity(rows)
|
||||||
// Detect completed downloads and trigger organize
|
// Detect completed downloads and trigger organize
|
||||||
for _, t := range live {
|
for _, t := range live {
|
||||||
hash := t.Hash
|
hash := t.Hash
|
||||||
complete := t.Progress >= 1.0
|
complete := t.Progress >= 1.0
|
||||||
|
d.syncDownloadTaskProgress(ctx, t, taskByKey)
|
||||||
if complete && !d.prevStates[hash] {
|
if complete && !d.prevStates[hash] {
|
||||||
// Just completed: trigger organize
|
// Just completed: trigger organize
|
||||||
go d.onTorrentComplete(ctx, hash, t.SavePath)
|
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.
|
// onTorrentComplete handles a torrent that just finished downloading.
|
||||||
// It tries to find the associated Media record and trigger organize.
|
// It tries to find the associated Media record and trigger organize.
|
||||||
func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, savePath string) {
|
func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, savePath string) {
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) {
|
func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) {
|
||||||
@@ -41,3 +50,57 @@ func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) {
|
|||||||
t.Fatalf("publicDownloadTitle = %q, want %q", got, "测试影片")
|
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)
|
c.Scheduler.Start(c.stopCtx)
|
||||||
|
|
||||||
// 不活跃清理巡检:随机 3~5 天窗口、默认关闭,由管理员在 Bot 设备策略开启。
|
// 账号删号/保号规则巡检:默认关闭,由管理员在 Bot 或运维工具开启。
|
||||||
// 每天触发一次评估(窗口天数随机,不固定)。
|
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||||
if c.Device != nil {
|
if c.Device != nil {
|
||||||
go c.runInactivitySweeper(c.stopCtx)
|
go c.runInactivitySweeper(c.stopCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInactivitySweeper periodically runs the inactivity-cleanup policy. The
|
// runInactivitySweeper periodically runs the account-cleanup policy. Kept with
|
||||||
// policy itself is a no-op unless an admin enabled it; this just provides the
|
// the historical name to avoid churn in callers.
|
||||||
// daily trigger with a non-fixed random window (handled inside the sweep).
|
|
||||||
func (c *Container) runInactivitySweeper(ctx context.Context) {
|
func (c *Container) runInactivitySweeper(ctx context.Context) {
|
||||||
ticker := time.NewTicker(24 * time.Hour)
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -257,10 +256,10 @@ func (c *Container) runInactivitySweeper(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if n, err := c.Device.SweepInactiveUsers(ctx); err != nil {
|
if n, err := c.Device.SweepAccountCleanup(ctx); err != nil {
|
||||||
c.Log.Warn("inactivity sweep failed", zap.Error(err))
|
c.Log.Warn("account cleanup sweep failed", zap.Error(err))
|
||||||
} else if n > 0 {
|
} 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
|
queued := 0
|
||||||
for _, item := range feed.Channel.Items {
|
for _, item := range feed.Channel.Items {
|
||||||
guid := item.GUID
|
guid := stableRSSItemGUID(item.Title, item.GUID, item.Link, item.Enclosure.URL)
|
||||||
if guid == "" {
|
|
||||||
guid = item.Link
|
|
||||||
}
|
|
||||||
if _, ok := seenSet[guid]; ok {
|
if _, ok := seenSet[guid]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -253,6 +250,11 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
|||||||
BackdropURL: sub.BackdropURL,
|
BackdropURL: sub.BackdropURL,
|
||||||
Overview: sub.Overview,
|
Overview: sub.Overview,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadAlreadyExists) {
|
||||||
|
seen = append(seen, guid)
|
||||||
|
seenSet[guid] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
s.log.Warn("subscription enqueue failed",
|
s.log.Warn("subscription enqueue failed",
|
||||||
zap.String("title", item.Title),
|
zap.String("title", item.Title),
|
||||||
zap.String("media_type", mediaType),
|
zap.String("media_type", mediaType),
|
||||||
@@ -338,6 +340,11 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
BackdropURL: sub.BackdropURL,
|
BackdropURL: sub.BackdropURL,
|
||||||
Overview: sub.Overview,
|
Overview: sub.Overview,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadAlreadyExists) {
|
||||||
|
seen = append(seen, candidate.GUID)
|
||||||
|
seenSet[candidate.GUID] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
lastEnqueueErr = err
|
lastEnqueueErr = err
|
||||||
s.log.Warn("site-search subscription enqueue failed",
|
s.log.Warn("site-search subscription enqueue failed",
|
||||||
zap.String("subscription", sub.Name),
|
zap.String("subscription", sub.Name),
|
||||||
@@ -389,7 +396,7 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
|
|||||||
if download == "" {
|
if download == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
guid := download
|
guid := stableSiteSearchGUID(item, download)
|
||||||
if _, ok := seenSet[guid]; ok {
|
if _, ok := seenSet[guid]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -472,6 +479,68 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
|
|||||||
return selected
|
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 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
|
// defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
|
||||||
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
|
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
|
||||||
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
|
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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) {
|
func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) {
|
||||||
sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
|
sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
|
||||||
results := []SearchResult{
|
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 {
|
if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil {
|
||||||
return telegramCommandReply{Text: "修改失败:" + err.Error()}
|
return telegramCommandReply{Text: "修改失败:" + err.Error()}
|
||||||
}
|
}
|
||||||
|
if s.device != nil {
|
||||||
|
_ = s.device.KickAllDevices(ctx, user.ID)
|
||||||
|
}
|
||||||
return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
|
return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,9 +522,17 @@ func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, un
|
|||||||
return telegramCommandReply{Text: reason}
|
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()}
|
return telegramCommandReply{Text: "操作失败:" + err.Error()}
|
||||||
}
|
}
|
||||||
|
if unban {
|
||||||
|
_ = s.repo.UserDevice.SetKickedByUser(ctx, userID, false)
|
||||||
|
}
|
||||||
return s.replyUserActions(ctx, userID)
|
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 {
|
func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
|
||||||
cfg := loadBotConfig(ctx, s.repo)
|
cfg := loadBotConfig(ctx, s.repo)
|
||||||
text := fmt.Sprintf(
|
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.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{
|
return telegramCommandReply{
|
||||||
Text: text,
|
Text: text,
|
||||||
Buttons: [][]telegramInlineButton{
|
Buttons: [][]telegramInlineButton{
|
||||||
{{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}},
|
{{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"}},
|
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -590,8 +601,8 @@ func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which
|
|||||||
switch which {
|
switch which {
|
||||||
case "antishare":
|
case "antishare":
|
||||||
_ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled))
|
_ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled))
|
||||||
case "inactive":
|
case "cleanup":
|
||||||
_ = s.repo.Setting.Set(ctx, SettingInactiveEnabled, strconv.FormatBool(!cfg.InactiveEnabled))
|
_ = s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, strconv.FormatBool(!cfg.AccountCleanupEnabled))
|
||||||
}
|
}
|
||||||
return s.replyDevicePolicy(ctx)
|
return s.replyDevicePolicy(ctx)
|
||||||
}
|
}
|
||||||
@@ -606,3 +617,24 @@ func toggleLabel(name string, enabled bool) string {
|
|||||||
}
|
}
|
||||||
return "开启" + name
|
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 错误定义。
|
// TokenService 错误定义。
|
||||||
var (
|
var (
|
||||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||||
ErrTokenExpired = errors.New("token expired")
|
ErrTokenExpired = errors.New("token expired")
|
||||||
ErrTokenRevoked = errors.New("token revoked")
|
ErrTokenRevoked = errors.New("token revoked")
|
||||||
)
|
)
|
||||||
|
|
||||||
// IssuePair 为用户签发新的令牌对。
|
// IssuePair 为用户签发新的令牌对。
|
||||||
@@ -150,6 +150,12 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
|||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, ErrInvalidRefreshToken
|
return nil, ErrInvalidRefreshToken
|
||||||
}
|
}
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, ErrUserInactive
|
||||||
|
}
|
||||||
|
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||||
|
return nil, ErrUserExpired
|
||||||
|
}
|
||||||
|
|
||||||
// 撤销旧的 Refresh Token
|
// 撤销旧的 Refresh Token
|
||||||
if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
|
if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const adminAPI = {
|
|||||||
resetUserPassword: (id: string, password: string) =>
|
resetUserPassword: (id: string, password: string) =>
|
||||||
api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data),
|
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),
|
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
listSettings: () => api.get<Setting[]>('/admin/settings').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 { FormEvent, useEffect, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
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 { adminAPI } from '../api/admin'
|
||||||
import { libraryAPI } from '../api/library'
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||||
@@ -289,6 +317,7 @@ function UsersPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="py-2">用户名</th>
|
<th className="py-2">用户名</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
<th>权限说明</th>
|
<th>权限说明</th>
|
||||||
<th>最近登录</th>
|
<th>最近登录</th>
|
||||||
<th className="text-right">操作</th>
|
<th className="text-right">操作</th>
|
||||||
@@ -312,6 +341,9 @@ function UsersPanel() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</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">
|
<td className="text-ink-50">
|
||||||
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
|
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
|
||||||
</td>
|
</td>
|
||||||
@@ -349,6 +381,19 @@ function UsersPanel() {
|
|||||||
>
|
>
|
||||||
<KeyRound size={12} />
|
<KeyRound size={12} />
|
||||||
</button>
|
</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
|
<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"
|
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}
|
disabled={u.is_protected}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function ToolsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<OrganizePanel />
|
<OrganizePanel />
|
||||||
|
<AccountCleanupPanel />
|
||||||
<NotifyPanel />
|
<NotifyPanel />
|
||||||
</div>
|
</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 }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
|
|||||||
Reference in New Issue
Block a user