Files
OJ2/apps/api/src/routes/helpers.ts
T
xuyueandClaude Opus 5.5 af2ca31de5
Deploy / deploy (push) Canceled after 0s
fix(学生管理员): 260 班看板 23 人、数据统计只有 22 人,学生管理员还能看同学的作业代码——学生管理员一律按学生算
- 统计、题目页各班表、流程图统计的班级名单原来只认普通用户,改成和看板、排行榜一样的
  STUDENT_ROLES,学生管理员进名单、进分母
- 看别人的提交代码和流程图只给老师、超管和出题人;题单防抄对学生管理员也生效。
  原来「比赛进行中学生管理员不能看」那条特例随之去掉
- 题单的「用户进度」原来只给题单创建者,其余老师点开报「题单不存在」;改成所有老师都能看,
  和看板、统计对所有班开放一致。后台能改能删的接口仍只给创建者

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:11 +08:00

231 lines
9.6 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, 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<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。
*/
/**
* 「这个班的人」:`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<string>()
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
}