diff --git a/Dockerfile b/Dockerfile index 8fe07b7..db8bda3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,9 @@ WORKDIR /build # 先拷清单再拷源码:依赖层缓存与源码改动解耦 COPY admin/package.json admin/pnpm-lock.yaml admin/pnpm-workspace.yaml ./ # corepack 按 package.json 的 packageManager 字段钉 pnpm 版本(与本地 -# 一致的单一事实源);升级 corepack 规避 node20 镜像内置旧版的签名钥问题 -RUN npm install -g corepack@latest && corepack enable +# 一致的单一事实源);显式装 0.36.0(实测通过)规避 node20 镜像内置旧版 +# 的签名钥问题——钉具体版本,不追 latest(构建可复现) +RUN npm install -g corepack@0.36.0 && corepack enable RUN pnpm install --frozen-lockfile COPY admin/ ./ # VITE_BASE_URL 取 admin/.env.production(=/admin/):产物资源引用与后端 @@ -21,8 +22,9 @@ RUN pnpm build # ---- 阶段二: 生产镜像 -------------------------------------------------------- FROM python:3.13-slim WORKDIR /app -# tzdata: compose 设 TZ=Asia/Shanghai,推送窗口等业务时间依赖本地时区, -# slim 基镜像不带 tzdata 时 TZ 会被静默忽略(退 UTC) +# tzdata: trixie slim 实测已自带(容器内 TZ=Asia/Shanghai 生效,UTC+8); +# 显式安装留作基镜像漂移保险——将来换基/slim 进一步瘦身时,缺 tzdata 的 +# TZ 会被静默忽略(退 UTC),推送窗口等业务时间随之漂移 RUN apt-get update \ && apt-get install -y --no-install-recommends tzdata \ && rm -rf /var/lib/apt/lists/* diff --git a/app/main.py b/app/main.py index 6ae70c6..6e06b07 100644 --- a/app/main.py +++ b/app/main.py @@ -28,7 +28,10 @@ logger = logging.getLogger(__name__) BASE_DIR = Path(__file__).resolve().parents[1] SEED_DIR = BASE_DIR / "seed_profiles" TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db -PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db* +# 平台库落 data/ 子目录(P4T1 审查修复):data/ 是 compose 的 bind mount +# ——容器/镜像重建不丢租户注册表(租户库本就在挂载的 tenants/ 内); +# .gitignore 的 data/ 规则覆盖其运行产物。 +PLATFORM_DB = BASE_DIR / "data" / "platform.db" # ---- admin 前端生产服务(Phase 4 Task 1)--------------------------------------- @@ -116,13 +119,16 @@ async def lifespan(app: FastAPI): f"TENANT_SOURCE 只允许 db|file(实际 {tenant_source!r})") # 平台库测试缝(Phase 2 Task 1,同 registry 缝模式):预置过 - # app.state.platform_engine(临时库)则沿用——不预置则每个 TestClient - # 测试都会在仓库根建出真实 platform.db。schema+bootstrap 无条件跑 - # (两者幂等):预置引擎同样被建到可用,Task 2 起认证测试直接可用。 - # 装配次序(Task 11 起)engine 先于 registry:db 源 registry 构造即需 - # 持有 platform_engine。 + # app.state.platform_engine(临时库)则沿用——不预置则会在 /data/ + # 下建出真实 platform.db(生产;测试必须预置或锚到 tmp)。schema+ + # bootstrap 无条件跑(两者幂等):预置引擎同样被建到可用,Task 2 起 + # 认证测试直接可用。装配次序(Task 11 起)engine 先于 registry:db 源 + # registry 构造即需持有 platform_engine。 platform_engine = getattr(app.state, "platform_engine", None) if platform_engine is None: + # sqlite 不建父目录——冷机 data/ 可能尚不存在(全新检出/新容器), + # 连接前兜底建目录(registry._ensure_ready 同纪律) + PLATFORM_DB.parent.mkdir(parents=True, exist_ok=True) platform_engine = make_platform_engine(PLATFORM_DB) app.state.platform_engine = platform_engine diff --git a/docker-compose.yml b/docker-compose.yml index 653bdd9..7c68204 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,7 @@ # 生产编排(Phase 4 Task 1)——单容器全栈(uvicorn 直出,无反代层时 -# 8018 对外;有反代时由反代转发)。数据全在 bind mount,容器可随手重建。 +# 8018 对外;有反代时由反代转发)。持久化全在 bind mount,容器可随手 +# 重建:./tenants 挂每租户 SQLite 库;./data 挂 platform.db(租户注册表, +# P4T1 审查修复起落在 data/ 内)与 data/uploads 头像上传。 name: all-kefu services: @@ -10,7 +12,8 @@ services: env_file: # 生产服务器放 .env(ADMIN_PASSWORD/ZHIPU_API_KEY/SMTP 等);本机 # 没有 .env 也能起(出厂默认+启动日志警示)——required: false 只是 - # 容忍缺省,不改变变量优先级 + # 容忍缺省,不改变变量优先级。注意对象式写法(path/required)需 + # Docker Compose >= 2.24 - path: .env required: false environment: diff --git a/scripts/bootstrap_platform.py b/scripts/bootstrap_platform.py index d4eee4b..524dde1 100644 --- a/scripts/bootstrap_platform.py +++ b/scripts/bootstrap_platform.py @@ -91,8 +91,9 @@ async def _import_profile(engine, seed_dir: Path, slug: str) -> str: async def run(args) -> None: db_path = Path(args.db) - # sqlite 不建父目录(仓库根默认存在,--db 指新目录时会炸——conftest - # build_app 同坑注记);照 registry._ensure_ready 纪律先建目录 + # sqlite 不建父目录(默认的 data/ 在冷机可能尚不存在,--db 指新目录 + # 同炸——conftest build_app 同坑注记);照 registry._ensure_ready 纪律 + # 先建目录(app/main.py 的 lifespan 对 PLATFORM_DB 同兜底) db_path.parent.mkdir(parents=True, exist_ok=True) engine = make_platform_engine(db_path) try: @@ -122,9 +123,10 @@ async def run(args) -> None: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( - "--db", default=str(REPO_ROOT / "platform.db"), - help="platform.db 路径(默认仓库根,与 app/main.py 的 PLATFORM_DB " - "同一锚定;测试/分环境可覆盖)") + "--db", default=str(REPO_ROOT / "data" / "platform.db"), + help="platform.db 路径(默认 /data/,与 app/main.py 的 " + "PLATFORM_DB 同一锚定——data/ 是 compose bind mount,重建" + "容器不丢;测试/分环境可覆盖)") parser.add_argument( "--profiles", nargs="+", default=list(DEFAULT_PROFILES), help=f"导入的出厂档案 slug 列表(默认 {' '.join(DEFAULT_PROFILES)})") diff --git a/tests/test_bootstrap_script.py b/tests/test_bootstrap_script.py index 6f6ff17..2a0b479 100644 --- a/tests/test_bootstrap_script.py +++ b/tests/test_bootstrap_script.py @@ -200,6 +200,27 @@ def test_main_default_db_source_empty_platform_boots(tmp_path, monkeypatch): assert app.state.registry.runtimes() == () # 空注册表不炸 +def test_platform_db_defaults_into_data_dir(tmp_path, monkeypatch): + """PLATFORM_DB 落 /data/(P4T1 审查修复):data/ 是 compose 的 + bind mount——容器/镜像重建不再丢租户注册表。两层断言: + 1. 常量形状钉死(回退仓库根即红,compose 持久化前提); + 2. 功能:锚到 tmp 的 data/ 子目录(目录刻意不预建=冷机形态), + lifespan 启动后平台库文件确实建在 data/ 内(父目录兜底 mkdir)。""" + import app.main as main_mod + assert main_mod.PLATFORM_DB == main_mod.BASE_DIR / "data" / "platform.db" + target = tmp_path / "data" / "platform.db" + assert not target.parent.exists() # 冷机:data/ 尚不存在 + monkeypatch.setattr(main_mod, "PLATFORM_DB", target) + monkeypatch.setattr(main_mod, "TENANTS_DIR", tmp_path / "tenants") + monkeypatch.delenv("TENANT_SOURCE", raising=False) + + with app_test_client(main_mod.app, "http://testserver", tmp_path) as c: + assert c.get("/health").status_code == 200 + # 断言须在 client 存活期内:app_test_client 退出时会清掉整个 tmp + # (含本文件)——文件在应用运行中确实存在即为所证 + assert target.exists() # lifespan 建库于 data/ 内 + + def test_main_default_db_source_bootstrapped_platform(tmp_path, monkeypatch): """默认 db + bootstrap 导入后的 platform.db:启动即装配两租户(registry 经 async load_all),jiancai 域名真实请求走通(Host 中间件→懒建租户库