Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
6.6 KiB
TypeScript
166 lines
6.6 KiB
TypeScript
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
|
||
|
||
import { and, count, eq, ilike, notInArray } 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
|
||
}
|
||
|
||
/**
|
||
* 拿 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 objectValue(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(objectValue(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}%`))
|
||
}
|