diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 8c168d7..1d3e4e4 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -698,6 +698,30 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri Where("user_id = ?", userID).Update("revoked", true).Error } +// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a +// user by revoking the oldest non-expired, non-revoked tokens. +func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error { + if limit < 1 { + limit = 1 + } + var tokens []model.RefreshToken + if err := r.db.WithContext(ctx). + Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()). + Order("created_at desc, id desc"). + Find(&tokens).Error; err != nil { + return err + } + if len(tokens) <= limit { + return nil + } + ids := make([]string, 0, len(tokens)-limit) + for _, token := range tokens[limit:] { + ids = append(ids, token.ID) + } + return r.db.WithContext(ctx).Model(&model.RefreshToken{}). + Where("id IN ?", ids).Update("revoked", true).Error +} + // DeleteExpired removes all expired refresh tokens. func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error { return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index 4570e1b..6d787e9 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -188,6 +188,34 @@ func TestAdminResetPasswordAllowsLoginWithNewPassword(t *testing.T) { } } +func TestLoginKeepsOnlyConfiguredActiveRefreshTokens(t *testing.T) { + ctx := context.Background() + repos, auth, _, _ := newAuthTestServices(t) + user, _, err := auth.Register(ctx, "viewer", "password") + if err != nil { + t.Fatalf("register: %v", err) + } + if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil { + t.Fatalf("set max clients: %v", err) + } + + for i := 0; i < 5; i++ { + if _, err := auth.Login(ctx, "viewer", "password"); err != nil { + t.Fatalf("login %d: %v", i+1, err) + } + } + + var active int64 + if err := repos.DB.Model(&model.RefreshToken{}). + Where("user_id = ? AND revoked = ? AND expires_at > ?", user.ID, false, time.Now()). + Count(&active).Error; err != nil { + t.Fatalf("count active refresh tokens: %v", err) + } + if active != 3 { + t.Fatalf("active refresh tokens should be capped at 3, got %d", active) + } +} + func TestDefaultPermissionsAreViewerOnly(t *testing.T) { perms := DefaultPermissions("user-1") if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer { diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go index f84515a..5cec584 100644 --- a/internal/service/bot_features_test.go +++ b/internal/service/bot_features_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "testing" "time" @@ -262,3 +263,113 @@ func TestProtectedAdminNeverViolated(t *testing.T) { t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings) } } + +func TestBotAdminCommandsManageDevicePolicy(t *testing.T) { + ctx := context.Background() + repos, bot := newBotTestService(t) + admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} + if err := repos.User.Create(ctx, admin); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 9001, + TelegramName: "@root", + ChatID: 9001, + UserID: admin.ID, + }).Error; err != nil { + t.Fatal(err) + } + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on play=4 login=5 warn=3") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "防共享:已开启") { + t.Fatalf("expected antishare enabled reply, got %q", reply.Text) + } + cfg := loadBotConfig(ctx, repos) + if !cfg.AntiShareEnabled || cfg.MaxConcurrentPlay != 4 || cfg.MaxLoggedClients != 5 || cfg.WarnThreshold != 3 { + t.Fatalf("unexpected device policy: %+v", cfg) + } + + reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_mode count 2") + if err != nil { + t.Fatal(err) + } + cfg = loadBotConfig(ctx, repos) + if cfg.AccountCleanupKeepMode != "count" || cfg.AccountCleanupRequiredCount != 2 { + t.Fatalf("unexpected cleanup mode: %+v; reply=%q", cfg, reply.Text) + } + + reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add recent_login login_7d 七天内登录 7") + if err != nil { + t.Fatal(err) + } + cfg = loadBotConfig(ctx, repos) + found := false + for _, rule := range cfg.AccountCleanupRules { + if rule.ID == "login_7d" && rule.Type == "recent_login" && rule.WindowDaysMax == 7 { + found = true + } + } + if !found { + t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) + } +} + +func TestBotUserCommandsAndAdminGate(t *testing.T) { + ctx := context.Background() + repos, bot := newBotTestService(t) + user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} + if err := repos.User.Create(ctx, user); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 9101, + TelegramName: "@viewer", + ChatID: 9101, + UserID: user.ID, + }).Error; err != nil { + t.Fatal(err) + } + now := time.Now() + if err := repos.UserDevice.Create(ctx, &model.UserDevice{ + UserID: user.ID, DeviceID: "dev-1", DeviceName: "iPhone", Client: "Infuse", FirstSeenAt: now, LastSeenAt: now, + }); err != nil { + t.Fatal(err) + } + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9101, Username: "viewer"}, Chat: TelegramChat{ID: 9101, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "仅管理员") { + t.Fatalf("regular user should not manage policy, got %q", reply.Text) + } + + reply, err = bot.executeCommand(ctx, channel, msg, "/devices") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "我的登录设备") { + t.Fatalf("expected device list, got %q", reply.Text) + } + + reply, err = bot.executeCommand(ctx, channel, msg, "/kick 1") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "已踢下线") { + t.Fatalf("expected kick feedback, got %q", reply.Text) + } + if kicked := bot.device; kicked != nil { + t.Fatal("test should not require wired device service") + } + if ok := NewDeviceService(zap.NewNop(), repos).IsDeviceKicked(ctx, user.ID, "dev-1"); !ok { + t.Fatal("device should be marked kicked") + } +} diff --git a/internal/service/bot_settings.go b/internal/service/bot_settings.go index 3631b9a..a75643f 100644 --- a/internal/service/bot_settings.go +++ b/internal/service/bot_settings.go @@ -9,8 +9,8 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) -// Bot / 设备管控相关的设置键。全部存储在 settings 表,可由管理员在 Bot 或 -// 系统设置页调整。带安全默认值:所有"自动删号"策略默认关闭。 +// Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过 +// Telegram Bot 命令调整。带安全默认值:所有"自动删号"策略默认关闭。 const ( // 开放注册(开注名额)。 SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册 diff --git a/internal/service/service.go b/internal/service/service.go index a1bee5a..a91ebff 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -239,7 +239,7 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) - // 账号删号/保号规则巡检:默认关闭,由管理员在 Bot 或运维工具开启。 + // 账号删号/保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { go c.runInactivitySweeper(c.stopCtx) diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 20232ca..6f3efdb 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -134,6 +134,8 @@ func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto * // 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。 const TelegramRegistrationSettingKey = "telegram.registration_enabled" +var errTelegramAccountAlreadyBound = errors.New("该媒体账号已绑定其他 Telegram,请联系管理员解绑") + // registrationEnabled 读取注册开关;默认关闭。 func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool { v, err := s.repo.Setting.Get(ctx, TelegramRegistrationSettingKey) @@ -251,13 +253,58 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model. return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil case "/hideadult", "/hide_adult", "/adult": return s.cmdHideAdult(ctx, msg, args), nil + case "/account", "/me": + return s.replyAccount(ctx, msg), nil + case "/devices": + return s.replyDevices(ctx, msg), nil + case "/kick": + return s.cmdKick(ctx, msg, args), nil + case "/setname", "/rename": + return s.cmdSetName(ctx, msg, args), nil + case "/setpass", "/passwd", "/password": + return s.cmdSetPass(ctx, msg, args), nil case "/register", "/reg", "/signup": return s.cmdRegister(ctx, channel, msg, args), nil - case "/registration", "/reg_switch": + case "/registration", "/reg_switch", "/openreg": if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil } return s.cmdRegistrationToggle(ctx, args), nil + case "/devicepolicy", "/policy": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdDevicePolicy(ctx, args), nil + case "/antishare": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdAntiShare(ctx, args), nil + case "/cleanup": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdCleanup(ctx, args), nil + case "/cleanup_mode": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdCleanupMode(ctx, args), nil + case "/cleanup_rule": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdCleanupRule(ctx, args), nil + case "/ban": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdUserBan(ctx, args, false), nil + case "/unban": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdUserBan(ctx, args, true), nil case "/status": if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil @@ -305,8 +352,10 @@ func telegramCommandName(text string) string { func telegramSupportedCommand(cmd string) bool { switch cmd { case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult", - "/register", "/reg", "/signup", "/registration", "/reg_switch", - "/status", "/search", "/downloads", "/stats": + "/account", "/me", "/devices", "/kick", "/setname", "/rename", "/setpass", "/passwd", "/password", + "/register", "/reg", "/signup", "/registration", "/reg_switch", "/openreg", + "/devicepolicy", "/policy", "/antishare", "/cleanup", "/cleanup_mode", "/cleanup_rule", + "/ban", "/unban", "/status", "/search", "/downloads", "/stats": return true default: return false @@ -461,14 +510,26 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) return "MediaStationGo 用户命令\n\n" + register + "/start 用户名 密码 — 绑定账号\n" + + "/account — 查看账号状态\n" + + "/devices — 查看登录设备\n" + + "/kick all|编号 — 踢下线设备\n" + + "/setname 新用户名 — 修改用户名\n" + + "/setpass 新密码 — 修改密码\n" + "/hideadult on|off — 隐藏或显示成人目录\n\n" + "系统状态、搜索、下载列表与统计命令仅管理员可用。" } return "MediaStationGo 命令列表\n\n" + "/start — 开始使用\n" + "/help — 帮助信息\n" + + "/account / /devices / /kick all|编号 — 用户自助设备管理\n" + + "/setname 新用户名 / /setpass 新密码 — 用户自助改名改密\n" + "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + "/registration on|off — 开启/关闭普通用户注册(管理员)\n" + + "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" + + "/cleanup on|off|run — 删号规则开关/巡检(管理员)\n" + + "/cleanup_mode any|all|count 2 — 保号模式(管理员)\n" + + "/cleanup_rule list|add|del|enable|disable — 保号规则(管理员)\n" + + "/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" + "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" + "/status — 系统运行状态\n" + "/search 关键词 — 搜索媒体库\n" + @@ -1102,6 +1163,11 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel var existing model.TelegramBinding err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(msg.From.ID)).First(&existing).Error if err == nil { + if existing.UserID != userID { + if err := s.ensureTelegramAccountBindingAvailable(ctx, userID, int64(msg.From.ID)); err != nil { + return err + } + } return s.repo.DB.WithContext(ctx).Model(&existing).Updates(map[string]any{ "telegram_name": name, "chat_id": int64(msg.Chat.ID), @@ -1114,6 +1180,9 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil { return err } + if err := s.ensureTelegramAccountBindingAvailable(ctx, userID, int64(msg.From.ID)); err != nil { + return err + } return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{ TelegramUserID: int64(msg.From.ID), TelegramName: name, @@ -1122,6 +1191,24 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel }).Error } +func (s *TelegramBotService) ensureTelegramAccountBindingAvailable(ctx context.Context, userID string, telegramUserID int64) error { + var bound model.TelegramBinding + err := s.repo.DB.WithContext(ctx). + Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID). + First(&bound).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + if user, _ := s.repo.User.FindByID(ctx, bound.UserID); user == nil { + _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", bound.ID).Error + return nil + } + return errTelegramAccountAlreadyBound +} + func parseStartCredentials(args []string) (string, string) { if len(args) >= 2 { return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " ")) diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go index 8925f14..5c6a7e2 100644 --- a/internal/service/telegram_bot_user_test.go +++ b/internal/service/telegram_bot_user_test.go @@ -161,3 +161,48 @@ func TestTelegramStartClearsStaleUserBinding(t *testing.T) { t.Fatalf("stale binding should be removed, got %d", count) } } + +func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T) { + ctx := t.Context() + repos, auth, _, _ := newAuthTestServices(t) + user, _, err := auth.Register(ctx, "viewer", "secret-pass") + if err != nil { + t.Fatalf("register: %v", err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 20001, + TelegramName: "@viewer-one", + ChatID: 20001, + UserID: user.ID, + }).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + + bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) + cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20002"}) + if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { + t.Fatalf("migrate notify channel: %v", err) + } + if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil { + t.Fatalf("create notify channel: %v", err) + } + msg := &TelegramMessage{ + From: TelegramUser{ID: 20002, Username: "viewer-two", FirstName: "Viewer Two"}, + Chat: TelegramChat{ID: 20002, Type: "private"}, + } + reply := bot.cmdStart(ctx, msg, []string{"viewer", "secret-pass"}) + + if !strings.Contains(reply.Text, "已绑定其他 Telegram") { + t.Fatalf("expected already-bound rejection, got %q", reply.Text) + } + var accountBindings int64 + if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&accountBindings).Error; err != nil { + t.Fatalf("count account bindings: %v", err) + } + if accountBindings != 1 { + t.Fatalf("account should keep exactly one telegram binding, got %d", accountBindings) + } + if binding := bot.telegramBinding(ctx, 20002); binding != nil { + t.Fatal("second telegram account must not be bound") + } +} diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index f228083..3053be0 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -216,6 +217,63 @@ func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *mod // ── 用户自助 ────────────────────────────────────────────────────────────── +func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + user := s.boundUser(ctx, msg.From.ID) + if user == nil { + return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} + } + if len(args) == 0 { + return telegramCommandReply{Text: "请指定要踢下线的设备:/kick all 或 /kick 设备编号。先用 /devices 查看编号。"} + } + target := strings.TrimSpace(args[0]) + if strings.EqualFold(target, "all") || target == "全部" { + if s.device != nil { + if err := s.device.KickAllDevices(ctx, user.ID); err != nil { + return telegramCommandReply{Text: "踢下线失败:" + err.Error()} + } + } else if err := s.repo.UserDevice.SetKickedByUser(ctx, user.ID, true); err != nil { + return telegramCommandReply{Text: "踢下线失败:" + err.Error()} + } + return telegramCommandReply{Text: "已踢下线此账号的全部设备。"} + } + devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID) + if len(devices) == 0 { + return telegramCommandReply{Text: "当前没有记录到登录设备。"} + } + var chosen *model.UserDevice + if n, err := strconv.Atoi(target); err == nil && n >= 1 && n <= len(devices) { + chosen = &devices[n-1] + } else { + for i := range devices { + if devices[i].ID == target || devices[i].DeviceID == target { + chosen = &devices[i] + break + } + } + } + if chosen == nil { + return telegramCommandReply{Text: "未找到该设备。请用 /devices 查看设备编号后重试。"} + } + if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil { + return telegramCommandReply{Text: "踢下线失败:" + err.Error()} + } + return telegramCommandReply{Text: fmt.Sprintf("已踢下线:%s。", deviceLabel(chosen.DeviceName, chosen.Client))} +} + +func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "请发送:/setname 新用户名"} + } + return s.selfSetName(ctx, msg, strings.Join(args, " ")) +} + +func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "请发送:/setpass 新密码"} + } + return s.selfSetPass(ctx, msg, strings.Join(args, " ")) +} + func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { @@ -583,7 +641,7 @@ func (s *TelegramBotService) protectReason(ctx context.Context, userID string) s func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply { cfg := loadBotConfig(ctx, s.repo) text := fmt.Sprintf( - "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② 自定义删号规则:%s\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n策略默认关闭;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。", + "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② 自定义删号规则:%s\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup on|off|run\n/cleanup_mode any|all|count 2\n/cleanup_rule list|add|del|enable|disable\n\n策略默认关闭;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。", onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold, onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), cfg.AccountCleanupRequiredCount, countEnabledCleanupRules(cfg.AccountCleanupRules)) return telegramCommandReply{ @@ -596,6 +654,168 @@ func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramComm } } +func (s *TelegramBotService) cmdDevicePolicy(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 || strings.EqualFold(args[0], "status") { + return s.replyDevicePolicy(ctx) + } + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "run", "sweep": + return s.cmdCleanup(ctx, []string{"run"}) + default: + return telegramCommandReply{Text: "用法:/devicepolicy 查看策略,或使用 /antishare、/cleanup、/cleanup_rule 管理。"} + } +} + +func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 || strings.EqualFold(args[0], "status") { + return s.replyDevicePolicy(ctx) + } + enabled, ok := parseCommandBool(args[0]) + if !ok { + return telegramCommandReply{Text: "用法:/antishare on|off [play=3] [login=3] [warn=2]"} + } + if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil { + return telegramCommandReply{Text: "更新失败:" + err.Error()} + } + for _, arg := range args[1:] { + key, value, ok := strings.Cut(arg, "=") + if !ok { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n < 1 { + continue + } + switch strings.ToLower(strings.TrimSpace(key)) { + case "play", "maxplay", "播放": + _ = s.repo.Setting.Set(ctx, SettingMaxConcurrentPlay, strconv.Itoa(n)) + case "login", "client", "clients", "登录": + _ = s.repo.Setting.Set(ctx, SettingMaxLoggedClients, strconv.Itoa(n)) + case "warn", "warnings", "警告": + _ = s.repo.Setting.Set(ctx, SettingWarnThreshold, strconv.Itoa(n)) + } + } + return s.replyDevicePolicy(ctx) +} + +func (s *TelegramBotService) cmdCleanup(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 || strings.EqualFold(args[0], "status") { + return s.replyDevicePolicy(ctx) + } + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "on", "true", "1", "开启", "enable": + if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { + return telegramCommandReply{Text: "开启失败:" + err.Error()} + } + return s.replyDevicePolicy(ctx) + case "off", "false", "0", "关闭", "disable": + if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "false"); err != nil { + return telegramCommandReply{Text: "关闭失败:" + err.Error()} + } + return s.replyDevicePolicy(ctx) + case "run", "sweep", "巡检": + device := s.device + if device == nil { + device = NewDeviceService(s.log, s.repo) + } + removed, err := device.SweepAccountCleanup(ctx) + if err != nil { + return telegramCommandReply{Text: "巡检失败:" + err.Error()} + } + return telegramCommandReply{Text: fmt.Sprintf("删号规则巡检完成,清理 %d 个账号。", removed)} + default: + return telegramCommandReply{Text: "用法:/cleanup on|off|run"} + } +} + +func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "用法:/cleanup_mode any、/cleanup_mode all 或 /cleanup_mode count 2"} + } + mode := strings.ToLower(strings.TrimSpace(args[0])) + if mode != "any" && mode != "all" && mode != "count" { + return telegramCommandReply{Text: "保号模式无效,只支持 any / all / count。"} + } + if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, mode); err != nil { + return telegramCommandReply{Text: "更新失败:" + err.Error()} + } + if mode == "count" && len(args) > 1 { + n, err := strconv.Atoi(args[1]) + if err == nil && n > 0 { + _ = s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, strconv.Itoa(n)) + } + } + return s.replyDevicePolicy(ctx) +} + +func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: cleanupRuleHelp()} + } + rules := s.currentCleanupRules(ctx) + action := strings.ToLower(strings.TrimSpace(args[0])) + switch action { + case "list", "ls", "status": + return telegramCommandReply{Text: formatCleanupRules(rules)} + case "del", "delete", "rm": + if len(args) < 2 { + return telegramCommandReply{Text: "用法:/cleanup_rule del 规则ID"} + } + next := make([]accountCleanupRule, 0, len(rules)) + removed := false + for _, r := range rules { + if r.ID == args[1] { + removed = true + continue + } + next = append(next, r) + } + if !removed { + return telegramCommandReply{Text: "未找到该规则。"} + } + if err := s.saveCleanupRules(ctx, next); err != nil { + return telegramCommandReply{Text: "保存失败:" + err.Error()} + } + return telegramCommandReply{Text: "已删除规则。\n\n" + formatCleanupRules(next)} + case "enable", "on", "disable", "off": + if len(args) < 2 { + return telegramCommandReply{Text: "用法:/cleanup_rule enable|disable 规则ID"} + } + enable := action == "enable" || action == "on" + changed := false + for i := range rules { + if rules[i].ID == args[1] { + rules[i].Enabled = enable + changed = true + } + } + if !changed { + return telegramCommandReply{Text: "未找到该规则。"} + } + if err := s.saveCleanupRules(ctx, rules); err != nil { + return telegramCommandReply{Text: "保存失败:" + err.Error()} + } + return telegramCommandReply{Text: "已更新规则状态。\n\n" + formatCleanupRules(rules)} + case "add": + rule, err := parseCleanupRuleCommand(args[1:]) + if err != nil { + return telegramCommandReply{Text: err.Error() + "\n\n" + cleanupRuleHelp()} + } + for _, r := range rules { + if r.ID == rule.ID { + return telegramCommandReply{Text: "规则 ID 已存在,请换一个 ID。"} + } + } + rules = normalizeCleanupRules(append(rules, rule)) + if err := s.saveCleanupRules(ctx, rules); err != nil { + return telegramCommandReply{Text: "保存失败:" + err.Error()} + } + return telegramCommandReply{Text: "已新增规则。\n\n" + formatCleanupRules(rules)} + default: + return telegramCommandReply{Text: cleanupRuleHelp()} + } +} + func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply { cfg := loadBotConfig(ctx, s.repo) switch which { @@ -607,6 +827,139 @@ func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which return s.replyDevicePolicy(ctx) } +func (s *TelegramBotService) cmdUserBan(ctx context.Context, args []string, unban bool) telegramCommandReply { + if len(args) == 0 { + if unban { + return telegramCommandReply{Text: "用法:/unban 用户名"} + } + return telegramCommandReply{Text: "用法:/ban 用户名"} + } + user, _ := s.repo.User.FindByUsername(ctx, args[0]) + if user == nil { + user, _ = s.repo.User.FindByID(ctx, args[0]) + } + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + return s.replyUserBan(ctx, user.ID, unban) +} + +func (s *TelegramBotService) currentCleanupRules(ctx context.Context) []accountCleanupRule { + cfg := loadBotConfig(ctx, s.repo) + return cfg.AccountCleanupRules +} + +func (s *TelegramBotService) saveCleanupRules(ctx context.Context, rules []accountCleanupRule) error { + raw, err := json.Marshal(normalizeCleanupRules(rules)) + if err != nil { + return err + } + return s.repo.Setting.Set(ctx, SettingAccountCleanupRules, string(raw)) +} + +func parseCommandBool(value string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": + return true, true + case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": + return false, true + default: + return false, false + } +} + +func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) { + if len(args) < 2 { + return accountCleanupRule{}, fmt.Errorf("新增规则参数不足") + } + rule := accountCleanupRule{ + Type: strings.ToLower(strings.TrimSpace(args[0])), + ID: strings.TrimSpace(args[1]), + Name: strings.TrimSpace(args[1]), + Enabled: true, + WindowDaysMin: 3, + WindowDaysMax: 5, + MinHours: 6, + MinCount: 1, + } + if len(args) > 2 { + rule.Name = strings.TrimSpace(args[2]) + } + switch rule.Type { + case "watch_hours": + if len(args) >= 6 { + rule.WindowDaysMin, _ = strconv.Atoi(args[3]) + rule.WindowDaysMax, _ = strconv.Atoi(args[4]) + rule.MinHours, _ = strconv.ParseFloat(args[5], 64) + } + case "recent_login": + if len(args) >= 4 { + rule.WindowDaysMax, _ = strconv.Atoi(args[3]) + } + case "signin_streak", "account_age_grace": + if len(args) >= 4 { + rule.MinCount, _ = strconv.Atoi(args[3]) + } + default: + return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type) + } + normalized := normalizeCleanupRules([]accountCleanupRule{rule}) + if len(normalized) == 0 { + return accountCleanupRule{}, fmt.Errorf("规则无效") + } + return normalized[0], nil +} + +func formatCleanupRules(rules []accountCleanupRule) string { + if len(rules) == 0 { + return "保号规则\n\n暂无规则。" + } + var sb strings.Builder + sb.WriteString("保号规则\n") + for i, r := range rules { + state := map[bool]string{true: "启用", false: "停用"}[r.Enabled] + sb.WriteString(fmt.Sprintf("\n%d. %s · %s · %s · %s", i+1, r.ID, r.Name, cleanupRuleTypeLabel(r.Type), state)) + switch r.Type { + case "watch_hours": + sb.WriteString(fmt.Sprintf(" · %d~%d 天 %.1f 小时", r.WindowDaysMin, r.WindowDaysMax, r.MinHours)) + case "recent_login": + sb.WriteString(fmt.Sprintf(" · %d 天内登录", r.WindowDaysMax)) + case "signin_streak": + sb.WriteString(fmt.Sprintf(" · 连续签到 %d 天", r.MinCount)) + case "account_age_grace": + sb.WriteString(fmt.Sprintf(" · 新号宽限 %d 天", r.MinCount)) + } + } + return sb.String() +} + +func cleanupRuleTypeLabel(t string) string { + switch t { + case "watch_hours": + return "观看时长" + case "recent_login": + return "最近登录" + case "signin_streak": + return "连续签到" + case "account_age_grace": + return "新号宽限" + default: + return t + } +} + +func cleanupRuleHelp() string { + return "删号/保号规则命令\n\n" + + "/cleanup_rule list — 查看规则\n" + + "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6\n" + + "/cleanup_rule add recent_login login_7d 七天内登录 7\n" + + "/cleanup_rule add signin_streak sign_3 连续签到3天 3\n" + + "/cleanup_rule add account_age_grace new_7d 新号宽限7天 7\n" + + "/cleanup_rule enable 规则ID / disable 规则ID\n" + + "/cleanup_rule del 规则ID\n\n" + + "保号模式:/cleanup_mode any|all|count 2" +} + func onOff(b bool) string { return map[bool]string{true: "已开启", false: "已关闭"}[b] } diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index 33c5665..62fc437 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -84,6 +84,9 @@ func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) if err := s.repo.RefreshToken.Create(ctx, rt); err != nil { return nil, err } + if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, userID, s.maxActiveRefreshTokens(ctx)); err != nil { + s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", userID), zap.Error(err)) + } return &TokenPair{ AccessToken: accessToken, @@ -93,6 +96,14 @@ func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) }, nil } +func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int { + cfg := loadBotConfig(ctx, s.repo) + if cfg.MaxLoggedClients < 1 { + return defaultBotConfig().MaxLoggedClients + } + return cfg.MaxLoggedClients +} + // issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。 func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) { claims := Claims{ diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1a04e7b..841daf9 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -267,20 +267,6 @@ const GROUPS: SettingGroup[] = [ }, ], }, - { - key: 'telegram', - label: 'Telegram Bot', - description: '机器人注册与普通用户权限', - items: [ - { - key: 'telegram.registration_enabled', - label: '允许普通用户通过 Bot 注册', - type: 'toggle', - hint: '默认关闭。开启后用户可在 Telegram 中用 /register 用户名 密码 注册账号并自动绑定;关闭后注册被拒绝。普通用户始终只能绑定账号、开关成人目录显隐及使用 /start、/help。', - defaultValue: 'false', - }, - ], - }, // qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器), // 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与 // /api/admin/download/clients 写入的数据来源冲突。 diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx index a41a895..09b42ed 100644 --- a/web/src/pages/ToolsPage.tsx +++ b/web/src/pages/ToolsPage.tsx @@ -43,7 +43,6 @@ export function ToolsPage() { /> - ) @@ -145,7 +144,7 @@ function OrganizePanel() { const sc = defaults['organizer.smart_classify'] setSmartClassify(sc === 'true' || sc === '1' || sc === 'on') toast.success('整理默认设置已保存') - } catch (err) { + } catch { toast.error('保存失败') } finally { setSavingDefaults(false) @@ -560,220 +559,6 @@ function NotifyPanel() { ) } -type CleanupRule = { - id: string - name: string - type: 'watch_hours' | 'recent_login' | 'signin_streak' | 'account_age_grace' - enabled: boolean - window_days_min?: number - window_days_max?: number - min_hours?: number - min_count?: number -} - -const cleanupKeys = { - enabled: 'device.account_cleanup_enabled', - mode: 'device.account_cleanup_keep_mode', - required: 'device.account_cleanup_required_count', - rules: 'device.account_cleanup_rules', - antishare: 'device.antishare_enabled', - maxPlay: 'device.max_concurrent_play', - maxClients: 'device.max_logged_clients', - warnThreshold: 'device.warn_threshold', -} - -function defaultCleanupRules(): CleanupRule[] { - return [ - { - id: 'watch_3_5d_6h', - name: '3~5 天观看满 6 小时', - type: 'watch_hours', - enabled: true, - window_days_min: 3, - window_days_max: 5, - min_hours: 6, - }, - ] -} - -function AccountCleanupPanel() { - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [enabled, setEnabled] = useState(false) - const [antiShare, setAntiShare] = useState(false) - const [maxPlay, setMaxPlay] = useState(3) - const [maxClients, setMaxClients] = useState(3) - const [warnThreshold, setWarnThreshold] = useState(2) - const [mode, setMode] = useState<'any' | 'all' | 'count'>('any') - const [required, setRequired] = useState(1) - const [rules, setRules] = useState(defaultCleanupRules()) - - useEffect(() => { - adminAPI - .listSettings() - .then((settings) => { - const byKey: Record = {} - for (const s of settings) byKey[s.key] = s.value - setEnabled(isOn(byKey[cleanupKeys.enabled])) - setAntiShare(isOn(byKey[cleanupKeys.antishare])) - setMode((['any', 'all', 'count'].includes(byKey[cleanupKeys.mode]) ? byKey[cleanupKeys.mode] : 'any') as 'any' | 'all' | 'count') - setRequired(Number(byKey[cleanupKeys.required] || 1)) - setMaxPlay(Number(byKey[cleanupKeys.maxPlay] || 3)) - setMaxClients(Number(byKey[cleanupKeys.maxClients] || 3)) - setWarnThreshold(Number(byKey[cleanupKeys.warnThreshold] || 2)) - if (byKey[cleanupKeys.rules]) { - try { - const parsed = JSON.parse(byKey[cleanupKeys.rules]) as CleanupRule[] - if (Array.isArray(parsed) && parsed.length > 0) setRules(parsed) - } catch { - // keep defaults - } - } - }) - .finally(() => setLoading(false)) - }, []) - - const updateRule = (id: string, patch: Partial) => - setRules((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))) - - const addRule = () => - setRules((prev) => [ - ...prev, - { - id: `rule_${Date.now()}`, - name: '新保号规则', - type: 'watch_hours', - enabled: true, - window_days_min: 3, - window_days_max: 5, - min_hours: 6, - }, - ]) - - const save = async () => { - setSaving(true) - try { - await adminAPI.updateSetting(cleanupKeys.antishare, antiShare ? 'true' : 'false') - await adminAPI.updateSetting(cleanupKeys.maxPlay, String(Math.max(1, maxPlay))) - await adminAPI.updateSetting(cleanupKeys.maxClients, String(Math.max(1, maxClients))) - await adminAPI.updateSetting(cleanupKeys.warnThreshold, String(Math.max(1, warnThreshold))) - await adminAPI.updateSetting(cleanupKeys.enabled, enabled ? 'true' : 'false') - await adminAPI.updateSetting(cleanupKeys.mode, mode) - await adminAPI.updateSetting(cleanupKeys.required, String(Math.max(1, required))) - await adminAPI.updateSetting(cleanupKeys.rules, JSON.stringify(rules)) - toast.success('设备策略与删号规则已保存') - } catch { - toast.error('保存失败') - } finally { - setSaving(false) - } - } - - return ( -
-
- -

设备限制 & 删号规则

-
-

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

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