fix: preserve database compatibility and harden request lifecycles

This commit is contained in:
jason jia
2026-10-17 01:29:51 +00:00
parent 5e496b35f1
commit 984b98e61b
24 changed files with 687 additions and 202 deletions
+34 -1
View File
@@ -40,6 +40,39 @@ jobs:
- name: 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:
name: Frontend (Node)
runs-on: ubuntu-latest
@@ -78,7 +111,7 @@ jobs:
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
needs: [backend, frontend, database-compatibility, smoke]
if: success()
steps:
- run: echo "CI passed, ready for release"
+9
View File
@@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count
go test ./cmd/server -count=1
```
数据库兼容测试默认覆盖 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`。
+3
View File
@@ -355,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) {
if s == nil {
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) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
+238
View File
@@ -0,0 +1,238 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
t.Helper()
var db *gorm.DB
if dialect == "sqlite" {
cfg := &config.Config{}
cfg.Database.Type = dialect
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.MaxOpenConns = 8
var err error
db, err = Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
} else {
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
}
pgcfg, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal("invalid PostgreSQL test configuration")
}
admin := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = admin.Close() })
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
t.Error(err)
}
})
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
conn := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = conn.Close() })
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
}
db.Logger = logger.Default.LogMode(logger.Silent)
if conn, err := db.DB(); err == nil {
t.Cleanup(func() { _ = conn.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestDatabaseCompatibility(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
t.Run("long_paths_and_restart", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Distinct CJK characters prevent TOAST compression from hiding
// the PostgreSQL btree entry size limit (> 2704 bytes).
var path strings.Builder
path.WriteString("/media/")
for i := range 980 {
path.WriteRune(rune(0x4e00 + i))
}
path.WriteString(".mkv")
media := model.Media{Path: path.String(), Title: "Long path"}
if err := db.Create(&media).Error; err != nil {
t.Fatalf("insert long media path: %v", err)
}
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
t.Fatal("duplicate path accepted")
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("migrate existing database: %v", err)
}
var found model.Media
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
t.Fatalf("lookup long path: %v", err)
}
})
if dialect == "postgres" {
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
db := compatibilityDB(t, dialect)
for _, unique := range []string{"UNIQUE ", ""} {
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := ensureMediaPathIndex(db); err != nil {
t.Fatal(err)
}
}
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil || !found || isUnique || method != "hash" {
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
}
}
})
}
t.Run("search_ranking", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Exercise LIKE fallback in both engines.
if dialect == "sqlite" {
resetMediaSearchIndex(db)
}
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
}
})
t.Run("concurrent_media_upsert", func(t *testing.T) {
db := compatibilityDB(t, dialect)
repo := repository.New(db).Media
const workers = 8
// Synchronize the initial lookups to force the insert race.
var lookups sync.WaitGroup
lookups.Add(workers)
if dialect == "postgres" {
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
lookups.Done()
lookups.Wait()
}
}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
errs := make(chan error, workers)
for range workers {
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
}
for range workers {
if err := <-errs; err != nil {
t.Errorf("concurrent upsert: %v", err)
}
}
var count int64
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("media count=%d, err=%v", count, err)
}
})
t.Run("search_filters", func(t *testing.T) {
db := compatibilityDB(t, dialect)
rows := []model.Media{
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
}
for i := range rows {
rows[i].Title = "Alpha " + rows[i].ID
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
t.Fatal(err)
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
UnwatchedOnly: true, UnwatchedUserID: "viewer",
})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
}
})
t.Run("migration_preserves_zero_values", func(t *testing.T) {
src := compatibilityDB(t, "sqlite")
dst := compatibilityDB(t, dialect)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
t.Fatal(err)
}
if _, _, err := copyModelTables(src, dst, 500); err != nil {
t.Fatal(err)
}
var got model.User
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
})
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package database
import (
"fmt"
"reflect"
"gorm.io/gorm"
)
// InsertPreservingValues copies persisted model rows without applying creation
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
// missing values, which would reactivate disabled accounts during migration or
// restore. Column maps preserve the exact values already stored in the source.
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(rows); err != nil {
return err
}
slice := reflect.Indirect(reflect.ValueOf(rows))
if slice.Kind() != reflect.Slice {
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
}
if len(stmt.Schema.DBNames) == 0 {
return fmt.Errorf("persisted rows have no database columns")
}
values := make([]map[string]any, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
row := make(map[string]any, len(stmt.Schema.DBNames))
for _, name := range stmt.Schema.DBNames {
field := stmt.Schema.FieldsByDBName[name]
if field.Creatable {
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
}
}
values = append(values, row)
}
if len(values) == 0 {
return nil
}
// Bound batches by each engine's bind-parameter limit.
parameterLimit := 65535
if isSQLite(db) {
parameterLimit = 32766
}
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
if batchSize <= 0 || batchSize > maxBatch {
batchSize = maxBatch
}
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
}
+13 -11
View File
@@ -71,8 +71,8 @@ func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) err
// index row size ... exceeds btree version 4 maximum 2704
//
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的
// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
func ensureMediaPathIndex(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable("media") {
return nil
@@ -84,37 +84,39 @@ func ensureMediaPathIndex(db *gorm.DB) 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, found, err := postgresIndexIsUnique(db, "idx_media_path")
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil {
return err
}
if found && unique {
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(path)`).Error
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
}
// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。
func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err 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
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
return false, "", false, err
}
if row.IsUnique == nil {
return false, false, nil
return false, "", false, nil
}
return *row.IsUnique, true, nil
return *row.IsUnique, row.Method, true, nil
}
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
+1 -1
View File
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
if filtered.Len() > 0 {
filteredPtr := reflect.New(filtered.Type())
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)
}
copiedForTable += int64(filtered.Len())
+14 -14
View File
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
if ctx == nil {
ctx = context.Background()
}
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。
if done := ctx.Done(); done != nil {
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-done:
// 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
wake := func() {
g.mu.Lock()
defer g.mu.Unlock()
g.cond.Broadcast()
case <-stop:
}
}()
}
stopCancel := context.AfterFunc(ctx, wake)
defer stopCancel()
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if g.owner == nil {
holder := &sqliteGateHolder{
id: sqliteGateHolderSeq.Add(1),
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.owner = holder
return holder, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
if remaining <= 0 {
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
g.owner = nil
g.cond.Broadcast()
continue
}
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
timer := time.AfterFunc(remaining, wake)
g.cond.Wait()
timer.Stop()
}
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"context"
"errors"
"testing"
"time"
)
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
gate := newSQLiteWriteGate()
holder, err := gate.Lock(ctx)
defer gate.Unlock(holder)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
}
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
// Make the lease expire shortly, without waiting a full minute.
gate.mu.Lock()
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
gate.mu.Unlock()
done := make(chan *sqliteGateHolder, 1)
go func() {
next, _ := gate.Lock(t.Context())
done <- next
}()
select {
case next := <-done:
gate.Unlock(holder) // A stale holder must not unlock its successor.
gate.mu.Lock()
owner := gate.owner
gate.mu.Unlock()
gate.Unlock(next)
if next == nil || owner != next {
t.Fatal("expired holder released the new owner's lock")
}
case <-time.After(time.Second):
gate.Unlock(holder)
gate.Unlock(<-done)
t.Fatal("waiter was not woken when the holder expired")
}
}
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
next, err := gate.Lock(ctx)
gate.Unlock(next)
done <- err
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
case <-time.After(time.Second):
gate.Unlock(holder)
<-done
t.Fatal("waiter was not woken for cancellation")
}
}
+3 -1
View File
@@ -26,7 +26,9 @@ import (
)
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 {
t.Fatal(err)
}
+11 -11
View File
@@ -63,38 +63,38 @@ type MediaQueryFilter struct {
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
if !filter.IncludeNSFW {
q = q.Where("nsfw = ?", false)
q = q.Where("media.nsfw = ?", false)
}
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 {
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
}
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 != "" {
q = q.Where("series_id = ?", seriesID)
q = q.Where("media.series_id = ?", seriesID)
}
if len(filter.Genres) > 0 {
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
}
if filter.YearMin > 0 {
q = q.Where("year >= ?", filter.YearMin)
q = q.Where("media.year >= ?", filter.YearMin)
}
if filter.YearMax > 0 {
q = q.Where("year <= ?", filter.YearMax)
q = q.Where("media.year <= ?", filter.YearMax)
}
if filter.RatingMin > 0 {
q = q.Where("rating >= ?", filter.RatingMin)
q = q.Where("media.rating >= ?", filter.RatingMin)
}
if filter.UnwatchedOnly {
userID := strings.TrimSpace(filter.UnwatchedUserID)
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
if userID != "" {
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,
)
}
@@ -113,7 +113,7 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
func genreMatchClause(genres []string) string {
clauses := make([]string, 0, len(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 ") + ")"
}
@@ -122,7 +122,7 @@ func genreMatchClause(genres []string) string {
//
// 必须与 genreMatchClause 的列端处理完全对称:
// - TRIM → TrimSpace
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ' ', ”) → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
func genreMatchArgs(genres []string) []any {
args := make([]any, 0, len(genres))
+16 -7
View File
@@ -7,6 +7,7 @@ import (
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
@@ -95,6 +96,9 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
return
}
for _, id := range ids {
if id == "" {
continue
@@ -193,14 +197,19 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
if m.ScrapeStatus == "" {
m.ScrapeStatus = "pending"
}
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
return *m, true, false, nil
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return model.Media{}, false, false, createErr
} else {
// 并发插入竞态:重查已命中既有行,直接走更新分支。
return existing, false, false, nil
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
if result.Error != nil {
return model.Media{}, false, false, result.Error
}
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 {
return model.Media{}, false, false, err
+4 -19
View File
@@ -6,6 +6,7 @@ import (
"unicode"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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").
Where("media.deleted_at IS NULL").
Where("media_search_fts MATCH ?", ftsQuery)
q = applyQualifiedMediaQueryFilter(q, filter)
q = applyMediaQueryFilter(q, filter)
if err := q.Count(&total).Error; err != nil {
return nil, 0, false
}
@@ -122,10 +123,10 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
if query != "" {
prefix := escapeLike(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",
exact, exact, prefix, prefix,
))
)})
} else {
q = q.Order("created_at desc")
}
@@ -133,22 +134,6 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
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 {
terms := mediaSearchTerms(query)
if len(terms) == 0 {
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/schema"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
@@ -271,7 +272,7 @@ func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) e
if err != nil {
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
}
if err := tx.Unscoped().CreateInBatches(rows, backupRestoreBatchSize).Error; err != nil {
if err := database.InsertPreservingValues(tx.Unscoped(), rows, backupRestoreBatchSize); err != nil {
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
}
}
@@ -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)
}
}
+2
View File
@@ -49,6 +49,8 @@ func TestSafeZipTargetRejectsTraversal(t *testing.T) {
}
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Chdir(t.TempDir())
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
st := svc.Status(context.Background())
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
+4 -60
View File
@@ -14,14 +14,12 @@ package service
import (
"context"
"errors"
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"go.uber.org/zap"
@@ -103,68 +101,14 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// from image.tmdb.org via their HTTP proxy without extra config. On
// Windows we also honor the current user's system proxy settings.
transport := NewExternalTransport()
if proxyConfiguredForImageFetch() {
// 走本地代理(如 127.0.0.1:7890)时,拨号目标是代理本身,
// 连接层 SSRF 校验会误杀本地回环代理;此时沿用 URL 级校验。
log.Info("image proxy: outbound proxy detected, connection-level SSRF guard disabled")
} else {
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
// 校验(含重定向后的每条连接)堵住该旁路。
//
// 用户明确配置的远程挂载源(如内网 Emby)与显式配置的服务(如
// MetaTube)豁免该私网限制。豁免必须在 DialContext 这一层判断:
// Control 回调收到的 address 已经是**解析后的 IP**,而放行名单里
// 存的是主机名(mebox-metatube-1、NAS 名),在 Control 里比较永远
// 不相等——那段分支对主机名条目是死代码,现场表现就是「名单里有
// 这个主机,回源依然被拒」。
guardDialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return errors.New("image proxy: refusing non-IP dial target")
}
if isPrivateIP(ip) {
return errors.New("image proxy: requests to private/internal hosts are not allowed")
}
return nil
},
}
// 放行目标的专用拨号器:不再做私网校验,但仍然只连到请求里的那个
// 主机(豁免的是私网限制,不是「任意目标」)。
allowlistedDialer := &net.Dialer{Timeout: 15 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if proxy.isAllowedRemoteHost(host) {
return allowlistedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}
proxy.guardTransport(transport)
directTransport := NewInternalTransport()
proxy.guardTransport(directTransport)
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: NewInternalTransport()}
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: directTransport}
return proxy
}
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
func proxyConfiguredForImageFetch() bool {
req, err := http.NewRequest(http.MethodGet, "https://image.tmdb.org/", nil)
if err != nil {
return false
}
proxy, err := ProxyFromEnvironmentOrSystem(req)
return err == nil && proxy != nil
}
// SetLibraryRootsProvider injects a callback that returns the current set of
// media library root directories. Sidecar posters live under these roots
// (which are arbitrary, user-defined, and not necessarily under the
@@ -76,9 +76,37 @@ func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T)
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
_, port := hostAndPortOf(t, upstream.URL)
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
for _, client := range []*http.Client{proxy.client, proxy.directClient} {
resp, err := client.Get("http://localhost:" + port + "/poster.jpg")
if err == nil {
resp.Body.Close()
t.Fatal("a non-allowlisted hostname resolving to a private IP must be refused")
}
}
}
func TestImageProxyAllowsSelectedLocalProxy(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
proxyURL, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
p := NewImageProxy(&config.Config{}, zap.NewNop())
transport := NewExternalTransport()
transport.Proxy = http.ProxyURL(proxyURL)
p.guardTransport(transport)
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport}
resp, err := client.Get("http://images.example.com/poster.jpg")
if err != nil {
t.Fatalf("configured local proxy must remain usable: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
}
+3 -1
View File
@@ -32,7 +32,9 @@ func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClie
}
direct := p.directClient
if direct == nil {
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
transport := NewInternalTransport()
p.guardTransport(transport)
direct = &http.Client{Timeout: timeout, Transport: transport}
}
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
+67
View File
@@ -0,0 +1,67 @@
package service
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strings"
"sync"
"syscall"
"time"
)
// guardTransport checks resolved destination addresses even when a deployment
// has an outbound proxy. Only actual proxy endpoints and explicitly configured
// image services may use private addresses; NO_PROXY and direct fallback
// requests still need the connection-level check against DNS rebinding.
func (p *ImageProxy) guardTransport(transport *http.Transport) {
var proxyEndpoints sync.Map
if selectProxy := transport.Proxy; selectProxy != nil {
transport.Proxy = func(req *http.Request) (*url.URL, error) {
proxyURL, err := selectProxy(req)
if err == nil && proxyURL != nil {
port := proxyURL.Port()
if port == "" {
switch strings.ToLower(proxyURL.Scheme) {
case "https":
port = "443"
case "socks5", "socks5h":
port = "1080"
default:
port = "80"
}
}
proxyEndpoints.Store(net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port), true)
}
return proxyURL, err
}
}
guardDialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || isPrivateIP(ip) {
return errors.New("image proxy: requests to private/internal hosts are not allowed")
}
return nil
},
}
trustedDialer := &net.Dialer{Timeout: 15 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
_, isProxyEndpoint := proxyEndpoints.Load(strings.ToLower(address))
if isProxyEndpoint || p.isAllowedRemoteHost(host) {
return trustedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}
@@ -13,6 +13,8 @@ import (
// be false both when transcoding is disabled by configuration and when no usable
// ffmpeg can be resolved.
func TestTranscoderAvailableGatesOnConfigAndBinary(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Chdir(t.TempDir())
cases := []struct {
name string
enabled bool
@@ -35,7 +35,6 @@ export default function ReaderReplacePage() {
.then(setRules)
.catch(() => toast.error('加载替换规则失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
const save = async () => {
@@ -49,7 +49,6 @@ export default function ReaderSourcesPage() {
.then(setSources)
.catch(() => toast.error('加载书源失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
+63 -66
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
@@ -266,6 +266,19 @@ function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride
})
}
function ReaderMenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{icon}
<span className="text-2xs">{label}</span>
</button>
)
}
export default function ReaderViewPage() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
@@ -316,6 +329,7 @@ export default function ReaderViewPage() {
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
const [scrollProgress, setScrollProgress] = useState(0)
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
@@ -338,8 +352,13 @@ export default function ReaderViewPage() {
const paragraphWarmRef = useRef<Uint8Array | null>(null)
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
const warmCenterRef = useRef(-1)
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
const chapterTitle = currentChapter?.title ?? ''
useLayoutEffect(() => {
vwRef.current = vw
pageCountRef.current = pageCount
chapterTitleRef.current = chapterTitle
}, [vw, pageCount, chapterTitle])
// 音频/漫画媒体状态
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
@@ -448,6 +467,7 @@ export default function ReaderViewPage() {
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
const cache = contentCache.current
;(async () => {
try {
setLoadingStage('book')
@@ -510,7 +530,7 @@ export default function ReaderViewPage() {
})()
return () => {
cancelled = true
contentCache.current.clear()
cache.clear()
}
}, [bookId, reloadKey])
@@ -910,6 +930,16 @@ export default function ReaderViewPage() {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// Only refresh the scroll slider while its menu is visible, keeping scroll
// events on the reading path free of whole-page React renders.
useLayoutEffect(() => {
if (!menuOpen || settings.pageMode !== 'scroll') return
const el = scrollRef.current
if (el) {
setScrollProgress(Math.round((el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight)) * 1000))
}
}, [menuOpen, settings.pageMode, content])
// 滚动模式恢复进度。
//
// 同时清掉 dataset.last:它是「上次保存的滚动百分比」,换章后必须重算。
@@ -1355,9 +1385,6 @@ export default function ReaderViewPage() {
return () => el.removeEventListener('wheel', onWheel)
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
chapterTitleRef.current = currentChapter?.title ?? ''
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
const menuShiftStyle = useMemo(
@@ -1397,11 +1424,7 @@ export default function ReaderViewPage() {
value:
settings.pageMode === 'page'
? page + 1
: Math.round(
((scrollRef.current?.scrollTop ?? 0) /
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
1000,
),
: scrollProgress,
onChange: (v: number) => {
if (settings.pageMode === 'page') {
commitTextPage(v - 1)
@@ -1569,6 +1592,7 @@ export default function ReaderViewPage() {
const max = el.scrollHeight - el.clientHeight
if (max > 0) {
const pct = el.scrollTop / max
if (menuOpen) setScrollProgress(Math.round(pct * 1000))
if (shouldSaveScrollProgress(pct, el.dataset.last)) {
el.dataset.last = formatScrollPct(pct)
savePos(Math.round(el.scrollTop))
@@ -1753,62 +1777,35 @@ export default function ReaderViewPage() {
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
<div className="flex pt-1" style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
{
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
label: settings.night ? '日间' : '夜间',
action: () => settings.toggleNight(),
},
{
icon: settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />,
label: settings.pageMode === 'page' ? '滚动' : '翻页',
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
},
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
? [
{
icon: <Columns2 size={18} />,
label: settings.comicDoublePage ? '单页' : '双页',
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
},
]
: []),
// 全屏:只在浏览器支持元素全屏时出现(iOS Safari 不支持,见上方注释)。
...(fullscreenSupported
? [
{
icon: fullscreen ? <Minimize size={18} /> : <Maximize size={18} />,
label: fullscreen ? '还原' : '全屏',
action: toggleFullscreen,
},
]
: []),
...(canSwitchSource
? [
{
icon: <ArrowLeftRight size={18} />,
label: '换源',
action: () => {
void openSourcePicker()
},
},
]
: []),
]).map((item) => (
<button
key={item.label}
type="button"
onClick={item.action}
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{item.icon}
<span className="text-2xs">{item.label}</span>
</button>
))}
<ReaderMenuButton icon={<LayoutList size={18} />} label="目录" onClick={() => openPanel('toc')} />
<ReaderMenuButton icon={<BookOpen size={18} />} label="界面" onClick={() => openPanel('style')} />
<ReaderMenuButton
icon={settings.night ? <Sun size={18} /> : <Moon size={18} />}
label={settings.night ? '日间' : '夜间'}
onClick={() => settings.toggleNight()}
/>
<ReaderMenuButton
icon={settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />}
label={settings.pageMode === 'page' ? '滚动' : '翻页'}
onClick={() => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page')}
/>
{contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH && (
<ReaderMenuButton
icon={<Columns2 size={18} />}
label={settings.comicDoublePage ? '单页' : '双页'}
onClick={() => settings.setComicDoublePage(!settings.comicDoublePage)}
/>
)}
{fullscreenSupported && (
<ReaderMenuButton
icon={fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
label={fullscreen ? '还原' : '全屏'}
onClick={toggleFullscreen}
/>
)}
{canSwitchSource && (
<ReaderMenuButton icon={<ArrowLeftRight size={18} />} label="换源" onClick={() => { void openSourcePicker() }} />
)}
</div>
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}