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()}