diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts new file mode 100644 index 0000000..0228932 --- /dev/null +++ b/apps/api/src/routes/admin/account.ts @@ -0,0 +1,276 @@ +import { + adminUserListSchema, + adminUserSchema, + deleteUsersRequestSchema, + importUsersRequestSchema, + resetPasswordResponseSchema, + updateUserRequestSchema, +} from "@oj2/contract" +import { randomInt } from "node:crypto" +import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm" +import { Hono } from "hono" + +import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" +import { db, schema } from "../../db" +import { failure, success } from "../../http" +import { queryInteger } from "../helpers" + +export const adminAccountRoutes = new Hono() + +const CLASS_NAME_MIN_DIGITS = 3 +const CLASS_NAME_MAX_DIGITS = 4 + +/** + * `ks251XXX` / `ks2510XX` → `251` / `2510`。不以 `ks+数字` 开头的(管理员、教师账号)返回 null。 + * + * 位数不对**直接报错,不猜** —— 猜错会把 className 存歪,而剥前缀显示姓名、班级下拉、 + * 统计页都依赖它准确。先用 `\d+` 抓全再判位数,不能直接用固定位数的正则匹配: + * 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。 + * 对齐旧 `account/views/admin.py:get_class_name`。 + */ +function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } { + const matched = /^ks(\d+)/.exec(username) + if (!matched) return { ok: true, value: null } + const digits = matched[1]! + if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) { + return { + ok: false, + message: `用户名 ${username} 的班级号 ${digits} 是 ${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`, + } + } + return { ok: true, value: digits } +} + +/** + * 旧 UserAdminAPI.put 按 admin_type 归一 problem_permission: + * 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。 + * 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。 + */ +function normalizePermission(adminType: string, requested: string) { + if (adminType === "Super Admin") return "All" + if (adminType === "Regular User") return "None" + return requested || "Own" +} + +function serialize(row: { + user: typeof schema.user.$inferSelect + realName: string | null +}) { + return adminUserSchema.parse({ + id: row.user.id, + username: row.user.username, + email: row.user.email, + adminType: row.user.adminType, + problemPermission: row.user.problemPermission, + realName: row.realName, + createTime: row.user.createTime, + lastLogin: row.user.lastLogin, + openApi: row.user.openApi, + isDisabled: row.user.isDisabled, + rawPassword: row.user.rawPassword, + className: row.user.className, + }) +} + +function selectUser(id: number) { + return db.select({ user: schema.user, realName: schema.userProfile.realName }) + .from(schema.user) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .where(eq(schema.user.id, id)).limit(1) +} + +adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => { + const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) + const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) + const filters = [] + const type = c.req.query("type")?.trim() + const keyword = c.req.query("keyword")?.trim() + if (type) filters.push(eq(schema.user.adminType, type)) + if (keyword) { + filters.push(or( + ilike(schema.user.username, `%${keyword}%`), + ilike(schema.userProfile.realName, `%${keyword}%`), + ilike(schema.user.email, `%${keyword}%`), + )!) + } + const where = filters.length ? and(...filters) : undefined + // 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了 + const order = c.req.query("orderBy") === "-lastLogin" + ? [sql`${schema.user.lastLogin} desc nulls last`] + : [desc(schema.user.createTime)] + + const [totalRows, rows] = await Promise.all([ + db.select({ value: count() }).from(schema.user) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where), + db.select({ user: schema.user, realName: schema.userProfile.realName }).from(schema.user) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where) + .orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset), + ]) + return success(c, adminUserListSchema.parse({ + results: rows.map(serialize), + total: totalRows[0]?.value ?? 0, + })) +}) + +adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => { + const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 })) + if (!row) return failure(c, 404, "user-not-found", "User does not exist") + return success(c, serialize(row)) +}) + +adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const parsed = updateUserRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const data = parsed.data + const [existing] = await selectUser(id) + if (!existing) return failure(c, 404, "user-not-found", "User does not exist") + + const username = data.username.toLowerCase() + const email = data.email.toLowerCase() + const className = classNameOf(username) + if (!className.ok) return failure(c, 400, "invalid-class-name", className.message) + + const [dupUsername] = await db.select({ id: schema.user.id }).from(schema.user) + .where(and(eq(schema.user.username, username), ne(schema.user.id, id))).limit(1) + if (dupUsername) return failure(c, 409, "username-exists", "Username already exists") + const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user) + .where(and(eq(schema.user.email, email), ne(schema.user.id, id))).limit(1) + if (dupEmail) return failure(c, 409, "email-exists", "Email already exists") + + const patch: Partial = { + username, + email, + className: className.value, + adminType: data.adminType, + isDisabled: data.isDisabled, + problemPermission: normalizePermission(data.adminType, data.problemPermission), + } + if (data.password) { + // 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求, + // 老师要能查学生密码,见设计文档 7.1.1。 + patch.password = await Bun.password.hash(data.password, { algorithm: "argon2id" }) + patch.rawPassword = data.password + } + if (data.openApi) { + // 已经开着就不重置 appkey,否则每次保存用户都会把对方的 key 换掉 + if (!existing.user.openApi) patch.openApiAppkey = randomBytes32() + } else { + patch.openApiAppkey = null + } + patch.openApi = data.openApi + + await db.transaction(async (tx) => { + await tx.update(schema.user).set(patch).where(eq(schema.user.id, id)) + // submission.username 是冗余列(判题历史按用户名查),改名后必须一起改,否则历史提交查不到 + if (existing.user.username !== username) { + await tx.update(schema.submission).set({ username }) + .where(eq(schema.submission.username, existing.user.username)) + } + await tx.update(schema.userProfile).set({ realName: data.realName }) + .where(eq(schema.userProfile.userId, id)) + }) + + const [row] = await selectUser(id) + return success(c, serialize(row!)) +}) + +adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { + const parsed = importUsersRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const rows = parsed.data.users + const prepared: { username: string; password: string; raw: string; email: string; realName: string; className: string | null }[] = [] + for (const [username, password, email, realName] of rows) { + const className = classNameOf(username) + if (!className.ok) return failure(c, 400, "invalid-class-name", className.message) + prepared.push({ + username, + password: await Bun.password.hash(password, { algorithm: "argon2id" }), + raw: password, + email, + realName, + className: className.value, + }) + } + + const existing = await db.select({ username: schema.user.username }).from(schema.user) + .where(inArray(schema.user.username, prepared.map((item) => item.username))) + if (existing.length) { + return failure(c, 409, "username-exists", `用户名已存在:${existing.map((row) => row.username).join("、")}`) + } + + // 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在 + const created = await db.transaction(async (tx) => { + const users = await tx.insert(schema.user).values(prepared.map((item) => ({ + username: item.username, + password: item.password, + rawPassword: item.raw, + email: item.email, + className: item.className, + adminType: "Regular User", + problemPermission: "None", + createTime: new Date().toISOString(), + openApi: false, + isDisabled: false, + sessionKeys: [], + }))).returning({ id: schema.user.id, username: schema.user.username }) + const byName = new Map(users.map((row) => [row.username, row.id])) + await tx.insert(schema.userProfile).values(prepared.map((item) => ({ + userId: byName.get(item.username)!, + realName: item.realName, + // avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致 + avatar: "/public/avatar/default.png", + acmProblemsStatus: {}, + submissionNumber: 0, + acceptedNumber: 0, + totalScore: 0, + }))) + return users.length + }) + return success(c, { imported: created }, 201) +}) + +adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => { + const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required") + const me = c.get("user")!.id + if (parsed.data.ids.includes(me)) { + return failure(c, 400, "cannot-delete-self", "Current user can not be deleted") + } + // 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大, + // 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来: + // 撞外键说明该用户还有历史数据,应当禁用而不是删除。 + try { + const deleted = await db.transaction(async (tx) => { + await tx.delete(schema.userProfile).where(inArray(schema.userProfile.userId, parsed.data.ids)) + return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids)) + .returning({ id: schema.user.id }) + }) + return success(c, { deleted: deleted.length }) + } catch { + return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号") + } +}) + +adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const [existing] = await db.select({ id: schema.user.id }).from(schema.user) + .where(eq(schema.user.id, id)).limit(1) + if (!existing) return failure(c, 404, "user-not-found", "User does not exist") + // 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清 + const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("") + await db.update(schema.user).set({ + password: await Bun.password.hash(password, { algorithm: "argon2id" }), + rawPassword: password, + }).where(eq(schema.user.id, id)) + return success(c, resetPasswordResponseSchema.parse({ password })) +}) + +function randomBytes32() { + return Array.from({ length: 32 }, () => + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[randomInt(62)]).join("") +} diff --git a/apps/api/src/routes/admin/achievement.ts b/apps/api/src/routes/admin/achievement.ts new file mode 100644 index 0000000..f30810f --- /dev/null +++ b/apps/api/src/routes/admin/achievement.ts @@ -0,0 +1,114 @@ +import { + achievementMetricSchema, + adminAchievementSchema, + createAchievementRequestSchema, + updateAchievementRequestSchema, +} from "@oj2/contract" +import { asc, eq } from "drizzle-orm" +import { Hono } from "hono" + +import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" +import { db, schema } from "../../db" +import { failure, success } from "../../http" +import { ACHIEVEMENT_METRICS, findMetric, metricName } from "../../services/achievement-metrics" +import { rescanAchievement } from "../../services/achievements" +import { queryInteger } from "../helpers" + +export const adminAchievementRoutes = new Hono() + +function serialize(row: typeof schema.achievement.$inferSelect) { + return adminAchievementSchema.parse({ + id: row.id, + name: row.name, + description: row.description, + icon: row.icon, + rarity: row.rarity, + hidden: row.hidden, + metric: row.metric, + metricName: metricName(row.metric), + operator: row.operator, + threshold: row.threshold, + visible: row.visible, + unlockCount: row.unlockCount, + order: row.order, + createTime: row.createTime, + }) +} + +/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */ +adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) => + success(c, ACHIEVEMENT_METRICS.map((item) => achievementMetricSchema.parse(item)))) + +adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => { + const rows = await db.select().from(schema.achievement) + .orderBy(asc(schema.achievement.order), asc(schema.achievement.id)) + return success(c, rows.map(serialize)) +}) + +adminAchievementRoutes.get("/achievements/:id", requireSuperAdmin, async (c) => { + const [row] = await db.select().from(schema.achievement) + .where(eq(schema.achievement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1) + if (!row) return failure(c, 404, "achievement-not-found", "成就不存在") + return success(c, serialize(row)) +}) + +adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => { + const parsed = createAchievementRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") + } + if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在") + + const [created] = await db.insert(schema.achievement).values({ + ...parsed.data, + unlockCount: 0, + createTime: new Date().toISOString(), + }).returning() + + // 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就 + // 只有从今往后的提交才算,老用户永远拿不到 + await rescanAchievement(created!.id) + const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, created!.id)).limit(1) + return success(c, serialize(row!), 201) +}) + +adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误") + } + if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在") + + const [before] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1) + if (!before) return failure(c, 404, "achievement-not-found", "成就不存在") + + const [after] = await db.update(schema.achievement).set(parsed.data) + .where(eq(schema.achievement.id, id)).returning() + + // 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略), + // 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。 + // 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人), + // 只看 operator/threshold 会漏掉这两种。 + const changed = + before.metric !== after!.metric || + before.operator !== after!.operator || + before.threshold !== after!.threshold || + before.visible !== after!.visible + if (after!.visible && changed) await rescanAchievement(id) + + const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1) + return success(c, serialize(row!)) +}) + +adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + // user_achievement 的外键同样是 NO ACTION(Django 的级联在应用层),先清子表 + const deleted = await db.transaction(async (tx) => { + await tx.delete(schema.userAchievement).where(eq(schema.userAchievement.achievementId, id)) + return tx.delete(schema.achievement).where(eq(schema.achievement.id, id)) + .returning({ id: schema.achievement.id }) + }) + if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在") + return success(c, null) +}) diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 82fb8a6..382410f 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -1,6 +1,8 @@ import { Hono } from "hono" import type { AppEnv } from "../../auth/middleware" +import { adminAccountRoutes } from "./account" +import { adminAchievementRoutes } from "./achievement" import { adminAiRoutes } from "./ai" import { adminAnnouncementRoutes } from "./announcement" import { adminTutorialRoutes } from "./tutorial" @@ -15,6 +17,8 @@ import { adminTutorialRoutes } from "./tutorial" */ export const adminRoutes = new Hono() +adminRoutes.route("/", adminAccountRoutes) +adminRoutes.route("/", adminAchievementRoutes) adminRoutes.route("/", adminAiRoutes) adminRoutes.route("/", adminAnnouncementRoutes) adminRoutes.route("/", adminTutorialRoutes) diff --git a/apps/api/src/services/achievement-metrics.ts b/apps/api/src/services/achievement-metrics.ts new file mode 100644 index 0000000..f0f5408 --- /dev/null +++ b/apps/api/src/services/achievement-metrics.ts @@ -0,0 +1,49 @@ +/** + * 成就指标注册表。对齐旧后端 `achievement/metrics.py` 的 METRIC_REGISTRY —— + * 那边是装饰器注册,这里是一张表,作用一样:**后台下拉框里有什么,取决于代码里注册了什么**。 + * + * 加一个新维度必须同时改这里和 `achievements.ts` 的计算逻辑并部署。 + * 只加这里而不算,会造出一个谁也拿不到的成就;只算而不加这里,后台就选不到它。 + */ +export interface AchievementMetric { + key: string + name: string + helpText: string + /** 元指标:统计的是「已解锁成就数」自身,判定要在其它成就结算完之后再跑一轮 */ + meta?: boolean +} + +export const ACHIEVEMENT_METRICS: AchievementMetric[] = [ + { key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" }, + { key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" }, + { key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" }, + { key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" }, + { key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" }, + { key: "max_ac_streak_days", name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数" }, + { key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" }, + { key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" }, + { key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" }, + { key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" }, + { key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" }, + { key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:00–5:00 之间的提交次数" }, + { key: "early_bird_submissions", name: "早起提交次数", helpText: "5:00–7:00 之间的提交次数" }, + { key: "compile_error_count", name: "编译错误次数", helpText: "累计编译错误的次数" }, + { key: "max_wa_before_ac", name: "屡败屡战", helpText: "单题失败最多多少次后终于通过" }, + { key: "max_ac_in_one_day", name: "单日最多 AC", helpText: "一天之内最多通过多少题" }, + { key: "max_code_lines", name: "最长代码行数", helpText: "提交过的最长代码有多少行" }, + { key: "achievement_unlocked_count", name: "已解锁成就数", helpText: "已解锁的成就数量(不含白金档)", meta: true }, +] + +const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item])) + +export function findMetric(key: string) { + return BY_KEY.get(key) ?? null +} + +export function metricName(key: string) { + return BY_KEY.get(key)?.name ?? key +} + +/** 稀有度四档。乱填的值会让成就汇总接口的分档统计对不上:野值算进总数却不出现在任何一档 */ +export const RARITIES = ["bronze", "silver", "gold", "platinum"] as const +export const OPERATORS = ["gte", "lte"] as const diff --git a/apps/api/src/services/achievements.ts b/apps/api/src/services/achievements.ts index cd29fc6..70fff44 100644 --- a/apps/api/src/services/achievements.ts +++ b/apps/api/src/services/achievements.ts @@ -1,6 +1,8 @@ -import { and, count, eq, isNull, ne, notInArray, sql } from "drizzle-orm" +import { and, count, countDistinct, eq, isNotNull, isNull, ne, notInArray, sql } from "drizzle-orm" import { db, schema } from "../db" +import { publishAchievementNotification } from "../events" +import { findMetric } from "./achievement-metrics" import { isAccepted, JudgeStatus } from "../judge/status" import { objectValue } from "../routes/helpers" @@ -158,3 +160,105 @@ export async function updateAchievementsForProblemSet(userId: number) { .where(eq(schema.userStat.userId, userId)) return [...first, ...(await unlockAchievements(userId, metrics, true))] } + +/** + * 参赛场次。旧后端 `ContestJoined` 只实现了 recompute、不走 on_submission + * (比赛提交在 build_ctx 就被跳过了),所以它只在 rescan 时刷新。这里保持同样口径: + * 去重数一遍该用户有过提交的比赛数。 + * + * 注意:迁移过来时新后端**整个漏掉了这个指标**,配在 contest_joined 上的成就 + * 会永远解锁不了。补上。 + */ +async function contestJoinedCount(userId: number) { + const [row] = await db + .select({ value: countDistinct(schema.submission.contestId) }) + .from(schema.submission) + .where(and(eq(schema.submission.userId, userId), isNotNull(schema.submission.contestId))) + return row?.value ?? 0 +} + +/** + * 新建成就、调低阈值、或从下架改成上架之后,把已达标的存量用户补发一遍。 + * + * 判定平时只在判题结算时发生,后台改了配置不会自动补发,必须显式扫。 + * 对齐旧 `rescan_achievement`:只处理 visible 的成就,逐个用户 unlock, + * 且标记 backfilled=true —— 这是补发不是刚挣到的,前端据此只显示「已获得」 + * 而不显示日期,否则一次补发会给几百人盖同一个时间戳,把「最近获得」板块冲垮。 + */ +export async function rescanAchievement(achievementId: number) { + const [achievement] = await db.select().from(schema.achievement) + .where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true))).limit(1) + if (!achievement) return { scanned: 0, unlocked: 0 } + + const metric = findMetric(achievement.metric) + if (!metric) return { scanned: 0, unlocked: 0 } + + // contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) + if (achievement.metric === "contest_joined") await refreshContestJoinedForAll() + + const already = new Set( + (await db.select({ userId: schema.userAchievement.userId }).from(schema.userAchievement) + .where(eq(schema.userAchievement.achievementId, achievement.id))).map((row) => row.userId), + ) + + const stats = await db.select({ userId: schema.userStat.userId, metrics: schema.userStat.metrics }) + .from(schema.userStat) + let unlocked = 0 + for (const stat of stats) { + if (already.has(stat.userId)) continue + const value = objectValue(stat.metrics)[achievement.metric] + if (typeof value !== "number") continue + const hit = achievement.operator === "gte" + ? value >= achievement.threshold + : value <= achievement.threshold + if (!hit) continue + const inserted = await db.insert(schema.userAchievement).values({ + userId: stat.userId, + achievementId: achievement.id, + unlockTime: new Date().toISOString(), + backfilled: true, + notified: false, + }).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }) + .returning({ id: schema.userAchievement.id }) + if (inserted.length === 0) continue + unlocked += 1 + await db.update(schema.achievement) + .set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` }) + .where(eq(schema.achievement.id, achievement.id)) + await publishAchievementNotification(stat.userId, [{ + id: achievement.id, + name: achievement.name, + description: achievement.description, + icon: achievement.icon, + rarity: achievement.rarity, + kind: "achievement", + }]) + } + return { scanned: stats.length, unlocked } +} + +/** 把所有有过比赛提交的用户的 contest_joined 重算一遍,供 rescan 前置调用 */ +async function refreshContestJoinedForAll() { + const rows = await db + .selectDistinct({ userId: schema.submission.userId }) + .from(schema.submission) + .where(isNotNull(schema.submission.contestId)) + for (const { userId } of rows) { + const value = await contestJoinedCount(userId) + await db.transaction(async (tx) => { + await tx.insert(schema.userStat).values({ + userId, + metrics: {}, + updateTime: new Date().toISOString(), + }).onConflictDoNothing({ target: schema.userStat.userId }) + const [stat] = await tx.select().from(schema.userStat) + .where(eq(schema.userStat.userId, userId)).for("update").limit(1) + if (!stat) return + const merged = objectValue(stat.metrics) + merged.contest_joined = value + await tx.update(schema.userStat) + .set({ metrics: merged, updateTime: new Date().toISOString() }) + .where(eq(schema.userStat.id, stat.id)) + }) + } +} diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index a21369f..313b710 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -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) { + 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("admin/achievement") + return legacyResponse(api2.get("admin/achievements")) } export function getMetricOptions() { - return http.get("admin/achievement/metrics") + return legacyResponse(api2.get("admin/achievement-metrics")) } export function createAchievement(data: Partial) { - return http.post("admin/achievement", data) + return legacyResponse( + api2.post("admin/achievements", toAchievementBody(data)), + ) } export function updateAchievement(data: Partial) { - return http.put("admin/achievement", data) + return legacyResponse( + 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}`) } diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index e2708fe..5241b3a 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -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() })