Merge pull request #47 from truewhile/main

拉齐代码
This commit is contained in:
truewhile
2026-10-20 11:33:06 +08:00
committed by GitHub
314 changed files with 32761 additions and 2005 deletions
+1 -1
View File
@@ -187,7 +187,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.26'
cache: true cache: true
- name: Download web/dist - name: Download web/dist
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
+33 -26
View File
@@ -4,10 +4,12 @@
# 产出: # 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build) # 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64) # 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) # 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
# #
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、 # 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
# 不打 release tag,只构建带 -beta 标识的产物供测试。 # 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
# 不打 release tag、不发布 GitHub Release、不部署服务器。
name: Beta Build name: Beta Build
@@ -22,9 +24,6 @@ permissions:
contents: read contents: read
packages: write packages: write
env:
BETA_VERSION_PREFIX: beta
jobs: jobs:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物 # 1) 编译验证 + 多平台二进制产物
@@ -32,17 +31,24 @@ jobs:
test-and-build: test-and-build:
name: Test & build artifacts name: Test & build artifacts
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
binary_version: ${{ steps.version.outputs.binary_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
- name: Resolve beta version - name: Resolve beta version
id: version id: version
run: | run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") git fetch origin version || true
BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7} SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist # The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package. # before the Go toolchain touches the web package.
@@ -59,7 +65,7 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.26'
cache: true cache: true
- name: go vet - name: go vet
@@ -71,13 +77,13 @@ jobs:
- name: go build (host) - name: go build (host)
run: go build ./... run: go build ./...
# 多平台可执行文件(嵌入刚构建的 web/dist) # 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
- name: Build linux/amd64 - name: Build linux/amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64 - name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64 - name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -87,7 +93,8 @@ jobs:
if-no-files-found: error if-no-files-found: error
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta) # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
# 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
docker-beta: docker-beta:
name: Build & push beta Docker image name: Build & push beta Docker image
@@ -98,13 +105,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
@@ -115,6 +115,15 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=beta
type=raw,value=${{ needs.test-and-build.outputs.version }}
- name: Build & push - name: Build & push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@@ -123,11 +132,9 @@ jobs:
push: true push: true
provenance: false provenance: false
sbom: false sbom: false
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta tags: ${{ steps.meta.outputs.tags }}
labels: | labels: ${{ steps.meta.outputs.labels }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
build-args: | build-args: |
VERSION=${{ steps.version.outputs.full_version }} VERSION=${{ needs.test-and-build.outputs.binary_version }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+35 -2
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.25' go-version: '1.26'
cache: true cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist # The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
@@ -40,6 +40,39 @@ jobs:
- name: go test - name: go test
run: go test ./... run: go test ./...
database-compatibility:
name: SQLite / PostgreSQL ${{ matrix.postgres }}
runs-on: ubuntu-latest
strategy:
matrix:
postgres: [16, 17]
services:
postgres:
image: postgres:${{ matrix.postgres }}-alpine
env:
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox-test-only
POSTGRES_DB: mebox_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U mebox -d mebox_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
MEBOX_TEST_POSTGRES_DSN: postgres://mebox:mebox-test-only@127.0.0.1:5432/mebox_test?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- name: Database regression tests
run: go test ./internal/database ./internal/repository -count=1
frontend: frontend:
name: Frontend (Node) name: Frontend (Node)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -78,7 +111,7 @@ jobs:
ci-success: ci-success:
name: CI Success name: CI Success
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend, frontend, smoke] needs: [backend, frontend, database-compatibility, smoke]
if: success() if: success()
steps: steps:
- run: echo "CI passed, ready for release" - run: echo "CI passed, ready for release"
+9
View File
@@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count
go test ./cmd/server -count=1 go test ./cmd/server -count=1
``` ```
数据库兼容测试默认覆盖 SQLite。连接专用测试 PostgreSQL 后可运行真实的双数据库回归:
```bash
MEBOX_TEST_POSTGRES_DSN='postgres://mebox:test-password@127.0.0.1:5432/mebox_test?sslmode=disable' \
go test ./internal/database ./internal/repository -count=1
```
测试账号需要创建、删除 schema 的权限;每个用例使用独立的临时 schema。CI 会分别在 PostgreSQL 16、17 上执行这些检查。
### 代码要求 ### 代码要求
- Go 代码使用 `gofmt`。 - Go 代码使用 `gofmt`。
+8 -5
View File
@@ -3,7 +3,7 @@
# Multi-architecture build for MeBox. # Multi-architecture build for MeBox.
# #
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle # Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0) # Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user # Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
# #
# Build: # Build:
@@ -25,7 +25,7 @@ COPY web/ .
RUN npm run build RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) ------------------- # ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ARG GOPROXY=https://proxy.golang.org,direct ARG GOPROXY=https://proxy.golang.org,direct
@@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ # 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1 # .http_scheme 选择协议并在失败时回退,无需用户改配置。
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["/usr/local/bin/mebox-healthcheck"]
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without # Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
# rewriting /etc/passwd or /etc/group on every container start. # rewriting /etc/passwd or /etc/group on every container start.
COPY docker-entrypoint.sh /entrypoint.sh COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck
RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]
+1 -1
View File
@@ -22,7 +22,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" /> <img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" /> <img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" /> <img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" /> <img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+1 -1
View File
@@ -21,7 +21,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" /> <img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" /> <img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" /> <img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" /> <img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+10 -1
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -76,7 +77,15 @@ func main() {
fatal("未从输入中识别到书源") fatal("未从输入中识别到书源")
} }
svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{}) // 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
// 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
// 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
cfg := &config.Config{}
cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
if err := os.MkdirAll(cacheDir, 0o750); err == nil {
cfg.Cache.CacheDir = cacheDir
}
svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{})
ctx := context.Background() ctx := context.Background()
results := make([]*reader.SmokeChainResult, len(sources)) results := make([]*reader.SmokeChainResult, len(sources))
+1 -1
View File
@@ -88,7 +88,7 @@ func (a *application) start() error {
a.logger.Error("database not ready", zap.Error(err)) a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err) return fmt.Errorf("database not ready: %w", err)
} }
if err := database.AutoMigrate(db); err != nil { if err := database.AutoMigrateWithConfig(db, a.cfg, a.logger); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err)) a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err) return fmt.Errorf("auto-migrate failed: %w", err)
} }
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bufio"
"net"
"sync"
"time"
)
// 开启 HTTPS 后,同一个端口会同时面对两类连接:
//
// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务;
// - 明确访问 http:// 的客户端——以前只会撞出一句
// "Client sent an HTTP request to an HTTPS server.",用户看不懂。
//
// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节:
// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。
// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被
// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。
const tlsRecordHandshake = 0x16
// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住
// goroutine。分流器按实例持有该值,便于测试用更短的超时。
const defaultPeekTimeout = 10 * time.Second
// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。
type splitListener struct {
ln net.Listener
tlsCh chan net.Conn
plainCh chan net.Conn
quit chan struct{}
once sync.Once
peek time.Duration
}
func newSplitListener(ln net.Listener) *splitListener {
return newSplitListenerWithTimeout(ln, defaultPeekTimeout)
}
func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener {
s := &splitListener{
ln: ln,
tlsCh: make(chan net.Conn),
plainCh: make(chan net.Conn),
quit: make(chan struct{}),
peek: peek,
}
go s.acceptLoop()
return s
}
// tlsListener 返回只收到 TLS 连接的 listener。
func (s *splitListener) tlsListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit}
}
// plainListener 返回只收到明文 HTTP 连接的 listener。
func (s *splitListener) plainListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit}
}
func (s *splitListener) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
// 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed,
// 由持有它们的 http.Server 自行收尾。
s.stop()
return
}
go s.dispatch(conn)
}
}
func (s *splitListener) dispatch(conn net.Conn) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(s.peek))
first, err := br.Peek(1)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return
}
peeked := &peekedConn{Conn: conn, r: br}
ch := s.plainCh
if first[0] == tlsRecordHandshake {
ch = s.tlsCh
}
select {
case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭
case <-s.quit:
_ = conn.Close()
}
}
func (s *splitListener) stop() {
s.once.Do(func() { close(s.quit) })
}
// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。
type channelListener struct {
addr net.Addr
conns <-chan net.Conn
quit <-chan struct{}
}
func (l *channelListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.quit:
return nil, net.ErrClosed
}
}
// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭,
// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。
func (l *channelListener) Close() error { return nil }
func (l *channelListener) Addr() net.Addr { return l.addr }
// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"io"
"net"
"net/http"
"testing"
"time"
)
func TestSplitListenerRoutesByFirstByte(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
split := newSplitListener(raw)
addr := raw.Addr().String()
// TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。
handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c}
tlsClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tlsClient.Close() }()
if _, err := tlsClient.Write(handshake); err != nil {
t.Fatal(err)
}
tlsConn := <-split.tlsCh
defer func() { _ = tlsConn.Close() }()
got := make([]byte, len(handshake))
if _, err := io.ReadFull(tlsConn, got); err != nil {
t.Fatalf("read peeked tls bytes: %v", err)
}
if string(got) != string(handshake) {
t.Fatalf("tls bytes = %v, want %v", got, handshake)
}
// 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。
request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n"
plainClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainClient.Close() }()
if _, err := plainClient.Write([]byte(request)); err != nil {
t.Fatal(err)
}
plainConn := <-split.plainCh
defer func() { _ = plainConn.Close() }()
head := make([]byte, len("GET /"))
if _, err := io.ReadFull(plainConn, head); err != nil {
t.Fatalf("read peeked plain bytes: %v", err)
}
if string(head) != request[:len(head)] {
t.Fatalf("plain bytes = %q, want prefix of %q", head, request)
}
// 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。
_ = raw.Close()
for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} {
deadline := time.Now().Add(3 * time.Second)
for {
_, err := ln.Accept()
if err == net.ErrClosed {
break
}
if time.Now().After(deadline) {
t.Fatalf("Accept after close = %v, want net.ErrClosed", err)
}
time.Sleep(10 * time.Millisecond)
}
}
}
// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。
func TestSplitListenerDropsIdleConnection(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = raw.Close() }()
split := newSplitListenerWithTimeout(raw, 200*time.Millisecond)
conn, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
select {
case c := <-split.plainCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case c := <-split.tlsCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case <-time.After(time.Second):
}
}
func TestHTTPSRedirectTarget(t *testing.T) {
cases := []struct {
name string
host string
rawURL string
want string
wantErr bool
}{
{name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"},
{name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"},
{name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"},
{name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"},
{name: "rejects empty host", host: "", rawURL: "/", wantErr: true},
{name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true},
{name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil)
if err != nil {
t.Fatal(err)
}
req.Host = tc.host
if host := redirectHostHeader(req); host == "" {
if !tc.wantErr {
t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host)
}
return
}
if tc.wantErr {
t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host)
}
if got := httpsRedirectURL(tc.host, req); got != tc.want {
t.Fatalf("redirect target = %q, want %q", got, tc.want)
}
})
}
}
+126 -18
View File
@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -17,6 +19,14 @@ import (
"github.com/truewhile/MeBox/internal/service" "github.com/truewhile/MeBox/internal/service"
) )
// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。
//
// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启
// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出
// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。
// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。
const httpSchemeFileName = ".http_scheme"
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。 // tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
type tlsPair struct { type tlsPair struct {
cert tls.Certificate cert tls.Certificate
@@ -35,6 +45,9 @@ type tlsPair struct {
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。 // - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
// //
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。 // 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
//
// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走
// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。
type serverManager struct { type serverManager struct {
cfg *config.Config cfg *config.Config
log *zap.Logger log *zap.Logger
@@ -43,6 +56,7 @@ type serverManager struct {
mu sync.Mutex mu sync.Mutex
srv *http.Server srv *http.Server
redirectSrv *http.Server
ln net.Listener ln net.Listener
pair *tlsPair pair *tlsPair
stopCh chan struct{} stopCh chan struct{}
@@ -92,11 +106,11 @@ func (m *serverManager) Reload() error {
return nil return nil
} }
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair
if oldLn != nil { if oldLn != nil {
_ = oldLn.Close() // 释放端口后再绑定新监听 _ = oldLn.Close() // 释放端口后再绑定新监听
} }
m.srv, m.ln, m.pair = nil, nil, nil m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil
firstErr := m.bind(pair) firstErr := m.bind(pair)
if firstErr != nil { if firstErr != nil {
@@ -107,6 +121,7 @@ func (m *serverManager) Reload() error {
} }
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。 // 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
m.drain(oldSrv) m.drain(oldSrv)
m.drain(oldRedirect)
m.logServerReady() m.logServerReady()
m.maybeStartAutoReloadLocked() m.maybeStartAutoReloadLocked()
return firstErr return firstErr
@@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
close(m.stopCh) close(m.stopCh)
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() targets := []*http.Server{m.srv, m.redirectSrv}
if m.srv == nil { m.mu.Unlock()
return nil
var firstErr error
for _, srv := range targets {
if srv == nil {
continue
}
if err := srv.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = err
}
} }
return m.srv.Shutdown(ctx) return firstErr
} }
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。 // desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
@@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool {
return pair.version == m.pair.version return pair.version == m.pair.version
} }
// bind 创建并按需启用 TLS 的监听,异步开始服务。 // bind 创建监听,异步开始服务。
//
// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接
// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动
// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS
// server."。
func (m *serverManager) bind(pair *tlsPair) error { func (m *serverManager) bind(pair *tlsPair) error {
ln, err := net.Listen("tcp", m.addr) ln, err := net.Listen("tcp", m.addr)
if err != nil { if err != nil {
return fmt.Errorf("listen %s: %w", m.addr, err) return fmt.Errorf("listen %s: %w", m.addr, err)
} }
srv := &http.Server{
Handler: m.handler, if pair == nil {
srv := m.newHTTPServer(m.handler)
m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil
m.writeSchemeState(nil)
go m.serve(srv, ln)
return nil
}
split := newSplitListener(ln)
srv := m.newHTTPServer(m.handler)
tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
redirect := m.newHTTPServer(m.httpsRedirectHandler())
m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair
m.writeSchemeState(pair)
go m.serve(srv, tlsLn)
go m.serve(redirect, split.plainListener())
return nil
}
func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server {
return &http.Server{
Handler: handler,
ReadHeaderTimeout: 15 * time.Second, ReadHeaderTimeout: 15 * time.Second,
} }
if pair != nil { }
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{pair.cert}, // httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、
MinVersion: tls.VersionTLS12, // 同路径的 https://。
}) //
// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期
// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。
func (m *serverManager) httpsRedirectHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := redirectHostHeader(r)
if host == "" {
// 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "HTTPS is enabled; open https://<host>:%d instead.\n", m.cfg.App.Port)
return
}
status := http.StatusFound
if r.Method != http.MethodGet && r.Method != http.MethodHead {
status = http.StatusTemporaryRedirect // 保住方法与请求体
}
w.Header().Set("Location", httpsRedirectURL(host, r))
w.WriteHeader(status)
})
}
// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。
func httpsRedirectURL(host string, r *http.Request) string {
target := "https://" + host + r.URL.EscapedPath()
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
return target
}
// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射
// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符
// 带进 Location 造成任意跳转。
func redirectHostHeader(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") {
return ""
}
return host
}
// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。
// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。
func (m *serverManager) writeSchemeState(pair *tlsPair) {
dataDir := strings.TrimSpace(m.cfg.App.DataDir)
if dataDir == "" {
return
}
scheme := "http"
if pair != nil {
scheme = "https"
}
path := filepath.Join(dataDir, httpSchemeFileName)
if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename.
m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err))
} }
m.srv, m.ln, m.pair = srv, ln, pair
go m.serve(srv, ln)
return nil
} }
func (m *serverManager) serve(s *http.Server, ln net.Listener) { func (m *serverManager) serve(s *http.Server, ln net.Listener) {
@@ -250,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) {
if s == nil { if s == nil {
return return
} }
// Stop reusing old connections before Reload returns. Asynchronous Shutdown
// alone leaves a window where a client receives the previous HTTP/TLS mode.
s.SetKeepAlivesEnabled(false)
go func(s *http.Server) { go func(s *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -271,7 +379,7 @@ func (m *serverManager) logServerReady() {
zap.String("listen", m.addr), zap.String("listen", m.addr),
) )
if m.pair != nil { if m.pair != nil {
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port", m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS",
zap.String("addr", m.addr), zap.String("addr", m.addr),
) )
} }
+139
View File
@@ -1,13 +1,17 @@
package main package main
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"io"
"math/big" "math/big"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
NotBefore: time.Now().Add(-time.Hour), NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"}, DNSNames: []string{"localhost"},
// 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
} }
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv) der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil { if err != nil {
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
t.Fatal("version should change after files replaced") t.Fatal("version should change after files replaced")
} }
} }
// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致,
// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。
func TestWriteSchemeState(t *testing.T) {
m := newTestServerManager(t)
m.cfg.App.DataDir = t.TempDir()
path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName)
m.writeSchemeState(nil)
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("plain HTTP state: data=%q err=%v", data, err)
}
m.writeSchemeState(&tlsPair{})
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("HTTPS state: data=%q err=%v", data, err)
}
// DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。
m.cfg.App.DataDir = ""
_ = os.Remove(path)
m.writeSchemeState(nil)
if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) {
t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err)
}
}
// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是
// 给用户看 "Client sent an HTTP request to an HTTPS server."。
func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) {
certPEM, keyPEM := makeTestPairPEM(t)
cfg := &config.Config{}
cfg.App.Port = 8080
cfg.App.DataDir = t.TempDir()
cfg.App.HTTPSEnabled = true
cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "secure:"+r.URL.Path)
})
m := newServerManager(cfg, zap.NewNop(), handler)
m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口
if err := m.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.Shutdown(context.Background()) })
addr := m.ln.Addr().String()
// 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。
m.addr = addr
schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName)
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("scheme state = %q err=%v, want https", data, err)
}
plain := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := plain.Get("http://" + addr + "/library?tab=1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = postResp.Body.Close() }()
if postResp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect)
}
if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want {
t.Fatalf("POST Location = %q, want %q", got, want)
}
// 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(certPEM)) {
t.Fatal("append test certificate to pool failed")
}
secure := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
secureResp, err := secure.Get("https://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = secureResp.Body.Close() }()
body, err := io.ReadAll(secureResp.Body)
if err != nil {
t.Fatal(err)
}
if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" {
t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library")
}
// 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。
cfg.App.HTTPSEnabled = false
if err := m.Reload(); err != nil {
t.Fatal(err)
}
if m.redirectSrv != nil {
t.Fatal("redirect server should be released when HTTPS is off")
}
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("scheme state = %q err=%v, want http", data, err)
}
plainResp, err := plain.Get("http://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainResp.Body.Close() }()
plainBody, err := io.ReadAll(plainResp.Body)
if err != nil {
t.Fatal(err)
}
if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" {
t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody)
}
}
+4 -2
View File
@@ -62,7 +62,7 @@ services:
# NAS 示例:source: /vol1/1000/Media # NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media # Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media - ./media:/media
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
@@ -105,7 +105,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck: healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
+3 -1
View File
@@ -115,7 +115,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck: healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
+3 -1
View File
@@ -73,7 +73,9 @@ services:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
healthcheck: healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
+4 -2
View File
@@ -44,7 +44,7 @@ services:
# NAS 示例:source: /vol1/1000/Media # NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media # Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media - ./media:/media
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
@@ -87,7 +87,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck: healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
+3 -1
View File
@@ -131,7 +131,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck: healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# MeBox 容器健康检查。
#
# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再
# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里
# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远
# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生
# "first record does not look like a TLS handshake"。
#
# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见
# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议,
# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。
set -u
port="${MEBOX_APP_PORT:-8080}"
data_dir="${MEBOX_APP_DATA_DIR:-/data}"
scheme_file="$data_dir/.http_scheme"
scheme=""
if [ -r "$scheme_file" ]; then
scheme="$(tr -d '[:space:]' <"$scheme_file")"
fi
if [ "$scheme" = "https" ]; then
first="https"
second="http"
else
first="http"
second="https"
fi
for proto in "$first" "$second"; do
# --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。
if busybox wget -q --spider -T 3 --no-check-certificate \
"$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then
exit 0
fi
done
exit 1
+91
View File
@@ -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 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
+389
View File
@@ -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: 可选的稳定结构化细节,未定义时客户端必须忽略。
+24 -1
View File
@@ -38,7 +38,7 @@ web/src/
components/reader/ components/reader/
``` ```
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。 **基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。 **数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
@@ -126,8 +126,31 @@ web/src/
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 | | P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 | | P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 | | P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 |
| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 |
| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 | | P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
### 实施记录:书源会话状态仍是跨用户共享(下一步)
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
的一批调用点,留作单独一轮处理。
### 实施记录:关于「宽松 @put 触发 panic」的复核
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。
真实缺陷是另外两个并已修复:
- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失;
- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。 P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
## 8. 风险与对策 ## 8. 风险与对策
+38 -2
View File
@@ -26,6 +26,31 @@
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。 - 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
- `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。 - `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。
**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)**
- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`:
- 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。
- 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
- 显示项开关:未读章数徽标、更新时间。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
**阅读器偏好按账号同步(跨设备)**
- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。
- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。
- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。
- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。
- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。
## 2. 阅读界面(重点) ## 2. 阅读界面(重点)
文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。 文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。
@@ -71,7 +96,17 @@
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。 - 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。 - 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。 - 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。 - 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。 - 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
**本地书籍(对应 legado 本地 TXT / EPUB)** **本地书籍(对应 legado 本地 TXT / EPUB)**
@@ -91,7 +126,7 @@
- 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。 - 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。
- 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。 - 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。
- 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。** - 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
- 播放进度按秒记忆(节流 10s 上报),跨端一致;播完自动下一章。 - 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。
- legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。 - legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。
## 3. 搜索 ## 3. 搜索
@@ -221,4 +256,5 @@
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。 - 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。 - 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。 - 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。 - 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
+4
View File
@@ -33,6 +33,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.cache_size", -40000) v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns) v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns) v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
v.SetDefault("database.postgres_trgm_index", true)
v.SetDefault("secrets.jwt_secret", "") v.SetDefault("secrets.jwt_secret", "")
@@ -52,6 +53,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.redis_prefix", "mebox") v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90) v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300) v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
v.SetDefault("search.backend", "") v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "") v.SetDefault("search.opensearch_url", "")
+9
View File
@@ -62,6 +62,15 @@ func (c *Config) normalize() error {
if c.Cache.MediaTTLSeconds < 1 { if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 90 c.Cache.MediaTTLSeconds = 90
} }
if c.Cache.ReaderContentMaxSizeMB < 0 {
c.Cache.ReaderContentMaxSizeMB = 0
}
if c.Cache.ReaderContentTTLHours < 0 {
c.Cache.ReaderContentTTLHours = 0
}
if c.Cache.ReaderFilesTTLHours < 0 {
c.Cache.ReaderFilesTTLHours = 0
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend)) c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" { if c.Search.Index == "" {
c.Search.Index = "mebox_media" c.Search.Index = "mebox_media"
+13 -1
View File
@@ -50,7 +50,8 @@ type AppConfig struct {
DataDir string `mapstructure:"data_dir"` DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"` WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置 // HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。 // SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
HTTPSEnabled bool `mapstructure:"https_enabled"` HTTPSEnabled bool `mapstructure:"https_enabled"`
// SSLCert 是 PEM 编码的 SSL 证书内容。 // SSLCert 是 PEM 编码的 SSL 证书内容。
SSLCert string `mapstructure:"ssl_cert"` SSLCert string `mapstructure:"ssl_cert"`
@@ -86,6 +87,10 @@ type DatabaseConfig struct {
CacheSize int `mapstructure:"cache_size"` CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"` MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"` MaxIdleConns int `mapstructure:"max_idle_conns"`
// PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的
// FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是
// media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。
PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"`
} }
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。 // SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
@@ -133,6 +138,13 @@ type CacheConfig struct {
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机 // 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。 // 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"` EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
// ReaderFilesTTLHours 书源文件缓存(java.cacheFile / downloadFile)的保留时长(小时)。
// 0 表示使用内置兜底值(30 天)。
ReaderFilesTTLHours int `mapstructure:"reader_files_ttl_hours"`
} }
type SearchConfig struct { type SearchConfig struct {
+238
View File
@@ -0,0 +1,238 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"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"
)
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
t.Helper()
var db *gorm.DB
if dialect == "sqlite" {
cfg := &config.Config{}
cfg.Database.Type = dialect
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.MaxOpenConns = 8
var err error
db, err = Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
} else {
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
}
pgcfg, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal("invalid PostgreSQL test configuration")
}
admin := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = admin.Close() })
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
t.Error(err)
}
})
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
conn := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = conn.Close() })
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
}
db.Logger = logger.Default.LogMode(logger.Silent)
if conn, err := db.DB(); err == nil {
t.Cleanup(func() { _ = conn.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestDatabaseCompatibility(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
t.Run("long_paths_and_restart", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Distinct CJK characters prevent TOAST compression from hiding
// the PostgreSQL btree entry size limit (> 2704 bytes).
var path strings.Builder
path.WriteString("/media/")
for i := range 980 {
path.WriteRune(rune(0x4e00 + i))
}
path.WriteString(".mkv")
media := model.Media{Path: path.String(), Title: "Long path"}
if err := db.Create(&media).Error; err != nil {
t.Fatalf("insert long media path: %v", err)
}
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
t.Fatal("duplicate path accepted")
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("migrate existing database: %v", err)
}
var found model.Media
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
t.Fatalf("lookup long path: %v", err)
}
})
if dialect == "postgres" {
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
db := compatibilityDB(t, dialect)
for _, unique := range []string{"UNIQUE ", ""} {
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := ensureMediaPathIndex(db); err != nil {
t.Fatal(err)
}
}
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil || !found || isUnique || method != "hash" {
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
}
}
})
}
t.Run("search_ranking", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Exercise LIKE fallback in both engines.
if dialect == "sqlite" {
resetMediaSearchIndex(db)
}
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
}
})
t.Run("concurrent_media_upsert", func(t *testing.T) {
db := compatibilityDB(t, dialect)
repo := repository.New(db).Media
const workers = 8
// Synchronize the initial lookups to force the insert race.
var lookups sync.WaitGroup
lookups.Add(workers)
if dialect == "postgres" {
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
lookups.Done()
lookups.Wait()
}
}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
errs := make(chan error, workers)
for range workers {
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
}
for range workers {
if err := <-errs; err != nil {
t.Errorf("concurrent upsert: %v", err)
}
}
var count int64
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("media count=%d, err=%v", count, err)
}
})
t.Run("search_filters", func(t *testing.T) {
db := compatibilityDB(t, dialect)
rows := []model.Media{
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
}
for i := range rows {
rows[i].Title = "Alpha " + rows[i].ID
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
t.Fatal(err)
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
UnwatchedOnly: true, UnwatchedUserID: "viewer",
})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
}
})
t.Run("migration_preserves_zero_values", func(t *testing.T) {
src := compatibilityDB(t, "sqlite")
dst := compatibilityDB(t, dialect)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
t.Fatal(err)
}
if _, _, err := copyModelTables(src, dst, 500); err != nil {
t.Fatal(err)
}
var got model.User
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
})
})
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
} }
// 1. 初始化目标库 Schema、类型与索引 // 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil { if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err) return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
} }
+50
View File
@@ -0,0 +1,50 @@
package database
import (
"fmt"
"reflect"
"gorm.io/gorm"
)
// InsertPreservingValues copies persisted model rows without applying creation
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
// missing values, which would reactivate disabled accounts during migration or
// restore. Column maps preserve the exact values already stored in the source.
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(rows); err != nil {
return err
}
slice := reflect.Indirect(reflect.ValueOf(rows))
if slice.Kind() != reflect.Slice {
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
}
if len(stmt.Schema.DBNames) == 0 {
return fmt.Errorf("persisted rows have no database columns")
}
values := make([]map[string]any, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
row := make(map[string]any, len(stmt.Schema.DBNames))
for _, name := range stmt.Schema.DBNames {
field := stmt.Schema.FieldsByDBName[name]
if field.Creatable {
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
}
}
values = append(values, row)
}
if len(values) == 0 {
return nil
}
// Bound batches by each engine's bind-parameter limit.
parameterLimit := 65535
if isSQLite(db) {
parameterLimit = 32766
}
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
if batchSize <= 0 || batchSize > maxBatch {
batchSize = maxBatch
}
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
}
@@ -0,0 +1,141 @@
package database
import (
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。
type legacyReaderSource struct {
ID string `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
SourceURL string `gorm:"type:varchar(512);uniqueIndex"`
RawJSON string
Enabled bool
EnabledExplore bool
Variables string
Header string
}
func (legacyReaderSource) TableName() string { return "reader_book_sources" }
// 迁移路径:老库(书源全局唯一、无 user_id)升级后
// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本;
// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本;
// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。
func TestReaderSourceMigrationBackfill(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。
if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil {
t.Fatal(err)
}
shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true}
if err := db.Create(shared).Error; err != nil {
t.Fatal(err)
}
softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"}
if err := db.Create(softDeleted).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删
t.Fatal(err)
}
// u1 两本、u2 一本都引用共享源;孤儿源无人引用。
books := []model.ReaderBook{
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"},
{UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"},
}
for i := range books {
if err := db.Create(&books[i]).Error; err != nil {
t.Fatal(err)
}
}
// 迁移。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
t.Fatalf("删旧索引失败: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatalf("回填失败: %v", err)
}
// 1. 旧唯一索引已删除:两个用户同 URL 各自一份。
var rows []model.ReaderBookSource
if err := db.Order("user_id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
byUser := map[string]model.ReaderBookSource{}
for _, r := range rows {
if r.SourceURL == "https://shared.example.com" {
byUser[r.UserID] = r
}
}
if len(byUser) != 2 {
t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser)
}
if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID {
t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID)
}
if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" {
t.Fatal("副本应保留原书源内容")
}
// 引用最多的用户保留原行(u1 有两本书)。
if byUser["u1"].ID != "legacy-1" {
t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID)
}
// 2. 软删的历史行被清理。
var softLeft int64
if err := db.Unscoped().Model(&model.ReaderBookSource{}).
Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil {
t.Fatal(err)
}
if softLeft != 0 {
t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft)
}
// 3. 无人引用的书源保持无归属(不会凭空分配给谁)。
var orphans int64
if err := db.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil {
t.Fatal(err)
}
if orphans != 0 {
t.Fatal("无人引用的书源不应被分配归属")
}
// 4. 回填是幂等的(再次执行不产生重复副本)。
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatal(err)
}
var total int64
if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil {
t.Fatal(err)
}
if total != 2 {
t.Fatalf("重复回填不应新增副本,实际 %d 份", total)
}
}
+288 -9
View File
@@ -1,27 +1,52 @@
package database package database
import ( import (
"fmt"
"strings"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
// AutoMigrate creates tables for every model registered in the model package. // AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error { func AutoMigrate(db *gorm.DB) error {
return AutoMigrateWithConfig(db, nil, nil)
}
// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志:
// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤
// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。
func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行, // 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
// 不去重会导致唯一索引 uniq_user_history 创建失败。 // 不去重会导致唯一索引 uniq_user_history 创建失败。
if err := dedupePlaybackHistories(db); err != nil { if err := dedupePlaybackHistories(db); err != nil {
return err return err
} }
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil { if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err return err
} }
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil { if err := ensurePostgresColumnCompatibility(db); err != nil {
return err return err
} }
if err := ensurePerformanceIndexes(db); err != nil { if err := ensurePerformanceIndexes(db); err != nil {
return err return err
} }
if err := ensureMediaPathIndex(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil { if err := ensureLibraryRootsCompatibility(db); err != nil {
return err return err
} }
@@ -34,6 +59,113 @@ func AutoMigrate(db *gorm.DB) error {
} }
return ensureSQLiteQueryOptimizer(db) return ensureSQLiteQueryOptimizer(db)
} }
return ensurePostgresSearchIndexes(db, cfg, log)
}
// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。
//
// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的
// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节,
// 对整列建唯一索引会让这类行插入时报
//
// index row size ... exceeds btree version 4 maximum 2704
//
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
func ensureMediaPathIndex(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable("media") {
return nil
}
if !isPostgres(db) {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
}
// 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
return err
}
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil {
return err
}
if found && (unique || method != "hash") {
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
return err
}
}
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
}
// postgresIndexProperties 返回当前 schema 下索引的唯一性与访问方法。
func postgresIndexProperties(db *gorm.DB, indexName string) (unique bool, method string, found bool, err error) {
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
var row struct {
IsUnique *bool
Method string
}
if err := db.Raw(`
SELECT i.indisunique AS is_unique, a.amname AS method
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_am a ON a.oid = c.relam
WHERE n.nspname = current_schema() AND c.relname = ?
`, indexName).Scan(&row).Error; err != nil {
return false, "", false, err
}
if row.IsUnique == nil {
return false, "", false, nil
}
return *row.IsUnique, row.Method, true, nil
}
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
//
// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下
// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE
// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引,
// 把搜索从整表扫描降到索引扫描。
//
// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会
// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。
//
// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条
// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。
func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
if !isPostgres(db) {
return nil
}
if cfg != nil && !cfg.Database.PostgresTrgmIndex {
if log != nil {
log.Info("postgres trigram search index disabled; media search falls back to sequential scan")
}
return nil
}
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
if log != nil {
log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err))
}
return nil
}
// 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因
// IF NOT EXISTS 直接跳过。
if log != nil {
log.Info("ensuring postgres trigram search indexes")
}
for _, stmt := range []string{
`CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`,
} {
if err := db.Exec(stmt).Error; err != nil {
if log != nil {
log.Warn("create trigram index failed; media search may fall back to sequential scan",
zap.String("statement", stmt), zap.Error(err))
}
return nil
}
}
return nil return nil
} }
@@ -42,6 +174,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error return db.Exec("ANALYZE").Error
} }
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
//
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
return err
}
}
}
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
}
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
//
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
// 这样谁都不会因为别人删源而失去书源。
//
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
// 列表里,但数据仍在,需要时可以人工认领。
func backfillReaderSourceOwnership(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
var pending []model.ReaderBookSource
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
return err
}
for i := range pending {
src := pending[i]
if err := assignOrCloneReaderSource(db, &src); err != nil {
return err
}
}
return nil
}
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
if src == nil || src.SourceURL == "" {
return nil
}
// 引用该书源的书籍分布(去重到用户)。
type ownerCount struct {
UserID string
N int64
}
var owners []ownerCount
if err := db.Model(&model.ReaderBook{}).
Select("user_id, COUNT(*) AS n").
Where("origin = ?", src.SourceURL).
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
return err
}
if len(owners) == 0 {
return nil // 无人引用:保持无归属
}
// 第一个用户(引用最多)直接接手这一行。
owner := owners[0].UserID
if owner == "" {
return nil
}
if err := db.Model(&model.ReaderBookSource{}).
Where("id = ?", src.ID).
Updates(map[string]any{"user_id": owner}).Error; err != nil {
return err
}
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
for _, o := range owners[1:] {
if o.UserID == "" || o.UserID == owner {
continue
}
var exists int64
if err := db.Model(&model.ReaderBookSource{}).
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
continue
}
clone := *src
clone.ID = ""
clone.UserID = o.UserID
if err := db.Create(&clone).Error; err != nil {
return err
}
}
return nil
}
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by // dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique // the former read-then-write upsert, so the uniq_user_history composite unique
// index can be created on existing databases. Keeps the most recent row per // index can be created on existing databases. Keeps the most recent row per
@@ -67,16 +298,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) { if !isPostgres(db) {
return nil return nil
} }
statements := []string{ // 只在类型确实不匹配时才 ALTER。
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`, //
`ALTER TABLE media ALTER COLUMN genres TYPE text`, // PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`, // 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`, // 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`, // 启动会卡住数分钟,且期间这张表连读都被阻塞。
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`, changes := []struct {
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`, table string
column string
target string
}{
{"media", "container", "varchar(128)"},
{"media", "genres", "text"},
{"media", "series_id", "varchar(128)"},
{"media", "duplicate_of", "varchar(128)"},
{"playback_histories", "media_id", "varchar(128)"},
{"favorites", "media_id", "varchar(128)"},
{"playlist_items", "media_id", "varchar(128)"},
} }
for _, stmt := range statements { for _, change := range changes {
current, found, err := postgresColumnType(db, change.table, change.column)
if err != nil {
return err
}
if !found || current == change.target {
continue
}
stmt := "ALTER TABLE " + quoteIdent(change.table) +
" ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target
if err := db.Exec(stmt).Error; err != nil { if err := db.Exec(stmt).Error; err != nil {
return err return err
} }
@@ -84,6 +334,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
return nil return nil
} }
// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如
// "varchar(128)" / "text"。列不存在时 found 为 false。
func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) {
var row struct {
DataType string
MaxLength *int64
}
if err := db.Raw(`
SELECT data_type AS data_type, character_maximum_length AS max_length
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
`, table, column).Scan(&row).Error; err != nil {
return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err)
}
dataType := strings.ToLower(strings.TrimSpace(row.DataType))
if dataType == "" {
return "", false, nil
}
// information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。
switch dataType {
case "character varying", "varchar":
dataType = "varchar"
}
if row.MaxLength != nil && *row.MaxLength > 0 {
dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength)
}
return dataType, true, nil
}
func ensurePerformanceIndexes(db *gorm.DB) error { func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{ statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY // 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
@@ -0,0 +1,54 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。
//
// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是
// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让
// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式
// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。
func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
// 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。
// t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
var definition string
if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil {
t.Fatal(err)
}
if definition == "" {
t.Fatal("AutoMigrate did not create idx_media_path")
}
if !strings.Contains(strings.ToUpper(definition), "UNIQUE") {
t.Fatalf("idx_media_path is not unique: %q", definition)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil {
t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced")
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
if filtered.Len() > 0 { if filtered.Len() > 0 {
filteredPtr := reflect.New(filtered.Type()) filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered) filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil { if err := InsertPreservingValues(target.Clauses(clause.OnConflict{DoNothing: true}), filteredPtr.Interface(), batchSize); err != nil {
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err) return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
} }
copiedForTable += int64(filtered.Len()) copiedForTable += int64(filtered.Len())
+15 -15
View File
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。 // 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
if done := ctx.Done(); done != nil { wake := func() {
stop := make(chan struct{}) g.mu.Lock()
defer close(stop) defer g.mu.Unlock()
go func() { g.cond.Broadcast()
select {
case <-done:
g.cond.Broadcast()
case <-stop:
}
}()
} }
stopCancel := context.AfterFunc(ctx, wake)
defer stopCancel()
for { for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if g.owner == nil { if g.owner == nil {
holder := &sqliteGateHolder{ holder := &sqliteGateHolder{
id: sqliteGateHolderSeq.Add(1), id: sqliteGateHolderSeq.Add(1),
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.owner = holder g.owner = holder
return holder, nil return holder, nil
} }
if ctx.Err() != nil { remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
return nil, ctx.Err() if remaining <= 0 {
}
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。 // 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
g.owner = nil g.owner = nil
g.cond.Broadcast() g.cond.Broadcast()
continue continue
} }
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
timer := time.AfterFunc(remaining, wake)
g.cond.Wait() g.cond.Wait()
timer.Stop()
} }
} }
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"context"
"errors"
"testing"
"time"
)
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
gate := newSQLiteWriteGate()
holder, err := gate.Lock(ctx)
defer gate.Unlock(holder)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
}
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
// Make the lease expire shortly, without waiting a full minute.
gate.mu.Lock()
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
gate.mu.Unlock()
done := make(chan *sqliteGateHolder, 1)
go func() {
next, _ := gate.Lock(t.Context())
done <- next
}()
select {
case next := <-done:
gate.Unlock(holder) // A stale holder must not unlock its successor.
gate.mu.Lock()
owner := gate.owner
gate.mu.Unlock()
gate.Unlock(next)
if next == nil || owner != next {
t.Fatal("expired holder released the new owner's lock")
}
case <-time.After(time.Second):
gate.Unlock(holder)
gate.Unlock(<-done)
t.Fatal("waiter was not woken when the holder expired")
}
}
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
next, err := gate.Lock(ctx)
gate.Unlock(next)
done <- err
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
case <-time.After(time.Second):
gate.Unlock(holder)
<-done
t.Fatal("waiter was not woken for cancellation")
}
}
@@ -0,0 +1,58 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
// 云 HLS 分片代理的错误映射:
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
// 而不是当成播放失败直接退回本地转码;
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
func newCloud115HLSSessionTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
svc := &service.Container{
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
}
router := gin.New()
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
return router
}
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
}
}
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 499 {
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
}
}
+13 -2
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
if err == nil || c.Writer.Written() { if err == nil || c.Writer.Written() {
return return
} }
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
switch { switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound): case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"}) // code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
// 而不是直接放弃云端播放退回本地转码。
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired): case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"}) c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
default: default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
} }
+26
View File
@@ -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 { type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"` 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())
}
}
+2
View File
@@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
registerPublicAuthRoutes(api, svc, log) registerPublicAuthRoutes(api, svc, log)
registerMobileRoutes(api, svc, log)
registerAuthenticatedRoutes(api, cfg, svc) registerAuthenticatedRoutes(api, cfg, svc)
registerAdminRoutes(api, cfg, svc) registerAdminRoutes(api, cfg, svc)
+14
View File
@@ -810,6 +810,20 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
// 远程条目可能是「整部剧/季/目录」:这类条目自身没有媒体源,远程 Emby 对它
// 们的 /Videos/{id}/stream 会直接回 500(Unable to cast object of type
// 'Series' to type 'IHasMediaSources')。网页端、外部播放器、第三方客户端都
// 可能把这种 ID 送进来,这里统一改投第一集:302 让客户端自己跟过去,播放器
// 原有的 token / proxy 查询参数一并保留。
if playable := svc.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID); playable != "" && playable != remoteID {
target := "/api/stream/" + service.EncodeEmbyRemoteID(mountID, playable)
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
target += "?" + rawQuery
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go), // 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。 // 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) { if mount.ProxyPlay || wantSameOriginProxy(c) {
+111
View File
@@ -0,0 +1,111 @@
package handler
import (
"encoding/json"
"fmt"
"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/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 TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"},
}},
})
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
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)
}
rows := []model.Media{
{Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
{Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
expectedIDs := map[string]string{lib.ID: "local-1"}
for _, suffix := range []string{"a", "b"} {
mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series")
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
}
request := func(params string) ([]model.Media, int) {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items, response.Total
}
items, _ := request("limit=8")
if len(items) != len(expectedIDs) {
t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items)
}
seen := make(map[string]bool)
for page := 1; page <= len(expectedIDs); page++ {
items, total := request(fmt.Sprintf("page=%d&page_size=1", page))
if total != len(expectedIDs) || len(items) != 1 {
t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items)
}
item := items[0]
if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" {
t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item)
}
seen[item.DisplayLibraryID] = true
}
}
+3 -1
View File
@@ -26,7 +26,9 @@ import (
) )
func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) { func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) // The scanner and polling loop use separate connections; :memory: gives
// each connection its own empty database.
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "scan.db")+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+395
View File
@@ -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
}
+434
View File
@@ -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)
}
}
+129
View File
@@ -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()
}
}
+81
View File
@@ -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)
}
}
+44 -2
View File
@@ -51,11 +51,53 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。 // 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = "" 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, "media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery, "stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL, "hls_url": hlsURL,
}) "resume": resumePayload,
"subtitles": subtitles,
}
if hasDirectURL {
payload["direct_url"] = directURL
}
c.JSON(http.StatusOK, payload)
} }
} }
+65
View File
@@ -22,6 +22,7 @@ import (
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service" "github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/cloud"
) )
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) { func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
@@ -435,6 +436,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
var payload struct { var payload struct {
StreamURL string `json:"stream_url"` StreamURL string `json:"stream_url"`
HlsURL string `json:"hls_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 { if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err) 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/") { if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL) 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) { func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
+40
View File
@@ -0,0 +1,40 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
//
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
// 这里只钉住契约与安全边界,避免把测试绑到网络。
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(container)
post := func(body string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
return w.Code
}
cases := []struct {
name string
body string
}{
{"缺少 url", `{"book_id":"x"}`},
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
}
for _, c := range cases {
if code := post(c.body); code != http.StatusBadRequest {
t.Fatalf("%s:应 400,得到 %d", c.name, code)
}
}
}
+240
View File
@@ -0,0 +1,240 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。
//
// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。
// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。
func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook {
t.Helper()
book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{
Origin: "https://example.com",
BookURL: bookURL,
}, name, "作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
return book
}
func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) {
t.Helper()
body, _ := json.Marshal(map[string]any{"groups": groups})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Groups []model.BookGroupSet `json:"groups"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Groups, w.Code
}
func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Groups []model.BookGroupSet `json:"groups"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Groups, w.Code
}
func namesOf(groups []model.BookGroupSet) string {
out := make([]string, 0, len(groups))
for _, g := range groups {
out = append(out, g.Name)
}
return strings.Join(out, ",")
}
func idsOf(groups []model.BookGroupSet, name string) string {
for _, g := range groups {
if g.Name == name {
return strings.Join(g.BookIDs, ",")
}
}
return "<missing>"
}
// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。
func TestBookGroupsEmptyStateIsArray(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
// 必须是 [] 而不是 null
if !strings.Contains(w.Body.String(), `"groups":[]`) {
t.Fatalf("空态应返回 [],实际 %s", w.Body.String())
}
}
func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3")
foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9")
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
{Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空
{Name: "玄幻", BookIDs: []string{b3.ID}},
{Name: "空组", BookIDs: []string{}},
{Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组
{Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉
})
if code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" {
t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names)
}
if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID {
t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID)
}
if ids := idsOf(got, "在读"); ids != "" {
t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids)
}
if ids := idsOf(got, "玄幻"); ids != b3.ID {
t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID)
}
if ids := idsOf(got, "外来"); ids != "" {
t.Fatalf("外来 应被过滤为空,实际 %q", ids)
}
// 读回来应当一致
loaded, code := getBookGroups(t, router, "u1")
if code != http.StatusOK {
t.Fatalf("读回 status=%d", code)
}
if names := namesOf(loaded); names != namesOf(got) {
t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got))
}
if ids := idsOf(loaded, "玄幻"); ids != b3.ID {
t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID)
}
}
// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。
func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
}); code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil {
t.Fatalf("移出书架失败: %v", err)
}
loaded, _ := getBookGroups(t, router, "u1")
if ids := idsOf(loaded, "科幻"); ids != b1.ID {
t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID)
}
}
func TestBookGroupsArePerUser(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2")
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "u1 的分组", BookIDs: []string{u1Book.ID}},
}); code != http.StatusOK {
t.Fatalf("u1 保存失败 status=%d", code)
}
// u2 看不到 u1 的分组
if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 {
t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code)
}
// u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架)
if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{
{Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}},
}); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID {
t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code)
}
// u1 的分组不受影响
back, _ := getBookGroups(t, router, "u1")
if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID {
t.Fatalf("u1 分组被 u2 影响: %+v", back)
}
}
// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。
func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "旧组", BookIDs: []string{b1.ID}},
}); code != http.StatusOK {
t.Fatalf("首次保存 status=%d", code)
}
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "新组", BookIDs: []string{}},
})
if code != http.StatusOK {
t.Fatalf("二次保存 status=%d", code)
}
if names := namesOf(got); names != "新组" {
t.Fatalf("二次保存后 = %q,期望只有 新组", names)
}
// 清空分组
if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 {
t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code)
}
if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 {
t.Fatalf("清空后读回 = %+v,期望空", groups)
}
}
+248
View File
@@ -0,0 +1,248 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 阅读器偏好接口(/reader/profile)的 HTTP 契约测试。
//
// 这些设置原先只存在浏览器 localStorage(设备级),换设备就丢。现在按用户落库,
// 所以要保证:新用户没有记录、写读一致、越界值收敛、以及用户之间互不影响。
// profileRouterForTest 用请求头 X-Test-User 指定调用者(默认 u1),
// 便于在一个测试里切换用户验证隔离。
func profileRouterForTest(t *testing.T, cfg *service.Container) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
user := c.GetHeader("X-Test-User")
if user == "" {
user = "u1"
}
c.Set(middleware.CtxUserID, user)
c.Next()
})
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
func getProfile(t *testing.T, router *gin.Engine, user string) (*reader.ReaderSettings, int) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/profile", nil)
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Profile *reader.ReaderSettings `json:"profile"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Profile, w.Code
}
func putProfile(t *testing.T, router *gin.Engine, user, body string) (*reader.ReaderSettings, int) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/reader/profile", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Profile *reader.ReaderSettings `json:"profile"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Profile, w.Code
}
func TestReaderProfileNullBeforeFirstSave(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
profile, code := getProfile(t, router, "u1")
if code != http.StatusOK {
t.Fatalf("status=%d", code)
}
// 没有记录时返回 null,由前端用它本地的值播种
if profile != nil {
t.Fatalf("新用户应返回 null,实际 %+v", profile)
}
}
func TestReaderProfileRoundTrip(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
body := `{
"theme_id":"preset2","night":true,"page_mode":"scroll",
"font_size":24,"line_height":2.1,"paragraph_spacing":12,
"audio_speed":1.5,"audio_timer_minutes":30,
"shelf_layout":"compact","shelf_grid_columns":4,"shelf_sort":"update",
"shelf_show_unread":false,"shelf_show_update_time":true
}`
saved, code := putProfile(t, router, "u1", body)
if code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if saved == nil {
t.Fatal("保存应返回偏好")
}
got, code := getProfile(t, router, "u1")
if code != http.StatusOK || got == nil {
t.Fatalf("读回失败 status=%d profile=%+v", code, got)
}
if *got != *saved {
t.Fatalf("读回与写入不一致:\n写入 %+v\n读回 %+v", *saved, *got)
}
if got.ThemeID != "preset2" || !got.Night || got.PageMode != "scroll" || got.FontSize != 24 ||
got.LineHeight != 2.1 || got.ParagraphSpacing != 12 || got.AudioSpeed != 1.5 ||
got.AudioTimerMinutes != 30 || got.ShelfLayout != "compact" || got.ShelfGridColumns != 4 ||
got.ShelfSort != "update" || got.ShelfShowUnread || !got.ShelfShowUpdateTime {
t.Fatalf("字段未按原值往返: %+v", *got)
}
}
func TestReaderProfileClampsAndFallsBack(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
// 全部越界 / 枚举非法,服务端应收敛而不是原样入库
body := `{
"theme_id":" ","night":false,"page_mode":"nope",
"font_size":999,"line_height":0.1,"paragraph_spacing":-5,
"audio_speed":99,"audio_timer_minutes":9999,
"shelf_layout":"diagonal","shelf_grid_columns":42,"shelf_sort":"random",
"shelf_show_unread":true,"shelf_show_update_time":false
}`
got, code := putProfile(t, router, "u1", body)
if code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if got.ThemeID != "preset1" {
t.Fatalf("空主题应回落到 preset1,实际 %q", got.ThemeID)
}
if got.PageMode != "page" {
t.Fatalf("非法 page_mode 应回落到 page,实际 %q", got.PageMode)
}
if got.FontSize != 32 {
t.Fatalf("font_size 应夹到上限 32,实际 %d", got.FontSize)
}
if got.LineHeight != 1.4 {
t.Fatalf("line_height 应夹到下限 1.4,实际 %v", got.LineHeight)
}
if got.ParagraphSpacing != 0 {
t.Fatalf("paragraph_spacing 应夹到下限 0,实际 %d", got.ParagraphSpacing)
}
if got.AudioSpeed != 3 {
t.Fatalf("audio_speed 应夹到上限 3,实际 %v", got.AudioSpeed)
}
if got.AudioTimerMinutes != 180 {
t.Fatalf("audio_timer_minutes 应夹到上限 180,实际 %d", got.AudioTimerMinutes)
}
if got.ShelfLayout != "grid" {
t.Fatalf("非法 shelf_layout 应回落到 grid,实际 %q", got.ShelfLayout)
}
if got.ShelfGridColumns != 6 {
t.Fatalf("shelf_grid_columns 应夹到上限 6,实际 %d", got.ShelfGridColumns)
}
if got.ShelfSort != "recent" {
t.Fatalf("非法 shelf_sort 应回落到 recent,实际 %q", got.ShelfSort)
}
// 布尔值不参与收敛,必须原样保留
if !got.ShelfShowUnread || got.ShelfShowUpdateTime {
t.Fatalf("布尔项被改动: unread=%v update=%v", got.ShelfShowUnread, got.ShelfShowUpdateTime)
}
}
func TestReaderProfileIsPerUser(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
if _, code := putProfile(t, router, "u1", `{
"theme_id":"preset3","night":true,"page_mode":"page","font_size":18,
"line_height":1.5,"paragraph_spacing":4,"audio_speed":1,
"audio_timer_minutes":0,"shelf_layout":"list","shelf_grid_columns":0,
"shelf_sort":"name","shelf_show_unread":true,"shelf_show_update_time":true
}`); code != http.StatusOK {
t.Fatalf("u1 保存失败 status=%d", code)
}
// 另一个用户不应看到 u1 的偏好,也不应在 u1 之外建出记录
other, code := getProfile(t, router, "u2")
if code != http.StatusOK {
t.Fatalf("u2 读取 status=%d", code)
}
if other != nil {
t.Fatalf("u2 应返回 null,实际 %+v", other)
}
if _, code := putProfile(t, router, "u2", `{
"theme_id":"preset4","night":false,"page_mode":"scroll","font_size":20,
"line_height":1.8,"paragraph_spacing":8,"audio_speed":2,
"audio_timer_minutes":60,"shelf_layout":"grid","shelf_grid_columns":3,
"shelf_sort":"author","shelf_show_unread":false,"shelf_show_update_time":false
}`); code != http.StatusOK {
t.Fatalf("u2 保存失败 status=%d", code)
}
// u1 的值不能被 u2 覆盖
back, code := getProfile(t, router, "u1")
if code != http.StatusOK || back == nil {
t.Fatalf("u1 读回失败 status=%d", code)
}
if back.ThemeID != "preset3" || back.ShelfLayout != "list" || back.ShelfSort != "name" || !back.Night {
t.Fatalf("u1 的偏好被 u2 覆盖: %+v", *back)
}
}
func TestReaderProfileSaveTwiceKeepsOneRow(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
first := `{"theme_id":"preset1","page_mode":"page","font_size":20,"line_height":1.8,
"paragraph_spacing":8,"audio_speed":1,"shelf_layout":"grid","shelf_sort":"recent"}`
if _, code := putProfile(t, router, "u1", first); code != http.StatusOK {
t.Fatalf("首次保存 status=%d", code)
}
second := `{"theme_id":"preset5","page_mode":"scroll","font_size":22,"line_height":2.0,
"paragraph_spacing":10,"audio_speed":1.25,"shelf_layout":"compact","shelf_sort":"mixed"}`
if _, code := putProfile(t, router, "u1", second); code != http.StatusOK {
t.Fatalf("二次保存 status=%d", code)
}
got, _ := getProfile(t, router, "u1")
if got == nil || got.ThemeID != "preset5" || got.ShelfSort != "mixed" {
t.Fatalf("二次保存未覆盖旧值: %+v", got)
}
// 一个用户只应有一条记录(唯一索引 + upsert)
var count int64
if err := svc.Repo.DB.Model(&model.ReaderProfile{}).
Where("user_id = ?", "u1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("u1 的记录数 = %d,期望 1", count)
}
}
+170
View File
@@ -0,0 +1,170 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 阅读进度接口的 HTTP 契约测试。
//
// 听书(音频)的进度是 audio.currentTime,天然带小数;漫画是图片序号、文本是页码,
// 都是整数。同一个接口必须同时接住这两种形态,否则音频进度会被 400 丢掉
// (前端是 fire-and-forget,失败不报错),表现为「听书退出后从头开始」。
func progressRouterForTest(cfg *service.Container, userID string) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Next()
})
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
func putProgress(t *testing.T, router *gin.Engine, bookID, body string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/reader/books/"+bookID+"/progress", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
return w
}
func TestSaveProgressAcceptsFractionalPos(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/1",
}, "有声书", "作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
// 37.5 秒:音频进度上报的真实形态
w := putProgress(t, router, book.ID, `{"chapter_index":2,"pos":37.5,"chapter_title":"第 3 章"}`)
if w.Code != http.StatusOK {
t.Fatalf("音频进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
}
books, err := svc.Reader.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if len(books) != 1 {
t.Fatalf("书架应有 1 本,实际 %d", len(books))
}
// 小数秒截断成整数秒落库(dur_chapter_pos 是 int)
if books[0].DurChapterPos != 37 {
t.Fatalf("进度 = %d,期望 37(37.5 截断)", books[0].DurChapterPos)
}
if books[0].DurChapterIndex != 2 || books[0].DurChapterTitle != "第 3 章" {
t.Fatalf("章节/标题未保存: index=%d title=%q", books[0].DurChapterIndex, books[0].DurChapterTitle)
}
}
func TestSaveProgressAcceptsIntegerPos(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/2",
}, "文本", "", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
// 文本/漫画仍是整数页码,不能被破坏
w := putProgress(t, router, book.ID, `{"chapter_index":7,"pos":12,"chapter_title":"第 8 章"}`)
if w.Code != http.StatusOK {
t.Fatalf("页码进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
}
books, err := svc.Reader.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if books[0].DurChapterPos != 12 || books[0].DurChapterIndex != 7 {
t.Fatalf("进度 = pos %d / index %d,期望 12 / 7", books[0].DurChapterPos, books[0].DurChapterIndex)
}
}
func TestSaveProgressRejectsNegativePos(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/3",
}, "文本", "", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
// 负数不是合法进度,落库前应被夹到 0,而不是写入负值
w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":-5,"chapter_title":"第 1 章"}`)
if w.Code != http.StatusOK {
t.Fatalf("负数进度不应 500,实际 status=%d body=%s", w.Code, w.Body.String())
}
books, err := svc.Reader.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if books[0].DurChapterPos != 0 {
t.Fatalf("进度 = %d,期望夹到 0", books[0].DurChapterPos)
}
}
// TestSaveProgressRoundTripsThroughJSON 防回归:进度写进去要能原样读回来。
func TestSaveProgressRoundTripsThroughJSON(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/4",
}, "有声书", "", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
if w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":123.75,"chapter_title":"第 1 章"}`); w.Code != http.StatusOK {
t.Fatalf("保存进度失败 status=%d body=%s", w.Code, w.Body.String())
}
// 走列表接口(前端书架/阅读器恢复进度读的就是它)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/books", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("书架列表 status=%d body=%s", w.Code, w.Body.String())
}
var res struct {
Books []struct {
DurChapterPos int `json:"dur_chapter_pos"`
} `json:"books"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析书架响应失败: %v", err)
}
if len(res.Books) != 1 || res.Books[0].DurChapterPos != 123 {
t.Fatalf("读回的进度 = %+v,期望 123", res.Books)
}
}
+319 -24
View File
@@ -11,8 +11,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware" "github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service" "github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader" "github.com/truewhile/MeBox/internal/service/reader"
readerrule "github.com/truewhile/MeBox/internal/service/reader/rule"
) )
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) { func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
@@ -39,6 +41,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie) // 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
g.POST("/browser/xhr", readerBrowserXHRHandler(svc)) g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
g.POST("/comments/open", readerOpenCommentHandler(svc))
// 搜索(多源聚合) // 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc)) g.POST("/search", readerSearchHandler(svc))
@@ -57,10 +62,24 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc)) g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
g.DELETE("/books/:id", readerRemoveBookHandler(svc)) g.DELETE("/books/:id", readerRemoveBookHandler(svc))
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc)) g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
// 更新目录:重抓书架里全部网络书籍的目录,刷新章节缓存与「最近更新」时间
g.POST("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc))
// 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步
g.GET("/profile", readerGetProfileHandler(svc))
g.PUT("/profile", readerSaveProfileHandler(svc))
// 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签
g.GET("/book-groups", readerGetBookGroupsHandler(svc))
g.PUT("/book-groups", readerSetBookGroupsHandler(svc))
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc)) g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
g.GET("/books/:id/chapters", readerListChaptersHandler(svc)) g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc)) g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
g.GET("/books/:id/content", readerBookContentHandler(svc)) g.GET("/books/:id/content", readerBookContentHandler(svc))
// 批量取正文(离线缓存协议,对应 legado CacheBook):返回每章内容与命中统计
g.POST("/books/:id/content-batch", readerBookContentBatchHandler(svc))
// 替换净化规则 // 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc)) g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
@@ -71,7 +90,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc { func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
sources, err := svc.Reader.ListSources(c.Request.Context()) userID := c.GetString(middleware.CtxUserID)
sources, err := svc.Reader.ListSources(c.Request.Context(), userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -89,7 +109,8 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text) userID := c.GetString(middleware.CtxUserID)
imported, err := svc.Reader.ImportSources(c.Request.Context(), userID, body.Text)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -107,7 +128,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"}) c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return return
} }
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil { userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -117,7 +139,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc { func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil { userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteSource(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -134,7 +157,8 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key) userID := c.GetString(middleware.CtxUserID)
logs, err := svc.Reader.Debug(c.Request.Context(), userID, c.Param("id"), body.Key)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -236,6 +260,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
BookID string `json:"book_id" binding:"required"`
URL string `json:"url" binding:"required"`
Title string `json:"title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, page)
}
}
// readerBrowserPageHandler 承载待办页面本体。// // readerBrowserPageHandler 承载待办页面本体。//
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求 // 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。 // 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
@@ -255,12 +303,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。 // 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc { func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s")) target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
if err != nil { if err != nil {
c.String(http.StatusForbidden, "%s", err.Error()) c.String(http.StatusForbidden, "%s", err.Error())
return return
} }
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target) contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
if err != nil { if err != nil {
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error()) c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
return return
@@ -275,7 +323,8 @@ func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc { func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil { userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -292,7 +341,8 @@ func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil { userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceVariable(c.Request.Context(), userID, c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -309,7 +359,8 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil { userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), userID, c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -320,13 +371,18 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
func readerSearchHandler(svc *service.Container) gin.HandlerFunc { func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct { var body struct {
Key string `json:"key" binding:"required"` Key string `json:"key" binding:"required"`
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
SourceIDs []string `json:"source_ids"`
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
Page int `json:"page"`
} }
return func(c *gin.Context) { return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key) userID := c.GetString(middleware.CtxUserID)
books, skipped, err := svc.Reader.Search(c.Request.Context(), userID, body.Key, body.SourceIDs, body.Page)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -345,6 +401,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info) c.JSON(http.StatusOK, info)
} }
} }
@@ -360,6 +420,11 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
// 「Cannot read properties of null」。空目录统一给 []。
if chapters == nil {
chapters = []reader.TocChapter{}
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters}) c.JSON(http.StatusOK, gin.H{"chapters": chapters})
} }
} }
@@ -386,6 +451,10 @@ func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// 封面解密代理只在下发时改写:库里仍存原始地址,换源/换签名密钥后不会失效。
for i := range books {
books[i].CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), books[i].ID, books[i].Origin, books[i].CoverURL)
}
c.JSON(http.StatusOK, gin.H{"books": books}) c.JSON(http.StatusOK, gin.H{"books": books})
} }
} }
@@ -414,6 +483,28 @@ func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。 // readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。 // 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc { func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
@@ -502,6 +593,20 @@ func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。
// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。
func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc { func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID) userID := c.GetString(middleware.CtxUserID)
@@ -513,11 +618,56 @@ func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc { // readerGetProfileHandler 读当前用户的阅读器偏好。
// 没保存过时返回 {"profile": null},前端据此用本地值播种。
func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": settings})
}
}
// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。
func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReaderSettings
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": saved})
}
}
// readerGetBookGroupsHandler 读当前用户的书架分组。
// 没有分组时返回空数组(不是 null),前端可以直接遍历。
func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
}
// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。
func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
var body struct { var body struct {
ChapterIndex int `json:"chapter_index"` Groups []model.BookGroupSet `json:"groups"`
Pos int `json:"pos"`
ChapterTitle string `json:"chapter_title"`
} }
return func(c *gin.Context) { return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
@@ -525,7 +675,44 @@ func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
userID := c.GetString(middleware.CtxUserID) userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, body.Pos, body.ChapterTitle); err != nil { groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
}
// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它,
// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。
const maxReaderChapterPos = 10_000_000
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndex int `json:"chapter_index"`
// Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数),
// 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求
// 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。
Pos float64 `json:"pos"`
ChapterTitle string `json:"chapter_title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。
posFloat := body.Pos
if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN)
posFloat = 0
}
if posFloat > maxReaderChapterPos {
posFloat = maxReaderChapterPos
}
pos := int(posFloat)
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -652,12 +839,37 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"}) c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return return
} }
// 封面解密代理(d=cover):签名主体可能是书源 URL(搜索结果尚未入库)。
// 封面不读取书籍正文,因此不需要书籍记录;统一整段读取后解密下发。
if c.Query("d") == "cover" {
resp, fetchErr := svc.Reader.FetchCover(c.Request.Context(), bookID, rawURL)
if fetchErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面拉取失败: " + fetchErr.Error()})
return
}
defer resp.Body.Close()
data, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(data) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面过大或读取失败"})
return
}
decoded := svc.Reader.DecodeCoverBytes(c.Request.Context(), bookID, rawURL, data)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID) book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return return
} }
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range")) // 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return return
@@ -665,13 +877,51 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
defer resp.Body.Close() defer resp.Body.Close()
ct := resp.Header.Get("Content-Type") ct := resp.Header.Get("Content-Type")
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") || // 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8") // 这类响应必须整段读取后处理,不再支持 Range(解密后长度会变)。
if isPlaylist { if decodeJS := svc.Reader.ImageDecodeRule(c.Request.Context(), book); decodeJS != "" && !isMediaPlaylistRequest(resp) {
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播 sniffed, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if readErr != nil || len(sniffed) > readerImageDecodeMaxBytes {
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data)) c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten)) return
}
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
return
}
contentType := ct
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
return return
} }
// 流式透传(含 206 Partial Content,支持音频拖动进度) // 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -685,6 +935,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
return strings.Contains(ct, "mpegurl")
}
// readerBodyIsPlaylist 判断上游响应体是否是 HLS 播放列表。
// 以 #EXTM3U 标记为准:Content-Type 只是辅助(上游常标成 octet-stream)。
func readerBodyIsPlaylist(head []byte, contentType string) bool {
return reader.BodyIsPlaylist(head, contentType)
}
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等): // readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。 // 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc { func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
@@ -773,3 +1039,32 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, content) c.JSON(http.StatusOK, content)
} }
} }
// readerBookContentBatchHandler 批量取正文(对应 legado CacheBook / getContentBatch)。
//
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
return
}
if len(body.ChapterIndexes) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多请求 64 章"})
return
}
userID := c.GetString(middleware.CtxUserID)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}
+1
View File
@@ -50,6 +50,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts) // Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc)) admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(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.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc)) admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc)) admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
@@ -0,0 +1,200 @@
package handler
import (
"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/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// 首页轮播/剧集卡片交给播放器的是「整部剧」的伪装 ID,它自身没有媒体源:远程 Emby
// 对它的取流请求会回 500(Unable to cast 'Series' to 'IHasMediaSources')。这里锁定
// 播放接口的行为——改投第一集,且客户端原有的查询参数(token / profile / proxy)
// 必须原样带过去,否则播放器跟过去之后会掉鉴权。
func TestStreamRedirectsRemoteContainerToPlayableEpisode(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "某部新番", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
map[string]any{
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
"ParentIndexNumber": 1, "IndexNumber": 1,
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
},
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
&model.StrmAccount{}, &model.EmbyMount{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "stream-redirect-secret"
log := zap.NewNop()
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote}
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/stream/:id", streamHandler(svc))
req := httptest.NewRequest(
http.MethodGet,
"/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "series-100")+"?token=tok-1&profile=p-1",
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
}
want := "/api/stream/" + service.EncodeEmbyRemoteID("mount-1", "ep-1") + "?token=tok-1&profile=p-1"
if got := w.Header().Get("Location"); got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
}
// 本身可播的远程条目(单集/电影)不能被改写,必须照原样进入原来的代理/直连分支。
func TestStreamKeepsPlayableRemoteItem(t *testing.T) {
gin.SetMode(gin.TestMode)
var streamCalls int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
"ParentIndexNumber": 1, "IndexNumber": 1,
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
})
case "/emby/Videos/ep-1/stream":
streamCalls++
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("bytes"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
&model.StrmAccount{}, &model.EmbyMount{},
); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "stream-keep-secret"
log := zap.NewNop()
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
emby := service.NewEmbyService(cfg, log, repos).SetEmbyRemote(remote)
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote, Emby: emby}
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatal(err)
}
// proxy_play=true:直接走反向代理分支,便于断言确实取到了流。
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
ProxyPlay: true,
Enabled: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/stream/:id", streamHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "ep-1"), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "bytes" {
t.Fatalf("body = %q, want the proxied stream", got)
}
if streamCalls != 1 {
t.Fatalf("upstream stream calls = %d, want 1", streamCalls)
}
}
+32 -4
View File
@@ -17,6 +17,13 @@ import (
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。 // defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
// maxDecompressedBody 解压后的响应体上限。
//
// 调用方只限制了压缩前的字节数(例如 reader 的 8MiB),而高压缩比响应可以
// 放大几个数量级:一个几 MB 的 gzip 就能在解压时把内存吃光。这里统一兜底,
// 超过上限时按解压失败处理(原样返回压缩字节,由调用方报错)。
const maxDecompressedBody = 32 << 20
// DecompressBody 兜底解压响应体(gzip / deflate)。 // DecompressBody 兜底解压响应体(gzip / deflate)。
// //
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip // 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
@@ -37,17 +44,20 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
return data return data
} }
defer r.Close() defer r.Close()
if out, err := io.ReadAll(r); err == nil { // 不吞掉多个 member:默认 Multistream(true) 会让串接的 member 继续解,
// 是压缩炸弹的常见放大手段。
r.Multistream(false)
if out, ok := readLimited(r); ok {
return out return out
} }
case "deflate": case "deflate":
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。 // deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil { if out, ok := readLimited(flate.NewReader(bytes.NewReader(data))); ok {
return out return out
} }
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil { if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
defer zr.Close() defer zr.Close()
if out, err := io.ReadAll(zr); err == nil { if out, ok := readLimited(zr); ok {
return out return out
} }
} }
@@ -55,6 +65,15 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
return data return data
} }
// readLimited 读取解压流,超过上限返回 ok=false。
func readLimited(r io.Reader) ([]byte, bool) {
out, err := io.ReadAll(io.LimitReader(r, maxDecompressedBody+1))
if err != nil || len(out) > maxDecompressedBody {
return nil, false
}
return out, true
}
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。 // StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
// //
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源 // 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
@@ -104,10 +123,19 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
// //
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值), // 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。 // 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
// 注意:Accept 刻意用 `*/*` 而不是浏览器页面导航的
// `text/html,application/xhtml+xml,...`。
//
// 很多书源接口是 DRF(Django REST framework)一类会做内容协商的后端:当 Accept
// 首选 text/html 时,它按浏览器语义返回「可浏览的 HTML 页面」(HTTP 200),
// 而不是 JSON。引擎再把这份 HTML 交给 JSONPath 解析,结果永远是 0 条——表现为
// 「同一个源在手机 App 里能搜到,在 MeBox 里搜不到」(拷贝系列源就是这个坑)。
// 手机端阅读 App 走 OkHttp,不显式设置 Accept,等价于 `*/*`;这里对齐该行为。
// 需要页面型 Accept 的书源可以在自身 header 里显式声明覆盖。
func HTTPHeaderPresets() map[string]string { func HTTPHeaderPresets() map[string]string {
return map[string]string{ return map[string]string{
"User-Agent": defaultUserAgent, "User-Agent": defaultUserAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Connection": "keep-alive", "Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1", "Upgrade-Insecure-Requests": "1",
+12
View File
@@ -12,6 +12,18 @@ import (
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http // 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。 // 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
// TestHeaderPresetsAcceptIsWildcard 预设 Accept 不得首选 text/html。
//
// 背景:DRF(Django REST framework)等会做内容协商的后端,在 Accept 首选
// text/html 时返回「可浏览 HTML 页面」而不是 JSON,书源 JSONPath 因此永远
// 解析出 0 条(拷贝轻小说源在 MeBox 搜不到、在阅读 App 能搜到的根因)。
func TestHeaderPresetsAcceptIsWildcard(t *testing.T) {
got := HTTPHeaderPresets()["Accept"]
if got != "*/*" {
t.Fatalf("Accept 预设应为 */*(对齐 legado/OkHttp),实际 %q", got)
}
}
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。 // TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。 // 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) { func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
+20
View File
@@ -17,6 +17,8 @@ type Claims struct {
Tier string `json:"tier,omitempty"` Tier string `json:"tier,omitempty"`
Purpose string `json:"purpose,omitempty"` Purpose string `json:"purpose,omitempty"`
MediaID string `json:"media_id,omitempty"` MediaID string `json:"media_id,omitempty"`
// Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。
Sid string `json:"sid,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"}) c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
return 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) syncAccessTokenCookie(c, raw, claims)
c.Set(CtxUserID, claims.UserID) c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role) c.Set(CtxUserRole, claims.Role)
@@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string {
} }
return "" return ""
} }
// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。
func ExtractToken(c *gin.Context) string {
return extractToken(c)
}
+48
View File
@@ -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)
}
+2
View File
@@ -9,6 +9,8 @@ const (
CtxUserTier = "ctx_user_tier" CtxUserTier = "ctx_user_tier"
CtxTokenPurpose = "ctx_token_purpose" CtxTokenPurpose = "ctx_token_purpose"
CtxTokenMediaID = "ctx_token_media_id" CtxTokenMediaID = "ctx_token_media_id"
// CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。
CtxAuthSessionID = "ctx_auth_session_id"
// AccessTokenCookieName carries the web access token for browser-managed // AccessTokenCookieName carries the web access token for browser-managed
// resource requests such as <img>, which cannot attach Authorization. // resource requests such as <img>, which cannot attach Authorization.
+49
View File
@@ -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()
}
+11 -7
View File
@@ -26,13 +26,17 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。 // Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct { type Media struct {
Base Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"` 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"` LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"` Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"` OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` // Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
// (SQLite 没有这个限制)。见 database.ensureMediaPathIndex。
Path string `gorm:"size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"` RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"` DurationSec int `json:"duration_sec"`
+4
View File
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
&APIConfig{}, &APIConfig{},
&UserPermission{}, &UserPermission{},
&RefreshToken{}, &RefreshToken{},
&AuthSession{},
&PlayProfile{}, &PlayProfile{},
&RegistrationCode{}, &RegistrationCode{},
&SignIn{}, &SignIn{},
@@ -65,6 +66,9 @@ func AllModels() []interface{} {
&ReaderSourceState{}, &ReaderSourceState{},
&ReaderBook{}, &ReaderBook{},
&ReaderChapter{}, &ReaderChapter{},
&ReaderContentCache{},
&ReaderReplaceRule{}, &ReaderReplaceRule{},
&ReaderProfile{},
&ReaderBookGroups{},
} }
} }
+102 -21
View File
@@ -8,26 +8,36 @@ import (
) )
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。 // ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
//
// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量),
// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠
// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。
type ReaderBookSource struct { type ReaderBookSource struct {
Base Base
Name string `gorm:"type:varchar(255);index" json:"name"` // UserID 归属用户。历史数据迁移时会回填;值为空表示「旧版全局书源」,
GroupName string `gorm:"type:varchar(255);index" json:"group"` // 只在没有任何用户引用时才会出现。
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频 UserID string `gorm:"type:varchar(36);uniqueIndex:uniq_reader_source_user_url;index" json:"user_id"`
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"` Name string `gorm:"type:varchar(255);index" json:"name"`
RawJSON string `gorm:"type:text" json:"-"` GroupName string `gorm:"type:varchar(255);index" json:"group"`
Enabled bool `gorm:"default:true" json:"enabled"` Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"` SourceURL string `gorm:"type:varchar(512);uniqueIndex:uniq_reader_source_user_url;index" json:"source_url"`
CustomOrder int `json:"custom_order"` RawJSON string `gorm:"type:text" json:"-"`
Weight int `json:"weight"` Enabled bool `gorm:"default:true" json:"enabled"`
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"` EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON CustomOrder int `json:"custom_order"`
Comment string `gorm:"type:text" json:"comment"` Weight int `json:"weight"`
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
LastUpdateTime int64 `json:"last_update_time"` Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
Comment string `gorm:"type:text" json:"comment"`
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
LastUpdateTime int64 `json:"last_update_time"`
LastCheckAt *time.Time `json:"last_check_at"` LastCheckAt *time.Time `json:"last_check_at"`
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms) RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。 // HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
HasLogin bool `gorm:"-" json:"has_login"` HasLogin bool `gorm:"-" json:"has_login"`
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
} }
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的 // ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
@@ -58,13 +68,17 @@ type ReaderBook struct {
Charset string `gorm:"type:varchar(32)" json:"charset"` Charset string `gorm:"type:varchar(32)" json:"charset"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片
LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"` LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"`
TotalChapterNum int `json:"total_chapter_num"` // LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。
DurChapterIndex int `json:"dur_chapter_index"` // 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。
DurChapterPos int `json:"dur_chapter_pos"` // 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"` LatestChapterTime int64 `json:"latest_chapter_time"`
DurChapterTime int64 `json:"dur_chapter_time"` TotalChapterNum int `json:"total_chapter_num"`
Order int `json:"order"` DurChapterIndex int `json:"dur_chapter_index"`
Variable string `gorm:"type:text" json:"variable"` DurChapterPos int `json:"dur_chapter_pos"`
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
DurChapterTime int64 `json:"dur_chapter_time"`
Order int `json:"order"`
Variable string `gorm:"type:text" json:"variable"`
// LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名 // LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名
// (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。 // (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。
// 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。 // 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。
@@ -91,6 +105,45 @@ type ReaderChapter struct {
Tag string `gorm:"type:varchar(255)" json:"tag"` Tag string `gorm:"type:varchar(255)" json:"tag"`
} }
// ReaderContentCache 正文持久缓存的索引行(内容本体在磁盘上,见 reader_content_cache.go)。
//
// 与 legado BookHelp 的章节正文缓存对应:缓存的是「书源侧产物」(书源 replaceRegex
// 之后、用户替换规则与代理改写之前),因此可以跨用户共享;用户维度的处理在读出后
// 逐请求应用,规则改动即时生效。
//
// 章节身份不落库为外键,而是 BookKey(书源 + 书本地址)与 ChapterKey(绝对化章节
// 地址或标题)的哈希:目录刷新(ReplaceChapters 物理重建、行 ID 会变)与书源更新
// 之后仍然能按同一身份命中或迁移。
type ReaderContentCache struct {
Base
// OriginHash 书源地址哈希(磁盘目录的第一层,清理时定位文件用)。
OriginHash string `gorm:"type:varchar(64)" json:"origin_hash"`
// BookKey 书源身份哈希(sha256(origin + "\0" + bookURL) 前 16 字节 hex)。
BookKey string `gorm:"type:varchar(64);index:idx_reader_content_book" json:"book_key"`
// ChapterKey 章节身份哈希(绝对化 URL 优先,退化为 title)。
ChapterKey string `gorm:"type:varchar(64);index:idx_reader_content_chapter" json:"chapter_key"`
// ChapterIdentity 章节身份原文(便于诊断与 remap 时的标题兜底匹配)。
ChapterIdentity string `gorm:"type:varchar(512)" json:"chapter_identity"`
// ChapterIndex 保存时的章节序号(remap 时更新)。
ChapterIndex int `json:"chapter_index"`
// ContentType text / audio / image。
ContentType string `gorm:"type:varchar(16)" json:"content_type"`
// SourceHash 书源内容指纹(RawJSON 哈希):书源更新后自然失效。
SourceHash string `gorm:"type:varchar(64)" json:"source_hash"`
// FormatVersion 缓存载荷格式版本:解析管线语义变化时递增,旧条目自然失效。
FormatVersion int `json:"format_version"`
// SizeBytes 载荷字节数(容量统计用)。
SizeBytes int64 `json:"size_bytes"`
// AssetCount 音频轨/图片张数(清单类内容的完整性统计)。
AssetCount int `json:"asset_count"`
// ExpiresAt 过期时间(unix 秒);0 表示不过期。
ExpiresAt int64 `json:"expires_at"`
// LastAccessAt 最近命中时间(unix 秒),LRU 淘汰依据。
LastAccessAt int64 `json:"last_access_at"`
// Hits 命中次数(诊断用)。
Hits int `json:"hits"`
}
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。 // ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
type ReaderReplaceRule struct { type ReaderReplaceRule struct {
Base Base
@@ -108,3 +161,31 @@ type ReaderReplaceRule struct {
TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"` TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"`
Order int `json:"order"` Order int `json:"order"`
} }
// ReaderProfile 阅读器偏好(每个用户一条)。
//
// 主题 / 排版 / 听书 / 书架展示这些设置原先只存在浏览器 localStorage,属于设备级:
// 换设备或换浏览器就丢。这里按用户落库作为权威来源,前端 localStorage 退化成首屏缓存。
// 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不在这里同步。
type ReaderProfile struct {
Base
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
ThemeID string `gorm:"type:varchar(32)" json:"theme_id"`
Night bool `json:"night"`
PageMode string `gorm:"type:varchar(16)" json:"page_mode"` // page 翻页 / scroll 滚动
FontSize int `json:"font_size"`
LineHeight float64 `json:"line_height"`
ParagraphSpacing int `json:"paragraph_spacing"`
// 听书偏好(对应 legado AudioPlay.playSpeed 与 AppConfig.ttsTimer)
AudioSpeed float64 `json:"audio_speed"`
AudioTimerMinutes int `json:"audio_timer_minutes"`
// 书架展示偏好(对应 legado 的书架设置)
ShelfLayout string `gorm:"type:varchar(16)" json:"shelf_layout"` // grid / list / compact
ShelfGridColumns int `json:"shelf_grid_columns"` // 0 表示自适应
ShelfSort string `gorm:"type:varchar(16)" json:"shelf_sort"`
ShelfShowUnread bool `json:"shelf_show_unread"`
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
}
+125
View File
@@ -0,0 +1,125 @@
package model
import (
"encoding/json"
"strings"
)
// 书架分组(对应 legado 的 BookGroup)。
//
// 与影视模块的媒体库标签(LibraryTagSet)同构:组名 → 成员 ID 列表,一个成员只归一个组。
// 区别只是归属对象从媒体库换成了书籍,因此前端可以照搬那套标签栏 / 管理弹窗的交互。
const (
// MaxBookGroups 是单个用户可创建的分组数量上限,避免恶意写入过大的 JSON。
MaxBookGroups = 50
// MaxBookGroupNameLen 是单个分组名的最大字符长度(按 rune 计数)。
MaxBookGroupNameLen = 24
)
// BookGroupSet 是一个书架分组:组名 + 组内书籍 ID(顺序即组内展示顺序)。
type BookGroupSet struct {
Name string `json:"name"`
BookIDs []string `json:"book_ids"`
}
// ReaderBookGroups 是某个用户的书架分组集合,每个用户一条记录。
//
// 分组是「整份替换」语义(前端一次 PUT 提交完整分组列表),所以直接存成一份 JSON;
// 与阅读器偏好(ReaderProfile)分表存放,两条写入路径互不干扰:
// 先建分组、后调偏好时不会因为整行 Save 把对方的列写成零值。
type ReaderBookGroups struct {
Base
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
// Groups 是 []BookGroupSet 的 JSON 文本;空字符串表示没有分组。
Groups string `gorm:"type:text" json:"-"`
}
// DecodeBookGroups 解析 Groups 字段,忽略损坏的数据。
func (r *ReaderBookGroups) DecodeBookGroups() []BookGroupSet {
if r == nil || strings.TrimSpace(r.Groups) == "" {
return nil
}
var groups []BookGroupSet
if err := json.Unmarshal([]byte(r.Groups), &groups); err != nil {
return nil
}
return NormalizeBookGroups(groups)
}
// EncodeBookGroups 把分组集合序列化为可写入 Groups 字段的 JSON 文本。
// 空集合序列化为空字符串,便于用零值表达「没有分组」。
func EncodeBookGroups(groups []BookGroupSet) (string, error) {
if len(groups) == 0 {
return "", nil
}
raw, err := json.Marshal(groups)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeBookGroups 清洗分组集合:去掉空名分组、合并重名分组、去掉组内重复的
// 书籍 ID,并保持传入顺序。不在这里做「一本书只归一个组」的收敛——那需要知道
// 书籍是否存在,属于服务层(SetBookGroups)的职责。
func NormalizeBookGroups(groups []BookGroupSet) []BookGroupSet {
if len(groups) == 0 {
return nil
}
out := make([]BookGroupSet, 0, len(groups))
indexByName := make(map[string]int, len(groups))
for _, group := range groups {
name := TruncateBookGroupName(group.Name)
if name == "" {
continue
}
if len(out) >= MaxBookGroups {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
out = append(out, BookGroupSet{Name: name, BookIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].BookIDs))
for _, id := range out[pos].BookIDs {
seen[id] = struct{}{}
}
for _, id := range group.BookIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].BookIDs = append(out[pos].BookIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].BookIDs == nil {
out[i].BookIDs = []string{}
}
}
return out
}
// TruncateBookGroupName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateBookGroupName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxBookGroupNameLen {
runes = runes[:MaxBookGroupNameLen]
}
return strings.TrimSpace(string(runes))
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"strings"
"testing"
)
// 书架分组的清洗与编解码回归测试(与 library_tag_test.go 同构)。
func TestNormalizeBookGroupsMergesDuplicatesAndTrims(t *testing.T) {
groups := []BookGroupSet{
{Name: " 科幻 ", BookIDs: []string{"a", "b"}},
{Name: "科幻", BookIDs: []string{"b", "c"}},
{Name: " ", BookIDs: []string{"d"}},
{Name: "在读", BookIDs: []string{"", "e", "e"}},
}
got := NormalizeBookGroups(groups)
if len(got) != 2 {
t.Fatalf("NormalizeBookGroups len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "科幻" || strings.Join(got[0].BookIDs, ",") != "a,b,c" {
t.Fatalf("首个分组 = %#v,期望 科幻[a,b,c]", got[0])
}
if got[1].Name != "在读" || strings.Join(got[1].BookIDs, ",") != "e" {
t.Fatalf("第二个分组 = %#v,期望 在读[e]", got[1])
}
}
func TestNormalizeBookGroupsCapsCountAndNameLength(t *testing.T) {
long := strings.Repeat("长", MaxBookGroupNameLen+10)
got := NormalizeBookGroups([]BookGroupSet{{Name: long}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxBookGroupNameLen {
t.Fatalf("分组名长度 = %d,期望 %d", len(runes), MaxBookGroupNameLen)
}
many := make([]BookGroupSet, 0, MaxBookGroups+5)
for i := 0; i < MaxBookGroups+5; i++ {
many = append(many, BookGroupSet{Name: string(rune('a' + i))})
}
if capped := NormalizeBookGroups(many); len(capped) != MaxBookGroups {
t.Fatalf("capped len = %d, want %d", len(capped), MaxBookGroups)
}
}
func TestNormalizeBookGroupsKeepsEmptyGroups(t *testing.T) {
got := NormalizeBookGroups([]BookGroupSet{{Name: "空组"}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
// 空分组的 BookIDs 应该是空数组而不是 nil,前端直接遍历时不会炸
if got[0].BookIDs == nil || len(got[0].BookIDs) != 0 {
t.Fatalf("BookIDs = %#v,期望空数组", got[0].BookIDs)
}
}
func TestEncodeDecodeBookGroupsRoundTrip(t *testing.T) {
if encoded, err := EncodeBookGroups(nil); err != nil || encoded != "" {
t.Fatalf("EncodeBookGroups(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeBookGroups([]BookGroupSet{{Name: "科幻", BookIDs: []string{"b-1"}}})
if err != nil {
t.Fatalf("EncodeBookGroups: %v", err)
}
row := &ReaderBookGroups{Groups: raw}
decoded := row.DecodeBookGroups()
if len(decoded) != 1 || decoded[0].Name != "科幻" || decoded[0].BookIDs[0] != "b-1" {
t.Fatalf("DecodeBookGroups = %#v", decoded)
}
row.Groups = "{not json"
if decoded := row.DecodeBookGroups(); decoded != nil {
t.Fatalf("损坏数据应返回 nil,实际 %#v", decoded)
}
// nil 接收者不应 panic(仓库层查不到记录时会返回 nil)
var nilRow *ReaderBookGroups
if decoded := nilRow.DecodeBookGroups(); decoded != nil {
t.Fatalf("nil 接收者应返回 nil,实际 %#v", decoded)
}
}
+8 -6
View File
@@ -11,12 +11,14 @@ import (
// RefreshToken 用于双令牌认证机制中的刷新令牌。 // RefreshToken 用于双令牌认证机制中的刷新令牌。
// 存储时使用 SHA256 哈希,原始令牌不存储。 // 存储时使用 SHA256 哈希,原始令牌不存储。
type RefreshToken struct { type RefreshToken struct {
ID string `gorm:"primaryKey;size:36" json:"id"` ID string `gorm:"primaryKey;size:36" json:"id"`
UserID string `gorm:"index;size:36;not null" json:"user_id"` UserID string `gorm:"index;size:36;not null" json:"user_id"`
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"` TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"` // AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。
CreatedAt time.Time `json:"created_at"` AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"`
Revoked bool `gorm:"default:false" json:"revoked"` ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
} }
// BeforeCreate 生成 UUID。 // 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
}
+21
View File
@@ -0,0 +1,21 @@
package repository
import "gorm.io/gorm"
// LikeOperator 返回当前方言下「与 SQLite LIKE 行为一致」的模式匹配运算符。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写(默认编译选项),PostgreSQL 的 LIKE 区分
// 大小写、只有 ILIKE 才不区分。同一个搜索词在两种方言下必须给出同样的结果集,
// 否则换库之后「inception」就再也搜不到「Inception」。
//
// 供构造原始 SQL 片段的调用方(含 service 层)使用。
func LikeOperator(db *gorm.DB) string {
return likeOperator(db)
}
func likeOperator(db *gorm.DB) string {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" {
return "ILIKE"
}
return "LIKE"
}
+54
View File
@@ -0,0 +1,54 @@
package repository
import (
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// TestSegmentPrewarmQueryBindsBooleanFound 守住 PostgreSQL 下片段预取查询的合法性。
//
// media_segment_fetches.found 是布尔列,而 PostgreSQL 没有 boolean = integer
// 运算符:写成 f.found = 0 / = 1 会让计划任务里的片段预取固定报
//
// operator does not exist: boolean = integer (SQLSTATE 42883)
//
// SQLite 一直能跑,所以这个错只在 PostgreSQL 上暴露(表现为日志里周期性出现
// 该错误、片头片尾数据永远补不上)。这里断言语句里只能出现占位符绑定。
func TestSegmentPrewarmQueryBindsBooleanFound(t *testing.T) {
query := segmentPrewarmCandidatesQuery
lower := strings.ToLower(query)
for _, forbidden := range []string{"found = 0", "found = 1", "found=0", "found=1", "found = true", "found = false"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("预取查询把布尔列与字面量比较(%q),PostgreSQL 会直接报错:\n%s", forbidden, query)
}
}
if got := strings.Count(query, "f.found = ?"); got != 2 {
t.Fatalf("expected 2 placeholder-bound found comparisons, got %d:\n%s", got, query)
}
}
// TestLikeOperatorMatchesSQLiteCaseInsensitivity 守住搜索大小写行为的方言差异。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写,PostgreSQL 的 LIKE 区分大小写:同一个
// 搜索词在两种方言下必须给出同样的结果集,PostgreSQL 必须走 ILIKE。
func TestLikeOperatorMatchesSQLiteCaseInsensitivity(t *testing.T) {
pg, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
if got := LikeOperator(pg); got != "ILIKE" {
t.Fatalf("postgres LikeOperator = %q, want ILIKE", got)
}
if got := LikeOperator(nil); got != "LIKE" {
t.Fatalf("nil LikeOperator = %q, want LIKE", got)
}
}
@@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"gorm.io/gorm" "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) { func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64 var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
+17 -3
View File
@@ -33,6 +33,20 @@ func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.Playbac
return r.upsert(ctx, h, true) return r.upsert(ctx, h, true)
} }
// historyColumn 生成「当前表」限定的列引用。
//
// PostgreSQL 的 ON CONFLICT DO UPDATE ... WHERE 里,未限定的列名会同时可解析到
// 目标表和本次插入的行,于是报
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 结果是 POST /api/history 一律 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查,所以这个错只在 PostgreSQL 上暴露。clause.CurrentTable 由 GORM 在构建语句
// 时替换成真实表名,两种方言都能正确渲染。
func historyColumn(name string) clause.Column {
return clause.Column{Table: clause.CurrentTable, Name: name}
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error { func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h) updates := playbackHistoryAssignments(h)
if versioned { if versioned {
@@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory
onConflict.Where = clause.Where{Exprs: []clause.Expression{ onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or( clause.Or(
clause.Lt{ clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"}, Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs, Value: h.SessionStartedAtMs,
}, },
clause.And( clause.And(
clause.Eq{ clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"}, Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs, Value: h.SessionStartedAtMs,
}, },
clause.Lte{ clause.Lte{
Column: clause.Column{Name: "sequence"}, Column: historyColumn("sequence"),
Value: h.Sequence, Value: h.Sequence,
}, },
), ),
@@ -0,0 +1,65 @@
package repository
import (
"context"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestHistoryProgressUpsertQualifiesConflictWhereColumns 守住 PostgreSQL 下进度上报
// 的 upsert 语句合法性。
//
// ON CONFLICT DO UPDATE 的 WHERE 里如果不给列名加表限定,PostgreSQL 会因为这些列
// 同时出现在本次插入的行里而拒绝该语句:
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 后果是 POST /api/history 全部 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查(旧写法在 SQLite 下一直正常),所以只能在 PostgreSQL 方言下断言。
func TestHistoryProgressUpsertQualifiesConflictWhereColumns(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
ctx := context.Background()
if err := repos.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 2_000,
SessionID: "session-1",
SessionStartedAtMs: 111,
Sequence: 2,
}); err != nil {
t.Fatalf("build upsert: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from UpsertProgress")
}
if !strings.Contains(sql, "ON CONFLICT") {
t.Fatalf("expected an ON CONFLICT upsert, got:\n%s", sql)
}
// 只看 ON CONFLICT 之后的部分:SET 的赋值目标和 CASE 里的 duration_ms 引用
// 属于另一套语法规则,不在这里断言。
conflict := sql[strings.Index(sql, "ON CONFLICT"):]
whereIdx := strings.Index(conflict, " WHERE ")
if whereIdx < 0 {
t.Fatalf("ON CONFLICT clause has no DO UPDATE ... WHERE guard:\n%s", sql)
}
where := conflict[whereIdx:]
for _, want := range []string{
`"playback_histories"."session_started_at_ms"`,
`"playback_histories"."sequence"`,
} {
if !strings.Contains(where, want) {
t.Fatalf("DO UPDATE WHERE is missing the table-qualified %s; it is ambiguous on PostgreSQL:\n%s", want, sql)
}
}
if strings.Contains(where, `("session_started_at_ms"`) || strings.Contains(where, ` AND "sequence"`) {
t.Fatalf("DO UPDATE WHERE still references unqualified columns:\n%s", sql)
}
}
+11 -11
View File
@@ -63,38 +63,38 @@ type MediaQueryFilter struct {
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB { func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW { if !filter.IncludeNSFW {
q = q.Where("nsfw = ?", false) q = q.Where("media.nsfw = ?", false)
} }
if len(filter.HiddenLibraryIDs) > 0 { if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs) q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
} }
if len(filter.AllowedLibraryIDs) > 0 { if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs) q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
} }
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" { if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
q = q.Where("library_id = ?", libraryID) q = q.Where("media.library_id = ?", libraryID)
} }
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" { if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("series_id = ?", seriesID) q = q.Where("media.series_id = ?", seriesID)
} }
if len(filter.Genres) > 0 { if len(filter.Genres) > 0 {
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...) q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
} }
if filter.YearMin > 0 { if filter.YearMin > 0 {
q = q.Where("year >= ?", filter.YearMin) q = q.Where("media.year >= ?", filter.YearMin)
} }
if filter.YearMax > 0 { if filter.YearMax > 0 {
q = q.Where("year <= ?", filter.YearMax) q = q.Where("media.year <= ?", filter.YearMax)
} }
if filter.RatingMin > 0 { if filter.RatingMin > 0 {
q = q.Where("rating >= ?", filter.RatingMin) q = q.Where("media.rating >= ?", filter.RatingMin)
} }
if filter.UnwatchedOnly { if filter.UnwatchedOnly {
userID := strings.TrimSpace(filter.UnwatchedUserID) userID := strings.TrimSpace(filter.UnwatchedUserID)
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。 // 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
if userID != "" { if userID != "" {
q = q.Where( q = q.Where(
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)", "media.id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
userID, true, userID, true,
) )
} }
@@ -113,7 +113,7 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
func genreMatchClause(genres []string) string { func genreMatchClause(genres []string) string {
clauses := make([]string, 0, len(genres)) clauses := make([]string, 0, len(genres))
for range genres { for range genres {
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?") clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(media.genres), ' ', ''), ',', ',') || ',' LIKE ?")
} }
return "(" + strings.Join(clauses, " OR ") + ")" return "(" + strings.Join(clauses, " OR ") + ")"
} }
@@ -122,7 +122,7 @@ func genreMatchClause(genres []string) string {
// //
// 必须与 genreMatchClause 的列端处理完全对称: // 必须与 genreMatchClause 的列端处理完全对称:
// - TRIM → TrimSpace // - TRIM → TrimSpace
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction") // - REPLACE(…, ' ', ”) → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",") // - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
func genreMatchArgs(genres []string) []any { func genreMatchArgs(genres []string) []any {
args := make([]any, 0, len(genres)) args := make([]any, 0, len(genres))
+65 -13
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
@@ -95,6 +96,9 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。 // indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) { func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
return
}
for _, id := range ids { for _, id := range ids {
if id == "" { if id == "" {
continue continue
@@ -115,7 +119,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return m.ID, nil return m.ID, nil
} }
updates := mediaUpsertUpdates(existing, *m) // 孤儿行回收:原库已被删除时(DeleteLibrary 硬删库行;扫描与删除并发时扫描也会
// 把行写回已删库),这条行会钉死在不存在的 library_id 上。media.path 是全局唯一
// 索引,重扫既插不进同路径新行,又因 LibraryID 非空而拒绝迁移归属,于是新库/重建库
// 永远查不到这些条目(表现为"扫描明明有文件,库却是空的")。这里显式识别并允许迁移。
reclaimLibrary := r.mediaRowLibraryIsOrphaned(ctx, db, existing.LibraryID, m.LibraryID, m.Path)
updates := mediaUpsertUpdates(existing, *m, reclaimLibrary)
if adopted { if adopted {
updates["path"] = m.Path updates["path"] = m.Path
if existing.DeletedAt.Valid { if existing.DeletedAt.Valid {
@@ -139,6 +148,42 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return existing.ID, nil return existing.ID, nil
} }
// mediaRowLibraryIsOrphaned reports whether a media row is still pinned to a
// library that no longer exists.
//
// 删除媒体库会硬删库行(见 MediaService.DeleteLibrary),但扫描与删除并发时,
// 已在途的扫描仍可能把这批文件写回已删库的 library_id;历史数据里也可能留下
// 指向早已消失的库的行。因为 media.path 是全局唯一索引,这些行一旦存在:
// 1. 重扫无法插入同路径的新行(唯一约束冲突);
// 2. addMediaPlacementUpdates 又因为 existing.LibraryID 非空而拒绝迁移归属。
//
// 结果就是新库 / 重建的同名库永远查不到这些条目,扫描报告有文件而库显示为空。
// 这里把"原库已不存在"这种情况单独判定出来,交给扫描把归属迁移到当前库。
func (r *MediaRepository) mediaRowLibraryIsOrphaned(ctx context.Context, db *gorm.DB, existingLibraryID, incomingLibraryID, incomingPath string) bool {
existingLibraryID = strings.TrimSpace(existingLibraryID)
incomingLibraryID = strings.TrimSpace(incomingLibraryID)
// 归属未变或原行为空(后者走既有的回填分支)时无需查库。
if existingLibraryID == "" || existingLibraryID == incomingLibraryID {
return false
}
// 云盘媒体的归属迁移由 addMediaPlacementUpdates 无条件处理,无需再查库。
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(incomingPath)), "cloud://") {
return false
}
if db == nil || !db.Migrator().HasTable(&model.Library{}) {
return false
}
var count int64
// 刻意不加 Unscoped:软删除的库同样不再出现在库里,挂在它下面的媒体行
// 也属于"查不到"的孤儿,一并允许回收。查询失败时保守处理:宁可保留旧
// 归属,也不要把仍有效的库误判成孤儿。
if err := db.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", existingLibraryID).Count(&count).Error; err != nil {
return false
}
return count == 0
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) { func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
@@ -152,14 +197,19 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" { if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending" m.ScrapeStatus = "pending"
} }
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil { // PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
return *m, true, false, nil // DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil { result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
return model.Media{}, false, false, createErr if result.Error != nil {
} else { return model.Media{}, false, false, result.Error
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, false, nil
} }
if result.RowsAffected > 0 {
return *m, true, false, nil
}
if err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; err != nil {
return model.Media{}, false, false, err
}
return existing, false, false, nil
} }
if err != nil { if err != nil {
return model.Media{}, false, false, err return model.Media{}, false, false, err
@@ -194,14 +244,14 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
return existing, err return existing, err
} }
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any { func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{} updates := map[string]any{}
addMediaFileScanUpdates(updates, existing, incoming) addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming) addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming) addMediaExternalIDUpdates(updates, existing, incoming)
addMatchedMediaMetadataUpdates(updates, existing, incoming) addMatchedMediaMetadataUpdates(updates, existing, incoming)
addMediaArtworkUpdates(updates, existing, incoming) addMediaArtworkUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming) addMediaPlacementUpdates(updates, existing, incoming, reclaimLibrary)
addMediaSTRMUpdate(updates, existing, incoming) addMediaSTRMUpdate(updates, existing, incoming)
return updates return updates
} }
@@ -308,13 +358,15 @@ func addMediaArtworkUpdates(updates map[string]any, existing, incoming model.Med
} }
} }
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media) { func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media, reclaimLibrary bool) {
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级 // 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库, // 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。 // 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。 // 本地媒体物理位置固定:仅在原 library_id 为空、或原库已不存在(孤儿行,
// reclaimLibrary)时才回填/迁移,既避免把仍属于其他库的本地媒体抢走,
// 又能让扫描回收被删除库遗留的行。
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID { if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID {
if isCloudMediaPath || existing.LibraryID == "" { if isCloudMediaPath || existing.LibraryID == "" || reclaimLibrary {
updates["library_id"] = incoming.LibraryID updates["library_id"] = incoming.LibraryID
} }
} }
+6 -24
View File
@@ -6,6 +6,7 @@ import (
"unicode" "unicode"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
@@ -86,7 +87,7 @@ func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, o
Joins("JOIN media_search_fts ON media_search_fts.rowid = media.rowid"). Joins("JOIN media_search_fts ON media_search_fts.rowid = media.rowid").
Where("media.deleted_at IS NULL"). Where("media.deleted_at IS NULL").
Where("media_search_fts MATCH ?", ftsQuery) Where("media_search_fts MATCH ?", ftsQuery)
q = applyQualifiedMediaQueryFilter(q, filter) q = applyMediaQueryFilter(q, filter)
if err := q.Count(&total).Error; err != nil { if err := q.Count(&total).Error; err != nil {
return nil, 0, false return nil, 0, false
} }
@@ -106,11 +107,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
q := r.db.WithContext(ctx).Model(&model.Media{}) q := r.db.WithContext(ctx).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter) q = applyMediaQueryFilter(q, filter)
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写, // SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
// 需用 ILIKE 保持两端搜索行为一致。 // 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。
likeOp := "LIKE" likeOp := likeOperator(r.db)
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
likeOp = "ILIKE"
}
terms := mediaSearchTerms(query) terms := mediaSearchTerms(query)
for _, term := range terms { for _, term := range terms {
like := "%" + escapeLike(term) + "%" like := "%" + escapeLike(term) + "%"
@@ -125,10 +123,10 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
if query != "" { if query != "" {
prefix := escapeLike(query) + "%" prefix := escapeLike(query) + "%"
exact := query exact := query
q = q.Order(gorm.Expr( q = q.Order(clause.OrderBy{Expression: gorm.Expr(
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc", "CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
exact, exact, prefix, prefix, exact, exact, prefix, prefix,
)) )})
} else { } else {
q = q.Order("created_at desc") q = q.Order("created_at desc")
} }
@@ -136,22 +134,6 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
return items, total, err return items, total, err
} }
func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("media.nsfw = ?", false)
}
if len(filter.HiddenLibraryIDs) > 0 {
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
}
if len(filter.AllowedLibraryIDs) > 0 {
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
q = q.Where("media.series_id = ?", seriesID)
}
return q
}
func mediaFTSQuery(query string) string { func mediaFTSQuery(query string) string {
terms := mediaSearchTerms(query) terms := mediaSearchTerms(query)
if len(terms) == 0 { if len(terms) == 0 {
+30 -18
View File
@@ -86,21 +86,15 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
} }
// ListPrewarmCandidates returns media that should be refreshed from source: // segmentPrewarmCandidatesQuery 是片段预取候选查询。
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore). //
// Recently played items come first so hot titles recover coverage sooner. // 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下
func (r *MediaSegmentRepository) ListPrewarmCandidates( // GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。
ctx context.Context, //
source string, // 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1:
missBefore, hitBefore time.Time, // PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883
limit int, // (SQLite 宽容,所以只在 PostgreSQL 上暴露)。
) ([]model.Media, error) { const segmentPrewarmCandidatesQuery = `
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).Raw(`
SELECT m.* SELECT m.*
FROM media m FROM media m
LEFT JOIN media_segment_fetches f LEFT JOIN media_segment_fetches f
@@ -119,11 +113,29 @@ WHERE m.deleted_at IS NULL
) )
AND ( AND (
f.id IS NULL f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?) OR (f.found = ? AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?) OR (f.found = ? AND f.fetched_at < ?)
) )
ORDER BY ph.last_played DESC ORDER BY ph.last_played DESC
LIMIT ? LIMIT ?
`, source, missBefore, hitBefore, limit).Scan(&rows).Error `
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).
Raw(segmentPrewarmCandidatesQuery, source, false, missBefore, true, hitBefore, limit).
Scan(&rows).Error
return rows, err return rows, err
} }
+53
View File
@@ -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
}
+281 -12
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
@@ -15,10 +16,22 @@ type ReaderRepository struct {
db *gorm.DB db *gorm.DB
} }
// ListSources 书源列表(按 customOrder 排序)。 // readerOrderBy 组装方言安全的 ORDER BY。
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) { //
// 用 clause.Column 让 GORM 按当前方言给列名加引号(SQLite 反引号 / PostgreSQL 双引号),
// 而不是把引号硬编码进排序字符串。reader_books.order、reader_chapters.index、
// reader_replace_rules.order 都撞上了 SQL 关键字(order 在 PostgreSQL 里是保留字):
// 之前写成 .Order("`order` ASC"),SQLite 下正常,PostgreSQL 下反引号被词法分析成
// 运算符,直接报 `syntax error at or near "order"`,书架 / 目录 / 替换规则整页 500。
func readerOrderBy(columns ...clause.OrderByColumn) clause.OrderBy {
return clause.OrderBy{Columns: columns}
}
// ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("custom_order ASC, updated_at DESC").Find(&out).Error
return out, err return out, err
} }
@@ -31,8 +44,31 @@ func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.Rea
return &out, nil return &out, nil
} }
// GetSourceByURL 按书源 URL 取书源(导入去重用)。 // GetSourceForUser 按 ID 取书源并校验归属用户。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) { func (r *ReaderRepository) GetSourceForUser(ctx context.Context, userID, id string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
if err := r.db.WithContext(ctx).First(&out, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetSourceByURL 按用户 + 书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, userID, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND source_url = ?", userID, sourceURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetSourceAnyByURL 按书源 URL 取任意一份副本(不限定用户)。
//
// 媒体代理、目录刷新等路径只拿得到书籍的 origin(书源 URL)而没有用户上下文;
// 请求目标由 URL 决定,任一副本都够用。多个用户各自改过规则/header 时,
// 取到哪一份只影响「用谁的默认 header」,不影响书籍本身的定位。
func (r *ReaderRepository) GetSourceAnyByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil { if err != nil {
@@ -41,6 +77,13 @@ func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string)
return &out, nil return &out, nil
} }
// ListSourceRowsByURL 取某书源 URL 的全部副本(判断是否仍有其他用户引用)。
func (r *ReaderRepository) ListSourceRowsByURL(ctx context.Context, sourceURL string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Where("source_url = ?", sourceURL).Find(&out).Error
return out, err
}
// CreateSource 新增书源。 // CreateSource 新增书源。
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error { func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Create(src).Error return r.db.WithContext(ctx).Create(src).Error
@@ -52,15 +95,34 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo
} }
// DeleteSource 删除书源(连带清理其会话状态)。 // DeleteSource 删除书源(连带清理其会话状态)。
//
// 用物理删除而不是软删:唯一键是 (user_id, source_url),而索引会覆盖软删行——
// 软删后再导入同一书源会撞唯一约束(表现为「导入失败」),删掉再导入是本模块的
// 正常操作。书源本身是可重新导入的数据,不需要软删保留。
//
// 会话状态表仍按 source_url 一源一条(跨用户共享),因此只有在该 URL 已无任何
// 其他用户的书源副本时才连带清理,否则会把别人的登录态一起删掉。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error { func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{} src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" { if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil { var others int64
if err := tx.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND id <> ?", src.SourceURL, id).Count(&others).Error; err != nil {
return err return err
} }
if others == 0 {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
} }
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error return tx.Unscoped().Delete(&model.ReaderBookSource{}, "id = ?", id).Error
}) })
} }
@@ -79,8 +141,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string)
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。 // SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error { func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
var existing model.ReaderSourceState var existing model.ReaderSourceState
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(st).Error return r.db.WithContext(ctx).Create(st).Error
} }
@@ -89,13 +154,22 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
} }
st.ID = existing.ID st.ID = existing.ID
st.CreatedAt = existing.CreatedAt st.CreatedAt = existing.CreatedAt
if existing.DeletedAt.Valid {
// 历史行被软删过:连 deleted_at 一起写回,把它复活
st.DeletedAt = gorm.DeletedAt{}
return r.db.WithContext(ctx).Unscoped().Save(st).Error
}
return r.db.WithContext(ctx).Save(st).Error return r.db.WithContext(ctx).Save(st).Error
} }
// ListBooks 用户书架(按 order 排序)。 // ListBooks 用户书架(按 order 排序)。
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) { func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
var out []model.ReaderBook var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` ASC, updated_at DESC").Find(&out).Error err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}},
clause.OrderByColumn{Column: clause.Column{Name: "updated_at"}, Desc: true},
)).Find(&out).Error
return out, err return out, err
} }
@@ -118,6 +192,15 @@ func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bo
return &out, nil return &out, nil
} }
// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。
// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的
// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。
func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error
return out, err
}
// CreateBook / UpdateBook / DeleteBook。 // CreateBook / UpdateBook / DeleteBook。
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error { func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Create(b).Error return r.db.WithContext(ctx).Create(b).Error
@@ -150,10 +233,127 @@ func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, c
}) })
} }
// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。
// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。
func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil {
return err
}
return tx.Save(book).Error
})
}
// ListChapters 按序取章节。 // ListChapters 按序取章节。
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) { func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
var out []model.ReaderChapter var out []model.ReaderChapter
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Order("`index` ASC").Find(&out).Error err := r.db.WithContext(ctx).Where("book_id = ?", bookID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "index"}},
)).Find(&out).Error
return out, err
}
// ─── 正文持久缓存索引 ──────────────────────────────────────────────────────
// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。
func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
var out model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType).
First(&out).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。
// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。
func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey)
if contentType != "" {
q = q.Where("content_type = ?", contentType)
}
var out model.ReaderContentCache
if err := q.Order("last_access_at DESC").First(&out).Error; err != nil {
return nil, err
}
return &out, nil
}
// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。
func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error {
existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType)
if err == nil && existing != nil {
existing.ChapterIdentity = row.ChapterIdentity
existing.ChapterIndex = row.ChapterIndex
existing.SourceHash = row.SourceHash
existing.FormatVersion = row.FormatVersion
existing.SizeBytes = row.SizeBytes
existing.AssetCount = row.AssetCount
existing.ExpiresAt = row.ExpiresAt
existing.LastAccessAt = row.LastAccessAt
return r.db.WithContext(ctx).Save(existing).Error
}
return r.db.WithContext(ctx).Create(row).Error
}
// TouchContentCache 记录一次命中(更新命中时间与次数)。
func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error {
return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Where("id = ?", id).
Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error
}
// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。
func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error
return out, err
}
// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。
func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
rows, err := r.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return nil, err
}
if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil {
return nil, err
}
return rows, nil
}
// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。
func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error
}
// ListContentCacheExpired 按 TTL 取过期条目。
func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("expires_at > 0 AND expires_at < ?", now).
Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。
func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) {
var row struct {
Count int64
Bytes int64
}
err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error
return row.Count, row.Bytes, err
}
// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。
func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err return out, err
} }
@@ -184,18 +384,87 @@ func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []st
} }
// GetChapter 取指定章节。 // GetChapter 取指定章节。
//
// index 在 PostgreSQL 里是保留字,必须让 GORM 按方言加引号(SQLite 反引号 /
// PostgreSQL 双引号)。此前写成硬编码的 "book_id = ? AND `index` = ?",
// PostgreSQL 会把反引号词法分析成运算符并报 syntax error at or near "`"。
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) { func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
var out model.ReaderChapter var out model.ReaderChapter
if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil { err := r.db.WithContext(ctx).
Where(clause.Eq{Column: clause.Column{Name: "book_id"}, Value: bookID}).
Where(clause.Eq{Column: clause.Column{Name: "index"}, Value: index}).
First(&out).Error
if err != nil {
return nil, err return nil, err
} }
return &out, nil return &out, nil
} }
// GetReaderProfile 取用户阅读器偏好;没有记录时返回 (nil, nil),
// 由调用方决定是否用前端上送的当前值播种。
func (r *ReaderRepository) GetReaderProfile(ctx context.Context, userID string) (*model.ReaderProfile, error) {
var out model.ReaderProfile
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// SaveReaderProfile 覆盖保存用户阅读器偏好(不存在则新建)。
func (r *ReaderRepository) SaveReaderProfile(ctx context.Context, p *model.ReaderProfile) error {
var existing model.ReaderProfile
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", p.UserID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(p).Error
}
if err != nil {
return err
}
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt
return r.db.WithContext(ctx).Save(p).Error
}
// GetBookGroups 取用户的书架分组;没有记录时返回 (nil, nil)。
func (r *ReaderRepository) GetBookGroups(ctx context.Context, userID string) (*model.ReaderBookGroups, error) {
var out model.ReaderBookGroups
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// SaveBookGroups 覆盖保存用户的书架分组(不存在则新建)。
func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.ReaderBookGroups) error {
var existing model.ReaderBookGroups
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", row.UserID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(row).Error
}
if err != nil {
return err
}
row.ID = existing.ID
row.CreatedAt = existing.CreatedAt
return r.db.WithContext(ctx).Save(row).Error
}
// ListReplaceRules 用户替换规则(按 order 排序)。 // ListReplaceRules 用户替换规则(按 order 排序)。
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) { func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
var out []model.ReaderReplaceRule var out []model.ReaderReplaceRule
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` DESC, created_at ASC").Find(&out).Error err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}, Desc: true},
clause.OrderByColumn{Column: clause.Column{Name: "created_at"}},
)).Find(&out).Error
return out, err return out, err
} }
@@ -0,0 +1,116 @@
package repository
import (
"context"
"strings"
"sync"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// sqlCaptureLogger 只记录 GORM 生成的 SQL,不打印任何日志。
type sqlCaptureLogger struct {
mu sync.Mutex
lines []string
}
func (l *sqlCaptureLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface { return l }
func (l *sqlCaptureLogger) Info(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Warn(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Error(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()
l.mu.Lock()
l.lines = append(l.lines, sql)
l.mu.Unlock()
}
func (l *sqlCaptureLogger) all() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.lines, "\n")
}
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
// SkipDefaultTransaction 则挡掉 GORM 默认事务包装——那一步会真的去开事务连接,
// 在 DryRun 下同样会拨号(ToSQL 内部也是这么绕开的)。于是可以直接断言生成语句
// 在 PostgreSQL 下是否合法。
func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) {
t.Helper()
rec := &sqlCaptureLogger{}
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
Logger: rec,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
return New(db), rec
}
// TestReaderListsUseDialectQuotedReservedColumns 守住阅读子系统列表查询在
// PostgreSQL 下的合法性。reader_books.order / reader_chapters.index /
// reader_replace_rules.order 都是关键字级列名(order 在 PostgreSQL 里是保留字)。
//
// 排序条件曾经写成硬编码反引号的原始字符串,例如 .Order("`order` ASC"):
// SQLite 正常,PostgreSQL 会把反引号词法分析成运算符,直接报
// `syntax error at or near "order"` (SQLSTATE 42601),书架 / 目录 / 替换规则整页 500。
// 现在改用 clause.Column,由 GORM 按方言加引号(SQLite 反引号 / PostgreSQL 双引号)。
func TestReaderListsUseDialectQuotedReservedColumns(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
run func(repos *Container) error
wants []string
}{
{
name: "books",
run: func(repos *Container) error { _, err := repos.Reader.ListBooks(ctx, "user-1"); return err },
wants: []string{`"order"`, `"updated_at" DESC`},
},
{
name: "chapters",
run: func(repos *Container) error { _, err := repos.Reader.ListChapters(ctx, "book-1"); return err },
wants: []string{`"index"`},
},
{
name: "replace rules",
run: func(repos *Container) error { _, err := repos.Reader.ListReplaceRules(ctx, "user-1"); return err },
wants: []string{`"order" DESC`, `"created_at"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
if err := tc.run(repos); err != nil {
t.Fatalf("build query: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from the repository call")
}
if strings.Contains(sql, "`") {
t.Fatalf("SQL uses backtick quoting, which PostgreSQL rejects:\n%s", sql)
}
if !strings.Contains(sql, "ORDER BY") {
t.Fatalf("SQL has no ORDER BY clause:\n%s", sql)
}
for _, want := range tc.wants {
if !strings.Contains(sql, want) {
t.Fatalf("SQL missing %q:\n%s", want, sql)
}
}
})
}
}
+79
View File
@@ -3,6 +3,7 @@ package repository
import ( import (
"errors" "errors"
"testing" "testing"
"time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) {
} }
} }
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
//
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
const url = "https://d.example.com"
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
}); err != nil {
t.Fatal(err)
}
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatal(err)
}
// 重新导入同 URL 的书源,再存一次状态
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
if err := repo.CreateSource(ctx, again); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
}); err != nil {
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
}
st, err := repo.GetSourceState(ctx, url)
if err != nil {
t.Fatal(err)
}
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
t.Fatalf("状态未写入: %+v", st)
}
}
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
//
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
const url = "https://e.example.com"
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
}); err != nil {
t.Fatal(err)
}
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
if err := repo.db.Exec(
"update reader_source_states set deleted_at = ? where source_url = ?",
time.Now(), url).Error; err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
}); err != nil {
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
}
st, err := repo.GetSourceState(ctx, url)
if err != nil {
t.Fatal(err)
}
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
t.Fatalf("复活后状态未写入: %+v", st)
}
}
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。 // TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
func TestImportUpdateKeepsSourceState(t *testing.T) { func TestImportUpdateKeepsSourceState(t *testing.T) {
repo := newReaderTestRepo(t) repo := newReaderTestRepo(t)
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
}) })
} }
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a // RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
// user by revoking the oldest non-expired, non-revoked tokens. // for a user among tokens that are not bound to a device auth session
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error { // (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 { if limit < 1 {
limit = 1 limit = 1
} }
return withSQLiteBusyRetry(ctx, func() error { return withSQLiteBusyRetry(ctx, func() error {
var tokens []model.RefreshToken var tokens []model.RefreshToken
if err := r.db.WithContext(ctx). 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"). Order("created_at desc, id desc").
Find(&tokens).Error; err != nil { Find(&tokens).Error; err != nil {
return err 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 { func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
return withSQLiteBusyRetry(ctx, func() error { return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.RefreshToken{}). 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. // HashToken returns the SHA256 hash of a token.
func HashToken(token string) string { func HashToken(token string) string {
h := sha256.Sum256([]byte(token)) h := sha256.Sum256([]byte(token))
+2
View File
@@ -23,6 +23,7 @@ type Container struct {
Log *AccessLogRepository Log *AccessLogRepository
Permission *PermissionRepository Permission *PermissionRepository
RefreshToken *RefreshTokenRepository RefreshToken *RefreshTokenRepository
AuthSession *AuthSessionRepository
ApiConfig *ApiConfigRepository ApiConfig *ApiConfigRepository
PlayProfile *PlayProfileRepository PlayProfile *PlayProfileRepository
RegCode *RegistrationCodeRepository RegCode *RegistrationCodeRepository
@@ -56,6 +57,7 @@ func New(db *gorm.DB) *Container {
Log: &AccessLogRepository{db: db}, Log: &AccessLogRepository{db: db},
Permission: &PermissionRepository{db: db}, Permission: &PermissionRepository{db: db},
RefreshToken: &RefreshTokenRepository{db: db}, RefreshToken: &RefreshTokenRepository{db: db},
AuthSession: &AuthSessionRepository{db: db},
ApiConfig: &ApiConfigRepository{db: db}, ApiConfig: &ApiConfigRepository{db: db},
PlayProfile: &PlayProfileRepository{db: db}, PlayProfile: &PlayProfileRepository{db: db},
RegCode: &RegistrationCodeRepository{db: db}, RegCode: &RegistrationCodeRepository{db: db},
+24 -7
View File
@@ -34,6 +34,7 @@ type AuthService struct {
repo *repository.Container repo *repository.Container
tokenSvc *TokenService tokenSvc *TokenService
permissionSvc *PermissionService permissionSvc *PermissionService
sessionSvc *AuthSessionService
tempPassMu sync.RWMutex tempPassMu sync.RWMutex
tempPasswords map[string]temporaryPasswordEntry 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. // Common service-level errors.
var ( var (
ErrInvalidCredentials = errors.New("invalid username or password") 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. // Login validates credentials and returns the user + a fresh JWT token pair.
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) { 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) u, err := s.repo.User.FindByUsername(ctx, username)
if err != nil { if err != nil {
return nil, err 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 { if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
// 签发令牌对 return u, nil
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
} }
func (s *AuthService) touchLoginBestEffort(userID string) { func (s *AuthService) touchLoginBestEffort(userID string) {
+187
View File
@@ -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])
}
+261
View File
@@ -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)
}
}
+8 -2
View File
@@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*
return s.tokenSvc.Refresh(ctx, refreshToken) return s.tokenSvc.Refresh(ctx, refreshToken)
} }
// Logout 撤销用户的所有刷新令牌。 // Logout 撤销用户的所有刷新令牌与认证会话。
func (s *AuthService) Logout(ctx context.Context, userID string) error { 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
} }
+64 -18
View File
@@ -1,9 +1,13 @@
// Package service — database hot backup and restore. // Package service — database hot backup and restore.
// //
// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to // Backup files are stored under {data_dir}/backups/ with a timestamp suffix.
// produce a consistent copy of the database without blocking writers. // The admin UI can list / download / delete / restore them.
// Backup files are stored under {data_dir}/backups/ with a timestamp //
// suffix. The admin UI can list / download / delete / restore them. // 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service package service
import ( import (
@@ -65,18 +69,34 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil return path, nil
} }
// Create produces a new hot backup via "VACUUM INTO". // isPostgres reports whether the service is backed by PostgreSQL.
//
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
// 因此备份/恢复必须按方言分支,不能共用一条路径。
func (b *BackupService) isPostgres() bool {
return b.db != nil && b.db.Dialector != nil && b.db.Dialector.Name() == "postgres"
}
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) { func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
dir := b.backupDir() dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil { if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err return nil, err
} }
ts := time.Now().Format("20060102_150405") ts := time.Now().Format("20060102_150405")
dst := filepath.Join(dir, fmt.Sprintf("mebox_%s.db", ts))
// VACUUM INTO creates a clean, non-WAL copy atomically. var (
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil { dst string
return nil, fmt.Errorf("VACUUM INTO failed: %w", err) err error
)
if b.isPostgres() {
dst, err = b.createPostgresBackup(ctx, ts)
} else {
dst, err = b.createSQLiteBackup(ctx, ts)
}
if err != nil {
return nil, err
} }
stat, err := os.Stat(dst) stat, err := os.Stat(dst)
if err != nil { if err != nil {
@@ -91,6 +111,15 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
}, nil }, 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 {
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
}
return dst, nil
}
// List returns every backup file in the backup directory, newest first. // List returns every backup file in the backup directory, newest first.
func (b *BackupService) List() ([]BackupInfo, error) { func (b *BackupService) List() ([]BackupInfo, error) {
dir := b.backupDir() dir := b.backupDir()
@@ -103,7 +132,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
} }
out := make([]BackupInfo, 0, len(entries)) out := make([]BackupInfo, 0, len(entries))
for _, e := range entries { for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") { if e.IsDir() || !isBackupFilename(e.Name()) {
continue continue
} }
info, err := e.Info() info, err := e.Info()
@@ -132,9 +161,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path) return os.Remove(path)
} }
// Restore copies a backup over the live database using VACUUM INTO in // Restore replaces the live database with the given backup.
// reverse. WARNING: this is destructive — the live DB will be replaced. // WARNING: this is destructive. SQLite restores by swapping the database file
// Callers should shut down the server after this call. // (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error { func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename) src, err := b.backupFilePath(filename)
if err != nil { if err != nil {
@@ -143,6 +173,19 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if _, err := os.Stat(src); err != nil { if _, err := os.Stat(src); err != nil {
return fmt.Errorf("backup not found: %s", filename) return fmt.Errorf("backup not found: %s", filename)
} }
if b.isPostgres() {
if !strings.HasSuffix(filename, postgresBackupSuffix) {
return errors.New("该备份是 SQLite 文件备份,当前运行在 PostgreSQL 上,无法恢复")
}
return b.restorePostgresBackup(ctx, src)
}
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) dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" { if err != nil || dbPath == "" {
return errors.New("invalid database path") return errors.New("invalid database path")
@@ -171,7 +214,13 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
return nil return nil
} }
// isValidBackupFilename rejects path traversal attempts and non-.db files. // isBackupFilename reports whether name looks like a backup produced by either
// dialect (SQLite ".db" or PostgreSQL ".json.gz").
func isBackupFilename(name string) bool {
return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, postgresBackupSuffix)
}
// isValidBackupFilename rejects path traversal attempts and non-backup files.
func isValidBackupFilename(name string) bool { func isValidBackupFilename(name string) bool {
if name == "" { if name == "" {
return false return false
@@ -179,8 +228,5 @@ func isValidBackupFilename(name string) bool {
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
return false return false
} }
if !strings.HasSuffix(name, ".db") { return isBackupFilename(name)
return false
}
return true
} }
+280
View File
@@ -0,0 +1,280 @@
package service
import (
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"time"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
// PostgreSQL 逻辑备份 / 恢复。
//
// SQLite 的备份是「VACUUM INTO 出一个一致的库文件,恢复时替换文件」,而
// PostgreSQL 既没有 VACUUM INTO,数据也由服务端进程管理,没有可替换的单文件,
// 所以这里实现逻辑备份:把每张表按主键顺序分块序列化成 JSON Lines(gzip 压缩)
// 写入 {data_dir}/backups/。
//
// 分块序列化而不是整表 marshal:media 表在真实媒体库上可达几十万行、每行还带
// overview / path 等长文本,整表进内存会把小内存的 NAS 打爆。
//
// 恢复在单个事务内「先清空全部模型表、再逐块插入」:任何一步失败(包括备份文件
// 读到一半损坏)都整体回滚,不会留下半清空的库。因此恢复不需要重启服务。
const (
postgresBackupSuffix = ".json.gz"
postgresBackupFormat = "mebox-postgres-logical"
postgresBackupVersion = 1
backupDumpChunkSize = 1000
backupRestoreBatchSize = 500
)
type postgresBackupHeader struct {
Format string `json:"format"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
// postgresBackupRow 是「列名 → JSON 值」的一行。
//
// 刻意逐列反射序列化,而不是直接 json.Marshal 模型结构体:模型里有大量带
// json:"-" 的列(Base.DeletedAt、User.AllowedLibraryIDs、APIConfig.APIKey、
// StrmAccount.Config、PlayProfile.PINHash、ReaderBookSource.RawJSON…),走结构体
// 自带的序列化会把这些列整列丢掉——软删除行会在恢复后「复活」,加密后的第三方
// 密钥与 PIN 会全部丢失。
type postgresBackupRow map[string]json.RawMessage
type postgresBackupChunk struct {
Table string `json:"table"`
Rows []postgresBackupRow `json:"rows"`
}
// parseModelSchema 返回模型对应的表名与 GORM schema(逐列反射所需)。
func parseModelSchema(db *gorm.DB, m any) (table string, sch *schema.Schema, err error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", nil, err
}
return stmt.Schema.Table, stmt.Schema, nil
}
// postgresBackupModelSchemas 返回「表名 → 模型 schema / 结构体类型」,用于把备份
// 里的行还原成 GORM 能插入的具体类型。
func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[string]reflect.Type, error) {
schemas := make(map[string]*schema.Schema, len(model.AllModels()))
types := make(map[string]reflect.Type, len(model.AllModels()))
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(db, m)
if err != nil {
return nil, nil, err
}
schemas[table] = sch
types[table] = reflect.TypeOf(m).Elem()
}
return schemas, types, nil
}
// 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)
if err != nil {
return "", err
}
complete := false
defer func() {
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
}
}()
zw := gzip.NewWriter(file)
enc := json.NewEncoder(zw)
if err := enc.Encode(postgresBackupHeader{
Format: postgresBackupFormat,
Version: postgresBackupVersion,
CreatedAt: time.Now(),
}); 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
}
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
}
if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err)
}
complete = true
return dst, nil
}
// dumpPostgresTable 按主键分块导出一张表。FindInBatches 会自己按主键升序游标
// 翻页(WHERE pk > last),不会像 OFFSET 分页那样在大表上做 O(n²) 扫描。
func (b *BackupService) dumpPostgresTable(ctx context.Context, m any, sch *schema.Schema, table string, enc *json.Encoder) error {
// 软删除行也要备份:恢复后应还原成删除前的样子,而不是让它们「复活」。
slicePtr := reflect.New(reflect.SliceOf(reflect.TypeOf(m).Elem()))
err := b.db.WithContext(ctx).Unscoped().
FindInBatches(slicePtr.Interface(), backupDumpChunkSize, func(_ *gorm.DB, _ int) error {
rows, err := encodePostgresBackupRows(ctx, sch, slicePtr.Elem())
if err != nil {
return fmt.Errorf("编码表 %s 失败: %w", table, err)
}
return enc.Encode(postgresBackupChunk{Table: table, Rows: rows})
}).Error
if err != nil {
return fmt.Errorf("导出表 %s 失败: %w", table, err)
}
return nil
}
// backupPersistableFields 返回真正对应数据库列的字段。
//
// GORM 的 Schema.Fields 里还带着 gorm:"-" 的字段(例如 User.AllowedLibraryList、
// LibraryTagList),它们的 DBName 是空字符串。不过滤的话多个「列」会共用空键:
// 编码时互相覆盖,解码时把某个字段的值写到另一个字段上,报出
// 「cannot unmarshal number into Go value of type []string」这类与真实数据无关的错。
func backupPersistableFields(sch *schema.Schema) []*schema.Field {
out := make([]*schema.Field, 0, len(sch.Fields))
for _, field := range sch.Fields {
if field.DBName == "" {
continue
}
out = append(out, field)
}
return out
}
// encodePostgresBackupRows 逐列反射序列化一个批次。
func encodePostgresBackupRows(ctx context.Context, sch *schema.Schema, slice reflect.Value) ([]postgresBackupRow, error) {
fields := backupPersistableFields(sch)
rows := make([]postgresBackupRow, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
element := slice.Index(i)
row := make(postgresBackupRow, len(fields))
for _, field := range fields {
value := field.ReflectValueOf(ctx, element)
if !value.IsValid() {
continue
}
raw, err := json.Marshal(value.Interface())
if err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
row[field.DBName] = raw
}
rows = append(rows, row)
}
return rows, nil
}
// decodePostgresBackupRows 把「列名 → JSON 值」还原成可直接插入的具体切片。
func decodePostgresBackupRows(ctx context.Context, sch *schema.Schema, modelType reflect.Type, rows []postgresBackupRow) (any, error) {
fields := backupPersistableFields(sch)
slice := reflect.MakeSlice(reflect.SliceOf(modelType), len(rows), len(rows))
for i, row := range rows {
element := slice.Index(i)
for _, field := range fields {
raw, ok := row[field.DBName]
if !ok {
continue
}
holder := reflect.New(field.FieldType)
if err := json.Unmarshal(raw, holder.Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
if err := field.Set(ctx, element, holder.Elem().Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
}
}
return slice.Interface(), nil
}
// restorePostgresBackup 用逻辑备份重建全库。
func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
zr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("备份文件不是有效的 gzip: %w", err)
}
defer zr.Close()
dec := json.NewDecoder(zr)
var header postgresBackupHeader
if err := dec.Decode(&header); err != nil {
return fmt.Errorf("备份头损坏: %w", err)
}
if header.Format != postgresBackupFormat || header.Version != postgresBackupVersion {
return fmt.Errorf("不支持的备份格式 %q (v%d)", header.Format, header.Version)
}
schemas, modelTypes, err := postgresBackupModelSchemas(b.db)
if err != nil {
return err
}
// 恢复必须在单个事务里完成:清空成功后中途失败若不回滚,库就空了。
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 逆序清空,保证 library_roots → libraries 这类外键先删子表。
models := model.AllModels()
for i := len(models) - 1; i >= 0; i-- {
m := models[i]
if !tx.Migrator().HasTable(m) {
continue
}
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil {
return fmt.Errorf("清空表失败: %w", err)
}
}
for {
var chunk postgresBackupChunk
decodeErr := dec.Decode(&chunk)
if errors.Is(decodeErr, io.EOF) {
return nil
}
if decodeErr != nil {
return fmt.Errorf("备份文件损坏: %w", decodeErr)
}
sch, ok := schemas[chunk.Table]
if !ok {
return fmt.Errorf("备份包含未知表 %s,可能来自不兼容的版本", chunk.Table)
}
if len(chunk.Rows) == 0 {
continue
}
rows, err := decodePostgresBackupRows(ctx, sch, modelTypes[chunk.Table], chunk.Rows)
if err != nil {
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
}
if err := database.InsertPreservingValues(tx.Unscoped(), rows, backupRestoreBatchSize); err != nil {
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
}
}
})
}
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"compress/gzip"
"context"
"os"
"path/filepath"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newBackupTestService(t *testing.T) (*BackupService, *config.Config) {
t.Helper()
cfg := &config.Config{}
cfg.App.DataDir = t.TempDir()
db := newServiceTestDB(t)
svc := &BackupService{cfg: cfg, log: zap.NewNop(), db: db}
// Create 会先建目录;直接调各方言的 create* 助手时需要它已存在。
if err := os.MkdirAll(svc.backupDir(), 0o750); err != nil {
t.Fatal(err)
}
return svc, cfg
}
// TestPostgresLogicalBackupRoundTrip 验证「备份 → 改库 → 恢复」后整库回到备份时的
// 样子,包括软删除行与 json:"-" 的列。
//
// 逻辑备份/恢复本身是方言无关的 GORM 代码(PostgreSQL 专有的只有文件后缀与
// isPostgres() 分派),所以可以直接在 SQLite 上跑。
func TestPostgresLogicalBackupRoundTrip(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
db := svc.db
lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.LibraryRoot{Base: model.Base{ID: "root-1"}, LibraryID: lib.ID, Path: "/media/movies"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-keep"}, LibraryID: lib.ID, Title: "Keep", Path: "/media/movies/keep.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-gone"}, LibraryID: lib.ID, Title: "Gone", Path: "/media/movies/gone.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&model.Media{}, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Setting{Key: "k", Value: "v"}).Error; err != nil {
t.Fatal(err)
}
// 下面这些列都带 json:"-":走结构体自带的 JSON 序列化会被整列丢掉,
// 恢复之后用户权限、置顶库、加密后的密钥会一起消失。
user := model.User{
Base: model.Base{ID: "u-1"},
Username: "alice",
PasswordHash: "hash",
Role: "user",
AllowedLibraryIDs: `["lib-1"]`,
PinnedLibraryIDs: `["lib-1"]`,
HideAdult: true,
IsActive: true,
DanmakuEnabled: true,
PlayerVr360GuideSeen: true,
}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.APIConfig{Base: model.Base{ID: "cfg-1"}, Provider: "tmdb", APIKey: "ciphertext", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(ctx, "20260101_000000")
if err != nil {
t.Fatalf("createPostgresBackup: %v", err)
}
if dump == "" {
t.Fatal("createPostgresBackup returned an empty path")
}
// 破坏当前数据:加一条备份里没有的、硬删一条备份里有的。
if err := db.Create(&model.Media{Base: model.Base{ID: "m-extra"}, LibraryID: lib.ID, Title: "Extra", Path: "/media/movies/extra.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Unscoped().Delete(&model.Media{}, "id = ?", "m-keep").Error; err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, dump); err != nil {
t.Fatalf("restorePostgresBackup: %v", err)
}
var ids []string
if err := db.Unscoped().Model(&model.Media{}).Order("id asc").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
if want := []string{"m-gone", "m-keep"}; !reflect.DeepEqual(ids, want) {
t.Fatalf("media ids after restore = %v, want %v", ids, want)
}
// 软删除状态必须原样还原,而不是被「复活」。
var gone model.Media
if err := db.Unscoped().First(&gone, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if !gone.DeletedAt.Valid {
t.Fatal("soft-deleted media was revived by restore")
}
var live int64
if err := db.Model(&model.Media{}).Where("id = ?", "m-keep").Count(&live).Error; err != nil {
t.Fatal(err)
}
if live != 1 {
t.Fatalf("live media count = %d, want 1", live)
}
var value string
if err := db.Model(&model.Setting{}).Where("key = ?", "k").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "v" {
t.Fatalf("setting value = %q, want v", value)
}
var libCount int64
if err := db.Model(&model.Library{}).Count(&libCount).Error; err != nil {
t.Fatal(err)
}
if libCount != 1 {
t.Fatalf("library count = %d, want 1", libCount)
}
var rootCount int64
if err := db.Model(&model.LibraryRoot{}).Count(&rootCount).Error; err != nil {
t.Fatal(err)
}
if rootCount != 1 {
t.Fatalf("library root count = %d, want 1", rootCount)
}
// json:"-" 的列必须同样被备份:它们是权限与凭据,丢了比丢一条媒体严重得多。
var restoredUser model.User
if err := db.First(&restoredUser, "id = ?", "u-1").Error; err != nil {
t.Fatal(err)
}
if restoredUser.PasswordHash != "hash" {
t.Fatalf("password hash = %q, want hash", restoredUser.PasswordHash)
}
if restoredUser.AllowedLibraryIDs != `["lib-1"]` {
t.Fatalf(`allowed library ids = %q, want ["lib-1"]`, restoredUser.AllowedLibraryIDs)
}
if restoredUser.PinnedLibraryIDs != `["lib-1"]` {
t.Fatalf(`pinned library ids = %q, want ["lib-1"]`, restoredUser.PinnedLibraryIDs)
}
if !restoredUser.HideAdult || !restoredUser.IsActive || !restoredUser.DanmakuEnabled || !restoredUser.PlayerVr360GuideSeen {
t.Fatalf("boolean columns lost: %+v", restoredUser)
}
var restoredConfig model.APIConfig
if err := db.First(&restoredConfig, "id = ?", "cfg-1").Error; err != nil {
t.Fatal(err)
}
if restoredConfig.APIKey != "ciphertext" {
t.Fatalf("api key = %q, want ciphertext", restoredConfig.APIKey)
}
}
// TestRestorePostgresBackupKeepsDataOnCorruptFile 验证损坏的备份不会在回滚后留下
// 半清空的库:恢复是先清空再插入,必须整体在一个事务里,否则中途失败就把库清掉了。
func TestRestorePostgresBackupKeepsDataOnCorruptFile(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
if err := svc.db.Create(&model.Setting{Key: "keep", Value: "me"}).Error; err != nil {
t.Fatal(err)
}
corrupt := filepath.Join(t.TempDir(), "broken.json.gz")
file, err := os.Create(corrupt)
if err != nil {
t.Fatal(err)
}
zw := gzip.NewWriter(file)
// 头部完整、正文半截:解码在清空表之后才会失败。
if _, err := zw.Write([]byte(
`{"format":"mebox-postgres-logical","version":1,"created_at":"2026-01-01T00:00:00Z"}` + "\n" +
`{"table":"media","rows":[`)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, corrupt); err == nil {
t.Fatal("corrupt backup should fail to restore")
}
var value string
if err := svc.db.Model(&model.Setting{}).Where("key = ?", "keep").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "me" {
t.Fatalf("a failed restore wiped the database: settings keep = %q", value)
}
}
// TestRestoreRejectsBackupFromOtherDialect 验证备份文件与当前方言不匹配时给出明确
// 错误,而不是把 SQLite 文件写到 PostgreSQL 部署的库路径上(那是原实现的隐患)。
func TestRestoreRejectsBackupFromOtherDialect(t *testing.T) {
svc, _ := newBackupTestService(t)
pgStyle := filepath.Join(svc.backupDir(), "mebox_20260101_000000.json.gz")
if err := os.WriteFile(pgStyle, []byte("not-gzip"), 0o600); err != nil {
t.Fatal(err)
}
// SQLite 路径下不允许恢复 PostgreSQL 逻辑备份。
if err := svc.Restore(context.Background(), filepath.Base(pgStyle)); err == nil {
t.Fatal("SQLite restore accepted a PostgreSQL logical backup")
}
}
@@ -0,0 +1,34 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestPostgresLogicalBackupPreservesZeroValues(t *testing.T) {
svc, _ := newBackupTestService(t)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := svc.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := svc.db.Model(&user).Updates(map[string]any{
"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false,
}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(t.Context(), "zero-values")
if err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(t.Context(), dump); err != nil {
t.Fatal(err)
}
var got model.User
if err := svc.db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("restore replaced zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
}
+89 -10
View File
@@ -1,26 +1,34 @@
// 下载直链进程级缓存。 // 下载直链进程级缓存。
// //
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是 // 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件 // 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目, // pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
// 下一轮重试会重新换取链接。 // 调用方主动清除对应条目,下一轮重试会重新换取链接。
package cloud115 package cloud115
import ( import (
"net/url"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右, // downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。 // 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
const downloadURLCacheTTL = 45 * time.Minute // 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
const downloadURLCacheMaxTTL = 45 * time.Minute
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
const linkExpirySafety = 2 * time.Minute
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。 // maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
const maxCachedURLs = 10000 const maxCachedURLs = 10000
type urlCacheEntry struct { type urlCacheEntry struct {
url string url string
createdAt time.Time
expiresAt time.Time expiresAt time.Time
} }
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
return pickCode + "@" + ua 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 返回未过期的缓存直链;不存在或已过期返回空串。 // GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
func GetDownloadURLCache(pickCode string, uas ...string) string { func GetDownloadURLCache(pickCode string, uas ...string) string {
if pickCode == "" { if pickCode == "" {
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
return entry.url return entry.url
} }
// SetDownloadURLCache 写入直链缓存。 // SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
// 直链直接丢弃,避免把死链缓存下来。
func SetDownloadURLCache(pickCode string, url string, uas ...string) { func SetDownloadURLCache(pickCode string, url string, uas ...string) {
if pickCode == "" || url == "" { if pickCode == "" || url == "" {
return return
} }
now := time.Now()
expiresAt := cacheExpiry(url, now)
if expiresAt.IsZero() {
return
}
ua := "" ua := ""
if len(uas) > 0 { if len(uas) > 0 {
ua = uas[0] ua = uas[0]
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
urlCacheMu.Lock() urlCacheMu.Lock()
defer urlCacheMu.Unlock() defer urlCacheMu.Unlock()
if len(urlCache) >= maxCachedURLs { if len(urlCache) >= maxCachedURLs {
now := time.Now()
for k, e := range urlCache { for k, e := range urlCache {
if now.After(e.expiresAt) { if now.After(e.expiresAt) {
delete(urlCache, k) delete(urlCache, k)
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
} }
} }
key := urlCacheKey(pickCode, ua) 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 时调用)。 // 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 package cloud115
import ( import (
"fmt"
"testing" "testing"
"time" "time"
) )
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
t.Fatalf("cleared entry should be a miss, got %q", got) 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)
}
}
+29 -4
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -12,6 +14,8 @@ import (
"sync" "sync"
"time" "time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service/cloud115" "github.com/truewhile/MeBox/internal/service/cloud115"
) )
@@ -24,6 +28,10 @@ const (
cloud115HLSSessionTTL = 30 * time.Minute cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256 cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20 cloud115HLSMaxManifest = 8 << 20
// cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址
// 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长,
// 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。
cloud115HLSMaxEntries = 20000
) )
type cloud115HLSSession struct { type cloud115HLSSession struct {
@@ -35,7 +43,17 @@ type cloud115HLSSession struct {
mu sync.Mutex mu sync.Mutex
entries map[string]string entries map[string]string
next int }
// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。
//
// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次
// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复
// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。
// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。
func cloud115HLSKeyForURL(upstream string) string {
sum := sha256.Sum256([]byte(upstream))
return hex.EncodeToString(sum[:12])
} }
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。 // Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
@@ -302,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba
} }
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string { func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
key := cloud115HLSKeyForURL(upstream)
session.mu.Lock() session.mu.Lock()
session.next++ _, known := session.entries[key]
key := "e" + strconv.Itoa(session.next) if !known && len(session.entries) < cloud115HLSMaxEntries {
session.entries[key] = upstream session.entries[key] = upstream
known = true
}
mediaID := session.MediaID mediaID := session.MediaID
session.mu.Unlock() session.mu.Unlock()
if !known && p != nil && p.service != nil && p.service.log != nil {
p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve",
zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries))
}
query := childProxyQuery(rawQuery, mediaID) query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
+30 -4
View File
@@ -25,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
if strings.Contains(rewritten, "cpats01.115.com") { if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten) t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
} }
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") { wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2")
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("proxy URL missing: %s", rewritten) t.Fatalf("proxy URL missing: %s", rewritten)
} }
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") { if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
@@ -36,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
} }
} }
func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" +
"https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n"
first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
entriesAfterFirst := len(session.entries)
second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
// 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。
if first != second {
t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second)
}
if len(session.entries) != entriesAfterFirst {
t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries))
}
if entriesAfterFirst != 2 {
t.Fatalf("session entries = %d, want 2", entriesAfterFirst)
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) { func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}} proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{ session := &cloud115HLSSession{
@@ -151,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
MediaID: "media-1", MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour), ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"}, entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
} }
proxy.sessions[session.ID] = session proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil) req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
@@ -162,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code) t.Fatalf("status = %d, want 200", rec.Code)
} }
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") { wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1")
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String()) t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
} }
if strings.Contains(rec.Body.String(), "cpats01.115.com") { if strings.Contains(rec.Body.String(), "cpats01.115.com") {
@@ -192,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
MediaID: "media-1", MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour), ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"}, entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
} }
proxy.sessions[session.ID] = session proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil) req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
@@ -0,0 +1,157 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// newDanmakuRemoteResolverTest 起一个假的远程 Emby(账号 + 挂载齐备),返回接好
// 弹幕解析器的 builder。
func newDanmakuRemoteResolverTest(t *testing.T, handler http.Handler) *serviceContainerBuilder {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}); err != nil {
t.Fatalf("create account: %v", err)
}
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: "acct-1",
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}); err != nil {
t.Fatalf("create mount: %v", err)
}
return &serviceContainerBuilder{
cfg: cfg,
log: zap.NewNop(),
repos: repos,
c: &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, EmbyRemote: remote},
}
}
func danmakuResolverTestEpisode(id string) map[string]any {
return map[string]any{
"Id": id,
"Name": "第 1 集",
"Type": "Episode",
"Path": "/media/anime/PSYREN/" + id + ".mkv",
"MediaSources": []any{
map[string]any{"Id": id + "-src", "Container": "mkv", "Size": int64(1 << 20)},
},
}
}
// 目录类条目(整部剧/季)自身没有媒体源,拿它的 ID 去请求远程 /Videos/{id}/stream 只会
// 回 500,哈希层于是每次都白白跳过。这条用例保证弹幕解析器跟随 /api/stream 的规则先
// 落到第一集:哈希算的是真正要播的那条流。
func TestResolveDanmakuRemoteMediaUsesPlayableChildForContainer(t *testing.T) {
var requested []string
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{
"Id": "series-100", "Name": "PSYREN", "Type": "Series", "IsFolder": true,
})
case "/emby/Users/uid-1/Items":
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
t.Errorf("ParentId = %q, want series-100", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
danmakuResolverTestEpisode("ep-1"),
},
})
case "/emby/Users/uid-1/Items/ep-1":
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-1"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "series-100"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-1/stream") {
t.Fatalf("stream url = %q, want the playable child ep-1", streamURL)
}
if strings.Contains(streamURL, "series-100") {
t.Fatalf("stream url = %q, must not point at the container id (remote Emby answers 500)", streamURL)
}
if !strings.Contains(strings.Join(requested, " "), "/Items/ep-1") {
t.Fatalf("metadata must come from the playable child, requests = %v", requested)
}
}
// 本身可播的条目(单集/电影)不能被改写:直接取它自己的流地址。
func TestResolveDanmakuRemoteMediaKeepsPlayableItem(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
t.Errorf("unexpected upstream path %q", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-2"))
}))
media, streamURL, err := b.resolveDanmakuRemoteMedia(
context.Background(), EncodeEmbyRemoteID("mount-1", "ep-2"))
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
if media == nil {
t.Fatal("resolve returned no media metadata")
}
if !strings.Contains(streamURL, "/Videos/ep-2/stream") {
t.Fatalf("stream url = %q, want /Videos/ep-2/stream", streamURL)
}
}
// 不是远程伪装 ID 时直接报错,不去猜一个可以拉取的地址。
func TestResolveDanmakuRemoteMediaRejectsPlainID(t *testing.T) {
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("plain local ids must not reach the remote Emby")
w.WriteHeader(http.StatusNotFound)
}))
if _, _, err := b.resolveDanmakuRemoteMedia(context.Background(), "local-media-1"); err == nil {
t.Fatal("expected an error for a non remote-emby id")
}
}
+16
View File
@@ -7,6 +7,8 @@ import (
"strings" "strings"
"time" "time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
@@ -28,6 +30,14 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
if err != nil { if err != nil {
return err 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))) s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
return nil 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 { if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
return err 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, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。") s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
return nil return nil
} }
+7
View File
@@ -31,6 +31,8 @@ type DeviceService struct {
log *zap.Logger log *zap.Logger
repo *repository.Container repo *repository.Container
sessions *SessionTrackerService sessions *SessionTrackerService
// authSessions 用于踢出设备时立即撤销其认证会话;nil 表示旧装配路径。
authSessions *AuthSessionService
// notifyUser sends a Telegram message to the local user (resolved to their // notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications. // Telegram binding). Wired by the bot service; nil disables notifications.
@@ -60,6 +62,11 @@ func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
s.sessions = tracker 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 // 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 // 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. // still counted as one terminal device; Client remains a login channel label.
@@ -0,0 +1,82 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestLikelyEpisodicPathSQLNormalisesSeparator 守住剧集路径判定的转义差异。
//
// 此前 embyLikelyEpisodicPathSQL 会额外生成一份反斜杠模式
// (strings.ReplaceAll(pattern, "/", `\`))。PostgreSQL 的 LIKE 默认就把反斜杠
// 当转义符,模式结尾的 `\%` 被解析成「字面百分号」而不是「反斜杠 + 通配符」,
// 那些模式在 PostgreSQL 下永不命中;SQLite 没有默认转义符所以一直正常。
// 现在统一在 SQL 里把分隔符归一化成 '/',模式本身不再含反斜杠。
func TestLikelyEpisodicPathSQLNormalisesSeparator(t *testing.T) {
clause, args := embyLikelyEpisodicPathSQL()
if !strings.Contains(clause, "REPLACE(media.path") {
t.Fatalf("clause no longer normalises the path separator:\n%s", clause)
}
if got := strings.Count(clause, "LIKE ?"); got != len(args) {
t.Fatalf("clause has %d placeholders but %d args:\n%s", got, len(args), clause)
}
for _, arg := range args {
pattern, ok := arg.(string)
if !ok {
t.Fatalf("unexpected pattern type %T", arg)
}
if strings.Contains(pattern, `\`) {
t.Fatalf("pattern %q still contains a backslash; PostgreSQL's LIKE would treat it as an escape character", pattern)
}
if !strings.Contains(pattern, "/") {
t.Fatalf("pattern %q lost its separator", pattern)
}
}
}
// TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths 验证反斜杠路径(Windows
// 宿主机入库的媒体)仍然被判成剧集并从电影库查询里排除。
func TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths(t *testing.T) {
db := newServiceTestDB(t)
episodic := model.Media{
Base: model.Base{ID: "ep-1"},
Path: `D:\Media\国产剧\我的剧\Season 1\我的剧 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
movie := model.Media{
Base: model.Base{ID: "mv-1"},
Path: `D:\Media\电影\某电影\某电影.mkv`,
}
if err := db.Create(&episodic).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&movie).Error; err != nil {
t.Fatal(err)
}
q := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0"),
)
var rows []model.Media
if err := q.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("windows-style episodic path was not excluded, got %d rows", len(rows))
}
// 反证:电影类型的行必须保留。
var kept []model.Media
keepQ := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("path LIKE ?", `D:\Media\电影\%`),
)
if err := keepQ.Find(&kept).Error; err != nil {
t.Fatal(err)
}
if len(kept) != 1 || kept[0].ID != "mv-1" {
t.Fatalf("plain movie row was dropped from the movie query: %+v", kept)
}
}

Some files were not shown because too many files have changed in this diff Show More