mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# MeBox Mobile API 接入与部署说明
|
||||||
|
|
||||||
|
更新日期:2026-10-17
|
||||||
|
适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。
|
||||||
|
|
||||||
|
## 1. 路径与版本
|
||||||
|
|
||||||
|
- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。
|
||||||
|
- 健康检查:`GET /api/health`。
|
||||||
|
- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。
|
||||||
|
- 契约字段与错误码见同目录 `openapi.yaml`。
|
||||||
|
|
||||||
|
`server-info.capabilities` 必须反映真实运行能力。当前分支只有
|
||||||
|
`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为
|
||||||
|
`false`,App 不得据此进入对应流程。
|
||||||
|
|
||||||
|
## 2. 连接与传输安全
|
||||||
|
|
||||||
|
- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。
|
||||||
|
- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源
|
||||||
|
使用明文,不会把 MeBox `Authorization` 发往其它主机。
|
||||||
|
- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝,
|
||||||
|
不会静默截断为根路径。
|
||||||
|
- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于
|
||||||
|
日志对账。
|
||||||
|
|
||||||
|
## 3. 反向代理检查项
|
||||||
|
|
||||||
|
以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置:
|
||||||
|
|
||||||
|
| 项目 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 |
|
||||||
|
| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) |
|
||||||
|
| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 |
|
||||||
|
| WebSocket | 升级头透传,读超时大于心跳间隔 |
|
||||||
|
| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 |
|
||||||
|
|
||||||
|
目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。
|
||||||
|
|
||||||
|
## 4. 认证与会话
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。
|
||||||
|
- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即
|
||||||
|
返回 401,不能借旧入口绕过。
|
||||||
|
- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌,
|
||||||
|
App 需要重新登录。
|
||||||
|
- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。
|
||||||
|
- 设备管理中的踢出操作会同步撤销该设备认证会话。
|
||||||
|
|
||||||
|
### 刷新轮换边界
|
||||||
|
|
||||||
|
- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。
|
||||||
|
- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应
|
||||||
|
且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受
|
||||||
|
该边界,未实现持久化幂等轮换。
|
||||||
|
- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。
|
||||||
|
|
||||||
|
## 5. 错误契约
|
||||||
|
|
||||||
|
统一响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SESSION_REVOKED",
|
||||||
|
"message": "auth session revoked or expired",
|
||||||
|
"request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3",
|
||||||
|
"retryable": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。
|
||||||
|
- 403 表示权限或账号状态,不触发刷新。
|
||||||
|
- 429 带 `Retry-After`,客户端必须等待。
|
||||||
|
- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。
|
||||||
|
|
||||||
|
## 6. 数据迁移与回滚
|
||||||
|
|
||||||
|
- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate
|
||||||
|
创建,属于增量变更,不清库。
|
||||||
|
- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行
|
||||||
|
`go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。
|
||||||
|
- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再
|
||||||
|
依赖 `sid` 即时撤销能力。
|
||||||
|
|
||||||
|
## 7. 已知限制
|
||||||
|
|
||||||
|
- 单实例部署;未实现多副本共享会话。
|
||||||
|
- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。
|
||||||
|
- 反代子路径、自签名证书信任、多服务器配置均未支持。
|
||||||
|
- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: MeBox Mobile API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载,
|
||||||
|
本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- JSON 字段使用 snake_case;
|
||||||
|
- 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒;
|
||||||
|
- 集合为空时返回 `[]`,未知字段客户端必须忽略;
|
||||||
|
- 错误响应统一为 `code`、`message`、`request_id`、`retryable`;
|
||||||
|
- 能力开关只有在接口实现且当前可运行时才为 true。
|
||||||
|
servers:
|
||||||
|
- url: /api
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
tags:
|
||||||
|
- name: server
|
||||||
|
- name: auth
|
||||||
|
paths:
|
||||||
|
/v1/server-info:
|
||||||
|
get:
|
||||||
|
tags: [server]
|
||||||
|
summary: 匿名读取实例身份与移动端能力
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 实例可连接
|
||||||
|
headers:
|
||||||
|
Cache-Control:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: 固定为 no-store
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ServerInfo'
|
||||||
|
'503':
|
||||||
|
description: 暂时无法读取实例身份
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
/v1/auth/login:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: 账号密码登录并创建认证会话
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/RequestId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 登录成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/InvalidArgument'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/InvalidCredentials'
|
||||||
|
'403':
|
||||||
|
description: 账号被禁用或已到期
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/RateLimited'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
/v1/auth/refresh:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: 使用刷新令牌轮换令牌对
|
||||||
|
security: []
|
||||||
|
description: |
|
||||||
|
刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的
|
||||||
|
并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。
|
||||||
|
会话被撤销后刷新返回 SESSION_REVOKED。
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RefreshRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 刷新成功,新令牌继续归属同一 auth_session_id
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/InvalidArgument'
|
||||||
|
'401':
|
||||||
|
description: 刷新令牌无效、过期或会话已撤销
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
/v1/auth/session:
|
||||||
|
get:
|
||||||
|
tags: [auth]
|
||||||
|
summary: 读取当前用户与认证会话
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 会话有效
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/SessionInvalid'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
/v1/auth/logout:
|
||||||
|
post:
|
||||||
|
tags: [auth]
|
||||||
|
summary: 撤销当前认证会话
|
||||||
|
description: 幂等;重复退出同样返回 200。
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 已退出(本机与远端)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/SessionInvalid'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
parameters:
|
||||||
|
RequestId:
|
||||||
|
name: X-Request-ID
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
description: |
|
||||||
|
可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在
|
||||||
|
响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
InvalidArgument:
|
||||||
|
description: 请求字段缺失或格式错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
InvalidCredentials:
|
||||||
|
description: 用户名或密码错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
SessionInvalid:
|
||||||
|
description: 访问令牌无效、过期或认证会话已撤销
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
RateLimited:
|
||||||
|
description: 请求过于频繁
|
||||||
|
headers:
|
||||||
|
Retry-After:
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: 建议等待秒数
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
ServiceUnavailable:
|
||||||
|
description: 暂时性服务/存储错误,可安全重试
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MobileError'
|
||||||
|
schemas:
|
||||||
|
ServerInfo:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- server_id
|
||||||
|
- server_version
|
||||||
|
- api_version
|
||||||
|
- min_supported_app_version_code
|
||||||
|
- capabilities
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: MeBox
|
||||||
|
server_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: 实例持久身份;备份克隆成独立实例时必须显式重置。
|
||||||
|
server_version:
|
||||||
|
type: string
|
||||||
|
example: MeBox-v0.1.16
|
||||||
|
api_version:
|
||||||
|
type: string
|
||||||
|
example: v1
|
||||||
|
min_supported_app_version_code:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
capabilities:
|
||||||
|
$ref: '#/components/schemas/ServerCapabilities'
|
||||||
|
ServerCapabilities:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- device_sessions
|
||||||
|
- playback_sessions
|
||||||
|
- reader_progress_v1
|
||||||
|
- reader_content_batch
|
||||||
|
- local_transcode
|
||||||
|
properties:
|
||||||
|
device_sessions:
|
||||||
|
type: boolean
|
||||||
|
description: M1 起为 true
|
||||||
|
playback_sessions:
|
||||||
|
type: boolean
|
||||||
|
description: 播放会话接口未实现前必须为 false
|
||||||
|
reader_progress_v1:
|
||||||
|
type: boolean
|
||||||
|
description: v1 阅读锚点/版本冲突未实现前必须为 false
|
||||||
|
reader_content_batch:
|
||||||
|
type: boolean
|
||||||
|
description: v1 批量正文与资源引用未实现前必须为 false
|
||||||
|
local_transcode:
|
||||||
|
type: boolean
|
||||||
|
description: 移动端可用的本地转码会话未实现前必须为 false
|
||||||
|
MobileUser:
|
||||||
|
type: object
|
||||||
|
required: [id, username, role, tier, is_active, force_password_reset]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
nickname:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [admin, user]
|
||||||
|
tier:
|
||||||
|
type: string
|
||||||
|
example: free
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
force_password_reset:
|
||||||
|
type: boolean
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
TokenPair:
|
||||||
|
type: object
|
||||||
|
required: [access_token, refresh_token, expires_in, token_type]
|
||||||
|
properties:
|
||||||
|
access_token:
|
||||||
|
type: string
|
||||||
|
refresh_token:
|
||||||
|
type: string
|
||||||
|
expires_in:
|
||||||
|
type: integer
|
||||||
|
description: 访问令牌有效秒数
|
||||||
|
token_type:
|
||||||
|
type: string
|
||||||
|
example: Bearer
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required: [username, password, device_id]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
device_id:
|
||||||
|
type: string
|
||||||
|
maxLength: 128
|
||||||
|
description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。
|
||||||
|
device_name:
|
||||||
|
type: string
|
||||||
|
maxLength: 128
|
||||||
|
client_name:
|
||||||
|
type: string
|
||||||
|
maxLength: 64
|
||||||
|
example: MeBox Android
|
||||||
|
client_version:
|
||||||
|
type: string
|
||||||
|
maxLength: 64
|
||||||
|
RefreshRequest:
|
||||||
|
type: object
|
||||||
|
required: [refresh_token]
|
||||||
|
properties:
|
||||||
|
refresh_token:
|
||||||
|
type: string
|
||||||
|
AuthResponse:
|
||||||
|
type: object
|
||||||
|
required: [auth_session_id, user, tokens]
|
||||||
|
properties:
|
||||||
|
auth_session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/MobileUser'
|
||||||
|
tokens:
|
||||||
|
$ref: '#/components/schemas/TokenPair'
|
||||||
|
SessionDevice:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
device_id:
|
||||||
|
type: string
|
||||||
|
device_name:
|
||||||
|
type: string
|
||||||
|
client_name:
|
||||||
|
type: string
|
||||||
|
client_version:
|
||||||
|
type: string
|
||||||
|
last_used_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
SessionResponse:
|
||||||
|
type: object
|
||||||
|
required: [auth_session_id, user]
|
||||||
|
properties:
|
||||||
|
auth_session_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/MobileUser'
|
||||||
|
session:
|
||||||
|
$ref: '#/components/schemas/SessionDevice'
|
||||||
|
MobileError:
|
||||||
|
type: object
|
||||||
|
required: [code, message, request_id, retryable]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- INVALID_ARGUMENT
|
||||||
|
- INVALID_CREDENTIALS
|
||||||
|
- TOKEN_EXPIRED
|
||||||
|
- SESSION_REVOKED
|
||||||
|
- ACCOUNT_DISABLED
|
||||||
|
- ACCOUNT_EXPIRED
|
||||||
|
- PERMISSION_DENIED
|
||||||
|
- PROFILE_PIN_REQUIRED
|
||||||
|
- RATE_LIMITED
|
||||||
|
- UPSTREAM_UNAVAILABLE
|
||||||
|
- TRANSCODE_BUSY
|
||||||
|
- PLAYBACK_SESSION_NOT_FOUND
|
||||||
|
- RESOURCE_EXPIRED
|
||||||
|
- PROGRESS_CONFLICT
|
||||||
|
- SERVICE_UNAVAILABLE
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
request_id:
|
||||||
|
type: string
|
||||||
|
retryable:
|
||||||
|
type: boolean
|
||||||
|
retry_after_sec:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
details:
|
||||||
|
description: 可选的稳定结构化细节,未定义时客户端必须忽略。
|
||||||
+24
-1
@@ -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
@@ -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/字段一一对应。
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// Package handler — 移动端 /api/v1 认证端点。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
|
||||||
|
// 使用的偏好和内部状态带进新契约。
|
||||||
|
type mobileUserPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
ForcePasswordReset bool `json:"force_password_reset"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMobileUserPayload(u *model.User) *mobileUserPayload {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &mobileUserPayload{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Role: u.Role,
|
||||||
|
Tier: u.Tier,
|
||||||
|
IsActive: u.IsActive,
|
||||||
|
ForcePasswordReset: u.ForcePasswordReset,
|
||||||
|
ExpiresAt: u.ExpiredAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mobileSessionPayload struct {
|
||||||
|
AuthSessionID string `json:"auth_session_id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name,omitempty"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
ClientVersion string `json:"client_version,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &mobileSessionPayload{
|
||||||
|
AuthSessionID: s.ID,
|
||||||
|
DeviceID: s.DeviceID,
|
||||||
|
DeviceName: s.DeviceName,
|
||||||
|
ClientName: s.ClientName,
|
||||||
|
ClientVersion: s.ClientVersion,
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
LastUsedAt: s.LastUsedAt,
|
||||||
|
ExpiresAt: s.ExpiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mobileLoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
ClientName string `json:"client_name"`
|
||||||
|
ClientVersion string `json:"client_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mobileRefreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
|
||||||
|
// 并统一输出 v1 错误契约。
|
||||||
|
func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
raw := middleware.ExtractToken(c)
|
||||||
|
if raw == "" {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"access token required", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := svc.Token.ValidateAccessToken(raw)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||||
|
"access token expired", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"invalid access token", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(claims.Purpose) != "" {
|
||||||
|
writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
|
||||||
|
"token scope denied for this endpoint", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := strings.TrimSpace(claims.Sid)
|
||||||
|
if sessionID == "" {
|
||||||
|
// v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"auth session required; sign in again", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if service.IsTransientDatabaseLock(err) {
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"auth session check temporarily unavailable", true, 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||||
|
"auth session revoked or expired", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(middleware.CtxUserID, claims.UserID)
|
||||||
|
c.Set(middleware.CtxUserRole, claims.Role)
|
||||||
|
c.Set(middleware.CtxUserTier, claims.Tier)
|
||||||
|
c.Set(middleware.CtxAuthSessionID, sessionID)
|
||||||
|
c.Set(mobileCtxAuthSession, session)
|
||||||
|
// 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
|
||||||
|
// 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
|
||||||
|
if time.Since(session.LastUsedAt) > time.Minute {
|
||||||
|
svc.AuthSessions.Touch(c.Request.Context(), sessionID)
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
|
||||||
|
// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
|
||||||
|
// 被会话校验拦成 401。
|
||||||
|
func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
raw := middleware.ExtractToken(c)
|
||||||
|
if raw == "" {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"access token required", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := svc.Token.ValidateAccessToken(raw)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||||
|
"access token expired", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"invalid access token", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID := strings.TrimSpace(claims.Sid)
|
||||||
|
if sessionID == "" {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"auth session required; sign in again", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(middleware.CtxUserID, claims.UserID)
|
||||||
|
c.Set(middleware.CtxAuthSessionID, sessionID)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req mobileLoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||||
|
"username, password and device_id are required", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||||
|
if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
|
||||||
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||||
|
"invalid username or device_id", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeMobileAuthError(c, log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceName := strings.TrimSpace(req.DeviceName)
|
||||||
|
clientName := strings.TrimSpace(req.ClientName)
|
||||||
|
if clientName == "" {
|
||||||
|
clientName = "MeBox Android"
|
||||||
|
}
|
||||||
|
session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
DeviceID: req.DeviceID,
|
||||||
|
DeviceName: deviceName,
|
||||||
|
ClientName: clientName,
|
||||||
|
ClientVersion: req.ClientVersion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isTransientServiceError(err) {
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"cannot create auth session right now", true, 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||||
|
"invalid device information", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
// 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
|
||||||
|
_ = svc.AuthSessions.Revoke(ctx, session.ID)
|
||||||
|
writeMobileAuthError(c, log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if svc.Device != nil {
|
||||||
|
svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
|
||||||
|
}
|
||||||
|
if svc.Audit != nil {
|
||||||
|
svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"auth_session_id": session.ID,
|
||||||
|
"user": toMobileUserPayload(user),
|
||||||
|
"tokens": tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req mobileRefreshRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||||
|
"refresh_token required", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
|
||||||
|
if err != nil {
|
||||||
|
if isTransientServiceError(err) {
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"token store temporarily unavailable", true, 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileAuthError(c, log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
|
||||||
|
// 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
|
||||||
|
// 由 TokenService 判定。
|
||||||
|
if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"refresh token is not bound to a device session; sign in again", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||||
|
if isTransientServiceError(err) {
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"auth session check temporarily unavailable", true, 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||||
|
"auth session revoked or expired", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
writeMobileAuthError(c, log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeMobileAuthError(c, log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"user not found", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"auth_session_id": rt.AuthSessionID,
|
||||||
|
"user": toMobileUserPayload(user),
|
||||||
|
"tokens": tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
userID := c.GetString(middleware.CtxUserID)
|
||||||
|
user, err := svc.Repo.User.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
writeMobileAuthError(c, svc.Log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"user not found", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, _ := c.Get(mobileCtxAuthSession)
|
||||||
|
sessionModel, _ := session.(*model.AuthSession)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"auth_session_id": c.GetString(middleware.CtxAuthSessionID),
|
||||||
|
"user": toMobileUserPayload(user),
|
||||||
|
"session": toMobileSessionPayload(sessionModel),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
sessionID := c.GetString(middleware.CtxAuthSessionID)
|
||||||
|
if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
|
||||||
|
if isTransientServiceError(err) {
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"cannot revoke session right now", true, 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
|
||||||
|
"logout failed", false, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMobileAuthError 把 service 层错误映射为稳定错误码。
|
||||||
|
// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
|
||||||
|
func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||||
|
"invalid username, password or refresh token", false, 0)
|
||||||
|
case errors.Is(err, service.ErrTokenExpired):
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||||
|
"refresh token expired", false, 0)
|
||||||
|
case errors.Is(err, service.ErrTokenRevoked),
|
||||||
|
errors.Is(err, service.ErrAuthSessionRevoked),
|
||||||
|
errors.Is(err, service.ErrAuthSessionExpired),
|
||||||
|
errors.Is(err, service.ErrAuthSessionRequired):
|
||||||
|
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||||
|
"auth session revoked or expired", false, 0)
|
||||||
|
case errors.Is(err, service.ErrUserInactive):
|
||||||
|
writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
|
||||||
|
"account is disabled", false, 0)
|
||||||
|
case errors.Is(err, service.ErrUserExpired):
|
||||||
|
writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
|
||||||
|
"account has expired", false, 0)
|
||||||
|
case isTransientServiceError(err):
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"service temporarily unavailable, please retry", true, 2)
|
||||||
|
default:
|
||||||
|
if log != nil {
|
||||||
|
log.Warn("mobile auth request failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"service temporarily unavailable, please retry", true, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientServiceError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if service.IsTransientDatabaseLock(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mobileTestSecret = "mobile-test-secret"
|
||||||
|
|
||||||
|
func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
|
||||||
|
// shared cache 名字在并行测试间串库。
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
|
||||||
|
&model.UserDevice{}, &model.UserPermission{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
|
||||||
|
svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
Register(router, cfg, zap.NewNop(), svc)
|
||||||
|
return router, svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
|
||||||
|
t.Helper()
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
|
||||||
|
if err := svc.Repo.User.Create(t.Context(), u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(raw)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, reader)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
||||||
|
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
|
||||||
|
router, _ := newMobileTestEnv(t)
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||||
|
}
|
||||||
|
var info service.ServerInfo
|
||||||
|
decodeMobileJSON(t, w, &info)
|
||||||
|
if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
|
||||||
|
t.Fatalf("unexpected server info: %+v", info)
|
||||||
|
}
|
||||||
|
if !info.Capabilities.DeviceSessions {
|
||||||
|
t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
|
||||||
|
}
|
||||||
|
if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
|
||||||
|
t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
|
||||||
|
}
|
||||||
|
if info.MinSupportedAppVersionCode < 1 {
|
||||||
|
t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匿名访问不应要求 Authorization,也不应记录 cookie。
|
||||||
|
if len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Fatalf("server-info should not set cookies")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileAuthLifecycle(t *testing.T) {
|
||||||
|
router, svc := newMobileTestEnv(t)
|
||||||
|
user := seedMobileUser(t, svc, "alice", "secret-123")
|
||||||
|
|
||||||
|
// 1. 错误密码:401 + 稳定错误码 + request_id。
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"username": "alice", "password": "wrong", "device_id": "device-1",
|
||||||
|
}, map[string]string{"X-Request-ID": "mobile-test-0001"})
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var authErr mobileErrorBody
|
||||||
|
decodeMobileJSON(t, w, &authErr)
|
||||||
|
if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
|
||||||
|
t.Fatalf("unexpected auth error: %+v", authErr)
|
||||||
|
}
|
||||||
|
if authErr.Retryable {
|
||||||
|
t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 缺少 device_id:400 INVALID_ARGUMENT。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"username": "alice", "password": "secret-123",
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &authErr)
|
||||||
|
if authErr.Code != mobileCodeInvalidArgument {
|
||||||
|
t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 正常登录。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"username": "alice", "password": "secret-123", "device_id": "device-1",
|
||||||
|
"device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var loginResp struct {
|
||||||
|
AuthSessionID string `json:"auth_session_id"`
|
||||||
|
User mobileUserPayload `json:"user"`
|
||||||
|
Tokens service.TokenPair `json:"tokens"`
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &loginResp)
|
||||||
|
if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
|
||||||
|
t.Fatalf("incomplete login response: %+v", loginResp)
|
||||||
|
}
|
||||||
|
if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
|
||||||
|
t.Fatalf("unexpected user payload: %+v", loginResp.User)
|
||||||
|
}
|
||||||
|
claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate access token: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Sid != loginResp.AuthSessionID {
|
||||||
|
t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
|
||||||
|
}
|
||||||
|
rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||||||
|
if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
|
||||||
|
t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 会话读取。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var sessionResp struct {
|
||||||
|
AuthSessionID string `json:"auth_session_id"`
|
||||||
|
User mobileUserPayload `json:"user"`
|
||||||
|
Session *mobileSessionPayload `json:"session"`
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &sessionResp)
|
||||||
|
if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
|
||||||
|
t.Fatalf("unexpected session response: %+v", sessionResp)
|
||||||
|
}
|
||||||
|
if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
|
||||||
|
t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||||
|
"refresh_token": loginResp.Tokens.RefreshToken,
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var refreshResp struct {
|
||||||
|
AuthSessionID string `json:"auth_session_id"`
|
||||||
|
Tokens service.TokenPair `json:"tokens"`
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &refreshResp)
|
||||||
|
if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
|
||||||
|
t.Fatalf("unexpected refresh response: %+v", refreshResp)
|
||||||
|
}
|
||||||
|
if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
|
||||||
|
t.Fatal("refresh token should rotate")
|
||||||
|
}
|
||||||
|
oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||||||
|
if err != nil || oldRT == nil || !oldRT.Revoked {
|
||||||
|
t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 退出:成功且可重复。
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &authErr)
|
||||||
|
if authErr.Code != mobileCodeSessionRevoked {
|
||||||
|
t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 被踢后的未过期 JWT 不能绕过旧 API。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 刷新令牌随会话一起撤销。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||||
|
"refresh_token": refreshResp.Tokens.RefreshToken,
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &authErr)
|
||||||
|
if authErr.Code != mobileCodeSessionRevoked {
|
||||||
|
t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
|
||||||
|
router, svc := newMobileTestEnv(t)
|
||||||
|
user := seedMobileUser(t, svc, "legacy", "secret-123")
|
||||||
|
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
|
||||||
|
t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var body mobileErrorBody
|
||||||
|
decodeMobileJSON(t, w, &body)
|
||||||
|
if body.Code != mobileCodeInvalidCredentials {
|
||||||
|
t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
|
||||||
|
router, svc := newMobileTestEnv(t)
|
||||||
|
user := seedMobileUser(t, svc, "bob", "secret-123")
|
||||||
|
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var body mobileErrorBody
|
||||||
|
decodeMobileJSON(t, w, &body)
|
||||||
|
if body.Code != mobileCodeInvalidCredentials {
|
||||||
|
t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileDeviceKickRevokesSession(t *testing.T) {
|
||||||
|
router, svc := newMobileTestEnv(t)
|
||||||
|
user := seedMobileUser(t, svc, "carol", "secret-123")
|
||||||
|
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"username": "carol", "password": "secret-123", "device_id": "device-kick",
|
||||||
|
"device_name": "Kick Me", "client_name": "MeBox Android",
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var loginResp struct {
|
||||||
|
AuthSessionID string `json:"auth_session_id"`
|
||||||
|
Tokens service.TokenPair `json:"tokens"`
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &loginResp)
|
||||||
|
|
||||||
|
if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
|
||||||
|
t.Fatalf("kick device: %v", err)
|
||||||
|
}
|
||||||
|
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||||||
|
if err != nil || session == nil || !session.Revoked {
|
||||||
|
t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
|
||||||
|
}
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
|
||||||
|
router, svc := newMobileTestEnv(t)
|
||||||
|
seedMobileUser(t, svc, "erin", "secret-123")
|
||||||
|
|
||||||
|
login := func(deviceID string) string {
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"username": "erin", "password": "secret-123", "device_id": deviceID,
|
||||||
|
"device_name": deviceID, "client_name": "MeBox Android",
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Tokens service.TokenPair `json:"tokens"`
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &resp)
|
||||||
|
return resp.Tokens.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenA := login("device-a")
|
||||||
|
tokenB := login("device-b")
|
||||||
|
|
||||||
|
if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
|
||||||
|
t.Fatalf("kick device a: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func svcUserID(t *testing.T, svc *service.Container, username string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := svc.Repo.User.FindByUsername(t.Context(), username)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
t.Fatalf("find user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
|
||||||
|
router, svc := newMobileTestEnv(t)
|
||||||
|
seedMobileUser(t, svc, "dave", "secret-123")
|
||||||
|
|
||||||
|
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"username": "dave", "password": "secret-123", "device_id": "device-default",
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var loginResp struct {
|
||||||
|
AuthSessionID string `json:"auth_session_id"`
|
||||||
|
Tokens service.TokenPair `json:"tokens"`
|
||||||
|
}
|
||||||
|
decodeMobileJSON(t, w, &loginResp)
|
||||||
|
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
t.Fatalf("load session: %v", err)
|
||||||
|
}
|
||||||
|
if session.ClientName != "MeBox Android" {
|
||||||
|
t.Fatalf("client_name default = %q", session.ClientName)
|
||||||
|
}
|
||||||
|
if session.ExpiresAt.Before(time.Now()) {
|
||||||
|
t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
|
||||||
|
}
|
||||||
|
if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
|
||||||
|
t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
|
||||||
|
const (
|
||||||
|
mobileCodeInvalidArgument = "INVALID_ARGUMENT"
|
||||||
|
mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
|
||||||
|
mobileCodeTokenExpired = "TOKEN_EXPIRED"
|
||||||
|
mobileCodeSessionRevoked = "SESSION_REVOKED"
|
||||||
|
mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
|
||||||
|
mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
|
||||||
|
mobileCodePermissionDenied = "PERMISSION_DENIED"
|
||||||
|
mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
|
||||||
|
mobileCodeRateLimited = "RATE_LIMITED"
|
||||||
|
mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
|
||||||
|
mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
|
||||||
|
mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
|
||||||
|
mobileCodeResourceExpired = "RESOURCE_EXPIRED"
|
||||||
|
mobileCodeProgressConflict = "PROGRESS_CONFLICT"
|
||||||
|
mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mobileCtxRequestID = "mobile_request_id"
|
||||||
|
mobileCtxAuthSession = "mobile_auth_session"
|
||||||
|
mobileRequestIDHeader = "X-Request-ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
|
||||||
|
// code 分流,不需要再判断后端历史包装层。
|
||||||
|
type mobileErrorBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
RetryAfterSec int `json:"retry_after_sec,omitempty"`
|
||||||
|
Details any `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
|
||||||
|
// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
|
||||||
|
// 重新生成;该值仅用于诊断,绝不参与鉴权。
|
||||||
|
func mobileRequestIDMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
|
||||||
|
if !validRequestID(id) {
|
||||||
|
id = uuid.NewString()
|
||||||
|
}
|
||||||
|
c.Set(mobileCtxRequestID, id)
|
||||||
|
c.Header(mobileRequestIDHeader, id)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRequestID(id string) bool {
|
||||||
|
if len(id) < 8 || len(id) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range id {
|
||||||
|
if r > unicode.MaxASCII {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
case '-', '_', '.', ':':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentMobileRequestID(c *gin.Context) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := c.Get(mobileCtxRequestID); ok {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
|
||||||
|
// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
|
||||||
|
func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
|
||||||
|
requestID := currentMobileRequestID(c)
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = uuid.NewString()
|
||||||
|
c.Header(mobileRequestIDHeader, requestID)
|
||||||
|
}
|
||||||
|
if retryAfterSec > 0 {
|
||||||
|
c.Header("Retry-After", strconv.Itoa(retryAfterSec))
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(status, mobileErrorBody{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
RequestID: requestID,
|
||||||
|
Retryable: retryable,
|
||||||
|
RetryAfterSec: retryAfterSec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
|
||||||
|
func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !limiter.Allow(c.ClientIP()) {
|
||||||
|
writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
|
||||||
|
"too many requests, please try again later", true, 60)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。
|
||||||
|
//
|
||||||
|
// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体
|
||||||
|
// 或书源业务逻辑。
|
||||||
|
func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
|
||||||
|
if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil {
|
||||||
|
// 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wireAuthSessionValidator(svc)
|
||||||
|
|
||||||
|
v1 := api.Group("/v1")
|
||||||
|
v1.Use(mobileRequestIDMiddleware())
|
||||||
|
|
||||||
|
v1.GET("/server-info", mobileServerInfoHandler(svc, log))
|
||||||
|
|
||||||
|
authLimiter := middleware.NewRateLimiter(30, time.Minute)
|
||||||
|
v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log))
|
||||||
|
v1.POST("/auth/refresh", mobileRefreshHandler(svc, log))
|
||||||
|
|
||||||
|
authed := v1.Group("/auth")
|
||||||
|
{
|
||||||
|
authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc))
|
||||||
|
authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。
|
||||||
|
// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。
|
||||||
|
func wireAuthSessionValidator(svc *service.Container) {
|
||||||
|
middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||||
|
if svc == nil || svc.AuthSessions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := svc.AuthSessions.Validate(ctx, sessionID, userID)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrAuthSessionRevoked) ||
|
||||||
|
errors.Is(err, service.ErrAuthSessionExpired) ||
|
||||||
|
errors.Is(err, service.ErrAuthSessionRequired) {
|
||||||
|
return middleware.ErrSessionRevoked
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc))
|
||||||
|
if err != nil {
|
||||||
|
if log != nil {
|
||||||
|
log.Warn("mobile server info failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||||
|
"server identity temporarily unavailable", true, 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,11 +51,53 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
// 远程 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware
|
||||||
|
// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。
|
||||||
|
var (
|
||||||
|
// ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。
|
||||||
|
ErrSessionRevoked = errors.New("auth session revoked")
|
||||||
|
// ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。
|
||||||
|
ErrSessionUnavailable = errors.New("auth session unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionValidator 校验认证会话是否仍属于该用户。
|
||||||
|
type SessionValidator func(ctx context.Context, sessionID, userID string) error
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionValidatorMu sync.RWMutex
|
||||||
|
sessionValidator SessionValidator
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。
|
||||||
|
func SetSessionValidator(fn SessionValidator) {
|
||||||
|
sessionValidatorMu.Lock()
|
||||||
|
defer sessionValidatorMu.Unlock()
|
||||||
|
sessionValidator = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行,
|
||||||
|
// 以保持旧测试和嵌入式调用的兼容行为。
|
||||||
|
func ValidateAuthSession(ctx context.Context, sessionID, userID string) error {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sessionValidatorMu.RLock()
|
||||||
|
fn := sessionValidator
|
||||||
|
sessionValidatorMu.RUnlock()
|
||||||
|
if fn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fn(ctx, sessionID, userID)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ const (
|
|||||||
CtxUserTier = "ctx_user_tier"
|
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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Package service — 设备认证会话。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 认证会话错误。middleware 与 handler 依据这些错误区分「重新登录」和
|
||||||
|
// 「临时基础设施故障」。
|
||||||
|
var (
|
||||||
|
ErrAuthSessionRequired = errors.New("auth session required")
|
||||||
|
ErrAuthSessionRevoked = errors.New("auth session revoked")
|
||||||
|
ErrAuthSessionExpired = errors.New("auth session expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthSessionService 管理设备会话的创建、校验和撤销。
|
||||||
|
type AuthSessionService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthSessionService 创建会话服务。
|
||||||
|
func NewAuthSessionService(log *zap.Logger, repo *repository.Container) *AuthSessionService {
|
||||||
|
return &AuthSessionService{log: log, repo: repo, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAuthSessionInput 是创建会话所需的最小设备信息。
|
||||||
|
type CreateAuthSessionInput struct {
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
DeviceName string
|
||||||
|
ClientName string
|
||||||
|
ClientVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建一次登录对应的认证会话。
|
||||||
|
//
|
||||||
|
// 设备 ID 由 App 安装随机生成,不接收 IMEI/MAC 等硬件标识;这里只做长度
|
||||||
|
// 与字符白名单校验,避免任意字符串进入索引和日志。
|
||||||
|
func (s *AuthSessionService) Create(ctx context.Context, in CreateAuthSessionInput) (*model.AuthSession, error) {
|
||||||
|
userID := strings.TrimSpace(in.UserID)
|
||||||
|
deviceID := strings.TrimSpace(in.DeviceID)
|
||||||
|
if userID == "" || deviceID == "" {
|
||||||
|
return nil, errors.New("user id and device id required")
|
||||||
|
}
|
||||||
|
if len(deviceID) > 128 || !validDeviceID(deviceID) {
|
||||||
|
return nil, errors.New("invalid device id")
|
||||||
|
}
|
||||||
|
now := s.currentTime()
|
||||||
|
session := &model.AuthSession{
|
||||||
|
UserID: userID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
DeviceName: truncateRunes(strings.TrimSpace(in.DeviceName), 128),
|
||||||
|
ClientName: truncateRunes(strings.TrimSpace(in.ClientName), 64),
|
||||||
|
ClientVersion: truncateRunes(strings.TrimSpace(in.ClientVersion), 64),
|
||||||
|
LastUsedAt: now,
|
||||||
|
ExpiresAt: now.Add(RefreshTokenDuration),
|
||||||
|
}
|
||||||
|
if err := s.repo.AuthSession.Create(ctx, session); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 校验会话仍可用于给定用户。
|
||||||
|
//
|
||||||
|
// 未找到的会话按「已撤销」处理:App 收到 SESSION_REVOKED 后应重新登录,
|
||||||
|
// 而不是无限刷新。数据库错误保持原样返回,由上层映射成可重试的 503。
|
||||||
|
func (s *AuthSessionService) Validate(ctx context.Context, sessionID, userID string) (*model.AuthSession, error) {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil, ErrAuthSessionRequired
|
||||||
|
}
|
||||||
|
session, err := s.repo.AuthSession.FindByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if session == nil || session.UserID != userID {
|
||||||
|
return nil, ErrAuthSessionRevoked
|
||||||
|
}
|
||||||
|
if session.Revoked {
|
||||||
|
return nil, ErrAuthSessionRevoked
|
||||||
|
}
|
||||||
|
if session.IsExpired() {
|
||||||
|
return nil, ErrAuthSessionExpired
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch 更新会话最近使用时间,失败只记日志,不影响请求。
|
||||||
|
func (s *AuthSessionService) Touch(ctx context.Context, sessionID string) {
|
||||||
|
if s == nil || sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.repo.AuthSession.TouchLastUsed(ctx, sessionID, s.currentTime()); err != nil && s.log != nil {
|
||||||
|
s.log.Debug("touch auth session failed", zap.String("session_id", sessionID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 撤销单个会话及其名下刷新令牌(幂等)。
|
||||||
|
func (s *AuthSessionService) Revoke(ctx context.Context, sessionID string) error {
|
||||||
|
sessionID = strings.TrimSpace(sessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.repo.AuthSession.Revoke(ctx, sessionID, s.currentTime()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.RefreshToken.RevokeByAuthSessionID(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeByUser 撤销某用户的全部会话(幂等)。
|
||||||
|
func (s *AuthSessionService) RevokeByUser(ctx context.Context, userID string) error {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
if userID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sessions, err := s.repo.AuthSession.ListActiveByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range sessions {
|
||||||
|
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeDevice 撤销某用户某设备的全部会话(幂等)。
|
||||||
|
func (s *AuthSessionService) RevokeDevice(ctx context.Context, userID, deviceID string) error {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
deviceID = strings.TrimSpace(deviceID)
|
||||||
|
if userID == "" || deviceID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sessions, err := s.repo.AuthSession.ListByDevice(ctx, userID, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range sessions {
|
||||||
|
if sessions[i].Revoked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthSessionService) currentTime() time.Time {
|
||||||
|
if s == nil || s.now == nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
return s.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDeviceID(id string) bool {
|
||||||
|
for _, r := range id {
|
||||||
|
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
if max <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max])
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAuthSessionTestEnv(t *testing.T) (*AuthSessionService, *TokenService, *repository.Container) {
|
||||||
|
t.Helper()
|
||||||
|
db := newServiceTestDB(t, &model.AuthSession{}, &model.RefreshToken{}, &model.User{}, &model.Setting{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
sessions := NewAuthSessionService(zap.NewNop(), repos)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.JWTSecret = "test-secret"
|
||||||
|
tokens := NewTokenService(cfg, zap.NewNop(), repos)
|
||||||
|
tokens.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||||
|
_, err := sessions.Validate(ctx, sessionID, userID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return sessions, tokens, repos
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedSessionUser(t *testing.T, repos *repository.Container) *model.User {
|
||||||
|
t.Helper()
|
||||||
|
u := &model.User{Username: "mobile", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||||
|
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSessionLifecycle(t *testing.T) {
|
||||||
|
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := seedSessionUser(t, repos)
|
||||||
|
|
||||||
|
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
DeviceID: "device-abc",
|
||||||
|
DeviceName: "Pixel",
|
||||||
|
ClientName: "MeBox Android",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
if session.ID == "" || session.ExpiresAt.IsZero() {
|
||||||
|
t.Fatal("session missing id or expiry")
|
||||||
|
}
|
||||||
|
if _, err := sessions.Validate(ctx, session.ID, user.ID); err != nil {
|
||||||
|
t.Fatalf("validate active session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := sessions.Validate(ctx, session.ID, "other-user"); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||||
|
t.Fatalf("cross-user validate err = %v, want ErrAuthSessionRevoked", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue pair: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := tokens.ValidateAccessToken(pair.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate access token: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Sid != session.ID {
|
||||||
|
t.Fatalf("access token sid = %q, want %q", claims.Sid, session.ID)
|
||||||
|
}
|
||||||
|
rt, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||||
|
if err != nil || rt == nil {
|
||||||
|
t.Fatalf("refresh token row = %v, err = %v", rt, err)
|
||||||
|
}
|
||||||
|
if rt.AuthSessionID != session.ID {
|
||||||
|
t.Fatalf("refresh token session = %q, want %q", rt.AuthSessionID, session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||||
|
t.Fatalf("revoke session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := sessions.Validate(ctx, session.ID, user.ID); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||||
|
t.Fatalf("validate revoked err = %v, want ErrAuthSessionRevoked", err)
|
||||||
|
}
|
||||||
|
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||||
|
if err != nil || rt == nil {
|
||||||
|
t.Fatalf("reload refresh token: %v", err)
|
||||||
|
}
|
||||||
|
if !rt.Revoked {
|
||||||
|
t.Fatal("refresh token should be revoked with the session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等:重复撤销不报错。
|
||||||
|
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||||
|
t.Fatalf("second revoke: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshRejectsRevokedSession(t *testing.T) {
|
||||||
|
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := seedSessionUser(t, repos)
|
||||||
|
|
||||||
|
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tokens.Refresh(ctx, pair.RefreshToken); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||||
|
t.Fatalf("refresh revoked session err = %v, want ErrAuthSessionRevoked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshKeepsSessionAssociation(t *testing.T) {
|
||||||
|
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := seedSessionUser(t, repos)
|
||||||
|
|
||||||
|
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rotated, err := tokens.Refresh(ctx, pair.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refresh: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := tokens.ValidateAccessToken(rotated.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if claims.Sid != session.ID {
|
||||||
|
t.Fatalf("rotated access sid = %q, want %q", claims.Sid, session.ID)
|
||||||
|
}
|
||||||
|
// 刷新路径使用 best-effort 落库;轮换结果可能稍后才写入。
|
||||||
|
var rt *model.RefreshToken
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(rotated.RefreshToken))
|
||||||
|
if err == nil && rt != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err != nil || rt == nil {
|
||||||
|
t.Fatalf("rotated refresh row: %v err=%v", rt, err)
|
||||||
|
}
|
||||||
|
if rt.AuthSessionID != session.ID {
|
||||||
|
t.Fatalf("rotated refresh session = %q, want %q", rt.AuthSessionID, session.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
|
||||||
|
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
|
||||||
|
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
|
||||||
|
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
|
||||||
|
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
|
||||||
|
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
|
||||||
|
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := seedSessionUser(t, repos)
|
||||||
|
|
||||||
|
// 默认上限是 3,这里故意登录 4 台设备。
|
||||||
|
const devices = 4
|
||||||
|
pairs := make([]*TokenPair, 0, devices)
|
||||||
|
for i := 0; i < devices; i++ {
|
||||||
|
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
DeviceID: fmt.Sprintf("device-%02d", i),
|
||||||
|
DeviceName: fmt.Sprintf("Phone %d", i+1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue pair %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
pairs = append(pairs, pair)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pair := range pairs {
|
||||||
|
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load device %d token: %v", i+1, err)
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
t.Fatalf("device %d refresh token missing", i+1)
|
||||||
|
}
|
||||||
|
if row.Revoked {
|
||||||
|
t.Fatalf("device %d refresh token was revoked by another device", i+1)
|
||||||
|
}
|
||||||
|
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
|
||||||
|
t.Fatalf("device %d refresh failed: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Setting{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first := NewServerIdentityService(repos, "MeBox-v1.2.3")
|
||||||
|
id1, err := first.ServerID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id1 == "" {
|
||||||
|
t.Fatal("empty server id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立服务实例(模拟重启)读到同一个持久身份。
|
||||||
|
second := NewServerIdentityService(repos, "MeBox-v1.2.3")
|
||||||
|
id2, err := second.ServerID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id2 != id1 {
|
||||||
|
t.Fatalf("server id changed across instances: %q != %q", id2, id1)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := second.Info(ctx, ServerCapabilities{DeviceSessions: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.ServerID != id1 || info.ServerVersion != "MeBox-v1.2.3" || info.APIVersion != "v1" {
|
||||||
|
t.Fatalf("unexpected info: %+v", info)
|
||||||
|
}
|
||||||
|
if !info.Capabilities.DeviceSessions || info.Capabilities.PlaybackSessions {
|
||||||
|
t.Fatalf("unexpected capabilities: %+v", info.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSessionExpiry(t *testing.T) {
|
||||||
|
sessions, _, repos := newAuthSessionTestEnv(t)
|
||||||
|
user := seedSessionUser(t, repos)
|
||||||
|
sessions.now = func() time.Time { return time.Now().Add(-40 * 24 * time.Hour) }
|
||||||
|
session, err := sessions.Create(context.Background(), CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-old"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sessions.now = time.Now
|
||||||
|
if _, err := sessions.Validate(context.Background(), session.ID, user.ID); !errors.Is(err, ErrAuthSessionExpired) {
|
||||||
|
t.Fatalf("expired validate err = %v, want ErrAuthSessionExpired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*
|
|||||||
return s.tokenSvc.Refresh(ctx, refreshToken)
|
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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user