Files
crearte-monorepo/docs/specs/2026-10-01-p8b2-deletion-catalog-design.md
T

103 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。