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