Files
OJ2/apps/api/src/routes/helpers.ts
T
xuyue d25b5dbe6d
Deploy / deploy (push) Canceled after 0s
update
2026-09-27 19:36:28 +08:00

174 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
import { and, count, eq, ilike, 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<T extends string | number>(value: string | number): T {
return value as T
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
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<string, string> = {}
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。
*/
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}%`))
}