13 KiB
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_id0010 注释明写「不加 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) (Session同包错误):GetByIDForUpdate→ 不存在 ErrUnauthorized;先判deleted_at != nil→ErrAccountDeleted(幂等拒二次注销;因墓碑 password_hash 已随机化,先验密码会误报 401 而非 410——审查后裁决 2026-10-01,实现照此);再VerifyPassword失败 →ErrInvalidCredentials。DeleteAccountByEmail(ctx, email string)(CLI 路径,免密码;不存在 →ErrUserNotFound)。- 共享内核
deleteAccountLocked:- D-D 护栏:
role==admin && CountAdmins<=1→ErrLastAdmin。 - 内容级联(
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,行将随墓碑保留,审核历史自洽。 - 用户墓碑(users 事务内最后一步):
MarkDeleted(ctx, id, email, username, passwordHash)——email/username 按 D-G 改写、display_name='已注销用户'、password_hash=新随机 64hex(物理上永不可登录)、role='user'、deleted_at=now()、token_version+1。 - tx 提交后 best-effort 删对象(ErrObjectNotFound 容忍,log 掉失败)。
- D-D 护栏:
- 认证护栏:
Authenticate与Login各加DeletedAt != nil→ErrUnauthorized/ErrInvalidCredentials(token_version 已 bump,此为纵深防御);ListAdminUsers过滤deleted_at IS NULL(内存实现同步);Register无需改(唯一约束天然处理)。 - 错误新增:
ErrAccountDeleted(api 映射 410account_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 → 400invalid_request"password is required";映射:ErrInvalidCredentials→401invalid_credentials;ErrLastAdmin→409last_admin;ErrAccountDeleted→410account_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/ deploye07c9f8),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 清零);deploycherry-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 定稿)
- server:容器四连(gofmt/vet/build/test)绿;空库
-count=1 -p 1集成全绿零 skip(含新account_postgres_test.go);-race不适用本批(无新并发面),P3 分支另行处理。 - server 冒烟(分支真产物 + 真 PG,复用 p8 流程):注册→投稿链简版→
DELETE /api/auth/account错密码 401 → 对密码 204 → 旧 token 打 me 401 → 墓碑断言(DB 行 email/username 已改写)→ 原邮箱可重新注册 → 唯一 admin 注销 409;catalog export跑通且产物可被 FEStaticContentRepository契约吃(node 断言 schemaVersion2、virtual 详情含 bundle)。 - FE:
npx vitest run/vue-tsc --noEmit/admin-flow/ 主套件 / noauth 四腿绿(基线 499/7/70+1skip/4 只增不减)。 - 双路独立审查(对照本文 §1–§5 全量,不只任务书)。
- deploy(P3):
backup.sh --dry-run无栈可跑;compose config 四 profile 绿;真 prod 栈 backup 三件套 + latest 软链 +BACKUP_KEEP_DAYS=1修剪;restore-drill.shPASS;server/metrics+JSON 日志实栈断言;full-loop 1 passed。
6. 非目标
- 注销冷静期/软删恢复流(v1 不做,墓碑已可人工 SQL 复活,文档不提 UI)。
- 注销账号名下作品物理删除 / 对象全量清扫(cleanup CLI 的 orphan 扫描覆盖 pending/bundles/covers 前缀,够用)。
- GDPR 数据导出接口(另立项)。
- catalog export 的定时化/服务化(runbook 一句 crontab 示例即可,不进 compose)。