diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 49adbe9..d3f5c81 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -34,7 +34,7 @@ import { revokeUserSessions } from "../../auth/session" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" -import { queryInteger, sampleUser } from "../helpers" +import { classPrefixCondition, queryInteger, sampleUser } from "../helpers" export const adminAccountRoutes = new Hono() @@ -130,7 +130,11 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => { const where = and( inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false), - keyword ? ilike(schema.user.username, `%${keyword}%`) : undefined, + // 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含 + keyword + ? (classPrefixCondition(keyword) ?? + ilike(schema.user.username, `%${keyword}%`)) + : undefined, ) const [totalRows, rows] = await Promise.all([ diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 355fa05..8e59ce6 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -26,7 +26,7 @@ import { failure, parseBody, success } from "../../http" import { sniffImageExtension } from "../../services/image" import { getWebsiteOptions } from "../../services/options" import { dayStart } from "../../time" -import { queryInteger } from "../helpers" +import { classPrefixCondition, queryInteger } from "../helpers" export const adminConfRoutes = new Hono() @@ -253,14 +253,17 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => { adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => { // 传的是**班级前缀**(形如 ks251),不是班级号 —— 前端输入框写的就是「班级前缀」, - // 拿到结果后按这个前缀 split 取姓名。这里按前缀匹配,与旧 istartswith 一致, - // 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松 + // 拿到结果后按这个前缀 split 取姓名。班级前缀走 classPrefixCondition(ks251 不带上 + // ks2511);别的形状的输入照旧 istartswith,与旧接口一致 const classroom = c.req.query("classroom")?.trim() if (!classroom) return failure(c, 400, "invalid-request", "需要班级号") const rows = await db .select({ username: schema.user.username }) .from(schema.user) - .where(ilike(schema.user.username, `${classroom}%`)) + .where( + classPrefixCondition(classroom) ?? + ilike(schema.user.username, `${classroom}%`), + ) .orderBy(sql`random()`) .limit(10) return success( diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 0ab5a20..a6c65f5 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -1,6 +1,6 @@ import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract" -import { and, count, eq, ilike, notInArray } from "drizzle-orm" +import { and, count, eq, ilike, notInArray, sql } from "drizzle-orm" import type { AuthUser } from "../auth/session" import { db, schema } from "../db" @@ -44,6 +44,22 @@ export function stripClassPrefix( 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` 这些)。 * diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index c1c617f..b1644dc 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -33,7 +33,13 @@ import { import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { computeProgress } from "../services/problemset" -import { asFilterValue, asRecord, queryInteger, sampleUser } from "./helpers" +import { + asFilterValue, + asRecord, + classPrefixCondition, + queryInteger, + sampleUser, +} from "./helpers" export const problemsetRoutes = new Hono() @@ -499,7 +505,15 @@ problemsetRoutes.get( const className = c.req.query("className")?.trim() const completion = c.req.query("completionStatus")?.trim() const filters = [eq(schema.problemsetProgress.problemsetId, id)] - if (className) filters.push(ilike(schema.user.username, `%${className}%`)) + // 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配, + // 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含 + if (className) { + const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className + filters.push( + classPrefixCondition(classPrefix) ?? + ilike(schema.user.username, `%${className}%`), + ) + } if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true)) else if (completion === "in_progress")