Files
crearte-monorepo/docs/specs/2026-09-30-admin-console-design.md
T

154 lines
11 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.
# 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 端到端验证)。