| 2 | BACKLOG 那条「上线前必做(架构评审 P1-4):Google Fonts 渲染阻塞」标的是「R9 或 R10」 | R9 做。顺带解决回滚演练缺真实代码差异的问题:v1 = 改字体前的镜像、v2 = 改后的,升级与回滚都有肉眼可验的差别,而不是两个内容相同只差 tag 的镜像 |
| 3 | 预发对外端口 | 80(SITE_ORIGIN=http://192.168.100.130)。Caddyfile 与 compose 零改动,与生产形态最接近 |
前端 · 字体自托管(BACKLOG R-BUN P1-4;规则 7 允许的接线性结构改动,理由见「本轮实测」)
构建
部署脚本与资产
部署脚本(补)
文档
轮次产出
| # | 检查 | 命令 / 期望 | 结果 |
|---|---|---|---|
| 1 | 两个镜像可构建 | .\dev.ps1 build 出 local/xray-api:<sha> 与 local/xray-web:<sha>,tag 均为 git 短 SHA | ✅ local/xray-api / local/xray-web 两版都出:c6231b4(v1)与 dbf61ce(v2) |
| 2 | 字体自托管 | 线上页面 HTML 与 CSS 中不出现 fonts.googleapis.com / fonts.gstatic.com;woff2 由本站 /_next/static/media/* 供,断外网仍是 JetBrains Mono | ✅ 线上零外部请求,woff2 由本站供(40404B,immutable) |
| 3 | 传输走文件 | docker save -o → scp → docker load -i;130 上 docker images 见两个 tag = 同一 git SHA | ✅ dev.ps1 ship 130 <sha>,tar 154.9 MB,远端 docker load 成功 |
| 4 | 先迁移后起服务 | up -d --wait postgres 阶段 130 的 80 端口无人监听;./migrate.sh 后再 up -d,业务接口首次响应即 200(不存在 500 窗口) | ✅ 起库阶段 80 端口 000;起服务后首次探测就是 200 |
| 5 | migrate.sh 幂等 | 复跑输出「无待执行迁移(已是最新)」;schema_migrations 与本机 encore run 库同构 | ✅ 复跑空操作;schema_migrations = `6 |
| 6 | 服务白名单逐项可达 | agent/trace/notes/mcp/metrics/about/system 七个服务各取一个正式端点,全部非 404 | ✅ 七个服务全部非 404;/api/spike/* 与 /admin 均 404 |
| 7 | 三 Tab | /(Runtime)、/notes、/about 均 200 且渲染真实数据(非 demo) | ✅ 三 Tab 200,内容来自库(经 MCP 发布的样本) |
| 8 | MCP 管理端点 | 无 token → 401 且有审计记录;带 token → server/discover 返回工具清单;一次写操作在审计表可见 | ✅ 无/错 token 与 GET 全 401 且有 denied 审计;server/discover 出 24 工具 |
| 9 | notes 配图路由 | /notes/<系列>/<哈希>.webp → 200 + ETag;带 If-None-Match 复请求 → 304(Caddy 扩展名分流生效) | ✅ 200 + ETag + max-age=86400;If-None-Match → 304;文章页未被劫走 |
| 10 | SSE ×2 冒烟 | /agent/ask 经 Caddy 流式出字;/trace/stream 有 15s 心跳、afterSeq 断线重连能回放;docker compose stop api 时客户端明确断流而非静默挂起 | ✅ 出字正常;15s 心跳;afterSeq=20 精确回放 21–39;stop api 时 curl 退出码 18(明确断流) |
| 11 | SSE 脱敏抽查 | 两条流的原始字节里搜不到 Authorization / api-key / apiKey / 明文 key 片段 | ✅ 明文 key / key 后 8 位 / 五种凭据头名 全部 0 命中 |
| 12 | 配额 | 经 MCP 把 dailyTokenLimit 调到极小 → 新会话被明确拒绝(非 500);恢复配置后可用 | ✅ daily_tokens 与 turn_limit 各回 429 + code;恢复后当轮生效 |
| 13 | agent_ro 写库失败 | 容器内 psql:SET ROLE agent_ro; INSERT INTO notes_series … → 权限错误(R7 落地补记 1 要求 R9 顺带核一句) | ✅ 写 notes 三表全 permission denied;读三表成功;其余十张表读也被拒 |
| 14 | 容器安全约束 | docker inspect 逐项为真:api/web 非 root(10001)、ReadonlyRootfs、CapDrop ALL、PidsLimit、Memory、no-new-privileges、tmpfs noexec | ✅ 逐项为真(明细见 smoke.md §7) |
| 15 | 最终运行镜像无 node | `docker run --rm --entrypoint sh local/xray-api:<sha> -c 'command -v node | |
| 16 | 网络分段 | 从 caddy / web 容器连 postgres:5432 不通;从 api 容器通 | ✅ web/caddy 连 postgres 解析都不通(NXDOMAIN);api 通 |
| 17 | 断连信号复测 | 真实拓扑(Caddy + 自托管镜像)下切断 SSE 客户端,服务端能否观测到 close —— 结论回填 BACKLOG 那三条(R3 一条 / R4 两条) | ✅ 已复测 —— 仍拿不到断开信号,三条 BACKLOG 均保持,让位机制不退役 |
| 18 | 回滚演练 | .env 的 IMAGE_TAG 换回上一 SHA + up -d 真跑;页面回到上一版形态(Google Fonts),再换回新 SHA 恢复 | ✅ v2→v1 用时 3s,页面回到 Google Fonts 形态且数据零丢失;再回 v2 恢复 |
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-09/BLOCKED.md,停下呼人。禁止放宽验收标准自我通过。
完整留证在 smoke.md(11 段 + 4 条更正)。这里只记「与计划不同」和「值得记住」的部分。
apps/web/Dockerfile 有一行 COPY --from=builder /app/public ./public。R5 时 public/ 装着 notes 配图,R6 按所有者裁定把图片搬进 Postgres 并删掉了整个目录 —— 那行从此指向一个不存在的 路径,docker build 直接失败。R6 之后没有人再构建过 web 镜像,所以这个洞一直没被发现, 正好由「R9 才第一次真构建」暴露出来。修法是删掉那行(自托管字体走 .next/static/media,不经 public/)。
这条的普遍教训:--services 白名单那种「漏改就静默 404」的维护热点已经进冒烟清单了, 但 Dockerfile 里的 COPY 是另一类 —— 它不静默,只是没人跑就发现不了。目前没有 CI (BACKLOG R0 那条),构建是靠轮次里手动触发的。
回滚演练如果两版镜像内容相同、只差 tag,验的只是 compose 的机制,验不了「回滚真的回到了上一版」。 所以本轮的提交刻意拆成 v1(构建修复 + ship)与 v2(字体自托管):v1 页面仍走 Google Fonts、 v2 是自托管,升级与回滚都有肉眼可验的差别。实测 v2→v1 用时 3s、页面确实回到 Google Fonts 形态、 库里的文章与配图零丢失。
BACKLOG 里有三条(R3 一条 / R4 两条)都挂着「等 R9 在真实拓扑下复测,若能拿到断开信号就放宽」。 结论是拿不到:开满 8 条 trace/stream 后 kill -9 掉全部客户端,等 6s,第 9 条仍然 429, api 日志没有任何释放迹象。加一层 Caddy 不改变 Encore 网关不传导断开的事实。 所以「同 clientId 让位」机制与 MAX_STREAM_MS 上界都要留着,三条 BACKLOG 全部保持打开。
顺带的正面结论:docker compose stop api 时客户端是明确断流(curl 退出码 18)而不是静默挂起, 优雅停机耗时 31s 与 stop_grace_period 40s 的配置吻合。
command -v node 在两个镜像里都命中 /usr/local/bun-node-fallback-bin/node —— 那是 oven/bun 基座自带的、指向 bun 的软链。结论本身没错(node -p "process.versions.bun" 回 1.4.0、 真实的 /usr/bin/node 不存在、nodejs 包未安装),但判据必须换。已回写 CLAUDE.md 规则 11 与冒烟清单第 12 条。
mcp/audit.ts 取 XFF 首段确实可被伪造(从 caddy 容器绕过反代直连 api:4000 时, 203.0.113.77 原样进了审计表),但经 Caddy 2.11.4 时伪造被挡住 —— 未配 trusted_proxies 的 Caddy 用真实对端 IP 覆盖不可信的 XFF。所以这条不紧急,但前提写进了 BACKLOG: 一旦前面再加一层代理或配了 trusted_proxies,保护就没了。
| # | 计划 | 实际 |
|---|---|---|
| 1 | 交付物里没有 deploy/migrate.sh | 加了一处去噪改动(advisory lock 从 SELECT 改 DO/PERFORM),行为不变;在隔离 compose 工程里从空库重跑 6 个迁移验证过 |
| 2 | 交付物里没有 docs/notes-content-spec.md | 所有者中途给了 D:\tmp\agent-xray-notes 并裁定「先给修改要求」,于是产出这份内容标准化契约。R9 仍不入库任何真实内容 |
| 3 | 计划只说「样本内容」 | 实际发布了四个分类 + 系列 r9-smoke + 2 篇文章 + 1 张配图 + 一份 About 内容 —— 因为验收 #7「三 Tab 渲染真实数据」在空库下验不了 About。这批样本要在真实内容到位时清掉(已记 BACKLOG) |
| 4 | 计划里 MCP 冒烟只写「带 token 实连」 | 实际踩到 2026-07-28 的 per-request envelope 键必须带 io.modelcontextprotocol/ 命名空间,少了直接 -32602。写进了 smoke.md,免得下次重踩 |