import { ADMIN_ROLES, NO_CLASS, STUDENT_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract" import { and, count, eq, ilike, inArray, isNull, notInArray, sql } from "drizzle-orm" import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { NON_FAILURE_RESULTS } from "../judge/status" /** * 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。 * * 旧后端把「是否下发真名」做成 `need_real_name` 开关,**默认关闭**,全仓 11 处调用里只有 * 比赛榜单一处显式打开。这里保持同一约定:`realName` 默认不下发,需要的地方显式传 * `{ includeRealName: true }`。 * * 所有下发用户对象的地方都必须走这个函数,不要再手写 `{ id, username, realName }` —— * 手写的话下次新增端点必然重犯「学生真名无条件下发」。 */ export function sampleUser( source: { id: number; username: string }, realName: string | null | undefined, options: { includeRealName?: boolean } = {}, ): SampleUser { return { id: source.id, username: source.username, realName: options.includeRealName === true ? (realName ?? null) : null, } satisfies SampleUser } /** * 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。 * 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。 * * 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配, * 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。 */ export function stripClassPrefix(username: string, className: string | null | undefined) { if (!className) return username const prefix = `ks${className}` return username.startsWith(prefix) ? username.slice(prefix.length) : username } /** * 用户名按「班级前缀」匹配:`ks251` 只匹配 251 班,**不能**把 `ks2511张三` 也捞进来。 * * 班级号 3~4 位并存(见 admin/account.ts 的 classNameOf),裸 `ilike 'ks251%'` 会把 * 2510~2519 班全算进 251 班。所以输入是 `ks` + 3 位以上数字时,要求紧跟着的不是数字; * 1~2 位(`ks25`)是年级前缀,照旧宽松匹配。输入不是 `ks+数字` 形状时返回 undefined, * 由调用方决定退回什么匹配方式。 */ export function classPrefixCondition(input: string) { const matched = /^ks(\d+)$/i.exec(input) if (!matched) return undefined const prefix = ilike(schema.user.username, `${input}%`) if (matched[1]!.length < 3) return prefix return and(prefix, sql`${schema.user.username} !~* ${`^${input}[0-9]`}`) } /** * 拿 query 里的筛选值去比对 `$type` 收窄过的列(`submission.result`、`problem.difficulty` 这些)。 * * 值来自 URL,不受控:前端下拉框以外的任何字符串都可能进来。对不上枚举时 SQL 一行都匹配不到, * 和列没收窄之前的行为完全一致 —— 所以这里只做类型上的交接,**不加校验**: * 在这儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」,那是另一种行为。 */ export function asFilterValue(value: string | number): T { return value as T } export function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} } export function queryInteger( value: string | undefined, fallback: number, options: { min?: number; max?: number } = {}, ) { const parsed = Number(value) if (!Number.isInteger(parsed)) return fallback if (options.min !== undefined && parsed < options.min) return fallback if (options.max !== undefined && parsed > options.max) return fallback return parsed } // 角色白名单本身在 `@oj2/contract` 的 roles.ts,那是全仓唯一的定义处; // 这里只是把它们包成吃 AuthUser 的谓词。为什么必须是白名单,见那边的注释。 export { TEACHER_ROLES } // 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 —— // 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。 // 需要「非管理员即受限」时一律用 !isAdminRole(user)。 export function isAdminRole(user: AuthUser | null | undefined) { return Boolean(user && ADMIN_ROLES.includes(user.adminType)) } export function isTeacherOrAbove(user: AuthUser | null | undefined) { return Boolean(user && TEACHER_ROLES.includes(user.adminType)) } export function isSuperAdmin(user: AuthUser | null | undefined) { return user?.adminType === "Super Admin" } export function publicTemplates(value: unknown) { const templates: Record = {} for (const [language, raw] of Object.entries(asRecord(value))) { if (typeof raw !== "string") continue const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/) templates[language] = match?.[1] ?? "" } return templates } export function rounded(value: number, digits = 2) { const factor = 10 ** digits return Math.round(value * factor) / factor } /** * 这个用户在这道题上失败了几次 —— 也就是 AI 提示的解锁进度。 * * 题目详情下发的 `myFailedCount` 和 `POST /ai/hint` 的服务端闸门必须用**同一个**口径, * 所以两边都走这里。原来是各写各的:详情那边 `notInArray(result, [0, 10])` 把 * 等待评分 / 正在评分也算成失败,连点三次提交就能让按钮亮起来,而 hint 端点排掉了 * 这两个状态,于是按钮亮着、点下去回 `hint-locked`。 */ export async function countFailedSubmissions(userId: number, problemId: number) { const [failed] = await db .select({ value: count() }) .from(schema.submission) .where( and( eq(schema.submission.userId, userId), eq(schema.submission.problemId, problemId), notInArray(schema.submission.result, NON_FAILURE_RESULTS), ), ) return failed?.value ?? 0 } /** * 用户名模糊匹配到的账号。统计的两件事都从它出发:**筛哪些提交**(拿 id), * 以及**花名册**(班级人数、谁没做,见调用处的过滤)。 * * 这里必须查 `user` 表而不是 `submission.username` —— 后者是提交那一刻冻结的 * 快照,学生改名之后旧提交还挂着旧名字,`ilike submission.username` 匹配不上。 * * 生产快照实测(2026-09-08):24 级数媒两个班改成编号制用户名之后,85 人的 * 提交挂在旧名下。查 `ks249` 旧口径 0 条 / 新口径 7 条 —— 整个班 48 人全掉进 * 「一条没交」;查 `ks248` 20 条 / 54 条,13 个人的成绩查不出来。 * * 返回**全部**匹配到的账号,禁用的和教师也在内 —— 「谁交过」不该受这两个条件 * 影响。花名册那一份在调用处再筛(未禁用 + 普通用户),教师和管理员不进分母。 * * 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有 * `user_id`),更是只能从这儿拿 id。 */ /** * 「这个班的人」:`user.class_name` 精确匹配;NO_CLASS 是没填班级的学生(class_name 为 null)。 * 统计、提交列表、流程图按班级筛都走它,没填班级的才能和普通班一样被圈出来。 * * NO_CLASS 只圈学生(含学生管理员,见 STUDENT_ROLES):老师、管理员的号本来就都不填班级,不挡的话老师自己试题的提交 * 会混进「没填班级」,还算进做完的人数里(切课那边也只算学生,两边口径一致) */ export function classCondition(className: string) { return className === NO_CLASS ? and(isNull(schema.user.className), inArray(schema.user.adminType, [...STUDENT_ROLES])) : eq(schema.user.className, className) } /** * 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给 * 就取交集,都没给返回 null(不圈人)。 * * 原来统计只收一个 username、前端拼成 `ks231` 去做包含匹配 —— 231 班会连带 2311、2312 班 * (快照里 37 人变成 138 人),而提交列表的班级筛选是精确的,两边的人对不上 */ export async function scopedUsers(username: string | undefined, className: string | undefined) { if (!username && !className) return null return db .select({ id: schema.user.id, username: schema.user.username, className: schema.user.className, isDisabled: schema.user.isDisabled, adminType: schema.user.adminType, }) .from(schema.user) .where( and( className ? classCondition(className) : undefined, username ? ilike(schema.user.username, `%${username}%`) : undefined, ), ) } export async function matchedUsers(username: string) { return db .select({ id: schema.user.id, username: schema.user.username, className: schema.user.className, isDisabled: schema.user.isDisabled, adminType: schema.user.adminType, }) .from(schema.user) .where(ilike(schema.user.username, `%${username}%`)) } /** * 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 —— * 老师在投影前手敲,不该因为打了个全角逗号就查不出来。 */ export function parseDisplayIds(raw: string) { const seen = new Set() const ids: string[] = [] for (const part of raw.split(/[,,;;\s]+/)) { const id = part.trim() if (!id) continue const key = id.toLowerCase() if (seen.has(key)) continue seen.add(key) ids.push(id) } return ids }