From 3c30c64a39f03d09ff26bf87fc89a22131ddccfa Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:44:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/reader_repository.go | 29 ++++- .../reader_repository_dialect_test.go | 113 ++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 internal/repository/reader_repository_dialect_test.go diff --git a/internal/repository/reader_repository.go b/internal/repository/reader_repository.go index 963cea7..34d2080 100644 --- a/internal/repository/reader_repository.go +++ b/internal/repository/reader_repository.go @@ -6,6 +6,7 @@ import ( "errors" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/truewhile/MeBox/internal/model" ) @@ -15,6 +16,17 @@ type ReaderRepository struct { db *gorm.DB } +// readerOrderBy 组装方言安全的 ORDER BY。 +// +// 用 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 @@ -153,7 +165,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader // ListBooks 用户书架(按 order 排序)。 func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) { 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 } @@ -231,7 +247,10 @@ func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.Rea // ListChapters 按序取章节。 func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) { 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 } @@ -433,7 +452,11 @@ func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.Reader // ListReplaceRules 用户替换规则(按 order 排序)。 func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) { 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 } diff --git a/internal/repository/reader_repository_dialect_test.go b/internal/repository/reader_repository_dialect_test.go new file mode 100644 index 0000000..46d279d --- /dev/null +++ b/internal/repository/reader_repository_dialect_test.go @@ -0,0 +1,113 @@ +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 让它无需真实数据库也能离线运行, +// 于是可以直接断言生成语句在 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, + }) + 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) + } + } + }) + } +}