Fix bot binding and terminal device limits

This commit is contained in:
ShukeBta
2026-06-17 01:06:20 +08:00
parent 8e5bf73edc
commit 3368ac2947
10 changed files with 142 additions and 56 deletions
+1 -1
View File
@@ -798,7 +798,7 @@ WHERE deleted_at IS NULL
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC, created_at DESC, id DESC) AS rn
FROM telegram_bindings
WHERE deleted_at IS NULL
) AS ranked_bindings
+7
View File
@@ -43,6 +43,13 @@ func TestEnforceTelegramBindingOneToOneCleansDuplicatesAndAddsIndex(t *testing.T
if count != 1 {
t.Fatalf("active bindings for user-1 = %d, want 1", count)
}
var kept model.TelegramBinding
if err := db.First(&kept, "user_id = ?", "user-1").Error; err != nil {
t.Fatal(err)
}
if kept.TelegramUserID != 10002 {
t.Fatalf("kept telegram binding = %d, want newest 10002", kept.TelegramUserID)
}
if err := db.Create(&model.TelegramBinding{TelegramUserID: 10003, ChatID: 10003, UserID: "user-1"}).Error; err == nil {
t.Fatal("expected unique index to reject another active binding for the same user")
}
+4 -4
View File
@@ -66,10 +66,10 @@ func (s *SignIn) BeforeCreate(_ *gorm.DB) error {
return nil
}
// UserDevice 记录一个用户在某台设备/客户端上的会话指纹,用于设备管控:
// - 登录设备数:某用户名下 UserDevice 行数(近期活跃)。
// - 并发播放数:LastPlayAt 在并发窗口内的 UserDevice 行数。
// - 设备指纹:首次见到的 Fingerprint;后续同一 DeviceID 上报不同指纹则告警。
// UserDevice 记录一个用户在某台终端设备上的登录渠道,用于设备管控:
// - 登录终端数:按 Fingerprint 去重后的近期活跃终端数。
// - 并发播放数:按 Fingerprint 去重后的播放终端数。
// - 设备指纹:同一终端不同 App 共用 Fingerprint,Client 只作为渠道标签。
// - 观看时长:结合 PlaybackHistory 统计随机窗口内的观看时长。
type UserDevice struct {
Base
+7 -6
View File
@@ -128,23 +128,24 @@ func (r *UserDeviceRepository) ListByUser(ctx context.Context, userID string) ([
return rows, err
}
// CountActiveClients counts distinct logged-in devices for a user that were
// seen on or after `since` (used for the "max logged-in clients" rule).
// CountActiveClients counts distinct terminal devices for a user that were
// seen on or after `since`. Multiple apps on the same terminal share the same
// fingerprint and count as one terminal; rows remain as login channels.
func (r *UserDeviceRepository) CountActiveClients(ctx context.Context, userID string, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND last_seen_at >= ? AND kicked = ?", userID, since, false).
Count(&n).Error
Select("COUNT(DISTINCT COALESCE(NULLIF(fingerprint, ''), device_id))").Scan(&n).Error
return n, err
}
// CountConcurrentPlaying counts devices for a user whose last playback ping was
// on or after `since` (used for the "max concurrent playback" rule).
// CountConcurrentPlaying counts terminal devices for a user whose last playback
// ping was on or after `since` (used for the max concurrent playback rule).
func (r *UserDeviceRepository) CountConcurrentPlaying(ctx context.Context, userID string, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND last_play_at IS NOT NULL AND last_play_at >= ?", userID, since).
Count(&n).Error
Select("COUNT(DISTINCT COALESCE(NULLIF(fingerprint, ''), device_id))").Scan(&n).Error
return n, err
}
+89
View File
@@ -100,6 +100,9 @@ func TestFingerprintStability(t *testing.T) {
if a != b {
t.Fatalf("fingerprint should be case/space-insensitive: %s != %s", a, b)
}
if a != fingerprint("Emby", "iPhone") {
t.Fatal("different apps on the same terminal must share one fingerprint")
}
if a == fingerprint("Infuse", "iPad") {
t.Fatal("different device names must yield different fingerprints")
}
@@ -239,6 +242,92 @@ func TestDeviceKickAndConcurrency(t *testing.T) {
}
}
func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
dev := NewDeviceService(zap.NewNop(), repos)
u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, SettingAntiShareEnabled, "true"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil {
t.Fatal(err)
}
for _, login := range []struct {
id string
name string
client string
}{
{id: "phone-infuse", name: "iPhone", client: "Infuse"},
{id: "phone-emby", name: " iPhone ", client: "Emby"},
{id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"},
} {
dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4")
}
count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("same terminal through multiple apps should count as 1, got %d", count)
}
got, _ := repos.User.FindByID(ctx, u.ID)
if !got.IsActive {
t.Fatal("same terminal through multiple apps must not disable the account")
}
dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4")
dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4")
count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour))
if err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("three distinct terminal devices should count as 3, got %d", count)
}
got, _ = repos.User.FindByID(ctx, u.ID)
if !got.IsActive {
t.Fatal("device limit is inclusive; 3 of 3 terminals should stay active")
}
dev.RecordLogin(ctx, u.ID, "tv", "Apple TV", "Emby", "1.2.3.4")
got, _ = repos.User.FindByID(ctx, u.ID)
if got.IsActive {
t.Fatal("fourth distinct terminal should disable the account")
}
}
func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
u := &model.User{Username: "play-user", PasswordHash: "x", Role: "user", IsActive: true}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
now := time.Now()
fp := fingerprint("Infuse", "Living Room TV")
for _, row := range []model.UserDevice{
{UserID: u.ID, DeviceID: "tv-emby", DeviceName: "Living Room TV", Client: "Emby", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
{UserID: u.ID, DeviceID: "tv-jellyfin", DeviceName: "living room tv", Client: "Jellyfin", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
{UserID: u.ID, DeviceID: "phone", DeviceName: "iPhone", Client: "Infuse", Fingerprint: fingerprint("Infuse", "iPhone"), FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now},
} {
if err := repos.UserDevice.Create(ctx, &row); err != nil {
t.Fatal(err)
}
}
count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
if err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("same terminal playback through multiple apps should count as 1 terminal, got %d", count)
}
}
func TestProtectedAdminNeverViolated(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
+2 -2
View File
@@ -27,10 +27,10 @@ const (
SettingOpenRegLimit = "telegram.openreg_limit" // 本轮开注名额上限(0=不限)
SettingOpenRegUsed = "telegram.openreg_used" // 本轮已用名额
// 防共享(警告制:并发播放 / 登录客户端 / 设备指纹)。
// 防共享(警告制:并发播放 / 登录终端 / 设备指纹)。
SettingAntiShareEnabled = "device.antishare_enabled" // 总开关(默认关)
SettingMaxConcurrentPlay = "device.max_concurrent_play" // 最大并发播放设备
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录客户端
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录终端
SettingWarnThreshold = "device.warn_threshold" // 警告几次后禁用
SettingPlayWindowSeconds = "device.play_window_seconds" // 并发播放判定窗口(秒)
SettingClientActiveDays = "device.client_active_days" // 登录设备活跃天数窗口
+16 -9
View File
@@ -45,11 +45,15 @@ func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text st
s.notifyUser = fn
}
// fingerprint derives a stable short hash from the client + device name. A
// changed fingerprint for the same device id signals the session was cloned
// onto different hardware/software.
// fingerprint derives a stable terminal hash from the device name. Client/app
// names are deliberately ignored so one phone/TV/PC using multiple apps is
// still counted as one terminal device; Client remains a login channel label.
func fingerprint(client, deviceName string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(client)) + "|" + strings.ToLower(strings.TrimSpace(deviceName))))
terminal := strings.ToLower(strings.TrimSpace(deviceName))
if terminal == "" {
terminal = strings.ToLower(strings.TrimSpace(client))
}
sum := sha256.Sum256([]byte(terminal))
return hex.EncodeToString(sum[:])[:16]
}
@@ -59,9 +63,9 @@ func (s *DeviceService) isProtected(ctx context.Context, u *model.User) bool {
return UserIsProtectedAccount(ctx, s.repo, u)
}
// RecordLogin records (or refreshes) a device session at authentication time
// and runs the logged-in-client + fingerprint anti-share checks. It is safe to
// call on every Emby/Jellyfin AuthenticateByName request.
// RecordLogin records (or refreshes) a login channel at authentication time and
// runs the terminal-device + fingerprint anti-share checks. It is safe to call
// on every Emby/Jellyfin AuthenticateByName request.
func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, deviceName, client, ip string) {
if userID == "" {
return
@@ -110,7 +114,7 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic
}
since := now.Add(-time.Duration(cfg.ClientActiveDays) * 24 * time.Hour)
if n, err := s.repo.UserDevice.CountActiveClients(ctx, userID, since); err == nil && int(n) > cfg.MaxLoggedClients {
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时登录客户端 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients))
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时登录终端设备 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients))
}
}
@@ -138,6 +142,9 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de
existing.LastPlayAt = &now
_ = s.repo.UserDevice.Create(ctx, existing)
} else {
existing.DeviceName = deviceName
existing.Client = client
existing.Fingerprint = fingerprint(client, deviceName)
existing.LastSeenAt = now
existing.LastPlayAt = &now
_ = s.repo.UserDevice.Save(ctx, existing)
@@ -149,7 +156,7 @@ func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, de
}
since := now.Add(-time.Duration(cfg.PlayWindowSeconds) * time.Second)
if n, err := s.repo.UserDevice.CountConcurrentPlaying(ctx, userID, since); err == nil && int(n) > cfg.MaxConcurrentPlay {
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时播放设备 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay))
s.disableForPolicy(ctx, userID, fmt.Sprintf("同时播放终端设备 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay))
}
}
+6 -27
View File
@@ -1369,10 +1369,8 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
var existing model.TelegramBinding
err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error
if err == nil {
if existing.UserID != userID {
if err := s.ensureTelegramAccountBindingAvailableTx(ctx, tx, userID, telegramUserID); err != nil {
return err
}
if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
return err
}
if err := tx.Model(&existing).Updates(map[string]any{
"telegram_name": name,
@@ -1391,7 +1389,7 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil {
return err
}
if err := s.ensureTelegramAccountBindingAvailableTx(ctx, tx, userID, telegramUserID); err != nil {
if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
return err
}
err = tx.Create(&model.TelegramBinding{
@@ -1430,29 +1428,10 @@ func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 {
return binding.TelegramUserID
}
func (s *TelegramBotService) ensureTelegramAccountBindingAvailable(ctx context.Context, userID string, telegramUserID int64) error {
return s.ensureTelegramAccountBindingAvailableTx(ctx, s.repo.DB.WithContext(ctx), userID, telegramUserID)
}
func (s *TelegramBotService) ensureTelegramAccountBindingAvailableTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
var bound model.TelegramBinding
err := tx.WithContext(ctx).
func (s *TelegramBotService) replaceTelegramAccountBindingTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
return tx.WithContext(ctx).Unscoped().
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
}
var user model.User
if err := tx.WithContext(ctx).Where("id = ?", bound.UserID).First(&user).Error; errors.Is(err, gorm.ErrRecordNotFound) {
_ = tx.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", bound.ID).Error
return nil
} else if err != nil {
return err
}
return errTelegramAccountAlreadyBound
Delete(&model.TelegramBinding{}).Error
}
func telegramBindingUniqueErr(err error) bool {
+8 -5
View File
@@ -302,7 +302,7 @@ func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
}
}
func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T) {
func TestTelegramStartReplacesAccountBindingFromAnotherTelegram(t *testing.T) {
ctx := t.Context()
repos, auth, _, _ := newAuthTestServices(t)
user, _, err := auth.Register(ctx, "viewer", "secret-pass")
@@ -332,8 +332,8 @@ func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T)
}
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)
if !strings.Contains(reply.Text, "绑定成功") {
t.Fatalf("expected new telegram account to replace old binding, got %q", reply.Text)
}
var accountBindings int64
if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&accountBindings).Error; err != nil {
@@ -342,8 +342,11 @@ func TestTelegramStartRejectsAccountAlreadyBoundToAnotherTelegram(t *testing.T)
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")
if binding := bot.telegramBinding(ctx, 20002); binding == nil || binding.UserID != user.ID {
t.Fatalf("new telegram account should be bound to user, got %#v", binding)
}
if binding := bot.telegramBinding(ctx, 20001); binding != nil {
t.Fatalf("old telegram binding should be removed, got %#v", binding)
}
}
+2 -2
View File
@@ -1125,7 +1125,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② Mgo 保号规则:<b>%s</b>\n 保号模式:%s;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup run</code> 预览候选\n<code>/cleanup run confirm</code> 确认清理\n<code>/cleanup on|off</code>\n<code>/cleanup_rule list|add|edit|修改|del|enable|disable</code>\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。",
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放终端上限 %d / 登录终端上限 %d;同一终端多个 App 只算 1 台,App 作为登录渠道记录。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:<b>%s</b>\n 保号模式:%s;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup run</code> 预览候选\n<code>/cleanup run confirm</code> 确认清理\n<code>/cleanup on|off</code>\n<code>/cleanup_rule list|add|edit|修改|del|enable|disable</code>\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。",
onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), countEnabledCleanupRules(cfg.AccountCleanupRules))
return telegramCommandReply{
@@ -1156,7 +1156,7 @@ func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) te
}
enabled, ok := parseCommandBool(args[0])
if !ok {
return telegramCommandReply{Text: "用法:<code>/antishare on|off [play=3] [login=3] [warn=2]</code>"}
return telegramCommandReply{Text: "用法:<code>/antishare on|off [play=3] [login=3] [warn=2]</code>,login 表示登录终端设备上限,同一终端多个 App 不重复计数。"}
}
if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil {
return telegramCommandReply{Text: "更新失败:" + err.Error()}