mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
feat(bot): manage device policy via telegram commands
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// Bot / 设备管控相关的设置键。全部存储在 settings 表,可由管理员在 Bot 或
|
||||
// 系统设置页调整。带安全默认值:所有"自动删号"策略默认关闭。
|
||||
// Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过
|
||||
// Telegram Bot 命令调整。带安全默认值:所有"自动删号"策略默认关闭。
|
||||
const (
|
||||
// 开放注册(开注名额)。
|
||||
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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">设备限制 & 删号规则</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 (
|
||||
|
||||
Reference in New Issue
Block a user