feat(账号): 新建的号不再加 ks + 班号前缀,班级单独选;学生改不了真名了

- 批量建号先选班再贴名单,用户名就是名字;被别的班或注册的号占了自动加数字(xuyue2)。
  查重改成查「这个班里有没有同名的」,老号按剥掉 ks 前缀的名字比
- 后台编辑、新建用户能直接选班级;以前班级只能从用户名的 ks 前缀推,改不了
- 登录点名字时名单接口一并给出用户名,新号老号都登得上;本机记的班级兼容旧的 ks257 写法
- 筛选、抽人、「看看他们的写法」都按班级认人,不再拼 ks 前缀(新号拼出来永远圈不进去)
- 接口 PUT /me/profile 不再收真名:界面早没入口,但早几届有 44 人把真名改成了外号
- 0042:两个被推成非班号班级的老号(xrm、王仁杰)改成没填班级
- 老账号不改名,只认 ks 前缀的地方收在契约 username.ts 一处

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 23:22:20 +08:00
1 parent 48590eee60
commit 221d264701
25 files changed
+4234 -156

No files matched your search

@@ -0,0 +1,4 @@
-- 2026-11 班级不再从用户名的 ks 前缀推出来,改成建号时明确给。两个老号当年被推成了不是班号的班级
-- (ksxrm → 'xrm',ks王仁杰2 → '王仁杰'),任何班级下拉都选不到它们,后台一编辑还会被班号校验挡住,
-- 改成没填班级。只动不是 3~4 位数字的,正常的班一行不碰
UPDATE "user" SET "class_name" = NULL WHERE "class_name" IS NOT NULL AND "class_name" !~ '^[0-9]{3,4}$';
File diff suppressed because it is too large. Load diff
+7
View File
@@ -295,6 +295,13 @@
"when": 1793626595966,
"tag": "0041_lesson_mode",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1793632543220,
"tag": "0042_class_name_cleanup",
"breakpoints": true
}
]
}
+47 -40
View File
@@ -2,6 +2,8 @@ import {
adminTypeSchema,
checkUsernamesRequestSchema,
deleteUsersRequestSchema,
displayName,
legacyClassOf,
NO_CLASS,
type CheckUsernamesResponse,
importUsersRequestSchema,
@@ -26,36 +28,12 @@ import { revokeUserSessions } from '../../auth/session'
import { requireSuperAdmin, type AppEnv } from '../../auth/middleware'
import { db, schema } from '../../db'
import { failure, parseBody, success } from '../../http'
import { classPrefixCondition, queryInteger, sampleUser } from '../helpers'
import { classKeywordCondition, queryInteger, sampleUser } from '../helpers'
import { publishConfigUpdate } from '../../events'
import { getWebsiteOptions } from '../../services/options'
export const adminAccountRoutes = new Hono<AppEnv>()
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。
@@ -120,8 +98,8 @@ adminAccountRoutes.get('/rankings/users', requireSuperAdmin, async (c) => {
const where = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
// 输入班级前缀(ks251)按班级匹配,否则照旧按用户名包含
keyword ? (classPrefixCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
// 填的是班号(251,或者老习惯 ks251)按班级圈,否则按用户名包含
keyword ? (classKeywordCondition(keyword) ?? ilike(schema.user.username, `%${keyword}%`)) : undefined,
)
const [totalRows, rows] = await Promise.all([
@@ -220,20 +198,45 @@ adminAccountRoutes.get('/users', requireSuperAdmin, async (c) => {
})
/**
* 批量建号之前查重(批量建号页边粘名单边查):哪些用户名已经有了、哪天建的。
* 比较时归一成小写,和导入、登录同一个口径
* 批量建号之前查重(批量建号页边粘名单边查):这个班里已经有谁了(多半是同一个人粘了两次),
* 以及每个名字会建成什么用户名(名字被别的班、注册的号占了就加数字)。比较归一成小写,和导入、登录同一个口径
*/
adminAccountRoutes.post('/users/check', requireSuperAdmin, async (c) => {
const parsed = await parseBody(c, checkUsernamesRequestSchema)
if (!parsed.success) return parsed.response
const names = [...new Set(parsed.data.usernames.map((name) => name.toLowerCase()))]
const rows = await db
const { className, names } = parsed.data
const wanted = [...new Set(names.map((name) => name.toLowerCase()))]
// 同班同名:老号是 ks232张三,按剥掉前缀的名字比
const classmates = await db
.select({ username: schema.user.username, createTime: schema.user.createTime })
.from(schema.user)
.where(inArray(sql`lower(${schema.user.username})`, names))
return success(c, { existing: rows } satisfies CheckUsernamesResponse)
.where(eq(schema.user.className, className))
const existing = classmates
.map((row) => ({ name: displayName(row.username), username: row.username, createTime: row.createTime }))
.filter((row) => wanted.includes(row.name.toLowerCase()))
// 用户名全站唯一:名字本身和「名字+数字」占了哪些
const taken = await db
.select({ username: sql<string>`lower(${schema.user.username})` })
.from(schema.user)
.where(inArray(sql`regexp_replace(lower(${schema.user.username}), '[0-9]+$', '')`, wanted))
return success(c, { existing, usernames: suggestUsernames(names, new Set(taken.map((row) => row.username))) } satisfies CheckUsernamesResponse)
})
/**
* 新号的用户名:名字没被占就是名字本身,占了往后加数字(杨涵宇2、杨涵宇3)。同一批里先到先得。
* 比较按小写,和登录、导入同一个口径
*/
function suggestUsernames(names: string[], taken: Set<string>) {
return names.map((name) => {
let candidate = name
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${name}${n}`
taken.add(candidate.toLowerCase())
return candidate
})
}
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')
@@ -250,8 +253,8 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
const username = data.username.trim().toLowerCase()
const email = data.email.trim().toLowerCase()
const className = classNameOf(username)
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
// 班级明确给了就用;老页面不发这一项,不动原来的班(以前是从用户名的 ks 前缀推,改名就跟着变)
const className = data.className !== undefined ? data.className : existing.user.className
const [dupUsername] = await db
.select({ id: schema.user.id })
@@ -270,7 +273,7 @@ adminAccountRoutes.put('/users/:id', requireSuperAdmin, async (c) => {
const patch: Partial<typeof schema.user.$inferInsert> = {
username,
email,
className: className.value,
className,
adminType: data.adminType,
isDisabled: data.isDisabled,
problemPermission: normalizePermission(data.adminType, data.problemPermission),
@@ -337,10 +340,14 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
// 查重建出第二个账号,两个人登录时撞成同一条记录。
const prepared: Prepared[] = []
for (const [username, password, email, realName] of rows) {
for (const [username, password, email, realName, explicitClass] of rows) {
const name = username.toLowerCase()
const className = classNameOf(name)
if (!className.ok) return failure(c, 400, 'invalid-class-name', className.message)
// 班级明确给了就用(新号不带 ks 前缀);老写法没给,退回从 ks 前缀推,位数不对报错不猜
const legacy = explicitClass === undefined ? legacyClassOf(name) : null
if (legacy === undefined) {
return failure(c, 400, 'invalid-class-name', `用户名 ${name} 里 ks 后面的班级号位数不对,班级号要是 3~4 位数字`)
}
const className = explicitClass ?? legacy
const mail = email.trim().toLowerCase()
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
@@ -355,7 +362,7 @@ adminAccountRoutes.post('/users', requireSuperAdmin, async (c) => {
raw: password,
email: mail,
realName,
className: className.value,
className,
})
}
+9 -9
View File
@@ -11,7 +11,7 @@ import {
import { randomInt } from 'node:crypto'
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { resolve } from 'node:path'
import { count, countDistinct, desc, eq, gte, ilike, inArray, isNull, not, sql } from 'drizzle-orm'
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, not, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAdmin, requireSuperAdmin, type AppEnv } from '../../auth/middleware'
@@ -22,12 +22,12 @@ import { failure, parseBody, success } from '../../http'
import { sniffImageExtension } from '../../services/image'
import { getWebsiteOptions } from '../../services/options'
import { calendarDay, dayStart, localTime, termStart, weekStart } from '../../time'
import { STUDENT_ROLES } from '@oj2/contract'
import { displayName, STUDENT_ROLES } from '@oj2/contract'
import { TEACHER_ROLES } from '../helpers'
import { onlineUserIds } from '../../auth/presence'
import { JudgeStatus } from '../../judge/status'
import { suggestActiveClass } from '../../services/class-activity'
import { classPrefixCondition } from '../helpers'
import { classKeywordCondition } from '../helpers'
export const adminConfRoutes = new Hono<AppEnv>()
@@ -346,20 +346,20 @@ adminConfRoutes.get('/dashboard', requireSuperAdmin, async (c) => {
})
adminConfRoutes.get('/random-usernames', requireSuperAdmin, async (c) => {
// 传的是**班级前缀**(形如 ks251),不是班级号 —— 前端输入框写的就是「班级前缀」,
// 拿到结果后按这个前缀 split 取姓名。班级前缀走 classPrefixCondition(ks251 不带上
// ks2511);别的形状的输入照旧 istartswith,与旧接口一致
// 后台首页「抽一个人」:从这个班随机拿 10 个,回的是界面上显示的名字(老号剥掉 ks 前缀)。
// 班号按 class_name 精确匹配;老页面发的是 ks251,classKeywordCondition 两种都认
const classroom = c.req.query('classroom')?.trim()
if (!classroom) return failure(c, 400, 'invalid-request', '需要班级号')
const where = classroom ? classKeywordCondition(classroom) : undefined
if (!where) return failure(c, 400, 'invalid-request', '需要班级号')
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(classPrefixCondition(classroom) ?? ilike(schema.user.username, `${classroom}%`))
.where(and(where, eq(schema.user.isDisabled, false)))
.orderBy(sql`random()`)
.limit(10)
return success(
c,
rows.map((row) => row.username),
rows.map((row) => displayName(row.username)),
)
})
+2 -2
View File
@@ -53,8 +53,8 @@ function classCondition(value: string | null) {
}
/**
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
* 推不出来时就是 null(见 admin/account.ts 的 classNameOf),把这些人过滤掉等于让他们
* 没有班级的学生**照样统计**(班级列显示为空)。班级是建号时给的(老号当年从用户名的 ks 前缀推),
* 没给就是 null,把这些人过滤掉等于让他们
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
*/
function studentCondition(value: string | null) {
+11 -14
View File
@@ -1,6 +1,6 @@
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 { and, count, eq, ilike, inArray, isNull, notInArray } from 'drizzle-orm'
import type { AuthUser } from '../auth/session'
import { db, schema } from '../db'
@@ -29,8 +29,9 @@ export function sampleUser(
}
/**
* 去掉用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
* 对齐旧后端 `utils/shortcuts.py:52` 的 `strip_class_prefix`。
* 去掉老号用户名里的 `ks<班级号>` 前缀,得到学生本人那一段:`ks251张三` + `251` → `张三`。
* 2026-11 起新建的号不带前缀(用户名就是名字),原样返回。和契约 `displayName` 是同一件事,
* 这里按这个人自己的班级剥,比正则更严:班号对不上的不剥。
*
* 用 startsWith + slice 而不是 replace:replace 会删掉字符串中间的匹配,
* 前缀对不上时从中间截出乱码。前缀不匹配就原样返回。
@@ -42,19 +43,15 @@ export function stripClassPrefix(username: string, className: string | null | un
}
/**
* 用户名按「班级前缀」匹配:`ks251` 只匹配 251 班,**不能**把 `ks2511张三` 也捞进来。
* 搜索框里填的是一个班(`232`,或者老习惯 `ks232`):按 `user.class_name` 精确圈这个班。
* 不是这个形状返回 undefined,由调用方退回按用户名包含匹配。
*
* 班级号 3~4 位并存(见 admin/account.ts 的 classNameOf),裸 `ilike 'ks251%'` 会把
* 2510~2519 班全算进 251 班。所以输入是 `ks` + 3 位以上数字时,要求紧跟着的不是数字;
* 1~2 位(`ks25`)是年级前缀,照旧宽松匹配。输入不是 `ks+数字` 形状时返回 undefined,
* 由调用方决定退回什么匹配方式。
* 原来是拿用户名前缀 `ks232` 去 ilike,还得专门挡住 `ks2321…` 被 232 班捞走;
* 2026-11 起新号不带前缀,前缀匹配会把他们全漏掉,所以只认班级这一列
*/
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]`}`)
export function classKeywordCondition(input: string) {
const matched = /^(?:ks)?(\d{3,4})$/i.exec(input)
return matched ? eq(schema.user.className, matched[1]!) : undefined
}
/**
+4 -4
View File
@@ -1,4 +1,4 @@
import type { Quote, WebsiteConfig } from '@oj2/contract'
import { CLASS_NAME_RE, type ClassMember, type Quote, type WebsiteConfig } from '@oj2/contract'
import { asc, desc, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { resolve } from 'node:path'
@@ -94,7 +94,7 @@ siteRoutes.get('/quotes/random', async (c) => {
siteRoutes.get('/classes/:className/usernames', async (c) => {
const className = c.req.param('className').trim()
if (!/^\d{3,4}$/.test(className)) {
if (!CLASS_NAME_RE.test(className)) {
return failure(c, 400, 'invalid-class', 'Class name must contain 3 or 4 digits')
}
const rows = await db
@@ -102,9 +102,9 @@ siteRoutes.get('/classes/:className/usernames', async (c) => {
.from(schema.user)
.where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
// 名字给人点,用户名拿去登录:老号是 ks232张三(点的是「张三」),新号两个一样
return success(
c,
rows.map(({ username }) => stripClassPrefix(username, className)),
rows.map(({ username }) => ({ name: stripClassPrefix(username, className), username }) satisfies ClassMember),
)
})
+1 -1
View File
@@ -403,7 +403,7 @@ const GRID_LIMIT = 5000
/**
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
* 班级和学生名的匹配和统计接口同一套(scopedUsers:班级精确、名字包含)。
*/
submissionStatisticsRoutes.get('/submissions/statistics/grid', requireTeacher, async (c) => {
const scope = await statisticsScope(c)