docs: P8 batch-2 spec+plan — account deletion (tombstone migration 0011, DELETE /api/auth/account, user delete CLI) + catalog export CLI (static fallback feed) + P7 error-copy tail; P3 relanding recorded as history-replay (owner's 3rd roadmap directive: implement everything pending)
This commit is contained in:
@@ -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) (AccountService 同包错误)`:`GetByIDForUpdate` → 不存在 ErrUnauthorized;`VerifyPassword` 失败 → `ErrInvalidCredentials`;`deleted_at != nil` → `ErrAccountDeleted`(幂等拒二次注销)。
|
||||
- `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`,last_admin 直出后端原文可接受)。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)。
|
||||
Reference in New Issue
Block a user