Compare commits
54
Commits
9c6149ae4c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70991b0ad | ||
|
|
51bed55704 | ||
|
|
dd5d0fabf9 | ||
|
|
9061c39ef3 | ||
|
|
37a0a8ddd1 | ||
|
|
02188eb3e0 | ||
|
|
82f0a6d360 | ||
|
|
163b0d703a | ||
|
|
33c2d8d4b8 | ||
|
|
11fa1e2d26 | ||
|
|
9965fd73ee | ||
|
|
ba1fe3ecb0 | ||
|
|
14d029e61b | ||
|
|
b9e59aee83 | ||
|
|
708f95eb3d | ||
|
|
0e63dc2448 | ||
|
|
015c2b9696 | ||
|
|
35b50539c6 | ||
|
|
0a7eba2682 | ||
|
|
9226926ebb | ||
|
|
3eec67513c | ||
|
|
115dfd5fdf | ||
|
|
67f7a802d1 | ||
|
|
1fca6f1c53 | ||
|
|
d6e08e1d03 | ||
|
|
f1d8d9858b | ||
|
|
5d20189d46 | ||
|
|
88163fdbcb | ||
|
|
77f4695fbe | ||
|
|
c7af5d0830 | ||
|
|
7095ad3775 | ||
|
|
d22672a1e4 | ||
|
|
36286b8a25 | ||
|
|
eec902b743 | ||
|
|
256f0d91b7 | ||
|
|
979026e181 | ||
|
|
e08cd7e7b3 | ||
|
|
21dc520a4e | ||
|
|
7180dbfdaa | ||
|
|
820914f0f1 | ||
|
|
34fbbe608d | ||
|
|
f8fe8482d8 | ||
|
|
7a6262f5ce | ||
|
|
7e7d1ab142 | ||
|
|
a6b4328edf | ||
|
|
20f5308116 | ||
|
|
8af7ef3501 | ||
|
|
0131d21ea4 | ||
|
|
cc2156b44d | ||
|
|
bab22940bd | ||
|
|
8ba1d7e2a2 | ||
|
|
d814914e21 | ||
|
|
2f3bade817 | ||
|
|
0b25a39e7b |
@@ -1,5 +1,6 @@
|
||||
crearte/
|
||||
crearte-deploy/
|
||||
crearte-server/
|
||||
.superpowers/
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## Repository role
|
||||
|
||||
Docker Compose orchestration for the crearte full stack. Three repos must stay siblings:
|
||||
Monorepo wrapper for the crearte full stack. This repo tracks `AGENTS.md`, `clone_all.sh`, and `docs/` (roadmap and all cross-repo design/plan docs). The three product repos are nested clones, each with its own git history and `AGENTS.md`:
|
||||
|
||||
```
|
||||
repos/
|
||||
crearte-monorepo/ # this repo (wrapper)
|
||||
├── crearte/ # frontend (build context for web-* images)
|
||||
├── crearte-server/ # backend Go API (build context for api-* images)
|
||||
└── crearte-deploy/ # this repo — compose only, no app code
|
||||
└── crearte-deploy/ # compose orchestration, no app code
|
||||
```
|
||||
|
||||
Everything runs in containers. Do not introduce host-run workflows (no `npm run dev` / `go run` on the host) in docs, scripts, or compose.
|
||||
@@ -16,9 +16,9 @@ Everything runs in containers. Do not introduce host-run workflows (no `npm run
|
||||
## Safety Rules
|
||||
|
||||
- Branch naming: `{feat|fix|docs|chore}/{branch-name}` (e.g. `fix/compose-port-clash`).
|
||||
- Before `git commit`: run `git branch --show-current`. If on `master`, do NOT commit — ask the user for a branch name, create it, and commit there.
|
||||
- Commits in this monorepo wrapper may go directly on `master` (user-approved 2026-09-29). In the inner repos (`crearte/`, `crearte-server/`, `crearte-deploy/`): before `git commit`, run `git branch --show-current`. If on `master`, do NOT commit — ask the user for a branch name, create it, and commit there.
|
||||
- `.env` is gitignored and must stay that way. Never commit secrets. Any new secret must land in `.env.example` as an empty placeholder (with a comment) in the same commit that introduces it.
|
||||
- Changes → `docs/CHANGELOG.md` (this repo is not under `web/`, so `CHANGELOG_webui.md` never applies). Higher versions on top.
|
||||
- Changes → `docs/CHANGELOG.md` of the repo being changed (this wrapper's `docs/CHANGELOG.md`, or an inner repo's `docs/CHANGELOG.md` for its own changes). This workspace is not under `web/`, so `CHANGELOG_webui.md` never applies. Higher versions on top.
|
||||
- CHANGELOG entry format: same entry has English line then Chinese line on consecutive lines (no blank line between them); different entries are separated by a blank line.
|
||||
- Merging into `master`: always create a merge commit — `git merge --no-ff <branch>` (never fast-forward, the integration point must be recorded). Delete the merged branch afterwards.
|
||||
- Compose changes that alter usage must update `README.md` in the same commit.
|
||||
@@ -41,8 +41,12 @@ Everything runs in containers. Do not introduce host-run workflows (no `npm run
|
||||
3. Smoke-test the affected path (e.g. `docker compose exec api-dev wget -qO- http://127.0.0.1:8080/healthz`, or the upload flow through http://localhost:8080).
|
||||
4. Tear down with plain `down` (never `-v`) unless the task is specifically about destroying data.
|
||||
|
||||
## Documentation
|
||||
|
||||
- All specs, implementation plans, roadmaps, and cross-repo coordination docs live in this wrapper's `docs/` (`docs/ROADMAP.md`, `docs/specs/`, `docs/plans/`). Do not scatter them into the inner repos; their `docs/superpowers/` copies are legacy (in `crearte` that path is gitignored). This supersedes the crearte-deploy 0.3.0 "canonical home" convention.
|
||||
- Register every new spec/plan in the doc index table at the bottom of `docs/ROADMAP.md`.
|
||||
|
||||
## Layout notes
|
||||
|
||||
- `docs/superpowers/` (plans/specs) is tracked in this repo — unlike in `crearte`, where it is gitignored.
|
||||
- No app code, no tests, no build scripts live here; those belong to `crearte` / `crearte-server`.
|
||||
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ cd "$ROOT_DIR" || exit 1
|
||||
# Project list: "<dir-name>|<git-url>|<branch>"
|
||||
# ---------------------------------------------------------------------------
|
||||
REPOS=(
|
||||
"crearte|git@github.com:XingfenD/crearte.git|feat/submission-preview"
|
||||
"crearte-server|git@github.com:XingfenD/crearte-server.git|feat/submission-preview"
|
||||
"crearte|git@github.com:XingfenD/crearte.git|master"
|
||||
"crearte-server|git@github.com:XingfenD/crearte-server.git|master"
|
||||
"crearte-deploy|git@github.com:XingfenD/crearte-deploy.git|master"
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+103
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
|
||||
# Author Page (`/users/:user`) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 新增作者主页 `/users/:user`:列出该作者全部已上架作品(最新在前),并从作品详情页与卡片提供入口。
|
||||
|
||||
**Architecture:** 纯前端实现 —— 新视图 `AuthorView.vue` 复用现有 `repo.listGames()`(API+static 双源合并、ETag 缓存、API 宕机降级),按 `resolveUserSlug(game).user` 过滤后交给 `filterGames` 的 `DEFAULT_FILTER`(`sort: 'new'`)排序渲染;路由 `/users/:user`(`props: true`)。crearte-server 零改动。
|
||||
|
||||
**Tech Stack:** Vue 3.5 `<script setup>` + TypeScript + vue-router 5 + Vitest/@vue/test-utils(happy-dom)+ Playwright(noauth 配置)。
|
||||
|
||||
**Spec:** `docs/specs/2026-09-29-author-page-design.md`(monorepo 仓)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **crearte-server 零改动**:本计划任何任务不得在 `crearte-server/` 产生 diff。
|
||||
- 页面只做纯聚合:无筛选器、无查询参数、无用户存在性探测。
|
||||
- 头部展示**账号 `username`**(路由参数),不从 `author.name` 推导;显示名统一用 `authorDisplayName(game)`(`@/lib/labels`),两者语义不混用。
|
||||
- 排序复用 `filterGames(games, DEFAULT_FILTER)` 的 `sort: 'new'`(`addedAt` 降序、`id` 决胜),**禁止复制比较器代码**。
|
||||
- TDD:每个任务先写失败测试、看它以正确理由失败,再实现;任务内提交。
|
||||
- 工作分支 `feat/author-page`(crearte 仓);CHANGELOG 条目为双语(英文行 + 中文行连续,条目间空行),版本 `0.14.0`。
|
||||
- **本机无 host node**:npm 命令一律容器内执行(见各任务 Run 命令;来源 `crearte-deploy/docs/HANDOFF.md` §2)。若 node_modules 卷为空,先在容器内 `npm ci`。
|
||||
- 别名 `@` → `src/app`。
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **旧式单段 id 作品**(无 `user`/`slug` 字段)不得错误匹配到任何作者页 —— Task 1 测试钉住 `resolveUserSlug` 回退(`user: ''` 不匹配)。
|
||||
2. **未知用户名**必须渲染空态(非 404、非报错)—— Task 1 测试钉住。
|
||||
3. **`author.name` 与 `username` 语义分离**:显示文本可以是 `author.name`,但链接目标永远是 `/users/<user>`;无 `user` 时不得产生内部链接 —— Task 2 测试钉住三种形态(内部链接 / 外链兜底 / 纯文本)。
|
||||
4. **GameCard 新增作者行**不得破坏既有卡片测试与布局断言 —— Task 2 跑全量前端测试。
|
||||
5. **加载失败可重试**:`repo.listGames()` reject 时进 `StatePanel` 错误态,`@retry` 重新加载 —— Task 1 测试钉住。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: AuthorView + 路由
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/views/AuthorView.vue`
|
||||
- Modify: `src/app/router/index.ts`(`routes` 数组,注册在 `/:pathMatch(.*)*` 之前)
|
||||
- Test: `src/app/views/AuthorView.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `repo.listGames(): Promise<GameSummary[]>`、`resolveUserSlug(game): { user, slug }`(`@/data`)、`filterGames(games, state)` + `DEFAULT_FILTER`(`@/lib/filter`)、`useAsync<T>(loader, deps)`(`@/composables/useAsync`)、`StatePanel`(props `{ loading, error }`,emit `retry`)、`GameCard`(props `{ game, headingLevel? }`)、`BaseButton`。
|
||||
- Produces: 路由 `{ path: '/users/:user', name: 'author', props: true }`;`AuthorView` props `user: string`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(`src/app/views/AuthorView.test.ts`,仿 `GameView.test.ts` 的 `vi.hoisted` + `vi.mock('@/data', …)` + memory-router + `RouterLink` stub 模式;`mount(AuthorView, { props: { user: 'alice' }, global: { plugins: [router], stubs: … } })`)
|
||||
|
||||
```ts
|
||||
// 夹具(模块内常量):matching(2 个 alice + 1 个 bob,addedAt 分别 2026-09-01/2026-09-03/2026-09-02)
|
||||
it('只显示该作者作品并按最新上架排序') // 渲染 2 张 GameCard,顺序为 addedAt 降序(09-03 在前)
|
||||
it('旧式单段 id 作品不匹配任何作者页') // { id: 'plain-legacy' /* 无 user/slug */ } 不出现在 alice 页
|
||||
it('复合 id 但缺 user 字段时按 id 前段解析') // { id: 'alice/legacy' /* 无 user/slug */ } 出现在 alice 页
|
||||
it('未知作者渲染空态而非报错') // props user='ghost' → 文案「该作者暂无已上架作品」可见,无 role="alert"
|
||||
it('头部展示 @username、作品数与面包屑') // 文本含 '@alice' 与 '2';面包屑「目录」链接 href='/games'
|
||||
it('加载失败进入错误态且可重试') // mockRejectedValue 一次 → StatePanel 错误;触发 retry 后 mockResolvedValue → 列表渲染
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `docker run --rm -v /root/workspace/crearte-monorepo/crearte:/repo -v crearte-deploy_mock_node_modules:/repo/src/node_modules -w /repo/src crearte:dev npm test -- AuthorView`
|
||||
Expected: FAIL(`AuthorView.vue` 不存在 / 路由名未注册),失败原因正确。
|
||||
|
||||
- [ ] **Step 3: 实现 `AuthorView.vue`**(结构镜像 `CatalogView.vue` 的 `StatePanel` 包裹 + `GameCard` 网格 + 空态块;无筛选 UI)
|
||||
|
||||
```ts
|
||||
const props = defineProps<{ user: string }>()
|
||||
const { data: games, error, loading, reload } = useAsync<GameSummary[]>(() => repo.listGames())
|
||||
const visible = computed(() =>
|
||||
filterGames((games.value ?? []).filter((g) => resolveUserSlug(g).user === props.user), DEFAULT_FILTER)
|
||||
)
|
||||
```
|
||||
|
||||
模板要点:面包屑「目录 / @user」(`RouterLink :to="{ name: 'catalog' }"`);`<h1>@{{ props.user }}</h1>`;作品数 `{{ visible.length }}`;`<GameCard v-for="game in visible" :key="game.id" :game="game" />` 网格(栅格类名抄 `CatalogView.vue:70`);空态块抄 `CatalogView.vue:73-85` 形态,文案「该作者暂无已上架作品」+ `BaseButton` 返回目录。
|
||||
|
||||
- [ ] **Step 4: 注册路由**(`src/app/router/index.ts`,catch-all 之前)
|
||||
|
||||
```ts
|
||||
{ path: '/users/:user', name: 'author', component: () => import('@/views/AuthorView.vue'), props: true },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过**:Step 2 同命令 → PASS;再跑全量 `… npm test` → 全绿。
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git checkout -b feat/author-page # 若尚未创建
|
||||
git add src/app/views/AuthorView.vue src/app/views/AuthorView.test.ts src/app/router/index.ts
|
||||
git commit -m "feat(views): author page /users/:user aggregating a user's published works"
|
||||
```
|
||||
|
||||
### Task 2: 入口链接(GameView 作者名 + GameCard 作者行)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/views/GameView.vue:55-66`(作者段落)
|
||||
- Modify: `src/app/components/GameCard.vue:35` 之后(描述 `</p>` 与徽章行之间)
|
||||
- Test: `src/app/views/GameView.test.ts`(追加用例)、`src/app/components/GameCard.test.ts`(追加用例)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `resolveUserSlug`(`@/data`)、`authorDisplayName(game)`(`@/lib/labels`,返回 `author?.name || user || '佚名'`)、Task 1 的路由 `name: 'author'`。
|
||||
- Produces: 无新 API;链接契约 `RouterLink :to="'/users/' + user"`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
GameView(三形态,显示文本统一 `authorDisplayName(game)`):
|
||||
```ts
|
||||
it('有命名空间时作者名链接到 /users/:user') // game{user:'alice', author:{name:'爱丽丝'}} → <a href="/users/alice">爱丽丝</a>
|
||||
it('无 user 但有 author.url 时保持外链') // game{author:{name:'X', url:'https://e.com'}} → href 含 toInterstitialIfExternal 结果(既有行为不回归)
|
||||
it('无 user 无 url 时纯文本') // 无 <a>,文本仍显示作者名
|
||||
```
|
||||
GameCard:
|
||||
```ts
|
||||
it('卡片渲染作者行并链接到作者页') // game{user:'alice'} → 文本含 authorDisplayName,href='/users/alice'
|
||||
it('无 user 的卡片作者行为纯文本') // 无 href='/users/' 前缀的链接
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `docker run --rm -v /root/workspace/crearte-monorepo/crearte:/repo -v crearte-deploy_mock_node_modules:/repo/src/node_modules -w /repo/src crearte:dev npm test -- GameView`
|
||||
Run: 同上 `… npm test -- GameCard`
|
||||
Expected: 两处 FAIL(作者行/内部链接不存在),失败原因正确。
|
||||
|
||||
- [ ] **Step 3: 实现**(优先级:`resolveUserSlug(game).user` 非空 → 内部 `RouterLink`;否则 `game.author?.url` → 既有外链(`toInterstitialIfExternal`);否则纯文本。GameCard 作者行放在描述与徽章行之间,`text-ink-soft` 小字,与卡片 `headingLevel` 无交互)
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**:Step 2 两命令 → PASS;全量 `… npm test` → 全绿(Review Focus #4:既有卡片/详情断言不回归)。
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/app/views/GameView.vue src/app/views/GameView.test.ts src/app/components/GameCard.vue src/app/components/GameCard.test.ts
|
||||
git commit -m "feat(views): link author names to the author page from detail and cards"
|
||||
```
|
||||
|
||||
### Task 3: e2e + CHANGELOG + 全量验证
|
||||
|
||||
**Files:**
|
||||
- Create: `src/e2e/author-page.noauth.spec.ts`
|
||||
- Modify: `crearte/docs/CHANGELOG.md`(顶部加 `[0.14.0]`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `src/e2e/helpers.ts` 的既有辅助(`fixtureGame` 等);noauth 配置 `testMatch: /noauth\.spec\.ts/`、`baseURL: http://localhost:4174`(`src/playwright.noauth.config.ts`,webServer 自动 `build:e2e:noauth`)。
|
||||
- Produces: 无。
|
||||
|
||||
- [ ] **Step 1: 写 e2e 用例**(夹具 20 作品全部 `user: 'fixture'`)
|
||||
|
||||
```ts
|
||||
test('/users/fixture 列出该作者作品并可跳转详情') // goto '/users/fixture' → '@fixture' 可见、卡片数 > 0、每张卡链接前缀 '/games/fixture/'
|
||||
test('/users/ghost 显示空态') // 「该作者暂无已上架作品」可见
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑 e2e**
|
||||
|
||||
Run: `docker run --rm --ipc=host -v /root/workspace/crearte-monorepo/crearte:/repo -v crearte-e2e-nm:/repo/src/node_modules -w /repo/src crearte:e2e npm run e2e:noauth`
|
||||
Expected: PASS(新用例 + 既有 noauth 用例全过)。
|
||||
|
||||
- [ ] **Step 3: 全量验证**:`… crearte:dev npm test` 全绿;`… crearte:dev npm run typecheck` 无错误。
|
||||
- [ ] **Step 4: CHANGELOG 条目**(`[0.14.0]`,双语:EN 行 + ZH 行):新增 `/users/:user` 作者主页与作者名互链。
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/e2e/author-page.noauth.spec.ts docs/CHANGELOG.md
|
||||
git commit -m "test(e2e): author page noauth coverage; docs: changelog 0.14.0"
|
||||
```
|
||||
|
||||
**收尾(非任务)**:三个任务完成后走 superpowers:finishing-a-development-branch —— `feat/author-page` 以 `git merge --no-ff` 并回 `master`、删除分支;随后回 monorepo 更新 `docs/ROADMAP.md` P4 状态与文档索引。
|
||||
@@ -0,0 +1,667 @@
|
||||
# P1 prod 写侧可用 实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 让 prod profile 在本机 compose 内具备完整写侧与游玩链路(上传/投稿/审核/virtual 子域运行时),并收掉 `POSTGRES_PASSWORD` 默认值。
|
||||
|
||||
**架构:** 方案 A(镜像 dev 直连法)——新增 `minio-prod`(主机 9001,浏览器跨源直连),`api-prod` 补 `STORAGE_S3_*`/`GAMES_BASE_DOMAIN`/`CORS_ALLOWED_ORIGINS`;`crearte` 的 nginx 配置改为 envsubst 模板,通配游戏域块参数化并补 `/api/` 同源反代;TLS 本轮不做。
|
||||
|
||||
**技术栈:** docker compose v5、nginx:1.27-alpine entrypoint envsubst 模板、Go CLI(容器内 `/app/crearte-server`)、curl+python3 冒烟链。
|
||||
|
||||
**依据:** `docs/specs/2026-09-29-prod-write-side-design.md`(已批准)。规格覆盖映射见文末自检。
|
||||
|
||||
---
|
||||
|
||||
## 环境事实(本机,worker 必读)
|
||||
|
||||
- 仓库布局:wrapper `crearte-monorepo/` 下平级 `crearte`、`crearte-server`、`crearte-deploy`(已 clone,全在 master 干净)。
|
||||
- 本机 `crearte-deploy/.env` **不存在**,`docker volume ls` **无任何 crearte 卷**(全新环境,无旧数据迁移负担;口令迁移警告仍要写 README,供其他主机用)。
|
||||
- 本机无 host node/go。前端测试一律容器内跑,镜像 `crearte:dev` 需先由 compose 构建(任务 2)。
|
||||
- `git@github.com` SSH 认证 = XingfenD(可推 crearte/crearte-deploy);wrapper remote = `git@git.yoresee.cc:XingfenD/crearte-monorepo.git`。各仓若报 `Author identity unknown`:`git config user.name XingfenD && git config user.email xingfen.fendy@outlook.com`(local,勿 --global)。
|
||||
- 主机端口 8080/9000/9001/5432/5434 当前均空闲。`*.localhost` 由 Chrome 内建解析、glibc 不保证——curl 一律加 `--resolve <sub>.localhost:8080:127.0.0.1`。
|
||||
- compose `:?` 守卫在 config 期对整个文件生效(不分 profile):任何 `--profile X config -q` 都要求 `.env` 五项 secret 齐(POSTGRES_PASSWORD/MINIO_ROOT_USER/MINIO_ROOT_PASSWORD/AUTH_TOKEN_SECRET/BUNDLE_KEK_k1)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 仓 | 文件 | 动作 | 职责 |
|
||||
|---|---|---|---|
|
||||
| crearte | `deploy/nginx.conf.template` | 新建(git mv 改名 + 编辑) | 两 server 块的 envsubst 模板:`_` 主站 + `${GAMES_SERVER_NAME}` 通配游戏域(含新 `/api/` 反代) |
|
||||
| crearte | `deploy/Dockerfile` | 修改 L15 | COPY 指向模板目录 |
|
||||
| crearte | `src/scripts/repo-yaml.test.ts` | 修改 describe 块 | 守卫测试同步读模板、新增 `/api/` 断言 |
|
||||
| crearte | `src/scripts/dev-game-runtime.ts` | 修改 L2 注释 | 引用路径改为 .template |
|
||||
| crearte | `docs/CHANGELOG.md` | 顶部加 0.15.0 | 变更账目 |
|
||||
| crearte-deploy | `docker-compose.yml` | 修改 | minio-prod、api-prod 写侧 env、web-prod args/env、口令去默认、新卷 |
|
||||
| crearte-deploy | `.env.example` | 重写(全文件) | secret 占位与新变量注释 |
|
||||
| crearte-deploy | `README.md` | 增 prod 小节 + 迁移警告 + 真机 TLS 清单 | 用法与数据 |
|
||||
| crearte-deploy | `docs/CHANGELOG.md` | 顶部加 0.5.0 | 变更账目 |
|
||||
| wrapper | `docs/ROADMAP.md`、`docs/CHANGELOG.md`、本计划索引 | 修改 | 状态与账目 |
|
||||
|
||||
提交顺序(spec §7):crearte → crearte-deploy → wrapper。
|
||||
|
||||
---
|
||||
|
||||
### 任务 1:crearte nginx 模板化(TDD)
|
||||
|
||||
**分支:** `cd crearte && git checkout -b feat/prod-nginx-wildcard-api`
|
||||
|
||||
- [ ] **步骤 1.1:改写守卫测试(先红)**
|
||||
|
||||
`src/scripts/repo-yaml.test.ts`:将整个 `describe('deploy/nginx.conf', ...)` 块替换为:
|
||||
|
||||
```ts
|
||||
describe('deploy/nginx.conf.template', () => {
|
||||
async function loadTemplate(): Promise<string> {
|
||||
return readFile(path.join(REPO_ROOT, 'deploy', 'nginx.conf.template'), 'utf8')
|
||||
}
|
||||
|
||||
it('通配游戏域 server block 暴露运行时三件套、/api/ 同源反代与 404 兜底', async () => {
|
||||
const wildcard = serverBlock(await loadTemplate(), '${GAMES_SERVER_NAME}')
|
||||
expect(wildcard).toMatch(/listen\s+80;/)
|
||||
expect(wildcard).toMatch(/root\s+\/usr\/share\/nginx\/html;/)
|
||||
|
||||
const exact = [...wildcard.matchAll(/location\s*=\s*(\S+)\s*\{([^}]*)\}/g)]
|
||||
expect(exact.map(([, location]) => location).sort()).toEqual(['/__bootstrap', '/agent.js', '/sw.js'])
|
||||
for (const [, location, body] of exact) {
|
||||
expect(body, `${location} 需要 no-store`).toContain('"no-store"')
|
||||
}
|
||||
|
||||
const api = wildcard.match(/location\s+\/api\/\s*\{([^}]*)\}/)
|
||||
expect(api, '通配块缺少 /api/ 反代(子域运行时取钥依赖同源代理)').toBeTruthy()
|
||||
expect(api?.[1]).toMatch(/proxy_pass\s+http:\/\/\$api_upstream;/)
|
||||
expect(api?.[1]).toMatch(/set\s+\$api_upstream\s+api:8080;/)
|
||||
|
||||
expect(wildcard).toMatch(/location\s*\/\s*\{[\s\S]*?return 404;\s*\}/)
|
||||
expect(wildcard).not.toMatch(/location\s+\/data\//)
|
||||
expect(wildcard).not.toMatch(/location\s+\/assets\//)
|
||||
})
|
||||
|
||||
it('主站为 /data/bundles/ 提供 CORS 且保留 /data/ 行为', async () => {
|
||||
const main = serverBlock(await loadTemplate(), '_')
|
||||
const bundles = main.match(/location\s+\/data\/bundles\/\s*\{([^}]*)\}/)
|
||||
expect(bundles, '主站缺少 /data/bundles/ location').toBeTruthy()
|
||||
expect(bundles?.[1]).toContain('Access-Control-Allow-Origin')
|
||||
expect(bundles?.[1]).toMatch(/try_files\s+\$uri\s+=404;/)
|
||||
expect(main).toMatch(/location\s+\/data\/\s*\{/)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
> `serverBlock()` 的 `server_name ${serverName};` 拼接传入字符串 `'${GAMES_SERVER_NAME}'` 后得到 `server_name ${GAMES_SERVER_NAME};`,与模板文本逐字匹配;JS 模板不会对已插值的 `$` 二次展开。
|
||||
|
||||
- [ ] **步骤 1.2:跑测试确认红**
|
||||
|
||||
```bash
|
||||
cd crearte && npx --yes vitest run src/scripts/repo-yaml.test.ts
|
||||
```
|
||||
|
||||
预期:FAIL(`ENOENT ... deploy/nginx.conf.template` 或 serverBlock 抛「缺少 server_name」)。**若本机 npx 因 node_modules 缺失失败,推迟到步骤 1.5 用容器跑,同样先确认红再继续。**
|
||||
|
||||
- [ ] **步骤 1.3:git mv 改名并编辑模板**
|
||||
|
||||
```bash
|
||||
cd crearte && git mv deploy/nginx.conf deploy/nginx.conf.template
|
||||
```
|
||||
|
||||
将文件整体重写为以下内容(头部注释为新增;通配块 `server_name` 参数化并新增 `/api/` location;其余与旧文件逐字一致):
|
||||
|
||||
```nginx
|
||||
# envsubst 模板:nginx 官方镜像 entrypoint(20-envsubst-on-templates.sh)按已定义环境变量
|
||||
# 渲染到 /etc/nginx/conf.d/default.conf。$uri/$host/$api_upstream 等 nginx 内建变量不是
|
||||
# 环境变量,原样保留。运行期唯一必须定义的变量:GAMES_SERVER_NAME(compose web-prod 注入)。
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /data/bundles/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /data/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $api_upstream api:8080;
|
||||
proxy_pass http://$api_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${GAMES_SERVER_NAME};
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location = /__bootstrap {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files /bootstrap/index.html =404;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files /sw.js =404;
|
||||
}
|
||||
|
||||
location = /agent.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files /agent.js =404;
|
||||
}
|
||||
|
||||
# 子域游玩时 sw/agent 取 bundle-key 走同源反代(prod 由本块承担;dev 等价逻辑在 vite 插件)
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $api_upstream api:8080;
|
||||
proxy_pass http://$api_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 1.4:改 Dockerfile 与过时注释**
|
||||
|
||||
`deploy/Dockerfile` L15:
|
||||
|
||||
```dockerfile
|
||||
# 旧:COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
```
|
||||
|
||||
`src/scripts/dev-game-runtime.ts` L2 注释:`(deploy/nginx.conf)` → `(deploy/nginx.conf.template)`。
|
||||
|
||||
- [ ] **步骤 1.5:容器内跑全量单测确认绿(含类型)**
|
||||
|
||||
先建镜像与依赖卷(在 deploy 目录,context 会用到当前分支工作树):
|
||||
|
||||
```bash
|
||||
cd ../crearte-deploy
|
||||
docker compose --profile mock build mock # 产出 crearte:dev
|
||||
docker run --rm -v "$PWD/../crearte":/repo \
|
||||
-v crearte-deploy_mock_node_modules:/repo/src/node_modules \
|
||||
-w /repo/src crearte:dev npm test
|
||||
```
|
||||
|
||||
预期:全部 PASS(含 `repo-yaml.test.ts`;必须挂整个 crearte 仓,只挂 `src/` 会假失败——HANDOFF 既有事实)。再:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD/../crearte":/repo \
|
||||
-v crearte-deploy_mock_node_modules:/repo/src/node_modules \
|
||||
-w /repo/src crearte:dev npm run typecheck
|
||||
```
|
||||
|
||||
预期:无错误。
|
||||
|
||||
- [ ] **步骤 1.6:CHANGELOG 0.15.0**
|
||||
|
||||
`crearte/docs/CHANGELOG.md` 顶部(`## [0.14.0]` 之前)插入(英中行相邻、条间空行):
|
||||
|
||||
```markdown
|
||||
## [0.15.0] - 2026-09-29
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
- `deploy/nginx.conf` became `deploy/nginx.conf.template`, rendered at container start by the official nginx entrypoint's envsubst pass (`/etc/nginx/templates/`). The wildcard game server's `server_name` is now the `GAMES_SERVER_NAME` variable instead of the hardcoded `*.games.example.com`, and the block gained `location /api/` same-origin reverse proxy to `api:8080` — without it, prod play runtime on `<sub>.<domain>` couldn't reach bundle-key (dev only worked because vite proxies `/api` on every host). The three-piece runtime (`/__bootstrap`, `/sw.js`, `/agent.js`) and the 404 fallback are unchanged; the shape is pinned by `repo-yaml.test.ts` against the template.
|
||||
- `deploy/nginx.conf` 改为 `deploy/nginx.conf.template`,由 nginx 官方镜像 entrypoint 的 envsubst 机制在容器启动时渲染到 `/etc/nginx/templates/`。通配游戏域 server 的 `server_name` 从硬编码 `*.games.example.com` 改为 `GAMES_SERVER_NAME` 变量,并新增 `location /api/` 同源反代到 `api:8080`——此前 prod 子域运行时根本够不着 bundle-key(dev 能跑全靠 vite 在每个 Host 上顺带代理 `/api`)。运行时三件套(`/__bootstrap`、`/sw.js`、`/agent.js`)与 404 兜底骨架不变,形状由 `repo-yaml.test.ts` 对模板钉死。
|
||||
```
|
||||
|
||||
- [ ] **步骤 1.7:Commit**
|
||||
|
||||
```bash
|
||||
cd crearte
|
||||
git status --short # 应只有 deploy/、src/scripts/、docs/CHANGELOG.md
|
||||
git add -A && git commit -m "feat(deploy): template nginx conf, wildcard block gains same-origin /api proxy
|
||||
|
||||
- deploy/nginx.conf -> nginx.conf.template (GAMES_SERVER_NAME rendered by
|
||||
nginx entrypoint envsubst; prod wildcard host was hardcoded dead config)
|
||||
- wildcard game server adds location /api/ -> api:8080 so prod subdomain
|
||||
play runtime can fetch bundle-key same-origin (dev-parity with vite)
|
||||
- repo-yaml.test.ts guards the template shape incl. /api/ assertions
|
||||
- docs: CHANGELOG 0.15.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 2:crearte 合入 master 并推送
|
||||
|
||||
- [ ] **步骤 2.1:合并(--no-ff,禁 fast-forward)**
|
||||
|
||||
```bash
|
||||
cd crearte
|
||||
git checkout master && git pull --ff-only origin master
|
||||
git merge --no-ff feat/prod-nginx-wildcard-api -m "Merge branch 'feat/prod-nginx-wildcard-api' — prod play runtime via templated nginx wildcard /api proxy (P1)"
|
||||
```
|
||||
|
||||
预期:新增 merge commit,无冲突(master 自 f12cbf1 后无新提交;若 pull 带来新改动导致冲突,停下向用户报告,勿强合)。
|
||||
|
||||
- [ ] **步骤 2.2:推送 + 删分支**
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
git branch -d feat/prod-nginx-wildcard-api && git push origin --delete feat/prod-nginx-wildcard-api
|
||||
git log --oneline -2 # 记录 merge hash,供 wrapper ROADMAP 状态引用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 3:crearte-deploy compose 与 .env.example
|
||||
|
||||
**分支:** `cd crearte-deploy && git checkout -b feat/prod-write-side`
|
||||
|
||||
- [ ] **步骤 3.1:anchor 与 DATABASE_URL 去默认口令**
|
||||
|
||||
`docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
# L5(x-postgres-env 内)旧: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-crearte}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
|
||||
# L54(api-dev)与 L126(api-prod)同型替换,旧: ...crearte:-crearte}@...
|
||||
DATABASE_URL: postgres://crearte:${POST…rd}
|
||||
|
||||
# L58/L59(minio-dev)显式化默认值(行为不变,为 :? 语义让路)
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO…min}
|
||||
```
|
||||
|
||||
> api-dev L54 一并改:anchor 共享,dev/prod 同受 `:?` 约束——README 迁移警告覆盖(任务 4)。
|
||||
|
||||
- [ ] **步骤 3.2:api-prod 补写侧 env**
|
||||
|
||||
`api-prod` 服务块整体替换为:
|
||||
|
||||
```yaml
|
||||
api-prod:
|
||||
profiles: ["prod"]
|
||||
build: *api-build
|
||||
image: crearte-server:prod
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db-prod:
|
||||
condition: service_healthy
|
||||
minio-prod:
|
||||
condition: service_started
|
||||
environment:
|
||||
<<: *api-env
|
||||
DATABASE_URL: postgres://crearte:${POST…rd}
|
||||
STORAGE_S3_ENDPOINT: http://minio-prod:9000
|
||||
STORAGE_S3_BUCKET: ${MINIO_BUCKET:-crearte}
|
||||
STORAGE_S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
|
||||
STORAGE_S3_SECRET_ACCESS_KEY: ${MINI…?:set MINIO_ROOT_PASSWORD in .env}
|
||||
STORAGE_S3_FORCE_PATH_STYLE: "true"
|
||||
# 浏览器可达的公开基址(PublicURL = base + "/" + key,需含 bucket 段)
|
||||
STORAGE_S3_PUBLIC_BASE_URL: http://localhost:${MINIO_PROD_PORT:-9001}/${MINIO_BUCKET:-crearte}
|
||||
# CORS 通配放行:host == base 或 *.base;http 仅对 *.localhost 开
|
||||
GAMES_BASE_DOMAIN: localhost
|
||||
CORS_ALLOWED_ORIGINS: ${HOST_ORIGIN:-http://localhost:8080}
|
||||
networks:
|
||||
default:
|
||||
aliases: [api]
|
||||
healthcheck:
|
||||
<<: *api-healthcheck
|
||||
```
|
||||
|
||||
- [ ] **步骤 3.3:新增 minio-prod 服务**
|
||||
|
||||
紧接 `api-prod` 之后(`db-debug` 之前)插入:
|
||||
|
||||
```yaml
|
||||
minio-prod:
|
||||
profiles: ["prod"]
|
||||
image: pgsty/minio:latest
|
||||
restart: unless-stopped
|
||||
command: ["server", "/data"]
|
||||
ports:
|
||||
- "${MINIO_PROD_PORT:-9001}:9000"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
|
||||
MINIO_ROOT_PASSWORD: ${MINI…?:set MINIO_ROOT_PASSWORD in .env}
|
||||
# 游玩/封面跨源直连(与 minio-dev 同策略)
|
||||
MINIO_API_CORS_ALLOW_ORIGIN: "*"
|
||||
volumes:
|
||||
- minio-data-prod:/data
|
||||
```
|
||||
|
||||
- [ ] **步骤 3.4:web-prod 补构建参数与模板变量**
|
||||
|
||||
`web-prod` 服务块整体替换为:
|
||||
|
||||
```yaml
|
||||
web-prod:
|
||||
profiles: ["prod"]
|
||||
build:
|
||||
context: ../crearte
|
||||
dockerfile: deploy/Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: /
|
||||
VITE_GAMES_BASE_DOMAIN: ${PUBLIC_GAMES_HOST:-localhost:8080}
|
||||
VITE_HOST_ORIGIN: ${HOST_ORIGIN:-http://localhost:8080}
|
||||
image: crearte:prod
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api-prod
|
||||
environment:
|
||||
GAMES_SERVER_NAME: ${GAMES_SERVER_NAME:-*.localhost}
|
||||
ports:
|
||||
- "8080:80"
|
||||
```
|
||||
|
||||
> `PUBLIC_GAMES_HOST`(客户端 host:port 拼接用)与 api 的 `GAMES_BASE_DOMAIN`(CORS 主机名后缀匹配)是两个语义不同的变量,刻意不共用,见 spec §3.2 命名注意。
|
||||
|
||||
- [ ] **步骤 3.5:卷声明追加**
|
||||
|
||||
文件尾 `volumes:` 块追加一行(不动既有卷名):
|
||||
|
||||
```yaml
|
||||
minio-data-prod:
|
||||
```
|
||||
|
||||
- [ ] **步骤 3.6:`.env.example` 整体重写**
|
||||
|
||||
```bash
|
||||
# 复制为 .env 后填写;.env 已被 .gitignore 忽略,绝不提交
|
||||
# 生成 secret:openssl rand -base64 32
|
||||
AUTH_TOKEN_SECRET=
|
||||
BUNDLE_KEK_ACTIVE=k1
|
||||
BUNDLE_KEK_k1=
|
||||
|
||||
# Postgres 口令(0.5.0 起无默认值,缺失 compose 直接报错)。
|
||||
# 老部署务必显式写旧卷的真实口令,否则鉴权失败,见 README「数据与配置」迁移警告。
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# MinIO root 凭据(:? 必填)。本机 dev/prod 演练可填 minioadmin/minioadmin;
|
||||
# 任何对外可达的 prod 必须换强口令:openssl rand -base64 32
|
||||
MINIO_ROOT_USER=
|
||||
MINIO_ROOT_PASSWORD=
|
||||
|
||||
# prod 写侧可选项(默认值即本机自包含演练形态,详见 README prod 小节):
|
||||
# MINIO_PROD_PORT=9001 # prod MinIO 主机端口(dev 用 9000)
|
||||
# HOST_ORIGIN=http://localhost:8080 # 主站对外 origin(CORS 白名单 + VITE_HOST_ORIGIN)
|
||||
# PUBLIC_GAMES_HOST=localhost:8080 # 游玩子域基址 host:port(客户端拼 <sub>:port)
|
||||
# GAMES_SERVER_NAME=*.localhost # nginx 通配 server_name(真域名时 *.games.example.com)
|
||||
# MINIO_BUCKET=crearte
|
||||
|
||||
# macOS / 网络盘上热更新不触发时设为 true
|
||||
# CHOKIDAR_USEPOLLING=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 4:crearte-deploy README 与 CHANGELOG
|
||||
|
||||
- [ ] **步骤 4.1:README「使用」内 dev 桶初始化段之后插入**
|
||||
|
||||
```markdown
|
||||
### prod 栈写侧(本机自包含演练)
|
||||
|
||||
prod 与 dev 互斥(都占 8080)。`minio-prod`(主机端口默认 **9001**)为 prod 启用写侧路由(上传/投稿/审核/预览);virtual 作品站内游玩走 `http://<sub>.localhost:8080`——web-prod 的 nginx 通配块按 `GAMES_SERVER_NAME` 渲染三件套 + `/api/` 同源反代,与 dev 的 vite 行为对齐。
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 必填 5 项:AUTH_TOKEN_SECRET、BUNDLE_KEK_k1、POSTGRES_PASSWORD、MINIO_ROOT_USER、MINIO_ROOT_PASSWORD
|
||||
docker compose --profile prod up -d --build
|
||||
# prod 卷首次创建后一次性初始化桶(单引号刻意——防宿主 shell 展开,容器内凭 compose 注入的凭据完成):
|
||||
docker compose --profile prod exec minio-prod sh -c 'mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" && mc mb --ignore-existing local/crearte && mc anonymous set download local/crearte'
|
||||
```
|
||||
|
||||
迁真实服务器的最小清单(本轮演练未含 TLS):泛解析 `*.games.<域名>` → 主机;`.env` 设 `HOST_ORIGIN`/`PUBLIC_GAMES_HOST`/`GAMES_SERVER_NAME` 为真实值并同步 api 的 `GAMES_BASE_DOMAIN`;`MINIO_PROD_PORT` 收内网或换外部 S3(改 `STORAGE_S3_*` 五项即可,应用零改动);上反代 TLS(Caddy/certbot + 通配证书)后把 `STORAGE_S3_PUBLIC_BASE_URL`、`HOST_ORIGIN` 切 https——server CORS 对 https 子域直接放行。
|
||||
```
|
||||
|
||||
- [ ] **步骤 4.2:README「数据与配置」小节更新**
|
||||
|
||||
卷列表行改为提及 `minio-data-prod`;并在其下追加迁移警告:
|
||||
|
||||
```markdown
|
||||
- **0.5.0 口令迁移警告**:`POSTGRES_PASSWORD` 不再有 `crearte` 默认值。既有主机的 `.env` 若没写过该行,升级后必须显式补 `POSTGRES_PASSWORD=<旧卷实际口令>`(全新 dev 卷旧口令即 `crearte`),否则 Postgres 鉴权失败。`MINIO_ROOT_USER/PASSWORD` 同理改为必填(dev 可继续用 minioadmin 值)。
|
||||
```
|
||||
|
||||
- [ ] **步骤 4.3:CHANGELOG 0.5.0**
|
||||
|
||||
`docs/CHANGELOG.md` 顶部(`## [0.4.0]` 之前)插入:
|
||||
|
||||
```markdown
|
||||
## [0.5.0] - 2026-09-29
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- The prod profile gains a full write side: new `minio-prod` object storage (host port `${MINIO_PROD_PORT:-9001}`, volume `minio-data-prod`, CORS-open) wired to `api-prod` via `STORAGE_S3_*`, plus `GAMES_BASE_DOMAIN=localhost` and `CORS_ALLOWED_ORIGINS` — upload/submission/review/preview routes now register in prod instead of 404-ing. `web-prod` gained `GAMES_SERVER_NAME` (renders the templated nginx wildcard block) and `VITE_GAMES_BASE_DOMAIN`/`VITE_HOST_ORIGIN` build args for the `*.localhost` play runtime. One-time prod bucket init and the real-server TLS checklist are in README.
|
||||
- prod profile 补齐写侧全链路:新增 `minio-prod`(主机端口 `${MINIO_PROD_PORT:-9001}`、卷 `minio-data-prod`、开放 CORS),`api-prod` 配置 `STORAGE_S3_*` 与 `GAMES_BASE_DOMAIN=localhost`、`CORS_ALLOWED_ORIGINS`——上传/投稿/审核/预览路由在生产启用(此前一律 404)。`web-prod` 新增 `GAMES_SERVER_NAME`(渲染 nginx 模板通配块)与游玩运行时构建参数 `VITE_GAMES_BASE_DOMAIN`/`VITE_HOST_ORIGIN`。prod 桶一次性初始化与真服务器 TLS 清单见 README。
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
- `POSTGRES_PASSWORD` lost its `crearte` fallback: the env anchor and both `DATABASE_URL`s use `${POSTGRES_PASSWORD:?…}`, so compose fails fast when missing. Old checkouts must set the line explicitly to match the volume's frozen password (see README migration warning). `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` became `:?`-required (empty placeholders in `.env.example`; minioadmin stays fine for local drills) and `minio-dev` now spells its defaults via the same variables.
|
||||
- `POSTGRES_PASSWORD` 去掉 `crearte` 兜底:anchor 与两条 `DATABASE_URL` 改为 `${POSTGRES_PASSWORD:?…}`,缺失即快速失败;既有部署须显式补写旧卷口令(见 README 迁移警告)。`MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` 升为 `:?` 必填(`.env.example` 空占位;本机演练填 minioadmin 即可),`minio-dev` 默认值经同组变量显式化(行为不变)。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 5:crearte-deploy 验证——config + prod 实跑 + 冒烟链
|
||||
|
||||
**前置:** 任务 2 已完成(prod 镜像构建需要 master 上的模板 nginx)。以下命令全部在 `crearte-deploy/` 执行。
|
||||
|
||||
- [ ] **步骤 5.1:生成 .env**
|
||||
|
||||
```bash
|
||||
test -f .env && echo "ABORT: .env 已存在,停下核对而不是覆盖" || cp .env.example .env
|
||||
{
|
||||
echo "AUTH_TOKEN_SECRET=$(openssl rand -base64 32)"
|
||||
echo "BUNDLE_KEK_k1=$(openssl rand -base64 32)"
|
||||
echo "POSTGRES_PASSWORD=crearte"
|
||||
echo "MINIO_ROOT_USER=minioadmin"
|
||||
echo "MINIO_ROOT_PASSWORD=***"
|
||||
} >> .env
|
||||
sed -i '/^AUTH_TOKEN_SECRET=$/D;/^BUNDLE_KEK_k1=$/D;/^POSTGRES_PASSWORD=$/D;/^MINIO_ROOT_USER=$/D;/^MINIO_ROOT_PASSWORD=$/D' .env
|
||||
```
|
||||
|
||||
- [ ] **步骤 5.2:四 profile config 全静默**
|
||||
|
||||
```bash
|
||||
for p in dev prod debug mock; do docker compose --profile $p config -q || echo "FAIL $p"; done
|
||||
```
|
||||
|
||||
预期:无输出无 FAIL。再验守卫确实生效:`POSTGRES_PASSWORD= docker compose --profile prod config -q` 应报 required variable 错误(测后恢复)。
|
||||
|
||||
- [ ] **步骤 5.3:prod 起栈 + 健康**
|
||||
|
||||
```bash
|
||||
docker compose --profile prod up -d --build
|
||||
docker compose --profile prod ps
|
||||
```
|
||||
|
||||
预期:`api-prod healthy`、`db-prod healthy`、`web-prod`/`minio-prod` running。首拉镜像慢属正常(GOPROXY 已指 goproxy.cn)。
|
||||
|
||||
- [ ] **步骤 5.4:prod 桶初始化**(README 命令原样执行,预期 `mc mb`/`anonymous set` 成功)
|
||||
|
||||
- [ ] **步骤 5.5:冒烟链(写侧 + 游玩链路 curl 等价物)**
|
||||
|
||||
逐条执行(`ADMIN_TOKEN` 全程 shell 变量传递,**不回显、不落盘敏感响应正文**):
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
B=http://localhost:8080
|
||||
|
||||
# 1) healthz(经 web-prod 反代)
|
||||
curl -sf $B/api/games >/dev/null && echo "catalog ok"
|
||||
docker compose exec -T api-prod wget -qO- http://127.0.0.1:8080/healthz | grep -q ok && echo "healthz ok"
|
||||
|
||||
# 2) 注册 -> 提权 -> 重登(set-role 会失效旧 token,必须重登)
|
||||
curl -s -o /tmp/p1reg.json -w '%{http_code}\n' -X POST $B/api/auth/register -H 'Content-Type: application/json' \
|
||||
-d '{"email":"p1@example.com","password":"***","display_name":"P1 Smoke","username":"p1admin"}' # 201
|
||||
docker compose exec -T api-prod /app/crearte-server user set-role p1@example.com admin
|
||||
curl -s -o /tmp/p1login.json -w '%{http_code}\n' -X POST $B/api/auth/login -H 'Content-Type: application/json' \
|
||||
-d '{"email":"p1@example.com","password":"***"}' # 200
|
||||
ADMIN_TOKEN=*** -c "import json;print(json.load(open('/tmp/p1login.json'))['token'])")
|
||||
|
||||
# 3) 上传真实 zip bundle
|
||||
python3 -c "import zipfile;zipfile.ZipFile('/tmp/p1.zip','w').writestr('index.html','<h1>p1</h1>')"
|
||||
curl -s -o /tmp/p1up.json -w '%{http_code}\n' -X POST $B/api/uploads -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-F kind=bundle -F slug=p1-smoke -F version=v1 -F file=@/tmp/p1.zip # 201
|
||||
UP_ID=$(python3 -c "import json;print(json.load(open('/tmp/p1up.json'))['upload_id'])")
|
||||
|
||||
# 4) 投稿(virtual,new_work,直提)+ external 第二件
|
||||
curl -s -o /tmp/p1sub.json -w '%{http_code}\n' -X POST $B/api/submissions -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{
|
||||
"kind":"new_work","work_id":"p1admin/p1-smoke","bundle_upload_id":"'"$UP_ID"'","submit":true,
|
||||
"payload":{"id":"p1admin/p1-smoke","runtime":"virtual","name":"P1 Smoke","url":"https://example.com",
|
||||
"author":{"name":"P1"},"description":"prod write-side smoke","durationMinutes":{"min":1,"max":60},
|
||||
"type":"puzzle","tags":["smoke"],"version":"v1"}}' # 201
|
||||
SUB_ID=$(python3 -c "import json;print(json.load(open('/tmp/p1sub.json'))['id'])")
|
||||
curl -s -o /tmp/p1ext.json -w '%{http_code}\n' -X POST $B/api/submissions -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{
|
||||
"kind":"new_work","work_id":"p1admin/p1-ext","submit":true,
|
||||
"payload":{"id":"p1admin/p1-ext","runtime":"external","name":"P1 Ext","url":"https://example.com",
|
||||
"author":{"name":"P1"},"description":"external smoke","durationMinutes":{"min":1,"max":60},
|
||||
"type":"puzzle","tags":[]}}' # 201
|
||||
|
||||
# 5) 审核上架两作品
|
||||
curl -s -o /dev/null -w 'approve1 %{http_code}\n' -X POST $B/api/admin/submissions/$SUB_ID/approve \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{"note":"smoke"}' # 200
|
||||
EXT_SUB_ID=$(python3 -c "import json;print(json.load(open('/tmp/p1ext.json'))['id'])")
|
||||
curl -s -o /dev/null -w 'approve2 %{http_code}\n' -X POST $B/api/admin/submissions/$EXT_SUB_ID/approve \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{"note":"smoke"}' # 200
|
||||
|
||||
# 6) 详情:schema v2、playSubdomain、匿名可读
|
||||
curl -s -o /tmp/p1det.json -w '%{http_code}\n' $B/api/games/p1admin/p1-smoke # 200(无 token)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
d = json.load(open('/tmp/p1det.json'))
|
||||
assert d['runtime'] == 'virtual' and d['playSubdomain'], d
|
||||
assert d.get('bundle', {}).get('url'), '缺 bundle.url'
|
||||
print('detail ok sub=', d['playSubdomain'])
|
||||
PY
|
||||
|
||||
# 7) 通配子域:三件套 + /api 同源反代 + bundle-key(--resolve 绕 glibc 不解析 *.localhost)
|
||||
SUB=$(python3 -c "import json;print(json.load(open('/tmp/p1det.json'))['playSubdomain'])")
|
||||
for path in /__bootstrap /sw.js /agent.js; do
|
||||
curl -sf --resolve "$SUB.localhost:8080:127.0.0.1" "http://$SUB.localhost:8080$path" >/dev/null && echo "sub $path ok"
|
||||
done
|
||||
curl -s -o /tmp/p1key.json -w 'bundle-key %{http_code}\n' \
|
||||
--resolve "$SUB.localhost:8080:127.0.0.1" -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"http://$SUB.localhost:8080/api/games/p1admin/p1-smoke/bundle-key" # 200
|
||||
python3 -c "import json;d=json.load(open('/tmp/p1key.json'));assert d['v']==1;print('key shape ok')"
|
||||
shred -u /tmp/p1key.json 2>/dev/null || rm -f /tmp/p1key.json # 密钥材料不落盘残留
|
||||
|
||||
# 8) 跨源直连:bundle 密文匿名下载 + Origin=CORS 头(server 放行 *.localhost)
|
||||
BU=$(python3 -c "import json;print(json.load(open('/tmp/p1det.json'))['bundle']['url'])")
|
||||
curl -sf -o /tmp/p1bundle.bin "$BU" && echo "bundle object ok ($(stat -c%s /tmp/p1bundle.bin)B)"
|
||||
curl -s -D- -o /dev/null -H "Origin: http://$SUB.localhost:8080" $B/api/games/p1admin/p1-smoke/bundle-key \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" | grep -i 'access-control-allow-origin' | grep -q "$SUB.localhost" && echo "cors wildcard ok"
|
||||
curl -s -o /dev/null -w 'ext detail %{http_code}\n' $B/api/games/p1admin/p1-ext # 200
|
||||
```
|
||||
|
||||
预期全绿。任一步 4xx/5xx:按 spec §4 排查(路由未注册→查 `STORAGE_S3_*` 是否被 `LoadStorage` 判不全;子域 404→查 `GAMES_SERVER_NAME` 渲染 `docker compose exec web-prod cat /etc/nginx/conf.d/default.conf`)。
|
||||
|
||||
- [ ] **步骤 5.6:dev 回归**
|
||||
|
||||
```bash
|
||||
docker compose --profile prod down
|
||||
docker compose --profile dev up -d --build
|
||||
docker compose --profile dev exec -T api-dev wget -qO- http://127.0.0.1:8080/healthz | grep -q ok && echo dev-api ok
|
||||
docker compose --profile dev exec minio-dev sh -c "mc alias set local http://localhost:9000 minioadmin minioadmin && mc mb --ignore-existing local/crearte && mc anonymous set download local/crearte"
|
||||
curl -sf http://localhost:8080/api/games >/dev/null && echo dev-catalog ok
|
||||
docker compose --profile dev down # 无 -v
|
||||
```
|
||||
|
||||
预期全绿(dev 的 minio-dev 仍走默认凭据;`.env` 里的 minioadmin 值与 `:-` 兼容)。
|
||||
|
||||
- [ ] **步骤 5.7:浏览器人工验收(可延后,标记即可)**
|
||||
|
||||
有 GUI 的机器上打开 `http://localhost:8080`(prod 栈),走 UI 上传→投稿→后台审核→站内游玩。本机为 headless,计划以步骤 5.5 的 curl 等价链为验收线,浏览器项标 `manual-deferred`。
|
||||
|
||||
---
|
||||
|
||||
### 任务 6:crearte-deploy 合入并推送
|
||||
|
||||
- [ ] **步骤 6.1**
|
||||
|
||||
```bash
|
||||
cd crearte-deploy
|
||||
git status --short # 应只有 docker-compose.yml、.env.example、README.md、docs/CHANGELOG.md;.env 绝不入列
|
||||
git add -A && git commit -m "feat(prod): write side via minio-prod + games runtime env; drop POSTGRES_PASSWORD default
|
||||
|
||||
- minio-prod (host 9001, volume minio-data-prod) wired to api-prod
|
||||
STORAGE_S3_*/GAMES_BASE_DOMAIN/CORS_ALLOWED_ORIGINS -> upload/submission/
|
||||
review routes register in prod
|
||||
- web-prod: GAMES_SERVER_NAME template var + VITE_GAMES_BASE_DOMAIN /
|
||||
VITE_HOST_ORIGIN build args
|
||||
- POSTGRES_PASSWORD & MINIO_ROOT_* become :?-required; .env.example synced;
|
||||
README gains prod bucket init, password migration warning, TLS checklist
|
||||
- docs: CHANGELOG 0.5.0"
|
||||
git checkout master && git pull --ff-only origin master
|
||||
git merge --no-ff feat/prod-write-side -m "Merge branch 'feat/prod-write-side' — prod write-side self-contained drill (P1)"
|
||||
git push origin master
|
||||
git branch -d feat/prod-write-side && git push origin --delete feat/prod-write-side
|
||||
git log --oneline -2 # 记录 merge hash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 7:wrapper 收尾(状态、账目、计划登记)
|
||||
|
||||
- [ ] **步骤 7.1:ROADMAP 更新**
|
||||
|
||||
P1 行状态列改为:`**完成**(crearte `<任务2 merge hash>` / deploy `<任务6 merge hash>`,2026-09-29 合并推送;TLS 与真域名留清单,见 spec §6)`。文档索引表 P1 行实现计划列改为 ``docs/plans/2026-09-29-prod-write-side.md`(已执行,2026-09-29)`。
|
||||
|
||||
- [ ] **步骤 7.2:wrapper CHANGELOG 0.2.2**
|
||||
|
||||
```markdown
|
||||
## [0.2.2] - 2026-09-29
|
||||
|
||||
### Docs / 文档
|
||||
|
||||
- Roadmap P1 delivered: prod write side went live in the compose drill — minio-prod + api-prod storage/games env + templated nginx wildcard block (crearte `<>` / crearte-deploy `<>`), POSTGRES_PASSWORD default retired, TLS deferred by design with a real-server checklist in README.
|
||||
- 路线图 P1 交付:prod 写侧在本机演练栈完整启用——minio-prod、api-prod 存储/游玩域名环境、nginx 通配块模板化(crearte `<>` / crearte-deploy `<>`);POSTGRES_PASSWORD 默认值退役;TLS 按设计缓做,真机清单已入 README。
|
||||
```
|
||||
|
||||
(`<>` 处以真实 merge hash 替换;英文行紧接中文行。)
|
||||
|
||||
- [ ] **步骤 7.3:Commit + Push(wrapper 可直提 master)**
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
git add docs/ROADMAP.md docs/CHANGELOG.md docs/plans/ docs/specs/
|
||||
git commit -m "docs: P1 delivered — prod write-side drill merged; plan registered, changelog 0.2.2"
|
||||
git push origin master
|
||||
```
|
||||
|
||||
- [ ] **步骤 7.4:终态核验**
|
||||
|
||||
```bash
|
||||
git -C crearte pull --ff-only origin master && git -C crearte log --oneline -1
|
||||
git -C crearte-deploy pull --ff-only origin master && git -C crearte-deploy log --oneline -1
|
||||
./clone_all.sh # 应输出 3 updated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自检(写完后执行,已内联修复)
|
||||
|
||||
1. **规格覆盖度**:spec §3.1-3.2 端口/服务/env → 任务 3.2-3.5;§3.3 口令链 → 3.1/3.6 + README 4.2;§3.4 nginx 模板 + 守卫测试 → 任务 1;§3.5 桶初始化 → README 4.1 + 步骤 5.4;§4 验证四步 → 5.1-5.7(浏览器项如实标 deferred);§5 影响文件表逐项对应;§6 不做项无对应任务(正确);§7 分支/顺序 → 任务 1/2/3/6/7。✔
|
||||
2. **占位符扫描**:merge hash 占位 `<>` 为运行时事实(任务 2/6 执行时产生),其余无 TODO/待定。✔
|
||||
3. **类型/命名一致性**:`GAMES_SERVER_NAME`(web-prod env ↔ nginx 模板 ↔ 测试断言)、`PUBLIC_GAMES_HOST`/`HOST_ORIGIN`/`MINIO_PROD_PORT`/`MINIO_BUCKET`(compose ↔ .env.example ↔ README)、模板路径 `deploy/nginx.conf.template`(Dockerfile ↔ repo-yaml.test.ts ↔ dev-game-runtime 注释)三向一致。✔
|
||||
|
||||
**范围外提醒(不入本计划)**:HANDOFF §4 前端零散 backlog 与本计划无关;若冒烟 5.5 步骤 7 发现 `sw.js`/`agent.js` 在 prod dist 缺失(`nginx exec ls /usr/share/nginx/html`),那是 crearte 构建产物的独立缺陷,上报而非顺手修。
|
||||
@@ -0,0 +1,112 @@
|
||||
# P7 管理后台增强 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** crearte-server 0.12.0(audit_log 表 + 审计中间件 + admin 路由收敛组 + 用户列表/角色端点 + 最后 admin 护栏)与 crearte 0.18.0(AdminUsersView / AdminAuditView / apiRepo 三方法 / e2e 扩流),端到端验证后合主。deploy 零改动。
|
||||
|
||||
**Architecture:** 见 spec `docs/specs/2026-09-30-admin-console-design.md`。核心决策:D-A 只记管理员动作;D-B 中间件统一记录 + **维护者定稿:admin 路由收敛为 gin 组(空前缀 `engine.Group("", RequireAdmin, AuditMiddleware)`),组内继续用既有 `RouteAdmin*` 全路径常量注册,禁止逐条手挂**;D-C 列表=身份字段+角色操作;D-D 唯一 admin 不可降级(service 层,CLI/API 双覆盖)。SetUserRole 现成语义:`UpdateRoleByEmail` SQL 自带 `token_version+1`(作废被改者全部 token),**不要**另加 `BumpTokenVersion` 调用。
|
||||
|
||||
**Tech Stack:** Go 1.24(gin 1.11 / pgx 5.8;**禁新增第三方依赖**)、Vue 3 + vue-router + vitest + Playwright(前端既有栈)、migrations 0010。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 工作目录:`/root/.openclaw/workspace/coder/crearte-monorepo/crearte-server/src`(Go 源码根)与 `…/crearte/src`(前端,package.json 在 src/ 下)。
|
||||
- 分支:两仓各从 `master` 切 `feat/admin-console`;**master 上禁直接提交**(各仓 AGENTS.md);合回一律 `--no-ff` 由控制者执行。
|
||||
- **Go 命令一律走容器**(AGENTS.md 本地约定,proxy.golang.org 不可达):
|
||||
`docker run --rm -v /root/.openclaw/workspace/coder/crearte-monorepo/crearte-server:/work -v crearte_gomod:/go/pkg/mod -v crearte_gocache:/gocache -e GOCACHE=/gocache -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn -w /work/src golang:1.24-alpine sh -c "<cmd>"`(首次冷 1–3 分钟,background+poll)。
|
||||
- 集成层:`docker compose --profile debug up -d db-test`(宿主 5434,fresh volume),`TEST_DATABASE_URL=postgres://crearte:***@localhost:5434/crearte?sslmode=disable go test -p 1 ./...`;守卫语义=不许出现 "TEST_DATABASE_URL not set" skip。跑完 `down` 清场(controller 负责最终清场;子代理用独占端口自查后自清)。
|
||||
- 审计中间件行为红线:actor 仅在 RequireAdmin 放行后记录(401/403 未遂不记);写失败 log-only 绝不影响响应;status 含 4xx/5xx(失败的 admin 尝试同样入史)。
|
||||
- 分页钳制逐字照抄 `ListQueue` 语义(limit≤0 或 >200→50;offset<0→0)。
|
||||
- 前端:admin 面两 GET 不缓存(无 ETag 依赖);错误经 `toContentMessage`;降级确认弹层文案必须含「对方登录态立即失效」与「最后一个 admin 会被拒绝」两点。
|
||||
- CHANGELOG:server 追加 `## [0.12.0] - 2026-09-30`(顶版 0.11.1)、crearte 追加 `## [0.18.0] - 2026-09-30`(顶版 0.17.0);英文行+中文行连续。
|
||||
- 完成定义:Task 3/4 各自测试绿 + 控制者验收(spec §7 四条)+ Task 5 合主。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: server — 仓储与服务层(迁移 0010 + audit/user repo + memory parity + 护栏)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/repository/migrations/0010_audit_log.sql`(spec §3.1 原样)
|
||||
- Create: `internal/repository/audit.go` + `audit_test.go`(单测走 sqlmock? 否——本仓无 sqlmock 先例;纯逻辑测 scan/组装即可,真库覆盖交给集成层)
|
||||
- Modify: `internal/repository/user.go`(+ `List(ctx,limit,offset,q) ([]model.User,int,error)`、`CountAdmins`)
|
||||
- Modify: `internal/repository/memory.go`(同名 parity:List 过滤/排序/total、CountAdmins、audit Insert/List)
|
||||
- Modify: store 聚合处(`UserStore` 接口或等价,看现码)接线新方法
|
||||
- Modify: `internal/service/auth.go`(`ErrLastAdmin`;`SetUserRole` 加护栏;新 `SetUserRoleByID`)
|
||||
- Create: `internal/service/audit.go`(`RecordAdminAction` / `ListAudit`)+ 表驱动单测
|
||||
- Modify: `internal/service/auth_test.go`(护栏矩阵)
|
||||
- Modify: `docs/CHANGELOG.md`(0.12.0 条目,Added+护栏)
|
||||
|
||||
**Interfaces(Produces):**
|
||||
- `repository.AuditRepository`、`PostgresUserRepository.List/CountAdmins`
|
||||
- `service.SetUserRole(ctx, store, email, role)`(签名不变+新错误)、`service.SetUserRoleByID(ctx, store, id, role)`
|
||||
- `service.ListAudit(ctx, store, limit, offset, routeFilter)`
|
||||
- `model.AuditEntry{ID, ActorID, ActorEmail, Method, Route, Path string; Status int; CreatedAt time.Time}`
|
||||
|
||||
**Steps:**
|
||||
- [ ] 读 spec §3.1–§3.3 与现有 `internal/repository/user.go`、`memory.go`、`service/auth.go` 全文
|
||||
- [ ] 迁移 0010;repo 三新方法;memory parity
|
||||
- [ ] service 护栏:唯一 admin 降→`ErrLastAdmin`(判序:role 合法→找用户→当前 role=admin 且目标 user 且 CountAdmins==1→拒);`SetUserRoleByID` 复用同判
|
||||
- [ ] `audit.go`:Record(吞错 log)/ List(钳制+倒序+route 过滤+total)
|
||||
- [ ] 单测:护栏矩阵(唯一 admin 降拒 / 双 admin 降一 OK / 升 OK / 非 admin 目标降 OK no-op 合法)、ListAudit 钳制、SetUserRoleByID 404
|
||||
- [ ] 容器四连 RC=0:`gofmt -l`、`go vet ./...`、`go build ./...`、`go test ./...`(无 TEST_DATABASE_URL,集成自动 skip 属预期)
|
||||
- [ ] commit 到 `feat/admin-console`(本地,不 push)
|
||||
|
||||
### Task 2: server — API 层(admin 组收敛 + AuditMiddleware + 三端点)
|
||||
|
||||
**Depends:** Task 1。
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/router.go`(admin 路由全部收敛进组:`adminGroup := engine.Group("", RequireAdmin(deps.AuthService), AuditMiddleware(deps.AuditStore))`,组内逐条 `adminGroup.GET/POST/PUT/PATCH(RouteAdmin*, handler)`;URL 常量与 `FullPath()` 输出不得变化)
|
||||
- Create: `internal/api/audit.go`(AuditMiddleware + 三 handler:ListUsers/SetRole/ListAudit,或按现码 admin handler 布放)
|
||||
- Modify: `internal/api/admin.go` / deps 结构(AuditStore 注入)
|
||||
- Modify: `cmd/serve.go`(store 注入链)
|
||||
- Modify: `internal/api/integration_test.go` 或新 `admin_users_test.go`(真库腿)
|
||||
- Modify: `docs/CHANGELOG.md`(追加端点条目)
|
||||
|
||||
**Endpoints(spec §3.4):**
|
||||
- `GET /api/admin/users?limit&offset&q` → `{users:[{id,email,username,display_name,role,created_at}], total}`
|
||||
- `PATCH /api/admin/users/:id/role` `{role}` → 200 用户体;400 非法 role;404 无此人;409 `last_admin`
|
||||
- `GET /api/admin/audit?limit&offset&route` → `{entries:[...], total}` 倒序
|
||||
|
||||
**Steps:**
|
||||
- [ ] 组收敛改造,跑既有 admin 测试证明 401/403 行为零变化
|
||||
- [ ] AuditMiddleware:`c.Next()` 后取 `handler.SetUser` 存的 user + `c.FullPath()` + `c.Writer.Status()`;user 非 admin 则跳过(理论不可达,防御)
|
||||
- [ ] 三端点 + 错误映射(复用 WriteError code 体系,新增 `last_admin`)
|
||||
- [ ] 集成测试(TEST_DATABASE_URL 空库):列表 q/分页/total;PATCH 升角色后**旧 token 打 /api/me 必 401**;409;一次 approve 后 audit 首行=该动作;404 的 revoke 也入史(status=404);401/403 未遂不入史
|
||||
- [ ] 容器四连 + 集成 `-p 1` RC=0;commit
|
||||
|
||||
### Task 3: 前端 — apiRepo + 两 view + 路由入口 + vitest
|
||||
|
||||
**Files(`crearte/src`):**
|
||||
- Modify: `app/data/apiRepo.ts` + `app/data/types.ts`(listAdminUsers / setUserRole / listAudit;`AdminUser`、`AuditEntry` 类型)
|
||||
- Create: `app/views/AdminUsersView.vue`、`app/views/AdminAuditView.vue`
|
||||
- Create: 对应 `*.test.ts`
|
||||
- Modify: router 注册(找现 AdminView 注册处仿写,admin 守卫一致);`app/views/AdminView.vue` 顶部加两入口链接
|
||||
- Modify: `docs/CHANGELOG.md`(0.18.0)
|
||||
|
||||
**Steps:**
|
||||
- [ ] 读 AdminView.vue / AdminSubmissionView.vue / apiRepo.ts 现有模式(StatePanel、分页、toContentMessage)
|
||||
- [ ] apiRepo 三方法 + 类型 + 单测(请求形状、错误映射、不缓存)
|
||||
- [ ] AdminUsersView:表列 用户名/邮箱/显示名/角色/注册时间;搜索防抖;分页 50;升降按钮 + 降级确认弹层(双要点文案)+ 409 原文回显
|
||||
- [ ] AdminAuditView:倒序流水(时间本地化/操作者/动作标签映射+未知回退原文/对象 path 提取/状态码着色)+ route 过滤下拉
|
||||
- [ ] vitest + typecheck RC=0;主套件 e2e 不受影响自查(夹具模式不依赖新后端端点的 spec 别动)
|
||||
- [ ] commit 到 `feat/admin-console`(本地,不 push)
|
||||
|
||||
### Task 4: 前端 — e2e 扩流(admin-flow.spec)
|
||||
|
||||
**Depends:** Task 2 端点形状冻结 + Task 3。**写测试所需夹具**:e2e 走 `e2e-stack` 真栈则直接打真端点;若 admin-flow 是夹具模式则补夹具路由。**先读 spec/e2e/admin-flow.spec.ts 现状再定**,与既有用例同构。
|
||||
|
||||
- [ ] 流①:admin 进 /admin/users → 搜索用户 → 升 admin → 列表即时更新 → 降回 → 确认弹层出现
|
||||
- [ ] 流②:执行一个管理动作 → /admin/audit 首行即该动作(时间/对象/结果码断言)
|
||||
- [ ] e2e 主套件 + 若走真栈则 CREARTE_STACK_REQUIRED=1 下 full-loop 同步绿;commit
|
||||
|
||||
### Task 5: 控制者 — 本机验收六腿 + 合主
|
||||
|
||||
- [ ] 双路审查子代理(server / fe)对照 spec 逐条 PASS/FAIL
|
||||
- [ ] 验收=spec §7 四条(含手工冒烟 CLI/API 护栏一致性、废 token 端到端)
|
||||
- [ ] 合主:两仓 `--no-ff` 合 `feat/admin-console` → push → 删分支;wrapper:ROADMAP P7 状态、CHANGELOG 0.2.6、文档索引登记 spec+plan → push
|
||||
- [ ] 台账 `.superpowers/sdd/progress.md`
|
||||
|
||||
---
|
||||
|
||||
**风险注记:** ① 组收敛若与 gin 版本路由注册交互有意外(如 FullPath 变组前缀),以「测试零改动通过」为准绳修正注册写法,不许改常量值。② `List` 返回 `[]model.User` 含 `password_hash`?看 `userColumns` 现码——若模型带 hash,序列化前必须裁(新 `AdminUserView` 投影结构体或 JSON tag 控制),集成测试断言响应体无 hash 字段。
|
||||
@@ -0,0 +1,106 @@
|
||||
# P3 备份 + 可观测 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** crearte-server 0.12.0(slog 结构化日志 + 请求日志 + 手写 `/metrics` + 限流权衡记录)与 crearte-deploy 0.6.0(`backup.sh` / `restore-drill.sh` / BACKUP-RUNBOOK / CI dry-run),端到端验证后合主。前端零改动。
|
||||
|
||||
**Architecture:** server 侧新增 `internal/observability` 包(mutex Registry + gin 中间件 + exposition 函数),config 增 `LOG_FORMAT`/`LOG_LEVEL`,26 处 `log.Printf` 机械迁移到 slog;deploy 侧结构零改动,备份靠 `compose exec/run` 一次性容器(db 镜像自带 pg_dump、minio 镜像自带 mc)。见 spec `docs/specs/2026-09-30-backup-observability-design.md`(§2 三个选型 D1/D2/D3、§4 脚本七步、§5 验收四条)。
|
||||
|
||||
**Tech Stack:** Go 1.24(gin 1.11 / pgx 5.8 / stdlib log-slog;**禁新增第三方依赖**)、bash + docker compose + mc、GitHub Actions(deploy 仓 validate.yml 已有 compose config 步骤可仿)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 工作目录:`crearte-monorepo/crearte-server/src`(Go 源码根)与 `crearte-monorepo/crearte-deploy`。
|
||||
- 分支:server 与 deploy 各从 `master` 切 `feat/backup-observability`;不碰 crearte。**master 上禁直接提交**(两仓 AGENTS.md);合回一律 `--no-ff`。
|
||||
- **Go 命令一律走容器**(AGENTS.md 本地约定,proxy.golang.org 不可达):
|
||||
`docker run --rm -v <repo>/crearte-server:/work -v crearte_gomod:/go/pkg/mod -v crearte_gocache:/gocache -e GOCACHE=/gocache -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn -w /work/src golang:1.24-alpine sh -c "<cmd>"`(`<repo>`=/root/.openclaw/workspace/coder/crearte-monorepo;首次冷 1–3 分钟用 background+poll)。
|
||||
- slog 迁移**机械映射**(spec §3.1):`log.Printf("X: k=%s e=%v", …)` → `slog.Error("X", "k", …, "error", err)`;原短语/键名不变;`error=` 一律 `*error` 值入 attr;不改任何调用语义。
|
||||
- `/metrics` 手写 exposition(D1):文本格式 v0.0.4,`# HELP`/`# TYPE` 行齐全,指标名与 label 集合严格按 spec §3.2;`/healthz` 请求既不记日志也不计指标。
|
||||
- 备份脚本:只删 `^\d{8}T\d{6}Z$` 形态目录;`restore-drill.sh` 只连 `db-test`(127.0.0.1:5434→内部 5432,debug profile),绝不指向 prod;凭据从 `.env` 读,不落命令行。
|
||||
- CHANGELOG:server `docs/CHANGELOG.md` 追加 `## [0.12.0] - 2026-09-30`(顶版 0.11.1 之上)、deploy 追加 `## [0.6.0] - 2026-09-30`(顶版 0.5.1 之上);英文行+中文行连续,条目间空行。
|
||||
- 完成定义:Task 4 六腿全绿(容器四连 / TEST_DATABASE_URL 集成层 / observability 新单测 / 实栈 prod 冒烟抓 /metrics+JSON 日志 / backup+restore-drill 真跑 / full-loop e2e 不回归),Task 5 合主推送。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: server — slog 基座(config 解析 + 26 处迁移 + 请求日志中间件)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/internal/config/config.go`(+ `LogFormat`/`LogLevel` 字段与解析)
|
||||
- Create: `src/internal/observability/logging.go`(slog 初始化 helper + `RequestLogger` 中间件)
|
||||
- Create: `src/internal/observability/logging_test.go`
|
||||
- Modify: `src/internal/api/router.go`(挂 `RequestLogger`;`gin.Recovery` 保留)
|
||||
- Modify: `src/cmd/serve.go`(启动时初始化 slog;启动行转 slog)
|
||||
- Modify: handler/service/api 其余 10 文件(机械迁移清单:`internal/handler/{games,admin,uploads,submissions,auth,bundlekey,reactions}.go`、`internal/service/{content,cleanup}.go`、`internal/api/{admin,auth}.go`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `observability.InitLogging(format, level string) error`;`observability.RequestLogger() gin.HandlerFunc`;`Config.LogFormat` / `Config.LogLevel`
|
||||
- 测试基建约定:新单测写临时文件 + `slog.New(slog.NewTextHandler(file, …))` 式注入,或直接断言 `InitLogging` 后 `slog.Default()` 输出——用 `httptest`+gin engine 打请求断言行内容。
|
||||
|
||||
- [ ] **Step 1: 写失败的单测** `observability/logging_test.go`:
|
||||
1. `InitLogging("json","warn")` 后以 `slog.Info("x")` 不输出、`slog.Warn` 输出且 `jq` 可解析(用 `bytes.Buffer` 接管 handler 输出断言,别起子进程)。
|
||||
2. `InitLogging("bogus",…)` / `InitLogging(…,"bogus")` 返回 error。
|
||||
3. `RequestLogger`:gin engine(含 `/healthz` 与一个业务路由 + 一个不存在路径)经 httptest 打四类请求 → 断言:业务请求有 `msg=http_request method=… route=/api/games… status=… duration_ms=… ip=…` 行;`/healthz` 无行;未匹配路由 `route=nomatch`;`status>=500` 级别为 ERROR(`level=ERROR`/`"level":"ERROR"`)。
|
||||
Run(容器): `go test ./internal/observability/ -run 'Logging|RequestLogger' -v` → Expected: FAIL(包不存在 → `go vet` 或 build 错误即红)。
|
||||
|
||||
- [ ] **Step 2: 实现** `config.go` 增 `LogFormat`/`LogLevel`(`LOG_FORMAT` 默认 `text`、`LOG_LEVEL` 默认 `info`,非法值 `fmt.Errorf` 报错——仿 `parseAllowedOrigins` 的返回 err 风格,`Load()` 里接线)。
|
||||
- [ ] **Step 3: 实现** `observability/logging.go`:`InitLogging` 组装 `slog.NewTextHandler`/`NewJSONHandler(os.Stderr, &HandlerOptions{Level:…})` 并 `slog.SetDefault`;`RequestLogger` 按 Interfaces 断言实现(`c.FullPath()` 空→`nomatch`;`path=="/healthz"` 直接 `c.Next()` 不记录)。
|
||||
- [ ] **Step 4: router.go** 在 CORS 之后 `engine.Use(observability.RequestLogger())`(Recovery 之前挂,保证 panic 路径也有行;`gin.Recovery()` 不动)。
|
||||
- [ ] **Step 5: 迁移 26 处 log.Printf**(`grep -rn 'log\.' src --include='*.go' | grep -v _test` 清零 stdlib `log` import;serve.go 启动行 → `slog.Info("crearte-server listening", "port", cfg.Port, "storage", storageEnabled)`)。
|
||||
- [ ] **Step 6: 容器四连** `gofmt -l . && go vet ./... && go build ./... && go test ./...` → 全绿;Step 1 新测试 PASS。
|
||||
- [ ] **Step 7: 提交** `git add -A && git commit -m "feat(observability): slog structured logging + per-request log middleware (LOG_FORMAT/LOG_LEVEL)"`(server 仓 feat/backup-observability)。
|
||||
|
||||
### Task 2: server — /metrics + 权衡记录 + CHANGELOG
|
||||
|
||||
**Files:**
|
||||
- Create: `src/internal/observability/metrics.go` + `metrics_test.go`
|
||||
- Modify: `src/internal/observability/logging.go`(计时点并入指标记录——或独立 `Metrics` 中间件,二选一,实现者定,测试覆盖为准)
|
||||
- Modify: `src/internal/api/router.go`(`RouteMetrics = "/metrics"` 注册;`Deps` 增 `Metrics http.Handler` 或 `Pool *pgxpool.Pool` 可选字段——**注意现有 router_test 构造 Deps 的地方要兼容零值**)
|
||||
- Modify: `docs/README.md`(「已知权衡:限流单实例」节)、`docs/CHANGELOG.md`(0.12.0)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `observability.NewRegistry()`;`registry.Observe(method, route string, status int, seconds float64)`;`registry.Expose() string`(或 `WriteTo(io.Writer)`);`GET /metrics` 文本。
|
||||
|
||||
- [ ] **Step 1: 写失败的单测** `metrics_test.go`:httptest 经真 router(无池 Deps)打 `GET /api/games`×2、`POST /login` 404×1 → `GET /metrics` 断言:`# TYPE crearte_http_requests_total counter` 行、`crearte_http_requests_total{method="GET",route="/api/games",status="200"} 2`、`nomatch` 桶有行、`_duration_seconds_sum`+`_count` 成对、`go_goroutines` 存在、pgpool 族**不出现**(nil pool 省略)。再断言 `/metrics` 自身与 `/healthz` 不计入(先取计数、抓一轮 scrape、计数不变)。
|
||||
- [ ] **Step 2: 实现** metrics.go(`sync.Mutex` + map key `method|route|status`; Exposition 按指标名字典序输出,label 值本场景集合安全无需转义但注明);router 注册 `/metrics`(Content-Type: `text/plain; version=0.0.4`);pgxpool gauge 从 `Deps.Pool` 可选注入(serve.go 传池;测试传 nil)。
|
||||
- [ ] **Step 3: 容器四连** 同 Task 1 Step 6 → 全绿。
|
||||
- [ ] **Step 4: README 权衡节**(spec §3.3 三点:N× 限额/重启清零/扩缩前置条件)+ CHANGELOG 0.12.0(Added:metrics+请求日志;Changed:slog 迁移与 LOG_FORMAT/LOG_LEVEL;英文行+中文行)。
|
||||
- [ ] **Step 5: 提交** `git commit -m "feat(observability): hand-rolled /metrics exposition with http+go+pgpool families; document single-instance rate-limit tradeoff"`。
|
||||
|
||||
### Task 3: deploy — 备份脚本 + 恢复演练 + runbook + CI(0.6.0)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/backup.sh`、`scripts/restore-drill.sh`、`docs/BACKUP-RUNBOOK.md`
|
||||
- Modify: `.env.example`(BACKUP_DIR/BACKUP_KEEP_DAYS 注释+键)、`README.md`(prod 节链 runbook)、`.github/workflows/validate.yml`(追加 dry-run 步骤)、`docs/CHANGELOG.md`(0.6.0)
|
||||
- 分支:deploy 仓 `feat/backup-observability`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: compose prod 服务名 `db-prod`/`minio-prod`、`.env` 变量 `POSTGRES_PASSWORD`/`MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD`/`MINIO_BUCKET`、debug profile `db-test`(宿主 127.0.0.1:${PG_TEST_PORT:-5432})
|
||||
- Produces: `backup.sh [--dry-run]`(退出码 0/非 0;stdout 计划/结果)、`restore-drill.sh`(PASS/FAIL 字面输出);产物布局 `$BACKUP_DIR/prod/<UTC 时间戳>/{database.dump,objects/,manifest.txt}` + `prod/latest` 软链。
|
||||
|
||||
- [ ] **Step 1: backup.sh**(bash `set -euo pipefail`,逐步实现 spec §4 七步;`compose()` 封装 `docker compose -f docker-compose.yml --profile prod`;dump 用 `exec -T db-prod pg_dump -U crearte -d crearte -Fc` 重定向到目标文件;mirror 用 `run --no-deps --rm -T minio-prod mc alias set local ... && mc mirror --overwrite --remove local/$MINIO_BUCKET $TARGET`——**注意 minio 镜像无 sh 之外的 coreutils 假设,先 `docker run --rm --entrypoint sh pgsty/minio:latest -c 'mc version'` 探明再写**;manifest 逐表 count 用循环 `psql -At -c "SELECT tablename FROM pg_tables WHERE schemaname='public'"` 拼表名到单条 `SELECT count(*) FROM x UNION ALL …`;修剪 `ls -1 | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort | head -n -$KEEP_DAYS` 删除;`latest` 用 `ln -sfn`)。
|
||||
- [ ] **Step 2: dry-run 腿** `--dry-run`:`set -a; . ./.env; set +a` 校验四变量非空 + 打印将执行的命令序列(不碰 docker)。本机先手工验证两态(缺 env 文件→红;.env 齐全→绿)。
|
||||
- [ ] **Step 3: restore-drill.sh**:读 `latest/manifest.txt` → `compose --profile debug` 起 db-test(`docker compose up -d db-test` 若未跑)→ `run --rm` 一次性 postgres:17-alpine 客户端 `pg_restore -h db-test -U crearte -d crearte --clean --if-exists`(drop/recreate 仅限该库)→ 重算逐表行数与 manifest diff → `RESTORE-DRILL: PASS|FAIL` 末行输出。
|
||||
- [ ] **Step 4: runbook**(定时约定 crontab 一行 + systemd timer 备选;两条恢复路径;`mc mirror` 反推对象恢复;3-2-1 与 rclone 可选钩子;已知风险节——同盘、非快照一致性、manifest 漂移,抄 spec §4 末段展开成操作语言)。
|
||||
- [ ] **Step 5: validate.yml 追加步骤**:现有 compose config 步骤后 `cp .env.example .env && printf 'POSTGRES_PASSWORD=…\nMINIO_ROOT_USER=…\nMINIO_ROOT_PASSWORD=…\n' >> .env && bash scripts/backup.sh --dry-run`(CI 无 docker daemon 场景 dry-run 不得触碰 docker 子命令——Step 1 实现顺序保证校验先于 docker 检查)。
|
||||
- [ ] **Step 6: 本机绿** `bash -n scripts/*.sh`、`cd .. && docker compose --profile prod config -q`、workflow 语法对照现有 validate.yml;CHANGELOG 0.6.0 双语;README 链接。
|
||||
- [ ] **Step 7: 提交** `git commit -m "feat(backup): pg_dump+mc-mirror backup with retention + restore drill against db-test; runbook; CI dry-run"`。
|
||||
|
||||
### Task 4: 本机全量验证(控制者亲跑,background+poll)
|
||||
|
||||
- [ ] 4.1 server 容器四连(Task 2 后终态复跑一遍,含全部新旧单测)。
|
||||
- [ ] 4.2 server 集成层:debug profile 起 db-test → `TEST_DATABASE_URL=postgres://crearte:crearte@127.0.0.1:5432/crearte?sslmode=disable go test ./internal/api/ -run Integration -count=1` 容器内跑(网络 `--network host` 或复用 compose 网),期望全 PASS 0 skip(P2 同款守卫)。
|
||||
- [ ] 4.3 prod 实栈冒烟:`compose --profile prod up -d --build` → 灌一轮真实流量(curl /api/games、login、404 路径、`GET /metrics`×2)→ `exec api-prod wget -qO- localhost:8080/metrics` 断言计数吻合;`logs api-prod` 断言 JSON 行(`LOG_FORMAT=json` 需在 .env 或 compose env 生效——验证 compose 是否透传,**若 api-env anchor 无此变量则本任务给 compose 增 `LOG_FORMAT: ${LOG_FORMAT:-text}`、`LOG_LEVEL: ${LOG_LEVEL:-info}` 两行透传**,属 deploy 侧合理补充,记入报告与 CHANGELOG 0.6.0);full-loop:`GO=1 REQUIRED=1 bash scripts/e2e-stack.sh`(端口冲突先停 prod 栈或错峰)。
|
||||
- [ ] 4.4 备份演练:`bash scripts/backup.sh` 全绿 → 产物三件套+latest 链在场;连跑第二次 + `BACKUP_KEEP_DAYS=1` 修剪断言;`bash scripts/restore-drill.sh` → PASS。
|
||||
- [ ] 4.5 收尾:prod 栈 down、4173/4174 释放、台账。
|
||||
|
||||
### Task 5: 合主推送 + wrapper 收尾(控制者)
|
||||
|
||||
- [ ] 5.1 server:`checkout master && pull --ff-only && merge --no-ff feat/backup-observability -m "Merge branch 'feat/backup-observability' — slog + /metrics (P3)" && push && branch -d`。
|
||||
- [ ] 5.2 deploy:同款,merge 信息 `— backup/restore scripts + runbook (P3)`。
|
||||
- [ ] 5.3 wrapper:ROADMAP P3 行 → 完成(双 hash+日期);索引表 P3 spec/plan 行加(已执行);CHANGELOG 0.2.6 双语;commit+push。
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- spec §3.1/§3.2/§3.3 → Task 1/2/2-Step4;§4 七步 → Task 3 Step 1-5;§5 四条验收 → Task 4.1-4.4;D1/D2/D3 无未落地项。
|
||||
- 已知不确定点(实现者探明回填):pgsty/minio 镜像内 mc 与 sh 可用性(Task 3 Step 1 先探测);compose 对 LOG_FORMAT 透传(Task 4.3 现场定);router Deps 兼容性(Task 2 Step 2 注意既有 router_test 构造点零值安全)。
|
||||
- 占位符扫描:无 TBD;每步含可执行命令或精确文件行为。
|
||||
@@ -0,0 +1,403 @@
|
||||
# P2 CI + 测试基线 实现计划
|
||||
|
||||
> **对人工审查者:** 必需的子技能:使用 subagent-driven-development(如果子智能体不存在则用 executing-plans)逐任务实现此计划。每个任务都由一个新鲜、有能力的工程师完成,他们可以将你的检查清单作为提示保留。不要并行执行两个实现任务。
|
||||
|
||||
**目标:** 三仓获得与其风险相称的 CI:后端 gofmt/vet/build/test + 真 Postgres 集成层(静默 skip 视为失败)+ 真栈 Playwright 全链路首次进 CI;部署仓获得 compose 守卫正反验证。
|
||||
|
||||
**架构:** 每仓一个 `.github/workflows/validate.yml`(对齐 crearte 既有文件的风格);e2e-stack job 落在 crearte-server 仓(私有/公开仓凭据不对称,见 spec §2.2 修订),用双 checkout 拼出脚本要求的兄弟仓布局并以 `CREARTE_STACK_BACKEND_REF=github.sha` 直测 PR 后端;crearte 侧只加两个脚本 env 开关(GO/REQUIRED),封死 runner 上的版本地板假绿与 CI 静默 skip 假绿。
|
||||
|
||||
**技术栈:** GitHub Actions YAML、Go 1.24(gh actions setup-go)、Node 24 + Playwright、docker compose v2、bash。
|
||||
|
||||
## 全局约束
|
||||
|
||||
- 分支:三仓各自新建 `feat/ci-test-baseline`(从各仓 master HEAD);**严禁直接 commit master**;合并(`git merge --no-ff`)+ 推送只在任务 5 由控制者执行。各仓提交前 `git branch --show-current` 核验。
|
||||
- CHANGELOG 格式(三仓 AGENTS.md 逐字):同一条目英文行紧跟中文行(两行之间无空行);不同条目间空一行;高版本置顶。
|
||||
- 本仓后端一切 go 命令走 docker:宿主 go 1.18 禁用。模板(缓存全热,首跑 1-3 分钟属正常):
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/coder/crearte-monorepo/crearte-server/src
|
||||
docker run --rm -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn -e GOCACHE=/gocache -v crearte_gocache:/gocache -v crearte_gomod:/go/pkg/mod -v "$PWD:/src" -w /src golang:1.24-alpine sh -c '<命令>'
|
||||
```
|
||||
- 长命令用 exec background=true + process poll(timeout≥15000) 续等,勿因无输出判死。
|
||||
- YAML 校验统一用:`python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1])); print('yaml ok')" <path>`。
|
||||
- 不修改任何 `_test.go`;不动 `git.yoresee.cc`;GitHub 分支保护/Actions 启用查证属 owner 手动尾巴(spec §2.4),不入任务。
|
||||
|
||||
## 涉及文件:各仓库角色
|
||||
|
||||
| 仓库 | 文件 | 操作 |
|
||||
|---|---|---|
|
||||
| crearte | `src/scripts/e2e-stack.sh` | 修改(两个 env 开关) |
|
||||
| crearte | `.github/workflows/validate.yml` | 修改(push 触发) |
|
||||
| crearte | `docs/CHANGELOG.md` | 0.16.1 |
|
||||
| crearte-server | `.github/workflows/validate.yml` | 新建(三 job) |
|
||||
| crearte-server | `docs/CHANGELOG.md` | 0.11.1 |
|
||||
| crearte-deploy | `.github/workflows/validate.yml`、`docs/CHANGELOG.md`、`README.md` | 新建/更新(0.5.1) |
|
||||
| wrapper | `docs/specs/2026-09-30-ci-test-baseline-design.md`(已写)、ROADMAP、`docs/CHANGELOG.md` | 任务 5 收尾 |
|
||||
|
||||
---
|
||||
|
||||
### 任务 1:crearte — e2e-stack.sh 开关 + push 触发 + CHANGELOG 0.16.1
|
||||
|
||||
**文件:**
|
||||
- 修改:`src/scripts/e2e-stack.sh`(两处,块 1.2)
|
||||
- 修改:`.github/workflows/validate.yml`(块 1.4)
|
||||
- 修改:`docs/CHANGELOG.md`(块 1.6)
|
||||
|
||||
- [ ] **步骤 1.1** 开分支并断言起点:
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/coder/crearte-monorepo/crearte
|
||||
test -z "$(git status --porcelain)" && git checkout -b feat/ci-test-baseline && git rev-parse HEAD
|
||||
```
|
||||
预期:HEAD 为 `eb5fbed`(master),分支创建成功。node_modules 已在,勿 npm ci。
|
||||
|
||||
- [ ] **步骤 1.2** 编辑 `src/scripts/e2e-stack.sh`。第一处——原文(约 27-28 行,逐字):
|
||||
```bash
|
||||
GO_BIN="/usr/local/go/bin/go"
|
||||
command -v "$GO_BIN" >/dev/null 2>&1 || GO_BIN="$(command -v go 2>/dev/null || true)"
|
||||
```
|
||||
替换为:
|
||||
```bash
|
||||
# ⚠️ CI runner 的 /usr/local/go 不随 setup-go 变(可能低于 go.mod 地板 → skip 模式假绿)。
|
||||
# CREARTE_STACK_GO 显式指定 go 二进制;不设即旧行为(本地零变化)。
|
||||
GO_BIN="${CREARTE_STACK_GO:-/usr/local/go/bin/go}"
|
||||
command -v "$GO_BIN" >/dev/null 2>&1 || GO_BIN="$(command -v go 2>/dev/null || true)"
|
||||
```
|
||||
第二处——原文(约 125-129 行,逐字):
|
||||
```bash
|
||||
else
|
||||
echo "[stack] dependencies missing — running in SKIP mode (frontend only, full-loop will skip)"
|
||||
(cd "$FRONT_ROOT" && npm run build:e2e) || exit 1
|
||||
fi
|
||||
```
|
||||
替换为:
|
||||
```bash
|
||||
else
|
||||
echo "[stack] dependencies missing — running in SKIP mode (frontend only, full-loop will skip)"
|
||||
# ⚠️ CI 真栈 job 必须设 CREARTE_STACK_REQUIRED=1:skip 模式在 CI 里等于假绿(full-loop 全 skip 仍 exit 0)。
|
||||
if [ "${CREARTE_STACK_REQUIRED:-0}" = "1" ]; then
|
||||
echo "[stack] CREARTE_STACK_REQUIRED=1 — refusing silent skip, failing fast" >&2
|
||||
exit 1
|
||||
fi
|
||||
(cd "$FRONT_ROOT" && npm run build:e2e) || exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **步骤 1.3** 语法与行为探针(快,不建栈):
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/coder/crearte-monorepo/crearte/src
|
||||
bash -n scripts/e2e-stack.sh && echo SYNTAX-OK
|
||||
mkdir -p /tmp/p2fakebin && printf '#!/bin/sh\nexit 127\n' > /tmp/p2fakebin/docker && chmod +x /tmp/p2fakebin/docker
|
||||
PATH="/tmp/p2fakebin:$PATH" CREARTE_STACK_REQUIRED=1 bash scripts/e2e-stack.sh >/tmp/p2req.log 2>&1; echo "req rc=$?"; grep -c 'refusing silent skip' /tmp/p2req.log
|
||||
```
|
||||
预期:`SYNTAX-OK`;`req rc=1` 且 grep 输出 ≥1。控制组(不设 REQUIRED,同一 fake docker,15 秒即掐——skip 模式会去 build:e2e,属预期):
|
||||
```bash
|
||||
timeout 15 env PATH="/tmp/p2fakebin:$PATH" bash scripts/e2e-stack.sh >/tmp/p2skip.log 2>&1; echo "skip rc=$?"; grep -c 'SKIP mode' /tmp/p2skip.log
|
||||
```
|
||||
预期:grep ≥1(rc 可为 124=timeout,skip 分支确实到达即可)。
|
||||
|
||||
- [ ] **步骤 1.4** 编辑 `.github/workflows/validate.yml`:`on:` 块(逐字原文)
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- '.github/workflows/validate.yml'
|
||||
```
|
||||
替换为:
|
||||
```yaml
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- '.github/workflows/validate.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
```
|
||||
两 job 与其余内容零改动。
|
||||
|
||||
- [ ] **步骤 1.5** `python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1])); print('yaml ok')" .github/workflows/validate.yml` 预期 `yaml ok`。
|
||||
|
||||
- [ ] **步骤 1.6** `docs/CHANGELOG.md` 在 `# Changelog` 前言块之后、`## [0.16.0]` 之前插入(逐字,注意英中相邻):
|
||||
```markdown
|
||||
## [0.16.1] - 2026-09-30
|
||||
|
||||
### CI / 持续集成
|
||||
|
||||
- `scripts/e2e-stack.sh` gains `CREARTE_STACK_GO` (explicit go binary for the backend build — a CI runner whose `/usr/local/go` predates the backend's go.mod floor used to trip the version gate into silent SKIP mode, a false green) and `CREARTE_STACK_REQUIRED=1` (fail fast when the stack cannot start instead of skipping full-loop silently); `validate.yml` now also runs on pushes to `master`.
|
||||
- `scripts/e2e-stack.sh` 新增 `CREARTE_STACK_GO`(显式指定编译后端的 go 二进制——runner 上 `/usr/local/go` 低于后端 go.mod 地板时曾掉进静默 SKIP 假绿)与 `CREARTE_STACK_REQUIRED=1`(栈起不来直接失败,不再静默跳过 full-loop);`validate.yml` 追加对 `master` 推送的触发。
|
||||
```
|
||||
|
||||
- [ ] **步骤 1.7** 回归:`git diff --stat` 仅 3 文件;`grep -c '\[0.16.1\]' docs/CHANGELOG.md` = 1。本任务不触碰 TS 源,vitest 由任务 4 全量覆盖。
|
||||
|
||||
- [ ] **步骤 1.8** 覆盖提交:
|
||||
```bash
|
||||
git add src/scripts/e2e-stack.sh .github/workflows/validate.yml docs/CHANGELOG.md
|
||||
git commit -m "ci: e2e-stack GO/REQUIRED env knobs + push:master trigger (P2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 2:crearte-server — validate.yml 三 job + CHANGELOG 0.11.1
|
||||
|
||||
**文件:**
|
||||
- 创建:`.github/workflows/validate.yml`(块 2.2 全文逐字)
|
||||
- 修改:`docs/CHANGELOG.md`(块 2.4)
|
||||
|
||||
- [ ] **步骤 2.1** 开分支:
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/coder/crearte-monorepo/crearte-server
|
||||
test -z "$(git status --porcelain)" && git checkout -b feat/ci-test-baseline && git rev-parse HEAD | head -c 7
|
||||
```
|
||||
预期:`e3da246`。
|
||||
|
||||
- [ ] **步骤 2.2** 写 `.github/workflows/validate.yml` **全文**(逐字;目录不存在则创建):
|
||||
```yaml
|
||||
name: validate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- '.github/workflows/validate.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: src/go.mod
|
||||
cache-dependency-path: src/go.sum
|
||||
- name: gofmt
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- name: vet
|
||||
run: go vet ./...
|
||||
- name: build
|
||||
run: go build ./...
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
timeout-minutes: 15
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: crearte
|
||||
POSTGRES_PASSWORD: crearte
|
||||
POSTGRES_DB: crearte
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U crearte"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://crearte:crearte@localhost:5432/crearte?sslmode=disable
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: src/go.mod
|
||||
cache-dependency-path: src/go.sum
|
||||
- name: integration tests (empty DB, serialized -p 1, verbose)
|
||||
run: set -o pipefail && go test -v -count=1 -p 1 ./... 2>&1 | tee integration.log
|
||||
- name: fail on silent skips
|
||||
run: '! grep -q "skipping postgres integration test" integration.log'
|
||||
|
||||
e2e-stack:
|
||||
runs-on: ubuntu-latest
|
||||
needs: integration
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: crearte-server
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: XingfenD/crearte
|
||||
path: crearte
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: crearte-server/src/go.mod
|
||||
cache-dependency-path: crearte-server/src/go.sum
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: crearte/src/package-lock.json
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: crearte/src
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Real-stack full-loop e2e
|
||||
working-directory: crearte/src
|
||||
env:
|
||||
CREARTE_STACK_BACKEND_REF: ${{ github.sha }}
|
||||
CREARTE_STACK_GO: go
|
||||
CREARTE_STACK_REQUIRED: '1'
|
||||
run: npm ci && npm run e2e:stack
|
||||
```
|
||||
设计要点(审查透镜,勿改):e2e job 双 checkout 的 `path` 必须保持 `crearte` 与 `crearte-server` 同级,否则脚本的兄弟仓路径解析失败(见脚本头部 ⚠️ 注释);`CREARTE_STACK_GO: go` 走 setup-go 的 PATH;`CREARTE_STACK_REQUIRED: '1'` 封死静默 skip。
|
||||
|
||||
- [ ] **步骤 2.3** 校验:yaml ok;且 `grep -c "path: crearte-server" .github/workflows/validate.yml` =1、`grep -c '\-p 1' .github/workflows/validate.yml` =1、`grep -c 'skipping postgres integration test' .github/workflows/validate.yml` =1。
|
||||
|
||||
- [ ] **步骤 2.4** `docs/CHANGELOG.md` 前言后、`## [0.11.0]` 前插入(逐字):
|
||||
```markdown
|
||||
## [0.11.1] - 2026-09-30
|
||||
|
||||
### CI / 持续集成
|
||||
|
||||
- New `validate.yml`: `check` (gofmt/vet/build/test), `integration` (empty Postgres 17 service, serialized `-p 1`, plus a guard that fails the job on any silent "TEST_DATABASE_URL not set" skip — empty-DB migration bootstrapping is exercised every PR), and `e2e-stack` (real-stack Playwright full-loop: sibling public crearte frontend + this PR's backend SHA, `CREARTE_STACK_REQUIRED=1` forbids false greens).
|
||||
- 新增 `validate.yml` 三 job:`check`(gofmt/vet/build/test)、`integration`(空库 postgres 17 service、`-p 1` 串行、外加"任何 TEST_DATABASE_URL 静默 skip 即判失败"的守卫——每次 PR 都在走空库完整迁移)、`e2e-stack`(真栈 Playwright full-loop:兄弟公开仓 crearte 前端 + 本 PR 后端 SHA,`CREARTE_STACK_REQUIRED=1` 杜绝假绿)。
|
||||
```
|
||||
|
||||
- [ ] **步骤 2.5** 覆盖提交:
|
||||
```bash
|
||||
git add .github/workflows/validate.yml docs/CHANGELOG.md
|
||||
git commit -m "ci: validate workflow — check/integration/e2e-stack with skip guards (P2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 3:crearte-deploy — compose config 正反两路 + 0.5.1 + README 边界小节
|
||||
|
||||
**文件:**
|
||||
- 创建:`.github/workflows/validate.yml`(块 3.2 全文)
|
||||
- 修改:`docs/CHANGELOG.md`(块 3.4)、`README.md`(块 3.5)
|
||||
|
||||
- [ ] **步骤 3.1** 开分支:
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/coder/crearte-monorepo/crearte-deploy
|
||||
test -z "$(git status --porcelain)" && git checkout -b feat/ci-test-baseline && git rev-parse HEAD | head -c 7
|
||||
```
|
||||
预期:`4d53d58`。
|
||||
|
||||
- [ ] **步骤 3.2** 写 `.github/workflows/validate.yml` **全文**(逐字):
|
||||
```yaml
|
||||
name: validate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docker-compose.yml'
|
||||
- '.env.example'
|
||||
- '.github/workflows/validate.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
compose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: compose parses under every profile
|
||||
env:
|
||||
POSTGRES_PASSWORD: ***-a-real-secret
|
||||
MINIO_ROOT_USER: ci
|
||||
MINIO_ROOT_PASSWORD: ***
|
||||
AUTH_TOKEN_SECRET: ***
|
||||
BUNDLE_KEK_k1: ci-not-a-real-secret
|
||||
run: |
|
||||
for profile in dev prod mock debug; do
|
||||
docker compose --profile "$profile" config -q
|
||||
done
|
||||
- name: guards refuse an empty POSTGRES_PASSWORD
|
||||
env:
|
||||
POSTGRES_PASSWORD: ''
|
||||
run: |
|
||||
if docker compose --profile dev config -q 2>/dev/null; then
|
||||
echo 'compose accepted an empty POSTGRES_PASSWORD — :? guard bypassed' >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **步骤 3.3** 本机跑通两 job 的等价命令(与 CI 完全同构,compose 只解析不建容器):
|
||||
```bash
|
||||
POSTGRES_PASSWORD=*** MINIO_ROOT_USER=ci MINIO_ROOT_PASSWORD=*** AUTH_TOKEN_SECRET=*** BUNDLE_KEK_k1=*** bash -c 'for p in dev prod mock debug; do docker compose --profile "$p" config -q || { echo "PROFILE-FAIL $p"; exit 1; }; done; echo CONFIG-ALL-OK'
|
||||
POSTGRES_PASSWORD='' bash -c 'docker compose --profile dev config -q >/dev/null 2>&1; echo "empty-pw rc=$?"'
|
||||
```
|
||||
预期:`CONFIG-ALL-OK`;`empty-pw rc=` 非 0。
|
||||
|
||||
- [ ] **步骤 3.4** `docs/CHANGELOG.md` 前言后、`## [0.5.0]` 前插入(逐字):
|
||||
```markdown
|
||||
## [0.5.1] - 2026-09-30
|
||||
|
||||
### CI / 持续集成
|
||||
|
||||
- New `validate.yml`: parses `docker compose config -q` under dev/prod/mock/debug with placeholder secrets, and asserts the `:?` guards refuse an empty `POSTGRES_PASSWORD`. Single-repo CI validates parsing only — deployable cross-repo build contexts live in the monorepo layout (README note added).
|
||||
- 新增 `validate.yml`:用占位 secret 对 dev/prod/mock/debug 四 profile 跑 `docker compose config -q`,并断言 `:?` 守卫在 `POSTGRES_PASSWORD` 为空时拒绝解析。单仓 CI 只验解析——可部署的跨仓 build 上下文存在于 monorepo 布局(README 已加边界说明)。
|
||||
```
|
||||
|
||||
- [ ] **步骤 3.5** `README.md`:在「### prod 栈写侧(本机自包含演练)」小节标题行**之前**插入新小节(逐字):
|
||||
```markdown
|
||||
### 单仓 checkout 能验什么(CI 边界)
|
||||
|
||||
本仓 CI(`validate.yml`)只校验 compose 解析与各 profile 的 `:?` 守卫——单仓 checkout 没有 `../crearte-server`、`../crearte` 兄弟目录,build context 无法成立。可部署性与跨仓联调以 monorepo(wrapper `docs/` 登记)演练为准:`../../` 级 `./clone_all.sh` 布局 + `docker compose up`。
|
||||
|
||||
```
|
||||
|
||||
- [ ] **步骤 3.6** yaml ok + `git diff --stat` 仅 3 文件。
|
||||
|
||||
- [ ] **步骤 3.7** 覆盖提交:
|
||||
```bash
|
||||
git add .github/workflows/validate.yml docs/CHANGELOG.md README.md
|
||||
git commit -m "ci: compose config validation with guard negative test (P2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 4:本机等价模拟(只验不改;live 证据留档)
|
||||
|
||||
**文件:** 创建:`/root/.openclaw/workspace/coder/crearte-monorepo/.superpowers/sdd/p2-task-4-report.md`(该目录 gitignored)。零代码改动。
|
||||
|
||||
前置:任务 1-3 审查通过并合入各自 feat 分支 HEAD(未合并 master 也可跑,模拟的是 job 内容而非触发)。
|
||||
|
||||
- [ ] **步骤 4.1** check job 等价(docker 模板见全局约束):`gofmt -l .` 空断言 + `go vet ./...` + `go build ./...` + `go test ./...`,记录输出尾部。
|
||||
- [ ] **步骤 4.2** integration 等价:起一次性 pg(**5434 端口,避免与演练栈 5433 冲突**):
|
||||
```bash
|
||||
docker rm -f p2-ci-pg >/dev/null 2>&1
|
||||
docker run -d --name p2-ci-pg -e POSTGRES_USER=crearte -e POSTGRES_PASSWORD=crearte -e POSTGRES_DB=crearte -p 5434:5432 postgres:17-alpine
|
||||
for i in $(seq 1 30); do docker exec p2-ci-pg pg_isready -U crearte >/dev/null 2>&1 && break; sleep 1; done
|
||||
```
|
||||
然后在 golang:1.24-alpine 容器里带 `--add-host=host.docker.internal:host-gateway` 与 `TEST_DATABASE_URL=postgres://crearte:crearte@host.docker.internal:5434/crearte?sslmode=disable` 跑 `sh -c 'set -o pipefail && go test -v -count=1 -p 1 ./... 2>&1 | tee /tmp/integration.log; ! grep -q "skipping postgres integration test" /tmp/integration.log && echo SKIP-GUARD-PASS'`(tee 到容器内文件后 `docker cp` 或 tail 取证;守卫须打印 SKIP-GUARD-PASS)。**跑两遍**:第二遍证明空库迁移幂等共存。记录 `--- PASS` 计数与 `ok` 包列表。跑完 `docker rm -f p2-ci-pg`。
|
||||
- [ ] **步骤 4.3** 真栈 e2e(本机 monorepo 天然满足兄弟仓布局;宿主 go 1.26.8 过地板检查):
|
||||
```bash
|
||||
cd /root/.openclaw/workspace/coder/crearte-monorepo/crearte/src
|
||||
npx playwright install chromium 2>&1 | tail -1
|
||||
CREARTE_STACK_REQUIRED=1 npm run e2e:stack 2>&1 | tee /tmp/p2e2e.log | tail -12
|
||||
grep -c '\[stack\] ready:' /tmp/p2e2e.log
|
||||
```
|
||||
预期:日志含 `[stack] ready:`(grep ≥1)且 playwright 输出 `N passed`、0 failed、无 skipped 的 full-loop 用例。若浏览器版本不匹配 `--with-deps` 需要 sudo——本机 root 可直接 `--with-deps`。全程 background+poll 耐心(首跑编译后端 1-3 分钟 + 前端 build)。
|
||||
- [ ] **步骤 4.4** deploy 两 job 等价:复用任务 3 块 3.3 两条命令,确认仍 `CONFIG-ALL-OK` / 非 0。
|
||||
- [ ] **步骤 4.5** 三仓 workflow 终稿 yaml 解析全过(从各 feat HEAD 读文件)。
|
||||
- [ ] **步骤 4.6** 清理与现场核验:`docker ps -a` 无 crearte-stack-*/p2-ci-pg 残留;`git -C ../crearte-server worktree list` 只剩主树;`ls fixtures/generated/stack.json` 不存在;`rm -f /tmp/p2*`;三仓 `git status --porcelain` 全空、仍停在各自 feat 分支。报告写全(p2-task-4-report.md)。
|
||||
|
||||
---
|
||||
|
||||
### 任务 5:三仓合并推送 + wrapper 收尾(控制者直接执行)
|
||||
|
||||
- [ ] **步骤 5.1** crearte-server:`git checkout master && git pull --ff-only origin master && git merge --no-ff feat/ci-test-baseline -m "Merge branch 'feat/ci-test-baseline' — CI + test baseline (P2)" && git push origin master && git branch -d feat/ci-test-baseline; git push origin --delete feat/ci-test-baseline 2>/dev/null; git log --oneline -1`。
|
||||
- [ ] **步骤 5.2** crearte、crearte-deploy 同款 merge message 尾缀 `(P2)`。
|
||||
- [ ] **步骤 5.3** wrapper:ROADMAP P2 行 → `**完成**(server \`<sha>\` / crearte \`<sha>\` / deploy \`<sha>\`,2026-09-30 合并推送;e2e-stack 宿主仓修订见 spec §2.2;Actions/分支保护 owner 手动)`;索引表加 P2 行;CHANGELOG 0.2.4 双语;commit+push。
|
||||
- [ ] **步骤 5.4** 终态核验:三仓 `git pull --ff-only origin master` 均 Already up to date + `./clone_all.sh` 零失败。
|
||||
- [ ] **步骤 5.5** `--ff-only` 拉来冲突时 `git merge --abort` 上报,禁强推。
|
||||
|
||||
---
|
||||
|
||||
## 自检记录(写计划后内联核过)
|
||||
|
||||
1. **规格覆盖**:spec §2.1→任务 2(check/integration+守卫);§2.2→任务 1(GO/REQUIRED 开关、crearte push 触发)+ 任务 2(e2e-stack job);§2.3→任务 3;§3 验证→任务 4 步骤 1-6;§2.4 owner 尾巴→任务 5 步骤 3 记入 ROADMAP 行。✔
|
||||
2. **占位符扫描**:无 TODO/待定;`ci-not-a-real-secret` 是刻意占位值(CI config -q 不消费真值,P1 教训「占位冒充真值」不适用于此处——已按 P1 教训改用无歧义占位串并在注释声明,且 deploy 反路测试证明空值守卫仍在)。✔
|
||||
3. **命名一致**:`CREARTE_STACK_GO/CREARTE_STACK_REQUIRED/CREARTE_STACK_BACKEND_REF`(任务 1 定义→任务 2 使用,拼写逐字);`integration.log` 守卫消息取 `migrate_test.go:22` 等 7 处公共子串 "skipping postgres integration test"(已 grep 全量核对,无第八处变体);路径 `crearte`/`crearte-server` 兄弟布局与 `e2e-stack.sh:11-15` 实际解析逐字对齐。✔
|
||||
@@ -0,0 +1,398 @@
|
||||
# 创作者中心(/creator)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 crearte 前端新增首页样式的「创作者中心」页(`/creator`):hero + 「作品数据」占位卡(按登录态三态分流)+「创作教程」占位卡(链现有文档),页头主导航加第三个 tab。
|
||||
|
||||
**Architecture:** 纯前端改动——一个新路由、一个新视图(复刻 `LandingView.vue` 的墨纸 class 体系,零新 token)、`AppHeader` 加 tab;登录态用既有 `authEnabled` + `session`(`@/auth`);页面无数据请求、无加载/失败态。测试走 Playwright e2e(默认配置 + noauth 配置),TDD:先写失败 e2e 再实现。
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup>` + TypeScript + vue-router + Tailwind class 体系;Playwright(`@playwright/test`);crearte 仓(monorepo 内层前端仓)。
|
||||
|
||||
**Spec:** `crearte-monorepo/docs/specs/2026-09-30-creator-center-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 工作目录:`crearte-monorepo/crearte`(内层前端仓)。除标注外,所有命令在 `crearte-monorepo/crearte/src` 下执行。
|
||||
- 分支:从 `master`(当前顶端 `eb5fbed`,changelog 0.16.0)切 `feat/creator-center`。crearte AGENTS.md:dev 分支命名 `{feat|fix|docs|chore}/{name}`;**不在 master 上直接提交**。
|
||||
- 每个 Task 完成即提交,提交信息风格对齐仓库现状(`feat(scope): 简述`)。
|
||||
- 文案逐字采用 spec §3.2,不得改写:hero 徽章「创作者」、标语 `SHARE YOUR CREATIONS`、说明「把你的作品分享给所有人」、按钮「提交作品」「投稿指南」;卡标题「作品数据」「创作教程」;数据卡三态文案与教程卡占位文案见各 Task。
|
||||
- 样式只复用 `LandingView.vue` 既有 class;不新增 design token、不改 `main.css`。
|
||||
- CHANGELOG:`docs/CHANGELOG.md`(crearte 仓)顶部追加 `## [0.17.0] - 2026-09-30` 条目;同一条目英文行、中文行连续书写(中间无空行),条目之间空行分隔。
|
||||
- 完成定义:`npx vitest run`、`npx vue-tsc --noEmit`、`npx playwright test`、`npx playwright test --config playwright.noauth.config.ts` 四条全绿;最后 `--no-ff` 合并回 `master`、删分支、推送。
|
||||
- 默认 e2e 构建注入了 `VITE_API_BASE_URL=http://localhost:4173`(`authEnabled === true`);noauth 构建注入空值(`authEnabled === false`)。两条路径都要覆盖。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 分支准备 + 路由 + 主导航 tab + hero
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/views/CreatorCenterView.vue`(先只含 hero)
|
||||
- Modify: `src/app/router/index.ts`(在 `home` 路由后插入 `creator` 路由)
|
||||
- Modify: `src/app/components/AppHeader.vue`(`onCreator` computed + 主导航第三个 tab)
|
||||
- Test: `src/e2e/creator.spec.ts`(新建,先写第 1 条用例)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 无(首个任务)
|
||||
- Produces: 路由 `{ path: '/creator', name: 'creator' }`;视图默认导出 `CreatorCenterView.vue`;header tab 可访问名「创作者中心」(激活时 `aria-current="page"`)
|
||||
|
||||
- [ ] **Step 1: 从 master 切功能分支**
|
||||
|
||||
```bash
|
||||
cd crearte-monorepo/crearte
|
||||
git checkout master
|
||||
git checkout -b feat/creator-center
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写失败的 e2e(第 1 条用例)**
|
||||
|
||||
创建 `src/e2e/creator.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('主导航有创作者中心 tab,/creator 渲染 hero', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/creator')
|
||||
|
||||
const tab = page.locator('header').getByRole('link', { name: '创作者中心' })
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(tab).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: '创作者中心' })).toBeVisible()
|
||||
await expect(page.getByText('SHARE YOUR CREATIONS')).toBeVisible()
|
||||
await expect(page.getByText('把你的作品分享给所有人')).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '提交作品' })).toHaveAttribute('href', '/submit/new')
|
||||
await expect(page.getByRole('link', { name: '投稿指南' })).toHaveAttribute('href', '/docs/contribute')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行确认失败**
|
||||
|
||||
Run: `cd src && npx playwright test e2e/creator.spec.ts`
|
||||
Expected: FAIL——`/creator` 落入 NotFound 路由,heading「创作者中心」不存在(Playwright 自动拉起 webServer:`build:e2e` + `serve-runtime`,首次约 1–3 分钟)。
|
||||
|
||||
- [ ] **Step 4: 新建视图(仅 hero)**
|
||||
|
||||
创建 `src/app/views/CreatorCenterView.vue`:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const SLOGAN = 'SHARE YOUR CREATIONS'
|
||||
const TAGLINE = '把你的作品分享给所有人'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-[3px] border-ink bg-surface px-6 py-10 text-center shadow-hard sm:px-10 sm:py-14">
|
||||
<span class="inline-block bg-ink px-2 py-1 text-[0.6875rem] font-bold tracking-[0.3em] text-paper">创作者</span>
|
||||
<h1 class="mt-3 font-display text-4xl leading-none font-black tracking-tight sm:text-5xl md:text-6xl">创作者中心</h1>
|
||||
<p class="mt-3 font-mono text-[0.6875rem] tracking-[0.3em] text-ink-soft">{{ SLOGAN }}</p>
|
||||
<div class="mx-auto my-5 h-[3px] w-16 bg-ink"></div>
|
||||
<p class="text-sm text-ink-soft">{{ TAGLINE }}</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<RouterLink to="/submit/new" class="btn-ink lift hover:shadow-hard active:shadow-none">提交作品</RouterLink>
|
||||
<RouterLink to="/docs/contribute" class="btn-surface lift hover:shadow-hard active:shadow-none">投稿指南</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 注册路由**
|
||||
|
||||
`src/app/router/index.ts`,在 `home` 路由行之后插入:
|
||||
|
||||
```ts
|
||||
{ path: '/creator', name: 'creator', component: () => import('@/views/CreatorCenterView.vue') },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: AppHeader 加 tab**
|
||||
|
||||
`src/app/components/AppHeader.vue` script 段,在 `onDocs` 行之后加:
|
||||
|
||||
```ts
|
||||
const onCreator = computed(() => route.name === 'creator')
|
||||
```
|
||||
|
||||
template 主导航 `<nav>` 内,「文档」RouterLink 之后、`</nav>` 之前插入:
|
||||
|
||||
```html
|
||||
<RouterLink
|
||||
to="/creator"
|
||||
class="border-b-[3px] pb-0.5 text-sm font-bold"
|
||||
:class="onCreator ? 'border-b-accent-ink text-accent-ink' : 'border-b-transparent text-ink-soft'"
|
||||
:aria-current="onCreator ? 'page' : undefined"
|
||||
>创作者中心</RouterLink>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 运行确认通过**
|
||||
|
||||
Run: `npx playwright test e2e/creator.spec.ts`
|
||||
Expected: PASS(1 passed)
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
cd crearte-monorepo/crearte
|
||||
git add src/app/views/CreatorCenterView.vue src/app/router/index.ts src/app/components/AppHeader.vue src/e2e/creator.spec.ts
|
||||
git commit -m "feat(creator): /creator route, header tab and hero — landing-style skeleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 作品数据卡(登录态三态分流)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/views/CreatorCenterView.vue`(script 加 `user` computed;template 在 hero 后加卡片行 + 「作品数据」卡)
|
||||
- Modify: `src/e2e/creator.spec.ts`(追加第 2、3 条用例)
|
||||
- Modify: `src/e2e/noauth.spec.ts`(在既有用例末尾追加 `/creator` 断言)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `/creator` 路由与视图;`authEnabled` / `session`(`@/auth`,`AppHeader.vue` 同款用法)
|
||||
- Produces: 卡片标题「作品数据」(h3);未登录时卡内「登录」链接 `href="/login?next=%2Fcreator"`
|
||||
|
||||
- [ ] **Step 1: 写失败的 e2e(第 2、3 条用例)**
|
||||
|
||||
`src/e2e/creator.spec.ts` 头部 import 改为:
|
||||
|
||||
```ts
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { seedSession } from './helpers'
|
||||
```
|
||||
|
||||
文件末尾追加:
|
||||
|
||||
```ts
|
||||
test('数据卡:未登录给登录引导且 next 回跳 /creator', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/creator')
|
||||
|
||||
const card = page.getByRole('heading', { name: '作品数据' }).locator('..')
|
||||
await expect(card.getByText('登录后即可查看您作品的数据。')).toBeVisible()
|
||||
await expect(card.getByRole('link', { name: '登录' })).toHaveAttribute('href', '/login?next=%2Fcreator')
|
||||
})
|
||||
|
||||
test('数据卡:已登录显示建设中文案且无登录链接', async ({ page }) => {
|
||||
await seedSession(page)
|
||||
await page.goto('http://localhost:4173/creator')
|
||||
|
||||
const card = page.getByRole('heading', { name: '作品数据' }).locator('..')
|
||||
await expect(card.getByText('数据面板正在建设中。上线后将展示您作品的浏览、下载与评分。')).toBeVisible()
|
||||
await expect(card.getByRole('link', { name: '登录' })).toHaveCount(0)
|
||||
})
|
||||
```
|
||||
|
||||
`src/e2e/noauth.spec.ts` 既有用例末尾(`expect(authRequests).toEqual([])` 之前)追加:
|
||||
|
||||
```ts
|
||||
await page.goto('/creator')
|
||||
await expect(page.getByRole('heading', { level: 1, name: '创作者中心' })).toBeVisible()
|
||||
await expect(page.getByText('数据面板正在建设中。', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '登录' })).toHaveCount(0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `npx playwright test e2e/creator.spec.ts`
|
||||
Expected: FAIL——「作品数据」heading 不存在。
|
||||
|
||||
- [ ] **Step 3: 实现数据卡**
|
||||
|
||||
`src/app/views/CreatorCenterView.vue` script 段改为:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { authEnabled, session } from '@/auth'
|
||||
|
||||
const SLOGAN = 'SHARE YOUR CREATIONS'
|
||||
const TAGLINE = '把你的作品分享给所有人'
|
||||
|
||||
const user = computed(() => session.state.user)
|
||||
</script>
|
||||
```
|
||||
|
||||
template 在 hero `</section>` 之后追加(本 Task 先只放数据卡,教程卡在 Task 3 加):
|
||||
|
||||
```html
|
||||
<section class="mt-8 flex flex-col gap-4 sm:flex-row">
|
||||
<h2 class="sr-only">创作者中心</h2>
|
||||
<section class="flex-1 border-2 border-ink bg-surface p-4 shadow-hard-sm">
|
||||
<h3 class="text-sm font-black">作品数据</h3>
|
||||
<p class="mt-2 text-xs leading-relaxed text-ink-soft">
|
||||
<template v-if="authEnabled && !user">登录后即可查看您作品的数据。<RouterLink :to="{ path: '/login', query: { next: '/creator' } }" class="underline">登录</RouterLink></template>
|
||||
<template v-else-if="authEnabled">数据面板正在建设中。上线后将展示您作品的浏览、下载与评分。</template>
|
||||
<template v-else>数据面板正在建设中。</template>
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过(默认配置)**
|
||||
|
||||
Run: `npx playwright test e2e/creator.spec.ts`
|
||||
Expected: PASS(3 passed)
|
||||
|
||||
- [ ] **Step 5: 运行 noauth 配置确认通过**
|
||||
|
||||
Run: `npx playwright test --config playwright.noauth.config.ts`
|
||||
Expected: PASS(1 passed,含新增的 `/creator` 断言)
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
cd crearte-monorepo/crearte
|
||||
git add src/app/views/CreatorCenterView.vue src/e2e/creator.spec.ts src/e2e/noauth.spec.ts
|
||||
git commit -m "feat(creator): work-data placeholder card with three auth states (anon prompt / signed-in / noauth)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 创作教程卡(占位 + 链现有文档)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/views/CreatorCenterView.vue`(卡片行内追加「创作教程」卡)
|
||||
- Modify: `src/e2e/creator.spec.ts`(追加第 4 条用例)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 的卡片行结构(`section.mt-8.flex ... sm:flex-row`)
|
||||
- Produces: 卡片标题「创作教程」(h3);卡内链接「《提交作品指南》」`href="/docs/contribute"`
|
||||
|
||||
- [ ] **Step 1: 写失败的 e2e(第 4 条用例)**
|
||||
|
||||
`src/e2e/creator.spec.ts` 末尾追加:
|
||||
|
||||
```ts
|
||||
test('教程卡占位并链到提交作品指南', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/creator')
|
||||
|
||||
const card = page.getByRole('heading', { name: '创作教程' }).locator('..')
|
||||
await expect(card.getByText('教程整理中,敬请期待。')).toBeVisible()
|
||||
await expect(card.getByRole('link', { name: '《提交作品指南》' })).toHaveAttribute('href', '/docs/contribute')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `npx playwright test e2e/creator.spec.ts`
|
||||
Expected: FAIL——「创作教程」heading 不存在。
|
||||
|
||||
- [ ] **Step 3: 实现教程卡**
|
||||
|
||||
`src/app/views/CreatorCenterView.vue`,在「作品数据」卡的 `</section>` 之后、卡片行 `</section>` 之前插入:
|
||||
|
||||
```html
|
||||
<section class="flex-1 border-2 border-ink bg-surface p-4 shadow-hard-sm">
|
||||
<h3 class="text-sm font-black">创作教程</h3>
|
||||
<p class="mt-2 text-xs leading-relaxed text-ink-soft">教程整理中,敬请期待。投稿流程、打包规范与过审要点正在重新整理,完成后在本页发布;可先阅读<RouterLink to="/docs/contribute" class="underline">《提交作品指南》</RouterLink>。</p>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `npx playwright test e2e/creator.spec.ts`
|
||||
Expected: PASS(4 passed)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
cd crearte-monorepo/crearte
|
||||
git add src/app/views/CreatorCenterView.vue src/e2e/creator.spec.ts
|
||||
git commit -m "feat(creator): tutorial placeholder card linking to the submission guide"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CHANGELOG + 全量验证 + 合并回 master + 推送
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/CHANGELOG.md`(crearte 仓,追加 0.17.0)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1–3 的全部提交(分支 `feat/creator-center`)
|
||||
- Produces: `master` 上的合并提交与 0.17.0 条目;远端更新
|
||||
|
||||
- [ ] **Step 1: 追加 CHANGELOG 条目**
|
||||
|
||||
`docs/CHANGELOG.md`,在文件说明段之后、`## [0.16.0] - 2026-09-30` 之前插入:
|
||||
|
||||
```md
|
||||
## [0.17.0] - 2026-09-30
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- Added the Creator Center (`/creator`): a homepage-style page with a hero (提交作品 / 投稿指南 CTAs) and two cards — 「作品数据」 (placeholder adapting to auth state: anonymous visitors get a login prompt with `next` return, signed-in users see build-in-progress copy, noauth deploys show no login link) and 「创作教程」 (placeholder linking to the existing submission guide until the tutorial content lands). The header main nav gains a third tab with the same active styling as 作品/文档. No backend or deploy changes.
|
||||
- 新增创作者中心(`/creator`):首页样式页面,hero(提交作品 / 投稿指南两个按钮)+ 两张卡——「作品数据」占位卡按登录态分流(未登录给登录引导并带 `next` 回跳、已登录显示建设中文案、noauth 部署不出现登录链接)与「创作教程」占位卡(教程内容定稿前链向现有《提交作品指南》)。页头主导航新增第三个 tab,激活样式与「作品 / 文档」一致。零后端、零部署改动。
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量验证(四条命令依次跑,必须全绿)**
|
||||
|
||||
```bash
|
||||
cd crearte-monorepo/crearte/src
|
||||
npx vitest run
|
||||
npx vue-tsc --noEmit
|
||||
npx playwright test
|
||||
npx playwright test --config playwright.noauth.config.ts
|
||||
```
|
||||
|
||||
Expected: vitest 全 passed(存量 333+,无新增单测);`vue-tsc` 无输出;默认 e2e 全 passed(含 creator.spec.ts 4 条,存量不回归);noauth e2e 1 passed。
|
||||
|
||||
- [ ] **Step 3: 提交 CHANGELOG**
|
||||
|
||||
```bash
|
||||
cd crearte-monorepo/crearte
|
||||
git add docs/CHANGELOG.md
|
||||
git commit -m "docs: changelog 0.17.0 — creator center"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 推送功能分支(触发 CI)**
|
||||
|
||||
```bash
|
||||
git push origin feat/creator-center
|
||||
```
|
||||
|
||||
Expected: 推送成功;`validate.yml` 工作流对分支运行(若失败,修复后追加提交再推送,勿改历史)。
|
||||
|
||||
- [ ] **Step 5: 合并回 master(--no-ff)并删分支**
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
git merge --no-ff feat/creator-center -m "merge: creator center (/creator) — placeholder data & tutorial cards, header tab; crearte 0.17.0"
|
||||
git branch -d feat/creator-center
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 推送 master**
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
```
|
||||
|
||||
Expected: `master` 更新,包含 4 个功能提交 + 1 个合并提交。
|
||||
|
||||
- [ ] **Step 7: 收尾核对**
|
||||
|
||||
```bash
|
||||
git log --oneline -6
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
Expected: 合并提交在顶端;工作区干净、与 `origin/master` 同步。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录(计划编写时自查)
|
||||
|
||||
**1. Spec 覆盖核对:**
|
||||
|
||||
| Spec 验收标准 | 对应步骤 |
|
||||
|---|---|
|
||||
| 1. 主导航 tab + 激活态一致 | Task 1 Step 2/6/7 |
|
||||
| 2. hero + 两卡、视觉语言复用 | Task 1 Step 4、Task 2 Step 3、Task 3 Step 3 |
|
||||
| 3. 三态分流(未登录 next / 已登录 / noauth) | Task 2 Step 1/3/4/5 |
|
||||
| 4. 教程卡占位 + /docs/contribute 链接 | Task 3 |
|
||||
| 5. 新增 e2e 通过 + 存量全量绿 | Task 2–4 各 Step 4/5、Task 4 Step 2 |
|
||||
| 6. server / deploy 零 diff | 全程只碰 `src/` 与 crearte 仓 `docs/CHANGELOG.md` |
|
||||
|
||||
**2. 占位符扫描:** 无 TBD/TODO;每步含确切代码或命令;失败预期逐条写明。
|
||||
|
||||
**3. 一致性:** 路由名 `creator`、视图 `CreatorCenterView.vue`、tab 名「创作者中心」、登录链接 `href="/login?next=%2Fcreator"`、教程链接 `href="/docs/contribute"` 在各 Task 间一致;CHANGELOG 版本 0.17.0 与 master 顶端 0.16.0 衔接。
|
||||
@@ -0,0 +1,357 @@
|
||||
# P5 收藏/评分 实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development 逐任务实现。步骤用 `- [ ]` 复选框跟踪。spec:`docs/specs/2026-09-30-favorites-ratings-design.md`(需求唯一真源)。
|
||||
|
||||
**目标:** 作品收藏(布尔幂等)+ 五星评分(upsert/可撤)贯通 server→web,目录新增「热门」排序,账号页显示我的反应。
|
||||
**架构:** 两新表 favorites/ratings(PK user_id+work_id);读侧把聚合嵌入 `GameSummary` 并把反应水位并入 ETag;「热门」= 贝叶斯均分 + 收藏对数,纯前端函数;deploy 零改动。
|
||||
**技术栈:** Go 1.24 + gin + pgx / Vue 3 + vitest。
|
||||
|
||||
## 全局约束(所有任务遵守)
|
||||
|
||||
- **分支**:crearte-server 与 crearte 各自 `feat/favorites-ratings`(从 master 新开)。两仓 AGENTS 红线:master 上禁 commit;合并必须 `--no-ff`。wrapper 可直提 master。
|
||||
- **基线**:crearte-server master = `a8ea755`(P0 后未动过);crearte master = `63bb96d`(P1 后)。开工前各自 `git pull --ff-only origin master` 确认。
|
||||
- **宿主 go 是 1.18,模块要求 1.24**:一切 go 命令走 docker——
|
||||
```bash
|
||||
cd crearte-server/src
|
||||
docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go test ./...'
|
||||
```
|
||||
`gofmt -l` 须输出空;首次拉镜像慢属正常。**宿主机严禁直接 go build/test**。
|
||||
- **web**:`cd crearte/src && npm ci`(node_modules 不在仓内,首次慢);验证 `npm test`、`npm run typecheck`、`npm run build` 全绿。
|
||||
- **不动的文件**:`.env`、compose、nginx——本计划 deploy 零 diff。`schemaVersion` 维持 2。
|
||||
- JSON 字段名逐字:`ratingAvg`(1 位小数)/`ratingCount`/`favoriteCount`;ReactionView:`favoriteCount/ratingCount/ratingAvg/favorited/rated/score`。
|
||||
- 每个测试步骤先 RED 后 GREEN;commit message 用计划给定文本。
|
||||
|
||||
---
|
||||
|
||||
### 任务 1:server 仓库层(迁移 + ReactionRepository + store 接线)
|
||||
|
||||
**文件:**
|
||||
- 创建:`src/internal/repository/migrations/0009_favorites_ratings.sql`
|
||||
- 创建:`src/internal/model/reaction.go`
|
||||
- 创建:`src/internal/repository/reaction.go`(Postgres 实现)
|
||||
- 修改:`src/internal/repository/content.go`(接口 + PostgresContentStore + txContent)
|
||||
- 修改:`src/internal/repository/memory_content.go`(MemoryContentStore)
|
||||
- 测试:`src/internal/repository/reaction_test.go`(memory 全量 + pg-gated 冒烟)
|
||||
|
||||
- [ ] **步骤 1.1** 开分支:`cd crearte-server && git checkout -b feat/favorites-ratings`
|
||||
|
||||
- [ ] **步骤 1.2** 写迁移 `0009_favorites_ratings.sql`(spec §3.1 逐字):
|
||||
|
||||
```sql
|
||||
CREATE TABLE favorites (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
work_id text NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, work_id)
|
||||
);
|
||||
CREATE TABLE ratings (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
work_id text NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
score smallint NOT NULL CHECK (score BETWEEN 1 AND 5),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, work_id)
|
||||
);
|
||||
CREATE INDEX favorites_work_idx ON favorites(work_id);
|
||||
CREATE INDEX ratings_work_idx ON ratings(work_id);
|
||||
```
|
||||
|
||||
- [ ] **步骤 1.3** `model/reaction.go`:
|
||||
|
||||
```go
|
||||
package model
|
||||
|
||||
type WorkReaction struct {
|
||||
FavoriteCount int
|
||||
RatingCount int
|
||||
RatingSum int
|
||||
}
|
||||
|
||||
func (r WorkReaction) Avg() float64 {
|
||||
if r.RatingCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.RatingSum) / float64(r.RatingCount)
|
||||
}
|
||||
|
||||
type UserReactions struct {
|
||||
Favorites []string
|
||||
Ratings map[string]int
|
||||
}
|
||||
|
||||
type ReactionWatermark struct {
|
||||
FavoriteCount int
|
||||
RatingCount int
|
||||
RatingSum int
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 1.4** `repository/reaction.go`——接口 + Postgres 实现(DBTX 模式同 `PostgresWorkRepository`):
|
||||
|
||||
```go
|
||||
type ReactionRepository interface {
|
||||
SetFavorite(ctx context.Context, userID, workID string) error
|
||||
DeleteFavorite(ctx context.Context, userID, workID string) error
|
||||
UpsertRating(ctx context.Context, userID, workID string, score int) error
|
||||
DeleteRating(ctx context.Context, userID, workID string) error
|
||||
HasFavorite(ctx context.Context, userID, workID string) (bool, error)
|
||||
GetRating(ctx context.Context, userID, workID string) (int, bool, error)
|
||||
AggregateFor(ctx context.Context, workID string) (model.WorkReaction, error)
|
||||
AggregateAll(ctx context.Context) (map[string]model.WorkReaction, error)
|
||||
ListByUser(ctx context.Context, userID string) (model.UserReactions, error)
|
||||
Watermark(ctx context.Context) (model.ReactionWatermark, error)
|
||||
}
|
||||
```
|
||||
|
||||
SQL 要点(全部 `mapPgError` 包装):
|
||||
- `SetFavorite`:`INSERT INTO favorites(user_id, work_id) VALUES($1,$2) ON CONFLICT DO NOTHING`
|
||||
- `UpsertRating`:`INSERT INTO ratings(user_id, work_id, score) VALUES($1,$2,$3) ON CONFLICT (user_id, work_id) DO UPDATE SET score=EXCLUDED.score, updated_at=now()`
|
||||
- `Delete*`:无条件 `DELETE FROM ... WHERE user_id=$1 AND work_id=$2`(删不存在也算成功)
|
||||
- `HasFavorite`:`SELECT EXISTS(...)`;`GetRating`:`SELECT score ...` + `pgx.ErrNoRows → (0,false,nil)`
|
||||
- `AggregateFor`:两条 `count(*)/COALESCE(sum(score),0)` 查询合成
|
||||
- `AggregateAll`:两表各 `GROUP BY work_id`,合并进 map(仅出现过的 work 有条目)
|
||||
- `ListByUser`:favorites 按 created_at 排序取 `[]string`;ratings 扫成 map
|
||||
- `Watermark`:四条标量——`SELECT count(*), COALESCE(sum(score),0) FROM ratings`、`SELECT count(*) FROM favorites`
|
||||
|
||||
- [ ] **步骤 1.5** 接线三处 store:
|
||||
1. `content.go`:`ContentRepository` 接口加 `Reactions() ReactionRepository`;`PostgresContentStore` 加字段 `reactions *PostgresReactionRepository`(构造里 `NewPostgresReactionRepository(pool)`),accessor 一行;`txContent` 同样加 `Reactions()`(返回 `&PostgresReactionRepository{q: tx}`)。
|
||||
2. `memory_content.go`:`MemoryContentStore` 加字段 `favorites map[[2]string]struct{}`、`ratings map[[2]string]int`(构造函数初始化);accessor `func (m *MemoryContentStore) Reactions() ReactionRepository { return memoryReactions{m} }`;`WithTx` 的快照 clone 与回滚补这两个 map(与 works 同款)。
|
||||
3. 新 `memoryReactions` 实现同一接口(锁 `m.mu`;键 `[2]string{userID, workID}`)。`Watermark` 遍历两 map 累加。
|
||||
|
||||
- [ ] **步骤 1.6** 写 `reaction_test.go`——先失败测试(memory):
|
||||
- `TestMemoryReactionRoundtrip`:SetFavorite 幂等(二次 count 仍 1)→ DeleteFavorite→count 0;UpsertRating 3→count1 sum3→改 5→sum5→Delete→count0。
|
||||
- `TestMemoryAggregateAll`:两作品多用户 → map 正确、缺席作品无条目。
|
||||
- `TestMemoryListByUser`:favorites 顺序 = 插入序(用 `SetNow` 推进时间),ratings map 完整。
|
||||
- `TestMemoryWatermarkChanges`:加收藏/加分/分从 3→5(Watermark.RatingSum 变)各触发水位变。
|
||||
- pg-gated:`func TestPostgresReactionGated(t)`——`os.Getenv("TEST_DATABASE_URL")==""` 时 `t.Skip`;非空时复用 `content_migrate_test.go` 的建库方式跑 Roundtrip 同断言。
|
||||
运行:`docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine go test ./internal/repository/ -run Reaction` → RED(接口不存在/编译失败也算 RED)。
|
||||
- [ ] **步骤 1.7** 实现至 GREEN:同命令 + `go vet ./...` + `gofmt -l .`(空输出)。
|
||||
- [ ] **步骤 1.8** Commit:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "feat(repository): favorites/ratings tables + ReactionRepository (pg + memory + store wiring)
|
||||
|
||||
- migration 0009 two tables PK(user_id,work_id), work_id indexes, FK cascade
|
||||
- AggregateFor/All, ListByUser, Watermark for ETag invalidation
|
||||
- ContentRepository gains Reactions(); tx + memory WithTx snapshot updated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 2:server 服务 + DTO + 路由 + 装配
|
||||
|
||||
**文件:**
|
||||
- 修改:`src/internal/service/content.go`(ListPublished/GetPublishedDetail 签名 + 写侧方法 + 水位并入 ETag)
|
||||
- 修改:`src/internal/handler/games.go`(summary/detail 填聚合)
|
||||
- 修改:`src/internal/api/dto/content.go`(GameSummary 三字段)
|
||||
- 创建:`src/internal/api/dto/reaction.go`
|
||||
- 创建:`src/internal/handler/reactions.go`
|
||||
- 修改:`src/internal/api/router.go`(常量 + Deps + 注册)、`src/internal/api/ratelimit.go`(`DefaultReactionRateLimit = 30`)
|
||||
- 修改:`src/cmd/serve.go`(装配 `Reactions: handler.NewReactions(contentSvc)`)
|
||||
- 测试:`src/internal/api/reactions_test.go`、更新 `src/internal/service/content_test.go` 调用点(`grep -rn 'ListPublished(\|GetPublishedDetail(' src` 找全)
|
||||
- 修改:`docs/CHANGELOG.md`(0.11.0 双语置顶)
|
||||
|
||||
- [ ] **步骤 2.1** 先写失败测试 `reactions_test.go`(helper `newReactionEngine` 照抄 `uploads_test.go` 的 newWriteEngine 骨架,Deps 加 `Reactions: handler.NewReactions(contentSvc)`,种子作品用 `demoExternalWork()` + publish)。断言清单:
|
||||
- 匿名 PUT favorite → 401。
|
||||
- u1 PUT favorite → 200 `{favoriteCount:1, favorited:true, ratingCount:0}`;再 PUT → favoriteCount 仍 1;DELETE → 0,可再 DELETE(幂等 200)。
|
||||
- u1 PUT rating `{"score":4}` → 200 avg4 count1;u2 `5` → avg 4.5;u1 改 `2` → avg 3.5;u1 DELETE → avg 5 count1;GET detail:`ratingAvg:5,ratingCount:1,favoriteCount:1`。
|
||||
- `score` 为 0/6/字符串/缺省 → 400 `invalid_request`;不存在作品 → 404 `not_found`;下架作品(SetDelisted true)→ 404。
|
||||
- GET `/api/games`:反应发生前后 ETag 头必须不同;条目含 `favoriteCount/ratingCount/ratingAvg`,无反应作品三字段缺省(omitempty)。
|
||||
- GET `/api/me/reactions`(u2)→ `{"favorites":[],"ratings":{"alice/2048":5}}`;u1 收藏后 favorites 含 id。
|
||||
- 运行 docker go test ./internal/api → RED。
|
||||
- [ ] **步骤 2.2** service 改造(GREEN 核心):
|
||||
|
||||
```go
|
||||
// 签名变更(调用点同步更新)
|
||||
func (s *ContentService) ListPublished(ctx context.Context) ([]model.WorkWithVersion, map[string]model.WorkReaction, string, error)
|
||||
func (s *ContentService) GetPublishedDetail(ctx context.Context, id string) (model.WorkWithVersion, model.WorkReaction, string, error)
|
||||
```
|
||||
- 列表:取 `agg, _ := s.store.Reactions().AggregateAll(ctx)`、`wm, _ := Watermark(ctx)`;哈希 basis 由 `stamp|count` 扩为 `stamp|count|<wm.FavoriteCount>|<wm.RatingCount>|<wm.RatingSum>`。
|
||||
- 详情:`reaction := AggregateFor(ctx,id)`;basis 追加 `|<fav>|<cnt>|<sum>`。
|
||||
- 写侧(每个先 `GetByID`+`Visible()` 校验 → `ErrNotFound/不可见` 映射 `ErrContentNotFound`):
|
||||
|
||||
```go
|
||||
func (s *ContentService) SetFavorite(ctx context.Context, userID, workID string, on bool) (model.WorkReaction, error) // on→SetFavorite off→DeleteFavorite
|
||||
func (s *ContentService) RateWork(ctx context.Context, userID, workID string, score int) (model.WorkReaction, error) // UpsertRating
|
||||
func (s *ContentService) UnrateWork(ctx context.Context, userID, workID string) (model.WorkReaction, error) // DeleteRating
|
||||
func (s *ContentService) UserReaction(ctx context.Context, userID, workID string) (favorited bool, score int, rated bool, err error)
|
||||
func (s *ContentService) MyReactions(ctx context.Context, userID string) (model.UserReactions, error)
|
||||
```
|
||||
|
||||
- [ ] **步骤 2.3** dto:`GameSummary` 尾部加
|
||||
|
||||
```go
|
||||
RatingAvg *float64 `json:"ratingAvg,omitempty"`
|
||||
RatingCount int `json:"ratingCount,omitempty"`
|
||||
FavoriteCount int `json:"favoriteCount,omitempty"`
|
||||
```
|
||||
|
||||
`dto/reaction.go`:
|
||||
|
||||
```go
|
||||
type ReactionView struct {
|
||||
FavoriteCount int `json:"favoriteCount"`
|
||||
RatingCount int `json:"ratingCount"`
|
||||
RatingAvg *float64 `json:"ratingAvg,omitempty"`
|
||||
Favorited bool `json:"favorited"`
|
||||
Rated bool `json:"rated"`
|
||||
Score int `json:"score,omitempty"`
|
||||
}
|
||||
func NewReactionView(r model.WorkReaction, favorited bool, score int, rated bool) ReactionView // Avg 保留 1 位小数:math.Round(a*10)/10,count==0 时 RatingAvg=nil
|
||||
type MeReactionsResponse struct {
|
||||
Favorites []string `json:"favorites"`
|
||||
Ratings map[string]int `json:"ratings"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2.4** handler:`games.go` 的 `summary(item)`→`summary(item, reaction model.WorkReaction)`(无反应传零值,仅 >0 时填字段;float 指针同样只在 RatingCount>0 时给)。List/Detail 用新签名;Detail 的 `h.detail(item, reaction)` 同步。新 `handler/reactions.go`:
|
||||
|
||||
```go
|
||||
type Reactions struct{ svc *service.ContentService }
|
||||
func NewReactions(svc *service.ContentService) *Reactions
|
||||
// workID(c):c.Param("user")/("slug") 过 service.UsernamePattern/SlugPattern,不合→400 invalid_request;service.JoinWorkID 拼 id
|
||||
// SetFavorite/DeleteFavorite/SetRating/DeleteRating:CurrentUser(c).ID → svc 写侧 → svc.UserReaction 组装 ReactionView → 200 JSON
|
||||
// SetRating body:struct{ Score int `json:"score"` };越界(1-5 之外)/非法 JSON → 400 invalid_request
|
||||
// ErrContentNotFound → 404 not_found;其他 err → 500 internal(log.Printf 同 games.go 风格)
|
||||
// Me:svc.MyReactions → MeReactionsResponse(空 favorites 输出 [],不输出 null)
|
||||
```
|
||||
|
||||
- [ ] **步骤 2.5** router:常量三条 + `DefaultReactionRateLimit = 30` + 注册块 + Deps 字段 `Reactions *handler.Reactions` + cmd/serve.go 装配(无 storage 也注册——反应不依赖对象存储):
|
||||
|
||||
```go
|
||||
RouteGameFavorite = "/api/games/:user/:slug/favorite"
|
||||
RouteGameRating = "/api/games/:user/:slug/rating"
|
||||
RouteMeReactions = "/api/me/reactions"
|
||||
```
|
||||
PUT 两条挂 `RequireUser + reactionLimiter`;DELETE 两条与 GET 只挂 `RequireUser`。
|
||||
|
||||
- [ ] **步骤 2.6** 全绿验证:docker `gofmt -l . && go vet ./... && go test ./...`(content_test.go 调用点已按新签名改)。
|
||||
- [ ] **步骤 2.7** CHANGELOG 置顶 `## [0.11.0] - 2026-09-30`,双语:Added——favorites/ratings 两表 + 六端点 + 目录/详情聚合字段 + 反应水位并入 ETag(英文行紧接中文行)。
|
||||
- [ ] **步骤 2.8** Commit:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "feat(api): favorites/ratings user-space endpoints + reaction aggregates in content read side
|
||||
|
||||
- PUT/DELETE /api/games/:u/:s/favorite & /rating (RequireUser, 30/min), GET /api/me/reactions
|
||||
- GameSummary gains ratingAvg/ratingCount/favoriteCount (omitempty, schemaVersion stays 2)
|
||||
- list/detail ETag folds reaction watermark (counts+sum) — aggregates never served stale
|
||||
- changelog 0.11.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 3:web 数据契约 + 热门排序
|
||||
|
||||
**文件:**
|
||||
- 修改:`src/app/data/types.ts`(GameSummary 可选字段)、`src/app/lib/filter.ts`、`src/app/lib/filter.test.ts`、`src/app/components/ResultMeta.vue`
|
||||
|
||||
- [ ] **步骤 3.1** `cd crearte && git checkout -b feat/favorites-ratings && cd src && npm ci`
|
||||
- [ ] **步骤 3.2** 失败测试(vitest):
|
||||
- `types.test.ts`/既有断言不因可选字段破坏;
|
||||
- `filter.test.ts` 新增 describe('sort hot'):
|
||||
- 构造 g1(n=1,sum=5,fav=0)、g2(n=100,sum=430,fav=0):m=435/101≈4.307 → g1 分 (5·4.307+5)/(105)=0.2527… vs g2 (5·4.307+430)/(105)=4.152… → g2 先;
|
||||
- **压平用例**:单人 5 星(n=1,fav=0)输给 n=3 sum=12(均 4)+fav=1 的作品;
|
||||
- 收藏对数:两作品同均分同 n,fav 10 vs 1 → fav10 先;
|
||||
- 静态缺省:三字段 undefined 的作品全获同分 → 按 addedAt 倒序(退化为 new);
|
||||
- URL 往返:`stateToQuery({sort:'hot'})` 出 `?sort=hot`,`parseQuery` 认 `hot`,未知值回退 `new`。
|
||||
- [ ] **步骤 3.3** 实现:
|
||||
- `types.ts` GameSummary 加 `ratingAvg?: number; ratingCount?: number; favoriteCount?: number`。
|
||||
- `filter.ts`:`SortKey = 'new' | 'name' | 'duration' | 'hot'`;`parseFilterState` 白名单加 `'hot'`;新导出:
|
||||
|
||||
```ts
|
||||
export function hotScore(game: GameSummary, priorC: number, priorMean: number): number {
|
||||
const n = game.ratingCount ?? 0
|
||||
const sum = (game.ratingAvg ?? 0) * n
|
||||
return (priorC * priorMean + sum) / (priorC + n) + 0.5 * Math.log(1 + (game.favoriteCount ?? 0))
|
||||
}
|
||||
```
|
||||
`filterGames` 的 hot 分支:先对**过滤后集合**求 `nAll=ΣratingCount`、`sumAll=Σ(ratingAvg×ratingCount)`,`m = nAll>0 ? sumAll/nAll : 4`;`scores = new Map(...)`(按 id 缓存一次计算,sort 比较器禁止重复算分);比较 `b-a`,`|Δ|<=1e-9` 时 `addedAt 倒序 → id 正序`。
|
||||
- `ResultMeta.vue`:`<option value="new">最新收录</option>` 之后插 `<option value="hot">热门</option>`。
|
||||
- [ ] **步骤 3.4** `npm test` 全绿 + `npm run typecheck`。
|
||||
- [ ] **步骤 3.5** Commit `git add -A && git commit -m "feat(catalog): hot sort — bayesian prior mean + favorite log weight, pure fn with tie-breaks"`
|
||||
|
||||
---
|
||||
|
||||
### 任务 4:web 反应 UI + 账号页
|
||||
|
||||
**文件:**
|
||||
- 创建:`src/app/lib/reactions.ts`(+ test)、`src/app/components/GameReactions.vue`(+ test)
|
||||
- 修改:`src/app/views/GameView.vue`、`src/app/components/GameCard.vue`(+ 其 test)、`src/app/views/AccountView.vue`(+ test)
|
||||
- 修改:`docs/CHANGELOG.md`(0.16.0)
|
||||
|
||||
- [ ] **步骤 4.1** 失败测试(列出断言,实现者按此写):
|
||||
- `reactions.test.ts`:注入假 fetch——PUT favorite 带 `Authorization: Bearer <token>`、URL 拼 `VITE_API_BASE_URL` 去尾斜杠;401 → 抛 `AuthRequiredError`;base 为空时 `reactionsEnabled=false`、调用抛错不发请求。
|
||||
- `GameReactions.test.ts`(mount,mock lib):显示 `4.5 · 2 人评分 · 3 收藏`;favorited=true 时 ♥ aria-pressed;点星 4 → setRating('u','s',4);当前分 4 再点 4 → unrate;匿名(session.getToken()=null)点按钮 → router.push('/login?next=…');乐观锁:在途禁再点(busy)。
|
||||
- `GameCard.test.ts` 增:ratingCount>0 显 `★ 4.5 · 2 人`;favoriteCount>0 显 `♥ 3`;两者 0/缺省不显徽标。
|
||||
- `AccountView.test.ts` 增:fetchMine → favorites 命中 repo 列表渲染链接行、`取消收藏` 调 setFavorite(on=false);空数据显「还没有收藏或评分。」
|
||||
- [ ] **步骤 4.2** `lib/reactions.ts`:
|
||||
|
||||
```ts
|
||||
import { session } from '@/auth'
|
||||
const rawBase = (import.meta.env.VITE_API_BASE_URL ?? '').trim()
|
||||
export const reactionsEnabled = rawBase !== ''
|
||||
const base = rawBase.replace(/\/+$/, '')
|
||||
export interface ReactionView { favoriteCount: number; ratingCount: number; ratingAvg?: number; favorited: boolean; rated: boolean; score?: number }
|
||||
async function call(path: string, init: RequestInit): Promise<ReactionView | unknown> {
|
||||
if (!reactionsEnabled) throw new Error('未配置后端')
|
||||
const token = session.getToken()
|
||||
if (!token) throw new AuthRequiredError()
|
||||
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.body ? { 'Content-Type': 'application/json' } : {}) } })
|
||||
if (res.status === 401) throw new AuthRequiredError()
|
||||
if (!res.ok) throw new Error(`请求失败 ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
export const setFavorite = (user, slug, on): Promise<ReactionView> => call(`/api/games/${user}/${slug}/favorite`, { method: on ? 'PUT' : 'DELETE' })
|
||||
export const setRating = (user, slug, score) => ...PUT body JSON.stringify({score})...
|
||||
export const unrate = (user, slug) => ...DELETE...
|
||||
export async function fetchMine(): Promise<{ favorites: string[]; ratings: Record<string, number> }>
|
||||
class AuthRequiredError extends Error {} // export
|
||||
```
|
||||
|
||||
- [ ] **步骤 4.3** `GameReactions.vue`(props `game: Game`;script:`favorited/rated/score/counts` ref,`onMounted` 若有 token 调 `fetchMine()` 初始化个人态;`toggleFav()`、`pickStar(i)`(i===score→unrate 否则 setRating)、`reset()`(已评且点击当前星);每次成功后用 ReactionView 全量替换本地态;失败显示一行错误文本 `aria-live`;`busy` 在途禁用全部按钮)。模板:♥ 按钮(`data-testid="fav-btn"`,`aria-pressed`,样式沿用 border-2 border-ink bg-surface lift 族)+ 五星条五颗 `data-testid="star-<i>"`(实心=floor(avg);已评显个人分)+ 文案 `{{ ratingCount>0 ? `${ratingAvg} · ${ratingCount} 人评分` : '暂无评分' }} · {{ favoriteCount }} 收藏`。挂载:GameView `<GameReactions v-if="game" :game="game" />` 插在操作链接(开始体验/GameHost)之后、intro 分隔线之前;`authEnabled`(VITE_API_BASE_URL 非空,import 自 `@/auth`)为假时组件内部直接不渲染(noauth 零请求)。
|
||||
- [ ] **步骤 4.4** `GameCard.vue`:作者行之后、tags 行之前加徽标 `<p v-if="hasReaction" data-testid="reaction-badge">…`(computed 拼 `★ avg · n 人` 与 `♥ f`,`aria-hidden` 星形用文本即可)。
|
||||
- [ ] **步骤 4.5** `AccountView.vue`:会话 section 前插「我的收藏」——`onMounted && session.state.status==='authenticated'` 时 `fetchMine()` + `repo.listGames()` join(GameSummary.id 匹配);渲染复用 GameCard 链接行样式(标题→/games/u/s + 取消收藏小钮);评分列表紧凑行 `作品名 ★<score>`;空态文案;`reactionsEnabled` 假时整节不渲染。
|
||||
- [ ] **步骤 4.6** 全绿:`npm test && npm run typecheck && npm run build`。
|
||||
- [ ] **步骤 4.7** CHANGELOG 0.16.0 双语置顶(Added:详情收藏/评分控件、目录热门排序与徽标、账号页我的反应)。
|
||||
- [ ] **步骤 4.8** Commit `git add -A && git commit -m "feat(ux): favorite & 5-star rating UI on game page, reaction badges on cards, my-reactions section on account, catalog hot sort option, changelog 0.16.0"`
|
||||
|
||||
---
|
||||
|
||||
### 任务 5:live 冒烟(prod 栈实跑,零代码改动)
|
||||
|
||||
- [ ] **步骤 5.1** `cd crearte-deploy && docker compose --profile prod build api-prod web-prod && docker compose --profile prod up -d`,等 api-prod healthy(新代码进镜像——**先 build 再 up**;web-prod 用最新前端)。
|
||||
- [ ] **步骤 5.2** 冒烟链(bash 脚本 /tmp/p5smoke.sh,`set -euo pipefail`;B=http://localhost:8080):
|
||||
1. `ETAG0=$(curl -sD- -o /dev/null $B/api/games | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2)`
|
||||
2. 注册 u1@p5.example/u2@p5.example(curl register ×2,各 login 拿 T1/T2)。
|
||||
3. u1 PUT `$B/api/games/p1admin/p1-smoke/favorite`(Bearer T1)→ jq `.favoriteCount==1 and .favorited==true`;再 PUT → 仍 1。
|
||||
4. u1 rating `{"score":4}` → `.ratingAvg==4`;u2 `{"score":5}` → `4.5`;u1 改 `{"score":2}` → `3.5`;u1 DELETE → `.ratingAvg==5 and .ratingCount==1`。
|
||||
5. `curl -sD- -o /dev/null $B/api/games` 取 ETAG1 ≠ ETAG0;带 `If-None-Match: <ETAG1>` → 304。
|
||||
6. `curl -s $B/api/games` jq 断言 p1admin/p1-smoke 条目 `favoriteCount==1, ratingCount==1, ratingAvg==5`;`curl -s $B/api/games/p1admin/p1-smoke` 详情同样。
|
||||
7. `curl -s $B/api/me/reactions -H "Authorization: Bearer ***")` → `.favorites|index("p1admin/p1-smoke")!=null`;u2 → `.ratings["p1admin/p1-smoke"]==5`。
|
||||
8. 负路:匿名 PUT favorite→401;`{"score":7}`→400;不存在作品→404。
|
||||
9. 徽标渲染抽查:`curl -s http://localhost:8080/ | head -c 300` 200 即可(SPA,DOM 断言归 vitest;浏览器级验收 manual-deferred 如常)。
|
||||
- [ ] **步骤 5.3** 收尾:`docker compose --profile prod down`(保留卷);删 /tmp/p5*。
|
||||
- [ ] **步骤 5.4** 复跑静态验证:docker go 全套、`npm test/typecheck/build`(若任务 2/4 后无改动可引用其报告,重跑一次留快照更稳)。
|
||||
报告写 `.superpowers/sdd/p5-task-5-report.md`。
|
||||
|
||||
---
|
||||
|
||||
### 任务 6:两仓合并 + 推送
|
||||
|
||||
- [ ] **步骤 6.1** crearte-server:`git checkout master && git pull --ff-only origin master && git merge --no-ff feat/favorites-ratings -m "Merge branch 'feat/favorites-ratings' — favorites & 5-star ratings, hot sort server side (P5)" && git push origin master && git branch -d feat/favorites-ratings && git push origin --delete feat/favorites-ratings 2>/dev/null; git log --oneline -1`
|
||||
- [ ] **步骤 6.2** crearte:同款 merge message 尾缀换 `(P5)`,push、删分支。
|
||||
- [ ] **步骤 6.3** `--ff-only` 若拉来新提交且冲突:`git merge --abort` 上报 BLOCKED,禁止强推。
|
||||
|
||||
---
|
||||
|
||||
### 任务 7:wrapper 收尾(控制者直接执行)
|
||||
|
||||
ROADMAP P5 行 → `**完成**(server \`<6.1 hash>\` / crearte \`<6.2 hash>\`,2026-09-30 合并推送;范围修订 deploy 零改动,见 spec)`;索引表加 P5 两行文档;CHANGELOG 0.2.3 双语;commit+push;终态核验 `git -C crearte-server pull --ff-only origin master` 等三条 + `./clone_all.sh`。
|
||||
|
||||
---
|
||||
|
||||
## 自检记录(已内联修复)
|
||||
|
||||
1. 规格覆盖:spec §3.1→任务 1;§3.2→任务 2;§3.3→任务 3/4;§4→任务 2/3/4/5 测试 + 任务 5 live;§2 ETag→1.4 Watermark/2.2 并入。✔
|
||||
2. 占位符扫描:无 TODO/待定;hash 占位为运行时事实。✔
|
||||
3. 命名一致:`ReactionRepository/Watermark/AggregateAll/UserReactions/ReactionView`(任务 1→2);`hotScore/setFavorite/unrate/fetchMine/AuthRequiredError`(任务 3→4);JSON 名三处(2.3、4.2、5.2)逐字一致。✔
|
||||
@@ -0,0 +1,49 @@
|
||||
# P6 hosted 投稿(方案 A)· 实施计划
|
||||
|
||||
依据:`docs/specs/2026-09-30-hosted-submission-design.md`(D-A…D-F 定稿)。分支名统一 `feat/p6-hosted`,各仓各自实现者自拉自提交,**不 push**;合主由控制者 `--no-ff`。
|
||||
|
||||
## Task 1 · server 校验收紧(crearte-server)
|
||||
|
||||
基线 master(`ca24805` 起)。改动点:
|
||||
|
||||
1. `src/internal/service/content.go` `ValidateSubmission`(约 :327 起的 switch):
|
||||
- `metadata_change` 分支:取现有 work 后新增不可变校验——`RuntimeOrDefault(p) != work.Runtime` → `ValidationError{Fields: []string{"runtime: immutable on metadata_change"}}`(D-B)。
|
||||
- 通用(new_work 与 metadata_change 都过):`runtime == hosted && bundleUploadID != ""` → `ValidationError{Fields: []string{"bundle_upload_id: hosted works cannot carry a bundle"}}`(D-C)。
|
||||
- `new_version` 分支已 virtual-only,不动。
|
||||
2. 单测(同文件所在包 service):
|
||||
- hosted+bundle → 校验失败且字段名精确;
|
||||
- metadata_change 改 runtime(hosted↔external/virtual 双向至少各一)→ 失败;runtime 相同 → 通过既有路径;
|
||||
- 回归确认:hosted new_work 无 bundle、带合法 https hostedUrl 仍通过(validate.go:140 既有)。
|
||||
3. 集成测试(带 `TEST_DATABASE_URL` 的既有风格,参考 admin 面集成六场景文件):hosted 草稿→submit-review→approve→`works` 行 `hosted_url/play_origin/fallback` 读回一致;`GET /api/games/<user/slug>` 载荷含 `runtime:"hosted"` 与 `hostedUrl`。
|
||||
4. `CHANGELOG.md` 中英成对,版本 **0.13.0**(Added=校验护栏+集成覆盖;Changed 若有措辞按实际)。
|
||||
|
||||
红线:零迁移、零新依赖、不动 handler 响应形状、不动 audit/admin 面、Approve 落库路径(PayloadToWork 映射已全,见 spec §1)不改。
|
||||
|
||||
验证(容器内,goproxy.cn 姿势同 AGENTS.md):`gofmt -l` 空、`go vet ./...`、`go build ./...`、`go test -p 1 ./internal/...`;带 db 的集成腿控制者跑,实现者确保无 `TEST_DATABASE_URL` 时 skip 语义与既有一致。
|
||||
|
||||
## Task 2 · crearte 前端表单打通
|
||||
|
||||
基线 master(`900f13a` 起),源码根 `crearte/src`。改动点:
|
||||
|
||||
1. 内容层类型:提交 payload runtime 联合扩为三档,`hostedUrl`、`fallback` 字段落位(对齐 server `WorkPayload` json 名 `hostedUrl`/`fallback`/`runtime`)。
|
||||
2. `app/views/SubmitFormView.vue`:
|
||||
- `:33` 表单 runtime 加 `'hosted'`;`:408-415` radio 组加第三项「自托管内嵌」(`data-testid="runtime-hosted"`)。
|
||||
- hosted 档字段:`hostedUrl`(必填 https)、`fallback` 选择(`external|hosted|none`,默认 `external`);fallback=external 时 `url` 必填(前端校验镜像 server)。
|
||||
- 提交形状:hosted 时 payload 带 `runtime:'hosted'` + `hostedUrl` + `fallback`,**不带** bundle/version/entry/features;`kind` 仅 new_work / metadata_change(new_version radio 保持 virtual-only 逻辑)。
|
||||
- 删除 `:144-147` hosted 预填拒绝;预填回填 hostedUrl/fallback(注意 server GET 载荷字段名)。
|
||||
- hosted 档下 cover 上传照常。
|
||||
3. vitest(SubmitFormView.test.ts 等):hosted 提交形状、必填校验失败面、预填回填、new_version 禁入 hosted。
|
||||
4. e2e:`admin-flow.spec.ts` 或提交流既有夹具内加 hosted 提交流(表单→draft payload 断言);**不动** `full-loop.spec.ts` / `playwright.stack.config.ts`。
|
||||
5. `docs/CHANGELOG.md` 中英成对,版本 **0.19.0**。
|
||||
|
||||
红线:不动运行时三件套(sw/agent/bootstrap/GameHost/useGameFrame——播放端零改动是 spec 决策);不动管理面;apiRepo 无新端点。
|
||||
|
||||
验证(`crearte/src`):`npx vitest run`、`npm run typecheck`、`npx playwright test e2e/admin-flow.spec.ts`、`npm run e2e` 全 RC=0。
|
||||
|
||||
## Task 3 · deploy 核证(控制者,非实现者任务)
|
||||
|
||||
生产/dev 主站响应头无 `frame-src` 限制即记录在案;若有则模板补丁+复验。
|
||||
|
||||
## 合主与验收(控制者)
|
||||
|
||||
双路独立审查(spec→代码逐条,PASS with notes 起)→ 六腿验收(spec §5)→ `--no-ff` 合主推送 → wrapper 记账(ROADMAP P6 行 + spec/plan 索引行 + CHANGELOG 0.2.7)→ 删分支。
|
||||
@@ -0,0 +1,376 @@
|
||||
# P10 UX 第一批(日常交互包)· 实现计划
|
||||
|
||||
spec:`docs/specs/2026-10-01-p10-ux-daily-interaction-design.md`(决策 D-A…D-L 以其为准;本计划与 spec 冲突时 spec 赢)。
|
||||
仓库:**仅 crearte**(`crearte-monorepo/crearte`),分支 `feat/p10-ux-daily`。server/deploy 零改动。
|
||||
npm 根:`crearte/src`(package.json 在此)。命令一律 `cd crearte/src` 后跑。
|
||||
|
||||
## 全局约束(逐字,进每个任务简报)
|
||||
|
||||
1. 零新依赖:不得改 `package.json` dependencies/devDependencies;version 字段恒 `0.1.0` 不动。
|
||||
2. 禁触文件/行:`e2e/landing.spec.ts` 既有全部用例、`e2e/merge-repo.spec.ts`、`e2e/noauth.spec.ts`、`playwright.config.ts`、`vitest.config.ts`、`vite.config.ts`、`runtime/` 下除 `GameHost.vue` 外一切文件(D-H 唯一例外)、`scripts/`。
|
||||
3. 样式语言:新粗野主义既有 tokens(`border-2 border-ink`、`shadow-hard*`、`bg-paper/surface/ink/highlight/accent/success`、`btn-ink/btn-surface/lift`、`font-mono text-[0.6875rem]`);注释中文、标识符英文(仓内惯例)。
|
||||
4. TDD:先写/改 vitest 再实现;e2e 用例随所属任务同 commit。
|
||||
5. 每任务独立 commit(message 用 `feat(ux):`/`fix(ux):` 前缀 + 任务号);**CHANGELOG 不由任务写**——控制者在全部任务后统一补 0.23.0 条目。
|
||||
6. 验收命令(每任务收尾自跑并贴输出摘要):`npm test -- <相关测试文件>`(改动面)+ `npm run typecheck`。全量 `npm test`/`npm run build`/`npm run e2e` 由控制者在波次末跑。
|
||||
7. 分支纪律:commit 前 `git branch --show-current` 必须是 `feat/p10-ux-daily`;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
|
||||
8. vitest 基线 551 全绿不回退;e2e 主套件基线 72+1skip 不回退。
|
||||
9. `data-testid` 命名精确使用 spec 给定值:`toast-host`、`toast`、`copy-link`、`tag-link`、`recent-strip`。
|
||||
10. 文案精确逐字(spec §3):守卫 `表单尚未保存,确定离开吗?未保存的修改将丢失。`;复制成功 `链接已复制`;复制失败 `复制失败,请手动复制地址栏链接`;toast 关闭按钮 `aria-label="关闭提示"`;条带标题 `继续游玩 · RECENTLY PLAYED`;toast 参数 success/info 3000ms、error 5000ms、上限 3 条。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: toast 基座(useToast + ToastHost + App.vue)
|
||||
|
||||
**新文件 `app/composables/useToast.ts`**(模块级单例 store,spec §3.1 + D-C/D-D):
|
||||
|
||||
```ts
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export type ToastKind = 'success' | 'error' | 'info'
|
||||
export interface ToastItem { id: number; kind: ToastKind; text: string }
|
||||
export const MAX_VISIBLE = 3
|
||||
export const DURATIONS: Record<ToastKind, number> = { success: 3000, error: 5000, info: 3000 }
|
||||
|
||||
const toasts: Ref<ToastItem[]> = ref([])
|
||||
let nextId = 1
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function dismiss(id: number): void {
|
||||
const t = timers.get(id)
|
||||
if (t) { clearTimeout(t); timers.delete(id) }
|
||||
toasts.value = toasts.value.filter((x) => x.id !== id)
|
||||
}
|
||||
|
||||
function push(kind: ToastKind, text: string, duration: number = DURATIONS[kind]): number {
|
||||
const id = nextId++
|
||||
toasts.value = [...toasts.value.slice(-(MAX_VISIBLE - 1)), { id, kind, text }]
|
||||
timers.set(id, setTimeout(() => dismiss(id), duration))
|
||||
return id
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts,
|
||||
push,
|
||||
success: (text: string) => push('success', text),
|
||||
error: (text: string) => push('error', text),
|
||||
info: (text: string) => push('info', text),
|
||||
dismiss
|
||||
}
|
||||
}
|
||||
// 测试隔离用:清空全部 toast 与计时器(仅测试导入)
|
||||
export function __resetToasts(): void {
|
||||
for (const t of timers.values()) clearTimeout(t)
|
||||
timers.clear()
|
||||
toasts.value = []
|
||||
nextId = 1
|
||||
}
|
||||
```
|
||||
|
||||
**新文件 `app/components/ToastHost.vue`**:不 Teleport(D-C 测试友好);容器 `data-testid="toast-host"` `role="status"` `aria-live="polite"` `class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"`;`<TransitionGroup name="toast">` 包每条;每条 `data-testid="toast"` + kind 配色(success `bg-success text-paper`、error `bg-accent text-paper`、info `bg-highlight text-ink`)+ `pointer-events-auto border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard flex items-start justify-between gap-2`;关闭按钮 `aria-label="关闭提示"`(PhX from `@phosphor-icons/vue`,size 12 weight bold,`aria-hidden="true"`)。`main.css` 追加 `.toast-enter-active/.toast-leave-active`(transform+opacity 150ms)与 `.toast-enter-from/.toast-leave-to`,放进既有 `@media (prefers-reduced-motion: reduce)` 归零分支可及的范围(reduce 时 transition 时长归零即可,写法对齐 main.css:95 既有分支)。store 空时容器仍渲染(`role="status"` 常驻,屏幕阅读器语义稳定)但零子条。
|
||||
|
||||
**改 `app/App.vue`**:`<AppFooter />` 之后挂 `<ToastHost />`(import 补上)。
|
||||
|
||||
**测试 新 `app/composables/useToast.test.ts`**:`beforeEach(__resetToasts)` + `vi.useFakeTimers`;钉:push 返回递增 id;success/info 3000ms、error 5000ms 后自动消失(`vi.advanceTimersByTime`);连 push 4 条只剩最末 3 条(最旧被移除);`dismiss` 提前清掉且计时器不泄漏(dismiss 后 advance 不抛);自定义 duration 生效。
|
||||
**测试 新 `app/components/ToastHost.test.ts`**:mount 后 store 空→容器在、零 `[data-testid=toast]`;push 三种 kind→各配色类命中;点关闭按钮→store 少一条;容器 `role="status"` `aria-live="polite"` 钉死。
|
||||
|
||||
**验收**:`npm test -- app/composables/useToast.test.ts app/components/ToastHost.test.ts` 全绿 + `npm run typecheck` 零错。
|
||||
**commit**:`feat(ux): P10-T1 toast store + ToastHost (D-C/D-D)`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 脏表单守卫(SubmitFormView)
|
||||
|
||||
**改 `app/views/SubmitFormView.vue`**(spec §3.2 + D-A/D-B)。锚点:`suppressAadWatch` 声明在 :114 附近;`loadExisting` :186-227(try 前 `suppressAadWatch = true`,finally `= false`);`save()` 成功路径 `await router.push('/submit')` :295 附近;既有 `onBeforeUnmount` :64-67。
|
||||
|
||||
实现要点:
|
||||
1. `import { onBeforeRouteLeave } from 'vue-router'`(该文件已从 vue-router import `useRoute, useRouter`,同行追加)。
|
||||
2. 常量 `const GUARD_COPY = '表单尚未保存,确定离开吗?未保存的修改将丢失。'`(放 KIND_LABELS 附近)。
|
||||
3. `const dirty = ref(false)`、`let suppressDirty = false`、`let suppressLeave = false`。
|
||||
4. 深度 watch:`watch([form, kind, () => bundle.value?.upload_id, () => cover.value?.upload_id], () => { if (!suppressDirty) dirty.value = true }, { deep: true })`。放既有 AAD watch 之后。注意:`form` 是 `reactive` 对象,watch 数组元素里直接放 `form`(reactive 对象自身可深度观察);`kind` 是 ref。
|
||||
5. `loadExisting`:进入 try 前 `suppressDirty = true`(与 `suppressAadWatch = true` 同点);finally 里 `suppressAadWatch = false` 后 `await nextTick()`(既有)再 `suppressDirty = false; dirty.value = false`。⚠️ 顺序:既有代码 finally 前已有 `await nextTick()` 在 try 内——实现时把 `suppressDirty = false; dirty.value = false` 放 finally 中 `suppressAadWatch = false` 之后、且需再 `await nextTick()` 一次让深度 watch 队列冲完再释放(防止回填触发的 watch 回调在释放后才跑而误置 dirty;深度 watch 默认 flush pre,nextTick 足以)。
|
||||
6. `prefill()` **不**抑制(D-B:其触发前提本身是用户修改)——不动 prefill。
|
||||
7. `save()` 成功分支:`await router.push('/submit')` 前插 `dirty.value = false; suppressLeave = true`。失败路径(catch)不动 dirty。
|
||||
8. `onBeforeRouteLeave(() => { if (suppressLeave || !dirty.value) return true; return window.confirm(GUARD_COPY) })`。
|
||||
9. beforeunload:`watch(dirty, (d) => { if (d) window.addEventListener('beforeunload', onBeforeUnload); else window.removeEventListener('beforeunload', onBeforeUnload) })` + `function onBeforeUnload(e: BeforeUnloadEvent): void { e.preventDefault(); e.returnValue = '' }` + 既有 `onBeforeUnmount` 里补 `window.removeEventListener('beforeunload', onBeforeUnload)`。
|
||||
|
||||
**测试 扩 `app/views/SubmitFormView.test.ts`**(先读既有文件沿用其 mock/router 惯例;该文件已有完整 contentClient mock 体系):
|
||||
- 用户改 `form.name`(`w.vm` 或直接 input setValue)→ 触发 router 导航被 `window.confirm` mock(返 false)拒 → 停留原路由;confirm 改返 true → 放行。
|
||||
- `loadExisting` 回填后(既有编辑模式用例路径)→ confirm spy 未被调(导航直通)。
|
||||
- save-draft 成功流(既有用例已有该流)→ push('/submit') 前 confirm 未被调(suppressLeave)。
|
||||
- dirty=true 时 `window.dispatchEvent(new Event('beforeunload', { cancelable: true }))` → `defaultPrevented` true;干净时 false。
|
||||
- 上传成功(既有 bundle 上传用例路径)→ dirty。
|
||||
|
||||
**e2e 扩 `e2e/submit-flow.spec.ts` 追加一条**(文件尾,既有用例一字不动):
|
||||
|
||||
```ts
|
||||
test('脏表单守卫:SPA 离开需确认,save 成功直通', async ({ page }) => {
|
||||
await seedSession(page)
|
||||
const state = { subs: [] as Sub[] }
|
||||
installSubmissionApi(page, state)
|
||||
await page.goto('http://localhost:4173/submit/new')
|
||||
await page.getByLabel('展示名称').fill('My Game')
|
||||
// 无监听 → Playwright 自动 dismiss → 导航被拒
|
||||
await page.getByRole('link', { name: '作品', exact: true }).click()
|
||||
await expect(page).toHaveURL('http://localhost:4173/submit/new')
|
||||
// accept → 放行
|
||||
page.once('dialog', (d) => void d.accept())
|
||||
await page.getByRole('link', { name: '作品', exact: true }).click()
|
||||
await expect(page).toHaveURL('http://localhost:4173/games')
|
||||
// 回到表单填好 → save-draft 成功 push 不被守卫拦(suppressLeave 回归)
|
||||
await page.goto('http://localhost:4173/submit/new')
|
||||
await fillNewWorkForm(page) // 注意:fillNewWorkForm 自带 goto /submit/new,直接调用即可
|
||||
await page.locator('[data-testid=save-draft]').click()
|
||||
await expect(page).toHaveURL('http://localhost:4173/submit')
|
||||
})
|
||||
```
|
||||
|
||||
(实施时按文件内既有 helper 命名微调;`fillNewWorkForm` 已含 goto,上面第三段直接调它、不再重复 goto。)
|
||||
|
||||
**验收**:`npm test -- app/views/SubmitFormView.test.ts` + typecheck。
|
||||
**commit**:`feat(ux): P10-T2 dirty-form leave guard on SubmitFormView (D-A/D-B)`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 标签可点(GameView + GameCard)
|
||||
|
||||
**改 `app/views/GameView.vue` :88-95**(spec §3.3 + D-G):tags `<span>` → `<RouterLink>`:
|
||||
|
||||
```html
|
||||
<RouterLink
|
||||
v-for="tag in game.tags"
|
||||
:key="tag"
|
||||
data-testid="tag-link"
|
||||
:to="{ path: '/games', query: { tag } }"
|
||||
class="border-[1.5px] border-ink bg-surface px-2 py-0.5 font-mono text-[0.6875rem] hover:bg-highlight"
|
||||
>{{ tag }}</RouterLink>
|
||||
```
|
||||
|
||||
**改 `app/components/GameCard.vue` :70-75**:`visibleTags` 的 `<span>` → `<RouterLink>`,同款样式 + `relative z-10`(卡面拉伸链接 `after:absolute inset-0` 在 :49,作者链接 :54-58 已用 `relative z-10` 同法)+ `data-testid="tag-link"`;`:to="{ path: '/games', query: { tag } }"`;`hiddenTags` 的 `+N` 角标(:76-79)**维持 span 不动**(D-G)。
|
||||
|
||||
**测试 扩 `app/views/GameView.test.ts`**:带 tags 的作品(既有夹具可扩)→ `[data-testid=tag-link]` 数量=tags 数、首个 href `/games?tag=<encodeURIComponent(t)>`(断言用 `router.resolve` 产物或字面量,中文 tag 注意编码)。
|
||||
**测试 扩 `app/components/GameCard.test.ts`**:有 tags → `a[data-testid=tag-link]` 带 `relative z-10` 类且 href 以 `/games?tag=` 开头;tags>2 → 第三个是 `+N` span 非链接;tags=[] → 零 tag-link。
|
||||
|
||||
**e2e 新文件 `e2e/ux.spec.ts`**(本任务创建,后续任务追加)第一条:
|
||||
|
||||
```ts
|
||||
test('作品页标签点击进入目录过滤', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/games/fixture/2048')
|
||||
const tag = page.locator('[data-testid=tag-link]').first()
|
||||
const text = (await tag.textContent())!.trim()
|
||||
await tag.click()
|
||||
await expect(page).toHaveURL(`http://localhost:4173/games?tag=${encodeURIComponent(text)}`)
|
||||
await expect(page.locator('[data-testid=game-card]').first()).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
(前置核实:`fixtures/generated/games/fixture__2048.json` 的 tags 非空;若为空换 `case-files` 等任一有 tag 的夹具,并把 spec 测试计划里的 slug 一并换——实施时以真实夹具为准,不改 spec 结构。)
|
||||
|
||||
**验收**:`npm test -- app/views/GameView.test.ts app/components/GameCard.test.ts` + typecheck。
|
||||
**commit**:`feat(ux): P10-T3 clickable tags on game page and cards (D-G)`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 复制链接 + toast 接线(GameView)
|
||||
|
||||
**改 `app/views/GameView.vue`**(spec §3.4 + D-E/D-F)。依赖 Task 1 的 `useToast`。
|
||||
|
||||
1. import:`PhLinkSimple`(`@phosphor-icons/vue` 同行追加)、`useToast`。
|
||||
2. setup:`const toast = useToast()`;
|
||||
|
||||
```ts
|
||||
async function copyLink(): Promise<void> {
|
||||
const url = location.origin + router.resolve({ name: 'game', params: { user: props.user, slug: props.slug } }).href
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('链接已复制')
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制地址栏链接')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 模板:meta 段(`预计时长` 那个 `<p>`,:80-90 区域)之后、description `<p>` 之前插:
|
||||
|
||||
```html
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
class="btn-surface lift inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold"
|
||||
@click="copyLink"
|
||||
><PhLinkSimple :size="14" weight="bold" aria-hidden="true" />复制链接</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
(happy-dom/旧环境 `navigator.clipboard` 可能 undefined——`try` 需覆盖属性访问:实现为 `await navigator.clipboard?.writeText(url)` 且 undefined 时手动 throw,或整段放 try 内直接访问 `navigator.clipboard.writeText`(undefined 属性访问抛 TypeError 也被 catch)。取后者:整段 try/catch 已兜住。)
|
||||
|
||||
**测试 扩 `app/views/GameView.test.ts`**:
|
||||
- mock `navigator.clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }`(happy-dom 下 `Object.defineProperty(navigator, 'clipboard', …)`;beforeEach 还原)+ `__resetToasts()`。
|
||||
- 点 `[data-testid=copy-link]` → writeText 收到 `http://localhost/games/fixture/minimal`(happy-dom location.origin 以实际环境为准,断言用 `location.origin + '/games/fixture/minimal'` 拼接式)→ toast store 有 kind=success text=`链接已复制`。
|
||||
- writeText reject → toast kind=error text=`复制失败,请手动复制地址栏链接`。
|
||||
- notFound 分支 → `[data-testid=copy-link]` 零渲染。
|
||||
|
||||
**e2e 追加 `e2e/ux.spec.ts`**:
|
||||
|
||||
```ts
|
||||
test('复制链接:clipboard 写入规范 URL + toast 确认', async ({ context, page }) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.goto('http://localhost:4173/games/fixture/2048')
|
||||
await page.locator('[data-testid=copy-link]').click()
|
||||
await expect(page.locator('[data-testid=toast]').first()).toContainText('链接已复制')
|
||||
const text = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(text).toBe('http://localhost:4173/games/fixture/2048')
|
||||
})
|
||||
```
|
||||
|
||||
**验收**:`npm test -- app/views/GameView.test.ts` + typecheck。
|
||||
**commit**:`feat(ux): P10-T4 copy-link share button wired to toast (D-E/D-F)`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 页头下拉外点/Esc 关闭(AppHeader)
|
||||
|
||||
**改 `app/components/AppHeader.vue`**(spec §3.5 + D-K)。锚点:`detailsRef` :9-10、route watch :11-13、`<details>` :79、`<summary>` :80。
|
||||
|
||||
1. import 追加 `onBeforeUnmount, onMounted`(vue 同行)。
|
||||
2. setup 追加:
|
||||
|
||||
```ts
|
||||
const menuOpen = ref(false)
|
||||
const summaryRef = ref<HTMLElement | null>(null)
|
||||
function onDocPointerDown(e: PointerEvent): void {
|
||||
if (menuOpen.value && detailsRef.value && !detailsRef.value.contains(e.target as Node)) {
|
||||
detailsRef.value.open = false
|
||||
}
|
||||
}
|
||||
function onDocKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && menuOpen.value && detailsRef.value) {
|
||||
detailsRef.value.open = false
|
||||
summaryRef.value?.focus()
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocPointerDown)
|
||||
document.addEventListener('keydown', onDocKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocPointerDown)
|
||||
document.removeEventListener('keydown', onDocKeydown)
|
||||
})
|
||||
```
|
||||
|
||||
3. 模板:`<details … @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">`;`<summary ref="summaryRef" …>`(其余属性类名一字不动)。既有 route.fullPath watch 保留(可在其中同步 `menuOpen.value = false`,或依赖 toggle 事件——实现取后者:程序性 `open = false` 也触发 toggle,无需重复)。
|
||||
|
||||
**测试 新 `app/components/AppHeader.test.ts`**(mock 惯例:`vi.mock('@/data')` 供 repo.listGames/listDocs、`vi.mock('@/auth')` 供 authEnabled=true + session.state.user 已登录;memory router 带 home/catalog 两路由):
|
||||
- 设 `details.open = true`(dispatchEvent toggle 或 `element.open=true` 后 `await nextTick()` 让 @toggle 同步 menuOpen)→ document 上 `dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`(target 为 document.body 外点)→ open 变 false。
|
||||
- 菜单内 pointerdown(target=details 子节点,bubbles)→ 不关。
|
||||
- open 后 `document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))` → 关且 `document.activeElement` 为 summary。
|
||||
- 卸载后监听移除(unmount → 再 dispatch 不抛)。
|
||||
|
||||
**e2e 追加 `e2e/ux.spec.ts`**:
|
||||
|
||||
```ts
|
||||
test('页头菜单:Esc 与外点关闭', async ({ page }) => {
|
||||
await seedSession(page) // 从 './helpers' import
|
||||
await page.goto('http://localhost:4173/')
|
||||
const summary = page.getByRole('button', { name: /tester/ }) // details summary 的可访问名;实施时按真实渲染调整选择器(可用 page.locator('details summary'))
|
||||
await summary.click()
|
||||
await expect(page.locator('details[open]')).toHaveCount(1)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('details[open]')).toHaveCount(0)
|
||||
await summary.click()
|
||||
await expect(page.locator('details[open]')).toHaveCount(1)
|
||||
await page.locator('main').click({ position: { x: 5, y: 5 } })
|
||||
await expect(page.locator('details[open]')).toHaveCount(0)
|
||||
})
|
||||
```
|
||||
|
||||
**验收**:`npm test -- app/components/AppHeader.test.ts` + typecheck。
|
||||
**commit**:`feat(ux): P10-T5 header dropdown closes on outside-click and Esc (D-K)`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 最近玩过(recent.ts + GameHost + LandingView)
|
||||
|
||||
**新文件 `app/lib/recent.ts`**(spec §3.6 + D-H/D-I):
|
||||
|
||||
```ts
|
||||
const KEY = 'crearte.recent.v1'
|
||||
const MAX_RECENT = 12
|
||||
|
||||
export interface RecentEntry { id: string; at: string }
|
||||
|
||||
export function listRecent(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return []
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter((e): e is RecentEntry => Boolean(e) && typeof (e as RecentEntry).id === 'string')
|
||||
.map((e) => e.id)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function recordPlay(id: string): void {
|
||||
if (!id) return
|
||||
try {
|
||||
const entries: RecentEntry[] = [{ id, at: new Date().toISOString() },
|
||||
...listRecentRaw().filter((e) => e.id !== id)].slice(0, MAX_RECENT)
|
||||
localStorage.setItem(KEY, JSON.stringify(entries))
|
||||
} catch {
|
||||
// 隐私模式/配额满:静默(spec §4)
|
||||
}
|
||||
}
|
||||
// listRecentRaw:同 listRecent 但返回 RecentEntry[](内部复用解析逻辑,导出与否以测试便利定)
|
||||
```
|
||||
|
||||
**改 `runtime/host/GameHost.vue`**(D-H,全局约束 2 的唯一 runtime 例外):
|
||||
- import `recordPlay`:`import { recordPlay } from '../../app/lib/recent'`(与 GameView import GameHost 的跨层相对路径惯例对称)。
|
||||
- setup 追加 `watch(() => frame.state.value.phase, (p) => { if (p === 'ready') recordPlay(props.game.id) })`(`watch` 已在 import 列表)。
|
||||
|
||||
**改 `app/views/LandingView.vue`**(spec §3.6 + D-J):
|
||||
- import `listRecent`;`const RECENT_LIMIT = 6`(FEATURED_LIMIT 旁)。
|
||||
- setup:`const recentIds = listRecent()`(setup 时读一次即可——路由重挂载天然刷新,D-J)。
|
||||
- `const recent = computed(() => { const byId = new Map((games.value ?? []).map((g) => [g.id, g])); return recentIds.map((id) => byId.get(id)).filter((g): g is GameSummary => Boolean(g)).slice(0, RECENT_LIMIT) })`。
|
||||
- 模板:hero `</section>` 与精选 `<section class="mt-8">` 之间插(**一字不差**按 spec §3.6 的条带模板,`data-testid="recent-strip"`、h2 样式逐字、`:heading-level="3"`)。
|
||||
|
||||
**测试 新 `app/lib/recent.test.ts`**:beforeEach `localStorage.clear()`;钉:空→[];recordPlay 前插(两次不同 id → 新者先);重复 id 去重冒泡到最前;13 次 → 截 12;损坏 JSON(手 setItem 'not-json')→ [];非数组 JSON → [];mock `localStorage.setItem` throw → recordPlay 不抛、listRecent 仍可用。
|
||||
**测试 扩 `runtime/host/GameHost.test.ts`**(先读既有 mock 体系;`vi.mock('../../app/lib/recent')` 或按该文件相对别名):phase → 'ready' 触发 `recordPlay(game.id)`;'booting'/'error' 不触发;restart 后再次 ready 再次触发。若既有 GameHost.test 的 frame mock 不便驱动 phase,允许对该测试文件做最小扩展(不删既有用例)。
|
||||
**测试 新/扩 `app/views/LandingView.test.ts`**(如不存在则新建,mock 惯例照抄 AuthorView.test.ts 的 vi.hoisted+vi.mock):games 夹具两款 + localStorage 预置 `[{"id":"b","at":"…"},{"id":"a","at":"…"},{"id":"gone","at":"…"}]` → `[data-testid=recent-strip]` 渲染 2 卡(gone 丢弃)且顺序 b,a;无记录 → 零渲染;记录全不可解析 → 零渲染。
|
||||
|
||||
**e2e 追加 `e2e/ux.spec.ts`**:
|
||||
|
||||
```ts
|
||||
test('最近玩过:站内运行就绪后落地页出现继续游玩条带', async ({ page }) => {
|
||||
await openGame(page, '2048') // helpers 既有;data-ready=1 即 phase ready
|
||||
await openGame(page, 'a-dark-room')
|
||||
await page.goto('http://localhost:4173/')
|
||||
const strip = page.locator('[data-testid=recent-strip]')
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(strip.getByText('继续游玩 · RECENTLY PLAYED')).toBeVisible()
|
||||
const hrefs = await strip.locator('a[href^="/games/"]').evaluateAll((els) => els.map((el) => el.getAttribute('href')))
|
||||
expect(hrefs[0]).toBe('/games/fixture/a-dark-room') // 最近玩的冒泡最前
|
||||
expect(hrefs).toContain('/games/fixture/2048')
|
||||
// 精选区计数不受影响(条带在 hero 与精选之间,选择器分开)
|
||||
await expect(page.locator('main > section:nth-of-type(3) a[href^="/games/"]')).toHaveCount(6)
|
||||
})
|
||||
```
|
||||
|
||||
(⚠️ 最后一行的选择器以真实 DOM 结构为准——条带渲染后 section 序号会变;实施时改用精选区自己的稳定定位,如给精选 section 的 h2 文本定位后 xpath following,或直接用 `page.locator('section', { hasText: '精选 · SELECTED' })` 圈定后计数。目的只有一个:证明 landing.spec 精选 6 卡钉桩在有条带时仍成立。)
|
||||
|
||||
**验收**:`npm test -- app/lib/recent.test.ts runtime/host/GameHost.test.ts app/views/LandingView.test.ts` + typecheck。
|
||||
**commit**:`feat(ux): P10-T6 recently-played strip (recent.ts + GameHost record + landing, D-H/D-I/D-J)`
|
||||
|
||||
---
|
||||
|
||||
### 控制者收尾(不派子代理)
|
||||
|
||||
1. 全量验收五腿:`npm test`(≥551+新增全绿)、`npm run typecheck`、`npm run build`、`npm run e2e`(72+1skip+新增全绿)、`npm run e2e:noauth`(4)。
|
||||
2. CHANGELOG 0.23.0 双语条目(Added:toast/标签/复制链接/最近玩过条带;Fixed:脏表单守卫口径归 Added「交互护栏」、下拉关闭归 Fixed)+ commit。
|
||||
3. 全分支审查包(`scripts/review-package $(git merge-base master HEAD) HEAD`)→ 派最终审查者(强模型)。
|
||||
4. 审查 notes 逐条裁定后 `--no-ff` 合 master、删分支、push 三仓(crearte + wrapper 记账:ROADMAP P10 行 + 索引登记 + wrapper CHANGELOG 0.3.2)。
|
||||
5. wrapper 记账 commit 引审查判定形("PASS with notes — note pinned by <sha>"惯例)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# P8 长尾打包(第一批)· 实施计划
|
||||
|
||||
依据:`docs/specs/2026-10-01-p8-long-tail-design.md`(D-1…D-5)。分支名 `feat/p8-long-tail`,各仓实现者自拉自提交**不 push**;合主由控制者 `--no-ff`。
|
||||
|
||||
## Task 1 · server(crearte-server,→0.14.0)
|
||||
|
||||
基线 master(`3e11446` 起)。改动点:
|
||||
|
||||
1. **②-1 `games.Detail` 400 细分**(`src/internal/handler/games.go:44-50`):把单一 `"invalid game id"` 拆为可区分消息——user 不合 `UsernamePattern` → `user: invalid format`;slug 不合 `SlugPattern` → `slug: invalid format`(两者都错时按 user 优先,或返回逗号并列,实现者择一但须有测试钉住)。沿用 `WriteError(400,"invalid_request",…)`,不改状态码。核既有 `games_test.go`/`router_test.go` 是否断言该文案,有则同步。
|
||||
2. **②-2 admin 工作路由校验**:`handler/admin.go` 四条 handler(Unpublish/Republish/SetWorkFeatures/RevokeVersion)入口补 `UsernamePattern/SlugPattern` 前置校验(与 `games.Detail:47` 同款),非法即 `WriteError(400,"invalid_request","invalid work id")` 并 return。**不改** `adminWorkID`(`:115`,纯拼接 helper)。若抽出小 helper(如 `validWorkParams(c) bool`)四 handler 共用,允许。
|
||||
3. **②-3 approve 同名竞态测试**:新增测试(`internal/service/content_test.go` 或新文件,视该包既有并发测试风格)——并发对同一 `work_id` 的两个 new_work 提交走 approve,断言:仅一条成功;其余返回 `ErrWorkIDTaken`/`ErrSubmissionConflict`;库中该 work 恰一行。**只加测试不改行为**。若既有测试用真库则归集成腿、若用 memory store 则单测即可(实现者按仓内既有模式定,注明)。
|
||||
4. **⑤ CHANGELOG 头部**(`docs/CHANGELOG.md` 顶部):去「模板/建议统一记录」腔,改为项目口径(保留一句格式出处),不动历史条目。
|
||||
5. `docs/CHANGELOG.md` 追加 `## [0.14.0] - 2026-10-01`(Fixed:②-1/②-2;Tests:②-3;Docs:⑤),中英成对。
|
||||
|
||||
红线:zero 新依赖/迁移;approve 行为不变;路由常量不动。
|
||||
|
||||
验证:容器内 `gofmt -l` 空、`go vet ./...`、`go build ./...`、`go test -p 1 ./internal/...` RC=0(容器姿势同 AGENTS.md,goproxy.cn)。集成腿由控制者跑。
|
||||
|
||||
## Task 2 · crearte(→0.20.0)
|
||||
|
||||
基线 master(`9bd9372` 起),源码根 `crearte/src`。改动点:
|
||||
|
||||
1. **① 权限开关全量**(`app/content/features.ts`):`EditableFeatures` 扩至七键 `eval, inlineScript, inlineStyle, wasm, coop, fullscreen, gamepad`;`EMPTY_FEATURES` 七键 false;`FEATURE_ITEMS` 补五条(**每条中文 label + hint 写清风险与适用场景**——这是本任务主要工作量);`collectFeatures` 输出七键齐全;`featuresToForm` 回读七键缺省 false;`hasAnyFeature` 自然覆盖。
|
||||
2. 三处 UI 自动同步(均读 `FEATURE_ITEMS`,核实无硬编码遗漏):投稿表单勾选区、`AdminView.vue` 作品展开行、`AdminSubmissionView.vue` 只读展示。若某处有超出 `FEATURE_ITEMS` 的定制文案/布局,补齐。
|
||||
3. 测试:`features` 单测(collectFeatures 七键、featuresToForm 回读、hasAnyFeature);投稿表单/审核详情相关 vitest 补断言(新开关出现、勾选进载荷)。
|
||||
4. **⑤ CHANGELOG 头部**(`docs/CHANGELOG.md` 顶部):「本**模板**…建议统一记录」改为项目口径。
|
||||
5. `docs/CHANGELOG.md` 追加 `## [0.20.0] - 2026-10-01`,中英成对。
|
||||
|
||||
红线:不动运行时三件套 / GameHost / useGameFrame(flag 消费端已存在);不为单处 UI 另开硬编码;不动管理面路由。
|
||||
|
||||
验证(`crearte/src`):`npx vitest run`、`npm run typecheck`、`npx playwright test e2e/admin-flow.spec.ts`、`npm run e2e` 全 RC=0。
|
||||
|
||||
## 合主与验收(控制者)
|
||||
|
||||
双路独立审查(依据 spec §1-§3 全量对照)→ 六腿验收 → `--no-ff` 合主推送 → wrapper 记账 0.2.8 → 删分支。
|
||||
|
||||
**注(教训落地)**:审查员任务书须显式要求「对照 spec 全文,任务书与 spec 冲突时以 spec 为准,并报告 spec 中未落地项」——P6 D-D 的漏项即由此产生。
|
||||
@@ -0,0 +1,81 @@
|
||||
# P8 第二批(③账号注销 + ④目录导出 + P7 文案尾)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** crearte-server `feat/p8b2-account-catalog`(0.15.0:迁移 0011、AccountService 注销内核、`DELETE /api/auth/account`、CLI `user delete`、CLI `catalog export`)与 crearte `feat/p8b2-account-ui`(0.21.0:AccountView 危险区注销、auth client `deleteAccount`+错误码、AdminUsersView 错误中文化)。spec:`docs/specs/2026-10-01-p8b2-deletion-catalog-design.md`(§1–§5 全量为审查依据,不只任务书)。
|
||||
|
||||
**Architecture:** 注销=单事务墓碑内核(users 改写 + works 下架 + submissions 收口),对象删除 tx 外 best-effort;导出=复用 handler.Games 映射的薄 Render 方法,cmd 落盘;前端只加第四段与错误映射。P3 重落是**另一批**(§历史重放,见 spec §4),本计划不含。
|
||||
|
||||
**Tech Stack:** Go 1.24(gin/pgx/stdlib;**禁新增依赖**)、Vue3+vitest+playwright、Postgres 17(gated 集成)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 工作目录:`crearte-monorepo/crearte-server/src`、`crearte-monorepo/crearte/src`。两内层仓从 `master`(server `921443a` / crearte `70ba10c`)切上述分支;**master 禁直接提交**;合回 `--no-ff`。
|
||||
- Go 命令一律容器化(AGENTS.md):`docker run --rm -v /root/.openclaw/workspace/coder/crearte-monorepo/crearte-server:/work -v crearte_gomod:/go/pkg/mod -v crearte_gocache:/gocache -e GOCACHE=/gocache -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn -w /work/src golang:1.24-alpine sh -c "<cmd>"`。冷跑用 background+poll,勿掐。
|
||||
- 集成测试 gated:`TEST_DATABASE_URL` 缺失即 `t.Skip`(既有惯例);gated 测试 TRUNCATE 共享表 → 全量集成必须 `-p 1 -count=1`。
|
||||
- 迁移只增不改:`0011_user_deletion.sql`;`userColumns` 追加 `deleted_at`,pg+memory 双实现同步(memory parity 注释照旧风格)。
|
||||
- CHANGELOG:server `docs/CHANGELOG.md` 顶版 0.14.0 之上追加 `## [0.15.0] - 2026-10-01`;crearte 0.20.0 之上追加 `## [0.21.0] - 2026-10-01`;英文行+中文行连续,条目间空行;沿用两仓既有文风(去模板腔)。
|
||||
- 完成定义:Task S/F 各自容器四连+新测试绿;控制者验收(Task V)六腿 + 冒烟全中;双审 PASS 后合主。
|
||||
|
||||
---
|
||||
|
||||
### Task S: server — 注销链 + catalog export
|
||||
|
||||
**Files:**
|
||||
- Create: `src/internal/repository/migrations/0011_user_deletion.sql`
|
||||
- Modify: `src/internal/model/user.go`(+`DeletedAt *time.Time`)、`src/internal/repository/user.go`(userColumns/scanUser/`MarkDeleted`/`ListBySubmitter` 无关——users repo 加 `MarkDeleted(ctx,id,Email,Username,PasswordHash)`;`List`/`CountAdmins` 过滤 deleted;memory 实现同步)
|
||||
- Modify: `src/internal/repository/work.go` + `memory_content.go`(+`DelistByOwnerTx`/计数;接口名 `DelistPublishedByOwner(ctx, ownerID string) (int, error)`)
|
||||
- Modify: `src/internal/repository/submission.go`(+`ListAllBySubmitter`? 否——`ListBySubmitter` 已够;`MarkReviewed` 已够;无新法——仅确认签名可用)
|
||||
- Create: `src/internal/service/account.go` + `account_test.go`
|
||||
- Create: `src/internal/handler/account.go` + `account_test.go`
|
||||
- Create: `src/internal/api/account_postgres_test.go`(gated 集成:全链断言)
|
||||
- Modify: `src/internal/api/router.go`(`RouteDeleteAccount`+`Deps.Account`)、`src/cmd/serve.go`(接线 AccountService/handler)、`src/cmd/user.go`(`user delete`)、Create `src/cmd/catalog.go`(`catalog export`)、`src/cmd/main.go` 或 root 注册
|
||||
- Modify: `src/internal/handler/games.go`(导出 `RenderIndex(ctx) (any, error)` / `RenderDetail(ctx, id string) (any, error)` 薄封装)
|
||||
- Modify: `docs/CHANGELOG.md`(0.15.0)
|
||||
|
||||
**Interfaces(冻结,FE/冒烟依赖):**
|
||||
- `DELETE /api/auth/account` body `{"password": string}` → 204 | 400 invalid_request | 401 invalid_credentials | 409 last_admin | 410 account_deleted;`Cache-Control: no-store`。
|
||||
- 错误常量:`service.ErrAccountDeleted`;`Authenticate`/`Login` 对墓碑分别 `ErrUnauthorized`/`ErrInvalidCredentials`。
|
||||
- `AccountService{users repository.UserStore; content repository.ContentStore; objects storage.ObjectStorage; now func() time.Time}`,方法 `DeleteSelf(ctx, userID, password) (deletionReport, error)`、`DeleteByEmail(ctx, email) (deletionReport, error)`、`deletionReport{Delisted, DraftsRemoved, PendingClosed int, UploadKeys []string}`。
|
||||
- CLI:`user delete <email>` 打印 `%s: deleted (works delisted=%d, drafts removed=%d, pending closed=%d)`;`catalog export --out DIR [--pretty]` 打印 `exported %d game(s) to %s`。
|
||||
- 导出产物:`DIR/index.json` = `{"schemaVersion":2,"generatedAt":RFC3339,"games":[GameSummary...]}`;`DIR/games/<user>__<slug>.json` = GameDetail(与 `/api/games/:user/:slug` 逐字段同形状——测试用同一 handler 输出对拍)。
|
||||
|
||||
**Steps:**
|
||||
- [ ] S1 写失败测试:`account_test.go`(memory):DeleteSelf 成功→墓碑字段断言(email=`deleted-<hexid>@deleted.invalid`、username=`gone-<id前8>`、display_name=已注销用户、role=user、token_version+1、deleted_at!=nil);错密码 ErrInvalidCredentials;二次注销 ErrAccountDeleted;名下 published work 隐身(`ListPublished` 不再含);draft 删、pending→rejected;唯一 admin ErrLastAdmin;非唯一 admin 可销。favorites/ratings 行保留(memory 聚合仍计入)。
|
||||
- [ ] S2 实现 migration 0011 + model/repo/memory 扩展(MarkDeleted 一条 UPDATE 完成全部改写;List/CountAdmins 过滤 `deleted_at IS NULL`)。
|
||||
- [ ] S3 实现 service/account.go 内核(顺序照 spec §3.2:护栏→内容级联 tx→墓碑 tx 内最后→tx 外删对象 best-effort);S1 测试转绿。
|
||||
- [ ] S4 Authenticate/Login 墓碑纵深(各加 DeletedAt 判断)+ handler/account.go + router Deps.Account + serve.go 接线 + `account` 组 api handler_test(httptest:204/401/400/409/410)。
|
||||
- [ ] S5 `user delete` CLI + gated 集成 `account_postgres_test.go`:真库全链(注册→import 作品→投稿 draft+pending→注销→目录隐身/详情 404/me 401/原邮箱重注册成功/audit actor 保留可读)。
|
||||
- [ ] S6 handler.Games Render 方法 + `catalog export` cmd + 单测(memory store 造 2 作品——1 virtual 1 external——导出后读回断言 schemaVersion/文件名/详情 bundle 对象/external 无 bundle;`--out` 建目录幂等)。
|
||||
- [ ] S7 容器四连 `gofmt -l .; go vet ./...; go build ./...; go test ./...` 全绿;集成腿带 `TEST_DATABASE_URL`(p8 同款一次性 postgres:17-alpine 容器,密码放 `/tmp/p8b2dbpw`)`-p 1 -count=1` 零 FAIL 零 skip(skip 守卫 grep 必须 0)。
|
||||
- [ ] S8 CHANGELOG 0.15.0(Added/Changed 双语)+ commit `feat(account): self-service account deletion (migration 0011, DELETE /api/auth/account, user delete CLI)` 与 `feat(catalog): catalog export CLI producing static-fallback JSON contract` 两提交(分支 feat/p8b2-account-catalog)。
|
||||
|
||||
### Task F: crearte — 注销 UI + 错误中文化
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/auth/client.ts`(`deleteAccount(password)` DELETE 204)、`src/app/auth/types.ts`(+`'account_deleted'` 码)、`src/app/auth/errors.ts`(`AUTH_ERROR_MESSAGES.account_deleted='该账号已注销'`)
|
||||
- Modify: `src/app/views/AccountView.vue`(第四段危险区)+ `AccountView.test.ts`(新用例:按钮禁用双闸/成功 invalidate+跳首页/401 出文案)
|
||||
- Modify: `src/app/views/AdminUsersView.vue`(`actionError` 按 code 映射表 validation/not_found/internal 中文化,last_admin/未知透传)+ `AdminUsersView.test.ts`(+3 断言)
|
||||
- Modify: `src/e2e/auth.spec.ts`(注销流,按该文件既有 mock 风格)
|
||||
- Modify: `docs/CHANGELOG.md`(0.21.0)
|
||||
|
||||
**Steps:**
|
||||
- [ ] F1 client/errors 单测先行(`client.test.ts`:204 空体 resolve、401 body code 解析、410 映射 account_deleted)→ 实现转绿。
|
||||
- [ ] F2 AccountView 危险区:密码 input + 勾选 checkbox(`data-testid="delete-confirm-check"`、`delete-confirm-password`、`delete-account-button`、`delete-account-error`);双闸才 enabled;成功链 `authClient.deleteAccount → session.invalidate() → router.push('/')`;失败 `toUserMessage(e)`。测试四例照 Interfaces 断言。
|
||||
- [ ] F3 AdminUsersView 文案映射(小纯函数 `adminActionMessage(e)` 便于单测直打)+ 用例。
|
||||
- [ ] F4 `npx vitest run` + `npx vue-tsc --noEmit` 全绿(基线 499 只增不减);`npx playwright test`(主套件 70+1skip 基线)与 `--config playwright.noauth.config.ts`(4 passed)不回归;commit 两提交(auth client+errors / UI+admin 文案)分支 feat/p8b2-account-ui。
|
||||
|
||||
### Task V: 控制者验收(两 Task 均 report DONE 后)
|
||||
|
||||
- [ ] V1 server 终态容器四连 + 空库 `-p 1 -count=1` 集成复跑(skip 守卫 0)。
|
||||
- [ ] V2 curl 冒烟(分支真产物静态编译 + 一次性 p8b2 postgres 容器,p8 同款流程):register→login→作品 import CLI(复用测试夹具 1 virtual)→投稿 draft+pending→**错密码 401→对密码 204→旧 token me 401→原邮箱重注册 201→唯一 admin 409**;`catalog export --out /tmp/p8b2cat` 产物三断言(index schemaVersion2、fixture__x.json 存在含 bundle、jq 形状对)。跑完即焚容器/密码。
|
||||
- [ ] V3 FE 四腿复跑:vitest/typecheck/admin-flow+主套件/noauth。
|
||||
- [ ] V4 双路独立审查(对照 spec §1–§5 + Interfaces 冻结,两路各自 fresh 会话,一路 server 一路 FE+跨端契约)。
|
||||
- [ ] V5 审查 note 分诊(可修则修后复跑受影响腿;产品级偏离回报 owner)→ 合主 `--no-ff` 双仓 + 删分支 + push。
|
||||
- [ ] V6 wrapper:ROADMAP P8 行改「第二批完成·③④交付(P3 另批进行中)」+ CHANGELOG 0.2.10 双语 + 索引登记本 spec/plan(已执行)→ commit push。
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- spec D-A…D-I → S1/S3(墓碑+级联+admin 护栏)、S4(API 映射)、S6/D-H(export 契约)、F2/F3(UI)、V2(边界矩阵)。D-G 的 username 正则满足性在 S1 断言(`gone-` + 8hex ≤39 且首尾合法——hex 小写含数字,`-` 不出头)。
|
||||
- 不确定点回填:`MarkReviewed` reviewer_id=自己(墓碑前)合法——FK 在行保留方案下恒成立;`catalog export` 走 `handler.Games` 需 `ContentService` 已在 serve.go 构造——cmd 里自建同栈。
|
||||
- 占位符扫描:无 TBD。
|
||||
@@ -0,0 +1,86 @@
|
||||
# P9 UX 第一批(浏览器反馈包)· 实现计划
|
||||
|
||||
Spec:`docs/specs/2026-10-01-p9-ux-browser-feedback-design.md`(§1–§6 全量为审查依据,不只本计划)。
|
||||
仓库:仅 crearte(分支 `feat/p9-ux-titles`,BASE `7d5f338`)。零 server/deploy 改动。版本 0.22.0。
|
||||
|
||||
## 环境与惯例(全部已验证)
|
||||
|
||||
- 测试/run 命令在 `/root/.openclaw/workspace/coder/crearte-monorepo/crearte/src`(package.json 在此)。vitest:happy-dom 文件首行 `// @vitest-environment happy-dom`。mock 惯例见 `app/views/AuthorView.test.ts`(`vi.hoisted` + `vi.mock('@/data')` + 复用真实 `resolveUserSlug`)。
|
||||
- e2e 主套件 baseURL `localhost:4173`(preview),helpers:`src/e2e/helpers.ts`(`openGame(page, slug)` 已存在)。跑 e2e 前先 `npm run build`(preview 吃 dist)。
|
||||
- 路由表在 `app/router/index.ts`:`routes: RouteRecordRaw[]`(export 供测试)、`beforeEach(resolveNavigation)`、`scrollBehavior`。文件顶部 import 区加 afterEach 接线。
|
||||
- 网格页(保持 cards 骨架):CatalogView.vue、LandingView.vue、AuthorView.vue。lines 转换页(六文件、八个实例,AdminView 有 3 处):GameView.vue:37、DocsView.vue:29、AdminView.vue:148/172/219、AdminUsersView.vue:113、AdminAuditView.vue:58、SubmitListView.vue:93。
|
||||
|
||||
## 任务 U1 — `app/lib/pageTitle.ts`(纯 builder + setter)
|
||||
|
||||
```ts
|
||||
export const SITE = 'crearte 创艺'
|
||||
export const DEFAULT_DESCRIPTION =
|
||||
'crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。'
|
||||
|
||||
export function clip(s: string, max: number): string // Array.from 码点截断,超出加 '…'
|
||||
export function joinTitle(...parts: Array<string | '' | null | undefined>): string
|
||||
// 过滤空段后 ' · ' 连接;joinTitle() === SITE
|
||||
export function gameTitle(name: string): string // joinTitle(name, SITE 去前缀逻辑见下)
|
||||
```
|
||||
|
||||
**命名格式硬约束(spec D-A/D-B)**:所有页面标题 = `段1 · 段2 · crearte 创艺`;home = 恰 `crearte 创艺`。实现建议:`sectionTitleOf(name: unknown): string` 返回中文段名(19 个 name 全表见 spec §3.2;未名中→`''`),`setPageTitle(joinTitle(section))` 自动把 SITE 收尾。builder 清单:
|
||||
|
||||
- `gameTitle(name)` → `${name} · ${SITE}`
|
||||
- `gameNotFoundTitle()` → `未找到的作品 · ${SITE}`
|
||||
- `catalogTitle(q)` → q 非空:`搜索「${clip(q, 40)}」 · 作品 · ${SITE}`;空:`作品 · ${SITE}`
|
||||
- `docsTitle(docTitle?)` → 有:`${docTitle} · 文档 · ${SITE}`;无:`文档 · ${SITE}`
|
||||
- `authorTitle(display)` → `${display} · 创作者 · ${SITE}`
|
||||
- `setPageTitle(t)`:`document.title = t`
|
||||
- `setPageDescription(d)`:`document.querySelector('meta[name="description"]')` 缺则 create+append head;`content = clip(d || DEFAULT_DESCRIPTION, 120)`
|
||||
|
||||
新 `app/lib/pageTitle.test.ts`(happy-dom):joinTitle 空段/全空、clip 中英混+emoji 码点、每 builder 一钉、setter 写 title、meta 自动建+回落 DEFAULT、截断 120。
|
||||
|
||||
## 任务 U2 — router afterEach
|
||||
|
||||
`app/router/index.ts`:`import { joinTitle, setPageTitle, sectionTitleOf } from '@/lib/pageTitle'`;在 `beforeEach` 之后挂 `router.afterEach((to) => setPageTitle(joinTitle(sectionTitleOf(to.name))))`。
|
||||
扩 `app/router/index.test.ts`(现文件用 `createMemoryHistory` + `makeRouter()`):push `/`→`document.title==='crearte 创艺'`;`/games`→`作品 · crearte 创艺`;`/account`→`我的账号 · crearte 创艺`;`/no-such-page`→`页面不存在 · crearte 创艺`。**注意**测试 router 是 makeRouter 重建,afterEach 挂在导出的 `router` 单例上——测试若用 `createRouter({routes})` 新实例则钩子不在。**解法**:把 afterEach 注册抽成导出函数 `attachTitleHook(r: Router)` 在 index.ts 底部对 `router` 调用,测试对 makeRouter 实例同样调用后再断言。
|
||||
|
||||
## 任务 U3 — 四数据页精化
|
||||
|
||||
- **GameView.vue** script 加:`watch(game, (g) => { if (notFound.value) { setPageTitle(gameNotFoundTitle()); setPageDescription('') } else if (g) { setPageTitle(gameTitle(g.name)); setPageDescription(g.description ?? '') } })`。loading/error(非 notFound)期不动(保持基线)。
|
||||
- **CatalogView.vue**:`watch(() => state.value.q, (q) => setPageTitle(catalogTitle(q)), { immediate: true })`。
|
||||
- **DocsView.vue**:`watch(doc, (d) => setPageTitle(docsTitle(d?.title)))`。
|
||||
- **AuthorView.vue**:`watch(games, (list) => { const first = (list ?? [])[0]; setPageTitle(authorTitle(first ? authorDisplayName(first) : props.user)) })`(import `authorDisplayName` from `@/lib/labels`)。
|
||||
|
||||
测试:
|
||||
- 扩 `GameView.test.ts`:现有 `minimalGame` 挂载 flush 后断言 `document.title` 为 `最小作品 · crearte 创艺`;给带 description 的 fixture 断言 meta content;notFound 腿(mock getGame reject NotFoundError)断言 `未找到的作品`。**每个 it 前重置 `document.title=''` 与 meta**(beforeEach 清)。
|
||||
- 新建 `CatalogView.test.ts`、`DocsView.test.ts`:mock 惯例照 AuthorView.test.ts;Catalog mount+push `/games?q=2048`(makeRouter 需该路由)断言标题含 `搜索「2048」`;Docs mock listDocs/getDoc 断言 `文档` 基线与 doc.title 精化。
|
||||
- 扩 `AuthorView.test.ts`:列表 [{author:{name:'笔锋'}}]→`笔锋 · 创作者 · crearte 创艺`;空列表→`@fixture · 创作者 · …`(props.user 兜底)。
|
||||
|
||||
## 任务 U4 — StatePanel variant + 六页 lines
|
||||
|
||||
`StatePanel.vue`:props 加 `variant?: 'cards' | 'lines'`(默认 cards)。cards 骨架 `v-for="n in 6"`(原 3);lines 分支:三个 `border-2 border-ink bg-surface shadow-hard p-4` 块内 `h-4 w-2/3` + `h-3 w-full` + `h-3 w-1/2`(bg-[#EFE9DA]、animate-skeleton),无 aspect-video。error/slot 段零改动。
|
||||
六页八个实例全部加 `variant="lines"`(行号见头部清单;AdminView 三处都要)。
|
||||
新 `StatePanel.test.ts`:默认 6 个 aspect-video 节点;lines 时 0 aspect-video、3 面板;error 腿 `role="alert"` 原样。
|
||||
|
||||
## 任务 U5 — index.html
|
||||
|
||||
head `theme-color` 行后加:`<meta name="description" content="DEFAULT_DESCRIPTION 同文(手抄一致,测试钉)" />`。
|
||||
|
||||
## 任务 U6 — e2e
|
||||
|
||||
`src/e2e/landing.spec.ts` **首条测试一字不动**(兼容回归钉)。新增一条:
|
||||
|
||||
```ts
|
||||
test('路由级标题:作品页与目录搜索态', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/games/fixture/2048')
|
||||
await expect(page).toHaveTitle(/^2048 · crearte 创艺$/)
|
||||
await page.goto('http://localhost:4173/games?q=2048')
|
||||
await expect(page).toHaveTitle(/搜索「2048」 · 作品 · crearte 创艺$/)
|
||||
})
|
||||
```
|
||||
|
||||
game 页 fixture「2048」名称核对:`public/data/index.json`(e2e 用静态数据)里该条目 name 若不为 `2048`,以实际 name 为准改正则(实施时第一步先 `grep '"name"' src/public/data/games/fixture__2048.json | head -1` 取证)。
|
||||
|
||||
## 任务 U7 — CHANGELOG + 提交
|
||||
|
||||
`docs/CHANGELOG.md` 顶插 `## [0.22.0] - 2026-10-01`(Added 双语:路由级 document.title 两段式、作品页 meta description、StatePanel variant 骨架对齐;注明 P9 UX 第一批)。commit 拆两枚:`feat(ux): router-level document titles and game-page description` 与 `fix(ux): align loading skeletons with actual grids (StatePanel variants)`。分支 `feat/p9-ux-titles`,**不 merge master、不 push**(控制者验收后合)。
|
||||
|
||||
## 验收腿(控制者跑)
|
||||
|
||||
vitest 全绿(基线 512+新增 ~20)、`npx vue-tsc --noEmit`、`npm run build`、主 e2e(71 存量含 landing 首条不动 + 新增 1)、noauth 4。
|
||||
@@ -0,0 +1,115 @@
|
||||
# P9-B UX 第二批(可访问与键盘效率)· 实现计划
|
||||
|
||||
spec:`docs/specs/2026-10-01-p9b-a11y-keyboard-design.md`(决策 D-A…D-K 以其为准;本计划与 spec 冲突时 spec 赢)。
|
||||
仓库:**仅 crearte**(`crearte-monorepo/crearte`),分支 `feat/p9b-a11y-keyboard`。server/deploy 零改动。
|
||||
npm 根:`crearte/src`(package.json 在此)。命令一律 `cd crearte/src` 后跑。
|
||||
|
||||
## 全局约束(逐字,进每个任务简报)
|
||||
|
||||
1. 零新依赖:不得改 `package.json` dependencies/devDependencies;`version` 字段恒 `0.1.0` 不动。
|
||||
2. 禁触文件:`e2e/landing.spec.ts`、`e2e/noauth.spec.ts`、`e2e/author-page.noauth.spec.ts`、`e2e/merge-repo.spec.ts`、`e2e/ux.spec.ts`、`e2e/submit-flow.spec.ts`、`e2e/admin-flow.spec.ts`、`e2e/helpers.ts`、`playwright.config.ts`、`playwright.noauth.config.ts`、`vitest.config.ts`、`vite.config.ts`、`vite.config.test.ts`、`scripts/`、`runtime/`(本批完全不动,含 `GameHost.vue`)。
|
||||
3. 样式语言:新粗野主义既有 tokens(`border-2 border-ink`、`shadow-hard*`、`bg-paper/surface/ink/highlight/accent/accent-ink/success`、`btn-ink/btn-surface/lift`、`font-mono text-[0.6875rem]`、`sr-only`)。注释中文、标识符英文(仓内惯例)。`app/lib/no-gradient.test.ts` 守卫(禁渐变/禁圆角工具类/禁非零 border-radius)必须继续绿——本批不得引入 `rounded*`、`gradient`、非零 radius。
|
||||
4. TDD:先写/改 vitest 跑红,再实现跑绿;每个任务在自己报告里贴 RED/GREEN 的命令与输出摘要。
|
||||
5. 每任务独立 commit(前缀 `feat(a11y):` 或 `fix(a11y):` + 任务号);**CHANGELOG 不由任务写**——控制者在波次末统一补 0.24.0。
|
||||
6. 验收命令(每任务收尾自跑并贴输出摘要):`npm test -- <本任务改动面的测试文件>` + `npm run typecheck`。全量 `npm test` / `npm run build` / `npm run e2e` / `npm run e2e:noauth` 由控制者在波次末跑。
|
||||
7. 分支纪律:commit 前 `git branch --show-current` 必须是 `feat/p9b-a11y-keyboard`;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
|
||||
8. 基线不回退:vitest **601** 全绿、主 e2e **77+1skip**、noauth **4**。既有测试断言只在 spec/本计划明确要求时才可改(本批有两处:`ToastHost.test.ts` 的 `bg-accent`→`bg-accent-ink` 与容器 role 断言反转)。
|
||||
9. 精确使用 spec 给定值:skip-link 文案 `跳到主内容`、`href="#main"`、`main` 的 `id="main" tabindex="-1"`;操作列表头 sr-only 文本 `操作`;星标 `aria-label` 模板 `评 ${i} 星`、分组 `aria-label` 模板(未评 `评分`,已评 `评分:${score} 星`);`--color-success: #1f7a4d`;error toast 类 `bg-accent-ink text-paper`;toast 文本节点 `<p role="status" aria-live="polite" class="min-w-0 flex-1">`。
|
||||
10. 本批**不做**移动端汉堡菜单(spec D-K:实测 360px/320px 视口下 `documentElement.scrollWidth` 恰等于视口宽,无溢出);**不做** `<td>`→`<th scope="row">` 行头重构(spec D-F);**不做**暗色模式(C 包另行立项)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 色彩对比达标 + toast 实时区重构(D-A / D-B / D-C / D-I)
|
||||
|
||||
改动文件:`app/styles/main.css`、`app/components/ToastHost.vue`、新 `app/lib/contrast.test.ts`、扩 `app/components/ToastHost.test.ts`。
|
||||
|
||||
**TDD 顺序**:
|
||||
|
||||
1. 先写 `app/lib/contrast.test.ts`(RED:`success` 当前 `#2fa46a`,`paper on success` 只有 3.16,断言 ≥4.5 必红)。逐字实现 spec §3.1 的 `channel`/`luminance`/`contrast` 三个函数(放测试文件内即可,不必新建 lib 模块——它是守卫而非生产代码)。令牌解析:从 `app/styles/main.css` 读文本,正则 `/--color-([\w-]+):\s*(#[0-9a-fA-F]{6})/g` 建 map;先断言 map 至少含 `paper/surface/ink/ink-soft/ink-faint/accent/accent-ink/highlight/success` 九个键(解析失败要红得明确,不要静默跳过配对断言)。
|
||||
2. 断言 ≥4.5 的配对(`fg on bg`,逐字用 spec §3.1 清单):`paper on success`、`paper on accent-ink`、`ink on highlight`、`ink on accent`、`ink-soft on paper`、`ink-faint on paper`、`ink-soft on surface`、`ink-faint on surface`、`paper on ink`、`accent-ink on paper`。断言 ≥3 的:`accent on paper`(焦点环,WCAG 1.4.11 非文本)。断言时给出实测值以便失败可读(例如 `expect(contrast(paper, success), 'paper on success').toBeGreaterThanOrEqual(4.5)`)。
|
||||
3. 改 `--color-success: #1f7a4d` → 跑 GREEN(预期 `paper on success` 4.76)。
|
||||
4. 改 `ToastHost.test.ts`:① 容器断言反转为**不含** `role`/`aria-live`(`expect(host.attributes('role')).toBeUndefined()`、`aria-live` 同);② 新断言每条 toast 内 `p[role=status][aria-live=polite]` 存在且 `text()` 等于消息;③ 新断言关闭按钮是实时区**兄弟**:`button.element.parentElement === p.element.parentElement` 且 `p.element.contains(button.element) === false`;④ kind 配色断言里 `error` 的 `bg-accent` 改 `bg-accent-ink`(`success`/`info` 两行不动);⑤ 既有「点关闭 → store 少一条」用例保留。先跑 RED。
|
||||
5. 按 spec §3.5 逐字重写 `ToastHost.vue` 模板 + 按 spec §3.1 改 `KIND_CLASS` 的 `error` 行 → GREEN。`<script setup>` 里除 `KIND_CLASS` 外一字不动(`PhX` import、`useToast` 解构都保留)。
|
||||
|
||||
**陷阱**:容器去掉 `role="status"` 后,`data-testid="toast-host"` 与全部定位类(`pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2`)必须原样保留——`ux.spec.ts`(禁触)与既有单测靠 `data-testid` 定位。`useToast.ts` 一字不动。
|
||||
|
||||
commit:`feat(a11y): P9B-T1 WCAG contrast tokens + toast live-region restructure (D-A/D-B/D-C/D-I)`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: skip-link + SPA 导航后焦点管理(D-D / D-E)
|
||||
|
||||
改动文件:`app/App.vue`、`app/router/index.ts`、扩 `app/router/index.test.ts`。
|
||||
|
||||
**TDD 顺序**:
|
||||
|
||||
1. 先扩 `app/router/index.test.ts`,新增 describe「SPA 导航后焦点(D-E)」,RED:
|
||||
- `beforeEach` 里往 `document.body` 注入 `<main id="main" tabindex="-1"></main>`(happy-dom 需要元素在文档里 `focus()` 才生效——P10 T5 已验证过这条),`afterEach` 移除它并 `document.activeElement?.blur?.()`,避免污染同文件既有标题用例。
|
||||
- 用例①:`makeRouter()` → `push('/')` → `push('/games')` → 断言 `document.activeElement?.id === 'main'`。
|
||||
- 用例②(同路径改 query 不抢焦点):`push('/games')` 后把焦点挪到别的元素(注入 `<button id="probe">` 并 focus),再 `push('/games?tag=数字')` → 断言 `document.activeElement?.id === 'probe'`(**不是** main)。
|
||||
- 用例③(`#main` 缺失时静默):移除注入的 main → `push('/docs')` 不抛错(`await expect(router.push('/docs')).resolves.not.toThrow()` 或包 try 后断言 `document.title` 仍被设置,证明 afterEach 其余职责照常执行)。
|
||||
2. 按 spec §3.2 逐字加 `focusMain()` 导出 + 改 `attachTitleHook` 回调为 `(to, from)` → GREEN。既有 `attachTitleHook` 的 title/description 两行与 `r.afterEach` 结构不动,只在末尾加 `if (to.path !== from.path) focusMain()`。
|
||||
3. 按 spec §3.2 逐字改 `App.vue` 模板(skip-link 作根 div 首子元素、`<main id="main" tabindex="-1">`)。`<script setup>` 一字不动。
|
||||
|
||||
**陷阱**:`router/index.ts` 的 `routes`/`scrollBehavior`/`beforeEach`/`attachTitleHook(router)` 末行全部不动;`pageTitle.test.ts` 与 `router/index.test.ts` 既有用例(标题基线、description 复位、game 复合 id)必须继续绿。skip-link 用 Tailwind `sr-only focus:not-sr-only` 组合,**不要**在 `main.css` 新增 `.skip-link` 规则(spec D-D)。
|
||||
|
||||
commit:`feat(a11y): P9B-T2 skip-link + focus main on SPA path navigation (D-D/D-E)`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 管理端表格列头语义 + 守卫(D-F / D-G)
|
||||
|
||||
改动文件:`app/views/AdminView.vue`、`app/views/AdminUsersView.vue`、`app/views/AdminAuditView.vue`、新 `app/lib/tableScope.test.ts`。
|
||||
|
||||
**TDD 顺序**:
|
||||
|
||||
1. 先写 `app/lib/tableScope.test.ts`(RED:当前全仓 19 个 `<th>` 零 scope)。仿 `app/lib/no-gradient.test.ts` 的结构逐字复用其 `walk`/`candidates` 思路:递归扫 `app/**/*.vue`(跳过 `*.test.ts`),对每个文件把内容按 `<th` 出现处切分,取从 `<th` 起到其后第一个 `>` 止的片段作为「起始标签」,断言其中含 `scope=`(这样多行书写的 `<th\n class=…\n>` 也能正确判定)。违规收集为 `相对路径:行号: 行内容` 并 `expect(violations).toEqual([])`。
|
||||
2. 给三个视图的每个 `<th>` 加 `scope="col"`(共 19 个),类名/文本/顺序一字不动。两处空表头(`AdminView.vue` 队列表 `<th class="px-3 py-2" />`、`AdminUsersView.vue` `<th class="px-3 py-2" />`)改为 spec §3.3 的 `<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>`。→ GREEN。
|
||||
|
||||
**陷阱**:`AdminView.vue` 有 3 张表(队列/已通过/作品管理),`AdminUsersView.vue` 1 张、`AdminAuditView.vue` 1 张,全部要覆盖;不得改 `<td>`、`<thead>`、`<tbody>`、`data-testid`、任何列文本。`AdminUsersView.test.ts` / `AdminAuditView.test.ts` 既有用例(`listAdminUsers` 调用形状、`user-row-*`、`audit-status-*`)必须继续绿。`AdminView.vue` 无测试文件,不必新建(守卫测试已覆盖其表格语义)。
|
||||
|
||||
commit:`feat(a11y): P9B-T3 table column-header scope on admin views + guard test (D-F/D-G)`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 五星评分可访问名(D-H)
|
||||
|
||||
改动文件:`app/components/GameReactions.vue`、扩 `app/components/GameReactions.test.ts`。
|
||||
|
||||
**TDD 顺序**:
|
||||
|
||||
1. 先扩 `GameReactions.test.ts`,RED:
|
||||
- star-1..5 各自 `attributes('aria-label')` 为 `评 1 星`…`评 5 星`。
|
||||
- 分组容器(星标外层 `span`)`role="group"`;`rated: false` 的 VIEW 下 `aria-label === '评分'`;构造 `fetchMine` 返回 `ratings: { '<user>/<slug>': 4 }`(沿用该文件既有的 mock 范式,见其 `h.fetchMine.mockResolvedValue` 用例)使 `rated === true`,断言 `aria-label === '评分:4 星'`。
|
||||
- 每颗星内层字形 span 有 `aria-hidden="true"`。
|
||||
- **既有用例全部保留且必须继续绿**:`.text()` 为 `★`/`☆` 的断言(`aria-label` 不改文本内容,故兼容)、`4.5 · 2 人评分 · 3 收藏`、`fav-btn` 的 `aria-pressed`、点 ♥ 调 `setFavorite`、点星调 `setRating`/`unrate`。
|
||||
2. 按 spec §3.4 逐字改 `GameReactions.vue` 的星标分组 → GREEN。`data-testid`、`:disabled="busy"`、类名、`@click="pickStar(i)"`、`litStars` 逻辑、撤评语义(点当前分撤评)全部不动。
|
||||
|
||||
**陷阱**:`role="group"` 加在既有的 `<span class="inline-flex items-center">` 上,不要新增包裹层(会改排版)。`rated`/`score` 是该组件既有 ref(`GameReactions.vue:15-16`),直接用,不要新造状态。
|
||||
|
||||
commit:`feat(a11y): P9B-T4 accessible names for the five-star rating control (D-H)`
|
||||
|
||||
---
|
||||
|
||||
### Task 5(控制者本人执行,不派发): e2e a11y.spec.ts(D-J)
|
||||
|
||||
新增 `src/e2e/a11y.spec.ts` 三条用例(spec §3.6 逐字),既有 e2e 文件与 `helpers.ts` 一字不动。
|
||||
|
||||
**已核实的运行期前提**(避免重蹈 P10 T6 的夹具错误):
|
||||
|
||||
- e2e 构建(`npm run build:e2e`)注入 `VITE_API_BASE_URL=http://localhost:4173` → `authEnabled` 与 `reactionsEnabled` 均为 true,故 `seedSession` 可用、`GameReactions` 会渲染。
|
||||
- `/admin/users` 的表格**需接口有数据才渲染 `<tbody>` 行**,但 `<thead>` 的 `columnheader` 在 `StatePanel` 插槽内、加载完成后即存在;`e2e/admin-flow.spec.ts` 的 `installAdminApi` **未导出**,故本用例自带最小 `page.route` mock:`${API}/api/admin/users**` 返回 `{ users: [<一个 AdminUser>], total: 1 }`,`AdminUser` 形状逐字为 `{ id, email, username, display_name, role, created_at }`(`app/data/types.ts:102-109`)。`toHaveCount(6)` 依赖 6 个 `<th>`(用户名/邮箱/显示名/角色/注册时间/操作)。
|
||||
- 星标用例走 `fixture/2048`:其源夹具 `runtime` 为 `external`,但本用例只断言 `star-3` 的可访问名,**不调 `openGame`**(不等 iframe ready),故 external 无碍;`GameReactions` 由 `v-if="game"` 门控,作品页加载完成即渲染。
|
||||
- skip-link 用例:`page.keyboard.press('Tab')` 的首站必须是 skip-link——它是根 div 首子元素,页头在其后。`Enter` 后原生锚点跳转把焦点交给 `#main`(`tabindex="-1"`)。
|
||||
|
||||
波次末由控制者跑五腿:`npm test` / `npm run typecheck` / `npm run build` / `npm run e2e` / `npm run e2e:noauth`。
|
||||
|
||||
commit:`test(e2e): P9B-T5 a11y spec — skip-link keyboard path, admin column headers, star accessible names (D-J)`
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序与并行性
|
||||
|
||||
T1/T2/T3/T4 改动文件两两不相交(T1: main.css+ToastHost+contrast.test;T2: App.vue+router;T3: 三 admin 视图+tableScope.test;T4: GameReactions)。但四者共用同一工作树与同一分支索引,并行 commit 会争 `index.lock` 并交叉 `git add`,故**串行派发**,每任务完成并过任务级审查后再派下一个。T5 由控制者在四者之后自己写并跑五腿。
|
||||
|
||||
每任务审查重点(任务级审查者的核查项):禁触清单零触碰、`no-gradient` 守卫仍绿、既有测试断言除授权两处外未被改动、spec 逐字代码与文案的偏差、以及各任务的 D 项是否真达成(不是「看起来改了」)。
|
||||
@@ -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,81 @@
|
||||
# P4 作者主页(`/users/:user`)设计 / Author Page Design
|
||||
|
||||
- **状态**:设计已批准(2026-09-29),待 spec 审阅后转实现计划。
|
||||
- **路线图**:`docs/ROADMAP.md` P4(第二波)。
|
||||
- **范围定性**:纯作品聚合页,极简列表;**零后端改动、零数据迁移**。
|
||||
|
||||
## 1. 背景与意图
|
||||
|
||||
crearte 的作品链接已按用户命名空间组织(`/games/<user>/<slug>`),但站内没有任何按作者聚合的入口:读者读完一个作品想看同作者的其他作品,只能回目录页手动搜索。P4 补上这块:**给每位创作者一个可分享的主页 `/users/:user`,列出其全部已上架作品**。
|
||||
|
||||
成功标准:从作品详情页点作者名能到作者页,页面只显示该作者的作品,按最新上架排序;未知用户名不报错。
|
||||
|
||||
约束(与维护者确认):
|
||||
|
||||
- 范围只到"纯作品聚合页":不做作者简介/头像等档案字段(不碰 `users` 表、不碰上传链路)。
|
||||
- 列表极简:无筛选侧栏、无查询参数(YAGNI,日后需要再加)。
|
||||
|
||||
## 2. 方案选型
|
||||
|
||||
| 方案 | 内容 | 取舍 |
|
||||
|---|---|---|
|
||||
| **A. 纯前端过滤(已选)** | `AuthorView` 复用 `repo.listGames()`,按 `resolveUserSlug(game).user` 过滤 | 零后端改动;与目录页同样拉全量列表(现状即如此,ETag/304);分页化另立子项目时再迁服务端过滤 |
|
||||
| B. 后端 `GET /api/games?author=` | 服务端过滤 + ETag 按参数派生 | 为分页铺路,但当下 YAGNI;动 handler/service/repository 三层 |
|
||||
| C. 新端点 `GET /api/users/:user` | 聚合用户信息 + 作品 | 引入用户名存在性探测面,超出纯聚合范围 |
|
||||
|
||||
**对路线图的偏离**:`docs/ROADMAP.md` P4 原文写"API 列表按作者过滤"。设计阶段发现列表 DTO(`GameSummary`)已含 `user?`/`slug?` 与 `resolveUserSlug()`,前端过滤即可满足需求,因此 P4 **不修改 crearte-server**(顺带不占用 Postgres 迁移号段)。路线图措辞随本 spec 落地同步修订。
|
||||
|
||||
## 3. 设计详述
|
||||
|
||||
### 3.1 路由与页面
|
||||
|
||||
- 新路由 `/users/:user`(与 `/games/:user/:slug` 风格一致),注册于 `src/app/router/index.ts`。
|
||||
- 新视图 `src/app/views/AuthorView.vue`,复用既有组件:`GameCard`、`StatePanel`、`BaseButton` 等。
|
||||
- 页面头部:`@<username>` + 作品数。**语义注意**:展示的是账号 `username`(命名空间所有者),不是作品元数据 `author_name`(自由文本、可空);卡片上的 `author_name` 照旧展示,两者不混用、不互相推导。
|
||||
- 列表按"最新上架"倒序,复用 `src/app/lib/filter.ts` 的既有排序逻辑(优先调用 `filterGames` 或其导出的比较器,**不复制**排序代码)。
|
||||
- 面包屑「目录 / @user」。
|
||||
- 入口:`GameView` 详情页作者名、`GameCard` 作者字样 → `/users/:user`。
|
||||
|
||||
### 3.2 数据流
|
||||
|
||||
`AuthorView` → `useAsync(repo.listGames())`(`src/app/data/` 的 `mergeRepo`:API + static 双源合并,API 宕机降级 static,与目录页同一链路)→ 按 `resolveUserSlug(game).user` 精确过滤(兼容旧式单段 id 作品:取 `id` 中 `/` 前段)→ `sort: 'new'` 排序 → 渲染。
|
||||
|
||||
### 3.3 边界与错误处理
|
||||
|
||||
- 未知用户名 / 该作者无已上架作品:空态「该作者暂无已上架作品」+ 返回目录按钮。**不做**用户存在性探测(需要新端点,YAGNI)。
|
||||
- 可见性语义与目录页完全一致:列表接口只返回已上架未下架作品,作者页自动继承。
|
||||
- 加载 / 失败态复用 `useAsync` + `StatePanel` 既有模式,不做新错误面。
|
||||
|
||||
## 4. 测试
|
||||
|
||||
- **Vitest**:AuthorView 过滤正确性(含 legacy 单段 id 形态与 `user` 字段缺失的回退)、空态渲染、`sort: 'new'` 排序、作者名链接跳转。
|
||||
- **Playwright `noauth`**:`/users/:user` 只显示该作者作品(基于 `src/fixtures/catalog` 现成作品数据)。
|
||||
- **后端**:无(零后端改动)。
|
||||
|
||||
## 5. 影响文件(预估)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/app/router/index.ts` | 注册 `/users/:user` |
|
||||
| `src/app/views/AuthorView.vue` | 新建 |
|
||||
| `src/app/views/GameView.vue` | 作者名加链接 |
|
||||
| `src/app/components/GameCard.vue` | 作者字样加链接(如展示作者) |
|
||||
| `src/app/lib/filter.ts` | 导出排序逻辑供 AuthorView 复用(如未导出) |
|
||||
| 测试文件 | 与视图测试同目录、同命名约定(co-located)新建 |
|
||||
| `docs/CHANGELOG.md`(crearte 仓) | 双语条目 |
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. `/users/:user` 只显示该作者的已上架作品(API+static 合并源),按最新上架倒序。
|
||||
2. 旧式单段 id 作品不破坏过滤逻辑(`resolveUserSlug` 回退路径覆盖)。
|
||||
3. 未知用户名 / 无作品:空态渲染,无报错、无 404 路由错误。
|
||||
4. `GameView` 与 `GameCard` 的作者入口可跳转到作者页。
|
||||
5. 新增 Vitest 与 `noauth` Playwright 用例通过,且**存量测试全绿**(含 `vue-tsc` 类型检查)。
|
||||
6. `crearte-server` 零改动(本子项目不产生任何后端 diff)。
|
||||
|
||||
## 7. 明确不做(Out of scope)
|
||||
|
||||
- 作者简介 / 头像 / 关注 / 统计等档案与社交面(对应路线图 P5 之后再议)。
|
||||
- 目录分页 / 服务端过滤(独立子项目)。
|
||||
- 用户存在性探测、`GET /api/users/:user` 类端点。
|
||||
- 静态兜底目录(P8)。
|
||||
@@ -0,0 +1,131 @@
|
||||
# P1 prod 写侧可用(本机自包含演练)设计 / Prod Write-Side Design
|
||||
|
||||
- **状态**:设计已批准(2026-09-29,方案 A + 跳过 TLS),待 spec 审阅后转实现计划。
|
||||
- **路线图**:`docs/ROADMAP.md` P1(第一波)。
|
||||
- **范围定性**:纯 compose/nginx/配置编排改动;**零后端代码改动、零前端应用代码改动**(仅 `deploy/` 资产与其守卫测试)。
|
||||
|
||||
## 1. 背景与意图
|
||||
|
||||
prod 栈的 `api-prod` 未配置任何 `STORAGE_S3_*`,server 启动时 `storageEnabled=false`,上传/投稿/审核/预览路由整体不注册——生产只读。本设计在**本机 compose 内**把 prod 栈补齐到与 dev 同等的写侧+游玩能力,作为日后迁真实服务器的定型演练。
|
||||
|
||||
已确认的两个上游决策(2026-09-29):
|
||||
|
||||
1. **形态**:本机自包含演练——MinIO 入 prod 栈,域名体系用 `*.localhost`。
|
||||
2. **TLS**:本轮跳过,全程 `http://localhost:8080`;README 记录迁真机时的 TLS 步骤。
|
||||
|
||||
成功标准(prod profile 起来后):注册→上传 bundle→投稿→审核上架→站内游玩(virtual 子域运行时 + external 外链)全链路可用;`docker compose --profile prod up -d --build` 后按 AGENTS.md 验证四步全绿。
|
||||
|
||||
## 2. 方案选型
|
||||
|
||||
| 方案 | 内容 | 取舍 |
|
||||
|---|---|---|
|
||||
| **A. 镜像 dev 直连法(已选)** | `minio-prod`(主机端口 9001),浏览器跨源直连 MinIO 拉密文/封面;nginx 通配块参数化 + 补 `/api/` 反代 | 复用 dev 已验证的跨源模式(`MINIO_API_CORS_ALLOW_ORIGIN` + `STORAGE_S3_PUBLIC_BASE_URL`);改动最小 |
|
||||
| B. nginx 同源反代 `/objects/` | 只暴露 8080,形态更近真 prod | 重写两个 server 块代理/改写 + MinIO 匿名策略变化,验证面大;上真服务器时再迁 |
|
||||
| C. 只做写侧不验游玩 | 范围最小 | P1 明文含 `GAMES_BASE_DOMAIN`,游玩链路是"写侧可用"的完整验收,砍掉不闭合 |
|
||||
|
||||
## 3. 设计详述
|
||||
|
||||
### 3.1 prod 端口与命名基线
|
||||
|
||||
- prod MinIO 主机端口 **`${MINIO_PROD_PORT:-9001}`**(避开 dev 的 9000,误起两栈时冲突面最小化);容器名 `minio-prod`,卷 `minio-data-prod`(新卷,不碰任何既有卷名)。
|
||||
- 桶名沿用 `${MINIO_BUCKET:-crearte}`;路径风格 `STORAGE_S3_FORCE_PATH_STYLE=true`;公开基址 `STORAGE_S3_PUBLIC_BASE_URL=http://localhost:${MINIO_PROD_PORT:-9001}/${MINIO_BUCKET:-crearte}`。
|
||||
|
||||
### 3.2 compose 改动(crearte-deploy/)
|
||||
|
||||
`api-prod` 补环境变量(其余不动):
|
||||
|
||||
```yaml
|
||||
depends_on:
|
||||
db-prod: { condition: service_healthy }
|
||||
minio-prod: { condition: service_started }
|
||||
environment:
|
||||
<<: *api-env
|
||||
DATABASE_URL: postgres://crearte:${POSTGRES_PASSWORD:?…}@db-prod:5432/crearte?sslmode=disable
|
||||
STORAGE_S3_ENDPOINT: http://minio-prod:9000
|
||||
STORAGE_S3_BUCKET: ${MINIO_BUCKET:-crearte}
|
||||
STORAGE_S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?…}
|
||||
STORAGE_S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?…}
|
||||
STORAGE_S3_FORCE_PATH_STYLE: "true"
|
||||
STORAGE_S3_PUBLIC_BASE_URL: http://localhost:${MINIO_PROD_PORT:-9001}/${MINIO_BUCKET:-crearte}
|
||||
GAMES_BASE_DOMAIN: localhost # server 默认即 localhost,显式写出以表意;真域名时改此值
|
||||
CORS_ALLOWED_ORIGINS: ${HOST_ORIGIN:-http://localhost:8080}
|
||||
```
|
||||
|
||||
`minio-prod` 服务:镜像 `pgsty/minio:latest`,`command: ["server", "/data"]`,`restart: unless-stopped`,端口 `${MINIO_PROD_PORT:-9001}:9000`,环境 `MINIO_ROOT_USER/MINIO_ROOT_PASSWORD`(同上 `:?`)+ `MINIO_API_CORS_ALLOW_ORIGIN: "*"`,卷 `minio-data-prod:/data`。
|
||||
|
||||
`web-prod` 补两项:
|
||||
|
||||
```yaml
|
||||
build:
|
||||
args:
|
||||
VITE_API_BASE_URL: /
|
||||
VITE_GAMES_BASE_DOMAIN: ${PUBLIC_GAMES_HOST:-localhost:8080} # host:port,含端口
|
||||
VITE_HOST_ORIGIN: ${HOST_ORIGIN:-http://localhost:8080}
|
||||
environment:
|
||||
GAMES_SERVER_NAME: ${GAMES_SERVER_NAME:-*.localhost} # nginx 模板渲染变量
|
||||
```
|
||||
|
||||
> 命名注意:`PUBLIC_GAMES_HOST`(客户端用,`localhost:8080` 带端口)与 server 的 `GAMES_BASE_DOMAIN`(CORS 后缀匹配用,纯主机名 `localhost`)是两个不同值,compose 变量必须分开,防止一处改动串坏另一处。
|
||||
|
||||
### 3.3 POSTGRES_PASSWORD 去默认值(全链)
|
||||
|
||||
- `x-postgres-env` 与两条 `DATABASE_URL` 中 `${POSTGRES_PASSWORD:-crearte}` 一律改 `${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}`。
|
||||
- **数据风险与迁移**:pgdata 卷内口令已固化;现存 `.env` 没写该变量的用户必须显式补 `POSTGRES_PASSWORD=crearte` 才能保住旧卷,README「数据与配置」加一条迁移警告(dev/prod 同理)。
|
||||
- `.env.example`:`POSTGRES_PASSWORD=crearte`(dev 开箱即用)+ 注释"prod 务必改强口令";新增 `MINIO_ROOT_USER=`/`MINIO_ROOT_PASSWORD=` 空占位(注释说明 dev 可留默认 minioadmin 需显式写值、prod 必填)。注意 `.env.example` 值与 compose `:?` 守卫同步。
|
||||
|
||||
### 3.4 nginx 模板化(crearte/deploy/)
|
||||
|
||||
现状两处死点:通配块 `server_name *.games.example.com` 硬编码;通配块不提供 `/api/` 反代(子域运行时取 bundle-key 会 404——dev 靠 vite 顺带代理才没暴露)。
|
||||
|
||||
改法:
|
||||
|
||||
1. `deploy/nginx.conf` → `deploy/nginx.conf.template`,主块保持 `server_name _;`,通配块改 `server_name ${GAMES_SERVER_NAME};`。nginx 官方镜像 entrypoint(`20-envsubst-on-templates.sh`)按**已定义环境变量**渲染到 `/etc/nginx/conf.d/`,`$uri`/`$host` 等 nginx 内建变量不受影响(非环境变量)。
|
||||
2. 通配块新增 `location /api/`,逐字照抄主块的 resolver + `proxy_pass http://$api_upstream` + `Host`/`X-Forwarded-For` 写法;其余维持"三件套 + `location / { return 404; }`"骨架不变。子域页面因此**同源**取 API,bundle-key 链路不再依赖跨源。
|
||||
3. Dockerfile:`COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf` → `COPY deploy/nginx.conf.template /etc/nginx/templates/default.conf.template`。
|
||||
4. **守卫测试同步**(`src/scripts/repo-yaml.test.ts`):读取路径改 `deploy/nginx.conf.template`;通配块查找键改 `server_name ${GAMES_SERVER_NAME};`;新增断言通配块含 `location /api/` 且 proxy 指向上游写法;"no /data/ no /assets/" 断言保留。`dev-game-runtime.ts` 头注里"对齐生产 nginx 通配 server block"的语义仍然成立,不动。
|
||||
|
||||
### 3.5 一次性桶初始化(prod)
|
||||
|
||||
与 dev 同型、端口不同,README「使用」prod 小节新增一行:
|
||||
|
||||
```bash
|
||||
docker compose --profile prod exec minio-prod sh -c 'mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" && mc mb --ignore-existing local/crearte && mc anonymous set download local/crearte'
|
||||
```
|
||||
|
||||
**单引号是刻意的**:宿主机 shell 不展开 `$MINIO_ROOT_USER/$MINIO_ROOT_PASSWORD`(`.env` 不会自动 export 到用户 shell),由容器内 shell 用 compose 注入的同名环境变量完成展开——与 dev 版(写死 minioadmin)行为等价但不再复制凭据字面量。不引入自动初始化——保持"卷首建需手动一次性初始化"的既有约定。
|
||||
|
||||
## 4. 验证(AGENTS.md 四步,逐条执行)
|
||||
|
||||
1. `docker compose config -q` 四个 profile 全过(dev/prod/debug/mock)。
|
||||
2. `--profile prod up -d --build` + `ps` 健康全绿;跑 prod MinIO 桶初始化。
|
||||
3. 冒烟链:`localhost:8080` 注册→`docker compose exec api-prod /app/crearte-server user set-role <email> admin`→登录→上传→投稿→管理端 approve→开 `<hash>.localhost:8080` 玩 virtual 作品(bootstrap→sw→bundle-key→解密→可玩);external 作品外链正常;匿名只读目录/详情 200。
|
||||
4. 回归:dev 栈 `up -d --build` 再起一次确认 9001/9000 无端口冲突、写侧未受 POSTGRES_PASSWORD 改动影响(`.env` 补值后);`repo-yaml.test.ts` 等前端单测在容器内全绿。
|
||||
5. `down`(**绝不 `-v`**)。
|
||||
|
||||
## 5. 影响文件(预估)
|
||||
|
||||
| 仓库 | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
| crearte-deploy | `docker-compose.yml` | minio-prod 服务、api-prod 补 env、web-prod 补 args/env、POSTGRES_PASSWORD 去默认、卷 minio-data-prod |
|
||||
| crearte-deploy | `.env.example` | POSTGRES_PASSWORD、MINIO_ROOT_* 占位与注释 |
|
||||
| crearte-deploy | `README.md` | prod 写侧用法、桶初始化、迁移警告、真机 TLS/域名 checklist |
|
||||
| crearte-deploy | `docs/CHANGELOG.md` | 0.5.0 条目 |
|
||||
| crearte | `deploy/nginx.conf.template`(新,替代 nginx.conf) | 通配块 server_name 参数化 + `/api/` 反代 |
|
||||
| crearte | `deploy/Dockerfile` | 模板 COPY 改路径 |
|
||||
| crearte | `src/scripts/repo-yaml.test.ts` | 读模板、断言更新 |
|
||||
| crearte | `docs/CHANGELOG.md` | 0.15.0 条目 |
|
||||
| wrapper | `docs/ROADMAP.md` | P1 状态、文档索引登记本 spec |
|
||||
|
||||
## 6. 明确不做(YAGNI / 出圈)
|
||||
|
||||
- 不做 TLS、不装反代层(真机时:Caddy/certbot + 泛解析 + 通配证书,README 记步骤即可)。
|
||||
- 不动 server/前端应用代码;不引 nginx `/objects/` 同源反代(方案 B,留待真服务器)。
|
||||
- 不改任何卷名;不新增 compose 全局网络拓扑。
|
||||
- P1 的"密码默认值"仅指 `POSTGRES_PASSWORD` 链(S3 root 凭据入 `:?` 见 3.2/3.3);`AUTH_TOKEN_SECRET`/`BUNDLE_KEK_k1` 已有守卫,不动。
|
||||
|
||||
## 7. 分支与提交
|
||||
|
||||
- crearte:`feat/prod-nginx-wildcard-api`,完成后 `--no-ff` 合入 master 并删分支。
|
||||
- crearte-deploy:`feat/prod-write-side`,同上。
|
||||
- wrapper:spec/ROADMAP/CHANGELOG 记录可直提 master。
|
||||
- 提交顺序:crearte(模板+测试)→ crearte-deploy(compose+文档,验证依赖前者)→ wrapper 收尾。
|
||||
@@ -0,0 +1,153 @@
|
||||
# P7 管理后台增强 — 设计规格
|
||||
|
||||
- 日期:2026-09-30
|
||||
- 涉及仓库:`crearte-server`(0.11.1 → 0.12.0)、`crearte`(0.17.0 → 0.18.0)。`crearte-deploy` 零改动。
|
||||
- 状态:设计已获维护者批准(2026-09-30 18:40 会话,四项决策 + 设计定稿拍板,均选推荐项)。
|
||||
- 前置:P0/P1/P2 完成(当前 master 为 P2 完成态;P3 备份+可观测已按维护者要求回退,与本设计无关)。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
ROADMAP P7 定义为「用户列表 / 角色管理 UI(替代 CLI `user set-role`)+ 审计日志」。
|
||||
|
||||
**做:**
|
||||
1. 审计日志:管理员动作的统一记录(新表 + 中间件 + 查询端点 + 前端流水页)。
|
||||
2. 用户管理:管理员列表查询端点 + 角色升降端点(复用既有 `SetUserRole` 语义)+ 前端用户管理页。
|
||||
3. 最后管理员护栏:禁止把系统降级为无 admin(API 与 CLI 两条路径同受约束)。
|
||||
|
||||
**不做(明确排除):**
|
||||
- 普通用户写操作(投稿创建/编辑)不进审计——`work_versions`/`submissions` 本身已是历史。
|
||||
- 登录事件不进审计(决策 D-A:只记管理员动作)。
|
||||
- 用户详情抽屉(作品列表/收藏展开)留给 P8 候选。
|
||||
- 审计保留策略/自动修剪:表自然增长,量级(管理员手速)短期无虞;出现压力另立小项。
|
||||
- 审计内容不做前后值 diff(决策 D-B:中间件统一记录,路由模板 + 路径 + 状态码即事实源)。
|
||||
|
||||
## 2. 已批准的决策
|
||||
|
||||
| # | 决策 | 内容 |
|
||||
|---|------|------|
|
||||
| D-A | 审计范围 | **只记管理员动作**。approve/reject、unpublish/republish、features、revoke、角色变更。 |
|
||||
| D-B | 审计机制 | **中间件统一记录 + admin 路由收敛为 gin 组**(维护者定稿:组上声明一次中间件,不逐条手挂)。零侵入七个现有 handler;不存请求体。 |
|
||||
| D-C | 用户列表深度 | **身份字段 + 角色操作**:用户名/邮箱/显示名/角色/注册时间 + 升降角色 + 搜索 + 分页。无聚合计数列。 |
|
||||
| D-D | 最后管理员护栏 | **禁止降级最后一个 admin**:service 层 `CountAdmins` 检查,唯一 admin 降角色 → 409;CLI 同样生效。 |
|
||||
|
||||
被否掉的备选(留档):服务层逐动作手写语义事件(要改七个 handler + 新增管道,回归面大);PATCH 端点按 email 定位(URL 携带 PII,选 id);不限制作死自杀式操作。
|
||||
|
||||
## 3. 后端设计(crearte-server)
|
||||
|
||||
### 3.1 迁移 `0010_audit_log.sql`
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id uuid NOT NULL, -- 不加 FK:用户注销后审计仍可读
|
||||
actor_email text NOT NULL, -- 冗余快照,同上理由
|
||||
method text NOT NULL, -- GET/POST/PUT/DELETE
|
||||
route text NOT NULL, -- gin FullPath() 模板,如 /api/admin/submissions/:id/approve
|
||||
path text NOT NULL, -- 具体请求路径,含对象 id
|
||||
status integer NOT NULL, -- 响应 HTTP 码(含 4xx/5xx:失败的管理尝试同样入史)
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX audit_log_created_at_idx ON audit_log (created_at DESC);
|
||||
```
|
||||
|
||||
语义要点:
|
||||
- **动作名 = method + route 模板**,不另造 action 字段;对象从 `path` 读。前端展示层负责把 route 模板映射为中文标签。
|
||||
- `actor` 只在通过 `RequireAdmin`(即确认是 admin)后才有值;401/403 的未遂访问**不记**——中间件在 `c.Next()` 之后检查 context 里的 user 是否存在且 role=admin,否则跳过。审计回答的是「管理员干了什么」,不是「谁试图闯进来」(后者属 D-A 被排除的登录事件范畴)。
|
||||
- 迁移编号 0010(现最大 0009)。空库 CI 集成腿每 PR 全量迁移,无需回填。
|
||||
|
||||
### 3.2 仓储层
|
||||
|
||||
`internal/repository/audit.go`:`AuditRepository`(照抄 user repository 的 DBTX/store 分层模式)
|
||||
- `Insert(ctx, entry model.AuditEntry) error`
|
||||
- `List(ctx, limit, offset int, routeFilter string) ([]model.AuditEntry, int, error)`(时间倒序,返回 total;`routeFilter` 为空则全量)
|
||||
|
||||
`internal/repository/user.go` 增补:
|
||||
- `List(ctx, limit, offset int, query string) ([]model.User, int, error)` — `query` 对 email/username 做 ILIKE `%q%`;排序 `created_at DESC`;返回 total。
|
||||
- `CountAdmins(ctx) (int, error)`
|
||||
- `GetByID` 已存在,角色端点用它定位。
|
||||
|
||||
`PostgresStore`(聚合接口)同步接线两个新方法组;memory store(集成测试用)同样补齐,语义一致。
|
||||
|
||||
### 3.3 服务层
|
||||
|
||||
`internal/service/audit.go`(或并入 content.go 同级新文件,实现者定,倾向新文件):
|
||||
- `RecordAdminAction(ctx, store, entry)` — 尽力而为:写失败 `log.Printf("audit: ...")` 吞掉,绝不影响业务响应。
|
||||
- `ListAudit(ctx, store, limit, offset, route)` — 分页钳制**逐字照抄 `ListQueue` 语义**(`limit<=0||limit>200 → 50`,`offset<0 → 0`)。
|
||||
|
||||
`internal/service/auth.go` 的 `SetUserRole` 改造(D-D 落点):
|
||||
- 新错误 `ErrLastAdmin`。
|
||||
- 流程:校验 role 合法(既有)→ 按 email 找用户 → **若目标 role=user 且该用户当前 role=admin 且 `CountAdmins()==1` → `ErrLastAdmin`** → 更新角色 + `BumpTokenVersion`(既有语义保持:角色变更必废旧 token)。
|
||||
- CLI `user set-role` 与 API 走同一函数,护栏自动双覆盖。
|
||||
|
||||
新 `SetUserRoleByID(ctx, store, id, role)`:按 id 版本(API 用 `:id`),同样护栏与废 token 语义;找不到 → `ErrUserNotFound`。
|
||||
|
||||
### 3.4 API 层
|
||||
|
||||
路由常量(`internal/api/router.go` 现有 `RouteAdmin*` 旁):
|
||||
- `GET /api/admin/users` → `RouteAdminUsers`
|
||||
- `PATCH /api/admin/users/:id/role` → `RouteAdminUserRole`
|
||||
- `GET /api/admin/audit` → `RouteAdminAudit`
|
||||
|
||||
**审计中间件挂载方式**(维护者定稿 2026-09-30 18:59:admin 路由改用 gin 分组,不再逐条挂 `RequireAdmin`):
|
||||
- 新 `AuditMiddleware(store)` gin 中间件;router.go 将七条既有 admin 路由 + 三条新路由**收敛为一个组**:`adminGroup := engine.Group("", RequireAdmin(deps.AuthService), AuditMiddleware(deps.AuditStore))`,组内逐条注册时继续引用既有 `RouteAdmin*` 全路径常量(`adminGroup.GET(RouteAdminSubmissions, deps.Admin.ListSubmissions)` …)。全路径常量不变 → 对外 URL、`FullPath()`、测试引用零变化;改的只是注册行前缀由 `engine.` 变 `adminGroup.`,中间件声明一次。注:若用 `Group("/api/admin")` + 相对子路径重写常量,会改动 `RouteAdmin*` 定义与既有测试引用,面更大;选空前缀组形态(定稿)。
|
||||
- 中间件仅当请求进入 admin 面时执行,`c.Next()` 后读 user + `c.FullPath()` + `c.Writer.Status()` 组装 entry。
|
||||
|
||||
端点行为:
|
||||
- `GET /api/admin/users?limit=&offset=&q=` → 200 `{ users: [ {id, email, username, display_name, role, created_at} ], total }`。email 完整暴露给 admin——角色操作按 email 的 CLI 语义需要它,且 admin 本就是全权角色(决策:不脱敏)。
|
||||
- `PATCH /api/admin/users/:id/role` body `{"role":"user"|"admin"}` → 200 返回更新后用户(含 `token_version`);非法 role → 400 `validation`;id 不存在 → 404;**唯一 admin 降级 → 409 `last_admin`**(message: "cannot demote the last admin")。
|
||||
- `GET /api/admin/audit?limit=&offset=&route=` → 200 `{ entries: [...], total }`,倒序。
|
||||
- 三条新路由同样被审计——包括「改角色」本身(管理员最重要的动作必须可查)与对 audit 的读(记录 GET 略噪,但保持一致性优先;`route` 过滤参数正好用于自查时排除)。
|
||||
|
||||
### 3.5 错误与日志
|
||||
|
||||
- 复用现有 `handler.WriteError` 的 code 体系(`forbidden`/`internal`/`validation`/新增 `last_admin`)。
|
||||
- 审计写失败只 log,不 5xx。
|
||||
|
||||
## 4. 前端设计(crearte)
|
||||
|
||||
### 4.1 apiRepo 新方法(`app/data/apiRepo.ts` + 类型)
|
||||
|
||||
- `listAdminUsers(params)` → `AdminUserPage`
|
||||
- `setUserRole(id, role)` → `AdminUser`
|
||||
- `listAudit(params)` → `AuditPage`
|
||||
|
||||
沿用现有 `request()` 封装、ETag 策略(管理面**不缓存**:两 GET 均 `cache: 'no-store'` 语义,数据随任意管理员动作即时变)、错误映射进 `toContentMessage`。
|
||||
|
||||
### 4.2 `/admin/users` — AdminUsersView.vue
|
||||
|
||||
- 复用 AdminView 的 StatePanel / 分页 / 错误直出模式。
|
||||
- 表格列:用户名、邮箱、显示名、角色、注册时间;行内「升为 admin / 降为 user」按钮。
|
||||
- **降级确认弹层**:文案必须写明两件事——「对方所有登录态立即失效」+「若其为最后一个管理员将被拒绝(409)」,并给出后端 409 的原文回显。
|
||||
- 搜索框(防抖,按 email/username)+ limit/offset 分页(默认 50,与后端钳制一致)。
|
||||
|
||||
### 4.3 `/admin/audit` — AdminAuditView.vue
|
||||
|
||||
- 倒序流水表:时间(本地化)、操作者邮箱、动作(route 模板→中文标签映射,映射外模板回退显示原文)、对象(从 path 提取 :id/:user/:slug 段)、结果(状态码着色:2xx 绿、4xx 黄)。
|
||||
- `route` 过滤下拉(选项 = 已知动作标签集)。
|
||||
|
||||
### 4.4 入口与路由
|
||||
|
||||
- router 增两条 `/admin/users`、`/admin/audit`,沿用现有 admin 路由守卫(非 admin 跳登录/403 行为与 AdminView 完全一致)。
|
||||
- AdminView 顶部现有「审核」链接旁加两个入口:用户管理、操作日志。
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
- **server 单测**:`SetUserRole`/`SetUserRoleByID` 护栏矩阵(唯一 admin 降→ErrLastAdmin;两个 admin 降一个→OK;升→OK)、`ErrInvalidRole` 保持、audit entry 组装纯函数。
|
||||
- **server 集成层**(TEST_DATABASE_URL,空库全迁移):用户列表 q/分页/total;PATCH 升角色成功且**旧 token 立即失效断言**(改前签发的 token 调 `/api/me` → 401);409 唯一 admin;CLI 与 API 行为一致;跑一次 approve 后 `GET /api/admin/audit` 出现对应行(含 status=200),失败动作(404 的 revoke)也入史。
|
||||
- **前端 vitest**:apiRepo 三方法(请求形状/错误映射)、两新 view 的渲染与角色操作确认流(mock repo)。
|
||||
- **e2e**(主套件,夹具模式):`admin-flow.spec.ts` 扩两条:① admin 进用户页升降角色 → 列表即时更新;② 执行一个管理动作后审计页首行即该动作。真栈 full-loop 不动(P2 既有腿保持绿即回归通过)。
|
||||
- **容器四连**:gofmt/vet/build/test RC=0。
|
||||
|
||||
## 6. 版本与账目
|
||||
|
||||
- server:CHANGELOG 新 `## [0.12.0]`(现顶 0.11.1)。
|
||||
- crearte:CHANGELOG 新 `## [0.18.0]`(现顶 0.17.0)。
|
||||
- deploy:无。
|
||||
- wrapper:ROADMAP P7 状态行 + CHANGELOG 0.2.6 记账 + 文档索引登记本 spec 与实现计划。
|
||||
|
||||
## 7. 验收标准(本机)
|
||||
|
||||
1. 容器四连 + 集成层(`-p 1` 官方跑法,空库)全绿,0 skip。
|
||||
2. 前端 vitest + typecheck + 主套件 e2e 绿;真栈 full-loop 不回归。
|
||||
3. 手工冒烟(compose debug/dev 栈或 e2e-stack 编排):CLI 建 admin → 登录 → 用户列表 → 升第二个用户为 admin → 降级第一个成功 → 试图降级最后一个 → 409 → 审计页/端点能看到全部动作含 409 那次失败尝试。
|
||||
4. 被降级用户旧 token 调 `/api/me` → 401(废 token 端到端验证)。
|
||||
@@ -0,0 +1,72 @@
|
||||
# P3 备份 + 可观测设计 / Backup & Observability Design
|
||||
|
||||
- **状态**:设计已定(2026-09-30;三个选型向维护者提问未被应答,按推荐默认拍板并记录 §2,维护者可事后推翻)。
|
||||
- **路线图**:P3(第一波末项),依赖 P1 拓扑已定型。
|
||||
- **涉及仓库**:crearte-server(0.11.1 → **0.12.0**)、crearte-deploy(0.5.1 → **0.6.0**);**crearte 前端零改动**。
|
||||
|
||||
## 1. 背景与现状(2026-09-30 实地盘点)
|
||||
|
||||
- prod 栈(`db-prod` postgres:17-alpine / `minio-prod` pgsty/minio / `api-prod` / `web-prod`)数据落命名卷 `pgdata-prod`、`minio-data-prod`,**没有任何备份面**;`api-prod` 无宿主端口映射(仅 `web-prod` 的 8080 反代 `/api/` 与静态),MinIO console 宿主端口 `MINIO_PROD_PORT:-9001`。
|
||||
- server 日志 = 26 处 stdlib `log.Printf` 散在 12 个文件(handler×7、service×2、api 中间件×2、cmd/serve.go×1 等);无请求日志中间件;无 `/metrics`。`gin.New()` 仅挂 `Recovery` + CORS。
|
||||
- 限流器 `internal/api/ratelimit.go`:进程内 `map[ip]*windowCounter` 固定窗口(bundle-key 60/min、login 10/min、register 5/h、upload 10/min、submission 20/h、reaction 30/min),`maxTrackedIPs=10000` 惰性清理——纯单实例语义。
|
||||
- 依赖基线:gin 1.11 / pgx 5.8 / aws-sdk-go-v2;**无 prometheus 依赖**。go.mod `go 1.24.1`。
|
||||
|
||||
## 2. 选型拍板(默认=推荐项)
|
||||
|
||||
| # | 岔路 | 决定 | 理由 / 放弃项 |
|
||||
|---|---|---|---|
|
||||
| D1 | `/metrics` 实现 | **手写极简 exposition**(零新依赖,Prometheus 文本格式 v0.0.4) | 单实例小站够用;代码可控可测;延续本仓最小依赖风格。放弃 `prometheus/client_golang`(依赖链重,Grafana 生态是未来事)与"不做"(ROADMAP 明列交付物)。 |
|
||||
| D2 | 备份调度 | **deploy 仓 `backup.sh` + 主机 crontab**(runbook 给一行安装示例) | 脚本可手跑、可 CI `--dry-run` 验证、不绑 systemd。放弃栈内 cron sidecar(挂 docker.sock = 攻击面);systemd timer 留作 runbook 备选写法。 |
|
||||
| D3 | 备份落点 | **主机目录 `BACKUP_DIR`(默认 `./backups/prod/<UTC 时间戳>/`)+ 保留最新 `BACKUP_KEEP_DAYS=7` 份** | 防误删/防配置炸;同盘不防磁盘坏——3-2-1 与 rclone 异地钩子写进 runbook 作可选步骤,不配不生效(本轮不引入外部 S3 凭据依赖)。 |
|
||||
|
||||
## 3. server 设计(0.12.0)
|
||||
|
||||
### 3.1 结构化日志(slog)
|
||||
|
||||
- `config.Load()` 新增 `LOG_FORMAT=text|json`(默认 `text`)、`LOG_LEVEL=debug|info|warn|error`(默认 `info`;非法值报错)。`serve.go` 启动时据此配置 `slog.Default`(输出 stderr;JSON 供 prod/容器采集)。
|
||||
- 26 处 `log.Printf` 全量迁移到 `slog`,**机械映射规则**:原 `msg k1=%s k2=%v` → `slog.<lvl>("<msg 前缀短语>", "k1", v1, "k2", v2)`;携带 `error=%v` 的一律 `slog.Error(..., "error", err)`;启动/生命周期一行 `slog.Info`。文案短语与键名不变。
|
||||
- 新增**请求日志中间件**(`internal/observability`):每请求一行 `http_request`,attrs = `method`、`route`(`c.FullPath()`,未匹配路由记 `nomatch`,基数有界)、`status`、`duration_ms`、`ip`;`/healthz` 整体跳过(不记日志**也不计指标**,防 scrape 噪声);`status >= 500` 用 Error 级。
|
||||
- `gin.Recovery()` 保留(panic 兜底),不动。
|
||||
|
||||
### 3.2 `/metrics`(手写)
|
||||
|
||||
- `internal/observability`:并发安全 Registry(mutex + `map[method|route|status]→{count,sumSeconds}`)。中间件一次计时同时喂日志与指标。
|
||||
- 指标族:
|
||||
- `crearte_http_requests_total{method,route,status}`(counter)
|
||||
- `crearte_http_request_duration_seconds_sum/_count{method,route,status}`(counter 语义,够算均值)
|
||||
- `go_goroutines`、`go_memstats_alloc_bytes`(`runtime` 现值 gauge)
|
||||
- `crearte_pgpool_*`(`pgxpool.Stat()`:`max_conns/total_conns/acquired_conns/idle_conns/acquire_count/empty_acquire_count` gauge;pool 为 nil 时省略——纯 mock 测试路由无池)
|
||||
- `GET /metrics` 与 `/healthz` 同口注册(`RouteMetrics="/metrics"`),无 auth、无 CORS 例外。
|
||||
- **暴露边界**:prod 形态 api-prod 宿主零端口 → `/metrics` 仅容器网内可达,抓取配方 `docker compose exec -T api-prod wget -qO- http://127.0.0.1:8080/metrics`。外网暴露 + allowlist/basic-auth 明确不做(未来接 Prometheus 抓取器再开 nginx 转发段)。
|
||||
- 测试路径无池、无真栈:httptest 经真 router 打请求,断言文本行、计数增长、`nomatch` 桶存在。
|
||||
|
||||
### 3.3 限流单实例权衡(记录,不改造)
|
||||
|
||||
server `docs/README.md` 新增「已知权衡」节:多实例部署时每实例独立计数(有效限额 ≈ N×、重启清零、跨实例不共享);`maxTrackedIPs` 上限的内存数量级;扩多实例的前置条件是引入 Redis/网关层共享计数。**只记录现状语义,不改限流器。**
|
||||
|
||||
## 4. deploy 设计(0.6.0)
|
||||
|
||||
- **compose 结构零改动**:备份全走 `docker compose exec` / `run --no-deps` 一次性容器(`db-prod` 自带 pg_dump/pg_restore/psql,unix socket trust 免口令;`minio-prod` 镜像自带 `mc`,凭据经容器 env 注入而非命令行字面量)。
|
||||
- `scripts/backup.sh`:
|
||||
1. `--dry-run`:不碰 docker,仅校验 `.env` 存在、`POSTGRES_PASSWORD`/`MINIO_ROOT_*` 非空(`:?` 守卫)并打印计划——CI 步骤用它。
|
||||
2. 预检:docker 可用、`compose --profile prod config -q`、`db-prod` 存活(`pg_isready`)。
|
||||
3. `pg_dump --format=custom` 流式 → `$BACKUP_DIR/prod/<UTC 时间戳>/database.dump`。
|
||||
4. `mc mirror --overwrite --remove` 桶(`MINIO_BUCKET:-crearte`)→ 同目录 `objects/`。
|
||||
5. 完整性:`pg_restore --list` 读 dump 头。
|
||||
6. manifest:全表**精确行数**(循环 `pg_tables` 逐表 `count(*)`)+ 产物体积。
|
||||
7. `prod/latest` 软链指向本次目录;按目录名排序保留最新 `BACKUP_KEEP_DAYS` 份,只删 `^\d{8}T\d{6}Z$` 白名单形态目录。
|
||||
- `scripts/restore-drill.sh`:latest dump 恢复到 **debug 栈 `db-test`**(drop/recreate 仅限该测试库),逐表行数与 manifest 对比,输出 PASS/FAIL。绝不连 prod 库。
|
||||
- `docs/BACKUP-RUNBOOK.md`:定时约定(crontab 一行 + systemd timer 备选写法);恢复步骤(恢复到现有 prod 库 / 恢复到全新栈两条路径);对象侧 `mc mirror` 反向推送;异地 3-2-1 与 rclone 可选钩子;已知风险(同盘单拷贝、`mc mirror` 非快照一致性——写侧对象上传少可接受、备份窗口内行数漂移 manifest 与 dump 快照的微小差)。
|
||||
- `.env.example` 增 `BACKUP_DIR`/`BACKUP_KEEP_DAYS` 注释示例;README prod 节链 runbook;`validate.yml` 增 `backup.sh --dry-run` 步骤(CI 里先 `cp .env.example .env` 再 append 假值过 `:?` 守卫)。
|
||||
- CHANGELOG 0.6.0(英/中连续行)。
|
||||
|
||||
## 5. 验收标准(本机任务 4 实测)
|
||||
|
||||
1. server:容器四连(gofmt/vet/build/test)+ `TEST_DATABASE_URL` 集成层全绿、0 skip;新单测覆盖指标格式、中间件计数、`nomatch` 桶、日志 JSON 合法(jq)、`LOG_*` 解析。
|
||||
2. 实栈冒烟(prod profile 演练栈):打一轮真实请求 → `docker compose exec` 抓 `/metrics` 计数吻合、JSON 日志行可读;`backup.sh` 全绿产出 dump + objects + manifest;`restore-drill.sh` 对 db-test **PASS**;连跑两次 + `BACKUP_KEEP_DAYS=1` 验证 latest 软链与修剪。
|
||||
3. `backup.sh --dry-run` 无栈可跑(CI 等价);deploy 新 workflow 步骤本机过。
|
||||
4. crearte e2e:stack full-loop 不回归(中间件动过路由)。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
Prometheus/Grafana 服务端搭建、`/metrics` 外网暴露与鉴权、异地同步落地实现、PITR/WAL 归档、卷快照级备份、限流器改造、dev/debug 栈备份(仅 prod profile)。
|
||||
@@ -0,0 +1,76 @@
|
||||
# P2 CI + 测试基线 — 设计规格
|
||||
|
||||
日期:2026-09-30 | 状态:已批准(用户指令:写计划、派子代理、端到端交付、不再提问)
|
||||
涉及仓库:crearte-server(GitHub 私有)、crearte-deploy(GitHub 私有)、crearte(GitHub 公开)
|
||||
前置事实:P0/P1/P4/P5 已交付;`crearte/.github/workflows/validate.yml` 已存在(pull_request 触发,node 24,check + e2e 两 job);后端/部署仓零 CI。
|
||||
|
||||
## 1. 问题定义(ROADMAP 原文拆解)
|
||||
|
||||
1. **后端/部署仓无 CI** → 两仓各建 `validate.yml`,风格对齐前端现有文件。
|
||||
2. **Postgres 集成层静默 skip** → 后端 CI 必须有真实 Postgres service,且**当且仅当集成测试真的跑了才算绿**(skip 在 CI 上下文里视为失败)。
|
||||
3. **e2e:stack 全链路** → 真栈 Playwright full-loop 首次进 CI(宿主仓因凭据限制定为 crearte-server,见 2.2 修订)。
|
||||
|
||||
## 2. 范围决策
|
||||
|
||||
### 2.1 crearte-server `validate.yml`(新文件)
|
||||
|
||||
- 触发:`pull_request`(paths: `src/**`、workflow 自身)+ `push: master`。与前端文件同构。
|
||||
- Job `check`(无 DB):`gofmt -l` 空断言 → `go vet ./...` → `go build ./...` → `go test ./...`。pg-gated 在此 job 正常 skip(语义与本地开发一致)。
|
||||
- Job `integration`:GitHub `services: postgres`(`postgres:17-alpine`,与 crearte-deploy 所用镜像一致,user/pass/db = crearte);`TEST_DATABASE_URL` 指向**空库**——集成测试自带 `repository.Migrate` 引导(`migrate_test.go:testDB` sync.Once + Migrate;service 层 gated 测试也各自 Migrate),所以**空库即全量真跑,同时白赚一条"从空库可完整迁移"的每 PR 回归**。
|
||||
- `go test -v -count=1 -p 1 ./...`:**`-p 1` 必须**——各 gated 测试对共享表做 `TRUNCATE`,包间并行(默认并行度)必互相踩踏;`-p 1` 序列化包执行是既有测试写法(不改测试)下唯一的正确姿势。
|
||||
- **skip 守卫**:`tee` 落日志后 `grep 'skipping postgres integration test'` 命中即 FAIL(pipefail 显式开)。没有守卫,这个 job 退化成 check 的复读机。
|
||||
- Go 版本:`actions/setup-go@v5` + `go-version-file: src/go.mod`(1.24.1)+ `cache-dependency-path: src/go.sum`。
|
||||
|
||||
### 2.2 真栈 e2e 的宿主仓:放在 crearte-server,不放 crearte(计划阶段勘察后的范围修订)
|
||||
|
||||
原稿把 e2e-stack 挂进 crearte CI——**勘察后不可行**:`XingfenD/crearte-server` 是私有仓(GitHub API 404 验证),公开仓的 workflow 用默认 token 无法 checkout 它;本机无 GitHub API 凭据,不能自助签发 PAT。反向则天然可行:server 的 workflow checkout 公开的 crearte 无需任何 token。
|
||||
|
||||
修订后的布局:
|
||||
- **e2e-stack job 落进 crearte-server 的 validate.yml**:双 checkout(server=默认 token,crearte=公开无 token,均带 path)+ setup-go(go-version-file 指向 server 仓内 go.mod)+ node 24 + playwright chromium;env `CREARTE_STACK_BACKEND_REF: ${{ github.sha }}`——直接测 **PR 那个后端 commit**,信号强于拉 master(原布局只能测 master 后端)。路径适配:两仓 path 必须使 `dirname(REPO_ROOT)/crearte-server` 命中兄弟仓(即 `path: crearte` 与 `path: crearte-server` 同级);脚本用 `$BACKEND/.git` 的 origin 去 fetch SHA(GitHub 允许 fetch 任意 commit SHA)。working-directory 全部 `crearte/src`。
|
||||
- **crearte 仓的改动缩小为**:`e2e-stack.sh` 支持 `CREARTE_STACK_GO`(显式指定 go 二进制,解 runner 上 `/usr/local/go/bin/go` 版本地板假绿隐患;不设即旧逻辑,本地零变化)与 `CREARTE_STACK_REQUIRED`(置 1 时栈起不来直接 exit 1,封死“CI 里静默 skip=假绿”通道;不设即本地 skip 模式);`validate.yml` 补 `push: branches: [master]` 触发(既有两 job 不动);CHANGELOG 0.17.0。server CI 的 e2e-stack job 设这两个 env。
|
||||
- full-loop 假绿风险由 REQUIRED 模式从根上封死:webServer 命令非零退出 → playwright job 红。无需 JSON 报告器断言,不赌 reporter 输出格式。
|
||||
- 已接受的限制(如实记录):纯前端 PR(只动 crearte)不再有真栈 e2e 信号,改由 server 侧每次 PR/push 带最新 crearte master 补位(前端变更合入后即被覆盖);若 owner 日后在 crearte 仓配 `STACK_BACKEND_TOKEN` secret,可镜像同构 job 回 crearte——列为可选增强,不阻塞交付。
|
||||
|
||||
### 2.3 crearte-deploy `validate.yml`(新文件)
|
||||
|
||||
- 无编译产物,CI 价值 = compose 健全性:`docker compose --profile dev --profile prod --profile full config -q`(`:?` 守卫变量注入假值,另测**缺 `POSTGRES_PASSWORD` 必须非零退出**,证明守卫没被无声旁路)。
|
||||
- `config` 不校验 bind 路径存在性,故 `../crearte` 挂载在单仓 checkout 下也能过——**如实记录**:单仓 config ≠ 可部署;跨仓挂载完整性仍由本 monorepo 演练承担(P1 已证)。deploy 仓 README「单仓 clone」小节加一行指向本 spec,防误读。
|
||||
- deploy 的 AGENTS.md「no host-run workflows」指本机别跑 npm/go;GH Actions 跑 `compose config` 不落任何宿主机进程,不违反。
|
||||
- CHANGELOG 新 patch 条目(双语)。
|
||||
|
||||
### 2.4 全局非目标
|
||||
|
||||
- **不做分支保护**:无 GitHub API 凭据(`gh` 缺失、token 库空),owner 在 Settings→Branches 手动开启(server/deploy 必开,crearte 建议开)。写进 spec 交付清单,不进计划任务。
|
||||
- **不验证 Actions 已启用**:私有仓 Actions 可能因计划限制未开/额度耗尽——推送后无 API 可查证。**如实交付**:计划里把「owner 在 GitHub UI 看 Actions 出现绿勾」列为部署后人工尾巴(与 P5 浏览器验收同类 manual-deferred);本机能验的全验(见 3)。
|
||||
- 不动 git.yoresee.cc 的 Gitea Actions(内层三仓不在 Gitea)。
|
||||
- 不重构测试文件(除任务 4 实测证明假绿通道时的最小修复)。
|
||||
- 不引入 lint(golangci-lint)、不引 codecov。
|
||||
|
||||
## 3. 验证策略(无 GitHub API,全部本地等价模拟)
|
||||
|
||||
任务 4 在本机以与 CI job **同构的容器化步骤**跑三套:
|
||||
|
||||
1. `check`:docker `golang:1.24-alpine`(GOPROXY 走 goproxy.cn——本机网络事实)四连。
|
||||
2. `integration`:`postgres:17-alpine` 容器(端口 5434,全新空库,`--add-host=host.docker.internal:host-gateway` 让测试容器可达)+ `go test -v -count=1 -p 1 ./...` + skip 守卫。跑**两遍**:第二遍证明 Migrate 幂等下的共存。
|
||||
3. `e2e:stack`:在 monorepo 真实布局(兄弟仓天然就位、宿主 go 1.26.8)跑 `npm run e2e:stack`,断言日志出现 `[stack] ready:`(真绿非假绿),另跑一次「无 docker 假绿反向验证」用 PATH 屏蔽 docker 令 `stack_ok=0`,确认 SKIP 模式在报告中显式可见。
|
||||
4. deploy config 正反两路。
|
||||
|
||||
推送本身(git push 成功)是 Actions 接收 workflow 的充分条件;job 结果查证为人工尾巴。
|
||||
|
||||
## 4. 交付物清单
|
||||
|
||||
| 仓库 | 文件 | 动作 |
|
||||
|---|---|---|
|
||||
| crearte | `src/scripts/e2e-stack.sh` | `CREARTE_STACK_GO` + `CREARTE_STACK_REQUIRED` 两个 env 开关 |
|
||||
| crearte | `.github/workflows/validate.yml`、`docs/CHANGELOG.md` | push:master 触发;0.16.1 |
|
||||
| crearte-server | `.github/workflows/validate.yml`、`docs/CHANGELOG.md` | check + integration + **e2e-stack** 三 job;0.11.1 |
|
||||
| crearte-deploy | `.github/workflows/validate.yml`、`docs/CHANGELOG.md`、`README.md` | compose config 正反两路;0.5.1 |
|
||||
| wrapper | 本 spec + 计划、ROADMAP P2 行、CHANGELOG 0.2.4 | 收尾 |
|
||||
| GitHub UI | Actions 启用确认 + 分支保护 | **owner 手动**(见 2.4) |
|
||||
|
||||
## 5. 验收标准(Definition of Done)
|
||||
|
||||
1. 三仓 `.github/workflows/validate.yml` 合入各自 master 并推送;分支删除。
|
||||
2. 任务 4 四组模拟全绿且证据留档(`.superpowers/sdd/p2-task-4-report.md`):integration 首跑必须含 `RUN Test…Postgres`/真跑字样、零 "skipping postgres integration test"。
|
||||
3. `e2e:stack` 真栈一跑 `[stack] ready:` 出现、full-loop 用例计数 >0 且全过;`CREARTE_STACK_REQUIRED=1` 反路在栈起不来时 exit 1(不静默 skip)。
|
||||
4. ROADMAP P2 → 完成(三 hash);wrapper CHANGELOG 0.2.4;人工尾巴(Actions 查证、分支保护)在 ROADMAP 行或 spec 有明确记载。
|
||||
@@ -0,0 +1,108 @@
|
||||
# 创作者中心(`/creator`)设计 / Creator Center Design
|
||||
|
||||
- **状态**:设计已批准(2026-09-30;入口=主导航、教程卡=占位+链现有文档、数据卡按登录态分流,逐项与维护者确认),待 spec 审阅后转实现计划。
|
||||
- **路线图**:维护者直接需求,未列入既有波次(P1–P8);仅在文末索引登记。
|
||||
- **范围定性**:纯前端新页面(crearte 仓);**零后端、零数据迁移、crearte-deploy 零改动**。
|
||||
|
||||
## 1. 背景与意图
|
||||
|
||||
站内投稿入口埋在登录后的用户下拉菜单里,主导航只有「作品 / 文档」:潜在创作者从任何公开页面都看不到"成为创作者"的路径;已投稿的创作者也没有回看自己作品数据(浏览 / 下载 / 评分)的承载页。
|
||||
|
||||
本子项目先搭骨架:一个首页样式的**创作者中心**(hero + 两张卡)——「作品数据」占位(后续接真实数据)与「创作教程」占位(教程内容维护者仍在构思,文档可能大规模重构,本轮只做占位并链向现有文档)。
|
||||
|
||||
成功标准:主导航出现第三个 tab;`/creator` 渲染 hero 与两张卡;未登录时数据卡引导登录并可回跳,已登录显示建设中文案;noauth 部署下页面可达且不出现死链。
|
||||
|
||||
约束(与维护者确认):
|
||||
|
||||
- 入口放页头主导航(「作品」「文档」旁),匿名可进。
|
||||
- 教程内容定稿前,教程卡只放占位说明 + 链到现有《提交作品指南》。
|
||||
- 数据卡是纯占位:不接任何真实聚合,不做图表。
|
||||
|
||||
## 2. 方案选型
|
||||
|
||||
| 方案 | 内容 | 取舍 |
|
||||
|---|---|---|
|
||||
| **A. 独立新视图 + 主导航 tab(已选)** | 新路由 `/creator`(无参数)、新 `CreatorCenterView`、`AppHeader` 加第三个 tab | 与既有信息架构同构(作品 / 文档 / 创作者中心);页面无参数无 404 面;后续数据卡、教程都可原地替换 |
|
||||
| B. 收进登录后用户下拉菜单 | 与「提交作品」并列 | 仅登录可见,潜在创作者看不到路径;维护者已明确否 |
|
||||
| C. 并入 `/docs` 文档流 | 教程进文档 sidebar,数据卡无处安放 | 数据卡无归属;且文档即将重构,现在挂进去白做 |
|
||||
|
||||
卡内三态(`authEnabled` 未登录 / 已登录 / noauth)直接用既有 `authEnabled` + `session`(`@/auth`,`AppHeader` 同款用法),不引入新的状态管理。
|
||||
|
||||
## 3. 设计详述
|
||||
|
||||
### 3.1 路由与导航
|
||||
|
||||
- `src/app/router/index.ts` 注册 `{ path: '/creator', name: 'creator', component: () => import('@/views/CreatorCenterView.vue') }`,**不加** `meta.requiresAuth`(公开页,登录态只在卡内部分流)。
|
||||
- `src/app/components/AppHeader.vue`:
|
||||
- 新增 `onCreator` computed(`route.name === 'creator'`)。
|
||||
- 主导航加第三个 `RouterLink`「创作者中心」,激活样式与「作品 / 文档」完全一致(`border-b-accent-ink` + `aria-current="page"`)。
|
||||
- 贴纸(sticker)逻辑不变:creator 页回落默认 `HOST YOUR CREATIONS`。
|
||||
|
||||
### 3.2 页面结构(`src/app/views/CreatorCenterView.vue`)
|
||||
|
||||
复刻 `LandingView.vue` 的墨纸语言:同类 class,不引新 design token、不改 `main.css`。
|
||||
|
||||
- **hero 区**(`border-[3px] border-ink bg-surface px-6 py-10 text-center shadow-hard sm:px-10 sm:py-14`):
|
||||
- 徽章「创作者」(`bg-ink` 反转块,同首页「创艺」徽章样式);
|
||||
- 标题「创作者中心」(`font-display` 大字);
|
||||
- 等宽字体标语行 `SHARE YOUR CREATIONS`;
|
||||
- 分隔条 + 说明行「把你的作品分享给所有人」;
|
||||
- 两个按钮:「提交作品」→ `/submit/new`(该路由 `requiresAuth`,未登录由既有守卫转 `/login?next=/submit/new`,无需本页处理)、「投稿指南」→ `/docs/contribute`。
|
||||
- **卡片行**(`mt-8 flex flex-col gap-4 sm:flex-row`,与首页「投稿与文档」行同款;外层 `sr-only` h2「创作者中心」保持大纲结构):
|
||||
- 卡 1 **作品数据**(占位),三态:
|
||||
- `authEnabled && !user`:「登录后即可查看您作品的数据。」+ 链接「登录」→ `/login?next=/creator`;
|
||||
- `authEnabled && user`:「数据面板正在建设中。上线后将展示您作品的浏览、下载与评分。」;
|
||||
- `!authEnabled`(noauth 部署):「数据面板正在建设中。」——**不给登录链接**(该模式无账号体系,链过去会被守卫打回首页)。
|
||||
- 卡 2 **创作教程**(占位):「教程整理中,敬请期待。」+「投稿流程、打包规范与过审要点正在重新整理,完成后在本页发布。」+ 链接「《提交作品指南》」→ `/docs/contribute`(先行阅读)。
|
||||
- 卡片与链接样式复用首页卡片同款 class(`flex-1 border-2 border-ink bg-surface p-4 shadow-hard-sm`、`h3 text-sm font-black`、正文 `mt-2 text-xs leading-relaxed text-ink-soft`、链接 `underline`)。
|
||||
|
||||
### 3.3 数据流与状态
|
||||
|
||||
- **无数据请求**:页面不调 `repo`,不用 `useAsync`;无加载 / 失败态,不套 `StatePanel`(与首页 hero 区一致)。
|
||||
- 登录态:`import { authEnabled, session } from '@/auth'`,`user = computed(() => session.state.user)`;`authEnabled` 为构建期常量(`VITE_API_BASE_URL` 判空,`src/app/content/index.ts` 同款判定)。
|
||||
- 不新增路由守卫、不新增 store、不新增 composable。
|
||||
|
||||
### 3.4 边界与错误处理
|
||||
|
||||
- 无异步 ⇒ 无错误面;`/creator` 无参数 ⇒ 无 404 面。
|
||||
- noauth 部署:页面可达,数据卡降级为纯建设中文案(无登录死链)。
|
||||
- 登录链接的 `next` 经 `LoginView` 既有 `sanitizeNext` 处理,只接受站内路径(`/creator` 合法)。
|
||||
|
||||
## 4. 测试
|
||||
|
||||
- **Playwright 新增 `src/e2e/creator.spec.ts`**:
|
||||
1. 主导航存在「创作者中心」tab;`/creator` 上该 tab `aria-current="page"`;hero 与两张卡渲染。
|
||||
2. 未登录(默认 context):数据卡含「登录」链接,`href="/login?next=/creator"`。
|
||||
3. 已登录(`seedSession`,参考 `auth.spec.ts` 既有用法):数据卡显示建设中文案、**无**登录链接。
|
||||
4. 教程卡含指向 `/docs/contribute` 的链接。
|
||||
5. noauth 配置(`playwright.noauth.config.ts`):`/creator` 可达、数据卡无登录链接。
|
||||
- **无 Vitest 单测**:视图只有登录态分支、无业务逻辑,e2e 覆盖更值(作者页有过滤逻辑故有单测,本页无可测逻辑)。
|
||||
- 门槛:`vue-tsc --noEmit` 全量绿;默认 + noauth 两套 e2e 全量绿(存量不回归)。
|
||||
|
||||
## 5. 影响文件(预估)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/app/router/index.ts` | 注册 `/creator` |
|
||||
| `src/app/views/CreatorCenterView.vue` | 新建 |
|
||||
| `src/app/components/AppHeader.vue` | 主导航加 tab + `onCreator` |
|
||||
| `src/e2e/creator.spec.ts` | 新建 |
|
||||
| `docs/CHANGELOG.md`(crearte 仓) | 0.17.0 双语条目(master 顶部为 0.16.0,本功能自 master 切分支) |
|
||||
| `docs/ROADMAP.md`(wrapper 仓) | 文末索引登记本 spec 与实现计划 |
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 主导航出现「创作者中心」tab,激活态与「作品 / 文档」一致。
|
||||
2. `/creator` 渲染 hero + 两张卡,视觉语言与首页一致(同类边框 / 阴影 / 字号,零新 token)。
|
||||
3. 三态分流正确:未登录给登录链接(`next=/creator`);已登录给建设中文案;noauth 可达且无登录死链。
|
||||
4. 教程卡占位文案 + `/docs/contribute` 链接就位。
|
||||
5. `e2e/creator.spec.ts` 新增用例通过,且存量全量(vitest + 默认 / noauth e2e + `vue-tsc`)全绿。
|
||||
6. `crearte-server` / `crearte-deploy` 零 diff。
|
||||
|
||||
## 7. 明确不做(Out of scope)
|
||||
|
||||
- **作品数据面板的真实聚合**:浏览 / 下载 / 评分的查询、图表、口径——后续数据卡范围,需先定数据来源与埋点。
|
||||
- **教程正文**:待维护者文档重构定稿后另行立项;本轮的占位卡即为将来的替换点。
|
||||
- 「我的提交」入口从用户下拉菜单迁移 / 合并(`/submit` 留在原处)。
|
||||
- 导航贴纸计数、hero 统计条等首页既有元件的复制。
|
||||
- 任何后端 / 部署 / 数据迁移改动。
|
||||
@@ -0,0 +1,64 @@
|
||||
# P5 收藏/评分 设计 / Favorites & Ratings Design
|
||||
|
||||
- **状态**:设计已批准(2026-09-30,§1-§3 逐节确认)。
|
||||
- **路线图**:`docs/ROADMAP.md` P5(第二波)。依赖 P1(写侧完成,可并行 P2)。
|
||||
- **范围定性**:新数据表(favorites + ratings)+ 用户态 API + 目录「热门」排序 + 详情/账号页 UI。**crearte-deploy 零改动**(nginx `/api/` 反代既有,新路由自动透传)——路线图"三仓"措辞按本 spec 修订为两仓。
|
||||
|
||||
## 1. 背景与意图
|
||||
|
||||
读者目前对作品只能"看"。P5 加上两类反应:**收藏**(布尔、幂等)与 **5 星评分**(一人一分,可改可撤),并让目录支持 **热门排序**:贝叶斯均分(单人满分霸榜被先验压平)+ 收藏对数权重。成功标准:dev 栈冒烟里"注册→收藏→评分→列表聚合与 ETag 变化→第二用户评分均值变化→我的反应→取消/幂等→校验与鉴权"全链成立;前端 typecheck/build 全绿。
|
||||
|
||||
约束(与维护者确认):
|
||||
- 键是**账号 uuid**(`users.id`),不是 username;改用户名不影响既有反应。
|
||||
- **下架作品的反应行保留**(不删、不级联到作品删除语义之外)。
|
||||
- 写端点全部 `RequireUser` + 按 IP 限流(30/min),作品必须存在且可见否则 404。
|
||||
- `ratingCount==0` 不显示均分;静态源(index.json 无反应字段)视为 0 优雅降级。
|
||||
- schemaVersion 维持 2:新增字段全部可选,老断言(`assertGameSummary`)天然容忍。
|
||||
|
||||
## 2. 方案选型
|
||||
|
||||
| 方案 | 内容 | 取舍 |
|
||||
|---|---|---|
|
||||
| **A. 两表 + 读侧嵌入 DTO(已选)** | favorites / ratings 各一张,PK (user_id, work_id);列表/详情返回服务端聚合;热门分前端纯函数计算 | 语义直白、可反查(谁收藏了/该作品几星);聚合是 O(表) 全量 map,千级作品无压力 |
|
||||
| B. 单表 interactions(kind) | 一张表 kind∈{favorite,rating} | 省一张表但 CHECK/语义纠缠,聚合查询反而绕 |
|
||||
| C. 服务端算热门分返回排序 | /api/games?sort=hot | 与现"前端纯函数过滤排序"架构冲突,且 ETag 会随排序参数碎片化 |
|
||||
|
||||
**ETag 正确性**是方案 A 的关键暗礁:反应一改,旧列表/详情 ETag 若不变,304 将回吐过期聚合。对策:读侧 ETag 输入并入反应水位(Postgres 用两表 count + hashtext 校验和 + 评分和;memory 用等价计数器摘要)。
|
||||
|
||||
## 3. 设计详述
|
||||
|
||||
### 3.1 server — 数据与仓库(crearte-server 0.11.0)
|
||||
- `0009_favorites_ratings.sql`:两表 + `work_id` 索引(FK 指 users/works,`ON DELETE CASCADE`;users 无删除路径故 favorites 行为事实不可变)。
|
||||
- `ReactionRepository`:`SetFavorite / SetRating / DeleteRating / ReactionFor(user,work) / AggregateFor(work) / AggregateAll() / ListByUser(user) / Watermark()`;Postgres(DBTX,双查询拼聚合)+ Memory(`map[[2]string]`,`WithTx` 快照补 favorites/ratings)。
|
||||
- `ContentRepository` 接口加 `Reactions()`;`PostgresContentStore`、`txContent`、`MemoryContentStore` 三处接线。
|
||||
|
||||
### 3.2 server — 服务与 API
|
||||
- `ContentService`:
|
||||
- `ListPublished` 返回值加 `map[string]model.WorkReaction`,ETag 摘要并入 `Watermark()`;
|
||||
- `GetPublishedDetail` 返回加 `model.WorkReaction`,ETag basis 追加 `favCnt|rateCnt|sum`;
|
||||
- 写侧:`SetFavorite(ctx,userID,workID,on)` / `RateWork(...,score)` / `UnrateWork` —— 先查 `GetByID + Visible()`(不可见 → `ErrContentNotFound`),再落库,返回 `dto.ReactionView`。
|
||||
- `dto`:`GameSummary` 增 `ratingAvg *float64 omitempty / ratingCount / favoriteCount`(count 为 0 时 omitempty,与静态缺省同形);新 `ReactionView {favoriteCount, ratingCount, ratingAvg?, favorited, rated, score?}`;`MeReactionsResponse {favorites []string, ratings map[string]int}`。均分保留 1 位小数(`math.Round(x*10)/10`)。
|
||||
- 路由(`router.go` 常量 + 注册):
|
||||
- `PUT|DELETE /api/games/:user/:slug/favorite`
|
||||
- `PUT /api/games/:user/:slug/rating`(body `{"score":1..5}`,非法 400)/ `DELETE` 同径撤
|
||||
- `GET /api/me/reactions`
|
||||
- 全部 `RequireUser`;写路径共享 `reactionLimiter = NewRateLimiter(30, time.Minute)`;handler `Reactions` 新文件,`Deps.Reactions` 装配(cmd/serve.go)。
|
||||
|
||||
### 3.3 web — 排序与反应 UI(crearte 0.16.0)
|
||||
- `data/types.ts`:`GameSummary` 三个可选字段;`apiRepo` 断言容忍(不加必选)。
|
||||
- `lib/filter.ts`:`SortKey` 增 `'hot'`(默认仍 `new`);查询白名单同步;纯函数
|
||||
`hotScore = (5·4 + Σ)/(5 + n) + 0.5·ln(1 + favoriteCount)`,`Σ = ratingAvg×ratingCount`;排序比较 `hotScore(b)-hotScore(a) || addedAt desc || id asc`,浮点容差 1e-9 回退。
|
||||
- `components/ResultMeta.vue`:下拉加 `<option value="hot">热门</option>`(在「最新收录」之后)。
|
||||
- 新 `lib/reactions.ts`:封装 fetch(Bearer 取 `session.getToken()`;base 与 data/apiRepo 同源 `VITE_API_BASE_URL`),导出 `toggleFavorite/rate/unrate/fetchMine`,`enabled` 判空 base。
|
||||
- 新 `components/GameReactions.vue`:♥ 收藏钮(`aria-pressed`)+ 五星条(点亮=floor 均值;点击=打分,再点当前分=撤)+ 计数文案 `{{avg}} · {{count}} 人评分 · {{fav}} 收藏`;匿名点击跳 `/login`(带 `?next=`);操作后即地用 `ReactionView` 更新。挂载于 GameView 标题 meta 区。
|
||||
- `GameCard.vue`:统计徽章行 `★ 4.3 · 12 人 · ♥ 30`(仅显示存在的聚合);点击仍进详情(徽章非交互)。
|
||||
- `AccountView.vue`:新「我的收藏」section —— 列表(链接 + 取消收藏钮)+「我的收藏评分」紧凑列表;空态「还没有收藏或评分。」
|
||||
|
||||
### 3.4 明确不做
|
||||
作品删除级联语义变更、username 级联、防刷(仅 IP 限流)、评分分布图、匿名半星、分页排序(客户端全量前提不变)。
|
||||
|
||||
## 4. 测试与验收
|
||||
- server:repository memory + pg-gated 测试(幂等、撤、聚合、Watermark 变);api httptest(401/404/400、PUT→DELETE 链、`/api/me/reactions` 形状、ETag 随反应变);`gofmt/vet/build/test` 经 docker golang:1.24 全绿。
|
||||
- web:vitest(hotScore 纯函数含压平/对数/容差用例、排序稳定性、GameReactions 交互 mock fetch、GameCard 徽章渲染);`typecheck`+`build` 绿。
|
||||
- live:dev 栈冒烟全链(见实现计划任务 7)。
|
||||
- 两仓 `feat/favorites-ratings` → 各自 master `--no-ff` 合并推送;wrapper 登记 ROADMAP P5 完成 + CHANGELOG 0.2.3。
|
||||
@@ -0,0 +1,72 @@
|
||||
# P6 hosted 作品投稿 · 设计 spec(方案 A:自托管内嵌)
|
||||
|
||||
日期:2026-09-30 | 决策人:owner(选 A)| 前置勘查:控制者本会话完成,证据均带 file:line。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
作者把游戏部署在别处(itch.io / 自建站),投稿填一个 https URL,平台站内用沙箱 iframe 播放;打通的是「入口」,播放机件早已全存在。
|
||||
|
||||
## 1. 现状勘查(grounded)
|
||||
|
||||
**后端(crearte-server)hosted 半边已通:**
|
||||
- `works` 表(migration 0002)含 `runtime IN ('external','virtual','hosted')`、`hosted_url`(CHECK https)、`play_origin`、`fallback IN ('external','hosted','none')`、`entry`。
|
||||
- 校验已认:`service/validate.go:133-141` —— `hosted` 合法 runtime,且要求 `HostedURL` 匹配 `payloadHTTPSPattern`。
|
||||
- **落库映射不缺**:`service/import.go:39 PayloadToWork` 已映射 `PlayOrigin/HostedURL/Fallback/Entry`(此前「Approve 落库缺口」系误报——grep 打在 content.go 而映射在 import.go);`repository/work.go:73/96/109` Create/Upsert/UpdateMetadata 全列含 `hosted_url`。
|
||||
- 提交创建 `content.go:327-358`:virtual 必须有 bundle;hosted 无 bundle 要求但也**未拒绝**携带 bundle;metadata_change 对 runtime **无不可变性校验**(作者可借元数据变更改 runtime)。
|
||||
- `PlaySubdomain(workID)`(`namespace.go:16`)确定性派生 sha256 前 8 字节 hex,对全部 runtime 生效;hosted 不需要平台签发子域(播放源即 hostedUrl),playOrigin 可显式覆盖(CONTRIBUTING:42 已写明)。
|
||||
|
||||
**前端(crearte)播放端全存在、入口焊死:**
|
||||
- `GameView.vue:30`:`playable = virtual || hosted`;`runtime/host/GameHost.vue:98-101`:iframe `:src="iframeSrc"`、`:sandbox`、`:allow`、`referrerpolicy="no-referrer"`、降级外链按钮(`:108` `degradedToExternal`)。
|
||||
- `runtime/host/useGameFrame.ts:58/159/162`:hosted 目标直接置 `ready`(不等 agent:boot),3 秒未收到 `agent:boot` 仅 console warn(桥不存在属预期)。
|
||||
- `types.ts:16`:`GameRuntimeMode` 三值齐全;但 `SubmitFormView.vue:33` 表单 runtime 仅 `external|virtual`,`:144-147` 对 hosted 作品预填**直接拒绝**(防元数据变更静默降级——该风险根源在后端缺 D-B,见下)。
|
||||
- 内容层提交 payload 的 FE 类型同样只两档(SubmitFormView:144 注释)。
|
||||
|
||||
**deploy / CSP:**
|
||||
- `sw/csp.ts:29`、`serve-runtime.mjs:41` 的 `frame-src 'none'` 是**运行时子域页面自身**的 CSP(管 play 页里再嵌 iframe),不是主站宿主页面——hosted iframe 挂在主站页面,不受此约束。
|
||||
- deploy 仓 grep 无主站 CSP frame-src 下发(nginx 模板未含);验收腿 T5 实核生产响应头。
|
||||
|
||||
**结论**:方案 A 的真实工作量 = 后端两处校验收紧 + 前端表单第三档 + 预填解锁 + 测试与验收,播放链路零改动。
|
||||
|
||||
## 2. 决策(D-A…D-F)
|
||||
|
||||
- **D-A hostedUrl 政策**:必须 https(已有校验复用);**任意域名**、不做白名单——第三方禁嵌(X-Frame-Options/CSP frame-ancestors)平台不可预判,由播放端既有 fallback 链兜底(降级外链)。iframe 安全参数沿用现状(sandbox 旗标集 + no-referrer + 外链 `rel=noopener noreferrer`),不扩权。
|
||||
- **D-B runtime 不可变**(根治,后端):`metadata_change` 提交的 `payload.runtime`(经 `RuntimeOrDefault`)与作品现存 runtime 不一致 → `ValidationError{Fields:["runtime: immutable on metadata_change"]}`。此后前端 `:144-147` 拒绝分支删除(表单能表达 hosted,预填安全)。收紧只作用于**新提交**:审批路径不复跑 ValidateSubmission(Approve 只做 ParseEnvelope+落库,`content.go:596+`),存量 draft 无 retroactive 失效。
|
||||
- **D-C hosted 语义纯度**:`runtime=hosted` 且带 `bundle_upload_id` → `ValidationError`(hosted 不吃平台文件);`features` 允许提交但桥不存在(armBridgeWarn 已容忍);`entry`/`playSubdomain` 对 hosted 无意义,存而不使用,不报错。cover 上传与 runtime 无关,照常允许。
|
||||
- **D-D 审核面最小改动**:AdminView 待审卡片对 hosted 提交展示 `hostedUrl` 文本行(不内嵌播放预览——审核者安全 + 最小变更)。
|
||||
- **D-E fallback**:复用现有列;表单在 hosted 档下提供 fallback 选择(`external|hosted|none`),默认引导 `external`(需 url 字段配合,表单校验:fallback=external 时 url 必填)。
|
||||
- **D-F deploy 仅核证**:实探主站 CSP 头;无 frame 限制则零改动记录在案,有则补模板并复验(T5)。
|
||||
|
||||
## 3. 范围
|
||||
|
||||
**server(→0.13.0)**
|
||||
1. D-B:metadata_change runtime 不可变校验 + 单测。
|
||||
2. D-C:hosted 禁 bundle 校验 + 单测。
|
||||
3. 集成(`-count=1` 空库腿):hosted new_work 全路径 create→approve→`works.hosted_url/play_origin/fallback` 落库读回;GET 公开作品含 `runtime=hosted` + `hostedUrl`。
|
||||
4. CHANGELOG 中英成对。
|
||||
|
||||
**crearte(→0.19.0)**
|
||||
1. FE 内容层提交 payload 类型三档 + `hostedUrl`/`fallback` 字段。
|
||||
2. SubmitFormView:第三 radio「自托管内嵌」+ hostedUrl 输入 + fallback 选择;校验(https、hosted 必填 hostedUrl、fallback=external 时 url 必填);预填解除 `:144-147` 拒绝、正确回填 hosted 字段。
|
||||
3. vitest:表单 hosted 分支(提交形状 / 校验失败面 / 预填回填)。
|
||||
4. e2e:admin-flow 夹具补 hosted 投稿流(表单提交形状断言);主套件全绿。
|
||||
5. CHANGELOG 中英成对。
|
||||
|
||||
**deploy**:仅 T5 核证,预期零改动。
|
||||
|
||||
**不做**(明确排除):URL 抓取/健康探测、域名信任列表、hosted 的版本更新(new_version 仍 virtual-only)、转码代理、离线缓存、播放端任何改动。
|
||||
|
||||
## 4. 红线
|
||||
|
||||
- 不动 `full-loop.spec.ts` / `playwright.stack.config.ts` / 运行时三件套(sw/agent/bootstrap)。
|
||||
- 不动生产凭据与 `.env`;deploy 改动(如有)只出模板级。
|
||||
- server 迁移零新增(hosted 列 0002 起就在)。
|
||||
- 前端不新增后端端点依赖;管理面仅 D-D 一行展示。
|
||||
|
||||
## 5. 验收(控制者本机,同 P7 六腿口径)
|
||||
|
||||
1. server 容器四连(gofmt/vet/build/test)RC=0。
|
||||
2. fresh 空库 `-count=1` 集成全过、零 FAIL 零静默 skip。
|
||||
3. curl 冒烟真栈:hosted 草稿→submit→approve→DB 行 `hosted_url` 非空、`GET /api/games/<id>` 含 runtime/hostedUrl;负面:runtime 篡改元数据变更 400、hosted 带 bundle 400。
|
||||
4. FE:vitest 全量 / typecheck / admin-flow / 主套件 RC=0。
|
||||
5. 真栈 full-loop 若分支后端被触及照跑 1 passed。
|
||||
6. 双路独立审查(spec→代码逐条)PASS 后合主,wrapper 记账(ROADMAP P6 行、CHANGELOG 0.2.7、spec/plan 索引)。
|
||||
@@ -0,0 +1,206 @@
|
||||
# P10 用户体验增强(第一批:日常交互包)· 设计 spec
|
||||
|
||||
日期:2026-10-01 | 决策链:owner 在 UX 摸底清单(第一/二/三梯队)后指令「按序实现」。第一批 = 第一梯队六项:脏表单守卫、标签可点、全局 toast、分享/复制链接、页头下拉外点关闭、最近玩过。后续批次(P9-B 可访问、OG/播放计数、暗色/创作者数据/目录分页)另行立项。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
把用户每天都会撞上的六个交互缺口一次补齐:投稿表单不再静默丢输入、标签变成可点的发现入口、全站有统一的操作反馈面(toast)、作品页能一键复制链接分享、页头菜单响应外点/Esc、落地页出现「继续游玩」。
|
||||
|
||||
## 1. 现状勘查(grounded,2026-10-01)
|
||||
|
||||
- **无脏表单守卫**:`SubmitFormView.vue`(502 行)grep 不到 `beforeunload` / `onBeforeRouteLeave` / dirty 跟踪。表单含 bundle/cover 上传、七键 CSP、hosted 三档;`save()` 成功路径 `await router.push('/submit')`(:295 附近)。已有回填抑制惯例:`suppressAadWatch`(:114-121)+ `loadExisting` 的 try/finally(:186-227)。
|
||||
- **标签是死的**:`GameView.vue:88-95` 与 `GameCard.vue:70-79` 的 tags 均为纯 `<span>`;而目录过滤已支持 `?tag=` 逗号多值 AND(`filter.ts:20,26` `parseFilterState` / `toQuery`)。
|
||||
- **无 toast 机制**:`components/ui/` 只有 Base 全家桶(Button/Checkbox/Input/Pagination/Select/Tabs/Textarea/FileInput);各页反馈是自带内联 `role="alert"` 段(AccountView:216、AdminUsersView:111、AdminView:137 等各写一套)。全站无 clipboard 使用。
|
||||
- **页头下拉不响应外点/Esc**:`AppHeader.vue:79` 用 `<details ref="detailsRef">`,仅 `watch(route.fullPath)` 收起(:11-13);点页面其他地方或按 Esc 不关。
|
||||
- **无「最近玩过」**:前端除 `lib/reactions` 外零 localStorage 消费;`GameHost.vue` 的 phase 机(booting/ready/degraded/error)是天然的「真玩过」信号点(`useGameFrame.ts:30`)。落地页结构:hero(:38-56)→ 精选(:58-72)→ 投稿与文档(:74-88),`FEATURED_LIMIT = 6`。
|
||||
- **兼容钉桩**(一字不许动):
|
||||
- `landing.spec.ts:6` home 标题恰为 `crearte 创艺`;`:60-68` 精选区 `a[href^="/games/"]` **toHaveCount(6)** 且逐 href 相等;`:148-156` 标题大纲层级(h1 crearte / h2 精选 / h3 2048 / h2 投稿与文档 / h3 ×2)。
|
||||
- `merge-repo.spec.ts:42` 目录页 `a[href^="/games/"]` toHaveCount(20)。
|
||||
- tag 链接 href 渲染为 `/games?tag=…`——`?` 不匹配 `^="/games/"` 前缀选择器,两处计数天然免疫(已核实)。
|
||||
- e2e 对话框惯例:Playwright 无监听时自动 dismiss;`submit-flow.spec.ts:172`、`full-loop.spec.ts:64` 等已用 `page.once('dialog', d => d.accept())` 接 `save()` 里既有的「确认提交审核?」confirm。
|
||||
- **localStorage key 惯例**:`crearte.auth.session.v1`(helpers.ts `SESSION_KEY`)。
|
||||
- **样式 tokens**:`shadow-hard(-sm/-lg)`、`bg-paper/surface/ink/highlight/accent/success`、`btn-ink/btn-surface/lift`(main.css:4-25,63-77);header z-40、下拉 z-50、GameHost 全屏 z-50(GameHost.vue:79)。
|
||||
- **版本记账惯例**:`package.json` version 恒 0.1.0 不动,版本以 CHANGELOG 为准(P9 = 0.22.0 → 本批 0.23.0)。
|
||||
|
||||
## 2. 决策记录
|
||||
|
||||
| # | 岔路 | 决定 | 理由 / 放弃项 |
|
||||
|---|------|------|--------------|
|
||||
| D-A | 脏守卫交互形态 | SPA 内离开用 `window.confirm`(`onBeforeRouteLeave` 返回其布尔值);浏览器级离开用 `beforeunload`(仅 dirty 时挂监听) | 与 `save()` 既有「确认提交审核?」confirm 同一形态,零新组件。放弃自绘弹层(B 批可访问时再统一升级不迟)。 |
|
||||
| D-B | dirty 判定 | 深度 watch `[form, kind, bundle.upload_id, cover.upload_id]`;`loadExisting` 回填期抑制(与 `suppressAadWatch` 同窗);`prefill` **不**抑制(其触发前提——手输 workId/切 kind——本身已是用户修改);上传成功计入 dirty;`save()` 成功后 `dirty=false` + `suppressLeave=true` 再 push | 回填≠用户修改(P6 既有语义)。suppressLeave 不置位则 save-draft 的 `router.push('/submit')` 会被守卫拦下、Playwright 自动 dismiss 把 e2e 卡死——已核实 submit-flow/full-loop 全部依赖该 push。 |
|
||||
| D-C | toast 架构 | 模块级单例 store(`useToast.ts` 导出 `toasts` ref + `push/success/error/info/dismiss`),`ToastHost.vue` 挂在 `App.vue`;不用 provide/inject | 任意组件(含非 setup 上下文的 lib)可直接 import 调用;测试可直接断言 store。放弃 mitt 等事件总线(零新依赖红线)。 |
|
||||
| D-D | toast 行为参数 | 默认 3000ms、error 5000ms 自动消失;同屏最多 3 条(超限移除最旧);容器 `role="status"` `aria-live="polite"`,右下角 `fixed bottom-4 right-4 z-[70]` | z-[70] 压过 GameHost 全屏 z-50 与下拉 z-50。条数上限防刷屏。 |
|
||||
| D-E | 复制失败兜底 | `navigator.clipboard.writeText` 失败 → error toast「复制失败,请手动复制地址栏链接」;不做 `execCommand` 降级 | localhost 与 prod https 都是 secure context,clipboard API 必可用;execCommand 已废弃。 |
|
||||
| D-F | 复制链接的目标 | `location.origin + router.resolve({ name:'game', params:{user,slug} }).href`(规范站内路径),非 `location.href`(可能带 query/hash) | 分享出去的是干净规范链接。 |
|
||||
| D-G | 标签链接目标 | `RouterLink :to="{ path:'/games', query:{ tag } }"` 单 tag;GameCard 内加 `relative z-10`(卡面有拉伸链接 `after:absolute inset-0`,GameCard.vue:49) | 点击=「看这个 tag 的所有作品」,从当前过滤态整体切换而非叠加(叠加语义留给目录侧栏)。「+N」折叠角标维持 span 不做链接。 |
|
||||
| D-H | 最近玩过记录点 | `GameHost.vue` watch phase 到 `ready` 时 `recordPlay(game.id)`,**`{ flush: 'sync' }`**(T6 审查 re-pin:hosted 目标 restart 内 booting→ready 同 tick 折叠,默认 pre-flush 看不见变化,「重开重新冒泡」对 hosted 不成立;sync 下每步相位变化可见,`if (p==='ready')` 守卫保语义不变,GameHost.test.ts 专项钉桩);**external 作品不记**(不经 GameHost,在站外玩) | 「玩过」以站内运行真就绪为准,booting 误触不算。放弃 OutboundView 记录(拿不到可靠 game id)。 |
|
||||
| D-I | 最近玩过存储 | key `crearte.recent.v1`,值 `[{id, at}]` JSON,cap 12、按 id 去重前插;落地页读取后将 id 对 `repo.listGames()` 结果解析成卡(解析不到的静默丢弃),展示上限 6(对齐 FEATURED_LIMIT 网格) | 只存 id+时间戳不存快照:作品改名/换封面/下架自动跟随或消失,无陈旧数据。解析失败(下架)不显示即诚实。 |
|
||||
| D-J | 落地页条带位置与钉桩 | hero 与精选之间新增 `<section v-if="recent.length" data-testid="recent-strip">`,h2「继续游玩 · RECENTLY PLAYED」沿用精选 h2 的 font-mono 样式;卡 `heading-level=3` | 无记录时整段不渲染 → landing.spec 的 6 卡计数、标题大纲、精选 href 列表全部零触碰(Playwright 每测试全新 context,localStorage 天然干净)。 |
|
||||
| D-K | 下拉关闭机制 | `<details>` 保留,加 `@toggle` 同步 `menuOpen` ref;document 级 `pointerdown`(外点关)+ `keydown`(Esc 关并把焦点还给 summary)监听,`onMounted` 挂 / `onBeforeUnmount` 卸 | 不换组件库、不动既有 route.fullPath 收起逻辑。Esc 还焦是可访问性基线(B 批深化)。 |
|
||||
| D-L | 仓库面 | 纯 crearte 前端;server/deploy 零改动;CHANGELOG 0.23.0(package.json version 不动) | 全部为客户端行为。 |
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 toast 基座(新文件)
|
||||
|
||||
`app/composables/useToast.ts`:
|
||||
|
||||
```ts
|
||||
export type ToastKind = 'success' | 'error' | 'info'
|
||||
export interface ToastItem { id: number; kind: ToastKind; text: string }
|
||||
export const MAX_VISIBLE = 3
|
||||
export const DURATIONS: Record<ToastKind, number> = { success: 3000, error: 5000, info: 3000 }
|
||||
// 模块级 store:toasts ref + push(kind, text, duration?) / success(text) / error(text) / info(text) / dismiss(id)
|
||||
// push 超限移除最旧;setTimeout 自动 dismiss;返回 toast id
|
||||
export function useToast(): { toasts: Ref<ToastItem[]>; push; success; error; info; dismiss }
|
||||
```
|
||||
|
||||
`app/components/ToastHost.vue`:`<Teleport to="body">` 可选(App 内直挂即可,不 Teleport——测试友好);容器 `data-testid="toast-host"` `role="status"` `aria-live="polite"` `class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"`;每条 `data-testid="toast"` `pointer-events-auto border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard`,kind 配色:success `bg-success text-paper`、error `bg-accent text-paper`、info `bg-highlight text-ink`;右侧关闭按钮 `aria-label="关闭提示"`(PhX 图标)。进出场过渡用 Vue `<TransitionGroup>`(name="toast",CSS 位移+透明,尊重既有 `prefers-reduced-motion` 全局分支)。
|
||||
|
||||
`App.vue`:`<AppFooter />` 之后挂 `<ToastHost />`。
|
||||
|
||||
### 3.2 脏表单守卫(SubmitFormView.vue)
|
||||
|
||||
```ts
|
||||
const GUARD_COPY = '表单尚未保存,确定离开吗?未保存的修改将丢失。'
|
||||
const dirty = ref(false)
|
||||
let suppressLeave = false
|
||||
onBeforeRouteLeave(() => {
|
||||
if (suppressLeave || !dirty.value) return true
|
||||
return window.confirm(GUARD_COPY)
|
||||
})
|
||||
watch([form, kind, () => bundle.value?.upload_id, () => cover.value?.upload_id],
|
||||
() => { if (!suppressDirty) dirty.value = true }, { deep: true })
|
||||
// loadExisting:suppressAadWatch 置位处同时置 suppressDirty=true;finally 同窗释放;
|
||||
// 释放后 dirty.value=false(回填完成=干净基线)
|
||||
// bundle/cover 上传成功路径(onBundleFile/onCoverFile try 尾)无需额外代码——upload_id watch 自动触发
|
||||
// save() 成功:dirty.value=false; suppressLeave=true; await router.push('/submit')
|
||||
// beforeunload:watch(dirty) 挂/卸 window 'beforeunload'(handler: e.preventDefault(); e.returnValue='');
|
||||
// onBeforeUnmount 兜底卸载
|
||||
```
|
||||
|
||||
模板层无可见变化(原生对话框)。
|
||||
|
||||
### 3.3 标签可点(GameView.vue + GameCard.vue)
|
||||
|
||||
GameView tags span → RouterLink:
|
||||
|
||||
```html
|
||||
<RouterLink v-for="tag in game.tags" :key="tag" data-testid="tag-link"
|
||||
:to="{ path: '/games', query: { tag } }"
|
||||
class="border-[1.5px] border-ink bg-surface px-2 py-0.5 font-mono text-[0.6875rem] hover:bg-highlight"
|
||||
>{{ tag }}</RouterLink>
|
||||
```
|
||||
|
||||
GameCard visibleTags span → RouterLink,同款样式 + `relative z-10`(拉伸链接之上,与作者链接 :54-58 同法);`+N` 角标维持 span。
|
||||
|
||||
### 3.4 分享/复制链接(GameView.vue)
|
||||
|
||||
meta 段(作者/时长/收录行)之后、description 之前插一行:
|
||||
|
||||
```html
|
||||
<button type="button" data-testid="copy-link" class="btn-surface lift inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold"
|
||||
@click="copyLink"><PhLinkSimple :size="14" weight="bold" aria-hidden="true" />复制链接</button>
|
||||
```
|
||||
|
||||
```ts
|
||||
async function copyLink(): Promise<void> {
|
||||
const url = location.origin + router.resolve({ name: 'game', params: { user: props.user, slug: props.slug } }).href
|
||||
try { await navigator.clipboard.writeText(url); toast.success('链接已复制') }
|
||||
catch { toast.error('复制失败,请手动复制地址栏链接') }
|
||||
}
|
||||
```
|
||||
|
||||
external/virtual/hosted 三档都显示(分享的是站内作品页,与 runtime 无关)。notFound 分支不渲染。
|
||||
|
||||
### 3.5 页头下拉(AppHeader.vue)
|
||||
|
||||
```ts
|
||||
const menuOpen = ref(false)
|
||||
const summaryRef = ref<HTMLElement | null>(null)
|
||||
function onPointerDown(e: PointerEvent): void {
|
||||
if (menuOpen.value && detailsRef.value && !detailsRef.value.contains(e.target as Node)) detailsRef.value.open = false
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && menuOpen.value && detailsRef.value) { detailsRef.value.open = false; summaryRef.value?.focus() }
|
||||
}
|
||||
// onMounted 挂 document 两监听,onBeforeUnmount 卸;<details @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">;
|
||||
// <summary ref="summaryRef">;既有 route.fullPath watch 保留
|
||||
```
|
||||
|
||||
### 3.6 最近玩过(lib/recent.ts + GameHost.vue + LandingView.vue)
|
||||
|
||||
`app/lib/recent.ts`(纯函数 + try/catch 包裹的 storage 读写):
|
||||
|
||||
```ts
|
||||
const KEY = 'crearte.recent.v1'
|
||||
const MAX_RECENT = 12
|
||||
export interface RecentEntry { id: string; at: string } // at = new Date().toISOString()
|
||||
export function listRecent(): string[] // 新→旧 id 列;解析失败/无存储 → []
|
||||
export function recordPlay(id: string): void // 去重前插、截断 MAX_RECENT、写失败静默
|
||||
```
|
||||
|
||||
GameHost.vue:`watch(() => frame.state.value.phase, (p) => { if (p === 'ready') recordPlay(props.game.id) })`(import 走 `../../app/lib/recent`,与 GameView import GameHost 的跨层惯例对称)。
|
||||
|
||||
LandingView.vue:
|
||||
|
||||
```ts
|
||||
const RECENT_LIMIT = 6
|
||||
const recentIds = listRecent() // setup 时读一次;路由重挂载天然刷新
|
||||
const recent = computed(() => {
|
||||
const byId = new Map((games.value ?? []).map((g) => [g.id, g]))
|
||||
return recentIds.map((id) => byId.get(id)).filter((g): g is GameSummary => Boolean(g)).slice(0, RECENT_LIMIT)
|
||||
})
|
||||
```
|
||||
|
||||
模板:hero `</section>` 与精选 `<section class="mt-8">` 之间:
|
||||
|
||||
```html
|
||||
<section v-if="recent.length" class="mt-8" data-testid="recent-strip">
|
||||
<h2 class="font-mono text-[0.6875rem] font-bold tracking-[0.08em]">继续游玩 · RECENTLY PLAYED</h2>
|
||||
<div class="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<GameCard v-for="game in recent" :key="game.id" :game="game" :heading-level="3" />
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## 4. 错误处理与边界
|
||||
|
||||
- localStorage 不可用(隐私模式/配额满):`recordPlay` try/catch 静默;`listRecent` 损坏 JSON → `[]`。条带随之不渲染,无用户可见错误。
|
||||
- clipboard 拒绝权限/非 secure context:走 catch → error toast(D-E)。
|
||||
- toast 计时器:组件卸载不悬空——dismiss 时 clearTimeout;store 模块级长存(App 生命周期=页面生命周期,可接受)。
|
||||
- 守卫与 `readOnly`(pending/approved 详情态):readOnly 表单不可编辑 → dirty 永不置位 → 守卫自然静默;`suppressLeave` 亦覆盖 save 成功路径。
|
||||
- 下拉:菜单项点击→路由变化→既有 watch 收起,pointerdown 先行关闭也不冲突(close 幂等)。
|
||||
- external 作品无 GameHost → 不进最近玩过(D-H 有意为之)。
|
||||
- 最近玩过 id 指向已下架作品 → 解析丢弃(D-I)。
|
||||
|
||||
## 5. 测试计划
|
||||
|
||||
**vitest(happy-dom,基线 551 全绿不回退)**
|
||||
|
||||
- 新 `useToast.test.ts`:push/success/error/info 形状、自动消失(`vi.useFakeTimers` 按 kind 时长)、超 3 条移最旧、dismiss 清计时器、id 递增。
|
||||
- 新 `ToastHost.test.ts`:三条 kind 渲染与配色类、`aria-live="polite"` `role="status"`、关闭按钮触发 dismiss、空 store 零渲染。
|
||||
- 扩 `SubmitFormView.test.ts`:①用户改 name → dirty(经 `onBeforeRouteLeave` 行为断言:mock `window.confirm` 返 false → 导航被拒;返 true → 放行)②`loadExisting` 回填不 dirty ③save 成功 push 不触发 confirm(suppressLeave)④dirty 时 beforeunload 监听已挂、干净时未挂(`window.addEventListener` spy 或 dispatchEvent 验 preventDefault)⑤上传成功置 dirty。
|
||||
- 扩 `GameView.test.ts`:tag 链接 href=`/games?tag=<t>`、copy-link 按钮存在且点击后 clipboard.writeText 收到 `http://localhost/games/fixture/minimal` 形规范 URL(mock navigator.clipboard)、成功→toast store 有「链接已复制」、reject→error toast;notFound 分支无 copy-link。
|
||||
- 扩 `GameCard.test.ts`:visibleTags 渲染为 `a[href^="/games?tag="]` 且带 `relative z-10`;`+N` 仍为 span;无 tag 零链接。
|
||||
- 新 `AppHeader.test.ts`:open 后 document 外 pointerdown → closed;Esc → closed 且焦点回 summary;菜单内点击不关(contains 分支);路由变化收起(既有行为回归)。
|
||||
- 新 `recent.test.ts`:recordPlay 前插去重截 12、listRecent 损坏 JSON → []、空存储 → []、写异常静默(mock localStorage throw)。
|
||||
- 扩 `GameHost.test.ts`:phase → ready 触发 recordPlay(mock lib/recent),booting/error 不触发,restart 再 ready 再记录。
|
||||
- 扩/新 `LandingView.test.ts`(如无则新建,mock 惯例照抄 AuthorView.test.ts):有记录且可解析 → `data-testid="recent-strip"` 渲染、上限 6、顺序新→旧;记录指向不存在 id → 不渲染该卡;零记录 → 整段不渲染。
|
||||
|
||||
**e2e(Playwright)**
|
||||
|
||||
- `landing.spec.ts` **既有全部一字不动**(D-J 保证:新 context 无 localStorage → 条带不渲染)。
|
||||
- 扩 `submit-flow.spec.ts` 一条「脏表单守卫」:seedSession + installSubmissionApi → goto /submit/new → fill 展示名称 → 点页头「作品」链接 →(无监听自动 dismiss)URL 仍 /submit/new → `page.once('dialog', accept)` 再点 → URL /games → 返回 /submit/new(此时干净,goto 无守卫)确认填表后 save-draft 仍直通 /submit(suppressLeave 回归)。
|
||||
- 新 `e2e/ux.spec.ts`(主套件,4173)四条:
|
||||
① tag 点击:goto /games/fixture/2048 → 记录首个 tag 文本 → 点击 → URL `/games?tag=<t>` 且结果计数 ≥1;
|
||||
② 复制链接:`context.grantPermissions(['clipboard-read','clipboard-write'])` → goto 作品页 → click `[data-testid=copy-link]` → `[data-testid=toast]` 含「链接已复制」→ `navigator.clipboard.readText()` 等于 `http://localhost:4173/games/fixture/2048`;
|
||||
③ 下拉:seedSession → click summary(菜单开)→ Escape → 菜单关;再开 → 点 main 区域 → 菜单关;
|
||||
④ 最近玩过:`openGame(page, '2048')`(helpers 既有,data-ready 即 phase ready)→ goto / → `[data-testid=recent-strip]` 可见且含 href `/games/fixture/2048` 的卡;再 openGame 'a-dark-room' → goto / → 2048 卡序在 a-dark-room 之后。
|
||||
- noauth 套件:零新增零改动(条带纯本地、无 auth 依赖;noauth 4 条基线不动)。
|
||||
|
||||
**验收腿(控制者跑,全绿才合并)**:`npm test`(vitest ≥551+新增全过)、`npm run typecheck` 零错、`npm run build` OK、`npm run e2e`(72+1skip 基线 + 新增全过)、`npm run e2e:noauth` 4。
|
||||
|
||||
## 6. 版本与记账
|
||||
|
||||
crearte CHANGELOG `0.23.0`(Added:toast 基座/标签可点/复制链接/最近玩过;Fixed 或 Added:脏表单守卫/下拉外点关闭——按 Keep a Changelog 归类,守卫与下拉归 Added「交互护栏」口径亦可,实施时统一为 Added + Fixed 两段);`package.json` version 不动(既有惯例)。wrapper:本 spec + `docs/plans/2026-10-01-p10-ux-daily-interaction.md` + ROADMAP 新增 P10 行与索引登记。server/deploy 零改动。分支 `feat/p10-ux-daily`,合并 `--no-ff` 后删分支。
|
||||
@@ -0,0 +1,57 @@
|
||||
# P8 长尾打包(第一批)· 设计 spec
|
||||
|
||||
日期:2026-10-01 | 决策:owner「可以」批准本批范围与顺序(②⑤ 后端小项+文案,① 权限开关 UI)。
|
||||
本批**不含** ③账号注销(新功能,待产品决策)与 ④静态兜底目录(按需)。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
清账批:把前几波刻意记为遗留的碎片收干净——后端三处 triage 小修、两份 CHANGELOG 去模板腔、前端把后端已支持但 UI 未开放的五个权限开关补齐。
|
||||
|
||||
## 1. 现状勘查(grounded,2026-10-01)
|
||||
|
||||
### ②-1 `games.Detail` 400 细分
|
||||
`handler/games.go:44-50`:user/slug 格式校验失败时统一 `WriteError(400, "invalid_request", "invalid game id")`——调用方无法区分「user 格式非法」「slug 格式非法」。校验辅助已存在:`service.UsernamePattern`(`namespace.go:11`)、`SlugPattern`(`:12`)。改进:逐字段细分错误消息(如 `user: invalid format` / `slug: invalid format`),沿用 `ValidationError.Fields` 的既有表达风格。
|
||||
|
||||
### ②-2 admin 工作路由 slug 校验
|
||||
`router.go:26-29` 四条路由(unpublish/republish/features/revoke)→ `handler/admin.go` 各 handler 直接调 `adminWorkID(c)`(`:115-117` = `JoinWorkID(c.Param("user"), c.Param("slug"))`),**无格式校验**:非法格式会带进 service/DB,最终多半落 404(掩盖了「格式错」与「不存在」的区别)。对照公开面 `games.Detail:47` 是有校验的。改进:四条 admin 路由的 handler 入口补 `UsernamePattern/SlugPattern` 前置校验,非法即 400。
|
||||
|
||||
### ②-3 approve 同名竞态测试
|
||||
`JoinWorkID(user, slug)` 同名撞车在 service 层有处理(approve precheck + `Works().Create` 的 `ErrConflict`→`ErrWorkIDTaken`),但无**并发**针对性测试。补一个并发 approve 同名 new_work 的竞态测试,断言最终只有一条成功、其余拿 `ErrWorkIDTaken`/`ErrSubmissionConflict`,且库中不出现重复行。
|
||||
|
||||
### ⑤ CHANGELOG 模板腔
|
||||
- `crearte/docs/CHANGELOG.md` 头部仍写「本**模板**的重要变更建议统一记录在此文件中」+「参考了 Keep a Changelog 的思路,也可以根据团队习惯调整」——仓库已迭代到 0.19.x,是项目不是模板。
|
||||
- `crearte-server/docs/CHANGELOG.md` 头部「本项目的重要变更统一记录在此文件中」尚可,但英文行 "should be documented" 偏模板口吻。
|
||||
改进:两仓头部改为项目自己的口径(保留 Keep a Changelog 的引用作格式出处说明),去掉「模板/建议/可以调整」的占位口吻。
|
||||
|
||||
### ① 权限开关 UI 全量
|
||||
后端 feature key 全集(`service/validate.go:60`):`eval, inlineScript, inlineStyle, wasm, coop, fullscreen, gamepad`(7 个)。
|
||||
前端 `content/features.ts` 的 `EditableFeatures`/`FEATURE_ITEMS` **只开放前 2 个**,注释明写「其余 flag(inline…epad)schema 与后端均已支持,但 UI 暂不开放(YAGNI):默认值已覆盖绝大多数作品,按需再添」。
|
||||
本批补齐剩余 **5 个**:`inlineStyle`、`wasm`、`coop`、`fullscreen`、`gamepad`。三处 UI 共用 `FEATURE_ITEMS`(投稿表单勾选区、admin 作品管理展开行、审核详情只读展示),故扩 `EditableFeatures`/`FEATURE_ITEMS`/`collectFeatures`/`featuresToForm`/`hasAnyFeature` 即三处同步生效;后端零改动。
|
||||
|
||||
## 2. 决策
|
||||
|
||||
- **D-1 错误粒度**:`games.Detail` 沿用 400 + `invalid_request`,仅**细分 message**(`user:`/`slug:` 前缀)——不改状态码、不加新错误码,最小变更;既有测试只断言 400 不断言文案者不受影响,若有断言文案则同步更新(实现者核)。
|
||||
- **D-2 admin 校验位置**:在 **handler 入口**补校验(与公开面 `games.Detail` 同层),不在 `adminWorkID` 内改(其为纯拼接 helper,改它会波及调用点语义);四条 handler 复用同一小段校验。
|
||||
- **D-3 开关默认值全 false**:五个新开关默认**关闭**(与现有一致——「默认值已覆盖绝大多数作品」)。`collectFeatures` 输出**七键齐全**(与既有「两键总是存在」纪律一致,后端据此区分「显式空对象=不放宽」vs「旧提交无键=保留」)。
|
||||
- **D-4 文案优先**:五个新开关的 `hint` 要写清风险与适用场景(这是①的主要工作量);文案风格沿用既有两条。
|
||||
- **D-5 ⑤ 仅头部**:只改 CHANGELOG 头部占位话术,不动历史条目。
|
||||
|
||||
## 3. 范围
|
||||
|
||||
**server(→0.14.0)**:②-1 错误细分、②-2 admin 校验、②-3 竞态测试、⑤ 头部文案;CHANGELOG 0.14.0 记 ②+⑤。
|
||||
**crearte(→0.20.0)**:① 五开关 UI 全量(三处同步 + 测试)、⑤ 头部文案;CHANGELOG 0.20.0 记 ①+⑤。
|
||||
**deploy**:零改动。
|
||||
|
||||
## 4. 红线
|
||||
|
||||
- 不动 approve 落库逻辑本身(②-3 只**加测试**,不改行为);不动路由常量值;不动 CSP 执行面(① 只补 UI,运行时的 flag 消费逻辑已存在)。
|
||||
- 不新增依赖、不新增迁移。
|
||||
- 前端三处 UI 一致性:投稿表单 / admin 展开行 / 审核只读展示由同一 `FEATURE_ITEMS` 驱动,不得为某一处另开硬编码。
|
||||
|
||||
## 5. 验收
|
||||
|
||||
1. server 容器四连 RC=0;新增/改动的单测全绿;②-3 竞态测试在 `-race` 下通过(若仓内既有 race 跑法,沿用)。
|
||||
2. 空库 `-count=1` 集成腿全过、零静默 skip。
|
||||
3. curl 冒烟:`GET /api/games/<非法user>/x` 与 `/x/<非法slug>` 各返 400 且 message 可区分;admin 四路由对非法 slug 返 400(负例)。
|
||||
4. FE:vitest / typecheck / admin-flow / 主套件 RC=0;五开关在投稿表单可见可勾选、载荷含七键、admin 展开行可改、审核详情只读展示。
|
||||
5. 双路独立审查(**依据 spec §1-§3 全量**,非仅任务书)PASS 后合主;wrapper 记账(ROADMAP P8 行分批完成、CHANGELOG 0.2.8、spec/plan 索引)。
|
||||
@@ -0,0 +1,102 @@
|
||||
# P8 长尾打包(第二批:③账号注销 + ④静态兜底目录导出)· 设计 spec
|
||||
|
||||
日期:2026-10-01 | 决策:owner 指令「实现 roadmap 所有待办事项」(03:27),③④ 由「待产品决策/按需」转为**批准开工**;产品细节按本文默认拍板并记录,owner 可事后推翻。
|
||||
范围同时捎带 P7 遗留小尾(用户管理面板 400/404 中文文案分支,§5)。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
③ 给账号一个不可逆的「注销」出口:自助 API + CLI,墓碑匿名化 + 内容级联下架;④ 把线上目录导出成静态 JSON(与 `public/data` 同 schema),补齐 no-backend 部署的静态兜底数据通道;尾部:admin 用户面板错误文案中文化。
|
||||
|
||||
## 1. 现状勘查(grounded,2026-10-01)
|
||||
|
||||
- FK 面:`submissions.submitter_id`/`uploads.owner_id`/`works.owner_id` 均**无 ON DELETE**(物理删用户必炸);`favorites`/`ratings` 已 CASCADE;`audit_log.actor_id` 0010 注释明写「不加 FK:用户注销后审计仍可读」——作者已为注销预留语义。
|
||||
- `works` 有 `published_at/delisted_at`;`ListVisibleWithCurrentVersion` 过滤 `published_at IS NOT NULL AND delisted_at IS NULL`——置 `delisted_at` 即全站隐身(目录 404、详情 404、游玩 404)。
|
||||
- 会话失效机件现成:`token_version` 每次 +1,`Authenticate` 比对 `claims.TokenVersion`。
|
||||
- submissions repo 已有 `SetStatus(id, from, to)`(pending→rejected 可走)、`Delete`(仅 draft/pending)、`ListBySubmitter`;uploads repo 已有 `ListBySubmission`;`DeleteSubmission` 的「先删行、后 best-effort 删对象」模式可仿。
|
||||
- 静态兜底通道现状:前端 `StaticContentRepository` 读 `/data/index.json` + `/data/games/<user>__<slug>.json`(schemaVersion 2,`assertGamesIndex/assertGameDetail` 校验,virtual 详情必须带 `bundle` 对象);`MergeContentRepository` 已做「API 挂→降级纯静态」。**缺的只是把线上目录灌成这套静态文件**——后端 CLI 导出是最短路径(`handler.Games.summary/detail` 映射已存在)。
|
||||
- FE:`AccountView` 已有改密/我的反应/会话三段,注销放第四段「危险区」;`auth/client.ts` 有 send 基建;`AdminUsersView` 的 `actionError` 对非 `last_admin` 错误目前直出后端英文原文。
|
||||
|
||||
## 2. 决策记录
|
||||
|
||||
| # | 岔路 | 决定 | 理由 / 放弃项 |
|
||||
|---|------|------|--------------|
|
||||
| D-A | 注销=物理删行 or 墓碑 | **墓碑匿名化**(users 加 `deleted_at`,PII 洗掉,行保留) | FK 无级联,物理删必炸或需大迁移;0010 注释的预留语义即「人会消失、记录要在」;放弃硬删(连坐删作品=删作者创作物,更糟)。 |
|
||||
| D-B | 注销后名下作品 | **全部自动下架**(delisted_at 置位),行保留 | 「注销=不再对外提供我的作品」的合理默认;目录/详情/游玩同时隐身;不物理删 works(历史 id/命名空间保留)。 |
|
||||
| D-C | 在途投稿 | draft 删除(连 upload 行 + best-effort 删对象);pending 自动 rejected(note「账号注销自动关闭」) | 复用现成 repo 方法;审核队列不留死人名条目。 |
|
||||
| D-D | admin 注销 | **唯一 admin 拒绝(409 last_admin,复用护栏)**;非唯一 admin 允许(墓碑时 role→user) | 与 P7 降级护栏同语义;杜绝把站管没了。 |
|
||||
| D-E | 入口 | 自助 `DELETE /api/auth/account`(body 带 `password` 二次确认)+ CLI `user delete <email>`(免密码,运维护栏) | API 路径要证明持有凭证;CLI 走既有 `config.LoadDatabase` 模式。 |
|
||||
| D-F | 反应数据 | favorites/ratings **保留**(匿名聚合继续计入) | CASCADE 语义本就允许;删了会让作品评分凭空漂移。 |
|
||||
| D-G | email/username 释放 | 墓碑改写为 `deleted-<id去杠>@deleted.invalid` / `gone-<id前8hex>`,**真实邮箱/用户名即刻可重用**(注册新号) | GDPR 式「删后可再注册」;新值仍满足 username CHECK 正则;冲突概率可忽略。 |
|
||||
| D-H | ④ 交付形态 | **server CLI `catalog export --out DIR`**:写 `index.json`(schemaVersion 2 + generatedAt)+ `games/<user>__<slug>.json` 详情,与 `/api/games*` 响应同形状 | 复用 handler 映射零漂移;FE 零改动即被静态兜底链吃到;放弃「前端构建期拉 API」(构建期与部署态耦合,且私有 API 凭据问题)。 |
|
||||
| D-I | ④ 边界 | 覆盖**已上架**作品全集;`STORAGE_S3_*` 未配时 cover/bundle.url 为空串/省略(文件仍合法,可浏览不可玩);docs.json 不生成(站内文档本就是构建产物) | 与目录 API 可见集一致;导出用途=只读兜底,可玩性依赖存储配置属运维前提,README 注明。 |
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 迁移 0011(server)
|
||||
|
||||
`internal/repository/migrations/0011_user_deletion.sql`:
|
||||
|
||||
```sql
|
||||
-- 账号注销(P8 ③):users 墓碑列。行永不物理删(FK 面无 CASCADE,审计预留语义见 0010)。
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||
```
|
||||
|
||||
`model.User` 增 `DeletedAt *time.Time json:"-"`;`userColumns` 追加 `deleted_at`;`MemoryUserStore` 同步支持。
|
||||
|
||||
### 3.2 服务层(server,`internal/service/account.go` 新文件)
|
||||
|
||||
`AccountService{users UserStore, content ContentStore, objects storage.ObjectStorage}`:
|
||||
|
||||
- `DeleteAccountSelf(ctx, userID, password string) (Session同包错误)`:`GetByIDForUpdate` → 不存在 ErrUnauthorized;**先判 `deleted_at != nil` → `ErrAccountDeleted`(幂等拒二次注销;因墓碑 password_hash 已随机化,先验密码会误报 401 而非 410——审查后裁决 2026-10-01,实现照此)**;再 `VerifyPassword` 失败 → `ErrInvalidCredentials`。
|
||||
- `DeleteAccountByEmail(ctx, email string)`(CLI 路径,免密码;不存在 → `ErrUserNotFound`)。
|
||||
- 共享内核 `deleteAccountLocked`:
|
||||
1. D-D 护栏:`role==admin && CountAdmins<=1` → `ErrLastAdmin`。
|
||||
2. 内容级联(`content.WithTx`):`DelistPublishedByOwnerTx`(新 WorkRepo 方法,返回计数);`ListBySubmitter` 遍历——draft:`ListBySubmission` 记 upload keys → `Uploads().Delete` → `Submissions().Delete`;pending:`SetStatus(id, "pending", "rejected")` + `MarkReviewed` 语义不可用(reviewer 是自己),改走 `UpdateDraft`? 否——直接用 `MarkReviewed(id, rejected, <自己id>, "账号注销自动关闭")`:签名允许任意 reviewer id,行将随墓碑保留,审核历史自洽。
|
||||
3. 用户墓碑(users 事务内最后一步):`MarkDeleted(ctx, id, email, username, passwordHash)`——email/username 按 D-G 改写、`display_name='已注销用户'`、password_hash=新随机 64hex(物理上永不可登录)、`role='user'`、`deleted_at=now()`、`token_version+1`。
|
||||
4. tx 提交后 best-effort 删对象(ErrObjectNotFound 容忍,log 掉失败)。
|
||||
- 认证护栏:`Authenticate` 与 `Login` 各加 `DeletedAt != nil` → `ErrUnauthorized`/`ErrInvalidCredentials`(token_version 已 bump,此为纵深防御);`ListAdminUsers` 过滤 `deleted_at IS NULL`(内存实现同步);`Register` 无需改(唯一约束天然处理)。
|
||||
- 错误新增:`ErrAccountDeleted`(api 映射 410 `account_deleted`)。
|
||||
|
||||
### 3.3 API(server)
|
||||
|
||||
`RouteDeleteAccount = "/api/auth/account"`,注册 `engine.DELETE(RouteDeleteAccount, RequireUser(deps.AuthService), deps.Account.DeleteAccount)`(storageEnabled 与否都要注册——注销不依赖对象存储;`objects==nil` 时跳过对象删除)。新增 `Deps.Account *handler.Account`(`NewAccount(accountSvc)`,与 NewAuth 平行,不动既有构造函数签名免碎全部测试现场)。
|
||||
|
||||
- dto:`DeleteAccountRequest{Password string json:"password"}`。
|
||||
- handler `Account.DeleteAccount`:body 缺 password → 400 `invalid_request` "password is required";映射:`ErrInvalidCredentials`→401 `invalid_credentials`;`ErrLastAdmin`→409 `last_admin`;`ErrAccountDeleted`→410 `account_deleted`;成功 204 + `Cache-Control: no-store`。
|
||||
- 旧 token 之后打 `/api/auth/me` → 401(version 失配)。
|
||||
|
||||
### 3.4 CLI(server)
|
||||
|
||||
- `user delete <email>`:跑 `deleteAccountLocked`(免密码);成功打印 `email: deleted (works delisted=N, drafts removed=M, pending closed=K)`;错误经 cobra RunE 冒泡非零退出。
|
||||
- `catalog export --out DIR [--pretty]`:`config.LoadDatabase` → pool → Migrate(幂等,保 schema 新鲜)→ `ContentService.ListPublished` + 逐条 `GetPublishedDetail`;文件写出:`index.json` = `{"schemaVersion":2,"generatedAt":"<UTC RFC3339>","games":[...]}`(与 `/api/games` 响应同形状,含 rating 聚合三字段);`games/<user>__<slug>.json` = `/api/games/:user/:slug` 详情同形状。计数打印 `exported N game(s) to DIR`。空目录先建;不删除 DIR 内既有无关文件(文档约定 DIR 专用)。
|
||||
- 实现取径:在 `handler.Games` 上导出 `RenderIndex(ctx)` / `RenderDetail(ctx, id)`(薄封装现有 `summary/detail` 私有映射 + service 调用,不做 ETag),cmd 侧只序列化落盘——映射零复制。
|
||||
|
||||
### 3.5 前端(crearte)
|
||||
|
||||
- `auth/client.ts`:`deleteAccount(password: string): Promise<void>`(204 空体);`AuthErrorCode` 并集补 `'account_deleted'`(410→「该账号已注销」),`AUTH_ERROR_MESSAGES` 补条目。
|
||||
- `AccountView.vue` 第四段「危险区:注销账号」:密码输入 + 「我已知晓作品将下架且不可恢复」勾选(两者齐备按钮才 enabled)+ 按钮「注销账号」;点击后 `busy` 锁;成功 → `session.invalidate()` → 跳首页 `router.push('/')`;失败 → `users-action-error` 风格 `role=alert` 行(文案走 `toUserMessage`/`AUTH_ERROR_MESSAGES`)。**审查后实定(2026-10-01):auth client 的 `toErrorCode` 对未知码(含 last_admin)一律归一为 `internal` 且丢弃后端原文,故 AccountView 侧 409 展示通用「服务暂时不可用」文案——接受该现状(唯一 admin 自注销属边缘场景,AdminUsersView 侧 409 原文回显已由 P7 覆盖);原文「last_admin 直出后端原文可接受」仅适用于 AdminUsersView。** noauth 下本页不可达(路由守卫),无需开关。
|
||||
- `AdminUsersView.vue`:`actionError` 改为按 `AdminApiError.code` 中文化:`validation`→「角色参数非法」、`not_found`→「用户不存在」、`internal`→「服务器内部错误,请稍后重试」、`last_admin`→保留后端原文透传(现有降级双要点文案已覆盖)、未知 code→后端 message 原样。
|
||||
- e2e:`auth.spec`(按现有 mock/桩模式)加注销流:勾选+密码齐才可点、错误密码出文案、成功清会话回首页;`account-view` 相关单测(vitest)覆盖按钮禁用态与 204 后行为。
|
||||
|
||||
### 3.6 deploy / P3 重落(另派,见 §4)
|
||||
|
||||
## 4. P3 重做注记(本 spec 附带记录,不另立文档)
|
||||
|
||||
- P3 于 2026-09-30 18:19 曾合入(server `7b4e5c8` / deploy `e07c9f8`),18:34 **owner 下令回滚**(`d6c384e`/`6f2e8b6`,「tree identical to P2 state」),spec/plan 文档保留、未删。本次 owner 指令「实现 roadmap 所有待办事项」= 重新落地。
|
||||
- 执行取径改为**历史重放 + 适配**而非重写 TDD:server 从 master 切 `feat/p3-observability`,`cherry-pick 6ffd1ee 93a3c70`(冲突机械解:P7/P8 后 games.go/admin.go/serve.go/router.go 已动;cherry-pick 后补迁新增 `log.Printf`——admin_console.go、service/audit.go 等,grep 清零);deploy `cherry-pick 7fcfe0e`(CHANGELOG 顶版对齐 0.6.0,`.gitignore` 与 0.5.2 的 `backups/` 行去重)。
|
||||
- 验收仍按原 spec §5 四条 + plan Task 4 全腿(含 prod 实栈 `/metrics` 冒烟、backup+restore-drill 真跑、full-loop e2e 不回归)。
|
||||
|
||||
## 5. 验收标准(2026-10-01 定稿)
|
||||
|
||||
1. server:容器四连(gofmt/vet/build/test)绿;空库 `-count=1 -p 1` 集成全绿零 skip(含新 `account_postgres_test.go`);`-race` 不适用本批(无新并发面),P3 分支另行处理。
|
||||
2. server 冒烟(分支真产物 + 真 PG,复用 p8 流程):注册→投稿链简版→`DELETE /api/auth/account` 错密码 401 → 对密码 204 → 旧 token 打 me 401 → 墓碑断言(DB 行 email/username 已改写)→ 原邮箱可重新注册 → 唯一 admin 注销 409;`catalog export` 跑通且产物可被 FE `StaticContentRepository` 契约吃(node 断言 schemaVersion2、virtual 详情含 bundle)。
|
||||
3. FE:`npx vitest run` / `vue-tsc --noEmit` / `admin-flow` / 主套件 / noauth 四腿绿(基线 499/7/70+1skip/4 只增不减)。
|
||||
4. 双路独立审查(对照本文 §1–§5 全量,不只任务书)。
|
||||
5. deploy(P3):`backup.sh --dry-run` 无栈可跑;compose config 四 profile 绿;真 prod 栈 backup 三件套 + latest 软链 + `BACKUP_KEEP_DAYS=1` 修剪;`restore-drill.sh` PASS;server `/metrics`+JSON 日志实栈断言;full-loop 1 passed。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
- 注销冷静期/软删恢复流(v1 不做,墓碑已可人工 SQL 复活,文档不提 UI)。
|
||||
- 注销账号名下作品物理删除 / 对象全量清扫(cleanup CLI 的 orphan 扫描覆盖 pending/bundles/covers 前缀,够用)。
|
||||
- GDPR 数据导出接口(另立项)。
|
||||
- catalog export 的定时化/服务化(runbook 一句 crontab 示例即可,不进 compose)。
|
||||
@@ -0,0 +1,113 @@
|
||||
# P9 用户体验增强(第一批:浏览器反馈包)· 设计 spec
|
||||
|
||||
日期:2026-10-01 | 决策:owner UX 摸底三包(A 浏览器反馈 / B 效率可访问 / C 暗色模式)选 **A**;B、C 留后续批次。「继续增强该项目,从用户体验方面」为总纲。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
让浏览器标签页「说人话」:每个路由有自己的 `document.title`(异步数据到达后精化),作品详情页带 `meta description`;列表加载骨架与实际网格对齐、不再误导非网格页。
|
||||
|
||||
## 1. 现状勘查(grounded,2026-10-01)
|
||||
|
||||
- `document.title` 全站无人设置:任何页面浏览器标签恒为 `index.html:10` 的 `crearte 创艺`(`grep -rn "document.title" app/` 零命中)。
|
||||
- `index.html` 无 `meta name=description`。
|
||||
- `e2e/landing.spec.ts:6` 钉住 home 标题**恰为** `crearte 创艺` —— home 无后缀既是现状也是本设计的兼容约束。
|
||||
- `StatePanel.vue` 骨架 `v-for="n in 3"`,网格消费页实际 `sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`(CatalogView:70、LandingView:62、AuthorView:36);骨架的游戏卡形状还被 6 个非网格页复用(Docs、Admin、AdminUsers、AdminAudit、SubmitList、Game 详情;AdminView 内 3 实例共 8 实例。初稿误写 7 个含 CreatorCenter——审查 ISSUE-2 复核其不消费 StatePanel,已按实修正),加载瞬间显示的是「不存在的卡片」。
|
||||
- 数据面锚点:`GameView` 有 `game.name/description`(`data/types.ts` Game extends GameSummary,description 可选);`CatalogView` 经 `useFilterState` 持 `state.q`(route.query 派生,天然响应);`DocsView` 有 `doc?.title`;`AuthorView` 有 works 列表→`authorDisplayName()`(labels.ts:21)与 `props.user` 兜底。
|
||||
- 路由 19 个 name(router/index.ts),其中 8 个纯静态(login/register/account/creator/outbound/admin*/submit*)无数据精化需求。
|
||||
|
||||
## 2. 决策记录
|
||||
|
||||
| # | 岔路 | 决定 | 理由 / 放弃项 |
|
||||
|---|------|------|--------------|
|
||||
| D-A | title 所有权 | **两段式**:router `afterEach` 按路由名设静态基线;4 个数据页(game/catalog/docs/author)watch 数据后精化覆盖 | 游戏名/文档名是异步数据,纯 `meta.title` 方案拿不到;纯 watch 方案则导航瞬间标签页还是旧题。放弃全站 meta 表驱动。 |
|
||||
| D-B | home 标题 | 保持恰为 `crearte 创艺`(无后缀);其余页 `段 · crearte 创艺` | 不破坏 `landing.spec.ts:6` 既有钉桩与用户书签;也是品牌页的合理特例。 |
|
||||
| D-C | description 范围 | **仅作品详情页**写 `game.description`(截 120 码点;缺省回落默认文案),离开该页恢复 index.html 默认(实现:router `afterEach` 每导航先 `setPageDescription('')` 回落,game 页再由 watch 精化——审查 ISSUE-1 补钉,router/index.test.ts 钉桩) | SEO 增益主要落在可分享的作品页;全站 per-page description 属 YAGNI(SPA、无抓取压力)。 |
|
||||
| D-D | 骨架策略 | cards 变体 **6 张**(3→6,覆盖 2/3/4 列断点各≥1.5 行);新增 `variant="lines"`(3 行横线、无封面纵横比结构)给 6 个非网格消费页(共 8 实例) | 行数固定 6 而非按视口动态生成:列数已由 CSS 断点处理,JS 测视口属过度工程。放弃「所有页维持游戏卡」。 |
|
||||
| D-E | 仓库面 | 纯 crearte 前端;server/deploy 零改动;版本 0.22.0 | 全部为客户端 DOM 写操作。 |
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 新文件 `app/lib/pageTitle.ts`(纯 builder + 两个 DOM setter)
|
||||
|
||||
```ts
|
||||
export const SITE = 'crearte 创艺'
|
||||
export const DEFAULT_DESCRIPTION =
|
||||
'crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。'
|
||||
|
||||
// joinTitle('作品') === '作品 · crearte 创艺';joinTitle() === 'crearte 创艺'(home/D-B)
|
||||
export function joinTitle(...parts: Array<string | '' | undefined | null>): string
|
||||
|
||||
export function gameTitle(name: string): string // `${name} · SITE`
|
||||
export function gameNotFoundTitle(): string // `未找到的作品 · SITE`
|
||||
export function catalogTitle(q: string): string // q ? `搜索「${clip(q, 40)}」 · 作品 · SITE` : `作品 · SITE`
|
||||
export function docsTitle(docTitle?: string): string // docTitle ? `${docTitle} · 文档 · SITE` : `文档 · SITE`
|
||||
export function authorTitle(displayName: string): string // `${displayName} · 创作者 · SITE`
|
||||
|
||||
export function setPageTitle(t: string): void // document.title = t
|
||||
export function setPageDescription(d: string): void // 找 meta[name=description](无则建入 head),content = clip(d || DEFAULT_DESCRIPTION, 120 码点)
|
||||
```
|
||||
|
||||
`clip` 用 `Array.from` 码点截断加 `…`,防长名炸标签页。纯函数无 DOM 依赖,setter 只在 happy-dom/浏览器可达。
|
||||
|
||||
### 3.2 路由基线(同文件内 `sectionTitleOf`)
|
||||
|
||||
19 个 name 全表:home→`''`(经 joinTitle 得 D-B 整串);catalog/game→作品;docs/doc→文档;author→创作者;creator→创作者中心;login→登录;register→注册;account→我的账号;submit→我的投稿;submit-new→提交作品;submit-edit→编辑投稿;outbound→离开本站;admin→审核;admin-submission→投稿审核;admin-users→用户管理;admin-audit→审计日志;not-found→页面不存在。**未名中路由回退 `SITE`**(新增路由忘配时优雅降级)。
|
||||
|
||||
`router/index.ts` 挂:
|
||||
|
||||
```ts
|
||||
router.afterEach((to) => {
|
||||
setPageTitle(joinTitle(sectionTitleOf(to.name)))
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 数据页精化(watch,进路由基线之后覆盖)
|
||||
|
||||
- **GameView**:`watch(game)`:notFound 计算态→`gameNotFoundTitle()`;有数据→`gameTitle(name)` + `setPageDescription(description ?? '')`。loading/网络 error 期保持基线不闪。
|
||||
- **CatalogView**:`watch(() => state.value.q)`→`catalogTitle(q)`。筛选变化即时反映(update() 走 router.replace,state 天然响应)。
|
||||
- **DocsView**:`watch(doc)`→`docsTitle(doc?.title)`;`watch(docs)` 仅列表就绪且无 slug 时保持 `文档` 基线(路由 replace 到首篇后走 doc 精化)。
|
||||
- **AuthorView**:`watch(games)`:取首个作品 `authorDisplayName()`,列表空/缺 author→`props.user` 兜底。
|
||||
|
||||
其余 15 页零改动,基线即终态。
|
||||
|
||||
### 3.4 StatePanel 骨架
|
||||
|
||||
```ts
|
||||
defineProps<{ loading: boolean; error: Error | null; variant?: 'cards' | 'lines' }>()
|
||||
```
|
||||
|
||||
- `cards`(默认,Catalog/Landing/Author 不改即得):`v-for="n in 6"`(3→6),其余结构原样。
|
||||
- `variant="lines"`:border-2 surface 面板 ×3 行(h-4 w-2/3 + h-3 w-full + h-3 w-1/2 线组,`animate-skeleton`,无 aspect-video 封面块);GameView/DocsView/AdminView/AdminUsersView/AdminAuditView/SubmitListView 六页显式传 `variant="lines"`(StatePanel 消费页共九个:三个网格页保持 cards,此六页换 lines)。
|
||||
- error/重试段零改动。
|
||||
|
||||
### 3.5 index.html
|
||||
|
||||
head 加 `<meta name="description" content="…默认文案…">`(`setPageDescription` 的回落锚,也让首屏无 JS 时爬虫可读)。
|
||||
|
||||
## 4. 错误处理与边界
|
||||
|
||||
- `game.description` 缺省/空串→DEFAULT;`q` 为纯空白→按空处理(parseFilterState 已 trim)。
|
||||
- 路由 `to.name` 为 undefined(catch-all 之外的怪值)→ sectionTitleOf 返回 `''` → 恰 home 同款整串,可接受。
|
||||
- afterEach 在守卫 redirect 后对最终路由触发(afterEach 收 to=重定向后目标),登录回跳 `?next=` 不影响标题正确性。
|
||||
- 不设 `beforeunload` 还原——SPA 内 afterEach 天然逐页重置。
|
||||
|
||||
## 5. 测试计划
|
||||
|
||||
**vitest(happy-dom)**
|
||||
- 新 `app/lib/pageTitle.test.ts`:joinTitle 空段/去重拼接、各 builder、clip(中英混/emoji 码点)、setter 写 document.title 与 meta(自动建 head 节点)、DEFAULT 回落。
|
||||
- 扩 `app/router/index.test.ts`:`/`→`crearte 创艺` 整串;`/games`→`作品 · crearte 创艺`;`/account`→`我的账号 · crearte 创艺`;`/no-such`→`页面不存在 · crearte 创艺`。
|
||||
- 扩 `GameView.test.ts`:加载后 `document.title === '最小作品 · crearte 创艺'` 且 meta description=作品描述(截断腿);notFound 腿标题为未找到款。
|
||||
- 扩/新 `CatalogView` 腿:mount 后 push `/games?q=2048`→标题含 `搜索「2048」`。
|
||||
- 新增 `StatePanel.test.ts`:默认渲染 6 个 aspect-video 卡;`variant="lines"` 零 aspect-video、3 个行块。
|
||||
- 扩 `AuthorView.test.ts` 标题精化断言 1 条;新建 `CatalogView.test.ts` 与 `DocsView.test.ts`(两者现无测试文件,mock 惯例照抄 AuthorView.test.ts 的 vi.hoisted+vi.mock 模式),各钉标题精化 1 条(Catalog 另钉骨架 6 卡)。
|
||||
|
||||
**e2e(Playwright)**
|
||||
- `landing.spec.ts` 首条**不动**(D-B 兼容回归即它的现有断言)。
|
||||
- 新增 1 条到 `landing.spec.ts`:`/games/fixture/2048` → `toHaveTitle(/^2048 · crearte 创艺$/)`;`/games?q=2048` → `toHaveTitle(/搜索「2048」/)`。
|
||||
- 实施时核对 `author-page.noauth.spec.ts` 与主套件有无标题假设(预期无)。
|
||||
|
||||
**验收腿**:vitest 全绿(基线 512+新增)、vue-tsc 零错、主 e2e(71+新增 1 条+1 skip)、noauth 4。
|
||||
|
||||
## 6. 版本与记账
|
||||
|
||||
crearte `0.22.0`:CHANGELOG 双语(Added:路由级标题/描述、骨架对齐;P9 第一批标注)。wrapper:本 spec + ROADMAP 加 P9 行(第一批完成、B/C 批次挂账)。server/deploy 零改动。
|
||||
@@ -0,0 +1,321 @@
|
||||
# P9-B UX 第二批设计:可访问与键盘效率(a11y / keyboard)
|
||||
|
||||
日期:2026-10-01 | 决策链:owner UX 摸底三包(A 浏览器反馈 / B 效率可访问 / C 暗色模式)——A 已作 P9 第一批交付(crearte 0.22.0),第一梯队日常交互包已作 P10 交付(crearte 0.23.0)。本批 = 摸底 **B 包**,并收口 P10 终审转办的 T1(d)(实时区内嵌交互控件)。总纲仍为「继续增强该项目,从用户体验方面」。
|
||||
|
||||
仓库面:纯 `crearte`(前端)。零后端/部署改动。
|
||||
|
||||
## §1 现状勘查结论(已实测,不是推测)
|
||||
|
||||
已经就位、本批**不动**的面(避免重复劳动与无谓回归):
|
||||
|
||||
- `:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px }` 全局焦点环已在 `main.css:46`,`accent` 对 paper 3.26 / 对 surface 3.64,满足 WCAG 1.4.11 非文本对比 3:1。
|
||||
- `prefers-reduced-motion: reduce` 全局归零分支已在 `main.css:95-101`,覆盖 toast/lift/骨架动画。
|
||||
- 播放 iframe 已有 `:title="game.name"`(`GameHost.vue:105`);封面 `<img :alt="game.name">`(`GameCover.vue`);投稿封面预览 `alt="封面预览"`。
|
||||
- 页头三条主导航已有 `:aria-current="onX ? 'page' : undefined"`。
|
||||
- `FilterDrawer` 用原生 `<dialog>.showModal()`,焦点圈闭与 Esc 关闭由浏览器负责;关闭按钮有 `aria-label="关闭筛选"` 且 44px 触达面(`min-h-11 min-w-11`)。
|
||||
- `BaseSelect` 自绘 listbox 的键盘面已完整:trigger 上 ArrowDown/Up/Enter/Space 开面板,面板内 Esc 关并回焦、Arrow 移动、Enter/Space 选中、Tab 关不回焦,`role=listbox`/`role=option`/`aria-selected`/`aria-expanded`/`roving tabindex` 齐备。
|
||||
- 表单控件均有可见或 `sr-only` 的 `<label for>`(CatalogView / AdminUsersView 搜索、SubmitFormView 全字段)。
|
||||
- 登录/注册/账号三页的错误提示已做 `id` + `:aria-describedby` 字段级关联。
|
||||
- 收藏按钮已有 `:aria-pressed="favorited"`,心形字形已 `aria-hidden`。
|
||||
- **移动端页头无溢出**:实测 360px 与 320px 视口下 `documentElement.scrollWidth` 恰等于视口宽(320/360),页头内层 `scrollWidth` 亦相等。故本批**不引入汉堡菜单**——没有需要修的问题,加菜单只会增加键盘陷阱面。
|
||||
|
||||
实测出的真实缺口(本批范围):
|
||||
|
||||
1. `--color-success: #2fa46a` 对 paper 仅 **2.83**,对 surface 3.16 —— 作为成功 toast 的底色配 `text-paper`(12px 粗体,非大字号)实测 **3.16**,AA 正文需 4.5,**FAIL**。
|
||||
2. 错误 toast 用 `bg-accent`(#e8552f)配 `text-paper` 实测 **3.64**,**FAIL**(AA 正文需 4.5)。
|
||||
3. 三个管理视图共 **24 个 `<th>` 元素零 `scope`**(`grep -c '<th'` 的行计数为 19——AdminView 三张表把多个 `<th>` 塞在同一 `<tr>` 行内,故行数 < 元素数;以词界 `<th\b` 的元素计数 24 为准),且两个「操作」列的表头是空 `<th class="px-3 py-2" />`(无可访问名);全仓 `scope="row"` 出现 0 次。屏幕阅读器读表格时无法把单元格与列头关联。
|
||||
4. 五星评分按钮的可访问名就是字形本身「★」/「☆」——读屏逐个念「星 星 星」,无法知道这是几分、当前评了几分、点了会怎样。
|
||||
5. 全站**无 skip-link**:键盘用户每个页面都要 Tab 过页头(logo + 3 条导航 + 贴纸 + 登录/用户菜单 4 项)才能到正文。
|
||||
6. SPA 导航后**无焦点管理**:`router` 的 `afterEach` 只设 title/description,焦点留在旧位置(通常是 body),读屏用户点链接后听不到任何新页面上下文。
|
||||
7. (P10 T1(d) 转办)toast 容器整体是 `role="status" aria-live="polite"`,而每条 toast 内含可交互的「关闭提示」按钮——实时区内嵌交互控件是公认反模式:读屏会在轮询实时区时把按钮一并播报,且用户无法可靠地把焦点停在该控件上。
|
||||
|
||||
## §2 决策表
|
||||
|
||||
| # | 决策点 | 定案 | 理由 / 放弃的替代 |
|
||||
|---|---|---|---|
|
||||
| D-A | `success` 色令牌加深 | `--color-success: #1f7a4d`(`text-paper` 在其上 **4.76**、白 **5.32**;作文字色对 paper 4.76 / 对 surface 5.32) | 唯一消费者是成功 toast 底色,改令牌即全站生效且不留分叉。放弃「只改 toast 用别的绿」——会让令牌与实际用色脱节,下一个消费者重踩。放弃 #186039(6.79):过暗,脱离新粗野主义的高饱和海报感。 |
|
||||
| D-B | 错误 toast 底色 | `error: 'bg-accent-ink text-paper'`(**4.87**) | `accent-ink` 已是既有令牌且已被 AdminUsersView 用作 `bg-accent-ink text-paper` 底色,复用即一致,不新增色。放弃新增 `--color-error` 令牌:与 `accent-ink` 同值同义,纯冗余。放弃给错误 toast 加白字:`text-paper` 本就是纸白,无需动。 |
|
||||
| D-C | 对比度守卫测试 | 新增 `app/lib/contrast.test.ts`:解析 `main.css` 的 `@theme` 令牌,按 WCAG 2.1 相对亮度算比值,钉死关键配色对 ≥4.5(焦点环 ≥3) | 仿既有 `app/lib/no-gradient.test.ts` 的源码级守卫范式,让「不许再引入低对比配色」成为可执行约束而非口头纪律。放弃逐组件视觉回归:成本高且测不到令牌层。 |
|
||||
| D-D | skip-link | `App.vue` 根 div 首子元素插 `<a href="#main" class="skip-link …">跳到主内容</a>`;`<main id="main" tabindex="-1">`;样式走 Tailwind `sr-only` + `focus:not-sr-only` 组合,**不新增 CSS 规则** | 原生锚点跳转会同时移动焦点(href 指向带 tabindex=-1 的 main),无需 JS。`focus:z-[80]` 压过 toast 的 `z-[70]`。放弃自绘 `.skip-link` CSS:工具类已足够,少一处需维护的样式。 |
|
||||
| D-E | 路由后焦点管理 | `attachTitleHook` 的 `afterEach` 改为 `(to, from)`,当 `to.path !== from.path` 时对 `#main` 调 `focus({ preventScroll: true })`;`#main` 不存在时静默 no-op | 只在**路径**变化时移焦:`/games → /games?tag=数字`(P10 T3 的标签点击)属同页筛选,抢焦点会打断用户;路径变化才是真正的「换页」。`preventScroll` 因为 `scrollBehavior` 已负责滚动,双重滚动会抖。放弃聚焦各页 h1:17 个视图有 2 个含双 h1(GameView / OutboundView 的 notFound 分支),聚焦目标不唯一,且 h1 加 tabindex 会污染 Tab 序。 |
|
||||
| D-F | 表格列头语义 | 三个管理视图全部 `<th>` 加 `scope="col"`(元素级 24 个:AdminView 13 + AdminUsersView 6 + AdminAuditView 5);两个空的操作列表头改为 `<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>` | `scope="col"` 是最小且正确的修复。**不**改 `<td>` → `<th scope="row">`:那要把每行首格的排版结构(含 `<span>` 嵌套、font-bold 混排)重写成表头单元格,视觉回归风险大而收益有限(列头关联已解决读屏的主要痛点)。空表头补 sr-only 文本,否则操作列在读屏里是「无名第 5 列」。 |
|
||||
| D-G | 表格守卫测试 | 新增 `app/lib/tableScope.test.ts`:扫描 `app/**/*.vue`,断言每个 `<th` 都带 `scope=` | 与 D-C 同为源码级守卫,覆盖现有 5 张表与将来新增的表,比逐视图单测便宜且不随表格增删失效。放弃只测两个有单测的视图:AdminView 无测试文件,逐视图补测是 3 份重复劳动。 |
|
||||
| D-H | 五星评分可访问名 | 分组 `<span role="group" :aria-label="rated ? \`评分:${score} 星\` : '评分'">`;每颗星 `:aria-label="\`评 ${i} 星\`"`,内层字形 `<span aria-hidden="true">` | `aria-label` 覆盖字形成为可访问名,读屏念「评 3 星」而非「星」。分组标签携带当前值(已评几分),补齐「点了会怎样/现在是什么」。**不**用 `radiogroup`/`radio` + `aria-checked`:本控件支持「点当前分 = 撤评」,radio 无法表达取消选中;也**不**用逐星 `aria-pressed`——点亮是 `i <= litStars` 的连续区间,逐星 pressed 会把「4 分」播报成「1、2、3、4 星都按下」,语义反而更糊。 |
|
||||
| D-I | toast 实时区重构(收口 P10 T1(d))**D-I′ re-pin,见 §3.5** | 容器与每条 toast 均**不带**任何播报语义;另设一个**常驻**的 `sr-only` 播报区 `<p role="status" aria-live="polite">`,其文字在挂载后由 watch 驱动变更 | 播报区必须**先存在**再变更文字,读屏才会播报。初稿方案(把 `role=status` 挂在每条 toast 的文本 `<p>` 上)被 T1 实现者指出为缺陷:**播报区与文字同时创建**,在多个读屏+浏览器组合下不播报首条——等于把「按钮噪音」问题换成「彻底静默」问题。改为 Radix Toast / Headless UI 等无障碍组件库的标准做法:视觉层与播报层解耦。放弃「容器保留 role、按钮 aria-hidden」:按钮对读屏消失,鼠标用户能关、读屏用户关不掉,更糟。放弃「每条 toast 各自 role=status」:即初稿,见上。 |
|
||||
| D-J | e2e 覆盖 | 新增 `e2e/a11y.spec.ts` 三条:① Tab 首站是 skip-link、回车后焦点落 `#main`;② `/admin/users` 的 columnheader 可访问名齐备(含「操作」);③ 作品页 star 按钮可访问名为「评 N 星」 | skip-link 是纯键盘时序行为,happy-dom 单测测不出真实 Tab 序;管理页需登录态(`helpers.seedSession(page,'admin')`)。既有 e2e 文件一字不动,只新增。 |
|
||||
| D-K | 移动端页头 | **本批不做**(实测无溢出,见 §1) | 没有问题就不修。引入汉堡菜单会新增焦点圈闭、Esc、aria-expanded 三处需维护的状态机,纯负收益。 |
|
||||
|
||||
## §3 设计细节(实现须逐字采用)
|
||||
|
||||
### 3.1 D-A / D-B / D-C:色彩对比
|
||||
|
||||
`app/styles/main.css` 的 `@theme` 块内,唯一改动:
|
||||
|
||||
```css
|
||||
--color-success: #1f7a4d;
|
||||
```
|
||||
|
||||
`app/components/ToastHost.vue` 的 `KIND_CLASS`,唯一改动(`error` 行):
|
||||
|
||||
```ts
|
||||
const KIND_CLASS: Record<ToastKind, string> = {
|
||||
success: 'bg-success text-paper',
|
||||
error: 'bg-accent-ink text-paper',
|
||||
info: 'bg-highlight text-ink'
|
||||
}
|
||||
```
|
||||
|
||||
新增 `app/lib/contrast.test.ts`:从 `main.css` 正则提取 `--color-<name>: #rrggbb`,实现 WCAG 相对亮度与对比度,断言下列配对(`fg on bg`):
|
||||
|
||||
- ≥ 4.5:`paper on success`、`paper on accent-ink`、`ink on highlight`、`ink on accent`、`ink-soft on paper`、`ink-faint on paper`、`ink-soft on surface`、`ink-faint on surface`、`paper on ink`、`accent-ink on paper`
|
||||
- ≥ 3:`accent on paper`(焦点环,1.4.11 非文本)
|
||||
|
||||
亮度公式(逐字,别自创):
|
||||
|
||||
```ts
|
||||
function channel(c: number): number {
|
||||
const s = c / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
function luminance(hex: string): number {
|
||||
const h = hex.replace('#', '')
|
||||
const [r, g, b] = [0, 2, 4].map((i) => channel(parseInt(h.slice(i, i + 2), 16)))
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
export function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 D-D / D-E:skip-link 与路由后焦点
|
||||
|
||||
`app/App.vue` 模板(`<AppHeader />` 之前插 skip-link,`<main>` 加 `id` 与 `tabindex`):
|
||||
|
||||
```html
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[80] focus:border-2 focus:border-ink focus:bg-highlight focus:px-3 focus:py-2 focus:text-sm focus:font-extrabold focus:shadow-hard"
|
||||
>跳到主内容</a>
|
||||
<AppHeader />
|
||||
<main id="main" tabindex="-1" class="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
`app/router/index.ts`:新增导出函数 + 改 `attachTitleHook` 签名内的回调:
|
||||
|
||||
```ts
|
||||
// SPA 导航后把焦点交给主内容区:读屏用户据此获得新页面上下文(D-E)。
|
||||
// 仅路径变化时移动——同路径改 query(如目录 /games?tag=x 筛选)不打断用户焦点。
|
||||
// preventScroll:滚动由 router.scrollBehavior 负责,避免双重滚动抖动。
|
||||
export function focusMain(): void {
|
||||
document.getElementById('main')?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
export function attachTitleHook(r: Router): void {
|
||||
r.afterEach((to, from) => {
|
||||
setPageTitle(joinTitle(sectionTitleOf(to.name)))
|
||||
setPageDescription('')
|
||||
if (to.path !== from.path) focusMain()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
注意:`to.path !== from.path` 在首次导航时 `from` 是 `START_LOCATION`(path `/`),从 `/` 进 `/` 不会误触;从 `/` 进 `/games` 会正确触发。`#main` 尚未挂载(router 早于 app mount 就绪的边界)时 `getElementById` 返回 null,可选链静默 no-op。
|
||||
|
||||
### 3.3 D-F / D-G:表格列头
|
||||
|
||||
三个文件的每个 `<th>` 加 `scope="col"`,类名与文本一字不动。两处空表头(`AdminView.vue:152` 队列表的 `<th class="px-3 py-2" />`、`AdminUsersView.vue:123` 的 `<th class="px-3 py-2" />`)改为:
|
||||
|
||||
```html
|
||||
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
|
||||
```
|
||||
|
||||
新增 `app/lib/tableScope.test.ts`:递归扫 `app/**/*.vue`(排除 `*.test.ts`),对每行匹配 `<th\b`,断言该 `<th` 起始标签内含 `scope=`。多行书写的 `<th` 需读到闭合 `>` 为止再判定(AdminUsersView 的空表头是单行,但守卫要对将来健壮)。违规时报告 `相对路径:行号: 行内容`,断言 `toEqual([])`——与 `no-gradient.test.ts` 同风格。
|
||||
|
||||
### 3.4 D-H:五星评分
|
||||
|
||||
`app/components/GameReactions.vue` 的星标分组:
|
||||
|
||||
```html
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
role="group"
|
||||
:aria-label="rated ? `评分:${score} 星` : '评分'"
|
||||
>
|
||||
<button
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
type="button"
|
||||
:data-testid="`star-${i}`"
|
||||
:aria-label="`评 ${i} 星`"
|
||||
:disabled="busy"
|
||||
class="px-0.5 font-mono text-base leading-none disabled:opacity-60"
|
||||
@click="pickStar(i)"
|
||||
><span aria-hidden="true" :class="i <= litStars ? 'text-accent-ink' : 'text-ink-soft'">{{ i <= litStars ? '★' : '☆' }}</span></button>
|
||||
</span>
|
||||
```
|
||||
|
||||
`data-testid`、`disabled`、类名、点击语义(点当前分撤评)全部不动;既有测试对 `.text()` 的断言不受影响(`aria-label` 不改文本内容)。
|
||||
|
||||
### 3.5 D-I:toast 实时区(D-I′ re-pin)
|
||||
|
||||
**为什么 re-pin**:初稿把 `role="status"` 挂在每条 toast 的文本 `<p>` 上。播报区与文字**同时被创建**——屏幕阅读器只可靠播报「已存在于页面上的播报区」内的变更,新出现的播报区在多个读屏+浏览器组合下不播报首条,结果是把「按钮噪音」换成了「彻底静默」。修正为视觉层与播报层解耦:常驻一个 `sr-only` 播报区,文字在挂载**之后**由 watch 驱动变更。
|
||||
|
||||
`app/components/ToastHost.vue` 全文(`<script setup>` + 模板):
|
||||
|
||||
```html
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { PhX } from '@phosphor-icons/vue'
|
||||
import { useToast, type ToastKind } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
// 各提示类型配色(新粗野主义:实底 + 描边 + 硬阴影)
|
||||
const KIND_CLASS: Record<ToastKind, string> = {
|
||||
success: 'bg-success text-paper',
|
||||
error: 'bg-accent-ink text-paper',
|
||||
info: 'bg-highlight text-ink'
|
||||
}
|
||||
|
||||
// 常驻播报区(D-I′):屏幕阅读器只播报「已存在于页面上的 live region」内的变更,
|
||||
// 故播报节点必须随组件挂载即存在、之后只改文字。可见 toast 不带任何播报语义,
|
||||
// 使「关闭提示」按钮不再是 live region 的后代(P10 T1(d) 收口)。
|
||||
// 取 toasts 末尾一条即最新推入者(useToast 把新条 append 到尾部)。
|
||||
const announcement = ref('')
|
||||
watch(
|
||||
() => toasts.value.length,
|
||||
(n) => {
|
||||
announcement.value = n > 0 ? (toasts.value[n - 1]?.text ?? '') : ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 不 Teleport:容器常驻,屏幕阅读器语义稳定 -->
|
||||
<div
|
||||
data-testid="toast-host"
|
||||
class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"
|
||||
>
|
||||
<!-- 播报层:sr-only 常驻节点,只由 watch 改文字,不含任何交互控件 -->
|
||||
<p data-testid="toast-announce" role="status" aria-live="polite" class="sr-only">{{ announcement }}</p>
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
data-testid="toast"
|
||||
:class="[
|
||||
'pointer-events-auto flex items-start justify-between gap-2 border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard',
|
||||
KIND_CLASS[t.kind]
|
||||
]"
|
||||
>
|
||||
<span class="min-w-0 flex-1">{{ t.text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭提示"
|
||||
class="-mr-1 shrink-0 cursor-pointer"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
<PhX :size="12" weight="bold" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 播报节点 `data-testid="toast-announce"` 在容器**内首子位置**、`TransitionGroup` 之外,故它不参与进出场动画,也不被 `v-for` 重建。
|
||||
- watch 只观察 `toasts.value.length`:新消息推入时长度必增(上限 3 条时 `push` 会同时移最旧再 append,长度仍是 3→3,但此时 `toasts.value[n-1]` 已换成新条——见下方陷阱)。
|
||||
- 可见 toast 的文本节点用 `<span class="min-w-0 flex-1">`(不是 `<p>`):它在 `<div data-testid="toast">` 内,用块级 `<p>` 也可以,但 span 保持与 P10 原版一致的最小结构变更;`min-w-0 flex-1` 负责长文案换行时按钮仍右贴。
|
||||
|
||||
**陷阱**:`MAX_VISIBLE=3` 且已满 3 条时,`push` 的实现是 `toasts.value = [...slice(-(MAX_VISIBLE-1)), newItem]`——长度 3→3 不变,只观察 `length` 的 watch **不会触发**,第 4 条消息将不被播报。故 watch 源必须是**末尾元素的 id** 而非长度:
|
||||
|
||||
```ts
|
||||
watch(
|
||||
() => toasts.value[toasts.value.length - 1]?.id,
|
||||
(id) => {
|
||||
announcement.value = id === undefined ? '' : (toasts.value[toasts.value.length - 1]?.text ?? '')
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
`id` 由 `useToast` 单调递增(`nextId++`),故「有新消息」当且仅当「末尾 id 变化」,饱和态同样成立。全部 toast 消失时末尾 id 为 `undefined`,播报文字清空。
|
||||
|
||||
`useToast.ts` 一字不动(store 语义与本批无关)。
|
||||
|
||||
### 3.6 D-J:e2e
|
||||
|
||||
新增 `src/e2e/a11y.spec.ts`,三条用例,既有 e2e 文件与 `helpers.ts` 一字不动:
|
||||
|
||||
```ts
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { seedSession } from './helpers'
|
||||
|
||||
test('skip-link:Tab 首站可见,回车后焦点落在主内容区', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/')
|
||||
await page.keyboard.press('Tab')
|
||||
const skip = page.getByRole('link', { name: '跳到主内容' })
|
||||
await expect(skip).toBeVisible()
|
||||
await expect(skip).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('main#main')).toBeFocused()
|
||||
})
|
||||
|
||||
test('管理端表格列头具备可访问名(含 sr-only 的操作列)', async ({ page }) => {
|
||||
await seedSession(page, 'admin')
|
||||
await page.goto('http://localhost:4173/admin/users')
|
||||
const headers = page.getByRole('columnheader')
|
||||
await expect(headers).toHaveCount(6)
|
||||
await expect(headers.nth(0)).toHaveAccessibleName('用户名')
|
||||
await expect(headers.nth(5)).toHaveAccessibleName('操作')
|
||||
})
|
||||
|
||||
test('评分按钮的可访问名是「评 N 星」而非字形', async ({ page }) => {
|
||||
await seedSession(page, 'user')
|
||||
await page.goto('http://localhost:4173/games/fixture/2048')
|
||||
await expect(page.getByTestId('star-3')).toHaveAccessibleName('评 3 星')
|
||||
await expect(page.locator('[role=group][aria-label^="评分"]')).toHaveCount(1)
|
||||
})
|
||||
```
|
||||
|
||||
## §4 边界与不做的事
|
||||
|
||||
- 禁触:`e2e/landing.spec.ts`、`e2e/noauth.spec.ts`、`e2e/author-page.noauth.spec.ts`、`e2e/merge-repo.spec.ts`、`e2e/ux.spec.ts`、`e2e/submit-flow.spec.ts`、`e2e/admin-flow.spec.ts`、`e2e/helpers.ts`、`playwright.config.ts`、`playwright.noauth.config.ts`、`vitest.config.ts`、`vite.config*.ts`、`scripts/`、`package.json`。
|
||||
- `e2e/ux.spec.ts`(禁触)靠 `[data-testid=toast]` 定位并断言 `toContainText('链接已复制')`——`data-testid="toast"` 与可见文本内容必须原样保留(文本搬进 `<span>` 后 `toContainText` 仍成立,子孙文本匹配)。
|
||||
- `runtime/` 本批**完全不动**(含 GameHost)——播放区可访问性(iframe title、全屏按钮语义)已在既有代码达标。
|
||||
- 不新增依赖、不改 `version`(恒 0.1.0)、不动 CHANGELOG(控制者统一写)。
|
||||
- 不改任何视觉设计:平面海报新粗野主义(无渐变/无圆角/硬阴影)必须保持,`no-gradient.test.ts` 守卫须继续绿。除 D-A/D-B 两处配色外,不动任何既有颜色与排版。
|
||||
- 不做移动端汉堡菜单(D-K,实测无溢出)。
|
||||
- 不做 `<td>` → `<th scope="row">` 的行头重构(D-F 理由)。
|
||||
- 不做暗色模式(C 包,另行立项)。
|
||||
|
||||
## §5 测试计划
|
||||
|
||||
vitest(新增/扩展):
|
||||
|
||||
- 新 `app/lib/contrast.test.ts`:令牌解析成功(能取到 paper/ink/success/accent-ink/highlight/accent/ink-soft/ink-faint/surface);§3.1 列出的 ≥4.5 配对逐条断言;焦点环 `accent on paper` ≥3。
|
||||
- 新 `app/lib/tableScope.test.ts`:守卫断言 `violations === []`(即全仓每个 `<th` 都带 scope)。
|
||||
- 扩 `app/components/ToastHost.test.ts`:① 容器与每条 toast 均**不含** `role`/`aria-live`(断言属性不存在);② 播报节点 `[data-testid=toast-announce][role=status][aria-live=polite]` 在 store 为空时**已存在**(常驻前提)且文字为空;③ push 后播报文字等于该消息;④ **饱和态播报**(D-I′ 陷阱钉桩):连推 4 条,第 4 条时 store 仍是 3 条(最旧被逐出)而播报文字必须是第 4 条的文本;⑤ 关闭按钮不是播报节点的后代,且播报节点内不含任何 `button`;⑥ kind 配色断言中 `error` 由 `bg-accent` 改 `bg-accent-ink`;⑦ 既有的「点关闭 → store 少一条」保留。
|
||||
- 扩 `app/router/index.test.ts`:新 describe「SPA 导航后焦点(D-E)」——先在 `document.body` 注入 `<main id="main" tabindex="-1">`,push `/` → `/games` 后断言 `document.activeElement.id === 'main'`;`/games` → `/games?tag=x` 后断言焦点**未**被 main 抢走(先 blur 或先聚焦别的元素再验);`#main` 不存在时 push 不抛错。
|
||||
- 扩 `app/components/GameReactions.test.ts`:star-1..5 的 `aria-label` 为「评 N 星」;分组 `role=group` 且未评时 `aria-label === '评分'`、已评(`rated: true, score: 4`)时为「评分:4 星」;内层字形 `aria-hidden="true"`;既有 `.text()` 为 ★/☆ 的断言全部保留且仍绿。
|
||||
|
||||
e2e:
|
||||
|
||||
- 新 `e2e/a11y.spec.ts` 三条(§3.6 逐字)。
|
||||
- 既有全部 e2e 文件一字不动,且必须继续全绿(`landing.spec.ts` 的 `getByRole('heading', { level: 1 })`、`locator('main')`、`ux.spec.ts` 的 `locator('main').click({position:{x:5,y:5}})` 与 toast 断言均须不受影响——skip-link 在 main 之外、toast 的 `data-testid` 与文本内容不变)。
|
||||
|
||||
验收腿:vitest 全绿(基线 601 + 新增)、`vue-tsc` 零错、`npm run build` OK、主 e2e(基线 77+1skip + 新增 3)、noauth 4。
|
||||
|
||||
## §6 CHANGELOG 口径(控制者写)
|
||||
|
||||
版本 `0.24.0`。Added:skip-link、路由后焦点管理、表格列头语义、评分可访问名、对比度/表格守卫测试、a11y e2e。Changed:success 令牌加深、错误 toast 底色换 accent-ink、toast 播报改为常驻 sr-only 播报区(视觉层与播报层解耦,交互控件移出播报区)。Tests:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。
|
||||
@@ -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