Files
shaguabijia-app-server/docs/superpowers/plans/2026-07-08-local-dev-postgres-docker.md
T
guke 9c55344e85 fix(dev): ensure_pg 日志对 GBK 控制台编码安全( emoji 不再崩 run.bat)
Windows cmd.exe 默认 GBK,print (U+2705)抛 UnicodeEncodeError → 脚本退非0、
run.bat 误判 ensure_pg 失败。改为启动时对 stdout/stderr 设 errors=backslashreplace:
中文照常,仅不可编码字符转义。顺带订正计划里的测试计数(11)。

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

27 KiB

本地开发切 Docker PostgreSQL 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 让 app-server 本地开发运行与 pytest 都跑在 Docker 化的 PostgreSQL 16 上,退掉 SQLite,run.bat/run.sh 启动时自动检测并拉起 PG(必要时先启 Docker Desktop、缺镜像先拉)。

Architecture: 新增 docker-compose.yml(声明 PG 服务/卷/健康检查)+ scripts/ensure_pg.py(跨平台引导:探测→启 Docker→compose up→等就绪→幂等建测试库),由 run.sh/run.bat/tests/conftest.py 三处共用。.env.example 默认切 PG。app/db/session.pyalembic/env.py 已天然支持 PG,无需改。

Tech Stack: Docker Compose、postgres:16-alpine、Python 3.10+ 标准库(socket/subprocess/urllib.parse)、psycopg3(已装)、SQLAlchemy 2.0 + Alembic、pytest。

工作目录: 本计划在 worktree .worktrees/local-dev-postgres-docker(分支 chore/local-dev-postgres-docker,基于 main)内执行。下面所有路径相对该 worktree 根(= 仓库根)。

设计依据: docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md

连接参数(全程固定值):

  • 镜像 postgres:16-alpine,容器名 shaguabijia-pg,宿主端口 5432
  • 用户 shaguabijia_app,dev 密码 shaguabijia_dev_pw(本地非机密)
  • 业务库 shaguabijia,测试库 shaguabijia_test,命名卷 pgdata
  • dev URL:postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
  • test URL:...@localhost:5432/shaguabijia_test

前置: 执行机已安装 Docker Desktop。


Task 1: Docker Compose + 测试库 initdb 脚本

Files:

  • Create: docker-compose.yml

  • Create: docker/initdb/01-create-test-db.sql

  • Step 1: 写 docker-compose.yml

# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
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:
  • Step 2: 写 docker/initdb/01-create-test-db.sql
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
  • Step 3: 起容器验证

Run: docker compose up -d Expected: 拉取 postgres:16-alpine(首次)后 Container shaguabijia-pg Started

  • Step 4: 验证两个库都在 + 健康

Run: docker compose exec -T postgres psql -U shaguabijia_app -d shaguabijia -tAc "SELECT datname FROM pg_database WHERE datname IN ('shaguabijia','shaguabijia_test') ORDER BY 1" Expected 输出:

shaguabijia
shaguabijia_test
  • Step 5: 验证 .worktrees/data/ 忽略不受影响、compose 无落盘到项目目录

Run: git status --short Expected: 只列出本任务新增的 docker-compose.ymldocker/initdb/01-create-test-db.sql(数据在命名卷 pgdata,不在项目目录;.worktrees/ 已忽略)。

  • Step 6: Commit
git add docker-compose.yml docker/initdb/01-create-test-db.sql
git commit -m "feat(dev): docker-compose 起本地 PostgreSQL(含测试库 initdb)"

Task 2: scripts/ensure_pg.py 引导脚本(TDD)

Files:

  • Create: scripts/ensure_pg.py
  • Test: tests/test_ensure_pg.py

说明:纯函数(URL 解析 / sqlite 判定 / 端口探测 / 平台命令映射 / sqlite 守卫 / 端口通时短路)走 TDD 单测;真正拉 Docker 的编排 ensure() 全链路靠 Task 3/5 的运行来验证(需真 Docker,不做单测)。此时 conftest.py 仍是 SQLite,不依赖 PG,单测可独立跑。

  • Step 1: 写失败测试 tests/test_ensure_pg.py
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
from __future__ import annotations

import socket

from scripts.ensure_pg import (
    _docker_desktop_cmd,
    _is_sqlite,
    _parse_host_port,
    _port_open,
    ensure,
)


def test_is_sqlite():
    assert _is_sqlite("sqlite:///./data/app.db")
    assert _is_sqlite("  SQLite:///x ")
    assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")


def test_parse_host_port_full():
    assert _parse_host_port(
        "postgresql+psycopg://u:p@localhost:5432/shaguabijia"
    ) == ("localhost", 5432)


def test_parse_host_port_defaults():
    # 缺端口 → 5432
    assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
    # 缺 host → localhost
    assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)


def test_parse_host_port_testdb():
    assert _parse_host_port(
        "postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
    ) == ("localhost", 5432)


def test_port_open_true():
    srv = socket.socket()
    srv.bind(("127.0.0.1", 0))
    srv.listen(1)
    port = srv.getsockname()[1]
    try:
        assert _port_open("127.0.0.1", port, timeout=1.0)
    finally:
        srv.close()


def test_port_open_false():
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()  # 释放端口,无人监听 → 连接应失败
    assert not _port_open("127.0.0.1", port, timeout=0.3)


def test_docker_desktop_cmd_windows():
    cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
    assert cmd is not None
    assert cmd[0].endswith("Docker Desktop.exe")
    assert "Docker" in cmd[0]


def test_docker_desktop_cmd_darwin():
    assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]


def test_docker_desktop_cmd_linux():
    assert _docker_desktop_cmd("linux", "") is None


def test_ensure_rejects_sqlite():
    # dev 守卫:sqlite 直接 False(不碰 Docker)
    assert ensure("sqlite:///./data/app.db") is False


def test_ensure_shortcircuits_when_pg_up(monkeypatch):
    # 端口通 → 直接 True,绝不触碰 docker
    monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)

    def _boom():
        raise AssertionError("端口通时不应调用 docker")

    monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
    assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
  • Step 2: 跑测试确认失败

Run: pytest tests/test_ensure_pg.py -v Expected: FAIL —— ModuleNotFoundError: No module named 'scripts.ensure_pg'(还没建)。

  • Step 3: 写实现 scripts/ensure_pg.py
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。

被三处复用:
  - run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
  - tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`

流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。全程无 SQLite 兜底。

生产用原生 PG(scripts/init_postgres.py),不走本模块。
"""
from __future__ import annotations

import os
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import urlsplit

ROOT = Path(__file__).resolve().parent.parent

# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(errors="backslashreplace")
    except (AttributeError, ValueError):
        pass

APP_DB = "shaguabijia"
TEST_DB = "shaguabijia_test"
DB_USER = "shaguabijia_app"
COMPOSE_SERVICE = "postgres"

DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
POLL_INTERVAL = 3.0

SQLITE_FIX_HINT = (
    "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
)


def _log(msg: str) -> None:
    print(f"[ensure_pg] {msg}", flush=True)


def _is_sqlite(url: str) -> bool:
    return url.strip().lower().startswith("sqlite")


def _parse_host_port(url: str) -> tuple[str, int]:
    """从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
    parts = urlsplit(url)
    return (parts.hostname or "localhost"), (parts.port or 5432)


def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except OSError:
        return False


def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
    """按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
    if platform.startswith("win"):
        return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
    if platform == "darwin":
        return ["open", "-a", "Docker"]
    return None


def _docker_ok(subcmd: str) -> bool:
    """`docker version`(CLI 在不在)/`docker info`(daemon 起没起)成功与否。"""
    try:
        subprocess.run(
            ["docker", subcmd],
            cwd=ROOT,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=True,
        )
        return True
    except (OSError, subprocess.CalledProcessError):
        return False


def _docker_cli_ok() -> bool:
    return _docker_ok("version")


def _docker_daemon_ok() -> bool:
    return _docker_ok("info")


def _start_docker_daemon() -> bool:
    """守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
    if _docker_daemon_ok():
        return True
    cmd = _docker_desktop_cmd(
        sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
    )
    if cmd is None:
        _log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
        return False
    if sys.platform.startswith("win") and not Path(cmd[0]).exists():
        _log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
        return False
    _log(f"启动 Docker Desktop(首次冷启可能 30-60s)…")
    try:
        subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except OSError as e:
        _log(f"启动 Docker Desktop 失败:{e}")
        return False
    deadline = time.monotonic() + DOCKER_START_TIMEOUT
    while time.monotonic() < deadline:
        if _docker_daemon_ok():
            _log("Docker 守护进程已就绪。")
            return True
        _log("等待 Docker 守护进程…")
        time.sleep(POLL_INTERVAL)
    _log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
    return False


def _compose_up() -> bool:
    _log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
    try:
        subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
        return True
    except (OSError, subprocess.CalledProcessError) as e:
        _log(f"docker compose up 失败:{e}")
        return False


def _pg_isready() -> bool:
    r = subprocess.run(
        ["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
         "pg_isready", "-U", DB_USER, "-d", APP_DB],
        cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    )
    return r.returncode == 0


def _wait_pg_ready(host: str, port: int) -> bool:
    deadline = time.monotonic() + PG_READY_TIMEOUT
    while time.monotonic() < deadline:
        if _port_open(host, port) and _pg_isready():
            _log("PostgreSQL 已就绪。")
            return True
        _log("等待 PostgreSQL 就绪…")
        time.sleep(POLL_INTERVAL)
    _log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
    return False


def _ensure_test_db() -> None:
    """幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。"""
    check = subprocess.run(
        ["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
         "psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
         f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
        cwd=ROOT, capture_output=True, text=True,
    )
    if check.returncode == 0 and check.stdout.strip() == "1":
        return
    _log(f"建测试库 {TEST_DB}…")
    subprocess.run(
        ["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
         "psql", "-U", DB_USER, "-d", APP_DB, "-c",
         f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
        cwd=ROOT, check=False,
    )


def ensure(database_url: str | None = None) -> bool:
    """确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。"""
    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

    host, port = _parse_host_port(database_url)

    if _port_open(host, port):
        _log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
        return True

    _log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")

    if not _docker_cli_ok():
        _log("未检测到 docker 命令。请先安装 Docker Desktop:")
        _log("  https://www.docker.com/products/docker-desktop/")
        return False
    if not _start_docker_daemon():
        return False
    if not _compose_up():
        return False
    if not _wait_pg_ready(host, port):
        return False

    _ensure_test_db()
    return True


if __name__ == "__main__":
    sys.exit(0 if ensure() else 1)
  • Step 4: 跑测试确认通过

Run: pytest tests/test_ensure_pg.py -v Expected: 11 passed。

  • Step 5: 手动冒烟(PG 已在跑时应秒过短路)

Run: python -m scripts.ensure_pg Expected: 打印 [ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行,跳过 Docker。,退出码 0。

  • Step 6: Commit
git add scripts/ensure_pg.py tests/test_ensure_pg.py
git commit -m "feat(dev): scripts/ensure_pg.py 探测/拉起本地 Docker PostgreSQL"

Task 3: run.sh / run.bat 接入 ensure_pg

Files:

  • Modify: run.sh(在 alembic upgrade head 前插一步)

  • Modify: run.bat(同上)

  • Step 1: 改 run.sh

mkdir -p data 之后、"$PY" -m alembic upgrade head 之前插入:

"$PY" -m scripts.ensure_pg   # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)

(set -e 已在文件顶部,ensure_pg 失败会自动终止脚本。)

  • Step 2: 改 run.bat

if not exist data mkdir data 之后、call "%PY%" -m alembic upgrade head 之前插入:

REM 确保本地 Docker PostgreSQL 就绪(没起会自动拉起 Docker + PG 容器)
call "%PY%" -m scripts.ensure_pg
if errorlevel 1 (
    echo [X] ensure_pg failed ^(PostgreSQL 未就绪^)
    exit /b %errorlevel%
)
  • Step 3: 验证 run.sh(PG 已在跑,应短路后继续 alembic + uvicorn)

Run(Git Bash):bash run.sh 8770 Expected: 依次出现 [ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行 → alembic 无报错 → uvicorn Application startup completeCtrl-C 停。

  • Step 4: 验证 run.bat(同上,Windows 原生)

Run(cmd/PowerShell):.\run.bat 8770 Expected: 同 Step 3。Ctrl-C 停。

  • Step 5: Commit
git add run.sh run.bat
git commit -m "feat(dev): run.sh/run.bat 启动前确保 Docker PostgreSQL 就绪"

Task 4: .env.example 默认切 PostgreSQL

Files:

  • Modify: .env.example(第 8-10 行「数据库」段)

  • Step 1: 改 .env.example 的 DATABASE_URL

把:

# ===== 数据库 =====
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
DATABASE_URL=sqlite:///./data/app.db

改成:

# ===== 数据库 =====
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
  • Step 2: 验证(新 .env 从模板复制后能起服务)

Run: cp .env.example /tmp/env.check && grep '^DATABASE_URL=' /tmp/env.check Expected: DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia

  • Step 3: Commit
git add .env.example
git commit -m "feat(dev): .env.example 默认 DATABASE_URL 切 Docker PostgreSQL"

Task 5: tests/conftest.py 切 PostgreSQL 测试库

Files:

  • Modify: tests/conftest.py(整体替换:去掉临时 SQLite,改指 shaguabijia_test + 调 ensure_pg + fixture 改 drop/create)

  • Step 1: 整体替换 tests/conftest.py

"""测试用 fixtures。

测试库用 Docker PG 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 →
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
"""
from __future__ import annotations

import os
from collections.abc import Iterator

# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期建 engine)
_TEST_DB_URL = (
    "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
)
os.environ["DATABASE_URL"] = _TEST_DB_URL
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
os.environ.setdefault("JG_APP_KEY", "test-key")
os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
os.environ.setdefault("SMS_MOCK", "true")
os.environ.setdefault("APP_ENV", "dev")
os.environ.setdefault("APP_DEBUG", "false")  # 测试不打 SQL 日志
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")

# 2) 保证 Docker PG 就绪 + 测试库存在(必须在 import app.db.session 建 engine 之前)
from scripts.ensure_pg import ensure

if not ensure(_TEST_DB_URL):
    raise RuntimeError(
        "测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
        "或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
    )

import pytest
from fastapi.testclient import TestClient

from app.db.base import Base
from app.db.session import engine
from app.main import app


@pytest.fixture(scope="session", autouse=True)
def _setup_db() -> Iterator[None]:
    # 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
    Base.metadata.drop_all(engine)
    Base.metadata.create_all(engine)
    yield
    Base.metadata.drop_all(engine)


@pytest.fixture()
def client() -> TestClient:
    return TestClient(app)
  • Step 2: 验证 conftest 能引导 PG 并收集用例(选一个不涉 DB 的测试文件)

Run: pytest tests/test_ensure_pg.py -v Expected: conftest 先打印 [ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行(或拉起过程),随后 12 passed。说明「测试走 PG 引导」链路通、且纯函数测试不受影响。

  • Step 3: 验证建表落到 PG 测试库(跑一个 DB 相关用例)

Run: pytest tests/test_invite.py -v Expected: 用例在 shaguabijia_test 上建表并执行(可能有个别红,留待 Task 6);关键是不再出现 SQLite 临时文件、engine 连的是 PG。

  • Step 4: Commit
git add tests/conftest.py
git commit -m "test(dev): conftest 切 shaguabijia_test(Docker PG),引导+drop/create"

Task 6: 全量跑 pytest on PG,逐个修红用例

SQLite 宽松、PG 严格,切库会暴露一批真 bug(迁移指南 §2.2 已列)。本任务是发现驱动:先跑全量、按类别归因、按下述配方修,直到全绿。修改范围限被测业务/模型代码,不改测试来掩盖真 bug(除非测试本身依赖 SQLite 特性,如秒级时间精度)。

Files:

  • Modify: 视失败而定(常见:app/models/*.pyapp/**/repositories/*.py、少量 tests/*.py)

  • Step 1: 全量跑,拿到失败清单

Run: pytest -q Expected: 大部分通过;记录所有 FAIL 的用例名与报错文本,按下面类别归因。

  • Step 2: 修「naive datetime / 时区」类

定位:git grep -n "utcnow()" app/。把 datetime.utcnow() 改成 datetime.now(timezone.utc)(并 from datetime import timezone)。 症状:PG TIMESTAMPTZ 与 naive datetime 比较/写入报错或结果错位;tests/test_cps_admin.py 已注释过 SQLite 忽略 tzinfo 的行为。

例:

# 改前
from datetime import datetime
ts = datetime.utcnow()
# 改后
from datetime import datetime, timezone
ts = datetime.now(timezone.utc)
  • Step 3: 修「字符串/整数隐式比较」类

症状:SQLite 允许 WHERE phone = 13800138000(自动转型),PG 直接报类型错。定位报错用例引用的查询,确保比较两侧类型一致(手机号等一律按字符串传参 :phone,不要传裸 int)。

  • Step 3.5: 修「事务已中止」类

症状:某用例后续报 current transaction is aborted, commands ignored until end of transaction block,根因是前一句 SQL 出错后业务代码缺 db.rollback()/db.commit() 边界。补上正确的 commit/rollback。

  • Step 4: 修「测试依赖 SQLite 特性」类(仅此类可改测试)

症状:测试断言依赖 SQLite 秒级时间精度或 FK 不强制(见 test_invite.py:328test_compare_harvest.py:153 的注释)。PG 下时间精度更高/FK 更严——调整测试数据(如手动拉开时间间隔、用合法 FK)使断言在 PG 下成立,不改业务逻辑。

  • Step 5: 反复跑到全绿

Run: pytest -q Expected: N passed(0 failed)。若仍有红,回到 Step 2-4 继续归因。

  • Step 6: Commit
git add -A
git commit -m "fix(db): 测试套件切 PostgreSQL 后修复严格性暴露的用例"

Task 7: 文档更新

Files:

  • Modify: docs/database/postgres-migration.md(§1 增「本地 Docker 一键起」小节)

  • Modify: CLAUDE.md(DB 段注明 dev/test = Docker PG)

  • Modify: scripts/init_postgres.py(顶部注释区分生产/本地)

  • Step 1: postgres-migration.md 在「## 1. 本地起 PG」开头插入推荐做法

## 1. 本地起 PG + 跑通空库(半天) 标题下、### 1.1 装 PG 之前插入:

### 1.0 推荐:Docker 一键起(本地开发/测试)

本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:

```bash
cp .env.example .env    # DATABASE_URL 默认已是 Docker PG 连接串
./run.sh                # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
pytest                  # conftest 自动引导同一容器的 shaguabijia_test 库

容器:postgres:16-alpine(名 shaguabijia-pg,端口 5432,命名卷 pgdata 持久化), 首启即建业务库 shaguabijia 与测试库 shaguabijia_test。下面 1.1-1.5 的手动装 PG 步骤仅在 不用 Docker 时才需要;生产仍走 §4 的原生 PG。


- [ ] **Step 2: `CLAUDE.md` DB 段补充**

找到 DB 相关行(`**Prod**: PostgreSQL — just change DATABASE_URL...`)所在段,在其上方加一行:

```markdown
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` 经 `scripts/ensure_pg.py` + `docker-compose.yml` 自动拉起;`.env.example` 默认即 PG 连接串;pytest 用同容器的 `shaguabijia_test` 库。**本地不再用 SQLite**。
  • Step 3: scripts/init_postgres.py 顶部注释区分场景

把模块 docstring 第一行下方(新机器初始化用。前置:... 那行)改为:

新机器初始化用(面向生产原生 PG:apt/systemd 装好的 PostgreSQL)
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码
  • Step 4: 验证无坏链接/格式

Run: git diff --stat Expected: 三个文档文件有改动,无其他文件被误改。

  • Step 5: Commit
git add docs/database/postgres-migration.md CLAUDE.md scripts/init_postgres.py
git commit -m "docs(dev): 记录本地 Docker PostgreSQL 用法,区分生产原生 PG 路径"

完成标准(对齐 spec §8 验收)

  • 全新机器(装了 Docker Desktop、.env.env.example 复制)跑 run.bat/run.sh 全自动拉起 PG 并起服务,无手动装 PG。
  • docker psshaguabijia-pg healthy;shaguabijiashaguabijia_test 两库都在。
  • PG 已在跑时再跑 run,ensure_pg 秒过短路。
  • pytest -q 全绿(连 shaguabijia_test)。
  • .env 改回 sqlite 时,python -m scripts.ensure_pg 硬失败并打印正确 PG 串。
  • 能在 admin/repositories 写一段 PG 专有聚合(如 count(*) FILTER (WHERE ...)),run 手动跑通且相应 pytest 通过。

Self-Review 记录(计划作者已核)

  • Spec 覆盖: spec §9 待实现清单 8 项 → Task 1(compose+initdb)、Task 2(ensure_pg)、Task 3(run 接线)、Task 4(.env.example)、Task 5(conftest)、Task 6(修红用例)、Task 7(文档);「session.py 无需改」在 Header 与 spec §4.7 说明;「data/ 忽略」在 Task 1 Step 5 验证。无遗漏。
  • 占位符: 全部步骤含真实代码/命令/期望输出。Task 6 是发现驱动,已用「类别+具体转换配方+定位命令」代替不可预知的逐条 diff——非占位。
  • 类型/命名一致: ensure(database_url=None) 签名在 Task 2 定义,Task 5 以 ensure(_TEST_DB_URL) 调用一致;库名/用户/密码/端口全程为 Header 固定值;compose 服务名 postgres 与 ensure_pg COMPOSE_SERVICE 一致;shaguabijia_test 在 initdb SQL、_ensure_test_db()、conftest 三处一致。