From 3368ac294794264482d597191db145fdb4fe430a Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:06:20 +0800 Subject: [PATCH] Fix bot binding and terminal device limits --- internal/database/database.go | 2 +- internal/database/database_test.go | 7 ++ internal/model/bot.go | 8 +- internal/repository/bot_repo.go | 13 ++-- internal/service/bot_features_test.go | 89 ++++++++++++++++++++++ internal/service/bot_settings.go | 4 +- internal/service/device_service.go | 25 +++--- internal/service/telegram_bot.go | 33 ++------ internal/service/telegram_bot_user_test.go | 13 ++-- internal/service/telegram_menu.go | 4 +- 10 files changed, 142 insertions(+), 56 deletions(-) diff --git a/internal/database/database.go b/internal/database/database.go index 4cb7fdd..ca85fdd 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -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 diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 6a7fdb5..c744ef0 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -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") } diff --git a/internal/model/bot.go b/internal/model/bot.go index 475ced7..7fe3453 100644 --- a/internal/model/bot.go +++ b/internal/model/bot.go @@ -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 diff --git a/internal/repository/bot_repo.go b/internal/repository/bot_repo.go index 5ffdadc..13900f0 100644 --- a/internal/repository/bot_repo.go +++ b/internal/repository/bot_repo.go @@ -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 } diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go index 3cff6d0..1044b20 100644 --- a/internal/service/bot_features_test.go +++ b/internal/service/bot_features_test.go @@ -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) diff --git a/internal/service/bot_settings.go b/internal/service/bot_settings.go index 33dd0bc..64991fa 100644 --- a/internal/service/bot_settings.go +++ b/internal/service/bot_settings.go @@ -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" // 登录设备活跃天数窗口 diff --git a/internal/service/device_service.go b/internal/service/device_service.go index ed3ad51..6ade617 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -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)) } } diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index f370cdb..b519945 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -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 { diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go index d208d03..e9835df 100644 --- a/internal/service/telegram_bot_user_test.go +++ b/internal/service/telegram_bot_user_test.go @@ -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) } } diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index d732972..00cc06c 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -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( - "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。", + "设备策略\n\n① 防共享:%s\n 并发播放终端上限 %d / 登录终端上限 %d;同一终端多个 App 只算 1 台,App 作为登录渠道记录。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_rule list|add|edit|修改|del|enable|disable\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: "用法:/antishare on|off [play=3] [login=3] [warn=2]"} + return telegramCommandReply{Text: "用法:/antishare on|off [play=3] [login=3] [warn=2],login 表示登录终端设备上限,同一终端多个 App 不重复计数。"} } if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil { return telegramCommandReply{Text: "更新失败:" + err.Error()}