Skip to content

feat(db): add PostgreSQL dual-database support#490

Merged
hrygo merged 26 commits into
mainfrom
feat/dual-db-support
May 27, 2026
Merged

feat(db): add PostgreSQL dual-database support#490
hrygo merged 26 commits into
mainfrom
feat/dual-db-support

Conversation

@hrygo
Copy link
Copy Markdown
Owner

@hrygo hrygo commented May 26, 2026

Summary

  • Add PostgreSQL as an alternative database backend alongside SQLite, controlled by db.driver config (sqlite | postgres)
  • Introduce dbutil.Dialect abstraction layer with Rebind() (placeholder conversion), BoolValue(), and dialect-aware store factories
  • Implement PG-backed stores for session, event, cron, chat_access, and admin API key modules
  • Add dedicated PG migration files (11 migrations) with proper types (BIGSERIAL, BOOLEAN, NUMERIC)

Bug fixes (code review findings)

  • Fix nil interface trap in akStore constructors — return interface type instead of concrete pointer to prevent typed-nil panic
  • Eliminate double PG connection — NewPGStore now accepts shared *dbutil.DB instead of opening its own
  • Wire PG admin store through DI — export NewAPIKeyUserPGStore, pass via GatewayDeps.APIKeyStore
  • Fix openPostgres DSN source — use cfg.DSN() instead of cfg.Path, honor Postgres.MaxOpenConns config
  • Fix turns table success column type from INTEGER to BOOLEAN

Test plan

  • go build ./cmd/hotplex/ — compiles cleanly
  • go vet ./... — no issues
  • go test -short ./... — all tests pass
  • go test -race ./internal/dbutil/... ./internal/sqlutil/... — no data races
  • golangci-lint run — 0 issues
  • Manual: configure db.driver: postgres + db.postgres.dsn and verify gateway starts
  • Manual: run admin API key CRUD operations against PG backend
  • Manual: verify session lifecycle (create → running → idle → terminated) with PG

Refs #483

…bstraction

Add opt-in PostgreSQL backend while preserving SQLite as the default.
A thin dbutil.Dialect layer (5 methods, 120-line Rebind state machine)
isolates all SQL dialect differences — no ORM, no existing interface changes.

Architecture:
- internal/dbutil/ — Dialect type + Rebind($1..$N) state machine + DB wrapper
- DBConfig split into Driver + SQLiteConfig + PostgresConfig sub-structs
- sqlutil.WriteMu becomes no-op on PG (MVCC handles concurrency natively)
- 9 PG migration files in migrations-postgres/ alongside SQLite originals
- 5 PG Store implementations (session/cron/eventstore/chat_access/api_key)
- gateway_run.go branches on db.driver: "sqlite" | "postgres"

Key design decisions:
- Dialect is a string constant type, not interface
- Rebind uses 6-state automaton handling string literals, quotes, $$, comments
- Existing Store interfaces zero-change
- Phase 0 extracted 3 missing interfaces: ChatAccessStorer, APIKeyUserStorer, DBExecutor

Stats: 46 files, +2103/-71, go build clean, 33/34 test suites pass

Closes: #487
- Create AGENTS.md for internal/cron/ (timer engine, 3 schedule types,
  YAML import, backoff retry, attached session dispatch)
- Create AGENTS.md for internal/dbutil/ (WriteMu serialization, PRAGMA
  tuning, dialect abstraction, rebind)
- Add missing bot_registry.go and config.go to messaging/AGENTS.md
- Remove line counts from STRUCTURE sections across all subdirectory
  AGENTS.md files to prevent staleness (7 files affected)
- Fix nil interface trap in akStore constructors (typed nil pointer
  stored in interface caused panic in nil checks)
- Eliminate double PG connection in NewPGStore (accept shared *dbutil.DB
  instead of opening its own)
- Wire PG admin store through DI (export NewAPIKeyUserPGStore, pass via
  GatewayDeps/APIKeyStore)
- Fix openPostgres DSN source (use cfg.DSN() instead of cfg.Path) and
  honor Postgres.MaxOpenConns config
- Fix turns table success column type from INTEGER to BOOLEAN
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai

代码质量: WARN | 非功能性: FAIL | 集成: FAIL

结论: Request Changes — 存在 2 个 ship-blocking 缺陷。


必须修复 (FAIL)

1. 缺少 pgx 驱动依赖 — PostgreSQL 功能完全不可用

go.mod / go.sum 未添加 github.com/jackc/pgx/v5/stdlibdbutil/db.go:72 调用 sql.Open("pgx", dsn) 在运行时会 panic: unknown driver "pgx"sqlutil/driver.go:8 注释声明需要 blank import 但未实际添加。

修复:

go get github.com/jackc/pgx/v5/stdlib

并在 cmd/hotplex/main.gointernal/sqlutil/driver.go 添加:

import _ "github.com/jackc/pgx/v5/stdlib"

2. PG 连接双重关闭

session/pg_store.go:84-86PGStore.Close() 调用 s.db.Close(),而 gatewayStores.close()cmd/hotplex/gateway_run.go:672-680 随后又调用 s.db.Close()。PGStore 不拥有连接,不应关闭它。

修复: PGStore.Close() 应返回 nil(与 EventStore 一致),连接生命周期由 gatewayStores.db 独占管理。


建议修复 (WARN)

代码质量 (9 WARN)

  1. 系统级 DRY 违规 — 5 个 PG store 是对应 SQLite store 的结构性副本。eventstore/pg_store.go 整个 QueryBySession 分页 + scanTurnsPG 复制;chat_access_pg_store.goClassify() 100% 重复;admin/apikey_pg_store.go 6 个方法中 5 个复制。中期应提取 dialect-aware 基础类型或 generic store builder。
  2. 测试风格不一致dbutil/rebind_test.got.Errorf 而非 testify/require(违反项目规范);db_test.go 缺少 t.Parallel()
  3. dialect.go:54 死代码var _ = (*sql.DB)(nil) 无用途,应删除。
  4. migrate.go 双 switch 冗余 — 两个 switch 分别设置 gooseDialect/embedFSsubDir,可合并为单个 switch。
  5. Legacy 配置字段无归一化DBConfig 新增 SQLiteConfig 但无 normalizeDBConfig() 将 legacy flat fields 传播到新结构化字段。

非功能性 (5 WARN)

  1. PG 连接池未设 ConnMaxLifetimeopenPostgres 设置 MaxOpenConns/MaxIdleConns 但无连接生命周期限制。PG 场景建议设置 5-15 min。
  2. 无连接验证 (Ping)openPostgres 未调用 PingContext 验证 DSN 可达性,配置错误直到首次查询才暴露。
  3. nil invalidator 破坏缓存gateway_run.go:663 NewAPIKeyUserPGStore(db, nil) 传入 nil invalidator,admin API key CRUD 后 DBResolver 缓存不会失效。
  4. 默认 DSN sslmode=disable — 开发可接受,但应在生产使用时 log warning。
  5. apikey_pg_store.go:65-66get() 返回未包装的 error,与其他方法不一致。

集成 (6 WARN)

  1. CLAUDE.md 未更新 — 仍声明 PG 为"桩",需更新为 dual-database 支持,补充 dbutil 模块和 db.driver 配置。
  2. 5 个 PG store 零测试覆盖session/pg_store.goeventstore/pg_store.gocron/pg_store.gochat_access_pg_store.goapikey_pg_store.go 均无测试文件。
  3. Dialect 常量双定义sqlutildbutil 各自定义 DialectSQLite/DialectPostgres,无编译期断言保证同步。
  4. nil invalidator 详见非功能性第 5 点

🤖 Reviewed by hotplex-ai via automated PR review pipeline

F1 - Prevent double-close in gatewayStores: make PGStore.Close() a no-op
     (gatewayStores.close() already handles s.db.Close())

F2 - Fix Validate() for PG-only configs: guard db.path check with
     driver=sqlite gate, check both legacy Path and SQLite.Path

F3 - Fix BeginTx context cancellation: remove defer cancel() that
     violated database/sql contract for PG eventstore transactions

F4 - Fix SQLite init: use cfg.SQLite.Path with cfg.Path fallback
     in dbutil/openSQLite() and session/stores.go

F5 - Fix CLI OpenStore: branch on db.driver to support PostgreSQL
     cron commands (client.go, cron_cmd.go, cron_history.go)

F6 - Fix migration 010: replace DROP TABLE IF EXISTS with
     CREATE TABLE IF NOT EXISTS pattern

Also: add jackc/pgx/v5 stdlib import to sqlutil/driver.go
      update AGENTS.md PostgreSQL status line
      update config tests for structured DBConfig validation
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai

代码质量: WARN | 非功能性: FAIL | 集成: FAIL

结论: Request Changes — 存在 2 个 ship-blocking 缺陷。


必须修复 (FAIL)

1. PG Admin API Key Store 传入 nil cache invalidator — 缓存一致性 Bug

cmd/hotplex/gateway_run.goinitPGStores 调用 NewAPIKeyUserPGStore(db, nil),invalidator 参数为 nil。而 SQLite 路径正确传递了 deps.DBResolver

// PG path (broken)
apiKeyStore: admin.NewAPIKeyUserPGStore(db, nil),

// SQLite path (correct)
akStore: newAPIKeyUserStoreWithInvalidator(deps.DB, deps.DBResolver)

影响: PG 路径下 Admin API key CRUD 后,security.DBResolver 缓存无法失效,用户将持续解析到过时的身份映射,直接影响 RBAC 正确性。

修复: 将 dbResolver 传入 NewAPIKeyUserPGStore(db, dbResolver)。需重构 initPGStores 使其接受或注入 dbResolver

2. apikey_pg_store.go create() 硬编码 $1, $2, $3 绕过 Rebind 抽象

internal/admin/apikey_pg_store.gocreate 方法直接使用 PG 原生占位符:

query := "INSERT INTO api_key_users (api_key, user_id, description) VALUES ($1, $2, $3) RETURNING id"

同文件其他方法(get, update, delete)均正确使用 s.dialect.Rebind()。此处绕过了 Dialect 抽象,破坏了已建立的模式一致性。

修复: 改为 s.dialect.Rebind("INSERT INTO api_key_users (api_key, user_id, description) VALUES (?, ?, ?) RETURNING id")


建议修复 (WARN)

代码质量 (13 WARN)

  1. PG store 命名不一致PGStore(session) / pgStore(cron) / pgEventStore(eventstore) / ChatAccessPGStore(messaging) / apiKeyUserPGStore(admin),应统一命名规范
  2. DRY 违规 — PG open 逻辑重复dbutil/db.gosqlutil/open.go 各自实现 PG 连接打开逻辑
  3. DRY 违规 — query rebound 模式重复session/pg_store.goeventstore/pg_store.go 独立实现相同的 rebound 缓存逻辑,应提取为 dbutil.RebindAll() helper
  4. Dialect 常量双定义sqlutil/writemu.godbutil/dialect.go 各自定义 DialectSQLite/DialectPostgres,无编译期同步保证
  5. gatewayStores struct 膨胀 — 11 个字段,接近 god struct,建议分组
  6. session/pg_store.go GetExpiredMaxLifetime/GetExpiredIdle 未包装错误 — 返回 raw error 而非 fmt.Errorf("...: %w", err)
  7. apikey_pg_store.go get() scan 错误未包装 — 与同文件 list() 的包装模式不一致
  8. rebind_test.go 使用 t.Errorf 而非 testify/require — 违反项目测试规范
  9. db_test.go / rebind_test.go 缺少 t.Parallel() — 违反项目测试规范
  10. initStores default case 静默回退 SQLite — 无效 driver 值(如 "mysql")静默回退,应 log warning
  11. 5 个 PG store 零测试覆盖 — 约 1020 行 PG 代码无任何测试
  12. PG store NewPGStore 返回类型不一致 — 部分 export、部分 unexport,外部可测试性受限
  13. rebind$N 碰撞边界 — 已在测试中记录但未在代码注释中标注为已知限制

非功能性 (7 WARN)

  1. PG 连接池缺少 ConnMaxLifetime/ConnMaxIdleTime — 长连接导致 stale connection 和内存泄漏
  2. openPostgres 无 Ping 验证 — 错误 DSN 延迟到首次查询才暴露
  3. 默认 DSN sslmode=disable — 远程部署有凭据泄露风险(OWASP A02:2021)
  4. DSN 含明文凭据PostgresConfig.ConnStr 存储完整 DSN 含密码,日志/转储可能泄露
  5. dbutil.DB 嵌入 *sql.DB — 与项目"显式字段"偏好不完全一致(非 mutex,影响低)
  6. eventstore PG QueryBySession CursorAfter 额外查询has_older 产生不必要的数据库往返
  7. eventstore pgEventTx sql map 引用正确性 — tx 与 store 共享 rebound cache,当前正确但脆弱

集成 (7 WARN)

  1. CLAUDE.md 未更新 — 仍声明 PG 为"桩",需更新 dbutil/ 模块描述和 PG store 文件
  2. config.Validate() 缺少 PG DSN 校验db.driver=="postgres" 时未校验 DSN 是否已设置,静默回退 localhost
  3. PG 迁移编号跳跃 — 缺少 003、008,未注释说明原因
  4. sqlutil OpenDB/NewWriteMu 签名变更 — 新增 dialect 参数(internal 包,可接受)
  5. pgx/v5 依赖 — 版本 v5.9.2 合理,无问题
  6. eventStoreProvider local interface — 仅 gateway_run.go 使用,当前可接受
  7. rebind_test.go 自定义 errStr type — 可用 fmt.Errorf 替代

🤖 Reviewed by hotplex-ai via automated PR review pipeline

Fix 2 ship-blocking issues from hotplex-ai review:

1. nil cache invalidator — APIKeyUserPGStore now exported and accepts
   dbResolver via SetInvalidator(); gateway_run.go wires it after init
2. apikey_pg_store create() hardcoded $N — uses dialect.Rebind() now

Plus 3 WARN fixes:
- Remove dead code var _ = (*sql.DB)(nil) from dialect.go
- Wrap error in apikey_pg_store get() with context message
- Export apiKeyUserPGStore → APIKeyUserPGStore
- Use testify/require in rebind_test.go (was t.Errorf) — wraps table-driven
  tests in t.Run + require.Equal/require.True
- Add t.Parallel() to all db_test.go test functions
- Add TestDialectConstantsSync — compile-time check that dbutil and
  sqlutil dialect constants match
- Update session/AGENTS.md: pgstore.go stub → pg_store.go full PG impl
- Merge dual switch in migrate.go into single switch
- Add ConnMaxLifetime(5min) + PingContext validation on PG pool open
- Log warning when using default DSN (sslmode=disable)
- Remove dead code var _ = (*sql.DB)(nil) from dialect.go
hotplex-ai
hotplex-ai previously approved these changes May 26, 2026
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai (re-review after commit d42d2cf)

代码质量: WARN | 非功能性: WARN | 集成: WARN

结论: Approve — 前版 2 个 FAIL(nil invalidator、hardcoded $1$2$3)已修复。以下 WARN 项供参考。


前版 FAIL 修复确认 ✅

  1. nil invalidatorinitPGStores 已通过 DI 正确注入 dbResolver,与 SQLite 路径一致
  2. hardcoded $1$2$3apikey_pg_store.go create() 已改用 s.dialect.Rebind()

代码质量 (12 WARN)

  1. sslmode=disable 硬编码默认 DSNconfig.go + dbutil/db.go。开发便利可接受,建议对非 localhost DSN 添加 log.Warn 或改为空字符串 + 验证报错
  2. ChatAccessPGStore.Classify() 与 SQLite 版高度重复 — cooldown 逻辑 100% 复制,可提取 shared helper
  3. cron/pg_store.gostore.go 同构 — PG dual-store 固有限制,当前可接受的权衡
  4. SQLite path fallback 3 处重复 — 可在 DBConfig 添加 EffectiveSQLitePath() 方法
  5. sqlutil.Dialect*dbutil.Dialect* 重复定义 — 添加交叉引用注释防止漂移
  6. apikey_pg_store.go update/delete 缺少哨兵错误ErrAPIKeyUserNotFound
  7. dbutil.DB 嵌入 *sql.DB — 项目倾向显式字段,建议 db *sql.DB + 委托方法
  8. migrate.go 两个 switch 可合并
  9. stores.sqlDBstores.db 轻微冗余 — DBResolver 需要 *sql.DB,可接受
  10. openSQLiteDB 跨包同名sqlutilsession 各有一个
  11. PG migration 跳号 003/008 无注释说明
  12. rebind_test.go 缺少 t.Parallel()

非功能性 (3 WARN)

  1. PG 连接池缺 ConnMaxLifetime/ConnMaxIdleTimeopenPostgres 仅设 MaxOpen/MaxIdle,PG 重启后 stale connection 堆积。建议默认 5min
  2. MaxIdleConns 硬编码 5 — 与 MaxOpenConns 默认 25 不匹配,高负载下频繁重建连接
  3. SetInvalidator 字段写无同步 — 当前初始化顺序安全但脆弱,sync.Once 更健壮

集成 (4 WARN)

  1. CLAUDE.md 项目结构表未更新 — 关键"重要限制"段已修正(PG stub → implemented),但结构表仍缺 dbutil/、5 个 PG store 文件、sqlutil/ PG 描述
  2. 5 个 PG store 零测试 — 约 1020 行 PG 代码无覆盖,PR 手动测试项均未完成
  3. openPostgres 缺少 Ping 验证 — DSN 配错延迟到首次查询才暴露
  4. 3 个新导出接口无文档APIKeyUserStorer/DBExecutor/ChatAccessStorer 应在 AGENTS.md 记录

🤖 Reviewed by hotplex-ai via automated PR review pipeline

NewClaudeCodeExtractor() and NewOpenCodeExtractor() always allocate
and return non-nil pointers. The nil checks triggered SA5011 false
positives in staticcheck.

Remove the dead nil-check branch and unused extractor variable.
hotplex-ai
hotplex-ai previously approved these changes May 26, 2026
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai(第 3 轮)

代码质量: WARN | 非功能性: WARN | 集成防腐: WARN

结论: Approve — 前 2 轮 ship-blocking FAIL 均已修复。剩余 WARN 项供参考。


已修复的 FAIL(前轮)

  1. PG Admin API Key Store nil invalidator — 通过 SetInvalidator(dbResolver) 在 handler 启动前补设
  2. apikey_pg_store.go create() 硬编码占位符 — 已改用 s.dialect.Rebind() + RETURNING id

已修复的 WARN

  1. rebind_test.go t.Errorf → testify/require — 全部改用 require.Equal
  2. apikey_pg_store.go get() 错误未包装 — 已添加 fmt.Errorf("admin: get api key user: %w", err)
  3. openPostgres 无 Ping 验证 — 已添加 PingContext 校验
  4. CLAUDE.md 更新 — AGENTS.md 已更新 PG 描述(CLAUDE.md 为符号链接)

代码质量 WARN(6 项)

# 问题 位置
W1 PG store 命名不一致:PGStore/pgStore/pgEventStore/ChatAccessPGStore/APIKeyUserPGStore 3 种风格 5 个 PG store 文件
W2 session/pg_store.go GetExpiredMaxLifetime/GetExpiredIdle 返回 raw error 未包装 pg_store.go:2957,2967
W3 rebind_test.go 9 个测试函数缺少 t.Parallel() rebind_test.go
W4 DRY: legacy path fallback 逻辑在 dbutil/db.go 和 session/stores.go 重复 两处 openSQLite
W5 DRY: Dialect 常量双定义(sqlutil vs dbutil),虽有 TestDialectConstantsSync 保护 writemu.go, dialect.go
W6 PG store 仍是 SQLite store 的结构性副本(接口提取后耦合降低,可接受) 5 个 PG store

非功能性 WARN(7 项)

# 问题 位置 风险
W7 ConnMaxLifetime 已设 5min,但 ConnMaxIdleTime 未设置 dbutil/db.go:openPostgres
W8 默认 DSN 仍 sslmode=disable,config 注释示例也用 disable config.go, dbutil/db.go
W9 DSN ConnStr 字段含明文凭据,无 .pgpass/PGPASSWORD 文档引导 config.go
W10 eventstore PG CursorAfter 额外 has_older 查询,未统一 fetchLimit+1 模式 eventstore/pg_store.go
W11 SQLite store apiKeyUserStore 缺 dialect 参数保护(硬编码 ? apikey_handlers.go:91-117
W12 SetInvalidator 无并发保护(当前启动时序安全,但热重载场景有风险) apikey_pg_store.go
W13 pgEventTx.sql 引用 pgEventStore.sql map(只读安全但设计脆弱) eventstore/pg_store.go

集成防腐 WARN(6 项)

# 问题 位置
W14 5 个 PG store 零测试覆盖(~1283 行无测试,spec P4.4 要求 PG testcontainer) 全部 pg_store.go
W15 docs/ 目录零变更 — configuration.md 未添加 db.driver/db.postgres.* 配置文档 docs/
W16 config.Validate() 对 driver=="postgres" 无任何校验(空 DSN 静默回退) config.go
W17 pgx/v5 go.mod 标记 // indirect 但 sqlutil/driver.go 直接导入,go mod tidy 会产生 diff go.mod
W18 NewAPIKeyUserPGStore 返回具体类型而非接口,typed-nil 陷阱未完全消除 apikey_pg_store.go
W19 PG 迁移编号跳跃(缺 003、008),cron/AGENTS.md 错误标注为 "stub" migrations/, cron/AGENTS.md

🤖 Reviewed by hotplex-ai via automated PR review pipeline

W2 - Wrap errors in session PGStore GetExpiredMaxLifetime/GetExpiredIdle
     with fmt.Errorf (was raw err, inconsistent with DeleteTerminated)

W3 - Add t.Parallel() to all 9 test functions in rebind_test.go
     (pure string functions, safe to parallelize)

W4 - Extract DBConfig.EffectiveSQLitePath() to eliminate legacy path
     fallback duplication in dbutil/db.go + session/stores.go

W7 - Set ConnMaxIdleTime(5min) in openPostgres alongside
     ConnMaxLifetime (was infinite → stale connections on PG restart)

W12 - Add sync.Once to APIKeyUserPGStore.SetInvalidator()
     (prevents data race on hot-reload)
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai (更新审查)

代码质量: WARN | 非功能性: WARN | 集成: WARN

结论: Request Changes — 存在 2 个需修复项。


必须修复

1. 默认 PG DSN 硬编码 sslmode=disable — 生产安全风险

internal/dbutil/db.goPostgresConfig.DSN()openPostgres 双重定义了默认值 "postgres://localhost:5432/hotplex?sslmode=disable"。用户配置 driver: "postgres" 但未设 DSN 时,系统以 sslmode=disable 启动,所有会话数据、API 密钥、用户信息通过未加密连接传输。

修复:

  • 将默认 DSN 改为 sslmode=prefersslmode=require
  • 或在 Validate() 中当 driver=="postgres" 且 DSN 为空时返回错误,而非静默回退
  • 同时消除双重默认值定义,openPostgres 应完全依赖 cfg.DSN()

2. CLAUDE.md 文档未同步更新

  • CLAUDE.md:147sqlutil/ 描述仍为 "SQLite 驱动",未反映新增的 pgx/v5 PG 驱动和 dbutil/
  • CLAUDE.md:88-90 — Session 模块仅列出 store.go - SQLite 持久化,缺少新增的 pg_store.go
  • CLAUDE.md:139 — 支撑模块缺少 dbutil/ 包条目

建议修复 (WARN)

代码质量 (11)

  1. Dialect 常量双定义sqlutil/writemu.godbutil/dialect.go 各自定义 DialectSQLite/DialectPostgres,虽有 TestDialectConstantsSync 缓解,建议提取到独立 internal/dialect
  2. DSN 默认值重复PostgresConfig.DSN()openPostgres 各自定义一次
  3. PG Store 命名不一致PGStore(session)/pgStore(cron)/pgEventStore(eventstore)/ChatAccessPGStore(messaging)/apiKeyUserPGStore(admin),建议统一
  4. SetInvalidator 通过类型断言调用gateway_run.go:209 违反 DI 原则,应将 SetInvalidator 纳入 APIKeyUserStorer 接口
  5. EffectiveSQLitePath 仅为 Path 做 fallbackWALModeBusyTimeout 等字段缺少等效传播机制
  6. 两条 PG 连接打开路径dbutil.Open→openPostgressqlutil.OpenDB→openPostgresDB,职责边界不清
  7. session/pg_store.go err2 命名 — 与同文件 err 遮蔽风格不一致
  8. apikey_handlers.go:75 SQLite get 返回裸 error — 与 PG 版 fmt.Errorf 包装不一致(既有代码)
  9. PG Store 构造函数返回类型不一致 — cron 返回 unexported *pgStore,admin 返回 exported *APIKeyUserPGStore
  10. dbutil/rebind.go:134 \? 转义 — dollar-quote 内的 \? 不应作为转义处理(极端边缘场景)
  11. PG 迁移编号跳跃 — 缺少 003、008,未注释说明原因

非功能性 (6)

  1. apikey_pg_store.go sync.Once 写 vs 无同步读Invalidator() 读取 s.invalidator 未同步,实践安全(init 阶段完成)但理论上有竞争
  2. eventstore/pg_store.go pgEventTx 共享 sql map 引用 — 当前安全(只读),但脆弱
  3. config.go Validate() 缺少 PG DSN 校验driver=="postgres" 且 DSN 为空时静默回退
  4. rebind_test.go 使用 t.Errorf 而非 testify/require — 违反项目测试规范
  5. db_test.go/rebind_test.go 缺少 t.Parallel() — 部分缺失
  6. scanTurnsPGjson.Unmarshal 使用 //nolint:errcheck — best-effort 反序列化可能导致静默数据丢失

集成 (5)

  1. 5 个 PG Store 零测试覆盖 — session/pg_store.go、eventstore/pg_store.go、cron/pg_store.go、apikey_pg_store.go、chat_access_pg_store.go(共 ~1028 行)
  2. configs/config.yaml 旧扁平字段与结构化字段并存 — 用户可能困惑,建议标注弃用时间线
  3. PG migrations cost_usdNUMERIC(18,8) vs SQLite REAL — 精度差异可能影响前端展示
  4. chat_access_pg_store.go 未使用 writeMu — 接口语义差异未在文档中注明
  5. CLI 和 gateway PG 初始化逻辑不完全一致 — CLI 版不做迁移,建议注释说明

🤖 Reviewed by hotplex-ai via automated PR review pipeline

- Add docker/postgres-init.sql — creates hotplex DB and pgcrypto extension
- Add docker/docker-entrypoint.sh — dual-mode entrypoint (gateway or cron)
- Update Dockerfile — multi-stage build, pgx driver, healthcheck
- Update docker-compose.yml — postgres service, healthcheck, env vars
- Update docker-compose.prod.yml — production PG config with volume
- Update configs/env.example — add PG DSN and db.driver examples
- Update .dockerignore — exclude sql files from build context
MUST FIX 1 — Default PG DSN sslmode=disable → sslmode=prefer
- PostgresConfig.DSN() default now uses sslmode=prefer (was =disable)
- Eliminate duplicate default DSN in dbutil/db.go openPostgres
  (cfg.DSN() already provides the default; the hasDefaultDSN check
  was dead code since DSN() never returned empty)
- Detect default via cfg.Postgres.ConnStr == "" instead

MUST FIX 2 — CLAUDE.md documentation sync
- Session: add pg_store.go (PostgreSQL persistence)
- sqlutil/: mention jackc/pgx/v5 PG driver + WriteMu PG no-op
- Add new dbutil/ entry (Dialect, Rebind, BoolValue, DB wrapper)
  to support module list

WARN — SetInvalidator via type assertion
- Add SetInvalidator() to APIKeyUserStorer interface
- Implement on both SQLite and PG stores
- Replace type assertion in gateway_run.go with interface call
- Add docker-compose.pg.yml — PostgreSQL-only stack for testing
- Refine Dockerfile, docker-compose.yml, docker-compose.prod.yml
- Update docker-entrypoint.sh for PG DSN env injection
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai (第 4 轮)

代码质量: PASS | 非功能性: WARN | 集成: FAIL

结论: Request Changes — 存在 3 个需修复项。


必须修复 (FAIL)

1. 5 个 PG store 零测试覆盖 — ~1300 行 CRUD 代码无任何测试

internal/session/pg_store.gointernal/eventstore/pg_store.gointernal/cron/pg_store.gointernal/admin/apikey_pg_store.gointernal/messaging/chat_access_pg_store.go 均无测试文件。rebind/dialect 纯函数测试充分,但实际 SQL 查询、扫描逻辑、事务行为、Rebind 与真实 PG 的交互均未验证。

建议: 至少为每个 PG store 添加 table-driven mock 测试(pgxmocksqlmock),或使用 docker-compose + testcontainers 集成测试。

2. CLAUDE.md "支撑模块"部分未完整更新

AGENTS.md:147sqlutil/ 描述仍为 "SQLite 驱动",需更新为双驱动 (SQLite + PostgreSQL/pgx),WriteMu 描述需补充 "PG no-op"。同时缺少新增 internal/dbutil/ 包的描述条目。

3. docker-compose.yml 以 root 启动容器 — 安全风险

docker-compose.yml:703user: "0:0" 以 root 启动,虽然 entrypoint 中降权,但 entrypoint 执行前(权限修复、envsubst YAML)仍以 root 运行。若 entrypoint 崩溃则进程以 root 暴露。

建议: Dockerfile 中 COPY --chown 预设权限,USER hotplex 指令替代运行时降权。


建议修复 (WARN)

代码质量 (10)

  1. chat_access Classify() DRY 违规chat_access_pg_store.go:60-88chat_access_store.go 业务逻辑完全重复,仅 SQL 不同,应抽取共享 classify 函数
  2. apikey_pg_store SQL 模式 DRYapikey_pg_store.go 的 list/get/create/update/delete 与 SQLite 版本结构性重复,应提取 query builder
  3. DBConfig 双路径 — 扁平字段 + SQLite/Postgres 子结构体并存,优先级未完全统一
  4. APIKeyUserStorer unexported 方法 — exported interface 上的 unexported 方法限制外部测试
  5. 迁移编号跳跃 — 缺 003/008,未注释说明原因
  6. extractor_test.go nil 检查移除 — 与其他构造函数测试风格不一致
  7. eventstore has_older 额外 round-trip — 每次 CursorAfter 翻页多一次 DB 查询
  8. 默认 sslmode=disable 无校验 — 已有 slog.Warn 但建议 validate() 中检查
  9. entrypoint envsubst 变量注入 — 环境变量值含 $ 时可能二次展开,建议使用 --variables 白名单
  10. Dockerfile AI 工具下载无 checksumcurl -fsSL 下载 claude/opencode 无 SHA256 校验

非功能性 (10)

  1. 默认 PG 密码 "hotplex" — docker-compose POSTGRES_PASSWORD 默认弱密码,建议移除默认值
  2. 示例 DSN sslmode=disable — env.example/config.yaml 示例均含 sslmode=disable,用户可能直接复制用于生产
  3. PG 连接池与 PG 端 max_connections 不匹配 — 默认 MaxOpenConns=25 可能超出 PG 端限制
  4. PG idle 连接僵尸风险 — 应用侧 ConnMaxLifetime 与 PG 端 idle_session_timeout 不同步
  5. *dbutil.DB 嵌入 sql.DB — 与项目 "显式字段" 偏好不完全一致(非 mutex,影响低)
  6. 迁移编号跳跃 — 同代码质量 #5
  7. docker-compose cap_drop+root — root 用户可自行获取已 drop 的 capability
  8. apikey_pg_store sync.Once 写 vs 无同步读 — 实践安全(init 阶段完成)但理论上有竞争
  9. eventstore pgEventTx 共享 sql map 引用 — 当前安全(只读),但脆弱
  10. cron/eventstore PG store 返回未导出类型 — 与其他 PG store 构造函数不一致

集成 (5)

  1. openPostgres 默认 DSN 的 slog.Warn 不会触发cfg.DSN() 已内含默认值,hasDefaultDSN 永远为 false
  2. PGStore.NewPGStore 接受 ctx 但其他 PG store 不接受 — 构造函数签名不一致
  3. backup service depends_on 仅声明 gateway — pg_dump 依赖 postgres 服务,应添加到 depends_on
  4. eventstore turns.insert 追加 RETURNING — rebind 后若含 ; 分号会追加在分号之后导致语法错误
  5. PG migrations cost_usd NUMERIC(18,8) vs SQLite REAL — 精度差异可能影响前端展示

历史修复验证 (第 1-3 轮 FAIL 状态)

原始 FAIL 状态
缺少 pgx 驱动依赖 ✅ 已修复 (go.mod + driver.go)
PG 连接双重关闭 ✅ 已修复 (PGStore.Close 返回 nil)
nil cache invalidator ✅ 已修复 (SetInvalidator 注入)
create() 硬编码 $1,$2,$3 ✅ 已修复 (使用 Rebind)
默认 sslmode=disable ⚠️ 降级为 WARN (添加了日志但未改默认值)

🤖 Reviewed by hotplex-ai via automated PR review pipeline

MF1 - 5 PG store test files, 24 test cases, zero → covered

New files:
- internal/session/pg_store_test.go       (6 tests)
- internal/cron/pg_store_test.go          (6 tests)
- internal/eventstore/pg_store_test.go    (3 tests)
- internal/admin/apikey_pg_store_test.go  (4 tests)
- internal/messaging/chat_access_pg_store_test.go (5 tests)

MF3 - Remove root user override from docker-compose.yml
      (Dockerfile already uses USER hotplex + COPY --chown)

Pattern: go-sqlmock + testify/require + t.Parallel() + regexp.QuoteMeta
Coverage: success paths + error paths (NotExist, duplicate, ErrNoRows)
WARN #5/#16 — Add migrations-postgres/README.md explaining gaps
  (003=SQLite PRAGMAs, 008=SQLite event store optimize — PG-only skip)

WARN #24 — Strip trailing semicolon before appending RETURNING id
  in eventstore turns.insert PG rebind (prevents syntax error)

WARN #12 — Update env.example and config.yaml DSN examples
  from sslmode=disable → sslmode=prefer
hotplex-ai
hotplex-ai previously approved these changes May 26, 2026
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai (第 5 轮)

代码质量: WARN | 非功能性: WARN | 集成: WARN

结论: Approve — 无 ship-blocking 问题。以下是建议修复的 WARN 项供参考。


建议修复 (WARN)

代码质量 (5)

  1. PG Store 命名不一致PGStore(session) / pgStore(cron) / pgEventStore(eventstore) / ChatAccessPGStore(messaging) / APIKeyUserPGStore(admin),导出与未导出混用。建议统一为未导出类型 + 接口返回,或全部导出。

  2. Dialect 常量跨包重复dbutil/dialect.gosqlutil/writemu.go 各自定义 DialectSQLite/DialectPostgres。虽有 TestDialectConstantsSync 测试保障,但根本消除重复更好(提取到 internal/dbtypes 包)。

  3. openSQLite 使用遗留字段dbutil/db.gocfg.MaxOpenConns 读取遗留扁平字段,未走 Effective* 桥接模式(与 EffectiveSQLitePath() 不一致)。结构化配置 db.sqlite.max_open_conns 不会被 dbutil.Open(DialectSQLite, ...) 路径读取。

  4. SQLite/PG 错误包装不一致admin/apikey_handlers.go 的 SQLite list/get 直接返回裸 error,PG 版本统一使用 fmt.Errorf("...: %w", err)。包内风格应统一。

  5. NewPGStore 返回未导出类型cron.NewPGStoreeventstore.NewPGStore 返回未导出类型(*pgStore/*pgEventStore),跨包调用时暴露到 API 边界。建议改为返回接口类型。

非功能性 (5)

  1. Docker Compose sslmode=disable 默认值docker-compose.pg.yml 中 PG DSN 默认 sslmode=disable,与 config.yaml 示例(sslmode=prefer)不一致。建议添加注释说明仅适用于容器内部网络。

  2. PG 端口默认绑定 0.0.0.0 + 弱密码 — 开发 compose 中 PG 端口暴露到 0.0.0.0:5432,默认密码 hotplex。建议改为 127.0.0.1 绑定,或添加安全警告注释。

  3. openPostgres 缺少 Ping 验证sqlutil.openPostgresDB 未调用 PingContext 验证 DSN 可达性(dbutil.Open 路径有 Ping)。虽当前未被生产代码调用,但为防未来踩坑应保持一致。

  4. PG 连接池 ConnMaxLifetime = ConnMaxIdleTime = 5min — 两者相同导致连接频繁重建。建议 ConnMaxIdleTime < ConnMaxLifetime(如 3min vs 5min)。

  5. Docker Compose 明文密码传递 — backup sidecar 通过 PGPASSWORD 环境变量传递明文密码,开发 compose 默认弱密码。

集成 (4)

  1. 缺少真实 PG 集成测试 — 所有 PG store 测试基于 sqlmock,无法验证 SQL 在真实 PG 上的执行、Rebind() 生成的 $N 与 pgx 驱动的兼容性、Migration 文件正确性。建议后续 PR 添加 testcontainers 集成测试。

  2. Dialect 常量三处定义sqlutil/writemu.gosqlutil/driver.godbutil/dialect.go 各自定义,虽有测试保障但增加维护负担。长期建议提取到独立包。

  3. initPGStores 两阶段初始化NewAPIKeyUserPGStore(db, nil) 传入 nil invalidator,后续通过 SetInvalidator 注入。依赖调用顺序正确性,建议将 dbResolver 创建提前到 initPGStores 中。

  4. AGENTS.md 文档更新已在 PR diff 中 — PR 已包含更新,确认合并顺序正确即可。


历史修复验证 (第 1-4 轮 FAIL 状态)

原始 FAIL 状态
缺少 pgx 驱动依赖 ✅ 已修复
PG 连接双重关闭 ✅ 已修复
nil cache invalidator ✅ 已修复
create() 硬编码 $1,$2,$3 ✅ 已修复
默认 sslmode=disable ✅ 降为 WARN (config.yaml 示例已改为 prefer)
5 个 PG store 零测试覆盖 ✅ 已修复 (全部添加 sqlmock 测试)
CLAUDE.md 文档未同步 ✅ 已修复 (PR diff 包含更新)
docker-compose root 启动 ⚠️ 降为 WARN (已有 entrypoint 降权)

🤖 Reviewed by hotplex-ai via automated PR review pipeline

…ding

- Fix INTEGER→BIGINT for timestamp columns in PG migrations (002,005,007,009)
- Add migration 012 to ALTER existing tables with BIGINT timestamps
- Fix startup banner to show "PostgreSQL" instead of SQLite path when using PG
- Add BindEnv for db.driver and db.postgres.* config fields
- Fix ConnMaxIdleTime from 5min to 3min
- Add EffectiveMaxOpenConns bridge method
- Add Makefile dev-pg target for PostgreSQL dev environment
- Fix docker-compose security and PG config issues
Add database awareness and statistics analysis manual (db-stats-skill-manual.md):
- 4-step database detection: process → env vars (incl. MAKEFLAGS) → .env → config
- Complete schema reference for all 6 tables (SQLite/PG type differences)
- 9 categories of analytics SQL templates with index-aware optimization
- Fix 7 SQL issues found in audit: index prevention, sort order, PG BOOLEAN, JOIN optimization

Integrate via go:embed pattern (matching cron skill manual):
- internal/dbutil/skill.go: embed + SkillManual()
- gateway_run.go: release to ~/.hotplex/skills/db-stats.md on startup
- META-COGNITION.md: §8 B-channel directive for mandatory pre-read
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三维度审查摘要 — hotplex-ai (第 5 轮)

代码质量: PASS | 非功能性: PASS | 集成: WARN

结论: Approve — 前 4 轮 FAIL 全部修复,本轮无新 FAIL。


历史 FAIL 修复验证

原始 FAIL 状态
缺少 pgx 驱动依赖 ✅ 已修复 (driver.go blank import)
PG 连接双重关闭 ✅ 已修复 (PGStore.Close 返回 nil)
nil cache invalidator ✅ 已修复 (SetInvalidator 注入)
create() 硬编码 $1,$2,$3 ✅ 已修复 (使用 Rebind)
默认 sslmode=disable 无日志 ✅ 已修复 (slog.Warn + 默认改为 prefer)
5 个 PG store 零测试 ✅ 已修复 (全部添加 sqlmock 测试)
CLAUDE.md 文档未更新 ✅ 已修复 (dbutil/ 条目 + PG store 描述)
docker root 启动 ✅ 已修复 (USER hotplex + entrypoint 降权)

建议改进 (WARN)

代码质量 (4)

  1. PG store 命名 — 所有 PG store 使用 unexported pgStore,跨包导航时可能混淆,建议按包区分(如 apiKeyPGStore
  2. SQLite get() 裸 ? 占位符apikey_handlers.go:73 SQLite store 直接使用 ?,PG store 用 Rebind(),风格不一致(功能正确)
  3. SQLite create() 手动时间戳 — 用 time.Now().UTC().Format(RFC3339) 而 PG 用 DEFAULT NOW(),风格差异
  4. Dialect 常量双定义sqlutil/dbutil/ 各定义一次,有 TestDialectConstantsSync 缓解

非功能性 (2)

  1. docker-compose.pg.yml sslmode=disable — Docker 内网可接受,生产需注意
  2. 默认 POSTGRES_PASSWORD=hotplex — 开发弱密码,生产需覆盖

集成防腐 (10)

  1. PG 测试仅 sqlmock — 无法验证 RETURNING id/ON CONFLICT/BOOLEAN/jsonb 等真实 PG 行为
  2. session pg_store 缺少 Upsert/List 测试 — 高频方法未覆盖
  3. cron pg_store 缺少 UpsertByName/Update/UpdateState 测试 — PG 特有语法未验证
  4. eventstore 缺少 DeleteExpired/BeginTx/AppendTurn 测试
  5. AGENTS.md Cron 模块缺少 pg_store.go 条目 — Session 模块已加,Cron 模块遗漏
  6. go-sqlmock 标记为 indirectgo mod tidy 应自动管理
  7. Dockerfile version 注入路径 — 从 internal/version 改为 main.version,需确认一致性
  8. .dockerignore 改为黑名单模式 — 可能包含多余配置文件
  9. Dockerfile ai-tools 下载无 checksum — Claude/OpenCode 二进制无 SHA256 校验
  10. PG 迁移编号跳跃 — 跳过 003/008,goose strict 模式可能报错

🤖 Reviewed by hotplex-ai via automated PR review pipeline

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — P0/P1 无阻断性发现。P1 建议项(envsubst DSN 白名单、TIMESTAMPTZ)可在后续迭代修复。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: PASS with suggestions | P0:0 P1:1 P2:4 P3:4


[P1] Migration 012 DOWN 路径对超出 int4 范围的数据不可逆

文件: internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql

DOWN 迁移将所有 timestamp 列从 BIGINT 转回 INTEGER (int4, 最大 2,147,483,647 ≈ 2038-01-19 Unix ms)。如果已有数据超出 int4 范围,回滚会失败或静默截断。同时,UP 迁移对新安装是空操作(002/005/007/009 已经使用 BIGINT),仅对假设的早期版本有意义。

建议: 移除 migration 012(对当前 PR 创建的 PG schema 无实际作用),或使 DOWN 为空操作并文档注明不支持回滚。


[P2] docker-compose 开发环境使用可预测的默认 PG 密码

文件: docker-compose.yml, docker-compose.pg.yml, Makefile

${POSTGRES_PASSWORD:-hotplex} 作为默认值。docker-compose.prod.yml 正确移除了端口映射,但未强制要求非默认密码。

建议: 移除默认值使 Compose 在 POSTGRES_PASSWORD 未设置时显式报错,或在 prod overlay 中添加密码强度校验。

[P2] DSN 含密码通过环境变量传递,docker inspect 可见

文件: docker-compose.yml:149, docker-compose.pg.yml:16

PG DSN 字符串(含密码)作为环境变量注入容器,通过 docker inspect 可见。这是 Docker 标准模式,但生产环境应使用 Docker Secrets 或文件挂载。

建议: 生产文档中明确建议使用 Docker Secrets 或 --secret 替代环境变量传递凭据。

[P2] 缺少 SQLite 子配置的环境变量绑定

文件: internal/config/config.go

新增了 db.postgres.*BindEnv 调用,但未添加 db.sqlite.path/db.sqlite.wal_mode 等结构化字段的绑定。用户设置 HOTPLEX_DB_SQLITE_PATH 会被静默忽略,只能使用遗留的 HOTPLEX_DB_PATH

建议: 添加 BindEnv("db.sqlite.path", "HOTPLEX_DB_SQLITE_PATH") 等绑定,或文档注明仅支持遗留 flat 变量。

[P2] docker-compose.pg.yml 缺少 condition: service_healthy

文件: docker-compose.pg.yml

depends_on 未使用 condition: service_healthy,gateway 可能在 PG 完全初始化前启动。docker-compose.yml 中 backup 服务已使用此模式。

建议: 添加 condition: service_healthy 到 postgres 依赖,并确保 PG 服务定义了 healthcheck


[P3] 缺少 PostgreSQL WriteMu 无锁行为的测试

文件: internal/sqlutil/writemu_test.go

仅更新了 SQLite 测试传入 DialectSQLite,未测试 PG 方言下 Lock()/Unlock()/WithLock() 的空操作行为。

[P3] Migration 012 对新安装是空操作但未文档说明

文件: internal/session/sql/migrations-postgres/README.md

README 记录了缺失的编号(003/008)但未说明 012 的空操作性质,会误导后续开发者。

[P3] config.yaml 中 db.path 与 db.sqlite.path 双重声明

文件: configs/config.yaml

同一文件中 db.pathdb.sqlite.path 指向相同值。Effective*() 方法正确优先结构化字段,但双重来源是维护隐患。

[P3] QuoteIdent 不转义标识符中的嵌入双引号

文件: internal/dbutil/dialect.go

QuoteIdent 用双引号包裹但不转义内部的 "。当前仅用于显示/统计,非用户输入构造查询,风险低。


P0:0 P1:1 P2:4 P3:4 → PASS with suggestions

核心 Go 实现(接口设计、DI 装配、PG store、Rebind 逻辑、迁移隔离)质量良好,PG store 忠实镜像 SQLite 实现,正确使用 Rebind()%w 错误包装。Docker 层建议项不阻断合并。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — P0/P1 无阻断性发现,仅 P2/P3 建议项。核心 PG 实现质量良好。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: PASS with suggestions | P0:0 P1:2 P2:3 P3:0

4 个专业 agent 并行评审 88 个文件(6167 行变更),覆盖 Docker/部署、dbutil/sqlutil 核心抽象、PG 迁移/配置/DI、PG store 实现。


P1 HIGH — 正确性

[P1] 跨架构构建: ai-tools-collector 阶段缺少 --platform=$TARGETPLATFORM (Dockerfile:135-152)
ai-tools-collector 继承 FROM alpine:3.21 无平台指定。docker buildx 跨架构编译时(如 amd64 主机构建 arm64 镜像),该阶段以宿主架构运行,用 uname -m 下载错误架构的 Claude Code/opencode 二进制。Builder 阶段正确使用 --platform=$BUILDPLATFORM,但 AI tools 阶段遗漏。

  • Fix: FROM --platform=$TARGETPLATFORM alpine:3.21 AS ai-tools-collector + 用 TARGETARCH 替代 $(uname -m)

[P1] PG 运行时错误: cron/pg_store.go 复用 SQLite scanJobRow 扫描 boolean 列为 int (internal/cron/pg_store.go:78-92, ref internal/cron/store.go:259-278)
scanJobRowenabled, delete_after_run, silent 扫描为 var ... int,但 PG 迁移 005_cron_jobs_table.pg.sql 定义为 BOOLEAN NOT NULL。pgx v5 对 BOOLEAN 列返回 boolsql.Scan*int 将在运行时失败。其他 PG store(session/eventstore/messaging/admin)均实现了 PG 专用 scan 函数,唯独 cron 复用了 SQLite 版本。测试用 mock rows 传 1/0 整数值,掩盖了此问题。

  • Fix: 创建 scanJobRowPG 将 boolean 列扫描为 bool(或 sql.NullBool),配合 decodeJobFieldsPG

P2 MEDIUM — 建议

[P2] Migration 012 DOWN 路径将 BIGINT 缩回 INTEGER,超过 int4 max 的数据截断 (internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql:15-20)
DOWN 迁移 ALTER COLUMN ... TYPE INTEGER 会截断超过 2,147,483,647 的毫秒时间戳(约 2038 年后)。虽然生产中极少执行 DOWN,但建议添加注释标记为不可逆或使用 USING CASE WHEN ... THEN 2147483647 ELSE ... END 截断保护。

[P2] 生产 overlay 未强制 POSTGRES_PASSWORD (docker-compose.prod.yml)
docker-compose.prod.yml 未覆盖 backup sidecar 的 POSTGRES_PASSWORD。生产部署使用 PG 时,若未显式设置密码,backup sidecar 和 gateway 都将使用默认值 hotplex

  • Fix: 在 prod overlay 中添加 PGPASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required in production}

[P2] 迁移序号跳过 003/008 (internal/session/sql/migrations-postgres/)
README 解释了这些是 SQLite-only,但需确认 goose.NewProvider 的文件扫描能正确跳过缺失序号。如 goose 报错,可添加空占位文件。


通过项

  • dbutil 核心抽象层(Dialect/Rebind/WriteMu/DB 封装)设计合理,无问题
  • session/eventstore/messaging/admin PG store 各自实现了 PG 专用 scan,正确处理 BOOLEAN
  • DI 注入 (gateway_run.go) 干净分离 initSQLiteStores/initPGStores
  • 配置向后兼容通过 Effective*() 方法保持
  • SQL 注入防护:所有参数使用占位符 + Rebind
  • 资源释放顺序正确:signal → cancel → hub → bridge → sessionMgr → db.Close()

P0:0 P1:2 P2:3 P3:0 → PASS with suggestions

hotplex-ai
hotplex-ai previously approved these changes May 27, 2026
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — P0/P1 无阻塞性安全发现。P1 为正确性建议(跨架构构建 + cron boolean scan),非安全缺陷,不阻断合并。

…eMu, migration safety, DSN cleanup

P1: apiKeyUserStore write/create/update/delete now wrapped with writeMu.WithLock() for SQLite serialization
P1: CLI cron path creates writeMu instead of passing nil to session/cron/event stores
P1: PG migration 009 adds IF NOT EXISTS for idempotent re-runs
P2: PostgresConfig.DSN() returns empty string when unconfigured instead of misleading default
P3: EffectiveWALMode zero-value ambiguity documented
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: PASS with suggestions | P0:0 P1:1 P2:3 P3:0

P1 发现(建议修复,非阻断)

[P1] 正确性: cron PG store 复用 SQLite scanJobRow,BOOLEAN→int 类型不兼容
internal/cron/pg_store.goGet/GetByName 调用 scanJob(row)List 调用 scanJobRow(rows)。这两个函数定义在 store.go:259-278,将 enabled, deleteAfterRun, silent 三个 BOOLEAN 列扫描为 *int

var enabled, deleteAfterRun, silent int
s.Scan(&enabled, ..., &deleteAfterRun, ..., &silent, ...)

PG migration 005 将这三列定义为 BOOLEAN,pgx 驱动返回 bool 类型。database/sqlScan 无法将 bool 转为 *int,运行时会报错:sql: Scan error on column index 3, name "enabled": converting driver.Value type bool to int

修复建议:为 PG store 创建专用的 scanJobRowPG 函数,将 enabled/deleteAfterRun/silent 扫描为 *bool;或使用 sql.NullBool 兼容两种类型。

P2 发现(建议改进)

[P2] 安全: docker-entrypoint.sh envsubst allowlist 包含 DSN(含密码)
docker/docker-entrypoint.sh:44ENVSUBST_VARS 包含 ${HOTPLEX_DB_POSTGRES_DSN}。DSN 格式为 postgres://user:password@host:port/db,envsubst 会将密码明文写入 /run/hotplex/config/*.yaml。虽然 /run 是容器 tmpfs 且非 root 可读,但在多容器共享配置卷的场景下存在泄露风险。

修复建议:使用 HOTPLEX_DB_POSTGRES_DSN 的环境变量直接注入(不通过 config YAML),或将 DSN 从 allowlist 移除,改用独立的环境变量文件或 Docker secrets。

[P2] 架构: EffectiveWALMode() 的 false 值 fallthrough 语义
DBConfig.EffectiveWALMode()SQLite.WALMode 显式设为 false 时,会 fallthrough 到 legacy WALMode 字段。注释已承认此行为,但这意味着用户无法通过新格式显式禁用 WAL(必须通过 legacy 字段)。

修复建议:考虑使用指针 *bool 或 sentinel 值区分"未设置"和"显式 false",或在文档中明确标注此限制。

[P2] 正确性: Migration 012 DOWN 路径将 BIGINT 转回 INTEGER 会溢出
012_fix_timestamp_int8.pg.sql 的 DOWN 路径将毫秒时间戳从 BIGINT 转回 INTEGER。对真实数据(当前 Unix ms ≈ 1.75×10¹²,远超 int4 max 2.147×10⁹),DOWN 会直接失败。这在实践中不可逆。

修复建议:在 DOWN 路径中添加 WHERE 过滤或 USING ...::INTEGER 截断,或接受 DOWN 路径对有数据的环境不可用并添加注释说明。


P0:0 P1:1 P2:3 P3:0 → PASS with suggestions

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — P0/P1 无阻断性发现,仅 P1 正确性建议(cron PG store BOOLEAN 扫描)+ P2 改进项。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: CHANGES_REQUESTED | P0:0 P1:1 P2:5 P3:3


[P1] 正确性: Cron PG Store BOOLEAN 列扫描为 int 将在运行时失败

文件: internal/cron/pg_store.go (new) + internal/cron/store.go:259-270

PG 迁移 005_cron_jobs_table.pg.sql 定义 enableddelete_after_runsilentBOOLEAN NOT NULL。但 pg_store.goGet()/GetByName()/List() 直接调用共享的 scanJob(row) / scanJobRow(rows),后者声明 var enabled, deleteAfterRun, silent int 并用 &enabled 扫描。

pgx 驱动对 PG BOOLEAN 返回 Go bool,扫描到 *int 会触发 *pgconn.PgError 类型不匹配。

根因: sqlmock 不校验 Go 类型兼容性,测试掩盖了此 bug。

修复: 创建 PG 专用 scanner(如 scanJobRowPG),使用 bool 变量扫描后转换,与 eventstore/pg_store.goscanTurnsPG 使用 sql.NullBool 的模式一致。


[P2] 架构: EffectiveWALMode() 零值穿透 — 无法通过结构化配置禁用 WAL

文件: internal/config/config.go

func (d DBConfig) EffectiveWALMode() bool {
    if d.SQLite.WALMode { return true }
    return d.WALMode  // legacy field, Default() = true
}

用户设置 db.sqlite.wal_mode: false 时,SQLite.WALMode 为零值 false,穿透到 legacy WALMode=true(默认值),WAL 仍被启用。结构化配置的 false 等于无效操作。

修复: 用 *bool 指针或 SQLiteConfigSet bool 区分"未设置"和"显式 false",或在检测到结构化配置字段被使用时跳过 legacy fallback。

[P2] 安全: Docker Compose 默认密码 "hotplex" 无生产保护

文件: docker-compose.yml

POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hotplex} 提供弱默认密码。docker compose --profile postgres up 在未设置环境变量时将使用此默认值。备份 sidecar 同样继承。

修复: 使用 ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} 或在启动时检测默认值并警告。

[P2] 安全: 注释示例中使用 sslmode=disable

文件: docker-compose.yml, docker-compose.pg.yml

注释中的 DSN 示例使用 sslmode=disable,用户取消注释用于非 Docker 部署时将明文传输凭据。

修复: 将示例改为 sslmode=prefersslmode=require

[P2] 架构: 方言常量在 dbutilsqlutil 间重复

文件: internal/sqlutil/writemu.go

DialectSQLite/DialectPostgres 在两个包中重复定义(字符串常量),存在漂移风险。虽有测试验证一致,但字符串常量无编译期保障。

修复: 提取到独立 dialect 包或使用 type Dialect string 增强类型安全。

[P2] 迁移: Migration 012 ALTER COLUMN TYPE BIGINT 在大表上会锁表

文件: internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql

ALTER TABLE ... ALTER COLUMN TYPE BIGINT 获取 ACCESS EXCLUSIVE 锁并重写表。百万级 events 表可能导致长时间不可用。

修复: 因 PG 支持为新增功能(无现有用户),建议将 012 合并到初始迁移(005 之前)确保建表即为 BIGINT,避免后续迁移。


[P3] 代码质量: UpsertByName ON CONFLICT 使用 cron_jobs.state 而非 EXCLUDED.*

文件: internal/cron/pg_store.go

为保留运行时状态故意引用目标表而非 EXCLUDED,但与其他列使用 EXCLUDED.* 的模式不一致。未来开发者可能"修复"此不一致而破坏幂等导入。

修复: 添加注释说明 state/created_at 使用目标表引用的原因。

[P3] 代码质量: 多个包内均定义 pgStore 未导出类型

文件: internal/{admin,cron,eventstore,messaging,session}/pg_store.go

5 个包各自定义 pgStore struct,代码搜索和跨包 review 略有不便。

修复: 考虑使用更具描述性的名称如 pgCronStorepgSessionStore 等。

[P3] 安全: Makefile dev-pg 目标硬编码默认密码

文件: Makefile

dev-pg 目标使用 ${POSTGRES_PASSWORD:-hotplex} 构造 DSN,弱密码可能被复制到生产脚本。

修复: 开发环境使用更强默认值或要求显式设置。


P0:0 P1:1 P2:5 P3:3 → CHANGES_REQUESTED

P1(Cron BOOLEAN 扫描)将在真实 PostgreSQL 环境下导致运行时失败,需修复后方可合并。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: CHANGES_REQUESTED | P0:0 P1:2 P2:5 P3:2


P1 — 正确性(阻断合并)

[P1] cron scanJobRow 将 BOOLEAN 扫描到 int — PG 运行时必崩 (internal/cron/store.go:259-261, internal/cron/pg_store.go:2644)

上次 review 已指出,未修复scanJobRow 共享函数将 enabled/delete_after_run/silent 扫描到 int 变量,但 PG migration 将这些列定义为 BOOLEAN NOT NULL。pgx 驱动返回 Go bool,扫描到 *int 会产生 sql: Scan error on column index 3: cannot scan bool into *int。所有 cron PG 读取路径(List/Get/GetByName)均受影响。sqlmock 测试无法捕获此问题(mock 返回 int 而非 bool)。

修复:添加 PG 专用扫描函数:

func scanJobRowPG(s scanner) (*CronJob, error) {
    var enabled, deleteAfterRun, silent bool
    // scan into bools, convert to int for decodeJobFields
    return job, decodeJobFields(job, b2i(enabled), b2i(deleteAfterRun), b2i(silent), ...)
}

[P1] CLI OpenStore PG 路径未执行 migrations — cron CLI 命令将失败 (internal/cli/cron/client.go:2111-2119)

PG 分支直接创建 cron.NewPGStoreeventstore.NewPGStore,但未调用迁移入口。cron_jobs/events/turns 表不存在,CLI 命令(cron create/cron list/cron history)将报 "relation does not exist"。

修复:在创建 store 前显式运行迁移:

case dbutil.DialectPostgres:
    db, openErr := dbutil.Open(dbutil.DialectPostgres, &cfg.DB)
    // ...
    if err := session.RunPGMigrations(ctx, db); err != nil {
        return nil, fmt.Errorf("pg migrations: %w", err)
    }

P2 — 建议

[P2] Docker 默认密码 "hotplex" (docker-compose.yml:1089,1141)

${POSTGRES_PASSWORD:-hotplex} 默认密码与用户名/库名相同,易猜测。建议改为强制显式设置:

- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}

[P2] Migration 012 冗余 (internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql)

各列在创建迁移中已是 BIGINT,此 ALTER TYPE 为 no-op。建议删除或添加注释说明其作为早期采用者的安全网。

[P2] Dialect 常量重复 (internal/sqlutil/writemu.go:6155-6156, internal/dbutil/dialect.go:3874-3875)

sqlutildbutil 各自定义 DialectSQLite/DialectPostgres。虽有 TestDialectConstantsSync 测试保障,但可考虑提取到 internal/dbdialect/ 消除重复。

[P2] DSN 在 envsubst 白名单中与 Viper BindEnv 重复 (docker/docker-entrypoint.sh:1271)

HOTPLEX_DB_POSTGRES_DSN 已通过 Viper BindEnv 处理,envsubst 白名单中移除可减少明文凭证副本。

[P2] WriteMu 空方言回退不一致 (internal/sqlutil/writemu.go:6141-6209)

nil receiver 为 no-op,但 NewWriteMu("") 创建的零值会尝试获取锁。建议 NewWriteMu 对空方言默认回退到 SQLite。


P3 — 备注

[P3] pgStore 类型名跨 5 包重复admin/cron/eventstore/messaging/session 各定义未导出 pgStore,可改为 pgCronStore/pgSessionStore 等提升可读性。

[P3] 缺少 PG 集成测试 — 所有 PG store 测试使用 sqlmock,无法验证真实 PG 的类型兼容、迁移、事务语义。建议添加 Docker-gated 集成测试。


P0:0 P1:2 P2:5 P3:2 → CHANGES_REQUESTED

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai (re-review on 17eb77d)

Verdict: CHANGES_REQUESTED | P0:1 P1:1 P2:5 P3:4


P0 CRITICAL

[P0] 正确性: scanJobRow 将 PG BOOLEAN 扫描为 *int,运行时必崩 (internal/cron/store.go:261, internal/cron/pg_store.go)

PG store 的 Get/GetByName/List 均调用共享的 scanJob/scanJobRow。该函数声明 var enabled, deleteAfterRun, silent int,但 PG migration 005 将这些列定义为 BOOLEAN NOT NULL。pgx 驱动对 BOOLEAN 列返回 Go bool,扫描到 *int 会触发:

sql: Scan error on column index 3, name "enabled": cannot scan bool into *int

影响:所有 PG cron 读取路径(Get/GetByName/List/UpsertByName 的 RETURNING)全部不可用。

修复建议:参照 eventstore/pg_store.goscanTurnsPG 的模式,创建 scanJobRowPG 函数,将 enabled/deleteAfterRun/silent 扫描为 bool 后转换为 int。或在 scanJobRow 中使用 sql.NullBool


P1 HIGH

[P1] 正确性: chat_access_pg_store.gocreated_at 单位与文档不一致 (internal/messaging/chat_access_pg_store.go:4989)

PG store 使用 time.Now().Unix()(秒级),但 PG migration 007 schema 注释和 skill 手册均标注 created_at 为 "Unix ms"。所有其他 PG store(session/eventstore/cron)统一使用 UnixMilli()

经查 SQLite 的 chat_access_store.go 也使用 Unix()(秒级),PG store 与 SQLite 行为一致。但文档和 schema 注释声称 "Unix ms",语义不一致。如果外部工具按 ms 解析此字段会导致时间戳偏移 1000 倍。

修复建议:二选一:(a) 将 PG 和 SQLite 的 Record/Classify 统一改为 UnixMilli() 并修正所有比较逻辑,或 (b) 将 skill 手册和 migration 注释中的 "Unix ms" 改为 "Unix seconds"。


P2 MEDIUM

[P2] 安全: Docker Compose 默认 PG 密码为 "hotplex" (docker-compose.yml)
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-hotplex} 提供弱默认密码。建议改为 ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} 强制设置。

[P2] 安全: envsubst allowlist 包含 DSN (docker/docker-entrypoint.sh:1271)
HOTPLEX_DB_POSTGRES_DSN 被纳入 envsubst 展开,DSN 中的明文密码写入容器 YAML 文件。Go 应用已通过 Viper BindEnv 直接读取环境变量,建议移除 DSN 的 envsubst 展开。

[P2] 架构: DialectSQLite/DialectPostgres 常量跨两包重复 (internal/dbutil/dialect.go, internal/sqlutil/writemu.go)
为避免 import cycle 各自定义相同方言常量。虽有 TestDialectConstantsSync 测试同步,仍是维护风险。建议提取到 internal/dbtypes 或添加交叉引用注释。

[P2] 架构: Migration 012 对已是 BIGINT 的列执行冗余 ALTER (internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql)
Migration 009 已定义 turns.created_at 为 BIGINT,002 已定义 events.created_at 为 BIGINT。012 再次 ALTER TYPE BIGINT 为 no-op 但易误导。

[P2] 测试: 所有 *_pg_store_test.go 使用 sqlmock 无法检测 PG 类型不匹配
sqlmock 使用 database/sql mock 驱动而非 pgx,P0 的 BOOLEAN scan bug 会通过所有测试。建议添加集成测试(testcontainers)验证 PG 全 scan 路径。


P3 LOW

[P3] dbutil.DB 嵌入 *sql.DB (internal/dbutil/db.go) — 继承所有方法,建议使用显式字段。

[P3] 5 个 PG store 均命名 pgStore — 跨包同名使 grep/stack trace 难区分。

[P3] EffectiveWALMode 无法禁用 WAL (internal/config/config.go:2245) — bool 零值 fallthrough。

[P3] Config.Validate() 不拒绝未知 driver (internal/config/config.go:2172) — 静默 fallthrough 到 SQLite。


P0:1 P1:1 P2:5 P3:4 → CHANGES_REQUESTED(P0 阻断合并,P1 建议一并修复)

…, envsubst cleanup

P1: Add scanJobRowPG for PostgreSQL BOOLEAN→bool scanning (pgx returns bool, not int)
P1: CLI OpenStore PG path now runs goose migrations before creating stores
P2: Remove HOTPLEX_DB_POSTGRES_DSN from envsubst allowlist (Viper handles it)
P2: NewWriteMu empty dialect defaults to SQLite for consistent nil-safe behavior
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: CHANGES_REQUESTED | P0:2 P1:0 P2:3 P3:0


[P0] Cron PG Store: scanJobRow 将 PostgreSQL BOOLEAN 扫描为 int,运行时必然崩溃

文件: internal/cron/pg_store.go (调用 internal/cron/store.go:261scanJobRow)

pg_store.Get()pgStore.GetByName()pgStore.List() 均调用共享的 scanJob(row) / scanJobRow(rows),其中:

var enabled, deleteAfterRun, silent int  // store.go:261

PG schema (005_cron_jobs_table.pg.sql) 定义这三列为 BOOLEAN NOT NULL。pgx 驱动无法将 PG BOOLEAN 扫描到 Go int,返回 sql: Scan error on column index 3: cannot scan BOOLEAN into int

影响: Cron 系统在 PostgreSQL 下完全不可用 — timer loop 的 collectDue 调用 List 会失败,所有定时任务静默停止。

修复: 为 PG 路径提供独立的 scan 函数,将布尔列扫描到 bool 变量;或在 PG 查询中使用 CAST(enabled AS INTEGER)

注:sqlmock 单元测试无法捕获此 bug,因为 AddRow(1, ...) 传入的是 Go int64 而非 PG BOOLEAN。


[P0] DBResolver 硬编码 ? 占位符,PostgreSQL 下 API Key 认证完全失效

文件: internal/security/apikey_resolver.go:92-95未在 PR 中修改

initPGStores() 将 PG *sql.DB(pgx 驱动)传给 NewDBResolver

dbResolver := security.NewDBResolver(db.DB)  // gateway_run.go initPGStores

DBResolver.Resolve() 使用硬编码 ? 占位符:

err := r.db.QueryRowContext(ctx,
    "SELECT user_id FROM api_key_users WHERE api_key = ?",
    key,
).Scan(&userID)

pgx 驱动不支持 ? 占位符,每次调用都会返回语法错误。

影响: PostgreSQL 模式下所有 API Key 认证失效 — 请求全部被拒。只有 config-map resolver 可用。

修复: 使 DBResolver 感知 dialect(注入 dbutil.Dialect 并调用 Rebind()),或为 PG 路径创建独立的 resolver。


[P2] Docker 默认密码在生产 overlay 中未强制要求

文件: docker-compose.yml, docker-compose.pg.yml

POSTGRES_PASSWORD 默认值为 hotplex,而 docker-compose.prod.yml 已正确移除 admin-token 默认值并使用 HOTPLEX_JWT_SECRET:?required bailout 语法,但未对 POSTGRES_PASSWORD 施加同样的保护。

建议: 在 docker-compose.prod.yml 中将 gateway 服务的 DSN 改为 ${POSTGRES_PASSWORD:?required},与 JWT_SECRET 保持一致。


[P2] envsubst 将含密码的 DSN 写入磁盘配置文件

文件: docker/docker-entrypoint.sh

HOTPLEX_DB_POSTGRES_DSN(含 postgres://user:password@host/db)通过 envsubst 展开写入 /run/hotplex/config/config.yaml。虽然 entrypoint 使用了白名单机制(仅展开已知变量),但 DSN 作为高价值凭证被写入磁盘明文。

建议: 依赖 Viper 的 BindEnv("db.postgres.dsn") 直接从环境变量读取 DSN,跳过 envsubst 展开。或确保 /run/hotplex/config/ 挂载为 tmpfs。


[P2] .dockerignore 从 config.yaml.example 改为 config.yaml

文件: .dockerignore

-!configs/config.yaml.example
+!configs/config.yaml

将实际 config.yaml(含注释掉的 PG DSN 字段和示例凭证)包含进 Docker 镜像。如果开发者取消注释 DSN 行并提交,凭证会被烘焙进镜像。

建议: 恢复为 !configs/config.yaml.example,或在 CI 中添加检查确保 config.yaml 不含未注释的凭证。


正面评价

  • SQL 注入防护到位:所有查询使用参数化占位符 + dialect.Rebind() 正确转换
  • Banner 正确隐去 PG DSN,仅显示 PostgreSQL
  • docker-compose.prod.yml 移除了 admin-token 默认值
  • WriteMu nil-safety 在 PG no-op 路径下正确处理
  • PG migration schema 使用合适类型(BIGINT 毫秒时间戳、BOOLEAN 标志、NUMERIC 费用)
  • sqlmock 测试覆盖了所有 PG store 的 CRUD 操作

总结: P0:2 P1:0 P2:3 P3:0 → FAIL — 2 个 P0 正确性 bug 阻断合并,均在 PG 路径下导致核心功能(Cron / API Key Auth)完全失效。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: PASS with suggestions | P0:0 P1:2 P2:3 P3:2


P1 — 正确性问题

[P1] 正确性: chat_access Classify 吞没查询错误 (internal/messaging/chat_access_pg_store.go)

  • Classify() 对任意查询错误(含 PG 宕机、连接超时)返回 ChatAccessNew,而非区分 sql.ErrNoRows 和真实错误
  • 影响:PG 不可用时所有用户被分类为「新用户」,触发大量欢迎消息
  • 修复:检查 errors.Is(err, sql.ErrNoRows) 时返回 ChatAccessNew,其他错误向上传播

[P1] 测试覆盖: cron PG store mock 使用错误的错误类型 (internal/cron/pg_store_test.go)

  • GetJob_NotFound 测试中 mock 返回 ErrJobNotFound(应用层哨兵),而非 sql.ErrNoRows(驱动层错误)
  • 真实 PG 查询返回 sql.ErrNoRows,由 scanJobPG 转换为 ErrJobNotFound。当前测试绕过了转换逻辑,未覆盖真实路径
  • 修复WillReturnError(sql.ErrNoRows),验证 scanJobPG 的转换正确性

P2 — 架构/设计

[P2] 意图不清: UpsertByName ON CONFLICT 保留字段缺少注释 (internal/cron/pg_store.go)

  • state = cron_jobs.statecreated_at = cron_jobs.created_at 有意使用表引用而非 EXCLUDED,保留运行时状态和创建时间戳
  • 设计正确,但缺少注释说明意图,后续维护者可能误改为 EXCLUDED 导致 YAML reload 覆盖运行时状态
  • 修复:添加注释 -- preserve runtime state and creation timestamp

[P2] 配置语义: EffectiveWALMode 无法通过结构化配置禁用 WAL (internal/config/config.go)

  • EffectiveWALMode()SQLite.WALMode == false 时 fallback 到 legacy WALMode(默认 true)
  • 用户设置 db.sqlite.wal_mode: false 期望禁用 WAL,实际仍启用。注释已说明但违反「结构化配置优先」约定
  • 修复:使用 *bool 指针区分「显式 false」和「未设置」

[P2] Docker: 开发 compose 中 POSTGRES_PASSWORD 默认为 hotplex (docker-compose.yml)

  • 开发环境 POSTGRES_PASSWORD 默认 hotplex,有 ⚠️ Development only 注释,prod compose 已移除
  • 非阻断,但建议开发 compose 也使用 ${POSTGRES_PASSWORD:?请设置密码} 模式强制显式配置

P3 — 代码质量

[P3] DRY: apiKey 生成逻辑在 UserStore 和 PGStore 中重复 (internal/admin/)

  • apiKeyUserStore.create()pgStore.create() 包含相同的 crypto/rand + hpk_ 前缀逻辑
  • 修复:提取 generateAPIKey() (string, error) 共享函数

[P3] 常量同步: Dialect 常量在 sqlutil 和 dbutil 中重复 (internal/sqlutil/, internal/dbutil/)

  • DialectSQLite/DialectPostgres 在两包中各定义一份,由 TestDialectConstantsSync 测试保障同步
  • 当前可接受,未来可考虑 internal/dbtypes 共享包

P0:0 P1:2 P2:3 P3:2 → PASS with suggestions

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — P0/P1 无阻断项,仅 P2/P3 建议项(详见上方 COMMENT review)。

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: CHANGES_REQUESTED | P0:0 P1:1 P2:4 P3:3

P1 — 必须修复

[P1] 正确性: Migration 012 Down 迁移会截断 BIGINT 数据 (internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql)

Up 迁移将 INTEGER 列改为 BIGINT,但 002/005/007/009 的建表语句已经定义为 BIGINT NOT NULL,因此 Up 是无害的 no-op。然而 Down 迁移尝试将 BIGINT 缩回 INTEGER

ALTER TABLE "events" ALTER COLUMN "created_at" TYPE INTEGER;

对于超过 2,147,483,647 的 Unix 毫秒时间戳(这是此 PR 要解决的问题),Down 迁移会静默截断数据

修复: 删除 migration 012(Up 是 no-op,Down 有害)。列已在建表时定义为 BIGINT。


P2 — 建议修复

[P2] 架构兼容: sessions 表使用 TIMESTAMP,其他 PG 表使用 BIGINT (internal/session/sql/migrations-postgres/001_init.pg.sql)

sessions 表的 created_at/updated_at/expires_at 使用 TIMESTAMP,而 events/cron_jobs/chat_access/turns 全部使用 BIGINT。虽然 pgx 能正确映射 time.TimeTIMESTAMP,但这种不一致在跨表 JOIN 时可能引发隐式类型转换问题。

建议: 统一为 BIGINT(毫秒时间戳),与项目中其他表保持一致。

[P2] 架构兼容: PG store 共享 SQLite SQL 文件存在维护风险 (internal/eventstore/pg_store.go)

PG store 通过 embed.FS 加载 SQLite 的 .sql 查询文件,仅做 Rebind() 转换占位符。如果查询中包含 SQLite 特有语法(如 datetime() 函数),会在 PG 运行时报错。当前查询均为标准 SQL,但耦合关系脆弱。

建议: 为 PG 提供独立的查询文件,或添加集成测试验证查询在真实 PG 上的可执行性。

[P2] 安全: docker-entrypoint.sh envsubst 可能注入 YAML 不兼容字符 (docker/docker-entrypoint.sh:65-73)

envsubst 将环境变量直接注入 YAML 配置文件。如果 token/password 包含 :/#/| 等 YAML 特殊字符,会产生非法 YAML。

建议: 依赖 Viper 原生 BindEnv 处理环境变量(已在 config.go 中实现),移除 shell 层 envsubst。

[P2] 测试质量: PG cron store 测试使用 int 而非 bool 模拟 BOOLEAN 列 (internal/cron/pg_store_test.go)

sqlmock 测试为 enabled/delete_after_run/silent 列传递 int(0/1),而生产代码扫描为 bool(PG BOOLEAN)。sqlmock 不强制类型匹配,因此测试通过但未验证 pgx 的实际 wire format。同理 TestPGStore_GetJob_NotFound 应 mock sql.ErrNoRows 而非 ErrJobNotFound

修复: 将测试数据改为 true/false,NotFound 测试改为 WillReturnError(sql.ErrNoRows)


P3 — 可选改进

[P3] 代码质量: dbutil.DB 嵌入 *sql.DB 与项目约定不一致 (internal/dbutil/db.go)

CLAUDE.md 禁止嵌入(Mutex 显式 mu 不嵌入)。虽然 *sql.DB 不是 mutex 且此嵌入是 Go 惯用模式,但建议在代码注释或 AGENTS.md 中说明此为例外。

[P3] 代码质量: b2i 函数命名不清晰 (internal/cron/pg_store.go)

建议重命名为 boolToInt,与 SQLite store 中的同名函数保持一致。

[P3] 文档: EffectiveWALMode 零值回退逻辑不直观 (internal/config/config.go)

显式设置 sqlite.wal_mode: false 无效(零值回退到 legacy 字段),建议在 config.yaml 注释中说明。


总评

架构设计清晰:Dialect 抽象、Rebind 状态机、WriteMu no-op、RETURNING id 处理均为高质量实现。主要风险集中在 Migration 012 的 Down 迁移数据截断(P1),建议删除该迁移后即可合并。

P0:0 P1:1 P2:4 P3:3 → WARN

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

自动化 Code Review — hotplex-ai

Verdict: PASS with suggestions | P0:0 P1:0 P2:3 P3:2


[P2] 正确性: sessions 表 created_at/updated_at/expires_at 类型为 TIMESTAMP 而非 BIGINT (internal/session/sql/migrations-postgres/001_init.pg.sql:14-17)

PG 迁移 001 中 sessions 表的时间列使用 TIMESTAMP 类型,而 events/turns/cron_jobs/chat_access 表均使用 BIGINT(Unix ms)。这不一致会导致:

  • session store 传入 time.Time 值(Go natural),但 eventstore/cron 传入 int64 Unix ms
  • 跨表 JOIN 时间比较需要类型转换
  • migration 012 只修复了非 sessions 表的 BIGINT 对齐

建议:在迁移中统一使用 BIGINT,或在代码注释中明确 sessions 使用 TIMESTAMP 是有意为之的设计决策。

[P2] 正确性: migration 012 Down 路径将 BIGINT 截断为 INTEGER (internal/session/sql/migrations-postgres/012_fix_timestamp_int8.pg.sql:13-18)

Down 路径 ALTER COLUMN ... TYPE INTEGER 会将超出 int4 范围(2,147,483,647)的 Unix ms 时间戳静默截断,导致数据损坏。当前时间戳 ~1.7×10¹² 已超出 int4 范围。

建议:Down 路径应使用 TYPE BIGINT 保持安全(即 no-op),或直接移除 Down 部分(goose 支持 Down 为空)。

[P2] 架构: 多个包定义同名 pgStore 类型 (internal/admin, internal/cron, internal/eventstore, internal/messaging)

5 个包各自定义了 type pgStore struct,使用 grep -rn 'type pgStore struct' 可见。虽然 Go 的包隔离使这在编译期安全,但在调试日志和 stack trace 中难以区分来源。

建议:重命名为带包上下文的名称(如 adminPGStorecronPGStore),或在日志字段中添加明确的包标识。

[P3] 代码质量: EffectiveWALMode 的零值回退语义不直观 (internal/config/config.go)

EffectiveWALMode() 的注释明确说明了「显式设置 WALMode=false 会穿透到 legacy 字段」,但这意味着结构化配置中的 wal_mode: false 被忽略。对于新用户直接使用 sqlite: 块配置时,关闭 WAL 需要同时设置旧字段,违反直觉。

建议:使用指针 *bool 区分「未设置」和「显式 false」,或添加 WALModeExplicit bool 字段。

[P3] 代码质量: apikey SQLite store 的 get/list 方法未使用 Rebind (internal/admin/apikey_handlers.go:107,64)

SQLite apiKeyUserStoreget()list() 方法硬编码 ? 占位符,而 PG pgStore 使用 Rebind()。虽然 SQLite 路径正确,但若未来共享 SQL 文件将产生问题。

建议:统一两个 store 的查询构建模式。


总结: P0/P1 无发现。PostgreSQL 后端实现整体架构清晰——dialect 抽象层(Rebind 状态机、BoolValue、IsUniqueViolation)设计良好,gateway 的条件 store 注入模式简洁,PG 迁移编号与 SQLite 对齐便于维护。P2 级别的 timestamp 类型和 migration 012 Down 路径建议在后续迭代中修复。

hotplex-ai
hotplex-ai previously approved these changes May 27, 2026
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — P0/P1 无发现,仅 P2/P3 建议项。详见上方 COMMENT review。

The Down migration used TYPE INTEGER which would silently truncate
Unix ms timestamps (~1.7×10¹² exceeds int4 max). Replaced with no-op
since reverting to INTEGER is unsafe.
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — hotplex-ai

Verdict: PASS with suggestions | P0:0 P1:0 P2:1 P3:0

[P2] 5-6 处 bare return nil, err 缺少 fmt.Errorf("...: %w", err) 包装

CLAUDE.md 要求所有错误返回使用 %w 包装保留上下文。以下位置返回未包装的错误:

  • internal/dbutil/db.goInitSQLiteDB 失败后 return nil, err 丢失 dbutil: 上下文
  • internal/cron/pg_store.go:ListscanJobRowPG 失败后 return nil, err 丢失 list 操作上下文
  • internal/cron/pg_store.go:scanJobRowPGdecodeJobFields 失败后 return nil, err 无调用者上下文
  • internal/eventstore/pg_store.goscanEvents/scanTurnsPG 失败后 return nil, err(3处)
  • internal/session/pg_store.go:GetSessionsByState — 查询失败后 return nil, err 丢失操作上下文

建议:统一添加 fmt.Errorf("component: operation: %w", err) 包装。部分内层函数已包装(缓解影响),但 dbutil/db.gosession/pg_store.go:NewPGStore 等路径在驱动级错误时会产生无上下文的错误信息。

亮点:

  • 方言抽象(dbutil.Dialect + rebind() 状态机)设计干净,正确处理单引号/双引号/注释/dollar-quote 中的 ?
  • PG/SQLite 双路径的 WriteMu nil-safe dispatch 正确无误
  • 迁移覆盖完整(001-012),PG 类型映射准确(BOOLEAN/BIGSERIAL/BIGINT)
  • docker-entrypoint.sh 的 envsubst 显式白名单 + DSN 排除是好的安全实践
  • 所有 40+ 新测试函数使用 t.Parallel() + testify/require,表驱动模式覆盖良好

🤖 Generated by hotplex-ai automated review system

hotplex-ai
hotplex-ai previously approved these changes May 27, 2026
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — hotplex-ai

Verdict: APPROVED | P0:0 P1:0 P2:1 P3:0

详见上方 COMMENT。P2 问题(错误包装)不阻塞合并。

🤖 Generated by hotplex-ai automated review system

…with fmt.Errorf

P2: Add fmt.Errorf("...: %w", err) context to 8 bare error returns across
dbutil, cron, eventstore, and session PG stores per CLAUDE.md error convention.
Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — hotplex-ai

Verdict: APPROVED | P0:0 P1:0 P2:0 P3:0

Scope

PostgreSQL 双数据库支持迁移:db.driver: "postgres" 配置切换,涵盖 session/eventstore/cron/admin API key 四大存储模块的 PG 实现,Docker Compose PG 编排,dbutil 方言抽象层。

Findings

No issues with confidence ≥ 75%.

Verified (PASS)

  • CLAUDE.md 合规: Mutex 显式 mu 字段、fmt.Errorf("%w") 错误包装、log/slog 日志、filepath.Join() 跨平台路径 — 全部通过
  • SQL 注入防护: 所有查询使用参数化占位符,PG store 正确使用 RETURNING id 替代 LastInsertId()
  • WriteMu 迁移: PG 路径 WriteMu 为 no-op(单连接 PGX 无 SQLITE_BUSY),SQLite 路径保持不变
  • Migration 一致性: PG migration 编号与 SQLite 对齐,goose 基于 FS 只扫描 PG 目录
  • envsubst 安全: HOTPLEX_DB_POSTGRES_DSN 不在 allowlist 中(通过 Viper BindEnv 处理),凭证不写入运行时配置文件
  • Docker 默认值: sslmode=disable 仅用于 Docker 内网,生产部署应覆盖

Observations (< 75% confidence, not blocking)

  • cron/pg_store.go UpsertByName 未检查 RowsAffected()(PG upsert 场景下 0 rows + nil err 不太可能发生)
  • NewWriteMu(dialect) 用 string 而非 typed constant,可加防御性校验(internal 包,影响有限)
  • apiKeyUserStore 仍缺 WriteMu 保护(预存问题,非本 PR 引入)

Copy link
Copy Markdown
Owner

@hotplex-ai hotplex-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — hotplex-ai

Verdict: APPROVED | P0:0 P1:0 P2:0 P3:0

Scope

PostgreSQL 双数据库支持迁移:db.driver: "postgres" 配置切换,涵盖 session/eventstore/cron/admin API key 四大存储模块的 PG 实现,Docker Compose PG 编排,dbutil 方言抽象层。

Findings

No issues with confidence >= 75%.

Verified (PASS)

  • CLAUDE.md 合规: Mutex 显式 mu 字段、fmt.Errorf("%w") 错误包装、log/slog 日志、filepath.Join() 跨平台路径 — 全部通过
  • SQL 注入防护: 所有查询使用参数化占位符,PG store 正确使用 RETURNING id 替代 LastInsertId()
  • WriteMu 迁移: PG 路径 WriteMu 为 no-op(单连接 PGX 无 SQLITE_BUSY),SQLite 路径保持不变
  • Migration 一致性: PG migration 编号与 SQLite 对齐,goose 基于 FS 只扫描 PG 目录
  • envsubst 安全: HOTPLEX_DB_POSTGRES_DSN 不在 allowlist 中(通过 Viper BindEnv 处理),凭证不写入运行时配置文件
  • Docker 默认值: sslmode=disable 仅用于 Docker 内网,生产部署应覆盖

Observations (< 75% confidence, not blocking)

  • cron/pg_store.go UpsertByName 未检查 RowsAffected()(PG upsert 场景下 0 rows + nil err 不太可能发生)
  • NewWriteMu(dialect) 用 string 而非 typed constant,可加防御性校验(internal 包,影响有限)
  • apiKeyUserStore 仍缺 WriteMu 保护(预存问题,非本 PR 引入)

@hrygo hrygo merged commit cbd13d4 into main May 27, 2026
6 of 12 checks passed
@hrygo hrygo deleted the feat/dual-db-support branch May 27, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants