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

11 KiB
Raw Permalink Blame History

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

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 端到端验证)。