mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36: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 {
|
||||
|
||||
Reference in New Issue
Block a user