Files
crearte-monorepo/docs/plans/2026-09-29-prod-write-side.md
XingfenD 8af7ef3501 docs(plan): P1 prod write-side implementation plan (7 tasks, TDD on nginx template)
- docs/plans/2026-09-29-prod-write-side.md: crearte nginx template ->
  crearte-deploy compose/.env/README -> four-profile config gate -> prod
  live run + curl smoke chain (write side + wildcard subdomain runtime)
  -> merges with --no-ff -> wrapper bookkeeping
- docs/ROADMAP.md: register plan in doc index
- facts pinned from recon: no .env/no crearte volumes on this box (fresh
  env), :? guards are file-wide (not per-profile), curl needs --resolve
  for *.localhost, set-role invalidates tokens (re-login required)
2026-09-29 23:08:18 +08:00

668 lines
32 KiB
Markdown
Raw Permalink 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.
# P1 prod 写侧可用 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 让 prod profile 在本机 compose 内具备完整写侧与游玩链路(上传/投稿/审核/virtual 子域运行时),并收掉 `POSTGRES_PASSWORD` 默认值。
**架构:** 方案 A(镜像 dev 直连法)——新增 `minio-prod`(主机 9001,浏览器跨源直连),`api-prod` 补 `STORAGE_S3_*`/`GAMES_BASE_DOMAIN`/`CORS_ALLOWED_ORIGINS`;`crearte` 的 nginx 配置改为 envsubst 模板,通配游戏域块参数化并补 `/api/` 同源反代;TLS 本轮不做。
**技术栈:** docker compose v5、nginx:1.27-alpine entrypoint envsubst 模板、Go CLI(容器内 `/app/crearte-server`)、curl+python3 冒烟链。
**依据:** `docs/specs/2026-09-29-prod-write-side-design.md`(已批准)。规格覆盖映射见文末自检。
---
## 环境事实(本机,worker 必读)
- 仓库布局:wrapper `crearte-monorepo/` 下平级 `crearte`、`crearte-server`、`crearte-deploy`(已 clone,全在 master 干净)。
- 本机 `crearte-deploy/.env` **不存在**,`docker volume ls` **无任何 crearte 卷**(全新环境,无旧数据迁移负担;口令迁移警告仍要写 README,供其他主机用)。
- 本机无 host node/go。前端测试一律容器内跑,镜像 `crearte:dev` 需先由 compose 构建(任务 2)。
- `git@github.com` SSH 认证 = XingfenD(可推 crearte/crearte-deploy);wrapper remote = `git@git.yoresee.cc:XingfenD/crearte-monorepo.git`。各仓若报 `Author identity unknown`:`git config user.name XingfenD && git config user.email xingfen.fendy@outlook.com`(local,勿 --global)。
- 主机端口 8080/9000/9001/5432/5434 当前均空闲。`*.localhost` 由 Chrome 内建解析、glibc 不保证——curl 一律加 `--resolve <sub>.localhost:8080:127.0.0.1`。
- compose `:?` 守卫在 config 期对整个文件生效(不分 profile):任何 `--profile X config -q` 都要求 `.env` 五项 secret 齐(POSTGRES_PASSWORD/MINIO_ROOT_USER/MINIO_ROOT_PASSWORD/AUTH_TOKEN_SECRET/BUNDLE_KEK_k1)。
## 文件结构
| 仓 | 文件 | 动作 | 职责 |
|---|---|---|---|
| crearte | `deploy/nginx.conf.template` | 新建(git mv 改名 + 编辑) | 两 server 块的 envsubst 模板:`_` 主站 + `${GAMES_SERVER_NAME}` 通配游戏域(含新 `/api/` 反代) |
| crearte | `deploy/Dockerfile` | 修改 L15 | COPY 指向模板目录 |
| crearte | `src/scripts/repo-yaml.test.ts` | 修改 describe 块 | 守卫测试同步读模板、新增 `/api/` 断言 |
| crearte | `src/scripts/dev-game-runtime.ts` | 修改 L2 注释 | 引用路径改为 .template |
| crearte | `docs/CHANGELOG.md` | 顶部加 0.15.0 | 变更账目 |
| crearte-deploy | `docker-compose.yml` | 修改 | minio-prod、api-prod 写侧 env、web-prod args/env、口令去默认、新卷 |
| crearte-deploy | `.env.example` | 重写(全文件) | secret 占位与新变量注释 |
| crearte-deploy | `README.md` | 增 prod 小节 + 迁移警告 + 真机 TLS 清单 | 用法与数据 |
| crearte-deploy | `docs/CHANGELOG.md` | 顶部加 0.5.0 | 变更账目 |
| wrapper | `docs/ROADMAP.md`、`docs/CHANGELOG.md`、本计划索引 | 修改 | 状态与账目 |
提交顺序(spec §7):crearte → crearte-deploy → wrapper。
---
### 任务 1:crearte nginx 模板化(TDD)
**分支:** `cd crearte && git checkout -b feat/prod-nginx-wildcard-api`
- [ ] **步骤 1.1:改写守卫测试(先红)**
`src/scripts/repo-yaml.test.ts`:将整个 `describe('deploy/nginx.conf', ...)` 块替换为:
```ts
describe('deploy/nginx.conf.template', () => {
async function loadTemplate(): Promise<string> {
return readFile(path.join(REPO_ROOT, 'deploy', 'nginx.conf.template'), 'utf8')
}
it('通配游戏域 server block 暴露运行时三件套、/api/ 同源反代与 404 兜底', async () => {
const wildcard = serverBlock(await loadTemplate(), '${GAMES_SERVER_NAME}')
expect(wildcard).toMatch(/listen\s+80;/)
expect(wildcard).toMatch(/root\s+\/usr\/share\/nginx\/html;/)
const exact = [...wildcard.matchAll(/location\s*=\s*(\S+)\s*\{([^}]*)\}/g)]
expect(exact.map(([, location]) => location).sort()).toEqual(['/__bootstrap', '/agent.js', '/sw.js'])
for (const [, location, body] of exact) {
expect(body, `${location} 需要 no-store`).toContain('"no-store"')
}
const api = wildcard.match(/location\s+\/api\/\s*\{([^}]*)\}/)
expect(api, '通配块缺少 /api/ 反代(子域运行时取钥依赖同源代理)').toBeTruthy()
expect(api?.[1]).toMatch(/proxy_pass\s+http:\/\/\$api_upstream;/)
expect(api?.[1]).toMatch(/set\s+\$api_upstream\s+api:8080;/)
expect(wildcard).toMatch(/location\s*\/\s*\{[\s\S]*?return 404;\s*\}/)
expect(wildcard).not.toMatch(/location\s+\/data\//)
expect(wildcard).not.toMatch(/location\s+\/assets\//)
})
it('主站为 /data/bundles/ 提供 CORS 且保留 /data/ 行为', async () => {
const main = serverBlock(await loadTemplate(), '_')
const bundles = main.match(/location\s+\/data\/bundles\/\s*\{([^}]*)\}/)
expect(bundles, '主站缺少 /data/bundles/ location').toBeTruthy()
expect(bundles?.[1]).toContain('Access-Control-Allow-Origin')
expect(bundles?.[1]).toMatch(/try_files\s+\$uri\s+=404;/)
expect(main).toMatch(/location\s+\/data\/\s*\{/)
})
})
```
> `serverBlock()` 的 `server_name ${serverName};` 拼接传入字符串 `'${GAMES_SERVER_NAME}'` 后得到 `server_name ${GAMES_SERVER_NAME};`,与模板文本逐字匹配;JS 模板不会对已插值的 `$` 二次展开。
- [ ] **步骤 1.2:跑测试确认红**
```bash
cd crearte && npx --yes vitest run src/scripts/repo-yaml.test.ts
```
预期:FAIL(`ENOENT ... deploy/nginx.conf.template` 或 serverBlock 抛「缺少 server_name」)。**若本机 npx 因 node_modules 缺失失败,推迟到步骤 1.5 用容器跑,同样先确认红再继续。**
- [ ] **步骤 1.3:git mv 改名并编辑模板**
```bash
cd crearte && git mv deploy/nginx.conf deploy/nginx.conf.template
```
将文件整体重写为以下内容(头部注释为新增;通配块 `server_name` 参数化并新增 `/api/` location;其余与旧文件逐字一致):
```nginx
# envsubst 模板:nginx 官方镜像 entrypoint(20-envsubst-on-templates.sh)按已定义环境变量
# 渲染到 /etc/nginx/conf.d/default.conf。$uri/$host/$api_upstream 等 nginx 内建变量不是
# 环境变量,原样保留。运行期唯一必须定义的变量:GAMES_SERVER_NAME(compose web-prod 注入)。
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location /data/bundles/ {
add_header Cache-Control "no-cache";
add_header Access-Control-Allow-Origin "*";
try_files $uri =404;
}
location /data/ {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location /api/ {
resolver 127.0.0.11 valid=10s;
set $api_upstream api:8080;
proxy_pass http://$api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri /index.html;
}
}
server {
listen 80;
server_name ${GAMES_SERVER_NAME};
root /usr/share/nginx/html;
location = /__bootstrap {
add_header Cache-Control "no-store";
try_files /bootstrap/index.html =404;
}
location = /sw.js {
add_header Cache-Control "no-store";
try_files /sw.js =404;
}
location = /agent.js {
add_header Cache-Control "no-store";
try_files /agent.js =404;
}
# 子域游玩时 sw/agent 取 bundle-key 走同源反代(prod 由本块承担;dev 等价逻辑在 vite 插件)
location /api/ {
resolver 127.0.0.11 valid=10s;
set $api_upstream api:8080;
proxy_pass http://$api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
return 404;
}
}
```
- [ ] **步骤 1.4:改 Dockerfile 与过时注释**
`deploy/Dockerfile` L15:
```dockerfile
# 旧:COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/nginx.conf.template /etc/nginx/templates/default.conf.template
```
`src/scripts/dev-game-runtime.ts` L2 注释:`(deploy/nginx.conf)` → `(deploy/nginx.conf.template)`。
- [ ] **步骤 1.5:容器内跑全量单测确认绿(含类型)**
先建镜像与依赖卷(在 deploy 目录,context 会用到当前分支工作树):
```bash
cd ../crearte-deploy
docker compose --profile mock build mock # 产出 crearte:dev
docker run --rm -v "$PWD/../crearte":/repo \
-v crearte-deploy_mock_node_modules:/repo/src/node_modules \
-w /repo/src crearte:dev npm test
```
预期:全部 PASS(含 `repo-yaml.test.ts`;必须挂整个 crearte 仓,只挂 `src/` 会假失败——HANDOFF 既有事实)。再:
```bash
docker run --rm -v "$PWD/../crearte":/repo \
-v crearte-deploy_mock_node_modules:/repo/src/node_modules \
-w /repo/src crearte:dev npm run typecheck
```
预期:无错误。
- [ ] **步骤 1.6:CHANGELOG 0.15.0**
`crearte/docs/CHANGELOG.md` 顶部(`## [0.14.0]` 之前)插入(英中行相邻、条间空行):
```markdown
## [0.15.0] - 2026-09-29
### Changed / 变更
- `deploy/nginx.conf` became `deploy/nginx.conf.template`, rendered at container start by the official nginx entrypoint's envsubst pass (`/etc/nginx/templates/`). The wildcard game server's `server_name` is now the `GAMES_SERVER_NAME` variable instead of the hardcoded `*.games.example.com`, and the block gained `location /api/` same-origin reverse proxy to `api:8080` — without it, prod play runtime on `<sub>.<domain>` couldn't reach bundle-key (dev only worked because vite proxies `/api` on every host). The three-piece runtime (`/__bootstrap`, `/sw.js`, `/agent.js`) and the 404 fallback are unchanged; the shape is pinned by `repo-yaml.test.ts` against the template.
- `deploy/nginx.conf` 改为 `deploy/nginx.conf.template`,由 nginx 官方镜像 entrypoint 的 envsubst 机制在容器启动时渲染到 `/etc/nginx/templates/`。通配游戏域 server 的 `server_name` 从硬编码 `*.games.example.com` 改为 `GAMES_SERVER_NAME` 变量,并新增 `location /api/` 同源反代到 `api:8080`——此前 prod 子域运行时根本够不着 bundle-key(dev 能跑全靠 vite 在每个 Host 上顺带代理 `/api`)。运行时三件套(`/__bootstrap`、`/sw.js`、`/agent.js`)与 404 兜底骨架不变,形状由 `repo-yaml.test.ts` 对模板钉死。
```
- [ ] **步骤 1.7:Commit**
```bash
cd crearte
git status --short # 应只有 deploy/、src/scripts/、docs/CHANGELOG.md
git add -A && git commit -m "feat(deploy): template nginx conf, wildcard block gains same-origin /api proxy
- deploy/nginx.conf -> nginx.conf.template (GAMES_SERVER_NAME rendered by
nginx entrypoint envsubst; prod wildcard host was hardcoded dead config)
- wildcard game server adds location /api/ -> api:8080 so prod subdomain
play runtime can fetch bundle-key same-origin (dev-parity with vite)
- repo-yaml.test.ts guards the template shape incl. /api/ assertions
- docs: CHANGELOG 0.15.0"
```
---
### 任务 2:crearte 合入 master 并推送
- [ ] **步骤 2.1:合并(--no-ff,禁 fast-forward)**
```bash
cd crearte
git checkout master && git pull --ff-only origin master
git merge --no-ff feat/prod-nginx-wildcard-api -m "Merge branch 'feat/prod-nginx-wildcard-api' — prod play runtime via templated nginx wildcard /api proxy (P1)"
```
预期:新增 merge commit,无冲突(master 自 f12cbf1 后无新提交;若 pull 带来新改动导致冲突,停下向用户报告,勿强合)。
- [ ] **步骤 2.2:推送 + 删分支**
```bash
git push origin master
git branch -d feat/prod-nginx-wildcard-api && git push origin --delete feat/prod-nginx-wildcard-api
git log --oneline -2 # 记录 merge hash,供 wrapper ROADMAP 状态引用
```
---
### 任务 3:crearte-deploy compose 与 .env.example
**分支:** `cd crearte-deploy && git checkout -b feat/prod-write-side`
- [ ] **步骤 3.1:anchor 与 DATABASE_URL 去默认口令**
`docker-compose.yml`:
```yaml
# L5(x-postgres-env 内)旧: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crearte}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
# L54(api-dev)与 L126(api-prod)同型替换,旧: ...crearte:-crearte}@...
DATABASE_URL: postgres://crearte:${POST…rd}
# L58/L59(minio-dev)显式化默认值(行为不变,为 :? 语义让路)
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO…min}
```
> api-dev L54 一并改:anchor 共享,dev/prod 同受 `:?` 约束——README 迁移警告覆盖(任务 4)。
- [ ] **步骤 3.2:api-prod 补写侧 env**
`api-prod` 服务块整体替换为:
```yaml
api-prod:
profiles: ["prod"]
build: *api-build
image: crearte-server:prod
restart: unless-stopped
depends_on:
db-prod:
condition: service_healthy
minio-prod:
condition: service_started
environment:
<<: *api-env
DATABASE_URL: postgres://crearte:${POST…rd}
STORAGE_S3_ENDPOINT: http://minio-prod:9000
STORAGE_S3_BUCKET: ${MINIO_BUCKET:-crearte}
STORAGE_S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
STORAGE_S3_SECRET_ACCESS_KEY: ${MINI…?:set MINIO_ROOT_PASSWORD in .env}
STORAGE_S3_FORCE_PATH_STYLE: "true"
# 浏览器可达的公开基址(PublicURL = base + "/" + key,需含 bucket 段)
STORAGE_S3_PUBLIC_BASE_URL: http://localhost:${MINIO_PROD_PORT:-9001}/${MINIO_BUCKET:-crearte}
# CORS 通配放行:host == base 或 *.base;http 仅对 *.localhost 开
GAMES_BASE_DOMAIN: localhost
CORS_ALLOWED_ORIGINS: ${HOST_ORIGIN:-http://localhost:8080}
networks:
default:
aliases: [api]
healthcheck:
<<: *api-healthcheck
```
- [ ] **步骤 3.3:新增 minio-prod 服务**
紧接 `api-prod` 之后(`db-debug` 之前)插入:
```yaml
minio-prod:
profiles: ["prod"]
image: pgsty/minio:latest
restart: unless-stopped
command: ["server", "/data"]
ports:
- "${MINIO_PROD_PORT:-9001}:9000"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
MINIO_ROOT_PASSWORD: ${MINI…?:set MINIO_ROOT_PASSWORD in .env}
# 游玩/封面跨源直连(与 minio-dev 同策略)
MINIO_API_CORS_ALLOW_ORIGIN: "*"
volumes:
- minio-data-prod:/data
```
- [ ] **步骤 3.4:web-prod 补构建参数与模板变量**
`web-prod` 服务块整体替换为:
```yaml
web-prod:
profiles: ["prod"]
build:
context: ../crearte
dockerfile: deploy/Dockerfile
args:
VITE_API_BASE_URL: /
VITE_GAMES_BASE_DOMAIN: ${PUBLIC_GAMES_HOST:-localhost:8080}
VITE_HOST_ORIGIN: ${HOST_ORIGIN:-http://localhost:8080}
image: crearte:prod
restart: unless-stopped
depends_on:
- api-prod
environment:
GAMES_SERVER_NAME: ${GAMES_SERVER_NAME:-*.localhost}
ports:
- "8080:80"
```
> `PUBLIC_GAMES_HOST`(客户端 host:port 拼接用)与 api 的 `GAMES_BASE_DOMAIN`(CORS 主机名后缀匹配)是两个语义不同的变量,刻意不共用,见 spec §3.2 命名注意。
- [ ] **步骤 3.5:卷声明追加**
文件尾 `volumes:` 块追加一行(不动既有卷名):
```yaml
minio-data-prod:
```
- [ ] **步骤 3.6:`.env.example` 整体重写**
```bash
# 复制为 .env 后填写;.env 已被 .gitignore 忽略,绝不提交
# 生成 secret:openssl rand -base64 32
AUTH_TOKEN_SECRET=
BUNDLE_KEK_ACTIVE=k1
BUNDLE_KEK_k1=
# Postgres 口令(0.5.0 起无默认值,缺失 compose 直接报错)。
# 老部署务必显式写旧卷的真实口令,否则鉴权失败,见 README「数据与配置」迁移警告。
POSTGRES_PASSWORD=
# MinIO root 凭据(:? 必填)。本机 dev/prod 演练可填 minioadmin/minioadmin;
# 任何对外可达的 prod 必须换强口令:openssl rand -base64 32
MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD=
# prod 写侧可选项(默认值即本机自包含演练形态,详见 README prod 小节):
# MINIO_PROD_PORT=9001 # prod MinIO 主机端口(dev 用 9000)
# HOST_ORIGIN=http://localhost:8080 # 主站对外 origin(CORS 白名单 + VITE_HOST_ORIGIN)
# PUBLIC_GAMES_HOST=localhost:8080 # 游玩子域基址 host:port(客户端拼 <sub>:port)
# GAMES_SERVER_NAME=*.localhost # nginx 通配 server_name(真域名时 *.games.example.com)
# MINIO_BUCKET=crearte
# macOS / 网络盘上热更新不触发时设为 true
# CHOKIDAR_USEPOLLING=false
```
---
### 任务 4:crearte-deploy README 与 CHANGELOG
- [ ] **步骤 4.1:README「使用」内 dev 桶初始化段之后插入**
```markdown
### prod 栈写侧(本机自包含演练)
prod 与 dev 互斥(都占 8080)。`minio-prod`(主机端口默认 **9001**)为 prod 启用写侧路由(上传/投稿/审核/预览);virtual 作品站内游玩走 `http://<sub>.localhost:8080`——web-prod 的 nginx 通配块按 `GAMES_SERVER_NAME` 渲染三件套 + `/api/` 同源反代,与 dev 的 vite 行为对齐。
```bash
cp .env.example .env # 必填 5 项:AUTH_TOKEN_SECRET、BUNDLE_KEK_k1、POSTGRES_PASSWORD、MINIO_ROOT_USER、MINIO_ROOT_PASSWORD
docker compose --profile prod up -d --build
# prod 卷首次创建后一次性初始化桶(单引号刻意——防宿主 shell 展开,容器内凭 compose 注入的凭据完成):
docker compose --profile prod exec minio-prod sh -c 'mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" && mc mb --ignore-existing local/crearte && mc anonymous set download local/crearte'
```
迁真实服务器的最小清单(本轮演练未含 TLS):泛解析 `*.games.<域名>` → 主机;`.env` 设 `HOST_ORIGIN`/`PUBLIC_GAMES_HOST`/`GAMES_SERVER_NAME` 为真实值并同步 api 的 `GAMES_BASE_DOMAIN`;`MINIO_PROD_PORT` 收内网或换外部 S3(改 `STORAGE_S3_*` 五项即可,应用零改动);上反代 TLS(Caddy/certbot + 通配证书)后把 `STORAGE_S3_PUBLIC_BASE_URL`、`HOST_ORIGIN` 切 https——server CORS 对 https 子域直接放行。
```
- [ ] **步骤 4.2:README「数据与配置」小节更新**
卷列表行改为提及 `minio-data-prod`;并在其下追加迁移警告:
```markdown
- **0.5.0 口令迁移警告**:`POSTGRES_PASSWORD` 不再有 `crearte` 默认值。既有主机的 `.env` 若没写过该行,升级后必须显式补 `POSTGRES_PASSWORD=<旧卷实际口令>`(全新 dev 卷旧口令即 `crearte`),否则 Postgres 鉴权失败。`MINIO_ROOT_USER/PASSWORD` 同理改为必填(dev 可继续用 minioadmin 值)。
```
- [ ] **步骤 4.3:CHANGELOG 0.5.0**
`docs/CHANGELOG.md` 顶部(`## [0.4.0]` 之前)插入:
```markdown
## [0.5.0] - 2026-09-29
### Added / 新增
- The prod profile gains a full write side: new `minio-prod` object storage (host port `${MINIO_PROD_PORT:-9001}`, volume `minio-data-prod`, CORS-open) wired to `api-prod` via `STORAGE_S3_*`, plus `GAMES_BASE_DOMAIN=localhost` and `CORS_ALLOWED_ORIGINS` — upload/submission/review/preview routes now register in prod instead of 404-ing. `web-prod` gained `GAMES_SERVER_NAME` (renders the templated nginx wildcard block) and `VITE_GAMES_BASE_DOMAIN`/`VITE_HOST_ORIGIN` build args for the `*.localhost` play runtime. One-time prod bucket init and the real-server TLS checklist are in README.
- prod profile 补齐写侧全链路:新增 `minio-prod`(主机端口 `${MINIO_PROD_PORT:-9001}`、卷 `minio-data-prod`、开放 CORS),`api-prod` 配置 `STORAGE_S3_*` 与 `GAMES_BASE_DOMAIN=localhost`、`CORS_ALLOWED_ORIGINS`——上传/投稿/审核/预览路由在生产启用(此前一律 404)。`web-prod` 新增 `GAMES_SERVER_NAME`(渲染 nginx 模板通配块)与游玩运行时构建参数 `VITE_GAMES_BASE_DOMAIN`/`VITE_HOST_ORIGIN`。prod 桶一次性初始化与真服务器 TLS 清单见 README。
### Changed / 变更
- `POSTGRES_PASSWORD` lost its `crearte` fallback: the env anchor and both `DATABASE_URL`s use `${POSTGRES_PASSWORD:?…}`, so compose fails fast when missing. Old checkouts must set the line explicitly to match the volume's frozen password (see README migration warning). `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` became `:?`-required (empty placeholders in `.env.example`; minioadmin stays fine for local drills) and `minio-dev` now spells its defaults via the same variables.
- `POSTGRES_PASSWORD` 去掉 `crearte` 兜底:anchor 与两条 `DATABASE_URL` 改为 `${POSTGRES_PASSWORD:?…}`,缺失即快速失败;既有部署须显式补写旧卷口令(见 README 迁移警告)。`MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` 升为 `:?` 必填(`.env.example` 空占位;本机演练填 minioadmin 即可),`minio-dev` 默认值经同组变量显式化(行为不变)。
```
---
### 任务 5:crearte-deploy 验证——config + prod 实跑 + 冒烟链
**前置:** 任务 2 已完成(prod 镜像构建需要 master 上的模板 nginx)。以下命令全部在 `crearte-deploy/` 执行。
- [ ] **步骤 5.1:生成 .env**
```bash
test -f .env && echo "ABORT: .env 已存在,停下核对而不是覆盖" || cp .env.example .env
{
echo "AUTH_TOKEN_SECRET=$(openssl rand -base64 32)"
echo "BUNDLE_KEK_k1=$(openssl rand -base64 32)"
echo "POSTGRES_PASSWORD=crearte"
echo "MINIO_ROOT_USER=minioadmin"
echo "MINIO_ROOT_PASSWORD=***"
} >> .env
sed -i '/^AUTH_TOKEN_SECRET=$/D;/^BUNDLE_KEK_k1=$/D;/^POSTGRES_PASSWORD=$/D;/^MINIO_ROOT_USER=$/D;/^MINIO_ROOT_PASSWORD=$/D' .env
```
- [ ] **步骤 5.2:四 profile config 全静默**
```bash
for p in dev prod debug mock; do docker compose --profile $p config -q || echo "FAIL $p"; done
```
预期:无输出无 FAIL。再验守卫确实生效:`POSTGRES_PASSWORD= docker compose --profile prod config -q` 应报 required variable 错误(测后恢复)。
- [ ] **步骤 5.3:prod 起栈 + 健康**
```bash
docker compose --profile prod up -d --build
docker compose --profile prod ps
```
预期:`api-prod healthy`、`db-prod healthy`、`web-prod`/`minio-prod` running。首拉镜像慢属正常(GOPROXY 已指 goproxy.cn)。
- [ ] **步骤 5.4:prod 桶初始化**(README 命令原样执行,预期 `mc mb`/`anonymous set` 成功)
- [ ] **步骤 5.5:冒烟链(写侧 + 游玩链路 curl 等价物)**
逐条执行(`ADMIN_TOKEN` 全程 shell 变量传递,**不回显、不落盘敏感响应正文**):
```bash
set -euo pipefail
B=http://localhost:8080
# 1) healthz(经 web-prod 反代)
curl -sf $B/api/games >/dev/null && echo "catalog ok"
docker compose exec -T api-prod wget -qO- http://127.0.0.1:8080/healthz | grep -q ok && echo "healthz ok"
# 2) 注册 -> 提权 -> 重登(set-role 会失效旧 token,必须重登)
curl -s -o /tmp/p1reg.json -w '%{http_code}\n' -X POST $B/api/auth/register -H 'Content-Type: application/json' \
-d '{"email":"p1@example.com","password":"***","display_name":"P1 Smoke","username":"p1admin"}' # 201
docker compose exec -T api-prod /app/crearte-server user set-role p1@example.com admin
curl -s -o /tmp/p1login.json -w '%{http_code}\n' -X POST $B/api/auth/login -H 'Content-Type: application/json' \
-d '{"email":"p1@example.com","password":"***"}' # 200
ADMIN_TOKEN=*** -c "import json;print(json.load(open('/tmp/p1login.json'))['token'])")
# 3) 上传真实 zip bundle
python3 -c "import zipfile;zipfile.ZipFile('/tmp/p1.zip','w').writestr('index.html','<h1>p1</h1>')"
curl -s -o /tmp/p1up.json -w '%{http_code}\n' -X POST $B/api/uploads -H "Authorization: Bearer $ADMIN_TOKEN" \
-F kind=bundle -F slug=p1-smoke -F version=v1 -F file=@/tmp/p1.zip # 201
UP_ID=$(python3 -c "import json;print(json.load(open('/tmp/p1up.json'))['upload_id'])")
# 4) 投稿(virtual,new_work,直提)+ external 第二件
curl -s -o /tmp/p1sub.json -w '%{http_code}\n' -X POST $B/api/submissions -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{
"kind":"new_work","work_id":"p1admin/p1-smoke","bundle_upload_id":"'"$UP_ID"'","submit":true,
"payload":{"id":"p1admin/p1-smoke","runtime":"virtual","name":"P1 Smoke","url":"https://example.com",
"author":{"name":"P1"},"description":"prod write-side smoke","durationMinutes":{"min":1,"max":60},
"type":"puzzle","tags":["smoke"],"version":"v1"}}' # 201
SUB_ID=$(python3 -c "import json;print(json.load(open('/tmp/p1sub.json'))['id'])")
curl -s -o /tmp/p1ext.json -w '%{http_code}\n' -X POST $B/api/submissions -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{
"kind":"new_work","work_id":"p1admin/p1-ext","submit":true,
"payload":{"id":"p1admin/p1-ext","runtime":"external","name":"P1 Ext","url":"https://example.com",
"author":{"name":"P1"},"description":"external smoke","durationMinutes":{"min":1,"max":60},
"type":"puzzle","tags":[]}}' # 201
# 5) 审核上架两作品
curl -s -o /dev/null -w 'approve1 %{http_code}\n' -X POST $B/api/admin/submissions/$SUB_ID/approve \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{"note":"smoke"}' # 200
EXT_SUB_ID=$(python3 -c "import json;print(json.load(open('/tmp/p1ext.json'))['id'])")
curl -s -o /dev/null -w 'approve2 %{http_code}\n' -X POST $B/api/admin/submissions/$EXT_SUB_ID/approve \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{"note":"smoke"}' # 200
# 6) 详情:schema v2、playSubdomain、匿名可读
curl -s -o /tmp/p1det.json -w '%{http_code}\n' $B/api/games/p1admin/p1-smoke # 200(无 token)
python3 - <<'PY'
import json
d = json.load(open('/tmp/p1det.json'))
assert d['runtime'] == 'virtual' and d['playSubdomain'], d
assert d.get('bundle', {}).get('url'), '缺 bundle.url'
print('detail ok sub=', d['playSubdomain'])
PY
# 7) 通配子域:三件套 + /api 同源反代 + bundle-key(--resolve 绕 glibc 不解析 *.localhost)
SUB=$(python3 -c "import json;print(json.load(open('/tmp/p1det.json'))['playSubdomain'])")
for path in /__bootstrap /sw.js /agent.js; do
curl -sf --resolve "$SUB.localhost:8080:127.0.0.1" "http://$SUB.localhost:8080$path" >/dev/null && echo "sub $path ok"
done
curl -s -o /tmp/p1key.json -w 'bundle-key %{http_code}\n' \
--resolve "$SUB.localhost:8080:127.0.0.1" -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://$SUB.localhost:8080/api/games/p1admin/p1-smoke/bundle-key" # 200
python3 -c "import json;d=json.load(open('/tmp/p1key.json'));assert d['v']==1;print('key shape ok')"
shred -u /tmp/p1key.json 2>/dev/null || rm -f /tmp/p1key.json # 密钥材料不落盘残留
# 8) 跨源直连:bundle 密文匿名下载 + Origin=CORS 头(server 放行 *.localhost)
BU=$(python3 -c "import json;print(json.load(open('/tmp/p1det.json'))['bundle']['url'])")
curl -sf -o /tmp/p1bundle.bin "$BU" && echo "bundle object ok ($(stat -c%s /tmp/p1bundle.bin)B)"
curl -s -D- -o /dev/null -H "Origin: http://$SUB.localhost:8080" $B/api/games/p1admin/p1-smoke/bundle-key \
-H "Authorization: Bearer $ADMIN_TOKEN" | grep -i 'access-control-allow-origin' | grep -q "$SUB.localhost" && echo "cors wildcard ok"
curl -s -o /dev/null -w 'ext detail %{http_code}\n' $B/api/games/p1admin/p1-ext # 200
```
预期全绿。任一步 4xx/5xx:按 spec §4 排查(路由未注册→查 `STORAGE_S3_*` 是否被 `LoadStorage` 判不全;子域 404→查 `GAMES_SERVER_NAME` 渲染 `docker compose exec web-prod cat /etc/nginx/conf.d/default.conf`)。
- [ ] **步骤 5.6:dev 回归**
```bash
docker compose --profile prod down
docker compose --profile dev up -d --build
docker compose --profile dev exec -T api-dev wget -qO- http://127.0.0.1:8080/healthz | grep -q ok && echo dev-api ok
docker compose --profile dev exec minio-dev sh -c "mc alias set local http://localhost:9000 minioadmin minioadmin && mc mb --ignore-existing local/crearte && mc anonymous set download local/crearte"
curl -sf http://localhost:8080/api/games >/dev/null && echo dev-catalog ok
docker compose --profile dev down # 无 -v
```
预期全绿(dev 的 minio-dev 仍走默认凭据;`.env` 里的 minioadmin 值与 `:-` 兼容)。
- [ ] **步骤 5.7:浏览器人工验收(可延后,标记即可)**
有 GUI 的机器上打开 `http://localhost:8080`(prod 栈),走 UI 上传→投稿→后台审核→站内游玩。本机为 headless,计划以步骤 5.5 的 curl 等价链为验收线,浏览器项标 `manual-deferred`。
---
### 任务 6:crearte-deploy 合入并推送
- [ ] **步骤 6.1**
```bash
cd crearte-deploy
git status --short # 应只有 docker-compose.yml、.env.example、README.md、docs/CHANGELOG.md;.env 绝不入列
git add -A && git commit -m "feat(prod): write side via minio-prod + games runtime env; drop POSTGRES_PASSWORD default
- minio-prod (host 9001, volume minio-data-prod) wired to api-prod
STORAGE_S3_*/GAMES_BASE_DOMAIN/CORS_ALLOWED_ORIGINS -> upload/submission/
review routes register in prod
- web-prod: GAMES_SERVER_NAME template var + VITE_GAMES_BASE_DOMAIN /
VITE_HOST_ORIGIN build args
- POSTGRES_PASSWORD & MINIO_ROOT_* become :?-required; .env.example synced;
README gains prod bucket init, password migration warning, TLS checklist
- docs: CHANGELOG 0.5.0"
git checkout master && git pull --ff-only origin master
git merge --no-ff feat/prod-write-side -m "Merge branch 'feat/prod-write-side' — prod write-side self-contained drill (P1)"
git push origin master
git branch -d feat/prod-write-side && git push origin --delete feat/prod-write-side
git log --oneline -2 # 记录 merge hash
```
---
### 任务 7:wrapper 收尾(状态、账目、计划登记)
- [ ] **步骤 7.1:ROADMAP 更新**
P1 行状态列改为:`**完成**(crearte `<任务2 merge hash>` / deploy `<任务6 merge hash>`,2026-09-29 合并推送;TLS 与真域名留清单,见 spec §6)`。文档索引表 P1 行实现计划列改为 ``docs/plans/2026-09-29-prod-write-side.md`(已执行,2026-09-29)`。
- [ ] **步骤 7.2:wrapper CHANGELOG 0.2.2**
```markdown
## [0.2.2] - 2026-09-29
### Docs / 文档
- Roadmap P1 delivered: prod write side went live in the compose drill — minio-prod + api-prod storage/games env + templated nginx wildcard block (crearte `<>` / crearte-deploy `<>`), POSTGRES_PASSWORD default retired, TLS deferred by design with a real-server checklist in README.
- 路线图 P1 交付:prod 写侧在本机演练栈完整启用——minio-prod、api-prod 存储/游玩域名环境、nginx 通配块模板化(crearte `<>` / crearte-deploy `<>`);POSTGRES_PASSWORD 默认值退役;TLS 按设计缓做,真机清单已入 README。
```
(`<>` 处以真实 merge hash 替换;英文行紧接中文行。)
- [ ] **步骤 7.3:Commit + Push(wrapper 可直提 master)**
```bash
cd ..
git add docs/ROADMAP.md docs/CHANGELOG.md docs/plans/ docs/specs/
git commit -m "docs: P1 delivered — prod write-side drill merged; plan registered, changelog 0.2.2"
git push origin master
```
- [ ] **步骤 7.4:终态核验**
```bash
git -C crearte pull --ff-only origin master && git -C crearte log --oneline -1
git -C crearte-deploy pull --ff-only origin master && git -C crearte-deploy log --oneline -1
./clone_all.sh # 应输出 3 updated
```
---
## 自检(写完后执行,已内联修复)
1. **规格覆盖度**:spec §3.1-3.2 端口/服务/env → 任务 3.2-3.5;§3.3 口令链 → 3.1/3.6 + README 4.2;§3.4 nginx 模板 + 守卫测试 → 任务 1;§3.5 桶初始化 → README 4.1 + 步骤 5.4;§4 验证四步 → 5.1-5.7(浏览器项如实标 deferred);§5 影响文件表逐项对应;§6 不做项无对应任务(正确);§7 分支/顺序 → 任务 1/2/3/6/7。✔
2. **占位符扫描**:merge hash 占位 `<>` 为运行时事实(任务 2/6 执行时产生),其余无 TODO/待定。✔
3. **类型/命名一致性**:`GAMES_SERVER_NAME`(web-prod env ↔ nginx 模板 ↔ 测试断言)、`PUBLIC_GAMES_HOST`/`HOST_ORIGIN`/`MINIO_PROD_PORT`/`MINIO_BUCKET`(compose ↔ .env.example ↔ README)、模板路径 `deploy/nginx.conf.template`(Dockerfile ↔ repo-yaml.test.ts ↔ dev-game-runtime 注释)三向一致。✔
**范围外提醒(不入本计划)**:HANDOFF §4 前端零散 backlog 与本计划无关;若冒烟 5.5 步骤 7 发现 `sw.js`/`agent.js` 在 prod dist 缺失(`nginx exec ls /usr/share/nginx/html`),那是 crearte 构建产物的独立缺陷,上报而非顺手修。