mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "未知设备"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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, 后台轮询器)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user