Files
book-comic-library/docs/superpowers/plans/2026-09-07-docker-deploy-conformance.md
T

678 lines
27 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 部署规范化改写 实施计划
> **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:** 把 booklib 的 Docker 部署重写为团队规范形态:dev/release 双 compose、共享 named volumes、prepare.sh 渲染配置、dev 源码热挂载 + delve、release 编译产物、文件存储挂 `deploy/api/storage`。
**Architecture:** 单 API(`backend/cmd/server`) + SPA(`web`)不变;部署层重写为 `deploy/` 内自包含(compose 项目目录=deploy/,build context=仓库根 `..`)。配置由 `deploy/templates/*.tpl` 经 `prepare.sh` 渲染为 `deploy/nginx/`、`deploy/redis/` 产物后 `:ro` 挂载。
**Tech Stack:** Docker Compose v2、Go 1.26(delve headless)、Node 22(vite)、nginx:1.27-alpine、postgres:16-alpine、redis:7-alpine。
**Spec:** `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`(本计划的验收即 spec §9)
## Global Constraints
- compose 项目名固定 `name: booklib`(两个 compose 文件顶层都有);named volume 一律命名 `postgres_data`、`redis_data`(实际名 `booklib_postgres_data`/`booklib_redis_data`),两文件完全一致。
- dev = `deploy/docker-compose.dev.yml`(`target: dev` 镜像、源码挂载、PG 宿主 5432、Redis 宿主 6379、delve 2345、healthcheck 门控);release = `deploy/docker-compose.yml`(`Dockerfile.*.prod` `target: runner`、无源码挂载、infra 不出宿主端口)。
- 所有 `:ro` 挂载的配置文件都是 `prepare.sh` 渲染产物,模板是唯一编辑入口;渲染产物目录全部 gitignore。
- 文件存储路径统一 `deploy/api/storage` → 容器 `/data/books`,`CACHE_DIR=/data/books/cache`。
- 环境变量键名不变:`JWT_SECRET`、`ADMIN_USER`、`ADMIN_PASSWORD`、`SCAN_INTERVAL_SEC`(`.env` 迁至 `deploy/.env`)。
- 删除清单(最终态不再存在):根 `docker-compose.yml`、根 `.env.example`、`deploy/Dockerfile.api`、`deploy/Dockerfile.web`、`deploy/nginx.conf`、根 `library/` 约定。
- 不动业务代码,唯一两处适配:`web/vite.config.ts` 代理目标读 env、两个 smoke 脚本的 `.env` 路径。
---
### Task 1: prepare.sh + 配置模板 + .env 迁移
**Files:**
- Create: `deploy/templates/nginx.conf.tpl`、`deploy/templates/default.conf.tpl`、`deploy/templates/redis.conf.tpl`
- Create: `deploy/prepare.sh`(chmod +x)
- Create: `deploy/api/storage/.gitkeep`
- Move: `.env.example` → `deploy/.env.example`;本地 `.env` → `deploy/.env`(untracked,用 `mv`)
- Modify: `.gitignore`、`.dockerignore`、`scripts/smoke.sh:6`、`scripts/smoke-web.sh:6`
**Interfaces:**
- Produces: 渲染产物 `deploy/nginx/nginx.conf`、`deploy/nginx/conf.d/default.conf`、`deploy/redis/redis.conf`;目录 `deploy/logs/nginx/`。变量表:`WEB_PORT`(8080)、`NGINX_CLIENT_MAX_BODY_SIZE`(200m)、`REDIS_MAXMEMORY`(128mb)、`REDIS_MAXMEMORY_POLICY`(allkeys-lru)、`DELVE_PORT`(2345)。
- [ ] **Step 1: 写三个模板**
`deploy/templates/nginx.conf.tpl`(nginx 主配置,日志写挂载目录):
```nginx
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
```
`deploy/templates/default.conf.tpl`(server 块,内容 = 现 `deploy/nginx.conf`,仅 max_body 参数化;此文件后续会被 entrypoint 拷入容器再注入 resolver,所以 resolver 行保留占位值):
```nginx
server {
listen 80;
client_max_body_size {{NGINX_CLIENT_MAX_BODY_SIZE}};
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);容器 entrypoint 启动时会按
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
resolver 127.0.0.11 valid=10s;
# spec §7 风险接受所假设的 CSP:全部同源,blob:/data: 供 SW/reader 用
add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:; object-src 'none'; frame-src 'self' blob:" always;
location /api/ {
set $api_upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本(spec §11)
proxy_pass $api_upstream; # 无 URI 部分:保留 /api 前缀转发
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}
```
`deploy/templates/redis.conf.tpl`:
```nginx
# booklib redis 配置 — 由 deploy/prepare.sh 从 templates/redis.conf.tpl 渲染,勿直接编辑产物
maxmemory {{REDIS_MAXMEMORY}}
maxmemory-policy {{REDIS_MAXMEMORY_POLICY}}
dir /data
```
- [ ] **Step 2: 写 `deploy/prepare.sh` 并 chmod +x**
```bash
#!/usr/bin/env bash
# 渲染 deploy/templates/*.tpl → deploy/nginx、deploy/redis,并创建运行所需目录。
# 约定:每次 up 之前(或改模板/.env 后)必须重跑本脚本。幂等,可反复执行。
set -eu
cd "$(dirname "$0")"
if [ -f .env ]; then
set -a; . ./.env; set +a
fi
NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-200m}
REDIS_MAXMEMORY=${REDIS_MAXMEMORY:-128mb}
REDIS_MAXMEMORY_POLICY=${REDIS_MAXMEMORY_POLICY:-allkeys-lru}
for t in templates/nginx.conf.tpl templates/default.conf.tpl templates/redis.conf.tpl; do
[ -f "$t" ] || { echo "prepare.sh: missing template $t" >&2; exit 1; }
done
mkdir -p nginx/conf.d redis logs/nginx api/storage
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
templates/nginx.conf.tpl > nginx/nginx.conf
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
templates/default.conf.tpl > nginx/conf.d/default.conf
sed -e "s|{{REDIS_MAXMEMORY}}|${REDIS_MAXMEMORY}|g" \
-e "s|{{REDIS_MAXMEMORY_POLICY}}|${REDIS_MAXMEMORY_POLICY}|g" \
templates/redis.conf.tpl > redis/redis.conf
echo "prepare.sh: rendered nginx($(pwd)/nginx), redis($(pwd)/redis), logs($(pwd)/logs/nginx), storage($(pwd)/api/storage)"
```
- [ ] **Step 3: 迁移 .env 与 .env.example**
```bash
git mv .env.example deploy/.env.example
[ -f .env ] && mv .env deploy/.env
```
改写 `deploy/.env.example` 为:
```bash
JWT_SECRET=change-me-openssl-rand-hex-32
ADMIN_USER=admin
ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60
# ---- 部署参数(deploy/prepare.sh 渲染模板 / compose 插值用) ----
WEB_PORT=8080
NGINX_CLIENT_MAX_BODY_SIZE=200m
REDIS_MAXMEMORY=128mb
REDIS_MAXMEMORY_POLICY=allkeys-lru
DELVE_PORT=2345
```
- [ ] **Step 4: 更新 .gitignore / .dockerignore / smoke 脚本,并放置 storage 占位**
```bash
mkdir -p deploy/api/storage && touch deploy/api/storage/.gitkeep
```
`.gitignore`:删除 `library/` 行,追加:
```
deploy/nginx/
deploy/redis/redis.conf
deploy/logs/
deploy/api/storage/*
!deploy/api/storage/.gitkeep
```
`.dockerignore`:删除 `library/` 行,追加 `deploy/logs/`、`deploy/nginx/`、`deploy/redis/`、`deploy/api/storage/`。
`scripts/smoke.sh` 和 `scripts/smoke-web.sh` 的第 6 行 `[ -f .env ] && set -a && . ./.env && set +a` 均替换为:
```bash
ENV_FILE=${ENV_FILE:-deploy/.env}
[ -f "$ENV_FILE" ] || ENV_FILE=.env
[ -f "$ENV_FILE" ] && set -a && . "./$ENV_FILE" && set +a
```
`scripts/smoke.sh` 依赖旧的 `./library:/data/books` 绑定(第 28、55 行),随存储路径迁移同步改为:
```bash
mkdir -p deploy/api/storage/smoke-books # 原:mkdir -p library/smoke-books
[ ! -f deploy/api/storage/smoke-books/note.txt ] || die "file survived delete" # 原:library/smoke-books/note.txt
```
- [ ] **Step 5: 验证渲染**
```bash
deploy/prepare.sh && deploy/prepare.sh # 幂等跑两遍
grep -R '{{' deploy/nginx deploy/redis && echo "FAIL: placeholder left" || echo OK
grep -q 'maxmemory 128mb' deploy/redis/redis.conf && echo OK
ls deploy/logs/nginx deploy/api/storage
```
Expected: 两次 `OK`,无 `FAIL`。再验证缺省值路径:临时 `env -i` 不行(脚本 source .env),改用 `ENV_FILE` 无法关——直接确认 `deploy/.env` 不存在时仍渲染默认值:`mv deploy/.env /tmp/e 2>/dev/null; deploy/prepare.sh; grep 'maxmemory 128mb' deploy/redis/redis.conf && mv /tmp/e deploy/.env 2>/dev/null; true`
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat(deploy): prepare.sh + config templates, migrate env to deploy/, gitignore rendered outputs"
```
---
### Task 2: 双 Dockerfile(dev/prod)+ entrypoint-resolver 改造
**Files:**
- Create: `deploy/Dockerfile.api.dev`、`deploy/Dockerfile.api.prod`、`deploy/Dockerfile.web.dev`、`deploy/Dockerfile.web.prod`
- Modify: `deploy/entrypoint-resolver.sh`
- Delete: `deploy/Dockerfile.api`、`deploy/Dockerfile.web`、`deploy/nginx.conf`
**Interfaces:**
- Consumes: Task 1 的 `/etc/booklib/{nginx.conf,default.conf}` 挂载约定(Task 3 落地)。
- Produces: 可构建镜像 target:`Dockerfile.api.dev`→`dev`、`Dockerfile.api.prod`→`runner`、`Dockerfile.web.dev`→`dev`、`Dockerfile.web.prod`→`runner`;build context=仓库根。
- [ ] **Step 1: 写 `deploy/Dockerfile.api.dev`**
```dockerfile
FROM golang:1.26 AS base
WORKDIR /app
FROM base AS dev
# 源码由 compose 挂载进 /app;镜像只带工具链 + delve
RUN go install github.com/go-delve/delve/cmd/dlv@latest
EXPOSE 8080 2345
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --continue --log"]
```
- [ ] **Step 2: 写 `deploy/Dockerfile.api.prod`**
```dockerfile
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
FROM alpine:3.20 AS runner
RUN adduser -D -H app
COPY --from=build /server /server
# /data/books 由宿主 bind(./api/storage)覆盖;/data 下目录预建并授权,兼容 podman
RUN mkdir -p /data && chown app:app /data
USER app
EXPOSE 8080
ENTRYPOINT ["/server"]
```
- [ ] **Step 3: 写 `deploy/Dockerfile.web.dev`**
```dockerfile
FROM node:22 AS dev
WORKDIR /app
COPY web/package.json web/package-lock.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
- [ ] **Step 4: 写 `deploy/Dockerfile.web.prod`(不再 baked nginx 配置)**
```dockerfile
FROM node:22-alpine AS build
WORKDIR /src
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine AS runner
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
COPY --from=build /src/dist /usr/share/nginx/html
# nginx 配置不 bake 进镜像:由 compose 挂到 /etc/booklib/(渲染产物),
# entrypoint 启动时拷入原位、注入运行时 resolver 再 exec nginx
ENTRYPOINT ["/entrypoint-resolver.sh"]
```
- [ ] **Step 5: 改 `deploy/entrypoint-resolver.sh`(先拷贝再 sed,因 `:ro` 挂载不可原地改写)**
```sh
#!/bin/sh
# 双运行时(Docker/podman)DNS 修复:镜像内 baked 的 resolver 地址无法同时成立
# (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。
# 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的
# 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。
# 配置以 :ro 挂在 /etc/booklib/(deploy/prepare.sh 渲染产物),先拷入原位再改写。
set -eu
for f in nginx.conf default.conf; do
[ -r "/etc/booklib/$f" ] || { echo "entrypoint-resolver: missing /etc/booklib/$f — 先跑 deploy/prepare.sh" >&2; exit 1; }
done
cp /etc/booklib/nginx.conf /etc/nginx/nginx.conf
cp /etc/booklib/default.conf /etc/nginx/conf.d/default.conf
RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
[ -n "$RESOLVER" ] || RESOLVER=127.0.0.11
CONF=/etc/nginx/conf.d/default.conf
sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF"
echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}"
nginx -t
if [ $# -gt 0 ]; then exec "$@"; fi
exec nginx -g 'daemon off;'
```
- [ ] **Step 6: 删除旧文件并构建四个镜像验证**
```bash
git rm deploy/Dockerfile.api deploy/Dockerfile.web deploy/nginx.conf
docker build -f deploy/Dockerfile.api.dev --target dev -t booklib:api-dev .
docker build -f deploy/Dockerfile.api.prod --target runner -t booklib:api-prod .
docker build -f deploy/Dockerfile.web.dev --target dev -t booklib:web-dev .
docker build -f deploy/Dockerfile.web.prod --target runner -t booklib:web-prod .
docker run --rm --entrypoint sh booklib:api-prod -c 'test -x /server && echo binary-ok'
docker run --rm --entrypoint sh booklib:api-dev -c 'command -v dlv >/dev/null && echo dlv-ok'
docker run --rm --entrypoint sh booklib:web-dev -c 'test -d node_modules/vite && echo deps-ok'
```
Expected: 四个 build 成功;`binary-ok`、`dlv-ok`、`deps-ok`。
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat(deploy): split Dockerfiles into dev/runner targets, entrypoint copies rendered nginx conf before resolver injection"
```
---
### Task 3: release compose(deploy/docker-compose.yml)
**Files:**
- Create: `deploy/docker-compose.yml`
- Delete: `docker-compose.yml`(根)
**Interfaces:**
- Consumes: Task 1 渲染产物路径、Task 2 的镜像 target 与 `/etc/booklib/` 约定、`deploy/.env`。
- Produces: 服务名 `web|api|postgres|redis`(Task 4 dev 文件沿用同名)。
- [ ] **Step 1: 写 `deploy/docker-compose.yml`**
```yaml
# release:编译产物、无源码挂载、infra 端口不出宿主机
name: booklib
services:
web:
build: { context: .., dockerfile: deploy/Dockerfile.web.prod, target: runner }
ports: ["${WEB_PORT:-8080}:80"]
volumes:
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
- ./nginx/conf.d/default.conf:/etc/booklib/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on: [api]
api:
build: { context: .., dockerfile: deploy/Dockerfile.api.prod, target: runner }
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ./api/storage:/data/books
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
volumes:
postgres_data:
redis_data:
```
- [ ] **Step 2: 删除根 compose**
```bash
git rm docker-compose.yml
```
- [ ] **Step 3: 未跑 prepare 的失败路径验证(spec §8)**
```bash
mv deploy/nginx /tmp/bk-ng && docker compose -f deploy/docker-compose.yml up -d 2>&1 | grep -i "not found\|error"; mv /tmp/bk-ng deploy/nginx
```
Expected: compose 因绑定挂载源缺失报错,无容器半起(如有残留先 `docker compose -f deploy/docker-compose.yml down`)。
- [ ] **Step 4: 起 release 并跑双冒烟**
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
docker compose -f deploy/docker-compose.yml ps # 全 healthy/running
curl -s localhost:8080/api/healthz
```
Expected: 两套冒烟全绿、`ok`。若宿主 :8080 被 Task 4 前的旧栈占用,先 `docker compose down` 旧项目。
- [ ] **Step 5: 验证卷名与 nginx 日志落宿主**
```bash
docker volume ls | grep booklib_
docker compose -f deploy/docker-compose.yml exec web sh -c 'ls /etc/nginx/conf.d/default.conf >/dev/null && grep -m1 resolver /etc/nginx/conf.d/default.conf'
ls deploy/logs/nginx/ # 有 access.log(冒烟请求后)
```
Expected: `booklib_postgres_data`、`booklib_redis_data`;resolver 行已是容器内实际 nameserver(非 127.0.0.11 亦可——Docker 下就是 127.0.0.11,podman 下为网关 IP);access.log 非空。
- [ ] **Step 6: Commit(数据卷留在盘上给 Task 4 做共享验证)**
```bash
git add -A
git commit -m "feat(deploy): release compose under deploy/ with shared named volumes and rendered config mounts; drop root compose"
```
---
### Task 4: dev compose(四服务全容器化)+ vite 代理适配
**Files:**
- Create: `deploy/docker-compose.dev.yml`(整体重写,替换旧内容)
- Modify: `web/vite.config.ts`(`server.proxy` 一行)
**Interfaces:**
- Consumes: Task 2 dev 镜像(`go`/`dlv`/`npm` 可用)、Task 1 `deploy/api/storage`、`deploy/redis/redis.conf`、Task 3 的卷(同名 → 数据共享)。
- Produces: `VITE_PROXY_TARGET` env 约定;dev 端口头约定:宿主 PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`、delve `:2345`、vite `:5173`。
- [ ] **Step 1: vite 代理目标可配置**
`web/vite.config.ts` 中 `server: { proxy: { "/api": "http://localhost:8080" } },` 替换为:
```ts
server: { proxy: { "/api": process.env.VITE_PROXY_TARGET ?? "http://localhost:8080" } },
```
- [ ] **Step 2: 重写 `deploy/docker-compose.dev.yml`**
```yaml
# dev:源码热挂载 + target: dev 镜像 + delve :2345,infra 端口暴露宿主,healthcheck 门控
# 起停用 down(不是 rm),否则 web 的匿名 node_modules 卷会成孤儿
name: booklib
services:
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
ports: ["5432:5432"]
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports: ["6379:6379"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
api:
build: { context: .., dockerfile: deploy/Dockerfile.api.dev, target: dev }
command: sh -c "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --continue --log"
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ../backend:/app
- ./api/storage:/data/books
ports: ["${DELVE_PORT:-2345}:2345"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
web:
build: { context: .., dockerfile: deploy/Dockerfile.web.dev, target: dev }
command: npm run dev -- --host 0.0.0.0
environment: { VITE_PROXY_TARGET: http://api:8080 }
volumes:
- ../web:/app
- /app/node_modules
ports: ["5173:5173"]
depends_on: [api]
volumes:
postgres_data:
redis_data:
```
- [ ] **Step 3: 起 dev 栈并验证四要点**
```bash
docker compose -f deploy/docker-compose.yml down # 先停 release,端口让给 dev
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
curl -sf localhost:5173/api/healthz # vite 代理 → 容器 api:200
curl -sf localhost:5173 | grep -qi '<div id="root">' # vite 页面
nc -z localhost 2345 && echo delve-ok # delve 端口
nc -z localhost 5432 && nc -z localhost 6379 && echo infra-ok
```
Expected: `delve-ok`、`infra-ok`,两个 curl 成功。
- [ ] **Step 4: 验证热重启语义(改码不 rebuild)**
```bash
touch backend/cmd/server/main.go
docker compose -f deploy/docker-compose.dev.yml restart api
sleep 8 && curl -sf localhost:5173/api/healthz && echo hot-reload-ok
```
Expected: `hot-reload-ok`(dlv 重新编译挂载的源码后健康检查恢复)。
- [ ] **Step 5: 验证数据与 release 共享(spec §9.3)**
Task 3 冒烟写入的数据(卷同名共享)应透过 dev 可见——用与 `scripts/smoke.sh` 相同的登录形状(`/api/auth/login`,body `{"username","password"}`)经 5173 代理断言:
```bash
set -a; . deploy/.env; set +a
TOK=$(curl -fsS localhost:5173/api/auth/login -H 'content-type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/')
[ -n "$TOK" ] && echo login-ok
curl -fsS localhost:5173/api/libraries -H "authorization: Bearer $TOK" | grep -q '"smoke"' && echo shared-data-ok
```
Expected: `login-ok`、`shared-data-ok`(libraries 含 Task 3 冒烟创建的 `smoke` 库)。
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat(deploy): dev compose runs full stack with source mounts, delve, shared volumes; vite proxy target via env"
```
---
### Task 5: README 重写 + 旧卷迁移文档 + 终验
**Files:**
- Modify: `README.md`(整体重写,见 Step 1)
**Interfaces:**
- Consumes: Task 1–4 全部产物。
- Produces: 无(收尾)。
- [ ] **Step 1: 重写 `README.md`**
````markdown
# Book & Comic Library
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。
## Deploymode
- release:`deploy/docker-compose.yml` — 多阶段 `Dockerfile.{api,web}.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../web:/app`(web 带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/server`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
## Volume Mount
- 共享 named volumes:`booklib_postgres_data`、`booklib_redis_data`(两个 compose 同名,dev/release 看到同一份数据;仅 `down -v` 清除)。
- 配置/日志绑定挂载:`deploy/nginx/{nginx.conf,conf.d/default.conf}`、`deploy/redis/redis.conf`(`:ro`)与 `deploy/logs/nginx` —— 全部来自 `deploy/prepare.sh`,**容器里配置不对/缺失 = 忘了重跑它**。
- 文件存储:`deploy/api/storage` → 容器 `/data/books`(缓存写 `/data/books/cache`)。
## 跑起来(生产形态)
```bash
cp deploy/.env.example deploy/.env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
deploy/prepare.sh
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
```
- web: `http://localhost:8080`(`WEB_PORT` 可改),API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
- 原始书放进 `deploy/api/storage/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
- nginx access/error 日志:`deploy/logs/nginx/`。
## 开发
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
```
- 前端: http://localhost:5173(vite,HMR 直接生效;`/api` 代理到容器内 api)。
- Go 改码后:`docker compose -f deploy/docker-compose.dev.yml restart api`(重编译挂载源码,无需 rebuild)。
- 断点调试:delve headless 在 `localhost:2345`(VSCode launch:`{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345}`;命中断点后用 dlv 命令继续)。
- 直连基础设施跑测试:PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`:
```bash
cd backend
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
export REDIS_URL='redis://localhost:6379'
go vet ./... && gofmt -l .
go test -p 1 -count=1 ./...
```
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
前端门槛:`cd web && npm run check`(tsc + vitest + vite build)。
## 旧卷迁移(一次性,升级自上一版部署)
```bash
# 老 PG 数据 → 新共享卷
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
# 老 cache 卷是封面/解压派生数据,直接丢弃(自动重建)
docker volume rm book-comic-library_pgdata book-comic-library_cache
```
书库文件:原宿主 `./library/` 的内容移入 `deploy/api/storage/`。
## 可信代理与限流
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。
## 改 schema 前必读
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
## PWA
不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
````
- [ ] **Step 2: release 终验(spec §9.1/9.5)**
```bash
docker compose -f deploy/docker-compose.dev.yml down
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
cd backend && go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...
cd ../web && npm run check
```
Expected: 冒烟全绿、后端测试全过、前端 check 过。
- [ ] **Step 3: 规范符合性核对表(spec §9.4,逐条人工打勾)**
| 规范条目 | 核对命令/位置 |
|---|---|
| dev/release 两文件路径 | `deploy/docker-compose{.dev,}.yml` |
| dev 源码挂载 + 匿名 node_modules | dev compose `volumes` |
| `target: dev` / `*.prod target: runner` | compose `build.target` |
| infra 端口:dev 暴露(5432/6379)、release 内网 | 两文件 `ports` |
| delve 2345 | dev compose `ports` |
| healthcheck 门控 | dev/release `depends_on.condition` |
| `{$project}_postgres/redis_data` 同名共享 | `docker volume ls` 一次 |
| 渲染配置 `:ro` + logs 挂载 | 两文件 `volumes` |
| 存储挂 `deploy/{service}/storage` | `./api/storage:/data/books` |
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs(readme): rewrite run/dev/migrate instructions for dev/release deploy spec"
```