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

13 KiB
Raw Blame History

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:

-- 账号注销(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)。