Files
shaguabijia-app-server/docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md
T
guke e052fb778b feat(dev): ensure_pg 加显式 SQLite 逃生舱 + 修 Docker 检测/项目名撞名;新增 run8771 起 admin
- ensure_pg: 无 Docker 时【显式】降级 SQLite(带醒目降级横幅),不再硬失败(反转设计 D4)
- ensure_pg: _docker_cli_ok 改用 `docker --version`(纯客户端),修「装了 Docker 但没启动」
  被 `docker version`(要连 daemon)误判成「没装 CLI」而绕过自动拉起(需求②)
- ensure_pg: 钉死 COMPOSE_PROJECT_NAME=shaguabijia + 清「同名但非本项目」残留容器,
  修跨目录/worktree 切换时 container_name 撞名 + pgdata 卷分裂
- run8771.bat: 新增 admin 后端(:8771)启动脚本,跑 app.admin.main:admin_app(run.bat 对等版)
- 设计文档 §10 记录以上 D4 反转与三处修复

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 19:12:29 +08:00

20 KiB
Raw Blame History

本地开发切 Docker PostgreSQL —— 设计文档

让本地开发与测试统一跑在 Docker 化的 PostgreSQL 上,彻底退掉 SQLite。 run.bat / run.sh 启动时自动检测本机 PG,没起就拉起 Docker → 起 PG 容器(镜像缺失先拉), 目的是让开发/大模型能放心用 PG 专有的高效聚合函数,不再为兼容 SQLite 而退化成"取基础数据后内存聚合"。

状态:已定稿(待用户复核)。作者对话日期:2026-07-08。 关联:postgres-migration.md(切引擎完整步骤)、scripts/init_postgres.py(生产原生 PG 初始化)。 ⚠️ 2026-07-27 增补(见 §10):D4 已从「sqlite 硬失败」松为「显式 SQLite 逃生舱」。§1-9 描述的是初版「彻底退掉 SQLite」设计;凡涉及「无 Docker / DATABASE_URL 是 sqlite 时如何处理」,以 §10 为准(测试仍只跑 PG 不变)。


1. 背景与目标

问题

当前开发环境默认用 SQLite(DATABASE_URL=sqlite:///./data/app.db),生产用 PostgreSQL 16。两套引擎并存,导致写数据访问代码时(尤其 app/admin/repositories/ 的报表聚合)为了"两边都能跑",放弃 PG 专有能力(窗口函数、FILTERJSONB 操作符、GROUPING SETS 等),改成"先查基础数据、再在 Python 内存里聚合"——既慢又啰嗦。

目标

本地开发与测试都跑在 PG 上,SQLite 退出本地开发闭环。之后写 PG 专有 SQL 时:

  • 开发运行时(run.bat/run.sh)直接连 PG,手动验证可行;
  • pytest 也连 PG,PG 专有 SQL 在被测代码路径里也安全,不会因 SQLite 而挂——这是"双库兼容代码彻底消失"的必要条件

非目标(本期不做)

  • 不动生产部署(生产仍是原生 PG16 + systemd,无 Docker;init_postgres.py 保持不变)。
  • 不做数据搬迁(MVP 阶段无真实用户数据,详见迁移指南背景假设)。
  • 不接 CI(仓库当前无 .github/workflows;若将来加 CI,再单独让 CI 起 PG service)。
  • 不引入 Redis / testcontainers / 连接池中间件。

2. 决策记录(本次对话已拍板)

# 决策点 结论 理由
D1 PG 覆盖范围 dev 运行 + 测试都切 PG 只切运行时的话,被 SQLite 测试覆盖的代码路径(如 test_cps_admin.py 覆盖的 admin/repositories/cps.py)仍不能用 PG 专有 SQL,双库代码不会真正消失
D2 打包方式 方案 A:Compose + scripts/ensure_pg.py 唯一真正需要定制的部分(启动 Docker 守护进程、等 PG 就绪)集中到一个跨平台模块,run.bat/run.sh/conftest.py 共用;声明式的容器/卷/健康检查交给 Compose
D3 宿主端口 5432(与生产/文档一致) 边界:若本机已有原生 PG 占 5432,ensure_pg 会探测到"PG 已在"直接复用它(可能连到不带业务库的实例)——见 §7 风险,文档提示
D4 dev 下 DATABASE_URL 仍是 sqlite 硬失败(打印一行 fix 后非 0 退出) 彻底断掉 SQLite 退路,符合"让大家都用 PG"的目标
D5 dev 数据库密码 固定 shaguabijia_dev_pw,写进 compose + .env.example 本地容器仅绑 localhost,非机密;保证 .env.example 复制即可用。生产密码另由 init_postgres.py 强随机生成,不复用
D6 改哪些启动脚本 run.batrun.sh 都改 仓库一贯保持两者同步
D7 镜像 postgres:16-alpine 对齐生产 PG16;alpine 体积小

3. 现状(改动前)

  • 配置:app/core/config.py DATABASE_URL 默认 sqlite:///./data/app.db,pydantic-settings 从 .env 读(环境变量优先级高于 .env 文件)。
  • 引擎:app/db/session.py_is_sqlite = DATABASE_URL.startswith("sqlite") 分流——SQLite 加 check_same_thread=False、不建池;非 SQLite 加 pool_size=10/max_overflow=20/pool_recycle=3600已天然支持 PG,无需改。
  • 迁移:alembic/env.pysettings.DATABASE_URL 读连接串,render_as_batch 仅对 sqlite 开;PG 下自动关。无 psycopg2 硬编码,切 PG 无需改。
  • 驱动:pyproject.toml 已装 psycopg[binary]>=3.1(psycopg3)。URL scheme 必须 postgresql+psycopg://(裸 postgresql:// 会被 SQLAlchemy 路由到未安装的 psycopg2 → ModuleNotFoundError)。
  • 测试:tests/conftest.py 在 import app 前把 DATABASE_URL 设成临时文件 SQLite;session 级 autouse fixture 做 Base.metadata.create_all(engine) / 结束 drop_all(schema 来自 model 而非 alembic,无逐用例 rollback,全会话共享一个库)。
  • 启动脚本:run.bat / run.sh 均为:校验 .env 存在 → mkdir dataalembic upgrade head → uvicorn 监听 0.0.0.0:8770(.sh--reload --reload-dir app)。
  • 现有 PG 资产:scripts/init_postgres.py(交互式:建用户/建库/授权/写 .env/跑迁移,面向已装好的原生 PG)、docs/database/postgres-migration.md(切引擎完整步骤,含 §2 测试切 PG、§2.2 会暴露的真 bug 清单)。
  • CI:无(.github/workflows 不存在),故测试切 PG 无 CI 联动负担。

4. 方案详解

4.1 新增 docker-compose.yml(app-server 根目录)

services:
  postgres:
    image: postgres:16-alpine
    container_name: shaguabijia-pg
    environment:
      POSTGRES_USER: shaguabijia_app
      POSTGRES_PASSWORD: shaguabijia_dev_pw
      POSTGRES_DB: shaguabijia
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./docker/initdb:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
      interval: 3s
      timeout: 3s
      retries: 20
volumes:
  pgdata:
  • POSTGRES_USER 设定后,该用户以超级用户身份创建并拥有 POSTGRES_DB,故能再建测试库。
  • 首启 initdb 脚本建测试库(见 4.2)。命名卷 pgdata 让数据跨重启留存。

4.2 新增 docker/initdb/01-create-test-db.sql

-- 仅在 pgdata 卷首次初始化时执行一次。以 shaguabijia_app(超级用户)连 shaguabijia 库运行。
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;

4.3 新增 scripts/ensure_pg.py(纯标准库 + docker CLI,跨平台)

对外同时暴露可导入函数 ensure()(供 conftest.py 直接调)和 CLI 入口 if __name__ == "__main__": sys.exit(0 if ensure() else 1)(供 runpython -m scripts.ensure_pg 跑)。ensure()app.core.config.settingsDATABASE_URL,解析 host/port,主流程:

  1. sqlite 守卫:若 DATABASE_URLsqlite 开头 → 打印"dev 已切 PG,请把 .env 的 DATABASE_URL 改成 postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"→ 非 0 退出(D4)。
  2. TCP 探测 host:port(stdlib socket,超时 1s)。通 → 打印" PG 已就绪"直接返回(幂等:PG 已在跑时开销≈一次握手)。
  3. 不通 → docker version 探 CLI;缺失 → 中文报错"请先安装 Docker Desktop:https://www.docker.com/products/docker-desktop/" → 非 0 退出。
  4. docker info 探守护进程;不通 → 按平台启动:
    • Windows:start "" "%ProgramFiles%\Docker\Docker\Docker Desktop.exe"(找不到则报错让用户手动开)
    • macOS:open -a Docker
    • Linux:不自动 sudo,打印 sudo systemctl start docker 让用户执行后重试 然后轮询 docker info 直到就绪或超时(默认 120s,每 3s 一次,打印进度)。
  5. docker compose up -d(Compose 在镜像缺失时自动拉取,首用拉 alpine ~90MB;有进度输出)。
  6. 轮询 healthcheck(docker inspect 的 health 状态)/ TCP 直到 PG 接受连接(默认 60s 超时)。
  7. 幂等确保测试库存在(兼容"老 pgdata 卷没跑过 initdb"的情况): docker compose exec -T postgres psql -U shaguabijia_app -tc "SELECT 1 FROM pg_database WHERE datname='shaguabijia_test'",不存在则 CREATE DATABASE shaguabijia_test OWNER shaguabijia_app

失败即清晰中文报错 + 非 0 退出,全程不回退 SQLite。所有超时可用环境变量覆盖(如 ENSURE_PG_DOCKER_TIMEOUT)。

4.4 run.bat / run.sh 接线

alembic upgrade head 之前插一行调用,失败即退出:

  • run.sh:"$PY" -m scripts.ensure_pg(set -e 已在,失败自动退出)
  • run.bat:call "%PY%" -m scripts.ensure_pg + if errorlevel 1 exit /b 1

其余逻辑不动(mkdir data 保留给 media 等落盘目录)。

4.5 .env.example 默认切 PG

DATABASE_URL=sqlite:///./data/app.db

改为

# 本地开发/测试统一用 Docker PG(run.bat/run.sh 会自动拉起容器;详见 docs/database/postgres-migration.md §本地 Docker 一键起)。
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia

4.6 tests/conftest.py 切 PG

调整顶部顺序(仍必须在 import app.* 之前完成 env 设定):

  1. os.environ["DATABASE_URL"] = "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"(测试库,永不碰 dev 业务库)。
  2. scripts.ensure_pg.ensure()(保证容器在 + 测试库在;PG 已在时几乎零开销)。
  3. import app...。 session 级 autouse fixture:改为 Base.metadata.drop_all(engine)create_all(engine)(开头先清干净,防持久卷里上一次跑残留的表/数据)→ yield → 结束 drop_all;删掉临时 SQLite 文件相关代码。

预期:部分用例会因 PG 的严格性变红(SQLite 宽松、PG 严格),按迁移指南 §2.2 逐个修——常见为:字符串/整数隐式比较、datetime.utcnow() naive vs TIMESTAMPTZ、事务边界(current transaction is aborted)。这既是工作量也是本次改造的直接收益(暴露真 bug)。实现阶段需为"跑 pytest 并修红用例"单列步骤。

4.7 app/db/session.py

无需改动——_is_sqlite 为假时自动走 PG 池化分支。

4.8 文档

  • docs/database/postgres-migration.md 增一节「本地 Docker 一键起 PG(推荐)」,指向 compose + ensure_pg,并说明它替代了 §1.1 的手动 brew/apt 装 PG。
  • CLAUDE.md 的 DB 段注明:dev/test = Docker PG(run 自动拉起);prod = 原生 PG(init_postgres.py)。
  • scripts/init_postgres.py 顶部注释补一句"本脚本面向生产原生 PG;本地开发用 docker-compose + scripts/ensure_pg"。

5. 连接参数汇总

镜像 postgres:16-alpine
容器名 shaguabijia-pg
宿主端口 5432
超级/业务用户 shaguabijia_app
dev 密码 shaguabijia_dev_pw(本地非机密)
业务库(dev 运行) shaguabijia
测试库(pytest) shaguabijia_test
dev DATABASE_URL postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
test DATABASE_URL postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test
数据持久化 命名卷 pgdata

6. 失败处理矩阵(无 SQLite 兜底)

情形 ensure_pg 行为
DATABASE_URL 是 sqlite 打印应改成的 PG 串 → 非 0 退出
PG 已在跑(TCP 通) 打印"已就绪" → 返回 0(跳过 docker)
无 docker CLI 提示装 Docker Desktop + 官网链接 → 非 0 退出
docker 守护进程未起 尝试按平台启动 Docker Desktop,轮询到就绪;超时则报错 → 非 0 退出
镜像缺失 docker compose up -d 自动拉取(不额外处理)
容器起了但 PG 未 ready 轮询 healthcheck 到超时;超时报错 → 非 0 退出
老 pgdata 卷缺测试库 幂等 CREATE DATABASE shaguabijia_test

7. 风险与边界

  • 端口占用(原生 PG 撞 5432):D3 选了 5432。若开发机已有原生 PG 监听 5432,step 2 的 TCP 探测会判"PG 已在"并复用它——但那个实例可能没有 shaguabijia/shaguabijia_test 库或用户,后续 alembic upgrade head / 测试会报连不上库或认证失败。缓解:文档提示"本机别再单独跑原生 PG";报错信息里提示检查是不是撞了原生 PG。
  • 首次启动慢:首用需 Docker Desktop 冷启(~3060s)+ 拉镜像(数十秒数分钟,视网络)。ensure_pg 全程打印进度,超时可配。
  • DATABASE_URL 环境变量优先级:pydantic-settings 里 shell 环境变量优先于 .env。若开发者 shell 残留旧的 DATABASE_URL(如指向 sqlite),会盖过 .env。sqlite 守卫(D4)能挡住 sqlite 残留;但若残留的是另一个 PG 串,则以它为准——文档提示。
  • 持久卷脏状态:测试用 drop_all→create_all 开头清库,避免上次崩溃残留污染;dev 业务库随卷留存(符合预期)。
  • Docker 未安装/公司网络拉镜像受限:硬失败并给出明确指引;不提供 SQLite 退路是刻意选择(D4/目标)。

8. 验收标准

  1. 全新机器(装了 Docker Desktop、.env.env.example 复制)执行 run.bat(或 run.sh):自动拉起 Docker→起 PG 容器→建库→alembic upgrade head→uvicorn 起在 8770,无手动装 PG 步骤。
  2. docker psshaguabijia-pg 健康;psql/客户端能连 shaguabijiashaguabijia_test 两个库。
  3. PG 已在跑时再次 run,ensure_pg 秒过(不重复拉容器)。
  4. pytestshaguabijia_test 跑;红用例全部修绿(PG 严格性暴露的问题)。
  5. .envDATABASE_URL 改回 sqlite 时,run/pytest 硬失败并打印正确的 PG 串。
  6. 能在 admin/repositories/ 里写一段 PG 专有聚合 SQL(如带 FILTER (WHERE ...) 的聚合),run 下手动跑通、相应 pytest 也通过——即"双库兼容负担消失"的实证。

9. 待实现清单(供 writing-plans 拆解)

  • 新增 docker-compose.yml
  • 新增 docker/initdb/01-create-test-db.sql
  • 新增 scripts/ensure_pg.py(TCP 探测 / 启 Docker Desktop 轮询 / compose up / 等 healthy / 幂等建测试库 / sqlite 守卫)
  • run.shrun.bat 接入 ensure_pg
  • .env.exampleDATABASE_URL 切 PG
  • tests/conftest.pyshaguabijia_test + 调 ensure_pg + fixture 改 drop/create
  • pytest,按迁移指南 §2.2 修红用例
  • 文档:postgres-migration.md 增「本地 Docker 一键起」节;CLAUDE.md DB 段;init_postgres.py 注释
  • .gitignore 确认 data/ 已忽略(compose 用命名卷,不落项目目录,无需额外忽略)

10. 增补(2026-07-27):D4 反转 —— 显式 SQLite 逃生舱

背景:§2 的 D4 定为「dev 下 DATABASE_URL 仍是 sqlite → 硬失败」,目的是彻底断掉 SQLite 退路。实践中这对「本机装不了 Docker」的开发者过于刚性——直接被卡死、连跑都跑不起来。本次(2026-07-27 对话)把 D4 从「硬失败」松成「显式逃生舱」:工具从不替你静默切库,但会在没 Docker 时告诉你怎么手动降级,且降级时每次启动都醒目告警。

10.1 决策更新

# 原决策 新决策 理由
D4 dev sqlite URL → 硬失败退出 放行 + 每次打印醒目降级横幅(仍非静默) 已手动改 .env=sqlite = 开发者的显式选择,尊重它;但吼一嗓子防止忘了自己在降级、把 PG 专有 SQL 提交上去
D8(新) (无) 无 docker CLI 时,报错里追加逃生舱指路(改 .env=sqlite),但仍非 0 退出 「显式」的关键:工具不替你切库,只指路;开发者改完 .env 再跑一次才真正降级

未变:D1(测试仍只跑 PG)、D2-D3、D5-D7 全部保留。逃生舱只作用于 run.sh/run.bat 运行时;pytest 仍写死连 PG 测试库(conftest.py 传 PG URL,sqlite 分支根本不触发),没 Docker 就 raise、跑不了完整套件——这正是 D1「测试上 PG 才能暴露真 bug」的初衷,刻意不给逃生舱。

10.2 代码改动(仅 scripts/ensure_pg.pyensure())

  1. sqlite 分支(原 return False)→ 打印多行降级横幅后 return True。横幅点明:PG 专有 SQL/严格类型在此模式不被验证、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把 DATABASE_URL 改回 PG 串。
  2. 无 docker CLI 分支(原仅提示装 Docker + return False)→ 追加一句「装不了 Docker?把 .envDATABASE_URL 改成 sqlite:///./data/app.db 可降级运行」;return False(run 脚本照常退出,开发者需显式改 .env 再跑)。
  3. 常量:新增 SQLITE_URL = "sqlite:///./data/app.db"(逃生舱指路用);SQLITE_FIX_HINT 重命名 PG_URL(降级横幅"改回 PG"引用)。
  4. 更新模块 docstring 中「全程无 SQLite 兜底」一句,改述为「无 Docker/sqlite URL 时【显式】降级 SQLite(带醒目告警),测试侧不降级」。

其余全不动:run.sh/run.bat(sqlite 下 ensure 返 True → 照常 alembic upgrade head + uvicorn)、docker-compose.ymlapp/db/session.py(SQLite 引擎分支本就保留为 fallback)、tests/conftest.py.env.example(默认仍 PG)。

10.3 改完后行为矩阵(覆盖用户列的 5 场景)

场景 DATABASE_URL ensure_pg 行为
① 无 Docker PG(默认) 报错 + 指逃生舱 → 退出;开发者改 .env=sqlite → 再跑 → 放行 + 降级横幅,alembic/uvicorn 跑 SQLite
② 有 Docker 未启动 PG 启 Docker Desktop → compose up → 等 ready → 建测试库(不变)
③ 有 Docker 已启动 PG compose up → 等 ready(不变)
④ PG 已在跑 PG TCP 通 → 秒过跳过 Docker(不变)
⑤ PG 起来后 任意 run 脚本 alembic upgrade head(不变;SQLite 走 render_as_batch)

10.4 风险

  • 降级被忽视:横幅仅在 run 启动时打印一次;若开发者用 IDE 直接起 uvicorn(绕过 run 脚本)则看不到。缓解:横幅足够醒目 + 文档强调;引入 app 启动期重复告警(YAGNI)。
  • 测试无 Docker 跑不了:刻意保留(D1)。文档提示无 Docker 者:要么装 Docker 跑全量测试,要么只在 CI/有 Docker 的机器上验证 PG 相关改动。

10.5 Redis 前瞻(不在本次)

§2 未涉及 Redis。②③ 场景未来若加 Redis 实例:在 docker-compose.ymlredis 服务即可,docker compose up -d 天然带起;仅当启动期有组件依赖 Redis 才需给 ensure_pg 加 redis readiness 探测。本次不做,方案对它友好。

10.6 附带修复:_docker_cli_ok 守护进程误判(2026-07-27)

诊断「装了 Docker Desktop 却报未检测到 docker」时发现的真 bug:_docker_cli_ok() 原用 docker version 判断 CLI 是否存在,但该命令要连 daemon,守护进程没起时退非零 → 把「Docker 装了但没启动」 误判成「没装 CLI」,ensure() 直接打印"请安装 Docker Desktop"并 return False,绕过了专为需求② 写的 _start_docker_daemon() 自动拉起逻辑——需求②(有 Docker 未启动 → 自动启动)因此从未真正生效。 修复:改用 docker --version(纯客户端、不连 daemon、退 0)。_docker_daemon_ok() 仍用 docker info (正确,该检查本就依赖 daemon)。实测机器:Docker Desktop 20.10.12 已装但引擎未起,修复前 _docker_cli_ok() 误报 False,修复后 True。

10.7 附带修复:固定 compose 项目名 + 清理残留同名容器(2026-07-27)

诊断「docker compose upcontainer name "/shaguabijia-pg" already in use」时发现的又一 bug:compose 项目名默认取运行目录 basename,在不同目录/worktree(如 local-dev-postgres-docker vs shaguabijia-app-server) 之间切换会各自成一个项目;而 docker-compose.yml 写死了 container_name: shaguabijia-pg(全局唯一名), 于是新项目 up 时要创建同名容器 → 撞上旧项目留下的那个 → 冲突。副作用:pgdata 卷也按项目名分裂 成 local-dev-postgres-docker_pgdata / shaguabijia-app-server_pgdata,数据被切成两半。

修复(均在 scripts/ensure_pg.py,docker-compose.yml 不动、容器名仍是 shaguabijia-pg):

  1. 模块级 os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia") —— 钉死项目名,无论从哪个 目录/worktree 跑都是同一个项目、同一个卷 shaguabijia_pgdata,所有 docker compose up/exec 一致。
  2. _compose_up() 前置 _remove_stale_container():若存在「同名但不属于本项目」的残留容器,先 docker rm -f 再 up(靠 docker ps --filter name/label 判归属;数据在命名卷里,删容器不丢)。旧目录/worktree 留下的 残留容器就此自动清掉,不需手动干预。

影响:本次修复后首跑,旧的 shaguabijia-pg(属项目 local-dev-postgres-docker)会被自动删除、在项目 shaguabijia 下重建,挂载全新的 shaguabijia_pgdata(空库,alembic upgrade head 重建表)。旧数据仍留在 local-dev-postgres-docker_pgdata 卷里(未删,可恢复);确认不需要后可 docker volume rm 清理两个旧卷。