Files
shaguabijia-app-server/docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md
T
guke 886e781a4f docs: 本地开发切 Docker PG 设计文档
新增 spec:让 dev 运行与 pytest 都跑在 Docker 化的 PostgreSQL 上,退掉
SQLite,使 admin 报表聚合可用 PG 专有函数(不再内存聚合)。方案 A:
docker-compose + scripts/ensure_pg.py,run.bat/run.sh/conftest 共用。

顺带把 .worktrees/ 加入 .gitignore(superpowers 隔离工作区)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:20:11 +08:00

209 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 本地开发切 Docker PostgreSQL —— 设计文档
> 让本地开发与测试统一跑在 Docker 化的 PostgreSQL 上,彻底退掉 SQLite。
> `run.bat` / `run.sh` 启动时自动检测本机 PG,没起就拉起 Docker → 起 PG 容器(镜像缺失先拉),
> 目的是让开发/大模型能放心用 PG 专有的高效聚合函数,不再为兼容 SQLite 而退化成"取基础数据后内存聚合"。
>
> 状态:已定稿(待用户复核)。作者对话日期:2026-07-08。
> 关联:[postgres-migration.md](../../database/postgres-migration.md)(切引擎完整步骤)、`scripts/init_postgres.py`(生产原生 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.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.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 根目录)
```yaml
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`
```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,主流程:
1. **sqlite 守卫**:若 `DATABASE_URL``sqlite` 开头 → 打印"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
```ini
DATABASE_URL=sqlite:///./data/app.db
```
改为
```ini
# 本地开发/测试统一用 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 ps``shaguabijia-pg` 健康;`psql`/客户端能连 `shaguabijia``shaguabijia_test` 两个库。
3. PG 已在跑时再次 `run`,`ensure_pg` 秒过(不重复拉容器)。
4. `pytest``shaguabijia_test` 跑;红用例全部修绿(PG 严格性暴露的问题)。
5. `.env``DATABASE_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.sh``run.bat` 接入 `ensure_pg`
- [ ] `.env.example``DATABASE_URL` 切 PG
- [ ] `tests/conftest.py``shaguabijia_test` + 调 `ensure_pg` + fixture 改 drop/create
- [ ]`pytest`,按迁移指南 §2.2 修红用例
- [ ] 文档:`postgres-migration.md` 增「本地 Docker 一键起」节;`CLAUDE.md` DB 段;`init_postgres.py` 注释
- [ ] `.gitignore` 确认 `data/` 已忽略(compose 用命名卷,不落项目目录,无需额外忽略)