mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-16 04:44:23 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e8fe2396e | |||
| e34e81cce0 | |||
| 3cf898756b | |||
| 0b68397265 | |||
| 90bfac3b09 | |||
| f1974a179e | |||
| f2357200be | |||
| ca9a80b862 | |||
| 4f19e2210e | |||
| 91667c400c | |||
| 0b8438d798 | |||
| 2faedae0bf | |||
| fd57d2e59b | |||
| 8356f5c3c7 | |||
| 5d137ab6db | |||
| e2133cd76f | |||
| 1c45483e1f |
@@ -79,6 +79,9 @@ func (a *application) start() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
|
||||
return fmt.Errorf("apply pending database restore: %w", err)
|
||||
}
|
||||
db, err := database.Open(a.cfg, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Error("database open failed", zap.Error(err))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# MeBox Mobile API 接入与部署说明
|
||||
|
||||
更新日期:2026-10-17
|
||||
适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。
|
||||
|
||||
## 1. 路径与版本
|
||||
|
||||
- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。
|
||||
- 健康检查:`GET /api/health`。
|
||||
- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。
|
||||
- 契约字段与错误码见同目录 `openapi.yaml`。
|
||||
|
||||
`server-info.capabilities` 必须反映真实运行能力。当前分支只有
|
||||
`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为
|
||||
`false`,App 不得据此进入对应流程。
|
||||
|
||||
## 2. 连接与传输安全
|
||||
|
||||
- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。
|
||||
- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源
|
||||
使用明文,不会把 MeBox `Authorization` 发往其它主机。
|
||||
- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝,
|
||||
不会静默截断为根路径。
|
||||
- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于
|
||||
日志对账。
|
||||
|
||||
## 3. 反向代理检查项
|
||||
|
||||
以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置:
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 |
|
||||
| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) |
|
||||
| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 |
|
||||
| WebSocket | 升级头透传,读超时大于心跳间隔 |
|
||||
| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 |
|
||||
|
||||
目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。
|
||||
|
||||
## 4. 认证与会话
|
||||
|
||||
- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。
|
||||
- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即
|
||||
返回 401,不能借旧入口绕过。
|
||||
- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌,
|
||||
App 需要重新登录。
|
||||
- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。
|
||||
- 设备管理中的踢出操作会同步撤销该设备认证会话。
|
||||
|
||||
### 刷新轮换边界
|
||||
|
||||
- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。
|
||||
- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应
|
||||
且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受
|
||||
该边界,未实现持久化幂等轮换。
|
||||
- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。
|
||||
|
||||
## 5. 错误契约
|
||||
|
||||
统一响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SESSION_REVOKED",
|
||||
"message": "auth session revoked or expired",
|
||||
"request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3",
|
||||
"retryable": false
|
||||
}
|
||||
```
|
||||
|
||||
- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。
|
||||
- 403 表示权限或账号状态,不触发刷新。
|
||||
- 429 带 `Retry-After`,客户端必须等待。
|
||||
- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。
|
||||
|
||||
## 6. 数据迁移与回滚
|
||||
|
||||
- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate
|
||||
创建,属于增量变更,不清库。
|
||||
- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行
|
||||
`go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。
|
||||
- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再
|
||||
依赖 `sid` 即时撤销能力。
|
||||
|
||||
## 7. 已知限制
|
||||
|
||||
- 单实例部署;未实现多副本共享会话。
|
||||
- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。
|
||||
- 反代子路径、自签名证书信任、多服务器配置均未支持。
|
||||
- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
|
||||
@@ -0,0 +1,389 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: MeBox Mobile API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载,
|
||||
本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。
|
||||
|
||||
约定:
|
||||
- JSON 字段使用 snake_case;
|
||||
- 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒;
|
||||
- 集合为空时返回 `[]`,未知字段客户端必须忽略;
|
||||
- 错误响应统一为 `code`、`message`、`request_id`、`retryable`;
|
||||
- 能力开关只有在接口实现且当前可运行时才为 true。
|
||||
servers:
|
||||
- url: /api
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- name: server
|
||||
- name: auth
|
||||
paths:
|
||||
/v1/server-info:
|
||||
get:
|
||||
tags: [server]
|
||||
summary: 匿名读取实例身份与移动端能力
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: 实例可连接
|
||||
headers:
|
||||
Cache-Control:
|
||||
schema:
|
||||
type: string
|
||||
description: 固定为 no-store
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerInfo'
|
||||
'503':
|
||||
description: 暂时无法读取实例身份
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
/v1/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 账号密码登录并创建认证会话
|
||||
security: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 登录成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidArgument'
|
||||
'401':
|
||||
$ref: '#/components/responses/InvalidCredentials'
|
||||
'403':
|
||||
description: 账号被禁用或已到期
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
'429':
|
||||
$ref: '#/components/responses/RateLimited'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
/v1/auth/refresh:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 使用刷新令牌轮换令牌对
|
||||
security: []
|
||||
description: |
|
||||
刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的
|
||||
并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。
|
||||
会话被撤销后刷新返回 SESSION_REVOKED。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RefreshRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 刷新成功,新令牌继续归属同一 auth_session_id
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidArgument'
|
||||
'401':
|
||||
description: 刷新令牌无效、过期或会话已撤销
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
/v1/auth/session:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: 读取当前用户与认证会话
|
||||
responses:
|
||||
'200':
|
||||
description: 会话有效
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/SessionInvalid'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
/v1/auth/logout:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 撤销当前认证会话
|
||||
description: 幂等;重复退出同样返回 200。
|
||||
responses:
|
||||
'200':
|
||||
description: 已退出(本机与远端)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
'401':
|
||||
$ref: '#/components/responses/SessionInvalid'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
RequestId:
|
||||
name: X-Request-ID
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在
|
||||
响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
InvalidArgument:
|
||||
description: 请求字段缺失或格式错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
InvalidCredentials:
|
||||
description: 用户名或密码错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
SessionInvalid:
|
||||
description: 访问令牌无效、过期或认证会话已撤销
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
RateLimited:
|
||||
description: 请求过于频繁
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: 建议等待秒数
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
ServiceUnavailable:
|
||||
description: 暂时性服务/存储错误,可安全重试
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
schemas:
|
||||
ServerInfo:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- server_id
|
||||
- server_version
|
||||
- api_version
|
||||
- min_supported_app_version_code
|
||||
- capabilities
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: MeBox
|
||||
server_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 实例持久身份;备份克隆成独立实例时必须显式重置。
|
||||
server_version:
|
||||
type: string
|
||||
example: MeBox-v0.1.16
|
||||
api_version:
|
||||
type: string
|
||||
example: v1
|
||||
min_supported_app_version_code:
|
||||
type: integer
|
||||
minimum: 1
|
||||
capabilities:
|
||||
$ref: '#/components/schemas/ServerCapabilities'
|
||||
ServerCapabilities:
|
||||
type: object
|
||||
required:
|
||||
- device_sessions
|
||||
- playback_sessions
|
||||
- reader_progress_v1
|
||||
- reader_content_batch
|
||||
- local_transcode
|
||||
properties:
|
||||
device_sessions:
|
||||
type: boolean
|
||||
description: M1 起为 true
|
||||
playback_sessions:
|
||||
type: boolean
|
||||
description: 播放会话接口未实现前必须为 false
|
||||
reader_progress_v1:
|
||||
type: boolean
|
||||
description: v1 阅读锚点/版本冲突未实现前必须为 false
|
||||
reader_content_batch:
|
||||
type: boolean
|
||||
description: v1 批量正文与资源引用未实现前必须为 false
|
||||
local_transcode:
|
||||
type: boolean
|
||||
description: 移动端可用的本地转码会话未实现前必须为 false
|
||||
MobileUser:
|
||||
type: object
|
||||
required: [id, username, role, tier, is_active, force_password_reset]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
nickname:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
enum: [admin, user]
|
||||
tier:
|
||||
type: string
|
||||
example: free
|
||||
is_active:
|
||||
type: boolean
|
||||
force_password_reset:
|
||||
type: boolean
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
TokenPair:
|
||||
type: object
|
||||
required: [access_token, refresh_token, expires_in, token_type]
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
refresh_token:
|
||||
type: string
|
||||
expires_in:
|
||||
type: integer
|
||||
description: 访问令牌有效秒数
|
||||
token_type:
|
||||
type: string
|
||||
example: Bearer
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [username, password, device_id]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。
|
||||
device_name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
client_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
example: MeBox Android
|
||||
client_version:
|
||||
type: string
|
||||
maxLength: 64
|
||||
RefreshRequest:
|
||||
type: object
|
||||
required: [refresh_token]
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
AuthResponse:
|
||||
type: object
|
||||
required: [auth_session_id, user, tokens]
|
||||
properties:
|
||||
auth_session_id:
|
||||
type: string
|
||||
format: uuid
|
||||
user:
|
||||
$ref: '#/components/schemas/MobileUser'
|
||||
tokens:
|
||||
$ref: '#/components/schemas/TokenPair'
|
||||
SessionDevice:
|
||||
type: object
|
||||
properties:
|
||||
device_id:
|
||||
type: string
|
||||
device_name:
|
||||
type: string
|
||||
client_name:
|
||||
type: string
|
||||
client_version:
|
||||
type: string
|
||||
last_used_at:
|
||||
type: string
|
||||
format: date-time
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
SessionResponse:
|
||||
type: object
|
||||
required: [auth_session_id, user]
|
||||
properties:
|
||||
auth_session_id:
|
||||
type: string
|
||||
format: uuid
|
||||
user:
|
||||
$ref: '#/components/schemas/MobileUser'
|
||||
session:
|
||||
$ref: '#/components/schemas/SessionDevice'
|
||||
MobileError:
|
||||
type: object
|
||||
required: [code, message, request_id, retryable]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- INVALID_ARGUMENT
|
||||
- INVALID_CREDENTIALS
|
||||
- TOKEN_EXPIRED
|
||||
- SESSION_REVOKED
|
||||
- ACCOUNT_DISABLED
|
||||
- ACCOUNT_EXPIRED
|
||||
- PERMISSION_DENIED
|
||||
- PROFILE_PIN_REQUIRED
|
||||
- RATE_LIMITED
|
||||
- UPSTREAM_UNAVAILABLE
|
||||
- TRANSCODE_BUSY
|
||||
- PLAYBACK_SESSION_NOT_FOUND
|
||||
- RESOURCE_EXPIRED
|
||||
- PROGRESS_CONFLICT
|
||||
- SERVICE_UNAVAILABLE
|
||||
message:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
retryable:
|
||||
type: boolean
|
||||
retry_after_sec:
|
||||
type: integer
|
||||
minimum: 0
|
||||
details:
|
||||
description: 可选的稳定结构化细节,未定义时客户端必须忽略。
|
||||
@@ -0,0 +1,189 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
var sqliteRestoreMu sync.Mutex
|
||||
|
||||
func sqliteRestorePath(cfg *config.Config) (string, error) {
|
||||
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
|
||||
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
|
||||
}
|
||||
return filepath.Abs(cfg.Database.DBPath)
|
||||
}
|
||||
|
||||
func sqliteRestoreDSN(path, mode string) string {
|
||||
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
|
||||
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func validateSQLiteRestore(ctx context.Context, path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errors.New("备份必须是常规文件")
|
||||
}
|
||||
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
var result string
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
|
||||
return fmt.Errorf("SQLite 备份校验失败: %w", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
return fmt.Errorf("SQLite 备份校验失败: %s", result)
|
||||
}
|
||||
var tables int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
|
||||
return err
|
||||
}
|
||||
if tables == 0 {
|
||||
return errors.New("备份不包含 MeBox 数据表")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StageSQLiteRestore leaves the live database and its WAL untouched. The
|
||||
// validated copy is applied on the next startup, before any database is opened.
|
||||
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
|
||||
sqliteRestoreMu.Lock()
|
||||
defer sqliteRestoreMu.Unlock()
|
||||
dbPath, err := sqliteRestorePath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pending := dbPath + ".restore-pending"
|
||||
if _, err := os.Lstat(pending); err == nil {
|
||||
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := validateSQLiteRestore(ctx, source); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := out.Name()
|
||||
defer os.Remove(tmp)
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSQLiteRestore(ctx, tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, pending)
|
||||
}
|
||||
|
||||
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
|
||||
// HTTP listeners start. The previous database is retained alongside the file.
|
||||
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
dialect := normalizeDatabaseType(cfg.Database.Type)
|
||||
if dialect == "auto" {
|
||||
dialect = effectiveAutoDatabaseType(cfg)
|
||||
}
|
||||
if dialect != "sqlite" {
|
||||
return nil
|
||||
}
|
||||
sqliteRestoreMu.Lock()
|
||||
defer sqliteRestoreMu.Unlock()
|
||||
dbPath, err := sqliteRestorePath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pending := dbPath + ".restore-pending"
|
||||
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSQLiteRestore(ctx, pending); err != nil {
|
||||
return err
|
||||
}
|
||||
previous := ""
|
||||
if _, err := os.Lstat(dbPath); err == nil {
|
||||
// A clean checkpoint prevents an old WAL from being replayed into the
|
||||
// replacement database. A busy checkpoint leaves all files untouched.
|
||||
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var busy, pages, copied int
|
||||
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
|
||||
closeErr := live.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if busy != 0 {
|
||||
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
|
||||
}
|
||||
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous = old.Name()
|
||||
if err := old.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(previous); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(dbPath, previous); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(pending, dbPath); err != nil {
|
||||
if previous != "" {
|
||||
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
|
||||
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if log != nil {
|
||||
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = "sqlite"
|
||||
cfg.Database.DBPath = filepath.Join(root, "live.db")
|
||||
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer live.Close()
|
||||
for _, statement := range []string{
|
||||
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
|
||||
"INSERT INTO settings VALUES ('state', 'backup')",
|
||||
} {
|
||||
if _, err := live.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(root, "backup.db")
|
||||
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value string
|
||||
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
|
||||
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
|
||||
}
|
||||
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
|
||||
t.Fatalf("database could not write after staging: %v", err)
|
||||
}
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
|
||||
t.Fatal("second staging should not replace the pending restore")
|
||||
}
|
||||
if err := os.Remove(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := live.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer restored.Close()
|
||||
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
|
||||
t.Fatalf("restore failed: value=%q err=%v", value, err)
|
||||
}
|
||||
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
|
||||
if err != nil || len(previous) != 1 {
|
||||
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
|
||||
}
|
||||
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer old.Close()
|
||||
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
|
||||
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
|
||||
}
|
||||
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
|
||||
t.Fatalf("repeat startup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.DBPath = filepath.Join(root, "live.db")
|
||||
source := filepath.Join(root, "corrupt.db")
|
||||
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
|
||||
t.Fatal("corrupt backup accepted")
|
||||
}
|
||||
link := filepath.Join(root, "link.db")
|
||||
if err := os.Symlink(source, link); err == nil {
|
||||
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
|
||||
t.Fatal("symlink backup accepted")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed validation left pending data: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Tasks == nil {
|
||||
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
filename := c.Query("filename")
|
||||
path, err := svc.Backup.ResolvePath(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.FileAttachment(path, filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
|
||||
message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
|
||||
if svc.Repo.DB.Dialector.Name() == "sqlite" {
|
||||
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
|
||||
//
|
||||
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
|
||||
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
|
||||
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.EmbyRemote == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
|
||||
return
|
||||
}
|
||||
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
|
||||
}
|
||||
}
|
||||
|
||||
type reorderEmbyMountsReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
|
||||
// 只影响目标账号,且配置保留(不是删除)。
|
||||
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := t.Context()
|
||||
|
||||
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
|
||||
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
|
||||
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
|
||||
for _, m := range []*model.EmbyMount{m1, m2, other} {
|
||||
if err := repos.EmbyMount.Create(ctx, m); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
|
||||
|
||||
call := func(body map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
w := call(map[string]any{"enabled": false})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Affected int64 `json:"affected"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Affected != 2 {
|
||||
t.Fatalf("expected ok with affected=2, got %+v", resp)
|
||||
}
|
||||
|
||||
rows, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
|
||||
if row.Enabled != want {
|
||||
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新启用:挂载记录仍在,只是开关回 true。
|
||||
w = call(map[string]any{"enabled": true})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
rows, err = repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.Enabled {
|
||||
t.Fatalf("mount %s should be enabled again", row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
|
||||
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
|
||||
registerPublicAuthRoutes(api, svc, log)
|
||||
|
||||
registerMobileRoutes(api, svc, log)
|
||||
|
||||
registerAuthenticatedRoutes(api, cfg, svc)
|
||||
|
||||
registerAdminRoutes(api, cfg, svc)
|
||||
|
||||
+148
-109
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -556,19 +557,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||
items, total, err := svc.Media.ListMediaVisibleSortedPage(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters, sortSpec, history)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
|
||||
items := service.PaginateMediaItems(grouped, page, size)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": len(grouped),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
@@ -656,6 +655,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
|
||||
if len(items) == 0 {
|
||||
return []T{}
|
||||
}
|
||||
if page-1 > len(items)/size {
|
||||
return []T{}
|
||||
}
|
||||
start := (page - 1) * size
|
||||
if start >= len(items) {
|
||||
return []T{}
|
||||
@@ -670,127 +672,164 @@ func paginateSlice[T any](items []T, page, size int) []T {
|
||||
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
q := c.Query("q")
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
||||
|
||||
fetchRemote := func(limit int) []model.Media {
|
||||
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
|
||||
return nil
|
||||
}
|
||||
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
|
||||
return remoteItems
|
||||
filter := service.MediaSearchFilter{
|
||||
LibraryID: strings.TrimSpace(c.Query("library_id")),
|
||||
YearMin: boundedSearchYear(c.Query("year_min")),
|
||||
YearMax: boundedSearchYear(c.Query("year_max")),
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
for _, genre := range c.QueryArray("genre") {
|
||||
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
|
||||
filter.Genres = append(filter.Genres, genre)
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
if !groupVersions {
|
||||
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(size * 2)
|
||||
all := append(localItems, remoteItems...)
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(size * 2)
|
||||
all := append(localItems, remoteItems...)
|
||||
grouped := service.GroupMediaVersions(all)
|
||||
paged := service.PaginateMediaItems(grouped, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(grouped),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
|
||||
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
if size < 1 {
|
||||
size = 50
|
||||
} else if size > 2000 {
|
||||
size = 2000
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
if limit < 1 {
|
||||
limit = 50
|
||||
} else if limit > 50000 {
|
||||
limit = 50000
|
||||
}
|
||||
if !groupVersions {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(limit)
|
||||
all := append(localItems, remoteItems...)
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
if all == nil {
|
||||
all = []model.Media{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
paginated := c.Query("page") != "" || c.Query("page_size") != ""
|
||||
sortBy := normalizeMediaSearchSort(c.Query("sort"))
|
||||
// Sorting and grouping must operate on the same candidate set before
|
||||
// either a page size or the caller's output limit is applied.
|
||||
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(limit)
|
||||
all := append(localItems, remoteItems...)
|
||||
grouped := service.GroupMediaVersions(all)
|
||||
items := service.FirstMediaItems(grouped, limit)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
if svc.EmbyRemote != nil && query != "" && (filter.LibraryID == "" || service.IsEmbyRemoteID(filter.LibraryID)) {
|
||||
remote, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, query, 50000, visibility)
|
||||
for _, item := range remote {
|
||||
if remoteSearchMatchesFilter(item, filter) {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
items = service.GroupMediaSeriesItems(items)
|
||||
sortSearchMedia(items, sortBy)
|
||||
writeMediaSearchResults(c, items, page, size, limit, paginated)
|
||||
return
|
||||
}
|
||||
if c.DefaultQuery("group_versions", "1") != "0" {
|
||||
grouped := service.GroupMediaVersions(items)
|
||||
if sortBy != "" {
|
||||
sort.SliceStable(grouped, func(i, j int) bool {
|
||||
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
|
||||
})
|
||||
}
|
||||
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
|
||||
return
|
||||
}
|
||||
sortSearchMedia(items, sortBy)
|
||||
writeMediaSearchResults(c, items, page, size, limit, paginated)
|
||||
}
|
||||
}
|
||||
|
||||
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
|
||||
if items == nil {
|
||||
items = []T{}
|
||||
}
|
||||
if paginated {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paginateSlice(items, page, size),
|
||||
"total": len(items), "page": page, "page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
|
||||
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
|
||||
return false
|
||||
}
|
||||
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
|
||||
return false
|
||||
}
|
||||
if len(filter.Genres) == 0 {
|
||||
return true
|
||||
}
|
||||
normalize := func(value string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
|
||||
}
|
||||
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
|
||||
for _, wanted := range filter.Genres {
|
||||
if normalize(genre) == normalize(wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeMediaSearchSort(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case "title_asc", "year_desc", "rating_desc":
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sortSearchMedia(items []model.Media, sortBy string) {
|
||||
if sortBy == "" {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return mediaSearchSortLess(items[i], items[j], sortBy)
|
||||
})
|
||||
}
|
||||
|
||||
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
|
||||
switch sortBy {
|
||||
case "title_asc":
|
||||
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
case "year_desc":
|
||||
if a.Year != b.Year {
|
||||
return a.Year > b.Year
|
||||
}
|
||||
case "rating_desc":
|
||||
if a.Rating != b.Rating {
|
||||
return a.Rating > b.Rating
|
||||
}
|
||||
}
|
||||
if !a.CreatedAt.Equal(b.CreatedAt) {
|
||||
return a.CreatedAt.After(b.CreatedAt)
|
||||
}
|
||||
return a.ID < b.ID
|
||||
}
|
||||
|
||||
func boundedSearchYear(value string) int {
|
||||
year, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || year < 1870 || year > 2200 {
|
||||
return 0
|
||||
}
|
||||
return year
|
||||
}
|
||||
|
||||
func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func mediaQualityHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
} else if page > 1000000 {
|
||||
page = 1000000
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
} else if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
issue := c.DefaultQuery("issue", "unmatched")
|
||||
if issue != "unmatched" && issue != "poster" && issue != "missing" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
|
||||
return
|
||||
}
|
||||
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := make([]model.Media, 2001)
|
||||
for i := range rows {
|
||||
rows[i] = model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
|
||||
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
|
||||
}
|
||||
}
|
||||
rows[2000].Year = 2025
|
||||
rows[2000].Genres = "Drama"
|
||||
if err := db.CreateInBatches(rows, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
|
||||
cases := []struct {
|
||||
name, query, expectedID string
|
||||
count, total, page, size int
|
||||
}{
|
||||
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
|
||||
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
|
||||
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
|
||||
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
|
||||
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
|
||||
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
ctx.Set(middleware.CtxUserID, "admin")
|
||||
ctx.Set(middleware.CtxUserRole, "admin")
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
|
||||
searchMediaHandler(svc)(ctx)
|
||||
var response struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"page_size"`
|
||||
}
|
||||
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
|
||||
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
|
||||
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
|
||||
}
|
||||
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
|
||||
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,7 +28,8 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{{
|
||||
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
|
||||
"ProviderIds": map[string]string{"Tmdb": "61709"},
|
||||
"ProviderIds": map[string]string{"Tmdb": "61709"},
|
||||
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
@@ -108,4 +110,9 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
|
||||
}
|
||||
seen[item.DisplayLibraryID] = true
|
||||
}
|
||||
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
|
||||
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
|
||||
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
|
||||
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Package handler — 移动端 /api/v1 认证端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
|
||||
// 使用的偏好和内部状态带进新契约。
|
||||
type mobileUserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ForcePasswordReset bool `json:"force_password_reset"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func toMobileUserPayload(u *model.User) *mobileUserPayload {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &mobileUserPayload{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Role: u.Role,
|
||||
Tier: u.Tier,
|
||||
IsActive: u.IsActive,
|
||||
ForcePasswordReset: u.ForcePasswordReset,
|
||||
ExpiresAt: u.ExpiredAt,
|
||||
}
|
||||
}
|
||||
|
||||
type mobileSessionPayload struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &mobileSessionPayload{
|
||||
AuthSessionID: s.ID,
|
||||
DeviceID: s.DeviceID,
|
||||
DeviceName: s.DeviceName,
|
||||
ClientName: s.ClientName,
|
||||
ClientVersion: s.ClientVersion,
|
||||
CreatedAt: s.CreatedAt,
|
||||
LastUsedAt: s.LastUsedAt,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
type mobileLoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ClientName string `json:"client_name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
}
|
||||
|
||||
type mobileRefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
|
||||
// 并统一输出 v1 错误契约。
|
||||
func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := middleware.ExtractToken(c)
|
||||
if raw == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"access token required", false, 0)
|
||||
return
|
||||
}
|
||||
claims, err := svc.Token.ValidateAccessToken(raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||
"access token expired", false, 0)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"invalid access token", false, 0)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(claims.Purpose) != "" {
|
||||
writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
|
||||
"token scope denied for this endpoint", false, 0)
|
||||
return
|
||||
}
|
||||
sessionID := strings.TrimSpace(claims.Sid)
|
||||
if sessionID == "" {
|
||||
// v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"auth session required; sign in again", false, 0)
|
||||
return
|
||||
}
|
||||
session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
|
||||
if err != nil {
|
||||
if service.IsTransientDatabaseLock(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"auth session check temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||
"auth session revoked or expired", false, 0)
|
||||
return
|
||||
}
|
||||
c.Set(middleware.CtxUserID, claims.UserID)
|
||||
c.Set(middleware.CtxUserRole, claims.Role)
|
||||
c.Set(middleware.CtxUserTier, claims.Tier)
|
||||
c.Set(middleware.CtxAuthSessionID, sessionID)
|
||||
c.Set(mobileCtxAuthSession, session)
|
||||
// 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
|
||||
// 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
|
||||
if time.Since(session.LastUsedAt) > time.Minute {
|
||||
svc.AuthSessions.Touch(c.Request.Context(), sessionID)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
|
||||
// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
|
||||
// 被会话校验拦成 401。
|
||||
func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := middleware.ExtractToken(c)
|
||||
if raw == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"access token required", false, 0)
|
||||
return
|
||||
}
|
||||
claims, err := svc.Token.ValidateAccessToken(raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||
"access token expired", false, 0)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"invalid access token", false, 0)
|
||||
return
|
||||
}
|
||||
sessionID := strings.TrimSpace(claims.Sid)
|
||||
if sessionID == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"auth session required; sign in again", false, 0)
|
||||
return
|
||||
}
|
||||
c.Set(middleware.CtxUserID, claims.UserID)
|
||||
c.Set(middleware.CtxAuthSessionID, sessionID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req mobileLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"username, password and device_id are required", false, 0)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"invalid username or device_id", false, 0)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
|
||||
deviceName := strings.TrimSpace(req.DeviceName)
|
||||
clientName := strings.TrimSpace(req.ClientName)
|
||||
if clientName == "" {
|
||||
clientName = "MeBox Android"
|
||||
}
|
||||
session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
|
||||
UserID: user.ID,
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: deviceName,
|
||||
ClientName: clientName,
|
||||
ClientVersion: req.ClientVersion,
|
||||
})
|
||||
if err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"cannot create auth session right now", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"invalid device information", false, 0)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
// 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
|
||||
_ = svc.AuthSessions.Revoke(ctx, session.ID)
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
if svc.Device != nil {
|
||||
svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
|
||||
}
|
||||
if svc.Audit != nil {
|
||||
svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auth_session_id": session.ID,
|
||||
"user": toMobileUserPayload(user),
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req mobileRefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"refresh_token required", false, 0)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
|
||||
if err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"token store temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
// 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
|
||||
// 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
|
||||
// 由 TokenService 判定。
|
||||
if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"refresh token is not bound to a device session; sign in again", false, 0)
|
||||
return
|
||||
}
|
||||
if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"auth session check temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||
"auth session revoked or expired", false, 0)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"user not found", false, 0)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auth_session_id": rt.AuthSessionID,
|
||||
"user": toMobileUserPayload(user),
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
user, err := svc.Repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, svc.Log, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"user not found", false, 0)
|
||||
return
|
||||
}
|
||||
session, _ := c.Get(mobileCtxAuthSession)
|
||||
sessionModel, _ := session.(*model.AuthSession)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auth_session_id": c.GetString(middleware.CtxAuthSessionID),
|
||||
"user": toMobileUserPayload(user),
|
||||
"session": toMobileSessionPayload(sessionModel),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sessionID := c.GetString(middleware.CtxAuthSessionID)
|
||||
if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"cannot revoke session right now", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
|
||||
"logout failed", false, 0)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
// writeMobileAuthError 把 service 层错误映射为稳定错误码。
|
||||
// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
|
||||
func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"invalid username, password or refresh token", false, 0)
|
||||
case errors.Is(err, service.ErrTokenExpired):
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||
"refresh token expired", false, 0)
|
||||
case errors.Is(err, service.ErrTokenRevoked),
|
||||
errors.Is(err, service.ErrAuthSessionRevoked),
|
||||
errors.Is(err, service.ErrAuthSessionExpired),
|
||||
errors.Is(err, service.ErrAuthSessionRequired):
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||
"auth session revoked or expired", false, 0)
|
||||
case errors.Is(err, service.ErrUserInactive):
|
||||
writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
|
||||
"account is disabled", false, 0)
|
||||
case errors.Is(err, service.ErrUserExpired):
|
||||
writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
|
||||
"account has expired", false, 0)
|
||||
case isTransientServiceError(err):
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"service temporarily unavailable, please retry", true, 2)
|
||||
default:
|
||||
if log != nil {
|
||||
log.Warn("mobile auth request failed", zap.Error(err))
|
||||
}
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"service temporarily unavailable, please retry", true, 2)
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientServiceError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if service.IsTransientDatabaseLock(err) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
const mobileTestSecret = "mobile-test-secret"
|
||||
|
||||
func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
|
||||
// shared cache 名字在并行测试间串库。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
|
||||
&model.UserDevice{}, &model.UserPermission{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
|
||||
svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
|
||||
|
||||
router := gin.New()
|
||||
Register(router, cfg, zap.NewNop(), svc)
|
||||
return router, svc
|
||||
}
|
||||
|
||||
func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
|
||||
if err := svc.Repo.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
|
||||
router, _ := newMobileTestEnv(t)
|
||||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
var info service.ServerInfo
|
||||
decodeMobileJSON(t, w, &info)
|
||||
if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
|
||||
t.Fatalf("unexpected server info: %+v", info)
|
||||
}
|
||||
if !info.Capabilities.DeviceSessions {
|
||||
t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
|
||||
}
|
||||
if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
|
||||
t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
|
||||
}
|
||||
if info.MinSupportedAppVersionCode < 1 {
|
||||
t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
|
||||
}
|
||||
|
||||
// 匿名访问不应要求 Authorization,也不应记录 cookie。
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("server-info should not set cookies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileAuthLifecycle(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "alice", "secret-123")
|
||||
|
||||
// 1. 错误密码:401 + 稳定错误码 + request_id。
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "alice", "password": "wrong", "device_id": "device-1",
|
||||
}, map[string]string{"X-Request-ID": "mobile-test-0001"})
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var authErr mobileErrorBody
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
|
||||
t.Fatalf("unexpected auth error: %+v", authErr)
|
||||
}
|
||||
if authErr.Retryable {
|
||||
t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
|
||||
}
|
||||
|
||||
// 2. 缺少 device_id:400 INVALID_ARGUMENT。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "alice", "password": "secret-123",
|
||||
}, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeInvalidArgument {
|
||||
t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
|
||||
}
|
||||
|
||||
// 3. 正常登录。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "alice", "password": "secret-123", "device_id": "device-1",
|
||||
"device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
User mobileUserPayload `json:"user"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &loginResp)
|
||||
if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
|
||||
t.Fatalf("incomplete login response: %+v", loginResp)
|
||||
}
|
||||
if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
|
||||
t.Fatalf("unexpected user payload: %+v", loginResp.User)
|
||||
}
|
||||
claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("validate access token: %v", err)
|
||||
}
|
||||
if claims.Sid != loginResp.AuthSessionID {
|
||||
t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
|
||||
}
|
||||
rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||||
if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
|
||||
t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
|
||||
}
|
||||
|
||||
// 4. 会话读取。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var sessionResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
User mobileUserPayload `json:"user"`
|
||||
Session *mobileSessionPayload `json:"session"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &sessionResp)
|
||||
if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
|
||||
t.Fatalf("unexpected session response: %+v", sessionResp)
|
||||
}
|
||||
if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
|
||||
t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
|
||||
}
|
||||
|
||||
// 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||
"refresh_token": loginResp.Tokens.RefreshToken,
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var refreshResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &refreshResp)
|
||||
if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
|
||||
t.Fatalf("unexpected refresh response: %+v", refreshResp)
|
||||
}
|
||||
if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
|
||||
t.Fatal("refresh token should rotate")
|
||||
}
|
||||
oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||||
if err != nil || oldRT == nil || !oldRT.Revoked {
|
||||
t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
|
||||
}
|
||||
|
||||
// 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 7. 退出:成功且可重复。
|
||||
for i := 0; i < 2; i++ {
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeSessionRevoked {
|
||||
t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||||
}
|
||||
|
||||
// 9. 被踢后的未过期 JWT 不能绕过旧 API。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 10. 刷新令牌随会话一起撤销。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||
"refresh_token": refreshResp.Tokens.RefreshToken,
|
||||
}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeSessionRevoked {
|
||||
t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "legacy", "secret-123")
|
||||
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
|
||||
t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
|
||||
}
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body mobileErrorBody
|
||||
decodeMobileJSON(t, w, &body)
|
||||
if body.Code != mobileCodeInvalidCredentials {
|
||||
t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||||
}
|
||||
|
||||
// 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "bob", "secret-123")
|
||||
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||
"refresh_token": pair.RefreshToken,
|
||||
}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body mobileErrorBody
|
||||
decodeMobileJSON(t, w, &body)
|
||||
if body.Code != mobileCodeInvalidCredentials {
|
||||
t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileDeviceKickRevokesSession(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "carol", "secret-123")
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "carol", "password": "secret-123", "device_id": "device-kick",
|
||||
"device_name": "Kick Me", "client_name": "MeBox Android",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &loginResp)
|
||||
|
||||
if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
|
||||
t.Fatalf("kick device: %v", err)
|
||||
}
|
||||
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||||
if err != nil || session == nil || !session.Revoked {
|
||||
t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
|
||||
}
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
seedMobileUser(t, svc, "erin", "secret-123")
|
||||
|
||||
login := func(deviceID string) string {
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "erin", "password": "secret-123", "device_id": deviceID,
|
||||
"device_name": deviceID, "client_name": "MeBox Android",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &resp)
|
||||
return resp.Tokens.AccessToken
|
||||
}
|
||||
|
||||
tokenA := login("device-a")
|
||||
tokenB := login("device-b")
|
||||
|
||||
if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
|
||||
t.Fatalf("kick device a: %v", err)
|
||||
}
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func svcUserID(t *testing.T, svc *service.Container, username string) string {
|
||||
t.Helper()
|
||||
u, err := svc.Repo.User.FindByUsername(t.Context(), username)
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("find user %s: %v", username, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
seedMobileUser(t, svc, "dave", "secret-123")
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "dave", "password": "secret-123", "device_id": "device-default",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &loginResp)
|
||||
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||||
if err != nil || session == nil {
|
||||
t.Fatalf("load session: %v", err)
|
||||
}
|
||||
if session.ClientName != "MeBox Android" {
|
||||
t.Fatalf("client_name default = %q", session.ClientName)
|
||||
}
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
|
||||
}
|
||||
if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
|
||||
t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
)
|
||||
|
||||
// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
|
||||
const (
|
||||
mobileCodeInvalidArgument = "INVALID_ARGUMENT"
|
||||
mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
|
||||
mobileCodeTokenExpired = "TOKEN_EXPIRED"
|
||||
mobileCodeSessionRevoked = "SESSION_REVOKED"
|
||||
mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
|
||||
mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
|
||||
mobileCodePermissionDenied = "PERMISSION_DENIED"
|
||||
mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
|
||||
mobileCodeRateLimited = "RATE_LIMITED"
|
||||
mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
|
||||
mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
|
||||
mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
|
||||
mobileCodeResourceExpired = "RESOURCE_EXPIRED"
|
||||
mobileCodeProgressConflict = "PROGRESS_CONFLICT"
|
||||
mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
const (
|
||||
mobileCtxRequestID = "mobile_request_id"
|
||||
mobileCtxAuthSession = "mobile_auth_session"
|
||||
mobileRequestIDHeader = "X-Request-ID"
|
||||
)
|
||||
|
||||
// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
|
||||
// code 分流,不需要再判断后端历史包装层。
|
||||
type mobileErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryAfterSec int `json:"retry_after_sec,omitempty"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
|
||||
// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
|
||||
// 重新生成;该值仅用于诊断,绝不参与鉴权。
|
||||
func mobileRequestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
|
||||
if !validRequestID(id) {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
c.Set(mobileCtxRequestID, id)
|
||||
c.Header(mobileRequestIDHeader, id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func validRequestID(id string) bool {
|
||||
if len(id) < 8 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if r > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func currentMobileRequestID(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := c.Get(mobileCtxRequestID); ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
|
||||
// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
|
||||
func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
|
||||
requestID := currentMobileRequestID(c)
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
c.Header(mobileRequestIDHeader, requestID)
|
||||
}
|
||||
if retryAfterSec > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfterSec))
|
||||
}
|
||||
c.AbortWithStatusJSON(status, mobileErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
Retryable: retryable,
|
||||
RetryAfterSec: retryAfterSec,
|
||||
})
|
||||
}
|
||||
|
||||
// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
|
||||
func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !limiter.Allow(c.ClientIP()) {
|
||||
writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
|
||||
"too many requests, please try again later", true, 60)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。
|
||||
//
|
||||
// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体
|
||||
// 或书源业务逻辑。
|
||||
func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
|
||||
if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil {
|
||||
// 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。
|
||||
return
|
||||
}
|
||||
wireAuthSessionValidator(svc)
|
||||
|
||||
v1 := api.Group("/v1")
|
||||
v1.Use(mobileRequestIDMiddleware())
|
||||
|
||||
v1.GET("/server-info", mobileServerInfoHandler(svc, log))
|
||||
|
||||
authLimiter := middleware.NewRateLimiter(30, time.Minute)
|
||||
v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log))
|
||||
v1.POST("/auth/refresh", mobileRefreshHandler(svc, log))
|
||||
|
||||
authed := v1.Group("/auth")
|
||||
{
|
||||
authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc))
|
||||
authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc))
|
||||
}
|
||||
}
|
||||
|
||||
// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。
|
||||
// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。
|
||||
func wireAuthSessionValidator(svc *service.Container) {
|
||||
middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||
if svc == nil || svc.AuthSessions == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := svc.AuthSessions.Validate(ctx, sessionID, userID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthSessionRevoked) ||
|
||||
errors.Is(err, service.ErrAuthSessionExpired) ||
|
||||
errors.Is(err, service.ErrAuthSessionRequired) {
|
||||
return middleware.ErrSessionRevoked
|
||||
}
|
||||
return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err)
|
||||
})
|
||||
}
|
||||
|
||||
func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc))
|
||||
if err != nil {
|
||||
if log != nil {
|
||||
log.Warn("mobile server info failed", zap.Error(err))
|
||||
}
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"server identity temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
// 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,53 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// 起播路径原本还要各发一次 /playback/{id}/resume 和
|
||||
// /media/{id}/subtitles。把这两份数据并进播放地址响应后,移动端起播
|
||||
// 只剩一个请求 —— 跨境链路上每多一条连接就多一次 DNS/TLS 建连,
|
||||
// 实测单次建连可到数秒。老客户端不读这两个字段,行为不变。
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
resumePayload := gin.H{"position_ms": int64(0), "duration_ms": int64(0), "completed": false}
|
||||
if svc.Playback != nil {
|
||||
if row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), m.ID); err == nil && row != nil {
|
||||
resumePayload = gin.H{
|
||||
"position_ms": row.PositionMs,
|
||||
"duration_ms": row.DurationMs,
|
||||
"completed": row.Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
// 直连播放只需要外挂字幕;内嵌字幕由播放器从容器里解。
|
||||
subtitles := []service.SubtitleTrack{}
|
||||
if svc.Subtitle != nil && !(svc.EmbyRemote != nil && service.IsEmbyRemoteID(m.ID)) {
|
||||
if discovered, err := svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), m.ID); err == nil && discovered != nil {
|
||||
subtitles = discovered
|
||||
}
|
||||
}
|
||||
// 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉
|
||||
// /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的
|
||||
// DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。
|
||||
// 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走
|
||||
// stream_url 跳转,行为与改动前一致。
|
||||
directURL, hasDirectURL := "", false
|
||||
if svc.Stream != nil {
|
||||
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
|
||||
// 重新换链,别把同一条死链再发一次。
|
||||
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
|
||||
service.InvalidateMediaLinkCache(m)
|
||||
}
|
||||
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
|
||||
}
|
||||
payload := gin.H{
|
||||
"media": m,
|
||||
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||
"hls_url": hlsURL,
|
||||
})
|
||||
"resume": resumePayload,
|
||||
"subtitles": subtitles,
|
||||
}
|
||||
if hasDirectURL {
|
||||
payload["direct_url"] = directURL
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
)
|
||||
|
||||
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
|
||||
@@ -435,6 +436,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
var payload struct {
|
||||
StreamURL string `json:"stream_url"`
|
||||
HlsURL string `json:"hls_url"`
|
||||
// 移动端起播只打这一个接口:resume / subtitles 必须始终存在,
|
||||
// 哪怕是默认值,否则客户端会退回额外两个请求。
|
||||
Resume *struct{ PositionMs int64 `json:"position_ms"` } `json:"resume"`
|
||||
Subtitles *[]struct{} `json:"subtitles"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
@@ -445,6 +450,66 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
if payload.Resume == nil {
|
||||
t.Fatalf("playback info must always carry resume (got body=%s)", w.Body.String())
|
||||
}
|
||||
if payload.Subtitles == nil {
|
||||
t.Fatalf("playback info must always carry subtitles (got body=%s)", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能
|
||||
// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。
|
||||
func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
|
||||
var gotUA string
|
||||
svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) {
|
||||
gotUA = userAgent
|
||||
return &service.StrmPlayResult{
|
||||
RedirectURL: direct,
|
||||
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
|
||||
}, nil
|
||||
})
|
||||
if err := svc.Repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-direct"},
|
||||
LibraryID: "lib-1",
|
||||
Title: "Direct",
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
DurationSec: 3600,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
DirectURL string `json:"direct_url"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if payload.DirectURL != direct {
|
||||
t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") {
|
||||
t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL)
|
||||
}
|
||||
// 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。
|
||||
if gotUA != "MeBox-Android/0.1.0" {
|
||||
t.Fatalf("resolver UA = %q, want the client UA", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||
|
||||
@@ -149,10 +149,10 @@ func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -13,6 +13,8 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
|
||||
registerAdminUserRoutes(admin, svc)
|
||||
admin.GET("/tasks", backgroundTasksHandler(svc))
|
||||
admin.GET("/quality", mediaQualityHandler(svc))
|
||||
registerAdminPermissionRoutes(admin, svc)
|
||||
registerAdminSystemRoutes(admin, svc)
|
||||
registerAdminBackupRoutes(admin, svc)
|
||||
@@ -50,6 +52,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
|
||||
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
|
||||
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
|
||||
admin.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
|
||||
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
|
||||
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
|
||||
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
|
||||
@@ -142,6 +145,7 @@ func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/backups", listBackupsHandler(svc))
|
||||
admin.POST("/backups", createBackupHandler(svc))
|
||||
admin.GET("/backups/download", downloadBackupHandler(svc))
|
||||
admin.DELETE("/backups", deleteBackupHandler(svc))
|
||||
admin.POST("/backups/restore", restoreBackupHandler(svc))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
}
|
||||
|
||||
func registerAuthedRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
// These streams carry operator tasks, including filesystem paths and scan
|
||||
// errors. Their payloads are not scoped to an individual user's libraries.
|
||||
authed.GET("/ws", middleware.AdminRequired(), wsHandler(svc))
|
||||
authed.GET("/events", middleware.AdminRequired(), sseHandler(svc))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Package handler — WebSocket endpoint.
|
||||
//
|
||||
// Clients connect to /api/ws?token=... (the token is the same JWT used for
|
||||
// REST calls). The first message they send is a JSON {"action":"subscribe",
|
||||
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
|
||||
// arrive as {"topic":"...","payload":{...}}.
|
||||
// Administrators connect to /api/ws?token=... with their REST access token.
|
||||
// Operator events arrive as {"topic":"...","payload":{...}}.
|
||||
package handler
|
||||
|
||||
import (
|
||||
@@ -52,8 +50,11 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
sub := svc.WSHub.Subscribe(id, nil)
|
||||
defer svc.WSHub.Unsubscribe(id)
|
||||
|
||||
// Reader: accept ping + subscription updates.
|
||||
// Reading processes WebSocket control frames and detects disconnects.
|
||||
done := make(chan struct{})
|
||||
conn.SetReadLimit(64 << 10)
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -67,6 +68,8 @@ func wsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case ev, ok := <-sub.Out:
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func realtimeTestRouter(hub *service.Hub) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
authed := router.Group("/api")
|
||||
authed.Use(middleware.AuthRequired("realtime-test"))
|
||||
registerAuthedRealtimeRoutes(authed, &service.Container{WSHub: hub})
|
||||
return router
|
||||
}
|
||||
|
||||
func realtimeTestToken(t *testing.T, role string) string {
|
||||
t.Helper()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
|
||||
UserID: "viewer", Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
|
||||
}).SignedString([]byte("realtime-test"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestRealtimeStreamsRejectOrdinaryUsers(t *testing.T) {
|
||||
router := realtimeTestRouter(nil)
|
||||
for _, path := range []string{"/api/ws", "/api/events"} {
|
||||
for _, role := range []string{"", "user"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
want := http.StatusUnauthorized
|
||||
if role != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+realtimeTestToken(t, role))
|
||||
want = http.StatusForbidden
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("%s role %q: status %d, want %d", path, role, recorder.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWebSocketReceivesTaskEvents(t *testing.T) {
|
||||
hub := service.NewHub(zap.NewNop())
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
server := httptest.NewServer(realtimeTestRouter(hub))
|
||||
defer server.Close()
|
||||
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/ws?token=" + realtimeTestToken(t, "admin")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
hub.Publish("task", service.BackgroundTask{ID: "task-1", SourcePath: "/media/private"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var event service.Event
|
||||
if err := conn.ReadJSON(&event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Topic != "task" || event.Payload.(map[string]any)["source_path"] != "/media/private" {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type Claims struct {
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
// Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。
|
||||
Sid string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
|
||||
return
|
||||
}
|
||||
// 新协议令牌携带 sid:旧 API 也必须检查会话是否已被撤销,否则
|
||||
// 设备被踢后仍能用未过期的 JWT 访问 /api/*。
|
||||
if sid := strings.TrimSpace(claims.Sid); sid != "" {
|
||||
if err := ValidateAuthSession(c.Request.Context(), sid, claims.UserID); err != nil {
|
||||
if errors.Is(err, ErrSessionUnavailable) {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": 50303, "message": "auth session unavailable"})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40104, "message": "auth session revoked"})
|
||||
return
|
||||
}
|
||||
c.Set(CtxAuthSessionID, sid)
|
||||
}
|
||||
syncAccessTokenCookie(c, raw, claims)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUserRole, claims.Role)
|
||||
@@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。
|
||||
func ExtractToken(c *gin.Context) string {
|
||||
return extractToken(c)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware
|
||||
// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。
|
||||
var (
|
||||
// ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。
|
||||
ErrSessionRevoked = errors.New("auth session revoked")
|
||||
// ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。
|
||||
ErrSessionUnavailable = errors.New("auth session unavailable")
|
||||
)
|
||||
|
||||
// SessionValidator 校验认证会话是否仍属于该用户。
|
||||
type SessionValidator func(ctx context.Context, sessionID, userID string) error
|
||||
|
||||
var (
|
||||
sessionValidatorMu sync.RWMutex
|
||||
sessionValidator SessionValidator
|
||||
)
|
||||
|
||||
// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。
|
||||
func SetSessionValidator(fn SessionValidator) {
|
||||
sessionValidatorMu.Lock()
|
||||
defer sessionValidatorMu.Unlock()
|
||||
sessionValidator = fn
|
||||
}
|
||||
|
||||
// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行,
|
||||
// 以保持旧测试和嵌入式调用的兼容行为。
|
||||
func ValidateAuthSession(ctx context.Context, sessionID, userID string) error {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
sessionValidatorMu.RLock()
|
||||
fn := sessionValidator
|
||||
sessionValidatorMu.RUnlock()
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(ctx, sessionID, userID)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const (
|
||||
CtxUserTier = "ctx_user_tier"
|
||||
CtxTokenPurpose = "ctx_token_purpose"
|
||||
CtxTokenMediaID = "ctx_token_media_id"
|
||||
// CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。
|
||||
CtxAuthSessionID = "ctx_auth_session_id"
|
||||
|
||||
// AccessTokenCookieName carries the web access token for browser-managed
|
||||
// resource requests such as <img>, which cannot attach Authorization.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthSession 是可撤销的设备认证会话。
|
||||
//
|
||||
// 会话与刷新令牌的关系是 1:N:一次登录创建一个会话,之后每次轮换刷新
|
||||
// 令牌都继续归属同一会话。访问令牌通过 JWT 的 sid 字段引用会话,因此
|
||||
// 服务端可以在 JWT 尚未过期时立即撤销某台设备的访问能力。
|
||||
//
|
||||
// 旧客户端没有会话关联(sid 为空),继续走既有兼容路径;本模型不强制
|
||||
// 把历史刷新令牌绑定到任何设备。
|
||||
type AuthSession struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
DeviceID string `gorm:"index:idx_auth_session_device,priority:1;size:128;not null" json:"device_id"`
|
||||
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
|
||||
ClientName string `gorm:"size:64" json:"client_name,omitempty"`
|
||||
ClientVersion string `gorm:"size:64" json:"client_version,omitempty"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID 主键。
|
||||
func (s *AuthSession) BeforeCreate(_ *gorm.DB) error {
|
||||
if s.ID == "" {
|
||||
s.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExpired 判断会话是否超过最长有效期。
|
||||
func (s *AuthSession) IsExpired() bool {
|
||||
return s != nil && !s.ExpiresAt.IsZero() && time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid 判断会话当前是否可继续使用。
|
||||
func (s *AuthSession) IsValid() bool {
|
||||
return s != nil && !s.Revoked && !s.IsExpired()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// BackgroundTaskRecord persists the operator-facing lifecycle of scan,
|
||||
// organize, and other long-running application tasks.
|
||||
type BackgroundTaskRecord struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
Kind string `gorm:"index;size:32;not null" json:"kind"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Status string `gorm:"index;size:24;not null" json:"status"`
|
||||
Stage string `gorm:"size:64" json:"stage"`
|
||||
SourcePath string `gorm:"size:2048" json:"source_path"`
|
||||
DestPath string `gorm:"size:2048" json:"dest_path"`
|
||||
Message string `gorm:"type:text" json:"message"`
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
Details string `gorm:"type:text" json:"details"`
|
||||
Metrics string `gorm:"type:text" json:"metrics"`
|
||||
Items string `gorm:"type:text" json:"items"`
|
||||
StartedAt time.Time `gorm:"index" json:"started_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
|
||||
type Library struct {
|
||||
Base
|
||||
@@ -26,12 +28,15 @@ type LibraryRoot struct {
|
||||
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
|
||||
type Media struct {
|
||||
Base
|
||||
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
|
||||
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
|
||||
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
||||
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
|
||||
AvailabilityState string `gorm:"index;size:16;not null;default:available" json:"availability_state"`
|
||||
MissingSince *time.Time `json:"missing_since,omitempty"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
|
||||
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
|
||||
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
||||
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
|
||||
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
|
||||
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
|
||||
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
|
||||
|
||||
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
|
||||
&APIConfig{},
|
||||
&UserPermission{},
|
||||
&RefreshToken{},
|
||||
&AuthSession{},
|
||||
&PlayProfile{},
|
||||
&RegistrationCode{},
|
||||
&SignIn{},
|
||||
@@ -60,6 +61,7 @@ func AllModels() []interface{} {
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&BackgroundTaskRecord{},
|
||||
&EmbyMount{},
|
||||
&ReaderBookSource{},
|
||||
&ReaderSourceState{},
|
||||
|
||||
@@ -11,12 +11,14 @@ import (
|
||||
// RefreshToken 用于双令牌认证机制中的刷新令牌。
|
||||
// 存储时使用 SHA256 哈希,原始令牌不存储。
|
||||
type RefreshToken struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
// AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。
|
||||
AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID。
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// AuthSessionRepository persists model.AuthSession records.
|
||||
type AuthSessionRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new authentication session.
|
||||
func (r *AuthSessionRepository) Create(ctx context.Context, s *model.AuthSession) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Create(s).Error
|
||||
})
|
||||
}
|
||||
|
||||
// FindByID returns the session or (nil, nil) when absent.
|
||||
func (r *AuthSessionRepository) FindByID(ctx context.Context, id string) (*model.AuthSession, error) {
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var s model.AuthSession
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
s = model.AuthSession{}
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// TouchLastUsed 更新会话最近使用时间。
|
||||
func (r *AuthSessionRepository) TouchLastUsed(ctx context.Context, id string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("id = ? AND revoked = ?", id, false).
|
||||
Update("last_used_at", at).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke 撤销单个会话(幂等)。
|
||||
func (r *AuthSessionRepository) Revoke(ctx context.Context, id string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("id = ? AND revoked = ?", id, false).
|
||||
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeByUser 撤销某用户的全部会话(幂等)。
|
||||
func (r *AuthSessionRepository) RevokeByUser(ctx context.Context, userID string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("user_id = ? AND revoked = ?", userID, false).
|
||||
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeByDevice 撤销某用户某设备关联的全部会话(幂等)。
|
||||
func (r *AuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("user_id = ? AND device_id = ? AND revoked = ?", userID, deviceID, false).
|
||||
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListActiveByUser 返回某用户当前有效(未撤销、未过期)的会话。
|
||||
func (r *AuthSessionRepository) ListActiveByUser(ctx context.Context, userID string) ([]model.AuthSession, error) {
|
||||
var rows []model.AuthSession
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
rows = nil
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
|
||||
Order("last_used_at desc, created_at desc").
|
||||
Find(&rows).Error
|
||||
})
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListByDevice 返回某用户某设备的全部会话,用于踢出设备时逐个撤销。
|
||||
func (r *AuthSessionRepository) ListByDevice(ctx context.Context, userID, deviceID string) ([]model.AuthSession, error) {
|
||||
var rows []model.AuthSession
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
rows = nil
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND device_id = ?", userID, deviceID).
|
||||
Find(&rows).Error
|
||||
})
|
||||
return rows, err
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
|
||||
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
|
||||
var affected int64
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
|
||||
affected = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return affected, err
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
|
||||
|
||||
@@ -178,14 +178,6 @@ func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter
|
||||
return min, max, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = backend.IndexMedia(ctx, []model.Media{media})
|
||||
}
|
||||
|
||||
// FindByID returns the media row or (nil, nil).
|
||||
func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media, error) {
|
||||
var m model.Media
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -96,15 +97,34 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
|
||||
|
||||
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
|
||||
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
|
||||
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
|
||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
unique := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if fresh, err := r.FindByID(ctx, id); err == nil && fresh != nil {
|
||||
r.indexMediaBestEffort(ctx, *fresh)
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
}
|
||||
// Read committed rows and submit one bulk per bounded batch. Keep network
|
||||
// work outside the transaction and below SQLite's parameter limit.
|
||||
const batchSize = 500
|
||||
for start := 0; start < len(unique); start += batchSize {
|
||||
batch := unique[start:min(start+batchSize, len(unique))]
|
||||
rows, err := r.ListMediaByIDs(ctx, batch)
|
||||
if err == nil {
|
||||
err = backend.IndexMedia(ctx, rows)
|
||||
}
|
||||
if err != nil {
|
||||
// The primary write has committed; do not report a failed upsert and
|
||||
// make scanners repeat it. Surface the separate index failure instead.
|
||||
r.db.Logger.Error(ctx, "media saved but search index sync failed for %d items: %v", len(batch), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +164,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
|
||||
existing.Path = m.Path
|
||||
existing.DeletedAt = gorm.DeletedAt{}
|
||||
}
|
||||
if _, ok := updates["availability_state"]; ok {
|
||||
existing.AvailabilityState = "available"
|
||||
existing.MissingSince = nil
|
||||
now := updates["last_seen_at"].(time.Time)
|
||||
existing.LastSeenAt = &now
|
||||
}
|
||||
*m = existing
|
||||
return existing.ID, nil
|
||||
}
|
||||
@@ -197,6 +223,10 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
|
||||
if m.ScrapeStatus == "" {
|
||||
m.ScrapeStatus = "pending"
|
||||
}
|
||||
now := time.Now()
|
||||
m.AvailabilityState = "available"
|
||||
m.MissingSince = nil
|
||||
m.LastSeenAt = &now
|
||||
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
|
||||
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
|
||||
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
|
||||
@@ -246,6 +276,11 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
|
||||
|
||||
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
|
||||
updates := map[string]any{}
|
||||
if existing.AvailabilityState != "available" || existing.MissingSince != nil {
|
||||
updates["availability_state"] = "available"
|
||||
updates["missing_since"] = nil
|
||||
updates["last_seen_at"] = time.Now()
|
||||
}
|
||||
addMediaFileScanUpdates(updates, existing, incoming)
|
||||
addMediaTitleUpdates(updates, existing, incoming)
|
||||
addMediaExternalIDUpdates(updates, existing, incoming)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaSeriesIndexColumns 是解析剧集归组 key 所需的全部列。
|
||||
//
|
||||
// 剧集归组(首页「最近添加」、媒体库卡片)必须看到整库每一行才能算出正确的
|
||||
// key 和集数,因此不能只用 SQL 顶端的几行。但整行扫描会把 overview、genres、
|
||||
// strm_url 这些大文本列一并读进来再丢掉:7k 行规模下驱动层的逐列 bind 占了
|
||||
// 绝大部分耗时(实测 ~60%)。这里只取归组真正用到的列,卡片代表行在选出
|
||||
// 前 N 个剧集后按 id 再补一次完整行。
|
||||
const MediaSeriesIndexColumns = "" +
|
||||
"id, created_at, updated_at, library_id, library_root_id, series_id, " +
|
||||
"title, original_name, path, poster_url, backdrop_url, " +
|
||||
"year, release_date, season_num, episode_num, episode_fraction, " +
|
||||
"tm_db_id, bangumi_id, douban_id, thetvdb_id, " +
|
||||
"width, height, size_bytes, video_codec, audio_codec, container, nsfw"
|
||||
|
||||
// ListMediaSeriesIndex 按 created_at 倒序返回可见媒体的「归组投影」。
|
||||
//
|
||||
// 与 SearchFiltered 的区别:
|
||||
// - 不做 COUNT(*)(调用方不需要总数,7k 行时这一条 SQL 白跑一遍全表);
|
||||
// - 只 SELECT 归组所需列,避免大文本列的解码与内存拷贝。
|
||||
func (r *MediaRepository) ListMediaSeriesIndex(ctx context.Context, limit int, filter MediaQueryFilter) ([]model.Media, error) {
|
||||
var items []model.Media
|
||||
q := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Select(MediaSeriesIndexColumns).
|
||||
Order("created_at desc")
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListMediaByIDs 按 ID 批量取完整行(用于补齐剧集卡片的代表行)。
|
||||
func (r *MediaRepository) ListMediaByIDs(ctx context.Context, ids []string) ([]model.Media, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var items []model.Media
|
||||
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -135,14 +137,113 @@ func (b *OpenSearchMediaBackend) EnsureIndex(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Media) error {
|
||||
const batchSize = 500
|
||||
var failures []error
|
||||
for start := 0; start < len(rows); start += batchSize {
|
||||
if err := b.indexMediaBatch(ctx, rows[start:min(start+batchSize, len(rows))]); err != nil {
|
||||
failures = append(failures, err)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
type openSearchBulkResponse struct {
|
||||
Errors bool `json:"errors"`
|
||||
Items []struct {
|
||||
Index struct {
|
||||
ID string `json:"_id"`
|
||||
Status int `json:"status"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
} `json:"index"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) indexMediaBatch(ctx context.Context, rows []model.Media) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
pending := rows
|
||||
var failures []error
|
||||
const attempts = 3
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.Join(append(failures, err)...)
|
||||
}
|
||||
if attempt > 0 {
|
||||
timer := time.NewTimer(time.Duration(100*(1<<(attempt-1))) * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return errors.Join(append(failures, ctx.Err())...)
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
response, err := b.submitMediaBulk(ctx, pending)
|
||||
if err != nil {
|
||||
if retryableOpenSearchError(err) && attempt < attempts-1 {
|
||||
continue
|
||||
}
|
||||
return errors.Join(append(failures, err)...)
|
||||
}
|
||||
if len(response.Items) != len(pending) {
|
||||
return errors.Join(append(failures, fmt.Errorf("opensearch bulk returned %d item results for %d documents", len(response.Items), len(pending)))...)
|
||||
}
|
||||
var retry []model.Media
|
||||
failed := false
|
||||
for i, item := range response.Items {
|
||||
result := item.Index
|
||||
if result.Status >= 200 && result.Status < 300 && result.Error == nil {
|
||||
continue
|
||||
}
|
||||
failed = true
|
||||
if retryableOpenSearchStatus(result.Status) && attempt < attempts-1 {
|
||||
retry = append(retry, pending[i])
|
||||
continue
|
||||
}
|
||||
errorType := "unknown"
|
||||
if result.Error != nil {
|
||||
errorType = result.Error.Type
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("opensearch bulk document %q failed: status %d (%s)", pending[i].ID, result.Status, errorType))
|
||||
}
|
||||
if response.Errors && !failed {
|
||||
failures = append(failures, errors.New("opensearch bulk reported errors without failed item results"))
|
||||
}
|
||||
if len(retry) == 0 {
|
||||
break
|
||||
}
|
||||
pending = retry
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func retryableOpenSearchStatus(status int) bool {
|
||||
return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500
|
||||
}
|
||||
|
||||
func retryableOpenSearchError(err error) bool {
|
||||
var statusError *openSearchHTTPError
|
||||
if errors.As(err, &statusError) {
|
||||
return retryableOpenSearchStatus(statusError.status)
|
||||
}
|
||||
var networkError net.Error
|
||||
return errors.As(err, &networkError)
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) submitMediaBulk(ctx context.Context, rows []model.Media) (*openSearchBulkResponse, error) {
|
||||
if len(rows) == 0 {
|
||||
return &openSearchBulkResponse{}, nil
|
||||
}
|
||||
var bulk bytes.Buffer
|
||||
enc := json.NewEncoder(&bulk)
|
||||
for _, row := range rows {
|
||||
if err := enc.Encode(map[string]any{"index": map[string]any{"_index": b.index, "_id": row.ID}}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Encode(map[string]any{
|
||||
"id": row.ID,
|
||||
@@ -155,10 +256,24 @@ func (b *OpenSearchMediaBackend) IndexMedia(ctx context.Context, rows []model.Me
|
||||
"deleted": row.DeletedAt.Valid,
|
||||
"created_at": row.CreatedAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", nil)
|
||||
var response openSearchBulkResponse
|
||||
if err := b.do(ctx, http.MethodPost, "/_bulk", &bulk, "application/x-ndjson", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
type openSearchHTTPError struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *openSearchHTTPError) Error() string {
|
||||
return fmt.Sprintf("opensearch %s %s returned %d", e.method, e.path, e.status)
|
||||
}
|
||||
|
||||
func (b *OpenSearchMediaBackend) doJSON(ctx context.Context, method, path string, body any, out any) error {
|
||||
@@ -190,7 +305,7 @@ func (b *OpenSearchMediaBackend) do(ctx context.Context, method, path string, bo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("opensearch %s %s returned %d", method, path, resp.StatusCode)
|
||||
return &openSearchHTTPError{method: method, path: path, status: resp.StatusCode}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
|
||||
@@ -55,3 +66,156 @@ func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) {
|
||||
t.Fatalf("ids=%#v total=%d", ids, total)
|
||||
}
|
||||
}
|
||||
|
||||
func bulkRequestIDs(r *http.Request) []string {
|
||||
var ids []string
|
||||
scanner := bufio.NewScanner(r.Body)
|
||||
for scanner.Scan() {
|
||||
var action struct {
|
||||
Index struct {
|
||||
ID string `json:"_id"`
|
||||
} `json:"index"`
|
||||
}
|
||||
_ = json.Unmarshal(scanner.Bytes(), &action)
|
||||
ids = append(ids, action.Index.ID)
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func writeBulkResults(w http.ResponseWriter, ids []string, statuses map[string]int) {
|
||||
items := make([]any, 0, len(ids))
|
||||
failed := false
|
||||
for _, id := range ids {
|
||||
status := statuses[id]
|
||||
if status == 0 {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
result := map[string]any{"_id": id, "status": status}
|
||||
if status >= 400 {
|
||||
failed = true
|
||||
result["error"] = map[string]any{"type": "test_failure"}
|
||||
}
|
||||
items = append(items, map[string]any{"index": result})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errors": failed, "items": items})
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkRetriesOnlyTransientFailedDocuments(t *testing.T) {
|
||||
var requests [][]string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ids := bulkRequestIDs(r)
|
||||
requests = append(requests, ids)
|
||||
statuses := map[string]int{}
|
||||
if len(requests) == 1 {
|
||||
statuses["retry"] = http.StatusTooManyRequests
|
||||
statuses["invalid"] = http.StatusBadRequest
|
||||
}
|
||||
writeBulkResults(w, ids, statuses)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
rows := []model.Media{{Base: model.Base{ID: "ok"}}, {Base: model.Base{ID: "retry"}}, {Base: model.Base{ID: "invalid"}}}
|
||||
err := backend.IndexMedia(t.Context(), rows)
|
||||
if err == nil || !strings.Contains(err.Error(), `"invalid"`) {
|
||||
t.Fatalf("error = %v, want permanent document failure", err)
|
||||
}
|
||||
if len(requests) != 2 || strings.Join(requests[1], ",") != "retry" {
|
||||
t.Fatalf("requests = %#v; successful/permanent documents must not be retried", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkBoundsRetriesAndRejectsMalformedResponses(t *testing.T) {
|
||||
for _, scenario := range []string{"transient-item", "transient-http", "invalid-http", "missing-results"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
switch scenario {
|
||||
case "transient-item":
|
||||
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusServiceUnavailable})
|
||||
case "transient-http":
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
case "invalid-http":
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case "missing-results":
|
||||
fmt.Fprint(w, `{"errors":false,"items":[]}`)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
if err := backend.IndexMedia(t.Context(), []model.Media{{Base: model.Base{ID: "m1"}}}); err == nil {
|
||||
t.Fatal("failed indexing was reported as successful")
|
||||
}
|
||||
want := 1
|
||||
if strings.HasPrefix(scenario, "transient") {
|
||||
want = 3
|
||||
}
|
||||
if calls != want {
|
||||
t.Fatalf("calls = %d, want %d", calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBulkHonorsCancellationDuringRetry(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
writeBulkResults(w, bulkRequestIDs(r), map[string]int{"m1": http.StatusTooManyRequests})
|
||||
cancel()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
backend := NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL})
|
||||
err := backend.IndexMedia(ctx, []model.Media{{Base: model.Base{ID: "m1"}}})
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("error = %v, calls = %d", err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaUpsertBatchIndexesCommittedRowsTogether(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
ids := bulkRequestIDs(r)
|
||||
if len(ids) != 5 {
|
||||
t.Errorf("bulk contains %d documents, want 5", len(ids))
|
||||
}
|
||||
// With only one DB connection this also detects network work inside the
|
||||
// transaction: a separate read could not observe the committed batch.
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).Model(&model.Media{}).Count(&count).Error; err != nil || count != 5 {
|
||||
t.Errorf("committed rows = %d, error = %v", count, err)
|
||||
}
|
||||
writeBulkResults(w, ids, nil)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
repos.Media.SetSearchBackend(NewOpenSearchMediaBackend(config.SearchConfig{Backend: "opensearch", OpenSearchURL: upstream.URL}))
|
||||
var rows []*model.Media
|
||||
for i := 0; i < 5; i++ {
|
||||
rows = append(rows, &model.Media{LibraryID: "lib", Path: fmt.Sprintf("/media/%d.mkv", i)})
|
||||
}
|
||||
if err := repos.Media.UpsertBatch(ctx, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("index requests = %d, want one bulk", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
|
||||
// user by revoking the oldest non-expired, non-revoked tokens.
|
||||
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
|
||||
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
|
||||
// for a user among tokens that are not bound to a device auth session
|
||||
// (legacy web clients). Device sessions are independent: trimming one
|
||||
// device's token because another device logged in would silently sign the
|
||||
// first device out even though its session is still valid.
|
||||
func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
var tokens []model.RefreshToken
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')",
|
||||
userID, false, time.Now()).
|
||||
Order("created_at desc, id desc").
|
||||
Find(&tokens).Error; err != nil {
|
||||
return err
|
||||
@@ -79,7 +83,7 @@ func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke revokes a specific refresh token.
|
||||
// Revoke 撤销某个刷新令牌。
|
||||
func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
|
||||
@@ -87,6 +91,18 @@ func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeByAuthSessionID 撤销某个认证会话名下的全部刷新令牌。
|
||||
// 设备被踢或主动退出后,旧刷新令牌不能再换出新令牌。
|
||||
func (r *RefreshTokenRepository) RevokeByAuthSessionID(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
|
||||
Where("auth_session_id = ?", sessionID).Update("revoked", true).Error
|
||||
})
|
||||
}
|
||||
|
||||
// HashToken returns the SHA256 hash of a token.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
|
||||
@@ -23,6 +23,7 @@ type Container struct {
|
||||
Log *AccessLogRepository
|
||||
Permission *PermissionRepository
|
||||
RefreshToken *RefreshTokenRepository
|
||||
AuthSession *AuthSessionRepository
|
||||
ApiConfig *ApiConfigRepository
|
||||
PlayProfile *PlayProfileRepository
|
||||
RegCode *RegistrationCodeRepository
|
||||
@@ -56,6 +57,7 @@ func New(db *gorm.DB) *Container {
|
||||
Log: &AccessLogRepository{db: db},
|
||||
Permission: &PermissionRepository{db: db},
|
||||
RefreshToken: &RefreshTokenRepository{db: db},
|
||||
AuthSession: &AuthSessionRepository{db: db},
|
||||
ApiConfig: &ApiConfigRepository{db: db},
|
||||
PlayProfile: &PlayProfileRepository{db: db},
|
||||
RegCode: &RegistrationCodeRepository{db: db},
|
||||
|
||||
@@ -34,6 +34,7 @@ type AuthService struct {
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
sessionSvc *AuthSessionService
|
||||
|
||||
tempPassMu sync.RWMutex
|
||||
tempPasswords map[string]temporaryPasswordEntry
|
||||
@@ -51,6 +52,11 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
|
||||
}
|
||||
}
|
||||
|
||||
// SetAuthSessionService 注入设备会话服务;旧登出路径会同时撤销全部会话。
|
||||
func (s *AuthService) SetAuthSessionService(sessions *AuthSessionService) {
|
||||
s.sessionSvc = sessions
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
@@ -162,6 +168,23 @@ type LoginResponse struct {
|
||||
|
||||
// Login validates credentials and returns the user + a fresh JWT token pair.
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
|
||||
u, err := s.Authenticate(ctx, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 签发令牌对
|
||||
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.touchLoginBestEffort(u.ID)
|
||||
return &LoginResponse{User: u, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
// Authenticate 只校验账号与密码,不签发任何令牌。
|
||||
// 移动端 v1 登录需要先确认账号有效,再创建设备会话并签发带 sid 的令牌;
|
||||
// 分离这一步可以避免先签发一对无会话的令牌再作废。
|
||||
func (s *AuthService) Authenticate(ctx context.Context, username, password string) (*model.User, error) {
|
||||
u, err := s.repo.User.FindByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -180,13 +203,7 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
// 签发令牌对
|
||||
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.touchLoginBestEffort(u.ID)
|
||||
return &LoginResponse{User: u, Tokens: tokens}, nil
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) touchLoginBestEffort(userID string) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package service — 设备认证会话。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 认证会话错误。middleware 与 handler 依据这些错误区分「重新登录」和
|
||||
// 「临时基础设施故障」。
|
||||
var (
|
||||
ErrAuthSessionRequired = errors.New("auth session required")
|
||||
ErrAuthSessionRevoked = errors.New("auth session revoked")
|
||||
ErrAuthSessionExpired = errors.New("auth session expired")
|
||||
)
|
||||
|
||||
// AuthSessionService 管理设备会话的创建、校验和撤销。
|
||||
type AuthSessionService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewAuthSessionService 创建会话服务。
|
||||
func NewAuthSessionService(log *zap.Logger, repo *repository.Container) *AuthSessionService {
|
||||
return &AuthSessionService{log: log, repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateAuthSessionInput 是创建会话所需的最小设备信息。
|
||||
type CreateAuthSessionInput struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
ClientName string
|
||||
ClientVersion string
|
||||
}
|
||||
|
||||
// Create 创建一次登录对应的认证会话。
|
||||
//
|
||||
// 设备 ID 由 App 安装随机生成,不接收 IMEI/MAC 等硬件标识;这里只做长度
|
||||
// 与字符白名单校验,避免任意字符串进入索引和日志。
|
||||
func (s *AuthSessionService) Create(ctx context.Context, in CreateAuthSessionInput) (*model.AuthSession, error) {
|
||||
userID := strings.TrimSpace(in.UserID)
|
||||
deviceID := strings.TrimSpace(in.DeviceID)
|
||||
if userID == "" || deviceID == "" {
|
||||
return nil, errors.New("user id and device id required")
|
||||
}
|
||||
if len(deviceID) > 128 || !validDeviceID(deviceID) {
|
||||
return nil, errors.New("invalid device id")
|
||||
}
|
||||
now := s.currentTime()
|
||||
session := &model.AuthSession{
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: truncateRunes(strings.TrimSpace(in.DeviceName), 128),
|
||||
ClientName: truncateRunes(strings.TrimSpace(in.ClientName), 64),
|
||||
ClientVersion: truncateRunes(strings.TrimSpace(in.ClientVersion), 64),
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now.Add(RefreshTokenDuration),
|
||||
}
|
||||
if err := s.repo.AuthSession.Create(ctx, session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Validate 校验会话仍可用于给定用户。
|
||||
//
|
||||
// 未找到的会话按「已撤销」处理:App 收到 SESSION_REVOKED 后应重新登录,
|
||||
// 而不是无限刷新。数据库错误保持原样返回,由上层映射成可重试的 503。
|
||||
func (s *AuthSessionService) Validate(ctx context.Context, sessionID, userID string) (*model.AuthSession, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, ErrAuthSessionRequired
|
||||
}
|
||||
session, err := s.repo.AuthSession.FindByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session == nil || session.UserID != userID {
|
||||
return nil, ErrAuthSessionRevoked
|
||||
}
|
||||
if session.Revoked {
|
||||
return nil, ErrAuthSessionRevoked
|
||||
}
|
||||
if session.IsExpired() {
|
||||
return nil, ErrAuthSessionExpired
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Touch 更新会话最近使用时间,失败只记日志,不影响请求。
|
||||
func (s *AuthSessionService) Touch(ctx context.Context, sessionID string) {
|
||||
if s == nil || sessionID == "" {
|
||||
return
|
||||
}
|
||||
if err := s.repo.AuthSession.TouchLastUsed(ctx, sessionID, s.currentTime()); err != nil && s.log != nil {
|
||||
s.log.Debug("touch auth session failed", zap.String("session_id", sessionID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke 撤销单个会话及其名下刷新令牌(幂等)。
|
||||
func (s *AuthSessionService) Revoke(ctx context.Context, sessionID string) error {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.AuthSession.Revoke(ctx, sessionID, s.currentTime()); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.RefreshToken.RevokeByAuthSessionID(ctx, sessionID)
|
||||
}
|
||||
|
||||
// RevokeByUser 撤销某用户的全部会话(幂等)。
|
||||
func (s *AuthSessionService) RevokeByUser(ctx context.Context, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
sessions, err := s.repo.AuthSession.ListActiveByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range sessions {
|
||||
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeDevice 撤销某用户某设备的全部会话(幂等)。
|
||||
func (s *AuthSessionService) RevokeDevice(ctx context.Context, userID, deviceID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if userID == "" || deviceID == "" {
|
||||
return nil
|
||||
}
|
||||
sessions, err := s.repo.AuthSession.ListByDevice(ctx, userID, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range sessions {
|
||||
if sessions[i].Revoked {
|
||||
continue
|
||||
}
|
||||
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthSessionService) currentTime() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.now()
|
||||
}
|
||||
|
||||
func validDeviceID(id string) bool {
|
||||
for _, r := range id {
|
||||
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newAuthSessionTestEnv(t *testing.T) (*AuthSessionService, *TokenService, *repository.Container) {
|
||||
t.Helper()
|
||||
db := newServiceTestDB(t, &model.AuthSession{}, &model.RefreshToken{}, &model.User{}, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
sessions := NewAuthSessionService(zap.NewNop(), repos)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
tokens := NewTokenService(cfg, zap.NewNop(), repos)
|
||||
tokens.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||
_, err := sessions.Validate(ctx, sessionID, userID)
|
||||
return err
|
||||
})
|
||||
return sessions, tokens, repos
|
||||
}
|
||||
|
||||
func seedSessionUser(t *testing.T, repos *repository.Container) *model.User {
|
||||
t.Helper()
|
||||
u := &model.User{Username: "mobile", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestAuthSessionLifecycle(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||
UserID: user.ID,
|
||||
DeviceID: "device-abc",
|
||||
DeviceName: "Pixel",
|
||||
ClientName: "MeBox Android",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
if session.ID == "" || session.ExpiresAt.IsZero() {
|
||||
t.Fatal("session missing id or expiry")
|
||||
}
|
||||
if _, err := sessions.Validate(ctx, session.ID, user.ID); err != nil {
|
||||
t.Fatalf("validate active session: %v", err)
|
||||
}
|
||||
if _, err := sessions.Validate(ctx, session.ID, "other-user"); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||
t.Fatalf("cross-user validate err = %v, want ErrAuthSessionRevoked", err)
|
||||
}
|
||||
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue pair: %v", err)
|
||||
}
|
||||
claims, err := tokens.ValidateAccessToken(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("validate access token: %v", err)
|
||||
}
|
||||
if claims.Sid != session.ID {
|
||||
t.Fatalf("access token sid = %q, want %q", claims.Sid, session.ID)
|
||||
}
|
||||
rt, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||
if err != nil || rt == nil {
|
||||
t.Fatalf("refresh token row = %v, err = %v", rt, err)
|
||||
}
|
||||
if rt.AuthSessionID != session.ID {
|
||||
t.Fatalf("refresh token session = %q, want %q", rt.AuthSessionID, session.ID)
|
||||
}
|
||||
|
||||
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||
t.Fatalf("revoke session: %v", err)
|
||||
}
|
||||
if _, err := sessions.Validate(ctx, session.ID, user.ID); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||
t.Fatalf("validate revoked err = %v, want ErrAuthSessionRevoked", err)
|
||||
}
|
||||
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||
if err != nil || rt == nil {
|
||||
t.Fatalf("reload refresh token: %v", err)
|
||||
}
|
||||
if !rt.Revoked {
|
||||
t.Fatal("refresh token should be revoked with the session")
|
||||
}
|
||||
|
||||
// 幂等:重复撤销不报错。
|
||||
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||
t.Fatalf("second revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRejectsRevokedSession(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tokens.Refresh(ctx, pair.RefreshToken); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||
t.Fatalf("refresh revoked session err = %v, want ErrAuthSessionRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshKeepsSessionAssociation(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := tokens.Refresh(ctx, pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
claims, err := tokens.ValidateAccessToken(rotated.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Sid != session.ID {
|
||||
t.Fatalf("rotated access sid = %q, want %q", claims.Sid, session.ID)
|
||||
}
|
||||
// 刷新路径使用 best-effort 落库;轮换结果可能稍后才写入。
|
||||
var rt *model.RefreshToken
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(rotated.RefreshToken))
|
||||
if err == nil && rt != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil || rt == nil {
|
||||
t.Fatalf("rotated refresh row: %v err=%v", rt, err)
|
||||
}
|
||||
if rt.AuthSessionID != session.ID {
|
||||
t.Fatalf("rotated refresh session = %q, want %q", rt.AuthSessionID, session.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
|
||||
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
|
||||
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
|
||||
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
|
||||
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
|
||||
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
// 默认上限是 3,这里故意登录 4 台设备。
|
||||
const devices = 4
|
||||
pairs := make([]*TokenPair, 0, devices)
|
||||
for i := 0; i < devices; i++ {
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||
UserID: user.ID,
|
||||
DeviceID: fmt.Sprintf("device-%02d", i),
|
||||
DeviceName: fmt.Sprintf("Phone %d", i+1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create session %d: %v", i+1, err)
|
||||
}
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue pair %d: %v", i+1, err)
|
||||
}
|
||||
pairs = append(pairs, pair)
|
||||
}
|
||||
|
||||
for i, pair := range pairs {
|
||||
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||
if err != nil {
|
||||
t.Fatalf("load device %d token: %v", i+1, err)
|
||||
}
|
||||
if row == nil {
|
||||
t.Fatalf("device %d refresh token missing", i+1)
|
||||
}
|
||||
if row.Revoked {
|
||||
t.Fatalf("device %d refresh token was revoked by another device", i+1)
|
||||
}
|
||||
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
|
||||
t.Fatalf("device %d refresh failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
ctx := context.Background()
|
||||
|
||||
first := NewServerIdentityService(repos, "MeBox-v1.2.3")
|
||||
id1, err := first.ServerID(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id1 == "" {
|
||||
t.Fatal("empty server id")
|
||||
}
|
||||
|
||||
// 独立服务实例(模拟重启)读到同一个持久身份。
|
||||
second := NewServerIdentityService(repos, "MeBox-v1.2.3")
|
||||
id2, err := second.ServerID(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Fatalf("server id changed across instances: %q != %q", id2, id1)
|
||||
}
|
||||
|
||||
info, err := second.Info(ctx, ServerCapabilities{DeviceSessions: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.ServerID != id1 || info.ServerVersion != "MeBox-v1.2.3" || info.APIVersion != "v1" {
|
||||
t.Fatalf("unexpected info: %+v", info)
|
||||
}
|
||||
if !info.Capabilities.DeviceSessions || info.Capabilities.PlaybackSessions {
|
||||
t.Fatalf("unexpected capabilities: %+v", info.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSessionExpiry(t *testing.T) {
|
||||
sessions, _, repos := newAuthSessionTestEnv(t)
|
||||
user := seedSessionUser(t, repos)
|
||||
sessions.now = func() time.Time { return time.Now().Add(-40 * 24 * time.Hour) }
|
||||
session, err := sessions.Create(context.Background(), CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-old"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions.now = time.Now
|
||||
if _, err := sessions.Validate(context.Background(), session.ID, user.ID); !errors.Is(err, ErrAuthSessionExpired) {
|
||||
t.Fatalf("expired validate err = %v, want ErrAuthSessionExpired", err)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*
|
||||
return s.tokenSvc.Refresh(ctx, refreshToken)
|
||||
}
|
||||
|
||||
// Logout 撤销用户的所有刷新令牌。
|
||||
// Logout 撤销用户的所有刷新令牌与认证会话。
|
||||
func (s *AuthService) Logout(ctx context.Context, userID string) error {
|
||||
return s.tokenSvc.RevokeAll(ctx, userID)
|
||||
if err := s.tokenSvc.RevokeAll(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.sessionSvc != nil {
|
||||
return s.sessionSvc.RevokeByUser(ctx, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+81
-37
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// 两种方言的备份形态不同:
|
||||
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
|
||||
// 扩展名 .db;恢复即「替换库文件 + 重启」。
|
||||
// 扩展名 .db;恢复先暂存,在下次启动打开数据库前替换文件。
|
||||
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
|
||||
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
|
||||
package service
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
)
|
||||
|
||||
// BackupService manages database backups.
|
||||
@@ -44,6 +45,7 @@ type BackupInfo struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Automatic bool `json:"automatic"`
|
||||
}
|
||||
|
||||
func (b *BackupService) backupDir() string {
|
||||
@@ -69,6 +71,23 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ResolvePath returns the absolute path of a listed backup after applying the
|
||||
// same filename and directory checks used by restore and delete operations.
|
||||
func (b *BackupService) ResolvePath(filename string) (string, error) {
|
||||
path, err := b.backupFilePath(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("backup is not a regular file")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// isPostgres reports whether the service is backed by PostgreSQL.
|
||||
//
|
||||
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
|
||||
@@ -80,11 +99,23 @@ func (b *BackupService) isPostgres() bool {
|
||||
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
|
||||
// a logical dump.
|
||||
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
||||
return b.create(ctx, false)
|
||||
}
|
||||
|
||||
func (b *BackupService) CreateAutomatic(ctx context.Context) (*BackupInfo, error) {
|
||||
return b.create(ctx, true)
|
||||
}
|
||||
|
||||
func (b *BackupService) create(ctx context.Context, automatic bool) (*BackupInfo, error) {
|
||||
dir := b.backupDir()
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := time.Now().Format("20060102_150405")
|
||||
now := time.Now()
|
||||
ts := fmt.Sprintf("%s_%09d", now.Format("20060102_150405"), now.Nanosecond())
|
||||
if automatic {
|
||||
ts = "auto_" + ts
|
||||
}
|
||||
|
||||
var (
|
||||
dst string
|
||||
@@ -108,15 +139,24 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
|
||||
Path: dst,
|
||||
Size: stat.Size(),
|
||||
CreatedAt: stat.ModTime(),
|
||||
Automatic: automatic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
|
||||
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
|
||||
// VACUUM INTO creates a clean, non-WAL copy atomically.
|
||||
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
|
||||
tmp := dst + ".tmp"
|
||||
defer os.Remove(tmp)
|
||||
// Publish only completed backups, with credentials protected by file mode.
|
||||
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", tmp).Error; err != nil {
|
||||
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
@@ -132,7 +172,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
||||
}
|
||||
out := make([]BackupInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !isBackupFilename(e.Name()) {
|
||||
if !e.Type().IsRegular() || !isBackupFilename(e.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
@@ -144,6 +184,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
||||
Path: filepath.Join(dir, e.Name()),
|
||||
Size: info.Size(),
|
||||
CreatedAt: info.ModTime(),
|
||||
Automatic: strings.HasPrefix(e.Name(), "mebox_auto_"),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -152,6 +193,36 @@ func (b *BackupService) List() ([]BackupInfo, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Prune keeps maxCount automatic backups for the current database dialect.
|
||||
// Manual backups and backups from another dialect are never pruned.
|
||||
// A value below one disables pruning so an invalid setting cannot erase all
|
||||
// recoverable copies.
|
||||
func (b *BackupService) Prune(maxCount int) error {
|
||||
if maxCount < 1 {
|
||||
return nil
|
||||
}
|
||||
items, err := b.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eligible := items[:0]
|
||||
for _, item := range items {
|
||||
if item.Automatic && strings.HasSuffix(item.Filename, postgresBackupSuffix) == b.isPostgres() {
|
||||
eligible = append(eligible, item)
|
||||
}
|
||||
}
|
||||
items = eligible
|
||||
if maxCount >= len(items) {
|
||||
return nil
|
||||
}
|
||||
for _, item := range items[maxCount:] {
|
||||
if err := b.Delete(item.Filename); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single backup file.
|
||||
func (b *BackupService) Delete(filename string) error {
|
||||
path, err := b.backupFilePath(filename)
|
||||
@@ -161,12 +232,10 @@ func (b *BackupService) Delete(filename string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// Restore replaces the live database with the given backup.
|
||||
// WARNING: this is destructive. SQLite restores by swapping the database file
|
||||
// (the caller should shut down the server afterwards); PostgreSQL restores by
|
||||
// rebuilding every table inside a single transaction.
|
||||
// Restore stages SQLite backups for the next startup. PostgreSQL restores
|
||||
// rebuild every table inside a single transaction.
|
||||
func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
||||
src, err := b.backupFilePath(filename)
|
||||
src, err := b.ResolvePath(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -182,35 +251,10 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
|
||||
if !strings.HasSuffix(filename, ".db") {
|
||||
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
|
||||
}
|
||||
return b.restoreSQLiteBackup(filename, src)
|
||||
}
|
||||
|
||||
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
|
||||
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
|
||||
if err != nil || dbPath == "" {
|
||||
return errors.New("invalid database path")
|
||||
}
|
||||
// Strategy: rename live → live.old, copy backup → live, delete old.
|
||||
old := dbPath + ".before_restore"
|
||||
if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
if err := database.StageSQLiteRestore(ctx, b.cfg, src); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory.
|
||||
if err != nil {
|
||||
// Revert
|
||||
_ = os.Rename(old, dbPath)
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore.
|
||||
_ = os.Rename(old, dbPath)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(old)
|
||||
// Also remove WAL/SHM so SQLite opens the fresh file cleanly.
|
||||
_ = os.Remove(dbPath + "-wal")
|
||||
_ = os.Remove(dbPath + "-shm")
|
||||
b.log.Warn("database restored from backup — restart the server",
|
||||
zap.String("backup", filename))
|
||||
b.log.Warn("SQLite backup staged; restart to apply", zap.String("backup", filename))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -87,7 +88,8 @@ func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[str
|
||||
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
|
||||
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
|
||||
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
|
||||
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
tmp := dst + ".tmp"
|
||||
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -96,7 +98,7 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
|
||||
_ = file.Close()
|
||||
if !complete {
|
||||
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
|
||||
_ = os.Remove(dst)
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -109,17 +111,30 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("写入备份头失败: %w", err)
|
||||
}
|
||||
for _, m := range model.AllModels() {
|
||||
table, sch, err := parseModelSchema(b.db, m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !b.db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
|
||||
return "", err
|
||||
var options []*sql.TxOptions
|
||||
if b.isPostgres() {
|
||||
options = append(options, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
}
|
||||
// Every table must come from the same snapshot, including while background
|
||||
// workers are inserting related rows during an automatic backup.
|
||||
if err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
snapshot := *b
|
||||
snapshot.db = tx
|
||||
for _, m := range model.AllModels() {
|
||||
table, sch, err := parseModelSchema(tx, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tx.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if err := snapshot.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, options...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return "", fmt.Errorf("压缩备份失败: %w", err)
|
||||
@@ -127,6 +142,12 @@ func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string)
|
||||
if err := file.Sync(); err != nil {
|
||||
return "", fmt.Errorf("写入备份失败: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
complete = true
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupRetentionPreservesManualAndOtherDialectBackups(t *testing.T) {
|
||||
svc, _ := newBackupTestService(t)
|
||||
names := []string{"mebox_auto_new.db", "mebox_auto_old.db", "mebox_manual.db", "mebox_auto_postgres.json.gz"}
|
||||
for i, name := range names {
|
||||
path := filepath.Join(svc.backupDir(), name)
|
||||
if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
when := time.Now().Add(-time.Duration(i) * time.Hour)
|
||||
if err := os.Chtimes(path, when, when); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := svc.Prune(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range names {
|
||||
_, err := os.Stat(filepath.Join(svc.backupDir(), name))
|
||||
if name == "mebox_auto_old.db" {
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("old automatic backup was retained: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("backup %s should have been preserved: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupCreateUsesUniquePrivateCompletedFiles(t *testing.T) {
|
||||
svc, _ := newBackupTestService(t)
|
||||
manual, err := svc.Create(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
automatic, err := svc.CreateAutomatic(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manual.Filename == automatic.Filename || manual.Automatic || !automatic.Automatic {
|
||||
t.Fatalf("invalid backup identities: manual=%#v automatic=%#v", manual, automatic)
|
||||
}
|
||||
items, err := svc.List()
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("completed backup list: items=%#v err=%v", items, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
info, err := os.Stat(item.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("backup permissions are not private: %s err=%v", item.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,34 @@
|
||||
// 下载直链进程级缓存。
|
||||
//
|
||||
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是
|
||||
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件
|
||||
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目,
|
||||
// 下一轮重试会重新换取链接。
|
||||
// 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
|
||||
// 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
|
||||
// pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
|
||||
// 调用方主动清除对应条目,下一轮重试会重新换取链接。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右,
|
||||
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。
|
||||
const downloadURLCacheTTL = 45 * time.Minute
|
||||
// downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
|
||||
// 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
|
||||
// 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
|
||||
const downloadURLCacheMaxTTL = 45 * time.Minute
|
||||
|
||||
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
|
||||
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
|
||||
const linkExpirySafety = 2 * time.Minute
|
||||
|
||||
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
|
||||
const maxCachedURLs = 10000
|
||||
|
||||
type urlCacheEntry struct {
|
||||
url string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
|
||||
return pickCode + "@" + ua
|
||||
}
|
||||
|
||||
// LinkExpiry 解析 115 直链自带的过期时间戳(`t=` 参数,单位秒)。
|
||||
// 解析不出时返回零值,调用方按兜底 TTL 处理。
|
||||
func LinkExpiry(rawURL string) time.Time {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
raw := strings.TrimSpace(u.Query().Get("t"))
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
sec, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || sec <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(sec, 0)
|
||||
}
|
||||
|
||||
// cacheExpiry 计算写入缓存时应使用的过期时刻:跟随直链自身的 `t=` 并提前
|
||||
// linkExpirySafety,解析不出时退回 downloadURLCacheMaxTTL。返回零值表示这条
|
||||
// 直链已经过期,不应写入缓存。
|
||||
func cacheExpiry(rawURL string, now time.Time) time.Time {
|
||||
maxExpiry := now.Add(downloadURLCacheMaxTTL)
|
||||
linkExpiry := LinkExpiry(rawURL)
|
||||
if linkExpiry.IsZero() {
|
||||
return maxExpiry
|
||||
}
|
||||
expiry := linkExpiry.Add(-linkExpirySafety)
|
||||
if !expiry.After(now) {
|
||||
return time.Time{}
|
||||
}
|
||||
if expiry.After(maxExpiry) {
|
||||
return maxExpiry
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
|
||||
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
|
||||
func GetDownloadURLCache(pickCode string, uas ...string) string {
|
||||
if pickCode == "" {
|
||||
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
|
||||
return entry.url
|
||||
}
|
||||
|
||||
// SetDownloadURLCache 写入直链缓存。
|
||||
// SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
|
||||
// 直链直接丢弃,避免把死链缓存下来。
|
||||
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
||||
if pickCode == "" || url == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := cacheExpiry(url, now)
|
||||
if expiresAt.IsZero() {
|
||||
return
|
||||
}
|
||||
ua := ""
|
||||
if len(uas) > 0 {
|
||||
ua = uas[0]
|
||||
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
if len(urlCache) >= maxCachedURLs {
|
||||
now := time.Now()
|
||||
for k, e := range urlCache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(urlCache, k)
|
||||
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
|
||||
}
|
||||
}
|
||||
key := urlCacheKey(pickCode, ua)
|
||||
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
|
||||
urlCache[key] = urlCacheEntry{url: url, createdAt: now, expiresAt: expiresAt}
|
||||
}
|
||||
|
||||
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
|
||||
@@ -95,3 +145,32 @@ func ClearDownloadURLCache(pickCode string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearDownloadURLCacheIfOlderThan 删除指定 pickcode 下「已经存在超过 minAge」
|
||||
// 的缓存条目,返回是否删除了至少一条。
|
||||
//
|
||||
// 客户端上报「直链被 CDN 拒绝」时用它强制换链:既让下一次解析拿到新地址,
|
||||
// 又不会让同一秒内的重试风暴把 115 换链接口打出突发。
|
||||
func ClearDownloadURLCacheIfOlderThan(pickCode string, minAge time.Duration) bool {
|
||||
if pickCode == "" {
|
||||
return false
|
||||
}
|
||||
if minAge < 0 {
|
||||
minAge = 0
|
||||
}
|
||||
now := time.Now()
|
||||
removed := false
|
||||
urlCacheMu.Lock()
|
||||
defer urlCacheMu.Unlock()
|
||||
for k, e := range urlCache {
|
||||
if k != pickCode && !strings.HasPrefix(k, pickCode+"@") {
|
||||
continue
|
||||
}
|
||||
if now.Sub(e.createdAt) < minAge {
|
||||
continue
|
||||
}
|
||||
delete(urlCache, k)
|
||||
removed = true
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
|
||||
t.Fatalf("cleared entry should be a miss, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 115 直链自带 `t=` 过期时间戳(实测为换取时刻 + 1800 秒)。缓存必须跟随它,
|
||||
// 否则会出现「服务端认为还有效、CDN 已经 403」的窗口:直链被缓存到 45 分钟,
|
||||
// 但第 30 分钟起客户端就必然拿到 403。
|
||||
func TestDownloadURLCacheExpiryFollowsLinkTimestamp(t *testing.T) {
|
||||
now := time.Now()
|
||||
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d&u=1&k=abc", now.Add(30*time.Minute).Unix())
|
||||
|
||||
got := cacheExpiry(link, now)
|
||||
want := now.Add(30*time.Minute - linkExpirySafety)
|
||||
if diff := got.Sub(want); diff > time.Second || diff < -time.Second {
|
||||
t.Fatalf("cacheExpiry = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// 解析不出 t= 时退回兜底 TTL,保持旧行为。
|
||||
fallback := cacheExpiry("https://cdn/1/a.mkv", now)
|
||||
if diff := fallback.Sub(now.Add(downloadURLCacheMaxTTL)); diff > time.Second || diff < -time.Second {
|
||||
t.Fatalf("fallback cacheExpiry = %v, want %v", fallback, now.Add(downloadURLCacheMaxTTL))
|
||||
}
|
||||
|
||||
// 直链本身已经过期(或只剩不足提前量的寿命)时不进缓存。
|
||||
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(-time.Minute).Unix()), now); !got.IsZero() {
|
||||
t.Fatalf("expired link must not be cached, got expiry %v", got)
|
||||
}
|
||||
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(time.Minute).Unix()), now); !got.IsZero() {
|
||||
t.Fatalf("link expiring inside the safety margin must not be cached, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端上报「直链被 CDN 拒绝」时按 pickcode 清缓存,但要挡住刚写入的条目,
|
||||
// 避免重试风暴把 115 换链接口打出突发。
|
||||
func TestClearDownloadURLCacheIfOlderThan(t *testing.T) {
|
||||
urlCacheMu.Lock()
|
||||
urlCache = map[string]urlCacheEntry{}
|
||||
urlCacheMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
urlCacheMu.Lock()
|
||||
urlCache = map[string]urlCacheEntry{}
|
||||
urlCacheMu.Unlock()
|
||||
})
|
||||
|
||||
future := time.Now().Add(30 * time.Minute)
|
||||
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d", future.Unix())
|
||||
SetDownloadURLCache("pick-fresh", link, "ua-a")
|
||||
if ClearDownloadURLCacheIfOlderThan("pick-fresh", time.Minute) {
|
||||
t.Fatal("fresh entry must survive the min-age guard")
|
||||
}
|
||||
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
|
||||
t.Fatalf("fresh entry lost, got %q", got)
|
||||
}
|
||||
|
||||
SetDownloadURLCache("pick-old", link, "ua-a")
|
||||
urlCacheMu.Lock()
|
||||
entry := urlCache[urlCacheKey("pick-old", "ua-a")]
|
||||
entry.createdAt = time.Now().Add(-10 * time.Minute)
|
||||
urlCache[urlCacheKey("pick-old", "ua-a")] = entry
|
||||
urlCacheMu.Unlock()
|
||||
|
||||
if !ClearDownloadURLCacheIfOlderThan("pick-old", time.Minute) {
|
||||
t.Fatal("stale entry should be cleared")
|
||||
}
|
||||
if got := GetDownloadURLCache("pick-old", "ua-a"); got != "" {
|
||||
t.Fatalf("stale entry should be gone, got %q", got)
|
||||
}
|
||||
// 无关 pickcode 不受影响。
|
||||
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
|
||||
t.Fatalf("unrelated pickcode lost, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,14 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 立即撤销该设备的认证会话:只标记 UserDevice 不足以让已签发的 JWT
|
||||
// 失效,新协议令牌必须同时被会话校验拒绝。
|
||||
if s.authSessions != nil {
|
||||
if err := s.authSessions.RevokeDevice(ctx, userID, d.DeviceID); err != nil && s.log != nil {
|
||||
s.log.Warn("revoke auth sessions for kicked device failed",
|
||||
zap.String("user_id", userID), zap.String("device_id", d.DeviceID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
|
||||
return nil
|
||||
}
|
||||
@@ -37,6 +47,12 @@ func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error
|
||||
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.authSessions != nil {
|
||||
if err := s.authSessions.RevokeByUser(ctx, userID); err != nil && s.log != nil {
|
||||
s.log.Warn("revoke auth sessions for kicked user failed",
|
||||
zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ type DeviceService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
sessions *SessionTrackerService
|
||||
// authSessions 用于踢出设备时立即撤销其认证会话;nil 表示旧装配路径。
|
||||
authSessions *AuthSessionService
|
||||
|
||||
// notifyUser sends a Telegram message to the local user (resolved to their
|
||||
// Telegram binding). Wired by the bot service; nil disables notifications.
|
||||
@@ -60,6 +62,11 @@ func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
|
||||
s.sessions = tracker
|
||||
}
|
||||
|
||||
// SetAuthSessionService 注入认证会话服务,使踢出设备同时撤销其新协议令牌。
|
||||
func (s *DeviceService) SetAuthSessionService(sessions *AuthSessionService) {
|
||||
s.authSessions = sessions
|
||||
}
|
||||
|
||||
// fingerprint derives a stable terminal hash from the device name. Client/app
|
||||
// names are deliberately ignored so one phone/TV/PC using multiple apps is
|
||||
// still counted as one terminal device; Client remains a login channel label.
|
||||
|
||||
@@ -40,6 +40,23 @@ import (
|
||||
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
|
||||
const embyRemoteHTTPTimeout = 15 * time.Second
|
||||
|
||||
// embyRemoteViewsBudget 是网页端聚合远程媒体库时的总时间预算。
|
||||
//
|
||||
// 上游 Emby 整体不可用时(典型表现:Cloudflare 522,连接卡住近 20 秒),
|
||||
// 首页 /api/libraries 会被同步阻塞,而前端 axios 30 秒就放弃,于是一个挂掉的
|
||||
// 远端会把整个首页拖成加载失败。这里给聚合调用一个硬预算:超时即降级为
|
||||
// 「只返回本地库」,把首屏确定性地控制在几秒内。
|
||||
var embyRemoteViewsBudget = 5 * time.Second
|
||||
|
||||
// embyRemoteViewsFailureTTL 是远程 Views 拉取失败后的负缓存时长。
|
||||
//
|
||||
// 失败结果也缓存一小段时间,避免每个请求都重新等满预算(首页每次刷新、
|
||||
// 每个并发客户端都会各打一次)。时间很短,远端恢复后最多 30 秒即可自动重试。
|
||||
var embyRemoteViewsFailureTTL = 30 * time.Second
|
||||
|
||||
// errRemoteViewsRecentlyFailed 表示远程 Emby 刚失败过,当前处于负缓存窗口。
|
||||
var errRemoteViewsRecentlyFailed = errors.New("远程 Emby 暂时不可用(近期请求失败,稍后自动重试)")
|
||||
|
||||
// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截
|
||||
// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。
|
||||
const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
@@ -422,6 +439,22 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMountsEnabled 批量启用/停用某账号下的全部挂载。
|
||||
//
|
||||
// 账号整体不可用时(远端宕机、线路全挂),管理员可以一键把所有挂载从媒体库
|
||||
// 中摘掉:网页端 /api/libraries 不再为该账号发起任何远程请求,首页恢复秒开;
|
||||
// 远端恢复后再一键启用即可,挂载配置和播放设置都不丢。
|
||||
func (r *EmbyRemoteService) SetMountsEnabled(ctx context.Context, accountID string, enabled bool) (int64, error) {
|
||||
affected, err := r.repo.EmbyMount.SetEnabledByAccountID(ctx, accountID, enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 启停会改变可见库集合,顺手清掉远程元数据缓存(含 Views 失败负缓存),
|
||||
// 让下一次请求立刻按新状态重新探测,而不是继续吃 30 秒的负缓存。
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// ReorderMounts 批量重排挂载媒体库顺序。
|
||||
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
@@ -822,7 +855,13 @@ func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.Strm
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
|
||||
if err := r.doGet(ctx, acct, cfg, "/System/Info", nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
// 连通性恢复:清掉 Views 失败负缓存与旧元数据,让下一次媒体库列表请求
|
||||
// 立即重新探测,而不是继续在负缓存窗口里显示「没有远程库」。
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。
|
||||
@@ -837,7 +876,34 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
|
||||
// ─── 元数据 / 目录聚合 ─────────────────────────────────────────────────────────
|
||||
|
||||
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
|
||||
//
|
||||
// 失败时写入短时负缓存:远程整站不可用时,避免每次首页刷新都重新等满超时。
|
||||
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
if acct == nil {
|
||||
return nil, errors.New("缺少远程 Emby 账号")
|
||||
}
|
||||
failKey := r.remoteCacheKey("views-fail", acct.ID)
|
||||
var failed bool
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, failKey, &failed) && failed {
|
||||
return nil, errRemoteViewsRecentlyFailed
|
||||
}
|
||||
views, err := r.remoteViewsUncached(ctx, acct)
|
||||
if err != nil {
|
||||
// 客户端主动断开(切换页面 / 关标签)不算上游故障,不能让它把
|
||||
// 负缓存窗口写进去,否则正常用户会被别人的一次取消拖慢 30 秒。
|
||||
if r.cache != nil && !errors.Is(err, context.Canceled) {
|
||||
r.cache.SetJSON(ctx, failKey, true, embyRemoteViewsFailureTTL)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, failKey, false, embyRemoteViewsFailureTTL)
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// remoteViewsUncached 是 RemoteViews 的实际拉取逻辑(含正常结果缓存)。
|
||||
func (r *EmbyRemoteService) remoteViewsUncached(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// TestRemoteLibrariesFastFailAndNegativeCache 覆盖首页可用的底线保证:
|
||||
// 上游 Emby 整体挂起时,聚合远程库必须在一个很短的预算内失败并降级为
|
||||
// 「没有远程库」,并且在负缓存窗口内不再重复打上游。
|
||||
func TestRemoteLibrariesFastFailAndNegativeCache(t *testing.T) {
|
||||
oldBudget, oldTTL := embyRemoteViewsBudget, embyRemoteViewsFailureTTL
|
||||
embyRemoteViewsBudget = 200 * time.Millisecond
|
||||
embyRemoteViewsFailureTTL = time.Minute
|
||||
t.Cleanup(func() {
|
||||
embyRemoteViewsBudget = oldBudget
|
||||
embyRemoteViewsFailureTTL = oldTTL
|
||||
})
|
||||
|
||||
release := make(chan struct{})
|
||||
var hits int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
// 模拟 Cloudflare 522 前近 20 秒的挂起:请求一直不返回。
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
|
||||
SetRuntimeCache(NewRuntimeCacheService(nil, zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": upstream.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-hang"},
|
||||
Name: "挂起的远端",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-hang"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "卡住的库",
|
||||
CollectionType: "movies",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
views, err := remote.RemoteLibraries(t.Context())
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLibraries should degrade instead of failing: %v", err)
|
||||
}
|
||||
if len(views) != 0 {
|
||||
t.Fatalf("expected no remote libraries while upstream hangs, got %d", len(views))
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("RemoteLibraries took %v, want within the short budget", elapsed)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) == 0 {
|
||||
t.Fatal("expected upstream to be probed once")
|
||||
}
|
||||
|
||||
// 负缓存窗口内:不再打上游,且立即返回空结果。
|
||||
before := atomic.LoadInt32(&hits)
|
||||
start = time.Now()
|
||||
views, err = remote.RemoteLibraries(t.Context())
|
||||
elapsed = time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteLibraries should not fail: %v", err)
|
||||
}
|
||||
if len(views) != 0 {
|
||||
t.Fatalf("expected no remote libraries from negative cache, got %d", len(views))
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("negative cache should answer immediately, took %v", elapsed)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != before {
|
||||
t.Fatalf("negative cache should skip upstream, hits went %d -> %d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteViewsNotNegativeCachedOnClientCancel 保证客户端主动断开不会污染
|
||||
// 负缓存:否则一个用户切页就会让所有人在 30 秒内看不到远程库。
|
||||
func TestRemoteViewsNotNegativeCachedOnClientCancel(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
cache := NewRuntimeCacheService(nil, zap.NewNop())
|
||||
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
|
||||
SetRuntimeCache(cache)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": upstream.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-cancel"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err := remote.RemoteViews(ctx, acct); err == nil {
|
||||
t.Fatal("expected canceled context to fail")
|
||||
}
|
||||
|
||||
var failed bool
|
||||
if cache.GetJSON(t.Context(), remote.remoteCacheKey("views-fail", acct.ID), &failed) && failed {
|
||||
t.Fatal("client cancel must not write the negative cache")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ type RemoteLibraryView struct {
|
||||
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
|
||||
// (只有显式挂载的库才出现在本项目媒体库中)。
|
||||
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
|
||||
// 远程库只是「追加」到本地库列表上,绝不允许它拖住整页首屏:给整段
|
||||
// 聚合(多账号 × 多线路故障转移)一个总预算,超时后各账号按失败处理,
|
||||
// 调用方随即降级为只返回本地库。
|
||||
ctx, cancel := context.WithTimeout(ctx, embyRemoteViewsBudget)
|
||||
defer cancel()
|
||||
|
||||
mounts, err := r.ListMounts(ctx)
|
||||
if err != nil || len(mounts) == 0 {
|
||||
return nil, err
|
||||
|
||||
@@ -35,16 +35,28 @@ func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, med
|
||||
func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error {
|
||||
if favorite {
|
||||
var existing model.Favorite
|
||||
err := repo.DB.WithContext(ctx).
|
||||
// 收藏行的删除是软删除;重新收藏时必须连已软删除的行一起查出来,
|
||||
// 否则会再次插入同一 (user_id, media_id),撞上唯一索引后稳定返回
|
||||
// 500,用户表现为“取消收藏后无法再收藏”。
|
||||
err := repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
case existing.DeletedAt.Valid:
|
||||
return repo.DB.WithContext(ctx).Unscoped().
|
||||
Model(&model.Favorite{}).
|
||||
Where("id = ?", existing.ID).
|
||||
Update("deleted_at", nil).Error
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
|
||||
@@ -44,6 +44,19 @@ func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) {
|
||||
if favorite {
|
||||
t.Fatal("expected remote favourite to be removed locally")
|
||||
}
|
||||
|
||||
// 软删除后重新收藏必须恢复原行:唯一索引 (user_id, media_id) 不允许
|
||||
// 再插入一条,旧的实现会在这里返回约束错误。
|
||||
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil {
|
||||
t.Fatalf("SyncUserFavorite re-favorite: %v", err)
|
||||
}
|
||||
favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsUserFavorite after re-favorite: %v", err)
|
||||
}
|
||||
if !favorite {
|
||||
t.Fatal("expected re-favourite to restore the soft-deleted row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) {
|
||||
|
||||
@@ -22,8 +22,9 @@ import (
|
||||
|
||||
const (
|
||||
// IntroDBBaseURL is the public read endpoint. Overridable on the service
|
||||
// for tests and for pointing at a mirror.
|
||||
IntroDBBaseURL = "https://api.theintrodb.org/v3"
|
||||
// for tests and for pointing at a mirror. v1 已删除,v2/v3 冻结在只读的
|
||||
// GET /media 上并被官方标记为弃用,所以这里直接跟 v4。
|
||||
IntroDBBaseURL = "https://api.theintrodb.org/v4"
|
||||
// IntroDBSource tags rows that came from this provider.
|
||||
IntroDBSource = "theintrodb"
|
||||
|
||||
@@ -92,8 +93,10 @@ type introDBRange struct {
|
||||
EndMs *int64 `json:"end_ms"`
|
||||
}
|
||||
|
||||
// introDBResponse 对应 v4 的 MediaResponse。v4 以 TVDB 为身份,响应只带
|
||||
// tvdb_id —— tmdb_id 字段已被官方移除,需要 tmdb_id 只能从请求侧自己记。
|
||||
type introDBResponse struct {
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
TVDBID int `json:"tvdb_id"`
|
||||
Type string `json:"type"`
|
||||
Intro []introDBRange `json:"intro"`
|
||||
Recap []introDBRange `json:"recap"`
|
||||
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
|
||||
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
|
||||
// 这两段响应是从 api.theintrodb.org/v4/media 实测抓下来的原文(v4 以 TVDB 为身份,
|
||||
// 响应里只有 tvdb_id),用来锁住 null 语义:start_ms: null = 从片头开始,
|
||||
// end_ms: null = 一直到片尾。
|
||||
const (
|
||||
introDBTVPayload = `{"tmdb_id":1396,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
|
||||
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
|
||||
introDBTVPayload = `{"tvdb_id":81189,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
|
||||
introDBMoviePayload = `{"tvdb_id":113,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
|
||||
)
|
||||
|
||||
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
||||
@@ -48,7 +49,7 @@ func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
||||
body := `{"tmdb_id":1,"type":"movie",
|
||||
body := `{"tvdb_id":1,"type":"movie",
|
||||
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
|
||||
"recap":[],"credits":[],"preview":[]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
@@ -62,7 +63,7 @@ func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
||||
body := `{"tmdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
|
||||
body := `{"tvdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
|
||||
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
if err != nil {
|
||||
@@ -82,12 +83,12 @@ func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
||||
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
|
||||
svc := NewIntroDBService(zap.NewNop())
|
||||
if got, want := svc.mediaURL(1396, 1, 1),
|
||||
"https://api.theintrodb.org/v3/media?tmdb_id=1396&season=1&episode=1"; got != want {
|
||||
"https://api.theintrodb.org/v4/media?tmdb_id=1396&season=1&episode=1"; got != want {
|
||||
t.Fatalf("tv url = %q, want %q", got, want)
|
||||
}
|
||||
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
|
||||
if got, want := svc.mediaURL(27205, 0, 0),
|
||||
"https://api.theintrodb.org/v3/media?tmdb_id=27205"; got != want {
|
||||
"https://api.theintrodb.org/v4/media?tmdb_id=27205"; got != want {
|
||||
t.Fatalf("movie url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,85 @@ func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkIgnoresLoosePosterInLibraryRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// 云下载这类库把不同影片平铺在同一个根目录里,根目录中残留的松散海报
|
||||
// 属于别的影片,不能被抓来当作当前影片的封面。
|
||||
if err := os.WriteFile(filepath.Join(root, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(root, "BBB-002.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
|
||||
t.Fatalf("library-root artwork must not be inherited: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkIgnoresLoosePosterInSharedFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "shared")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"AAA-001.mp4", "BBB-002.mp4"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(filepath.Join(dir, "BBB-002.mp4"), root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
|
||||
t.Fatalf("sibling artwork must not be inherited in a shared folder: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkKeepsLooseFallbackInItemFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "BBB-002")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "video.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "japanese-title-cover.jpg")
|
||||
fanart := filepath.Join(dir, "japanese-title-fanart.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster || got.BackdropURL != fanart {
|
||||
t.Fatalf("single-item folder should keep loose artwork fallback: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||
|
||||
@@ -11,23 +11,23 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
|
||||
func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata {
|
||||
meta := &LocalMetadata{}
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
|
||||
if meta.PosterURL == "" && meta.BackdropURL == "" {
|
||||
return nil
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
|
||||
meta.PosterURL = localPoster
|
||||
} else if meta.PosterURL == "" {
|
||||
} else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) {
|
||||
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
|
||||
}
|
||||
dirs := []string{mediaDir, showBaseDir}
|
||||
@@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
meta.BackdropURL = img
|
||||
break
|
||||
}
|
||||
if meta.BackdropURL == "" {
|
||||
if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) {
|
||||
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// looseArtworkFallbackAllowed reports whether an unmatched image sitting in dir
|
||||
// can be attributed to mediaPath. Such an image carries no filename relation to
|
||||
// a specific video, so it is only trustworthy when the folder holds a single
|
||||
// media file and that folder is not the library root. Download tools routinely
|
||||
// drop unrelated titles flat into one directory (a 115 cloud-download library,
|
||||
// for example); claiming the first "*-poster.jpg" there made every item show the
|
||||
// same artwork. Mirrors the root-level guard findMovieNFO already applies to
|
||||
// loose NFO files.
|
||||
func looseArtworkFallbackAllowed(dir, libraryRoot string) bool {
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
if libraryRoot != "" && samePath(dir, libraryRoot) {
|
||||
return false
|
||||
}
|
||||
return countMediaFilesInDir(dir) <= 1
|
||||
}
|
||||
|
||||
// countMediaFilesInDir counts the video files directly inside dir, stopping once
|
||||
// more than one is found. It walks the real directory rather than reusing the
|
||||
// loose-image glob because a shared folder may contain no images at all.
|
||||
func countMediaFilesInDir(dir string) int {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))]; !ok {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > 1 {
|
||||
return count
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func firstAdultLooseImage(dir, kind string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
|
||||
@@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return metadataFromArtwork(mediaPath, ""), nil
|
||||
return metadataFromArtwork(mediaPath, "", libraryRoot), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path), libraryRoot)
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
|
||||
@@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
}
|
||||
}
|
||||
if meta == nil {
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir)
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot)
|
||||
} else {
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
|
||||
}
|
||||
// A sidecar written while resolution tokens were misread as SxxExx must not
|
||||
// reintroduce the bogus season/episode on rescan.
|
||||
|
||||
@@ -17,7 +17,9 @@ type MediaService struct {
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
groupedMediaFlight singleflight.Group
|
||||
mediaSortFlight singleflight.Group
|
||||
libraryRowsFlight singleflight.Group
|
||||
recentSeriesFlight singleflight.Group
|
||||
}
|
||||
|
||||
type MediaVisibility struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
@@ -129,6 +129,26 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
visibility MediaVisibility,
|
||||
filters MediaListFilters,
|
||||
) ([]MediaItem, error) {
|
||||
value, err := s.groupedMediaVisibleFiltered(ctx, libraryID, visibility, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value.Items, nil
|
||||
}
|
||||
|
||||
// Each immutable grouping has its own revision. Lightweight sort indices can
|
||||
// reference that revision without retaining another copy of the full rows.
|
||||
type groupedMediaCacheValue struct {
|
||||
Items []MediaItem
|
||||
Revision string
|
||||
}
|
||||
|
||||
func (s *MediaService) groupedMediaVisibleFiltered(
|
||||
ctx context.Context,
|
||||
libraryID string,
|
||||
visibility MediaVisibility,
|
||||
filters MediaListFilters,
|
||||
) (*groupedMediaCacheValue, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||
if err != nil {
|
||||
@@ -143,7 +163,7 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
|
||||
if s.cache != nil {
|
||||
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
|
||||
if cached, ok := cachedObj.([]MediaItem); ok {
|
||||
if cached, ok := cachedObj.(*groupedMediaCacheValue); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
@@ -154,8 +174,8 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grouped := groupMediaVersions(items)
|
||||
if s.cache != nil && len(grouped) > 0 {
|
||||
grouped := &groupedMediaCacheValue{Items: groupMediaVersions(items), Revision: uuid.NewString()}
|
||||
if s.cache != nil && len(grouped.Items) > 0 {
|
||||
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
|
||||
}
|
||||
return grouped, nil
|
||||
@@ -163,10 +183,10 @@ func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if grouped, ok := value.([]MediaItem); ok {
|
||||
if grouped, ok := value.(*groupedMediaCacheValue); ok {
|
||||
return grouped, nil
|
||||
}
|
||||
return nil, nil
|
||||
return &groupedMediaCacheValue{}, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility, filters MediaListFilters) ([]model.Media, error) {
|
||||
@@ -180,24 +200,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
|
||||
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
|
||||
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
|
||||
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
|
||||
// Group before paginating so versions stay together. The search safety limit
|
||||
// must not truncate a library listing or its reported grouped total.
|
||||
items, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) >= maxMediaSearchLimit && s.log != nil {
|
||||
s.log.Warn("media version grouping may be truncated by safety limit",
|
||||
zap.String("library_id", libraryID),
|
||||
zap.Int("limit", maxMediaSearchLimit))
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, items)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetMedia returns a single media row.
|
||||
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { media, err := s.repo.Media.FindByID(ctx, id)
|
||||
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
|
||||
media, err := s.repo.Media.FindByID(ctx, id)
|
||||
if err != nil || media == nil {
|
||||
return media, err
|
||||
}
|
||||
|
||||
@@ -2,17 +2,71 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type MediaQualityItem struct {
|
||||
Issue string `json:"issue"`
|
||||
Media model.Media `json:"media"`
|
||||
}
|
||||
|
||||
func (s *MediaService) QualityIssues(ctx context.Context, issue string, page, pageSize int) ([]MediaQualityItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
q := s.repo.DB.WithContext(ctx).Model(&model.Media{})
|
||||
switch issue {
|
||||
case "missing":
|
||||
q = q.Where("availability_state = ?", "missing")
|
||||
case "unmatched":
|
||||
q = q.Where("scrape_status IN ? OR scrape_status = ''", []string{"pending", "no_match"})
|
||||
case "poster":
|
||||
q = q.Where("poster_url IS NULL OR TRIM(poster_url) = ''")
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unknown quality issue: %s", issue)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Order("updated_at DESC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, rows)
|
||||
items := make([]MediaQualityItem, 0, len(rows))
|
||||
for _, media := range rows {
|
||||
items = append(items, MediaQualityItem{Issue: issue, Media: media})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
type MediaSearchFilter struct {
|
||||
LibraryID string
|
||||
YearMin int
|
||||
YearMax int
|
||||
Genres []string
|
||||
}
|
||||
|
||||
// SearchMedia performs a simple LIKE search across titles.
|
||||
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
|
||||
return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true})
|
||||
}
|
||||
|
||||
func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
|
||||
return s.SearchMediaVisibleFiltered(ctx, query, limit, visibility, MediaSearchFilter{})
|
||||
}
|
||||
|
||||
func (s *MediaService) SearchMediaVisibleFiltered(ctx context.Context, query string, limit int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
} else if limit > maxMediaSearchLimit {
|
||||
@@ -23,6 +77,10 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
LibraryID: filter.LibraryID,
|
||||
YearMin: filter.YearMin,
|
||||
YearMax: filter.YearMax,
|
||||
Genres: filter.Genres,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -45,6 +103,10 @@ func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query stri
|
||||
}
|
||||
|
||||
func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||
return s.SearchMediaVisiblePageFiltered(ctx, query, page, pageSize, visibility, MediaSearchFilter{})
|
||||
}
|
||||
|
||||
func (s *MediaService) SearchMediaVisiblePageFiltered(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility, filter MediaSearchFilter) ([]model.Media, int64, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
@@ -59,6 +121,10 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string,
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
LibraryID: filter.LibraryID,
|
||||
YearMin: filter.YearMin,
|
||||
YearMax: filter.YearMax,
|
||||
Genres: filter.Genres,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -239,26 +239,6 @@ func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaLis
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||
if limit <= 0 {
|
||||
limit = 24
|
||||
} else if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.SearchMediaVisible(ctx, "", maxMediaSearchLimit, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := groupMediaSeriesCards(rows)
|
||||
if len(cards) == 0 {
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
if len(cards) > limit {
|
||||
cards = cards[:limit]
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
|
||||
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -129,15 +130,28 @@ var (
|
||||
)
|
||||
|
||||
func normalizeSeriesTitle(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = seriesYearRE.ReplaceAllString(value, " ")
|
||||
value = seriesIDRE.ReplaceAllString(value, " ")
|
||||
value = seriesBraceRE.ReplaceAllString(value, " ")
|
||||
value = seriesSpacerRE.ReplaceAllString(value, " ")
|
||||
return strings.TrimSpace(value)
|
||||
// 归组时每行都会调用(且同一部剧的每集标题相同),纯函数缓存能省掉
|
||||
// 4 条正则的重复执行。
|
||||
if normalized, ok := seriesTitleNormMemo.get(value); ok {
|
||||
return normalized
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = seriesYearRE.ReplaceAllString(normalized, " ")
|
||||
normalized = seriesIDRE.ReplaceAllString(normalized, " ")
|
||||
normalized = seriesBraceRE.ReplaceAllString(normalized, " ")
|
||||
normalized = seriesSpacerRE.ReplaceAllString(normalized, " ")
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
seriesTitleNormMemo.put(value, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeSeriesPathTitle(value string) string {
|
||||
// 整库归组时每个文件都会走到这里,而 CleanQuery 内部是十几条正则。
|
||||
// 同一部剧的几十集共享同一个目录名,按目录名缓存后,首页「最近添加」
|
||||
// 的分组阶段从 240ms 降到几十毫秒(7k 行实测)。
|
||||
if title, ok := seriesPathTitleMemo.get(value); ok {
|
||||
return title
|
||||
}
|
||||
title, _ := CleanQuery(value)
|
||||
if title == "" {
|
||||
title = normalizeSeriesTitle(value)
|
||||
@@ -146,11 +160,52 @@ func normalizeSeriesPathTitle(value string) string {
|
||||
}
|
||||
stripped := stripSeriesSpecialSuffix(title)
|
||||
if stripped != "" {
|
||||
return stripped
|
||||
title = stripped
|
||||
}
|
||||
seriesPathTitleMemo.put(value, title)
|
||||
return title
|
||||
}
|
||||
|
||||
// boundedStringMemo 是纯字符串函数的定长缓存。
|
||||
//
|
||||
// 只用于「入参唯一决定出参、不读配置/数据库」的函数:剧集标题规范化、
|
||||
// 目录名清洗等。整库归组会把这些函数在每行上重复调用几十次,缓存后首页
|
||||
// 「最近添加」的 CPU 从 ~240ms 降到 ~40ms(7k 行实测)。上限到达后整体
|
||||
// 清空,避免长跑进程无限增长(不同目录名/标题的数量远小于集数)。
|
||||
type boundedStringMemo struct {
|
||||
mu sync.RWMutex
|
||||
max int
|
||||
entries map[string]string
|
||||
}
|
||||
|
||||
func newBoundedStringMemo(max int) *boundedStringMemo {
|
||||
if max <= 0 {
|
||||
max = 1024
|
||||
}
|
||||
return &boundedStringMemo{max: max, entries: make(map[string]string, max/8)}
|
||||
}
|
||||
|
||||
func (m *boundedStringMemo) get(key string) (string, bool) {
|
||||
m.mu.RLock()
|
||||
value, ok := m.entries[key]
|
||||
m.mu.RUnlock()
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (m *boundedStringMemo) put(key, value string) {
|
||||
m.mu.Lock()
|
||||
if len(m.entries) >= m.max {
|
||||
m.entries = make(map[string]string, m.max/4)
|
||||
}
|
||||
m.entries[key] = value
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
var (
|
||||
seriesPathTitleMemo = newBoundedStringMemo(4096)
|
||||
seriesTitleNormMemo = newBoundedStringMemo(4096)
|
||||
)
|
||||
|
||||
func stripSeriesSpecialSuffix(title string) string {
|
||||
for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
|
||||
stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// recentSeriesCardsCacheValue 缓存「最近添加」剧集卡片。Cards 视为不可变,
|
||||
// 调用方只读不改(与 seriesCardsCacheValue 相同的约定)。
|
||||
type recentSeriesCardsCacheValue struct {
|
||||
Cards []SeriesCard
|
||||
}
|
||||
|
||||
// ListRecentSeriesCards 返回最近入库的剧集卡片(首页「最近添加」)。
|
||||
//
|
||||
// 性能约定:首页每次刷新都会打这条路径,因此不能再用 SearchMediaVisible:
|
||||
// 它会先对整表跑一次 COUNT(*),再 SELECT 全部列(含 overview/genres 等大文本)。
|
||||
// 7k 行规模下,驱动层逐列解码就占掉 60% 的耗时,实测 ≈1.6s。
|
||||
//
|
||||
// 现在的做法:
|
||||
// 1. 只取归组必需的列(repository.ListMediaSeriesIndex),不做 COUNT;
|
||||
// 2. 在内存里完成分组/计数/排序,截断到 limit;
|
||||
// 3. 只对最终这几十张卡片的代表行补一次完整行(客户端要 overview、genres);
|
||||
// 4. 结果按可见性缓存,媒体入库/刮削会按 "media:" 前缀统一失效。
|
||||
//
|
||||
// 语义与旧实现一致:key 与 Count 仍然基于整库所有可见行计算,不能只看
|
||||
// 前 N 行,否则剧集 key 会和 /libraries/{id}/series/episodes 对不上。
|
||||
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||
if limit <= 0 {
|
||||
limit = 24
|
||||
} else if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
cacheKey := s.recentSeriesCardsCacheKey(limit, visibility)
|
||||
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
|
||||
return cards, nil
|
||||
}
|
||||
// singleflight:多个客户端同时刷新首页时只扫一遍库。
|
||||
value, err, _ := s.recentSeriesFlight.Do(cacheKey, func() (any, error) {
|
||||
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
|
||||
return &recentSeriesCardsCacheValue{Cards: cards}, nil
|
||||
}
|
||||
// 首个调用者取消不应让其它等待者一起失败。
|
||||
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
defer cancel()
|
||||
cards, err := s.loadRecentSeriesCards(loadCtx, limit, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached := &recentSeriesCardsCacheValue{Cards: cards}
|
||||
if s.cache != nil {
|
||||
s.cache.SetObject(cacheKey, cached, s.derivedReadCacheTTL())
|
||||
}
|
||||
return cached, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cached, ok := value.(*recentSeriesCardsCacheValue); ok {
|
||||
return cached.Cards, nil
|
||||
}
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) cachedRecentSeriesCards(cacheKey string) ([]SeriesCard, bool) {
|
||||
if s == nil || s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
obj, ok := s.cache.GetObject(cacheKey)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cached, ok := obj.(*recentSeriesCardsCacheValue)
|
||||
if !ok || cached == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cached.Cards, true
|
||||
}
|
||||
|
||||
func (s *MediaService) loadRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||
rows, err := s.repo.Media.ListMediaSeriesIndex(ctx, maxMediaSearchLimit, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
// 归组 key 依赖库元数据(display library 路径参与指纹计算)。
|
||||
s.attachLibraryMetadata(ctx, rows)
|
||||
cards := groupMediaSeriesCards(rows)
|
||||
if len(cards) == 0 {
|
||||
return []SeriesCard{}, nil
|
||||
}
|
||||
if len(cards) > limit {
|
||||
cards = cards[:limit]
|
||||
}
|
||||
s.hydrateSeriesCardRows(ctx, cards)
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// hydrateSeriesCardRows 把投影行替换成完整媒体行。
|
||||
//
|
||||
// 归组只需要少量列,但客户端展示卡片要用整行(简介、类型、编码等)。这里
|
||||
// 只针对最终返回的卡片取两次 id 集合,一次 IN 查询补齐,避免把整库大文本列
|
||||
// 都读出来。
|
||||
func (s *MediaService) hydrateSeriesCardRows(ctx context.Context, cards []SeriesCard) {
|
||||
if s == nil || s.repo == nil || len(cards) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cards)*2)
|
||||
ids := make([]string, 0, len(cards)*2)
|
||||
add := func(id string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for i := range cards {
|
||||
add(cards[i].Rep.ID)
|
||||
add(cards[i].LinkMedia.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
rows, err := s.repo.Media.ListMediaByIDs(ctx, ids)
|
||||
if err != nil || len(rows) == 0 {
|
||||
// 补齐失败时保留投影行:卡片列表仍可用,只是缺少大文本字段。
|
||||
return
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, rows)
|
||||
byID := make(map[string]model.Media, len(rows))
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
for i := range cards {
|
||||
if row, ok := byID[cards[i].Rep.ID]; ok {
|
||||
cards[i].Rep = row
|
||||
}
|
||||
if row, ok := byID[cards[i].LinkMedia.ID]; ok {
|
||||
cards[i].LinkMedia = row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaService) recentSeriesCardsCacheKey(limit int, visibility MediaVisibility) string {
|
||||
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
|
||||
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
|
||||
sort.Strings(allowed)
|
||||
sort.Strings(hidden)
|
||||
return "media:obj:recent-series:" + hashObjectCacheKey([]string{
|
||||
fmt.Sprintf("%d:%t", limit, visibility.IncludeNSFW),
|
||||
strings.Join(allowed, ","),
|
||||
strings.Join(hidden, ","),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 「最近添加」必须只做投影扫描,但仍然要返回完整媒体行、正确的集数和
|
||||
// 可用于剧集导航的 key —— 这些语义是 /libraries/{id}/series/episodes 的
|
||||
// 前置条件,任何一项退化成「近似值」都会让点开卡片后选集为空。
|
||||
func TestListRecentSeriesCardsHydratesRowsAndKeepsExactCounts(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||
rows := make([]model.Media, 0, 64)
|
||||
// 长剧:48 集,最新一集入库时间最晚,必须排在首位。
|
||||
for i := 1; i <= 48; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("long-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "长剧",
|
||||
Path: fmt.Sprintf("/media/anime/长剧/Season 1/长剧.S01E%02d.mkv", i),
|
||||
Overview: "简介内容",
|
||||
Genres: "Animation",
|
||||
PosterURL: "/media/anime/长剧/poster.jpg",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
ReleaseDate: "2026-01-01",
|
||||
})
|
||||
}
|
||||
// 另一部短剧,入库更早。
|
||||
for i := 1; i <= 2; i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("short-%02d", i), CreatedAt: now.Add(-time.Hour)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "短剧",
|
||||
Path: fmt.Sprintf("/media/anime/短剧/Season 1/短剧.S01E%02d.mkv", i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
ReleaseDate: "2025-05-05",
|
||||
})
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 50).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
cards, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("cards = %d, want 2", len(cards))
|
||||
}
|
||||
if cards[0].Count != 48 {
|
||||
t.Fatalf("长剧 count = %d, want 48 (不能只统计扫描窗口内的行)", cards[0].Count)
|
||||
}
|
||||
// 投影行没有 overview/genres,返回前必须补齐完整体。
|
||||
if cards[0].Rep.Overview == "" || cards[0].Rep.Genres == "" {
|
||||
t.Fatalf("representative row was not hydrated: %#v", cards[0].Rep)
|
||||
}
|
||||
if cards[0].Rep.LibraryName != lib.Name {
|
||||
t.Fatalf("library metadata missing on hydrated row: %q", cards[0].Rep.LibraryName)
|
||||
}
|
||||
|
||||
// 卡片 key 必须能解析出同一部剧的全部剧集(详情页选集依赖它)。
|
||||
episodes, err := svc.ListLibrarySeriesEpisodes(t.Context(), lib.ID, cards[0].Key, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(episodes) != 48 {
|
||||
t.Fatalf("episodes for card key = %d, want 48", len(episodes))
|
||||
}
|
||||
}
|
||||
|
||||
// 结果缓存命中后必须返回同样的卡片,且不能因为缓存把调用方可见的切片搞乱。
|
||||
func TestListRecentSeriesCardsCachesResult(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("movie-%d", i), CreatedAt: time.Now().Add(-time.Duration(i) * time.Hour)},
|
||||
LibraryID: lib.ID,
|
||||
Title: fmt.Sprintf("电影 %d", i),
|
||||
Path: fmt.Sprintf("/media/movies/电影 %d/movie.mkv", i),
|
||||
ReleaseDate: "2024-01-01",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||
t.Cleanup(func() { cache.Close() })
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
|
||||
|
||||
first, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first) != 2 || len(second) != 2 {
|
||||
t.Fatalf("first = %d second = %d, want 2/2", len(first), len(second))
|
||||
}
|
||||
if first[0].Key != second[0].Key || first[0].Count != second[0].Count {
|
||||
t.Fatalf("cached result mismatch: %#v vs %#v", first[0], second[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListMediaVisibleSortedPage reuses grouped rows and a small position index.
|
||||
// Only the requested page is copied; pagination does not sort the full library
|
||||
// again or retain a second full-size MediaItem slice for every sort order.
|
||||
func (s *MediaService) ListMediaVisibleSortedPage(
|
||||
ctx context.Context,
|
||||
libraryID string,
|
||||
page, pageSize int,
|
||||
visibility MediaVisibility,
|
||||
filters MediaListFilters,
|
||||
spec MediaSortSpec,
|
||||
history map[string]time.Time,
|
||||
) ([]MediaItem, int64, error) {
|
||||
grouped, err := s.groupedMediaVisibleFiltered(ctx, libraryID, visibility, filters)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
spec = NormalizeMediaSort(spec.Field, spec.Order)
|
||||
indices := s.sortedMediaIndices(grouped, spec, history)
|
||||
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
||||
total := len(grouped.Items)
|
||||
// Division avoids overflowing (page-1)*pageSize on an excessive page value.
|
||||
if total == 0 || page-1 > (total-1)/pageSize {
|
||||
return []MediaItem{}, int64(total), nil
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
end := min(start+pageSize, total)
|
||||
items := make([]MediaItem, 0, end-start)
|
||||
for _, index := range indices[start:end] {
|
||||
items = append(items, grouped.Items[index])
|
||||
}
|
||||
return items, int64(total), nil
|
||||
}
|
||||
|
||||
func (s *MediaService) sortedMediaIndices(grouped *groupedMediaCacheValue, spec MediaSortSpec, history map[string]time.Time) []int {
|
||||
// Playback history changes independently of the media cache and is personal.
|
||||
// Keep this explicit sort live rather than caching another user's history.
|
||||
if s.cache == nil || spec.Field == "last_played" || len(grouped.Items) == 0 {
|
||||
return sortMediaIndices(grouped.Items, spec, history)
|
||||
}
|
||||
key := fmt.Sprintf("media:obj:sort:%s:%s:%s", grouped.Revision, spec.Field, spec.Order)
|
||||
value, _, _ := s.mediaSortFlight.Do(key, func() (any, error) {
|
||||
if cached, ok := s.cache.GetObject(key); ok {
|
||||
if indices, ok := cached.([]int); ok {
|
||||
return indices, nil
|
||||
}
|
||||
}
|
||||
indices := sortMediaIndices(grouped.Items, spec, history)
|
||||
s.cache.SetObject(key, indices, s.mediaObjectTTL())
|
||||
return indices, nil
|
||||
})
|
||||
return value.([]int)
|
||||
}
|
||||
|
||||
func sortMediaIndices(items []MediaItem, spec MediaSortSpec, history map[string]time.Time) []int {
|
||||
indices := make([]int, len(items))
|
||||
for i := range indices {
|
||||
indices[i] = i
|
||||
}
|
||||
sort.SliceStable(indices, func(i, j int) bool {
|
||||
a, b := items[indices[i]], items[indices[j]]
|
||||
cmp := compareMediaByField(a, b, spec, history)
|
||||
if cmp == 0 {
|
||||
cmp = compareID(a.ID, b.ID)
|
||||
}
|
||||
return cmp < 0
|
||||
})
|
||||
return indices
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestSortedMediaPageIncludesRowsBeyondSearchLimit(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "Large", Path: "/media/large", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This must cross the historical 50,000-row boundary: a smaller fixture
|
||||
// cannot detect truncation before grouping, counting, and sorting.
|
||||
now := time.Now()
|
||||
for start := 0; start <= maxMediaSearchLimit; start += 500 {
|
||||
var rows []model.Media
|
||||
for i := start; i < min(start+500, maxMediaSearchLimit+1); i++ {
|
||||
rows = append(rows, model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("large-%05d", i), CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Path: fmt.Sprintf("/media/large/%05d.mkv", i),
|
||||
Title: fmt.Sprintf("Movie %05d", i), TMDbID: i + 1,
|
||||
})
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
items, total, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, maxMediaSearchLimit/100+1, 100,
|
||||
MediaVisibility{IncludeNSFW: true}, MediaListFilters{}, MediaSortSpec{Field: "title", Order: "asc"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != int64(maxMediaSearchLimit+1) || len(items) != 1 || items[0].ID != "large-50000" {
|
||||
t.Fatalf("total=%d, page length=%d, want 50001 rows and final item large-50000", total, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedMediaPagesReuseIndicesAndRefreshAfterInvalidation(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "alpha"}, LibraryID: lib.ID, Path: "/media/movies/a.mkv", Title: "Alpha", TMDbID: 1, SizeBytes: 100},
|
||||
{Base: model.Base{ID: "alpha-alt"}, LibraryID: lib.ID, Path: "/media/movies/a-alt.mkv", Title: "Alpha", TMDbID: 1, SizeBytes: 50},
|
||||
{Base: model.Base{ID: "beta"}, LibraryID: lib.ID, Path: "/media/movies/b.mkv", Title: "Beta", TMDbID: 2},
|
||||
{Base: model.Base{ID: "hidden"}, LibraryID: lib.ID, Path: "/media/movies/hidden.mkv", Title: "Hidden", TMDbID: 3, NSFW: true},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
visibility := MediaVisibility{IncludeNSFW: false}
|
||||
spec := MediaSortSpec{Field: "title", Order: "asc"}
|
||||
first, total, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 1, 1, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || total != 2 || len(first) != 1 || first[0].ID != "alpha" || len(first[0].Versions) != 2 {
|
||||
t.Fatalf("first=%#v, total=%d, error=%v", first, total, err)
|
||||
}
|
||||
grouped, err := svc.groupedMediaVisibleFiltered(t.Context(), lib.ID, visibility, MediaListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := svc.sortedMediaIndices(grouped, spec, nil)
|
||||
second, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 2, 1, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || len(second) != 1 || second[0].ID != "beta" {
|
||||
t.Fatalf("second=%#v, error=%v", second, err)
|
||||
}
|
||||
cachedIndex := svc.sortedMediaIndices(grouped, spec, nil)
|
||||
if &index[0] != &cachedIndex[0] {
|
||||
t.Fatal("pagination rebuilt the sorted position index")
|
||||
}
|
||||
// The returned page is independently writable without corrupting cached rows.
|
||||
first[0].Title = "Modified by caller"
|
||||
if err := db.Model(&model.Media{}).Where("id = ?", "beta").Update("title", "Aardvark").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.invalidateMediaCache(t.Context())
|
||||
fresh, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, 1, 1, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || len(fresh) != 1 || fresh[0].ID != "beta" {
|
||||
t.Fatalf("updated page=%#v, error=%v", fresh, err)
|
||||
}
|
||||
empty, _, err := svc.ListMediaVisibleSortedPage(t.Context(), lib.ID, math.MaxInt, 100, visibility, MediaListFilters{}, spec, nil)
|
||||
if err != nil || empty == nil || len(empty) != 0 {
|
||||
t.Fatalf("out-of-range page=%#v, error=%v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSortIndexDoesNotCachePersonalPlaybackHistory(t *testing.T) {
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), nil).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
grouped := &groupedMediaCacheValue{Revision: "history-test", Items: []MediaItem{
|
||||
{Media: model.Media{Base: model.Base{ID: "a"}}},
|
||||
{Media: model.Media{Base: model.Base{ID: "b"}}},
|
||||
}}
|
||||
spec := MediaSortSpec{Field: "last_played", Order: "desc"}
|
||||
first := svc.sortedMediaIndices(grouped, spec, map[string]time.Time{"a": time.Now()})
|
||||
second := svc.sortedMediaIndices(grouped, spec, map[string]time.Time{"b": time.Now()})
|
||||
if first[0] != 0 || second[0] != 1 {
|
||||
t.Fatalf("history sorts = %v, %v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMediaSortIndex(b *testing.B) {
|
||||
items := make([]MediaItem, 10000)
|
||||
for i := range items {
|
||||
items[i].ID = fmt.Sprint(i)
|
||||
items[i].Title = fmt.Sprintf("Movie %05d", len(items)-i)
|
||||
}
|
||||
spec := MediaSortSpec{Field: "title", Order: "asc"}
|
||||
b.Run("sort-every-page", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
SortMediaItems(items, spec.Field, spec.Order, nil)
|
||||
}
|
||||
})
|
||||
b.Run("cached-position-index", func(b *testing.B) {
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), nil).
|
||||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||||
grouped := &groupedMediaCacheValue{Items: items, Revision: "benchmark"}
|
||||
svc.sortedMediaIndices(grouped, spec, nil)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
svc.sortedMediaIndices(grouped, spec, nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
+29
-26
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -144,32 +145,34 @@ type localMediaProbeTask struct {
|
||||
}
|
||||
|
||||
type existingLocalMedia struct {
|
||||
LibraryRootID string
|
||||
RelativePath string
|
||||
Title string
|
||||
OriginalName string
|
||||
EpisodeTitle string
|
||||
SizeBytes int64
|
||||
DurationSec int
|
||||
Width int
|
||||
Height int
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Container string
|
||||
STRMURL string
|
||||
FileID string
|
||||
PosterURL string
|
||||
BackdropURL string
|
||||
Overview string
|
||||
Year int
|
||||
ReleaseDate string
|
||||
Rating float32
|
||||
TMDbID int
|
||||
BangumiID int
|
||||
DoubanID string
|
||||
TheTVDBID string
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
AvailabilityState string
|
||||
MissingSince *time.Time
|
||||
LibraryRootID string
|
||||
RelativePath string
|
||||
Title string
|
||||
OriginalName string
|
||||
EpisodeTitle string
|
||||
SizeBytes int64
|
||||
DurationSec int
|
||||
Width int
|
||||
Height int
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Container string
|
||||
STRMURL string
|
||||
FileID string
|
||||
PosterURL string
|
||||
BackdropURL string
|
||||
Overview string
|
||||
Year int
|
||||
ReleaseDate string
|
||||
Rating float32
|
||||
TMDbID int
|
||||
BangumiID int
|
||||
DoubanID string
|
||||
TheTVDBID string
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
// EpisodeFraction 是集号的小数部分(S01E11.5 → 0.5);变化同样要触发刷新,
|
||||
// 否则已有行上的「半集」标记不会被写回。
|
||||
EpisodeFraction float64
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
|
||||
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
|
||||
}
|
||||
rows, err := query.
|
||||
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status").
|
||||
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "episode_fraction", "genres", "countries", "languages", "nsfw", "scrape_status", "availability_state", "missing_since").
|
||||
Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -80,6 +80,8 @@ func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context,
|
||||
&row.Languages,
|
||||
&row.NSFW,
|
||||
&row.ScrapeStatus,
|
||||
&row.AvailabilityState,
|
||||
&row.MissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ func TestScanLibraryMapsPersistedHostLibraryPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePathDeletesVanishedMedia(t *testing.T) {
|
||||
func TestRemovePathRetainsVanishedMedia(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||
sc.SetRuntimeCache(cache)
|
||||
@@ -589,8 +589,15 @@ func TestRemovePathDeletesVanishedMedia(t *testing.T) {
|
||||
if removed != 1 {
|
||||
t.Fatalf("removed = %d, want 1", removed)
|
||||
}
|
||||
if countMedia(t, repos) != 0 {
|
||||
t.Fatal("expected 0 media after removal")
|
||||
if countMedia(t, repos) != 1 {
|
||||
t.Fatal("missing media metadata must be retained")
|
||||
}
|
||||
var missing model.Media
|
||||
if err := repos.DB.Where("path = ?", file).First(&missing).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if missing.AvailabilityState != "missing" || missing.MissingSince == nil {
|
||||
t.Fatalf("missing state not recorded: %#v", missing)
|
||||
}
|
||||
if cache.GetJSON(t.Context(), "media:list:test", &cached) {
|
||||
t.Fatal("vanished media removal should invalidate media cache")
|
||||
|
||||
@@ -67,8 +67,16 @@ func TestScanLibraryScansMultipleRootsAndPrunesPerRoot(t *testing.T) {
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Path != fileB {
|
||||
t.Fatalf("remaining rows = %#v, want offline root media preserved", rows)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("remaining rows = %#v, want both media records preserved", rows)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Path == fileA && row.AvailabilityState != "missing" {
|
||||
t.Fatalf("vanished file was not marked missing: %#v", row)
|
||||
}
|
||||
if row.Path == fileB && row.AvailabilityState != "available" {
|
||||
t.Fatalf("offline root status was changed: %#v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool,
|
||||
}
|
||||
existing, exists := in.existingMedia[in.cleanPath]
|
||||
isNewMedia := !exists
|
||||
if exists && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) {
|
||||
if exists && existing.AvailabilityState != "missing" && existing.MissingSince == nil && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) {
|
||||
// Unchanged rows are skipped, but a row that never received ffprobe data
|
||||
// still needs a backfill probe so installing ffmpeg later repairs the library.
|
||||
s.queueProbeBackfillIfNeeded(in.path, in.ext, existing)
|
||||
|
||||
@@ -372,7 +372,14 @@ func TestScanLibraryPrunesMissingMedia(t *testing.T) {
|
||||
if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("media count = %d, want 1", count)
|
||||
if count != 2 {
|
||||
t.Fatalf("media count = %d, want current and missing records preserved", count)
|
||||
}
|
||||
var missing model.Media
|
||||
if err := db.First(&missing, "id = ?", stale.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if missing.AvailabilityState != "missing" {
|
||||
t.Fatalf("stale media state = %q, want missing", missing.AvailabilityState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestScanRestoresUnchangedMissingFileAndMetadata(t *testing.T) {
|
||||
scanner, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
library := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &library); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "Example (2024).mkv")
|
||||
writeTestFile(t, path, "unchanged content")
|
||||
if _, err := scanner.ScanLibrary(t.Context(), library.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var original model.Media
|
||||
if err := repos.DB.Where("path = ?", path).First(&original).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Model(&original).Updates(map[string]any{
|
||||
"title": "Manually matched title", "scrape_status": "matched", "tm_db_id": 42,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed, err := scanner.RemovePath(t.Context(), path); err != nil || changed != 1 {
|
||||
t.Fatalf("mark missing: changed=%d err=%v", changed, err)
|
||||
}
|
||||
if changed, err := scanner.RemovePath(t.Context(), path); err != nil || changed != 0 {
|
||||
t.Fatalf("repeated missing event: changed=%d err=%v", changed, err)
|
||||
}
|
||||
writeTestFile(t, path, "unchanged content")
|
||||
if _, err := scanner.ScanLibrary(t.Context(), library.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored model.Media
|
||||
if err := repos.DB.Where("path = ?", path).First(&restored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.ID != original.ID || restored.TMDbID != 42 || restored.Title != "Manually matched title" {
|
||||
t.Fatalf("restored file lost identity or metadata: %#v", restored)
|
||||
}
|
||||
if restored.AvailabilityState != "available" || restored.MissingSince != nil || restored.LastSeenAt == nil {
|
||||
t.Fatalf("unchanged restored file stayed missing: %#v", restored)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,11 +13,15 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// RemovePath 移除磁盘上已不存在的媒体记录。普通的删除仍做物理删除;
|
||||
// 可确认是 STRM 扩展名改名的路径则先保留软删除墓碑,供后续 ingest 继承元数据。
|
||||
// RemovePath 将确认不存在的媒体标记为失联;只有可确认的 STRM 扩展名改名
|
||||
// 才保留软删除墓碑,供后续 ingest 继承元数据。
|
||||
func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return 0, nil // still exists; nothing to remove
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
// Permission, I/O, and mount errors are not evidence that a file was
|
||||
// removed. Preserve records until the path can be inspected reliably.
|
||||
return 0, nil
|
||||
}
|
||||
// 目录整体消失(删除/改名离开):连同其子树下的媒体行一并移除。
|
||||
// 此前只删 path 精确匹配的行——目录本身通常没有 media 行,导致目录
|
||||
@@ -33,7 +38,7 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
|
||||
return 0, err
|
||||
}
|
||||
softIDs := make([]string, 0, len(rows))
|
||||
hardIDs := make([]string, 0, len(rows))
|
||||
missingIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// LIKE 里的 % _ 是通配符(候选集只会偏大),用 Go 前缀精确过滤,
|
||||
// 避免对含 % / _ 的路径误删。
|
||||
@@ -43,12 +48,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
|
||||
// A vanished STRM with a live sibling (foo.strm <-> foo.mkv.strm)
|
||||
// is a rename. Keep a soft-deleted tombstone so the later ingest can
|
||||
// adopt its ID and scraped metadata even if fsnotify delivers the
|
||||
// remove event before the create event. A true deletion stays hard.
|
||||
// remove event before the create event.
|
||||
if row.Path == path && liveSTRMPathAlias(path) {
|
||||
softIDs = append(softIDs, row.ID)
|
||||
continue
|
||||
}
|
||||
hardIDs = append(hardIDs, row.ID)
|
||||
missingIDs = append(missingIDs, row.ID)
|
||||
}
|
||||
var removed int64
|
||||
if len(softIDs) > 0 {
|
||||
@@ -60,14 +65,12 @@ func (s *ScannerService) RemovePath(ctx context.Context, path string) (int64, er
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
if len(hardIDs) > 0 {
|
||||
res := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("id IN ?", hardIDs).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
if len(missingIDs) > 0 {
|
||||
count, err := s.markMediaMissingByIDs(ctx, missingIDs)
|
||||
if err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
removed += count
|
||||
}
|
||||
if removed > 0 {
|
||||
s.invalidateMediaCache(ctx)
|
||||
@@ -82,7 +85,7 @@ const strmRenameTombstoneRetention = 7 * 24 * time.Hour
|
||||
|
||||
// purgeExpiredSTRMTombstones keeps the soft-delete grace period bounded.
|
||||
// It is deliberately restricted to deleted media rows under a STRM alias
|
||||
// workflow; normal deletions are still hard-deleted immediately.
|
||||
// workflow; ordinary missing files retain their metadata for recovery.
|
||||
func (s *ScannerService) purgeExpiredSTRMTombstones(ctx context.Context) {
|
||||
cutoff := time.Now().Add(-strmRenameTombstoneRetention)
|
||||
if err := s.repo.DB.WithContext(ctx).Unscoped().
|
||||
@@ -121,7 +124,7 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string
|
||||
}
|
||||
stale = append(stale, row.ID)
|
||||
}
|
||||
return s.deleteMediaByIDs(ctx, stale, true)
|
||||
return s.markMediaMissingByIDs(ctx, stale)
|
||||
}
|
||||
|
||||
func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) {
|
||||
@@ -158,7 +161,33 @@ func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID
|
||||
}
|
||||
stale = append(stale, row.ID)
|
||||
}
|
||||
return s.deleteMediaByIDs(ctx, stale, true)
|
||||
return s.markMediaMissingByIDs(ctx, stale)
|
||||
}
|
||||
|
||||
func (s *ScannerService) markMediaMissingByIDs(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
const batch = 500
|
||||
var changed int64
|
||||
now := time.Now()
|
||||
for i := 0; i < len(ids); i += batch {
|
||||
end := i + batch
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
result := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id IN ? AND (availability_state IS NULL OR availability_state <> ?)", ids[i:end], "missing").
|
||||
Updates(map[string]any{"availability_state": "missing", "missing_since": now})
|
||||
if result.Error != nil {
|
||||
return changed, result.Error
|
||||
}
|
||||
changed += result.RowsAffected
|
||||
}
|
||||
if changed > 0 {
|
||||
s.invalidateMediaCache(ctx)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func pathBelongsToRoot(pathValue, rootPath string) bool {
|
||||
|
||||
@@ -202,7 +202,7 @@ func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.L
|
||||
func existingLocalMediaFileIDs(existingMedia map[string]existingLocalMedia) map[string]string {
|
||||
seenInodes := make(map[string]string)
|
||||
for path, existing := range existingMedia {
|
||||
if existing.FileID != "" {
|
||||
if existing.FileID != "" && existing.AvailabilityState != "missing" {
|
||||
seenInodes[existing.FileID] = path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestIngestPathAdoptsSoftDeletedSTRMRenameTombstone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
|
||||
func TestRemovePathRetainsTrueDeletionWithoutAlias(t *testing.T) {
|
||||
sc, repos := newScannerTestEnv(t)
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "Anime", Path: root, Type: "anime", Enabled: true}
|
||||
@@ -197,8 +197,15 @@ func TestRemovePathStillHardDeletesTrueDeletionWithoutAlias(t *testing.T) {
|
||||
if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", path).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("true deletion left %d tombstone row(s)", count)
|
||||
if count != 1 {
|
||||
t.Fatalf("missing media count = %d, want metadata retained", count)
|
||||
}
|
||||
var media model.Media
|
||||
if err := repos.DB.Where("path = ?", path).First(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.AvailabilityState != "missing" || media.MissingSince == nil {
|
||||
t.Fatalf("true deletion did not record missing state: %#v", media)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ type SchedulerService struct {
|
||||
readerFilesTTLHours int
|
||||
|
||||
segments *MediaSegmentService
|
||||
backup *BackupService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
|
||||
s.tasks = tasks
|
||||
}
|
||||
|
||||
func (s *SchedulerService) SetBackupService(backup *BackupService) {
|
||||
s.backup = backup
|
||||
}
|
||||
|
||||
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
||||
s.organizePipeline = pipeline
|
||||
}
|
||||
@@ -196,6 +201,13 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
run: s.jobTelegramExpiryWarning,
|
||||
})
|
||||
}
|
||||
if s.backup != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
name: "database_backup",
|
||||
interval: 5 * time.Minute,
|
||||
run: s.jobDatabaseBackup,
|
||||
})
|
||||
}
|
||||
for _, j := range s.jobs {
|
||||
initialDelay := 15 * time.Second
|
||||
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
|
||||
|
||||
@@ -11,6 +11,53 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const lastAutomaticBackupDateKey = "backup.last_automatic_date"
|
||||
|
||||
func (s *SchedulerService) jobDatabaseBackup(ctx context.Context) error {
|
||||
if s == nil || s.backup == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return nil
|
||||
}
|
||||
enabled, _ := s.repo.Setting.Get(ctx, "backup.auto_enabled")
|
||||
if !parseBoolSetting(enabled, false) {
|
||||
return nil
|
||||
}
|
||||
clock, _ := s.repo.Setting.Get(ctx, "backup.auto_time")
|
||||
if clock == "" {
|
||||
clock = "03:00"
|
||||
}
|
||||
parsed, err := time.Parse("15:04", clock)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
now := s.currentTime().In(time.Local)
|
||||
dueAt := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, now.Location())
|
||||
if now.Before(dueAt) {
|
||||
return nil
|
||||
}
|
||||
date := now.Format("2006-01-02")
|
||||
last, _ := s.repo.Setting.Get(ctx, lastAutomaticBackupDateKey)
|
||||
if last == date {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.backup.CreateAutomatic(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record the successful backup before pruning. If retention cleanup fails,
|
||||
// the next scheduler tick must not create another copy for the same day.
|
||||
if err := s.repo.Setting.Set(ctx, lastAutomaticBackupDateKey, date); err != nil {
|
||||
return err
|
||||
}
|
||||
retentionValue, _ := s.repo.Setting.Get(ctx, "backup.retention")
|
||||
retention, err := strconv.Atoi(strings.TrimSpace(retentionValue))
|
||||
if err != nil || retention < 1 {
|
||||
retention = 7
|
||||
}
|
||||
if err := s.backup.Prune(retention); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jobScanLibraries re-walks every enabled library.
|
||||
//
|
||||
// 默认关闭:文件变更由 WatcherService 增量入库,无需周期性全量重扫。
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package service — 服务器身份与移动端能力声明。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 服务器身份与移动端契约常量。
|
||||
const (
|
||||
// ServerIDSettingKey 是持久化实例身份的设置键。普通重启、升级和同实例
|
||||
// 数据恢复都沿用同一个值;把备份克隆成独立服务时需要显式重置。
|
||||
ServerIDSettingKey = "mobile.server_id"
|
||||
|
||||
// MobileAPIVersion 是 /api/v1 契约版本。
|
||||
MobileAPIVersion = "v1"
|
||||
|
||||
// MinSupportedAppVersionCode 是首个移动端契约要求的最低 App versionCode。
|
||||
MinSupportedAppVersionCode = 1
|
||||
|
||||
// MobileServerName 是 server-info 暴露的实例名称。
|
||||
MobileServerName = "MeBox"
|
||||
)
|
||||
|
||||
// ServerCapabilities 是 server-info 的能力开关。
|
||||
//
|
||||
// 每项都由「接口已实现且当前可运行」共同决定;仅注册了 handler 但功能
|
||||
// 不可用时必须返回 false,避免 App 依据虚假能力进入不可用流程。
|
||||
type ServerCapabilities struct {
|
||||
DeviceSessions bool `json:"device_sessions"`
|
||||
PlaybackSessions bool `json:"playback_sessions"`
|
||||
ReaderProgressV1 bool `json:"reader_progress_v1"`
|
||||
ReaderContentBatch bool `json:"reader_content_batch"`
|
||||
LocalTranscode bool `json:"local_transcode"`
|
||||
}
|
||||
|
||||
// ServerInfo 是 GET /api/v1/server-info 的响应。
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
ServerID string `json:"server_id"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
APIVersion string `json:"api_version"`
|
||||
MinSupportedAppVersionCode int `json:"min_supported_app_version_code"`
|
||||
Capabilities ServerCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// ServerIdentityService 负责生成并持久化实例身份。
|
||||
type ServerIdentityService struct {
|
||||
repo *repository.Container
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
cached string
|
||||
}
|
||||
|
||||
// NewServerIdentityService 创建实例身份服务。
|
||||
func NewServerIdentityService(repo *repository.Container, version string) *ServerIdentityService {
|
||||
return &ServerIdentityService{repo: repo, version: strings.TrimSpace(version)}
|
||||
}
|
||||
|
||||
// ServerID 返回持久化的实例 ID,首次调用时生成并写入设置表。
|
||||
func (s *ServerIdentityService) ServerID(ctx context.Context) (string, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return "", errors.New("server identity unavailable")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cached != "" {
|
||||
return s.cached, nil
|
||||
}
|
||||
stored, err := s.repo.Setting.Get(ctx, ServerIDSettingKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stored = strings.TrimSpace(stored)
|
||||
if stored != "" {
|
||||
s.cached = stored
|
||||
return stored, nil
|
||||
}
|
||||
generated := uuid.NewString()
|
||||
if err := s.repo.Setting.Set(ctx, ServerIDSettingKey, generated); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.cached = generated
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
// Info 组装 server-info 响应。capabilities 由调用方按真实可用服务推导。
|
||||
func (s *ServerIdentityService) Info(ctx context.Context, capabilities ServerCapabilities) (ServerInfo, error) {
|
||||
serverID, err := s.ServerID(ctx)
|
||||
if err != nil {
|
||||
return ServerInfo{}, err
|
||||
}
|
||||
version := strings.TrimSpace(s.version)
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
return ServerInfo{
|
||||
Name: MobileServerName,
|
||||
ServerID: serverID,
|
||||
ServerVersion: version,
|
||||
APIVersion: MobileAPIVersion,
|
||||
MinSupportedAppVersionCode: MinSupportedAppVersionCode,
|
||||
Capabilities: capabilities,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CurrentServerCapabilities 依据服务容器推导移动端能力。
|
||||
//
|
||||
// M1 只启用了设备会话;播放会话、阅读 v1 进度与批量正文属于后续阶段,
|
||||
// 在对应任务真正实现并通过契约测试前保持 false。
|
||||
func CurrentServerCapabilities(c *Container) ServerCapabilities {
|
||||
return ServerCapabilities{
|
||||
DeviceSessions: c != nil && c.AuthSessions != nil,
|
||||
PlaybackSessions: false,
|
||||
ReaderProgressV1: false,
|
||||
ReaderContentBatch: false,
|
||||
LocalTranscode: false,
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ type Container struct {
|
||||
OrganizePipeline *OrganizePipelineService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
AuthSessions *AuthSessionService
|
||||
ServerIdentity *ServerIdentityService
|
||||
ApiConfig *ApiConfigService
|
||||
Device *DeviceService
|
||||
Telegram *TelegramService
|
||||
|
||||
@@ -63,6 +63,9 @@ func (b *serviceContainerBuilder) startRealtimeServices() {
|
||||
b.c.WSHub = NewHub(b.log)
|
||||
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
|
||||
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
|
||||
if b.repos != nil {
|
||||
b.c.Tasks.SetPersistence(b.repos.DB)
|
||||
}
|
||||
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
|
||||
|
||||
b.c.SSEHub = NewSSEHub(b.log)
|
||||
@@ -206,6 +209,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
|
||||
)
|
||||
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
||||
b.c.Scheduler.SetBackupService(b.c.Backup)
|
||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||
b.c.Scheduler.SetSegments(b.c.Segments)
|
||||
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
|
||||
@@ -234,10 +238,20 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
|
||||
func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
b.c.Token = NewTokenService(b.cfg, b.log, b.repos)
|
||||
b.c.AuthSessions = NewAuthSessionService(b.log, b.repos)
|
||||
b.c.ServerIdentity = NewServerIdentityService(b.repos, b.version)
|
||||
// 刷新令牌轮换时必须校验所属会话仍然有效,否则被踢设备可以用旧刷新
|
||||
// 令牌换出新令牌。
|
||||
b.c.Token.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||
_, err := b.c.AuthSessions.Validate(ctx, sessionID, userID)
|
||||
return err
|
||||
})
|
||||
b.c.Auth = NewAuthService(b.cfg, b.log, b.repos, b.c.Token, b.c.Permissions)
|
||||
b.c.Auth.SetAuthSessionService(b.c.AuthSessions)
|
||||
b.c.Sessions = NewSessionTrackerService(b.log)
|
||||
b.c.Device = NewDeviceService(b.log, b.repos)
|
||||
b.c.Device.SetSessionTracker(b.c.Sessions)
|
||||
b.c.Device.SetAuthSessionService(b.c.AuthSessions)
|
||||
// Telegram 通知:未启用时 Start 不会占用 goroutine,SendToUser 静默跳过。
|
||||
b.c.Telegram = NewTelegramService(b.log, b.repos)
|
||||
b.c.Device.SetNotifier(b.c.Telegram.SendToUser)
|
||||
|
||||
@@ -3,11 +3,13 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
|
||||
@@ -145,6 +148,53 @@ func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// `?refresh=1`:客户端手上的直链已被 CDN 403,服务端必须丢掉缓存重新换链,
|
||||
// 不能把同一条死链再发一次;不带该参数时缓存照旧命中,保住快路径。
|
||||
func TestServeFileRefreshDropsCachedDirectLink(t *testing.T) {
|
||||
repos := directRedirectTestRepo(t)
|
||||
seedCloudSTRMMedia(t, repos, "cloud-refresh", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc-refresh")
|
||||
cached := fmt.Sprintf("https://cdn.example/stale.mkv?t=%d", time.Now().Add(30*time.Minute).Unix())
|
||||
fresh := "https://cdn.example/fresh.mkv?t=2"
|
||||
cloud115.SetDownloadURLCache("pc-refresh", cached, "RodelPlayer/2.0")
|
||||
t.Cleanup(func() { cloud115.ClearDownloadURLCache("pc-refresh") })
|
||||
oldMinAge := directLinkRefreshMinAge
|
||||
directLinkRefreshMinAge = 0
|
||||
defer func() { directLinkRefreshMinAge = oldMinAge }()
|
||||
|
||||
run := func(t *testing.T, query string) (cachedAtResolve, location string) {
|
||||
t.Helper()
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
|
||||
SetStrmPlayTargetResolver(func(_ context.Context, _ string, userAgent string) (*StrmPlayResult, error) {
|
||||
cachedAtResolve = cloud115.GetDownloadURLCache("pc-refresh", userAgent)
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: fresh,
|
||||
Link: &cloud.DirectLink{URL: fresh, Headers: map[string]string{"User-Agent": userAgent}},
|
||||
}, nil
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-refresh?token=jwt123"+query, nil)
|
||||
req.Header.Set("User-Agent", "RodelPlayer/2.0")
|
||||
w := httptest.NewRecorder()
|
||||
if err := svc.ServeFile(w, req, "cloud-refresh"); err != nil {
|
||||
t.Fatalf("ServeFile: %v", err)
|
||||
}
|
||||
return cachedAtResolve, w.Header().Get("Location")
|
||||
}
|
||||
|
||||
// 正常播放:缓存里的直链照旧命中,服务端不该白跑一次换链。
|
||||
if got, loc := run(t, ""); got != cached {
|
||||
t.Fatalf("cached link = %q, want the cached %q", got, cached)
|
||||
} else if loc != fresh {
|
||||
t.Fatalf("Location = %q, want the resolved direct link", loc)
|
||||
}
|
||||
|
||||
// 客户端报告直链失效:换链前必须先清掉缓存。
|
||||
if got, loc := run(t, "&refresh=1"); got != "" {
|
||||
t.Fatalf("cached link must be dropped before resolving, got %q", got)
|
||||
} else if loc != fresh {
|
||||
t.Fatalf("Location = %q, want the freshly resolved direct link", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
|
||||
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
|
||||
repos := directRedirectTestRepo(t)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// ServeFile streams the file backing the given media ID using
|
||||
@@ -22,6 +23,47 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
return s.ServeFileWithCloudMode(w, r, mediaID, "")
|
||||
}
|
||||
|
||||
// PickCodeFromPlayTarget 从 /api/strm/play/... 形式的播放目标里取出 pickcode。
|
||||
// 115 直链缓存按 pickcode 分键,客户端要求强制换链时用它定位缓存条目。
|
||||
func PickCodeFromPlayTarget(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(u.Query().Get("pickcode"))
|
||||
}
|
||||
|
||||
// directLinkRefreshMinAge 是「强制换链」的最小间隔:缓存条目刚写入不久就
|
||||
// 收到换链请求时忽略它,避免客户端重试风暴把 115 换链接口打出突发。
|
||||
// 测试会临时改成 0,让「CDN 拒绝 → 立刻换链」的路径可验证。
|
||||
var directLinkRefreshMinAge = 30 * time.Second
|
||||
|
||||
// wantsFreshLink 判断客户端是否要求跳过直链缓存重新换链(`?refresh=1`)。
|
||||
//
|
||||
// 115 直链只有 30 分钟有效期,客户端拿到过期直链被 CDN 403 后需要一条新链;
|
||||
// 正常路径下缓存会先于直链过期,这里是客户端上报失败时的兜底。
|
||||
func wantsFreshLink(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateMediaLinkCache 清除媒体播放目标对应的云盘直链缓存,让下一次解析
|
||||
// 重新换链。非云盘/无 pickcode 的媒体是空操作。
|
||||
func InvalidateMediaLinkCache(m *model.Media) {
|
||||
pickCode := PickCodeFromPlayTarget(MediaSTRMTarget(m))
|
||||
if pickCode == "" {
|
||||
return
|
||||
}
|
||||
cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge)
|
||||
}
|
||||
|
||||
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
|
||||
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
|
||||
// 仍拿不到返回空串。
|
||||
@@ -57,6 +99,11 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
|
||||
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
|
||||
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
|
||||
// 缓存,后续 /api/strm/play 请求直接命中。
|
||||
// `?refresh=1` 表示客户端手上的直链已经失效(被 CDN 403),要求丢掉
|
||||
// 缓存重新换链。
|
||||
if wantsFreshLink(r) {
|
||||
InvalidateMediaLinkCache(m)
|
||||
}
|
||||
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
|
||||
setCloudRedirectNoStore(w)
|
||||
http.Redirect(w, r, direct, http.StatusFound)
|
||||
@@ -110,6 +157,29 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
|
||||
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
|
||||
const directPlayResolveTimeout = 10 * time.Second
|
||||
|
||||
// directPlayInfoResolveTimeout 是播放地址响应(/api/playback/{id}/info)里
|
||||
// 预换链的等待上限。这条路径是用户点播放后的同步阻塞,宁可退回 /api/stream
|
||||
// 跳转,也不能把起播卡在换链上;命中直链缓存时通常只有微秒级。
|
||||
const directPlayInfoResolveTimeout = 3 * time.Second
|
||||
|
||||
// ResolveDirectPlayURL 尝试把媒体的 STRM/网盘目标换成客户端可直接播放的最终
|
||||
// 直链,供 /api/playback/{id}/info 预置给客户端。
|
||||
//
|
||||
// 客户端拿到直链后可以跳过 /api/stream 的 302 往返;在高延迟/跨境链路上,
|
||||
// 这一次「DNS + TCP + TLS + 请求」常常是数秒,正好落在起播关键路径上。
|
||||
// 返回 false 表示必须继续走 /api/stream(本地文件、需要服务端加头的网盘、
|
||||
// 解析失败或超时),行为与改动前一致。
|
||||
func (s *StreamService) ResolveDirectPlayURL(r *http.Request, m *model.Media) (string, bool) {
|
||||
if s == nil || r == nil || m == nil {
|
||||
return "", false
|
||||
}
|
||||
raw := MediaSTRMTarget(m)
|
||||
if raw == "" || !playableSTRMTarget(r.Context(), s.repo, raw, m) {
|
||||
return "", false
|
||||
}
|
||||
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayInfoResolveTimeout)
|
||||
}
|
||||
|
||||
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
|
||||
// 最终直链,供调用方直接 302。
|
||||
//
|
||||
@@ -117,10 +187,18 @@ const directPlayResolveTimeout = 10 * time.Second
|
||||
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
|
||||
// strm 端点跳转,行为不会变差。
|
||||
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
|
||||
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayResolveTimeout)
|
||||
}
|
||||
|
||||
func (s *StreamService) resolveDirectPlayTargetURLWithin(
|
||||
r *http.Request,
|
||||
raw string,
|
||||
timeout time.Duration,
|
||||
) (string, bool) {
|
||||
if s == nil || s.strmResolve == nil || r == nil {
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
|
||||
// 与播放器不匹配(或未命中缓存)的地址。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
)
|
||||
|
||||
// /api/playback/{id}/info 预置换链:能换到直链时返回 true,并把客户端 UA
|
||||
// 透传给 115(直链绑定换取时的 UA,播放器必须用同一个)。
|
||||
func TestResolveDirectPlayURLForPlaybackInfo(t *testing.T) {
|
||||
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
|
||||
var gotUA string
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
|
||||
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
|
||||
gotUA = userAgent
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: direct,
|
||||
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
|
||||
}, nil
|
||||
})
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "cloud-info"},
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
STRMURL: "https://mebox.example/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-info/info", nil)
|
||||
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
|
||||
|
||||
got, ok := svc.ResolveDirectPlayURL(req, m)
|
||||
if !ok || got != direct {
|
||||
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (%q, true)", got, ok, direct)
|
||||
}
|
||||
if gotUA != "MeBox-Android/0.1.0" {
|
||||
t.Fatalf("resolver UA = %q, want the requesting client UA", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件没有可直连的 CDN 地址,信息接口不能凭空给出 direct_url。
|
||||
func TestResolveDirectPlayURLSkipsLocalMedia(t *testing.T) {
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
|
||||
SetStrmPlayTargetResolver(func(context.Context, string, string) (*StrmPlayResult, error) {
|
||||
t.Fatal("本地文件不应该触发换链")
|
||||
return nil, nil
|
||||
})
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "local-1"},
|
||||
Path: "/media/movie.mkv",
|
||||
Container: "mkv",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playback/local-1/info", nil)
|
||||
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
|
||||
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 需要服务端附加其它请求头(Referer 等)的直链不能短路给客户端,否则播放端
|
||||
// 少了鉴权头会 403;这类媒体继续走 /api/stream 由服务端转发。
|
||||
func TestResolveDirectPlayURLSkipsHeaderBoundLinks(t *testing.T) {
|
||||
const direct = "https://cdn.example.com/Movie.mkv"
|
||||
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
|
||||
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: direct,
|
||||
Link: &cloud.DirectLink{
|
||||
URL: direct,
|
||||
Headers: map[string]string{"User-Agent": userAgent, "Referer": "https://pan.example"},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "cloud-header"},
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-header/info", nil)
|
||||
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
|
||||
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
|
||||
@@ -250,21 +251,87 @@ func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWrite
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case result.Link != nil && result.Link.URL != "":
|
||||
return s.ProxyDirect(ctx, w, r, result.Link)
|
||||
case result.RedirectURL != "":
|
||||
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
|
||||
default:
|
||||
link := directLinkOfPlayResult(result)
|
||||
if link == nil {
|
||||
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
|
||||
return ErrStrmProxyNotApplicable
|
||||
}
|
||||
resp, method, err := s.openDirectUpstream(ctx, r, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 直链被 CDN 拒绝:115 直链带 30 分钟过期时间戳,缓存命中也可能拿到已经
|
||||
// 失效的地址。这里清掉缓存重新换链再试一次,避免客户端只看到 403。
|
||||
if directLinkRejected(resp.StatusCode) {
|
||||
_ = resp.Body.Close()
|
||||
if fresh := s.refreshDirectLink(ctx, raw); fresh != nil {
|
||||
if retry, retryMethod, retryErr := s.openDirectUpstream(ctx, r, fresh); retryErr == nil {
|
||||
resp, method = retry, retryMethod
|
||||
}
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return writeDirectUpstream(w, resp, method)
|
||||
}
|
||||
|
||||
// directLinkOfPlayResult 取出可反向代理的直链;本地文件返回 nil。
|
||||
func directLinkOfPlayResult(result *StrmPlayResult) *cloud.DirectLink {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case result.Link != nil && result.Link.URL != "":
|
||||
return result.Link
|
||||
case result.RedirectURL != "":
|
||||
return &cloud.DirectLink{URL: result.RedirectURL}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// directLinkRejected 判断上游状态码是否属于「直链本身失效」(而不是文件/权限
|
||||
// 之类与链接无关的问题)。
|
||||
func directLinkRejected(status int) bool {
|
||||
switch status {
|
||||
case http.StatusForbidden, http.StatusNotFound, http.StatusGone:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// refreshDirectLink 丢弃当前直链缓存并重新换链,返回新的直链;无法换链时返回
|
||||
// nil,调用方继续用原来那条。
|
||||
func (s *StrmService) refreshDirectLink(ctx context.Context, raw string) *cloud.DirectLink {
|
||||
pickCode := PickCodeFromPlayTarget(raw)
|
||||
if pickCode == "" {
|
||||
return nil
|
||||
}
|
||||
if !cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge) {
|
||||
// 缓存刚写入不久:说明失败与链接过期无关,重换一次也会拿到同一条。
|
||||
return nil
|
||||
}
|
||||
fresh, err := s.ResolvePlayTarget(ctx, raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return directLinkOfPlayResult(fresh)
|
||||
}
|
||||
|
||||
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
|
||||
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
|
||||
resp, method, err := s.openDirectUpstream(ctx, r, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return writeDirectUpstream(w, resp, method)
|
||||
}
|
||||
|
||||
// openDirectUpstream 向网盘直链发起一次请求,保留 Range 与 UA 语义。
|
||||
func (s *StrmService) openDirectUpstream(ctx context.Context, r *http.Request, link *cloud.DirectLink) (*http.Response, string, error) {
|
||||
if link == nil || link.URL == "" {
|
||||
return errors.New("空直链")
|
||||
return nil, "", errors.New("空直链")
|
||||
}
|
||||
method := http.MethodGet
|
||||
if r != nil && r.Method == http.MethodHead {
|
||||
@@ -272,7 +339,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, "", err
|
||||
}
|
||||
for k, v := range link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -288,9 +355,13 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
|
||||
}
|
||||
resp, err := s.streamHTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp, method, nil
|
||||
}
|
||||
|
||||
// writeDirectUpstream 把上游响应原样写给客户端。
|
||||
func writeDirectUpstream(w http.ResponseWriter, resp *http.Response, method string) error {
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
|
||||
if value := resp.Header.Get(header); value != "" {
|
||||
w.Header().Set(header, value)
|
||||
@@ -310,7 +381,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
writer = &flushWriter{writer: w, flusher: flusher}
|
||||
}
|
||||
_, err = io.Copy(writer, resp.Body)
|
||||
_, err := io.Copy(writer, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
|
||||
@@ -55,6 +59,134 @@ func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 115 直链只有 30 分钟寿命,缓存命中也可能拿到已经失效的地址。同源转发遇到
|
||||
// CDN 403 时必须丢掉缓存重新换链再试一次,而不是把 403 直接透给客户端。
|
||||
func TestProxyMediaDirectRetriesWithFreshLinkWhenCDNRejects(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
|
||||
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/fresh.mkv" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/x-matroska")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("fresh-bytes"))
|
||||
}))
|
||||
defer cdn.Close()
|
||||
|
||||
// 第一次换链给出「已过期」的旧地址,重新换链才给新地址。
|
||||
var mu sync.Mutex
|
||||
var calls int
|
||||
expiry := time.Now().Add(30 * time.Minute).Unix()
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
attempt := calls
|
||||
mu.Unlock()
|
||||
link := cdn.URL + "/old.mkv"
|
||||
if attempt > 1 {
|
||||
link = cdn.URL + "/fresh.mkv"
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-retry","url":{"url":%q}}}}`,
|
||||
fmt.Sprintf("%s?t=%d&u=1&k=abc", link, expiry))
|
||||
}))
|
||||
defer api.Close()
|
||||
oldPro := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = api.URL
|
||||
defer func() { cloud115.ProAPIBase = oldPro }()
|
||||
defer cloud115.ClearDownloadURLCache("pc-retry")
|
||||
oldMinAge := directLinkRefreshMinAge
|
||||
directLinkRefreshMinAge = 0
|
||||
defer func() { directLinkRefreshMinAge = oldMinAge }()
|
||||
|
||||
media := &model.Media{
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-retry",
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
req.Header.Set("User-Agent", "MeBoxTest/1.0")
|
||||
|
||||
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206 (body=%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "fresh-bytes" {
|
||||
t.Fatalf("body = %q, want the fresh link payload", rec.Body.String())
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls != 2 {
|
||||
t.Fatalf("downurl calls = %d, want 2 (one retry after the CDN rejected the stale link)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 换链后依然是同一条失效直链时,保持原有行为:原样透传 CDN 的状态码。
|
||||
func TestProxyMediaDirectPassesRejectedStatusWhenRefreshCannotHelp(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
|
||||
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer cdn.Close()
|
||||
|
||||
expiry := time.Now().Add(30 * time.Minute).Unix()
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-dead","url":{"url":%q}}}}`,
|
||||
fmt.Sprintf("%s/dead.mkv?t=%d&u=1&k=abc", cdn.URL, expiry))
|
||||
}))
|
||||
defer api.Close()
|
||||
oldPro := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = api.URL
|
||||
defer func() { cloud115.ProAPIBase = oldPro }()
|
||||
defer cloud115.ClearDownloadURLCache("pc-dead")
|
||||
oldMinAge := directLinkRefreshMinAge
|
||||
directLinkRefreshMinAge = 0
|
||||
defer func() { directLinkRefreshMinAge = oldMinAge }()
|
||||
|
||||
media := &model.Media{
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-dead",
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
|
||||
|
||||
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
|
||||
t.Fatalf("ProxyMediaDirect: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want the upstream 403 passed through", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 强制换链只认 /api/strm/play 形式播放目标里的 pickcode。
|
||||
func TestPickCodeFromPlayTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", "pc1"},
|
||||
{"https://nas.local/api/strm/play/cloud115/video.mkv?pickcode=pc2&acct=a1", "pc2"},
|
||||
{"/api/strm/play/cloud115/video.mkv?acct=a1", ""},
|
||||
{"https://cdn.example.test/Movie.mkv?sign=1", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := PickCodeFromPlayTarget(tc.raw); got != tc.want {
|
||||
t.Fatalf("PickCodeFromPlayTarget(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
|
||||
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
|
||||
@@ -182,6 +182,77 @@ func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID
|
||||
return discoverExternalSubtitleTracks(m), nil
|
||||
}
|
||||
|
||||
// subtitleLanguageNames 把文件名后缀/容器里的语言标记折成可读名称。
|
||||
//
|
||||
// 外挂字幕只能从文件名拿到语言(`movie.chs.ass` → `chs`),直接展示给用户
|
||||
// 就是「chs」这种看不懂的值;内嵌轨没有 title 标签时同理。认不出来的标记
|
||||
// 原样保留,交给调用方(例如 Emby 的「字幕 (ASS)」兜底)处理。
|
||||
var subtitleLanguageNames = map[string]string{
|
||||
"zh": "中文",
|
||||
"chi": "中文",
|
||||
"zho": "中文",
|
||||
"cn": "中文",
|
||||
|
||||
"chs": "简体中文",
|
||||
"sc": "简体中文",
|
||||
"zh-cn": "简体中文",
|
||||
"zh-hans": "简体中文",
|
||||
"zh-sg": "简体中文",
|
||||
|
||||
"cht": "繁体中文",
|
||||
"tc": "繁体中文",
|
||||
"zh-tw": "繁体中文",
|
||||
"zh-hk": "繁体中文",
|
||||
"zh-mo": "繁体中文",
|
||||
"zh-hant": "繁体中文",
|
||||
|
||||
"jpn": "日本語",
|
||||
"ja": "日本語",
|
||||
"jp": "日本語",
|
||||
|
||||
"eng": "English",
|
||||
"en": "English",
|
||||
|
||||
"kor": "한국어",
|
||||
"ko": "한국어",
|
||||
|
||||
"yue": "粤语",
|
||||
}
|
||||
|
||||
// subtitleLanguageName 返回语言标记的可读名称,认不出来时返回空串。
|
||||
func subtitleLanguageName(raw string) string {
|
||||
tag := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "_", "-")))
|
||||
if tag == "" {
|
||||
return ""
|
||||
}
|
||||
if name, ok := subtitleLanguageNames[tag]; ok {
|
||||
return name
|
||||
}
|
||||
// `chs[bilibiliid_danmu]`、`chs.ass` 这类带附加信息的标记先取前缀。
|
||||
head := tag
|
||||
if i := strings.IndexAny(head, "[.@"); i > 0 {
|
||||
head = strings.TrimSpace(head[:i])
|
||||
}
|
||||
return subtitleLanguageNames[head]
|
||||
}
|
||||
|
||||
// subtitleDisplayLabel 把语言标记换成可读名称;认不出来时保留原值(含 und)。
|
||||
func subtitleDisplayLabel(raw string) string {
|
||||
if name := subtitleLanguageName(raw); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
// isDanmakuSubtitle 报告字幕文件是否是弹幕转存(`xxx[BilibiliID_danmu].ass`)。
|
||||
// 这类轨道是飘过的弹幕而不是对白字幕,名称里要标出来,避免和对白字幕混淆。
|
||||
func isDanmakuSubtitle(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return strings.Contains(lower, "danmu") ||
|
||||
strings.Contains(lower, "danmaku") ||
|
||||
strings.Contains(name, "弹幕")
|
||||
}
|
||||
|
||||
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
dir := filepath.Dir(m.Path)
|
||||
bases := mediaSidecarBaseVariants(m.Path)
|
||||
@@ -228,9 +299,13 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
|
||||
matchedBase = bases[0]
|
||||
}
|
||||
lang := detectLang(fullName, matchedBase)
|
||||
label := subtitleDisplayLabel(lang)
|
||||
if isDanmakuSubtitle(e.Name()) {
|
||||
label += " · 弹幕"
|
||||
}
|
||||
tracks = append(tracks, SubtitleTrack{
|
||||
Lang: lang,
|
||||
Label: lang,
|
||||
Label: label,
|
||||
Path: filepath.Join(c, e.Name()),
|
||||
Codec: codec,
|
||||
Source: "external",
|
||||
@@ -319,7 +394,11 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
|
||||
}
|
||||
label := strings.TrimSpace(stream.Tags.Title)
|
||||
if label == "" {
|
||||
label = lang
|
||||
// 没有 title 标签时用可读语言名(chi → 中文)代替裸语言码。
|
||||
label = subtitleDisplayLabel(lang)
|
||||
}
|
||||
if label == "" {
|
||||
label = "字幕"
|
||||
}
|
||||
if stream.Disposition.Forced != 0 {
|
||||
label += "(强制)"
|
||||
|
||||
@@ -202,3 +202,103 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||
t.Fatal("ServeRaw accepted a non-subtitle file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleLanguageNameMapsCommonTags(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"chs": "简体中文",
|
||||
"CHS": "简体中文",
|
||||
"zh-Hans": "简体中文",
|
||||
"cht": "繁体中文",
|
||||
"chi": "中文",
|
||||
"zh": "中文",
|
||||
"jpn": "日本語",
|
||||
"eng": "English",
|
||||
"kor": "한국어",
|
||||
"chs[bilibiliid_danmu]": "简体中文",
|
||||
"und": "",
|
||||
"": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := subtitleLanguageName(raw); got != want {
|
||||
t.Errorf("subtitleLanguageName(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleDisplayLabelKeepsUnknownTags(t *testing.T) {
|
||||
// 认不出来的标记原样返回:Emby 的 DisplayTitle 依赖 `und` 走「字幕 (ASS)」兜底。
|
||||
if got := subtitleDisplayLabel("und"); got != "und" {
|
||||
t.Fatalf("subtitleDisplayLabel(und) = %q, want und", got)
|
||||
}
|
||||
if got := subtitleDisplayLabel("weird"); got != "weird" {
|
||||
t.Fatalf("subtitleDisplayLabel(weird) = %q, want weird", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDanmakuSubtitle(t *testing.T) {
|
||||
if !isDanmakuSubtitle("S01E01.chs[BilibiliID_danmu].ass") {
|
||||
t.Fatal("danmaku sidecar was not detected")
|
||||
}
|
||||
if !isDanmakuSubtitle("弹幕.ass") {
|
||||
t.Fatal("Chinese danmaku name was not detected")
|
||||
}
|
||||
if isDanmakuSubtitle("S01E01.chs.ass") {
|
||||
t.Fatal("normal subtitle was misdetected as danmaku")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverExternalSubtitleLabelsAreReadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
videoPath := filepath.Join(dir, "S01E01.mkv")
|
||||
files := map[string]string{
|
||||
"S01E01.chs.ass": "[Script Info]\n",
|
||||
"S01E01.chs[BilibiliID_danmu].ass": "[Script Info]\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
m := &model.Media{Path: videoPath}
|
||||
tracks := discoverExternalSubtitleTracks(m)
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("len(tracks) = %d, want 2: %#v", len(tracks), tracks)
|
||||
}
|
||||
labels := map[string]string{}
|
||||
for _, track := range tracks {
|
||||
labels[filepath.Base(track.Path)] = track.Label
|
||||
}
|
||||
if got := labels["S01E01.chs.ass"]; got != "简体中文" {
|
||||
t.Fatalf("chs.ass label = %q, want 简体中文", got)
|
||||
}
|
||||
if got := labels["S01E01.chs[BilibiliID_danmu].ass"]; got != "简体中文 · 弹幕" {
|
||||
t.Fatalf("danmaku label = %q, want 简体中文 · 弹幕", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSubtitleProbeUsesReadableLanguage(t *testing.T) {
|
||||
var probe embeddedSubtitleProbe
|
||||
raw := []byte(`{"streams":[
|
||||
{"index":2,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"chi"},"disposition":{"default":1}},
|
||||
{"index":3,"codec_name":"subrip","tags":{"language":"jpn"}},
|
||||
{"index":4,"codec_name":"subrip","tags":{"language":"chi","title":"简日双语"}}
|
||||
]}`)
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracks := subtitleTracksFromProbe(probe)
|
||||
if len(tracks) != 3 {
|
||||
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
|
||||
}
|
||||
if got := tracks[0].Label; got != "中文(默认)(内嵌·图片)" {
|
||||
t.Fatalf("bitmap label = %q", got)
|
||||
}
|
||||
if got := tracks[1].Label; got != "日本語(内嵌)" {
|
||||
t.Fatalf("text label = %q", got)
|
||||
}
|
||||
// 容器自带 title 时优先用 title。
|
||||
if got := tracks[2].Label; got != "简日双语(内嵌)" {
|
||||
t.Fatalf("titled label = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -9,12 +10,16 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskStatusRunning = "running"
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusFailed = "failed"
|
||||
TaskStatusRunning = "running"
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusFailed = "failed"
|
||||
TaskStatusInterrupted = "interrupted"
|
||||
|
||||
TaskKindOrganize = "organize"
|
||||
TaskKindScan = "scan"
|
||||
@@ -70,6 +75,26 @@ type TaskTrackerService struct {
|
||||
recent []BackgroundTask
|
||||
maxRecent int
|
||||
now func() time.Time
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// SetPersistence enables durable task history and marks tasks left running by
|
||||
// a previous process as interrupted. Actual task execution is never resumed
|
||||
// automatically; this record is an operator-visible recovery point.
|
||||
func (t *TaskTrackerService) SetPersistence(db *gorm.DB) {
|
||||
if t == nil || db == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.db = db
|
||||
now := t.currentTime()
|
||||
t.mu.Unlock()
|
||||
err := db.Model(&model.BackgroundTaskRecord{}).
|
||||
Where("status = ?", TaskStatusRunning).
|
||||
Updates(map[string]any{"status": TaskStatusInterrupted, "error": "服务在任务完成前重启;请检查文件状态后重新执行", "updated_at": now, "finished_at": now}).Error
|
||||
if err != nil && t.log != nil {
|
||||
t.log.Warn("recover interrupted background tasks failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
type TaskHandle struct {
|
||||
@@ -105,9 +130,11 @@ func (t *TaskTrackerService) Start(kind, name string, update TaskUpdate) *TaskHa
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
applyTaskUpdate(task, update)
|
||||
t.mu.Lock()
|
||||
t.active[task.ID] = task
|
||||
snapshot := cloneBackgroundTask(*task)
|
||||
t.persist(snapshot)
|
||||
t.mu.Unlock()
|
||||
t.publish(snapshot)
|
||||
return &TaskHandle{tracker: t, id: task.ID}
|
||||
@@ -132,18 +159,47 @@ func (t *TaskTrackerService) Snapshot() TaskSnapshot {
|
||||
return TaskSnapshot{}
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
active := make([]BackgroundTask, 0, len(t.active))
|
||||
for _, task := range t.active {
|
||||
active = append(active, cloneBackgroundTask(*task))
|
||||
}
|
||||
recent := make([]BackgroundTask, 0, len(t.recent))
|
||||
db := t.db
|
||||
limit := t.maxRecent
|
||||
if limit < 1 {
|
||||
limit = 30
|
||||
}
|
||||
for _, task := range t.recent {
|
||||
recent = append(recent, cloneBackgroundTask(task))
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if db != nil {
|
||||
var rows []model.BackgroundTaskRecord
|
||||
if err := db.Where("status <> ?", TaskStatusRunning).Order("started_at DESC").Limit(limit).Find(&rows).Error; err == nil {
|
||||
recent = make([]BackgroundTask, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
recent = append(recent, backgroundTaskFromRecord(row))
|
||||
}
|
||||
} else if t.log != nil {
|
||||
t.log.Warn("load background task history failed; using memory history", zap.Error(err))
|
||||
}
|
||||
}
|
||||
return TaskSnapshot{Active: active, Recent: recent}
|
||||
}
|
||||
|
||||
func backgroundTaskFromRecord(row model.BackgroundTaskRecord) BackgroundTask {
|
||||
task := BackgroundTask{
|
||||
ID: row.ID, Kind: row.Kind, Name: row.Name, Status: row.Status,
|
||||
Stage: row.Stage, SourcePath: row.SourcePath, DestPath: row.DestPath,
|
||||
Message: row.Message, Error: row.Error, StartedAt: row.StartedAt,
|
||||
UpdatedAt: row.UpdatedAt, FinishedAt: row.FinishedAt,
|
||||
}
|
||||
_ = json.Unmarshal([]byte(row.Details), &task.Details)
|
||||
_ = json.Unmarshal([]byte(row.Metrics), &task.Metrics)
|
||||
_ = json.Unmarshal([]byte(row.Items), &task.Items)
|
||||
return task
|
||||
}
|
||||
|
||||
func (t *TaskTrackerService) update(id string, update TaskUpdate) {
|
||||
now := t.currentTime()
|
||||
t.mu.Lock()
|
||||
@@ -155,6 +211,7 @@ func (t *TaskTrackerService) update(id string, update TaskUpdate) {
|
||||
applyTaskUpdate(task, update)
|
||||
task.UpdatedAt = now
|
||||
snapshot := cloneBackgroundTask(*task)
|
||||
t.persist(snapshot)
|
||||
t.mu.Unlock()
|
||||
t.publish(snapshot)
|
||||
}
|
||||
@@ -186,6 +243,7 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
|
||||
t.recent = t.recent[:t.maxRecent]
|
||||
}
|
||||
notifier := t.failureNotifier
|
||||
t.persist(snapshot)
|
||||
t.mu.Unlock()
|
||||
t.publish(snapshot)
|
||||
|
||||
@@ -195,6 +253,26 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TaskTrackerService) persist(task BackgroundTask) {
|
||||
if t == nil || t.db == nil {
|
||||
return
|
||||
}
|
||||
encode := func(value any) string {
|
||||
data, _ := json.Marshal(value)
|
||||
return string(data)
|
||||
}
|
||||
row := model.BackgroundTaskRecord{
|
||||
ID: task.ID, Kind: task.Kind, Name: task.Name, Status: task.Status,
|
||||
Stage: task.Stage, SourcePath: task.SourcePath, DestPath: task.DestPath,
|
||||
Message: task.Message, Error: task.Error, Details: encode(task.Details),
|
||||
Metrics: encode(task.Metrics), Items: encode(task.Items), StartedAt: task.StartedAt,
|
||||
UpdatedAt: task.UpdatedAt, FinishedAt: task.FinishedAt,
|
||||
}
|
||||
if err := t.db.Save(&row).Error; err != nil && t.log != nil {
|
||||
t.log.Warn("persist background task failed", zap.String("task_id", task.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// SetFailureNotifier 注入任务失败时的管理员通知回调。
|
||||
func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) {
|
||||
if t == nil {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestTaskTrackerPersistsHistoryAcrossRestart(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
tracker := NewTaskTrackerService(zap.NewNop(), nil)
|
||||
tracker.SetPersistence(db)
|
||||
task := tracker.Start(TaskKindScan, "scan", TaskUpdate{Details: []string{"initial detail"}})
|
||||
task.Update(TaskUpdate{Metrics: map[string]int64{"visited": 42}})
|
||||
task.Finish(nil, TaskUpdate{Message: "done"})
|
||||
restarted := NewTaskTrackerService(zap.NewNop(), nil)
|
||||
restarted.SetPersistence(db)
|
||||
snapshot := restarted.Snapshot()
|
||||
if len(snapshot.Active) != 0 || len(snapshot.Recent) != 1 {
|
||||
t.Fatalf("unexpected history: %#v", snapshot)
|
||||
}
|
||||
recent := snapshot.Recent[0]
|
||||
if recent.Status != TaskStatusCompleted || recent.Metrics["visited"] != 42 || len(recent.Details) != 1 || recent.FinishedAt == nil {
|
||||
t.Fatalf("task history lost fields: %#v", recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTrackerConcurrentUpdatesDoNotRegressFinishedTasks(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
tracker := NewTaskTrackerService(zap.NewNop(), nil)
|
||||
tracker.SetPersistence(db)
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
workers.Add(1)
|
||||
go func(index int) {
|
||||
defer workers.Done()
|
||||
task := tracker.Start(TaskKindScan, fmt.Sprintf("scan-%d", index), TaskUpdate{})
|
||||
task.Update(TaskUpdate{Metrics: map[string]int64{"visited": 1}})
|
||||
task.Finish(nil, TaskUpdate{})
|
||||
task.Update(TaskUpdate{Message: "late update"})
|
||||
_ = tracker.Snapshot()
|
||||
}(i)
|
||||
}
|
||||
workers.Wait()
|
||||
var completed int64
|
||||
if err := db.Model(&model.BackgroundTaskRecord{}).Where("status = ?", TaskStatusCompleted).Count(&completed).Error; err != nil || completed != 12 {
|
||||
t.Fatalf("finished task status regressed: completed=%d err=%v", completed, err)
|
||||
}
|
||||
if snapshot := tracker.Snapshot(); len(snapshot.Active) != 0 || len(snapshot.Recent) != 12 {
|
||||
t.Fatalf("inconsistent task snapshot: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskTrackerMarksAbandonedTasksInterrupted(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
tracker := NewTaskTrackerService(zap.NewNop(), nil)
|
||||
tracker.SetPersistence(db)
|
||||
tracker.Start(TaskKindOrganize, "unfinished", TaskUpdate{})
|
||||
restarted := NewTaskTrackerService(zap.NewNop(), nil)
|
||||
restarted.SetPersistence(db)
|
||||
snapshot := restarted.Snapshot()
|
||||
if len(snapshot.Recent) != 1 || snapshot.Recent[0].Status != TaskStatusInterrupted || snapshot.Recent[0].FinishedAt == nil {
|
||||
t.Fatalf("abandoned task not marked interrupted: %#v", snapshot)
|
||||
}
|
||||
var running int64
|
||||
if err := db.Model(&model.BackgroundTaskRecord{}).Where("status = ?", TaskStatusRunning).Count(&running).Error; err != nil || running != 0 {
|
||||
t.Fatalf("stale running tasks: %d err=%v", running, err)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type Claims struct {
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
// Sid 是设备认证会话 ID。旧令牌没有该字段,继续走兼容路径。
|
||||
Sid string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -60,6 +62,10 @@ type TokenService struct {
|
||||
// rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内
|
||||
// 幂等复用(见 token_refresh_rotation.go)。
|
||||
rotations map[string]rotatedRefreshToken
|
||||
|
||||
// sessionValidator 校验刷新令牌所属认证会话是否仍有效;由服务构建器
|
||||
// 注入,nil 表示不做会话校验(测试或旧配置)。
|
||||
sessionValidator func(ctx context.Context, sessionID, userID string) error
|
||||
}
|
||||
|
||||
// NewTokenService 创建令牌服务实例。
|
||||
@@ -92,19 +98,35 @@ var (
|
||||
|
||||
// IssuePair 为用户签发新的令牌对。
|
||||
func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, false)
|
||||
return s.issuePair(ctx, userID, role, tier, "", false)
|
||||
}
|
||||
|
||||
// IssuePairBestEffort 为登录签发令牌。SQLite 被后台扫描长期写锁占用时,
|
||||
// 登录不能因为 refresh token 暂时无法落库而失败:先返回可用 access token,
|
||||
// 再在后台把 refresh token 补写进库。
|
||||
func (s *TokenService) IssuePairBestEffort(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, true)
|
||||
return s.issuePair(ctx, userID, role, tier, "", true)
|
||||
}
|
||||
|
||||
func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string, bestEffort bool) (*TokenPair, error) {
|
||||
// IssuePairForSession 为指定认证会话签发令牌对,访问令牌携带 sid,
|
||||
// 刷新令牌记录关联该会话。
|
||||
func (s *TokenService) IssuePairForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, sessionID, false)
|
||||
}
|
||||
|
||||
// IssuePairBestEffortForSession 与 IssuePairBestEffort 相同,但绑定会话。
|
||||
func (s *TokenService) IssuePairBestEffortForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, sessionID, true)
|
||||
}
|
||||
|
||||
// SetSessionValidator 注入认证会话校验器。
|
||||
func (s *TokenService) SetSessionValidator(fn func(ctx context.Context, sessionID, userID string) error) {
|
||||
s.sessionValidator = fn
|
||||
}
|
||||
|
||||
func (s *TokenService) issuePair(ctx context.Context, userID, role, tier, sessionID string, bestEffort bool) (*TokenPair, error) {
|
||||
// 生成 Access Token
|
||||
accessToken, err := s.issueAccessToken(userID, role, tier)
|
||||
accessToken, err := s.issueAccessToken(userID, role, tier, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,12 +140,13 @@ func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string,
|
||||
// 存储 Refresh Token 哈希
|
||||
tokenHash := repository.HashToken(refreshToken)
|
||||
rt := &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenDuration),
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: sessionID,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenDuration),
|
||||
}
|
||||
if bestEffort {
|
||||
s.storeRefreshTokenBestEffort(userID, tokenHash, rt.ExpiresAt)
|
||||
s.storeRefreshTokenBestEffort(userID, tokenHash, sessionID, rt.ExpiresAt)
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
@@ -147,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
|
||||
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
|
||||
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
|
||||
// 「最大同时登录终端」只约束未绑定设备会话的旧客户端。
|
||||
//
|
||||
// 移动端每台设备有独立认证会话,刷新令牌归属该会话;如果仍按用户统一
|
||||
// 裁剪活跃令牌,第 N+1 台设备登录(或任一设备刷新)就会把最早那台设备
|
||||
// 的刷新令牌标记为已撤销,那台设备下次刷新收到 SESSION_REVOKED,被迫
|
||||
// 重新登录。设备数量由设备策略单独约束,不能靠撤销刷新令牌实现。
|
||||
if rt.AuthSessionID == "" {
|
||||
if err := s.repo.RefreshToken.RevokeOldestActiveLegacyByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
|
||||
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -162,11 +193,12 @@ func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int {
|
||||
}
|
||||
|
||||
// issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。
|
||||
func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
|
||||
func (s *TokenService) issueAccessToken(userID, role, tier, sessionID string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Tier: tier,
|
||||
Sid: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||
@@ -232,14 +264,28 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
rt = &model.RefreshToken{
|
||||
UserID: pending.UserID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: pending.ExpiresAt,
|
||||
UserID: pending.UserID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: pending.SessionID,
|
||||
ExpiresAt: pending.ExpiresAt,
|
||||
}
|
||||
// 尚未落库的令牌同样受会话撤销约束。
|
||||
if rt.AuthSessionID != "" && s.sessionValidator != nil {
|
||||
if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已撤销
|
||||
if rt.Revoked {
|
||||
// 会话被撤销时必须在宽限期复用之前拦截:否则登出/被踢后的 60 秒
|
||||
// 内,旧刷新令牌仍能从内存复用表换回一对有效令牌。
|
||||
if rt.AuthSessionID != "" && s.sessionValidator != nil {
|
||||
if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的
|
||||
// 客户端拿到 revoked 401 后清空整个会话。
|
||||
if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok {
|
||||
@@ -268,14 +314,22 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke
|
||||
return nil, ErrUserExpired
|
||||
}
|
||||
|
||||
// 会话被踢出/撤销后,旧刷新令牌不能再换出新会话。检查必须发生在撤销
|
||||
// 旧令牌之前,否则一次失败的重试会把仍然有效的令牌标记成已撤销。
|
||||
if rt.AuthSessionID != "" && s.sessionValidator != nil {
|
||||
if err := s.sessionValidator(ctx, rt.AuthSessionID, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销旧的 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)
|
||||
|
||||
// 签发新的令牌对
|
||||
pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
|
||||
// 签发新的令牌对;轮换后的令牌继续归属同一个认证会话。
|
||||
pair, err := s.IssuePairBestEffortForSession(ctx, user.ID, user.Role, user.Tier, rt.AuthSessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,12 @@ const loginRefreshTokenStoreTimeout = 750 * time.Millisecond
|
||||
|
||||
type pendingRefreshToken struct {
|
||||
UserID string
|
||||
SessionID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, expiresAt time.Time) {
|
||||
if !s.trackDelayedStore(userID, tokenHash, expiresAt) {
|
||||
func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash, sessionID string, expiresAt time.Time) {
|
||||
if !s.trackDelayedStore(userID, tokenHash, sessionID, expiresAt) {
|
||||
return
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
@@ -29,16 +30,17 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
||||
defer cancel()
|
||||
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
|
||||
return s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err)
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err)
|
||||
case <-time.After(loginRefreshTokenStoreTimeout):
|
||||
if s.log != nil {
|
||||
s.log.Warn("refresh token store delayed; login will continue",
|
||||
@@ -47,12 +49,12 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
||||
}
|
||||
go func() {
|
||||
err := <-done
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err)
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash string, expiresAt time.Time, err error) {
|
||||
func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID string, expiresAt time.Time, err error) {
|
||||
if err == nil {
|
||||
s.untrackDelayedStore(userID, tokenHash)
|
||||
return
|
||||
@@ -63,7 +65,7 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
}
|
||||
s.storeRefreshTokenEventually(userID, tokenHash, expiresAt)
|
||||
s.storeRefreshTokenEventually(userID, tokenHash, sessionID, expiresAt)
|
||||
return
|
||||
}
|
||||
s.untrackDelayedStore(userID, tokenHash)
|
||||
@@ -72,22 +74,23 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, expiresAt time.Time) {
|
||||
func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash, sessionID string, expiresAt time.Time) {
|
||||
defer s.untrackDelayedStore(userID, tokenHash)
|
||||
delay := time.Second
|
||||
for attempt := 1; attempt <= 8; attempt++ {
|
||||
timer := time.NewTimer(delay)
|
||||
<-timer.C
|
||||
// 令牌可能已在等待期间被轮换/登出(从 pending 表移除),
|
||||
// 此时绝不能再写库,否则会复活一个已被替换的旧令牌。
|
||||
// ?????????????/???? 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,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
cancel()
|
||||
if err == nil {
|
||||
@@ -114,7 +117,7 @@ func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, exp
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt time.Time) bool {
|
||||
func (s *TokenService) trackDelayedStore(userID, tokenHash, sessionID string, expiresAt time.Time) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
@@ -126,7 +129,7 @@ func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt tim
|
||||
if _, ok := s.delayedStores[tokenHash]; ok {
|
||||
return false
|
||||
}
|
||||
s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, ExpiresAt: expiresAt}
|
||||
s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, SessionID: sessionID, ExpiresAt: expiresAt}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -139,7 +142,7 @@ func (s *TokenService) untrackDelayedStore(userID, tokenHash string) {
|
||||
s.delayedStoreMu.Unlock()
|
||||
}
|
||||
|
||||
// pendingDelayedStore 返回尚未落库的 refresh token 信息(如果存在)。
|
||||
// pendingDelayedStore ??????? refresh token ?????????
|
||||
func (s *TokenService) pendingDelayedStore(tokenHash string) (pendingRefreshToken, bool) {
|
||||
if s == nil {
|
||||
return pendingRefreshToken{}, false
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
|
||||
|
||||
refreshToken := "pending-token-value"
|
||||
hash := repository.HashToken(refreshToken)
|
||||
if !svc.trackDelayedStore(u.ID, hash, time.Now().Add(time.Hour)) {
|
||||
if !svc.trackDelayedStore(u.ID, hash, "", time.Now().Add(time.Hour)) {
|
||||
t.Fatal("trackDelayedStore returned false")
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestRefreshRejectsExpiredPendingToken(t *testing.T) {
|
||||
|
||||
refreshToken := "expired-pending"
|
||||
hash := repository.HashToken(refreshToken)
|
||||
svc.trackDelayedStore("user-x", hash, time.Now().Add(-time.Minute))
|
||||
svc.trackDelayedStore("user-x", hash, "", time.Now().Add(-time.Minute))
|
||||
|
||||
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
|
||||
t.Fatal("expired pending token should be rejected")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { api, LONG_REQUEST_TIMEOUT } from './client'
|
||||
|
||||
export interface BackupInfo {
|
||||
filename: string
|
||||
size: number
|
||||
created_at: string
|
||||
automatic: boolean
|
||||
}
|
||||
|
||||
export const backupAPI = {
|
||||
list: () => api.get<{ items: BackupInfo[] }>('/admin/backups').then((r) => r.data.items ?? []),
|
||||
create: () => api.post<BackupInfo>('/admin/backups', {}, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
remove: (filename: string) => api.delete('/admin/backups', { params: { filename } }),
|
||||
restore: (filename: string) => api.post<{ message: string; restart_required: boolean }>('/admin/backups/restore', null, { params: { filename }, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
download: async (filename: string) => {
|
||||
const response = await api.get<Blob>('/admin/backups/download', {
|
||||
params: { filename },
|
||||
responseType: 'blob',
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user