Compare commits
15
Commits
0e63dc2448
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70991b0ad | ||
|
|
51bed55704 | ||
|
|
dd5d0fabf9 | ||
|
|
9061c39ef3 | ||
|
|
37a0a8ddd1 | ||
|
|
02188eb3e0 | ||
|
|
82f0a6d360 | ||
|
|
163b0d703a | ||
|
|
33c2d8d4b8 | ||
|
|
11fa1e2d26 | ||
|
|
9965fd73ee | ||
|
|
ba1fe3ecb0 | ||
|
|
14d029e61b | ||
|
|
b9e59aee83 | ||
|
|
708f95eb3d |
File diff suppressed because one or more lines are too long
+38
-3
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
|
|||||||
|
# P11 服务端安全硬化批 — 实现计划
|
||||||
|
|
||||||
|
日期:2026-10-02 | spec:`docs/specs/2026-10-02-p11-server-hardening-design.md`(权威,冲突时 spec 赢)
|
||||||
|
|
||||||
|
## 全局约束(逐字进每个任务简报)
|
||||||
|
|
||||||
|
1. **零新依赖**:`crearte-server/src/go.mod` 与 `go.sum` 不得出现新 require;前端 `package.json` 不动。
|
||||||
|
2. **Go 工具链(硬性)**:宿主 Go 是 1.18,**不可用**。所有 `go build/vet/test` 必须在容器里跑:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd <repo>/crearte-server/src && docker run --rm \
|
||||||
|
-v "$PWD":/src -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod \
|
||||||
|
-e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine go test ./internal/api/ -run 'X' -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
冷构建 1–3 分钟属正常,用 `exec` 后台 + 耐心轮询,**不要**因为没立刻出结果就重跑。`proxy.golang.org` 在本机不可达,漏掉 `GOPROXY` 会挂死。
|
||||||
|
3. **禁触文件**:`crearte-server` 的 `internal/bundle/**`、`internal/repository/migrations/**`、`internal/storage/**`、`cmd/**`(除 spec 明列);`crearte` 的 `src/app/**`、`src/e2e/**`、`vite.config.ts`、`package.json`、各 config;`crearte-deploy` 的 `scripts/**`、`.github/**`、`backups/**`。
|
||||||
|
4. **compose 数据身份红线**:不得重命名或增删任何卷(`pgdata-*`、`bundle-keys-*`、`minio-data-*`、`dev_node_modules`、`mock_node_modules`)。不得改 `depends_on` 健康门控。不得移除 `${VAR:?…}` 守卫。
|
||||||
|
5. **绝不 `docker compose … down -v`**(会毁 postgres 数据、bundle keys、MinIO 对象,且 MinIO bucket 初始化须手工重做)。收尾用裸 `down`。
|
||||||
|
6. **`TEST_DATABASE_URL` 绝不指向 `db-debug`/`pgdata-dev`**——`db-debug` 与 `db-dev` 共享 `pgdata-dev` 卷,误指会清空开发数据。集成测试只用 `db-test`(`pgdata-test`,127.0.0.1:5432)。
|
||||||
|
7. **TDD**:先写/改测试跑红,再实现跑绿;报告里贴 RED/GREEN 的命令与输出摘要。
|
||||||
|
8. **每任务独立 commit**,前缀 `fix(security):` / `fix(config):` / `feat(observability):` / `chore(deploy):` + 任务号;**CHANGELOG 不由任务写**,控制者波次末统一补。
|
||||||
|
9. **分支纪律**:commit 前 `git branch --show-current` 必须是本任务指定分支;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
|
||||||
|
- `crearte-server` → `fix/p11-server-hardening`
|
||||||
|
- `crearte-deploy` → `feat/p11-trusted-proxies`
|
||||||
|
- `crearte` → `feat/p11-trusted-proxies`
|
||||||
|
10. **精确使用 spec 给定值**:subnet 与 `TRUSTED_PROXIES` 默认值均为 `172.28.0.0/24`(已核实宿主 `172.17.0.0/16` docker0、`172.18.0.0/16` baota_net 之外空闲);`X-Real-IP` 用 `$remote_addr`;`Referrer-Policy` 用 `strict-origin-when-cross-origin`;`X-Content-Type-Options` 用 `nosniff`;两个 `add_header` 都带 `always`。
|
||||||
|
11. **数量/数值必须实测核实**:简报或 spec 里引用的计数(几处 `location`、几个 `add_header`、几个限流器)动手前自己 grep 核准,发现不符以实测为准并在报告里披露。本仓上一波(P9-B)控制者连错两次数字(对比度 3.16 应为 2.83、「19 个 th」是行计数应为 24 元素),均由实现者纠正——这条纪律有效,继续保持。
|
||||||
|
12. **验收命令**(每任务收尾自跑并贴摘要):server 任务 = 本包 `go test` + `go vet ./...` + `gofmt -l`(应无输出);deploy/crearte 任务见各自简报。全量腿由控制者波次末跑。
|
||||||
|
|
||||||
|
## 任务分解与并行度
|
||||||
|
|
||||||
|
文件面两两不相交才可并行。同一 Go 包内并发编辑会让兄弟任务的 `go test` 看到半成品而假红,故 **T1 与 T2 同属 `internal/api`,必须串行**。
|
||||||
|
|
||||||
|
| 任务 | 仓 / 包 | 改动文件 | 波次 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T1 限流表硬上界(缺陷 B) | server / `internal/api` | `ratelimit.go`、`ratelimit_test.go` | **1** |
|
||||||
|
| T2 信任代理链 + 启动告警(缺陷 A、D-C) | server / `internal/api` | `router.go`、新 `clientip_test.go`、`router_test.go` | **2**(T1 后) |
|
||||||
|
| T3 LOG 归一 + `parseTrustedProxies` 覆盖(缺陷 C) | server / `internal/config` | `config.go`、`config_test.go` | **1** |
|
||||||
|
| T4 compose 固定 subnet + `TRUSTED_PROXIES`(D-A) | deploy | `docker-compose.yml`、`.env.example`、`README.md` | **1** |
|
||||||
|
| T5 nginx 反代头加固(D-E) | crearte | `deploy/nginx.conf.template` | **1** |
|
||||||
|
| T6 验收(控制者本人,不派发) | 三仓 | — | **3** |
|
||||||
|
|
||||||
|
**波次 1 并行**:T1 + T3 + T4 + T5(四个不同仓/包,零文件重叠)。
|
||||||
|
**波次 2**:T2(等 T1 落地,同包串行)。
|
||||||
|
**波次 3**:T6 控制者验收 + 终审。
|
||||||
|
|
||||||
|
## T6 验收腿(控制者本人)
|
||||||
|
|
||||||
|
1. **server**:`gofmt -l`(空)、`go vet ./...`、`go test ./... -count=1`、`go test ./... -race -count=1`、`go build ./...`;集成腿用 `db-test`(`docker compose --profile debug up -d db-test` 后 `TEST_DATABASE_URL=postgres://crearte:<pw>@127.0.0.1:5432/crearte?sslmode=disable go test ./... -count=1`),确认 skip 守卫数为 0。
|
||||||
|
2. **deploy**:四 profile `docker compose --profile dev|prod|debug|mock config -q` 全过;`config` 输出断言 `api-prod`/`api-dev` 的 `TRUSTED_PROXIES` = `172.28.0.0/24`、`networks.default.ipam.config[0].subnet` 同值、**卷名集合与改动前逐字相同**。
|
||||||
|
3. **crearte**:五腿全量不回退(vitest **626** / vue-tsc 0 / build OK / 主 e2e **80+1skip** / noauth **4**)——nginx 模板属部署资产,前端测试不应受影响,但必须实跑确认。
|
||||||
|
4. **端到端(缺陷 A 的回归钉桩,最关键一腿)**:`docker compose --profile dev up -d --build` → `ps` 健康 →
|
||||||
|
- 启动日志**无** D-C 告警(因为 T4 注入了 `TRUSTED_PROXIES`);
|
||||||
|
- 用两个不同 `X-Forwarded-For` 各打 `/api/auth/login` 若干次,确认**各自独立计数**(改前是共享桶 → 第二个用户首次即 429;改后两个用户互不干扰);
|
||||||
|
- 伪造抗性:客户端自带 `X-Forwarded-For: 8.8.8.8` 打一次,确认服务端日志/限流键取的是**网关追加后的真实值**而非 `8.8.8.8`;
|
||||||
|
- `nginx -t` 校验模板渲染;确认新安全头出现在响应里(含 4xx 路径,验 `always`)。
|
||||||
|
- 收尾 `docker compose --profile dev down`(**绝不 `-v`**)。
|
||||||
|
5. **prod profile 冒烟**:`--profile prod up -d --build` → `/healthz` + `/metrics` → `down`。
|
||||||
|
|
||||||
|
## 记账(控制者,波次末)
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` → **0.17.0**;`crearte-deploy/docs/CHANGELOG.md` → **0.7.0**;`crearte/docs/CHANGELOG.md` → **0.24.1**;wrapper `docs/CHANGELOG.md` → **0.3.4**。四仓均双语(同条目英中相邻行、条目间空行、高版本在上)。
|
||||||
|
- 三内仓各自 `git merge --no-ff <branch>` → push → 删分支(内仓不得在 master 直接 commit)。wrapper 可 master 直提(2026-09-29 已获批)。
|
||||||
|
- wrapper `docs/ROADMAP.md`:新增 P11 行 + 文档索引表补 spec/plan 两行(AGENTS.md 硬性要求)。
|
||||||
|
- `memory/2026-10-02.md`:记录本波缺陷链(A 掩盖 B)、spike 方法论、以及「修一个缺陷可能让另一个从不可达变可达」这条教训。
|
||||||
|
|
||||||
|
## 打磨批 / 挂账(不在本批)
|
||||||
|
|
||||||
|
- CSP(iframe + Service Worker 加载用户作品,需独立设计批)。
|
||||||
|
- 共享限流存储(Redis 等)——多实例化前置条件,见 spec D-G。
|
||||||
|
- P9-B 打磨批四条(vue-router 升级复看弱断言、撤评语义进可访问名、手动关最新 toast 的播报重念、spec 口径已修)。
|
||||||
|
- 备份恢复演练实证(`backup.sh`/`restore-drill.sh` 已交付但 prod 栈演练未跑,属 owner 手动尾巴)。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# P12 窄屏溢出修复与打磨批 — 实现计划
|
||||||
|
|
||||||
|
- spec:`docs/specs/2026-10-02-p12-narrow-viewport-overflow-design.md`(权威,含全部实测数字与决策理由)
|
||||||
|
- 证据:`crearte/.superpowers/sdd-p12/FINDINGS-survey.md`(16 轮 spike 实测归档)
|
||||||
|
- 分支名:三仓统一 `fix/p12-narrow-viewport-overflow`
|
||||||
|
- 基线:crearte `d25c497`(0.24.1)/ crearte-server `9da39b6`(0.17.0)/ crearte-deploy `740958a`(0.7.0)
|
||||||
|
|
||||||
|
## 任务切分与波次
|
||||||
|
|
||||||
|
**派发结构(依 `sdd-parallel-dispatch` 技能 §1「一仓一写者」)**:T1/T2/T3/T5 全在 crearte **同仓同分支**,共享 git index → 四路并行会争用 `.git/index.lock`,故**合并为单个子代理**(四任务文件互不重叠,单写者无内部冲突)。三仓三路并行:crearte 子代理 + 控制者本人做 T6(server)/T7(deploy)——沿用 P11-T5「小而机械、控制者已元素级核实前提」的控制者自做范式(T6 前提已实测:`applyLogging` 确实回显归一化值、`strings` 已 import;T7 插入点已定位:`validate.yml` 第 19-29 行 step 之后)。
|
||||||
|
|
||||||
|
**T4 从「波次 2」提前为 crearte 子代理的第一步(RED→GREEN)**:守卫断言的正是修复后状态,先写守卫 → 跑出 RED(复现 spec §1 的 +380/+325/+76/+21/+3 实测数字)→ 再实现 T1/T2/T3 直到 GREEN。好处:① 消除 throwaway 验证脚本(不必像勘查期那样写完再删);② **RED 输出本身就是「守卫有牙」的证明**,无需事后再临时 revert 一处修复来自证;③ 实现者拿到真实反馈而非推断。T5 打磨三项不涉溢出,排在 GREEN 之后。
|
||||||
|
|
||||||
|
### crearte 子代理(单写者,TDD 顺序)
|
||||||
|
|
||||||
|
| 任务 | 仓 | 文件 | spec | 要点 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| T1 页头用户名截断 | crearte | `app/components/AppHeader.vue`、`app/components/AppHeader.test.ts` | §3.1 / D-A / D-G | summary 改 flex 三件 + `max-w-[6rem]` + `:title`;`▾` 独立 span 带 `aria-hidden`;**保留 `ref="summaryRef"`**(Esc 回焦依赖) |
|
||||||
|
| T2 五表格滚动包裹 | crearte | `app/views/AdminUsersView.vue`、`AdminView.vue`、`AdminAuditView.vue` | §3.2 / D-B / D-C / D-D | 包裹层 `relative overflow-x-auto pr-1 pb-1`;**三处 `v-else` 上移到包裹层**;表格内部(`scope`/sr-only/`data-testid`)一字不动 |
|
||||||
|
| T3 目录排序 + 账号昵称 | crearte | `app/components/ResultMeta.vue`、`app/views/AccountView.vue` | §3.3–3.4 / D-E / D-F | ResultMeta `:22` `shrink-0`→`min-w-0`(同排另两个 `shrink-0` **不动**);AccountView `:190` 加 `min-w-0 wrap-anywhere` |
|
||||||
|
| T5 打磨三项 | crearte | `app/router/index.test.ts`、`app/components/GameReactions.vue` + 其 test、`app/components/ToastHost.vue` + 其 test | §3.5(a)(b)(c) | P9B-1 直测 `focusMain()`;P9B-2 动态 `aria-label`(仅 `rated && i===score` 分支改写);P9B-4 `announcedId` 单调比较 + 新单测 |
|
||||||
|
| T6 配置错误消息钉桩 | crearte-server | `src/internal/config/config_test.go` | §3.5(d) | `LOG_LEVEL=" BOGUS "` 断言 err 含 `bogus` 且不含原样 ` BOGUS `;先核 `strings` import 与 `applyLogging` 现有文案 |
|
||||||
|
| T7 CI 空默认守卫 | crearte-deploy | `.github/workflows/validate.yml` | §3.5(e) | 新 step 三态断言(空默认 / 不得为 CIDR / 覆盖仍生效);**本地实跑 run 块含反向验证** |
|
||||||
|
|
||||||
|
### 控制者本人(与 crearte 子代理并行,各自独占一仓)
|
||||||
|
|
||||||
|
| 任务 | 仓 | 文件 | spec | 要点 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| T6 配置错误消息钉桩 | crearte-server | `src/internal/config/config_test.go` | §3.5(d) | 前提已核实:`config.go` `applyLogging` 错误文案为 `fmt.Errorf("config: LOG_LEVEL: invalid value %q", cfg.LogLevel)`,`cfg.LogLevel` 已是归一化后的值 → **回显成立,纯补测试**;`strings` 已在 test import 中 |
|
||||||
|
| T7 CI 空默认守卫 | crearte-deploy | `.github/workflows/validate.yml` | §3.5(e) | 插入点已定位:`compose` job 内第 19 行 step 之后、第 30 行 step 之前;**本地实跑 run 块三态**(当前通过 / 注入 CIDR 应失败 / 覆盖 `10.0.0.0/8` 应通过) |
|
||||||
|
|
||||||
|
(T4 两层机器守卫已并入 crearte 子代理的第一步,见上。)
|
||||||
|
|
||||||
|
## 验收口径(合并前必跑,全量)
|
||||||
|
|
||||||
|
- **crearte**:`npm run test`(基线 626 + 新增)、`npm run typecheck`、`npm run build`、`npm run build:runtime`、`npm run e2e`(基线 80+1skip + 新增 responsive)、`npm run e2e:noauth`(基线 4)。脚本名以 `package.json` 为准,**禁用 `--if-present`**(P11 假绿事件)。
|
||||||
|
- **crearte-server**:容器化 Go 1.24(`GOPROXY=https://goproxy.cn,direct`、`GOSUMDB=sum.golang.google.cn`、命名卷 `crearte_gomod`/`crearte_gocache`):`gofmt -l .` 空 / `go vet ./...` 0 / `go build ./...` 0 / `go test ./... -count=1` 11 包 ok。
|
||||||
|
- **crearte-deploy**:四 profile `config -q`;卷名集合与基线逐字相同;T7 守卫脚本本地三态实跑。
|
||||||
|
- 退出码:管道后一律 `set -o pipefail` 或不管道(P11 三度踩坑)。
|
||||||
|
|
||||||
|
## 纪律(延续 P9-B / P11 教训)
|
||||||
|
|
||||||
|
1. 派发简报引用的**每个数量**由控制者预先元素级 grep 核准(P9-B「19 个 th」教训)。
|
||||||
|
2. 简报里对既有代码/环境行为的**事实陈述**同样要核实(P11 四次被抓)。
|
||||||
|
3. 任务级审查 → 控制者裁定 → 波次末全分支终审(最强模型),终审须独立复跑验收腿并对账。
|
||||||
|
4. 审查依据是 **spec §3 全量**,不是任务书(P6 D-D 教训:任务书漏列项审查扯不出)。
|
||||||
|
5. 内仓禁 master 直提;合并用 `--no-ff`;push 不用管道且推后 `ls-remote` 非空对账。
|
||||||
|
6. **本批不做的**:D4(nav 逐字换行,park 待设计决策)、汉堡菜单(已证伪)、CSP/HSTS/TLS(独立批)、后端校验规则(60 字是既有合法契约)。
|
||||||
|
|
||||||
|
## 挂账处置(随本批记账)
|
||||||
|
|
||||||
|
- ROADMAP / 账本中「移动端页头 / 汉堡菜单」→ 标注**已证伪删除**(nav 内容宽 74–158px,320–1280px 零溢出)。
|
||||||
|
- D4(nav 链接逐字换行)→ 标注 **park**:外观级、无溢出无功能损失,修法在 320px 与 D1a 争空间,需设计决策(更短名上限 / 汉堡菜单 / nav 缩写)。
|
||||||
|
- P9B-3(spec「19 个 th」口径)→ 已在 P9-B 波次内修完,不属本批。
|
||||||
|
- P11 其余次要 notes(T1 按需回收、T2 注释语言、T2 共享片段唯一性、T3 `" warn "` 未断言 LogFormat、T4 README 窗口口径、T5 过程性陈述)→ **纯留档,无可动手改动**,不入本批。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# P13 实现计划:暗色模式
|
||||||
|
|
||||||
|
- **spec**:`docs/specs/2026-10-03-p13-dark-mode-design.md`(权威依据,冲突时以 spec 为准)
|
||||||
|
- **证据归档**:`crearte/.superpowers/sdd-p13/FINDINGS-survey.md`(7 轮 spike 实测)
|
||||||
|
- **分支**:`feat/p13-dark-mode`(已建,从 crearte `e59a171` 起)
|
||||||
|
- **范围**:仅 `crearte`。**`crearte-server` 与 `crearte-deploy` 零改动**(spec §4 边界)。
|
||||||
|
- **版本**:crearte 0.26.0 / wrapper 0.3.6(记账由控制者做,实现者**禁动** ROADMAP、CHANGELOG、`docs/`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务切分与波次
|
||||||
|
|
||||||
|
全部任务都在 `crearte` 单一工作树与 git index 上 → 按 `sdd-parallel-dispatch` §1「一仓一写者」,**交给一个子代理串行完成**,不拆并行(拆了就是四路争用同一个 `.git/index.lock`,P12 已验证这条纪律)。
|
||||||
|
|
||||||
|
**TDD 顺序:守卫先行**。T1 写守卫并**看它红**(RED 输出必须复现 spec 引用的实测数字),再 T2 让它绿。P12 的经验:这个顺序使 RED 输出本身成为「守卫有牙」的证明,免去事后 revert 自证那一轮。
|
||||||
|
|
||||||
|
| 序 | 任务 | 文件 | 完成判据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1** | 守卫重构为双主题(**RED 先行**) | `app/lib/contrast.test.ts` | 按块解析(亮/暗各自 Map);两块各断言 12 令牌齐备;双主题各断言 spec §1.3/§7 的 12 条真实配对(阈值:正文 4.5 / wordmark 大字号 3.0 / 焦点环非文本 3.0);`scrim` 分离**按主题钉实际分离侧**(D-F):亮色钉填充侧 `paper vs scrim ≥3`(17.60)、暗色钉边框侧 `ink vs scrim ≥3`(17.60)——**不得写成 `max(两侧) ≥3`**,两侧互补使其理论下界 = √16.50 = 4.0621 > 3 → **数学恒真、永不可能失败**(审查 finding #1,控制者暂力重算证实;实证:亮 scrim 改纯白得填充侧 1.1165 但 max()=18.42 → 仍绿)。也不得只钉 `ink vs scrim` 于两主题(亮色仅 1.07 → 误红);含「旧单 Map 解析器对含暗色块的 CSS 会得出错误结果」的反面钉桩(防后人简化回去)。**此时暗色块尚不存在 → 必须 RED**,红输出须显示「暗色 12 令牌缺失」 |
|
||||||
|
| **T2** | 令牌层落地 | `app/styles/main.css` | `@theme` 新增 `--color-skeleton: #efe9da` / `--color-scrim: #0d0b08`;五个 `--shadow-hard*` 内联 hex → `var(--color-ink)` / `var(--color-accent)`(D-D);追加 `html[data-theme="dark"] { … }` 全量 12 令牌 + `color-scheme: dark`(D-B/D-C,**必须** `html[data-theme=dark]` 特异性 (0,1,1),禁裸属性选择器、禁 `!important`、禁 `@apply` 绕过);`::selection`/`strong`/`.wordmark-label` **零改动**(D-A 核心收益)。**T1 由红转绿** |
|
||||||
|
| **T3** | 三处 usage 迁移 + 硬编码色守卫 | `ResultMeta.vue:52`、`StatePanel.vue` ×7、`FilterDrawer.vue:32`、新守卫文件 | ① `bg-accent text-ink` → `bg-accent-ink text-paper`(唯一把 accent 当背景的地方,迁移后 accent 纯非文本);② `bg-[#EFE9DA]` ×7 → `bg-skeleton`;③ `backdrop:bg-ink/60` → `backdrop:bg-scrim/80`(D-E)。新增源码级守卫扫描 `app/**/*.vue` **禁止** `bg-[#`/`text-[#`/`border-[#` 硬编码 hex,**RED 先行**(迁移前应报 7 处违规),且**必须沿用 P12 `tableOverflow.test.ts` 的屏蔽范式**(`<script>`/`<style>`/HTML 注释/`<textarea>`/`<title>` → 等长空白),否则注释里的示例会误报(P12 FE-1 假绿同源教训) |
|
||||||
|
| **T4** | 主题机制 | `app/composables/useTheme.ts`(新)、`index.html`、`app/App.vue`、`app/components/AppHeader.vue`、`useTheme.test.ts`(新)、`AppHeader.test.ts`(扩展) | 按 spec §3.5–§3.8 逐字落地。**关键约束**:开关**必须** `h-8 w-8`(32px;spike 3 实测 28px 在最坏格 margin 仅 5px);容器行 `gap-6`→`gap-2 sm:gap-6`、nav `gap-4`→`gap-2 sm:gap-4`(候选 B,最坏格 margin=16);**不得**改 `<summary>` 的 `max-w-[6rem] min-w-0` 与两 span 结构(P12 F3 钉桩);**不得**改 logo 文字/字号;`index.html` 内联 pre-paint 脚本的判定优先级须与 `useTheme.ts` **逐字一致**(stored → `prefers-color-scheme` → light);`useTheme` 若做模块级单例**必须**提供 `__resetTheme()`(P9-B D-I 跨测试污染教训) |
|
||||||
|
| **T5** | e2e | `e2e/dark.spec.ts`(新) | spec §5-T5 的 7 条最低覆盖:默认跟随系统(**且在 Vue 挂载前就已设置** = 验 D-K 无 FOUC)、开关切换(`data-theme` + `theme-color` meta + localStorage 三者同步)、reload 持久化、**计算值实证**(body `rgb(23,20,15)`/`rgb(247,242,231)`,且某 `.shadow-hard` 的 `box-shadow` 含 `rgb(247,242,231)` = spike 6 传导在真产物上复验)、遮罩不泛白、开关 `toHaveAccessibleName` 含当前态、暗色下 @320/@375 登录态 ascii60 零横向溢出 |
|
||||||
|
|
||||||
|
### 全量回归(实现者交付前必跑,`set -o pipefail`,**禁 `--if-present`**)
|
||||||
|
|
||||||
|
| 腿 | 命令 | 基线(P12 合并态,**不得回退**) |
|
||||||
|
|---|---|---|
|
||||||
|
| vitest | `npm run test` | **635 passed / 74 files** |
|
||||||
|
| typecheck | `npm run typecheck` | exit 0 |
|
||||||
|
| build | `npm run build` | exit 0(含 `vue-tsc --noEmit`) |
|
||||||
|
| build:runtime | `npm run build:runtime` | exit 0 |
|
||||||
|
| 主 e2e | `npm run e2e` | **92 passed + 1 skipped**(含 P12 的 12 条 responsive 腿) |
|
||||||
|
| noauth e2e | `npm run e2e:noauth` | **4 passed** |
|
||||||
|
|
||||||
|
新增测试后数字应**上升**;任何既有腿下降必须解释。**P12 的 12 条 responsive 腿与 `AppHeader.test.ts` 的 F3 形态钉桩属不得回退项**——若 gap 变更导致某腿数值变化,**不得改断言迁就**,先查明是否真溢出。
|
||||||
|
|
||||||
|
### 构建产物核验(T2 后必做,grep dist CSS)
|
||||||
|
|
||||||
|
- `var(--color-*)` 引用数 **≥ 64**(不得下降)
|
||||||
|
- 内联 `#141414` 从 **11 降到 ≤ 3**,且逐处说明剩余来源
|
||||||
|
- ⚠️ **测试/注释里禁写已迁移掉的 Tailwind 类名字面量**(实现者 T2 期发现,spec 与 FINDINGS 均未预料)。Tailwind v4 的自动内容探测**扫描全部源文件,含 `.test.ts`**:`FilterDrawer.test.ts` 里一句 `not.toContain('backdrop:bg-ink/60')` 断言(及其注释)让 Tailwind 把这个类名当候选、**重新生成 utility 及 `#14141499` fallback 烧回产物**,使该计数停在 **3** 而非 2。修法:用**字符串拼接**构造字面量(`'backdrop:bg-i' + 'nk/60'`)。与 P12 屏蔽范式同源,方向相反——那里防「守卫扫到注释里的 table」,这里栽在「Tailwind 扫到测试里的死 utility」。任何「某 utility 不得再出现」的回退钉桩都必须拼接。
|
||||||
|
- 暗色块出现在产物中且未被 Tailwind 层吞掉
|
||||||
|
- ⚠️ **grep 模式必须容忍 minifier 去引号**(实现者发现、控制者独立复现 2026-10-03):本 plan 与 spec 原先写 `grep -c 'html\[data-theme="dark"\]'`(带双引号),但 lightningcss **会去掉属性选择器里非必需的引号**,产物实际是 `html[data-theme=dark]` → 带引号的字面模式**返回 0,会被误判为「暗色块被吞掉」**。正确:`grep -c 'html\[data-theme="\?dark"\?\]' "$CSS"` 或去引号版,须 ≥ 1。**源码里仍须写带引号的 `html[data-theme="dark"]`**(CSS 源码选择器)——错的只是产物核验的 grep。
|
||||||
|
- `.shadow-hard` 的 `--tw-shadow` 含 `var(--color-ink)`(spike 6 传导前提)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收口径
|
||||||
|
|
||||||
|
实现者报告须含:六腿实跑输出摘录(数字,不是「应该没问题」)、构建产物四项 grep 结果、T1/T3 的 **RED 输出原文**(须复现 spec 引用的实测数字,如暗色 `ink on highlight` = 1.46)、**mutation 自查输出**(见下)、throwaway 清理证明(`porcelain=0`)。
|
||||||
|
|
||||||
|
### mutation 自查(实现者必做并附输出)
|
||||||
|
|
||||||
|
1. **亮色守卫仍被守**(D-H 的核心风险 = 重构把亮色守卫静默弄丢):把亮色 `--color-success` 临时改成低对比值 → **亮色腿必须红**;改回 → 绿。
|
||||||
|
2. **暗色守卫有牙**:把暗色 `--color-highlight` 临时改成 `#f5c518`(亮黄)→ `ink on highlight` 暗色 = **1.46 红**。
|
||||||
|
3. **硬编码色守卫有牙**:临时在某 `.vue` 加 `bg-[#123456]` → 守卫红;删除 → 绿。
|
||||||
|
4. **P12 成果未回退**:`npx vitest run app/lib/tableOverflow.test.ts app/lib/tableScope.test.ts` 仍绿;`responsive.spec.ts` 12 腿仍绿。
|
||||||
|
|
||||||
|
每次 mutation 后 `git checkout --` 恢复并核对 `git diff` 为空。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 纪律(延续 P9-B / P11 / P12 教训,spec §8 全文适用)
|
||||||
|
|
||||||
|
1. **不推断 CSS/Vue/构建行为,只实测。** 本批已有两次栽在推断上:spike 5 用运行时注入验证**构建期烘焙**(失败:令牌已变而 `box-shadow` 仍 `rgb(20,20,20)`)、spike 2 第一版用 `APPLY.toString()` + `new Function` 序列化注入(`ReferenceError: APPLY is not defined`)。
|
||||||
|
2. **守卫先写、先看它红**;红输出须复现 spec 数字,否则守卫可能无牙。
|
||||||
|
3. **守卫自身要受同等审视**(P12 FE-1:注释掉包裹层守卫仍绿 = 假信心)。
|
||||||
|
4. **P12/P9-B 成果不得回退**:`max-w-[6rem]`、五表格包裹层 `relative overflow-x-auto pr-1 pb-1`、`ResultMeta.vue:22` 的 `relative min-w-0`、`AccountView` 的 `min-w-0 wrap-anywhere`、skip-link 首子位置、`useToast` 的 `announcedId` 单调语义、`scope="col"` 计数 13/6/5。
|
||||||
|
5. **throwaway 跑完即删**;注意 `npm run build` 含 `vue-tsc --noEmit` 会检查 `e2e/*.spec.ts` 类型(spike 6 曾因此 `BUILD_EXIT=2`)——spike 期间可用 `npm run build:e2e` 绕开,但**交付前 `npm run build` 必须真过**。
|
||||||
|
6. **禁动**:`docs/`(ROADMAP、CHANGELOG、spec、plan)、`.superpowers/`(只写自己的报告)、`crearte-server/`、`crearte-deploy/`、`playwright.config.ts`、`.gitignore`。
|
||||||
|
7. **发现 spec 有误就纠正 spec 并在报告里说明**,不要迁就实现(P12 实现者纠正了 768px 归因,控制者用 spike 17 独立定案其成立并 re-pin 五处下游)。
|
||||||
|
8. commit 信息含任务号与决策号(`P13-T<n>` / `D-<X>`);**禁 `git add -A`**(`.superpowers/`、`src/test-results/` 不得入库)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 挂账处置(随本批记账,由控制者执行)
|
||||||
|
|
||||||
|
- **清偿**:ROADMAP P9-B 行尾「C 暗色模式」→ 标注已由 P13 清偿。
|
||||||
|
- **新增挂账**:
|
||||||
|
- 第三态「恢复跟随系统」(spec §4:header 无像素容纳带标签控件;三态循环在 32px 无标签按钮上不可发现)
|
||||||
|
- CSP 设计批(内联 pre-paint 脚本届时需 `nonce` 或外置 —— spec §3.6 已留注释提示)
|
||||||
|
- OG 社交卡片、播放计数、TLS+HSTS、D4 nav 逐字换行设计决策、备份恢复演练实证(owner 手动尾巴)
|
||||||
|
- 上传面一处低危 fallthrough(`uploads.go:30-36`:`ParseMultipartForm` 返回非 `MaxBytesError` 时不 return,落到 switch 给出误导性的 400「kind must be bundle or cover」而非真实原因)—— 本轮证据扫描发现,属 server 侧打磨候选
|
||||||
|
- `--color-info` 为**死令牌**(`bg-info` / `text-info` 实测均 0 实例,仅 1 处 token 定义)—— 清理或启用,属代码优雅候选
|
||||||
|
- **bundle-key 端点的公开信任边界无决策记录 + 一条误导性死管道**(server 侧,零行为变更的微批候选)。P13 勘查期的只读审计发现,**非缺陷,是文档缺口**:
|
||||||
|
- 事实:`router.go:104` 的 `engine.GET(RouteBundleKey, limiter.Middleware(), deps.BundleKey.Get)` 是路由表里**唯一没有 `RequireUser` 的数据端点**。
|
||||||
|
- **定性为有意设计的证据(共 7 处钉桩,非顺带覆盖)**:server 侧 **6 处**显式无鉴权断言 —— `router_test.go:62`(用**完全不带 `Authorization` 头**的 `httptest.NewRequest` 断言 **200** + `Cache-Control: no-store`)、`router_test.go:85`(无头 → 第 61 次 **429** + `Retry-After`,即限流是边界而非鉴权)、`admin_test.go:121`(`doRequest(..., "", "")` token 为空 → 200)、`integration_test.go:128`(同 → 200)、`integration_test.go:153`(同 → **410**)、`admin_works_test.go:120`(同 → 200 后吊销变 410);前端侧 `e2e/full-loop.spec.ts:112-118` 从**全新无 token 浏览器上下文** `request.get` 断言 **410**。若该路由挂了 `RequireUser`,这些请求会先返 **401**、永远到不了吊销/限流分支 —— 故「无鉴权可取钥」是被**多点钉死**的既有行为。
|
||||||
|
- 支撑链:MinIO 桶 `mc anonymous set download`(deploy README:26/41,密文公开可下载)→ `content.go:202` 经 `PublicURL(ObjectKey)` 把 bundle URL 交给**公开的** game detail 端点 → `noauth.spec.ts`(4 条)证明站点支持全匿名模式。即密文与 CEK 双双公开,**已发布作品对任何人可下载**——这与产品定位(公开托管社区)一致。
|
||||||
|
- 真实信任边界 = **60/min 限流 + 管理员吊销(410)**,不是鉴权。加密在此的作用是**可吊销的交付机制**(`bundle.UnwrapCEK` + `row.RevokedAt`),不是访问控制。
|
||||||
|
- **误导性死管道**:`runtime/sw/keyfetch.ts:6` 有 `token?: string`,`runtime/sw/index.ts:82` 一路透传,`keyfetch.test.ts:28-33` 断言 Authorization 头透传,`cors.go:31` 的 ACAH 也允许 `Authorization`——但服务端**从不要求、从不校验**它。
|
||||||
|
- 风险:将来有人「修好」缺失的 `RequireUser` → **匿名访客将无法游玩任何作品**(站点必须支持登出态游玩)。上述 7 处钉桩会以 401≠200/410/429 立即抓住,**故静默破坏风险实际很低**。真正的缺口只剩一个:`router.go:102-105` 处**没有任何注释说明这个端点为何故意不挂 `RequireUser`**,而它是路由表里唯一没有鉴权的数据端点——视觉上极像遗漏。
|
||||||
|
- 处置(P11「大声留痕」哲学的同款应用,零行为变更):**仅需① 在 `router.go` 该处加决策记录注释**,写明「有意公开:匿名访客必须能取钥游玩;信任边界是 60/min 限流 + 管理员吊销(410),不是鉴权;密文本身亦经 MinIO 匿名 download 公开,加密在此是可吊销的交付机制而非访问控制;加 `RequireUser` 会破坏登出态游玩并被 `router_test.go:62`/`integration_test.go:153`/`full-loop.spec.ts` 抓住」。
|
||||||
|
- ~~② 补反面钉桩测试~~ —— **撤销此建议**:`router_test.go:62` 已经就是那条测试(不带 Authorization 头断言 200)。我先前写「e2e 顺带覆盖」是**未核实就下的判断**,实际有 6 处 server 侧显式钉桩。教训:登记挂账前必须先 grep 既有测试,否则会开出「补一条已存在的测试」这种伪工作。
|
||||||
|
- ③ `keyfetch.ts:6` 的 `token?: string` 管道待查清后再定(属 crearte,P13 实现者正独占该仓,本批不动)。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# P14 实现计划:拆分 `ContentService` god object
|
||||||
|
|
||||||
|
- **spec(权威)**:`docs/specs/2026-10-03-p14-content-service-split-design.md`
|
||||||
|
- **取证账本**:`crearte-server/.superpowers/sdd-p14/survey.md`(全部数字实测,含 Route C spike 输出与三次归属分析的自我纠错)
|
||||||
|
- **仓**:`crearte-server`(**仅此一个**)· **base** `dbf7fe5`(0.17.2)· 分支 `chore/p14-content-service-split`
|
||||||
|
> ⚠️ 分支前缀用 `chore/`,**不是 `refactor/`**:AGENTS.md 只允许 `{feat|fix|docs|chore}/` 四种前缀(P12 用 `fix/`、P13 用 `feat/`)。纯结构重构归 `chore/`。
|
||||||
|
- **性质**:纯结构重构,**零行为变更**
|
||||||
|
- **目标版本**:crearte-server **0.18.0** · wrapper **0.3.7**
|
||||||
|
- **派发**:**一个**实现者子代理做完 T1→T3(所有任务同动单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
|
||||||
|
|
||||||
|
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 §2 决策表 D-A…D-K、§3 实现要求(含逐字代码骨架与机械迁移规则)、§5 测试计划、§8 实现纪律(10 条累计教训)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务表
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 要求 | 完成判据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1** | `internal/service/architecture_test.go`(新增) | **RED 先行**(spec D-J / §5-T1)。三类 reflect 断言:① `ContentService` 恰 5 字段、全 `Anonymous`、全 `Ptr`、类型名集合恰为五个子 service ② 五个子 service 各自的**导出方法名集合逐字等于**其职责线预期集合(spec §5-T1.2 列全)③ `ContentService` 与五个子 service **都不得有名为 `now` 的字段** | T1 单独提交时 `go test ./internal/service/` 以**编译错误**失败(`undefined: CatalogService` 等),输出**逐字存证**到 `crearte-server/.superpowers/sdd-p14/impl-evidence/t1-red.txt`。Go 的 RED 不给断言级消息,**编译错误本身即 RED 证据,但必须存档** |
|
||||||
|
| **T2** | `catalog.go` / `reaction.go` / `upload.go` / `submission.go` / `moderation.go`(新增)+ `content.go`(改为组合根 + 共享声明) | 按 spec §3.1–§3.2 机械迁移。**方法体逐字不动**(规则 5);私有 helper 随唯一使用线迁移(规则 2);每个文件 import **只列实际用到的包**(规则 4);删除 `ContentService.now`(D-H),并**自行 grep 确认** `time` import 是否变悬空(spec §3.1 警告,不许凭 spec 推断) | T1 由红转绿;`go test ./...` **11 包全绿**;**既有 `*_test.go` 修改文件数 = 0**(`git diff --stat` 只应出现 T1 新增的那个);`content.go` < 120 行;六文件最大 < 300 行 |
|
||||||
|
| **T3** | 五个 handler 的窄接口(D-E)+ `cmd/catalog.go` 依赖面收窄(D-I) | 按 spec §3.3–§3.4。接口**定义在消费方**(handler 自己的文件内,小写包内私有),方法签名**从 service 侧逐字复制**(spec §3.3 警告:`ListPublished`/`GetPublishedDetail` 含未命名的 ETag `string` 返回值,须 `grep -n 'func (s \*ContentService) ListPublished' -A2` 取原文,**不要凭 spec 省略号推断**);`admin.go` 的 `bundle *service.BundleService` 与 `uploads.go` 的 `maxBundle, maxCover int64` 参数**保持不变** | `grep -rn 'service.ContentService' internal/handler/` → **零命中**;`cmd/catalog.go` 内 `nil` 占位与 `NewPostgresUserStore(pool)` 消失(依赖槽 4→2);**`cmd/serve.go` 零改动**(spike H2 的可核推论——若被迫改动,说明窄接口方法集抄漏了,按编译错误补全而非放宽接口);既有 handler/api 测试**断言零修改** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收(控制者独立复跑,不采信自报)
|
||||||
|
|
||||||
|
### 四门(dockerized Go,AGENTS.md 红线)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/src:/src" -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...'
|
||||||
|
```
|
||||||
|
|
||||||
|
基线(已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 最慢 ~11.5s 含真库集成)。**host Go 1.18 不可用**;缺 `GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → background + 耐心 poll。
|
||||||
|
|
||||||
|
### 结构指标(spec §5 表,交付时须报实测数字)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| `content.go` 行数 | 856 | **< 120** |
|
||||||
|
| 六文件最大行数 | 856 | **< 300** |
|
||||||
|
| `service/` 内 >400 行生产文件 | 1 | **0** |
|
||||||
|
| handler 里 `service.ContentService` 引用 | 10 处 | **0** |
|
||||||
|
| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** |
|
||||||
|
| `ContentService` 自身声明的方法数 | 25 | **0** |
|
||||||
|
| 既有 `*_test.go` 修改文件数 | — | **0** |
|
||||||
|
|
||||||
|
### mutation 抽查(spec §5-T1 的 a/b/c,控制者独立复现,不复用实现者结果)
|
||||||
|
|
||||||
|
- (a) 把 `CoverURL` 从 `catalog.go` 移回 `content.go` 并改接收者为 `*ContentService` → 架构守卫必须红
|
||||||
|
- (b) 给 `ContentService` 加一个具体字段(如 `store repository.ContentStore`)→ 必须红
|
||||||
|
- (c) 给任一子 service 加回 `now func() time.Time` → 必须红
|
||||||
|
|
||||||
|
每次 mutation 后 `git checkout -- <file>` 恢复并核 `git diff` 空。**修守卫必须两个方向都验**(对合法值不误红 + mutation 下不误绿)——P13 控制者正是只验了前者,交付了一个数学恒真的断言。
|
||||||
|
|
||||||
|
### 行为不变的额外证据
|
||||||
|
|
||||||
|
- `git diff dbf7fe5..HEAD --stat`:既有 `*_test.go` **零文件**出现在 diff 里(除 T1 新增)
|
||||||
|
- 逐函数比对:方法体应只有接收者类型变化,**无逻辑改动**(审查者用 `git diff` 核,不信报告表格)
|
||||||
|
- 错误值文本、slog 字段、仓储调用顺序、事务边界**逐字不变**(spec §3.2 规则 5 / D-G)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界(**不做**,spec §4)
|
||||||
|
|
||||||
|
不改任何行为 · 不重构 `Approve` 内部(170 行 7 阶段,登记挂账留下一批)· 不动 `AccountService`/`AuthService`/`BundleService`/`CleanupService` · 不动 `memory_content.go`(814 行但实测是测试替身)· 不做 handler 错误映射去重(候选 C)· 不引入新依赖 · 不改任何测试断言语义。
|
||||||
|
|
||||||
|
**若实现者发现必须改某个既有测试才能编译 → 停下来报告,不要改。** 那本身说明拆分做错了(D-A 的设计目标就是构造点零改动)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记账(控制者做,实现者禁动 `docs/`)
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` 新增 `## [0.18.0] - 2026-10-03`(插 `## [0.17.2]` 前)
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 `## [0.3.7] - 2026-10-03`(插 `## [0.3.6]` 前,小节 `### Done / 完成`)
|
||||||
|
- `docs/ROADMAP.md`:新增 P14 行(P13 行后)+ 文档索引 P14 行(P13 索引行后);**哈希引用 merge commit**
|
||||||
|
- **不 bump 任何 version 文件**(Go 服务无 package.json 类版本文件)
|
||||||
|
- 挂账新增:`Approve` 170 行 7 阶段内部重构、`memory_content.go` 测试替身规模、handler 错误映射去重(候选 C)、`AccountService`/`CleanupService` 的 `now` 字段使用情况复核(本批只确证 `ContentService.now` 死)
|
||||||
|
- 格式:同条目英文行紧跟中文行**无空行**、不同条目**空一行**、双语小节标题
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**
|
||||||
|
- 合并:inner repo 提交前 `git branch --show-current` 确认不在 master;`--no-ff` 建合并提交;推送**禁管道**;对账用 `git ls-remote` 且**必须校验变量非空**(空变量会让 `[ "$L" = "$R" ]` 假 MATCH)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审查阶梯(P11–P13 惯例,不可省)
|
||||||
|
|
||||||
|
1. **任务级审查**(只读):spec 全文为权威,独立核 §3 逐条落地 + 结构指标 + 自设 mutation(**不照抄**上面的 a/b/c)+ 红线(`docs/`、`go.mod`、其它仓、既有测试断言)
|
||||||
|
2. **全分支终审**(只读):spec 全文自洽性 + 跨任务缝隙(T2 子 service 与 T3 窄接口的方法集是否逐字对应)+ 审查控制者的裁定工作 + 独立复现 mutation
|
||||||
|
3. 每道门的 notes **逐条裁定后方可合并**(`sdd-pre-merge-review` §3:未裁定的 note 阻塞合并;「PASS with notes」不等于门过了)
|
||||||
|
4. **P11 先例:全分支终审裁定过「需修复后合并」**(N1 一条 `slog.Warn` hint 仍在推荐刚被判不安全的配置、N2 spec 五处未随 re-pin 更新)——这道门不是橡皮章
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# P15-A 实现计划:拆分 `Approve` 169 行七阶段
|
||||||
|
|
||||||
|
- **spec(权威)**:`docs/specs/2026-10-03-p15a-approve-phases-design.md`
|
||||||
|
- **取证账本**:`crearte-server/.superpowers/sdd-p15a/survey.md`(控制者派发前落盘;P15 前期取证在 `crearte/.superpowers/sdd-p15/survey.md` §8.1)
|
||||||
|
- **仓**:`crearte-server`(**仅此一个**)· **base** `fe810dd`(0.18.0,master)· 分支 `chore/p15a-approve-phases`
|
||||||
|
> ⚠️ 分支前缀用 `chore/`:AGENTS.md 只允许 `{feat|fix|docs|chore}/` 四种(**`refactor/` 不允许**,P14 控制者曾写错)。纯结构重构归 `chore/`。
|
||||||
|
> ⚠️ inner repo 红线:**不得在 `master` 上提交**。`git commit` 前先 `git branch --show-current` 确认。
|
||||||
|
- **性质**:纯结构重构,**零行为变更**(spec D-G)
|
||||||
|
- **目标版本**:crearte-server **0.19.0** · wrapper **0.3.8**
|
||||||
|
- **派发**:**一个**实现者子代理做完 T1→T3(所有任务同动单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
|
||||||
|
- **并行**:P15-B 在 `crearte` 仓同时进行 → **不得触碰 `crearte/`、`crearte-deploy/`、wrapper 的任何文件**
|
||||||
|
|
||||||
|
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 **§1.2(④⑥不同形,禁止合并)**、§1.3(helper 形态实测)、§2 决策表 D-A…D-I、§3.2(逐阶段要求,含 6 处"逐字保留")、§5-T1(mutation a–h 及 (b) 的已知盲区警告)、§7 风险表、§8 纪律 10 条。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务表
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 要求 | 完成判据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1** | `internal/service/function_length_test.go`(**新增文件**,不改 `architecture_test.go`) | **RED 先行**(spec §5-T1)。三条断言:① `TestNoOversizedFunction`:`service/` 内非测试 `.go` 无任何函数 >100 行(阈值依据见 spec §0 普查:当前只有 `Approve` 169 超过,次大 83)② `TestApproveIsSmall`:`Approve` <60 行 ③ `TestApproveHelpersExist`:五个 helper 名存在——**必须用源码文本匹配,不能用 reflect**(spec §1.3:未导出标识符 reflect 不可见)。断言内须含"至少扫到 N 个 `.go` 文件"的前提检查(spec §5 mutation (e) 的教训) | RED 输出**逐字存证** `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`,且**红的原因是断言失败而非编译错误**(与 P14 T1 的编译红形态**不同**:本批引用的都是既有类型)。三条各自的红须可分辨 |
|
||||||
|
| **T2** | `internal/service/moderation.go`(改) | 按 spec §3.1 骨架 + §3.2 逐阶段要求执行拆分:`loadApprovePlan`(①②③) / `precheckApprove`(④) / `copyApprovalObjects`(⑤,**收 `*approvePlan`**) / `applyApprovalInTx`(⑥,**包级函数**收 `tx repository.ContentRepository`) / `cleanupApprovalUploads`(⑦) + 包级私有 struct `approvePlan`(spec D-B 七字段)。**六处"逐字保留"**见 spec §3.2 的 ⚠️ 标注 | `go test ./... -count=1` **11 包全绿**;**既有 `*_test.go` 修改数 = 0**;`architecture_test.go` **修改行数 = 0**;P14 七断言 `-v` 全 PASS |
|
||||||
|
| **T3** | 字节级保真证明(加做项,P14 同款) | 写脚本对 base `fe810dd` 的 `Approve` 函数体与新树的 `Approve`+5 helper 做**语句级比对**(归一化缩进与接收者前缀),产出 `verbatim` / `changed`(逐条给理由)/ `missing`(**必须 0**) 三张清单。**脚本的三个已知坑见 spec §5-T3 的 ⚠️**(字符串字面量里的 `//` 被当注释、单行 `var` 须提前终止、意外值先怀疑自己的模式) | 三份清单存证 `.superpowers/sdd-p15a/impl-evidence/t3-verbatim.txt`;`missing = 0`;`changed` 每条有理由且都落在 spec §3.2 允许的范围内 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收(控制者独立复跑,不采信自报)
|
||||||
|
|
||||||
|
### 四门(dockerized Go,AGENTS.md 硬红线)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crearte-server && docker run --rm -v "$PWD/src:/src" -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine sh -c 'gofmt -l . ; test -z "$(gofmt -l .)" ; echo gofmt_gate=$? ; go vet ./... ; go build ./... ; go test ./... -count=1'
|
||||||
|
```
|
||||||
|
|
||||||
|
基线(spec §0,已实测):**`test -z "$(gofmt -l .)"` exit 0**(⚠️ 审查者 F3:`gofmt -l` 的语义是「列出需要格式化的文件」,**列出时仍 exit 0**,只在解析失败时 exit 2 → `gofmt_exit=0` **不是证据**;须同时存证 `gofmt -l .` 的**原始输出为空**。控制者与实现者共用旧配方,故同一盲点被复算两次而未被发现)
|
||||||
|
|
||||||
|
⚠️ **host Go 1.18 不可用**;**必须带 `GOPROXY=https://goproxy.cn,direct`**(`proxy.golang.org` 本机不可达,默认下载**挂死并杀代理**);冷构建 1–3 min → `background: true` + 耐心 poll,**不要 sleep 循环、不要因没立刻返回就断定失败**。
|
||||||
|
⚠️ **`-count=1` 强制实跑**(禁缓存)。
|
||||||
|
⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀)。
|
||||||
|
⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
|
||||||
|
⚠️ **`pkill -f <pattern>` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern` 括号技巧。
|
||||||
|
|
||||||
|
### 结构指标(spec §5 表,交付时须报实测数字,**一律 `wc -l` 口径**)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Approve` 行数 | **169** | **< 60** |
|
||||||
|
| `service/` 包内 >100 行的函数数 | **1** | **0** |
|
||||||
|
| `service/` 包内最大单函数行数 | **169** | **< 100**(次大者 83,故落点应在 83–100) |
|
||||||
|
| `moderation.go` 行数 | 298 | **不作指标**(spec D-H:helper 同文件,可能不降反升;报了即可) |
|
||||||
|
| 既有 `*_test.go` 修改文件数 | — | **0** |
|
||||||
|
| `architecture_test.go` 修改行数 | — | **0** |
|
||||||
|
| `Approve` 签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** |
|
||||||
|
| 字节级比对 `missing` | — | **0** |
|
||||||
|
|
||||||
|
> ⚠️ **不要用 `len(text.split('\n'))` 数行数**:文件以换行结尾时它多算一个空段。P14 F5 就因此让控制者报出 83/299 而真值是 82/298,被任务级审查者抓到。`wc -l` 是本仓钉死的口径(P14 spec §5 RE-PIN)。
|
||||||
|
|
||||||
|
### mutation 抽查(spec §5-T1 的 a–h,控制者独立复现,不复用实现者结果)
|
||||||
|
|
||||||
|
**八条**,每条测"预期红 + 其余绿 + **非编译红** + 恢复证明":
|
||||||
|
|
||||||
|
| # | mutation | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| (a) | helper 存在但 `Approve` 不调它、改为原地展开 | `TestApproveIsSmall` 红 |
|
||||||
|
| (b) | 构造一个 **>100 行**的 helper(⚠️ spec §5 已警告:把⑥整体 80 行搬进 `applyApprovalInTx` **不会红**,那是阈值 100 的已知盲区,不是守卫失效) | `TestNoOversizedFunction` 红 |
|
||||||
|
| (c) | 删掉一个 helper(内容合并进 `Approve`) | `TestApproveHelpersExist` 红 |
|
||||||
|
| (d) | helper 改名(如 `loadApprovePlan`→`loadApproveInputs`) | `TestApproveHelpersExist` 红(**(c) 的对照**:证明钉的是名字集合而非"存在任意 5 个函数") |
|
||||||
|
| (e) | 扫描范围改成空目录 | 红(防"扫 0 文件报 0 违规") |
|
||||||
|
| (f) | 阈值 100 → 1000 | 红(防阈值被放宽;断言须自证阈值字面量) |
|
||||||
|
| (g) | **给 `ModerationService` 加字段**(`clock func() time.Time`) | **P14 断言 6 `TestLineFields` 红**(跨批回归) |
|
||||||
|
| (h) | **在 `ContentService` 上加方法** | **P14 断言 5 红**,且**须同时测有名与匿名两种接收者形态**(P14 FR-1 教训:只测有名会漏掉 `func (*ContentService) M()`) |
|
||||||
|
|
||||||
|
⚠️ **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**(P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后四轮全在测没有修复的树、汇总报"绿=3")。每轮恢复后核 `git diff HEAD --exit-code -- <file>` 空 + 守卫文件 `func Test` 计数不变。
|
||||||
|
⚠️ **mutation 若产生编译错误,不算"守卫有牙"**(P14 控制者第 22 次错误:正则截断签名致编译红,却被报成"守卫拦住了遮蔽")。
|
||||||
|
|
||||||
|
### 行为不变的额外证据
|
||||||
|
|
||||||
|
- **既有 13 个测试函数就是行为守卫**(spec §1.6 表):`TestApproveConcurrentOnlyOneWins`(⑥行锁+重校)· `TestApproveSameWorkIDConcurrent`(④⑥的 `ErrWorkIDTaken`,断言文本含 `want ErrWorkIDTaken or ErrSubmissionConflict`)· `TestApproveMetadataChangeFeaturesSemantics`(⑥ 的 `Features` **三态语义**,用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分)· `TestApproveOptionalFieldsRoundTrip` · `TestMetadataChangeRuntimeImmutable` · `TestNewVersionMetadataChangeOwnership` · api 层 5 个 + 2 个真库发布链
|
||||||
|
- **错误文案逐字比对**:`service: load submission: %w` / `service: resolve submitter: %w` / **`service: approve precheck: %w`(两处,只在④)** / `service: copy bundle: %w` / `service: copy cover: %w`;以及 `ErrContentNotFound`(①) vs **`ErrSubmissionConflict`(⑥ 的 `GetByIDForUpdate` 未找到)** 这个**刻意的差异**(spec §3.2 ⑥)
|
||||||
|
- **两条 slog 逐字**:`slog.Error("approve: pending delete failed", "key", key, "error", err)`(⑦ helper 内)· `slog.Info("approve", "submission", …, "work", …, "kind", …, "admin", …)`(**留在 `Approve` 本体**,spec §3.2 ⑦)
|
||||||
|
- **`Approve` 只有一个非测试调用点** `handler/admin.go:62`(spec §1.7)→ helper 无须导出;签名变更会打破 `moderationPort` 满足性 → `serve.go` 编译红
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界(**不做**,spec §4)
|
||||||
|
|
||||||
|
- **不合并④⑥的 switch**(spec §1.2 四处实质差异 + 乐观/悲观语义 + 错误文案不同;被否方案 A/B)
|
||||||
|
- 不动 `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)
|
||||||
|
- 不动 `deleteAccountLocked`(83)/`UpdateSubmission`(76)/`ValidateWorkFields`(75)/`ImportDir`(70)/`Run`(70)/`checkSubmissionRules`(66) —— 六个 >60 行函数各自独立成批(spec §4 挂账)
|
||||||
|
- 不新增/修改任何测试(既有测试即守卫,spec §2.1 被否方案 D)
|
||||||
|
- **不改 `architecture_test.go`**(P14 七断言)——若某条因新 helper 变红,**停下来报告**,不自行改守卫
|
||||||
|
- 不碰 `docs/`、`go.mod`/`go.sum`(实现者红线;记账由控制者做)
|
||||||
|
- 不碰其它三个仓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记账(控制者做,实现者禁动 `docs/`)
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`;**与 P15-B 合并为一条还是分两条按合并时序定**
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-A 行** + 文档索引表新增一行(**哈希引 merge commit**,P12/P13/P14 惯例)
|
||||||
|
- **挂账新增**:六个 >60 行函数 · `TestNoOversizedFunction` 阈值 100 的已知盲区(80 行 helper 不被拦)
|
||||||
|
- 格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**绝不 stage**
|
||||||
|
- **合并**:CHANGELOG 改动提交到**特性分支** → `git checkout master` → `git merge --no-ff` → 记 merge 哈希 → 推送(**禁管道**)→ `git ls-remote origin master` **非空**对账(`-z` 检查,防空变量假 MATCH)→ 删分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审查阶梯(P11–P14 惯例,不可省)
|
||||||
|
|
||||||
|
1. **实现者自报** + 证据落盘(RED 输出、四门、mutation、字节级比对)
|
||||||
|
2. **控制者独立核实**(全部自己跑,不采信自报数字;P14 控制者因此抓到实现者报告可信度高、也抓到自己 6 处错)
|
||||||
|
3. **任务级审查者**(只读,**自设 mutation 不照抄 spec 的 a–h**,审守卫恒真/恒假、跨任务缝隙、spec 自身问题)
|
||||||
|
- P13 先例:10 条自设 mutation 挖出 5 条 findings,**M1 抓到控制者自己写错两次的恒真守卫**
|
||||||
|
- P14 先例:抓到 **spec D-J 的守卫目的无断言覆盖** + **spec 里 positive control 的绕行路径** + 控制者的行数口径错
|
||||||
|
4. **控制者逐条裁定**(`sdd-pre-merge-review` §3:**未裁定的 note 阻塞合并**,"PASS with notes" 不等于门过了)
|
||||||
|
5. **全分支终审者**(只读;spec 全文自洽性 + **审控制者的裁定工作** + 独立复现 mutation)
|
||||||
|
- P11 先例:终审裁定过「**需修复后合并**」→ **不是橡皮章**
|
||||||
|
- P14 先例:终审在**第一轮修复自身**里找到洞(匿名接收者绕过源码扫描),并**推翻控制者两处已入库的全称声明**
|
||||||
|
6. **spec re-pin**(裁定后一次性批量改,**勿在审查者读 spec 期间改**=移动靶);**代码改了 spec 必须同步**(P14:正则字面量写进 spec,改代码后 spec 立刻不一致)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# P15-B 实现计划:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理
|
||||||
|
|
||||||
|
- **spec(权威)**:`docs/specs/2026-10-03-p15b-bootstrap-dark-design.md`
|
||||||
|
- **取证账本**:`crearte/.superpowers/sdd-p15/survey.md`(§2/§3/§5/§7/§8 全部实测;含"叉积当违规清单"与"三元互斥分支当同元素共现"两次自我纠错)
|
||||||
|
- **仓**:`crearte`(**仅此一个**)· **base** `f823195`(master)· 分支 `feat/p15b-bootstrap-dark-and-guard-pins`
|
||||||
|
> ⚠️ 分支前缀用 `feat/`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`)。本批含用户可感知的暗色改动 → `feat/`;若实现者认为纯守卫部分占主体,**也不得改用 `refactor/`**(不在允许集内)。
|
||||||
|
> ⚠️ inner repo 红线:**不得在 `master` 上提交**。`git commit` 前先 `git branch --show-current` 确认。
|
||||||
|
- **性质**:用户可见改动(加载屏暗色)+ 守卫补强 + 死代码清理,**三者一批**(spec D-A…D-H)
|
||||||
|
- **目标版本**:crearte **0.27.0** · wrapper **0.3.8**
|
||||||
|
- **派发**:**一个**实现者子代理做完 T1→T4(所有任务同动 crearte 单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者)
|
||||||
|
- **并行**:P15-A 在 `crearte-server` 仓同时进行 → **不得触碰 `crearte-server/`、`crearte-deploy/`、wrapper 的任何文件**
|
||||||
|
|
||||||
|
> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 **§1.1 的 🔴 架构陷阱(响应式注入会让运行中的游戏被重载)**、§1.3 的连带面表(5 文件)、§1.4(为何不能扩 `noHardcodedColor`)、§2.1 被否方案 A–F、§5-T1 的 10 腿清单 + ⚠️ 恒真审视三条、§8 纪律 12 条(前端 4 条加粗)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键路径约定(与后端不同,勿照搬 P15-A)
|
||||||
|
|
||||||
|
- **前端源码与 `package.json` 都在 `crearte/src/`,不在仓根**。所有 `npx`/`npm` 命令须 `cd crearte/src`。(P15 取证期控制者两次因 `cd` 层级写错路径而拿到假结果:`cd` 仓根却写 `app/...`(真根 `src/app/...`)致 grep 全失败输出 `bg-surface=0`;`ls ../e2e` 猜错致空输出。**任何"零命中"结论都要先证明扫描范围非空。**)
|
||||||
|
- **测试在宿主机跑 `npx vitest run`,不进容器**(P13 plan 第 58 行同款先例:`npx vitest run app/lib/tableOverflow.test.ts app/lib/tableScope.test.ts`)。`crearte/src/node_modules` 已装(232M)。AGENTS.md 的"一切在容器内"针对的是 compose 栈与应用服务,**不针对前端单测**;e2e 需要浏览器,同样在宿主机跑。
|
||||||
|
- **vitest 环境是 node**(`src/vite.config.ts` 的 `test` 段只有 `exclude`,**无 `environment` 键**)→ 源码级守卫可用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件。**P13 教训:happy-dom 下该写法抛 `ERR_INVALID_URL_SCHEME`**;本批新增守卫**不得引入需要 DOM 的断言**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务表
|
||||||
|
|
||||||
|
| 任务 | 交付物 | 要求 | 完成判据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **T1** | `app/lib/bootstrapTheme.test.ts`(**新增**) | **RED 先行**(spec §5-T1)。**10 腿**:①pre-paint 脚本在 `<style>` 与 `<body>` 之前 ②判定序 hash→prefers→light(`indexOf` 比较,**先断言三者都 `> -1`**)③白名单校验 hash theme(只接受 `light`/`dark`)④IIFE 且只用 `var` ⑤**`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** ⑥bootstrap 暗色块六个 hex 与 `main.css` 暗色调色板**逐值一致**(字符串相等,**不得写成 `max(a,b) ≥ k`**)⑦bootstrap 亮色六个 hex **未被改动** ⑧`bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`** ⑨**前提检查**(读到 ≥3 个文件且 bootstrap html 长度 >500)⑩`AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` 不含 `info`、长度 `toBe(11)` | RED 输出**逐字存证** `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**:腿 1/2/5/6/8/10 须红,**腿 7/9 应当已绿**(它们是"不得回退"钉桩,不是新功能断言——spec §5-T1 明写这是有意的) |
|
||||||
|
| **T2** | `app/lib/contrast.test.ts`(改)+ `app/styles/main.css`(改) | 按 spec §3.2/§3.3:`AA_PAIRS` 插入 `['ink','surface','卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85']`(**不得改其它 10 对**);删 `main.css:12`(亮)与`:53`(暗) 两行 `--color-info`;`REQUIRED_KEYS` 删 `'info',`;四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)。⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测) | `npx vitest run` **80 文件 / 688+N 全绿**(N = T1 腿数 ≥10);`--color-info` 全仓命中 **0**;`AA_PAIRS` **11** 对;`REQUIRED_KEYS` **11** 项;`LEGACY_KEYS` **9** 项不动 |
|
||||||
|
| **T3** | `bootstrap/index.html`(改)+ `runtime/host/adapters.ts`(改)+ `runtime/host/GameHost.vue`(改) | 按 spec §3.1(a)–(e):head 内 `<style>` **之前**插 pre-paint 脚本(**逐字对齐 spec 给的代码块**,含 CSP 注释与 `catch` 兜底);`<style>` 加 `html[data-theme="dark"]` 覆盖块(**亮色值逐字不变**,`color-scheme: dark` 随块声明一次);**删两处 inline `color`**(`:25` 的 `#a3a3a3`、`:27` 的 `#f87171`,各保留 `font-size`);`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' \| 'dark'`,**仅当存在时**才写 `fragment.theme`;`GameHost.vue` 注入 **`effectiveTheme()`** 并写明陷阱注释 | `npx vitest run` 全绿;`npm run typecheck`(`vue-tsc --noEmit`)**零错**;**`adapters.test.ts` 4 处既有调用零修改仍绿**(验证 `theme` 是可选键);既有 `*.test.ts` 修改数 = **1**(仅 `contrast.test.ts`,须在报告显式声明) |
|
||||||
|
| **T4** | e2e / 产物验证 + mutation 自查 | ⚠️ **RE-PIN 2026-10-03(审查裁定后)**:**偏离 5 已驳回**——端到端腿**可**确定性化,用 **`context.route`(不是 `page.route`)** + inert sw.js(`page.route` 拦不到 SW 注册请求 / SW 发起的请求 / 被 SW `respondWith` 合成的导航,实测命中 0);断言须含 **`snap.url === 父侧 iframe src`**(因果链闭合);另加**行为腿**(游戏页切主题 → iframe `src` 不变,D-B 的唯一行为级防线)与**反方向格**(子侧 `[系统暗色] × [hash=light]` → 期望 `light`,H1/H3 在**子侧直访**路径上的唯一鉴别格;端到端亮色格是第二个鉴别格(RE-PIN 2026-10-04))。产物验证**必须在 e2e 之后重跑生产 `npm run build`** 之上做,并用 `grep -c -F 'localhost:4173'` = **0** 证明量的是生产构建(控制者当日量了 e2e 残留 → 7069 vs 7056 的假矛盾)。计数须**同时给 raw 与屏蔽注释后两个数**、字节用 `wc -c`(详见 spec §5-T4.3/§5-T4.4 与 `adjudication.md` §三) | **端到端腿 + 行为腿 + 反方向格三类都跑绿**;mutation 复验须跑审查者自设的 **G1/G2/H1/H3/F1/F2/H4 七条**(spec 的 (a)–(j) 对这七条**全部无牙**)+ 全部对照组 + 合法树 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收(控制者独立复跑,不采信自报)
|
||||||
|
|
||||||
|
### 测试与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crearte/src
|
||||||
|
npx vitest run # 基线 79 文件 / 688 测试 → 目标 80 / 688+N
|
||||||
|
npm run typecheck # vue-tsc --noEmit,零错
|
||||||
|
npm run build # 含 vue-tsc + vite build + build-runtime.mjs
|
||||||
|
npm run e2e # 基线 102+1skip(dark.spec.ts 10 腿)
|
||||||
|
```
|
||||||
|
|
||||||
|
基线(spec §0,已实测 2026-10-03 13:07):**vitest 79 文件 / 688 测试全绿,Duration 44.83s**——与 P13 交付值逐字相同(P14 未触及前端)。**e2e 102+1skip** 是 P13 交付值。
|
||||||
|
|
||||||
|
⚠️ `npm run build` 含 `vue-tsc --noEmit`,会**类型检查 `e2e/*.spec.ts`**;spike 期间若只想快速验产物可用 `npx vite build`(P13 纪律 5 的同款做法),但**交付验收必须跑完整 `npm run build`**。
|
||||||
|
|
||||||
|
### 结构指标(spec §5 表,交付时须报实测数字)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N ≥ 10) |
|
||||||
|
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,仅因 "12→11 令牌" 文案;**须在报告显式声明**) |
|
||||||
|
| `AA_PAIRS` 对数 | 10 | **11** |
|
||||||
|
| `REQUIRED_KEYS` 项数 | 12 | **11** |
|
||||||
|
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
|
||||||
|
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
|
||||||
|
| bootstrap 内 `localStorage` / `prefers-color-scheme` / `data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` **仍须为 0**:源隔离,spec D-A) |
|
||||||
|
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
|
||||||
|
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
|
||||||
|
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
|
||||||
|
|
||||||
|
### mutation 抽查(spec §5-T1 的 (a)–(j),控制者独立复现,不复用实现者结果)
|
||||||
|
|
||||||
|
**十条**,每条测"预期红 + 其余绿 + 恢复证明":
|
||||||
|
|
||||||
|
| # | mutation | 预期 |
|
||||||
|
|---|---|---|
|
||||||
|
| (a) | bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 | 腿 1 红 |
|
||||||
|
| (b) | 交换 hash theme 与 `prefers-color-scheme` 的判定先后 | 腿 2 红 |
|
||||||
|
| (c) | hash theme 校验放宽成 `hashTheme ? hashTheme : …` | 腿 3 红 |
|
||||||
|
| (d) | **`GameHost.vue` 改用 `useTheme().theme.value`** | 腿 5 红(**这条是 spec D-B 的牙**:响应式注入会让主题切换重载运行中的游戏) |
|
||||||
|
| (e) | 改 bootstrap 暗色的一个 hex | 腿 6 红 |
|
||||||
|
| (f) | 加回 `style="color:#a3a3a3"` | 腿 8 红 |
|
||||||
|
| (g) | 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` | 腿 10 红 |
|
||||||
|
| (h) | 把 `info` 加回 `REQUIRED_KEYS` | 腿 10 红 |
|
||||||
|
| (i) | **守卫的文件路径改成不存在的文件** | 腿 9 红(防"读 0 文件报 0 违规"的假信心) |
|
||||||
|
| (j) | **删掉 `main.css` 的暗色块**(模拟 P13 成果回退) | 腿 6 或 `contrast.test.ts` 的暗色腿红 |
|
||||||
|
|
||||||
|
⚠️ **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**(P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后四轮全在测没有修复的树、汇总报"绿=3")。每轮恢复后核 `git diff HEAD --exit-code -- <file>` 空 + 守卫文件腿数不变。
|
||||||
|
|
||||||
|
⚠️ **写守卫时注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(spec §8-8:**Tailwind v4 扫描全部源文件含 `.test.ts`**,注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入,靠重建抓出)。
|
||||||
|
|
||||||
|
### 产物验证(P13 教训:源码级守卫绿 ≠ 产物正确)
|
||||||
|
|
||||||
|
- **必须验真实 `dist/`**,不得用运行时注入或 `APPLY.toString()`+`new Function` 序列化注入替代(P13 控制者勘查期两次栽在这两种无效手法上)
|
||||||
|
- 选择器/字符串 grep **用 `grep -F`**,不手写反斜杠转义(P13:`.backdrop\:bg-scrim` 手写转义对 minified 产物假阴性)
|
||||||
|
- **带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到压缩形态)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 边界(**不做**,spec §4)
|
||||||
|
|
||||||
|
- **不动 `themeBootstrap.test.ts`**(主应用 pre-paint 守卫 7 腿)——若某腿因本批变红,**停下来报告**
|
||||||
|
- **不扩 `noHardcodedColor.test.ts` 的扫描根**(spec §1.4 + 被否方案 D:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,只会制造"已覆盖"的假信心)
|
||||||
|
- **不动 `useTheme.ts`**(`effectiveTheme()` 已够用;改它会波及主应用 10 腿 e2e)
|
||||||
|
- **不动 `src/index.html`**(主应用入口,P13 交付)
|
||||||
|
- **不改 P13 的 CHANGELOG 0.26.0 条目与 P13 spec 原文**(spec D-C:改写会让"当时交付了什么"失真;改为新条目说明 + P13 spec 加 ⚠️ RE-PIN 标注块,那是控制者的记账动作)
|
||||||
|
- **不修 GameHost 亮色徽标 WCAG 3.26**(spec §4 挂账 + 被否方案 F:属**设计决策**,显而易见的一行修法已实测证伪——亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存,改完会把两个语义压成一个视觉信号;ROADMAP `9965fd7` 已登记该约束)
|
||||||
|
- **不做第三态"恢复跟随系统"**(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)
|
||||||
|
- 不新增任何令牌(只删 `--color-info`)
|
||||||
|
- 不碰 `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;记账由控制者做)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记账(控制者做,实现者禁动 `docs/`)
|
||||||
|
|
||||||
|
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`;**与 P15-A 合并为一条还是分两条按合并时序定**
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行(**哈希引 merge commit**);挂账里**删掉「死令牌 `--color-info`」**(已处置)、**新增「bootstrap 内联脚本的 CSP nonce」**(与主应用同批)
|
||||||
|
- P13 spec 加 **⚠️ RE-PIN 标注块**(spec D-C:12→11 令牌),**不改写原文**
|
||||||
|
- 格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**绝不 stage**
|
||||||
|
- **合并**:CHANGELOG 改动提交到**特性分支** → `git checkout master` → `git merge --no-ff` → 记 merge 哈希 → 推送(**禁管道**)→ `git ls-remote origin master` **非空**对账(`-z` 检查,防空变量假 MATCH)→ 删分支
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审查阶梯(P11–P14 惯例,不可省)
|
||||||
|
|
||||||
|
1. **实现者自报** + 证据落盘(RED 逐腿标注、mutation 十条、产物验证)
|
||||||
|
2. **控制者独立核实**(全部自己跑,不采信自报数字)
|
||||||
|
3. **任务级审查者**(只读,**自设 mutation 不照抄 spec 的 (a)–(j)**,审守卫恒真/恒假、spec 自身问题、**特别是腿 5 这条架构陷阱断言是否真能防住响应式注入**)
|
||||||
|
- P13 先例:10 条自设 mutation 挖出 5 条 findings,**M1 抓到控制者自己写错两次的恒真守卫**(`max(a,b) ≥ 3` 互补下界 4.0621)
|
||||||
|
- P14 先例:抓到 **spec D-J 的守卫目的无断言覆盖** + **spec 里 positive control 的绕行路径** + 控制者行数口径错(`split` vs `wc -l`)
|
||||||
|
4. **控制者逐条裁定**(`sdd-pre-merge-review` §3:**未裁定的 note 阻塞合并**,"PASS with notes" 不等于门过了)
|
||||||
|
5. **全分支终审者**(只读;spec 全文自洽性 + **审控制者的裁定工作** + 独立复现 mutation)
|
||||||
|
- P11 先例:终审裁定过「**需修复后合并**」→ **不是橡皮章**
|
||||||
|
- P14 先例:终审在**第一轮修复自身**里找到洞(匿名接收者 `func (*ContentService) M()` 绕过源码扫描),并**推翻控制者两处已入库的全称声明**
|
||||||
|
6. **spec re-pin**(裁定后一次性批量改,**勿在审查者读 spec 期间改**=移动靶);**代码改了 spec 必须同步**(P14:正则字面量写进 spec,改代码后 spec 立刻不一致,构成"需修复后合并"的同类形态)
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# P11 服务端安全硬化批 — 设计文档
|
||||||
|
|
||||||
|
日期:2026-10-02 | 涉及仓:`crearte-server`(主)、`crearte-deploy`(编排)、`crearte`(仅 nginx 模板)| 分支:`fix/p11-server-hardening`(server)/ `feat/p11-trusted-proxies`(deploy、crearte)
|
||||||
|
|
||||||
|
## 1. 问题(三处实测缺陷,全部有 spike 证据)
|
||||||
|
|
||||||
|
### 缺口 A:限流桶全站塌缩为单桶(高危)
|
||||||
|
|
||||||
|
`crearte-server` 的六个限流器实例全部以 `ctx.ClientIP()` 为键(`internal/api/ratelimit.go:42`)。gin 的 `ClientIP()` 只有在 **peer 本身落在 `SetTrustedProxies` 信任列表内**时才会解析 `X-Forwarded-For`;否则忽略该头、返回 peer IP。
|
||||||
|
|
||||||
|
部署拓扑事实:
|
||||||
|
|
||||||
|
- `api-prod` / `api-dev` **都不发布宿主端口**,唯一入站路径是 nginx(prod)或 vite(dev)反代。
|
||||||
|
- nginx 设 `X-Forwarded-For $proxy_add_x_forwarded_for`(`crearte/deploy/nginx.conf.template`,两个 server 块各一处),于是 API 看到的 peer 恒为 nginx 容器 IP。
|
||||||
|
- `TRUSTED_PROXIES` 在 `crearte-deploy/docker-compose.yml` 与 `.env.example` 中**均未定义**,README 也未提及 → `parseTrustedProxies("")` 返回空列表 → `SetTrustedProxies([]string{})`。
|
||||||
|
|
||||||
|
后果:`ClientIP()` 恒返回 nginx 容器 IP,**全站所有用户共用一个限流桶**。
|
||||||
|
|
||||||
|
spike 实测(`crearte-server/.superpowers/sdd-p11/spike_clientip_test.go.evidence`,gin v1.11.0):
|
||||||
|
|
||||||
|
| 输入 | `ClientIP()` 返回 |
|
||||||
|
|---|---|
|
||||||
|
| XFF=`203.0.113.7`,peer=`172.18.0.3` | `172.18.0.3` ← 真实用户 IP 被丢弃 |
|
||||||
|
| XFF=`198.51.100.42`,peer=`172.18.0.3` | `172.18.0.3` ← 第二个不同用户仍是同一值 |
|
||||||
|
|
||||||
|
限流器行为实测:两个不同用户各发一次,第三个请求即 `429`(`[204 204 429]`)——**第二个用户的首次尝试就被限流**。
|
||||||
|
|
||||||
|
> **⚠️ 端到端验收修正(2026-10-02,re-pin 前)**:上面两条 spike 把 peer 设为容器 IP(`172.18.0.3`)并直接带 XFF,**跳过了 compose 里横在 nginx 前面的 docker SNAT 层**。起真实栈后实测(见 `.superpowers/sdd-p11/FINDING-endpoint-snat.md` 与 `spike_snat_test.go.evidence`):docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1` 而非真实客户端,故 nginx 追加进 XFF 的也是网关。**结论:在 compose 本机演练形态下,`ClientIP()` 无论如何都无法解析出真实客户端 IP**——缺陷 A 的「塌缩」是 SNAT 的固有后果,`TRUSTED_PROXIES` 修不了它;而按原 D-A 信任整个 `/24`(含网关)反而**引入限流绕过**(客户端预置 XFF 会被采纳)。原 D-A 已作废,见 §2 D-A′。
|
||||||
|
|
||||||
|
影响面按严重度排序(六桶的 limit/window 见 `internal/api/router.go`):
|
||||||
|
|
||||||
|
1. **`bundle-key`(60/min,`RouteBundleKey`)无鉴权且是游玩必需路径**:任意访客刷 60 次即让**全站所有用户无法加载任何 hosted 游戏**。这是当前最可被利用的一条——攻击者无需账号。
|
||||||
|
2. **`register`(5/hour)全站共享**:第 6 个访客(含爬虫、预取、健康检查)之后,**全站一小时无法注册**。
|
||||||
|
3. **`login`(10/min)全站共享**:任何 10 次登录尝试后全站锁死一分钟;同时这也让**按 IP 的口令爆破防护形同虚设**(攻击者与全体用户同桶,限流并不单独针对攻击者,而 `DummyVerify` 只挡了时序侧信道)。
|
||||||
|
4. `upload`(10/min)、`submission`(20/hour)、`reaction`(30/min):多用户同时投稿/评分时互相挤兑。
|
||||||
|
|
||||||
|
### 缺口 B:限流表无界增长(高危,且被 A 掩盖)
|
||||||
|
|
||||||
|
`maxTrackedIPs = 10000` **不是上限,只是过期清理的触发条件**。`allow()` 的清理循环只删除「窗口已过期」的表项;当大量不同 IP 在**同一窗口内**到达时,没有表项可删,map 持续增长。
|
||||||
|
|
||||||
|
spike 实测(`spike_xff_test.go.evidence`):`NewRateLimiter(60, time.Hour)`,15000 个不同 IP 各发一次 → `len(hits) == 15000`,超「上限」5000 条,**无一被逐出**。
|
||||||
|
|
||||||
|
耦合关系(本批的核心判断):**A 当前正在掩盖 B**——因为所有用户塌缩成一个 nginx 容器 IP,生产上 map 实际只有 1 条表项。修复 A 让 XFF 生效的同时,会把 B 从「结构上存在但不可达」变成「攻击者可达」:每个真实访客、每个僵尸网络节点、每个 IPv6 地址都成为一条新表项,而 `register`/`submission` 的窗口长达 1 小时。因此 **A 与 B 必须同批落地**,不得只做 A。
|
||||||
|
|
||||||
|
内存量级:`windowCounter`(`time.Time` 24B + `int` 8B = 32B)+ map 桶开销 + IP 字符串键(15–45B)≈ 每条 100–150B。10000 条 ≈ 1.5MB,可控;无界则随攻击流量线性增长。
|
||||||
|
|
||||||
|
### 缺口 C:`LOG_LEVEL` / `LOG_FORMAT` 大小写策略不一致(低,P8 deferred 债)
|
||||||
|
|
||||||
|
同一对取值在两层被以两种策略校验:
|
||||||
|
|
||||||
|
- `internal/config/config.go:88`(`applyLogging`)**严格**:`switch cfg.LogLevel { case "debug","info","warn","error": }`,`LOG_LEVEL=INFO` 直接返回错误、**进程启动失败**。
|
||||||
|
- `internal/observability/logging.go`(`parseLevel` / `newLogger`)**宽容**:`strings.ToLower(strings.TrimSpace(level))`,接受 `INFO`、` info `。
|
||||||
|
|
||||||
|
调用顺序是 `config.Load()`(严格)→ `observability.InitLogging(cfg.LogFormat, cfg.LogLevel)`(宽容),所以宽容分支永远收不到大小写不规范的输入,属死代码;而运维写 `LOG_LEVEL=INFO` 会得到一次启动失败。P8 第二批审查已裁决 deferred,本批收口。
|
||||||
|
|
||||||
|
## 2. 决策表
|
||||||
|
|
||||||
|
| # | 主题 | 定案 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D-A′(re-pin,作废原 D-A) | 信任代理链怎么配 | **compose 默认 `TRUSTED_PROXIES` 为空**(不注入 subnet)。保留 `TRUSTED_PROXIES` 的 `.env` 可覆盖项与管线,但**默认值改为空**,并把「真实 prod 必须按实际反代 IP/网段配置」写进 README 作为部署前置。**subnet 固定同步回退**:初版(commit `1a7570b`)曾固定 `172.28.0.0/24`,但其唯一目的是给 `TRUSTED_PROXIES` 一个稳定值——空信任默认下与子网值无关,保留反而新增「与宿主其他项目网段冲突」的失败模式,docker 动态分配即可(实栈验证:栈起在动态 `172.19.0.0/16` 上全部断言成立,见 deploy CHANGELOG 0.7.0 Removed 段)。 | 端到端实测推翻原 D-A 的前提「subnet 是封闭边界、边界内只有本项目 web 容器」:**docker 网桥网关 `172.28.0.1` 也在该 subnet 内**,而 compose 发布端口时 docker 做 SNAT,使 nginx 看到的源恒为网关。信任整个 `/24` 于是把网关划进信任范围 → gin 右向左走信任跳时跳过网关、**采纳客户端预置的 XFF**(spike_snat 实测 `8.8.8.8, 172.28.0.1` + 信任 `/24` → `ClientIP()=8.8.8.8`)→ **限流可被客户端自选桶绕过**,比原缺陷更糟。而真实浏览器(不带 XFF)在 compose 下恒塌缩到网关(SNAT 固有),**缺陷 A 对合法流量无法靠 TRUSTED_PROXIES 修复**。故安全默认是空信任列表:XFF 被整体忽略、无绕过、`ClientIP()` 取 peer(nginx 容器 IP,仍是单桶但不引入新漏洞),D-C 告警如实提示。spike_snat 第 6 用例证明:真实 prod 形态(nginx 直接见真实客户端、信任 nginx)下 `ClientIP()` 能取到真实 IP 且拦截伪造——所以**空默认不损害真实 prod**,只是把配置责任交给运维(README 写明)。 |
|
||||||
|
| D-B | 限流表如何变成真上界 | `RateLimiter` 增加 `maxEntries` 字段(由 `NewRateLimiter` 设为 `maxTrackedIPs`)。`allow()` 在**新建表项前**:若 `len(hits) >= maxEntries`,先跑一次过期清理;仍满则**拒绝**该请求(返回 `false` → `429` + `Retry-After`),并且**不插入**表项 | 「满则拒」而非「满则逐最旧」:逐最旧会让攻击者用新 IP 冲刷把正常用户的计数器挤掉(等价于绕过限流),而满则拒是**失败关闭**——在极端情况下宁可多拒也不失去上界。既有键(已在表中的 IP)永远不受影响,只影响「表满时的新面孔」,而表满本身就是异常态。清理循环从 `> maxTrackedIPs` 改为 `>= maxEntries`,并把「清理」与「仍满则拒」写成同一条路径,避免只删不判的旧语义。 |
|
||||||
|
| D-C | 桶塌缩要不要加可观测性 | 在 `router.go` 装配处,当 `TrustedProxies` 为空且引擎已挂载限流器时,`slog.Warn` 一条启动告警,说明「所有客户端将共享同一限流桶」 | 这是「静默降级」类缺陷的通用解法:配置缺失时不猜测、不静默,而是**大声告诉运维**。空信任列表本身是合法配置(直连、无反代的部署),所以不能报错退出,但必须留痕。告警走既有 `slog`,不加新依赖、不加新配置项。 |
|
||||||
|
| D-D | `LOG_LEVEL`/`LOG_FORMAT` 哪边迁就哪边 | 在 `config.applyLogging` 中先 `strings.ToLower(strings.TrimSpace(v))` 归一,再按小写白名单校验;`observability` 层**保持原样** | 两层都宽容会让「配置值到底是什么」失去单一真相;两层都严格会让 `INFO` 这种常见写法启动失败。选择在**入口层归一**:`cfg.LogLevel` 从此恒为规范小写,下游(含 `observability.parseLevel` 的 ToLower)成为无害的幂等操作,无需改动、无需删它的宽容逻辑(它还被 `newLogger` 的单元测试直接使用)。 |
|
||||||
|
| D-E | nginx 模板要不要改 | 给两个 `location /api/` 块补 `proxy_set_header X-Real-IP $remote_addr;`,并在 server 块补 `add_header X-Content-Type-Options "nosniff" always;` 与 `add_header Referrer-Policy "strict-origin-when-cross-origin" always;` | `X-Real-IP` 给限流与日志一个**不经链式追加、不可被客户端预置污染**的单跳真相(nginx 用 `$remote_addr` 覆写,客户端发什么都会被替换),作为 XFF 的冗余校验与未来 `TrustedPlatform` 选项的入口。`nosniff` 与 `Referrer-Policy` 是静态资源与 API 反代共用的最低成本加固;`always` 保证 4xx/5xx 响应也带头。**不加 CSP**:本站的游玩子域要靠 iframe + Service Worker 加载用户上传的作品,CSP 需要单独一批设计与验证(挂账)。 |
|
||||||
|
| D-F | dev 侧要不要开 vite `xfwd` | **不做** | 已核实 vite 8.3.0 的 `ProxyOptions` 支持 `xfwd?: boolean`(`node_modules/vite/dist/node/index.d.ts:605`,bundled http-proxy 认它)。但 spike case 3/4 证明:dev 下浏览器经宿主端口进 web-dev,vite 看到的 peer 是 docker 网关(`172.28.0.1`),开 `xfwd` 只会把塌缩值从 vite 容器 IP 换成网关 IP——**不修复任何东西**,且 dev 本就是单用户 localhost 环境。改它属于无收益的前端改动,故 P11 不碰 `crearte` 的 `vite.config.ts`。 |
|
||||||
|
| D-G | 限流是否升级为共享存储(Redis 等) | **不做**,仅在 spec 记录权衡 | 多实例部署下进程内 map 仍是每实例独立(P3 已文档化该权衡)。引入 Redis 会带来新依赖、新故障域与新 compose 服务,而当前拓扑是单实例;「A 修好后按真实 IP 分桶」已经恢复了限流的设计意图。挂账为未来多实例化的前置条件。 |
|
||||||
|
| D-H | 是否顺手做 `td`→`th scope="row"` 之外的其他前端项 | **不做** | P11 是服务端批,前端只碰 `crearte/deploy/nginx.conf.template`(属部署资产,非应用代码)。保持批次边界清晰,避免与 P9-B 打磨批、C 暗色模式批冲突。 |
|
||||||
|
|
||||||
|
## 3. 实现
|
||||||
|
|
||||||
|
### 3.1 `internal/api/ratelimit.go`(D-B)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
maxEntries int
|
||||||
|
hits map[string]*windowCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
limit: limit,
|
||||||
|
window: window,
|
||||||
|
maxEntries: maxTrackedIPs,
|
||||||
|
hits: map[string]*windowCounter{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`allow()` 改为(保持既有签名与 `now` 注入以便测试):
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (l *RateLimiter) allow(ip string, now time.Time) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
counter, ok := l.hits[ip]
|
||||||
|
if !ok {
|
||||||
|
// 新面孔:先确认表有空间。maxEntries 是硬上界(D-B),
|
||||||
|
// 满则失败关闭——拒绝且不插入,避免无界增长。
|
||||||
|
if len(l.hits) >= l.maxEntries {
|
||||||
|
l.evictExpired(now)
|
||||||
|
if len(l.hits) >= l.maxEntries {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.hits[ip] = &windowCounter{start: now, count: 1}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if now.Sub(counter.start) >= l.window {
|
||||||
|
counter.start = now
|
||||||
|
counter.count = 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if counter.count >= l.limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
counter.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictExpired 删除窗口已过期的表项。调用方必须持有 l.mu。
|
||||||
|
func (l *RateLimiter) evictExpired(now time.Time) {
|
||||||
|
for key, counter := range l.hits {
|
||||||
|
if now.Sub(counter.start) >= l.window {
|
||||||
|
delete(l.hits, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:**既有键的路径语义与旧实现逐字等价**(窗口过期则重置为 `count:1` 并放行;未过期且已达 limit 则拒;否则自增放行),只有「新面孔」多了上界检查。旧实现里 `counter.start` 的重置发生在 map 赋值处,新实现改为原地改字段——效果相同,避免为已存在的键重新分配结构体。
|
||||||
|
|
||||||
|
### 3.2 `internal/config/config.go`(D-D)
|
||||||
|
|
||||||
|
```go
|
||||||
|
if v := os.Getenv("LOG_FORMAT"); v != "" {
|
||||||
|
cfg.LogFormat = strings.ToLower(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
if v := os.Getenv("LOG_LEVEL"); v != "" {
|
||||||
|
cfg.LogLevel = strings.ToLower(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
白名单 `switch` 与错误信息**保持不变**(校验的仍是小写集合;错误消息里回显的是归一后的值,便于运维看到实际被解析成什么)。`DefaultLogFormat`/`DefaultLogLevel` 已是小写,不动。
|
||||||
|
|
||||||
|
### 3.3 `internal/api/router.go`(D-C)
|
||||||
|
|
||||||
|
在 `SetTrustedProxies` 之后、路由注册之前插入:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if len(proxies) == 0 {
|
||||||
|
// 无反代直连是合法拓扑,但此时 ClientIP() 恒为 peer IP。若 API 位于
|
||||||
|
// nginx/vite 之后而未配 TRUSTED_PROXIES,所有客户端会共享同一个限流桶
|
||||||
|
// (bundle-key 60/min、register 5/hour、login 10/min 均按 IP 计),
|
||||||
|
// 少量流量即可让全站拒绝服务。故大声留痕而非静默降级。
|
||||||
|
slog.Warn("api: no trusted proxies configured; client IP resolution falls back to the peer address, so every client behind a reverse proxy shares one rate-limit bucket",
|
||||||
|
"hint", "set TRUSTED_PROXIES to the reverse proxy's own IP/CIDR — never a range that also contains clients or the docker bridge gateway, or clients can spoof X-Forwarded-For to pick their own rate-limit bucket")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/api` 需新增 `log/slog` import。
|
||||||
|
|
||||||
|
### 3.4 `crearte-deploy/docker-compose.yml`(D-A′)
|
||||||
|
|
||||||
|
**不新增顶层 `networks` 键**——subnet 固定已回退(理由见 §2 D-A′;初版 `1a7570b` 加了它,`08149aa` 删掉),docker 动态分配默认网络即可,空信任默认与子网值无关。
|
||||||
|
|
||||||
|
`x-api-env` 锚点(第 20–26 行)新增一行,但**默认值为空**(D-A′)——compose 本机演练下 docker SNAT 使真实客户端 IP 不可达,空信任列表是安全默认(XFF 整体忽略、无绕过、D-C 告警如实提示单桶):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 prod 部署(nginx 直接见真实客户端,或前置公网 LB)由运维在 `.env` 里把 `TRUSTED_PROXIES` 设为实际反代 IP/网段(README 写明)。注意空默认下 `parseTrustedProxies("")` 返回空切片,`router.go` 的 `if proxies == nil` 分支不触发(空切片非 nil),但 `len(proxies) == 0` 仍真 → D-C 告警照常发。
|
||||||
|
|
||||||
|
兼容性:`api-dev`/`api-prod` 的 `networks: default: aliases: [api]`(服务级接入声明)不受影响——无顶层 `networks` 键时 compose 自动创建默认网络,服务名解析不变。**不得改动任何卷名**(`pgdata-*`/`minio-data-*`/`dev_node_modules`/`mock_node_modules` 是数据身份),不得改 `depends_on` 健康门控,不得移除 `${VAR:?…}` 守卫。
|
||||||
|
|
||||||
|
### 3.5 `crearte-deploy/.env.example`(D-A′)
|
||||||
|
|
||||||
|
在「prod 写侧可选项」段之后新增:
|
||||||
|
|
||||||
|
```
|
||||||
|
# --- 客户端 IP 解析与限流(默认留空,通常无需设置)---
|
||||||
|
# compose 本机演练下 docker 对发布端口做 SNAT:反代看到的源恒为网桥网关而非真实客户端,
|
||||||
|
# 真实客户端 IP 在本机形态下不可达,限流因而是单桶(演练环境单用户,可接受)。
|
||||||
|
# 切勿设为整个 compose 网段:网关也在网段内,信任它会让客户端预置的 X-Forwarded-For
|
||||||
|
# 被采纳,使限流可被自选桶绕过。详见 README「客户端 IP 解析与限流」。
|
||||||
|
# 真实 prod(反代直接见真实客户端,或前置公网 LB)才需设为实际反代 IP/网段:
|
||||||
|
# TRUSTED_PROXIES=
|
||||||
|
```
|
||||||
|
|
||||||
|
(`COMPOSE_SUBNET` 项随 subnet 固定回退一并移除。)
|
||||||
|
|
||||||
|
### 3.6 `crearte/deploy/nginx.conf.template`(D-E)
|
||||||
|
|
||||||
|
两个 `location /api/` 块各补一行:
|
||||||
|
|
||||||
|
```
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
两个 `server` 块各补两行(`listen`/`server_name` 之后):
|
||||||
|
|
||||||
|
```
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 nginx `add_header` 的**继承陷阱**:子 `location` 内出现任何 `add_header` 会**完全屏蔽**父级 `server` 的 `add_header`。现有 `location = /index.html`、`/assets/`、`/data/`、`/data/bundles/` 都已有自己的 `add_header`,故这些路径**不会**继承新的两个安全头。定案:在 server 级加,并**同时**在已有的全部**七个**自带 `add_header` 的 `location` 块内各自补上同样两行(第一个 server:`= /index.html`、`/assets/`、`/data/bundles/`、`/data/`;第二个 server:`= /__bootstrap`、`= /sw.js`、`= /agent.js`),保证全站一致(这属于「加固必须无洞」而非过度设计)。合计落点:2 个 server 块 + 7 个 location 块 = **9 处**,每处两行 `add_header`;另 `X-Real-IP` 共 **2 处**(两个 `location /api/` 各一行)。
|
||||||
|
|
||||||
|
### 3.7 README(`crearte-deploy/README.md`,D-A′/D-E)
|
||||||
|
|
||||||
|
新增一节「客户端 IP 解析与限流」,说明:
|
||||||
|
- **compose 本机演练形态**:docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1`,真实客户端 IP 不可达 → 默认 `TRUSTED_PROXIES` 空,限流是单桶(演练环境单用户,可接受),D-C 启动告警会如实提示。
|
||||||
|
- **为什么默认不信任整个子网**:网关也在子网内,信任它会让客户端预置的 XFF 被采纳(spike_snat 实测 `8.8.8.8` 被当成客户端 IP)→ 限流可被自选桶绕过。这是本批端到端验收抓出并 re-pin 的关键修正。
|
||||||
|
- **真实 prod 部署**:nginx 直接见真实客户端(host 网络/直接暴露)或前置公网 LB 时,把 `TRUSTED_PROXIES` 设为实际反代 IP/网段;此时 gin 右向左走信任跳能取到真实客户端 IP 且拦截伪造前缀(spike_snat 第 6 用例)。
|
||||||
|
- **如何验证**:查启动日志有无 D-C 告警(空信任列表时应有);真实 prod 配好后用两个不同真实来源打 `/api/auth/login` 观察是否各自独立计数。
|
||||||
|
- **`X-Real-IP` 与 `XFF` 的分工**:XFF 链式追加、可被客户端预置前缀污染(gin 从右向左走信任跳故仍安全);`X-Real-IP` 由 nginx 用 `$remote_addr` 覆写、单跳不可伪造。
|
||||||
|
|
||||||
|
compose 行为变更必须同步 README(AGENTS.md 硬性要求)。
|
||||||
|
|
||||||
|
## 4. 边界(明确不做)
|
||||||
|
|
||||||
|
- **不加 CSP**(iframe + Service Worker 加载用户作品,需独立设计批)。
|
||||||
|
- **不引入 Redis / 共享限流存储**(D-G,单实例拓扑下无收益,挂账为多实例化前置条件)。
|
||||||
|
- **不改 `vite.config.ts`**(D-F)。
|
||||||
|
- **不改 `observability/logging.go`**(D-D:归一在入口层做,下游宽容逻辑保留且变幂等)。
|
||||||
|
- **不改口令散列 / JWT / CORS**:已核实均为正确实现——pbkdf2-sha256 **600000** 轮 + `subtle.ConstantTimeCompare` + 不存在用户走 `DummyVerify` 挡时序侧信道;JWT `HS256` 钉死 + `WithValidMethods` + `WithExpirationRequired` + 32 字节密钥强校验;CORS 白名单精确匹配 + `Vary: Origin`。不是靶子,不动。
|
||||||
|
- **不改任何既有 handler 的 `Cache-Control`**:已核实 auth/reactions/account/admin_console 的敏感响应均已带 `no-store`。
|
||||||
|
- **不改六个限流器的 limit/window 数值**:本批修的是「按谁计数」与「表能否无界」,不是配额策略。
|
||||||
|
|
||||||
|
## 5. 测试计划
|
||||||
|
|
||||||
|
### T1(server)限流表硬上界
|
||||||
|
`internal/api/ratelimit_test.go` 扩展:
|
||||||
|
1. **RED→GREEN 上界**:`ratelimit_test.go` 是 `package api`(同包),可直接构造 `&RateLimiter{limit: …, window: …, maxEntries: 4, hits: map[string]*windowCounter{}}` 注入小上界——**不得为测试往生产代码加导出/私有构造函数**。填满表后:新 IP 被拒(`429`)、`len(hits)` **不超过** `maxEntries`、既有 IP 仍可正常计数(不被挤掉)。
|
||||||
|
2. **过期后可回收**:表满 → 时间推进超过窗口 → 新 IP 放行且 `len(hits)` 回落。
|
||||||
|
3. **既有语义不回退**:`TestRateLimiterAllowsUpToLimit`、`TestRateLimiterWindowResets` 逐字保留且继续绿(注意两者都用 `gin.New()` 且不设 `SetTrustedProxies`、只设 `RemoteAddr` 不设 XFF,gin 默认信任全网段故 `ClientIP()` 取 peer IP——新增上界逻辑不得改变这条路径)。
|
||||||
|
4. 把 spike 的 15000-IP 场景改写成**断言**(`len(hits) <= maxEntries`)而非日志——这是缺陷 B 的回归钉桩。该用例须用 `maxEntries` 注入的小值跑(否则真造 15000 条会拖慢套件);另保留一条用 `NewRateLimiter` 的断言,钉住生产构造确实用 `maxTrackedIPs` 作上界。
|
||||||
|
|
||||||
|
### T2(server)信任代理链解析
|
||||||
|
`internal/api/router_test.go` 扩展 + 新 `internal/api/clientip_test.go`:
|
||||||
|
1. `Deps.TrustedProxies` 为空 → `NewRouter` 返回的引擎对「peer 在容器网段 + XFF 带真实 IP」的请求,`ClientIP()` 返回 **peer IP**(XFF 整体忽略——这正是 D-A′ 的安全默认,也是 compose 本机演练的实际形态)。
|
||||||
|
2. `Deps.TrustedProxies = ["172.28.0.0/24"]` → 同样请求返回 **XFF 中的真实 IP**(真实 prod 形态:nginx 直接见真实客户端并追加,信任 nginx 后能取到真实 IP)。
|
||||||
|
3. **伪造抗性**(把 spike 五个场景变成断言):客户端预置 `X-Forwarded-For: 8.8.8.8, <真实IP>` / 多跳伪造 / 伪造信任网段内 IP / 双层代理——全部返回最右侧不可信跳,**不得**返回客户端可控的前缀值。
|
||||||
|
4. **启动告警**:`TrustedProxies` 为空时 `NewRouter` 产生一条 `slog.Warn`(用 `bytes.Buffer` + `slog.New(slog.NewJSONHandler(...))` 捕获,仿 `observability/logging_test.go:23` 既有范式),断言消息含 `trusted proxies`;非空时**不**产生该告警。告警断言只调 `NewRouter`、不发请求,避免 `RequestLogger` 噪声混入捕获 buffer。
|
||||||
|
5. all-trusted 边界钉桩(`spike_alltrusted_test.go.evidence`):XFF 全为信任跳时 gin 返回最左条目(dev 拓扑的网关 IP)——作为已知可接受行为钉桩(spec D-F),测试注释说明缘由。
|
||||||
|
6. **SNAT 网关陷阱钉桩(新增,`spike_snat_test.go.evidence`)**:`TrustedProxies = ["172.28.0.0/24"]`(含网关)+ peer=nginx 容器 IP + XFF=`8.8.8.8, 172.28.0.1`(客户端预置 + nginx 追加 SNAT 网关)→ `ClientIP()` 返回 **`8.8.8.8`**(客户端可控值被采纳)。这是**反面钉桩**:记录「信任整个含网关的 subnet 会引入限流绕过」这个陷阱,防止将来有人把 compose 默认改回 subnet 信任。测试注释须说明这正是 D-A′ 把默认改为空的原因。另钉:`TrustedProxies` 空 + 同 XFF → 返回 peer(nginx IP),伪造被忽略。
|
||||||
|
|
||||||
|
### T3(server)LOG 归一 + `parseTrustedProxies` 覆盖
|
||||||
|
`internal/config/config_test.go` 扩展 `TestApplyLogging`:
|
||||||
|
1. `LOG_LEVEL=INFO`、`LOG_FORMAT=" JSON "` → 成功,`cfg.LogLevel == "info"`、`cfg.LogFormat == "json"`。
|
||||||
|
2. `LOG_LEVEL=" warn "`(两侧空格)→ 成功归一为 `warn`。
|
||||||
|
3. `LOG_LEVEL=bogus` → 仍报错(既有断言保留)。
|
||||||
|
4. 空值 → 仍取默认(既有断言保留)。
|
||||||
|
5. 新增 `parseTrustedProxies` 测试(当前**零覆盖**;归 T3 因为它属 `internal/config` 包,与 T2 的 api 包分开以免并行冲突):`"172.28.0.0/24"` → 单元素切片;`"10.0.0.1, 192.168.0.0/16"` → 两元素(含 trim);`""` 与 `" , , "` → 空切片;`"not-an-ip"`、`"172.28.0.0/33"` → 报错。
|
||||||
|
|
||||||
|
### T4(deploy + crearte)编排与反代(D-A′ 修正后)
|
||||||
|
1. `docker compose --profile dev|prod|debug|mock config -q` 四个 profile 全过(AGENTS.md 要求)。
|
||||||
|
2. 断言 `config` 输出中 `api-prod`/`api-dev` 的 `TRUSTED_PROXIES` **默认为空**(未设 `.env` 时),且输出**无** subnet 固定(顶层 `networks` 键不存在,`config | grep subnet` 为空);另断言 `TRUSTED_PROXIES=10.0.0.0/8` 覆盖时跟随(管线仍通)。
|
||||||
|
3. **卷名不变**核查:`config` 输出的 `volumes` 键集合与改动前逐字相同(数据身份红线)。
|
||||||
|
4. `nginx -t` 校验模板渲染结果(`envsubst` + `docker run nginx:1.27-alpine nginx -t`)。
|
||||||
|
5. 起 prod 栈端到端(**D-A′ 的关键回归,用真实浏览器路径而非客户端自带 XFF**):`up -d --build` → `ps` 健康 → ① 启动日志**有** D-C 告警(空信任列表);② **不带任何 XFF** 打 `/api/auth/login`,api 日志 `ip=` 应为 nginx 容器 IP(单桶,SNAT 固有,如实记录);③ **客户端自带 `X-Forwarded-For: 8.8.8.8`** 打一次,api 日志 `ip=` 应仍为 nginx 容器 IP(**不是** `8.8.8.8`)——证明空信任列表下伪造 XFF 被忽略、无绕过。裸 `down`(绝不 `-v`)。
|
||||||
|
|
||||||
|
### T5(波次末,控制者)验收腿
|
||||||
|
- server:`gofmt -l`、`go vet`、`go test ./...`(docker 化 Go 1.24 + `GOPROXY=goproxy.cn`)、`-race` 腿、`TEST_DATABASE_URL` 指向 `db-test` 的集成腿(**绝不**指向 `db-debug`/`pgdata-dev`)。
|
||||||
|
- deploy:四 profile `config -q` + CI 的 `validate.yml` compose 腿。
|
||||||
|
- crearte:五腿全量不回退(vitest 626 / vue-tsc 0 / build OK / 主 e2e 80+1skip / noauth 4)——nginx 模板改动属部署资产,前端测试不应受影响,但必须实跑确认。
|
||||||
|
- 端到端:dev 栈 + prod 栈各起一次,`/healthz` 与 `/metrics` 冒烟,`down`(**绝不 `-v`**)。
|
||||||
|
|
||||||
|
## 6. CHANGELOG / 版本
|
||||||
|
|
||||||
|
- `crearte-server`:`0.17.0`(Fixed 段记 A/B/C,Added 段记 D-C 启动告警;Tests 段记测试增量)。
|
||||||
|
- `crearte-deploy`:`0.7.0`(Changed 段记 `TRUSTED_PROXIES` 空默认 + SNAT 绕过理由;**Removed 段记 subnet 固定回退**;文档段记 README 新节与 .env.example 说明块)。
|
||||||
|
- `crearte`:`0.24.1`(Changed 段记 nginx 模板的 `X-Real-IP` + 两个安全头;纯部署资产,patch 级)。
|
||||||
|
- wrapper `crearte-monorepo`:`0.3.4`(Done 段记 P11 交付 + ROADMAP P11 行 + 文档索引表补 spec/plan 两行)。
|
||||||
|
- 格式遵循各仓既有惯例:同条目英文行紧接中文行(无空行),不同条目间空行,高版本在上。
|
||||||
|
|
||||||
|
## 7. 证据存档
|
||||||
|
|
||||||
|
四份 spike 已存于 `crearte-server/.superpowers/sdd-p11/`(`.gitignore` 已加 `.superpowers/`,不入库):
|
||||||
|
|
||||||
|
- `spike_clientip_test.go.evidence` — 缺陷 A:塌缩实测 + 共享桶 `[204 204 429]`。
|
||||||
|
- `spike_xff_test.go.evidence` — 修复安全性(gin 右向左走信任跳,5 个伪造场景全过)+ 缺陷 B(15000 IP → `len(hits)=15000`)。
|
||||||
|
- `spike_alltrusted_test.go.evidence` — all-trusted 边界(XFF 全为信任跳时返回最左条目 = 网关 IP),D-F 判定依据。
|
||||||
|
- `spike_snat_test.go.evidence` — **compose SNAT 信任矩阵(D-A′ re-pin 的决定性证据)**:复刻 compose 拓扑(peer=nginx 容器 IP、XFF 含 SNAT 网关),六用例证明 ① 信任整个 `/24`(含网关)时客户端预置 `8.8.8.8` 被采纳(绕过);② 只信任 nginx IP 或信任空时伪造被拦截;③ 真实 prod 形态(nginx 见真实客户端、信任 nginx)下能取到真实 IP 且拦截伪造前缀。
|
||||||
|
- `FINDING-endpoint-snat.md` — 端到端验收发现全文:实测证据链(prod 栈 `ip=` 分布)、根因链(docker SNAT → nginx 追加网关 → 信任 subnet 含网关 → gin 跳过网关采纳客户端值)、原 D-A 设计错误如实记录、修正方向与 spike 验证。
|
||||||
|
|
||||||
|
gin v1.11.0 `ClientIP()` 源码已交叉验证:`trusted := c.engine.isTrustedProxy(remoteIP)`,仅当 peer 在信任列表内且 `ForwardedByClientIP` 时才走 `validateHeader`,否则 `return remoteIP.String()`。
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# P12 窄屏溢出修复与打磨批 — 设计文档
|
||||||
|
|
||||||
|
- 日期:2026-10-02
|
||||||
|
- 涉及仓库:`crearte`(主)+ `crearte-server`(测试)+ `crearte-deploy`(CI)+ wrapper(记账)
|
||||||
|
- 前置:P11(`docs/specs/2026-10-02-p11-server-hardening-design.md`,已交付 server 0.17.0 / deploy 0.7.0 / crearte 0.24.1)
|
||||||
|
- 证据:`crearte/.superpowers/sdd-p12/FINDINGS-survey.md`(16 轮 throwaway spike 的实测输出固化,spike 文件已删、工作树净)
|
||||||
|
|
||||||
|
本批**零新功能、零后端行为变更**。做两件事:① 修四个实测可达的窄屏溢出缺陷;② 清偿 P9-B / P11 挂账的可动手打磨项,并把「本批缺陷类型」变成机器守卫(正面应用 P11 N6 的教训)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 缺口(全部实测,非推断)
|
||||||
|
|
||||||
|
度量口径统一为 `document.documentElement.scrollWidth - clientWidth`(>0 即页面横向溢出),在真实构建产物上由 Playwright 实测(`build:e2e` + `serve-runtime.mjs --port 4173`)。
|
||||||
|
|
||||||
|
### 缺口 A(D1a):合法长用户名把页头撑出视口,且波及**全站每个路由**
|
||||||
|
|
||||||
|
`AppHeader.vue:102` 的 `<summary>` 直接插值 `{{ user.display_name }} ▾`,无任何宽度约束。60 字符 **ASCII** 名在 `word-break: normal` 下不可断行,summary 索取 484px 内容宽:
|
||||||
|
|
||||||
|
| 视口 | 实测 |
|
||||||
|
|---|---|
|
||||||
|
| 320px | `doc=700/320` → **+380px**;`sumW=484 sumRight=700` |
|
||||||
|
| 375px | `doc=700/375` → **+325px** |
|
||||||
|
| 768px | `/` ok;`/account` **+40px** — spike 17 反证更正:此 +40 由**本缺口(页头)**驱动,非缺口 B(回退 dd 修复 @768 仍 `over=+0`;回退页头 @768 → 红)。原表把它记在缺口 B 名下是归因错误 |
|
||||||
|
|
||||||
|
**可达性是硬事实,不是敌意构造**:后端 `internal/handler/auth.go:25` `maxDisplayNameLen = 60`、`:76` 错误文案「display name must be 1-60 characters」;前端 `app/auth/validation.ts:4` `DISPLAY_NAME_MAX = 60`、`RegisterView.vue:90` `maxlength="60"`。即**任何注册用户填满合法上限即触发**,且因页头全站常驻,`/`、`/games`、`/docs`、`/creator`、`/login`、`/account`、`/admin/*` 无一幸免。
|
||||||
|
|
||||||
|
24 字 **CJK** 名不触发(CJK 可断行,实测 `doc=320/320 ok`)——缺陷取决于**字符可断性**而非长度本身,故修复必须针对「不可断行串」而非「截断到 N 字」。
|
||||||
|
|
||||||
|
### 缺口 B(D1b):账号页昵称 `dd` 逃逸
|
||||||
|
|
||||||
|
`AccountView.vue:190` 的 `<dd class="text-sm font-bold">` 位于 `flex flex-wrap items-baseline gap-2` 内,但 60 字 ASCII 不可断行且 flex item 默认 `min-width:auto` 拒绝收缩:320px 实测 `ddW=542 ddRight=592 ddEscapes=true`(视口 320),`ddWhiteSpace=normal ddOverflowWrap=normal`。
|
||||||
|
|
||||||
|
**牙口位置(spike 17 2×2 消融实测)**:dd 固有宽 542px、左缘 50px → 右缘恒为 592px,故它在**任何 <592px 的视口**都溢出:回退本修复 @320px → `over=+272`(`scrollWidth=592`,即 `ddRight=592`)、@375px → `over=+217`。但 @768px dd 右缘 672 < 768,**本修复在 768 非必要**——该视口 `/account` 的 +40px 是缺口 A(页头)驱动(见上表更正)。即:dd 修复的牙在 320/375,页头修复的牙在全视口;两者独立必要、缺一不可,但**不是同一视口的同一症状**。
|
||||||
|
|
||||||
|
### 缺口 C(D2):目录页排序 select 的 `shrink-0`
|
||||||
|
|
||||||
|
`ResultMeta.vue:22` 的 `<div class="relative shrink-0">` 包裹排序 `<select>`,`shrink-0` 使其拒绝收缩。实测 `/games` @320px `doc=323/320` → **+3px**,**短名短数据也复现**(与缺口 A 无关);360px 及以上无溢出。spike 7 的四候选对照精确定位到它:仅「移除该包裹层 `shrink-0` + `min-width:0`」把 +3 归零,其余三个候选无效。
|
||||||
|
|
||||||
|
### 缺口 D(D3):五个 admin 表格零 overflow 包裹层,**短数据也溢出**
|
||||||
|
|
||||||
|
静态审计:`app/**` 共 **5 个 `<table>`**(`AdminUsersView.vue:115` 6 列、`AdminView.vue:150/173/220` 共 13 列、`AdminAuditView.vue:60` 5 列),`overflow-x`/`overflow-auto` 在表格上下文中**零命中**,全部 `table-layout: auto`、父级 `overflow-x: visible`。
|
||||||
|
|
||||||
|
短名短邮箱数据实测(与 display_name 长度无关):
|
||||||
|
|
||||||
|
| 路由 | 320px | 375px | 768px+ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/admin/users` | **+76px**(`t0 w=364 right=396`) | **+21px** | ok |
|
||||||
|
| `/admin/audit` | **+38px**(`t0 w=326 right=358`) | ok | ok |
|
||||||
|
| `/admin` | ok(`t0 w=282`) | ok | ok |
|
||||||
|
|
||||||
|
根因:表格 `auto` 布局的 min-content 宽由各列固有内容决定(邮箱等宽串、`toLocaleString('zh-CN')` 日期、角色徽章、操作按钮),`w-full` 只是 `width:100%` 的**建议**,撑不破 min-content 下限;父级无裁剪 → 直接推宽文档。
|
||||||
|
|
||||||
|
### 缺口 E(D5):长名下用户下拉菜单逃逸视口
|
||||||
|
|
||||||
|
`AppHeader.vue:103` 的菜单是 `absolute right-0 w-32`,相对 `relative` 的 `<details>` 定位。缺口 A 使 details 本身宽 484px 且右边缘在 700px,菜单随之被推出屏外:60 字名 @320px 实测 `menu {left:357, right:485, escapesRight:true}`。**修好缺口 A 即自动修好本项**(spike 15/16 全部变体实测 `escapesRight:false`),无需独立改动,但须有断言钉住。
|
||||||
|
|
||||||
|
### 非缺口(勘查证伪,避免做无用功)
|
||||||
|
|
||||||
|
- **「移动端页头 / 汉堡菜单」挂账证伪**:nav 三链接内容宽仅 **74–158px**,320/360/375/412/768/1280px **全部零横向溢出**(spike 1+4)。ROADMAP 与账本中的该项挂账应从「待启动」改为「已证伪,删除」。
|
||||||
|
- **D4(nav 链接逐字换行)park**:320px 下「作品」渲染为 `作/品`(link `h=45`、`navH=45` vs 行高 `h-14`=56px),所有手机宽度都发生(含 375px = iPhone 12/13/14)。属**外观级**:无溢出、无截断(`selfOverflow=false`)、无功能损失。修法 `whitespace-nowrap` 实测在 320px 引入 **+11px** 溢出(demand 347 > 320),七种 gap 收缩组合(C1–C6)在「320px + 长名」下**全部失败**(+19~+75px)——logo(77) + nowrap nav(138–158) + 截断名(96) 物理放不下,与缺口 A 争同一份空间。需设计决策(更短的名上限 / 汉堡菜单 / nav 缩写),**不入本批**,转独立决策项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 决策表
|
||||||
|
|
||||||
|
| # | 主题 | 定案 | 理由(实测依据) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D-A | `<summary>` 怎么截断 | **flex 三件**:`summary` 加 `flex max-w-[6rem] min-w-0 items-center gap-1`;名字包一层 `<span class="truncate min-w-0">`;`▾` 包一层 `<span class="shrink-0" aria-hidden="true">`;`summary` 加 `:title="user.display_name"` | spike 15 证伪了「运行时把名字节点包起来、▾ 留在外面」这条路:**Vue 把 `{{ user.display_name }} ▾` 编译成单个文本节点**,任何基于 `childNodes[0]` 的拆分都会把 `▾` 一起搬进截断 span(V2–V4 实测 `caret=HIDDEN`)。必须改模板显式拆两个 span。spike 16 六变体实测:F1(flex 无 cap)在 60 字名时 320px **+390**、375px 同样 ❌(纯 shrink 无效,summary 仍索取内容宽;375px 的具体 px 当时未归档,终审 N-F2 故删去原写的「+405」——该数无出处,且不承载任何结论:归档的「F1 仍 ❌」已足够);F2(7rem)320px **+7**;**F3(6rem)320/375px × {短名, 60字ASCII, 60字CJK} 六格全 `over=+0` 且 `caret=VIS`**;F4/F5(5/4rem)也过但 summary 更窄、无额外收益。6rem 同时是 spike 15 整体截断形态的实测赢家(`sumRight=311 ≤ 320`),两种形态上限一致 → 定 6rem。`title` 承载全名(鼠标悬停可读全文),避免截断丢信息。 |
|
||||||
|
| D-B | 包裹层为什么必须 `relative` | 五个表格的包裹层统一为 `class="relative overflow-x-auto pr-1 pb-1"` | **`relative` 不是装饰,是修 bug**:P9-B 为空「操作」列头补的 `<span class="sr-only">操作</span>` 是 `position:absolute` + `margin:-1px`,而 Tailwind 的 `sr-only` **不含 `top`/`left`** → 其包含块是最近的**已定位**祖先。`position:static` 的 `overflow-x:auto` 包裹层不构成包含块,该 span 逃出滚动裁剪,**独自**把文档撑宽:烧蚀实验(逐元素 `display:none` + 重测权威度量)定位到 `d=11 span.sr-only l=351 r=352 w=1`,短数据残差 `doc=352` = 其右边缘 352;长数据 `doc=1061` = 其右边缘 1061。加 `relative` 后残差归零(spike 12 W1 实测 `over=+0`;W2「th 加 relative」同样 `over=+0`,选 W1 因改动集中在一处新增元素而非逐个 `<th>`)。这也解释了 spike 9 的 `trueOffenders=0` 矛盾:offender 扫描把「有可滚动祖先」的元素当合法溢出筛掉了,而它恰恰逃出了容器——**边框盒扫描看不见它,只有烧蚀能**。 |
|
||||||
|
| D-C | 包裹层要不要留 padding | 留 `pr-1 pb-1`(4px) | 五个表格都带 `shadow-hard` = `4px 4px 0 #141414`(`main.css:20`)。overflow 裁剪发生在 **padding box**,无 padding 时 `roomBottom=0`(阴影被裁),`pr-1 pb-1` 时 `roomBottom=4` 恰好容下(spike 7 Q2 / spike 12 W3 对照实测)。Tailwind v4 `p*-1` = 0.25rem = 4px,与阴影偏移逐值相等。 |
|
||||||
|
| D-D | `v-else` 怎么处置 | **`v-else` 从 `<table>` 上移到包裹层** | 三个表格是 `<p v-if="空态">` + `<table v-else>` 的**相邻兄弟配对**(`AdminUsersView.vue:114-115`、`AdminView.vue:149-150`、`AdminAuditView.vue:59-60`)。把 `<table>` 包进 `<div>` 而不迁移 `v-else`,会使 `v-else` 失去配对对象 → Vue 编译报错或空态/表格逻辑错乱。另两个表格(`AdminView.vue:173/220`)无 `v-if` 兄弟,仅包裹、不涉 `v-else`。 |
|
||||||
|
| D-E | 排序 select 怎么改 | `ResultMeta.vue:22` 的 `relative shrink-0` → `relative min-w-0` | spike 7 四候选对照中唯一把 `/games` @320px 的 +3px 归零的就是它(候选 B);同排的 `shrink-0` 计数 `<p>`(`:20`)与「筛选」按钮实测**不是**驱动元素(候选 C/D 无效)。保留 `relative`(`PhCaretDown` 靠它绝对定位)。 |
|
||||||
|
| D-F | `dd` 怎么改 | `AccountView.vue:190` 的 `text-sm font-bold` → `text-sm font-bold min-w-0 wrap-anywhere` | `min-w-0` 解除 flex item 的 `min-width:auto` 下限(否则拒绝收缩),`wrap-anywhere`(`overflow-wrap:anywhere`)让不可断行串可在任意位置折行。已查包确认 Tailwind 4.3.3 **存在** `wrap-anywhere` 工具类(`node_modules/tailwindcss/dist/lib.js` 命中),非推断。**不用** `break-all`:它对 CJK/拉丁混排断词更粗暴,且本处只需「允许在任意点折行」而非「强制逐字断」。 |
|
||||||
|
| D-G | 可访问名怎么保住 | 不改任何 `aria-*`、`scope`、`sr-only` 结构;`▾` 的 span 加 `aria-hidden="true"` | `▾` 是纯装饰字形(既有代码里它就是文本节点的一部分,从未参与语义)。加 `aria-hidden` 后 `<summary>` 的可访问名从「用户名 ▾」变为「用户名」——**语义更准**(`▾` 不是名字的一部分),且 `e2e/ux.spec.ts:26` 的 locator `header details summary` 与断言(`toBeVisible` / 点击 / `details[open]` 计数)不依赖可访问名文本,实测不破。`e2e/ux.spec.ts:25` 的注释「details summary 的可访问名即用户名」在改动后**反而更准确**(原先含 `▾`),同批更新该注释措辞。 |
|
||||||
|
| D-H | 机器守卫怎么做 | 两层:① **e2e 视口守卫** `e2e/responsive.spec.ts`(进 CI 主腿);② **源码级守卫** `app/lib/tableOverflow.test.ts`(沿用 `tableScope.test.ts` 范式) | P11 终审 N6 的核心观察:**文档层防御密集但无机器守卫**,改回坏配置不会被任何测试抓住。本批把这个教训正面应用——D1/D2/D3 全部是「人眼看构建产物才会发现」的缺陷,正是 e2e 视口断言的用武之地。`playwright.config.ts` 的 `testIgnore: /noauth\.spec\.ts/` 意味着新增 `responsive.spec.ts` **自动进 CI `e2e` job**(`validate.yml:50` 跑 `npm run e2e`),无需改 CI 配置。源码级守卫按「每个 `<table>` 的**父元素**是否带 `overflow-x-auto`」判定,**不能**用全局 `overflow-x-auto` 计数 1:1 断言——`DocSidebar.vue:25` 已有一处用在 `<nav>` 上(实测基线 = 1 而非 0),全局计数会写错。 |
|
||||||
|
| D-I | 打磨项取舍 | 纳入 P9B-1/P9B-2/P9B-4、P11-N5/N6;**不纳入** P9B-3(已修)、P11 其余次要 notes(纯留档,无可动手改动) | 逐条判据见 §3.5。关键:P9B-2 与 P9B-4 都改既有断言覆盖的行为,须先证明**既有断言不破**再动手——P9B-2 的 `a11y.spec.ts:61` 在未评分态(`rated=false`)下断言 `评 3 星`,动态标签只在「已评且 i === score」时改写,故该断言路径不变;P9B-4 的六处 `toast-announce` 断言均不涉及「手动关掉最新一条」,新语义是**追加**而非改写。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 实现
|
||||||
|
|
||||||
|
### 3.1 `crearte/src/app/components/AppHeader.vue`(D-A / D-G / 缺口 E)
|
||||||
|
|
||||||
|
当前 `:101-102`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<details v-else ref="detailsRef" class="relative ml-auto sm:ml-0" @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">
|
||||||
|
<summary ref="summaryRef" class="list-none cursor-pointer select-none border-2 border-ink bg-surface px-2 py-1 text-xs font-bold [&::-webkit-details-marker]:hidden">{{ user.display_name }} ▾</summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
改为(`<details>` 一行**不动**,仅改 `<summary>` 及其内容):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<summary
|
||||||
|
ref="summaryRef"
|
||||||
|
:title="user.display_name"
|
||||||
|
class="flex max-w-[6rem] min-w-0 list-none cursor-pointer select-none items-center gap-1 border-2 border-ink bg-surface px-2 py-1 text-xs font-bold [&::-webkit-details-marker]:hidden"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{{ user.display_name }}</span>
|
||||||
|
<span class="shrink-0" aria-hidden="true">▾</span>
|
||||||
|
</summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ref="summaryRef"` 必须保留(`onDocKeydown` 的 Esc 回焦依赖它,见 `:26` `summaryRef.value?.focus()`)。`truncate` = `overflow:hidden` + `text-overflow:ellipsis` + `white-space:nowrap`,配合 `min-w-0` 才能在 flex 子项上生效。
|
||||||
|
|
||||||
|
### 3.2 `crearte/src/app/views/{AdminUsersView,AdminView,AdminAuditView}.vue`(D-B / D-C / D-D)
|
||||||
|
|
||||||
|
五处统一模式。**带 `v-else` 的三处**(`AdminUsersView.vue:115`、`AdminView.vue:150`、`AdminAuditView.vue:60`)——`v-else` 上移:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 改前 -->
|
||||||
|
<table v-else class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
|
||||||
|
…
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 改后 -->
|
||||||
|
<div v-else class="relative overflow-x-auto pr-1 pb-1">
|
||||||
|
<table class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
|
||||||
|
…
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**不带 `v-else` 的两处**(`AdminView.vue:173`、`AdminView.vue:220`)——仅包裹:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div class="relative overflow-x-auto pr-1 pb-1">
|
||||||
|
<table class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
|
||||||
|
…
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
表格开闭行号(供核对,改动会移动后续行号,以内容锚点为准):`AdminUsersView.vue` 115→161;`AdminView.vue` 150→165、173→214、220→239;`AdminAuditView.vue` 60→82。表格**内部**(`<thead>`/`<tbody>`/`<th scope>`/`data-testid`)一字不动——P9-B 的 `scope="col"` 与 sr-only「操作」必须原样保留(`a11y.spec.ts:52` 断言 `headers.nth(5)` 可访问名为 `操作`)。
|
||||||
|
|
||||||
|
### 3.3 `crearte/src/app/components/ResultMeta.vue`(D-E)
|
||||||
|
|
||||||
|
`:22` 单行改动(全文件仅此一处 `relative shrink-0`,唯一匹配):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 改前 --> <div class="relative shrink-0">
|
||||||
|
<!-- 改后 --> <div class="relative min-w-0">
|
||||||
|
```
|
||||||
|
|
||||||
|
该文件共 **4 处** `shrink-0`(元素级核准),只改 `:22` 这一处,**其余三处不动**:`:18` 计数 `<p class="shrink-0 text-[0.9375rem] font-extrabold" aria-live="polite">`、`:20` `<PhArrowsDownUp … class="hidden shrink-0 text-ink-soft sm:block" />`、`:43`「筛选」按钮 `class="lift flex shrink-0 items-center gap-1.5 …"`。spike 7 的四候选对照实测:只有移除 select 包裹层的 `shrink-0`(候选 B)能把 `/games` @320px 的 +3px 归零;针对计数 `<p>`(候选 C)与筛选按钮(候选 D)的改动均**无效**(over 仍 +3px),故不动它们。
|
||||||
|
|
||||||
|
### 3.4 `crearte/src/app/views/AccountView.vue`(D-F)
|
||||||
|
|
||||||
|
`:190` 单行改动:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 改前 --> <dd class="text-sm font-bold">{{ user.display_name }}</dd>
|
||||||
|
<!-- 改后 --> <dd class="min-w-0 text-sm font-bold wrap-anywhere">{{ user.display_name }}</dd>
|
||||||
|
```
|
||||||
|
|
||||||
|
同文件 `:194` 的用户名 `dd` 与其他 `dd` **不动**(用户名有 `maxlength="39"`,实测不溢出)。
|
||||||
|
|
||||||
|
### 3.5 打磨项(跨仓,各自独立)
|
||||||
|
|
||||||
|
**(a) P9B-1 — `crearte/src/app/router/index.test.ts:111`**
|
||||||
|
|
||||||
|
当前弱断言:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await expect(router.push('/docs')).resolves.not.toThrow()
|
||||||
|
```
|
||||||
|
|
||||||
|
它依赖 vue-router 5.3.1「`afterEach` 抛错不被吞」的语义,升级后若改为吞抛则**恒真**(假绿)。改为直接单测被测函数本身,绕开 router 版本语义:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 直接验证 focusMain 在 #main 缺失时静默 no-op(不经 router,故不依赖
|
||||||
|
// vue-router「afterEach 抛错不被吞」的版本语义——那会让断言退化为恒真)
|
||||||
|
expect(() => focusMain()).not.toThrow()
|
||||||
|
```
|
||||||
|
|
||||||
|
并从 `@/router` 补 `focusMain` 到既有 import。保留其后的 `expect(document.title).toBe('文档 · crearte 创艺')`(标题职责仍经 router 验证)。
|
||||||
|
|
||||||
|
**(b) P9B-2 — `crearte/src/app/components/GameReactions.vue:105`**
|
||||||
|
|
||||||
|
当前每颗星静态 `:aria-label="`评 ${i} 星`"`,读屏用户听「评 4 星」无法预知「再点同一颗星会撤评」(该控件支持点当前分取消)。改为按状态动态:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:aria-label="rated && i === score ? `已评 ${i} 星,点击取消评分` : `评 ${i} 星`"
|
||||||
|
```
|
||||||
|
|
||||||
|
`rated`/`score` 均为既有 ref(`:16-17`,由 `apply(view)` 从服务端 `ReactionView` 全量替换,`:44-51`)。**既有断言不破**:`e2e/a11y.spec.ts:61` 在 `seedSession(page,'user')` + 夹具无个人评分下走 `rated=false` 分支,可访问名仍是 `评 3 星`。新增单测须覆盖两态(未评 → `评 N 星`;已评 N → `已评 N 星,点击取消评分`;已评 M≠N → `评 N 星`)。**不改** `role="group"` 的 `:aria-label`(`:98`,P9-B 已定稿)与星形字形 `aria-hidden`。
|
||||||
|
|
||||||
|
**(c) P9B-4 — `crearte/src/app/components/ToastHost.vue:22-28`**
|
||||||
|
|
||||||
|
当前 watch 源是**数组末尾条目的 id**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
watch(
|
||||||
|
() => toasts.value[toasts.value.length - 1]?.id,
|
||||||
|
(id) => {
|
||||||
|
announcement.value =
|
||||||
|
id === undefined ? '' : (toasts.value[toasts.value.length - 1]?.text ?? '')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
饱和态(`MAX_VISIBLE=3`)下 push 会「逐最旧 + append」,长度 3→3 不变而末尾 id 变,故 P9-B 特意以 id 为源(这个选择是对的,**保留**)。副作用是:**手动关掉最新一条**时末尾 id 回退到较旧条 → watch 触发 → 播报区**重念一条仍在屏上的旧消息**(自动过期不触发,因为过期的是最旧条)。修法:只在 id **单调变新**时播报,并记住已播报的最大 id:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const announcement = ref('')
|
||||||
|
// 已播报的最大 id:useToast 的 nextId 单调递增,故「比它大」即「新消息」。
|
||||||
|
// 手动关掉最新一条会让数组末尾 id 回退到较旧条,若不比较大小就会重念一条
|
||||||
|
// 仍在屏上的旧消息(P9B-打磨4)。
|
||||||
|
let announcedId = 0
|
||||||
|
watch(
|
||||||
|
() => toasts.value[toasts.value.length - 1]?.id,
|
||||||
|
(id) => {
|
||||||
|
if (id === undefined) {
|
||||||
|
// 全部消失:清空播报,但不重置 announcedId(后续新消息 id 必然更大)
|
||||||
|
announcement.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (id <= announcedId) return
|
||||||
|
announcedId = id
|
||||||
|
announcement.value = toasts.value[toasts.value.length - 1]?.text ?? ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**六处既有断言均不破**(逐一核对 `ToastHost.test.ts`):`:49-53` 常驻空播报(初始 `announcement=''`);`:61` push 后等于该消息(id 1 > 0);`:74-78` 消失后清空(`id===undefined` 分支);`:98` 饱和态播报第 4 条(id 4 > 3);`:106` 播报节点内零交互控件(结构未变)。新增单测须覆盖「三条并存 → 关掉最新 → 播报文字**不变**(不重念旧消息)」。注意 `announcedId` 是模块内闭包变量,而 `toasts` 是 `useToast` 的模块级单例 ref——测试间须用既有 `__resetToasts()`(`useToast.ts:38`)隔离;若 `announcedId` 残留导致跨测试污染,改为把它也挂到 store 侧或在 `__resetToasts` 里一并复位(实现者按实测选择,并在测试注释里写明)。
|
||||||
|
|
||||||
|
**(d) P11-N5 — `crearte-server/src/internal/config/config_test.go`**
|
||||||
|
|
||||||
|
P11 T3 加了归一化,但「错误消息回显归一化后的值」无钉桩。在 `TestApplyLogging` 的 bogus 段(`:264` 附近,`LOG_FORMAT` bogus 之后)补:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 归一化后的错误消息须回显归一化值(P11-N5):运维看到 " BOGUS " 原样
|
||||||
|
// 会以为是空格问题,回显 "bogus" 才指向真正的白名单不匹配。
|
||||||
|
t.Setenv("LOG_LEVEL", " BOGUS ")
|
||||||
|
err := applyLogging(&cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid LOG_LEVEL must fail after normalization")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "bogus") {
|
||||||
|
t.Errorf("error = %q, want it to echo the normalized value %q", err, "bogus")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), " BOGUS ") {
|
||||||
|
t.Errorf("error = %q, must not echo the raw un-normalized value", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
须先 `grep -n "\"strings\"" internal/config/config_test.go` 确认 import;缺失则补。先读 `applyLogging` 现有错误文案,确认它确实回显归一化值(P11 T3 报告称是)——若实测不回显,则本项从「补测试」变为「补实现 + 测试」,须在报告里说明。
|
||||||
|
|
||||||
|
> **勘误(2026-10-02,终审 N-F1)**:上方片段的第一条断言写的是裸子串 `strings.Contains(err.Error(), "bogus")`。审查 note SD-N1 指出它有理论盲区:若实现改为回显**小写但未 trim** 的 `" bogus "`,裸子串断言与下方大小写敏感的原样形式检查**会同时放过**。落地的 `config_test.go`(server `fa39d27`)已收紧为 `%q` 渲染出的**带引号形式** `` `"bogus"` ``,一次钉住 trim 与 lower 两个性质。终审已用 mutation 独立证实两条断言对「回显原始 env」同时开火。**不要按上方片段把它弱化回裸子串。**
|
||||||
|
|
||||||
|
**(e) P11-N6 — `crearte-deploy/.github/workflows/validate.yml`**
|
||||||
|
|
||||||
|
在 `compose` job 的「compose parses under every profile」step 之后新增一个 step,把 P11 D-A′ 的**空默认**变成机器守卫(终审 N6:现无任何自动化测试能抓住 compose 文件被改回 subnet 信任):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: TRUSTED_PROXIES defaults empty (P11 D-A' guard)
|
||||||
|
env:
|
||||||
|
POSTGRES_PASSWORD: "***"
|
||||||
|
MINIO_ROOT_USER: ci
|
||||||
|
MINIO_ROOT_PASSWORD: "***"
|
||||||
|
AUTH_TOKEN_SECRET: "***"
|
||||||
|
BUNDLE_KEK_k1: ci-not-a-real-secret
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# 空默认是安全默认:信任整个 compose 网段会把 docker 网桥网关划进信任范围,
|
||||||
|
# 使客户端预置的 X-Forwarded-For 被采纳(限流可自选桶绕过)。
|
||||||
|
# 详见 README「客户端 IP 解析与限流」。
|
||||||
|
rendered="$(docker compose --profile prod config)"
|
||||||
|
# 未设 .env 时必须解析为空字符串(config 输出形如 TRUSTED_PROXIES: "")
|
||||||
|
echo "$rendered" | grep -q 'TRUSTED_PROXIES: ""' \
|
||||||
|
|| { echo 'TRUSTED_PROXIES must default to empty — see README' >&2; exit 1; }
|
||||||
|
# 且不得出现网段信任(172.28.0.0/24 或任何 COMPOSE_SUBNET 派生)
|
||||||
|
if echo "$rendered" | grep -Eq 'TRUSTED_PROXIES: "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||||
|
echo 'TRUSTED_PROXIES must not default to a CIDR (docker SNAT makes the bridge gateway trusted)' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# 覆盖仍须生效(管线未断)
|
||||||
|
TRUSTED_PROXIES=10.0.0.0/8 docker compose --profile prod config \
|
||||||
|
| grep -q 'TRUSTED_PROXIES: "10.0.0.0/8"' \
|
||||||
|
|| { echo 'TRUSTED_PROXIES override pipeline broken' >&2; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
> **勘误(2026-10-02,审查 SD-D1 定案)**:上方字面片段的断言②③模式写的是**带引号**形式(`TRUSTED_PROXIES: "[0-9]+…`、`TRUSTED_PROXIES: "10.0.0.0/8"`),但 compose 实测只对**空串**加引号,非空标量**裸渲染**(`TRUSTED_PROXIES: 10.0.0.0/8`)。照抄字面片段会同时产出:②**恒不匹配的死断言**(改回 CIDR 默认也放行 = 假信心)与③**恒红的假警**(正向 CI 必炸)。落地的 `validate.yml`(deploy `17748bf`)已改为兼容裸标量的 ` *[0-9]` / ` *10\.0\.0\.0/8` 并渲染到文件复用,三态实跑验证(正向绿 / 注入 CIDR 红 / 删注入行红)。**不要按字面片段「修回去」。**
|
||||||
|
|
||||||
|
注意 `paths:` 过滤器已含 `docker-compose.yml` 与 `.github/workflows/validate.yml`,无需改触发条件。**本地无法跑 GitHub Actions**,故本项的验证方式是:把 `run:` 块内容作为脚本在本地对 `docker compose --profile prod config` 实跑一遍(含覆盖分支与「故意改坏 → 应失败」的反向验证),并在报告里贴输出。反向验证必须做——只证明「当前通过」不证明守卫有牙(P11 mutation 抽查同理)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 边界(不做的事)
|
||||||
|
|
||||||
|
- **不动 D4**(nav 逐字换行):外观级、无溢出、修法在 320px 与缺口 A 争空间,需独立设计决策。
|
||||||
|
- **不引入汉堡菜单 / 不改 nav 结构**:勘查已证伪其必要性。
|
||||||
|
- **不动 CSP / HSTS / TLS**:P11 已明确挂账为独立批(CSP 需设计——游玩子域经 iframe+SW 加载用户作品;HSTS 待 TLS)。
|
||||||
|
- **不动后端任何行为**:`maxDisplayNameLen=60` 是既有合法契约,本批不改校验规则(改短会破坏已有账号数据)。缺口 A 是**前端渲染**未防御合法输入,修在前端。
|
||||||
|
- **不改 `sr-only` 的 Tailwind 定义或 P9-B 的 `scope="col"` 结构**:D-B 的 `relative` 是在**包裹层**上补包含块,不动 sr-only 本身(它是全仓通用工具类,改它波及面不可控)。
|
||||||
|
- **不加 `overflow-x-hidden` 到 body/html**:那会把溢出**藏起来**而非修掉,且会裁掉 `sticky` 页头与 `shadow-hard`。缺口必须真消除(实测 `scrollWidth == clientWidth`),不是视觉遮盖。
|
||||||
|
- **不动 `AdminView.vue` 的三表内部结构**(列数、`data-testid`、按钮文案):只加包裹层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试计划
|
||||||
|
|
||||||
|
### T1(AppHeader,缺口 A/E)
|
||||||
|
1. 既有 `app/components/AppHeader.test.ts` 六个测试全绿(它们用 `w.get('summary')` / `w.get('details')` / `w.get('details a')`,与 summary **内部**结构无关;`:23` 的 mock `display_name: 'tester'`)。
|
||||||
|
2. 新增单测:60 字 ASCII 名下 ① `<summary>` 有 `title` 属性且值为全名;② 名字 span 带 `truncate`、`▾` span 带 `aria-hidden="true"`;③ summary 的可访问名不含 `▾`。
|
||||||
|
3. e2e:`e2e/responsive.spec.ts`(见 T4)覆盖 320/375px × 60 字名 × 6 路由零溢出 + 菜单不逃逸。
|
||||||
|
|
||||||
|
### T2(三视图五表格,缺口 D)
|
||||||
|
1. 既有 `e2e/a11y.spec.ts:50/52` 的 `columnheader` 断言全绿(`headers` 计数仍 6、`nth(5)` 可访问名仍 `操作`)——`getByRole('columnheader')` 不受包裹层影响,须实跑确认。
|
||||||
|
2. 既有 `e2e/admin-flow.spec.ts` 全绿(`queue-sub-p1` 等 `data-testid` 定位不变)。
|
||||||
|
3. 新增源码级守卫 `app/lib/tableOverflow.test.ts`:扫 `app/**/*.vue`,对每个 `<table` 起始标签,向上取**同一文件文本内**最近的开标签父元素,断言其 `class` 含 `overflow-x-auto`;违规报 `文件:行号: 行内容`。沿用 `tableScope.test.ts` 的 `walk()`/`candidates()`/跨行起始标签截取范式。**不得**用全局 `overflow-x-auto` 计数断言(`DocSidebar.vue:25` 的 `<nav>` 是合法既存用例,基线 = 1)。
|
||||||
|
4. e2e:短数据下 `/admin/users`、`/admin`、`/admin/audit` 在 320/375px 零溢出,且表格**仍可横向滚动到**(断言包裹层 `scrollWidth > clientWidth` 时内容可达——修掉溢出不能靠藏内容)。
|
||||||
|
|
||||||
|
### T3(ResultMeta + AccountView,缺口 B/C)
|
||||||
|
1. 既有 `e2e/author-page.noauth.spec.ts:12` 的 `${count} 款作品` 断言全绿(只改类名,不动文本)。
|
||||||
|
2. 既有 `app/views/CatalogView.test.ts` 全绿。
|
||||||
|
3. e2e:`/games` @320px 零溢出;`/account` 在 60 字名下 320/375/768px 零溢出。(**归因更正**:spike 17 2×2 消融实测,768px 的 +40px 由缺口 A 页头驱动、非本项 dd——回退 dd @768 仍 `over=+0`;dd 修复的牙在 320/375px,回退 dd @320 → `scrollWidth=592`。故 768 腿钉的是**页头回归**,320/375 腿才是本项 dd 的牙口。)
|
||||||
|
|
||||||
|
### T4(机器守卫,D-H)
|
||||||
|
1. 新增 `e2e/responsive.spec.ts`,**必须进 CI 主腿**(`playwright.config.ts` `testIgnore: /noauth\.spec\.ts/` 不含它):
|
||||||
|
- 视口 320 / 375;名字形态 短名(`安`)/ 60 字 ASCII / 60 字 CJK;登录态 登出 / user / admin。
|
||||||
|
- 路由 `/`、`/games`、`/docs`、`/creator`、`/login`、`/register`、`/account`、`/admin/users`、`/admin`、`/admin/audit`(后四个需 admin seed + 最小 mock,沿用 `a11y.spec.ts:26` 与 `admin-flow.spec.ts:30-56` 的端点范式;表格由 `v-else` 门控,**mock 必须返回至少一条数据**否则 `<thead>` 不渲染、守卫空跑)。
|
||||||
|
- 每格断言 `document.documentElement.scrollWidth <= clientWidth + 1`。
|
||||||
|
- 另断言:60 字名下 `header details > div` 菜单 `right <= innerWidth`(缺口 E 钉桩);`<summary>` 的 `▾` 仍**可见**(防止将来有人用「整体 truncate」把 caret 吞掉——spike 15 实测那条路 `caret=HIDDEN`)。
|
||||||
|
- `seedSession` 的 `display_name` 硬编码为 `role`(`helpers.ts:37`),长名场景需**扩展可选参数**(`seedSession(page, role, displayName?)`,默认值保持 `role` 以免动既有 21 个 spec)或在 `responsive.spec.ts` 内自带 seed——二选一,实现者定,但**不得**改既有调用点的行为。
|
||||||
|
- 组合数控制:不必跑满 3×3×10 全矩阵(会拖慢 CI)。至少覆盖 {320,375} × {60字ASCII, 短名} × {全部 10 路由} + {375} × {60字CJK} × {`/`, `/account`, `/admin/users`}。总时长目标 ≤ 90s(参考:既有主 e2e 81 测试 1.1min)。
|
||||||
|
2. 守卫**必须有牙**:实现者须在本地临时 revert 任一修复(如把 `relative` 去掉、或把 `max-w-[6rem]` 去掉),确认 `responsive.spec.ts` / `tableOverflow.test.ts` 变红,再恢复;报告里贴红/绿两次输出。这是 P11 mutation 抽查纪律的落地。
|
||||||
|
|
||||||
|
### T5(打磨项)
|
||||||
|
1. (a) `router/index.test.ts` 全绿,且新断言不经 router(版本无关)。
|
||||||
|
2. (b) `GameReactions.test.ts` 新增两态可访问名断言;既有 `e2e/a11y.spec.ts:61` 不破(实跑确认)。
|
||||||
|
3. (c) `ToastHost.test.ts` 既有六处断言全绿 + 新增「关掉最新条不重念旧消息」;`__resetToasts()` 隔离实测有效。
|
||||||
|
4. (d) server `go test ./internal/config/ -run TestApplyLogging -v` 绿;`gofmt -l .` 空、`go vet ./...` 0。
|
||||||
|
5. (e) deploy 守卫脚本本地实跑三态:当前通过 / `TRUSTED_PROXIES=172.28.0.0/24` 注入 `.env` 时**失败** / 覆盖 `10.0.0.0/8` 时通过。四 profile `config -q` 仍全绿。
|
||||||
|
|
||||||
|
### 全量回归(合并前必跑)
|
||||||
|
- crearte:`npm run test`(vitest,基线 **626** + 本批新增)、`npm run typecheck`、`npm run build`、`npm run build:runtime`、`npm run e2e`(基线 **80+1skip** + 新增 responsive)、`npm run e2e:noauth`(基线 **4**)。**脚本名先查 `package.json`**——P11 曾把 `test`/`typecheck` 写成 `test:unit`/`type-check` 配 `--if-present` 导致假绿。
|
||||||
|
- server:容器化 `gofmt -l .` / `go vet ./...` / `go build ./...` / `go test ./... -count=1`(基线 11 包 ok)。
|
||||||
|
- deploy:四 profile `config -q` + 卷名集合与基线逐字相同。
|
||||||
|
- 管道后取退出码一律 `set -o pipefail` 或不管道(P11 三度踩坑)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CHANGELOG 与版本
|
||||||
|
|
||||||
|
- `crearte`:**0.25.0**(Fixed 段记缺口 A/B/C/D/E 四缺陷 + 下拉菜单逃逸;Added 段记两层机器守卫;Changed 段记打磨三项 P9B-1/2/4)。
|
||||||
|
- `crearte-server`:**0.17.1**(Tests 段记 P11-N5 错误消息回显钉桩;纯测试,patch 级)。
|
||||||
|
- `crearte-deploy`:**0.7.1**(CI 段记 P11-N6 `TRUSTED_PROXIES` 空默认机器守卫;纯 workflow,patch 级)。
|
||||||
|
- wrapper:**0.3.5**(Done 段记 P12 交付;ROADMAP P12 行 + 文档索引表补 spec/plan 两行;同时把「移动端页头/汉堡菜单」挂账标注为**已证伪删除**、D4 标注为 **park 待设计决策**)。
|
||||||
|
|
||||||
|
条目格式沿用 AGENTS.md:同条目英文行紧跟中文行(无空行),不同条目间空行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 证据存档
|
||||||
|
|
||||||
|
`crearte/.superpowers/sdd-p12/`(`.gitignore` 已含 `.superpowers/`,不入库):
|
||||||
|
|
||||||
|
- `FINDINGS-survey.md` — **16 轮 spike 的实测输出固化**:四轮基线审计与「移动端页头」证伪、D1a 发现与范围扩张(三渲染点)、D3 独立性证明(短数据)、修法候选对照(含两个实验缺陷:Q1 误抓 sr-only `<h2>`、Q2 被 D1 污染)、残差归因三轮(矛盾 → 逐层探测 → **烧蚀定案 sr-only span**)、H1/H2 机制验证 + H3 24 格全绿矩阵、D4 七候选全灭 → park、D1a 精确形态定案(Vue 单文本节点导致 V2–V4 失效 → flex F3 胜出)。每个数字都有出处。
|
||||||
|
- 本 spec §1/§2 的所有实测值均引自该文件;spike 源文件跑完即删(工作树 `porcelain=0` 已核),不入库。
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
# P13 设计:暗色模式(Dark Mode)
|
||||||
|
|
||||||
|
- **状态**:已定稿,待实现
|
||||||
|
- **日期**:2026-10-03
|
||||||
|
- **批次**:P13(UX 第三批 / 挂账清偿)
|
||||||
|
- **范围**:仅 `crearte`(前端)。**零后端改动、零部署改动**——主题纯粹是客户端表现层。
|
||||||
|
- **依据**:`.superpowers/sdd-p13/FINDINGS-survey.md`(7 轮 spike 实测归档,gitignored)。本 spec 的**每个数字都出自该文件**,不得凭推断增删。
|
||||||
|
- **前置**:P12 已闭合(crearte `e59a171`)。本批**必须保持 P12 的 12 条 responsive 守卫腿与 `AppHeader.test.ts` 的 F3 形态钉桩全绿**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 目标与非目标
|
||||||
|
|
||||||
|
**目标**:为全站提供暗色主题,满足 WCAG 2.1 AA(正文 ≥4.5、大字号 ≥3.0、非文本 1.4.11 ≥3.0),保留 neo-brutalist 的「墨与纸 + 硬阴影」设计身份,并把配色约束从口头纪律变成**双主题可执行守卫**。
|
||||||
|
|
||||||
|
**非目标**:不做 SSR/预渲染、不做 per-route 主题、不做用户自定义配色、不改品牌标识(logo 与 wordmark 的形态与配色语义)、不动 `COVER_COLORS` 生成色板(实测主题无关,见 FINDINGS 第 0 轮)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现状(全部实测,非推断)
|
||||||
|
|
||||||
|
### 1.1 令牌架构支持运行时翻转(方案前提,spike 0)
|
||||||
|
|
||||||
|
Tailwind v4 把色令牌输出到 `:root,:host{--color-paper:#f7f2e7;…}`,而 utility **引用 var 而非内联 hex**:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.bg-paper{background-color:var(--color-paper)}
|
||||||
|
.text-ink{color:var(--color-ink)}
|
||||||
|
.border-ink{border-color:var(--color-ink)}
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物 `dist/assets/main-*.css`(32726 B)实测:`var(--color-*)` 引用 **64 处**;内联 hex 仅 `#141414` **11 处**(5 个 `--shadow-hard*` 令牌 + wordmark 关键帧)、`#e8552f` 3 处、`#f7f2e7` 2 处、`#f5c518` 1 处、`#ffffff` 0 处。
|
||||||
|
|
||||||
|
→ **运行时令牌翻转方案成立**;需要处理的是那 11+3 处内联 hex(阴影令牌,见 D-D)与三处组件级硬编码(见 §1.3)。
|
||||||
|
|
||||||
|
### 1.2 硬编码破面(暗色下不会翻转的地方)
|
||||||
|
|
||||||
|
| # | 位置 | 现状 | 暗色下的后果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `main.css:19-23` 五个 `--shadow-hard*` | 内联 `#141414` ×3 / `#e8552f` ×2 | 硬阴影在深底上**隐形** → neo-brutalist 身份丢失 |
|
||||||
|
| 2 | `main.css:44` `html { color-scheme: light }` | 硬编码 light | 原生控件(`<select>`、`<input>`、滚动条)不翻转 |
|
||||||
|
| 3 | `StatePanel.vue:19,20,21,30,32,33,34` | `bg-[#EFE9DA]` ×7 | 骨架屏在暗色下是**七块亮色斑** |
|
||||||
|
| 4 | `FilterDrawer.vue:32` | `backdrop:bg-ink/60` | 见 §1.4,遮罩**泛白** |
|
||||||
|
| 5 | `index.html:7,8` | `color-scheme` / `theme-color` meta 硬编码 light / `#F7F2E7` | 浏览器 UI 与首屏不跟随 |
|
||||||
|
|
||||||
|
### 1.3 配色配对的真实站点分布(决定方案选型)
|
||||||
|
|
||||||
|
grep 实证的配对用量(**这是方案 B 胜出的依据**):
|
||||||
|
|
||||||
|
| 配对 | 站点数 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `bg-highlight` + 文字为 `ink`(显式或继承) | **29** | 8 处 error alert、markdown `strong`、`::selection`、toast info、FilterSidebar/DocSidebar/BaseSelect/BaseTabs 选中态、GameCard 角标、AppHeader sticker、skip-link |
|
||||||
|
| `bg-accent-ink` + `text-paper` | **11** | badge / 按钮 / error toast |
|
||||||
|
| `bg-success` + `text-paper` | 1 | toast success |
|
||||||
|
| `bg-ink` + `text-paper` | 多处 | `.btn-ink`、markdown `th`、logo |
|
||||||
|
| `text-accent-ink` on paper | 多处 | 链接 |
|
||||||
|
| `.wordmark-label` = `accent-ink` on `highlight` | 1 | 品牌标签 |
|
||||||
|
| `bg-accent` + `text-ink` | **1** | `ResultMeta.vue:52` 折叠角标(`app/` 下唯一把 accent 当背景用的地方) |
|
||||||
|
| `bg-accent` + `text-paper` | **1** | ⚠️ **终审 N1 补录(2026-10-03)**:`runtime/host/GameHost.vue:39` 的「已降级外链」徒标(渲染于 `:90`,11px bold)。spike 0 的 grep 只扫了 `app/`、**漏了 `runtime/`**(GameHost 与 `app` 同级,经 `app/views/GameView.vue:120 <GameHost>` 在 SPA 路由内、受暗色块作用域)。亮色 `paper on accent` = **3.2590 < 4.5 FAIL**(暗色 7.1218 PASS)——**P9-B 以来就存在的 pre-existing 违规,P13 未使其变差**,且不在 P13 范围(P13 是暗色主题,非修所有既有亮色 WCAG)。已登记挂账(可访问打磨批);`contrast.test.ts` 的 AA_PAIRS **不含** `paper on accent`,故该配对目前无守卫 |
|
||||||
|
| `bg-info` | **0** | info 令牌无背景用途(`text-info` 亦 0) |
|
||||||
|
|
||||||
|
### 1.4 暗色下的两个**必然**缺陷(若照抄亮色语义)
|
||||||
|
|
||||||
|
**① 亮色块上的深字失效。** 暗色下 `ink` 变浅(`#f7f2e7`),若 `highlight` 仍是亮黄 `#f5c518`,则 `ink on highlight` = **1.46 ❌**(29 处站点全废)。实测反面:`ink on accent` 暗色 = **2.31 ❌**(亮色侥幸 5.06 ✅)。
|
||||||
|
|
||||||
|
**② 遮罩泛白。** `backdrop:bg-ink/60` 的产物是 `color-mix(in oklab, var(--color-ink) 60%, transparent)`,暗色下实测解析为 **`oklab(0.962022 0.00101227 0.0155178 / 0.6)`** —— L≈0.96 即近白,遮罩失去遮罩功能。
|
||||||
|
|
||||||
|
### 1.5 header 像素预算(主题开关的落点约束,spike 1–3)
|
||||||
|
|
||||||
|
P12 spike 17 已测得 @320px 登录态 summary 右缘 = 311px。本轮实测 baseline 几何(@320px 登录态 ascii60 `/account`):
|
||||||
|
|
||||||
|
```
|
||||||
|
inner=288 a(logo)=77 + nav=74 + details=96 + gaps(24×2)=48 = 295 > 288 slack = −7
|
||||||
|
```
|
||||||
|
|
||||||
|
即**该格已无收缩余量**。注入 32px 开关后 `docOverflow = +47` ❌(P12 的 12 条守卫腿会全红)。四格实测:
|
||||||
|
|
||||||
|
| 格 | 注入 32px 开关后 docOverflow |
|
||||||
|
|---|---|
|
||||||
|
| @320 登出态 | ✅ 0(flex 收缩吸收) |
|
||||||
|
| @320 登录态 ascii60 | **❌ +47** |
|
||||||
|
| @375 登出态 | ✅ 0 |
|
||||||
|
| @375 登录态 ascii60 | ✅ 0 |
|
||||||
|
|
||||||
|
**唯一失败格 = @320px + 登录态 + 60 字长名**(正是 P12 守卫矩阵的最坏格)。
|
||||||
|
|
||||||
|
**关键约束**:`AppFooter.vue` 实测**无任何导航**(全文只有两行文字 span)。→「移动端隐藏 header nav」会让手机用户彻底失去导航,**不可行**。
|
||||||
|
|
||||||
|
### 1.6 守卫的既有缺陷(P12「守卫自身要受同等审视」同类)
|
||||||
|
|
||||||
|
`app/lib/contrast.test.ts` 的 `parseTokens()` 用**单个全局 Map + 全局正则**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const re = /--color-([\w-]+):\s*(#[0-9a-fA-F]{6})/g
|
||||||
|
for (const m of css.matchAll(re)) map.set(m[1], m[2])
|
||||||
|
```
|
||||||
|
|
||||||
|
加入暗色块后 `matchAll` 会同时命中 `@theme`(亮)与 `html[data-theme="dark"]`(暗),`map.set` **后写覆盖** → 九个 `REQUIRED_KEYS` 全解析成暗色值,而断言标签仍写「light palette」→ 守卫**静默变成只守暗色、完全不守亮色**。
|
||||||
|
|
||||||
|
这是**假信心**,与 P12 的「注释掉包裹层守卫仍绿」、P11-N6 的「恒不匹配的死断言」同一类。必须在加暗色块**之前**重构(见 T1)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 决策表
|
||||||
|
|
||||||
|
| # | 决策 | 选型 | 依据(FINDINGS 轮次) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D-A** | 配色策略 | **方案 B:亮块反向翻转**。文字恒用 `ink`/`paper`(随主题翻),语义块反向翻转以维持对比 | 第 7 轮。迁移 **3 处** usage vs 方案 A(新增 `on-bright` 令牌)的 **32+ 处**;29 处 `bg-highlight`、12 处 `bg-accent-ink text-paper`、wordmark、`::selection`、`strong`、toast、`.btn-ink` **全部零改动** |
|
||||||
|
| **D-B** | 暗色调色板 | 见 §3.1 全量 12 令牌 | 第 7 轮:双主题 **12 条真实配对全 PASS**,零 FAIL(⚠️ 原写 14 为计数笔误,终审 N5:§5-T1.3、FINDINGS 第 7 轮配对表、代码 AA_PAIRS 三方一致为 **12**) |
|
||||||
|
| **D-C** | 暗色块选择器 | **必须 `html[data-theme="dark"]`**(特异性 (0,1,1))。**禁用**裸 `[data-theme="dark"]` | 第 4 轮:Tailwind 输出到 `:root,:host`((0,1,0))。裸属性选择器同特异性,实测虽生效但**靠源码顺序取胜**,Tailwind 层顺序一变即失效 |
|
||||||
|
| **D-D** | 阴影令牌 | 五个 `--shadow-hard*` 的内联 hex → `var(--color-ink)` / `var(--color-accent)`。**无需** per-utility 暗色覆盖规则 | 第 6 轮决定性实测:源码改 var + **真重建**后,暗色下 `.shadow-hard` 的 `box-shadow` 实测变 `rgb(247,242,231)`、`shadow-hard-accent` 变 `rgb(255,122,77)` → `--tw-shadow` 保留 var 引用,硬阴影自动跟随 |
|
||||||
|
| **D-E** | scrim | 新增 `--color-scrim: #0d0b08`,**两主题同值、不参与翻转**;`FilterDrawer` 的 `backdrop:bg-ink/60` → `backdrop:bg-scrim/80` | 第 4 轮:暗色下 `color-mix(...var(--color-ink)...)` 解析为 `oklab(L=0.962)` **泛白** |
|
||||||
|
| **D-F** | scrim 分离判据 | 分离由哪一侧提供是**主题相关**的,故守卫**按主题钉实际分离侧**:亮色 = **填充侧** `paper`(17.60;边框侧 `ink` 仅 1.07)、暗色 = **边框侧** `ink`(17.60;填充侧 `paper` 仅 1.07,分离靠 `border-t-[3px] border-ink`)。两侧阀值均 ≥ 3 | 第 7 轮 + **审查 re-pin**。暗色钉边框侧与本设计语言一致(实测亮色 `surface(#ffffff) vs paper(#f7f2e7)` 仅 **1.1165**、暗色 `surface(#221e18) vs paper(#17140f)` 仅 **1.1080**,而卡片边界仍清晰,靠 2px ink 边框)。⚠️ 原写「P12 已实测…仅 **1.06**」不可复现且 P12 文档查无出处(终审 N7)——控制者全仓 `grep -F '1.06'` 仅命中本 spec 与 FINDINGS 自己,实测值实为 1.1165,故已纠正;该数字不影响 D-F 决策方向(1.06 还是 1.12 都 <3,都靠边框分离)。⚠️ **不得写成 `max(填充侧, 边框侧) ≥ 3`**:两侧互补,对任意 scrim 色 c 都有 `max(cr(paper,c), cr(ink,c)) ≥ √cr(paper,ink)` = √16.50 = **4.0621 > 3** → **数学恒真、永不可能失败**(node 暂力验证 50653 个采样色,亮色最小 4.0621 @#eb0541、暗色 4.0560 @#0f78d2)。实证:把亮色 scrim 改成纯白(正是 D-E 要防的泛白),填充侧 `paper vs 白` = **1.1165**(面板与遮罩真的不可辨)但 max() = 18.42 → 守卫仍绿 |
|
||||||
|
| **D-G** | skeleton | 新增 `--color-skeleton`(亮 `#EFE9DA` / 暗 `#2c2721`),`StatePanel.vue` 7 处硬编码 → `bg-skeleton` | 第 0/7 轮。装饰性(无 WCAG 要求)但须可见:vs paper 亮 1.08 / 暗 1.24 |
|
||||||
|
| **D-H** | 守卫重构 | `contrast.test.ts` 改为**按块解析**(亮/暗各自 Map、各自断言),RED 先行 | §1.6。不重构则守卫静默失效 |
|
||||||
|
| **D-I** | 开关落点 | header 行 `gap-6` → **`gap-2 sm:gap-6`**,nav `gap-4` → **`gap-2 sm:gap-4`**,开关 32px(`h-8 w-8`)追加在行尾 | 第 3 轮候选 B:最坏格 **margin=16**(= 容器 `px-4` 右内边距,内容恰好填满 inner box)。**不触碰 `max-w-[6rem]`** → P12 F3 钉桩与 12 条守卫腿原样保绿;不动 logo、不动 nav 结构;`sm:`(640px) 以上零视觉变化 |
|
||||||
|
| **D-J** | 主题状态模型 | **两态**(`light` ↔ `dark`);**首次访问跟随 `prefers-color-scheme`**;用户显式点击后持久化到 `localStorage['crearte.theme.v1']` | §4 边界:第三态「恢复跟随系统」本批**有意不做**(spike 3 证明 header 无像素容纳带标签的控件;放 footer 会把同一控件拆到两处) |
|
||||||
|
| **D-K** | 防 FOUC | `index.html` `<head>` 内**内联 pre-paint 脚本**,在 CSS 之前设置 `data-theme` 与 `theme-color` meta | 第 4 轮:SPA 首屏在 Vue 挂载前就会绘制,若等 composable 则暗色用户每次刷新闪白 |
|
||||||
|
| **D-L** | `color-scheme` | `main.css` 的 `html { color-scheme: light }` → 由 `[data-theme]` 驱动(亮 `light` / 暗 `dark`) | §1.2-2。原生控件与滚动条须随主题 |
|
||||||
|
|
||||||
|
### 2.1 被否方案(附实测理由,防止后人重提)
|
||||||
|
|
||||||
|
| 方案 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **方案 A:新增 `on-bright` 令牌**(亮块两主题都保持亮,文字恒深) | 须迁移 **29 处** `bg-highlight` + wordmark + `::selection` + `strong`(第 7 轮)。方案 B 只需 3 处,且 B 的语义更简单(文字恒 `ink`/`paper`) |
|
||||||
|
| **暗色 highlight 深化到 `#5c430c` / `#4d380a`** | `ink/hl` 高达 8.31/9.98,但 `hl vs paper` 仅 1.98/1.65 → **选中态与页面底色几乎同色**,FilterSidebar/DocSidebar/BaseSelect/BaseTabs 的选中态视觉消失(第 7 轮) |
|
||||||
|
| **暗色 highlight 取 `#96701a`** | `ink/hl` = **4.06 ❌** < 4.5,8 处 error alert 正文不达标(第 7 轮) |
|
||||||
|
| **开关放 `S4`:移动端隐藏 nav** | `AppFooter.vue` 实测**无导航** → 手机用户彻底失去导航(第 1 轮) |
|
||||||
|
| **开关放 `S6`:塞进用户下拉菜单** | **登出态用户无法切换主题**(`<details>` 仅在 `user` 存在时渲染)(第 2 轮) |
|
||||||
|
| **开关放 `S5`/`S7`/`S8`:瘦身 logo** | @320 实测 `S5` **+38 ❌**;且去掉「创艺」副标损毁品牌标识(第 2 轮) |
|
||||||
|
| **`S1`:仅 `gap-6`→`gap-2`** | 达标但 **margin 仅 1px**(sumRight=319 vs 视口 320)。P12 正是因 768px 只剩 ~9px 余量引发一整轮归因调查 —— 1px 不可接受(第 2/3 轮) |
|
||||||
|
| **`S3`/`A`/`D`/`E`/`G`:缩 `max-w-[6rem]`** | 全部 margin=16 达标,但**触碰 P12 的 F3 形态钉桩**(`AppHeader.test.ts` 三处断言 + spec §5 T1 钉的形态)。候选 B 同样 margin=16 且不动钉桩 → 选 B(第 3 轮) |
|
||||||
|
| **wordmark 暗色改用 `on-bright` / 深化 highlight** | `.wordmark-label` 不声明 font-size,继承 `<h1 class="text-4xl sm:text-5xl md:text-6xl font-black">`(`LandingView.vue:47`)= 36px+/900 → **WCAG 大字号,阈值 3.0**。实测亮 3.34 ✅ / 暗 3.15 ✅(accent-ink on highlight),**零改动**。且 3.34 是现网既有值、P9-B 守卫从未钉它 → 要求 4.5 等于越界改品牌标识(第 7 轮) |
|
||||||
|
| **构建期双 CSS(两份产物切换)** | spike 0 已证 utility 全走 `var()`,运行时翻转即可;双产物会使缓存与首屏逻辑复杂化,无收益 |
|
||||||
|
| **运行时注入改阴影令牌**(我曾试过) | **方法错误**:Tailwind 把 shadow 编译成 `--tw-shadow:<构建期烘焙值>`,运行时改令牌无效(第 5 轮实测:令牌已变 `#f7f2e7` 而 `box-shadow` 仍 `rgb(20,20,20)`)。必须改源码 + 真重建(第 6 轮) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 实现
|
||||||
|
|
||||||
|
### 3.1 `crearte/src/app/styles/main.css`(D-B / D-D / D-E / D-G / D-L)
|
||||||
|
|
||||||
|
**(a) `@theme` 内新增两个令牌**(亮色值),并把五个阴影令牌改为 `var()`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
--color-paper: #f7f2e7;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-ink: #141414;
|
||||||
|
--color-ink-soft: #5c584d;
|
||||||
|
--color-ink-faint: #6f6a5c;
|
||||||
|
--color-accent: #e8552f;
|
||||||
|
--color-accent-ink: #c03a1b;
|
||||||
|
--color-highlight: #f5c518;
|
||||||
|
--color-info: #2b62cc;
|
||||||
|
--color-success: #1f7a4d;
|
||||||
|
/* P13 新增 */
|
||||||
|
--color-skeleton: #efe9da; /* 原 StatePanel 硬编码 bg-[#EFE9DA] */
|
||||||
|
--color-scrim: #0d0b08; /* 遮罩专用,两主题同值,不参与翻转(D-E) */
|
||||||
|
|
||||||
|
/* P13:内联 hex → var(),使硬阴影随主题翻转(D-D,spike 6 实测传导成立) */
|
||||||
|
--shadow-hard-sm: 3px 3px 0 var(--color-ink);
|
||||||
|
--shadow-hard: 4px 4px 0 var(--color-ink);
|
||||||
|
--shadow-hard-lg: 6px 6px 0 var(--color-ink);
|
||||||
|
--shadow-hard-accent: 4px 4px 0 var(--color-accent);
|
||||||
|
--shadow-hard-accent-lg: 6px 6px 0 var(--color-accent);
|
||||||
|
/* 其余(--font-*、--animate-skeleton、@keyframes)原样不动 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**(b) 暗色令牌块**(追加在 `@theme` 之后、`@font-face` 前后均可,但**必须**用 `html[data-theme="dark"]`,D-C):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* P13 暗色调色板(D-B)。选择器特异性 (0,1,1) 必须压过 Tailwind 的 :root,:host (0,1,0)——
|
||||||
|
写成裸 [data-theme="dark"] 虽也能生效,但特异性相同、靠源码顺序取胜,脆弱(spike 4 实测)。 */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--color-paper: #17140f;
|
||||||
|
--color-surface: #221e18;
|
||||||
|
--color-ink: #f7f2e7;
|
||||||
|
--color-ink-soft: #cbc4b5;
|
||||||
|
--color-ink-faint: #a79f8e;
|
||||||
|
--color-accent: #ff7a4d;
|
||||||
|
--color-accent-ink: #ffb59a;
|
||||||
|
--color-highlight: #8a6412;
|
||||||
|
--color-info: #7aa7f0;
|
||||||
|
--color-success: #2fa46a;
|
||||||
|
--color-skeleton: #2c2721;
|
||||||
|
--color-scrim: #0d0b08; /* 与亮色同值:遮罩不翻转(D-E) */
|
||||||
|
color-scheme: dark; /* D-L:原生控件与滚动条随主题 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**(c) `@layer base` 的 `color-scheme` 改为属性驱动**(D-L):
|
||||||
|
|
||||||
|
```css
|
||||||
|
@layer base {
|
||||||
|
html { color-scheme: light; } /* 保留:默认亮色 */
|
||||||
|
html[data-theme="dark"] { color-scheme: dark; } /* 新增;若已写在暗色块内则此处不必重复,二选一,实现者按实测择一并在注释说明 */
|
||||||
|
/* 其余(border-radius 归零、::placeholder、::selection、:focus-visible、dialog overflow)原样不动 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`::selection { background: var(--color-highlight); color: var(--color-ink); }` 与 `.markdown-body strong`(`main.css:169` 附近)、`.wordmark-label`(`:107` 附近)**全部零改动** —— 方案 B 的核心收益(D-A)。实测双主题:`ink on highlight` 亮 11.30 / 暗 4.81 ✅;`accent-ink on highlight`(wordmark)亮 3.34 / 暗 3.15 ✅(大字号阈值 3.0)。
|
||||||
|
|
||||||
|
**(d) wordmark 关键帧里的 `rgb(20 20 20 / 0)`**(`box-shadow: 0 0 0 0 rgb(20 20 20 / 0)`):这是**全透明**起始值,颜色不可见,**无需改动**。若实现者改为 `var()` 亦可,但不得改变其透明语义。
|
||||||
|
|
||||||
|
### 3.2 `crearte/src/app/components/ResultMeta.vue:52`(D-A 迁移 1/3)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- class="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center bg-accent px-1 font-mono text-[0.625rem] text-ink"
|
||||||
|
+ class="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center bg-accent-ink px-1 font-mono text-[0.625rem] text-paper"
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:这是 `app/` 下**唯一**把 `accent` 当背景用的地方(spike 0 grep:`bg-accent` 排除 `bg-accent-ink` 后仅 1 处)。迁移后 `accent` 在 `app/` 内成为**纯非文本令牌**(焦点环 + h3 左边框 + li 圆点),语义干净。
|
||||||
|
|
||||||
|
> ⚠️ **本节原文有误,已由全分支终审 N1 发现并经控制者独立重算证实(2026-10-03)**:原文写「全仓**唯一**」「迁移后 `accent` 成为**纯非文本令牌**」——**两句均为假**。`runtime/host/GameHost.vue:39/47/122` 有 **3 处 `bg-accent` 活引用**(base `e59a171` 既有、P13 未触碰),其中 `:39→:90` 是 `bg-accent text-paper` 的**文本徒标**(11px bold < 18.66px → 阈值 4.5)。根因:spike 0 的 grep 与 `noHardcodedColor` 守卫都只覆盖 `app/`,**漏了与 `app` 同级的 `runtime/`**。
|
||||||
|
> **后果**:① accent 迁移后**并非**纯非文本令牌;② 一条真实 WCAG 违规无守卫覆盖:亮色 `paper on accent` = **3.2590 < 4.5 FAIL**(暗色 7.1218 PASS)。该违规自 P9-B 就存在(旧 AA_PAIRS 钉的是 `ink on accent`,也非 `paper on accent`),**P13 未使其变差**,不属 P13 范围。
|
||||||
|
> **已处置**:① 本节与 §1.3 配对表已纠正(见上);② GameHost 亮色徒标 3.26 已登记为挂账(pre-existing WCAG 违规,归可访问打磨批,**不在 P13 合并前修**以免扩大已审 diff);③ `noHardcodedColor` 扫描根已扩到 `app/` + `runtime/`(commit `bb2cb42`,N4)。
|
||||||
|
> **教训**:「全仓唯一」这类全称声明必须用**全仓范围**的 grep 支撑,不能用子目录的 grep 得出。spike 0 的盲区直接导致了 spec 事实错误 + 守卫覆盖缺口两层后果。反面实测:若不迁移,暗色 `ink on accent` = **2.31 ❌**(亮色 5.06 侥幸过)。改用 `accent-ink`/`paper` 后与其他 11 处 badge 统一,双主题 `paper on accent-ink` = 亮 4.87 / 暗 10.78 ✅。
|
||||||
|
|
||||||
|
**注意**:`ResultMeta.vue:22` 的 `relative min-w-0`(P12 T3 的缺口 C 修复)**不得改动**。
|
||||||
|
|
||||||
|
### 3.3 `crearte/src/app/components/StatePanel.vue`(D-G 迁移 2/3)
|
||||||
|
|
||||||
|
7 处 `bg-[#EFE9DA]` → `bg-skeleton`(行 19/20/21/30/32/33/34)。**其余(`border-2 border-ink`、`animate-skeleton`、`aspect-video`、`shadow-hard`)原样不动。**
|
||||||
|
|
||||||
|
### 3.4 `crearte/src/app/components/FilterDrawer.vue:32`(D-E 迁移 3/3)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- class="m-0 mt-auto max-h-[85vh] w-full max-w-none overflow-y-auto border-t-[3px] border-ink bg-paper backdrop:bg-ink/60"
|
||||||
|
+ class="m-0 mt-auto max-h-[85vh] w-full max-w-none overflow-y-auto border-t-[3px] border-ink bg-paper backdrop:bg-scrim/80"
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:spike 4 实测暗色下 `color-mix(in oklab, var(--color-ink) 60%, transparent)` → `oklab(L=0.962)` **泛白**,遮罩失效。`scrim` 是固定深色(两主题同值),故两主题都正常遮罩。**透明度从 60% 提到 80%**:`scrim`(#0d0b08) 比 `ink`(#141414) 更深,80% 保持亮色下的既有观感强度(spike 4:亮色 `paper vs scrim` = 17.60,比原 60% ink 叠纸更深,层次更强,非回归)。
|
||||||
|
> ⚠️ **原引数字已纠正(终审 N6,2026-10-03)**:原文写「近似实色 **5.12**」,但该值**不可复现**——控制者用三种混合法独立重算 `60% ink(#141414) over paper(#f7f2e7)` 的 `cr(paper, ·)`:gamma 空间线性混合 = **4.6292**、线性光(物理正确)= **2.2842**、`color-mix(in oklab, ink 60%, paper)`(Tailwind 实际产出)= **5.3691**,无一种得 5.12。原数字未注明算法且无法复现,故删除具体值、只保留方向结论(scrim 80% 混合后 cr=11.64 确实比 ink 60% 更深,非回归,该结论不受影响)。**教训:spec 里每个实测数字都要么注明算法、要么可被守卫/测试复现;不可复现的孤立数字会被终审当缺陷抓出来。**
|
||||||
|
|
||||||
|
> 若实现者实测认为 80% 在亮色下过重,可回调至 70%,但**必须在报告里附两主题的实测截图或计算值**,不得凭感觉。
|
||||||
|
|
||||||
|
### 3.5 `crearte/src/app/composables/useTheme.ts`(D-J,新增)
|
||||||
|
|
||||||
|
沿用 `app/lib/recent.ts` 的 localStorage 容错范式(配额/隐私模式写失败静默、损坏值读回默认)。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
export const THEME_KEY = 'crearte.theme.v1'
|
||||||
|
|
||||||
|
/** 读持久化选择;缺失或损坏 → null(表示「跟随系统」)。 */
|
||||||
|
export function readStoredTheme(): Theme | null {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(THEME_KEY)
|
||||||
|
return v === 'light' || v === 'dark' ? v : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统偏好;不支持 matchMedia 的环境(含 happy-dom 部分场景)→ 'light'。 */
|
||||||
|
export function systemTheme(): Theme {
|
||||||
|
try {
|
||||||
|
return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
} catch {
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把主题落到 <html data-theme> 与 theme-color meta(D-K 的内联脚本做首屏,本函数做后续切换)。 */
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模块级单例(与 useToast 同范式)。⚠️ P9-B D-I 教训:单例须配 __resetTheme() 供测试隔离。
|
||||||
|
const theme: Ref<Theme> = ref(readStoredTheme() ?? systemTheme())
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
function toggle(): void {
|
||||||
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_KEY, theme.value)
|
||||||
|
} catch {
|
||||||
|
/* 配额/隐私模式:静默,主题本次会话内仍生效 */
|
||||||
|
}
|
||||||
|
applyTheme(theme.value)
|
||||||
|
}
|
||||||
|
/** 当前是否为用户显式选择(false = 仍在跟随系统)。供 aria-label 措辞与测试用。 */
|
||||||
|
const isExplicit = () => readStoredTheme() !== null
|
||||||
|
return { theme, toggle, isExplicit }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetTheme(): void {
|
||||||
|
theme.value = readStoredTheme() ?? systemTheme()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**要求**:
|
||||||
|
- `theme` 的**初始值**必须与 `index.html` 内联脚本的判定逻辑一致(同一优先级:stored → system → light),否则首屏与挂载后会闪一下。
|
||||||
|
- `applyTheme` 须在 composable 首次被使用时调用一次(`App.vue` 的 `onMounted` 或 `useTheme()` 内),以覆盖内联脚本未执行的场景(如 e2e 直接注入 DOM)。
|
||||||
|
- 不做 `matchMedia` 的 `change` 监听(用户已显式选择后系统切换不应覆盖;未显式选择时刷新即跟随)——**在代码注释里写明这是有意的**。
|
||||||
|
|
||||||
|
### 3.6 `crearte/src/index.html`(D-K)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
- <meta name="color-scheme" content="light" />
|
||||||
|
- <meta name="theme-color" content="#F7F2E7" />
|
||||||
|
+ <meta name="color-scheme" content="light dark" />
|
||||||
|
+ <meta name="theme-color" content="#F7F2E7" />
|
||||||
|
<meta name="description" content="crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。" />
|
||||||
|
<title>crearte 创艺</title>
|
||||||
|
+ <!-- P13 D-K:pre-paint 主题脚本,必须在 CSS 与 Vue 之前执行,否则暗色用户每次刷新闪白。
|
||||||
|
+ 判定优先级与 useTheme.ts 逐字一致:stored → prefers-color-scheme → light。 -->
|
||||||
|
+ <script>
|
||||||
|
+ (function () {
|
||||||
|
+ try {
|
||||||
|
+ var stored = localStorage.getItem('crearte.theme.v1')
|
||||||
|
+ var theme =
|
||||||
|
+ stored === 'light' || stored === 'dark'
|
||||||
|
+ ? stored
|
||||||
|
+ : window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
+ ? 'dark'
|
||||||
|
+ : 'light'
|
||||||
|
+ document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
+ var meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
+ if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
|
||||||
|
+ } catch (e) {
|
||||||
|
+ document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
+ }
|
||||||
|
+ })()
|
||||||
|
+ </script>
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `<script>` 必须放在 `<head>` 内、且在 `<body>` 之前(现结构已满足:body 里才有 `<script type="module">`)。用 `var` 与 IIFE,不依赖 ES module 时序。**CSP 注意**:本批不引入 CSP(P11 已判定需独立设计批),内联脚本当前可用;若将来上 CSP,此脚本需 `nonce` 或外置——**在脚本注释里留一句提示**。
|
||||||
|
|
||||||
|
### 3.7 `crearte/src/app/components/AppHeader.vue`(D-I)
|
||||||
|
|
||||||
|
**(a) 容器行与 nav 的 gap**:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- <div class="mx-auto flex h-14 w-full max-w-6xl items-center gap-6 px-4">
|
||||||
|
+ <div class="mx-auto flex h-14 w-full max-w-6xl items-center gap-2 px-4 sm:gap-6">
|
||||||
|
```
|
||||||
|
```diff
|
||||||
|
- <nav class="flex gap-4 text-sm font-bold">
|
||||||
|
+ <nav class="flex gap-2 text-sm font-bold sm:gap-4">
|
||||||
|
```
|
||||||
|
|
||||||
|
**(b) 主题开关**:追加在 header 行**末尾**(`<template v-if="authEnabled">` 之后,即 `</div>` 之前):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="theme-toggle"
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center border-2 border-ink bg-surface text-sm font-bold"
|
||||||
|
:aria-label="themeLabel"
|
||||||
|
:title="themeLabel"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{{ theme === 'dark' ? '☀' : '☾' }}</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<script setup>` 内:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
const themeLabel = computed(() =>
|
||||||
|
theme.value === 'dark' ? '切换为亮色主题(当前:暗色)' : '切换为暗色主题(当前:亮色)'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**约束(必须逐条满足,均有 spike 依据)**:
|
||||||
|
- 开关尺寸**必须** `h-8 w-8`(32px)。spike 3 实测 28px(`h-7`)在最坏格 margin 仅 5px(过紧),32px 为 16px。
|
||||||
|
- **不得**改动 `<summary>` 的 `max-w-[6rem] min-w-0` 与内部两 span 结构(P12 F3 钉桩 + `AppHeader.test.ts` 三处断言 + `responsive.spec.ts` 的 caret/accname 腿)。
|
||||||
|
- **不得**改动 logo 的文字与字号(spike 2 的 S5/S7/S8 瘦身方案已否)。
|
||||||
|
- `shrink-0` 必须有,否则开关会被 flex 压缩。
|
||||||
|
- 图标字形(☀/☾)包 `aria-hidden`,可访问名由 `aria-label` 提供 —— 与 P9-B 的星形按钮、P12 的 caret 同一范式。**`aria-label` 必须同时说明当前态与动作**(两态开关无可见标签,AT 用户须能听到状态)。
|
||||||
|
|
||||||
|
**(c) 布局余量核对**(实现后必须实测,不得凭算术):spike 3 已测 @320px 登录态 ascii60 在此方案下 `margin=16`、`docOverflow=0`;@375/@320-short/@375-short 全 `margin=14~16`。**实现后由 `responsive.spec.ts` 既有 12 腿 + T5 新增暗色腿复验。**
|
||||||
|
|
||||||
|
### 3.8 `crearte/src/app/App.vue`
|
||||||
|
|
||||||
|
在 `<script setup>` 中调用一次 `useTheme()` 并 `applyTheme(theme.value)`(或 `onMounted`),确保内联脚本未生效的场景(e2e 直接操作 DOM、SSR-less 的极端时序)也能落地主题。**不改模板结构**(skip-link 仍是根 div 首子,P9-B 钉桩)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 边界(不做的事,附理由)
|
||||||
|
|
||||||
|
| 不做 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| **第三态「恢复跟随系统」** | spike 3 证明 header 在 @320px 最坏格无像素容纳带标签的控件;放进 footer 会把同一控件拆到两处;三态循环(light→dark→system)在 32px 无标签图标按钮上不可发现,且「暗色回亮色要点两次」是已知反模式。**登记为打磨批候选**。 |
|
||||||
|
| **per-route / per-组件主题** | 无需求;令牌是全局的,局部覆盖会破坏对比守卫的可判定性 |
|
||||||
|
| **用户自定义配色 / 色板选择器** | 超出本批;且自定义色无法保证 WCAG,等于拆掉守卫 |
|
||||||
|
| **SSR / 预渲染 / OG 卡片** | OG 是独立挂账批(需服务端注入或预渲染,nginx 当前无 `try_files` 配置面) |
|
||||||
|
| **改 `COVER_COLORS` 或 `GameCover` 的 `text-white`** | spike 0 实测:inline style + 8 色固定表,`text-white on` 全部 ≥4.72(且字为 `text-4xl/6xl font-black` 大字号,阈值 3.0)→ **主题无关,零改动** |
|
||||||
|
| **改品牌标识(logo 文字/字号、wordmark 形态与配色)** | wordmark 是 WCAG 大字号(阈值 3.0),实测亮 3.34 / 暗 3.15 双达标;3.34 是现网既有值且 P9-B 守卫从未钉它 → 改它属越界 |
|
||||||
|
| **移动端隐藏 header nav / 汉堡菜单** | `AppFooter.vue` 实测无导航 → 隐藏即失去导航。P12 已证伪汉堡菜单的必要性(nav 内容宽 74–158px、320–1280px 零溢出) |
|
||||||
|
| **D4:nav 链接逐字换行** | P12 已 park 待设计决策(纯外观、无溢出)。本批的 `gap-2 sm:gap-4` 会让 @320px 的 nav 从 74px 降到 59px(换行数变化),**这是有意的**:spike 3 实测该形态 margin=16 且不触碰 P12 钉桩。逐字换行本身仍属外观问题,不在本批处理 |
|
||||||
|
| **CSP / HSTS** | P11 已判定:CSP 需独立设计批(游玩子域经 iframe+SW 加载用户作品),HSTS 待 TLS 批 |
|
||||||
|
| **后端 / 部署改动** | 主题纯客户端表现层。`crearte-server` 与 `crearte-deploy` **零改动** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试计划
|
||||||
|
|
||||||
|
### T1(守卫重构,**必须第一个做,RED 先行**)—— `app/lib/contrast.test.ts`(D-H)
|
||||||
|
|
||||||
|
1. **重构 `parseTokens()` 为按块解析**:返回 `{ light: Map, dark: Map }`。亮色块 = `@theme { … }` 内的 `--color-*`;暗色块 = `html[data-theme="dark"] { … }` 内的 `--color-*`。**解析失败要红得明确**(沿用现有 `throw new Error(...)` 范式,不许静默跳过)。
|
||||||
|
2. **两块各自断言** §3.1(b) 的 12 个令牌齐备(`paper`/`surface`/`ink`/`ink-soft`/`ink-faint`/`accent`/`accent-ink`/`highlight`/`info`/`success`/`skeleton`/`scrim`)。
|
||||||
|
3. **双主题各断言 §1.3/§7 的真实配对表**(12 条),阈值按 WCAG 正确取值:正文 4.5、大字号 3.0(wordmark)、非文本 3.0(焦点环)。
|
||||||
|
4. **`scrim` 的分离判据 = 按主题钉实际分离侧**(D-F):亮色钉 **`paper vs scrim ≥ 3`**(填充侧,实测 17.60)、暗色钉 **`ink vs scrim ≥ 3`**(边框侧,实测 17.60)。
|
||||||
|
> **⚠️ 本节经两次纠正(2026-10-03),第二次是对第一次的修正。**
|
||||||
|
> **原文(错)**:「`ink vs scrim ≥ 3` **两主题**」。但亮色 `ink(#141414) vs scrim(#0d0b08)` 实测只有 **1.07**(两个深色互不分离)——照字面写死会让**亮色腿误红**。由实现者发现、控制者独立复现。
|
||||||
|
> **第一次纠正(仍错)**:改为 `max(填充侧, 边框侧) ≥ 3`。控制者当时**只验了「会不会误红」(不会),没验「有没有牙」**——而它恒真:两侧互补,`max` 的理论下界 = `√cr(paper,ink)` = **4.0621 > 3**,对**任意** scrim 取值都不可能失败。实证:亮色 scrim 改纯白(= D-E 要防的泛白缺陷)时填充侧得 1.1165、守卫仍绿。由**任务级审查 finding #1** 发现(M1:亮 scrim 改 `#ffffff` → 绿 32/32),控制者用暂力重算独立证实。
|
||||||
|
> **第二次纠正(现行)**:拆成按主题钉**具体那一侧**。白 scrim 得 1.1165 < 3 → 红,牙口恢复(mutation 已实测)。
|
||||||
|
> **教训(两层,均源于 P12「守卫自身要受同等审视」)**:① 控制者给守卫写了不成立的断言;② **纠正时只验了一个失效方向**——守卫有两种失效:「误红」(假阳性)与「恒绿」(假阴性)。第一次纠正只排除了前者。**修守卫时必须两个方向都验:对合法值不误红 + 对非法值必红(mutation)。** 另:`max(a,b) ≥ k` 形态的断言是恒真高发区——当 a、b 互补时 max 有非平凡下界,阀值低于该下界就永远不失败。
|
||||||
|
5. **RED 证明牙口**(在加暗色块**之前**先写测试):
|
||||||
|
- 暗色块缺失 → 「暗色 12 令牌齐备」红
|
||||||
|
- 把暗色 `highlight` 临时改成 `#f5c518`(亮黄)→ `ink on highlight` 暗色 = **1.46** 红
|
||||||
|
- 把暗色 `accent` 改回 `#e8552f` → 焦点环仍过,但**反面**:若同时把 ResultMeta 迁移回退,`ink on accent` 暗色 = 2.31(此条由 T3 的组件测试覆盖,守卫层记录数值即可)
|
||||||
|
- **旧解析器回归证明**:用重构前的单 Map 逻辑跑含暗色块的 CSS,断言其结果**错误**(九键全为暗色值)—— 这条测试本身就是 D-H 缺陷的钉桩,防止后人「简化」回单 Map
|
||||||
|
6. **mutation 自查**(实现者必做并附输出):把亮色 `success`(现行 `#1f7a4d`,`paper on success` = **4.7628**)改成任意低对比值 → 亮色腿必须红。**证明重构后亮色仍被守**(这是 D-H 的核心风险)。实现者实际用 `#cccccc` → 实测 **1.4384 红**;控制者复跑同值同结果。
|
||||||
|
> ⚠️ **本条原文有误(终审 N8,2026-10-03 纠正)**:原文写「把亮色 `success` 改回 P9-B 前的 `#2fa46a` 之前的值 `#2fa46a` → 应仍过(4.76)」——**措辞自相矛盾**(同一值既是「之前的值」又是它自己),且 **`#2fa46a` 是暗色** success 令牌值;把**亮色** success 改成它会得 `paper(#f7f2e7) on #2fa46a` = **2.8331 < 4.5 → 红**,不是「仍过」;4.76 实属现行亮色 `#1f7a4d`。实现者未受影响(它用的是 `#cccccc`),仅本说明文字错。
|
||||||
|
|
||||||
|
### T2(令牌层)—— `main.css`(D-B/D-D/D-E/D-G/D-L)
|
||||||
|
|
||||||
|
- 按 §3.1 落地。T1 的守卫应从红转绿。
|
||||||
|
- **构建产物核验**(`npm run build` 后 grep dist CSS):
|
||||||
|
- `var(--color-*)` 引用数 **≥ 64**(不得下降)
|
||||||
|
- 内联 `#141414` 从 **11 降到 ≤ 3**(余下的是 wordmark 关键帧透明值等非令牌处;实现者须逐处说明剩余来源)
|
||||||
|
- 暗色块出现在产物中且**未被 Tailwind 层吞掉**
|
||||||
|
- `.shadow-hard` 的 `--tw-shadow` 含 `var(--color-ink)`(spike 6 的传导前提)
|
||||||
|
> **⚠️ grep 命令必须容忍 minifier 去引号(实现者发现,控制者独立复现,2026-10-03)**:本 spec 与 plan 原先写 `grep -c 'html\[data-theme="dark"\]'`(带双引号)。但 lightningcss 的 minifier **会去掉属性选择器里非必需的引号**,实际产物是 `html[data-theme=dark]`。用带引号的字面命令 grep 产物 → **返回 0,会被误判为「暗色块被 Tailwind 吞掉」**。
|
||||||
|
> 正确核验:`grep -c 'html\[data-theme="\?dark"\?\]' "$CSS"` 或去引号版,须 ≥ 1。**源码里必须写带引号的 `html[data-theme="dark"]`(CSS 源码选择器)是对的;错的只是产物核验的 grep 模式。** 实测:带引号 0 / 去引号 1,暗色块逐字完整 `html[data-theme=dark]{--color-paper:#17140f;…;color-scheme:dark}`。
|
||||||
|
- **禁止**用 `@apply` 或 `!important` 绕过特异性问题(D-C)。
|
||||||
|
|
||||||
|
### T3(三处 usage 迁移)—— §3.2 / §3.3 / §3.4
|
||||||
|
|
||||||
|
- `ResultMeta.vue:52`:`bg-accent text-ink` → `bg-accent-ink text-paper`。**须新增/更新组件测试**断言该角标的类名(防止回退;spike 反面:暗色 2.31 ❌)。
|
||||||
|
- `StatePanel.vue` ×7:`bg-[#EFE9DA]` → `bg-skeleton`。新增源码级断言(可加进 `tableOverflow.test.ts` 同族的守卫,或新建 `app/lib/noHardcodedColor.test.ts`):**扫描 `app/**/*.vue`,禁止 `bg-[#`/`text-[#`/`border-[#` 形态的硬编码 hex**。RED 先行(迁移前应报 7 处违规)。
|
||||||
|
- ⚠️ 该守卫须沿用 P12 `tableOverflow.test.ts` 的**屏蔽范式**:`<script>`/`<style>`/HTML 注释/`<textarea>`/`<title>` 内容替换为等长空白后再扫描,否则注释里的示例会误报(P12 FE-1 的假绿教训同源)。
|
||||||
|
- `FilterDrawer.vue:32`:`backdrop:bg-ink/60` → `backdrop:bg-scrim/80`。
|
||||||
|
|
||||||
|
### T4(主题机制)—— `useTheme.ts` + `index.html` + `App.vue` + `AppHeader.vue`
|
||||||
|
|
||||||
|
- **`useTheme.test.ts`**(新增,沿用 `useToast.test.ts` 的隔离范式,用 `__resetTheme()`):
|
||||||
|
- stored=`dark` → 初始 `dark`;stored=`light` → `light`;stored 缺失 → 跟随 `matchMedia`;stored 为垃圾值(`'neon'`)→ 跟随系统
|
||||||
|
- `toggle()` 翻转、写 localStorage、调 `applyTheme`(`document.documentElement.dataset.theme` 与 `meta[name=theme-color]` 都变)
|
||||||
|
- localStorage 抛错(配额/隐私模式,`vi.spyOn(...).mockImplementation(() => { throw … })`)→ **静默**,主题仍在会话内生效
|
||||||
|
- `matchMedia` 不存在 → 回落 `light`,不抛错
|
||||||
|
- `isExplicit()`:未点击过 → false;点击后 → true
|
||||||
|
- **`AppHeader.test.ts`**(扩展,**既有断言零删除**):
|
||||||
|
- 开关存在、`data-testid="theme-toggle"`、`aria-label` 含当前态与动作、图标 span 为 `aria-hidden`
|
||||||
|
- 点击 → `useTheme().theme` 翻转(mock 或真实 store + `__resetTheme()`)
|
||||||
|
- **P12 的 F3 形态钉桩必须仍绿**:`max-w-[6rem]` + `flex` + `min-w-0` + 两 span 结构 + `:title`
|
||||||
|
- gap 变更钉桩:容器行含 `gap-2 sm:gap-6`、nav 含 `gap-2 sm:gap-4`
|
||||||
|
- **`index.html`**:内联脚本的判定优先级须与 `useTheme.ts` **逐字一致**。加一条源码级守卫(可并入 T3 的新守卫文件或 `contrast.test.ts` 同族)断言两处优先级串一致,或至少在测试里注释指明「改一处必须改另一处」并加断言比对 `THEME_KEY` 字面量出现在 `index.html` 中。
|
||||||
|
- **`App.vue`**:调 `applyTheme`;**模板结构不得改**(skip-link 仍首子,P9-B 钉桩)。
|
||||||
|
|
||||||
|
### T5(e2e)—— `e2e/dark.spec.ts`(新增)+ `e2e/responsive.spec.ts`(**既有 12 腿必须保持绿**)
|
||||||
|
|
||||||
|
`dark.spec.ts` 最低覆盖(复用 `helpers.ts` 的 `seedSession(page, role, displayName)`):
|
||||||
|
|
||||||
|
1. **默认跟随系统**:`page.emulateMedia({ colorScheme: 'dark' })` + 清空 localStorage → 首屏 `html[data-theme]` = `dark`(**且必须在 Vue 挂载前就已设置**:用 `addInitScript` 或 `goto` 后立刻断言,验证 D-K 的 pre-paint 无 FOUC)
|
||||||
|
2. **开关切换**:点击 → `data-theme` 翻转、`meta[name=theme-color]` 同步、`localStorage['crearte.theme.v1']` 写入
|
||||||
|
3. **持久化**:切换后 reload → 主题保持(不闪回)
|
||||||
|
4. **计算值实证**(不只查属性):暗色下 `getComputedStyle(document.body).backgroundColor` = `rgb(23, 20, 15)`、`color` = `rgb(247, 242, 231)`;**硬阴影跟随**:某 `.shadow-hard` 元素的 `box-shadow` 含 `rgb(247, 242, 231)`(spike 6 的传导在真产物上复验)
|
||||||
|
5. **遮罩不泛白**:打开 FilterDrawer,`::backdrop` 的 `background-color` 在暗色下**不是**近白(断言其解析值的亮度低于阈值,或直接断言 `scrim` 令牌值生效)
|
||||||
|
> **⚠️ 补充(2026-10-03 审查期):只断言亮度不够,必须同时钉 alpha。** 控制者实测:兜底分支若只看最大通道,全透明的 `rgba(0,0,0,0)`(= 遮罩功能完全失效)会通过。D-E 钉的是 `backdrop:bg-scrim/80`,故 **亮度(oklab L < 0.4,失败形态 L=0.962)与透明度(alpha ≈ 0.8)两条都要断言**,且**主分支与兜底分支同等严格**——兜底分支正是为「未来浏览器改变序列化形态」而存在,它比主分支松就等于那时守卫静默失效。
|
||||||
|
> 实现期实测:Tailwind 产出 `color-mix(in oklab, var(--color-scrim) 80%, transparent)`,Chromium 对 `::backdrop` 的 `getComputedStyle` **保留 oklab 形态**(`oklab(0.150853 0.00139775 0.00721639 / 0.8)`)而非 rgb,故须按色彩空间解析。
|
||||||
|
6. **可访问名**:开关的 `toHaveAccessibleName` 含当前态(与 `a11y.spec.ts` 同一 API)
|
||||||
|
7. **零横向溢出(暗色)**:@320/@375 × 登录态 ascii60,`scrollWidth ≤ clientWidth + 1`(暗色下 header gap 变更的回归钉桩)
|
||||||
|
|
||||||
|
`responsive.spec.ts`:**既有 12 腿一字不改、必须全绿**(P12 成果)。若 gap 变更导致某腿数值变化,**不得改断言迁就**——先查明是否真溢出,是则修实现。
|
||||||
|
|
||||||
|
### 全量回归(合并前必跑,`set -o pipefail`,**禁 `--if-present`**)
|
||||||
|
|
||||||
|
| 腿 | 命令 | 基线(P12 合并态) |
|
||||||
|
|---|---|---|
|
||||||
|
| vitest | `npm run test` | **635 passed / 74 files** |
|
||||||
|
| typecheck | `npm run typecheck` | exit 0 |
|
||||||
|
| build | `npm run build` | exit 0(含 `vue-tsc --noEmit`) |
|
||||||
|
| build:runtime | `npm run build:runtime` | exit 0 |
|
||||||
|
| 主 e2e | `npm run e2e` | **92 passed + 1 skipped** |
|
||||||
|
| noauth e2e | `npm run e2e:noauth` | **4 passed** |
|
||||||
|
|
||||||
|
新增测试后数字应上升;**任何既有腿下降都必须解释**。P12 的 12 条 responsive 腿与 `AppHeader.test.ts` 的 F3 钉桩属**不得回退**项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CHANGELOG 与版本
|
||||||
|
|
||||||
|
- `crearte` → **0.26.0**(minor:新特性 = 暗色主题 + 两层守卫扩展)。条目分 `Added`(暗色主题、`useTheme`、`dark.spec.ts`、硬编码色守卫)、`Changed`(阴影令牌 var 化、header gap、三处 usage 迁移)、`Tests`、`Context`。
|
||||||
|
- `crearte-server` / `crearte-deploy` → **无改动,不发版**。
|
||||||
|
- wrapper → **0.3.6**(记账:ROADMAP P13 行 + 文档索引注册本 spec 与 plan + P9-B/P11 行尾「C 暗色模式」挂账标注为已清偿)。
|
||||||
|
|
||||||
|
条目格式:同条目英文行紧跟中文行(**无空行**),不同条目间**空一行**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 证据存档
|
||||||
|
|
||||||
|
`crearte/.superpowers/sdd-p13/`(`.gitignore` 已含 `.superpowers/`,不入库):
|
||||||
|
|
||||||
|
- `FINDINGS-survey.md` — **7 轮 spike 的实测输出固化**:Tailwind v4 令牌形态(运行时翻转可行性)、header 余量四格实测与唯一失败格、9 候选粗筛、以 margin≥8px 为判据的细化(候选 B 胜出)、机制验证(特异性/var 传导/color-mix 泛白)、**阴影传导的方法错误与纠正**(第 5 轮失败 → 第 6 轮源码+重建成功)、调色板锁定(highlight 深度搜索 6 候选、双主题 **12** 配对全 PASS、两个自身错误的修正)、迁移清单、守卫重构必要性。每个数字都有出处。
|
||||||
|
- `spike1-header-slack.txt` … `spike6-shadow-source.txt` — 六轮原始输出(第 5 轮的**失败**输出也保留,它是方法错误的证据)。
|
||||||
|
- spike 源文件(`e2e/_spike-p13-*.spec.ts`)跑完即删,工作树 `porcelain=0` 已核。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 实现者必读的纪律(延续 P9-B / P11 / P12 教训)
|
||||||
|
|
||||||
|
1. **不推断 CSS/Vue/构建行为,只实测。** 本批已有两次栽在推断上:spike 5 用运行时注入验证构建期烘焙(失败)、spike 1 第一版用 `APPLY.toString()` + `new Function` 序列化注入(`ReferenceError: APPLY is not defined`)。
|
||||||
|
2. **守卫先写、先看它红。** RED 输出必须复现本 spec 引用的实测数字(如暗色 `ink on highlight` = 1.46),否则守卫可能无牙。
|
||||||
|
3. **守卫自身要受同等审视。** 写完守卫后做 mutation:故意破坏被测属性,确认守卫变红;再确认**亮色腿在重构后仍然守得住**(D-H 的核心风险是重构把亮色守卫静默弄丢)。
|
||||||
|
4. **P12 成果不得回退。** `max-w-[6rem]`、五个表格包裹层的 `relative overflow-x-auto pr-1 pb-1`、`ResultMeta.vue:22` 的 `relative min-w-0`、`AccountView` 的 `min-w-0 wrap-anywhere`、skip-link 首子位置、`useToast` 的 `announcedId` 单调语义 —— 全部原样保留。
|
||||||
|
5. **throwaway 文件跑完即删**,且注意 `npm run build` 含 `vue-tsc --noEmit` 会检查 `e2e/*.spec.ts` 的类型(spike 6 曾因此 `BUILD_EXIT=2`);spike 期间用 `npm run build:e2e` 绕开,但**交付前 `npm run build` 必须真过**。
|
||||||
|
5b. **⚠️ 测试与注释里禁写 Tailwind 类名字面量**(实现者 T2 期发现,spec 与 FINDINGS 均未预料)。Tailwind v4 的自动内容探测**扫描全部源文件,含 `.test.ts`**:`FilterDrawer.test.ts` 里一句 `not.toContain('backdrop:bg-ink/60')` 断言(及其注释)让 Tailwind 把这个已迁移掉的类名当候选、**重新生成 utility 及其 `#14141499` fallback 烧回产物**——内联 `#141414` 因此停在 3 而非 2。修法是**用字符串拼接构造字面量**(`'backdrop:bg-i' + 'nk/60'`),使其不出现在任何源文件里。
|
||||||
|
与 P12 的 `noHardcodedColor`/`tableOverflow` 屏蔽范式**同源**(注释里的示例会污染扫描),只是方向相反:那里防「守卫扫到注释里的 table」,这里栽在「Tailwind 扫到测试里的死 utility」。**任何「某 utility 不得再出现」的回退钉桩,都必须用拼接而非字面量。**
|
||||||
|
6. **报数字要报实跑输出**,不报「应该没问题」。
|
||||||
|
7. **发现 spec 有误就纠正 spec**,不要迁就实现(P12 的实现者纠正了 768px 归因,控制者用 spike 17 独立定案其成立并 re-pin 了五处下游)。
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# P14 设计:拆分 `ContentService` god object(服务架构维度)
|
||||||
|
|
||||||
|
- **日期**:2026-10-03
|
||||||
|
- **仓**:`crearte-server`(**仅此一仓**;crearte / crearte-deploy 零改动)
|
||||||
|
- **base**:`dbf7fe5`(0.17.2,master)
|
||||||
|
- **性质**:**纯结构重构,零行为变更**。验收的核心是「四门全绿 + 既有测试断言零修改 + 公开 API 语义不变」。
|
||||||
|
- **取证账本**:`crearte-server/.superpowers/sdd-p14/survey.md`(全部数字实测,含 Route C spike 输出与三次归属分析的自我纠错)
|
||||||
|
- **目标版本**:crearte-server **0.18.0**(服务层结构调整,minor);wrapper **0.3.7**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 现状与动机
|
||||||
|
|
||||||
|
`internal/service/content.go` = **856 行 / 30 个函数**,是 `internal/service/` 唯一的异常值:
|
||||||
|
|
||||||
|
| 文件 | 行数 |
|
||||||
|
|---|---|
|
||||||
|
| **content.go** | **856** |
|
||||||
|
| content_test.go | 376 |
|
||||||
|
| account_test.go | 274 |
|
||||||
|
| auth.go | 234 |
|
||||||
|
| auth_test.go | 229 |
|
||||||
|
| validate.go | 196 |
|
||||||
|
|
||||||
|
`service/` 目录合计 3884 行,content.go 独占 **22%**;是次大生产文件 `auth.go` 的 **3.7 倍**。
|
||||||
|
|
||||||
|
它承载**五条互不相干的职责线**(目录读 / 反应 / 上传 / 投稿 CRUD / 审核发布),且:
|
||||||
|
|
||||||
|
- struct 仅 5 个字段,**全是共享仓储依赖、无可变内部状态** → 没有「必须放在一起」的状态理由;
|
||||||
|
- **五个 handler 各只用一条线**(零交叉),却各自声明对**22 个公开方法**的依赖;
|
||||||
|
- `cmd/catalog.go`(静态目录导出)**显式传 `nil` 给 bundle 参数**,注释「bundle 不参与导出(list/detail 只用 versions)」——god object 迫使一个只需 2 个方法的命令构造携带 22 方法 / 4 依赖槽的服务,并用 `nil` 占位绕开不需要的依赖;
|
||||||
|
- 单函数最大 **`Approve` 170 行**(605–774)。
|
||||||
|
|
||||||
|
**为什么现在做**:六维度循环中「服务架构」维度自 P11(服务端安全硬化)后未再触碰;P13(UI/UX)刚交付, crearte 仓空闲但本批不需要它。此项证据最强(见 §1 三条决定性发现),且**风险可被既有 11 包测试完全兜住**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 取证:三条决定性发现(全部实测)
|
||||||
|
|
||||||
|
### 1.1 跨职责线调用 = 7 处,**全部指向包级 helper 函数**
|
||||||
|
|
||||||
|
python AST 级扫描 `content.go` 内部调用图:同线内部调用 9 处,**跨线 7 处**,逐条:
|
||||||
|
|
||||||
|
| 调用方(线) | 被调(线) | 行 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CreateBundleUpload`(upload) | `randomToken`(helper) | 239 |
|
||||||
|
| `CreateCoverUpload`(upload) | `randomToken`(helper) | 261 |
|
||||||
|
| `UpdateSubmission`(submission) | `ParseSubmissionEnvelope`(helper) | 486 |
|
||||||
|
| `Approve`(moderation) | `ParseSubmissionEnvelope`(helper) | 616 |
|
||||||
|
| `Approve`(moderation) | `versionFromUpload`(helper) | 711, 726 |
|
||||||
|
| `Approve`(moderation) | `pendingKeys`(helper) | 766 |
|
||||||
|
|
||||||
|
**零个「方法 → 方法」跨线调用**:`s.CoverURL(` / `s.BundleURL(` 在 content.go 内**零命中**(只被 handler 从外部调);`reactionTarget` 3 处全在反应线内;`checkSubmissionRules` / `checkUploadRefs` 4 处全在投稿线内。
|
||||||
|
|
||||||
|
→ **五条线只通过包级 helper 共享代码,helper 无 struct 依赖**(实测 `helper` 线用到的 struct 字段为空)。拆分后 helper 保持包级即可被各子 service 直接调用,**不产生任何跨 service 回调、不产生循环依赖**。这是可拆性的最强证据。
|
||||||
|
|
||||||
|
### 1.2 handler 依赖面 = 恰好一线,零交叉
|
||||||
|
|
||||||
|
| handler | 现构造签名 | 用的方法(实测) | 线 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `games.go:23` | `NewGames(svc *service.ContentService)` | ListPublished, GetPublishedDetail, CoverURL, BundleURL | **catalog** |
|
||||||
|
| `reactions.go:20` | `NewReactions(svc *service.ContentService)` | SetFavorite, RateWork, UnrateWork, UserReaction, MyReactions | **reaction** |
|
||||||
|
| `uploads.go:21` | `NewUploads(svc *service.ContentService, maxBundle, maxCover int64)` | CreateBundleUpload, CreateCoverUpload | **upload** |
|
||||||
|
| `submissions.go:21` | `NewSubmissions(svc *service.ContentService)` | CreateSubmission, ListMine, GetSubmission, UpdateSubmission, DeleteSubmission | **submission** |
|
||||||
|
| `admin.go:20` | `NewAdmin(svc *service.ContentService, bundle *service.BundleService)` | ListQueue, Approve, Reject, Unpublish, Republish, UpdateWorkFeatures | **moderation** |
|
||||||
|
|
||||||
|
### 1.3 构造点爆炸半径:19 个测试调用点里 **11 个只构造不调方法**
|
||||||
|
|
||||||
|
- 生产 2 处:`cmd/serve.go:64`(全量依赖 → 喂 5 个 handler)、`cmd/catalog.go:62`(**传 `nil` bundle**,只喂 `handler.NewGames`)。
|
||||||
|
- 测试 **14 文件 / 19 调用点**,其中 **11 个文件「只构造、不直接调方法」**(把 svc 交给 handler/router 走 HTTP 层测试);仅 3 个直接调:
|
||||||
|
- `content_hosted_test.go`:1 调用点、1 线(submission)
|
||||||
|
- `content_test.go`:4 调用点、2 线(catalog + moderation)
|
||||||
|
- `namespace_test.go`:2 调用点、2 线(submission + upload)
|
||||||
|
|
||||||
|
### 1.4 各线规模与依赖(实测,决定子 service 构造签名)
|
||||||
|
|
||||||
|
| 线 | 行数 | 函数数 | 用到的 struct 字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **catalog** | 86 | 4 | `store`, `objects` |
|
||||||
|
| **reaction** | 82 | 6(含私有 `reactionTarget`) | `store` |
|
||||||
|
| **upload** | 78 | 2 | `store`, `users`, `objects`, `bundle` |
|
||||||
|
| **submission** | 267 | 7(含私有 `checkSubmissionRules`/`checkUploadRefs`) | `store`, `objects` |
|
||||||
|
| **moderation** | 255 | 6 | `store`, `users`, `objects` |
|
||||||
|
| **helper**(包级函数) | 49 | 4 | 无 |
|
||||||
|
|
||||||
|
### 1.5 Route C spike:Go 嵌入提升满足消费方窄接口(`golang:1.24-alpine` 实测,`go vet` 净)
|
||||||
|
|
||||||
|
玩具类型与真仓同构(子 service 持子仓储接口、组合根嵌入指针、消费方定义窄接口)。四个假设**全部编译通过且运行正确**:
|
||||||
|
|
||||||
|
- **H1** 组合根嵌入 `*ReactionService` + `*UploadService`(指针字段 + 指针接收者)后,`c.SetFavorite(...)` / `c.CreateUpload(...)` 直接可用(方法提升)。
|
||||||
|
- **H2** ⭐ `var rp reactionPort = c` / `var up uploadPort = c` 成立 → **提升后的方法集使组合根满足任何窄接口**,故 19 个测试构造点与 `serve.go` 五处装配**零改动**。
|
||||||
|
- **H3** `NewReactionService(store)` 签名里根本没有 bundle → **`cmd/catalog.go` 的 `nil` 占位可消失**。
|
||||||
|
- **H4** 组合根与子 service 都能喂给收窄后的 handler → 两路线可并存、可渐进。
|
||||||
|
|
||||||
|
嵌入提升的前提在真仓复核:25 个方法名 **零重名**(`sort | uniq -d` = 0)→ 无 ambiguous selector;3 个私有方法(`reactionTarget`/`checkSubmissionRules`/`checkUploadRefs`)均只在本线内使用、不参与提升。
|
||||||
|
|
||||||
|
**仓内已有先例**(拆分与惯例一致,非外来重构口味):`NewAccountService(users, content, objects)` 与 `NewBundleService(versions, uploads, keks, activeKEK)` 都直接收**子仓储接口**而非大 store;`ContentStore` 已按职责分子仓储(`Works()`/`Versions()`/`Submissions()`/`Uploads()`/`Reactions()`);`cmd/catalog.go:79` 有 `catalogRenderer` 接口 = **消费方定义窄接口**的先例;`admin_users.go` 用自由函数。
|
||||||
|
|
||||||
|
### 1.6 拆分安全性:三个风险已实测证伪
|
||||||
|
|
||||||
|
1. **包内直接读 ContentService 私有字段** = **零**。`account.go`/`auth.go` 里的 `s.store`/`s.users` 属于**它们自己的 struct**;`AccountService` 持的是 `repository.ContentStore`(仓储接口)而非 `*ContentService`,**完全不受拆分影响**。
|
||||||
|
2. **ContentService 被 interface 化 / 类型断言 / 方法值使用** = **零**(`type ContentService struct` 全仓仅一处定义)。
|
||||||
|
3. **共享声明的包外依赖** = 10 个(见 §3-D),全部保持导出即可,无签名变更。
|
||||||
|
|
||||||
|
### 1.7 新发现:`ContentService.now` 是**死字段**
|
||||||
|
|
||||||
|
`content.go` 内 `s.now` **零引用**;全文件只有两行提到 `now`——line 31 字段声明、line 35 构造器赋值 `now: time.Now`。测试里也无 `.now =` 注入(全仓 `.now =` 只命中 `cleanup.go:26` 的 `SetNow` setter)。
|
||||||
|
|
||||||
|
对照组证明它是照抄兄弟 service 的模式而从未使用:`auth.go:159` 真读 `s.now()`、`cleanup.go` 有 `SetNow(fn)` test seam、`account.go` 也有 `now` 字段(其使用情况不属本批范围)。
|
||||||
|
|
||||||
|
→ **本批删除该死字段**。因为它在拆分爆炸半径内,不删就得决定它归哪条线。删除**不影响任何调用点**(`NewContentService` 的 4 参数签名不变,`now: time.Now` 是构造器内部赋值)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 决策表
|
||||||
|
|
||||||
|
| # | 主题 | 决策 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D-A** | 拆分路线 | **Route C:组合根嵌入五个子 service + handler 侧收窄接口**。`ContentService` 保留为**组合根**(5 个嵌入指针字段、自身零方法),五个子 service 各持自己需要的依赖;五个 handler 的 `svc` 字段类型改为**消费方定义的窄接口** | §1.5 spike H1–H4 实测;H2 使 19 测试点 + serve.go 五处装配零改动,H3 使 catalog.go 的 nil 占位消失。**facade 与窄接口两条路线的收益同时拿到,不是二选一** |
|
||||||
|
| **D-B** | 文件布局 | `content.go`(组合根 + 共享声明)+ `catalog.go` + `reaction.go` + `upload.go` + `submission.go` + `moderation.go`,共 **6 个文件**;命名与仓内惯例一致(`account.go`→`AccountService`、`bundle.go`→`BundleService`、`auth.go`→`AuthService`、`cleanup.go`→`CleanupService`) | §1.4 五条线 + §5c 归属映射 |
|
||||||
|
| **D-C** | 子 service 构造签名 | `NewCatalogService(store, objects)`、`NewReactionService(store)`、`NewUploadService(store, users, objects, bundle)`、`NewSubmissionService(store, objects)`、`NewModerationService(store, users, objects)`。**每个只收它实测用到的字段**(§1.4),不多收 | §1.4 依赖映射;先例 `NewBundleService` 只收两个子仓储 |
|
||||||
|
| **D-D** | 私有 helper 归属 | `randomToken` → `upload.go`;`versionFromUpload` + `pendingKeys` → `moderation.go`;`ParseSubmissionEnvelope` + `SubmissionEnvelope` → **留 `content.go`(SHARED,且 handler 依赖须保持导出)**;`ErrUnsupportedMediaType` + `allowedCoverTypes` → `upload.go`(**独占**,实测仅 256/258 使用);其余 11 个共享错误/类型 → 留 `content.go` | §5c 归属映射(含我对两处误归属的纠正记录)。**RE-PIN 补记**(审查者 M-8 实测):`ErrSubmissionConflict` 除投稿/审核两线与 `handler/submissions.go` 外,还被**两线之外的 `AccountService`(`account.go:136`,账号注销的 DeletionReport 路径)**使用——把它降级为小写即 `account.go:136:29: undefined: ErrSubmissionConflict` 编译红。故其 SHARED 判定**不仅正确而且必要** |
|
||||||
|
| **D-E** | handler 窄接口 | 五个 handler 各自在**自己的文件内**声明消费方接口(`catalogPort`/`reactionPort`/`uploadPort`/`submissionPort`/`moderationPort`,小写=包内私有),只列它实测调用的方法;struct 字段与构造参数类型改为该接口 | §1.2 零交叉;先例 `cmd/catalog.go:79 catalogRenderer`。**接口定义在消费方**(Go 惯例),不放 service 包 |
|
||||||
|
| **D-F** | `Approve` 170 行 | **本批不拆**。`moderation.go` 255 行在可接受范围;`Approve` 的 7 阶段内部重构(① 加载+状态校验 606–619 ② owner 查取 622–628 ③ 上传解析 630–644 ④ 重复性检查 switch 646–659 ⑤ 对象 Copy 661–681 ⑥ `WithTx` 事务 switch 683–759 ⑦ pending key 清理 + slog 766–772)**登记挂账**,留下一批(代码优雅维度) | 一批一个关注点。混入会让 diff 与审查面翻倍;P13 经验证明小而聚焦的批次审查质量更高。7 阶段映射已取证固化,下批可直接用 |
|
||||||
|
| **D-G** | 行为不变契约 | 纯结构重构:**零行为变更**。验收 = ① 四门全绿(gofmt/vet/build/`go test ./...` 11 包)② **既有测试断言零修改**(只允许因构造函数/类型变化而改装配代码,不允许改任何 `assert`/`expect` 语义)③ 公开 API 语义不变(22 个方法签名逐字不变,10 个共享声明保持导出)④ HTTP 行为逐字节不变 | 重构的定义。既有 11 包测试就是本批的守卫 |
|
||||||
|
| **D-H** | 死字段 | 删除 `ContentService.now`(§1.7)。`NewContentService` 的 **4 参数签名不变** → 19 个测试构造点与 2 个生产构造点**零改动** | §1.7 实测零引用 |
|
||||||
|
| **D-I** | `cmd/catalog.go` 依赖面 | 改为直接构造 `service.NewCatalogService(store, objects)` 喂 `handler.NewGames(...)`,**删除 `nil` bundle 占位与不需要的 `users` 依赖**(依赖槽 4 → 2) | §1.5 H3;现状注释「bundle 不参与导出」正是 god object 代价的自白 |
|
||||||
|
| **D-J** | 架构守卫(RED 先行) | 新增 `internal/service/architecture_test.go`,用 **reflect 钉住拆分结构**:① `ContentService` 恰有 5 个字段、**全部匿名(嵌入)、全部指针**、类型名恰为五个子 service ② 每个子 service 的**导出方法名集合**逐字等于其职责线的预期集合 ③ `ContentService` **无名为 `now` 的字段** | 防「方法迁回组合根」「重新长出具体字段」「死字段复活」。P12/P13 先例:守卫任务提前为 TDD 第一步,RED 输出本身即「有牙」证明 |
|
||||||
|
|
||||||
|
> ⚠️ **RE-PIN(2026-10-03,任务级审查 F1 important)**:原 D-J 的**三条断言与其自述目的不匹配**——三项里的「防方法迁回组合根」**没有任何一条断言覆盖**(断言①只数字段、②只看子 service、③只查字段名)。实测(审查者 M-3b + 控制者独立复现):在组合根上新增方法后**三断言全 PASS、`go build`/`go vet` 绿、全量 11 包测试也全绿** → god object 可静默回潮,零机械信号。另实测 `go vet` **不报告**组合根方法对提升方法的遮蔽(M-9,`vet_exit=0`)。
|
||||||
|
>
|
||||||
|
> **D-J 增订为七类断言**(④–⑦ 为 re-pin 后补,已由 commit `e195be0` 交付):④ `ContentService` 的**指针**方法名集合恰为五线导出方法之并(22 个)——抓「组合根新增方法」⑤ **源码扫描**:包内非测试 `.go` 里不得存在任何以 `ContentService` 为接收者的方法声明——这是唯一能抓「遮蔽提升方法」的机械手段(遮蔽后反射方法名集合不变、`go vet` 沉默),也是唯一能抓「**未导出**的组合根方法」的手段(`NumMethod()` 只暴露导出名,故断言④看不见它们)。⚠️ **RE-PIN(全分支终审 FR-1,important)**:④–⑦ 原由 `e195be0` 交付,但⑤的正则当时把接收者名写成**必需**分组(`\s*\w+\s+`),而 Go 允许省略不用的接收者名——遮蔽体恰好常不需它(`func (*ContentService) CoverURL(...)`),故该形态下 build/vet/七断言全绿而遮蔽确实生效(终审实测:经组合根调用返被接管值、直调子 service 返原值)。已由 `e04694b` 改为可选分组 `(?:\w+\s+)?` 并双向验证(合法树 7/7 绿;匿名指针/匿名值/有名三形态均 A5 红;负控制不过度捕获 `ContentServiceFoo`)。⑥ 五个子 service 的**字段名集合**逐字等于其依赖面(§1.4)——把「无可变内部状态」这个 §0 赖以论证可拆分的不变量变成机器契约(原断言③只禁字面名 `now`,故 `clock`/`logger`/`cache` 任何名字都能静默通过:钉的是名字而非不变量)⑦ 实例化 `NewContentService(nil, nil, nil, nil)` 后断言五个嵌入指针**均非 nil**——原断言①是纯静态类型检查,故构造器漏装某条线时 build/vet/守卫全绿,故障以运行时 nil 解引用 panic 出现(M-7)。
|
||||||
|
>
|
||||||
|
> **两条被 Go 提升规则 spike 实测否决的修法**(勿再尝试,理由已写入 `architecture_test.go` 注释):(i) 「断言 `reflect.TypeOf(ContentService{}).NumMethod() == 0`」**不成立**——嵌入的是 `*T`,其方法**同时进入值类型与指针类型的方法集**,故合法树上值类型 NumMethod 已是 22,该断言**恒假**(P13 恒真断言的镜像形态)。(ii) 「比 `Method(i).Func` 的同一性以侦测遮蔽」**不成立**——提升方法本身是 forwarding wrapper,合法树上 `root.CoverURL.Func`(5153408) 与 `CatalogService.CoverURL.Func`(5148192) **已不同**,遮蔽后为 5148288,两侧都 false → **无法区分遮蔽与否**。
|
||||||
|
| **D-K** | 派发形态 | **一个实现者子代理**做完 T1→T3。所有任务同动 `crearte-server` 单一工作树与 git index | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13 同款 |
|
||||||
|
|
||||||
|
### §2.1 被否方案
|
||||||
|
|
||||||
|
| 方案 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **A:只拆文件、不拆类型**(`ContentService` 保持一个大 struct,方法分散到 6 个文件) | 消除不了 god object:每个 handler 仍声明对 22 方法的依赖,`catalog.go` 仍需 `nil` 占位。只解决「文件太长」这一个症状,不解决架构问题 |
|
||||||
|
| **B:删除 `ContentService`,五个 handler 各收子 service** | 真消除 god object,但 **19 个测试构造点 + 2 个生产构造点全部要改**,diff 与审查面显著变大,而 Route C 用嵌入拿到同样的架构收益且构造点零改动(spike H2/H4 实测)。B 可作为 Route C 之后的**渐进收尾**(若将来组合根不再被任何调用方需要) |
|
||||||
|
| **C:把五条线拆成五个独立包** | 过度。它们共享 `ContentStore` 与 11 个共享错误/类型,跨包会迫使这些声明全部导出并制造包间依赖;仓内惯例是 service 包内多文件多 struct(`account.go`/`bundle.go`/`auth.go`/`cleanup.go` 全在 `internal/service`) |
|
||||||
|
| **D:本批一并重构 `Approve` 170 行** | 见 D-F。两个关注点混在一批会让「行为不变」的验收判断变难 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 实现要求(逐条可核)
|
||||||
|
|
||||||
|
### 3.1 `internal/service/content.go`(拆分后 = 组合根 + 共享声明)
|
||||||
|
|
||||||
|
保留:`ErrContentNotFound`;`ContentService` struct(改为 5 个嵌入指针);`NewContentService`(4 参数签名不变,内部改为组装五个子 service,**不再赋 `now`**);11 个共享声明(`ErrSubmissionConflict`、`ErrWorkIDTaken`、`ErrVersionExists`、`ErrUploadUnavailable`、`ErrForbidden`、`submissionKinds`、`SubmissionEnvelope`、`ParseSubmissionEnvelope`、`SubmissionInput`、`SubmissionUpdate`,以及 `ErrUnsupportedMediaType` 迁走后其余保持原位)。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 组合根:五个职责线的嵌入组合。自身不声明任何方法——全部由嵌入提升。
|
||||||
|
// 保留它的唯一理由是让 19 个测试构造点与 serve.go 的装配零改动(spec §1.5 H2);
|
||||||
|
// 若将来无调用方需要「全量服务」,可整块删除(方案 B)。
|
||||||
|
type ContentService struct {
|
||||||
|
*CatalogService
|
||||||
|
*ReactionService
|
||||||
|
*UploadService
|
||||||
|
*SubmissionService
|
||||||
|
*ModerationService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContentService(store repository.ContentStore, users repository.UserRepository, objects storage.ObjectStorage, bundle *BundleService) *ContentService {
|
||||||
|
return &ContentService{
|
||||||
|
CatalogService: NewCatalogService(store, objects),
|
||||||
|
ReactionService: NewReactionService(store),
|
||||||
|
UploadService: NewUploadService(store, users, objects, bundle),
|
||||||
|
SubmissionService: NewSubmissionService(store, objects),
|
||||||
|
ModerationService: NewModerationService(store, users, objects),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **`now` 字段必须删除**(D-H)。删除后若 `time` 包在 content.go 内不再被使用,**必须同步删掉 import**(否则 `go build` 报 imported and not used)。实测 `content.go` 内 `time` 仅出现在 `now func() time.Time` 与 `now: time.Now` 两处 —— 实现者须自行 `grep -n 'time\.' internal/service/content.go` 确认后处置,**不要凭本句推断**。
|
||||||
|
|
||||||
|
### 3.2 五个子 service 文件
|
||||||
|
|
||||||
|
每个文件的形态(以 `reaction.go` 为例,最小依赖线):
|
||||||
|
|
||||||
|
```go
|
||||||
|
package service
|
||||||
|
|
||||||
|
// ReactionService = 反应职责线(收藏 / 评分)。只依赖 store(spec §1.4 实测)。
|
||||||
|
type ReactionService struct {
|
||||||
|
store repository.ContentStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReactionService(store repository.ContentStore) *ReactionService {
|
||||||
|
return &ReactionService{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reactionTarget 是私有 helper,仅本线内使用(spec §1.1:3 处调用全在反应线)
|
||||||
|
func (s *ReactionService) reactionTarget(...) error { ... }
|
||||||
|
|
||||||
|
func (s *ReactionService) SetFavorite(...) (...) { ... }
|
||||||
|
// RateWork / UnrateWork / UserReaction / MyReactions
|
||||||
|
```
|
||||||
|
|
||||||
|
**机械迁移规则**(逐条可核):
|
||||||
|
|
||||||
|
1. 方法接收者 `s *ContentService` → `s *<Line>Service`;**方法体逐字不动**(字段访问 `s.store`/`s.users`/`s.objects`/`s.bundle` 在子 service 上仍然有效,因为各子 service 有自己的同名字段)。
|
||||||
|
2. 私有 helper 随其唯一使用线迁移(`reactionTarget`→reaction、`checkSubmissionRules`/`checkUploadRefs`→submission、`randomToken`→upload、`versionFromUpload`/`pendingKeys`→moderation)。
|
||||||
|
3. `ErrUnsupportedMediaType` + `allowedCoverTypes` → `upload.go`(独占,§5c)。
|
||||||
|
4. 每个新文件的 import 块**只列该文件实际用到的包**(`goimports` 语义)。禁止照抄 content.go 的 16 个 import。
|
||||||
|
5. **不得改动任何方法签名、错误值文本、slog 字段、SQL/仓储调用顺序**。这是 D-G 行为不变契约的机械保证。
|
||||||
|
|
||||||
|
### 3.3 五个 handler 的窄接口(D-E)
|
||||||
|
|
||||||
|
每个 handler 文件内声明自己需要的接口,**方法签名逐字照抄 service 侧**(不得简化参数或返回值):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// games.go
|
||||||
|
// catalogPort 是本 handler 实际消费的最小面(spec §1.2:4 个方法)。
|
||||||
|
// 定义在消费方(Go 惯例;仓内先例 cmd/catalog.go:79 catalogRenderer)。
|
||||||
|
type catalogPort interface {
|
||||||
|
ListPublished(ctx context.Context, ...) ([]model.WorkWithVersion, map[string]model.WorkReaction, string, error)
|
||||||
|
GetPublishedDetail(ctx context.Context, ...) (model.WorkWithVersion, model.WorkReaction, string, error)
|
||||||
|
CoverURL(coverKey string) string
|
||||||
|
BundleURL(v model.WorkVersion) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Games struct {
|
||||||
|
svc catalogPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGames(svc catalogPort) *Games {
|
||||||
|
return &Games{svc: svc}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **`ListPublished` / `GetPublishedDetail` 的完整签名必须从 `content.go` 逐字复制**(含中间那个未命名 `string` 返回值 = ETag)。不要凭 spec 里的省略号推断。实现者须先 `grep -n 'func (s \*ContentService) ListPublished' -A2 internal/service/content.go` 取原文。
|
||||||
|
|
||||||
|
其余四个同理:`reactionPort`(5 方法)、`uploadPort`(2 方法)、`submissionPort`(5 方法)、`moderationPort`(6 方法)。`admin.go` 的 `bundle *service.BundleService` 参数**保持不变**(它不是 ContentService 的一部分)。`uploads.go` 的 `maxBundle, maxCover int64` 同样不变。
|
||||||
|
|
||||||
|
### 3.4 `cmd/serve.go` 与 `cmd/catalog.go`(D-I)
|
||||||
|
|
||||||
|
- `serve.go`:五处 `handler.NewXxx(contentSvc, ...)` **零改动**(spike H2:提升后的组合根满足各窄接口)。
|
||||||
|
- `catalog.go`:把
|
||||||
|
```go
|
||||||
|
contentSvc := service.NewContentService(
|
||||||
|
repository.NewPostgresContentStore(pool), repository.NewPostgresUserStore(pool), objects, nil)
|
||||||
|
count, err := exportCatalog(cmd.Context(), handler.NewGames(contentSvc), out, pretty)
|
||||||
|
```
|
||||||
|
改为直接构造 catalog 线(**删除 `nil` bundle 占位与 `NewPostgresUserStore(pool)`**):
|
||||||
|
```go
|
||||||
|
catalogSvc := service.NewCatalogService(repository.NewPostgresContentStore(pool), objects)
|
||||||
|
count, err := exportCatalog(cmd.Context(), handler.NewGames(catalogSvc), out, pretty)
|
||||||
|
```
|
||||||
|
并同步更新那条「bundle 不参与导出」的注释——**依赖面已从 4 槽降到 2 槽,`nil` 占位不再存在**。若 `repository.NewPostgresUserStore` 在该文件内因此不再被使用,须检查 import / 变量是否残留。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 边界(本批**不做**)
|
||||||
|
|
||||||
|
- **不改任何行为**:不动校验规则、错误语义、状态机、事务边界、slog 字段、HTTP 状态码映射。
|
||||||
|
- **不重构 `Approve` 内部**(D-F,登记挂账)。
|
||||||
|
- **不动 `AccountService` / `AuthService` / `BundleService` / `CleanupService`**(它们不持有 `*ContentService`,§1.6)。
|
||||||
|
- **不动 `memory_content.go`**(814 行,但实测是**测试替身**:除自身外零个非测试文件引用 `NewMemoryContentStore`,只 6 个 `_test.go` 用;属测试基建规模问题,非服务架构问题)。
|
||||||
|
- **不做 handler 错误映射去重**(`WriteError` 92 处,但 `errors.Is(err, service.ErrForbidden)` 仅 2 处 → 重复度不足以证明收益,候选 C,低于本批)。
|
||||||
|
- **不引入新依赖、不改 `go.mod`**。
|
||||||
|
- **不改任何测试的断言语义**(D-G)。若某个测试因构造函数签名变化而无法编译,**这本身说明拆分做错了**(D-A 的设计目标就是构造点零改动)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 测试计划
|
||||||
|
|
||||||
|
### T1 —— 架构守卫(**RED 先行**):`internal/service/architecture_test.go`(新增)
|
||||||
|
|
||||||
|
用 reflect 钉住拆分结构(D-J)。**七类断言**(①–③ 为原设计、④–⑦ 为 RE-PIN 后补,见 D-J 的增订说明):
|
||||||
|
|
||||||
|
1. **组合根形态**:`reflect.TypeOf(service.ContentService{})` 恰有 **5 个字段**,每个 `Anonymous == true`(嵌入)、`Kind == reflect.Ptr`,且 `Elem().Name()` 的集合恰为 `{CatalogService, ReactionService, UploadService, SubmissionService, ModerationService}`。
|
||||||
|
2. **各线导出方法名集合**(逐字,防方法迁移):
|
||||||
|
- `CatalogService` = `{ListPublished, GetPublishedDetail, CoverURL, BundleURL}`
|
||||||
|
- `ReactionService` = `{SetFavorite, RateWork, UnrateWork, UserReaction, MyReactions}`
|
||||||
|
- `UploadService` = `{CreateBundleUpload, CreateCoverUpload}`
|
||||||
|
- `SubmissionService` = `{CreateSubmission, ListMine, GetSubmission, UpdateSubmission, DeleteSubmission}`
|
||||||
|
- `ModerationService` = `{ListQueue, Approve, Reject, Unpublish, Republish, UpdateWorkFeatures}`
|
||||||
|
3. **死字段不复活**:`ContentService` 与五个子 service **都不得有名为 `now` 的字段**(D-H)。
|
||||||
|
4. **组合根方法集恰为提升之并**:`reflect.TypeOf(&ContentService{})` 的方法名集合逐字等于断言 2 五个集合的并(22 个)。**必须用指针类型**(子 service 方法全是指针接收者)。抓「组合根新增方法」。
|
||||||
|
5. **源码不得声明组合根方法**:扫描包内非测试 `.go`,任何匹配 `^func\s+\(\s*(?:\w+\s+)?\*?ContentService\s*\)` 的行都是违规。**接收者名必须是可选分组**(全分支终审 FR-1,important,已由 commit `e04694b` 修正):Go 允许省略不使用的接收者名,而遮蔽体恰好常常不需要它(`func (*ContentService) CoverURL(k string) string { return "X" }`);旧写法把名字当必需,故该形态下 build/vet/七条断言全绿而遮蔽**确实生效**。本扫描是**词法级**的:块注释内部以 `func (…ContentService)` 开头的行也会命中(FR-2 实测假红)——这是**刻意的过严**,不要为此放宽正则;若真被规约注释误伤,改法是剥掉 `/* */` 与 `//` 后再扫,而不是删掉这条断言。仓内有先例:`bundle/vector_test.go`、`cmd/catalog_test.go` 都用 `os.ReadFile`。
|
||||||
|
- ⚠️ **范围限定(终审 FR-1 推翻了我原先的全称声明)**:原文曾写「这是**唯一**能抓『遮蔽提升方法』的手段」。在 FR-1 修正**之前**这句为假(匿名接收者形态连它也抓不到)。修正后它在**导出与未导出方法的全部接收者形态**上成立,且范围比原文更宽:因 `reflect.Type.NumMethod()` **只暴露导出方法**,断言 4 的视野仅 22 个导出名,故**未导出的组合根方法也只有本断言能抓**(四轮 mutation 仅变方法名大小写即实测坐实:未导出两轮 A4 绿/A5 红,导出两轮 A4 红/A5 红)。
|
||||||
|
6. **各线字段名集合**:五个子 service 的字段名逐字等于 §1.4 的依赖面——`CatalogService={objects,store}`、`ReactionService={store}`、`UploadService={bundle,objects,store,users}`、`SubmissionService={objects,store}`、`ModerationService={objects,store,users}`。抓「重新长出内部状态」与「依赖面被悄悄放宽」。
|
||||||
|
7. **构造器不漏装**:`NewContentService(nil, nil, nil, nil)` 后五个嵌入指针均非 nil。传 `nil` 依赖是安全的:构造器只做纯组装、不解引用参数(既有测试已有多处以 `nil` bundle 构造,已证安全)。
|
||||||
|
|
||||||
|
**RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/` 必须以**编译错误**失败(`undefined: CatalogService` 等)。实现者须把该输出**逐字存证**到 `crearte-server/.superpowers/sdd-p14/impl-evidence/t1-red.txt`。Go 的 RED 不如 TS 那样能给出断言级消息,故**编译错误本身就是 RED 证据**——但必须存档,不能只在报告里描述。
|
||||||
|
|
||||||
|
**mutation 自查(实现者必做并附输出)**:守卫写完后,逐个验证它有牙:
|
||||||
|
- (a) 把某个方法(如 `CoverURL`)从 `catalog.go` 移回 `content.go` 并改接收者为 `*ContentService` → 断言 2 必须红;
|
||||||
|
- (b) 给 `ContentService` 加一个具体字段(如 `store repository.ContentStore`)→ 断言 1 必须红;
|
||||||
|
- (c) 给任一子 service 加回 `now func() time.Time` → 断言 3 必须红。
|
||||||
|
- (d) 在组合根**新增**一个方法(不移除任何子 service 方法)→ 断言 4 **与** 断言 5 必须红;
|
||||||
|
- (e) 在组合根**遮蔽**一个提升方法(**保留**子 service 原件)→ 断言 5 必须红、**断言 4 必须保持绿**(这正是断言 5 不可省的证明);
|
||||||
|
- (f) 给任一子 service 加一个**非 `now`** 的字段(如 `clock func() int64`)→ 断言 6 必须红、断言 3 必须保持绿;
|
||||||
|
- (g) 给某条线**放宽依赖面**(如给 `CatalogService` 塞 `users`)→ 断言 6 必须红;
|
||||||
|
- (h) 从 `NewContentService` 删掉某条线的组装行 → 断言 7 必须红。
|
||||||
|
每次 mutation 后 `git checkout -- <file>` 恢复并核 `git diff` 空。
|
||||||
|
|
||||||
|
> ⚠️ **RE-PIN(审查者 §12-1):mutation (a) 的原设计有一条绕行路径。** (a) 之所以会红,是因为它同时把方法从 `CatalogService` **拿走**了(断言 2 的集合缺员),**不是因为断言侦测到组合根上多出方法**。若把它改成「**复制**一份到组合根而保留 `catalog.go` 原件」(即 (e) 的遮蔽形态),(a) 的设计就会**误绿**——这是我 spec 里 positive control 自身的一个未被发现的盲区,由 (d)(e) 补上。
|
||||||
|
>
|
||||||
|
> ⚠️ **RE-PIN(控制者自伤教训):mutation 验证脚本的 `restore()` 绝不能用 `git checkout -- .`。** 若 fix-forward 编辑**尚未提交**,全量回滚会把它一起抹掉,导致后续各轮**全在测一棵没有修复的树**(本会话实测:第一轮后四条新断言被抹掉、`architecture_test.go` 回到 118 行,后四轮报「绿=3」= 原始三断言数,汇总显示四条「未按预期」)。修法:`restore()` 只回滚 mutation **触及的那几个源文件**,且每轮恢复后断言守卫文件的 `func Test` 计数仍为预期值,否则 **FATAL 终止**——让脚本在被自毁时大声失败,而不是静默产出「未按预期」的假结论。
|
||||||
|
>
|
||||||
|
> ⚠️ **遮蔽型 mutation 的签名必须逐字取原文。** 控制者第一版 (e) 用正则 `^func \(s \*CatalogService\) (Approve\(.*?\))` 抽签名,非贪婪 `.*?` 把返回值 `(error)` 截断了 → 生成的遮蔽体 `return nil` 与空返回值冲突 → **编译红而非断言红**,脚本却把它报成「守卫拦住了」。mutation 没编译过 ≠ 守卫有牙。
|
||||||
|
|
||||||
|
### T2 —— 提取五个子 service(T1 由红转绿)
|
||||||
|
|
||||||
|
按 §3.1–§3.2 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**(`git diff --stat` 里不应出现任何 `*_test.go`,除 T1 新增的那个)。
|
||||||
|
|
||||||
|
### T3 —— 收窄五个 handler + 消除 catalog.go 的 nil 占位
|
||||||
|
|
||||||
|
按 §3.3–§3.4 执行。**验收硬指标**:
|
||||||
|
- `grep -rn 'service.ContentService' internal/handler/` → **零命中**(五个 handler 全部改用窄接口);
|
||||||
|
- `cmd/catalog.go` 内 **`nil` 占位消失**、`NewPostgresUserStore` 不再被该构造使用;
|
||||||
|
- `cmd/serve.go` **零改动**(spike H2 的可核推论;若被迫改动,说明窄接口方法集抄漏了);
|
||||||
|
- 既有 handler 测试(`admin_test.go`/`games_test.go`/`reactions_test.go`/`uploads_test.go`/`integration_test.go` 等)**断言零修改**。
|
||||||
|
|
||||||
|
### 四门验收(dockerized Go,AGENTS.md 红线)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/src:/src" -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...'
|
||||||
|
```
|
||||||
|
|
||||||
|
基线(P14 起点,已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 包最慢 ~11.5s,含真库集成)。**host Go 1.18 不可用**;`proxy.golang.org` 从本机不可达,缺 `-e GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → 用 background + 耐心 poll。
|
||||||
|
|
||||||
|
### 结构指标(交付时须报实测数字)
|
||||||
|
|
||||||
|
> **RE-PIN(审查者 F5):行数口径统一为 `wc -l`**(POSIX:数换行符),与 §0 的基线 856、`auth.go` 234 同口径——已实测核对:`git show dbf7fe5:src/internal/service/content.go | wc -l` = **856**、`wc -l auth.go` = **234**。**不要用 `len(text.split('\n'))`**:文件以换行结尾时它会多算一个空段(本批实测 content.go `wc -l`=82 而 split=83,六文件最大 `wc -l`=298 而 split=299,控制者据此报错过一轮数字)。本批两种口径都远低于目标,无实际影响;但若将来某文件恰落在边界(如 299 vs 300),口径歧义会直接决定 pass/fail。
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 | 实测(`wc -l` 口径) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `content.go` 行数 | 856 | **< 120**(组合根 + 共享声明) | **82** ✅ |
|
||||||
|
| 六个文件最大行数 | 856 | **< 300**(submission.go 预计 ~290) | **298**(`moderation.go`,非 spec 预估的 submission.go)✅ |
|
||||||
|
| `service/` 目录内 >400 行的生产文件 | 1(content.go) | **0** | **0** ✅ |
|
||||||
|
| handler 里 `service.ContentService` 引用 | 10 处 | **0** | **0** ✅ |
|
||||||
|
| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** | **0** ✅(`NewPostgresUserStore` 亦归零) |
|
||||||
|
| `ContentService` 自身声明的方法数 | 25 | **0**(全部提升) | **0** ✅ |
|
||||||
|
| 既有 `*_test.go` 修改文件数 | — | **0** | **0** ✅(仅新增 `architecture_test.go`) |
|
||||||
|
|
||||||
|
六文件实测行数(`wc -l`):`content.go` 82 · `catalog.go` 110 · `reaction.go` 102 · `upload.go` 99 · `submission.go` 291 · `moderation.go` 298。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 记账
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.18.0] - 2026-10-03`**(插 `## [0.17.2]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.7] - 2026-10-03`**(插 `## [0.3.6]` 前),小节 `### Done / 完成`。
|
||||||
|
- `docs/ROADMAP.md`:新增 P14 行(P13 行之后)+ 文档索引表新增 P14 行(P13 索引行之后)。**哈希引用 merge commit**(P12/P13 惯例:ROADMAP 引合并提交、记账 commit 另列)。
|
||||||
|
- **`go.mod` / 任何 version 文件不 bump**(Go 服务无 package.json 类版本文件;仓内版本约定只体现在 CHANGELOG)。
|
||||||
|
- 挂账新增:`Approve` 170 行的 7 阶段内部重构(D-F)、`memory_content.go` 814 行测试替身规模、handler 错误映射去重(候选 C)、`AccountService`/`CleanupService` 的 `now` 字段使用情况复核(本批只确证 `ContentService.now` 死,未审其它)。
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §7 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| **嵌入提升在某处不满足窄接口** → 编译失败 | `go build ./...` 即验证。玩具 spike 已证机制(H2/H4),真签名的验证交给编译器;若失败,说明 §3.3 的方法集抄漏,按编译错误补全而非放宽接口 |
|
||||||
|
| **方法迁移时漏改字段名或误改方法体** | D-G + §3.2 规则 5「方法体逐字不动」。审查者用 `git diff` 逐函数比对;`go test ./...` 11 包(含 api 层真库集成 11.5s)兜住行为 |
|
||||||
|
| **import 块照抄导致 `imported and not used`** | §3.2 规则 4:每个文件只列实际用到的包。`go build` 会强制暴露 |
|
||||||
|
| **`time` import 在 content.go 变悬空**(删 `now` 后) | §3.1 已点名,但**要求实现者自行 grep 确认**而非凭 spec 推断 |
|
||||||
|
| **测试被迫修改 = 设计失败的信号** | §4 末条 + T3 验收硬指标「既有 `*_test.go` 修改文件数 = 0」。若实现者发现必须改测试,**停下来报告**而不是改 |
|
||||||
|
| **架构守卫本身写成败 assertion** | T1 的 mutation 自查(a–h)必须附红/绿输出。P13 教训:`max(a,b) ≥ k` 形态恒真、positive control 只钉一个子树 → **守卫自身要受同等审视**。**P14 实证了这条风险的两种形态**:(i) 恒真(P13)与恒假(本批被否决的 `NumMethod()==0` 修法)都要防;(ii) **守卫覆盖不全**比恒真更隐蔽——本批三条断言各自都真有牙(M-1b/M-2/M-5 双向验证实证),但合起来漏掉了「组合根长方法」整个维度,且 spec 自己的 positive control (a) 有绕行路径(见 §5-T1 的 RE-PIN) |
|
||||||
|
| **RE-PIN:组合根上直接访问依赖会编译失败(Route C 的额外性质,非缺陷)** | 组合根 `ContentService` **零具名字段**,故 `s.store` / `s.users` / `s.objects` / `s.bundle` 在组合根上都是 **ambiguous selector**(`catalog`/`upload`/`submission`/`moderation` 四个子 service 都有 `objects`,Go 提升规则下深度相同即歧义)。要访问须限定为 `s.CatalogService.objects` 这种形式。**这是比架构守卫更早的一道防线**:往组合根直接摸依赖的新代码编译期即失败。反过来也解释了为何 mutation (b) 加**具体字段**后守卫是唯一防线——加具体字段本身在 Go 里可编译(不与嵌入冲突) |
|
||||||
|
| **RE-PIN:`go vet` 不报告组合根方法对提升方法的遮蔽** | 审查者 M-9 实测 `vet_exit=0`:在组合根上写一个与提升方法同名同签名的方法(Go 深度规则下 depth 0 胜出)会**静默接管**全部调用,lint 层零信号。故断言 5(源码扫描)不可省——反射方法名集合在遮蔽后不变、断言 4 会保持绿(已由 mutation (e) 实证)。**这两条同属「组合根一旦长出代码就静默出问题」的同一风险面**,也是 §2.1 方案 B(将来若删除组合根)的收尾条件之一:只要组合根存在,就必须有断言 4+5 守着它。⚠️ **RE-PIN(终审 FR-1):这句曾是全称声明而范围为假**——断言 5 原正则要求接收者**有名**,故匿名接收者形态(`func (*ContentService) M(...)`)连它也抓不到,而遮蔽确实生效。现已由 `e04694b` 改为可选分组,该全称声明在**全部接收者形态**上成立;另因 `NumMethod()` 只暴露导出名,断言 4 对**未导出**的组合根方法是盲的,那部分同样只能靠断言 5。**教训:「唯一手段」这类全称声明写下时就要枚举形态(有名/匿名、值/指针、导出/未导出),否则它会成为下一个审查者的反例靶子** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §8 实现纪律(P11–P13 累计教训,逐条来自真实事故)
|
||||||
|
|
||||||
|
1. **不推断,只实测。** 任何「应该是 / 大概 / 按惯例」都要跑一条命令确认。P13 控制者在勘查期栽两次(用运行时注入验证构建期烘焙、用 `APPLY.toString()`+`new Function` 序列化注入),复核期写错断言/grep/锚点 **13 次**。
|
||||||
|
2. **断言/grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P13 实例:核不得回退项时两条 grep 返 0 而实际零变更(漏了中间三个类名 / 类序不同);查产物死 utility 时手写反斜杠转义返 0 而它真在(应用 `grep -F`);做归属分析时把**声明行**算成使用行。
|
||||||
|
3. **修守卫必须两个方向都验**:对合法值不误红 **且** 在 mutation 下不误绿。P13 控制者第一次纠正 scrim 判据时只验了前者,交付了一个**数学恒真**的断言(`max(填充侧,边框侧) ≥ 3`,下界 √16.50=4.0621>3);同一毛病在 alpha 兜底分支重演(只对当前浏览器输出验,漏了它声称支持的 CSS Color 4 形态)。
|
||||||
|
4. **`max(a,b) ≥ k` 是恒真断言高发区**:当 a、b 互补时 max 有非平凡下界,阈值低于该下界即永不可能失败。
|
||||||
|
5. **全称声明需要全称范围的证据。** P13 的 spec 写「accent 是全仓唯一作背景的地方」为假——spike 0 的 grep 与新守卫都只覆盖 `app/`,而 `runtime/` 与之**同级**。本批同理:任何「全仓零引用」「唯一使用点」的断言,grep 范围必须覆盖 `cmd/` + `internal/` 全部子树,且**区分声明行与使用行**。
|
||||||
|
6. **链式命令里别放会因「无匹配」而退 1 的 grep**(`grep -c` 输出 0 即退 1、`grep -v` 无剩余即退 1),会静默中断 `&&` 链导致后续步骤(如 commit)未执行。P13 因此丢过一次 commit。
|
||||||
|
7. **相对路径 `cd` 在循环里会漂移。** P13 收口对账时 `for r in ...; do cd $r; ...; cd ..; done` 让后三轮跑进错误目录,输出一堆 `fatal` 并**误报一个仓 porcelain=15**。用绝对路径 + 每仓独立子 shell。
|
||||||
|
8. **一仓一写者**(`sdd-parallel-dispatch` §1):所有任务同动 `crearte-server` 单一工作树,故交**一个**子代理,不并行争用 `.git/index.lock`。
|
||||||
|
9. **登记挂账前先 grep 既有测试与文档**,否则会开出「补一条已存在的测试」这类伪工作(P13 实例:`router_test.go:62` 早已钉住某边界,而我在 plan 里建议「补反面钉桩」)。
|
||||||
|
10. **合并纪律**:inner repo 提交前必须 `git branch --show-current` 确认不在 master;合并用 `--no-ff`;推送**禁管道**(P13 遇过 push 管道假绿);对账用 `git ls-remote` 且**必须校验变量非空**(空变量会让 `[ "$L" = "$R" ]` 假 MATCH)。
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# P15-A 设计:拆分 `Approve` 169 行七阶段(crearte-server)
|
||||||
|
|
||||||
|
- **日期**:2026-10-03
|
||||||
|
- **仓**:`crearte-server`(Go module 根在 `src/`)
|
||||||
|
- **分支**:`chore/p15a-approve-phases`(AGENTS.md:18 只允许 `{feat|fix|docs|chore}/`)
|
||||||
|
- **base**:`fe810dd`(P14 merge,master)
|
||||||
|
- **维度**:代码优雅(USER.md 六维度循环)
|
||||||
|
- **前置**:P14 已交付(`ContentService` 拆分 + 七条架构守卫)。**本批不得改动 `architecture_test.go`**,除非某条断言因新 helper 变红(见 §5-T1 的 mutation (g))。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 基线(已实测,2026-10-03 12:50)
|
||||||
|
|
||||||
|
**四门**(dockerized `golang:1.24-alpine`,AGENTS.md 硬红线;host Go 1.18 不可用):**`test -z "$(gofmt -l .)"` exit 0**(⚠️ **不能用 `gofmt -l . ; echo $?`**:`gofmt -l` 的语义是「列出需要格式化的文件」,**列出时仍 exit 0**,只在解析失败时 exit 2 → 旧配方的 `gofmt_exit=0` 不是证据。审查者 F3 实测。同时须把 `gofmt -l .` 的**原始输出**一并存证以证明为空)· `go vet ./...` exit 0 · `go build ./...` exit 0 · `go test ./... -count=1` **11 包 ok**(`internal/api` ~11.0s、`internal/service` ~3.7s)。⚠️ **这两个耗时是 mock 路径**:未设 `TEST_DATABASE_URL` 时真库测试**静默 SKIP**(实测 `internal/api` 4 个、全仓 `--- SKIP` 行 26 条)。受该变量门控的 Test 函数**全仓共 11 个**:`cmd` 2(`TestRewrapRotatesVersionsInPostgres`、`TestUserSetRoleCommand`)· `api` 4(`TestFullPublishChainOnPostgres`、`TestHostedPublishChainOnPostgres`、`TestAccountDeletionChainOnPostgres`、`TestAdminConsoleEndpointsOnPostgres`)· `repository` 2(`TestPostgresReactionGated`、`TestUserUsernameBackfillMigration`)· `service` 3(`TestApproveConcurrentOnlyOneWins`、`TestApproveSameWorkIDConcurrent`、`TestApproveOptionalFieldsRoundTrip`)。**「11 包 ok」不等于真库路径被验过**:本批实现者、任务级审查者、fix-forward 与控制者的**所有**跑批都 SKIP 了它们(§1.6 点名的两个并发/TOCTOU 守卫正在其中),直到全分支终审另起 `postgres:16-alpine` 对 base 与 HEAD 各跑全量 `-p 1 -v` 才补上这一层:**base `fe810dd` 194 PASS / 0 FAIL vs HEAD `b15c2a8` 199 PASS / 0 FAIL**(+5 恰为本批新增守卫),状态多重集完全一致、5 个 DB 守卫两树均真跑 PASS —— 这是「零行为变更」在 postgres 生产路径上的第一份运行时证明(此前所有证据都是静态的:T3 语句级比对、needle/标识符计数、mock 路径测试)。**合并前验证清单须含 DB-enabled 对照,否则下一批仍会静默 SKIP。**
|
||||||
|
|
||||||
|
**目标函数**:`internal/service/moderation.go:47-215` = **169 行**(`wc -l` 口径,P14 spec §5 已钉该口径)。
|
||||||
|
|
||||||
|
**`service/` 包内非测试文件的函数长度分布**(78 个函数,实测普查):
|
||||||
|
|
||||||
|
| 阈值 | 超过的函数数 | 清单 |
|
||||||
|
|---|---|---|
|
||||||
|
| >100 行 | **1** | `*ModerationService.Approve`(169) |
|
||||||
|
| >80 行 | 2 | + `*AccountService.deleteAccountLocked`(83) |
|
||||||
|
| >70 行 | 4 | + `*SubmissionService.UpdateSubmission`(76)、`ValidateWorkFields`(75) |
|
||||||
|
| >60 行 | 7 | + `*ImportService.ImportDir`(70)、`*CleanupService.Run`(70)、`*SubmissionService.checkSubmissionRules`(66) |
|
||||||
|
|
||||||
|
→ **`Approve` 是包内唯一 >100 行的函数**,且比次大者(83)大一倍。这就是本批的选型依据:不是"文件太长",是**单个函数承担了七件事**。
|
||||||
|
|
||||||
|
**行数口径**:一律 `wc -l`。**不要用 `len(text.split('\n'))`**——文件以换行结尾时后者多算一个空段(P14 F5:控制者与任务级审查者为此报出 83/299 与 82/298 两套数字)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 取证(决定设计,全部实测)
|
||||||
|
|
||||||
|
### 1.1 七阶段边界(`moderation.go` 真实行号;P14 挂账里 base 树的 606–772 **已作废**)
|
||||||
|
|
||||||
|
| 阶段 | 行 | 内容 | 返回的错误 | 是否碰 `tx` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| ① 加载 + 状态校验 + 解析 | 48–62 | `Submissions().GetByID` → `ErrNotFound` 映射 `ErrContentNotFound`;`sub.Status != Pending` → `ErrSubmissionConflict`;`ParseSubmissionEnvelope(sub.Payload)`;`p := env.Work` | `ErrContentNotFound` / `ErrSubmissionConflict` / envelope 解析错 | 否 |
|
||||||
|
| ② owner 查取 | 64–70 | **仅** `Kind == NewWork` 时 `s.users.GetByID(sub.SubmitterID)` | `service: resolve submitter: %w` | 否 |
|
||||||
|
| ③ 上传解析 | **72–86** | `BundleUploadID`/`CoverUploadID` 各 `Uploads().GetByID`,并校验 `up.ConsumedBy == sub.ID` | `ErrUploadUnavailable` | 否 |
|
||||||
|
| ④ **乐观预检** switch | **88–101** | `NewWork`:`Works().GetByID` 已存在 → `ErrWorkIDTaken`;`NewVersion`:`Versions().Get` 已存在 → `ErrVersionExists`;其它 kind 无 case | `ErrWorkIDTaken` / `ErrVersionExists` / **`service: approve precheck: %w`** | 否(用 `s.store`) |
|
||||||
|
| ⑤ 对象 Copy | 102–123 | `finalBundleKey = "bundles/"+sub.WorkID+"/"+p.Version+".bin"`;`finalCoverKey = "covers/"+sub.WorkID+"/"+coverUpload.SHA256+path.Ext(...)`;各 `s.objects.Copy` | `ErrUploadUnavailable`(`storage.ErrObjectNotFound`)/ `service: copy bundle\|cover: %w` | 否(用 `s.objects`) |
|
||||||
|
| ⑥ **`WithTx` 事务** | **125–201** | `GetByIDForUpdate` 锁行 → **重校** `Status == Pending` → **悲观重检** switch(三分支)→ 建 Work / Version / Touch / UpdateMetadata → `MarkReviewed` | 见 §1.2 | **是**(`tx repository.ContentRepository`) |
|
||||||
|
| ⑦ 事务错误映射 + pending 清理 + slog | **202–214** | 事务错误映射(`repository.ErrConflict` → `ErrSubmissionConflict`);`pendingKeys(bundleUpload, coverUpload)` 逐个 `s.objects.Delete`(**只 `slog.Error` 不返回**,best-effort);`slog.Info("approve", …)`;`return nil` | `ErrSubmissionConflict` / 裸 `err` | 否 |
|
||||||
|
|
||||||
|
**`WithTx` 签名**(`internal/repository/content.go:38`):`WithTx(ctx context.Context, fn func(ContentRepository) error) error`。
|
||||||
|
|
||||||
|
### 1.2 🔴 阶段④与阶段⑥的 switch **不同形,禁止合并**(本批最重要的约束)
|
||||||
|
|
||||||
|
两者都 `switch sub.Kind`、都查 `Works().GetByID` / `Versions().Get`、都可能返回 `ErrWorkIDTaken`/`ErrVersionExists`,故看上去可以抽成一个"重复性检查" helper 共用。**实测比对后确认不可合并**,四处实质差异:
|
||||||
|
|
||||||
|
| 差异 | 阶段④(88–101) | 阶段⑥(136–199) |
|
||||||
|
|---|---|---|
|
||||||
|
| **kind 覆盖** | 只有 `NewWork` / `NewVersion` 两个 case | 三个 case:`NewWork` / `NewVersion` / **`MetadataChange`** |
|
||||||
|
| **`NewVersion` 的额外校验** | 只查 `Versions().Get` 是否已存在 | **还查** `work.Runtime != model.RuntimeVirtual \|\| bundleUpload == nil` → `ErrSubmissionConflict`;且 `Versions().Create` 后**还调** `tx.Works().Touch` |
|
||||||
|
| **`NewWork` 的动作** | 只查冲突,不建实体 | `PayloadToWork(p)` + 设 `OwnerID`/`CoverKey` + `tx.Works().Create`(`ErrConflict` → `ErrWorkIDTaken`)+ 条件建 Version |
|
||||||
|
| **错误文案** | 非 `ErrNotFound` 的错误包成 **`service: approve precheck: %w`** | **裸返 `err`**,无包装 |
|
||||||
|
|
||||||
|
**语义差异**(不可合并的根本原因):④是**乐观快失败**——无锁、在**任何对象 Copy 之前**,失败时副作用为零;⑥是**悲观重检**——持 `SELECT … FOR UPDATE` 行锁、在对象**已 Copy 之后**,失败时须靠阶段⑦清理 pending 对象。合并成一个 helper 会让"检查"与"写入"的边界模糊,且**改变错误文案**(`service: approve precheck: …` 会消失或蔓延到事务内),属行为变更。
|
||||||
|
|
||||||
|
→ **裁定:④与⑥各自独立拆 helper,不共享代码。** 表面重复是**有意的双层校验**(TOCTOU 防护:预检减少无谓 Copy,重检保证正确性),不是可消除的冗余。spec 里写明这条,防实现者"顺手 DRY"。
|
||||||
|
|
||||||
|
### 1.3 helper 形态:**未导出方法与包级函数都不触架构守卫**(实测,推翻 P15 账本 §8.1 的说法)
|
||||||
|
|
||||||
|
账本原写"新 helper 应是包级 `func`,因为方法会改动 `expectedLineFields`/断言 2 的方法集并触发守卫红"。**这句是错的**,已用两轮 mutation 实测推翻:
|
||||||
|
|
||||||
|
| 探针 | 追加到 `moderation.go` 的声明 | `go build` | 七断言 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A | `func (s *ModerationService) approvePrecheckLocked(ctx context.Context) error { return nil }`(未导出**方法**) | exit 0 | **7 PASS / 0 FAIL** |
|
||||||
|
| B | `func approvePrecheck(store repository.ContentStore, sub model.Submission) error { return nil }`(**包级**函数) | exit 0 | **7 PASS / 0 FAIL** |
|
||||||
|
|
||||||
|
**机制**:断言 2 `TestLineExportedMethods` 用 `m.IsExported()` 过滤,断言 4 遍历的 `reflect.Type.NumMethod()` **本身只暴露导出方法** → 未导出方法根本不在两条断言的视野内。断言 6 只看**字段**,不看方法。
|
||||||
|
|
||||||
|
→ **形态选择依据是"是否需要接收者字段",不是守卫约束**:
|
||||||
|
- 需要 `s.store` / `s.objects` / `s.users` 的 → **未导出方法**(体例:`account.go:78` 的 `func (s *AccountService) deleteAccountLocked(ctx, user) (DeletionReport, error)`,注释「共享内核,调用方已确认账号存在且未注销」,两处调用)
|
||||||
|
- 只需 `tx` + 已备好的数据 → **包级函数**(体例:`moderation.go:283` `versionFromUpload(workID, version, objectKey, up)`、`:290` `pendingKeys(uploads ...*model.Upload)`)
|
||||||
|
|
||||||
|
⚠️ **不得给 `ModerationService` 加字段**(断言 6 会红:`expectedLineFields["ModerationService"] = {objects, store, users}`);**不得在 `ContentService` 上加任何方法**(断言 5 源码扫描会红)。
|
||||||
|
|
||||||
|
### 1.4 `ContentStore` 内嵌 `ContentRepository`(决定事务 helper 的签名)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/repository/content.go:28-39
|
||||||
|
type ContentRepository interface {
|
||||||
|
Works() WorkRepository
|
||||||
|
Versions() WorkVersionRepository
|
||||||
|
Submissions() SubmissionRepository
|
||||||
|
Uploads() UploadRepository
|
||||||
|
Reactions() ReactionRepository
|
||||||
|
}
|
||||||
|
type ContentStore interface {
|
||||||
|
ContentRepository
|
||||||
|
WithTx(ctx context.Context, fn func(ContentRepository) error) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ **`ContentStore` 是 `ContentRepository` 的超集**。故收 `repository.ContentRepository` 的 helper **既能接 `s.store`(阶段④)也能接 `tx`(阶段⑥)**。但**本批刻意不用这个能力去合并④⑥**(见 §1.2);它只用于让事务体 helper 的签名收 `tx`。
|
||||||
|
|
||||||
|
### 1.5 `ParseSubmissionEnvelope` 的返回类型(决定 plan 结构体字段类型)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/service/content.go:54-60
|
||||||
|
type SubmissionEnvelope struct {
|
||||||
|
Work WorkPayload `json:"work"`
|
||||||
|
BundleUploadID string `json:"bundle_upload_id,omitempty"`
|
||||||
|
CoverUploadID string `json:"cover_upload_id,omitempty"`
|
||||||
|
}
|
||||||
|
func ParseSubmissionEnvelope(raw []byte) (SubmissionEnvelope, error) // 值返回,非指针
|
||||||
|
```
|
||||||
|
`Approve` 内 `p := env.Work`(`WorkPayload` 值)。**plan 结构体应持有 `p WorkPayload` 值而非 `env` 指针**——与现有代码逐字一致,避免引入 nil 判定分支(那会是行为变更)。
|
||||||
|
|
||||||
|
### 1.6 既有测试覆盖:**充分,不需要先补 characterization 测试**
|
||||||
|
|
||||||
|
`Approve` 有 5 个直接调用点(`internal/service/content_test.go:91,205,268,284,359`)与 13 个相关测试函数:
|
||||||
|
|
||||||
|
| 测试 | 覆盖的阶段/分支 |
|
||||||
|
|---|---|
|
||||||
|
| `content_test.go:18 TestApproveConcurrentOnlyOneWins` | ⑥ 的行锁 + 重校状态(并发只有一个赢) |
|
||||||
|
| `content_test.go:126 TestApproveSameWorkIDConcurrent` | ④/⑥ 的 `ErrWorkIDTaken`(断言文本:`loser error = %v, want ErrWorkIDTaken or ErrSubmissionConflict`) |
|
||||||
|
| `content_test.go:237 TestApproveMetadataChangeFeaturesSemantics` | ⑥ 的 `MetadataChange` 分支 + **`Features` 三态语义**(用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分 nil=保留 / 空 map=清空) |
|
||||||
|
| `content_test.go:293 TestApproveOptionalFieldsRoundTrip` | ①② 的 envelope 解析与字段往返 |
|
||||||
|
| `content_hosted_test.go:76 TestMetadataChangeRuntimeImmutable` | ⑥ 的 Runtime 不可变约束 |
|
||||||
|
| `namespace_test.go:132 TestNewVersionMetadataChangeOwnership` | ⑥ 的 OwnerID 归属 |
|
||||||
|
| `api/admin_test.go:83,131,149,197,231`(5 个) | HTTP 层:发布、Forbidden+DoubleApprove、同 slug 异命名空间、NewVersion、MetadataChange |
|
||||||
|
| `api/integration_test.go:27 TestFullPublishChainOnPostgres`、`api/hosted_postgres_test.go:28 TestHostedPublishChainOnPostgres` | ⑤⑥ 的完整发布链(真库) |
|
||||||
|
|
||||||
|
→ **既有测试就是本批的行为守卫**(与 P14 D-G 同构)。验收硬指标:**既有 `*_test.go` 修改数 = 0**。若实现者发现必须改测试,**停下来报告**而不是改(P14 §4 末条)。
|
||||||
|
|
||||||
|
### 1.7 调用面:`Approve` 只有一个非测试调用点
|
||||||
|
|
||||||
|
`internal/handler/admin.go:62`:`h.svc.Approve(c.Request.Context(), CurrentUser(c).ID, c.Param("id"), body.Note)`(经 `moderationPort` 窄接口)→ **新 helper 无须导出**,且 `Approve` 的**签名与错误语义必须逐字不变**(`moderationPort` 由 P14 断言 2 钉住 6 个方法名,签名变更会打破 `serve.go` 的接口满足性 → 编译红)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 设计决策
|
||||||
|
|
||||||
|
| # | 决策点 | 裁定 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D-A** | 拆分粒度 | **按七阶段拆 5 个 helper**(①②③ 合为一个"输入装载"、④ 一个、⑤ 一个、⑥ 一个(内部再拆 3 个 kind 分支)、⑦ 一个),不逐行拆 | §1.1 的阶段边界是**语义边界**(副作用发生点),不是任意切点。①②③ 都只读、都产出 plan 的字段、失败时零副作用 → 合为一个 helper 不损失可读性;④⑤⑥⑦ 各自有独立副作用(预检/Copy/事务/清理)→ 必须分开 |
|
||||||
|
| **D-B** | plan 结构体 | 新增**包级私有 struct** `approvePlan`,字段:`sub model.Submission`、`p WorkPayload`、`owner model.User`、`bundleUpload *model.Upload`、`coverUpload *model.Upload`、`finalBundleKey string`、`finalCoverKey string` | 七阶段之间传递的就是这七样东西(实测自函数体)。用 struct 而非 7 个返回值:Go 的多返回值超过 4 个即难读,且 ⑤ 要往 plan 里写 `finalBundleKey`/`finalCoverKey` 供 ⑥⑦ 用 |
|
||||||
|
| **D-C** | helper 形态 | ①②③④⑤⑦ → **未导出方法**(需 `s.store`/`s.users`/`s.objects`);⑥ 事务体 → **包级函数** `applyApprovalInTx(ctx, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string) error`;⑥ 内三个 kind 分支 → **包级函数** | §1.3 实测两种形态都不触守卫;选择依据是"是否需要接收者字段"。事务体不碰 `s.*`(只用 `tx`)→ 包级函数更纯,且签名自证"事务内不得访问 store 外的东西"。体例分别同 `deleteAccountLocked` 与 `versionFromUpload` |
|
||||||
|
| **D-D** | ④与⑥**不合并** | 各自独立 helper,不共享代码 | §1.2:四处实质差异 + 乐观/悲观语义不同 + 错误文案不同。表面重复是**有意的 TOCTOU 双层校验** |
|
||||||
|
| **D-E** | 事务边界与 Copy 顺序 | **一行不动**:⑤ 的两次 `s.objects.Copy` 必须在 ⑥ `WithTx` **之外**(故 ⑦ 需要清理);⑥ 的全部写入必须在 `WithTx` **之内**;`MarkReviewed` 必须是事务内**最后一个**调用 | 改变顺序即行为变更:Copy 进事务会让长耗时 I/O 持锁;`MarkReviewed` 提前会让"实体建成但状态未改"的中间态可被并发观察到 |
|
||||||
|
| **D-F** | `Features` 三态语义 | `if p.Features == nil { work.Features = existing.Features }` **连注释一起搬**进 MetadataChange helper,**不得"简化"** | 该注释原文:「旧提交(features 采集上线前创建)的 payload 无 features 键:保留现值,避免审批通过时静默清空已发布作品的运行权限;显式 `{}` 才是清空」。三态(nil=保留 / 空 map=清空 / 有值=覆盖)由 `content_test.go:237` 精确钉住,简化即红 |
|
||||||
|
| **D-G** | 零行为变更 | 纯结构重构:**方法体逐字迁移**,只改接收者/参数传递与缩进。`Approve` 的签名、返回的每个错误值与错误文案、`slog` 的两条日志(键名与顺序)**全部逐字不变** | P14 同款契约。验收:既有测试零修改 + 四门绿 + 字节级方法体比对 |
|
||||||
|
| **D-H** | 结构指标钉什么 | **钉「最大单函数行数」,不钉文件行数** | helper 仍在 `moderation.go`(298 行)内,拆完文件**可能不降反升**。用文件行数当指标会逼实现者把 helper 塞进新文件,那是无意义的文件增殖 |
|
||||||
|
| **D-I** | 派发形态 | **一个实现者子代理**做完 T1→T3(同动 crearte-server 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款。**P15-B 在 crearte 仓,与本批可并行**(两仓各自单写者) |
|
||||||
|
|
||||||
|
### §2.1 被否方案
|
||||||
|
|
||||||
|
| 方案 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **A:把④⑥的 switch 抽成共享 helper(DRY)** | §1.2 实测四处差异 + 乐观/悲观语义不同。合并会改变错误文案(`service: approve precheck: %w` 消失或蔓延)与 `NewVersion` 分支的校验集 → **行为变更**,违反 D-G |
|
||||||
|
| **B:用 `repository.ContentRepository` 参数统一④⑥(机械上可行)** | §1.4 证明 `ContentStore` 内嵌 `ContentRepository`,故技术上能让一个 helper 同时接 `s.store` 与 `tx`。但"能"不等于"该":它把①的乐观预检与⑥的持锁重检伪装成同一件事,**读者无法从签名看出锁语义差别** → 比 A 更隐蔽的行为语义损失 |
|
||||||
|
| **C:把七阶段拆到七个新文件** | 文件增殖。`moderation.go` 298 行本身不超标(P14 目标 <300),拆文件不解决"单函数太长"这个真问题,且 D-H 已判定不用文件行数当指标 |
|
||||||
|
| **D:先补 characterization 测试再重构** | §1.6 实测既有 13 个测试函数已覆盖全部七阶段与三态语义(含并发、真库发布链)。补测试是**额外成本而非额外保障**,且会违反"既有 `*_test.go` 修改数 = 0"这个更硬的验收判据 |
|
||||||
|
| **E:本批一并拆 `deleteAccountLocked`(83) / `UpdateSubmission`(76)** | D-F「一批一个关注点」(P14 同款)。三个函数分属 account/submission/moderation 三条线,混在一批会让"行为不变"的验收判断面翻三倍。已挂账 |
|
||||||
|
| **F:把 `Approve` 改成状态机/策略模式** | 过度设计。七个阶段是**线性**的(无分支跳转、无回退),`switch sub.Kind` 已经是策略分派。引入状态机会把 169 行变成更多的行 + 一个新抽象层,且**改变错误的产生位置**(行为变更) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 实现要求
|
||||||
|
|
||||||
|
### 3.1 目标形态(骨架,**签名为示意,实现者须按 §1.1 的真实类型逐字对齐**)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *ModerationService) Approve(ctx context.Context, adminID, submissionID, note string) error {
|
||||||
|
plan, err := s.loadApprovePlan(ctx, submissionID) // ①②③
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.precheckApprove(ctx, plan); err != nil { // ④(乐观、无锁、Copy 之前)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.copyApprovalObjects(ctx, &plan); err != nil { // ⑤(写 plan.finalBundleKey/finalCoverKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = s.store.WithTx(ctx, func(tx repository.ContentRepository) error {
|
||||||
|
return applyApprovalInTx(ctx, tx, submissionID, plan, adminID, note) // ⑥
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrConflict) {
|
||||||
|
return ErrSubmissionConflict
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.cleanupApprovalUploads(ctx, plan) // ⑦(best-effort,只 slog.Error)
|
||||||
|
slog.Info("approve", "submission", submissionID, "work", plan.sub.WorkID, "kind", plan.sub.Kind, "admin", adminID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **注意 `copyApprovalObjects` 必须能写回 plan**:`plan` 是值类型,故该方法签名须为 `(ctx context.Context, plan *approvePlan) error`(收指针),或返回新的 plan。**选前者**——与 ⑤ 原地修改 `finalBundleKey`/`finalCoverKey` 的现有语义一致,且避免"返回新 plan 但调用方忘了接"的静默丢值。
|
||||||
|
|
||||||
|
### 3.2 逐阶段要求
|
||||||
|
|
||||||
|
**①②③ `loadApprovePlan`**(未导出方法):
|
||||||
|
- 逐字搬 **48–86** 行。返回 `(approvePlan, error)`。
|
||||||
|
- 三处错误映射逐字保留:`errors.Is(err, repository.ErrNotFound)` → `ErrContentNotFound`;`sub.Status != model.SubmissionStatusPending` → `ErrSubmissionConflict`;`fmt.Errorf("service: load submission: %w", err)`;`fmt.Errorf("service: resolve submitter: %w", err)`;两处 `ErrUploadUnavailable`(含 `up.ConsumedBy != sub.ID` 判定)。
|
||||||
|
- ② 的 `if sub.Kind == model.SubmissionKindNewWork` 条件**必须保留**(其它 kind 不查 owner,`plan.owner` 为零值;⑥ 的 `NewWork` 分支才用 `owner.ID`)。
|
||||||
|
- ③ 的 `bundleUpload = &up` 取地址语义**必须保留**(`up` 是循环内局部变量,取其地址是本函数刻意的写法;改成存值会让 `plan.bundleUpload != nil` 判定失效)。
|
||||||
|
|
||||||
|
**④ `precheckApprove`**(未导出方法):
|
||||||
|
- 逐字搬 **88–101** 行。**保持用 `s.store`**(不是 `tx`)。
|
||||||
|
- 错误文案 `fmt.Errorf("service: approve precheck: %w", err)` **逐字保留**(两处)。
|
||||||
|
- **不得**增加 `MetadataChange` case(④ 现在没有,加了会改变 `MetadataChange` 提交的失败时机)。
|
||||||
|
|
||||||
|
**⑤ `copyApprovalObjects`**(未导出方法,收 `*approvePlan`):
|
||||||
|
- 逐字搬 102–123 行。key 拼接公式**逐字保留**(`"bundles/"+sub.WorkID+"/"+p.Version+".bin"`、`"covers/"+sub.WorkID+"/"+coverUpload.SHA256+ext`,`ext := path.Ext(coverUpload.ObjectKey)`)。
|
||||||
|
- `if bundleUpload != nil` / `if coverUpload != nil` 的**条件 Copy 语义保留**(无上传时 `finalXxxKey` 保持 `""`,⑥ 的 `MetadataChange` 分支靠 `if finalCoverKey != ""` 判定是否覆盖)。
|
||||||
|
- `storage.ErrObjectNotFound` → `ErrUploadUnavailable` 的映射逐字保留;`service: copy bundle|cover: %w` 两条文案逐字保留。
|
||||||
|
|
||||||
|
**⑥ `applyApprovalInTx`**(包级函数):
|
||||||
|
|
||||||
|
> ⚠️ **RE-PIN 2026-10-04(第二次,闭合全分支终审 N1–N4)**:本 spec 里凡可执行的技术细节(形参顺序、行号区间、needle 命中行)一律以**代码实体**为准 —— `applyApprovalInTx` 的形参序是 `ctx context.Context, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string`(`submissionID` 在 `plan` 之前);base 树 `fe810dd` 的阶段区间为 ①②③`48–86` / ④`88–101` / ⑥闭包体`126–200`(75 行)/ ⑥整体`125–201`(77 行)/ ⑥内 switch`136–199`;HEAD 上 CG2 needle 唯一命中 `:201`。首次 re-pin 时控制者凭记忆写了 4 处不一致(N1 形参序 3 处、N2/N3 区间 staleness、N4 行号与算术),全分支终审逐条实测抓出。**教训已入 §8 纪律 11:可执行细节必须从代码实体 grep 出来粘贴,不得手写。**
|
||||||
|
- 搬 **126–200** 行(`WithTx` 闭包体)。⚠️ **`:201` 是 `})`,`:202–207` 的事务错误映射属阶段⑦,都不得搬进 helper**(照抄旧区间「126–205」会把 `})` 与闭包外半截搬进去 → 编译红或行为变更)。签名收 `(ctx, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string) error`(**含 `submissionID`,见 F9 裁定**)。
|
||||||
|
- **`GetByIDForUpdate` 锁行 + 重校 `Status == Pending` 必须在最前**(125–134),且 `errors.Is(err, repository.ErrNotFound)` → `ErrSubmissionConflict`(**注意与①不同**:① 映射 `ErrContentNotFound`,⑥ 映射 `ErrSubmissionConflict`——这个差异是刻意的,事务内消失意味着并发删除,语义是冲突而非未找到)。
|
||||||
|
- 三个 kind 分支各自拆包级函数(`createWorkInTx` / `createVersionInTx` / `applyMetadataChangeInTx`),或保留为一个 switch——**由实现者按可读性定**,但 `switch` 的分支顺序与 `MarkReviewed` 在末尾的位置**不得变**。
|
||||||
|
- `MetadataChange` 分支的 `Features` 三态**连注释一起搬**(D-F)。
|
||||||
|
- 事务内错误**裸返**,不加 `service: approve precheck` 之类包装(§1.2)。
|
||||||
|
|
||||||
|
**⑦ `cleanupApprovalUploads`**(未导出方法):
|
||||||
|
- 搬 **208–212** 行(pending 循环)。⚠️ **`:213` 的 `slog.Info` 与 `:214` 的 `return nil` 留在 `Approve` 本体,不得搬走**(旧区间「212–213」会把该搬走的和该留下的正好搞反)。`for _, key := range pendingKeys(plan.bundleUpload, plan.coverUpload)` + `s.objects.Delete` + `if err != nil && !errors.Is(err, storage.ErrObjectNotFound) { slog.Error("approve: pending delete failed", "key", key, "error", err) }` **逐字保留**。
|
||||||
|
- **不得返回 error**(best-effort 语义:清理失败不能让已成功的审批变失败)。
|
||||||
|
- ⚠️ `slog.Info("approve", …)` 与 `return nil` **留在 `Approve` 本体**,不进 helper——helper 名是 `cleanup`,把成功日志塞进去会让职责不清。
|
||||||
|
|
||||||
|
### 3.3 不得触碰
|
||||||
|
|
||||||
|
- `architecture_test.go`(P14 的七条守卫)——除非 mutation (g) 证明某条因新 helper 变红,那时**停下来报告**,不要自行改守卫。
|
||||||
|
- `moderationPort`(`handler/admin.go`)与 `serve.go`。
|
||||||
|
- `versionFromUpload` / `pendingKeys` 的**签名**(可调用,不可改)。
|
||||||
|
- 任何 `*_test.go`。
|
||||||
|
- `docs/`、`go.mod`/`go.sum`(AGENTS.md 红线:实现者不得碰)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 边界
|
||||||
|
|
||||||
|
| 项 | 在范围内 | 不在范围内 |
|
||||||
|
|---|---|---|
|
||||||
|
| 函数 | `Approve` 及其拆出的 helper | `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)、`deleteAccountLocked`(83)、`UpdateSubmission`(76) |
|
||||||
|
| 文件 | `internal/service/moderation.go` | 其它 service 文件(除非编译需要,须报告) |
|
||||||
|
| 行为 | 零变更(D-G) | 任何错误文案/日志键名/错误产生时机的调整 |
|
||||||
|
| 测试 | 无(既有测试即守卫) | 新增测试、修改既有测试 |
|
||||||
|
| 守卫 | 无 | 改 `architecture_test.go` |
|
||||||
|
|
||||||
|
**挂账**(本批发现但刻意不做):`deleteAccountLocked`(83 行,account 线)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`CleanupService.Run`(70)、`checkSubmissionRules`(66) —— 六个 >60 行函数,各自独立成批。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 测试计划
|
||||||
|
|
||||||
|
### T1 —— 结构守卫(**RED 先行**):扩展或新增函数长度断言
|
||||||
|
|
||||||
|
P14 的七条断言钉的是**类型形态**,没有一条钉**函数长度**。本批的验收核心是"`Approve` 从 169 行降到 <60",若没有机械守卫,下一个贡献者可以把它加回去而无人拦截。
|
||||||
|
|
||||||
|
**新增 `internal/service/function_length_test.go`**(**新文件,不改 `architecture_test.go`**):
|
||||||
|
1. **`TestNoOversizedFunction`**:解析 `internal/service/` 下所有非测试 `.go`,统计每个 `func` 的行数(`^func ` 起,括号深度归零止),断言**没有任何函数 >100 行**。
|
||||||
|
- **阈值 100 的依据(实测,非拍脑袋)**:§0 普查显示当前包内 >100 行的函数**只有 `Approve`(169) 一个**,次大是 83 → 阈值 100 在**修前恰好红一条**(`Approve`)、**修后全绿**,且给次大者(83)留 17 行余量不至于误伤。
|
||||||
|
- ⚠️ **不得用 60 当阈值**:那会让 `deleteAccountLocked`(83)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`Run`(70)、`checkSubmissionRules`(66) 六个**本批范围外**的既有函数立刻报红,把"重构 `Approve`"变成"重构整个包"(违反 D-F/E)。
|
||||||
|
2. **`TestApproveIsSmall`**:断言 `Approve` 本身 **<60 行**(本批的直接目标;169 → <60)。
|
||||||
|
3. **`TestApproveHelpersExist`**:断言 §3.1 骨架里的 helper 名存在(`loadApprovePlan`/`precheckApprove`/`copyApprovalObjects`/`applyApprovalInTx`/`cleanupApprovalUploads`)——防实现者只把代码挪进一个匿名闭包了事。
|
||||||
|
- ⚠️ **这条断言的实现方式必须是源码文本匹配**(`os.ReadDir` + 正则),**不能用 reflect**:未导出函数在 reflect 里不可见(§1.3 同一机制)。
|
||||||
|
|
||||||
|
**RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/ -run 'TestNoOversizedFunction|TestApproveIsSmall|TestApproveHelpersExist'` 必须**红**,且红的原因是**断言失败而非编译错误**(`Approve` 169 行 > 100、helper 名不存在)。**这与 P14 T1 的 RED 形态不同**(P14 是 `undefined: CatalogService` 编译红)——本批 T1 引用的都是既有类型,故必须是断言红。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`。
|
||||||
|
|
||||||
|
**mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 非编译红 + 恢复证明")**:
|
||||||
|
- (a) 把 `Approve` 的某阶段**内联回去**(helper 存在但 `Approve` 不调它,改为原地展开)→ `TestApproveIsSmall` 必须红
|
||||||
|
- (b) 给某个 helper 塞代码使 `Approve` 仍 <60 但**某函数 >100**(例如把⑥的三个 kind 分支全塞进 `applyApprovalInTx` 而不拆)→ `TestNoOversizedFunction` 必须红
|
||||||
|
> ⚠️ **(b) 的可行性须先实测**:若⑥整体(126–200 = **75 行**)搬进 `applyApprovalInTx` 而不再拆 → **不会红**;**更宽的实测盲区:仅合并④⑥(正是 §2.1 被否方案 A 的形态)= 91 行,仍不红**;真红下界实测 >100 行。这是**阈值 100 的已知盲区**,不是守卫失效:它钉的是"不再有 169 行的巨函数",不是"每个 helper 都短"。实现者须在报告里写明这一点,**不得声称 (b) 覆盖了所有塞代码形态**。若要在 (b) 上取得真红,须构造 >100 行的 helper(例如把 ④⑥ 两个 switch 都塞进一个 helper)。
|
||||||
|
- (c) 删掉一个 helper(把其内容合并进 `Approve`)→ `TestApproveHelpersExist` 必须红
|
||||||
|
- (d) 把 helper 名改成别的(如 `loadApproveInputs`)→ `TestApproveHelpersExist` 必须红(**这条是 (c) 的对照**:证明断言钉的是名字集合而非"存在任意 5 个函数")
|
||||||
|
- (e) 把 `TestNoOversizedFunction` 的扫描范围改成一个空目录 → 必须红(**防"扫 0 文件也报 0 违规"的假信心**,P13 审查者 M5 的同族教训;断言内须含"至少扫到 N 个 `.go` 文件"的前提检查)
|
||||||
|
- (f) 把阈值从 100 改成 1000 → 必须红(同 (e),防阈值被放宽;断言须自证阈值字面量)
|
||||||
|
- (g) **给 `ModerationService` 加字段**(如 `clock func() time.Time`)→ P14 的断言 6 `TestLineFields` 必须红(**跨批回归检查**:证明本批新增文件没有削弱 P14 守卫)
|
||||||
|
- (h) **在 `ContentService` 上加方法** → P14 的断言 5 必须红(同 (g),且**须同时测有名与匿名两种接收者形态**——P14 FR-1 的教训:只测有名会漏掉 `func (*ContentService) M()`)
|
||||||
|
- ⚠️ **(RE-PIN 2026-10-03)spec 的 (a)–(h) 对审查者发现的缺口全部无牙**,fix-forward 轮须复跑审查者自己的轮次:**RV-A / RV-B / RV-C / RV-J**(四条合并形态,修后应由 CG 守卫红)· **RV-I / RV-S**(修后应由正向存在性前提红)· **RV-E**(修后应给出合理错误而非 `read memory.go: no such file`)· **RV-O2**(修后应由 F2 新断言红)· **RV-P / RV-Q**(修后应由自证钉红)· **RV-R**(修后**仍应全绿** = 固有上限,**须在报告里明写「未能防住,靠 review 兜」,不得声称已修**)· **RV-N**(泛型形态修后应命中)· **RV-F / RV-G / RV-H**(跨批回归,应仍与实现者报告一致)
|
||||||
|
|
||||||
|
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚会抹掉未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件 `func Test` 计数不变。
|
||||||
|
|
||||||
|
### T1.4 —— ④⑥ 独立性**常驻**守卫(⚠️ RE-PIN 2026-10-03,审查者 F10 裁定采纳)
|
||||||
|
|
||||||
|
spec §7 风险表第 1 行原写「T3 字节级比对会暴露合并」——**但 T3 是一次性证据,报告归档后下一个贡献者合并 ④⑥ 时没有任何东西会红**。审查者实测确认这个状态不可接受(§1.2 把 ④⑥ 不合并列为「本批最重要的约束」,却零常驻机械信号)。
|
||||||
|
|
||||||
|
**新增 `internal/service/approve_phases_test.go`**(新文件;不动 `architecture_test.go`),三条子断言:
|
||||||
|
|
||||||
|
- **CG1**:`fmt.Errorf("service: approve precheck: %w"` 只能出现在 `precheckApprove` 的 span 内
|
||||||
|
- **CG2**:`.Submissions().GetByIDForUpdate(` 只能出现在 `applyApprovalInTx` 的 span 内
|
||||||
|
> ⚠️ needle **必须收紧成 `.Submissions().GetByIDForUpdate(`**:`moderation.go` 内唯一命中 `:201`;而 `account.go:46`、`auth.go:122` 是 **`users.GetByIDForUpdate`**(不同接收者)。不收紧则将来 `moderation.go` 新增别的 `GetByIDForUpdate` 调用会误红。
|
||||||
|
- **CG3**:两个 helper 体内各自含 `switch …Kind`,且**互不调用**
|
||||||
|
|
||||||
|
**关键设计**:语料**先过 `stripComments`(只掩注释、保留字符串字面量)**。`moderation.go:141` 与 `:191` 的注释里**合法地**提到 precheck 文案 → 不掩注释会假红;而字符串字面量必须保留,否则 `fmt.Errorf("...")` 这个 needle 根本匹配不到。span 复用 `function_length_test.go` 的 `scanFuncs`(共享同一分词器,也就共享 F2 的闭包盲区,但闭包形态与 ④⑥ 合并无关)。
|
||||||
|
|
||||||
|
**审查者已双向验证**:合法树 **绿**;**4 种合并/退化形态全红**且全部 `compile_red=False`(是断言红不是编译红),三条 shipped 守卫在这些形态下**全绿**:
|
||||||
|
|
||||||
|
| 轮 | 形态 | shipped 三条 | CG 守卫 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| RV-A | **活的** ④⑥ 语义合并:两个 helper 都委托给同一个 `checkKindConflicts(ctx, store repository.ContentRepository, plan)`(= §2.1 被否方案 **B** 的实现;比实现者 (b-iii) 的静态拼接更强——它真的会跑) | 全绿 | **红**(2 条) |
|
||||||
|
| RV-B | 掏空 ④:`precheckApprove` → `return nil`(声明保留,名字守卫满足) | 全绿 | **红** |
|
||||||
|
| RV-C | 把 ⑥ 的 `GetByIDForUpdate` 搬进 ④(摧毁 TOCTOU 分层,**标识符计数完全不变**) | 全绿 | **红**(CG2 越界) |
|
||||||
|
| RV-J | 「最自然的 DRY」:④ 的两个 case 折进 ⑥ 的 switch、⑥ 持有 precheck 文案、④ 变空壳 | 全绿 | **红**(CG1 越界 + CG3) |
|
||||||
|
|
||||||
|
> ⚠️ **RV-C 同时推翻了控制者的核实方法**:把 `GetByIDForUpdate` 从 ⑥ 搬进 ④,标识符计数不变、错误文案不变,而 TOCTOU 分层被摧毁(乐观预检变成持锁预检,失败时机与锁语义全变)。**「计数一致」证明不了控制流没变**——必须配 **顺序** 与 **位置** 证明(T3 的 order-violation 检查 + 审查者的 per-destination 单调性检查)。
|
||||||
|
|
||||||
|
**⚠️ 已知边界(须原样写进守卫注释;纪律 #4:不写全称声明)**:若合并者把 precheck 文案**留在** `precheckApprove` 内、同时把 ⑥ 的逻辑**复制**进去(复制而非移动),CG1 不红——此时需要靠「两处 `switch …Kind` 的 case 集合不同」这类更强断言。**该形态未验证,故不声称 CG1–CG3 覆盖所有合并形态。**
|
||||||
|
|
||||||
|
### T2 —— 执行拆分(T1 由红转绿)
|
||||||
|
|
||||||
|
按 §3 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**。
|
||||||
|
|
||||||
|
### T3 —— 字节级方法体保真证明(加做项,P14 同款)
|
||||||
|
|
||||||
|
写脚本对 base 树(`fe810dd`)的 `Approve` 函数体与新树的 `Approve` + 5 个 helper 做**语句级比对**:base 的每一行(归一化缩进与接收者前缀后)必须在新树中出现,且**顺序保持**(阶段①→⑦ 的相对顺序不得变)。产出三张清单:`verbatim`(逐字一致)、`changed`(刻意变更,须逐条给理由)、`missing`(**必须为 0**)。
|
||||||
|
|
||||||
|
⚠️ **脚本的已知坑(P14 两位审查者都栽过)**:
|
||||||
|
- 提取声明块时**不能"剥行注释后数括号"**——字符串字面量里的 `//`(如 `strings.HasPrefix(k, "https://")`)会被当注释起点吃掉右括号。须用**状态机分词器**跟踪 `"` / `` ` `` / `'` / `//` / `/* */`,把字面量与注释内容替换为**等长空格**后再数括号(P14 任务级审查者 F6 的修法)。
|
||||||
|
- **单行 `var X = errors.New(...)` 须有提前终止分支**,否则会把后续声明并入同一块(P14 F6 的残留局限)。
|
||||||
|
- 若工具给出意外值(如某方法报 CHANGED),**先怀疑自己的模式**,不要据此指控实现者(P14 F6:第一版工具误报 `CoverURL` 为 CHANGED,若照它写报告会产生一条完全虚假的 critical finding)。
|
||||||
|
|
||||||
|
### T4 —— 审查裁定后的守卫加固(fix-forward 轮,⚠️ RE-PIN 2026-10-03)
|
||||||
|
|
||||||
|
任务级审查(`.superpowers/sdd-p15a/task-review-report.md`,18 轮自设 mutation)裁决 **PASS with notes**,控制者裁定合并前修 6 项(`.superpowers/sdd-p15a/adjudication.md` §二):
|
||||||
|
|
||||||
|
| # | finding | 改什么 | 审查者的实测证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **F1** 🔴 | `readNonTestSources` 的 `dir` 参数是**半成品**:`os.ReadDir(dir)` 但 `os.ReadFile(name)` → `dir` 只重定向「数哪些文件」,不重定向「读哪些内容」 | `os.ReadFile(filepath.Join(dir, name))` + import `path/filepath` | **RV-I**:base 树(`Approve` 169 行)+ `dir="zzscan"`(12 个同名文件、不含 `moderation.go`)→ 前提满足、`TestNoOversizedFunction` **PASS**,**而它唯一存在的理由(拦住 169 行 `Approve`)完全失效**。这恰好是守卫自己注释声称能防住的形态 |
|
||||||
|
| **F2** 🔴 | `scanFuncs` 只对 `^func` 开 span → **`var f = func(...) {…}` 的函数体永不被测量** | 新断言:service 包内 `^var .* = func(` 形态的**包级闭包数量 = 0**(现状 0) | **RV-O2**:真实可编译、gofmt 干净的包内文件含 **125 行闭包** → 四门全绿、三条守卫全 PASS(`scanned 16 files / 86 funcs`——文件被数进去但一个函数都没测出来)。**比实现者自曝的 (b) 盲区更宽:那是「≤100 行的合并形态」,这是「任意长度的非 `func` 形态」** |
|
||||||
|
| **F3** 🔴 | **四门的 gofmt 门是退出码盲的**:`gofmt -l .` 列出未格式化文件时**仍 exit 0**(只在解析失败时 exit 2) | 配方改 `test -z "$(gofmt -l .)"`,并同时存证原始输出为空 | 构造含缩进顶层声明的文件:`gofmt -l .` **列出** `main.go` 而 `gofmt_l_exit=0`。**控制者与实现者共用旧配方 → 同一盲点被复算两次而没被发现**(记为控制者错误 #32)。**与 F2 叠加后缩进逃逸路径完全敞开**:` func …` Go 编译器接受、`scanFuncs` 扫不到、三条守卫全绿,唯一防线就是这个不会红的 gofmt 门。**这是 P14「管道吞退出码」教训的镜像形态:命令本身不产生非零退出码,比管道吞码更隐蔽,因为 `$?` 看起来可信** |
|
||||||
|
| **F4** 🔴 | 三个守卫常量里**两个无自证钉**,有钉的那个**只防单边改** | (a) **必修**:前提检查从「文件数 ≥ N」改成**正向存在性断言** `if _, ok := srcs["moderation.go"]; !ok { t.Fatalf(...) }`(不依赖魔法数字,同时治 RV-I 与 RV-S 的根);(b) 给 `approveLineBudget`/`minScannedFiles` 补自证钉;(c) 注释写明「同步改值 + 同步改钉」是**固有上限**、只能靠 review 兜 | **RV-P**(`approveLineBudget` 60→**600**)全绿、日志 `Approve = 24 lines (< 600)`,**静默失去全部牙齿**;**RV-Q**(`minScannedFiles` 12→**0**)全绿,前提变 `len(srcs) < 0` **恒假 → 永不触发**;**RV-R**(**同步**改阈值 100→1000 **且**同步改自证钉)全绿 → **自证钉防不住阈值放宽**;**RV-S**(RV-Q + `dir="zzempty"` 0 个 .go)→ **PASS**、日志 `scanned 0 files / 0 funcs`,**精确复现守卫注释声称能防住的「扫 0 文件也报 0 违规的假信心」** |
|
||||||
|
| **F7** 🟡 | helper 正则要求名字后**紧跟 `(`** → **泛型声明形态** `HELPER[T any](` 不命中 | `HELPER\(` → `HELPER(?:\[[^\]]*\])?\(` | RV-N 实测三个 helper 均 `false`。**失败方向是红(过严)不是绿(绕过)**,故不是安全洞而是脆弱性;但与 F2 的缩进逃逸**同源**:都源于 `^func` / `HELPER\(` 这类**行首锚定的词法匹配** |
|
||||||
|
| **F9** 🟢 | `applyApprovalInTx` 签名不含 `submissionID`,实现者用 `plan.sub.ID` 替代(附跨两个 repository 实现的同值论证) | **加 `submissionID` 形参**,⑥内两处(`GetByIDForUpdate`、`MarkReviewed`)恢复逐字用 `submissionID` | 审查者独立复核**同值成立**、并发安全验证通过;但本批唯一验收判据是「零行为变更」,而该替换让判据**依赖一条跨实现论证** → 加形参后这两处逐字一致、**彻底消除依赖**,且 T3 的 changed 从 **15 降到 13**(证据更强) |
|
||||||
|
| **F6** 🟡 | 实现者报告 §6 的 changed 分类表**与它自己的证据对不上账**(stated `9+3+2+2 = 16`,证据标签合计 **15**) | 更正为 `多值返回 7 · 声明折叠 2 · 零值折叠 2 · 赋值形态 1 · =→:= 1 · 实参来源 2 = 15` | **实质无问题**(15 条逐条有理由、162 = 147+15+0 对账闭合、审查者独立 differ 精确复现归属),是**汇总表算术/誊写错误**非证据造假。但 USER.md 明写「报数字要报实跑输出」,且 P14 有过「控制者报出 83/299 与 82/298 两套数字」的先例 |
|
||||||
|
|
||||||
|
**DEFER(另开批 + 挂账)**:**F5** —— P14 断言 5 有一条未封死的绕过:**类型别名接收者**(`type X = ContentService` + `func (c *X) CoverURL(...)`)。审查者 RV-M 硬证据:**遮蔽确实生效**(组合根返回 `RV-M-SHADOW`),而**用与 `architecture_test.go` 逐字相同的正则**实测命中数 = **0**;RV-K:全部 **10 条守卫 + vet + build 全绿**。与 P14 FR-1(匿名接收者)**同族**:FR-1 的修法把接收者名改成可选分组,但仍要求 `ContentService` 这个字面 token 出现在 `(` 之后,别名形态恰好把这个 token 换掉了。→ `architecture_test.go:173` 的「**唯一**能侦测遮蔽提升方法的机械手段」这个全称声明**在别名形态下被推翻**(纪律 #4)。**pre-existing(P14 遗留),不算本批扣分项**;且修它必须动 `architecture_test.go`(本批禁触)→ 另开批。最小修法:禁止包内出现指向 `ContentService` 的类型别名,一条正则 `^type\s+\w+\s*=\s*\*?ContentService\b`(合法树 0 命中、RV-K/RV-M 红);更强修法:改用 `go/ast` 判定接收者类型是否 resolve 到 `ContentService`(含别名),**一次性解决 F2/F5/F7 三条的词法根因**。
|
||||||
|
|
||||||
|
**偏离 1 的裁定(审查者 §10)**:`5fd12e5` 给 `readNonTestSources` 加 `dir` 参数——**动机成立**(spec §5 要求每条 mutation 测「预期红 + 其余绿」,共享扫描入口无法隔离)、**「RED 输出前后逐字同款」验证成立**(两份存证失败内容完全一致,只有行号整体 +3)、**但实现有 bug(F1)且换来的收益是虚的**(审查者:「为了演示而给生产代码加参数、且加出 bug,是净损失」)。审查者倾向回退;**控制者裁定保留参数化并按 F1+F4(a) 修**——理由:(e) 的隔离演示有独立价值(它是「预期红 + **其余绿**」这个硬要求的唯一实现路径),F1 是一行修,而 F4(a) 的正向存在性断言比回退更能治根。
|
||||||
|
|
||||||
|
### 四门验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crearte-server && docker run --rm -v "$PWD/src:/src" -w /src \
|
||||||
|
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||||||
|
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||||||
|
golang:1.24-alpine sh -c 'gofmt -l . ; test -z "$(gofmt -l .)" ; echo gofmt_gate=$? ; go vet ./... ; go build ./... ; go test ./... -count=1'
|
||||||
|
```
|
||||||
|
⚠️ **`-count=1` 强制实跑**(禁缓存)。⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀——P14 实现者与任务级审查者都踩过)。⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。⚠️ **`pkill -f <pattern>` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern`。
|
||||||
|
|
||||||
|
### 结构指标(交付时须报实测数字,`wc -l` 口径)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Approve` 行数 | **169** | **< 60** |
|
||||||
|
| `service/` 包内 >100 行的函数数 | **1** | **0** |
|
||||||
|
| `service/` 包内最大单函数行数 | **169** | **< 100**(实测次大者 83,故目标应落在 83–100 之间) |
|
||||||
|
| `moderation.go` 行数 | 298 | **不作指标**(D-H:helper 同文件,可能不降反升;报了即可,不设目标) |
|
||||||
|
| 既有 `*_test.go` 修改文件数 | — | **0** |
|
||||||
|
| `architecture_test.go` 修改行数 | — | **0** |
|
||||||
|
| `Approve` 的签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** |
|
||||||
|
| 字节级比对 `missing` | — | **0** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 记账
|
||||||
|
|
||||||
|
- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-A 行**(P14 行之后)+ 文档索引表新增一行。**哈希引 merge commit**(P12/P13/P14 惯例),记账 commit 另列。
|
||||||
|
- **挂账新增**:① 六个 >60 行函数(`deleteAccountLocked` 83 / `UpdateSubmission` 76 / `ValidateWorkFields` 75 / `ImportDir` 70 / `CleanupService.Run` 70 / `checkSubmissionRules` 66)② **阈值 100 的盲区扩写为「≤100 行的任何合并形态」**(实测:⑥整体搬进 `applyApprovalInTx` = **77 行**不红;**仅合并④⑥ = 91 行仍不红**;真红下界 >100 行)③ **`TestApproveIsSmall` 的镜像盲区**:须内联四个阶段才把 `Approve` 推到 79 ≥ 60;单内联⑦(28 行)时三条守卫全绿(由 (c)/(d) 的名字集合断言兜)→ 守卫钉的是「编排体小 + 五个名字存在 + 无巨函数」,**不钉「每个阶段必须经由 helper」** ④ **非 `func` 形态的任意长度逃逸**(F2:125 行包级闭包四门全绿)⑤ **三个字面量常量的自证缺口**(F4:RV-S 复现「0 文件 0 违规 PASS」)⑥ **F3:四门配方的 gofmt 门退出码盲**(影响**所有**批次,不只 P15-A)⑦ **F5:P14 断言 5 的类型别名接收者绕过**(pre-existing)
|
||||||
|
- **`go.mod` / 任何 version 文件不 bump**。
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §7 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| **实现者"顺手 DRY"合并④⑥的 switch** | §1.2 已写明四处实质差异 + 语义差别(乐观/悲观)+ 错误文案差别;D-D 明令禁止;T3 字节级比对会暴露合并(⚠️ **但 T3 是一次性证据,报告归档后下一个贡献者合并 ④⑥ 时没有任何东西会红** → 由 `approve_phases_test.go` 的 **CG1–CG3 常驻断言**拦截,见 §5-T1.4)(base 的 `service: approve precheck: %w` 文案会消失或移位);既有 `TestApproveSameWorkIDConcurrent` 断言的错误集合会变 |
|
||||||
|
| **`plan` 值传递导致 ⑤ 的 `finalXxxKey` 静默丢失** | §3.1 已点名:`copyApprovalObjects` 必须收 `*approvePlan`。`TestApproveOptionalFieldsRoundTrip` 与 api 层发布链测试会抓到(key 为空 → 作品无 bundle) |
|
||||||
|
| **② 的 `owner` 零值被误当成"未加载"** | §3.2 已写明 `if sub.Kind == NewWork` 条件必须保留;`namespace_test.go:132 TestNewVersionMetadataChangeOwnership` 会抓到 OwnerID 错误 |
|
||||||
|
| **③ 的 `bundleUpload = &up` 取地址语义被改成存值** | §3.2 已点名;改了会让 `plan.bundleUpload != nil` 恒真或恒假 → ⑤⑥⑦ 全链路错,api 层发布链测试必红 |
|
||||||
|
| **⑦ 的 `slog.Info` 被搬进 cleanup helper** | §3.2 已明令留在 `Approve` 本体(职责清晰)。日志键名/顺序变更由 D-G 约束;若实现者搬了,代码审查阶段抓 |
|
||||||
|
| **新守卫 `TestNoOversizedFunction` 写成恒真/恒假** | mutation (e)(f) 双向验证(改扫描范围→红、改阈值→红)+ P13/P14 教训:`max(a,b) ≥ k` 是恒真高发区、`NumMethod()==0` 是恒假形态。**本批新增镜像形态须防:断言里若含"至少扫到 N 个文件"的前提检查,N 写太小会让前提恒成立而主体断言失去意义** |
|
||||||
|
| **未导出 helper 让 reflect 类断言失效** | §1.3 已实测:未导出方法不在断言 2/4 视野内 → 这是**特性不是缺陷**(重构私有实现不该触守卫)。但 T1.3 的 `TestApproveHelpersExist` 因此**必须用源码文本匹配而非 reflect** |
|
||||||
|
| **本批削弱 P14 守卫** | mutation (g)(h) 跨批回归检查(加字段→断言 6 红;加组合根方法→断言 5 红,**且须测有名与匿名两种接收者形态**,P14 FR-1 教训) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §8 实现纪律(P11–P14 累计教训,逐条来自真实事故)
|
||||||
|
|
||||||
|
1. **不推断,只实测。** 任何"应该是/大概/按惯例"都要跑一条命令确认。P14 控制者在勘查期与裁定期共犯 28 次断言/grep/锚点/区间/路径/正则错误。
|
||||||
|
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者五次因这条纪律避免了指控子代理的假缺陷。
|
||||||
|
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = 4.0621 > 3,50653 采样暴力验证)。
|
||||||
|
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过);写"两层覆盖完整"被推翻(孤儿私有方法)。**写"唯一/全部/任何"之前先枚举形态**(有名/匿名接收者、值/指针、导出/未导出)。
|
||||||
|
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 控制者 spike 否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)就是恒假形态。
|
||||||
|
6. **管道会吞掉真退出码。** `go test ./... | tail -30; echo $?` 取的是 `tail` 的退出码。P14 终审者踩过(`full_exit=0` 而实际套件 FAIL)。
|
||||||
|
7. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"。** P14 终审者 R0 轮整轮结论无证据支撑,自查后在真仓用绝对路径重跑;控制者的 `bg-surface=0` 同形态(`cd` 到仓根却写 `app/...`,真根是 `src/app/...`)。**任何"零命中"结论都要先证明扫描范围非空。**
|
||||||
|
8. **长时命令落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向。** exec session 一断容器就被杀,P14 实现者的 `t3-gates.txt` 首跑只写 1/12 包。
|
||||||
|
9. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在(`func Test` 计数),否则 FATAL 终止——让脚本在被自毁时大声失败,而不是静默产出"未按预期"的假结论。
|
||||||
|
10. **测试被迫修改 = 设计失败的信号。** 若必须改既有测试,**停下来报告**而不是改。本批的验收硬指标就是"既有 `*_test.go` 修改数 = 0"。
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
# P15-B 设计:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理(crearte)
|
||||||
|
|
||||||
|
- **日期**:2026-10-03
|
||||||
|
- **仓**:`crearte`(前端;`package.json` 与全部源码在 **`src/`**,不是仓根)
|
||||||
|
- **分支**:`feat/p15b-bootstrap-dark-and-guard-pins`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`;本批含用户可感知的暗色改动,故用 `feat/`)
|
||||||
|
- **base**:`f823195`(master,P13 交付后的 AGENTS.md 不变量 commit)
|
||||||
|
- **维度**:用户体验 / UI 交互 + 代码优雅(USER.md 六维度循环)
|
||||||
|
- **并行**:P15-A 在 `crearte-server` 仓,两仓各自单写者 → **可同时派发**(`sdd-parallel-dispatch` §1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 基线(已实测,2026-10-03 13:07)
|
||||||
|
|
||||||
|
**vitest**:`Test Files 79 passed (79)` · `Tests 688 passed (688)` · Duration 44.83s。
|
||||||
|
命令:`cd crearte/src && npx vitest run`。**与 P13 交付时报的 688/79 逐字相同** → P14 未触及前端,且此后无新增测试。这是本批的比对基线。
|
||||||
|
|
||||||
|
`bootstrap/host-origin.test.ts (3 tests)` 已在 79 个文件里 → **`bootstrap/` 目录已有测试先例**,新增测试文件不属破例。
|
||||||
|
|
||||||
|
**vitest 环境**:`src/vite.config.ts` 的 `test` 段只有 `exclude: [...configDefaults.exclude, 'e2e/**']`,**无 `environment` 键** → 默认 **node**。这是 `contrast.test.ts` / `noHardcodedColor.test.ts` / `themeBootstrap.test.ts` 能用 `fileURLToPath(new URL('../..', import.meta.url))` 读源码文件的前提(**P13 教训:happy-dom 下会抛 `ERR_INVALID_URL_SCHEME`**)。本批新增的守卫**必须沿用同款写法**,不得引入需要 DOM 的断言。
|
||||||
|
|
||||||
|
**e2e**:`src/e2e/`,`playwright.config.ts` / `.noauth.` / `.stack.` 三份配置都是 `testDir: './e2e'`。`dark.spec.ts` **10 腿**(P13 交付),其中 `:100` 是 **pre-paint 归因腿**:「Vue 包被拦截时 `data-theme` 仍为 dark(证明是内联脚本而非挂载后设置)」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 三项改动的取证
|
||||||
|
|
||||||
|
### 1.1 改动①:bootstrap 游戏加载屏对暗色用户「白闪」
|
||||||
|
|
||||||
|
**现象**:`src/bootstrap/index.html`(虚拟模式游戏的加载屏,`<title>游戏加载中…</title>` + 进度条 + 状态行 + 错误区)把整套配色**硬编码为亮色值**,且 `localStorage` / `prefers-color-scheme` / `data-theme` / `@media` **四项全部零命中**。暗色用户启动虚拟游戏时,会在暗色页面里看到一块亮色矩形。
|
||||||
|
|
||||||
|
**它确实是用户面**(非死代码):
|
||||||
|
- `src/vite.config.ts:21` 把 `bootstrap/index.html` 注册为名为 `bootstrap` 的**第二个 Vite 构建入口**
|
||||||
|
- `runtime/sw/router.ts:1` `export const BOOTSTRAP_PATH = '/__bootstrap'`;`sw/index.ts:211,218` 有 `redirect-bootstrap` 与离线兜底
|
||||||
|
- `runtime/host/adapters.ts:38`:`targets.push({ mode: 'virtual', url: \`${origin}/__bootstrap#${hash.toString()}\`, origin })`
|
||||||
|
- `useGameFrame.test.ts`:virtual 模式 url 形如 `http://demo.localhost:4173/__bootstrap#v=1`
|
||||||
|
- 游戏跑在**独立子域**:`runtime/host/config.ts:18` `derivePlayOrigin → ${protocol}//${16hex}.${baseDomain}`,由 `GameHost.vue` 以 iframe 嵌入
|
||||||
|
|
||||||
|
**根因不是漏改,是 P13 的令牌化只覆盖了单入口**:P13 改的是 `src/index.html`(主应用入口)+ `app/styles/main.css`(12 令牌)+ `app/` 与 `runtime/` 的 `.vue`。`bootstrap/` 是**第二个 HTML 入口**,不在任何一处覆盖范围内。
|
||||||
|
→ **教训(已入 ROADMAP 挂账):换肤/令牌化类改动须先 `grep -n 'input' src/vite.config.ts` 枚举所有构建入口,逐个确认覆盖。单入口假设会漏掉多入口应用。**
|
||||||
|
|
||||||
|
**跨源约束(决定修法的上限)**:
|
||||||
|
- **`localStorage` 按源隔离** → bootstrap 跑在游戏子域,**读不到**主应用存在主域 `localStorage['crearte.theme.v1']` 的显式选择。主应用 `src/index.html:15-31` 的 pre-paint 脚本判定序是 `stored → prefers-color-scheme → light`(由 `themeBootstrap.test.ts` 7 腿钉住),**在子域上拿不到 `stored` 那一级**。
|
||||||
|
- **`prefers-color-scheme` 是浏览器级、不按源隔离** → 可用。
|
||||||
|
- **hash 通道已存在**:`bootstrap/main.ts:4` 就是 `const params = new URLSearchParams(location.hash.replace(/^#/, ''))`,且 `main.ts:97` 有 `fail('启动参数完整', location.href)` 说明它是必需参数集 → **主题可搭车现有 hash 通道传入,不必新建 postMessage 协议**(P15 账本 §7 原估「完整修法须 postMessage,成本更高」,**这条被证伪**)。
|
||||||
|
|
||||||
|
**🔑 架构陷阱(本批最重要的设计约束)**:`GameHost.vue:27-31` 的 `targets` 是 **`computed`**:
|
||||||
|
```js
|
||||||
|
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
|
||||||
|
baseDomain: config.baseDomain, protocol: location.protocol, config
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
而 `useTheme.ts:51` 是模块级响应式单例 `const theme: Ref<Theme> = ref(effectiveTheme())`。
|
||||||
|
|
||||||
|
| 注入方式 | 响应式追踪 | 后果 |
|
||||||
|
|---|---|---|
|
||||||
|
| ❌ `theme: useTheme().theme.value` | **是**(读 `ref` 的 getter → computed 建立依赖) | 用户在游戏页切主题 → `targets` 重算 → **iframe `src` 变化 → 正在运行的游戏被重载**(存档/进度丢失) |
|
||||||
|
| ✅ `theme: effectiveTheme()` | **否**(`effectiveTheme()` 内部读 `localStorage` 与 `matchMedia`,都不是响应式源) | computed 不依赖主题;iframe 不重载。代价:切主题后已打开的加载屏保持旧主题,直到下次导航 |
|
||||||
|
|
||||||
|
→ **裁定用 `effectiveTheme()`**(`useTheme.ts:39` 已导出)。"游戏运行中不重载" 比 "加载屏实时跟随主题切换" 重要得多:加载屏只在**启动瞬间**可见,而重载会毁掉正在进行的游戏。**这条必须有测试钉住**(§5-T1 **腿 5**;⚠️ RE-PIN 2026-10-03:原写「腿 4」,而腿 4 是 IIFE/var、注入是**腿 5**——spec 内部自相矛盾,§5-T1 表自身与 §5 mutation (d)、§7 风险表都写腿 5),否则下一个贡献者"顺手改成响应式"就会引入静默的游戏重载缺陷。
|
||||||
|
|
||||||
|
**色值映射(实测;`bootstrap/index.html` 里 8 个硬编码 hex 的令牌归属)**:
|
||||||
|
|
||||||
|
| hex | 令牌归属 | 暗色对应值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `#f7f2e7` | 亮 `paper` **且** 暗 `ink`(翻转对称点) | `#17140f`(暗 paper) |
|
||||||
|
| `#141414` | 亮 `ink` | `#f7f2e7` |
|
||||||
|
| `#ffffff` | 亮 `surface` | `#221e18` |
|
||||||
|
| `#5c584d` | 亮 `ink-soft` | `#cbc4b5` |
|
||||||
|
| `#e8552f` | 亮 `accent` | `#ff7a4d` |
|
||||||
|
| `#c03a1b` | 亮 `accent-ink` | `#ffb59a` |
|
||||||
|
| `#a3a3a3` | ❌ **不对应任何令牌** | 见下 |
|
||||||
|
| `#f87171` | ❌ **不对应任何令牌** | 见下 |
|
||||||
|
|
||||||
|
**🔴 同批发现两条 pre-existing WCAG 违规(inline `style` 覆盖了达标的样式表值)**:
|
||||||
|
|
||||||
|
| 元素 | 样式表值(`<style>` 块) | inline `style=""` 值 | 谁生效 | 对比度(on `#f7f2e7`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `#status`(`:25`) | `#5c584d` | **`#a3a3a3`** | **inline 胜**(CSS 特异性) | 样式表 **6.3586 ✅** / inline **2.2594 ❌** |
|
||||||
|
| `#error-message`(`:27`) | `#c03a1b` | **`#f87171`** | **inline 胜** | 样式表 **4.8700 ✅** / inline **2.4776 ❌** |
|
||||||
|
|
||||||
|
→ **inline 是冗余重复且用了更低的对比度**:样式表里同元素**已有达标值**,inline 属性把它覆盖成不达标的。**删掉 inline 的 `color` 声明即修复**(保留 `font-size`),达标值自动生效。这是有实测对比度支撑的一行改,**不是设计决策**(与 GameHost 亮色徽标那条不同——那条的显而易见修法已被证伪,属撞色设计决策,仍挂账)。
|
||||||
|
|
||||||
|
**暗色候选配色(8 项全部实算通过)**:
|
||||||
|
|
||||||
|
| 用途 | 暗色值 | on | 对比度 | 需 | 判定 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| body 正文 | `#f7f2e7`(ink) | `#17140f`(paper) | **16.4507** | 4.5 | ✅ |
|
||||||
|
| `#status` / `pre` | `#cbc4b5`(ink-soft) | `#17140f` | **10.5847** | 4.5 | ✅ |
|
||||||
|
| `#error-message` | `#ffb59a`(accent-ink) | `#17140f` | **10.7821** | 4.5 | ✅ |
|
||||||
|
| 按钮文字 | `#17140f`(paper) | `#f7f2e7`(ink) | **16.4507** | 4.5 | ✅ |
|
||||||
|
| 进度条值 | `#ff7a4d`(accent) | `#221e18`(surface) | **6.4274** | 3.0 | ✅ |
|
||||||
|
| 进度条边框/阴影 | `#f7f2e7`(ink) | `#221e18` | **14.8468** | 3.0 | ✅ |
|
||||||
|
|
||||||
|
亮色现状(**须逐字保持不变**):body 正文 16.5010 ✅ · `#status` 样式表 6.3586 ✅ · `#error-message` 样式表 4.8700 ✅ · 按钮 16.5010 ✅ · 进度条值 `#e8552f` on `#ffffff` = 3.6385 ✅(需 3.0)。
|
||||||
|
|
||||||
|
### 1.2 改动②:`AA_PAIRS` 补钉 `['ink', 'surface']`
|
||||||
|
|
||||||
|
**`AA_PAIRS` 现状**(`app/lib/contrast.test.ts:102`,类型 `Array<[fg: string, bg: string, note: string]>`,**10 对**,断言循环在 `:124`):
|
||||||
|
```
|
||||||
|
['ink','paper','正文全站 亮16.50/暗16.45'] ['paper','accent-ink','badge/按钮/error toast 亮4.87/暗10.78']
|
||||||
|
['ink-soft','paper','次要文 亮6.36/暗10.58'] ['paper','success','success toast 亮4.76/暗5.81']
|
||||||
|
['ink-faint','paper','::placeholder+弱文 亮4.83/暗6.99'] ['paper','ink','.btn-ink/markdown th 亮16.50/暗16.45']
|
||||||
|
['ink-soft','surface','卡片次要文 亮7.10/暗9.55'] ['accent-ink','paper','链接 text-accent-ink 亮4.87/暗10.78']
|
||||||
|
['ink-faint','surface','卡片弱文 亮5.40/暗6.31']
|
||||||
|
['ink','highlight','alert×8/strong/选中态/::selection 亮11.30/暗4.81']
|
||||||
|
```
|
||||||
|
|
||||||
|
**缺口**:`ink on surface` **未钉**,而它是**所有未钉配对里使用面最大的**:
|
||||||
|
- `bg-surface` **`.vue` 模板内 62 处**(`app/` + `runtime/`);⚠️ 整个 `src` 是 **66 处**(另 4 处在 `.test.ts`:BaseInput/BaseTabs/BaseTextarea/FileInput 各 1)→ 原写「全仓 62 处」与 66 冲突(RE-PIN 2026-10-03)。**不影响结论**:Tailwind 只从 `.vue` 模板生成 utility,`.test.ts` 里的字面量属 §8-8 的另一回事(⚠️ RE-PIN 2026-10-04 补口径,闭合终审 §6-3:以上是 **base 树 `f823195`** 的计数;HEAD 树上整个 `src` 是 **67** 处、`.test.ts` 是 **5** 处——多的 1 处正是本批在 `contrast.test.ts` 的 AA_PAIRS note 文案里引用了这个数字。**结论不受影响**:Tailwind 只从 `.vue` 模板生成 utility,`.test.ts` 里的字面量属 §8-8 的另一回事)
|
||||||
|
- 其中只有 **4 处**显式写 `text-ink-soft`(已钉)、**2 处**的 `text-paper` 属三元表达式**另一支**(P15 账本 §1 第 19 次错误:把互斥分支当同元素共现,是假阳性)
|
||||||
|
- **58 处靠继承**:全局文字色源是 `src/index.html:34` `<body class="bg-paper text-ink font-sans antialiased">` → 继承到 **`ink`**
|
||||||
|
- 实测对比度:亮 **18.4225** / 暗 **14.8468** → **必然通过 4.5**
|
||||||
|
|
||||||
|
**为什么值得钉(不是"必然通过就不用钉")**:已钉配对里使用面最大的是 `paper on accent-ink`(14 处)。`ink on surface` 有 62 处却无守卫 → 若将来调 `--color-surface`(如暗色 `#221e18` 提亮)或 `--color-ink`,**58 处继承文字会静默回归而零拦截**。这正是 P13/P14 反复出现的形态:**守卫钉住了容易想到的,漏了使用面最大的**。
|
||||||
|
|
||||||
|
**note 文案**(体例同现有 10 条,须含用途 + 亮/暗实测值):
|
||||||
|
```
|
||||||
|
['ink', 'surface', '卡片/表格/表单底(62 处,58 靠 body 继承)亮18.42/暗14.85'],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 改动③:`--color-info` 死令牌清理
|
||||||
|
|
||||||
|
**定义**:`app/styles/main.css:12`(亮 `#2b62cc`)、`:53`(暗 `#7aa7f0`)。
|
||||||
|
|
||||||
|
**零使用**(实测):全仓 `bg-info` / `text-info` / `border-info` / `ring-info` **零命中**;`color-info` 在整个 `src/` + `e2e/` 里**只有那两行定义**(命中数 = 2,即定义本身)。`main.css` 内 `info` 字样也只出现在那两行。
|
||||||
|
|
||||||
|
**矛盾点**:它在 `REQUIRED_KEYS`(`contrast.test.ts:75-88`,**12 项**,`info` 在**第 9 位**(第 8 是 `highlight`;⚠️ RE-PIN 2026-10-03:原写第 8 位))里 → `it(`${zh} 12 令牌齐备(spec §3.1(b))`)`(`:119`)会**在删除时变红**。**守卫在强制一个零使用的令牌存在。**
|
||||||
|
|
||||||
|
**连带面(实测清点,5 个文件,不是"顺手删两行")**:
|
||||||
|
|
||||||
|
| 文件 | 行 | 改什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `crearte/src/app/styles/main.css` | 12, 53 | 删两行定义 |
|
||||||
|
| `crearte/src/app/lib/contrast.test.ts` | 84 | 从 `REQUIRED_KEYS` 删 `'info',` |
|
||||||
|
| 同上 | 47 | 注释「由「12 令牌齐备」断言明确报「暗色 12 令牌缺失」」→ 改 11 |
|
||||||
|
| 同上 | 74 | 注释「P13 全量 12 令牌(spec §3.1(b))」→ 改 11 |
|
||||||
|
| 同上 | 119 | `it` 标题 `${zh} 12 令牌齐备(spec §3.1(b))` → 改 11 |
|
||||||
|
| 同上 | 121 | 断言消息 `${zh} 12 令牌缺失: …` → 改 11 |
|
||||||
|
| `crearte/docs/CHANGELOG.md` | 14(0.26.0 段) | 「每主题 **12 个设计令牌**」→ 须说明 0.19.0 起为 11(**历史条目不改写**,改为在新条目里说明;见 D-C) |
|
||||||
|
| `wrapper/docs/specs/2026-10-03-p13-dark-mode-design.md` | 109, 155, 185, 427 | P13 spec 的 D-B「全量 12 令牌」+ 两处 `--color-info` 代码块 + §「暗色块缺失 → 「暗色 12 令牌齐备」红」→ **加 RE-PIN 标注**(同 P14 手法:保留历史决策 + 标注增订),不改写原文 |
|
||||||
|
| `wrapper/docs/ROADMAP.md` | 42(P13 行) | P13 行的「每主题 **12 个设计令牌**」→ 保留原文(历史记录),挂账里删掉「死令牌 `--color-info`」这一项(已处置) |
|
||||||
|
|
||||||
|
**✅ `LEGACY_KEYS` 不受影响**(实测):`contrast.test.ts:197` 的九键反面钉桩是 `['paper','surface','ink','ink-soft','ink-faint','accent','accent-ink','highlight','success']`,**不含 `info`** → 删令牌**不波及** P13 那条"防后人简化回去"的钉桩。这条必须实测确认过再动手,否则会误改一处刻意保留的历史锚点。
|
||||||
|
|
||||||
|
**为什么不选"启用它"**(处置 (b)):`success` 与 `highlight` 已覆盖 toast 与 alert 的语义需求(`paper on success` 是 success toast、`ink on highlight` 覆盖 alert×8/strong/选中态/`::selection`),`info` **无对应 UI 位置**。启用它需要新造一个 info toast 变体 = 改视觉 + 加功能,超出"代码优雅"范围,且会为一个新的 UI 元素引入新的对比度配对需要钉。**删比造便宜,且删掉的是零使用面。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 设计决策
|
||||||
|
|
||||||
|
| # | 决策点 | 裁定 | 依据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **D-A** | bootstrap 主题来源 | **hash 参数 `theme` 优先 → `prefers-color-scheme` → `light`**(三级,比主应用少 `stored` 一级) | §1.1:`localStorage` 按源隔离读不到;hash 通道已存在(`main.ts:4`);`prefers-color-scheme` 不受源隔离。**判定序必须与主应用 `useTheme.effectiveTheme()` 的后两级逐字一致**,否则同一用户在主应用与游戏加载屏看到不同主题 |
|
||||||
|
| **D-B** | 父侧注入方式 | **`effectiveTheme()` 非响应式快照**,**不得**用 `useTheme().theme.value` | §1.1 的架构陷阱:响应式会让主题切换重算 `targets` → iframe `src` 变 → **运行中的游戏被重载**。必须有测试钉住(§5-T1 **腿 5**;RE-PIN:原写腿 4) |
|
||||||
|
| **D-C** | 死令牌清理的历史条目处理 | **不改写 P13 的 CHANGELOG 条目与 spec 原文**,改为:新条目(0.27.0)说明变更 + P13 spec 加 **RE-PIN 标注块** | CHANGELOG 是发布历史,改写会让"当时交付了什么"失真。P14 已建立正确手法:保留历史决策原文 + 紧随带 ⚠️ RE-PIN 标记的增订块(全分支终审判定这是"正确的 re-pin 手法"而非自相矛盾) |
|
||||||
|
| **D-D** | 新守卫的形态 | **新增 `app/lib/bootstrapTheme.test.ts`**(源码级、node 环境、读文件文本),**不扩展 `noHardcodedColor.test.ts`** | §1.4:`noHardcodedColor` 的 `candidates()` 只收 `.vue`(`if (file.endsWith('.vue'))`),且 `maskNonTemplate()` 把 `<style>` 块整体等长空白掉 → **它从设计上无法覆盖 `bootstrap/index.html`**(HTML 文件 + 颜色全在 `<style>` 里)。扩扫描根不会有任何效果 |
|
||||||
|
| **D-E** | inline `style` 的两处违规 | **删掉 inline 的 `color` 声明**(保留 `font-size`),让样式表的达标值生效 | §1.1 实测:inline 是冗余重复且对比度更低(2.2594 / 2.4776 vs 样式表 6.3586 / 4.8700)。删 inline 即修复,**同时消除"两处定义同一元素颜色"的分歧源** |
|
||||||
|
| **D-F** | 暗色实现方式 | 在 `bootstrap/index.html` 的 `<style>` 块里用 **`html[data-theme="dark"]` 覆盖**(同主应用 `main.css:44` 的手法),**不用 `@media (prefers-color-scheme)`** | 主应用用 `data-theme` 属性驱动(`color-scheme` 也由它驱动,D-L),使"显式选择能压过系统偏好"。bootstrap 若用 `@media` 就只能跟随系统、无法接收 hash 传入的显式主题 → **两套机制会让判定序无法一致**(违反 D-A) |
|
||||||
|
| **D-G** | pre-paint 脚本形态 | **IIFE + 仅 `var`,置于 `<head>` 内、任何 CSS 与 `<body>` 之前**,**形态**逐字对齐主应用 `src/index.html:15-31`(IIFE / 仅 `var` / 位置 / catch 兜底 / 媒体查询字面量五项)——⚠️ **脚本体本身不同**:主应用多 stored 一级(D-A 明列),token 级差异 16 处;RE-PIN 2026-10-04 闭合终审 §6-5,原措辞「逐字对齐…的写法」会被读成脚本体也相同 | 主应用该形态由 `themeBootstrap.test.ts` 腿 4("脚本在 `<head>` 内、`<body>` 之前")与腿 5(IIFE + var)钉住,理由是 CSP 落地前不能用 module/let。**同款形态让两处可被同一套守卫断言** |
|
||||||
|
| **D-H** | 派发形态 | **一个实现者子代理**做完 T1→T4(同动 crearte 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款 |
|
||||||
|
|
||||||
|
### §2.1 被否方案
|
||||||
|
|
||||||
|
| 方案 | 否决理由 |
|
||||||
|
|---|---|
|
||||||
|
| **A:用 `@media (prefers-color-scheme: dark)` 实现 bootstrap 暗色** | 只跟随系统偏好,**无法接收 hash 传入的用户显式选择** → 显式设了暗色但系统是亮色的用户仍看到亮色加载屏。且与主应用的 `data-theme` 机制分叉,判定序无法逐字一致(违反 D-A)。这是 P15 账本 §7 原估的"廉价修法",**取证后判定不够** |
|
||||||
|
| **B:postMessage 传主题** | hash 通道已存在(`main.ts:4`),无需新协议。postMessage 是**异步**的,而加载屏是 pre-paint → 会先绘亮色再翻暗色,**正是要消除的白闪**。`host-origin.ts` 的信令通道用于安装进度上报,时序上不适合主题 |
|
||||||
|
| **C:给 bootstrap 也读 `localStorage`** | 源隔离,物理上读不到主域的值(`derivePlayOrigin` → 独立子域)。若为绕过而放宽子域隔离,会破坏 P11 的安全模型 |
|
||||||
|
| **D:扩展 `noHardcodedColor` 的扫描根到 `bootstrap/`** | §1.4:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,且会给人"已覆盖"的假信心(P13 审查者 M5 的同族形态:扫描根改坏后"扫 0 文件也报 0 违规") |
|
||||||
|
| **E:把 bootstrap 的配色改成引用 `var(--color-*)` 令牌** | 令牌定义在 `app/styles/main.css`,**bootstrap 是独立入口、不加载主应用 CSS**(它只有自己的 `<style>` 块)。要引令牌就得把整个 `@theme` 块复制进 bootstrap = 两处调色板需要同步维护,比硬编码更糟。故 bootstrap **刻意**保持自包含的 hex 值,由新守卫钉住两主题的值与主应用调色板一致 |
|
||||||
|
| **F:一并修 GameHost 亮色徽标 WCAG 3.26** | 属**设计决策**而非一行改:亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存于展柜标题栏,改成 `bg-accent-ink` 会把两个语义压成一个视觉信号(已实测证伪显而易见修法,ROADMAP `9965fd7` 已登记约束)。仍需挂账 |
|
||||||
|
|
||||||
|
### §1.4 `noHardcodedColor` 为何无法覆盖 bootstrap(D-D 的依据,逐字取证)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/lib/noHardcodedColor.test.ts:23-56(⚠️ RE-PIN 2026-10-03:原标 :23-45,但下方引文一直引到 :56 的 maskNonTemplate 结束——而 maskNonTemplate 恰是 D-D 论证的**关键一半**「<style> 块整体空白掉」,旧标注把最有说服力的部分排除在外。另:引文块内的 ← 旁注是**控制者批注**,不在原文里)
|
||||||
|
const SRC_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||||
|
// 终审 N4:扫描根必须含 `runtime/`。…实测 runtime/ 当前零硬编码 hex,扩根不会立刻红。
|
||||||
|
const SCAN_ROOTS = [join(SRC_ROOT, 'app'), join(SRC_ROOT, 'runtime')]
|
||||||
|
|
||||||
|
function candidates(): string[] {
|
||||||
|
const files: string[] = []
|
||||||
|
for (const root of SCAN_ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
if (file.endsWith('.test.ts')) continue
|
||||||
|
if (file.endsWith('.vue')) files.push(file) // ← 只收 .vue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
function maskNonTemplate(content: string): string {
|
||||||
|
const blank = (block: string): string => block.replace(/[^\n]/g, ' ')
|
||||||
|
return content
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, blank)
|
||||||
|
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, blank) // ← <style> 块整体空白
|
||||||
|
.replace(/<(textarea|title)\b[^>]*>[\s\S]*?<\/\1>/gi, blank)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ 两道过滤各自都足以排除 `bootstrap/index.html`:**扩展名不是 `.vue`**,且**颜色全在 `<style>` 块内**(会被 `blank` 掉)。该守卫的设计目标是"禁止在 `.vue` **模板**里用 `bg-[#…]` 形态的硬编码 hex"(文件头注释原文),HTML 入口的 `<style>` 块本就不在其范围内。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 实现要求
|
||||||
|
|
||||||
|
### 3.1 改动① bootstrap 暗色(`src/bootstrap/index.html` + `src/runtime/host/adapters.ts` + `src/runtime/host/GameHost.vue`)
|
||||||
|
|
||||||
|
**(a) `bootstrap/index.html` 的 `<head>`**:在 `<style>` **之前**插入 pre-paint 脚本,逐字对齐主应用形态(D-G):
|
||||||
|
```html
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#F7F2E7" />
|
||||||
|
<!-- P15-B D-A/D-G:加载屏 pre-paint 主题脚本,须在样式与文档体之前执行,否则暗色用户每次启动游戏都闪白。
|
||||||
|
判定序比主应用少 stored 一级(游戏跑在独立子域,浏览器存储按源隔离,读不到主域的 crearte.theme.v1):
|
||||||
|
hash theme → prefers-color-scheme → light。后两级与 useTheme.effectiveTheme() 逐字一致,
|
||||||
|
一致性由 app/lib/bootstrapTheme.test.ts 钉住(改一处必须改另一处)。 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''))
|
||||||
|
var hashTheme = params.get('theme')
|
||||||
|
var theme =
|
||||||
|
hashTheme === 'light' || hashTheme === 'dark'
|
||||||
|
? hashTheme
|
||||||
|
: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
var meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **RE-PIN 2026-10-03(本段是控制者批注,不属于上面可复制的代码块)**:上面注释的措辞**刻意避开** `<style`、`<body`、`localStorage` 三个字面量。实现者与审查者**双双实测**:原措辞「必须在 `<style>` 与 `<body>` 之前」会让**腿 1 对正确实现假红**(注释自身在脚本之前 → `indexOf('<style')` 先命中注释;审查者复现 styleOpen=145 < scriptPos=811),而注释里的存储键字样会让 §5 指标「bootstrap 内该项 = 0」**字面不达标**。**对照**:主应用 `src/index.html` 的注释刻意写「必须在 **CSS 与 Vue** 之前」正是为了避开这两个字面量,所以 `themeBootstrap.test.ts` 的 naive indexOf 才不误红 —— 原措辞违反了这一既有范式。**照抄上面代码块时不要把这批注抄进去。** 🔴 **RE-PIN 2026-10-04 更正(终审 F-FINAL-1)**:本批注描述的「避开三个字面量」范式**仍然成立且必要**,但当时三方都以为 `maskSourceComments()` 已经把注释这一整类载体中和了——**实测并非如此**:该函数的 HTML 注释分支用 2 字符切片去和 4 字符的 `<!--` 比较,恒 false = **死代码**,故它此前只屏蔽 `//` 与 CSS 块注释两种载体。后果是「守卫不依赖措辞自律」这句声称不成立:仅仅把注释措辞改成含 `<style` / `<body` 就会让腿 1–4 假红(同变异跑 e2e 9/9 全绿,证明红的是守卫不是实现)。修法(1 行新增 + 1 处比较对象)已由第二轮 fix-forward 执行并验牙。
|
||||||
|
⚠️ **白名单校验必须与主应用同款**(`=== 'light' || === 'dark'`),不得放宽成"任意 truthy"——`themeBootstrap.test.ts` 腿 3 正是为此存在(P13 finding #2 / N2:垃圾 `stored=neon` 必须被忽略)。
|
||||||
|
|
||||||
|
**(b) `<style>` 块**:加暗色覆盖块(D-F),值取 §1.1 的暗色调色板;`color-scheme: dark` 随块声明一次(同 `main.css:57` 的手法与理由)。**亮色色值逐字不变、渲染结果等价**(⚠️ RE-PIN 2026-10-03:裁定**接受**偏离 4——亮色值改由具名 `--bs-*` 变量供给、暗色块覆盖同名变量。理由三条:① 审查者独立写 CSS 解析器验证亮色态 **15 条颜色相关声明零差异**(含 4 条简写内嵌 `var()` 的 border/box-shadow)② 手法与主应用 `main.css` 同构(`@theme` 定义令牌 + `html[data-theme="dark"]` 覆盖),符合 D-F ③ 严格守「文本形态逐字不变」则暗色块须重写 **12 条规则**,同步维护面从 6 个值涨到 12 条规则)。
|
||||||
|
|
||||||
|
**(c) 两处 inline `style`**(D-E):`:25` 删 `color:#a3a3a3`、`:27` 删 `color:#f87171`,各保留 `font-size`。删后由 `<style>` 块的 `#status`(`#5c584d`) 与 `#error-message`(`#c03a1b`) 生效 → 对比度 6.3586 / 4.8700 达标。
|
||||||
|
|
||||||
|
**(d) `adapters.ts:10,37-38`**:`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' | 'dark'`;仅当 `opts.theme` 存在时 `fragment.theme = opts.theme`(**不加就不写这个键**,保持 external/hosted 模式的 url 完全不变)。
|
||||||
|
```ts
|
||||||
|
export function resolveRuntimeTargets(game: Game, opts: {
|
||||||
|
baseDomain: string; protocol: string; config?: ReturnType<typeof runtimeConfig>; theme?: 'light' | 'dark'
|
||||||
|
}): RuntimeTarget[] {
|
||||||
|
```
|
||||||
|
⚠️ **必须可选**:`adapters.test.ts` 有 **11 处**调用不带 `theme`(8 处裸 `opts` + 3 处 `{...opts, config}`;⚠️ RE-PIN 2026-10-03:原写「4 处」,实测 `resolveRuntimeTargets(` 出现 **11** 次、含 `theme:` **0** 次、按 `test(` 而非 `it(` 组织 → 没有任何口径得出 4),且 `useGameFrame.test.ts` 也 import 了 `RuntimeTarget` 类型 → 加必填键会破既有测试(违反"既有测试修改数 = 0")。
|
||||||
|
|
||||||
|
**(e) `GameHost.vue:27-31`**:注入非响应式快照(D-B):
|
||||||
|
```js
|
||||||
|
import { effectiveTheme } from '../../app/composables/useTheme'
|
||||||
|
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
|
||||||
|
baseDomain: config.baseDomain, protocol: location.protocol, config, theme: effectiveTheme()
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
⚠️ **不得写成 `useTheme().theme.value`**(会让 computed 依赖 theme ref → 主题切换重载 iframe)。注释须写明这个陷阱,否则下一个贡献者会"顺手改成响应式"。
|
||||||
|
|
||||||
|
### 3.2 改动② `AA_PAIRS` 补钉(`src/app/lib/contrast.test.ts:102-113`)
|
||||||
|
|
||||||
|
在 `['ink','highlight',…]` 之后(或按现有分组习惯置于 `surface` 两对之后)插入:
|
||||||
|
```ts
|
||||||
|
['ink', 'surface', '卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85'],
|
||||||
|
```
|
||||||
|
**不得改其它 10 对**(它们的 note 里带实测值,是 P13 交付的一部分)。
|
||||||
|
|
||||||
|
### 3.3 改动③ 死令牌清理(5 文件,见 §1.3 连带面表)
|
||||||
|
|
||||||
|
- `main.css:12`(亮)与 `:53`(暗)删两行 `--color-info`
|
||||||
|
- `contrast.test.ts`:`REQUIRED_KEYS` 删 `'info',`(`:84`)+ 四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)
|
||||||
|
- ⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测)
|
||||||
|
- P13 spec 加 RE-PIN 标注块(D-C,不改写原文)
|
||||||
|
- crearte CHANGELOG **不改写 0.26.0 条目**,在 0.27.0 新条目里说明
|
||||||
|
|
||||||
|
### 3.4 不得触碰
|
||||||
|
|
||||||
|
- `themeBootstrap.test.ts`(主应用 pre-paint 守卫,7 腿)——除非某腿因本批变红,那时**停下来报告**
|
||||||
|
- `noHardcodedColor.test.ts`(D-D:扩它无用)
|
||||||
|
- `useTheme.ts`(`effectiveTheme()` 已够用,改它会波及主应用 10 腿 e2e)
|
||||||
|
- `src/index.html`(主应用入口,P13 交付)
|
||||||
|
- 任何既有 `*.test.ts` / `e2e/*.spec.ts`
|
||||||
|
- `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;wrapper 的 spec/CHANGELOG/ROADMAP 由**控制者**记账)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 边界
|
||||||
|
|
||||||
|
| 项 | 在范围内 | 不在范围内 |
|
||||||
|
|---|---|---|
|
||||||
|
| 文件 | `bootstrap/index.html`、`runtime/host/adapters.ts`、`runtime/host/GameHost.vue`、`app/lib/contrast.test.ts`、`app/styles/main.css`、**新增** `app/lib/bootstrapTheme.test.ts`、**新增** `e2e/bootstrapTheme.spec.ts`(RE-PIN 2026-10-04,闭合终审 §6-4:该文件 294 行 / 9 腿、fix 轮 +162 −13,§5-T4.3 有它的完整测试计划而 §4 的范围内清单漏列) | 其它 `.vue` / `useTheme.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` |
|
||||||
|
| 主题 | bootstrap 加载屏的两态 | GameHost 展柜框架本身(它吃主应用令牌,已正确跟随暗色;其亮色徽标 3.26 属设计决策,挂账) |
|
||||||
|
| 守卫 | 新增 bootstrap 主题一致性守卫 + `AA_PAIRS` 补一对 | 改既有守卫的断言语义 |
|
||||||
|
| 令牌 | 删 `--color-info` | 新增任何令牌 |
|
||||||
|
| 行为 | 加载屏配色 + hash 多一个可选键 | 任何游戏运行时行为、SW 路由、bundle 解密 |
|
||||||
|
|
||||||
|
**挂账**(本批发现但刻意不做):GameHost 亮色徽标 WCAG 3.26(设计决策,撞色约束已登记 ROADMAP `9965fd7`)· 第三态"恢复跟随系统"(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)· CSP 落地时 bootstrap 内联脚本也需 `nonce`(与主应用同批处理)· SFC `<style>` 块内硬编码 hex 守卫不覆盖(P13 accepted:`app/` 无 `<style>` 块)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 测试计划
|
||||||
|
|
||||||
|
### T1 —— 守卫先行(**RED 先行**):新增 `app/lib/bootstrapTheme.test.ts`
|
||||||
|
|
||||||
|
源码级守卫,**node 环境**(§0:vitest 默认 node,沿用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件,与 `contrast.test.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` 同款)。
|
||||||
|
|
||||||
|
读三个文件:`bootstrap/index.html`、`app/composables/useTheme.ts`、`app/styles/main.css`。
|
||||||
|
|
||||||
|
**腿清单**(每条都要有 mutation 证明有牙):
|
||||||
|
|
||||||
|
| # | 断言 | 钉什么 | mutation(应变红) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | bootstrap 的 `<head>` 内有 pre-paint 脚本,且在 `<style>` 与 `<body>` **之前** | D-G 的 pre-paint 位置(否则暗色用户每次启动闪白) | 把 `<script>` 移到 `<style>` 之后 |
|
||||||
|
| 2 | bootstrap 判定序 = **hash theme → prefers-color-scheme → light**,且用 `indexOf` 比较三者位置 | D-A 的判定序 | 交换 hash 与 prefers 的先后;或删掉 light 兜底 |
|
||||||
|
| 3 | bootstrap 用**白名单**校验 hash theme(只接受 `light`/`dark`) | 防"任意 truthy"放宽(P13 finding #2 / N2 的同族) | 改成 `hashTheme ? hashTheme : …` |
|
||||||
|
| 4 | 脚本是 **IIFE 且只用 `var`**(无 `let`/`const`/箭头函数/`import`) | D-G 的 CSP 前形态 | 把 `var` 改成 `const` |
|
||||||
|
| 5 | **`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** | 🔴 **D-B 的架构陷阱**(响应式会让主题切换重载运行中的游戏) | 改成 `theme.value` → 必须红 |
|
||||||
|
| 6 | bootstrap 暗色块的 hex 值与 `main.css` 的 `html[data-theme="dark"]` 调色板**逐值一致**(`paper`/`surface`/`ink`/`ink-soft`/`accent`/`accent-ink` 六个) | D-E 方案否决理由:bootstrap 自包含 hex,故须钉"两处调色板不分叉" | 把 bootstrap 暗色 `--paper` 改成别的值 |
|
||||||
|
| 7 | bootstrap **亮色调色板的值**未被改动 —— ⚠️ **须从 `:root` 块提取 `--bs-*` 再逐值比对(与腿 6 对称),不得用全文子串搜索**(RE-PIN 2026-10-03:审查者 F-4 实测 `#f7f2e7` 在文件里出现 **3 次**(`:root` 的 `--bs-paper`、暗色块的 `--bs-ink`、脚本的 theme-color meta)→ 全文搜索**必然命中**;把 `:root` 亮色 paper 改成 `#eeeeee` 时 `RED=[]`,且**四个守卫合跑 38 passed / 0 failed 全绿** = 本腿的守卫目的「防顺手把亮色也改了」**无断言覆盖**,是 P14「spec D-J 守卫目的无断言覆盖」的同族形态。改为按 scope 提取后**同时消除「暗色块 `--bs-ink` 与亮色 `--bs-paper` 同值」造成的耦合**,即 P13 `LEGACY_KEYS` 钉桩里「scrim 两主题同值 → 后写覆盖不可观测,故排除」的同一类推理) | 防"顺手把亮色也改了"(本批只加暗色) | 改 `:root` 的亮色 paper |
|
||||||
|
| 8 | `bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`**(D-E 的回归钉桩)—— ⚠️ **修法必须覆盖双引号 / 单引号 / 无引号三种属性形态**(RE-PIN 2026-10-04,闭合终审 F-FINAL-4):**提取所有 `style=` 属性值、去引号后逐条查 `color:`**,即已实现的 `inlineStyleValues()`。🔴 **正则字面量刻意不写进本表格**——markdown 表格单元格里的竖线必须转义,而转义后的竖线在正则里是**字面量字符**而非「或」;控制者上一轮正是这样把一条本来正确的正则抄成 **4/7 形态失配**(比裁定原文的 2/7 更错),而它检查了「表格每行竖线数 == 表头」、**没检查转义后的代码是否还是原来那段代码**。要看正则请读代码实体 `src/app/lib/bootstrapTheme.test.ts` 的 `inlineStyleValues()`。⚠️ **不要写成「先匹配 style 属性、再往后找 `color:`」的一行正则**:实测该形态在单引号与无引号上失配——贪婪的引号内匹配会吃掉整个属性值(含内部的 `color:`),闭引号之后剩余文本以尖括号开头 → 失配。与 P13「`bg-[#…]` 手写反斜杠转义对 minified 假阴性」、控制者「带引号 grep 对 minified 假阴性」**同族:模式只覆盖了自己见过的那种写法** | 防 inline 违规复发(本批刚删两处 2.2594 / 2.4776) | 加回 `style="color:#a3a3a3"`、**或单引号形态、或无引号形态** |
|
||||||
|
| 9 | **前提检查**:扫描到的文件数 ≥ 3、bootstrap html 含 `<head>` / `<style` / `<body` **三个结构性标记**、且长度 **> 4000**(⚠️ 单位是**源码字符 4423**,不是产物 7056 字符 / 7588 字节) | 防"读 0 文件也报 0 违规"的假信心(P13 审查者 M5/M6、P14 终审者 R0 的同族教训)。**RE-PIN 2026-10-04**(闭合终审 §6-1):原写「长度 > 500」是 F-8 修**前**的旧值——500 对 4423 而言宽松到截断至 501 字符仍绿(mutation K4 实测),代码已改为 4000 并补了三个结构性前提 | 把路径改成不存在的文件;**或截断到 501 字符**(腿9 应红——修前它仍绿) |
|
||||||
|
| 10 | `AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` **不含** `info`、长度为 **11** | 改动②③ 的回归钉桩 | 删掉补钉的那一对;或把 `info` 加回 |
|
||||||
|
|
||||||
|
**RED 相位要求**:T1 在 T2/T3/T4 之前提交时,`npx vitest run app/lib/bootstrapTheme.test.ts` 必须**红**,且**至少腿 1/2/5/6/8/10 红**(bootstrap 还没有脚本、没有暗色块、GameHost 还没注入、AA_PAIRS 还没补、info 还在)。腿 7/9 应当**已绿**(亮色值本来就在、文件本来可读)——**这是有意的**:它们是本批的"不得回退"钉桩,不是新功能的断言。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**。
|
||||||
|
|
||||||
|
⚠️ **恒真/恒假审视(P13/P14 累计教训,每条腿都要过)**:
|
||||||
|
- 腿 2 用 `indexOf` 比较位置时,**若某个 `indexOf` 返回 -1(未找到),`-1 < 任何正数` 会让顺序断言恒真** → 必须先断言三者都 `> -1`(`themeBootstrap.test.ts:42-44` 正是这么写的,照抄这个手法)
|
||||||
|
- 腿 6 的"逐值一致"**不得写成 `max(a,b) ≥ k` 形态**(P13 的恒真高发区:互补两侧有非平凡下界);应写成**字符串相等**
|
||||||
|
- 腿 10 的长度断言须是 `toBe(11)` 而非 `toBeGreaterThanOrEqual(…)`(后者在删令牌后仍绿 = 无牙)
|
||||||
|
- 🔴 **(⚠️ RE-PIN 2026-10-03 补:原三条遗漏的第四类恒真形态)循环变量集合自身可被清空 → `for` 执行零次 = 恒真空绿**。审查者 **H4** 实测:清空 `DARK_TOKENS` 后腿 6 的 `for` 零次执行、`RED=[]`,**而退化是真的**——这是 P13 审查者 **M5**(扫描根改 e2e → 扫 0 个 `.vue`)/ **M6**(`HEX_RE` 改恒不匹配)的**精确同族**。**修法:每条以集合驱动的腿都须加 `expect(LIST.length).toBe(N)`**(腿 6 加 `DARK_TOKENS.length === 6`、腿 7 加 `LIGHT_HEX.length === 6`,与腿 10 的 `toBe(11)` 同款手法)
|
||||||
|
|
||||||
|
### T2 —— 改动②③(守卫腿 10 由红转绿)
|
||||||
|
|
||||||
|
按 §3.2 / §3.3 执行。**完成后 `npx vitest run` 必须 79 文件 / 688+N 测试全绿**(N = T1 新增的腿数),且**既有测试文件零修改**。
|
||||||
|
|
||||||
|
⚠️ 改动③会让 `contrast.test.ts` 的 `it` 标题从 "12 令牌齐备" 变 "11 令牌齐备" → **测试名变了但文件是"修改"而非"新增"**。这是**唯一被允许的既有测试文件修改**,且必须在报告里显式声明(同 P14 的 `architecture_test.go` 例外处理)。除此之外 `--diff-filter=M -- '*_test.ts'` 必须为空。
|
||||||
|
|
||||||
|
### T3 —— 改动①(bootstrap 暗色 + hash 注入)
|
||||||
|
|
||||||
|
按 §3.1 执行。完成后:
|
||||||
|
- `npx vitest run` 全绿
|
||||||
|
- `npm run typecheck`(`vue-tsc --noEmit`)零错
|
||||||
|
- `adapters.test.ts` 的既有 **11 处**调用**零修改**仍绿(验证 `theme` 是可选键)
|
||||||
|
|
||||||
|
### T4 —— e2e 与构建产物验证
|
||||||
|
|
||||||
|
1. `npm run build`(含 `vue-tsc --noEmit` + `vite build` + `build-runtime.mjs`)exit 0
|
||||||
|
⚠️ **`npm run build` 会类型检查 `e2e/*.spec.ts`**(P13 spike 6 曾因此 `BUILD_EXIT=2`)
|
||||||
|
2. `npm run e2e` 主套件 **102+1skip** 基线不回退(P13 交付值;`dark.spec.ts` 10 腿须全绿)
|
||||||
|
3. **新增 e2e 腿**(⚠️ RE-PIN 2026-10-03:**偏离 5 已被裁定驳回**,本项按原意执行,不接受「fixture 不支持所以不测」):(a) **端到端腿**——暗色 + 虚拟游戏页,用 **`context.route`(不是 `page.route`)** + inert sw.js 冻住加载屏,断言 **`snap.url === 父侧 iframe src`**(**因果链闭合**:子文档读到的就是父侧算出的那个 url,不是测试手搓的 hash)、`attr=dark`、`bg=rgb(23,20,15)`、h1 是加载屏、`theme-color` meta = `#17140f`、**+8s 后 `path` 仍是 `/__bootstrap`**;另加亮色格(`stored=light` 压过系统暗色)。🔴 **根因纠正**:实现者四种拦截手法**全用 `page.route`**,审查者 PROBE-6A 实测**三个 route 命中数全为 0**(SW 注册请求、SW 发起的请求、被 SW `respondWith` 合成的导航都**不经 page 级路由**)→ 「挂起从未生效,真 SW 照常安装 → `runtime:ready` → `location.replace('/')`」被误归因为「加载屏本质瞬态」。改 `context.route` 后 PROBE-7A `sw.js hits = 1`、子文档稳定停在 `/__bootstrap`;PROBE-7B **T+0/T+10s/T+25s 三次全 `attr=dark`**,T+25s 因 Playwright 自身 `timeout: 45_000` 中断而**不是被顶掉**。(b) **行为腿**——游戏页切主题 → iframe `src` **不变**(同一 inert-SW 手法下可确定性断言):这是 D-B 那个 critical 性质的**唯一行为级防线**(腿 5 改回全文断言后仍有残留局限,见 §5-T1 腿 5)。(c) **反方向格**——子侧 `[系统暗色] × [hash=light]` → 期望 **`light`**(6 行):审查者实测 **H1**(白名单提取成变量 + prefers 优先)与 **H3**(拆中间变量的嵌套三元)都让腿 2 全绿,而**缺陷是真的**(显式选亮色的暗色系统用户看到暗色加载屏,违反 D-A「hash 优先」);且 **H1 同时逃过源码守卫(10 passed)与全部 5 条 e2e 腿(5 passed)**。缺失的正是这一格,它是 H1/H3 在**子侧直访**路径上的唯一鉴别格(⚠️ RE-PIN 2026-10-04,闭合终审 §6-2:实测判定序真缺陷会让**两条**腿红——本格**与端到端亮色格**,故「唯一」只在子侧直访这一类里成立;原措辞是纪律 #4 惩罚的全称声明)。对照:主应用 `dark.spec.ts:169` **有**这条反方向腿(「stored 压过 system(反方向)」),bootstrap 侧缺
|
||||||
|
4. **产物验证**(P13 教训:源码级守卫不等于产物正确):⚠️ **必须在 e2e 之后重跑生产 `npm run build` 之上做**——`npm run e2e` 内部跑 `build:e2e`(`playwright.config.ts:12` 的 `webServer.command`)会**覆盖 `dist/`**,并用 `grep -c -F 'localhost:4173' src/dist/bootstrap/index.html` = **0** 证明量的是生产构建而非 e2e 残留(⚠️ RE-PIN 2026-10-03:控制者当日正是量了 e2e 残留,导致它的 7069 与实现者的 7056 本就不该相等而被当成矛盾;实现者报告 §2 已明写这个顺序陷阱)。判据:`dist/bootstrap/index.html` **含暗色块**、**不含** `#a3a3a3` 与 `#f87171`;主应用 CSS 里 `--color-info` **零命中**、两个色值(`2b62cc`/`7aa7f0`)**零命中**;**定义侧**令牌数 **12→11**(源码 `@theme` 与 `html[data-theme="dark"]` 各 11、产物暗色块 11);**usage 侧** `var(--color-*)` 计数与 P13 交付值 **75 保持不变**——`info` 是**零 usage 的死令牌**(7 种 utility 后缀 `bg-`/`text-`/`border-`/`ring-`/`fill-`/`stroke-`/`outline-` 全部零命中),删它**必然**不改变 usage 计数,这是预期行为而非缺陷。🔴 **判据方向**:若 usage 计数**发生变化**,才说明存在对 `info` 的引用,与 §1.3 的「零使用」取证前提矛盾,须停下来重新取证。**定义侧与 usage 侧是两个口径,不得混用**(⚠️ RE-PIN:原判据「删一个令牌应使计数变化」方向写反了,由实现者发现、审查者独立复现成立)。⚠️ **产物 grep 用 `grep -F`**,不手写转义、不用带引号模式(minifier 会去属性引号:产物是 `data-theme=dark` 而非 `data-theme="dark"`;CSS 类名是**转义**形态 `.bg-\[\#…\]`)。⚠️ **报数字必须同时报口径**:字节用 `wc -c`(**不要用 `len(str)` 标注成 B**,那是字符数,差值精确等于 UTF-8 多字节贡献)、计数**同时给 raw 与屏蔽注释后两个数**并声明大小写敏感性、行数用 `wc -l`
|
||||||
|
⚠️ **产物 grep 用 `grep -F` 不手写转义**(P13 教训:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;AGENTS.md 已立不变量)
|
||||||
|
|
||||||
|
### mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 恢复证明")
|
||||||
|
|
||||||
|
- (a) 把 bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 → 腿 1 红
|
||||||
|
- (b) 交换 hash theme 与 `prefers-color-scheme` 的判定先后 → 腿 2 红
|
||||||
|
- (c) 把 hash theme 校验放宽成 `hashTheme ? hashTheme : …` → 腿 3 红
|
||||||
|
- (d) `GameHost.vue` 改用 `useTheme().theme.value` → 腿 5 红(**这条是 D-B 的牙**)
|
||||||
|
- (e) 改 bootstrap 暗色的一个 hex → 腿 6 红
|
||||||
|
- (f) 加回 `style="color:#a3a3a3"` → 腿 8 红
|
||||||
|
- (g) 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` → 腿 10 红
|
||||||
|
- (h) 把 `info` 加回 `REQUIRED_KEYS` → 腿 10 红
|
||||||
|
- (i) **把守卫的文件路径改成不存在的文件** → 腿 9 红(防"读 0 文件报 0 违规")
|
||||||
|
- (j) **删掉 `main.css` 的暗色块**(模拟"P13 成果回退")→ 腿 6 或 `contrast.test.ts` 的暗色腿红
|
||||||
|
|
||||||
|
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件腿数不变。
|
||||||
|
|
||||||
|
### 结构指标(交付时须报实测数字)
|
||||||
|
|
||||||
|
| 指标 | 基线 | 目标 |
|
||||||
|
|---|---|---|
|
||||||
|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N = T1 腿数,≥10) |
|
||||||
|
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,且仅因 "12 令牌"→"11 令牌" 文案;须在报告显式声明) |
|
||||||
|
| `AA_PAIRS` 对数 | 10 | **11** |
|
||||||
|
| `REQUIRED_KEYS` 项数 | 12 | **11** |
|
||||||
|
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
|
||||||
|
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
|
||||||
|
| bootstrap 内 `localStorage`/`prefers-color-scheme`/`data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` 仍须为 0:源隔离,D-A) |
|
||||||
|
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
|
||||||
|
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
|
||||||
|
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 记账
|
||||||
|
|
||||||
|
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语。
|
||||||
|
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。**与 P15-A 合并为一条还是分两条由控制者按合并时序定**(若两批同时合并,写一条含两仓的条目更清楚)。
|
||||||
|
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行。**哈希引 merge commit**。挂账里删掉「死令牌 `--color-info`」(已处置)、新增「bootstrap 内联脚本的 CSP nonce」(与主应用同批)。
|
||||||
|
- P13 spec 加 **RE-PIN 标注块**(D-C:12→11 令牌),不改写原文。
|
||||||
|
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` **绝不 stage**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §7 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| **`theme` 键写成必填 → 破既有测试** | §3.1(d) 明令可选;`adapters.test.ts` **11 处**调用零修改仍绿是验收项 |
|
||||||
|
| **🔴 注入改成响应式 → 主题切换重载运行中的游戏** | D-B + §3.1(e) 的 ⚠️ 注释 + **T1 腿 5 与 mutation (d) 专门钉这条** + **§5-T4.3(b) 的行为腿**(RE-PIN 2026-10-03)。⚠️ **腿 5 只钉字面形态,不够**:审查者 F-1(critical)实测 **G1**(调用点字面量与交付版**逐字相同**、依赖在 computed 体内别处建立)与 **G2**(import 别名 + 本地同名响应式包装)**双双绕过**(10 腿全绿、`vue-tsc` exit 0),而用 `effectScope` + `computed` 求值计数**证明缺陷语义上真实**(基准 `evals=1`/url 不变;G1/G2 均 `evals=2`、url 从 `…theme=light` 翻成 `…theme=dark` → **iframe `src` 变化 → 运行中的游戏被重载、存档丢失**)。**修法(一行 ×2)**:腿 5 的两条负向断言从 `args`(调用点内)改回 `gameHostMasked`(已屏蔽注释的**全文**)——审查者逐字验证 `maskSourceComments()` **本身已解决**「陷阱注释必然写出被禁字面量」的冲突(🔴 **RE-PIN 2026-10-04 限定适用范围,终审 F-FINAL-1**:这句对**当前这个文件**是真的,但成立原因是「陷阱注释恰好是 `//` 行注释形态」,**不是**「屏蔽了注释」这一整类——该函数的 HTML 注释分支当时是死代码。故 F-1 的修法方向正确、必须做,而它的**安全论证**依赖一个只对 `//` 成立的泛化,那个泛化已被 C5/G1x 实测推翻)(屏蔽后 `useTheme()` 与 `theme.value` 都从文件消失、`effectiveTheme` 真实代码保留、陷阱注释 28–33 行屏蔽后全空)→ **缩小断言范围不必要,第二道防线反而制造了缺口**(与 P14 终审「在第一轮修复自身里找到洞」完全同构)。已验五种组合:交付形态+G1/G2 = 绿(绕过);改法+G1/G2 = **红(抓到)**;改法+合法树 = **10 腿全绿(不误红)**。⚠️ **残留局限须写进守卫注释、不得声称「唯一手段」**(纪律 #4):改后仍抓不住「在 computed 之外的**模块级作用域**读 ref 再传值」,但该形态**无害**(快照在模块加载时求值一次、无依赖 → 不会重载);真正的解药是行为腿 |
|
||||||
|
| **判定序与主应用分叉 → 同一用户两处不同主题** | T1 腿 2 钉 bootstrap 内部顺序;**另需一条腿比对 `useTheme.effectiveTheme()` 的后两级顺序**(`themeBootstrap.test.ts` 腿 2 的同款手法:用 `indexOf` 比较,且先断言三者都 `> -1`) |
|
||||||
|
| **bootstrap 与主应用调色板分叉**(bootstrap 自包含 hex) | T1 腿 6 逐值比对六个暗色 hex + 腿 7 钉亮色值不变。**这是 D-E 方案(引令牌)被否后的必要补偿** |
|
||||||
|
| **新守卫写成恒真** | §5-T1 的 ⚠️ 三条(`indexOf` 返 -1 时顺序断言恒真、`max(a,b) ≥ k` 形态、`toBeGreaterThanOrEqual` 无牙)+ mutation (i) 腿 9 前提检查 + (a)–(h) 逐条验牙 |
|
||||||
|
| **inline 违规复发** | T1 腿 8 + mutation (f)。**本批发现的两处是 pre-existing(P13 之前就存在),修完须有钉桩**,否则下一次改 bootstrap 又会加回来 |
|
||||||
|
| **产物与源码不一致**(源码级守卫绿但产物错) | §5-T4.4 产物验证。P13 教训:`APPLY.toString()` + `new Function` 序列化注入验证构建期烘焙是**无效手法**;必须验真实 `dist/` |
|
||||||
|
| **e2e fixture 无虚拟游戏 → hash 注入无覆盖** | ⚠️ RE-PIN 2026-10-03:**该风险的前提被证伪**——审查者实测加载屏**可**确定性冻结(`context.route` + inert sw.js,稳定 ≥25s),并已写出跑绿 2 条端到端腿(8.9s + 0.6s)。原「退路」(改 vitest 腿挂 `GameHost`)**删除**;§5-T4.3 的「必须有一腿覆盖」按原意执行。**快照存档不能替代断言**(快照是一次性观测、不会自己变红)——与 P13 教训同一枚硬币的两面:那边是「产物验证不能替代源码守卫」,这边是「**存档观测不能替代 CI 断言**」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §8 实现纪律(P11–P14 累计教训,与 P15-A spec §8 同源,前端部分加粗)
|
||||||
|
|
||||||
|
1. **不推断,只实测。** P13 控制者在勘查期栽两次(**用运行时注入验证构建期烘焙**、**用 `APPLY.toString()`+`new Function` 序列化注入**),复核期写错断言/grep/锚点 13 次。
|
||||||
|
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者六次因这条纪律避免假指控(含 `bg-surface=0`、`main.css 无全局色`、`七类断言=0`)。
|
||||||
|
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = **4.0621 > 3**,50653 采样暴力验证)。
|
||||||
|
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过)。**写"唯一/全部/任何"之前先枚举形态。**
|
||||||
|
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 被否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)。
|
||||||
|
6. **产物 CSS 选择器用 `grep -F`,不手写转义。** P13:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;**带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到 `bg-[#123456]` 的压缩形态)。已立为 crearte AGENTS.md 不变量。
|
||||||
|
7. **源码级守卫跑 node 不跑 happy-dom。** `fileURLToPath(new URL('../..', import.meta.url))` 在 happy-dom 下抛 `ERR_INVALID_URL_SCHEME`。已立为 AGENTS.md 不变量;§0 已确认本仓 vitest 无 `environment` 键 → 默认 node。
|
||||||
|
8. **Tailwind v4 扫描全部源文件含 `.test.ts`/`.spec.ts`**:测试注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入。已立为 AGENTS.md 不变量 §8-5b。**本批写守卫时,注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(用字符串拼接或省略号)。
|
||||||
|
9. **管道会吞掉真退出码**;**`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
|
||||||
|
10. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在,否则 FATAL 终止。
|
||||||
|
11. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"** → 任何"零命中"结论都要先证明扫描范围非空(T1 腿 9 就是这条的机械化)。
|
||||||
|
12. **测试被迫修改 = 设计失败的信号**,除非 spec 明列例外(本批唯一例外:`contrast.test.ts` 的 "12→11 令牌" 文案)。若发现必须改其它测试,**停下来报告**。
|
||||||
|
13. 🔴 **(RE-PIN 2026-10-03,审查者 F-2)手法的适用范围没验证就当已穷尽**:`page.route` **拦不到** SW 注册请求、SW 发起的请求、以及被 SW `respondWith` 合成的导航 → 冻住 SW 驱动的加载屏须用 **`context.route`**。**用错层级会让「挂起」静默失效,并把结果误归因为「被测对象本质瞬态」**——实现者四种手法全用 `page.route`(实测命中 0),据此得出「加载屏无法冻结、端到端腿必然 flaky」并撤掉了腿,而归因是错的。与第 6 条(带引号 grep 对 minified 假阴性)**同族**。**换一层再试一次,再下结论。**
|
||||||
|
14. **(RE-PIN 2026-10-03,审查者 F-9)报数字必须同时报口径**:字节用 `wc -c`(**不要用 `len(str)` 标注成 B**——那是字符数,差值精确等于 UTF-8 多字节贡献:CSS 12、html 532);计数**同时给 raw 与屏蔽注释后两个数**并声明大小写敏感性(控制者用 raw、实现者用屏蔽注释+大小写不敏感 → 同一份产物两份互斥数字、第三方无法判定谁错);行数用 `wc -l`(不要 `split('\n')`);**产物验证须在 e2e 之后重跑生产 build**(`npm run e2e` 内部跑 `build:e2e` 会覆盖 `dist/`)。**没有口径的数字无法交叉核对,等于没报。**
|
||||||
|
15. **一次性验证脚本一律落盘存证**(控制者的 CSS 解析器没落盘 → 结论虽被审查者独立复现,但**实现不可审计**)。**适用例补全(RE-PIN 2026-10-04,终审 §8.2):census / 统计类脚本同样必须落盘** —— 前任的 arbitrary-utility census 脚本没落盘,导致「48 vs 47」这个差 1 的口径争议**至今无法对齐**(终审用三份口径独立数都得 48,判据「真 hex 色 = 0」三方一致,故结论不受影响,但差异本身不可追溯)。
|
||||||
|
16. 🔴 **(RE-PIN 2026-10-04,终审 §8.7)报告里引用的每一份证据文件,交付前须逐个 `ls` 核在盘上;引用别处输出须给出该输出的落盘路径,路径不存在即视为未验证。** 这条纪律本轮**两次生效**:fix 者靠它抓到自己的假取证(把审查者的 PROBE 数字写成自己的实测结论——**是靠核「文件在不在盘上」抓到的,不是靠核结论对不对,因为结论恰好是对的**);终审者靠同一手法抓到控制者 `verify-fix-v4.py` 的 §③ 是 21 条无条件 `print('[OK]')`、且它引用的 v2 输出从未归档 → 「0 FAIL」结论虽真却不自足。**落盘纪律的价值不在结论正确性,而在可审计性。**
|
||||||
|
17. 🔴 **(RE-PIN 2026-10-04)spec 与裁定里凡可执行的技术细节(正则、函数签名、字面量、行号区间、对比度数字)必须从代码实体 `grep` / `git show` 出来粘贴,或自己按公式复算,不得凭记忆手写。** 本批控制者因此错了三处:① 把审查者建议的一行正则抄进 §5-T1 腿8,而 markdown 表格的竖线转义让它变成 **4/7 形态失配**(比裁定原文的 2/7 更错)② 漏 re-pin 腿9(spec 仍写 > 500 而代码已是 4000)③ 引用的对比度 `3.0269` 从未被任何人复核,自算实为 **2.5519**(反查任何灰底都得不到 3.0269 → 那是个孤值)。与 P15-A 的同族错误(形参顺序、阶段区间行号)合并为一条纪律,两份 spec §8 同源。
|
||||||
Reference in New Issue
Block a user