mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 00:06:37 +08:00
fix: 资源占用/登录稳定性/QB整理入库/第三方播放404 综合修复
资源占用(Docker 部署 CPU/内存长期居高): - 云盘探测预算改为按尝试扣减,杜绝队列满时对每个文件反复入队 并刷出数万条 WARN(实测日志 41165 条) - 探测队列满时给文件挂 30 分钟退避 + 告警限速为每分钟一条 - 扫描时每个文件的海报/背景图由同步下载(单张最长 20s)改为 后台预取队列,云盘大库扫描不再串行拉图数小时 - PlaybackInfo 的云盘 ffprobe 探测改异步(原同步最长 8s, 既拖慢起播又放大云盘流量),带单飞去重 - 访问日志跳过 /api/health 与静态资源;logging.level/format 配置真正生效(此前是死配置) 登录稳定性(经常登录报错): - refresh token 未及时落库期间,刷新请求可识别「待落库令牌」, 不再把用户踢回登录页;轮换/登出后取消后台补写,防止旧令牌复活 QB 下载整理入库: - 新增 download.path_mappings 设置:自定义下载器→本程序路径映射 (每行 客户端路径=本地路径),并复用 compose 环境变量映射规则 - 应用重启后补整理最近 24h 内完成的种子(此前重启即永久漏掉) - 下载客户端初始化失败仍注册并惰性重连(容器启动顺序免疫) - 硬链接跨文件系统(EXDEV)自动降级为复制,保种语义不变 第三方播放器 404: - 播放处理器不再把所有错误吞成 404:媒体不存在→404, 云盘解析失败/STRM 关闭→502+原因 - 存库的云盘播放 URL 规范化为相对路径,免疫扫描时固化的旧 host - 云盘媒体 SupportsDirectPlay=false,强制走带鉴权的 DirectStream
This commit is contained in:
@@ -42,12 +42,22 @@ type TokenService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
delayedStoreMu sync.Mutex
|
||||
delayedStores map[string]struct{}
|
||||
// delayedStores 记录「已发给客户端但还没写进库」的 refresh token。
|
||||
// 键是 token 哈希;值携带签发信息,让 Refresh 在落库完成前也能识别
|
||||
// 这些令牌——否则用户登录成功、一小时后 access token 过期,刷新时
|
||||
// 因为 refresh token 从未落库而被判定无效,被强制踢回登录页,
|
||||
// 表现就是「经常登录报错」。
|
||||
delayedStores map[string]pendingRefreshToken
|
||||
}
|
||||
|
||||
type pendingRefreshToken struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewTokenService 创建令牌服务实例。
|
||||
func NewTokenService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *TokenService {
|
||||
return &TokenService{cfg: cfg, log: log, repo: repo, delayedStores: make(map[string]struct{})}
|
||||
return &TokenService{cfg: cfg, log: log, repo: repo, delayedStores: make(map[string]pendingRefreshToken)}
|
||||
}
|
||||
|
||||
// TokenPair 包含访问令牌和刷新令牌。
|
||||
@@ -113,7 +123,7 @@ func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string,
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
}
|
||||
if s.trackDelayedStore(userID, tokenHash) {
|
||||
if s.trackDelayedStore(userID, tokenHash, rt.ExpiresAt) {
|
||||
go s.storeRefreshTokenEventually(userID, tokenHash, rt.ExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -142,6 +152,11 @@ func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, exp
|
||||
for attempt := 1; attempt <= 8; attempt++ {
|
||||
timer := time.NewTimer(delay)
|
||||
<-timer.C
|
||||
// 令牌可能已在等待期间被轮换/登出(从 pending 表移除),
|
||||
// 此时绝不能再写库,否则会复活一个已被替换的旧令牌。
|
||||
if _, stillPending := s.pendingDelayedStore(tokenHash); !stillPending {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
err := s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
@@ -173,20 +188,19 @@ func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, exp
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) trackDelayedStore(userID, tokenHash string) bool {
|
||||
func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt time.Time) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
key := userID + "\x00" + tokenHash
|
||||
s.delayedStoreMu.Lock()
|
||||
defer s.delayedStoreMu.Unlock()
|
||||
if s.delayedStores == nil {
|
||||
s.delayedStores = make(map[string]struct{})
|
||||
s.delayedStores = make(map[string]pendingRefreshToken)
|
||||
}
|
||||
if _, ok := s.delayedStores[key]; ok {
|
||||
if _, ok := s.delayedStores[tokenHash]; ok {
|
||||
return false
|
||||
}
|
||||
s.delayedStores[key] = struct{}{}
|
||||
s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, ExpiresAt: expiresAt}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -194,12 +208,22 @@ func (s *TokenService) untrackDelayedStore(userID, tokenHash string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
key := userID + "\x00" + tokenHash
|
||||
s.delayedStoreMu.Lock()
|
||||
delete(s.delayedStores, key)
|
||||
delete(s.delayedStores, tokenHash)
|
||||
s.delayedStoreMu.Unlock()
|
||||
}
|
||||
|
||||
// pendingDelayedStore 返回尚未落库的 refresh token 信息(如果存在)。
|
||||
func (s *TokenService) pendingDelayedStore(tokenHash string) (pendingRefreshToken, bool) {
|
||||
if s == nil {
|
||||
return pendingRefreshToken{}, false
|
||||
}
|
||||
s.delayedStoreMu.Lock()
|
||||
defer s.delayedStoreMu.Unlock()
|
||||
pending, ok := s.delayedStores[tokenHash]
|
||||
return pending, ok
|
||||
}
|
||||
|
||||
func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int {
|
||||
cfg := loadBotConfig(ctx, s.repo)
|
||||
if cfg.MaxLoggedClients < 1 {
|
||||
@@ -244,7 +268,17 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
return nil, err
|
||||
}
|
||||
if rt == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
// 登录高峰/扫描写压力下,refresh token 可能还在后台补写队列里
|
||||
// 没来得及落库。此时令牌对客户端而言是合法的,不能判无效。
|
||||
pending, ok := s.pendingDelayedStore(tokenHash)
|
||||
if !ok || time.Now().After(pending.ExpiresAt) {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
rt = &model.RefreshToken{
|
||||
UserID: pending.UserID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: pending.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已撤销
|
||||
@@ -272,10 +306,11 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
return nil, ErrUserExpired
|
||||
}
|
||||
|
||||
// 撤销旧的 Refresh Token
|
||||
// 撤销旧的 Refresh Token(包括可能仍在后台补写队列里的副本)。
|
||||
if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
|
||||
s.log.Warn("failed to revoke old refresh token", zap.Error(err))
|
||||
}
|
||||
s.untrackDelayedStore(rt.UserID, tokenHash)
|
||||
|
||||
// 签发新的令牌对
|
||||
return s.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
||||
|
||||
Reference in New Issue
Block a user