chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
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>
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-16 08:27:34 -06:00
1 parent e600fd24cf
commit ed56a209ea
122 files changed
+10557 -4843

No files matched your search

+309 -122
View File
@@ -14,7 +14,18 @@ import {
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { z } from "zod"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono"
import { hashPassword } from "../../auth/password"
@@ -38,11 +49,16 @@ const CLASS_NAME_MAX_DIGITS = 4
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
* 对齐旧 `account/views/admin.py:get_class_name`。
*/
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } {
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) {
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} 位数字`,
@@ -56,16 +72,22 @@ function classNameOf(username: string): { ok: true; value: string | null } | { o
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
*/
function normalizePermission(adminType: AdminType, requested: ProblemPermission): ProblemPermission {
function normalizePermission(
adminType: AdminType,
requested: ProblemPermission,
): ProblemPermission {
if (adminType === "Super Admin") return "All"
if (adminType === "Regular User") return "None"
return requested || "Own"
}
function serialize(row: {
user: typeof schema.user.$inferSelect
realName: string | null
}, isOnline: boolean) {
function serialize(
row: {
user: typeof schema.user.$inferSelect
realName: string | null
},
isOnline: boolean,
) {
return {
id: row.user.id,
username: row.user.username,
@@ -83,10 +105,12 @@ function serialize(row: {
}
function selectUser(id: number) {
return db.select({ user: schema.user, realName: schema.userProfile.realName })
return db
.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, id)).limit(1)
.where(eq(schema.user.id, id))
.limit(1)
}
/**
@@ -110,29 +134,39 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where),
db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(where),
db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(
desc(schema.userProfile.acceptedNumber),
asc(schema.userProfile.submissionNumber),
asc(schema.user.id),
)
.limit(limit).offset(offset),
.limit(limit)
.offset(offset),
])
return success(c, {
results: rows.map(({ profile, user }) => ({
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
isOnline: null,
} satisfies RankProfile)),
results: rows.map(
({ profile, user }) =>
({
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
isOnline: null,
}) satisfies RankProfile,
),
total: totalRows[0]?.value ?? 0,
} satisfies AdminUserRank)
})
@@ -147,15 +181,18 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
// 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。
// 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。
const parsedType = adminTypeSchema.safeParse(type)
if (!parsedType.success) return failure(c, 400, "invalid-request", "角色筛选值不合法")
if (!parsedType.success)
return failure(c, 400, "invalid-request", "角色筛选值不合法")
filters.push(eq(schema.user.adminType, parsedType.data))
}
if (keyword) {
filters.push(or(
ilike(schema.user.username, `%${keyword}%`),
ilike(schema.userProfile.realName, `%${keyword}%`),
ilike(schema.user.email, `%${keyword}%`),
)!)
filters.push(
or(
ilike(schema.user.username, `%${keyword}%`),
ilike(schema.userProfile.realName, `%${keyword}%`),
ilike(schema.user.email, `%${keyword}%`),
)!,
)
}
const where = filters.length ? and(...filters) : undefined
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
@@ -166,23 +203,40 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
// 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来
// 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。
// 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。
const order = orderBy === "-online"
? [
...(online.size
? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`]
: []),
sql`${schema.user.lastLogin} desc nulls last`,
]
: orderBy === "-lastLogin"
? [sql`${schema.user.lastLogin} desc nulls last`]
: [desc(schema.user.createTime)]
const order =
orderBy === "-online"
? [
...(online.size
? [
sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`,
]
: []),
sql`${schema.user.lastLogin} desc nulls last`,
]
: orderBy === "-lastLogin"
? [sql`${schema.user.lastLogin} desc nulls last`]
: [desc(schema.user.createTime)]
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where),
db.select({ user: schema.user, realName: schema.userProfile.realName }).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
db
.select({ value: count() })
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where),
db
.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(...order, asc(schema.user.id))
.limit(limit)
.offset(offset),
])
return success(c, {
results: rows.map((row) => serialize(row, online.has(row.user.id))),
@@ -198,9 +252,16 @@ adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateUserRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = updateUserRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const data = parsed.data
const [existing] = await selectUser(id)
@@ -209,14 +270,24 @@ 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)
if (!className.ok)
return failure(c, 400, "invalid-class-name", className.message)
const [dupUsername] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(eq(schema.user.username, username), ne(schema.user.id, id))).limit(1)
if (dupUsername) return failure(c, 409, "username-exists", "Username already exists")
const [dupUsername] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(eq(schema.user.username, username), ne(schema.user.id, id)))
.limit(1)
if (dupUsername)
return failure(c, 409, "username-exists", "Username already exists")
// 比 lower(email):存量数据里有大小写混着的邮箱,按原值比会漏掉冲突
const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id))).limit(1)
const [dupEmail] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id)),
)
.limit(1)
if (dupEmail) return failure(c, 409, "email-exists", "Email already exists")
const patch: Partial<typeof schema.user.$inferInsert> = {
@@ -225,7 +296,10 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
className: className.value,
adminType: data.adminType,
isDisabled: data.isDisabled,
problemPermission: normalizePermission(data.adminType, data.problemPermission),
problemPermission: normalizePermission(
data.adminType,
data.problemPermission,
),
}
if (data.password) {
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
@@ -248,10 +322,14 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
*/
if (existing.user.username !== username) {
await tx.update(schema.submission).set({ username })
await tx
.update(schema.submission)
.set({ username })
.where(eq(schema.submission.userId, id))
}
await tx.update(schema.userProfile).set({ realName: data.realName })
await tx
.update(schema.userProfile)
.set({ realName: data.realName })
.where(eq(schema.userProfile.userId, id))
})
@@ -272,12 +350,26 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
})
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
const parsed = importUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = importUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const rows = parsed.data.users
type Prepared = { username: string; password: string; raw: string; email: string; realName: string; className: string | null }
type Prepared = {
username: string
password: string
raw: string
email: string
realName: string
className: string | null
}
// 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数
// (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。
@@ -289,48 +381,94 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
for (const [username, password, email, realName] of rows) {
const name = username.toLowerCase()
const className = classNameOf(name)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
if (!className.ok)
return failure(c, 400, "invalid-class-name", className.message)
const mail = email.trim().toLowerCase()
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
// 必然重号。存进去不会报错(库里没有唯一约束),但这两个账号从此**编辑不了**:
// PUT 一保存就撞自己的查重回 409,老师只看到「Email already exists」。
if (!z.email().max(64).safeParse(mail).success) {
return failure(c, 400, "invalid-email", `用户 ${name} 的邮箱 ${mail || "(空)"} 不是合法邮箱`)
return failure(
c,
400,
"invalid-email",
`用户 ${name} 的邮箱 ${mail || "(空)"} 不是合法邮箱`,
)
}
prepared.push({ username: name, password: "", raw: password, email: mail, realName, className: className.value })
prepared.push({
username: name,
password: "",
raw: password,
email: mail,
realName,
className: className.value,
})
}
const dupInBatch = (values: string[]) => {
const seen = new Set<string>()
return [...new Set(values.filter((value) => seen.size === seen.add(value).size))]
return [
...new Set(values.filter((value) => seen.size === seen.add(value).size)),
]
}
const batchNames = dupInBatch(prepared.map((item) => item.username))
if (batchNames.length) {
return failure(c, 409, "username-exists", `这批名单里用户名重复:${batchNames.join("、")}`)
return failure(
c,
409,
"username-exists",
`这批名单里用户名重复:${batchNames.join("、")}`,
)
}
const batchMails = dupInBatch(prepared.map((item) => item.email))
if (batchMails.length) {
return failure(c, 409, "email-exists", `这批名单里邮箱重复:${batchMails.join("、")}`)
return failure(
c,
409,
"email-exists",
`这批名单里邮箱重复:${batchMails.join("、")}`,
)
}
const existing = await db.select({ username: schema.user.username, email: schema.user.email })
const existing = await db
.select({ username: schema.user.username, email: schema.user.email })
.from(schema.user)
.where(or(
inArray(schema.user.username, prepared.map((item) => item.username)),
inArray(sql`lower(${schema.user.email})`, prepared.map((item) => item.email)),
))
.where(
or(
inArray(
schema.user.username,
prepared.map((item) => item.username),
),
inArray(
sql`lower(${schema.user.email})`,
prepared.map((item) => item.email),
),
),
)
const takenNames = new Set(prepared.map((item) => item.username))
const clashNames = existing.filter((row) => takenNames.has(row.username)).map((row) => row.username)
const clashNames = existing
.filter((row) => takenNames.has(row.username))
.map((row) => row.username)
if (clashNames.length) {
return failure(c, 409, "username-exists", `用户名已存在:${clashNames.join("、")}`)
return failure(
c,
409,
"username-exists",
`用户名已存在:${clashNames.join("、")}`,
)
}
const takenMails = new Set(prepared.map((item) => item.email))
const clashMails = existing
.map((row) => row.email?.toLowerCase())
.filter((mail): mail is string => !!mail && takenMails.has(mail))
if (clashMails.length) {
return failure(c, 409, "email-exists", `邮箱已被占用:${[...new Set(clashMails)].join("、")}`)
return failure(
c,
409,
"email-exists",
`邮箱已被占用:${[...new Set(clashMails)].join("、")}`,
)
}
// argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all
@@ -339,36 +477,48 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
// 固定 4 路并发,瞬时峰值 76MiB 封顶。
const HASH_CONCURRENCY = 4
let cursor = 0
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => {
while (cursor < prepared.length) {
const item = prepared[cursor++]!
item.password = await hashPassword(item.raw)
}
}))
await Promise.all(
Array.from(
{ length: Math.min(HASH_CONCURRENCY, prepared.length) },
async () => {
while (cursor < prepared.length) {
const item = prepared[cursor++]!
item.password = await hashPassword(item.raw)
}
},
),
)
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
const created = await db.transaction(async (tx) => {
const users = await tx.insert(schema.user).values(prepared.map((item) => ({
username: item.username,
password: item.password,
rawPassword: item.raw,
email: item.email,
className: item.className,
adminType: "Regular User" as const,
problemPermission: "None" as const,
createTime: new Date().toISOString(),
isDisabled: false,
}))).returning({ id: schema.user.id, username: schema.user.username })
const users = await tx
.insert(schema.user)
.values(
prepared.map((item) => ({
username: item.username,
password: item.password,
rawPassword: item.raw,
email: item.email,
className: item.className,
adminType: "Regular User" as const,
problemPermission: "None" as const,
createTime: new Date().toISOString(),
isDisabled: false,
})),
)
.returning({ id: schema.user.id, username: schema.user.username })
const byName = new Map(users.map((row) => [row.username, row.id]))
await tx.insert(schema.userProfile).values(prepared.map((item) => ({
userId: byName.get(item.username)!,
realName: item.realName,
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
avatar: "/public/avatar/default.png",
acmProblemsStatus: {},
submissionNumber: 0,
acceptedNumber: 0,
})))
await tx.insert(schema.userProfile).values(
prepared.map((item) => ({
userId: byName.get(item.username)!,
realName: item.realName,
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
avatar: "/public/avatar/default.png",
acmProblemsStatus: {},
submissionNumber: 0,
acceptedNumber: 0,
})),
)
return users.length
})
return success(c, { imported: created }, 201)
@@ -380,7 +530,11 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
* 删除失败都当成系统故障报 500。
*/
function isForeignKeyViolation(error: unknown) {
for (let current = error; current; current = (current as { cause?: unknown }).cause) {
for (
let current = error;
current;
current = (current as { cause?: unknown }).cause
) {
if ((current as { code?: string }).code === "23503") return true
}
return false
@@ -390,11 +544,19 @@ function isForeignKeyViolation(error: unknown) {
class UserHasSubmissionsError extends Error {}
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
const parsed = deleteUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "ids is required")
const me = c.get("user")!.id
if (parsed.data.ids.includes(me)) {
return failure(c, 400, "cannot-delete-self", "Current user can not be deleted")
return failure(
c,
400,
"cannot-delete-self",
"Current user can not be deleted",
)
}
// 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大,
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
@@ -425,30 +587,55 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
.limit(1)
if (withSubmission) throw new UserHasSubmissionsError()
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
return tx
.delete(schema.user)
.where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
})
return success(c, { deleted: deleted.length })
} catch (error) {
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
if (
!(error instanceof UserHasSubmissionsError) &&
!isForeignKeyViolation(error)
)
throw error
return failure(
c,
409,
"user-in-use",
"该用户还有提交、题目等历史数据,无法删除;请改为禁用账号",
)
}
})
adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db.select({ id: schema.user.id }).from(schema.user)
.where(eq(schema.user.id, id)).limit(1)
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
// 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清
const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("")
await db.update(schema.user).set({
password: await hashPassword(password),
rawPassword: password,
}).where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended")
return success(c, { password } satisfies ResetPasswordResponse)
})
adminAccountRoutes.post(
"/users/:id/reset-password",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.id, id))
.limit(1)
if (!existing)
return failure(c, 404, "user-not-found", "User does not exist")
// 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清
const password = Array.from(
{ length: 6 },
() => "123456789"[randomInt(9)],
).join("")
await db
.update(schema.user)
.set({
password: await hashPassword(password),
rawPassword: password,
})
.where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended")
return success(c, { password } satisfies ResetPasswordResponse)
},
)
+114 -50
View File
@@ -10,7 +10,11 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { ACHIEVEMENT_METRICS, findMetric, metricName } from "../../services/achievement-metrics"
import {
ACHIEVEMENT_METRICS,
findMetric,
metricName,
} from "../../services/achievement-metrics"
import { rescanAchievement } from "../../services/achievements"
import { queryInteger } from "../helpers"
@@ -37,75 +41,135 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]))
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]),
)
adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.achievement)
const rows = await db
.select()
.from(schema.achievement)
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id))
return success(c, rows.map(serialize))
})
adminAchievementRoutes.get("/achievements/:id", requireSuperAdmin, async (c) => {
const [row] = await db.select().from(schema.achievement)
.where(eq(schema.achievement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!row) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, serialize(row))
})
adminAchievementRoutes.get(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
const [row] = await db
.select()
.from(schema.achievement)
.where(
eq(
schema.achievement.id,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.limit(1)
if (!row) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, serialize(row))
},
)
adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
const parsed = createAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = createAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
const [created] = await db.insert(schema.achievement).values({
...parsed.data,
unlockCount: 0,
createTime: new Date().toISOString(),
}).returning()
const [created] = await db
.insert(schema.achievement)
.values({
...parsed.data,
unlockCount: 0,
createTime: new Date().toISOString(),
})
.returning()
// 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就
// 只有从今往后的提交才算,老用户永远拿不到
await rescanAchievement(created!.id)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, created!.id)).limit(1)
const [row] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, created!.id))
.limit(1)
return success(c, serialize(row!), 201)
})
adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
adminAchievementRoutes.put(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
const [before] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
const [before] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, id))
.limit(1)
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
const [after] = await db.update(schema.achievement).set(parsed.data)
.where(eq(schema.achievement.id, id)).returning()
const [after] = await db
.update(schema.achievement)
.set(parsed.data)
.where(eq(schema.achievement.id, id))
.returning()
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
// 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人),
// 只看 operator/threshold 会漏掉这两种。
const changed =
before.metric !== after!.metric ||
before.operator !== after!.operator ||
before.threshold !== after!.threshold ||
before.visible !== after!.visible
if (after!.visible && changed) await rescanAchievement(id)
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
// 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人),
// 只看 operator/threshold 会漏掉这两种。
const changed =
before.metric !== after!.metric ||
before.operator !== after!.operator ||
before.threshold !== after!.threshold ||
before.visible !== after!.visible
if (after!.visible && changed) await rescanAchievement(id)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
return success(c, serialize(row!))
})
const [row] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, id))
.limit(1)
return success(c, serialize(row!))
},
)
adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE(0010)
const deleted = await db.delete(schema.achievement).where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id })
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, null)
})
adminAchievementRoutes.delete(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE(0010)
const deleted = await db
.delete(schema.achievement)
.where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id })
if (deleted.length === 0)
return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, null)
},
)
+63 -22
View File
@@ -21,7 +21,13 @@ function excerpt(analysis: string | null) {
return text.length <= 120 ? text : `${text.slice(0, 120)}…`
}
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) {
function listItem(row: {
id: number
username: string
createTime: string
analysis: string
isPinned: boolean
}) {
return {
id: row.id,
username: row.username,
@@ -41,7 +47,9 @@ const listColumns = {
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim()
const where = username ? ilike(schema.user.username, `%${username}%`) : undefined
const where = username
? ilike(schema.user.username, `%${username}%`)
: undefined
// 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走。
// 但**形状必须和分页那支一样**:同一个 URL 返回两种形状,调用方没法照着一个类型写。
@@ -49,7 +57,9 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
// 读的是 res.results,于是拿到 undefined,`pinnedReports.length` 在渲染时抛
// 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。
if (c.req.query("pinnedOnly") === "true") {
const rows = await db.select(listColumns).from(schema.aiAnalysis)
const rows = await db
.select(listColumns)
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
.orderBy(desc(schema.aiAnalysis.createTime))
@@ -62,11 +72,19 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where),
db.select(listColumns).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where)
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset),
db
.select({ value: count() })
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(where),
db
.select(listColumns)
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.aiAnalysis.createTime))
.limit(limit)
.offset(offset),
])
return success(c, {
results: rows.map(listItem),
@@ -75,15 +93,20 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
})
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
const [row] = await db.select({
id: schema.aiAnalysis.id,
username: schema.user.username,
className: schema.user.className,
createTime: schema.aiAnalysis.createTime,
analysis: schema.aiAnalysis.analysis,
}).from(schema.aiAnalysis)
const [row] = await db
.select({
id: schema.aiAnalysis.id,
username: schema.user.username,
className: schema.user.className,
createTime: schema.aiAnalysis.createTime,
analysis: schema.aiAnalysis.analysis,
})
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
.where(
eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.limit(1)
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
return success(c, row satisfies AdminAiReport)
@@ -91,18 +114,36 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [report] = await db.select({ id: schema.aiAnalysis.id, userId: schema.aiAnalysis.userId, isPinned: schema.aiAnalysis.isPinned })
.from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1)
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found")
const [report] = await db
.select({
id: schema.aiAnalysis.id,
userId: schema.aiAnalysis.userId,
isPinned: schema.aiAnalysis.isPinned,
})
.from(schema.aiAnalysis)
.where(eq(schema.aiAnalysis.id, id))
.limit(1)
if (!report)
return failure(c, 404, "report-not-found", "AIAnalysis not found")
// 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份
const next = !report.isPinned
await db.transaction(async (tx) => {
if (next) {
await tx.update(schema.aiAnalysis).set({ isPinned: false })
.where(and(eq(schema.aiAnalysis.userId, report.userId), eq(schema.aiAnalysis.isPinned, true)))
await tx
.update(schema.aiAnalysis)
.set({ isPinned: false })
.where(
and(
eq(schema.aiAnalysis.userId, report.userId),
eq(schema.aiAnalysis.isPinned, true),
),
)
}
await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id))
await tx
.update(schema.aiAnalysis)
.set({ isPinned: next })
.where(eq(schema.aiAnalysis.id, id))
})
return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
})
+113 -44
View File
@@ -34,7 +34,11 @@ function serialize(row: {
function selectOne(id: number) {
return db
.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -47,10 +51,21 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.announcement),
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.innerJoin(
schema.user,
eq(schema.announcement.createdById, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.orderBy(desc(schema.announcement.createTime))
.limit(limit)
.offset(offset),
@@ -63,52 +78,106 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
})
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
const parsed = createAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = createAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const now = new Date().toISOString()
const [created] = await db.insert(schema.announcement).values({
...parsed.data,
createTime: now,
lastUpdateTime: now,
createdById: c.get("user")!.id,
}).returning({ id: schema.announcement.id })
const [created] = await db
.insert(schema.announcement)
.values({
...parsed.data,
createTime: now,
lastUpdateTime: now,
createdById: c.get("user")!.id,
})
.returning({ id: schema.announcement.id })
const [row] = await selectOne(created!.id)
return success(c, serialize(row!), 201)
})
adminAnnouncementRoutes.get("/announcements/:id", requireSuperAdmin, async (c) => {
const [row] = await selectOne(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, serialize(row))
})
adminAnnouncementRoutes.get(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
const [row] = await selectOne(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row)
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
return success(c, serialize(row))
},
)
adminAnnouncementRoutes.put("/announcements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const updated = await db.update(schema.announcement)
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.announcement.id, id))
.returning({ id: schema.announcement.id })
if (updated.length === 0) {
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
}
const [row] = await selectOne(id)
return success(c, serialize(row!))
})
adminAnnouncementRoutes.put(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const updated = await db
.update(schema.announcement)
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.announcement.id, id))
.returning({ id: schema.announcement.id })
if (updated.length === 0) {
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
}
const [row] = await selectOne(id)
return success(c, serialize(row!))
},
)
adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => {
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
const deleted = await db.delete(schema.announcement)
.where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.announcement.id })
if (deleted.length === 0) {
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
}
return success(c, null)
})
adminAnnouncementRoutes.delete(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
const deleted = await db
.delete(schema.announcement)
.where(
eq(
schema.announcement.id,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.returning({ id: schema.announcement.id })
if (deleted.length === 0) {
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
}
return success(c, null)
},
)
+137 -44
View File
@@ -14,7 +14,11 @@ import { resolve } from "node:path"
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import {
requireAdmin,
requireSuperAdmin,
type AppEnv,
} from "../../auth/middleware"
import { config } from "../../config"
import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events"
@@ -39,7 +43,9 @@ function aliveSince() {
* 于是同一天的心跳永远小于阈值,**所有判题机都会被标成离线**。
*/
function isAlive(lastHeartbeat: string) {
return Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
return (
Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
)
}
// ---------------------------------------------------------------- 网站配置
@@ -71,14 +77,24 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
})
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
const parsed = updateWebsiteConfigRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = updateWebsiteConfigRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const entries = (Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][])
.map(([field, key]) => ({ field, key, value: parsed.data[field] }))
const entries = (
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
// 8 个键一条 upsert 写完,不再一个键一次往返
await db.insert(schema.optionsSysoptions).values(entries.map(({ key, value }) => ({ key, value })))
await db
.insert(schema.optionsSysoptions)
.values(entries.map(({ key, value }) => ({ key, value })))
.onConflictDoUpdate({
target: schema.optionsSysoptions.key,
set: { value: sql`excluded.value` },
@@ -89,45 +105,75 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段,
// 一条也命中不了,整个「改完不必刷新」空转了很久。
for (const entry of entries) await publishConfigUpdate(entry.field, entry.value)
for (const entry of entries)
await publishConfigUpdate(entry.field, entry.value)
return success(c, null)
})
// ---------------------------------------------------------------- 判题机
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
const rows = await db
.select()
.from(schema.judgeServer)
.orderBy(desc(schema.judgeServer.lastHeartbeat))
return success(c, {
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
token: config.judgeServerToken,
servers: rows.map((row) => ({
...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
} satisfies JudgeServer)),
servers: rows.map(
(row) =>
({
...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
}) satisfies JudgeServer,
),
} satisfies JudgeServerList)
})
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
const parsed = updateJudgeServerRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required")
const updated = await db.update(schema.judgeServer)
const parsed = updateJudgeServerRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isDisabled is required")
const updated = await db
.update(schema.judgeServer)
.set({ isDisabled: parsed.data.isDisabled })
.where(eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.where(
eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.returning({ id: schema.judgeServer.id })
if (updated.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
if (updated.length === 0)
return failure(
c,
404,
"judge-server-not-found",
"Judge server does not exist",
)
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
return success(c, null)
})
adminConfRoutes.delete("/judge-servers/:hostname", requireSuperAdmin, async (c) => {
const deleted = await db.delete(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
.returning({ id: schema.judgeServer.id })
if (deleted.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
return success(c, null)
})
adminConfRoutes.delete(
"/judge-servers/:hostname",
requireSuperAdmin,
async (c) => {
const deleted = await db
.delete(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
.returning({ id: schema.judgeServer.id })
if (deleted.length === 0)
return failure(
c,
404,
"judge-server-not-found",
"Judge server does not exist",
)
return success(c, null)
},
)
// ---------------------------------------------------------------- 孤儿测试用例
@@ -140,15 +186,24 @@ async function orphanTestCaseIds() {
db.select({ id: schema.problem.testCaseId }).from(schema.problem),
])
const referenced = new Set(inDb.map((row) => row.id))
return onDisk.filter((name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name))
return onDisk.filter(
(name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name),
)
}
adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
const ids = await orphanTestCaseIds()
const rows = await Promise.all(ids.map(async (id) => {
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase
}))
const rows = await Promise.all(
ids.map(async (id) => {
const info = await stat(resolve(config.testCaseDirectory, id)).catch(
() => null,
)
return {
id,
createTime: info ? info.mtimeMs / 1000 : 0,
} satisfies OrphanTestCase
}),
)
return success(c, rows)
})
@@ -159,10 +214,18 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
// 而测试数据没有别处备份 —— 旧后端这里是不校验的。
const targets = requested ? orphans.filter((id) => id === requested) : orphans
if (requested && targets.length === 0) {
return failure(c, 404, "not-an-orphan", "该用例目录不存在或仍被题目引用,未删除")
return failure(
c,
404,
"not-an-orphan",
"该用例目录不存在或仍被题目引用,未删除",
)
}
for (const id of targets) {
await rm(resolve(config.testCaseDirectory, id), { recursive: true, force: true })
await rm(resolve(config.testCaseDirectory, id), {
recursive: true,
force: true,
})
}
return success(c, { deleted: targets.length })
})
@@ -173,11 +236,17 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
const now = new Date().toISOString()
const [[users], [submissions], [contests], [servers]] = await Promise.all([
db.select({ value: count() }).from(schema.user),
db.select({ value: count() }).from(schema.submission)
db
.select({ value: count() })
.from(schema.submission)
.where(gte(schema.submission.createTime, todayStart())),
db.select({ value: count() }).from(schema.contest)
db
.select({ value: count() })
.from(schema.contest)
.where(not(sql`${schema.contest.endTime} < ${now}`)),
db.select({ value: count() }).from(schema.judgeServer)
db
.select({ value: count() })
.from(schema.judgeServer)
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
])
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
@@ -195,10 +264,16 @@ adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
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)
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(ilike(schema.user.username, `${classroom}%`))
.orderBy(sql`random()`).limit(10)
return success(c, rows.map((row) => row.username))
.orderBy(sql`random()`)
.limit(10)
return success(
c,
rows.map((row) => row.username),
)
})
// ---------------------------------------------------------------- 富文本图片上传
@@ -219,16 +294,28 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
const form = await c.req.formData().catch(() => null)
const image = form?.get("image")
if (!(image instanceof File)) {
return success(c, { success: false, msg: "Upload failed", filePath: "" } satisfies UploadImageResponse)
return success(c, {
success: false,
msg: "Upload failed",
filePath: "",
} satisfies UploadImageResponse)
}
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
if (!IMAGE_SUFFIXES.includes(suffix)) {
return success(c, { success: false, msg: "Unsupported file format", filePath: "" } satisfies UploadImageResponse)
return success(c, {
success: false,
msg: "Unsupported file format",
filePath: "",
} satisfies UploadImageResponse)
}
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
if (image.size > MAX_IMAGE_BYTES) {
return success(c, { success: false, msg: "图片不能超过 10MB", filePath: "" } satisfies UploadImageResponse)
return success(c, {
success: false,
msg: "图片不能超过 10MB",
filePath: "",
} satisfies UploadImageResponse)
}
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
const name = `${randomFileName()}${suffix}`
@@ -237,7 +324,11 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
await Bun.write(resolve(config.uploadDirectory, name), image)
} catch (error) {
console.error("Failed to save uploaded image", error)
return success(c, { success: false, msg: "Upload Error", filePath: "" } satisfies UploadImageResponse)
return success(c, {
success: false,
msg: "Upload Error",
filePath: "",
} satisfies UploadImageResponse)
}
return success(c, {
success: true,
@@ -247,6 +338,8 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
})
function randomFileName() {
return Array.from({ length: 10 }, () =>
"abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("")
return Array.from(
{ length: 10 },
() => "abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)],
).join("")
}
+266 -148
View File
@@ -49,18 +49,25 @@ async function serialize(row: {
}
function selectContest(id: number) {
return db.select({ contest: schema.contest, user: schema.user, realName: schema.userProfile.realName })
return db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.contest.id, id)).limit(1)
.where(eq(schema.contest.id, id))
.limit(1)
}
/** 请求体里的时间校验,创建和编辑共用 */
function validatePayload(data: { startTime: string; endTime: string }) {
const start = Date.parse(data.startTime)
const end = Date.parse(data.endTime)
if (!Number.isFinite(start) || !Number.isFinite(end)) return "开始或结束时间不是合法的时间格式"
if (!Number.isFinite(start) || !Number.isFinite(end))
return "开始或结束时间不是合法的时间格式"
if (end <= start) return "Start time must occur earlier than end time"
return null
}
@@ -71,18 +78,30 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
const user = c.get("user")!
const filters = []
// 非超管只看得到自己建的比赛,与旧后端一致
if (user.adminType !== "Super Admin") filters.push(eq(schema.contest.createdById, user.id))
if (user.adminType !== "Super Admin")
filters.push(eq(schema.contest.createdById, user.id))
const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.contest).where(where),
db.select({ contest: schema.contest, user: schema.user, realName: schema.userProfile.realName })
db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset),
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(desc(schema.contest.createTime))
.limit(limit)
.offset(offset),
])
return success(c, {
results: await Promise.all(rows.map(serialize)),
@@ -91,7 +110,9 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
})
adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
const [row] = await selectContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
const [row] = await selectContest(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row || !ownedBy(c.get("user")!, row.contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
@@ -99,36 +120,53 @@ adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
})
adminContestRoutes.post("/contests", requireTeacher, async (c) => {
const parsed = createContestRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = createContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error)
const now = new Date().toISOString()
const [created] = await db.insert(schema.contest).values({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
startTime: new Date(parsed.data.startTime).toISOString(),
endTime: new Date(parsed.data.endTime).toISOString(),
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
password: parsed.data.password || null,
visible: parsed.data.visible,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
}).returning({ id: schema.contest.id })
const [created] = await db
.insert(schema.contest)
.values({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
startTime: new Date(parsed.data.startTime).toISOString(),
endTime: new Date(parsed.data.endTime).toISOString(),
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
password: parsed.data.password || null,
visible: parsed.data.visible,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
})
.returning({ id: schema.contest.id })
const [row] = await selectContest(created!.id)
return success(c, await serialize(row!), 201)
})
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateContestRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = updateContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const [existing] = await selectContest(id)
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
@@ -137,16 +175,19 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error)
await db.update(schema.contest).set({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
startTime: new Date(parsed.data.startTime).toISOString(),
endTime: new Date(parsed.data.endTime).toISOString(),
password: parsed.data.password || null,
visible: parsed.data.visible,
lastUpdateTime: new Date().toISOString(),
}).where(eq(schema.contest.id, id))
await db
.update(schema.contest)
.set({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
startTime: new Date(parsed.data.startTime).toISOString(),
endTime: new Date(parsed.data.endTime).toISOString(),
password: parsed.data.password || null,
visible: parsed.data.visible,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.contest.id, id))
const [row] = await selectContest(id)
return success(c, await serialize(row!))
})
@@ -169,9 +210,12 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
//
// 已知的副作用,别当成 bug 去"修":副本和原题共用同一个测试点目录(testCaseId 原样复制),
// 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!original)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const duration = Date.parse(original.contest.endTime) - Date.parse(original.contest.startTime)
const duration =
Date.parse(original.contest.endTime) -
Date.parse(original.contest.startTime)
// 新比赛从 10 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
// 又不至于建出一个已经结束的比赛
const start = new Date(Date.now() + 10 * 60 * 1000)
@@ -180,51 +224,79 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
const me = c.get("user")!.id
const cloned = await db.transaction(async (tx) => {
const [contest] = await tx.insert(schema.contest).values({
title: original.contest.title,
description: original.contest.description,
tag: original.contest.tag,
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
password: null,
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
visible: false,
startTime: start.toISOString(),
endTime: end.toISOString(),
createdById: me,
createTime: now,
lastUpdateTime: now,
}).returning({ id: schema.contest.id })
const [contest] = await tx
.insert(schema.contest)
.values({
title: original.contest.title,
description: original.contest.description,
tag: original.contest.tag,
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
password: null,
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
visible: false,
startTime: start.toISOString(),
endTime: end.toISOString(),
createdById: me,
createTime: now,
lastUpdateTime: now,
})
.returning({ id: schema.contest.id })
const problems = await tx.select().from(schema.problem)
const problems = await tx
.select()
.from(schema.problem)
.where(eq(schema.problem.contestId, id))
if (problems.length === 0) return contest!.id
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
const copies = await tx.insert(schema.problem).values(problems.map(({ id: _oldId, ...rest }) => ({
...rest,
contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createdById: me,
createTime: now,
lastUpdateTime: now,
}))).returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
const copies = await tx
.insert(schema.problem)
.values(
problems.map(({ id: _oldId, ...rest }) => ({
...rest,
contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createdById: me,
createTime: now,
lastUpdateTime: now,
})),
)
.returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(
copies.map((copy) => [copy.displayId, copy.id]),
)
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx.select({ problemId: schema.problemTags.problemId, tagId: schema.problemTags.problemtagId })
.from(schema.problemTags).where(inArray(schema.problemTags.problemId, problems.map((problem) => problem.id)))
const tags = await tx
.select({
problemId: schema.problemTags.problemId,
tagId: schema.problemTags.problemtagId,
})
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemId,
problems.map((problem) => problem.id),
),
)
if (tags.length) {
const displayIdByOldId = new Map(problems.map((problem) => [problem.id, problem.displayId]))
const displayIdByOldId = new Map(
problems.map((problem) => [problem.id, problem.displayId]),
)
const links = tags.flatMap((tag) => {
const newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? "")
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }]
const newId = newIdByDisplayId.get(
displayIdByOldId.get(tag.problemId) ?? "",
)
return newId === undefined
? []
: [{ problemId: newId, problemtagId: tag.tagId }]
})
if (links.length) await tx.insert(schema.problemTags).values(links)
}
@@ -237,82 +309,128 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
// ---------------------------------------------------------------- ACM 赛后核查
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
const [contest] = await db.select().from(schema.contest)
.where(eq(schema.contest.id, id)).limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const [problems, ranks] = await Promise.all([
db.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem).where(eq(schema.problem.contestId, id)),
db.select({
id: schema.acmContestRank.id,
username: schema.user.username,
realName: schema.userProfile.realName,
submissionInfo: schema.acmContestRank.submissionInfo,
acceptedNumber: schema.acmContestRank.acceptedNumber,
}).from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.acmContestRank.contestId, id)),
])
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
const results = []
for (const rank of ranks) {
if (rank.acceptedNumber <= 0) continue
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
if (info.is_ac !== true) continue
results.push({
id: rank.id,
username: rank.username,
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
// 接口已由 requireTeacher + ownedBy 双重把关。
realName: rank.realName,
problemId,
problemDisplayId: displayIds.get(problemId) ?? problemId,
acInfo: info,
checked: info.checked === true,
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
})
adminContestRoutes.get(
"/contests/:id/acm-helper",
requireTeacher,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, id))
.limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
}
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime)
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
})
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAcmHelperRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
// 那样带上任意 rank_id 就能改别的比赛的核查标记
const [rank] = await db.select().from(schema.acmContestRank).where(and(
eq(schema.acmContestRank.id, parsed.data.rankId),
eq(schema.acmContestRank.contestId, contestId),
)).limit(1)
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
const [problems, ranks] = await Promise.all([
db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(eq(schema.problem.contestId, id)),
db
.select({
id: schema.acmContestRank.id,
username: schema.user.username,
realName: schema.userProfile.realName,
submissionInfo: schema.acmContestRank.submissionInfo,
acceptedNumber: schema.acmContestRank.acceptedNumber,
})
.from(schema.acmContestRank)
.innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(eq(schema.acmContestRank.contestId, id)),
])
const displayIds = new Map(
problems.map((problem) => [String(problem.id), problem.displayId]),
)
const info = rank.submissionInfo
const entry = info[parsed.data.problemId]
if (!entry) {
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
}
entry.checked = parsed.data.checked
info[parsed.data.problemId] = entry
await db.update(schema.acmContestRank).set({ submissionInfo: info })
.where(eq(schema.acmContestRank.id, rank.id))
return success(c, null)
})
const results = []
for (const rank of ranks) {
if (rank.acceptedNumber <= 0) continue
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
if (info.is_ac !== true) continue
results.push({
id: rank.id,
username: rank.username,
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
// 接口已由 requireTeacher + ownedBy 双重把关。
realName: rank.realName,
problemId,
problemDisplayId: displayIds.get(problemId) ?? problemId,
acInfo: info,
checked: info.checked === true,
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
})
}
}
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime)
return success(
c,
results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[],
)
},
)
adminContestRoutes.put(
"/contests/:id/acm-helper",
requireTeacher,
async (c) => {
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAcmHelperRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
// 那样带上任意 rank_id 就能改别的比赛的核查标记
const [rank] = await db
.select()
.from(schema.acmContestRank)
.where(
and(
eq(schema.acmContestRank.id, parsed.data.rankId),
eq(schema.acmContestRank.contestId, contestId),
),
)
.limit(1)
if (!rank)
return failure(c, 404, "rank-not-found", "Rank id does not exist")
const info = rank.submissionInfo
const entry = info[parsed.data.problemId]
if (!entry) {
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
}
entry.checked = parsed.data.checked
info[parsed.data.problemId] = entry
await db
.update(schema.acmContestRank)
.set({ submissionInfo: info })
.where(eq(schema.acmContestRank.id, rank.id))
return success(c, null)
},
)
+262 -133
View File
@@ -71,8 +71,12 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
const tutorials = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
const tutorials = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
const tutorialIds = tutorials.map((row) => row.id)
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
@@ -87,93 +91,160 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
// 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上
// 就是这个下场,用 filter 也救不回来
const [rows, exerciseRows] = await Promise.all([
db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
}).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount:
sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(
Number,
),
lastViewedAt: sql<
string | null
>`max(${schema.tutorialProgress.lastViewedAt})`,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.leftJoin(schema.tutorialProgress, progressJoin)
.where(studentCondition(className.value))
.groupBy(schema.user.id, schema.user.username, schema.userProfile.realName, schema.user.className),
db.select({
userId: schema.exerciseAttempt.userId,
tried: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number),
}).from(schema.exerciseAttempt)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(
schema.user.id,
schema.user.username,
schema.userProfile.realName,
schema.user.className,
),
db
.select({
userId: schema.exerciseAttempt.userId,
tried: count(),
solved:
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
attempts:
sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(
Number,
),
})
.from(schema.exerciseAttempt)
.innerJoin(
schema.exercise,
eq(schema.exercise.id, schema.exerciseAttempt.exerciseId),
)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(schema.exerciseAttempt.userId),
])
const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
const [exerciseCountRow] = tutorialIds.length
? await db.select({ value: count() }).from(schema.exercise)
? await db
.select({ value: count() })
.from(schema.exercise)
.where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }]
return success(c, {
tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => ({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
} satisfies LearnStudentProgress)),
results: rows.map(
(row) =>
({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
}) satisfies LearnStudentProgress,
),
} satisfies LearnStudentProgressList)
})
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
adminLearnRoutes.get(
"/learn-analytics/tutorials",
requireTeacher,
async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const studentCount = studentCountRow?.value ?? 0
const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
.where(studentCondition(className.value))
const studentCount = studentCountRow?.value ?? 0
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
const rows = await db.select({
tutorialId: schema.tutorial.id,
title: schema.tutorial.title,
order: schema.tutorial.order,
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
.leftJoin(schema.user, and(
eq(schema.user.id, schema.tutorialProgress.userId),
studentCondition(className.value),
))
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order))
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
const rows = await db
.select({
tutorialId: schema.tutorial.id,
title: schema.tutorial.title,
order: schema.tutorial.order,
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(
Number,
),
})
.from(schema.tutorial)
.leftJoin(
schema.tutorialProgress,
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
)
.leftJoin(
schema.user,
and(
eq(schema.user.id, schema.tutorialProgress.userId),
studentCondition(className.value),
),
)
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order))
return success(c, {
studentCount,
results: rows.map(({ readSeconds, ...row }) => ({
...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
} satisfies LearnTutorialProgress)),
} satisfies LearnTutorialProgressList)
})
return success(c, {
studentCount,
results: rows.map(
({ readSeconds, ...row }) =>
({
...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
}) satisfies LearnTutorialProgress,
),
} satisfies LearnTutorialProgressList)
},
)
/**
* 按练习:哪道练一练卡住了全班。
@@ -181,73 +252,131 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
adminLearnRoutes.get(
"/learn-analytics/exercises",
requireTeacher,
async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
.where(studentCondition(className.value))
const rows = await db.select({
exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title,
tutorialOrder: schema.tutorial.order,
type: schema.exercise.type,
order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
solvedUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
firstTryUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve: sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, eq(schema.exerciseAttempt.exerciseId, schema.exercise.id))
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(schema.user, and(
eq(schema.user.id, schema.exerciseAttempt.userId),
studentCondition(className.value),
))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
const rows = await db
.select({
exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title,
tutorialOrder: schema.tutorial.order,
type: schema.exercise.type,
order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(
Number,
),
solvedUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
firstTryUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(
Number,
),
attempts:
sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve:
sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
})
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.leftJoin(
schema.exerciseAttempt,
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
)
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(
schema.user,
and(
eq(schema.user.id, schema.exerciseAttempt.userId),
studentCondition(className.value),
),
)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(
schema.exercise.id,
schema.tutorial.id,
schema.tutorial.title,
schema.tutorial.order,
)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, {
studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => ({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
} satisfies LearnExerciseProgress)),
} satisfies LearnExerciseProgressList)
})
return success(c, {
studentCount: studentCountRow?.value ?? 0,
results: rows.map(
(row) =>
({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
}) satisfies LearnExerciseProgress,
),
} satisfies LearnExerciseProgressList)
},
)
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
adminLearnRoutes.get(
"/learn-analytics/exercises/:id/attempts",
requireTeacher,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
attempts: schema.exerciseAttempt.attempts,
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
solved: schema.exerciseAttempt.solved,
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
}).from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.exerciseAttempt.exerciseId, id), studentCondition(className.value)))
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
const rows = await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
attempts: schema.exerciseAttempt.attempts,
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
solved: schema.exerciseAttempt.solved,
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
})
.from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.exerciseAttempt.exerciseId, id),
studentCondition(className.value),
),
)
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(
asc(schema.exerciseAttempt.solved),
desc(schema.exerciseAttempt.wrongAttempts),
)
return success(c, rows satisfies LearnExerciseAttempt[])
})
return success(c, rows satisfies LearnExerciseAttempt[])
},
)
File diff suppressed because it is too large. Load diff
+541 -261
View File
@@ -12,7 +12,18 @@ import {
type AdminProblemSetProblem,
type AdminProblemSetProgress,
} from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
@@ -35,9 +46,16 @@ function ownedBy(user: AuthUser, row: { createdById: number }) {
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
*/
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) {
async function loadOwned(
c: { req: { param(name: string): string } },
user: AuthUser,
) {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db.select().from(schema.problemset).where(eq(schema.problemset.id, id)).limit(1)
const [row] = await db
.select()
.from(schema.problemset)
.where(eq(schema.problemset.id, id))
.limit(1)
return row && ownedBy(user, row) ? row : null
}
@@ -50,18 +68,45 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
if (rows.length === 0) return []
const ids = rows.map((row) => row.id)
const [problems, participants, creators] = await Promise.all([
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
db
.select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId),
db.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
.from(schema.problemsetProgress).where(inArray(schema.problemsetProgress.problemsetId, ids))
db
.select({
problemsetId: schema.problemsetProgress.problemsetId,
value: count(),
})
.from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId),
db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
db
.select({
id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
inArray(schema.user.id, [
...new Set(rows.map((row) => row.createdById)),
]),
),
])
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value]))
const problemsBySet = new Map(
problems.map((item) => [item.problemsetId, item.value]),
)
const participantsBySet = new Map(
participants.map((item) => [item.problemsetId, item.value]),
)
const creatorById = new Map(creators.map((item) => [item.id, item]))
return rows.map((row) => {
const creator = creatorById.get(row.createdById)
@@ -73,7 +118,10 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
status: row.status,
endTime: row.endTime,
visible: row.visible,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
createdBy: sampleUser(
creator ?? { id: row.createdById, username: "" },
creator?.realName,
),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.get(row.id) ?? 0,
@@ -92,24 +140,33 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失,
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
if (user.adminType !== "Super Admin") filters.push(eq(schema.problemset.createdById, user.id))
if (user.adminType !== "Super Admin")
filters.push(eq(schema.problemset.createdById, user.id))
const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim()
if (keyword) {
filters.push(or(
ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`),
)!)
filters.push(
or(
ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`),
)!,
)
}
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (difficulty)
filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where),
db.select().from(schema.problemset).where(where)
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
db
.select()
.from(schema.problemset)
.where(where)
.orderBy(desc(schema.problemset.createTime))
.limit(limit)
.offset(offset),
])
return success(c, {
results: await serializeMany(rows),
@@ -118,18 +175,30 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
})
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
const parsed = createProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = createProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const now = new Date().toISOString()
const [created] = await db.insert(schema.problemset).values({
...parsed.data,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
}).returning()
const [created] = await db
.insert(schema.problemset)
.values({
...parsed.data,
endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
})
.returning()
return success(c, await serialize(created!), 201)
})
@@ -142,38 +211,69 @@ adminProblemSetRoutes.get("/problem-sets/:id", requireTeacher, async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = updateProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const [updated] = await db.update(schema.problemset).set({
...parsed.data,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
lastUpdateTime: new Date().toISOString(),
}).where(eq(schema.problemset.id, row.id)).returning()
const [updated] = await db
.update(schema.problemset)
.set({
...parsed.data,
endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!))
})
adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
const [updated] = await db.update(schema.problemset)
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.problemset.id, row.id)).returning()
return success(c, await serialize(updated!))
})
adminProblemSetRoutes.put(
"/problem-sets/:id/visibility",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
const [updated] = await db
.update(schema.problemset)
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!))
},
)
adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetStatusRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "status 不合法")
const [updated] = await db.update(schema.problemset)
.set({ status: parsed.data.status, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.problemset.id, row.id)).returning()
return success(c, await serialize(updated!))
})
adminProblemSetRoutes.put(
"/problem-sets/:id/status",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetStatusRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "status 不合法")
const [updated] = await db
.update(schema.problemset)
.set({
status: parsed.data.status,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
.returning()
return success(c, await serialize(updated!))
},
)
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
@@ -186,95 +286,175 @@ adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
// ---------------------------------------------------------------- 题单里的题目
adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db.select({ item: schema.problemsetProblem, problem: schema.problem })
.from(schema.problemsetProblem)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, row.id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
return success(c, rows.map(({ item, problem }) => ({
id: item.id,
problemsetId: item.problemsetId,
problemId: item.problemId,
displayId: problem.displayId,
title: problem.title,
difficulty: problem.difficulty,
order: item.order,
isRequired: item.isRequired,
score: item.score,
hint: item.hint,
} satisfies AdminProblemSetProblem)))
})
adminProblemSetRoutes.get(
"/problem-sets/:id/problems",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db
.select({ item: schema.problemsetProblem, problem: schema.problem })
.from(schema.problemsetProblem)
.innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
.where(eq(schema.problemsetProblem.problemsetId, row.id))
.orderBy(
asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
)
return success(
c,
rows.map(
({ item, problem }) =>
({
id: item.id,
problemsetId: item.problemsetId,
problemId: item.problemId,
displayId: problem.displayId,
title: problem.title,
difficulty: problem.difficulty,
order: item.order,
isRequired: item.isRequired,
score: item.score,
hint: item.hint,
}) satisfies AdminProblemSetProblem,
),
)
},
)
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = addProblemToSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
adminProblemSetRoutes.post(
"/problem-sets/:id/problems",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = addProblemToSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "题目不存在或不可见")
const [duplicate] = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem)
.where(and(
eq(schema.problemsetProblem.problemsetId, row.id),
eq(schema.problemsetProblem.problemId, problem.id),
)).limit(1)
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
const [duplicate] = await db
.select({ id: schema.problemsetProblem.id })
.from(schema.problemsetProblem)
.where(
and(
eq(schema.problemsetProblem.problemsetId, row.id),
eq(schema.problemsetProblem.problemId, problem.id),
),
)
.limit(1)
if (duplicate)
return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
const [created] = await db.insert(schema.problemsetProblem).values({
problemsetId: row.id,
problemId: problem.id,
order: parsed.data.order,
isRequired: parsed.data.isRequired,
score: parsed.data.score,
hint: parsed.data.hint,
}).returning({ id: schema.problemsetProblem.id })
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
await resyncProgress(row.id)
return success(c, { id: created!.id }, 201)
})
const [created] = await db
.insert(schema.problemsetProblem)
.values({
problemsetId: row.id,
problemId: problem.id,
order: parsed.data.order,
isRequired: parsed.data.isRequired,
score: parsed.data.score,
hint: parsed.data.hint,
})
.returning({ id: schema.problemsetProblem.id })
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
await resyncProgress(row.id)
return success(c, { id: created!.id }, 201)
},
)
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemInSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
const updated = await db.update(schema.problemsetProblem).set(parsed.data).where(and(
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
eq(schema.problemsetProblem.problemsetId, row.id),
)).returning({ id: schema.problemsetProblem.id })
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
if (parsed.data.score !== undefined) await resyncProgress(row.id)
return success(c, null)
})
adminProblemSetRoutes.put(
"/problem-sets/:id/problems/:itemId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemInSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
const updated = await db
.update(schema.problemsetProblem)
.set(parsed.data)
.where(
and(
eq(
schema.problemsetProblem.id,
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
),
eq(schema.problemsetProblem.problemsetId, row.id),
),
)
.returning({ id: schema.problemsetProblem.id })
if (updated.length === 0)
return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
if (parsed.data.score !== undefined) await resyncProgress(row.id)
return success(c, null)
},
)
adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const deleted = await db.delete(schema.problemsetProblem).where(and(
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
eq(schema.problemsetProblem.problemsetId, row.id),
)).returning({ id: schema.problemsetProblem.id, problemId: schema.problemsetProblem.problemId })
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
await db.delete(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
))
await resyncProgress(row.id)
return success(c, null)
})
adminProblemSetRoutes.delete(
"/problem-sets/:id/problems/:itemId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const deleted = await db
.delete(schema.problemsetProblem)
.where(
and(
eq(
schema.problemsetProblem.id,
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
),
eq(schema.problemsetProblem.problemsetId, row.id),
),
)
.returning({
id: schema.problemsetProblem.id,
problemId: schema.problemsetProblem.problemId,
})
if (deleted.length === 0)
return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
await db
.delete(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
),
)
await resyncProgress(row.id)
return success(c, null)
},
)
// ---------------------------------------------------------------- 奖章
@@ -285,135 +465,235 @@ async function badgeWithCount(badge: BadgeRow) {
/** 批量版:一条 group by 数完整批奖章的获得人数 */
async function badgesWithCount(badges: BadgeRow[]) {
if (badges.length === 0) return []
const earned = await db.select({ badgeId: schema.userBadge.badgeId, value: count() })
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)))
const earned = await db
.select({ badgeId: schema.userBadge.badgeId, value: count() })
.from(schema.userBadge)
.where(
inArray(
schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
)
.groupBy(schema.userBadge.badgeId)
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
return badges.map((badge) => ({
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
description: badge.description,
icon: badge.icon,
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
earnedCount: countByBadge.get(badge.id) ?? 0,
} satisfies AdminProblemSetBadge))
return badges.map(
(badge) =>
({
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
description: badge.description,
icon: badge.icon,
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
earnedCount: countByBadge.get(badge.id) ?? 0,
}) satisfies AdminProblemSetBadge,
)
}
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
return success(c, await badgesWithCount(badges))
})
adminProblemSetRoutes.get(
"/problem-sets/:id/badges",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
.orderBy(asc(schema.problemsetBadge.id))
return success(c, await badgesWithCount(badges))
},
)
adminProblemSetRoutes.post("/problem-sets/:id/badges", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = createProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [created] = await db.insert(schema.problemsetBadge).values({
...parsed.data,
problemsetId: row.id,
}).returning()
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
await recalculateBadge(created!)
return success(c, await badgeWithCount(created!), 201)
})
adminProblemSetRoutes.post(
"/problem-sets/:id/badges",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = createProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const [created] = await db
.insert(schema.problemsetBadge)
.values({
...parsed.data,
problemsetId: row.id,
})
.returning()
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
await recalculateBadge(created!)
return success(c, await badgeWithCount(created!), 201)
},
)
adminProblemSetRoutes.put("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [updated] = await db.update(schema.problemsetBadge).set(parsed.data).where(and(
eq(schema.problemsetBadge.id, queryInteger(c.req.param("badgeId"), 0, { min: 1 })),
eq(schema.problemsetBadge.problemsetId, row.id),
)).returning()
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
await recalculateBadge(updated)
return success(c, await badgeWithCount(updated))
})
adminProblemSetRoutes.put(
"/problem-sets/:id/badges/:badgeId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const [updated] = await db
.update(schema.problemsetBadge)
.set(parsed.data)
.where(
and(
eq(
schema.problemsetBadge.id,
queryInteger(c.req.param("badgeId"), 0, { min: 1 }),
),
eq(schema.problemsetBadge.problemsetId, row.id),
),
)
.returning()
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
await recalculateBadge(updated)
return success(c, await badgeWithCount(updated))
},
)
adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
const [badge] = await db.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(and(
eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id),
)).limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
// 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE(0010)
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
return success(c, null)
})
adminProblemSetRoutes.delete(
"/problem-sets/:id/badges/:badgeId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
const [badge] = await db
.select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
.where(
and(
eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id),
),
)
.limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
// 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE(0010)
await db
.delete(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.id, badge.id))
return success(c, null)
},
)
// ---------------------------------------------------------------- 学生进度
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db.select({
progress: schema.problemsetProgress,
username: schema.user.username,
realName: schema.userProfile.realName,
}).from(schema.problemsetProgress)
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.problemsetProgress.problemsetId, row.id))
.orderBy(desc(schema.problemsetProgress.joinTime))
return success(c, rows.map(({ progress, username, realName }) =>
({
id: progress.id,
userId: progress.userId,
username,
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
realName,
joinTime: progress.joinTime,
completeTime: progress.completeTime,
isCompleted: progress.isCompleted,
progressPercentage: progress.progressPercentage,
completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
} satisfies AdminProblemSetProgress)))
})
adminProblemSetRoutes.get(
"/problem-sets/:id/progress",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db
.select({
progress: schema.problemsetProgress,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.problemsetProgress)
.innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(eq(schema.problemsetProgress.problemsetId, row.id))
.orderBy(desc(schema.problemsetProgress.joinTime))
return success(
c,
rows.map(
({ progress, username, realName }) =>
({
id: progress.id,
userId: progress.userId,
username,
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
realName,
joinTime: progress.joinTime,
completeTime: progress.completeTime,
isCompleted: progress.isCompleted,
progressPercentage: progress.progressPercentage,
completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
}) satisfies AdminProblemSetProgress,
),
)
},
)
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => {
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
const badges = await tx.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
if (badges.length) {
await tx.delete(schema.userBadge).where(and(
eq(schema.userBadge.userId, userId),
inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)),
))
}
await tx.delete(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.userId, userId),
))
return tx.delete(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.problemsetId, row.id),
eq(schema.problemsetProgress.userId, userId),
)).returning({ id: schema.problemsetProgress.id })
})
if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单")
return success(c, null)
})
adminProblemSetRoutes.delete(
"/problem-sets/:id/progress/:userId",
requireTeacher,
async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => {
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
const badges = await tx
.select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
if (badges.length) {
await tx.delete(schema.userBadge).where(
and(
eq(schema.userBadge.userId, userId),
inArray(
schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
),
)
}
await tx
.delete(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.userId, userId),
),
)
return tx
.delete(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, row.id),
eq(schema.problemsetProgress.userId, userId),
),
)
.returning({ id: schema.problemsetProgress.id })
})
if (deleted.length === 0)
return failure(c, 404, "progress-not-found", "用户未加入该题单")
return success(c, null)
},
)
+334 -152
View File
@@ -9,10 +9,28 @@ import {
type RenameTagResponse,
type StuckProblem,
} from "@oj2/contract"
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
import {
and,
asc,
countDistinct,
count,
desc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
sql,
} from "drizzle-orm"
import { Hono } from "hono"
import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware"
import {
requireProblemPermission,
requireTeacher,
type AppEnv,
} from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
@@ -25,7 +43,11 @@ import { findTagsByName, normalizeTagNames } from "./problem"
export const adminTagRoutes = new Hono<AppEnv>()
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR]
const FAILED = [
JudgeStatus.WRONG_ANSWER,
JudgeStatus.COMPILE_ERROR,
JudgeStatus.RUNTIME_ERROR,
]
/** 能管所有题目:超管,或 problemPermission 为 All */
function canManageAllProblems(user: AuthUser) {
@@ -36,51 +58,91 @@ function canManageAllProblems(user: AuthUser) {
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
const keyword = c.req.query("keyword")?.trim()
const rows = await db.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
problemCount: countDistinct(schema.problemTags.problemId),
}).from(schema.problemTag)
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
const rows = await db
.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag)
.leftJoin(
schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name)
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
.orderBy(
desc(countDistinct(schema.problemTags.problemId)),
asc(schema.problemTag.name),
)
return success(c, rows satisfies AdminTag[])
})
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = renameTagRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "标签名不能为空")
const parsed = renameTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "标签名不能为空")
const name = parsed.data.name
const [tag] = await db.select().from(schema.problemTag).where(eq(schema.problemTag.id, id)).limit(1)
const [tag] = await db
.select()
.from(schema.problemTag)
.where(eq(schema.problemTag.id, id))
.limit(1)
if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
const [target] = await db.select().from(schema.problemTag)
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1)
const [target] = await db
.select()
.from(schema.problemTag)
.where(
and(
sql`lower(${schema.problemTag.name}) = lower(${name})`,
ne(schema.problemTag.id, id),
),
)
.limit(1)
if (!target) {
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse)
await db
.update(schema.problemTag)
.set({ name })
.where(eq(schema.problemTag.id, id))
return success(c, {
merged: false,
id,
name,
affectedCount: 0,
} satisfies RenameTagResponse)
}
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
const affected = await db.transaction(async (tx) => {
const links = await tx.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
const already = new Set((await tx.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, target.id)))
.map((row) => row.problemId))
const links = await tx
.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemtagId, id))
const already = new Set(
(
await tx
.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemtagId, target.id))
).map((row) => row.problemId),
)
// 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束
const missing = links.filter((link) => !already.has(link.problemId))
if (missing.length) {
await tx.insert(schema.problemTags).values(missing.map((link) => ({
problemId: link.problemId,
problemtagId: target.id,
})))
await tx.insert(schema.problemTags).values(
missing.map((link) => ({
problemId: link.problemId,
problemtagId: target.id,
})),
)
}
// 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE(0010)。
// 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。
@@ -88,93 +150,158 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
return links.length
})
return success(c, {
merged: true, id: target.id, name: target.name, affectedCount: affected,
merged: true,
id: target.id,
name: target.name,
affectedCount: affected,
} satisfies RenameTagResponse)
})
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
const deleted = await db.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null)
})
adminTagRoutes.delete(
"/problem-tags/:id",
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
const deleted = await db
.delete(schema.problemTag)
.where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
if (deleted.length === 0)
return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null)
},
)
adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) => {
const parsed = batchProblemTagRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const user = c.get("user")!
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)]
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id))
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(...filters))
if (problems.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目")
adminTagRoutes.post(
"/problems/batch-tag",
requireProblemPermission,
async (c) => {
const parsed = batchProblemTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
}
const user = c.get("user")!
const filters = [
inArray(schema.problem.id, parsed.data.problemIds),
isNull(schema.problem.contestId),
]
if (!canManageAllProblems(user))
filters.push(eq(schema.problem.createdById, user.id))
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(and(...filters))
if (problems.length === 0)
return failure(c, 404, "no-problems", "没有可操作的题目")
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
const wanted = normalizeTagNames(parsed.data.tagNames)
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
const wanted = normalizeTagNames(parsed.data.tagNames)
const tagIds = await db.transaction(async (tx) => {
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
if (parsed.data.action === "add") {
const missing = wanted.filter((name) => !existing.has(name.toLowerCase()))
if (missing.length) {
const created = await tx.insert(schema.problemTag).values(missing.map((name) => ({ name })))
.returning({ id: schema.problemTag.id, name: schema.problemTag.name })
for (const row of created) existing.set(row.name.toLowerCase(), row.id)
const tagIds = await db.transaction(async (tx) => {
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
if (parsed.data.action === "add") {
const missing = wanted.filter(
(name) => !existing.has(name.toLowerCase()),
)
if (missing.length) {
const created = await tx
.insert(schema.problemTag)
.values(missing.map((name) => ({ name })))
.returning({
id: schema.problemTag.id,
name: schema.problemTag.name,
})
for (const row of created)
existing.set(row.name.toLowerCase(), row.id)
}
}
}
return wanted.map((name) => existing.get(name.toLowerCase())).filter((id) => id !== undefined)
})
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
return wanted
.map((name) => existing.get(name.toLowerCase()))
.filter((id) => id !== undefined)
})
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
const problemIds = problems.map((problem) => problem.id)
await db.transaction(async (tx) => {
if (parsed.data.action === "remove") {
await tx.delete(schema.problemTags).where(and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
))
return
}
const existing = await tx.select().from(schema.problemTags).where(and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
))
const have = new Set(existing.map((row) => `${row.problemId}:${row.problemtagId}`))
const rows = []
for (const problemId of problemIds) {
for (const tagId of tagIds) {
if (!have.has(`${problemId}:${tagId}`)) rows.push({ problemId, problemtagId: tagId })
const problemIds = problems.map((problem) => problem.id)
await db.transaction(async (tx) => {
if (parsed.data.action === "remove") {
await tx
.delete(schema.problemTags)
.where(
and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
),
)
return
}
}
if (rows.length) await tx.insert(schema.problemTags).values(rows)
})
const existing = await tx
.select()
.from(schema.problemTags)
.where(
and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
),
)
const have = new Set(
existing.map((row) => `${row.problemId}:${row.problemtagId}`),
)
const rows = []
for (const problemId of problemIds) {
for (const tagId of tagIds) {
if (!have.has(`${problemId}:${tagId}`))
rows.push({ problemId, problemtagId: tagId })
}
}
if (rows.length) await tx.insert(schema.problemTags).values(rows)
})
return success(c, {
problemCount: problems.length,
tagCount: tagIds.length,
} satisfies BatchProblemTagResponse)
})
return success(c, {
problemCount: problems.length,
tagCount: tagIds.length,
} satisfies BatchProblemTagResponse)
},
)
// ---------------------------------------------------------------- 题目可见性
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problem] = await db.select({ id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById })
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
// AttributeError(500)。这里正常返回 404。
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
const user = c.get("user")!
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
return failure(c, 404, "problem-not-found", "题目不存在")
}
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id))
return success(c, { visible: !problem.visible })
})
adminTagRoutes.put(
"/problems/:id/visibility",
requireProblemPermission,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problem] = await db
.select({
id: schema.problem.id,
visible: schema.problem.visible,
createdById: schema.problem.createdById,
})
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
// AttributeError(500)。这里正常返回 404。
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
const user = c.get("user")!
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
return failure(c, 404, "problem-not-found", "题目不存在")
}
await db
.update(schema.problem)
.set({ visible: !problem.visible })
.where(eq(schema.problem.id, id))
return success(c, { visible: !problem.visible })
},
)
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
@@ -184,15 +311,26 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
const rows = await db.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
total: count(),
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
}).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
const rows = await db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
total: count(),
accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
failedUsers:
sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(
Number,
),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
/**
* 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题
* 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题
@@ -207,17 +345,27 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
*/
.where(isNull(schema.submission.contestId))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
.having(
sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`,
)
.orderBy(
desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`),
)
.limit(40)
return success(c, rows.map((row) => ({
problemId: row.displayId,
problemTitle: row.title,
total: row.total,
failed: row.failed,
failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
} satisfies StuckProblem)))
return success(
c,
rows.map(
(row) =>
({
problemId: row.displayId,
problemTitle: row.title,
total: row.total,
failed: row.failed,
failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
}) satisfies StuckProblem,
),
)
})
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
@@ -226,37 +374,64 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1
if (untilYear < sinceYear || untilYear > currentYear)
untilYear = currentYear - 1
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(Number)
const rows = await db.select({
problemId: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
year,
total: count(),
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
}).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(
isNull(schema.submission.contestId),
gte(year, sinceYear),
lte(year, untilYear),
))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
const year =
sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
Number,
)
const rows = await db
.select({
problemId: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
year,
total: count(),
accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(
and(
isNull(schema.submission.contestId),
gte(year, sinceYear),
lte(year, untilYear),
),
)
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
year,
)
.orderBy(asc(schema.problem.id), asc(year))
const required = new Set<number>()
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
const grouped = new Map<number, { displayId: string; title: string; yearly: typeof rows }>()
const grouped = new Map<
number,
{ displayId: string; title: string; yearly: typeof rows }
>()
for (const row of rows) {
const bucket = grouped.get(row.problemId)
if (bucket) bucket.yearly.push(row)
else grouped.set(row.problemId, { displayId: row.displayId, title: row.title, yearly: [row] })
else
grouped.set(row.problemId, {
displayId: row.displayId,
title: row.title,
yearly: [row],
})
}
const result = []
@@ -283,20 +458,27 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
// ---------------------------------------------------------------- Python → 流程图
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空")
try {
const flowchart = await completeChat(
`你是一个可以将Python代码转换为mermaid的助手。
adminTagRoutes.post(
"/problems/flowchart",
requireProblemPermission,
async (c) => {
const parsed = generateFlowchartRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "python 代码不能为空")
try {
const flowchart = await completeChat(
`你是一个可以将Python代码转换为mermaid的助手。
请将用户提供的Python代码转换为 Mermaid 纯文本。
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
parsed.data.python,
)
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
} catch (error) {
console.error("Flowchart generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
})
parsed.data.python,
)
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
} catch (error) {
console.error("Flowchart generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
},
)
+146 -58
View File
@@ -40,7 +40,11 @@ function serializeTutorial(row: {
function selectTutorial(id: number) {
return db
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -50,7 +54,11 @@ function selectTutorial(id: number) {
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
const rows = await db
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -64,62 +72,98 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
})
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
const parsed = createTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = createTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const now = new Date().toISOString()
const [created] = await db.insert(schema.tutorial).values({
...parsed.data,
createdAt: now,
updatedAt: now,
createdById: c.get("user")!.id,
}).returning({ id: schema.tutorial.id })
const [created] = await db
.insert(schema.tutorial)
.values({
...parsed.data,
createdAt: now,
updatedAt: now,
createdById: c.get("user")!.id,
})
.returning({ id: schema.tutorial.id })
const [row] = await selectTutorial(created!.id)
return success(c, serializeTutorial(row!), 201)
})
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, serializeTutorial(row))
})
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = updateTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const updated = await db.update(schema.tutorial)
const updated = await db
.update(schema.tutorial)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (updated.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
})
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required")
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
const updated = await db.update(schema.tutorial)
.set({ isPublic: parsed.data.isPublic })
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
})
adminTutorialRoutes.put(
"/tutorials/:id/visibility",
requireSuperAdmin,
async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setTutorialVisibilityRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isPublic is required")
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
const updated = await db
.update(schema.tutorial)
.set({ isPublic: parsed.data.isPublic })
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (updated.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
},
)
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
const deleted = await db.delete(schema.tutorial).where(eq(schema.tutorial.id, id))
const deleted = await db
.delete(schema.tutorial)
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
if (deleted.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, null)
})
@@ -136,52 +180,96 @@ function serializeExercise(row: typeof schema.exercise.$inferSelect) {
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.exercise)
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 })))
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
return success(c, rows.map(serializeExercise))
})
adminTutorialRoutes.get(
"/tutorials/:id/exercises",
requireSuperAdmin,
async (c) => {
const rows = await db
.select()
.from(schema.exercise)
.where(
eq(
schema.exercise.tutorialId,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
return success(c, rows.map(serializeExercise))
},
)
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
const parsed = createExerciseRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = createExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId))
.limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [created] = await db.insert(schema.exercise).values({
tutorialId: parsed.data.tutorialId,
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
createdAt: new Date().toISOString(),
}).returning()
const [created] = await db
.insert(schema.exercise)
.values({
tutorialId: parsed.data.tutorialId,
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
createdAt: new Date().toISOString(),
})
.returning()
return success(c, serializeExercise(created!), 201)
})
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
const parsed = updateExerciseRequestSchema.safeParse(await c.req.json().catch(() => null))
const parsed = updateExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
}
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [updated] = await db.update(schema.exercise)
.set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order })
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
const [updated] = await db
.update(schema.exercise)
.set({
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
})
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.returning()
if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
if (!updated)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, serializeExercise(updated))
})
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
const deleted = await db.delete(schema.exercise)
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
const deleted = await db
.delete(schema.exercise)
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.returning({ id: schema.exercise.id })
if (deleted.length === 0) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
if (deleted.length === 0)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, null)
})