feat(bot): manage device policy via telegram commands

This commit is contained in:
ShukeBta
2026-06-07 12:24:21 +08:00
parent cf2f77f35d
commit b38d47a27d
11 changed files with 667 additions and 237 deletions
+24
View File
@@ -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
+28
View File
@@ -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 {
+111
View File
@@ -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, "防共享:<b>已开启</b>") {
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")
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// Bot / 设备管控相关的设置键。全部存储在 settings 表,可由管理员在 Bot 或
// 系统设置页调整。带安全默认值:所有"自动删号"策略默认关闭。
// Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过
// Telegram Bot 命令调整。带安全默认值:所有"自动删号"策略默认关闭。
const (
// 开放注册(开注名额)。
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
+1 -1
View File
@@ -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)
+90 -3
View File
@@ -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 "<b>MediaStationGo 用户命令</b>\n\n" +
register +
"<b>/start 用户名 密码</b> — 绑定账号\n" +
"<b>/account</b> — 查看账号状态\n" +
"<b>/devices</b> — 查看登录设备\n" +
"<b>/kick all|编号</b> — 踢下线设备\n" +
"<b>/setname 新用户名</b> — 修改用户名\n" +
"<b>/setpass 新密码</b> — 修改密码\n" +
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
}
return "<b>MediaStationGo 命令列表</b>\n\n" +
"<b>/start</b> — 开始使用\n" +
"<b>/help</b> — 帮助信息\n" +
"<b>/account</b> / <b>/devices</b> / <b>/kick all|编号</b> — 用户自助设备管理\n" +
"<b>/setname 新用户名</b> / <b>/setpass 新密码</b> — 用户自助改名改密\n" +
"<b>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
"<b>/registration on|off</b> — 开启/关闭普通用户注册(管理员)\n" +
"<b>/antishare on play=3 login=3 warn=2</b> — 防共享策略(管理员)\n" +
"<b>/cleanup on|off|run</b> — 删号规则开关/巡检(管理员)\n" +
"<b>/cleanup_mode any|all|count 2</b> — 保号模式(管理员)\n" +
"<b>/cleanup_rule list|add|del|enable|disable</b> — 保号规则(管理员)\n" +
"<b>/ban 用户名</b> / <b>/unban 用户名</b> — 禁用/解禁用户(管理员)\n" +
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
"<b>/status</b> — 系统运行状态\n" +
"<b>/search 关键词</b> — 搜索媒体库\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:], " "))
@@ -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")
}
}
+354 -1
View File
@@ -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: "请先绑定账号:<code>/start 用户名 密码</code>"}
}
if len(args) == 0 {
return telegramCommandReply{Text: "请指定要踢下线的设备:<code>/kick all</code> 或 <code>/kick 设备编号</code>。先用 <code>/devices</code> 查看编号。"}
}
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: "未找到该设备。请用 <code>/devices</code> 查看设备编号后重试。"}
}
if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil {
return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
}
return telegramCommandReply{Text: fmt.Sprintf("已踢下线:<b>%s</b>。", deviceLabel(chosen.DeviceName, chosen.Client))}
}
func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "请发送:<code>/setname 新用户名</code>"}
}
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: "请发送:<code>/setpass 新密码</code>"}
}
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(
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② 自定义删号规则:<b>%s</b>\n 保号模式:%s;需要满足 %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<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup on|off|run</code>\n<code>/cleanup_mode any|all|count 2</code>\n<code>/cleanup_rule list|add|del|enable|disable</code>\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: "用法:<code>/devicepolicy</code> 查看策略,或使用 <code>/antishare</code>、<code>/cleanup</code>、<code>/cleanup_rule</code> 管理。"}
}
}
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: "用法:<code>/antishare on|off [play=3] [login=3] [warn=2]</code>"}
}
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("删号规则巡检完成,清理 <b>%d</b> 个账号。", removed)}
default:
return telegramCommandReply{Text: "用法:<code>/cleanup on|off|run</code>"}
}
}
func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "用法:<code>/cleanup_mode any</code>、<code>/cleanup_mode all</code> 或 <code>/cleanup_mode count 2</code>"}
}
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: "用法:<code>/cleanup_rule del 规则ID</code>"}
}
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: "用法:<code>/cleanup_rule enable|disable 规则ID</code>"}
}
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: "用法:<code>/unban 用户名</code>"}
}
return telegramCommandReply{Text: "用法:<code>/ban 用户名</code>"}
}
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 "<b>保号规则</b>\n\n暂无规则。"
}
var sb strings.Builder
sb.WriteString("<b>保号规则</b>\n")
for i, r := range rules {
state := map[bool]string{true: "启用", false: "停用"}[r.Enabled]
sb.WriteString(fmt.Sprintf("\n%d. <code>%s</code> · %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 "<b>删号/保号规则命令</b>\n\n" +
"<code>/cleanup_rule list</code> — 查看规则\n" +
"<code>/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6</code>\n" +
"<code>/cleanup_rule add recent_login login_7d 七天内登录 7</code>\n" +
"<code>/cleanup_rule add signin_streak sign_3 连续签到3天 3</code>\n" +
"<code>/cleanup_rule add account_age_grace new_7d 新号宽限7天 7</code>\n" +
"<code>/cleanup_rule enable 规则ID</code> / <code>disable 规则ID</code>\n" +
"<code>/cleanup_rule del 规则ID</code>\n\n" +
"保号模式:<code>/cleanup_mode any|all|count 2</code>"
}
func onOff(b bool) string {
return map[bool]string{true: "已开启", false: "已关闭"}[b]
}
+11
View File
@@ -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{
-14
View File
@@ -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 写入的数据来源冲突。
+1 -216
View File
@@ -43,7 +43,6 @@ export function ToolsPage() {
/>
<OrganizePanel />
<AccountCleanupPanel />
<NotifyPanel />
</div>
)
@@ -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<CleanupRule[]>(defaultCleanupRules())
useEffect(() => {
adminAPI
.listSettings()
.then((settings) => {
const byKey: Record<string, string> = {}
for (const s of settings) byKey[s.key] = s.value
setEnabled(isOn(byKey[cleanupKeys.enabled]))
setAntiShare(isOn(byKey[cleanupKeys.antishare]))
setMode((['any', 'all', 'count'].includes(byKey[cleanupKeys.mode]) ? byKey[cleanupKeys.mode] : 'any') as 'any' | 'all' | 'count')
setRequired(Number(byKey[cleanupKeys.required] || 1))
setMaxPlay(Number(byKey[cleanupKeys.maxPlay] || 3))
setMaxClients(Number(byKey[cleanupKeys.maxClients] || 3))
setWarnThreshold(Number(byKey[cleanupKeys.warnThreshold] || 2))
if (byKey[cleanupKeys.rules]) {
try {
const parsed = JSON.parse(byKey[cleanupKeys.rules]) as CleanupRule[]
if (Array.isArray(parsed) && parsed.length > 0) setRules(parsed)
} catch {
// keep defaults
}
}
})
.finally(() => setLoading(false))
}, [])
const updateRule = (id: string, patch: Partial<CleanupRule>) =>
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)))
const addRule = () =>
setRules((prev) => [
...prev,
{
id: `rule_${Date.now()}`,
name: '新保号规则',
type: 'watch_hours',
enabled: true,
window_days_min: 3,
window_days_max: 5,
min_hours: 6,
},
])
const save = async () => {
setSaving(true)
try {
await adminAPI.updateSetting(cleanupKeys.antishare, antiShare ? 'true' : 'false')
await adminAPI.updateSetting(cleanupKeys.maxPlay, String(Math.max(1, maxPlay)))
await adminAPI.updateSetting(cleanupKeys.maxClients, String(Math.max(1, maxClients)))
await adminAPI.updateSetting(cleanupKeys.warnThreshold, String(Math.max(1, warnThreshold)))
await adminAPI.updateSetting(cleanupKeys.enabled, enabled ? 'true' : 'false')
await adminAPI.updateSetting(cleanupKeys.mode, mode)
await adminAPI.updateSetting(cleanupKeys.required, String(Math.max(1, required)))
await adminAPI.updateSetting(cleanupKeys.rules, JSON.stringify(rules))
toast.success('设备策略与删号规则已保存')
} catch {
toast.error('保存失败')
} finally {
setSaving(false)
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Info size={18} className="text-red-400" />
<h2 className="font-display text-lg font-semibold text-ink-600">设备限制 &amp; 删号规则</h2>
</div>
<p className="text-xs text-sand-500">
设备限制用于防共享:同一用户同时播放超过上限会被禁用,管理员可在用户管理或 Bot 中解禁。删号规则为“保号规则”:用户满足足够条件则保留,否则巡检时删除。
</p>
{loading ? (
<div className="text-sm text-ink-50">读取配置中…</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-4">
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm text-ink-600">
<input type="checkbox" checked={antiShare} onChange={(e) => setAntiShare(e.target.checked)} />
开启设备限制
</label>
<NumberField label="同时播放上限" value={maxPlay} onChange={setMaxPlay} />
<NumberField label="登录设备上限" value={maxClients} onChange={setMaxClients} />
<NumberField label="指纹警告阈值" value={warnThreshold} onChange={setWarnThreshold} />
</div>
<div className="grid gap-3 rounded-xl border border-gray-200 bg-white/40 p-3 md:grid-cols-4">
<label className="flex items-center gap-2 text-sm text-ink-600">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
开启删号规则巡检
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">保号模式</span>
<select className="input-base w-full" value={mode} onChange={(e) => setMode(e.target.value as 'any' | 'all' | 'count')}>
<option value="any">满足任意一条规则</option>
<option value="all">满足全部规则</option>
<option value="count">满足指定数量规则</option>
</select>
</label>
<NumberField label="指定数量" value={required} onChange={setRequired} disabled={mode !== 'count'} />
<button type="button" className="neon-button self-end" onClick={addRule}>
添加规则
</button>
</div>
<div className="space-y-3">
{rules.map((rule) => (
<div key={rule.id} className="grid gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 md:grid-cols-6">
<label className="flex items-center gap-2 text-xs text-ink-600">
<input type="checkbox" checked={rule.enabled} onChange={(e) => updateRule(rule.id, { enabled: e.target.checked })} />
启用
</label>
<input className="input-base" value={rule.name} onChange={(e) => updateRule(rule.id, { name: e.target.value })} />
<select className="input-base" value={rule.type} onChange={(e) => updateRule(rule.id, { type: e.target.value as CleanupRule['type'] })}>
<option value="watch_hours">观看时长</option>
<option value="recent_login">最近登录</option>
<option value="signin_streak">连续签到</option>
<option value="account_age_grace">新号宽限</option>
</select>
<NumberField label="窗口下限/天" value={rule.window_days_min ?? 3} onChange={(v) => updateRule(rule.id, { window_days_min: v })} />
<NumberField label="窗口上限/天" value={rule.window_days_max ?? 5} onChange={(v) => updateRule(rule.id, { window_days_max: v })} />
<div className="flex gap-2">
{rule.type === 'watch_hours' ? (
<NumberField label="最低小时" value={rule.min_hours ?? 6} onChange={(v) => updateRule(rule.id, { min_hours: v })} />
) : (
<NumberField label="最低数量/天" value={rule.min_count ?? 1} onChange={(v) => updateRule(rule.id, { min_count: v })} />
)}
<button type="button" className="rounded-lg border border-red-400/40 px-2 text-xs text-red-400" onClick={() => setRules((prev) => prev.filter((r) => r.id !== rule.id))}>
删除
</button>
</div>
</div>
))}
</div>
<button type="button" disabled={saving} className="neon-button" onClick={save}>
{saving ? <Loader2 size={16} className="animate-spin" /> : null}
保存设备与删号规则
</button>
</>
)}
</section>
)
}
function NumberField({
label,
value,
onChange,
disabled,
}: {
label: string
value: number
onChange: (value: number) => void
disabled?: boolean
}) {
return (
<label className="space-y-1">
<span className="text-xs text-ink-50">{label}</span>
<input
type="number"
min={0}
step={label.includes('小时') ? 0.5 : 1}
disabled={disabled}
className="input-base w-full"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (