- 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>
20 KiB
本地开发切 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 专有能力(窗口函数、FILTER、JSONB 操作符、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.bat 与 run.sh 都改 |
仓库一贯保持两者同步 |
| D7 | 镜像 | postgres:16-alpine |
对齐生产 PG16;alpine 体积小 |
3. 现状(改动前)
- 配置:
app/core/config.pyDATABASE_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.py从settings.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 data→alembic 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)(供 run 以 python -m scripts.ensure_pg 跑)。ensure() 从 app.core.config.settings 读 DATABASE_URL,解析 host/port,主流程:
- sqlite 守卫:若
DATABASE_URL以sqlite开头 → 打印"dev 已切 PG,请把 .env 的 DATABASE_URL 改成postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"→ 非 0 退出(D4)。 - TCP 探测
host:port(stdlibsocket,超时 1s)。通 → 打印"✅ PG 已就绪"直接返回(幂等:PG 已在跑时开销≈一次握手)。 - 不通 →
docker version探 CLI;缺失 → 中文报错"请先安装 Docker Desktop:https://www.docker.com/products/docker-desktop/" → 非 0 退出。 docker info探守护进程;不通 → 按平台启动:- Windows:
start "" "%ProgramFiles%\Docker\Docker\Docker Desktop.exe"(找不到则报错让用户手动开) - macOS:
open -a Docker - Linux:不自动 sudo,打印
sudo systemctl start docker让用户执行后重试 然后轮询docker info直到就绪或超时(默认 120s,每 3s 一次,打印进度)。
- Windows:
docker compose up -d(Compose 在镜像缺失时自动拉取,首用拉 alpine ~90MB;有进度输出)。- 轮询 healthcheck(
docker inspect的 health 状态)/ TCP 直到 PG 接受连接(默认 60s 超时)。 - 幂等确保测试库存在(兼容"老 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 设定):
- 设
os.environ["DATABASE_URL"] = "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"(测试库,永不碰 dev 业务库)。 - 调
scripts.ensure_pg.ensure()(保证容器在 + 测试库在;PG 已在时几乎零开销)。 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 vsTIMESTAMPTZ、事务边界(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 冷启(~30–60s)+ 拉镜像(
数十秒数分钟,视网络)。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. 验收标准
- 全新机器(装了 Docker Desktop、
.env从.env.example复制)执行run.bat(或run.sh):自动拉起 Docker→起 PG 容器→建库→alembic upgrade head→uvicorn 起在 8770,无手动装 PG 步骤。 docker ps见shaguabijia-pg健康;psql/客户端能连shaguabijia与shaguabijia_test两个库。- PG 已在跑时再次
run,ensure_pg秒过(不重复拉容器)。 pytest连shaguabijia_test跑;红用例全部修绿(PG 严格性暴露的问题)。.env的DATABASE_URL改回 sqlite 时,run/pytest硬失败并打印正确的 PG 串。- 能在
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.sh、run.bat接入ensure_pg.env.example的DATABASE_URL切 PGtests/conftest.py切shaguabijia_test+ 调ensure_pg+ fixture 改 drop/create- 跑
pytest,按迁移指南 §2.2 修红用例 - 文档:
postgres-migration.md增「本地 Docker 一键起」节;CLAUDE.mdDB 段;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.py 的 ensure())
- sqlite 分支(原
return False)→ 打印多行降级横幅后return True。横幅点明:PG 专有 SQL/严格类型在此模式不被验证、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把DATABASE_URL改回 PG 串。 - 无 docker CLI 分支(原仅提示装 Docker +
return False)→ 追加一句「装不了 Docker?把.env的DATABASE_URL改成sqlite:///./data/app.db可降级运行」;仍return False(run 脚本照常退出,开发者需显式改 .env 再跑)。 - 常量:新增
SQLITE_URL = "sqlite:///./data/app.db"(逃生舱指路用);SQLITE_FIX_HINT重命名PG_URL(降级横幅"改回 PG"引用)。 - 更新模块 docstring 中「全程无 SQLite 兜底」一句,改述为「无 Docker/sqlite URL 时【显式】降级 SQLite(带醒目告警),测试侧不降级」。
其余全不动:run.sh/run.bat(sqlite 下 ensure 返 True → 照常 alembic upgrade head + uvicorn)、docker-compose.yml、app/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.yml 增 redis 服务即可,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 up 报 container 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):
- 模块级
os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")—— 钉死项目名,无论从哪个 目录/worktree 跑都是同一个项目、同一个卷shaguabijia_pgdata,所有docker compose up/exec一致。 _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 清理两个旧卷。