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:
@@ -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() })
|
||||
|
||||
Reference in New Issue
Block a user