优化兼容性

This commit is contained in:
truewhile
2026-10-11 23:44:11 +08:00
parent 3c30c64a39
commit 079b3ff063
23 changed files with 1246 additions and 142 deletions
+21
View File
@@ -0,0 +1,21 @@
package repository
import "gorm.io/gorm"
// LikeOperator 返回当前方言下「与 SQLite LIKE 行为一致」的模式匹配运算符。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写(默认编译选项),PostgreSQL 的 LIKE 区分
// 大小写、只有 ILIKE 才不区分。同一个搜索词在两种方言下必须给出同样的结果集,
// 否则换库之后「inception」就再也搜不到「Inception」。
//
// 供构造原始 SQL 片段的调用方(含 service 层)使用。
func LikeOperator(db *gorm.DB) string {
return likeOperator(db)
}
func likeOperator(db *gorm.DB) string {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" {
return "ILIKE"
}
return "LIKE"
}
+54
View File
@@ -0,0 +1,54 @@
package repository
import (
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// TestSegmentPrewarmQueryBindsBooleanFound 守住 PostgreSQL 下片段预取查询的合法性。
//
// media_segment_fetches.found 是布尔列,而 PostgreSQL 没有 boolean = integer
// 运算符:写成 f.found = 0 / = 1 会让计划任务里的片段预取固定报
//
// operator does not exist: boolean = integer (SQLSTATE 42883)
//
// SQLite 一直能跑,所以这个错只在 PostgreSQL 上暴露(表现为日志里周期性出现
// 该错误、片头片尾数据永远补不上)。这里断言语句里只能出现占位符绑定。
func TestSegmentPrewarmQueryBindsBooleanFound(t *testing.T) {
query := segmentPrewarmCandidatesQuery
lower := strings.ToLower(query)
for _, forbidden := range []string{"found = 0", "found = 1", "found=0", "found=1", "found = true", "found = false"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("预取查询把布尔列与字面量比较(%q),PostgreSQL 会直接报错:\n%s", forbidden, query)
}
}
if got := strings.Count(query, "f.found = ?"); got != 2 {
t.Fatalf("expected 2 placeholder-bound found comparisons, got %d:\n%s", got, query)
}
}
// TestLikeOperatorMatchesSQLiteCaseInsensitivity 守住搜索大小写行为的方言差异。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写,PostgreSQL 的 LIKE 区分大小写:同一个
// 搜索词在两种方言下必须给出同样的结果集,PostgreSQL 必须走 ILIKE。
func TestLikeOperatorMatchesSQLiteCaseInsensitivity(t *testing.T) {
pg, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
if got := LikeOperator(pg); got != "ILIKE" {
t.Fatalf("postgres LikeOperator = %q, want ILIKE", got)
}
if got := LikeOperator(nil); got != "LIKE" {
t.Fatalf("nil LikeOperator = %q, want LIKE", got)
}
}
+17 -3
View File
@@ -33,6 +33,20 @@ func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.Playbac
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 {
updates := playbackHistoryAssignments(h)
if versioned {
@@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Column: historyColumn("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)
}
}
@@ -106,11 +106,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
q := r.db.WithContext(ctx).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter)
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
// 需用 ILIKE 保持两端搜索行为一致。
likeOp := "LIKE"
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
likeOp = "ILIKE"
}
// 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。
likeOp := likeOperator(r.db)
terms := mediaSearchTerms(query)
for _, term := range terms {
like := "%" + escapeLike(term) + "%"
+30 -18
View File
@@ -86,21 +86,15 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// 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 是片段预取候选查询。
//
// 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下
// GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。
//
// 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1:
// PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883
// (SQLite 宽容,所以只在 PostgreSQL 上暴露)。
const segmentPrewarmCandidatesQuery = `
SELECT m.*
FROM media m
LEFT JOIN media_segment_fetches f
@@ -119,11 +113,29 @@ WHERE m.deleted_at IS NULL
)
AND (
f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
)
ORDER BY ph.last_played DESC
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
}
+9 -1
View File
@@ -384,9 +384,17 @@ func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []st
}
// 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) {
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 &out, nil
@@ -38,16 +38,19 @@ func (l *sqlCaptureLogger) all() string {
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
// 于是可以直接断言生成语句在 PostgreSQL 下是否合法。
// 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,
Logger: rec,
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
Logger: rec,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)