fix(organize): strip release tags/roman numerals/season markers; de-hardcode paths

feat(bot): button menu, capacity/open-reg quota, redemption codes, user mgmt,
account expiry + signin streak, device anti-sharing + inactivity cleanup,
one-click kick, self-service username/password

- Consolidate organize/rename defaults into Tools panel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
shuk shuk
2026-05-30 17:57:17 +00:00
committed by Shuke
parent f1d87638f3
commit 22b64d3d47
22 changed files with 2353 additions and 133 deletions
+20
View File
@@ -206,6 +206,14 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
if svc.Device != nil {
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-Emby-Client"),
c.ClientIP())
}
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
@@ -622,7 +630,19 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusOK) // Emby 期望 2xx;不是关键操作
return
}
// 被「一键踢下线」的设备拒绝继续播放,直到重新登录。
if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, c.GetHeader("X-Emby-Device-Id")) {
c.Status(http.StatusUnauthorized)
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
// 标记该设备正在播放并执行并发播放防共享检测。
if svc.Device != nil {
svc.Device.RecordPlayback(c.Request.Context(), uid,
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-Emby-Client"))
}
c.Status(http.StatusNoContent)
}
}
+104
View File
@@ -0,0 +1,104 @@
// Package model — Telegram Bot 相关数据模型:注册兑换码、签到记录、用户设备会话。
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// RegistrationCodeKind 区分兑换码用途。
const (
// RegistrationCodeRegister 用于注册一个新账号(兑换后自动绑定 / 创建账号)。
RegistrationCodeRegister = "register"
// RegistrationCodeRenew 用于给已有账号续期(延长到期时间 DurationDays 天)。
RegistrationCodeRenew = "renew"
)
// RegistrationCode 是一次性兑换码。管理员生成后发给用户,用户通过 Bot 兑换:
// - register:创建并绑定一个新账号;兑换时按 DurationDays 设置账号有效期。
// - renew:给当前绑定账号延长 DurationDays 天有效期。
//
// 兑换成功后记录 UsedByUserID + UsedAt,之后不可再用。ExpiresAt 是兑换码本身
// 的有效期(过期后即使未使用也不能再兑换)。
type RegistrationCode struct {
Base
Code string `gorm:"uniqueIndex;size:32;not null" json:"code"`
Kind string `gorm:"size:16;not null;default:register" json:"kind"`
DurationDays int `gorm:"default:0" json:"duration_days"` // 账号有效期天数;0 表示永久
CreatedByID string `gorm:"size:36" json:"created_by_id,omitempty"`
UsedByUserID string `gorm:"index;size:36" json:"used_by_user_id,omitempty"`
UsedAt *time.Time `json:"used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 兑换码本身的有效期
}
// BeforeCreate 生成 UUID。
func (c *RegistrationCode) BeforeCreate(_ *gorm.DB) error {
if c.ID == "" {
c.ID = uuid.NewString()
}
return nil
}
// IsUsed 报告兑换码是否已被使用。
func (c *RegistrationCode) IsUsed() bool { return c.UsedAt != nil }
// IsExpired 报告兑换码自身是否过期(与账号有效期无关)。
func (c *RegistrationCode) IsExpired() bool {
return c.ExpiresAt != nil && time.Now().After(*c.ExpiresAt)
}
// SignIn 记录单个用户的连续签到天数(不挂钩积分)。
type SignIn struct {
Base
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
LastSignIn time.Time `json:"last_sign_in"`
StreakDays int `gorm:"default:0" json:"streak_days"` // 当前连续签到天数
TotalDays int `gorm:"default:0" json:"total_days"` // 累计签到天数
}
// BeforeCreate 生成 UUID。
func (s *SignIn) BeforeCreate(_ *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.NewString()
}
return nil
}
// UserDevice 记录一个用户在某台设备/客户端上的会话指纹,用于设备管控:
// - 登录设备数:某用户名下 UserDevice 行数(近期活跃)。
// - 并发播放数:LastPlayAt 在并发窗口内的 UserDevice 行数。
// - 设备指纹:首次见到的 Fingerprint;后续同一 DeviceID 上报不同指纹则告警。
// - 观看时长:结合 PlaybackHistory 统计随机窗口内的观看时长。
type UserDevice struct {
Base
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_device" json:"user_id"`
DeviceID string `gorm:"size:128;not null;uniqueIndex:uniq_user_device" json:"device_id"`
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
Client string `gorm:"size:128" json:"client,omitempty"`
Fingerprint string `gorm:"size:64" json:"fingerprint,omitempty"`
LastIP string `gorm:"size:64" json:"last_ip,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `gorm:"index" json:"last_seen_at"`
LastPlayAt *time.Time `gorm:"index" json:"last_play_at,omitempty"`
Warnings int `gorm:"default:0" json:"warnings"` // 指纹不匹配累计告警次数
Kicked bool `gorm:"default:false" json:"kicked"` // 被一键踢下线(强制重新登录)
}
// BeforeCreate 生成 UUID。
func (d *UserDevice) BeforeCreate(_ *gorm.DB) error {
if d.ID == "" {
d.ID = uuid.NewString()
}
return nil
}
// BotModels 返回 Bot 相关模型,供 AutoMigrate 使用。
func BotModels() []interface{} {
return []interface{}{
&RegistrationCode{},
&SignIn{},
&UserDevice{},
}
}
+14 -2
View File
@@ -44,8 +44,17 @@ type User struct {
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"`
IsProtected bool `gorm:"-" json:"is_protected,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
ExpiredAt *time.Time `json:"expired_at,omitempty"`
// ShareWarnings counts anti-account-sharing warnings (too many concurrent
// playbacks / logged-in clients / device-fingerprint mismatches). Once it
// exceeds the configured threshold a re-offence deletes the account.
ShareWarnings int `gorm:"default:0" json:"share_warnings"`
LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"`
IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"`
IsProtected bool `gorm:"-" json:"is_protected,omitempty"`
}
// Library 表示用户定义的媒体根目录。
@@ -331,5 +340,8 @@ func AllModels() []interface{} {
&StorageConfig{},
&AssistantSession{},
&AssistantMessage{},
&RegistrationCode{},
&SignIn{},
&UserDevice{},
}
}
+177
View File
@@ -0,0 +1,177 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ─── RegistrationCode ─────────────────────────────────────────────────────────
// RegistrationCodeRepository persists model.RegistrationCode records.
type RegistrationCodeRepository struct{ db *gorm.DB }
// Create inserts a new code.
func (r *RegistrationCodeRepository) Create(ctx context.Context, c *model.RegistrationCode) error {
return r.db.WithContext(ctx).Create(c).Error
}
// FindByCode returns the code row, or (nil, nil) when absent.
func (r *RegistrationCodeRepository) FindByCode(ctx context.Context, code string) (*model.RegistrationCode, error) {
var c model.RegistrationCode
err := r.db.WithContext(ctx).Where("code = ?", code).First(&c).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// MarkUsed atomically marks an unused, unexpired code as consumed by userID.
// It returns gorm.ErrRecordNotFound when the code was already used so callers
// can avoid double-spend races.
func (r *RegistrationCodeRepository) MarkUsed(ctx context.Context, id, userID string) error {
now := time.Now()
res := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
Where("id = ? AND used_at IS NULL", id).
Updates(map[string]any{"used_by_user_id": userID, "used_at": &now})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// List returns the most recent codes (newest first).
func (r *RegistrationCodeRepository) List(ctx context.Context, limit int) ([]model.RegistrationCode, error) {
if limit <= 0 {
limit = 50
}
var rows []model.RegistrationCode
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
// CountUnused returns the number of codes that are still redeemable.
func (r *RegistrationCodeRepository) CountUnused(ctx context.Context) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
Where("used_at IS NULL").Count(&n).Error
return n, err
}
// ─── SignIn ───────────────────────────────────────────────────────────────────
// SignInRepository persists model.SignIn records.
type SignInRepository struct{ db *gorm.DB }
// Get returns the sign-in row for a user, or (nil, nil) when absent.
func (r *SignInRepository) Get(ctx context.Context, userID string) (*model.SignIn, error) {
var s model.SignIn
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// Save inserts or updates a sign-in row.
func (r *SignInRepository) Save(ctx context.Context, s *model.SignIn) error {
return r.db.WithContext(ctx).Save(s).Error
}
// ─── UserDevice ───────────────────────────────────────────────────────────────
// UserDeviceRepository persists model.UserDevice records.
type UserDeviceRepository struct{ db *gorm.DB }
// Find returns the device row for (user, device), or (nil, nil) when absent.
func (r *UserDeviceRepository) Find(ctx context.Context, userID, deviceID string) (*model.UserDevice, error) {
var d model.UserDevice
err := r.db.WithContext(ctx).
Where("user_id = ? AND device_id = ?", userID, deviceID).First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
// Create inserts a new device row.
func (r *UserDeviceRepository) Create(ctx context.Context, d *model.UserDevice) error {
return r.db.WithContext(ctx).Create(d).Error
}
// Save persists changes to an existing device row.
func (r *UserDeviceRepository) Save(ctx context.Context, d *model.UserDevice) error {
return r.db.WithContext(ctx).Save(d).Error
}
// ListByUser returns all device rows for a user, newest activity first.
func (r *UserDeviceRepository) ListByUser(ctx context.Context, userID string) ([]model.UserDevice, error) {
var rows []model.UserDevice
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("last_seen_at desc").Find(&rows).Error
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).
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
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).
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
return n, err
}
// Delete removes a single device row by primary key.
func (r *UserDeviceRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Unscoped().Delete(&model.UserDevice{}, "id = ?", id).Error
}
// DeleteByUser removes every device row for a user (used on account deletion).
func (r *UserDeviceRepository) DeleteByUser(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserDevice{}).Error
}
// SetKicked marks a device as kicked (forces re-login on next request).
func (r *UserDeviceRepository) SetKicked(ctx context.Context, id string, kicked bool) error {
return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("id = ?", id).
Update("kicked", kicked).Error
}
// WatchedMillisSince approximates the total watched milliseconds for a user
// since `since`, using the last known playback position per media. Playback
// history keeps one row per (user, media), so this is an activity proxy rather
// than an exact watch-time integral — sufficient for the inactivity rule.
func (r *UserDeviceRepository) WatchedMillisSince(ctx context.Context, userID string, since time.Time) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.PlaybackHistory{}).
Where("user_id = ? AND watched_at >= ?", userID, since).
Select("COALESCE(SUM(position_ms), 0)").Scan(&total).Error
return total, err
}
+6
View File
@@ -41,6 +41,9 @@ type Container struct {
PlayProfile *PlayProfileRepository
StorageConfig *StorageConfigRepository
Assistant *AssistantRepository
RegCode *RegistrationCodeRepository
SignIn *SignInRepository
UserDevice *UserDeviceRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -68,6 +71,9 @@ func New(db *gorm.DB) *Container {
PlayProfile: &PlayProfileRepository{db: db},
StorageConfig: &StorageConfigRepository{db: db},
Assistant: &AssistantRepository{db: db},
RegCode: &RegistrationCodeRepository{db: db},
SignIn: &SignInRepository{db: db},
UserDevice: &UserDeviceRepository{db: db},
}
}
+5
View File
@@ -38,6 +38,7 @@ var (
ErrUsernameTaken = errors.New("username already taken")
ErrUserInactive = errors.New("user account is inactive")
ErrUserLimitReached = errors.New("user limit reached")
ErrUserExpired = errors.New("user account has expired")
)
// MaxUsers is kept for compatibility with tests and callers; dynamic runtime
@@ -150,6 +151,10 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
if !u.IsActive {
return nil, ErrUserInactive
}
// 账号到期则停用登录,直到管理员或兑换码续期。
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
return nil, ErrUserExpired
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
+265
View File
@@ -0,0 +1,265 @@
package service
import (
"context"
"crypto/rand"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ── 容量 / 开注名额 ──────────────────────────────────────────────────────────
// capacityInfo 描述当前用户容量(随凭证授权实时变化)与开注名额状态。
type capacityInfo struct {
UsedUsers int64
MaxUsers int64 // 来自 LicensedMaxUsers,随授权实时变化
OpenRegOn bool
OpenRegLimit int // 0 = 不限(仅受 MaxUsers 约束)
OpenRegUsed int
}
// Remaining 返回还能注册多少个账号(同时受授权上限与开注名额约束)。
func (c capacityInfo) Remaining() int64 {
byLicense := c.MaxUsers - c.UsedUsers
if byLicense < 0 {
byLicense = 0
}
if c.OpenRegLimit > 0 {
byQuota := int64(c.OpenRegLimit - c.OpenRegUsed)
if byQuota < 0 {
byQuota = 0
}
if byQuota < byLicense {
return byQuota
}
}
return byLicense
}
// loadCapacity reads live capacity + open-reg quota state.
func (s *TelegramBotService) loadCapacity(ctx context.Context) capacityInfo {
used, _ := s.repo.User.Count(ctx)
info := capacityInfo{
UsedUsers: used,
MaxUsers: LicensedMaxUsers(ctx, s.repo),
OpenRegOn: s.openRegEnabled(ctx),
OpenRegLimit: s.intSetting(ctx, SettingOpenRegLimit, 0),
OpenRegUsed: s.intSetting(ctx, SettingOpenRegUsed, 0),
}
return info
}
func (s *TelegramBotService) intSetting(ctx context.Context, key string, fallback int) int {
v, err := s.repo.Setting.Get(ctx, key)
if err != nil {
return fallback
}
return parseIntSettingDefault(v, fallback)
}
// openRegEnabled reports whether bot registration is currently open. It honours
// both the new open-reg switch and the legacy registration switch.
func (s *TelegramBotService) openRegEnabled(ctx context.Context) bool {
if v, _ := s.repo.Setting.Get(ctx, SettingOpenRegEnabled); v != "" {
return parseBoolSetting(v, false)
}
return s.registrationEnabled(ctx)
}
// openRegistration opens registration for `limit` new accounts (0 = unlimited,
// bounded only by the license). Resets the used counter.
func (s *TelegramBotService) openRegistration(ctx context.Context, limit int) error {
if limit < 0 {
limit = 0
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "true"); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegLimit, strconv.Itoa(limit)); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegUsed, "0"); err != nil {
return err
}
// 与旧开关同步,兼容系统设置页。
return s.setRegistrationEnabled(ctx, true)
}
// closeRegistration disables bot registration.
func (s *TelegramBotService) closeRegistration(ctx context.Context) error {
if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil {
return err
}
return s.setRegistrationEnabled(ctx, false)
}
// consumeOpenRegSlot increments the used counter and auto-closes registration
// once the quota is exhausted. Call after a successful bot registration.
func (s *TelegramBotService) consumeOpenRegSlot(ctx context.Context) {
limit := s.intSetting(ctx, SettingOpenRegLimit, 0)
used := s.intSetting(ctx, SettingOpenRegUsed, 0) + 1
_ = s.repo.Setting.Set(ctx, SettingOpenRegUsed, strconv.Itoa(used))
if limit > 0 && used >= limit {
_ = s.closeRegistration(ctx)
}
}
// ── 兑换码 ──────────────────────────────────────────────────────────────────
// generateCode creates a random redemption code of the given kind. durationDays
// sets the account validity granted on redeem (0 = permanent). validDays sets
// how long the code itself stays redeemable (0 = never expires).
func (s *TelegramBotService) generateCode(ctx context.Context, kind string, durationDays, validDays int, createdBy string) (*model.RegistrationCode, error) {
if kind != model.RegistrationCodeRegister && kind != model.RegistrationCodeRenew {
kind = model.RegistrationCodeRegister
}
code := &model.RegistrationCode{
Code: randomCode(12),
Kind: kind,
DurationDays: durationDays,
CreatedByID: createdBy,
}
if validDays > 0 {
exp := time.Now().Add(time.Duration(validDays) * 24 * time.Hour)
code.ExpiresAt = &exp
}
if err := s.repo.RegCode.Create(ctx, code); err != nil {
return nil, err
}
return code, nil
}
// lookupRedeemableCode validates a code without consuming it. Callers mark it
// used only after the dependent action (account create / renew) succeeds, so a
// failed action never burns a code.
func (s *TelegramBotService) lookupRedeemableCode(ctx context.Context, raw, wantKind string) (*model.RegistrationCode, string) {
code := strings.TrimSpace(raw)
if code == "" {
return nil, "请提供兑换码。"
}
rc, err := s.repo.RegCode.FindByCode(ctx, code)
if err != nil || rc == nil {
return nil, "兑换码无效。"
}
if rc.IsUsed() {
return nil, "兑换码已被使用。"
}
if rc.IsExpired() {
return nil, "兑换码已过期。"
}
if wantKind != "" && rc.Kind != wantKind {
switch rc.Kind {
case model.RegistrationCodeRenew:
return nil, "这是续期兑换码,请在「我的账号」里使用它续期。"
default:
return nil, "这是注册兑换码,请用于注册新账号。"
}
}
return rc, ""
}
// ── 续期 ────────────────────────────────────────────────────────────────────
// renewUser extends a user's expiry by durationDays. A nil/zero current expiry
// starts from now; a future expiry is extended from that point. durationDays<=0
// sets the account to never expire (permanent).
func renewExpiry(current *time.Time, durationDays int) *time.Time {
if durationDays <= 0 {
return nil // permanent
}
base := time.Now()
if current != nil && current.After(base) {
base = *current
}
exp := base.Add(time.Duration(durationDays) * 24 * time.Hour)
return &exp
}
// applyRenewal renews a user account and clears any expiry-related suspension.
func (s *TelegramBotService) applyRenewal(ctx context.Context, userID string, durationDays int) error {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return fmt.Errorf("user not found")
}
exp := renewExpiry(u.ExpiredAt, durationDays)
updates := map[string]any{"expired_at": exp, "is_active": true}
return s.repo.User.UpdateFields(ctx, userID, updates)
}
// ── 签到 ────────────────────────────────────────────────────────────────────
// signInResult 描述一次签到的结果。
type signInResult struct {
AlreadySigned bool
Streak int
Total int
}
// signIn records a daily sign-in for the user, tracking consecutive-day streaks
// only (no points). A second sign-in on the same calendar day is a no-op.
func (s *TelegramBotService) signIn(ctx context.Context, userID string) (signInResult, error) {
now := time.Now()
today := now.Truncate(24 * time.Hour)
rec, err := s.repo.SignIn.Get(ctx, userID)
if err != nil {
return signInResult{}, err
}
if rec == nil {
rec = &model.SignIn{UserID: userID, LastSignIn: now, StreakDays: 1, TotalDays: 1}
if err := s.repo.SignIn.Save(ctx, rec); err != nil {
return signInResult{}, err
}
return signInResult{Streak: 1, Total: 1}, nil
}
last := rec.LastSignIn.Truncate(24 * time.Hour)
switch {
case last.Equal(today):
return signInResult{AlreadySigned: true, Streak: rec.StreakDays, Total: rec.TotalDays}, nil
case last.Equal(today.Add(-24 * time.Hour)):
rec.StreakDays++
default:
rec.StreakDays = 1 // streak broken
}
rec.TotalDays++
rec.LastSignIn = now
if err := s.repo.SignIn.Save(ctx, rec); err != nil {
return signInResult{}, err
}
return signInResult{Streak: rec.StreakDays, Total: rec.TotalDays}, nil
}
// ── helpers ─────────────────────────────────────────────────────────────────
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I
func randomCode(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// crypto/rand failure is extremely unlikely; fall back to time noise.
seed := time.Now().UnixNano()
for i := range b {
b[i] = byte(seed >> (uint(i%8) * 8))
}
}
out := make([]byte, n)
for i := range b {
out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
}
return string(out)
}
// formatExpiry renders a user's expiry status for display.
func formatExpiry(t *time.Time) string {
if t == nil {
return "永久有效"
}
if time.Now().After(*t) {
return "已过期(" + t.Format("2006-01-02") + ")"
}
days := int(time.Until(*t).Hours() / 24)
return fmt.Sprintf("%s(剩 %d 天)", t.Format("2006-01-02"), days)
}
+261
View File
@@ -0,0 +1,261 @@
package service
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func newBotTestService(t *testing.T) (*repository.Container, *TelegramBotService) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
perms := NewPermissionService(log, repos)
tokenSvc := NewTokenService(cfg, log, repos)
auth := NewAuthService(cfg, log, repos, tokenSvc, perms)
crypto := NewCryptoService("test-secret", log)
bot := NewTelegramBotService(log, repos, crypto, auth)
return repos, bot
}
// ── pure logic ──────────────────────────────────────────────────────────────
func TestRenewExpiry(t *testing.T) {
// 永久(0 天)→ nil
if got := renewExpiry(nil, 0); got != nil {
t.Fatalf("expected nil for permanent, got %v", got)
}
// 从现在起 +30 天(当前为空)
got := renewExpiry(nil, 30)
if got == nil || got.Before(time.Now().Add(29*24*time.Hour)) {
t.Fatalf("expected ~30d expiry, got %v", got)
}
// 已有未来到期 → 在原到期基础上叠加
future := time.Now().Add(10 * 24 * time.Hour)
got = renewExpiry(&future, 30)
if got == nil || got.Before(future.Add(29*24*time.Hour)) {
t.Fatalf("expected stacking on future expiry, got %v", got)
}
// 已过期 → 从现在起算
past := time.Now().Add(-10 * 24 * time.Hour)
got = renewExpiry(&past, 5)
if got == nil || got.Before(time.Now().Add(4*24*time.Hour)) {
t.Fatalf("expected fresh window from now, got %v", got)
}
}
func TestCapacityRemaining(t *testing.T) {
cases := []struct {
name string
c capacityInfo
want int64
}{
{"license only", capacityInfo{UsedUsers: 5, MaxUsers: 20}, 15},
{"quota tighter", capacityInfo{UsedUsers: 5, MaxUsers: 100, OpenRegLimit: 10, OpenRegUsed: 3}, 7},
{"license tighter", capacityInfo{UsedUsers: 95, MaxUsers: 100, OpenRegLimit: 50, OpenRegUsed: 0}, 5},
{"full", capacityInfo{UsedUsers: 20, MaxUsers: 20}, 0},
{"quota exhausted", capacityInfo{UsedUsers: 1, MaxUsers: 100, OpenRegLimit: 5, OpenRegUsed: 5}, 0},
}
for _, tc := range cases {
if got := tc.c.Remaining(); got != tc.want {
t.Errorf("%s: Remaining()=%d want %d", tc.name, got, tc.want)
}
}
}
func TestRandomWindowDays(t *testing.T) {
for i := 0; i < 200; i++ {
d := randomWindowDays(3, 5)
if d < 3 || d > 5 {
t.Fatalf("randomWindowDays(3,5)=%d out of range", d)
}
}
if d := randomWindowDays(4, 4); d != 4 {
t.Fatalf("randomWindowDays(4,4)=%d want 4", d)
}
}
func TestFingerprintStability(t *testing.T) {
a := fingerprint("Infuse", "iPhone")
b := fingerprint("infuse", " iPhone ")
if a != b {
t.Fatalf("fingerprint should be case/space-insensitive: %s != %s", a, b)
}
if a == fingerprint("Infuse", "iPad") {
t.Fatal("different device names must yield different fingerprints")
}
}
// ── DB-backed ─────────────────────────────────────────────────────────────
func TestSignInStreak(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
u := &model.User{Username: "alice", PasswordHash: "x", Role: "user"}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
res, err := bot.signIn(ctx, u.ID)
if err != nil || res.Streak != 1 || res.Total != 1 {
t.Fatalf("first sign-in: %+v err=%v", res, err)
}
// 同日重复签到 → 不增长
res, _ = bot.signIn(ctx, u.ID)
if !res.AlreadySigned || res.Streak != 1 {
t.Fatalf("same-day re-signin should be no-op: %+v", res)
}
// 模拟昨天签到 → 连续 +1
rec, _ := repos.SignIn.Get(ctx, u.ID)
rec.LastSignIn = time.Now().Add(-24 * time.Hour)
_ = repos.SignIn.Save(ctx, rec)
res, _ = bot.signIn(ctx, u.ID)
if res.Streak != 2 || res.Total != 2 {
t.Fatalf("consecutive day should bump streak: %+v", res)
}
// 中断(前天)→ 重置为 1
rec, _ = repos.SignIn.Get(ctx, u.ID)
rec.LastSignIn = time.Now().Add(-72 * time.Hour)
_ = repos.SignIn.Save(ctx, rec)
res, _ = bot.signIn(ctx, u.ID)
if res.Streak != 1 {
t.Fatalf("broken streak should reset to 1: %+v", res)
}
}
func TestRegistrationCodeRedeemOnce(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
code, err := bot.generateCode(ctx, model.RegistrationCodeRenew, 30, 0, "")
if err != nil {
t.Fatal(err)
}
// 首次校验通过
rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
if rc == nil {
t.Fatalf("expected valid code, got msg=%q", msg)
}
// 标记使用后不可再用
if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
t.Fatal(err)
}
if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
t.Fatal("used code must not validate again")
}
// 第二次 MarkUsed 应失败(防止双花)
if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err == nil {
t.Fatal("double-spend should be rejected")
}
// 类型不匹配应被拒
reg, _ := bot.generateCode(ctx, model.RegistrationCodeRegister, 0, 0, "")
if _, msg := bot.lookupRedeemableCode(ctx, reg.Code, model.RegistrationCodeRenew); msg == "" {
t.Fatal("register code should not validate as renew")
}
}
func TestRenewalClearsExpiry(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
past := time.Now().Add(-time.Hour)
u := &model.User{Username: "bob", PasswordHash: "x", Role: "user", IsActive: false, ExpiredAt: &past}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
if err := bot.applyRenewal(ctx, u.ID, 30); err != nil {
t.Fatal(err)
}
got, _ := repos.User.FindByID(ctx, u.ID)
if !got.IsActive {
t.Fatal("renewal should re-activate account")
}
if got.ExpiredAt == nil || got.ExpiredAt.Before(time.Now()) {
t.Fatalf("renewal should set future expiry, got %v", got.ExpiredAt)
}
}
func TestDeviceKickAndConcurrency(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
dev := NewDeviceService(zap.NewNop(), repos)
u := &model.User{Username: "carol", PasswordHash: "x", Role: "user", IsActive: true}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
dev.RecordPlayback(ctx, u.ID, "dev-1", "iPhone", "Infuse")
devices, _ := dev.ListDevices(ctx, u.ID)
if len(devices) != 1 {
t.Fatalf("expected 1 device, got %d", len(devices))
}
// 踢下线后命中 kicked
if err := dev.KickDevice(ctx, u.ID, "dev-1"); err != nil {
t.Fatal(err)
}
if !dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
t.Fatal("device should be kicked")
}
// 重新登录清除 kicked
dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
if dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
t.Fatal("re-login should clear kicked flag")
}
// 并发播放计数
now := time.Now()
for i, id := range []string{"d1", "d2", "d3", "d4"} {
_ = repos.UserDevice.Create(ctx, &model.UserDevice{
UserID: u.ID, DeviceID: id, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now,
})
_ = i
}
n, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
if err != nil {
t.Fatal(err)
}
if n < 4 {
t.Fatalf("expected >=4 concurrent playing, got %d", n)
}
}
func TestProtectedAdminNeverViolated(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
dev := NewDeviceService(zap.NewNop(), repos)
admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
if err := repos.User.Create(ctx, admin); err != nil {
t.Fatal(err)
}
_ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true")
cfg := loadBotConfig(ctx, repos)
// 多次违规也不应删除/警告管理员
for i := 0; i < 5; i++ {
dev.registerViolation(ctx, admin.ID, "test", cfg)
}
got, _ := repos.User.FindByID(ctx, admin.ID)
if got == nil {
t.Fatal("admin must never be auto-deleted")
}
if got.ShareWarnings != 0 {
t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
}
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"context"
"strconv"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// Bot / 设备管控相关的设置键。全部存储在 settings 表,可由管理员在 Bot 或
// 系统设置页调整。带安全默认值:所有"自动删号"策略默认关闭。
const (
// 开放注册(开注名额)。
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
SettingOpenRegLimit = "telegram.openreg_limit" // 本轮开注名额上限(0=不限)
SettingOpenRegUsed = "telegram.openreg_used" // 本轮已用名额
// 防共享(警告制:并发播放 / 登录客户端 / 设备指纹)。
SettingAntiShareEnabled = "device.antishare_enabled" // 总开关(默认关)
SettingMaxConcurrentPlay = "device.max_concurrent_play" // 最大并发播放设备
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录客户端
SettingWarnThreshold = "device.warn_threshold" // 警告几次后删号
SettingPlayWindowSeconds = "device.play_window_seconds" // 并发播放判定窗口(秒)
SettingClientActiveDays = "device.client_active_days" // 登录设备活跃天数窗口
// 不活跃清理(独立开关)。
SettingInactiveEnabled = "device.inactive_enabled" // 总开关(默认关)
SettingInactiveMinHours = "device.inactive_min_hours" // 窗口内最低观看小时
SettingInactiveWindowMin = "device.inactive_window_days_min" // 随机窗口下限(天)
SettingInactiveWindowMax = "device.inactive_window_days_max" // 随机窗口上限(天)
SettingInactiveGraceDays = "device.inactive_grace_days" // 新号宽限期(天)
)
// botConfig 是设备管控的已解析配置(含默认值)。
type botConfig struct {
AntiShareEnabled bool
MaxConcurrentPlay int
MaxLoggedClients int
WarnThreshold int
PlayWindowSeconds int
ClientActiveDays int
InactiveEnabled bool
InactiveMinHours int
InactiveWindowMin int
InactiveWindowMax int
InactiveGraceDays int
}
// defaultBotConfig returns the safe defaults requested by the operator.
func defaultBotConfig() botConfig {
return botConfig{
AntiShareEnabled: false, // 自动删号默认关闭,需管理员显式开启
MaxConcurrentPlay: 3,
MaxLoggedClients: 3,
WarnThreshold: 2, // 两次警告后再犯删号
PlayWindowSeconds: 90,
ClientActiveDays: 30,
InactiveEnabled: false, // 默认关闭
InactiveMinHours: 6,
InactiveWindowMin: 3,
InactiveWindowMax: 5,
InactiveGraceDays: 7,
}
}
// loadBotConfig reads the device-management configuration from settings,
// falling back to safe defaults for any missing/invalid key.
func loadBotConfig(ctx context.Context, repo *repository.Container) botConfig {
cfg := defaultBotConfig()
get := func(key string) string {
v, err := repo.Setting.Get(ctx, key)
if err != nil {
return ""
}
return v
}
cfg.AntiShareEnabled = parseBoolSetting(get(SettingAntiShareEnabled), cfg.AntiShareEnabled)
cfg.InactiveEnabled = parseBoolSetting(get(SettingInactiveEnabled), cfg.InactiveEnabled)
cfg.MaxConcurrentPlay = parseIntSettingDefault(get(SettingMaxConcurrentPlay), cfg.MaxConcurrentPlay)
cfg.MaxLoggedClients = parseIntSettingDefault(get(SettingMaxLoggedClients), cfg.MaxLoggedClients)
cfg.WarnThreshold = parseIntSettingDefault(get(SettingWarnThreshold), cfg.WarnThreshold)
cfg.PlayWindowSeconds = parseIntSettingDefault(get(SettingPlayWindowSeconds), cfg.PlayWindowSeconds)
cfg.ClientActiveDays = parseIntSettingDefault(get(SettingClientActiveDays), cfg.ClientActiveDays)
cfg.InactiveMinHours = parseIntSettingDefault(get(SettingInactiveMinHours), cfg.InactiveMinHours)
cfg.InactiveWindowMin = parseIntSettingDefault(get(SettingInactiveWindowMin), cfg.InactiveWindowMin)
cfg.InactiveWindowMax = parseIntSettingDefault(get(SettingInactiveWindowMax), cfg.InactiveWindowMax)
cfg.InactiveGraceDays = parseIntSettingDefault(get(SettingInactiveGraceDays), cfg.InactiveGraceDays)
if cfg.InactiveWindowMax < cfg.InactiveWindowMin {
cfg.InactiveWindowMax = cfg.InactiveWindowMin
}
return cfg
}
// parseIntSettingDefault parses an int setting, returning fallback on error.
func parseIntSettingDefault(value string, fallback int) int {
n, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return n
}
+311
View File
@@ -0,0 +1,311 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// DeviceService implements device/session tracking and the two decoupled
// enforcement policies requested by the operator:
//
// ① 防共享 (anti-sharing, warning-based): too many concurrent playbacks,
// too many logged-in clients, or a device-fingerprint mismatch each emit a
// warning. After the configured number of warnings, a re-offence deletes
// the account. These three sub-rules share one per-user warning counter.
// ② 不活跃清理 (inactivity cleanup, independent toggle): watching less than
// the configured hours over a random 3–5 day window deletes the account.
//
// Safeguards (always enforced): admin / protected accounts are never auto
// disabled or deleted; a Telegram notification is sent before any destructive
// action; every threshold is configurable and both policies default to OFF.
type DeviceService struct {
log *zap.Logger
repo *repository.Container
// notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications.
notifyUser func(ctx context.Context, userID, text string)
}
// NewDeviceService constructs a DeviceService.
func NewDeviceService(log *zap.Logger, repo *repository.Container) *DeviceService {
return &DeviceService{log: log, repo: repo}
}
// SetNotifier wires the per-user Telegram notification callback.
func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text string)) {
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.
func fingerprint(client, deviceName string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(client)) + "|" + strings.ToLower(strings.TrimSpace(deviceName))))
return hex.EncodeToString(sum[:])[:16]
}
// isProtected reports whether a user must never be auto disabled/deleted.
// Admins are always protected; the earliest admin (default admin) too.
func (s *DeviceService) isProtected(ctx context.Context, u *model.User) bool {
if u == nil {
return true
}
if u.Role == "admin" {
return true
}
if first, err := s.repo.User.FirstAdmin(ctx); err == nil && first != nil && first.ID == u.ID {
return true
}
return false
}
// 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.
func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, deviceName, client, ip string) {
if userID == "" {
return
}
if deviceID == "" {
// Fall back to a fingerprint-derived id so headless clients still count.
deviceID = "fp-" + fingerprint(client, deviceName)
}
fp := fingerprint(client, deviceName)
now := time.Now()
existing, _ := s.repo.UserDevice.Find(ctx, userID, deviceID)
mismatch := false
if existing == nil {
_ = s.repo.UserDevice.Create(ctx, &model.UserDevice{
UserID: userID,
DeviceID: deviceID,
DeviceName: deviceName,
Client: client,
Fingerprint: fp,
LastIP: ip,
FirstSeenAt: now,
LastSeenAt: now,
})
} else {
if existing.Fingerprint != "" && existing.Fingerprint != fp {
mismatch = true
}
existing.DeviceName = deviceName
existing.Client = client
existing.Fingerprint = fp
existing.LastIP = ip
existing.LastSeenAt = now
existing.Kicked = false
_ = s.repo.UserDevice.Save(ctx, existing)
}
cfg := loadBotConfig(ctx, s.repo)
if !cfg.AntiShareEnabled {
return
}
if mismatch {
s.registerViolation(ctx, userID, fmt.Sprintf("设备指纹变更(设备:%s)", deviceLabel(deviceName, client)), cfg)
return
}
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.registerViolation(ctx, userID, fmt.Sprintf("同时登录客户端 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients), cfg)
}
}
// RecordPlayback marks a device as actively playing and runs the concurrent
// playback anti-share check. Call from playback-progress reporting.
func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, deviceName, client string) {
if userID == "" {
return
}
if deviceID == "" {
deviceID = "fp-" + fingerprint(client, deviceName)
}
now := time.Now()
existing, _ := s.repo.UserDevice.Find(ctx, userID, deviceID)
if existing == nil {
existing = &model.UserDevice{
UserID: userID,
DeviceID: deviceID,
DeviceName: deviceName,
Client: client,
Fingerprint: fingerprint(client, deviceName),
FirstSeenAt: now,
LastSeenAt: now,
}
existing.LastPlayAt = &now
_ = s.repo.UserDevice.Create(ctx, existing)
} else {
existing.LastSeenAt = now
existing.LastPlayAt = &now
_ = s.repo.UserDevice.Save(ctx, existing)
}
cfg := loadBotConfig(ctx, s.repo)
if !cfg.AntiShareEnabled {
return
}
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.registerViolation(ctx, userID, fmt.Sprintf("同时播放 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay), cfg)
}
}
// registerViolation increments the shared anti-share warning counter for a
// user, notifies them, and deletes the account once warnings exceed the
// threshold. Protected accounts are never auto-deleted. Violations are
// debounced so a single burst counts at most once per minute.
func (s *DeviceService) registerViolation(ctx context.Context, userID, reason string, cfg botConfig) {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return
}
if s.isProtected(ctx, u) {
s.log.Info("anti-share: skipping protected account", zap.String("user", u.Username), zap.String("reason", reason))
return
}
now := time.Now()
if u.LastShareWarnAt != nil && now.Sub(*u.LastShareWarnAt) < time.Minute {
return // debounce burst
}
warnings := u.ShareWarnings + 1
if warnings > cfg.WarnThreshold {
// Exhausted warnings → delete (notify first).
s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因多次触发防共享规则(%s)已被删除。如有疑问请联系管理员。", u.Username, reason))
s.log.Warn("anti-share: deleting account after warnings",
zap.String("user", u.Username), zap.Int("warnings", u.ShareWarnings), zap.String("reason", reason))
_ = s.repo.UserDevice.DeleteByUser(ctx, userID)
_ = s.repo.User.Delete(ctx, userID)
return
}
_ = s.repo.User.UpdateFields(ctx, userID, map[string]any{
"share_warnings": warnings,
"last_share_warn_at": &now,
})
left := cfg.WarnThreshold + 1 - warnings
s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发防共享规则:%s\n这是第 <b>%d</b> 次警告,再违规 <b>%d</b> 次将删除账号。请使用 Bot 的「我的设备」一键踢下线多余设备。", u.Username, reason, warnings, left))
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
}
// SweepInactiveUsers runs the inactivity cleanup policy once. It picks a random
// window in [min,max] days, then deletes non-protected users (past their grace
// period) who watched less than the configured hours in that window. Returns
// the number of accounts removed. No-op when the policy is disabled.
func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) {
cfg := loadBotConfig(ctx, s.repo)
if !cfg.InactiveEnabled {
return 0, nil
}
windowDays := randomWindowDays(cfg.InactiveWindowMin, cfg.InactiveWindowMax)
since := time.Now().Add(-time.Duration(windowDays) * 24 * time.Hour)
graceCutoff := time.Now().Add(-time.Duration(cfg.InactiveGraceDays) * 24 * time.Hour)
minMs := int64(cfg.InactiveMinHours) * 3600 * 1000
users, err := s.repo.User.List(ctx)
if err != nil {
return 0, err
}
removed := 0
for i := range users {
u := &users[i]
if s.isProtected(ctx, u) || !u.IsActive {
continue
}
if u.CreatedAt.After(graceCutoff) {
continue // still within new-account grace period
}
watched, err := s.repo.UserDevice.WatchedMillisSince(ctx, u.ID, since)
if err != nil {
continue
}
if watched >= minMs {
continue // active enough → keep
}
s.notify(ctx, u.ID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因近 %d 天观看时长不足 %d 小时(不活跃)已被清理。如需恢复请联系管理员。", u.Username, windowDays, cfg.InactiveMinHours))
s.log.Warn("inactivity: deleting inactive account",
zap.String("user", u.Username), zap.Int("window_days", windowDays), zap.Int64("watched_ms", watched))
_ = s.repo.UserDevice.DeleteByUser(ctx, u.ID)
if err := s.repo.User.Delete(ctx, u.ID); err == nil {
removed++
}
}
return removed, nil
}
// KickDevice marks a device as kicked so the next request from it is rejected
// (the client must log in again). Returns the affected device for messaging.
func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) error {
d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
if err != nil {
return err
}
if d == nil {
return fmt.Errorf("device not found")
}
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
}
// ListDevices returns the device sessions for a user.
func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
return s.repo.UserDevice.ListByUser(ctx, userID)
}
// IsDeviceKicked reports whether a (user, device) pair was kicked and should be
// forced to re-authenticate.
func (s *DeviceService) IsDeviceKicked(ctx context.Context, userID, deviceID string) bool {
if userID == "" || deviceID == "" {
return false
}
d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
return err == nil && d != nil && d.Kicked
}
func (s *DeviceService) notify(ctx context.Context, userID, text string) {
if s.notifyUser != nil {
s.notifyUser(ctx, userID, text)
}
}
// randomWindowDays returns a random integer in [min,max]. The window is
// intentionally non-fixed per the operator's requirement ("随机触发").
func randomWindowDays(min, max int) int {
if min < 1 {
min = 1
}
if max < min {
max = min
}
if max == min {
return min
}
return min + rand.Intn(max-min+1)
}
func deviceLabel(name, client string) string {
name = strings.TrimSpace(name)
client = strings.TrimSpace(client)
switch {
case name != "" && client != "":
return name + " / " + client
case name != "":
return name
case client != "":
return client
default:
return "未知设备"
}
}
+2
View File
@@ -26,6 +26,8 @@ var (
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話期]`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*(\d{1,2})\s*季`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
)
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
func TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) {
root := t.TempDir()
hostRoot := filepath.Join(root, "nas", "moviepilot-v2", "media")
hostRoot := filepath.Join(root, "nas", "host", "media")
containerRoot := filepath.Join(root, "container", "media")
containerLibrary := filepath.Join(containerRoot, "电视剧", "国产剧")
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
+61
View File
@@ -0,0 +1,61 @@
package service
import "testing"
// TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory:
// CleanQuery (title/year) + ParseEpisode (season/episode) + titleCaseWords.
// These cases previously regressed (release tags such as BD/UHD leaking into
// the title, Roman-numeral sequels becoming "Ii", Chinese season markers like
// 第二季 polluting the title).
func TestOrganizeNaming(t *testing.T) {
cases := []struct {
file string
wantTitle string // titleCaseWords(CleanQuery) output
wantYear int
wantSeason int
wantEp int
}{
{"流浪地球2.2023.2160p.WEB-DL.H265.mkv", "流浪地球2", 2023, 0, 0},
{"[阳光电影www.ygdy8.com].复仇者联盟4.2019.BD.1080p.mkv", "复仇者联盟4", 2019, 0, 0},
{"狂飙.S01E05.2023.1080p.WEB-DL.mp4", "狂飙", 2023, 1, 5},
{"The.Wandering.Earth.II.2023.2160p.mkv", "The Wandering Earth II", 2023, 0, 0},
{"庆余年第二季.Joy.of.Life.S02E01.2024.mp4", "庆余年 Joy Of Life", 2024, 2, 1},
{"三体.Three-Body.2023.S01E03.4K.mkv", "三体 Three Body", 2023, 1, 3},
{"Friends.S03E12.1994.720p.mkv", "Friends", 1994, 3, 12},
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0},
{"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0},
}
for _, tc := range cases {
t.Run(tc.file, func(t *testing.T) {
title, year := CleanQuery(tc.file)
gotTitle := sanitizeFilename(titleCaseWords(title))
season, ep := ParseEpisode(tc.file)
if gotTitle != tc.wantTitle {
t.Errorf("title = %q, want %q", gotTitle, tc.wantTitle)
}
if year != tc.wantYear {
t.Errorf("year = %d, want %d", year, tc.wantYear)
}
if season != tc.wantSeason || ep != tc.wantEp {
t.Errorf("season/ep = %d/%d, want %d/%d", season, ep, tc.wantSeason, tc.wantEp)
}
})
}
}
// TestTitleCaseWordsRomanNumerals verifies sequel numerals are upper-cased
// while ordinary words that merely resemble numerals are not.
func TestTitleCaseWordsRomanNumerals(t *testing.T) {
cases := map[string]string{
"wandering earth ii": "Wandering Earth II",
"rocky iv": "Rocky IV",
"final fantasy vii": "Final Fantasy VII",
"the mix tape": "The Mix Tape", // "mix" must NOT become "MIX"
"sid and nancy": "Sid And Nancy",
}
for in, want := range cases {
if got := titleCaseWords(in); got != want {
t.Errorf("titleCaseWords(%q) = %q, want %q", in, got, want)
}
}
}
+22 -3
View File
@@ -5,8 +5,8 @@
// Unlike OrganizeLibraryWithOptions, which only touches model.Media rows that
// already belong to a registered library, OrganizeDirectory walks the source
// directory on disk directly. This lets operators organize the whole download
// directory (/downloads or a NAS direct-read path like
// /vol1/1000/qBittorrent/downloads) even though it is not a registered library.
// directory (/downloads or a NAS direct-read path configured by the operator)
// even though it is not a registered library.
//
// Two protections requested by operators:
//
@@ -302,10 +302,16 @@ func (o *OrganizerService) existingByFolder(destDir, episodeTag string) []string
}
// titleCaseWords upper-cases the first letter of each ASCII word; CJK and other
// non-ASCII leading characters are left untouched.
// non-ASCII leading characters are left untouched. Roman numerals (ii, iii, iv,
// …) are fully upper-cased so sequels like "Wandering Earth II" keep their
// canonical casing instead of becoming "Ii".
func titleCaseWords(s string) string {
fields := strings.Fields(s)
for i, w := range fields {
if isRomanNumeral(w) {
fields[i] = strings.ToUpper(w)
continue
}
r := []rune(w)
if len(r) > 0 && r[0] < 128 {
r[0] = unicode.ToUpper(r[0])
@@ -315,6 +321,19 @@ func titleCaseWords(s string) string {
return strings.Join(fields, " ")
}
// sequelNumerals is a conservative whitelist of multi-letter Roman numerals
// used for movie/series sequels. A whitelist avoids false positives on normal
// English words that happen to be valid numerals (e.g. "mix", "civ", "mi").
var sequelNumerals = map[string]struct{}{
"ii": {}, "iii": {}, "iv": {}, "vi": {}, "vii": {}, "viii": {},
"ix": {}, "xi": {}, "xii": {}, "xiii": {}, "xiv": {}, "xv": {},
}
func isRomanNumeral(w string) bool {
_, ok := sequelNumerals[strings.ToLower(w)]
return ok
}
// replaceVersions removes the existing lower-resolution files (and their NFO
// sidecars + DB rows) and transfers src into dst.
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
+11 -5
View File
@@ -67,12 +67,15 @@ var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
// noiseTokens are stripped before search.
var noiseTokens = []string{
// 视频规格
"1080p", "2160p", "4k", "720p", "480p",
"1080p", "2160p", "4k", "720p", "480p", "uhd", "ds4k", "fhd",
"bd", "bdrip", "brrip", "dvd", "dvdrip", "hdtv", "pdtv", "webdl",
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
"x264", "x265", "h264", "h265", "hevc", "avc",
"hdr", "sdr", "dts", "ddp", "atmos", "aac", "ac3", "flac",
"remux", "extended", "uncut", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt",
"x264", "x265", "h264", "h265", "hevc", "avc", "10bit", "8bit", "hi10p", "hi10",
"hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd",
"dovi", "atmos", "aac", "ac3", "flac",
"remux", "extended", "uncut", "remastered", "repack", "proper", "internal",
"limited", "imax", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
@@ -126,6 +129,9 @@ func CleanQuery(raw string) (title string, year int) {
lower = patNxE.ReplaceAllString(lower, " ")
lower = patEP.ReplaceAllString(lower, " ")
lower = patCN.ReplaceAllString(lower, " ")
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
// 搜索查询又导致整理后的目录名重复季信息。
lower = patCNSeason.ReplaceAllString(lower, " ")
for _, pat := range multiWordNoise {
lower = pat.ReplaceAllString(lower, " ")
+33
View File
@@ -68,6 +68,7 @@ type Container struct {
DownloadMgr *DownloadManager
Notify *NotifyService
Site *SiteService
Device *DeviceService
stopCtx context.Context
stopCancel context.CancelFunc
@@ -120,7 +121,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
// 初始化认证相关服务
tokenSvc := NewTokenService(cfg, log, repos)
authSvc := NewAuthService(cfg, log, repos, tokenSvc, permissions)
deviceSvc := NewDeviceService(log, repos)
telegramBot := NewTelegramBotService(log, repos, crypto, authSvc)
telegramBot.SetDeviceService(deviceSvc)
// Allow the device-enforcement service to DM users (warnings / deletions)
// through their Telegram binding before any destructive action.
deviceSvc.SetNotifier(telegramBot.NotifyUserByID)
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
downloadMgr := NewDownloadManager(log, repos, crypto)
notifySvc := NewNotifyService(log, repos, crypto)
@@ -207,6 +213,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
DownloadMgr: downloadMgr,
Notify: notifySvc,
Site: siteSvc,
Device: deviceSvc,
stopCtx: ctx,
stopCancel: cancel,
}
@@ -231,6 +238,32 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
// 不活跃清理巡检:随机 3~5 天窗口、默认关闭,由管理员在 Bot 设备策略开启。
// 每天触发一次评估(窗口天数随机,不固定)。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
}
}
// runInactivitySweeper periodically runs the inactivity-cleanup policy. The
// policy itself is a no-op unless an admin enabled it; this just provides the
// daily trigger with a non-fixed random window (handled inside the sweep).
func (c *Container) runInactivitySweeper(ctx context.Context) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := c.Device.SweepInactiveUsers(ctx); err != nil {
c.Log.Warn("inactivity sweep failed", zap.Error(err))
} else if n > 0 {
c.Log.Info("inactivity sweep removed accounts", zap.Int("count", n))
}
}
}
}
// Close 释放 services 持有的任何资源(websocket hub, ffmpeg 转码, fsnotify, 后台轮询器)。
+78 -4
View File
@@ -76,9 +76,46 @@ type TelegramBotService struct {
repo *repository.Container
crypto *CryptoService
auth *AuthService
device *DeviceService
pollingMu sync.Mutex
pollingCancel map[string]context.CancelFunc // bot_token -> cancel
pendingMu sync.Mutex
pending map[int64]pendingInput // telegram_user_id -> awaited text input
}
// pendingInput tracks a button-initiated action that awaits the user's next
// text message (e.g. tapping「注册」then sending "用户名 密码").
type pendingInput struct {
Kind string // register / redeem_register / redeem_renew / setname / setpass / openreg_limit / gencode_user
CreatedAt time.Time
}
// SetDeviceService wires the device-management service used by the device
// menu (list / kick) and enforcement notifications.
func (s *TelegramBotService) SetDeviceService(d *DeviceService) { s.device = d }
// NotifyUserByID sends a Telegram message to the local user identified by
// userID, resolved through their Telegram binding. Used by enforcement to warn
// users before destructive actions. No-op when the user has no binding.
func (s *TelegramBotService) NotifyUserByID(ctx context.Context, userID, text string) {
if userID == "" || strings.TrimSpace(text) == "" {
return
}
var binding model.TelegramBinding
if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", userID).First(&binding).Error; err != nil {
return
}
channel := s.findChannelByChatID(ctx, int(binding.ChatID))
if channel == nil {
channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
if err != nil || len(channels) == 0 {
return
}
channel = &channels[0]
}
_ = s.reply(ctx, channel, int(binding.ChatID), telegramCommandReply{Text: text})
}
// NewTelegramBotService 创建 Telegram Bot 服务。
@@ -89,6 +126,7 @@ func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *
crypto: crypto,
auth: auth,
pollingCancel: make(map[string]context.CancelFunc),
pending: make(map[int64]pendingInput),
}
}
@@ -127,7 +165,23 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
msg := update.Message
text := strings.TrimSpace(msg.Text)
// Button-initiated text prompts (register / redeem / change name·password /
// open-reg limit) arrive as ordinary messages. Consume them here before the
// command gate so the button-driven menu can collect free-form input.
if !telegramIsCommandText(text) {
if msg.Chat.Type == "" || msg.Chat.Type == "private" {
if channel := s.findChannelForMessage(ctx, msg); channel != nil {
if reply, handled := s.handlePendingText(ctx, channel, msg, text); handled {
if reply.Text != "" {
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
s.log.Error("reply failed", zap.Error(err))
}
}
return nil
}
}
}
return nil
}
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !telegramSupportedCommand(telegramCommandName(text)) {
@@ -184,7 +238,15 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.
switch cmd {
case "/start":
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
case "/menu":
return s.mainMenu(ctx, channel, msg), nil
case "/cancel":
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
case "/help":
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
case "/hideadult", "/hide_adult", "/adult":
@@ -242,7 +304,7 @@ func telegramCommandName(text string) string {
func telegramSupportedCommand(cmd string) bool {
switch cmd {
case "/start", "/help", "/hideadult", "/hide_adult", "/adult",
case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult",
"/register", "/reg", "/signup", "/registration", "/reg_switch",
"/status", "/search", "/downloads", "/stats":
return true
@@ -315,9 +377,13 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage,
// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot
// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。
func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
if !s.registrationEnabled(ctx) {
if !s.openRegEnabled(ctx) {
return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"}
}
// 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。
if c := s.loadCapacity(ctx); c.Remaining() <= 0 {
return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"}
}
if s.auth == nil {
return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"}
}
@@ -347,6 +413,8 @@ func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.Not
return telegramCommandReply{Text: "注册失败:" + err.Error()}
}
}
// 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。
s.consumeOpenRegSlot(ctx)
if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
return telegramCommandReply{Text: fmt.Sprintf("账号 <b>%s</b> 注册成功,但自动绑定失败:%s\n请稍后使用 <code>/start %s 密码</code> 重新绑定。", user.Username, err.Error(), user.Username)}
}
@@ -836,12 +904,18 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal
}
// 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。
s.answerCallback(ctx, channel, cb.ID)
switch strings.TrimSpace(cb.Data) {
case "adult_toggle":
data := strings.TrimSpace(cb.Data)
if data == "adult_toggle" {
reply := s.cmdHideAdult(ctx, &msg, nil)
if reply.Text != "" {
return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
}
return nil
}
if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled {
if reply.Text != "" {
return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
}
}
return nil
}
+608
View File
@@ -0,0 +1,608 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// pendingTTL bounds how long a button-initiated text prompt stays valid.
const pendingTTL = 5 * time.Minute
func (s *TelegramBotService) setPending(userID int64, kind string) {
s.pendingMu.Lock()
s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()}
s.pendingMu.Unlock()
}
func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
p, ok := s.pending[userID]
if ok {
delete(s.pending, userID)
}
if ok && time.Since(p.CreatedAt) > pendingTTL {
return pendingInput{}, false
}
return p, ok
}
// boundUser resolves the local user bound to a Telegram account, or nil.
func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User {
binding := s.telegramBinding(ctx, telegramUserID)
if binding == nil {
return nil
}
u, _ := s.repo.User.FindByID(ctx, binding.UserID)
return u
}
// mainMenu builds the button-based menu, tailored to the user's binding and
// admin status. Ordinary users only see self-service actions; admins get an
// extra management section.
func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply {
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
user := s.boundUser(ctx, msg.From.ID)
var rows [][]telegramInlineButton
var header string
if user == nil {
header = "<b>MediaStationGo</b>\n\n你还没有绑定媒体中心账号。"
rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}})
if s.openRegEnabled(ctx) {
rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}})
}
rows = append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}})
} else {
adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
header = fmt.Sprintf("<b>MediaStationGo</b>\n\n账号:<b>%s</b>\n到期:<b>%s</b>\n成人目录:<b>%s</b>",
user.Username, formatExpiry(user.ExpiredAt), adult)
rows = append(rows,
[]telegramInlineButton{
{Text: "👤 我的账号", Data: "act_account"},
{Text: "📅 签到", Data: "act_signin"},
},
[]telegramInlineButton{
{Text: "📱 我的设备", Data: "act_devices"},
{Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"},
},
[]telegramInlineButton{
{Text: "✏️ 改用户名", Data: "act_setname"},
{Text: "🔑 改密码", Data: "act_setpass"},
},
[]telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}},
)
}
if isAdmin {
rows = append(rows,
[]telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
[]telegramInlineButton{
{Text: "📊 容量/状态", Data: "adm_capacity"},
{Text: "👥 用户管理", Data: "adm_users"},
},
[]telegramInlineButton{
{Text: "🔓 开注设置", Data: "adm_openreg"},
{Text: "🎟 生成兑换码", Data: "adm_gencode"},
},
[]telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
)
}
return telegramCommandReply{Text: header, Buttons: rows}
}
// handleMenuCallback routes inline-button taps. Returns (reply, handled).
func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
switch {
case data == "noop":
return telegramCommandReply{}, true
case data == "menu_main":
return s.mainMenu(ctx, channel, msg), true
case data == "act_bind":
return telegramCommandReply{Text: "请发送:<code>/start 用户名 密码</code> 绑定已有账号。"}, true
case data == "act_register":
if !s.openRegEnabled(ctx) {
return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true
}
s.setPending(int64(msg.From.ID), "register")
return telegramCommandReply{Text: "请发送新账号的 <b>用户名 密码</b>(空格分隔),例如:<code>alice mypass123</code>"}, true
case data == "act_redeem_register":
s.setPending(int64(msg.From.ID), "redeem_register")
return telegramCommandReply{Text: "请发送你的<b>注册兑换码</b>,例如:<code>ABCD2345EFGH</code>\n(兑换后会要求设置用户名密码)"}, true
case data == "act_redeem_renew":
s.setPending(int64(msg.From.ID), "redeem_renew")
return telegramCommandReply{Text: "请发送你的<b>续期兑换码</b>,将为当前绑定账号续期。"}, true
case data == "act_account":
return s.replyAccount(ctx, msg), true
case data == "act_signin":
return s.replySignIn(ctx, msg), true
case data == "act_devices":
return s.replyDevices(ctx, msg), true
case data == "act_setname":
s.setPending(int64(msg.From.ID), "setname")
return telegramCommandReply{Text: "请发送新的<b>用户名</b>。"}, true
case data == "act_setpass":
s.setPending(int64(msg.From.ID), "setpass")
return telegramCommandReply{Text: "请发送新的<b>密码</b>(至少 6 位)。"}, true
case strings.HasPrefix(data, "kick:"):
return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true
}
// ── 管理员专属 ──
if !isAdmin {
return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
}
switch {
case data == "adm_capacity":
return s.replyCapacity(ctx), true
case data == "adm_openreg":
return s.replyOpenRegMenu(ctx), true
case data == "adm_openreg_close":
_ = s.closeRegistration(ctx)
return telegramCommandReply{Text: "已关闭注册。"}, true
case strings.HasPrefix(data, "adm_openreg_set:"):
n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:"))
if err := s.openRegistration(ctx, n); err != nil {
return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
}
label := "不限"
if n > 0 {
label = fmt.Sprintf("%d 个名额", n)
}
return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true
case data == "adm_gencode":
return s.replyGenCodeMenu(), true
case strings.HasPrefix(data, "gc:"):
return s.replyGenCode(ctx, msg, data), true
case data == "adm_users":
return s.replyUserList(ctx), true
case strings.HasPrefix(data, "usr:"):
return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true
case strings.HasPrefix(data, "uban:"):
return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true
case strings.HasPrefix(data, "uunban:"):
return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true
case strings.HasPrefix(data, "udel:"):
return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true
case strings.HasPrefix(data, "urenew:"):
return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true
case data == "adm_devicepolicy":
return s.replyDevicePolicy(ctx), true
case strings.HasPrefix(data, "dp_toggle:"):
return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true
}
return telegramCommandReply{}, false
}
// handlePendingText consumes a button-initiated text prompt. Returns (reply,
// handled). handled=false means there was no pending prompt for this user.
func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) {
p, ok := s.takePending(int64(msg.From.ID))
if !ok {
return telegramCommandReply{}, false
}
switch p.Kind {
case "register":
return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true
case "redeem_register":
return s.redeemRegisterFlow(ctx, channel, msg, text), true
case "redeem_renew":
return s.redeemRenewFlow(ctx, msg, text), true
case "setname":
return s.selfSetName(ctx, msg, text), true
case "setpass":
return s.selfSetPass(ctx, msg, text), true
case "openreg_limit":
n, err := strconv.Atoi(strings.TrimSpace(text))
if err != nil || n < 0 {
return telegramCommandReply{Text: "请输入有效的非负整数。"}, true
}
if err := s.openRegistration(ctx, n); err != nil {
return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
}
return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true
}
return telegramCommandReply{}, false
}
// ── 用户自助 ──────────────────────────────────────────────────────────────
func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号:<code>/start 用户名 密码</code>"}
}
streak := 0
if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil {
streak = rec.StreakDays
}
devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID)
text := fmt.Sprintf("<b>我的账号</b>\n\n用户名:<b>%s</b>\n状态:<b>%s</b>\n到期:<b>%s</b>\n连续签到:<b>%d 天</b>\n登录设备:<b>%d 台</b>",
user.Username,
map[bool]string{true: "正常", false: "已禁用"}[user.IsActive],
formatExpiry(user.ExpiredAt), streak, len(devices))
return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
}
func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号后再签到。"}
}
res, err := s.signIn(ctx, user.ID)
if err != nil {
return telegramCommandReply{Text: "签到失败:" + err.Error()}
}
if res.AlreadySigned {
return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 <b>%d</b> 天,累计 <b>%d</b> 天。", res.Streak, res.Total)}
}
return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 <b>%d</b> 天,累计 <b>%d</b> 天。", res.Streak, res.Total)}
}
func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID)
if len(devices) == 0 {
return telegramCommandReply{Text: "当前没有记录到登录设备。"}
}
var sb strings.Builder
sb.WriteString("<b>我的登录设备</b>\n点击下方按钮可一键踢下线:\n")
var rows [][]telegramInlineButton
for i, d := range devices {
status := ""
if d.Kicked {
status = "(已踢下线)"
}
sb.WriteString(fmt.Sprintf("\n%d. <b>%s</b>%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04")))
if !d.Kicked {
rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}})
}
}
rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
return telegramCommandReply{Text: sb.String(), Buttons: rows}
}
func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
// Verify the device belongs to this user before kicking.
var d model.UserDevice
if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil {
return telegramCommandReply{Text: "未找到该设备。"}
}
if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil {
return telegramCommandReply{Text: "操作失败:" + err.Error()}
}
return s.replyDevices(ctx, msg)
}
func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, newName string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
newName = strings.TrimSpace(newName)
if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") {
return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"}
}
if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID {
return telegramCommandReply{Text: "该用户名已被占用,请换一个。"}
}
if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil {
return telegramCommandReply{Text: "修改失败:" + err.Error()}
}
return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 <b>%s</b>。请用新用户名登录。", newName)}
}
func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, newPass string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
newPass = strings.TrimSpace(newPass)
if s.auth == nil {
return telegramCommandReply{Text: "服务暂不可用。"}
}
if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil {
return telegramCommandReply{Text: "修改失败:" + err.Error()}
}
return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
}
// ── 兑换码流程 ───────────────────────────────────────────────────────────────
func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply {
rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister)
if rc == nil {
return telegramCommandReply{Text: errMsg}
}
if s.auth == nil {
return telegramCommandReply{Text: "注册服务暂不可用。"}
}
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil {
return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 <b>%s</b>,无需再用注册码。", u.Username)}
}
}
// Generate a memorable default account from the code; users can rename via
//「改用户名/改密码」afterwards. We avoid asking for two more text turns here.
username := "u" + strings.ToLower(rc.Code[:8])
password := randomCode(10)
user, _, err := s.auth.Register(ctx, username, password)
if err != nil {
return telegramCommandReply{Text: "注册失败:" + err.Error()}
}
if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
// Code was raced; roll back the just-created account to avoid free signups.
_ = s.repo.User.Delete(ctx, user.ID)
return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
}
if rc.DurationDays > 0 {
_ = s.applyRenewal(ctx, user.ID, rc.DurationDays)
}
_ = s.upsertTelegramBinding(ctx, msg, user.ID)
return telegramCommandReply{
Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:<b>%s</b>\n密码:<b>%s</b>\n到期:<b>%s</b>\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。",
username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}},
}
}
func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号再续期。"}
}
rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew)
if rc == nil {
return telegramCommandReply{Text: errMsg}
}
if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
}
if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil {
return telegramCommandReply{Text: "续期失败:" + err.Error()}
}
return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:<b>%s</b>", formatExpiry(s.userExpiry(ctx, user.ID)))}
}
func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time {
if u, _ := s.repo.User.FindByID(ctx, userID); u != nil {
return u.ExpiredAt
}
return nil
}
// ── 管理员:容量 / 开注 / 兑换码 / 用户管理 / 设备策略 ─────────────────────────
func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply {
c := s.loadCapacity(ctx)
quota := "未开放"
if c.OpenRegOn {
if c.OpenRegLimit > 0 {
quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit)
} else {
quota = "已开放(不限名额,受授权上限约束)"
}
}
text := fmt.Sprintf("<b>容量 / 状态</b>\n\n授权上限:<b>%d</b> 人(随凭证授权实时变化)\n已用:<b>%d</b> 人\n剩余可注册:<b>%d</b> 人\n开注状态:<b>%s</b>",
c.MaxUsers, c.UsedUsers, c.Remaining(), quota)
return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
}
func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply {
c := s.loadCapacity(ctx)
state := "未开放"
if c.OpenRegOn {
state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit)
}
return telegramCommandReply{
Text: "<b>开注设置</b>\n当前:" + state + "\n选择要开放的名额:",
Buttons: [][]telegramInlineButton{
{{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}},
{{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}},
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
},
}
}
func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply {
return telegramCommandReply{
Text: "<b>生成兑换码</b>\n选择类型与时长:",
Buttons: [][]telegramInlineButton{
{{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}},
{{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}},
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
},
}
}
func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply {
parts := strings.Split(data, ":") // gc:<kind>:<days>
if len(parts) != 3 {
return telegramCommandReply{Text: "参数错误。"}
}
kind := parts[1]
days, _ := strconv.Atoi(parts[2])
createdBy := ""
if u := s.boundUser(ctx, msg.From.ID); u != nil {
createdBy = u.ID
}
code, err := s.generateCode(ctx, kind, days, 0, createdBy)
if err != nil {
return telegramCommandReply{Text: "生成失败:" + err.Error()}
}
kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind]
dur := "永久"
if days > 0 {
dur = fmt.Sprintf("%d 天", days)
}
return telegramCommandReply{
Text: fmt.Sprintf("已生成%s(%s):\n\n<code>%s</code>\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code),
Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}},
}
}
func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
users, err := s.repo.User.List(ctx)
if err != nil {
return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
}
if len(users) == 0 {
return telegramCommandReply{Text: "暂无用户。"}
}
var rows [][]telegramInlineButton
limit := len(users)
if limit > 12 {
limit = 12
}
for i := 0; i < limit; i++ {
u := users[i]
flag := ""
if !u.IsActive {
flag = "🚫"
}
if u.Role == "admin" {
flag = "👑"
}
rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}})
}
rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
return telegramCommandReply{Text: fmt.Sprintf("<b>用户管理</b>(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows}
}
func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return telegramCommandReply{Text: "用户不存在。"}
}
protected := u.Role == "admin"
if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
protected = true
}
text := fmt.Sprintf("<b>%s</b>\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次",
u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings)
if protected {
return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
}
banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID}
if !u.IsActive {
banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID}
}
return telegramCommandReply{
Text: text,
Buttons: [][]telegramInlineButton{
{banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}},
{{Text: "🗑 删除用户", Data: "udel:" + u.ID}},
{{Text: "⬅️ 返回", Data: "adm_users"}},
},
}
}
func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply {
if !unban {
if reason := s.protectReason(ctx, userID); reason != "" {
return telegramCommandReply{Text: reason}
}
}
if err := s.repo.User.UpdateFields(ctx, userID, map[string]any{"is_active": unban}); err != nil {
return telegramCommandReply{Text: "操作失败:" + err.Error()}
}
return s.replyUserActions(ctx, userID)
}
func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply {
if reason := s.protectReason(ctx, userID); reason != "" {
return telegramCommandReply{Text: reason}
}
u, _ := s.repo.User.FindByID(ctx, userID)
_ = s.repo.UserDevice.DeleteByUser(ctx, userID)
if err := s.repo.User.Delete(ctx, userID); err != nil {
return telegramCommandReply{Text: "删除失败:" + err.Error()}
}
name := userID
if u != nil {
name = u.Username
}
return telegramCommandReply{Text: fmt.Sprintf("已删除用户 <b>%s</b>。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
}
func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply {
parts := strings.Split(payload, ":") // <id>:<days>
if len(parts) != 2 {
return telegramCommandReply{Text: "参数错误。"}
}
days, _ := strconv.Atoi(parts[1])
if err := s.applyRenewal(ctx, parts[0], days); err != nil {
return telegramCommandReply{Text: "续期失败:" + err.Error()}
}
return s.replyUserActions(ctx, parts[0])
}
// protectReason returns a non-empty message when a user must not be
// disabled/deleted (admins and the default admin are protected).
func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return "用户不存在。"
}
if u.Role == "admin" {
return "管理员账号受保护,不可禁用/删除。"
}
if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
return "默认管理员账号受保护,不可禁用/删除。"
}
return ""
}
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 / 警告 %d 次后删号\n\n② 不活跃清理:<b>%s</b>\n 随机 %d~%d 天窗口观看 < %d 小时则删号(新号 %d 天宽限)\n\n两套策略默认关闭、互不干扰;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。",
onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
onOff(cfg.InactiveEnabled), cfg.InactiveWindowMin, cfg.InactiveWindowMax, cfg.InactiveMinHours, cfg.InactiveGraceDays)
return telegramCommandReply{
Text: text,
Buttons: [][]telegramInlineButton{
{{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}},
{{Text: toggleLabel("不活跃清理", cfg.InactiveEnabled), Data: "dp_toggle:inactive"}},
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
},
}
}
func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply {
cfg := loadBotConfig(ctx, s.repo)
switch which {
case "antishare":
_ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled))
case "inactive":
_ = s.repo.Setting.Set(ctx, SettingInactiveEnabled, strconv.FormatBool(!cfg.InactiveEnabled))
}
return s.replyDevicePolicy(ctx)
}
func onOff(b bool) string {
return map[bool]string{true: "已开启", false: "已关闭"}[b]
}
func toggleLabel(name string, enabled bool) string {
if enabled {
return "关闭" + name
}
return "开启" + name
}