diff --git a/docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md b/docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md index 2e19903..f9a3703 100644 --- a/docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md +++ b/docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md @@ -6,6 +6,7 @@ > > 状态:已定稿(待用户复核)。作者对话日期:2026-07-08。 > 关联:[postgres-migration.md](../../database/postgres-migration.md)(切引擎完整步骤)、`scripts/init_postgres.py`(生产原生 PG 初始化)。 +> ⚠️ **2026-07-27 增补(见 §10)**:D4 已从「sqlite 硬失败」松为「显式 SQLite 逃生舱」。§1-9 描述的是初版「彻底退掉 SQLite」设计;凡涉及「无 Docker / DATABASE_URL 是 sqlite 时如何处理」,**以 §10 为准**(测试仍只跑 PG 不变)。 --- @@ -206,3 +207,75 @@ session 级 autouse fixture:改为 **`Base.metadata.drop_all(engine)` → `creat - [ ] 跑 `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.py` 的 `ensure()`) + +1. **sqlite 分支**(原 `return False`)→ 打印多行降级横幅后 `return True`。横幅点明:PG 专有 SQL/严格类型在此模式**不被验证**、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把 `DATABASE_URL` 改回 PG 串。 +2. **无 docker CLI 分支**(原仅提示装 Docker + `return False`)→ 追加一句「装不了 Docker?把 `.env` 的 `DATABASE_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.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`): +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` 清理两个旧卷。 diff --git a/run8771.bat b/run8771.bat new file mode 100644 index 0000000..257527d --- /dev/null +++ b/run8771.bat @@ -0,0 +1,50 @@ +@echo off +REM Admin backend startup (Windows) - the :8771 peer of run.bat. +REM +REM Usage: +REM cd shaguabijia-app-server +REM run8771.bat +REM +REM Runs the ADMIN FastAPI app (app.admin.main:admin_app) on 127.0.0.1:8771 — +REM a SEPARATE process from run.bat (which runs app.main:app on 8770). The admin +REM web frontend (Next.js :3001) points at http://localhost:8771. Auto-reload on +REM code change. +REM +REM Prerequisite (first time): +REM conda activate pricebot ^&^& pip install -e . +REM copy .env.example .env ^&^& fill JWT_SECRET_KEY +REM +REM Tip: shaguabijia-admin-web\start.bat starts user-api(8770) + admin-api(8771) +REM + frontend(3001) in one go, if you prefer a single command. + +cd /d "%~dp0" + +REM Prefer the project virtualenv (.venv) so we never inherit a wrong +REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import +REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python. +set "PY=python" +if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe" + +if not exist .env ( + echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^) + exit /b 1 +) + +if not exist data mkdir data + +REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed) +call "%PY%" -m scripts.ensure_pg +if errorlevel 1 ( + echo [X] ensure_pg failed ^(PostgreSQL not ready^) + exit /b %errorlevel% +) + +REM Build/upgrade schema (idempotent; no-op if already at head) +call "%PY%" -m alembic upgrade head +if errorlevel 1 ( + echo [X] alembic upgrade head failed + exit /b %errorlevel% +) + +REM Long-running foreground process. Ctrl+C to stop. +"%PY%" -m uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771 --reload diff --git a/scripts/ensure_pg.py b/scripts/ensure_pg.py index 891ca7a..30aa618 100644 --- a/scripts/ensure_pg.py +++ b/scripts/ensure_pg.py @@ -5,7 +5,11 @@ - tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)` 流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→ -`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。全程无 SQLite 兜底。 +`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。 + +运行时(run.sh/run.bat)支持【显式】SQLite 逃生舱:DATABASE_URL 设为 sqlite → 放行并打印 +醒目降级横幅(绝不静默替你切库);无 docker CLI 时报错里也指路该逃生舱。测试侧 +(conftest 传 PG URL)不降级——sqlite 分支不触发,没 PG 直接 raise。详见设计文档 §10。 生产用原生 PG(scripts/init_postgres.py),不走本模块。 """ @@ -33,6 +37,12 @@ APP_DB = "shaguabijia" TEST_DB = "shaguabijia_test" DB_USER = "shaguabijia_app" COMPOSE_SERVICE = "postgres" +CONTAINER_NAME = "shaguabijia-pg" # 必须与 docker-compose.yml 的 container_name 一致 + +# 钉死 compose 项目名:否则它默认取运行目录 basename,在不同目录/worktree 之间切会各自 +# 成一个项目 → 同一个固定 container_name 撞名报错、pgdata 卷还会按项目名分裂成多份。 +# 钉成 app 名后,无论从哪个目录/worktree 跑都是同一个项目、同一个卷。setdefault:尊重外部覆盖。 +os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia") DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120")) PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60")) @@ -41,9 +51,10 @@ PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60")) DOCKER_CMD_TIMEOUT = int(os.environ.get("ENSURE_PG_CMD_TIMEOUT", "15")) POLL_INTERVAL = 3.0 -SQLITE_FIX_HINT = ( +PG_URL = ( "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia" ) +SQLITE_URL = "sqlite:///./data/app.db" # 无 Docker 时的显式降级逃生舱(仅 run 运行时) def _log(msg: str) -> None: @@ -78,7 +89,7 @@ def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None: def _docker_ok(subcmd: str) -> bool: - """`docker version`(CLI 在不在)/`docker info`(daemon 起没起)成功与否。""" + """`docker --version`(CLI 在不在,纯客户端)/`docker info`(daemon 起没起)成功与否。""" try: subprocess.run( ["docker", subcmd], @@ -94,7 +105,10 @@ def _docker_ok(subcmd: str) -> bool: def _docker_cli_ok() -> bool: - return _docker_ok("version") + # 必须用 `docker --version`(纯客户端,不连 daemon)而非 `docker version` + # (后者要连 daemon,守护进程没起时退非零)——否则「装了 Docker 但没启动」 + # 会被误判成「没装 CLI」,直接绕过下面 _start_docker_daemon() 的自动拉起(需求②)。 + return _docker_ok("--version") def _docker_daemon_ok() -> bool: @@ -131,7 +145,47 @@ def _start_docker_daemon() -> bool: return False +def _ps_names(*filters: str) -> str: + """docker ps -a 按 filter 查容器名(每行一个);失败返回空串。""" + args = ["docker", "ps", "-a", "--format", "{{.Names}}"] + for f in filters: + args += ["--filter", f] + try: + r = subprocess.run( + args, cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return r.stdout if r.returncode == 0 else "" + + +def _remove_stale_container() -> None: + """删掉「同名但不属于本 compose 项目」的残留容器(旧目录/worktree 建的)。 + + 固定的 container_name 是全局唯一名:若旧项目留下一个同名容器,`docker compose up` + 会因撞名报 "container name already in use" 而失败。这里在 up 之前主动清掉它。 + 数据在命名卷(_pgdata)里,删容器不删卷、不丢数据。 + """ + project = os.environ.get("COMPOSE_PROJECT_NAME", "") + name_filter = f"name=^{CONTAINER_NAME}$" + if CONTAINER_NAME not in _ps_names(name_filter).split(): + return # 没有同名容器 + ours = _ps_names(name_filter, f"label=com.docker.compose.project={project}") + if CONTAINER_NAME in ours.split(): + return # 就是本项目的容器,compose 会自己 start/复用,别删 + _log(f"发现残留同名容器 {CONTAINER_NAME}(非本项目 '{project}'),删除以避免撞名" + f"(数据在卷里,不丢)…") + try: + subprocess.run( + ["docker", "rm", "-f", CONTAINER_NAME], cwd=ROOT, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DOCKER_CMD_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired): + _log(f"⚠️ 删除残留容器失败,可手动: docker rm -f {CONTAINER_NAME}") + + def _compose_up() -> bool: + _remove_stale_container() _log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…") try: subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True) @@ -206,17 +260,33 @@ def ensure_test_db() -> bool: return False +def _warn_sqlite_degraded() -> None: + """DATABASE_URL 是 SQLite 时打印醒目降级横幅(显式逃生舱,非静默切库)。""" + for line in ( + "⚠️ ================= 降级模式(SQLite) =================", + "⚠️ DATABASE_URL 是 SQLite,不是 PostgreSQL。", + "⚠️ PG 专有 SQL(窗口函数/FILTER/JSONB)与严格类型在此模式【不被验证】。", + "⚠️ 提交前请在装了 Docker 的机器上用 PG 复跑;装好后把 DATABASE_URL 改回:", + f"⚠️ {PG_URL}", + "⚠️ ===================================================", + ): + _log(line) + + def ensure(database_url: str | None = None) -> bool: - """确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。""" + """确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。 + + 运行时若 DATABASE_URL 是 SQLite → 打印降级横幅并返回 True(显式逃生舱); + conftest 传的是 PG URL,故测试侧永不走此分支。 + """ if database_url is None: from app.core.config import settings # 延迟导入,避免过早固化 settings database_url = settings.DATABASE_URL if _is_sqlite(database_url): - _log("检测到 DATABASE_URL 仍是 SQLite。本地开发/测试已切 PostgreSQL,请改成:") - _log(f" DATABASE_URL={SQLITE_FIX_HINT}") - return False + _warn_sqlite_degraded() + return True host, port = _parse_host_port(database_url) @@ -229,6 +299,8 @@ def ensure(database_url: str | None = None) -> bool: if not _docker_cli_ok(): _log("未检测到 docker 命令。请先安装 Docker Desktop:") _log(" https://www.docker.com/products/docker-desktop/") + _log(f"装不了 Docker?把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑") + _log(" (PG 专有 SQL/严格性不被验证,仅救急);改完重跑 run.sh/run.bat。") return False if not _start_docker_daemon(): return False