feat(阶段4): 用户管理 + 成就管理;补上 rescan 与 contest_joined

GET/POST/DELETE   admin/users            (DELETE 走 body 传 ids)
  GET/PUT           admin/users/:id
  POST              admin/users/:id/reset-password
  GET               admin/achievement-metrics
  GET/POST          admin/achievements
  GET/PUT/DELETE    admin/achievements/:id

顺带补了新后端缺失的两块(不补的话成就后台建出来的东西是坏的):

1. **rescanAchievement**:旧后端 `rescan_achievement` 的对应实现。判定平时只在判题
   结算时发生,后台新建成就或调低阈值不会自动补发,必须显式扫一遍。补发标记
   backfilled=true —— 前端据此只显示「已获得」不显示日期,否则一次补发会给几百人
   盖同一个时间戳,把「最近获得」板块冲垮。
   触发判据包含 metric 与 visible 的变化,不只看 operator/threshold:换维度、
   以及从下架改上架(草稿期已达标的人)这两种都会漏。
2. **contest_joined 指标整个漏掉了**。旧 METRIC_REGISTRY 有 18 个指标,新后端只算
   17 个,配在这个指标上的成就永远解锁不了。已补上计算,并把注册表抽成
   services/achievement-metrics.ts 作为单一事实源 —— 后台下拉框和参数校验都读它,
   避免「下拉框里选得到但没人算」这种组合。

用户管理的几处要点:

- className 解析位数不对**直接报错不猜**。猜错会把 class_name 存歪,而剥前缀显示
  姓名、班级下拉、统计页都依赖它准确。
- problem_permission 按 admin_type 归一(超管恒 All、普通用户恒 None),否则把超管
  降级成普通用户后他还留着 All。
- 改用户名要同步 submission.username 这个冗余列,否则历史提交查不到。
- openApi 已开着就不重置 appkey,否则每次保存用户都把对方的 key 换掉。
- **删除用户不复刻 Django 的应用层级联硬删**。用户是被引用最广的一张表,改成让
  数据库外键拦下来:撞外键说明还有历史数据,应当禁用而不是删除,返回 409 并说明。

实测:学生 403;导入 2 人 / 重复 409 / 班级号位数报错文案正确;改名+降权后
permission 归一为 None、重名 409;重置密码 6 位无 0;删自己 400;
成就指标 18 项、新建后补发 unlockCount=2、野指标与野稀有度均 400。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 07:27:36 -06:00
parent af9cb796bd
commit 7e1a782747
7 changed files with 700 additions and 15 deletions

View File

@@ -1,5 +1,6 @@
import { z } from "zod"
import { achievementRaritySchema } from "./achievement"
import { paginatedSchema, sampleUserSchema } from "./common"
/**
@@ -131,3 +132,96 @@ export const adminAiReportSchema = z.object({
export const adminAiReportListSchema = paginatedSchema(adminAiReportListItemSchema)
export const toggleAiReportPinResponseSchema = z.object({ isPinned: z.boolean() })
// ---------------------------------------------------------------- 成就
// rarity 复用 achievement.ts 里已有的定义,不重复声明 —— 两份枚举迟早会长歪
export const achievementOperatorSchema = z.enum(["gte", "lte"])
export const achievementMetricSchema = z.object({
key: z.string(),
name: z.string(),
helpText: z.string(),
})
export const adminAchievementSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
hidden: z.boolean(),
metric: z.string(),
metricName: z.string(),
operator: achievementOperatorSchema,
threshold: z.number().int(),
visible: z.boolean(),
// 后台列表必须显示:阈值配错时学生永远拿不到也永远不会来问,这个计数器是唯一的仪表盘
unlockCount: z.number().int(),
order: z.number().int(),
createTime: z.string().nullable(),
})
export const createAchievementRequestSchema = z.object({
name: z.string().trim().min(1).max(64),
description: z.string().trim().min(1),
icon: z.string().trim().min(1),
rarity: achievementRaritySchema,
hidden: z.boolean().default(false),
metric: z.string().min(1),
operator: achievementOperatorSchema,
threshold: z.number().int(),
visible: z.boolean().default(true),
order: z.number().int().default(0),
})
export const updateAchievementRequestSchema = createAchievementRequestSchema
// ---------------------------------------------------------------- 用户管理
export const adminUserSchema = z.object({
id: z.number().int(),
username: z.string(),
email: z.string().nullable(),
adminType: z.string(),
problemPermission: z.string(),
realName: z.string().nullable(),
createTime: z.string().nullable(),
lastLogin: z.string().nullable(),
openApi: z.boolean(),
isDisabled: z.boolean(),
// 明文密码。是有意保留的运营需求:老师要能查学生的密码。
// 只在超管专属的这一个接口下发,别往任何其它地方复制。
rawPassword: z.string().nullable(),
className: z.string().nullable(),
})
export const adminUserListSchema = paginatedSchema(adminUserSchema)
export const updateUserRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
email: z.email().max(64),
adminType: z.enum(["Regular User", "Student Admin", "Teacher Admin", "Super Admin"]),
problemPermission: z.enum(["None", "Own", "All"]),
realName: z.string().max(32).nullable().default(null),
isDisabled: z.boolean(),
openApi: z.boolean(),
// 空串表示不改密码,与旧 EditUserSerializer 的 allow_blank 一致
password: z.string().max(128).default(""),
})
/** 导入用户:每行 [用户名, 密码, 邮箱, 真名],与前端粘贴的 Excel 列序一致 */
export const importUsersRequestSchema = z.object({
users: z.array(z.tuple([
z.string().trim().min(1).max(32),
z.string().min(1),
z.string(),
z.string(),
])).min(1),
})
export const deleteUsersRequestSchema = z.object({
ids: z.array(z.number().int().positive()).min(1),
})
export const resetPasswordResponseSchema = z.object({ password: z.string() })