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

@@ -126,29 +126,53 @@ export function getUserList(
keyword: string,
orderBy = "",
) {
return http.get("admin/user", {
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
})
return legacyResponse(
api2.get("admin/users", {
// 旧接口的 order_by 只有 "-last_login" 一个取值
params: {
offset,
limit,
keyword,
type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
},
}),
)
}
// 编辑用户
export function editUser(user: User) {
return http.put("admin/user", user)
return legacyResponse(
api2.put(`admin/users/${user.id}`, {
username: user.username,
email: user.email,
adminType: user.admin_type,
problemPermission: user.problem_permission,
realName: user.real_name ?? null,
isDisabled: user.is_disabled,
openApi: user.open_api,
password: user.password ?? "",
}),
)
}
// 重置用户密码
export function resetPassword(userID: number) {
return http.post("admin/reset_password", { id: userID })
// 重置用户密码。调用方直接用 res.data 当密码字符串(旧后端返回的就是裸字符串),
// 新后端返回 { password },在这里解包,组件不动
export async function resetPassword(userID: number) {
const res = await api2.post<{ password: string }>(
`admin/users/${userID}/reset-password`,
)
return { error: res.error, data: res.data.password }
}
// 导入用户
export function importUsers(users: string[][]) {
return http.post("admin/user", { users })
return api2.post("admin/users", { users })
}
// 批量删除用户
export function deleteUsers(userIDs: number[]) {
return http.delete("admin/user", { params: { id: userIDs.join(",") } })
return api2.delete("admin/users", { data: { ids: userIDs } })
}
export function getContestList(offset = 0, limit = 10, keyword: string) {
@@ -622,22 +646,42 @@ export interface MetricOption {
help_text: string
}
/** 组件里的成就对象是 snake_case出站转成新后端要的 camelCase */
function toAchievementBody(data: Partial<AdminAchievement>) {
return {
name: data.name,
description: data.description,
icon: data.icon,
rarity: data.rarity,
hidden: data.hidden ?? false,
metric: data.metric,
operator: data.operator,
threshold: data.threshold,
visible: data.visible ?? true,
order: data.order ?? 0,
}
}
export function getAdminAchievements() {
return http.get<AdminAchievement[]>("admin/achievement")
return legacyResponse<AdminAchievement[]>(api2.get("admin/achievements"))
}
export function getMetricOptions() {
return http.get<MetricOption[]>("admin/achievement/metrics")
return legacyResponse<MetricOption[]>(api2.get("admin/achievement-metrics"))
}
export function createAchievement(data: Partial<AdminAchievement>) {
return http.post<AdminAchievement>("admin/achievement", data)
return legacyResponse<AdminAchievement>(
api2.post("admin/achievements", toAchievementBody(data)),
)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return http.put<AdminAchievement>("admin/achievement", data)
return legacyResponse<AdminAchievement>(
api2.put(`admin/achievements/${data.id}`, toAchievementBody(data)),
)
}
export function deleteAchievement(id: number) {
return http.delete("admin/achievement", { params: { id } })
return api2.delete(`admin/achievements/${id}`)
}