mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +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
|
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.
|
// DeleteExpired removes all expired refresh tokens.
|
||||||
func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
|
func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
|
||||||
return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).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) {
|
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
|
||||||
perms := DefaultPermissions("user-1")
|
perms := DefaultPermissions("user-1")
|
||||||
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -262,3 +263,113 @@ func TestProtectedAdminNeverViolated(t *testing.T) {
|
|||||||
t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
|
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"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bot / 设备管控相关的设置键。全部存储在 settings 表,可由管理员在 Bot 或
|
// Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过
|
||||||
// 系统设置页调整。带安全默认值:所有"自动删号"策略默认关闭。
|
// Telegram Bot 命令调整。带安全默认值:所有"自动删号"策略默认关闭。
|
||||||
const (
|
const (
|
||||||
// 开放注册(开注名额)。
|
// 开放注册(开注名额)。
|
||||||
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
|
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ func (c *Container) Boot() {
|
|||||||
// 启动调度器定时任务
|
// 启动调度器定时任务
|
||||||
c.Scheduler.Start(c.stopCtx)
|
c.Scheduler.Start(c.stopCtx)
|
||||||
|
|
||||||
// 账号删号/保号规则巡检:默认关闭,由管理员在 Bot 或运维工具开启。
|
// 账号删号/保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||||
if c.Device != nil {
|
if c.Device != nil {
|
||||||
go c.runInactivitySweeper(c.stopCtx)
|
go c.runInactivitySweeper(c.stopCtx)
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *
|
|||||||
// 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。
|
// 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。
|
||||||
const TelegramRegistrationSettingKey = "telegram.registration_enabled"
|
const TelegramRegistrationSettingKey = "telegram.registration_enabled"
|
||||||
|
|
||||||
|
var errTelegramAccountAlreadyBound = errors.New("该媒体账号已绑定其他 Telegram,请联系管理员解绑")
|
||||||
|
|
||||||
// registrationEnabled 读取注册开关;默认关闭。
|
// registrationEnabled 读取注册开关;默认关闭。
|
||||||
func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool {
|
func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool {
|
||||||
v, err := s.repo.Setting.Get(ctx, TelegramRegistrationSettingKey)
|
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
|
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
|
||||||
case "/hideadult", "/hide_adult", "/adult":
|
case "/hideadult", "/hide_adult", "/adult":
|
||||||
return s.cmdHideAdult(ctx, msg, args), nil
|
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":
|
case "/register", "/reg", "/signup":
|
||||||
return s.cmdRegister(ctx, channel, msg, args), nil
|
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) {
|
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
||||||
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
|
||||||
}
|
}
|
||||||
return s.cmdRegistrationToggle(ctx, args), 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":
|
case "/status":
|
||||||
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
|
||||||
return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil
|
return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil
|
||||||
@@ -305,8 +352,10 @@ func telegramCommandName(text string) string {
|
|||||||
func telegramSupportedCommand(cmd string) bool {
|
func telegramSupportedCommand(cmd string) bool {
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult",
|
case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult",
|
||||||
"/register", "/reg", "/signup", "/registration", "/reg_switch",
|
"/account", "/me", "/devices", "/kick", "/setname", "/rename", "/setpass", "/passwd", "/password",
|
||||||
"/status", "/search", "/downloads", "/stats":
|
"/register", "/reg", "/signup", "/registration", "/reg_switch", "/openreg",
|
||||||
|
"/devicepolicy", "/policy", "/antishare", "/cleanup", "/cleanup_mode", "/cleanup_rule",
|
||||||
|
"/ban", "/unban", "/status", "/search", "/downloads", "/stats":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -461,14 +510,26 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
|||||||
return "<b>MediaStationGo 用户命令</b>\n\n" +
|
return "<b>MediaStationGo 用户命令</b>\n\n" +
|
||||||
register +
|
register +
|
||||||
"<b>/start 用户名 密码</b> — 绑定账号\n" +
|
"<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" +
|
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
||||||
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
|
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
|
||||||
}
|
}
|
||||||
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
return "<b>MediaStationGo 命令列表</b>\n\n" +
|
||||||
"<b>/start</b> — 开始使用\n" +
|
"<b>/start</b> — 开始使用\n" +
|
||||||
"<b>/help</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>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
|
||||||
"<b>/registration on|off</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>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
||||||
"<b>/status</b> — 系统运行状态\n" +
|
"<b>/status</b> — 系统运行状态\n" +
|
||||||
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
||||||
@@ -1102,6 +1163,11 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
|
|||||||
var existing model.TelegramBinding
|
var existing model.TelegramBinding
|
||||||
err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(msg.From.ID)).First(&existing).Error
|
err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(msg.From.ID)).First(&existing).Error
|
||||||
if err == nil {
|
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{
|
return s.repo.DB.WithContext(ctx).Model(&existing).Updates(map[string]any{
|
||||||
"telegram_name": name,
|
"telegram_name": name,
|
||||||
"chat_id": int64(msg.Chat.ID),
|
"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 {
|
if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil {
|
||||||
return err
|
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{
|
return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{
|
||||||
TelegramUserID: int64(msg.From.ID),
|
TelegramUserID: int64(msg.From.ID),
|
||||||
TelegramName: name,
|
TelegramName: name,
|
||||||
@@ -1122,6 +1191,24 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
|
|||||||
}).Error
|
}).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) {
|
func parseStartCredentials(args []string) (string, string) {
|
||||||
if len(args) >= 2 {
|
if len(args) >= 2 {
|
||||||
return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " "))
|
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)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 {
|
func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
|
||||||
user := s.boundUser(ctx, msg.From.ID)
|
user := s.boundUser(ctx, msg.From.ID)
|
||||||
if user == nil {
|
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 {
|
func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
|
||||||
cfg := loadBotConfig(ctx, s.repo)
|
cfg := loadBotConfig(ctx, s.repo)
|
||||||
text := fmt.Sprintf(
|
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.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
|
||||||
onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), cfg.AccountCleanupRequiredCount, countEnabledCleanupRules(cfg.AccountCleanupRules))
|
onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), cfg.AccountCleanupRequiredCount, countEnabledCleanupRules(cfg.AccountCleanupRules))
|
||||||
return telegramCommandReply{
|
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 {
|
func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply {
|
||||||
cfg := loadBotConfig(ctx, s.repo)
|
cfg := loadBotConfig(ctx, s.repo)
|
||||||
switch which {
|
switch which {
|
||||||
@@ -607,6 +827,139 @@ func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which
|
|||||||
return s.replyDevicePolicy(ctx)
|
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 {
|
func onOff(b bool) string {
|
||||||
return map[bool]string{true: "已开启", false: "已关闭"}[b]
|
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 {
|
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
||||||
return nil, err
|
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{
|
return &TokenPair{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
@@ -93,6 +96,14 @@ func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string)
|
|||||||
}, nil
|
}, 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分钟有效期)。
|
// issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。
|
||||||
func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
|
func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
|
||||||
claims := Claims{
|
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 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器),
|
// qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器),
|
||||||
// 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与
|
// 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与
|
||||||
// /api/admin/download/clients 写入的数据来源冲突。
|
// /api/admin/download/clients 写入的数据来源冲突。
|
||||||
|
|||||||
+1
-216
@@ -43,7 +43,6 @@ export function ToolsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<OrganizePanel />
|
<OrganizePanel />
|
||||||
<AccountCleanupPanel />
|
|
||||||
<NotifyPanel />
|
<NotifyPanel />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -145,7 +144,7 @@ function OrganizePanel() {
|
|||||||
const sc = defaults['organizer.smart_classify']
|
const sc = defaults['organizer.smart_classify']
|
||||||
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
|
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
|
||||||
toast.success('整理默认设置已保存')
|
toast.success('整理默认设置已保存')
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error('保存失败')
|
toast.error('保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
setSavingDefaults(false)
|
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 }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user