原来只有 `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:
1 parent
e600fd24cf
commit
ed56a209ea
122 files changed
+10557
-4843
No files matched your search
+275
-102
@@ -42,15 +42,28 @@ import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { getUserProfileById } from "../services/profile"
|
||||
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
import {
|
||||
isTeacherOrAbove,
|
||||
objectValue,
|
||||
queryInteger,
|
||||
sampleUser,
|
||||
} from "./helpers"
|
||||
|
||||
export const accountRoutes = new Hono<AppEnv>()
|
||||
|
||||
accountRoutes.post("/users", async (c) => {
|
||||
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||
const parsed = registerRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||
if (!(await getBooleanOption("allow_register", true))) {
|
||||
return failure(c, 403, "registration-disabled", "Register function has been disabled by admin")
|
||||
return failure(
|
||||
c,
|
||||
403,
|
||||
"registration-disabled",
|
||||
"Register function has been disabled by admin",
|
||||
)
|
||||
}
|
||||
|
||||
const username = parsed.data.username.toLowerCase()
|
||||
@@ -58,7 +71,12 @@ accountRoutes.post("/users", async (c) => {
|
||||
const [duplicate] = await db
|
||||
.select({ username: schema.user.username, email: schema.user.email })
|
||||
.from(schema.user)
|
||||
.where(or(sql`lower(${schema.user.username}) = ${username}`, sql`lower(${schema.user.email}) = ${email}`))
|
||||
.where(
|
||||
or(
|
||||
sql`lower(${schema.user.username}) = ${username}`,
|
||||
sql`lower(${schema.user.email}) = ${email}`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (duplicate?.username.toLowerCase() === username) {
|
||||
return failure(c, 409, "username-exists", "Username already exists")
|
||||
@@ -70,18 +88,21 @@ accountRoutes.post("/users", async (c) => {
|
||||
const now = new Date().toISOString()
|
||||
const password = await hashPassword(parsed.data.password)
|
||||
await db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(schema.user).values({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
rawPassword: parsed.data.password.slice(0, 20),
|
||||
lastLogin: null,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
isDisabled: false,
|
||||
problemPermission: "None",
|
||||
className: null,
|
||||
}).returning({ id: schema.user.id })
|
||||
const [created] = await tx
|
||||
.insert(schema.user)
|
||||
.values({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
rawPassword: parsed.data.password.slice(0, 20),
|
||||
lastLogin: null,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
isDisabled: false,
|
||||
problemPermission: "None",
|
||||
className: null,
|
||||
})
|
||||
.returning({ id: schema.user.id })
|
||||
if (!created) throw new Error("User insert did not return an id")
|
||||
await tx.insert(schema.userProfile).values({
|
||||
userId: created.id,
|
||||
@@ -101,31 +122,57 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
||||
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
|
||||
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
|
||||
if (!c.get("user")) return success(c, null)
|
||||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
||||
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1)
|
||||
const [target] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`,
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!target) return failure(c, 404, "user-not-found", "User does not exist")
|
||||
const profile = await getUserProfileById(target.id, c.get("user")?.id === target.id)
|
||||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
const profile = await getUserProfileById(
|
||||
target.id,
|
||||
c.get("user")?.id === target.id,
|
||||
)
|
||||
if (!profile)
|
||||
return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
return success(c, profile)
|
||||
})
|
||||
|
||||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||||
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid profile payload")
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(parsed.data).map(([key, value]) => [key, value === "" ? null : value]),
|
||||
const parsed = updateProfileRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
await db.update(schema.userProfile).set(values).where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid profile payload")
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(parsed.data).map(([key, value]) => [
|
||||
key,
|
||||
value === "" ? null : value,
|
||||
]),
|
||||
)
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set(values)
|
||||
.where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
const profile = await getUserProfileById(c.get("user")!.id, true)
|
||||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
if (!profile)
|
||||
return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
return success(c, profile)
|
||||
})
|
||||
|
||||
accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
||||
const body: Record<string, string | File> = await c.req.parseBody().catch(() => ({}))
|
||||
const body: Record<string, string | File> = await c.req
|
||||
.parseBody()
|
||||
.catch(() => ({}))
|
||||
const image = body.image
|
||||
if (!(image instanceof File)) return failure(c, 400, "invalid-file", "Invalid file content")
|
||||
if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large")
|
||||
if (!(image instanceof File))
|
||||
return failure(c, 400, "invalid-file", "Invalid file content")
|
||||
if (image.size > 2 * 1024 * 1024)
|
||||
return failure(c, 400, "file-too-large", "Picture is too large")
|
||||
const extension = extname(image.name).toLowerCase()
|
||||
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
||||
return failure(c, 400, "unsupported-file", "Unsupported file format")
|
||||
@@ -135,17 +182,35 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
||||
await Bun.$`mkdir -p ${directory}`.quiet()
|
||||
await Bun.write(resolve(directory, filename), image)
|
||||
const avatar = `${config.avatarUriPrefix}/${filename}`
|
||||
await db.update(schema.userProfile).set({ avatar }).where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set({ avatar })
|
||||
.where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
return success(c, { avatar })
|
||||
})
|
||||
|
||||
accountRoutes.get("/users/:id/metrics", async (c) => {
|
||||
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ total: count(), first: min(schema.submission.createTime), latest: sql<string>`max(${schema.submission.createTime})` })
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: count(),
|
||||
first: min(schema.submission.createTime),
|
||||
latest: sql<string>`max(${schema.submission.createTime})`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
|
||||
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
|
||||
return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, userId),
|
||||
isNull(schema.submission.contestId),
|
||||
),
|
||||
)
|
||||
if (!row?.total || !row.first || !row.latest)
|
||||
return failure(c, 404, "no-submissions", "暂无提交")
|
||||
return success(c, {
|
||||
now: new Date().toISOString(),
|
||||
first: row.first,
|
||||
latest: row.latest,
|
||||
} satisfies Metrics)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -178,7 +243,10 @@ const leaderboardOrder = [
|
||||
]
|
||||
|
||||
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
|
||||
const limit = queryInteger(c.req.query("limit"), 10, {
|
||||
min: 1,
|
||||
max: LEADERBOARD_SIZE,
|
||||
})
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
|
||||
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
|
||||
@@ -188,14 +256,22 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
|
||||
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline)
|
||||
const [totalRow, rows, me, online] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.userProfile)
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere).then(([row]) => row),
|
||||
pageLimit === 0 ? [] : db
|
||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||||
.limit(pageLimit).offset(offset),
|
||||
.where(leaderboardWhere)
|
||||
.then(([row]) => row),
|
||||
pageLimit === 0
|
||||
? []
|
||||
: db
|
||||
.select({ profile: schema.userProfile, user: schema.user })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere)
|
||||
.orderBy(...leaderboardOrder)
|
||||
.limit(pageLimit)
|
||||
.offset(offset),
|
||||
myLeaderboardRank(c.get("user")?.id),
|
||||
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
|
||||
])
|
||||
@@ -207,10 +283,16 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
} satisfies UserRank)
|
||||
})
|
||||
|
||||
function serializeRankRow({ profile, user }: {
|
||||
profile: typeof schema.userProfile.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
}, online: Set<number> | null = null) {
|
||||
function serializeRankRow(
|
||||
{
|
||||
profile,
|
||||
user,
|
||||
}: {
|
||||
profile: typeof schema.userProfile.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
},
|
||||
online: Set<number> | null = null,
|
||||
) {
|
||||
return {
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
@@ -231,26 +313,35 @@ function serializeRankRow({ profile, user }: {
|
||||
async function myLeaderboardRank(userId: number | undefined) {
|
||||
if (!userId) return null
|
||||
const [mine] = await db
|
||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.select({ profile: schema.userProfile, user: schema.user })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(leaderboardWhere, eq(schema.user.id, userId))).limit(1)
|
||||
.where(and(leaderboardWhere, eq(schema.user.id, userId)))
|
||||
.limit(1)
|
||||
if (!mine) return null
|
||||
|
||||
const { acceptedNumber, submissionNumber } = mine.profile
|
||||
const [ahead] = await db.select({ value: count() }).from(schema.userProfile)
|
||||
const [ahead] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(leaderboardWhere, or(
|
||||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||||
leaderboardWhere,
|
||||
or(
|
||||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||||
),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||||
lt(schema.user.id, userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||||
lt(schema.user.id, userId),
|
||||
),
|
||||
)))
|
||||
)
|
||||
|
||||
return {
|
||||
...serializeRankRow(mine),
|
||||
@@ -260,7 +351,8 @@ async function myLeaderboardRank(userId: number | undefined) {
|
||||
|
||||
accountRoutes.get("/rankings/activity", async (c) => {
|
||||
const start = c.req.query("start")
|
||||
if (!start || Number.isNaN(Date.parse(start))) return failure(c, 400, "invalid-start", "start time is required")
|
||||
if (!start || Number.isNaN(Date.parse(start)))
|
||||
return failure(c, 400, "invalid-start", "start time is required")
|
||||
/**
|
||||
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
|
||||
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
|
||||
@@ -268,43 +360,105 @@ accountRoutes.get("/rankings/activity", async (c) => {
|
||||
*
|
||||
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
|
||||
*/
|
||||
const rows = await db.select({ username: schema.user.username, value: countDistinct(schema.submission.problemId) })
|
||||
const rows = await db
|
||||
.select({
|
||||
username: schema.user.username,
|
||||
value: countDistinct(schema.submission.problemId),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
||||
.where(and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, start),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
ne(schema.user.adminType, "Super Admin"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, start),
|
||||
inArray(schema.submission.result, [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
ne(schema.user.adminType, "Super Admin"),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.user.username)
|
||||
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
||||
return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem)))
|
||||
.orderBy(desc(countDistinct(schema.submission.problemId)))
|
||||
.limit(10)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
username: row.username,
|
||||
count: row.value,
|
||||
}) satisfies ActivityRankItem,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10]))
|
||||
const [all] = await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(accepted)
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const accepted = and(
|
||||
eq(schema.submission.problemId, problem.id),
|
||||
inArray(schema.submission.result, [0, 10]),
|
||||
)
|
||||
const [all] = await db
|
||||
.select({ value: countDistinct(schema.submission.userId) })
|
||||
.from(schema.submission)
|
||||
.where(accepted)
|
||||
const className = user.className ?? ""
|
||||
const classWhere = className
|
||||
? and(accepted, inArray(schema.submission.userId, db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false)))))
|
||||
? and(
|
||||
accepted,
|
||||
inArray(
|
||||
schema.submission.userId,
|
||||
db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.className, className),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: accepted
|
||||
const [classCount] = className
|
||||
? await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(classWhere)
|
||||
? await db
|
||||
.select({ value: countDistinct(schema.submission.userId) })
|
||||
.from(schema.submission)
|
||||
.where(classWhere)
|
||||
: [{ value: 0 }]
|
||||
const [first] = await db.select({ value: min(schema.submission.createTime) }).from(schema.submission)
|
||||
const [first] = await db
|
||||
.select({ value: min(schema.submission.createTime) })
|
||||
.from(schema.submission)
|
||||
.where(and(classWhere, eq(schema.submission.userId, user.id)))
|
||||
let rank = -1
|
||||
if (first?.value) {
|
||||
const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value)))
|
||||
const [rankRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
.where(and(classWhere, lte(schema.submission.createTime, first.value)))
|
||||
rank = rankRow?.value ?? -1
|
||||
}
|
||||
return success(c, { className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 } satisfies ProblemRank)
|
||||
return success(c, {
|
||||
className,
|
||||
rank,
|
||||
classAcCount: classCount?.value ?? 0,
|
||||
allAcCount: all?.value ?? 0,
|
||||
} satisfies ProblemRank)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -319,25 +473,44 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
|
||||
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
|
||||
*/
|
||||
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id)).limit(1)
|
||||
const status = objectValue(profile?.value)
|
||||
const problems = objectValue(status.problems)
|
||||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||||
if (ids.length > 0) {
|
||||
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.displayId }).from(schema.problem)
|
||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true)))
|
||||
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId]))
|
||||
for (const [id, value] of Object.entries(problems)) {
|
||||
const item = objectValue(value)
|
||||
const displayId = displayIds.get(id)
|
||||
if (displayId) item._id = displayId
|
||||
problems[id] = item
|
||||
accountRoutes.post(
|
||||
"/me/problem-display-ids/refresh",
|
||||
requireAuth,
|
||||
async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [profile] = await db
|
||||
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const status = objectValue(profile?.value)
|
||||
const problems = objectValue(status.problems)
|
||||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||||
if (ids.length > 0) {
|
||||
const rows = await db
|
||||
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.problem.id, ids),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
const displayIds = new Map(
|
||||
rows.map((row) => [String(row.id), row.displayId]),
|
||||
)
|
||||
for (const [id, value] of Object.entries(problems)) {
|
||||
const item = objectValue(value)
|
||||
const displayId = displayIds.get(id)
|
||||
if (displayId) item._id = displayId
|
||||
problems[id] = item
|
||||
}
|
||||
status.problems = problems
|
||||
await db
|
||||
.update(schema.userProfile)
|
||||
.set({ acmProblemsStatus: status })
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
}
|
||||
status.problems = problems
|
||||
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
return success(c, null)
|
||||
},
|
||||
)
|
||||
@@ -17,16 +17,29 @@ export const achievementRoutes = new Hono<AppEnv>()
|
||||
|
||||
async function resolveUser(requested: string | undefined, currentId: number) {
|
||||
if (!requested) {
|
||||
const [current] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
||||
.where(eq(schema.user.id, currentId)).limit(1)
|
||||
const [current] = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, currentId))
|
||||
.limit(1)
|
||||
return current ?? null
|
||||
}
|
||||
const [target] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
||||
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false))).limit(1)
|
||||
const [target] = await db
|
||||
.select({ id: schema.user.id, username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.username, requested),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
return target ?? null
|
||||
}
|
||||
|
||||
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
|
||||
function pendingData(row: {
|
||||
achievement: typeof schema.achievement.$inferSelect
|
||||
}) {
|
||||
return {
|
||||
id: row.achievement.id,
|
||||
name: row.achievement.name,
|
||||
@@ -40,10 +53,24 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([
|
||||
db.select().from(schema.achievement).where(eq(schema.achievement.visible, true)).orderBy(asc(schema.achievement.order), asc(schema.achievement.id)),
|
||||
db.select().from(schema.userAchievement).where(eq(schema.userAchievement.userId, target.id)),
|
||||
db.select({ metrics: schema.userStat.metrics }).from(schema.userStat).where(eq(schema.userStat.userId, target.id)).limit(1),
|
||||
db.select({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.achievement)
|
||||
.where(eq(schema.achievement.visible, true))
|
||||
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id)),
|
||||
db
|
||||
.select()
|
||||
.from(schema.userAchievement)
|
||||
.where(eq(schema.userAchievement.userId, target.id)),
|
||||
db
|
||||
.select({ metrics: schema.userStat.metrics })
|
||||
.from(schema.userStat)
|
||||
.where(eq(schema.userStat.userId, target.id))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.isDisabled, false)),
|
||||
])
|
||||
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
|
||||
const metrics = objectValue(statRows[0]?.metrics)
|
||||
@@ -66,22 +93,50 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
unlockTime: record?.unlockTime ?? null,
|
||||
backfilled: record?.backfilled ?? false,
|
||||
progress: masked ? null : typeof progress === "number" ? progress : 0,
|
||||
unlockRate: active > 0 ? Math.round(achievement.unlockCount / active * 1000) / 10 : 0,
|
||||
unlockRate:
|
||||
active > 0
|
||||
? Math.round((achievement.unlockCount / active) * 1000) / 10
|
||||
: 0,
|
||||
} satisfies Achievement
|
||||
})
|
||||
return success(c, { username: target.username, achievements: result } satisfies AchievementList)
|
||||
return success(c, {
|
||||
username: target.username,
|
||||
achievements: result,
|
||||
} satisfies AchievementList)
|
||||
})
|
||||
|
||||
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const [achievements, unlockedRows] = await Promise.all([
|
||||
db.select({ id: schema.achievement.id, rarity: schema.achievement.rarity }).from(schema.achievement).where(eq(schema.achievement.visible, true)),
|
||||
db.select({ record: schema.userAchievement, achievement: schema.achievement }).from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true))).orderBy(desc(schema.userAchievement.unlockTime)),
|
||||
db
|
||||
.select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
|
||||
.from(schema.achievement)
|
||||
.where(eq(schema.achievement.visible, true)),
|
||||
db
|
||||
.select({
|
||||
record: schema.userAchievement,
|
||||
achievement: schema.achievement,
|
||||
})
|
||||
.from(schema.userAchievement)
|
||||
.innerJoin(
|
||||
schema.achievement,
|
||||
eq(schema.userAchievement.achievementId, schema.achievement.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userAchievement.userId, target.id),
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.userAchievement.unlockTime)),
|
||||
])
|
||||
const labels = { bronze: "青铜", silver: "白银", gold: "黄金", platinum: "白金" }
|
||||
const labels = {
|
||||
bronze: "青铜",
|
||||
silver: "白银",
|
||||
gold: "黄金",
|
||||
platinum: "白金",
|
||||
}
|
||||
const rarities = ["bronze", "silver", "gold", "platinum"] as const
|
||||
const total = achievements.length
|
||||
const unlocked = unlockedRows.length
|
||||
@@ -89,33 +144,54 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
||||
username: target.username,
|
||||
total,
|
||||
unlocked,
|
||||
percent: total > 0 ? Math.round(unlocked / total * 1000) / 10 : 0,
|
||||
percent: total > 0 ? Math.round((unlocked / total) * 1000) / 10 : 0,
|
||||
rarity: rarities.map((rarity) => ({
|
||||
rarity,
|
||||
label: labels[rarity],
|
||||
total: achievements.filter((item) => item.rarity === rarity).length,
|
||||
unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length,
|
||||
unlocked: unlockedRows.filter(
|
||||
(item) => item.achievement.rarity === rarity,
|
||||
).length,
|
||||
})),
|
||||
recent: unlockedRows.slice(0, 10).map(pendingData),
|
||||
} satisfies AchievementSummary)
|
||||
})
|
||||
|
||||
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||
const rows = await db.select({ record: schema.userAchievement, achievement: schema.achievement })
|
||||
.from(schema.userAchievement).innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, c.get("user")!.id), eq(schema.userAchievement.notified, false), eq(schema.achievement.visible, true)))
|
||||
const rows = await db
|
||||
.select({ record: schema.userAchievement, achievement: schema.achievement })
|
||||
.from(schema.userAchievement)
|
||||
.innerJoin(
|
||||
schema.achievement,
|
||||
eq(schema.userAchievement.achievementId, schema.achievement.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||
eq(schema.userAchievement.notified, false),
|
||||
eq(schema.achievement.visible, true),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.userAchievement.unlockTime))
|
||||
return success(c, rows.map(pendingData))
|
||||
})
|
||||
|
||||
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
|
||||
const parsed = markAchievementsReadSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||
const parsed = markAchievementsReadSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||
if (parsed.data.ids.length > 0) {
|
||||
await db.update(schema.userAchievement).set({ notified: true }).where(and(
|
||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
||||
))
|
||||
await db
|
||||
.update(schema.userAchievement)
|
||||
.set({ notified: true })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
||||
),
|
||||
)
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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", "生成失败,请稍后再试")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
+732
-216
File diff suppressed because it is too large.
Load diff
@@ -38,25 +38,36 @@ async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
|
||||
]
|
||||
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
||||
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
|
||||
const rows = await db.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
}).from(schema.user).innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(and(...filters))
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
})
|
||||
.from(schema.user)
|
||||
.innerJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.where(and(...filters))
|
||||
return rows.filter((row): row is ClassUser => row.className !== null)
|
||||
}
|
||||
|
||||
function mean(values: number[]) {
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
||||
return values.length
|
||||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
: 0
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (!values.length) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||
return sorted.length % 2
|
||||
? sorted[middle]!
|
||||
: (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||
}
|
||||
|
||||
function quantile(values: number[], p: number) {
|
||||
@@ -73,35 +84,59 @@ function quantile(values: number[], p: number) {
|
||||
function sampleStdDev(values: number[]) {
|
||||
if (values.length <= 1) return 0
|
||||
const average = mean(values)
|
||||
return Math.sqrt(values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1))
|
||||
return Math.sqrt(
|
||||
values.reduce((sum, value) => sum + (value - average) ** 2, 0) /
|
||||
(values.length - 1),
|
||||
)
|
||||
}
|
||||
|
||||
classroomRoutes.get("/rankings/classes", async (c) => {
|
||||
const grade = c.req.query("grade")?.trim()
|
||||
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
|
||||
if (!grade || !/^\d+$/.test(grade))
|
||||
return failure(c, 400, "invalid-grade", "grade is required")
|
||||
const users = await loadClassUsers(undefined, grade)
|
||||
const groups = new Map<string, ClassUser[]>()
|
||||
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||
const result = [...groups].map(([className, members]) => {
|
||||
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
|
||||
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
|
||||
return {
|
||||
className,
|
||||
userCount: members.length,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(totalAc / members.length),
|
||||
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
||||
}
|
||||
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
|
||||
return success(c, result.map((item, index) => ({ ...item, rank: index + 1 } satisfies ClassRankItem)))
|
||||
for (const user of users)
|
||||
groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||
const result = [...groups]
|
||||
.map(([className, members]) => {
|
||||
const totalAc = members.reduce(
|
||||
(sum, member) => sum + member.acceptedNumber,
|
||||
0,
|
||||
)
|
||||
const totalSubmission = members.reduce(
|
||||
(sum, member) => sum + member.submissionNumber,
|
||||
0,
|
||||
)
|
||||
return {
|
||||
className,
|
||||
userCount: members.length,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(totalAc / members.length),
|
||||
acRate:
|
||||
totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission,
|
||||
)
|
||||
return success(
|
||||
c,
|
||||
result.map(
|
||||
(item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息")
|
||||
if (!user.className)
|
||||
return failure(c, 400, "class-missing", "用户没有班级信息")
|
||||
const members = (await loadClassUsers([user.className])).sort(
|
||||
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber,
|
||||
(a, b) =>
|
||||
b.acceptedNumber - a.acceptedNumber ||
|
||||
a.submissionNumber - b.submissionNumber,
|
||||
)
|
||||
const ranks = members.map((member, index) => ({
|
||||
userId: member.userId,
|
||||
@@ -121,35 +156,64 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
|
||||
selected = ranks.slice(start, start + 10)
|
||||
}
|
||||
return success(c, { className: user.className, myRank, total: ranks.length, ranks: selected } satisfies ClassUserRank)
|
||||
return success(c, {
|
||||
className: user.className,
|
||||
myRank,
|
||||
total: ranks.length,
|
||||
ranks: selected,
|
||||
} satisfies ClassUserRank)
|
||||
})
|
||||
|
||||
classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required")
|
||||
const parsed = classComparisonRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "At least one class is required")
|
||||
const users = await loadClassUsers(parsed.data.classNames)
|
||||
const allAc = users.map((user) => user.acceptedNumber)
|
||||
const globalQ1 = quantile(allAc, 0.25)
|
||||
const globalQ3 = quantile(allAc, 0.75)
|
||||
const byClass = new Map<string, ClassUser[]>()
|
||||
for (const user of users) byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
|
||||
for (const user of users)
|
||||
byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
|
||||
|
||||
let recentByUser = new Map<number, Set<number>>()
|
||||
let recentSubmissionCount = new Map<string, number>()
|
||||
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
|
||||
if (hasTimeRange) {
|
||||
const rows = await db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, result: schema.submission.result })
|
||||
.from(schema.submission).where(and(
|
||||
inArray(schema.submission.userId, users.map((user) => user.userId)),
|
||||
gte(schema.submission.createTime, parsed.data.startTime!),
|
||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
||||
))
|
||||
const userClass = new Map(users.map((user) => [user.userId, user.className]))
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
schema.submission.userId,
|
||||
users.map((user) => user.userId),
|
||||
),
|
||||
gte(schema.submission.createTime, parsed.data.startTime!),
|
||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
||||
),
|
||||
)
|
||||
const userClass = new Map(
|
||||
users.map((user) => [user.userId, user.className]),
|
||||
)
|
||||
for (const row of rows) {
|
||||
const className = userClass.get(row.userId)
|
||||
if (!className) continue
|
||||
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
|
||||
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
|
||||
recentSubmissionCount.set(
|
||||
className,
|
||||
(recentSubmissionCount.get(className) ?? 0) + 1,
|
||||
)
|
||||
if (
|
||||
[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(
|
||||
row.result as 0 | 10,
|
||||
)
|
||||
) {
|
||||
const set = recentByUser.get(row.userId) ?? new Set<number>()
|
||||
set.add(row.problemId)
|
||||
recentByUser.set(row.userId, set)
|
||||
@@ -158,12 +222,17 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
}
|
||||
|
||||
const comparisons = [...byClass].map(([className, members]) => {
|
||||
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
|
||||
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
|
||||
const ac = members
|
||||
.map((member) => member.acceptedNumber)
|
||||
.sort((a, b) => b - a)
|
||||
const submissions = members
|
||||
.map((member) => member.submissionNumber)
|
||||
.sort((a, b) => b - a)
|
||||
const userCount = members.length
|
||||
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
|
||||
const bottomCount = topCount
|
||||
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
|
||||
const middle =
|
||||
topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
|
||||
const totalAc = ac.reduce((sum, value) => sum + value, 0)
|
||||
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
|
||||
const base: ClassComparison = {
|
||||
@@ -180,19 +249,30 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
top10Avg: rounded(mean(ac.slice(0, topCount))),
|
||||
middle80Avg: rounded(mean(middle)),
|
||||
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
|
||||
excellentRate: rounded(ac.filter((value) => value >= globalQ3).length / userCount * 100),
|
||||
passRate: rounded(ac.filter((value) => value >= globalQ1).length / userCount * 100),
|
||||
activeRate: rounded(submissions.filter((value) => value > 0).length / userCount * 100),
|
||||
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
||||
excellentRate: rounded(
|
||||
(ac.filter((value) => value >= globalQ3).length / userCount) * 100,
|
||||
),
|
||||
passRate: rounded(
|
||||
(ac.filter((value) => value >= globalQ1).length / userCount) * 100,
|
||||
),
|
||||
activeRate: rounded(
|
||||
(submissions.filter((value) => value > 0).length / userCount) * 100,
|
||||
),
|
||||
acRate:
|
||||
totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
|
||||
compositeScore: 0,
|
||||
}
|
||||
if (hasTimeRange) {
|
||||
const recent = members.map((member) => recentByUser.get(member.userId)?.size ?? 0).sort((a, b) => b - a)
|
||||
const recent = members
|
||||
.map((member) => recentByUser.get(member.userId)?.size ?? 0)
|
||||
.sort((a, b) => b - a)
|
||||
base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
|
||||
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
|
||||
base.recentAvgAc = rounded(mean(recent))
|
||||
base.recentMedianAc = rounded(median(recent))
|
||||
base.recentTop10Avg = rounded(mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))))
|
||||
base.recentTop10Avg = rounded(
|
||||
mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))),
|
||||
)
|
||||
base.recentActiveCount = recent.filter((value) => value > 0).length
|
||||
}
|
||||
return base
|
||||
@@ -201,14 +281,19 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
|
||||
for (const item of comparisons) {
|
||||
item.compositeScore = rounded(
|
||||
0.4 * (item.medianAc / maxMedian * 100) +
|
||||
0.15 * (item.middle80Avg / maxMiddle * 100) +
|
||||
0.2 * item.activeRate +
|
||||
0.15 * item.passRate +
|
||||
0.1 * item.excellentRate,
|
||||
0.4 * ((item.medianAc / maxMedian) * 100) +
|
||||
0.15 * ((item.middle80Avg / maxMiddle) * 100) +
|
||||
0.2 * item.activeRate +
|
||||
0.15 * item.passRate +
|
||||
0.1 * item.excellentRate,
|
||||
1,
|
||||
)
|
||||
}
|
||||
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
|
||||
return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse)
|
||||
comparisons.sort(
|
||||
(a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc,
|
||||
)
|
||||
return success(c, {
|
||||
comparisons,
|
||||
hasTimeRange,
|
||||
} satisfies ClassComparisonResponse)
|
||||
})
|
||||
+387
-168
@@ -33,34 +33,75 @@ contentRoutes.get("/announcements", 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.announcement).where(eq(schema.announcement.visible, true)),
|
||||
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))
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.announcement)
|
||||
.where(eq(schema.announcement.visible, true)),
|
||||
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),
|
||||
)
|
||||
.where(eq(schema.announcement.visible, true))
|
||||
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
|
||||
.orderBy(
|
||||
desc(schema.announcement.top),
|
||||
desc(schema.announcement.createTime),
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map(({ announcement, user, realName }) => ({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
} satisfies AnnouncementListItem)),
|
||||
results: rows.map(
|
||||
({ announcement, user, realName }) =>
|
||||
({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
}) satisfies AnnouncementListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies AnnouncementList)
|
||||
})
|
||||
|
||||
contentRoutes.get("/announcements/:id", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await 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))
|
||||
const [row] = await 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))
|
||||
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||||
.where(
|
||||
and(
|
||||
eq(schema.announcement.id, id),
|
||||
eq(schema.announcement.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!row)
|
||||
return failure(
|
||||
c,
|
||||
404,
|
||||
"announcement-not-found",
|
||||
"Announcement does not exist",
|
||||
)
|
||||
return success(c, {
|
||||
id: row.announcement.id,
|
||||
title: row.announcement.title,
|
||||
@@ -78,36 +119,62 @@ contentRoutes.get("/messages", requireAuth, 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.message).where(eq(schema.message.recipientId, user.id)),
|
||||
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId })
|
||||
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.message)
|
||||
.where(eq(schema.message.recipientId, user.id)),
|
||||
db
|
||||
.select({
|
||||
message: schema.message,
|
||||
sender: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
submission: schema.submission,
|
||||
displayId: schema.problem.displayId,
|
||||
})
|
||||
.from(schema.message)
|
||||
.innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.submission,
|
||||
eq(schema.message.submissionId, schema.submission.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.submission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.message.recipientId, user.id))
|
||||
.orderBy(desc(schema.message.createTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||
language: submission.language,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
} satisfies EmbeddedSubmission,
|
||||
} satisfies Message)),
|
||||
results: rows.map(
|
||||
({ message, sender, realName, submission, displayId }) =>
|
||||
({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||
language: submission.language,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
} satisfies EmbeddedSubmission,
|
||||
}) satisfies Message,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies MessageList)
|
||||
})
|
||||
@@ -120,15 +187,39 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
*/
|
||||
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
|
||||
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
|
||||
const parsed = createMessageRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid message payload")
|
||||
if (parsed.data.recipientId === user.id)
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-recipient",
|
||||
"Can not send a message to yourself",
|
||||
)
|
||||
const [[recipient], [submission]] = await Promise.all([
|
||||
db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false))).limit(1),
|
||||
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
|
||||
db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.id, parsed.data.recipientId),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: schema.submission.id })
|
||||
.from(schema.submission)
|
||||
.where(eq(schema.submission.id, parsed.data.submissionId))
|
||||
.limit(1),
|
||||
])
|
||||
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist")
|
||||
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
if (!recipient)
|
||||
return failure(c, 404, "user-not-found", "User does not exist")
|
||||
if (!submission)
|
||||
return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
await db.insert(schema.message).values({
|
||||
message: parsed.data.message,
|
||||
createTime: new Date().toISOString(),
|
||||
@@ -140,11 +231,22 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
async function reactionState(problemId: number, userId: number) {
|
||||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
||||
const [mine] = await db
|
||||
.select({ type: schema.reaction.type })
|
||||
.from(schema.reaction)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.reaction.problemId, problemId),
|
||||
eq(schema.reaction.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!mine) return { mine: null, counts: null } satisfies ReactionState
|
||||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
||||
const rows = await db
|
||||
.select({ type: schema.reaction.type, value: count() })
|
||||
.from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId))
|
||||
.groupBy(schema.reaction.type)
|
||||
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
|
||||
// row.type 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||||
const counts = Object.fromEntries(
|
||||
@@ -161,41 +263,85 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
|
||||
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const parsed = setReactionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const user = c.get("user")!
|
||||
const [[problem], [solved]] = await Promise.all([
|
||||
db.select({ id: schema.problem.id }).from(schema.problem).where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true))).limit(1),
|
||||
db.select({ id: schema.submission.id }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
)).limit(1),
|
||||
db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true)),
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: schema.submission.id })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
eq(schema.submission.problemId, problemId),
|
||||
inArray(schema.submission.result, [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
])
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
|
||||
await db.insert(schema.reaction).values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!solved)
|
||||
return failure(
|
||||
c,
|
||||
403,
|
||||
"accepted-submission-required",
|
||||
"An accepted submission is required",
|
||||
)
|
||||
await db
|
||||
.insert(schema.reaction)
|
||||
.values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [schema.reaction.problemId, schema.reaction.userId],
|
||||
})
|
||||
return success(c, await reactionState(problemId, user.id))
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials", async (c) => {
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
||||
const rows = await db
|
||||
.select({ id: schema.tutorial.id, title: schema.tutorial.title })
|
||||
.from(schema.tutorial)
|
||||
.where(
|
||||
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
|
||||
)
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
return success(c, rows satisfies TutorialSummary[])
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
const [row] = await db
|
||||
.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))
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!row)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
return success(c, {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
@@ -222,48 +368,73 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const visible = and(eq(schema.tutorial.type, type), eq(schema.tutorial.isPublic, true))
|
||||
const visible = and(
|
||||
eq(schema.tutorial.type, type),
|
||||
eq(schema.tutorial.isPublic, true),
|
||||
)
|
||||
|
||||
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
|
||||
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
|
||||
const [rows, exerciseRows] = await Promise.all([
|
||||
db.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
firstViewedAt: schema.tutorialProgress.firstViewedAt,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
}).from(schema.tutorial)
|
||||
.leftJoin(schema.tutorialProgress, and(
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
eq(schema.tutorialProgress.userId, user.id),
|
||||
))
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
firstViewedAt: schema.tutorialProgress.firstViewedAt,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.leftJoin(
|
||||
schema.tutorialProgress,
|
||||
and(
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
eq(schema.tutorialProgress.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(visible)
|
||||
.orderBy(asc(schema.tutorial.order)),
|
||||
db.select({
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
total: count(),
|
||||
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
|
||||
}).from(schema.exercise)
|
||||
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
|
||||
.leftJoin(schema.exerciseAttempt, and(
|
||||
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||
eq(schema.exerciseAttempt.userId, user.id),
|
||||
))
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
total: count(),
|
||||
solved:
|
||||
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.exercise)
|
||||
.innerJoin(
|
||||
schema.tutorial,
|
||||
eq(schema.tutorial.id, schema.exercise.tutorialId),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.exerciseAttempt,
|
||||
and(
|
||||
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||
eq(schema.exerciseAttempt.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(visible)
|
||||
.groupBy(schema.exercise.tutorialId),
|
||||
])
|
||||
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
|
||||
|
||||
return success(c, rows.map((row) => ({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
firstViewedAt: row.firstViewedAt,
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
} satisfies TutorialProgress)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
firstViewedAt: row.firstViewedAt,
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
}) satisfies TutorialProgress,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -276,31 +447,44 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
|
||||
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const parsed = tutorialProgressPingSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid progress payload")
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!tutorial)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { seconds, opened } = parsed.data
|
||||
await db.insert(schema.tutorialProgress).values({
|
||||
userId: user.id,
|
||||
tutorialId: id,
|
||||
viewCount: opened ? 1 : 0,
|
||||
totalSeconds: seconds,
|
||||
firstViewedAt: now,
|
||||
lastViewedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
|
||||
set: {
|
||||
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||
await db
|
||||
.insert(schema.tutorialProgress)
|
||||
.values({
|
||||
userId: user.id,
|
||||
tutorialId: id,
|
||||
viewCount: opened ? 1 : 0,
|
||||
totalSeconds: seconds,
|
||||
firstViewedAt: now,
|
||||
lastViewedAt: now,
|
||||
},
|
||||
})
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.tutorialProgress.userId,
|
||||
schema.tutorialProgress.tutorialId,
|
||||
],
|
||||
set: {
|
||||
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||
lastViewedAt: now,
|
||||
},
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
@@ -317,60 +501,95 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload")
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid attempt payload")
|
||||
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
|
||||
const [exercise] = await db.select({ id: schema.exercise.id }).from(schema.exercise)
|
||||
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
|
||||
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
|
||||
const [exercise] = await db
|
||||
.select({ id: schema.exercise.id })
|
||||
.from(schema.exercise)
|
||||
.innerJoin(
|
||||
schema.tutorial,
|
||||
eq(schema.tutorial.id, schema.exercise.tutorialId),
|
||||
)
|
||||
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!exercise)
|
||||
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { correct } = parsed.data
|
||||
const answer = correct ? null : (parsed.data.answer ?? null)
|
||||
await db.insert(schema.exerciseAttempt).values({
|
||||
userId: user.id,
|
||||
exerciseId: id,
|
||||
attempts: 1,
|
||||
wrongAttempts: correct ? 0 : 1,
|
||||
solved: correct,
|
||||
attemptsToSolve: correct ? 1 : null,
|
||||
lastWrongAnswer: answer,
|
||||
firstAttemptAt: now,
|
||||
lastAttemptAt: now,
|
||||
solvedAt: correct ? now : null,
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
|
||||
set: {
|
||||
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||
//
|
||||
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||
attemptsToSolve: sql`case
|
||||
await db
|
||||
.insert(schema.exerciseAttempt)
|
||||
.values({
|
||||
userId: user.id,
|
||||
exerciseId: id,
|
||||
attempts: 1,
|
||||
wrongAttempts: correct ? 0 : 1,
|
||||
solved: correct,
|
||||
attemptsToSolve: correct ? 1 : null,
|
||||
lastWrongAnswer: answer,
|
||||
firstAttemptAt: now,
|
||||
lastAttemptAt: now,
|
||||
solvedAt: correct ? now : null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.exerciseAttempt.userId,
|
||||
schema.exerciseAttempt.exerciseId,
|
||||
],
|
||||
set: {
|
||||
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||
//
|
||||
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||
attemptsToSolve: sql`case
|
||||
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.attemptsToSolve}
|
||||
when ${correct} then ${schema.exerciseAttempt.attempts} + 1
|
||||
else null end`,
|
||||
solvedAt: sql`case
|
||||
solvedAt: sql`case
|
||||
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.solvedAt}
|
||||
when ${correct} then ${now}::timestamptz
|
||||
else null end`,
|
||||
lastWrongAnswer: sql`case
|
||||
lastWrongAnswer: sql`case
|
||||
when ${schema.exerciseAttempt.solved} or ${correct} then ${schema.exerciseAttempt.lastWrongAnswer}
|
||||
else ${answer} end`,
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
})
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
|
||||
return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise)))
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!tutorial)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.exercise)
|
||||
.where(eq(schema.exercise.tutorialId, id))
|
||||
.orderBy(asc(schema.exercise.order))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
data: objectValue(row.data),
|
||||
order: row.order,
|
||||
}) satisfies Exercise,
|
||||
),
|
||||
)
|
||||
})
|
||||
+294
-133
@@ -9,7 +9,18 @@ import {
|
||||
type ProblemDetail,
|
||||
type ProblemListItem,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
lte,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth } from "../auth/middleware"
|
||||
@@ -27,7 +38,12 @@ import {
|
||||
requireContestAccess,
|
||||
type ContestEnv,
|
||||
} from "../services/contest"
|
||||
import { objectValue, publicTemplates, queryInteger, sampleUser } from "./helpers"
|
||||
import {
|
||||
objectValue,
|
||||
publicTemplates,
|
||||
queryInteger,
|
||||
sampleUser,
|
||||
} from "./helpers"
|
||||
|
||||
export const contestRoutes = new Hono<ContestEnv>()
|
||||
|
||||
@@ -35,8 +51,14 @@ export const contestRoutes = new Hono<ContestEnv>()
|
||||
async function creators(ids: number[]) {
|
||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||
if (ids.length === 0) return map
|
||||
const rows = await 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))
|
||||
const rows = await 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, ids))
|
||||
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
||||
return map
|
||||
@@ -75,18 +97,33 @@ contestRoutes.get("/contests", async (c) => {
|
||||
if (tag) filters.push(eq(schema.contest.tag, tag))
|
||||
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
||||
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
|
||||
else if (status === "0") filters.push(and(lte(schema.contest.startTime, now), gte(schema.contest.endTime, now))!)
|
||||
else if (status === "0")
|
||||
filters.push(
|
||||
and(
|
||||
lte(schema.contest.startTime, now),
|
||||
gte(schema.contest.endTime, now),
|
||||
)!,
|
||||
)
|
||||
const where = and(...filters)
|
||||
const [totalRow, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.contest).where(where),
|
||||
db.select().from(schema.contest).where(where).orderBy(desc(schema.contest.startTime)).limit(limit).offset(offset),
|
||||
db
|
||||
.select()
|
||||
.from(schema.contest)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.contest.startTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
|
||||
return success(c, {
|
||||
results: rows.map((row) => serializeContest(
|
||||
row,
|
||||
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
||||
)),
|
||||
results: rows.map((row) =>
|
||||
serializeContest(
|
||||
row,
|
||||
byId.get(row.createdById) ??
|
||||
sampleUser({ id: row.createdById, username: "" }, null),
|
||||
),
|
||||
),
|
||||
total: totalRow[0]?.value ?? 0,
|
||||
} satisfies ContestList)
|
||||
})
|
||||
@@ -94,31 +131,55 @@ contestRoutes.get("/contests", async (c) => {
|
||||
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
|
||||
// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
|
||||
contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const contest = await findAccessibleContest(
|
||||
c.get("user"),
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
)
|
||||
if (!contest)
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const byId = await creators([contest.createdById])
|
||||
return success(c, serializeContest(
|
||||
contest,
|
||||
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null),
|
||||
true,
|
||||
))
|
||||
return success(
|
||||
c,
|
||||
serializeContest(
|
||||
contest,
|
||||
byId.get(contest.createdById) ??
|
||||
sampleUser({ id: contest.createdById, username: "" }, null),
|
||||
true,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
|
||||
const contest = await findAccessibleContest(
|
||||
c.get("user"),
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
)
|
||||
if (!contest || !contest.password)
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const parsed = contestPasswordRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Password is required")
|
||||
if (!checkContestPassword(parsed.data.password, contest.password)) {
|
||||
return failure(c, 403, "wrong-password", "Wrong password or password expired")
|
||||
return failure(
|
||||
c,
|
||||
403,
|
||||
"wrong-password",
|
||||
"Wrong password or password expired",
|
||||
)
|
||||
}
|
||||
await setContestPassword(c, contest.id, parsed.data.password)
|
||||
return success(c, true)
|
||||
})
|
||||
|
||||
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const contest = await findAccessibleContest(
|
||||
c.get("user"),
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
)
|
||||
if (!contest || !contest.password)
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const access = await canAccessContest(c, contest, "details")
|
||||
return success(c, { access: access.ok } satisfies ContestAccess)
|
||||
})
|
||||
@@ -136,8 +197,11 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||
*/
|
||||
async function contestProblemStatuses(userId: number | undefined) {
|
||||
if (!userId) return {}
|
||||
const [profile] = await db.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, userId))
|
||||
.limit(1)
|
||||
return objectValue(objectValue(profile?.status).contest_problems)
|
||||
}
|
||||
|
||||
@@ -148,117 +212,214 @@ function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
|
||||
|
||||
async function contestProblemTags(problemIds: number[]) {
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags).innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const map = new Map<number, string[]>()
|
||||
for (const row of rows) map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name])
|
||||
for (const row of rows)
|
||||
map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name])
|
||||
return map
|
||||
}
|
||||
|
||||
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
|
||||
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(c, rows.map(({ problem, user, realName }) => ({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||
difficulty: allowed ? problem.difficulty : null,
|
||||
createdBy: sampleUser(user, realName),
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: contest.id,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: myStatusOf(statuses, problem.id),
|
||||
} satisfies ProblemListItem)))
|
||||
})
|
||||
contestRoutes.get(
|
||||
"/contests/:id/problems",
|
||||
optionalAuth,
|
||||
requireContestAccess("problems"),
|
||||
async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const rows = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.contestId, contest.id),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.problem.displayId))
|
||||
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ problem, user, realName }) =>
|
||||
({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||
difficulty: allowed ? problem.difficulty : null,
|
||||
createdBy: sampleUser(user, realName),
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: contest.id,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: myStatusOf(statuses, problem.id),
|
||||
}) satisfies ProblemListItem,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true), sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`)).limit(1)
|
||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const tags = await contestProblemTags([row.problem.id])
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(c, {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: allowed ? row.problem.difficulty : null,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
||||
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
||||
contestId: contest.id,
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
myStatus: myStatusOf(statuses, row.problem.id),
|
||||
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
|
||||
myFailedCount: 0,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail)
|
||||
})
|
||||
contestRoutes.get(
|
||||
"/contests/:id/problems/:displayId",
|
||||
optionalAuth,
|
||||
requireContestAccess("problems"),
|
||||
async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const [row] = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.contestId, contest.id),
|
||||
eq(schema.problem.visible, true),
|
||||
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!row)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const tags = await contestProblemTags([row.problem.id])
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
return success(c, {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: allowed ? row.problem.difficulty : null,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
||||
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
||||
contestId: contest.id,
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
myStatus: myStatusOf(statuses, row.problem.id),
|
||||
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
|
||||
myFailedCount: 0,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail)
|
||||
},
|
||||
)
|
||||
|
||||
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false))
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where),
|
||||
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
|
||||
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
|
||||
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, {
|
||||
results: rows.map(({ rank, user, realName }) => ({
|
||||
id: rank.id,
|
||||
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
||||
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
||||
user: sampleUser(user, realName, { includeRealName: admin }),
|
||||
submissionNumber: rank.submissionNumber,
|
||||
acceptedNumber: rank.acceptedNumber,
|
||||
totalTime: rank.totalTime,
|
||||
submissionInfo: rank.submissionInfo,
|
||||
contestId: rank.contestId,
|
||||
} satisfies ContestRankItem)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies ContestRank)
|
||||
})
|
||||
contestRoutes.get(
|
||||
"/contests/:id/rank",
|
||||
optionalAuth,
|
||||
requireContestAccess("ranks"),
|
||||
async (c) => {
|
||||
const contest = c.get("contest")!
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const where = and(
|
||||
eq(schema.acmContestRank.contestId, contest.id),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
)
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.acmContestRank)
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.acmContestRank.userId, schema.user.id),
|
||||
)
|
||||
.where(where),
|
||||
db
|
||||
.select({
|
||||
rank: schema.acmContestRank,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.acmContestRank)
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.acmContestRank.userId, schema.user.id),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.where(where)
|
||||
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
|
||||
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
|
||||
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
|
||||
.orderBy(
|
||||
desc(schema.acmContestRank.acceptedNumber),
|
||||
asc(schema.acmContestRank.totalTime),
|
||||
asc(schema.acmContestRank.id),
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, {
|
||||
results: rows.map(
|
||||
({ rank, user, realName }) =>
|
||||
({
|
||||
id: rank.id,
|
||||
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
||||
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
||||
user: sampleUser(user, realName, { includeRealName: admin }),
|
||||
submissionNumber: rank.submissionNumber,
|
||||
acceptedNumber: rank.acceptedNumber,
|
||||
totalTime: rank.totalTime,
|
||||
submissionInfo: rank.submissionInfo,
|
||||
contestId: rank.contestId,
|
||||
}) satisfies ContestRankItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies ContestRank)
|
||||
},
|
||||
)
|
||||
+364
-157
@@ -10,7 +10,17 @@ import {
|
||||
type FlowchartStatistics,
|
||||
type FlowchartSubmission,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
@@ -38,8 +48,16 @@ function flowchartThrottleKey(userId: number) {
|
||||
return `flowchart:${userId}`
|
||||
}
|
||||
|
||||
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
|
||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
||||
function canView(
|
||||
user: import("../auth/session").AuthUser,
|
||||
row: { userId: number },
|
||||
problem: { createdById: number },
|
||||
) {
|
||||
return (
|
||||
row.userId === user.id ||
|
||||
isAdminRole(user) ||
|
||||
problem.createdById === user.id
|
||||
)
|
||||
}
|
||||
|
||||
function flowchartData(
|
||||
@@ -67,20 +85,48 @@ function flowchartData(
|
||||
}
|
||||
|
||||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
const parsed = createFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) {
|
||||
return failure(c, 400, "invalid-request", parsed.error?.issues[0]?.message ?? "Flowchart data is too large")
|
||||
const parsed = createFlowchartRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (
|
||||
!parsed.success ||
|
||||
JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024
|
||||
) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error?.issues[0]?.message ?? "Flowchart data is too large",
|
||||
)
|
||||
}
|
||||
const [problem] = await db.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart }).from(schema.problem)
|
||||
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission")
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart })
|
||||
.from(schema.problem)
|
||||
.where(eq(schema.problem.id, parsed.data.problemId))
|
||||
.limit(1)
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!problem.allow)
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"flowchart-not-allowed",
|
||||
"This problem does not allow flowchart submission",
|
||||
)
|
||||
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
|
||||
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
|
||||
// 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。
|
||||
const throttle = await consumeToken("user", flowchartThrottleKey(c.get("user")!.id))
|
||||
const throttle = await consumeToken(
|
||||
"user",
|
||||
flowchartThrottleKey(c.get("user")!.id),
|
||||
)
|
||||
if (!throttle.allowed) {
|
||||
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
|
||||
return failure(
|
||||
c,
|
||||
429,
|
||||
"too-many-submissions",
|
||||
`Please wait ${Math.floor(throttle.wait)} seconds`,
|
||||
)
|
||||
}
|
||||
const id = randomBytes(16).toString("hex")
|
||||
await db.insert(schema.flowchartSubmission).values({
|
||||
@@ -104,10 +150,22 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
try {
|
||||
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
||||
} catch (error) {
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
.set({ status: 3 })
|
||||
.where(eq(schema.flowchartSubmission.id, id))
|
||||
return failure(
|
||||
c,
|
||||
502,
|
||||
"queue-unavailable",
|
||||
"Evaluation queue is unavailable",
|
||||
)
|
||||
}
|
||||
return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201)
|
||||
return success(
|
||||
c,
|
||||
{ submissionId: id, status: "pending" } satisfies CreateFlowchartResponse,
|
||||
201,
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -128,20 +186,27 @@ async function flowchartProblemFilter(displayId: string) {
|
||||
const problems = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||||
isNull(schema.problem.contestId),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
|
||||
// 比赛题的 _id 撞号是常态,不该被筛进来
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
return problems.length
|
||||
? inArray(schema.flowchartSubmission.problemId, problems.map((row) => row.id))
|
||||
? inArray(
|
||||
schema.flowchartSubmission.problemId,
|
||||
problems.map((row) => row.id),
|
||||
)
|
||||
: sql`false`
|
||||
}
|
||||
|
||||
async function flowchartUserFilter(username: string) {
|
||||
const ids = (await matchedUsers(username)).map((row) => row.id)
|
||||
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`
|
||||
return ids.length
|
||||
? inArray(schema.flowchartSubmission.userId, ids)
|
||||
: sql`false`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,46 +252,69 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
|
||||
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
|
||||
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
|
||||
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
|
||||
if (
|
||||
!(await getBooleanOption("submission_list_show_all", true)) &&
|
||||
!isAdminRole(user)
|
||||
) {
|
||||
return success(c, { results: [], total: 0 } satisfies FlowchartList)
|
||||
}
|
||||
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
|
||||
const onlyMyself = c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")
|
||||
const onlyMyself =
|
||||
c.req.query("myself") === "1" ||
|
||||
(!username && user.adminType === "Regular User")
|
||||
const filters: Array<SQL | undefined> = []
|
||||
filters.push(...await Promise.all([
|
||||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||||
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
||||
]))
|
||||
filters.push(
|
||||
...(await Promise.all([
|
||||
displayId ? flowchartProblemFilter(displayId) : undefined,
|
||||
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
|
||||
])),
|
||||
)
|
||||
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||
if (c.req.query("today") === "1") filters.push(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
|
||||
if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
|
||||
if (c.req.query("today") === "1")
|
||||
filters.push(
|
||||
sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`,
|
||||
)
|
||||
if (["S", "A", "B", "C"].includes(grade ?? ""))
|
||||
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
|
||||
const where = and(...filters)
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
|
||||
db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
|
||||
db.select(flowchartListColumns)
|
||||
db
|
||||
.select(flowchartListColumns)
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.flowchartSubmission.userId, schema.user.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map(({ flowchart, username, problem }) => ({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problem: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
status: flowchart.status,
|
||||
createTime: flowchart.createTime,
|
||||
aiScore: flowchart.aiScore,
|
||||
aiGrade: flowchart.aiGrade,
|
||||
aiProvider: flowchart.aiProvider,
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
} satisfies FlowchartListItem)),
|
||||
results: rows.map(
|
||||
({ flowchart, username, problem }) =>
|
||||
({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problem: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
status: flowchart.status,
|
||||
createTime: flowchart.createTime,
|
||||
aiScore: flowchart.aiScore,
|
||||
aiGrade: flowchart.aiGrade,
|
||||
aiProvider: flowchart.aiProvider,
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
}) satisfies FlowchartListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies FlowchartList)
|
||||
})
|
||||
@@ -262,20 +350,24 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
||||
sql`${schema.flowchartSubmission.createTime} <= ${end}`,
|
||||
]
|
||||
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
|
||||
if (start)
|
||||
filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
|
||||
|
||||
const displayId = c.req.query("problemId")?.trim()
|
||||
if (displayId) {
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
|
||||
}
|
||||
|
||||
@@ -286,13 +378,17 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
if (username) {
|
||||
const ids = matched.map((row) => row.id)
|
||||
// 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」
|
||||
filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`)
|
||||
filters.push(
|
||||
ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`,
|
||||
)
|
||||
}
|
||||
const where = and(...filters)
|
||||
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
|
||||
// 未禁用的普通用户才进分母,教师和管理员不算
|
||||
const roster = username
|
||||
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
|
||||
? matched.filter(
|
||||
(row) => !row.isDisabled && row.adminType === "Regular User",
|
||||
)
|
||||
: []
|
||||
|
||||
/**
|
||||
@@ -304,46 +400,56 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
|
||||
* 点击 18MB,而老师是开着面板反复切时段、切班的。
|
||||
*/
|
||||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
/**
|
||||
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
|
||||
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
|
||||
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
|
||||
*/
|
||||
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(Number),
|
||||
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(Number),
|
||||
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
|
||||
// 按 user_id 就不必 join user
|
||||
completedCount: sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number),
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where),
|
||||
db
|
||||
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.groupBy(schema.flowchartSubmission.aiGrade),
|
||||
/**
|
||||
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
|
||||
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
|
||||
* `typeof detail.score !== "number"` 的 continue 一致。
|
||||
*
|
||||
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
|
||||
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
|
||||
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
|
||||
*
|
||||
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
|
||||
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
|
||||
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
|
||||
*
|
||||
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
|
||||
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
|
||||
* 实测 254ms → 842ms,而满分本来就是几个常数。
|
||||
*/
|
||||
db.execute<{ key: string; avg: number }>(sql`
|
||||
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
/**
|
||||
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
|
||||
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
|
||||
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
|
||||
*/
|
||||
scoreSum:
|
||||
sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
scoreCount:
|
||||
sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
|
||||
Number,
|
||||
),
|
||||
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
|
||||
// 按 user_id 就不必 join user
|
||||
completedCount:
|
||||
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where),
|
||||
db
|
||||
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.groupBy(schema.flowchartSubmission.aiGrade),
|
||||
/**
|
||||
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
|
||||
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
|
||||
* `typeof detail.score !== "number"` 的 continue 一致。
|
||||
*
|
||||
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
|
||||
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
|
||||
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
|
||||
*
|
||||
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
|
||||
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
|
||||
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
|
||||
*
|
||||
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
|
||||
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
|
||||
* 实测 254ms → 842ms,而满分本来就是几个常数。
|
||||
*/
|
||||
db.execute<{ key: string; avg: number }>(sql`
|
||||
select e.key as key, avg((e.value->>'score')::double precision) as avg
|
||||
from ${schema.flowchartSubmission}
|
||||
cross join lateral jsonb_each(
|
||||
@@ -354,25 +460,25 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
where ${where} and jsonb_typeof(e.value->'score') = 'number'
|
||||
group by e.key
|
||||
`),
|
||||
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
|
||||
db
|
||||
.select({
|
||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||
feedback: schema.flowchartSubmission.aiFeedback,
|
||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(WORDCLOUD_TEXT_LIMIT),
|
||||
// 「谁没做」只在有花名册时算得出来,行数也就一个班
|
||||
roster.length
|
||||
? db
|
||||
.selectDistinct({ userId: schema.flowchartSubmission.userId })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
: [],
|
||||
])
|
||||
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
|
||||
db
|
||||
.select({
|
||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||
feedback: schema.flowchartSubmission.aiFeedback,
|
||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
.limit(WORDCLOUD_TEXT_LIMIT),
|
||||
// 「谁没做」只在有花名册时算得出来,行数也就一个班
|
||||
roster.length
|
||||
? db
|
||||
.selectDistinct({ userId: schema.flowchartSubmission.userId })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(where)
|
||||
: [],
|
||||
])
|
||||
|
||||
if (!totals || totals.total === 0) {
|
||||
return success(c, {
|
||||
@@ -423,7 +529,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
if (!criteriaMax.has(key)) {
|
||||
criteriaMax.set(key, typeof detail.max === "number" ? detail.max : 100)
|
||||
}
|
||||
if (typeof detail.comment === "string" && detail.comment) pushText(detail.comment)
|
||||
if (typeof detail.comment === "string" && detail.comment)
|
||||
pushText(detail.comment)
|
||||
}
|
||||
if (row.feedback) pushText(row.feedback)
|
||||
if (row.suggestions) pushText(row.suggestions)
|
||||
@@ -431,13 +538,18 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
|
||||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||||
for (const row of criteriaRows) {
|
||||
criteriaAverages[row.key] = { avg: rounded(row.avg, 1), max: criteriaMax.get(row.key) ?? 100 }
|
||||
criteriaAverages[row.key] = {
|
||||
avg: rounded(row.avg, 1),
|
||||
max: criteriaMax.get(row.key) ?? 100,
|
||||
}
|
||||
}
|
||||
|
||||
const submitted = new Set(submittedRows.map((row) => row.userId))
|
||||
return success(c, {
|
||||
totalCount: totals.total,
|
||||
avgScore: totals.scoreCount ? rounded(totals.scoreSum / totals.scoreCount, 1) : 0,
|
||||
avgScore: totals.scoreCount
|
||||
? rounded(totals.scoreSum / totals.scoreCount, 1)
|
||||
: 0,
|
||||
gradeDistribution,
|
||||
criteriaAverages,
|
||||
personCount: roster.length,
|
||||
@@ -453,33 +565,74 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
const [row] = await db
|
||||
.select({
|
||||
flowchart: schema.flowchartSubmission,
|
||||
username: schema.user.username,
|
||||
problem: schema.problem,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.flowchartSubmission.userId, schema.user.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||
.limit(1)
|
||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem))
|
||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
return success(c, flowchartData(row.flowchart, row.username))
|
||||
})
|
||||
|
||||
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
||||
if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
||||
const [row] = await db
|
||||
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||
.limit(1)
|
||||
if (!row || !canView(user, row.flowchart, row.problem))
|
||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
if (![2, 3].includes(row.flowchart.status))
|
||||
return failure(
|
||||
c,
|
||||
409,
|
||||
"retry-not-allowed",
|
||||
"Submission is not in a state that allows retry",
|
||||
)
|
||||
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
|
||||
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
||||
if (!isAdminRole(user)) {
|
||||
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
||||
if (!throttle.allowed) {
|
||||
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
|
||||
return failure(
|
||||
c,
|
||||
429,
|
||||
"too-many-submissions",
|
||||
`Please wait ${Math.floor(throttle.wait)} seconds`,
|
||||
)
|
||||
}
|
||||
}
|
||||
await db.update(schema.flowchartSubmission).set({
|
||||
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
|
||||
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
|
||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
.set({
|
||||
status: 0,
|
||||
aiScore: null,
|
||||
aiGrade: null,
|
||||
aiFeedback: null,
|
||||
aiSuggestions: null,
|
||||
aiCriteriaDetails: {},
|
||||
processingTime: null,
|
||||
evaluationTime: null,
|
||||
})
|
||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
try {
|
||||
// jobId 必须**正好三段**:bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
|
||||
// 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛
|
||||
@@ -493,28 +646,82 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
)
|
||||
} catch (error) {
|
||||
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
.set({ status: 3 })
|
||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
return failure(
|
||||
c,
|
||||
502,
|
||||
"queue-unavailable",
|
||||
"Evaluation queue is unavailable",
|
||||
)
|
||||
}
|
||||
return success(c, { submissionId: row.flowchart.id, status: "pending" } satisfies CreateFlowchartResponse)
|
||||
return success(c, {
|
||||
submissionId: row.flowchart.id,
|
||||
status: "pending",
|
||||
} satisfies CreateFlowchartResponse)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
|
||||
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent)
|
||||
})
|
||||
flowchartRoutes.get(
|
||||
"/problems/:id/flowchart/current",
|
||||
requireAuth,
|
||||
async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const rows = await db
|
||||
.select({
|
||||
score: schema.flowchartSubmission.aiScore,
|
||||
grade: schema.flowchartSubmission.aiGrade,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
|
||||
eq(schema.flowchartSubmission.problemId, problemId),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
return success(c, {
|
||||
count: rows.length,
|
||||
score: rows[0]?.score ?? 0,
|
||||
grade: rows[0]?.grade ?? "",
|
||||
} satisfies FlowchartCurrent)
|
||||
},
|
||||
)
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const rows = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime))
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail)
|
||||
})
|
||||
flowchartRoutes.get(
|
||||
"/problems/:id/flowchart/history",
|
||||
requireAuth,
|
||||
async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const rows = await db
|
||||
.select({
|
||||
flowchart: schema.flowchartSubmission,
|
||||
username: schema.user.username,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.flowchartSubmission.userId, schema.user.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
|
||||
eq(schema.flowchartSubmission.problemId, problemId),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime))
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length)
|
||||
return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
return success(c, {
|
||||
submission: selected
|
||||
? flowchartData(selected.flowchart, selected.username)
|
||||
: null,
|
||||
count: rows.length,
|
||||
} satisfies FlowchartDetail)
|
||||
},
|
||||
)
|
||||
@@ -51,7 +51,9 @@ export function stripClassPrefix(
|
||||
* 和列没收窄之前的行为完全一致 —— 所以这里只做类型上的交接,**不加校验**:
|
||||
* 在这儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」,那是另一种行为。
|
||||
*/
|
||||
export function asFilterValue<T extends string | number>(value: string | number): T {
|
||||
export function asFilterValue<T extends string | number>(
|
||||
value: string | number,
|
||||
): T {
|
||||
return value as T
|
||||
}
|
||||
|
||||
@@ -115,7 +117,10 @@ export function rounded(value: number, digits = 2) {
|
||||
* 等待评分 / 正在评分也算成失败,连点三次提交就能让按钮亮起来,而 hint 端点排掉了
|
||||
* 这两个状态,于是按钮亮着、点下去回 `hint-locked`。
|
||||
*/
|
||||
export async function countFailedSubmissions(userId: number, problemId: number) {
|
||||
export async function countFailedSubmissions(
|
||||
userId: number,
|
||||
problemId: number,
|
||||
) {
|
||||
const [failed] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
|
||||
+463
-258
@@ -1,18 +1,25 @@
|
||||
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract"
|
||||
import type {
|
||||
ProblemAuthor,
|
||||
ProblemDetail,
|
||||
ProblemList,
|
||||
ProblemListItem,
|
||||
Tag,
|
||||
YearlyAc,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
@@ -22,296 +29,494 @@ import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { localTime, shiftMonthsByCalendar, todayStart } from "../time"
|
||||
import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
||||
import {
|
||||
asFilterValue,
|
||||
countFailedSubmissions,
|
||||
objectValue as toObject,
|
||||
queryInteger,
|
||||
sampleUser,
|
||||
} from "./helpers"
|
||||
|
||||
export const problemRoutes = new Hono<AppEnv>()
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function publicTemplates(value: unknown) {
|
||||
const templates: Record<string, string> = {}
|
||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||
if (typeof raw !== "string") continue
|
||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||
templates[language] = match?.[1] ?? ""
|
||||
}
|
||||
return templates
|
||||
const templates: Record<string, string> = {}
|
||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||
if (typeof raw !== "string") continue
|
||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||
templates[language] = match?.[1] ?? ""
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
async function getProblemStatuses(userId: number | undefined) {
|
||||
if (!userId) return {}
|
||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
|
||||
return toObject(toObject(profile?.value).problems)
|
||||
if (!userId) return {}
|
||||
const [profile] = await db
|
||||
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, userId))
|
||||
.limit(1)
|
||||
return toObject(toObject(profile?.value).problems)
|
||||
}
|
||||
|
||||
async function getProblemTags(problemIds: number[]) {
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const result = new Map<number, string[]>()
|
||||
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
|
||||
return result
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const result = new Map<number, string[]>()
|
||||
for (const row of rows)
|
||||
result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
|
||||
return result
|
||||
}
|
||||
|
||||
function listItem(
|
||||
row: { problem: typeof schema.problem.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null },
|
||||
tags: Map<number, string[]>,
|
||||
statuses: Record<string, unknown>,
|
||||
row: {
|
||||
problem: typeof schema.problem.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
},
|
||||
tags: Map<number, string[]>,
|
||||
statuses: Record<string, unknown>,
|
||||
) {
|
||||
const status = toObject(statuses[String(row.problem.id)]).status
|
||||
return {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
difficulty: row.problem.difficulty,
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
contestId: row.problem.contestId,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
myStatus: typeof status === "number" ? status : null,
|
||||
} satisfies ProblemListItem
|
||||
const status = toObject(statuses[String(row.problem.id)]).status
|
||||
return {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
difficulty: row.problem.difficulty,
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
contestId: row.problem.contestId,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
myStatus: typeof status === "number" ? status : null,
|
||||
} satisfies ProblemListItem
|
||||
}
|
||||
|
||||
problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
|
||||
const author = c.req.query("author")?.trim()
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
if (author) filters.push(eq(schema.user.username, author))
|
||||
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
|
||||
if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
|
||||
if (tag) {
|
||||
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(eq(schema.problemTag.name, tag))))
|
||||
}
|
||||
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
]
|
||||
const author = c.req.query("author")?.trim()
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
if (author) filters.push(eq(schema.user.username, author))
|
||||
if (keyword)
|
||||
filters.push(
|
||||
or(
|
||||
ilike(schema.problem.title, `%${keyword}%`),
|
||||
ilike(schema.problem.displayId, `%${keyword}%`),
|
||||
)!,
|
||||
)
|
||||
if (difficulty)
|
||||
filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
|
||||
if (tag) {
|
||||
filters.push(
|
||||
inArray(
|
||||
schema.problem.id,
|
||||
db
|
||||
.select({ id: schema.problemTags.problemId })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTag.name, tag)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const where = and(...filters)
|
||||
const sort = c.req.query("sort")
|
||||
const order = sort === "flowchart"
|
||||
? [desc(schema.problem.allowFlowchart), desc(schema.problem.showFlowchart), desc(schema.problem.createTime)]
|
||||
: sort === "ast"
|
||||
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
|
||||
: sort === "-accepted_number"
|
||||
? [desc(schema.problem.acceptedNumber)]
|
||||
: sort === "accepted_number"
|
||||
? [asc(schema.problem.acceptedNumber)]
|
||||
: sort === "-submission_number"
|
||||
? [desc(schema.problem.submissionNumber)]
|
||||
: sort === "submission_number"
|
||||
? [asc(schema.problem.submissionNumber)]
|
||||
: sort === "difficulty"
|
||||
? [asc(schema.problem.difficulty)]
|
||||
: sort === "create_time"
|
||||
? [asc(schema.problem.createTime)]
|
||||
: [desc(schema.problem.createTime)]
|
||||
const [totalRow] = await db.select({ value: countDistinct(schema.problem.id) }).from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)).where(where)
|
||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where).orderBy(...order).limit(limit).offset(offset)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||
total: totalRow?.value ?? 0,
|
||||
} satisfies ProblemList)
|
||||
const where = and(...filters)
|
||||
const sort = c.req.query("sort")
|
||||
const order =
|
||||
sort === "flowchart"
|
||||
? [
|
||||
desc(schema.problem.allowFlowchart),
|
||||
desc(schema.problem.showFlowchart),
|
||||
desc(schema.problem.createTime),
|
||||
]
|
||||
: sort === "ast"
|
||||
? [
|
||||
desc(sql`(${schema.problem.astRules} is not null)`),
|
||||
desc(schema.problem.createTime),
|
||||
]
|
||||
: sort === "-accepted_number"
|
||||
? [desc(schema.problem.acceptedNumber)]
|
||||
: sort === "accepted_number"
|
||||
? [asc(schema.problem.acceptedNumber)]
|
||||
: sort === "-submission_number"
|
||||
? [desc(schema.problem.submissionNumber)]
|
||||
: sort === "submission_number"
|
||||
? [asc(schema.problem.submissionNumber)]
|
||||
: sort === "difficulty"
|
||||
? [asc(schema.problem.difficulty)]
|
||||
: sort === "create_time"
|
||||
? [asc(schema.problem.createTime)]
|
||||
: [desc(schema.problem.createTime)]
|
||||
const [totalRow] = await db
|
||||
.select({ value: countDistinct(schema.problem.id) })
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(where)
|
||||
const rows = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||
total: totalRow?.value ?? 0,
|
||||
} satisfies ProblemList)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-tags", async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
||||
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
|
||||
const rows = await db.select({ id: schema.problemTag.id, name: schema.problemTag.name, problemCount: countDistinct(schema.problemTags.problemId) })
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.innerJoin(schema.problem, and(
|
||||
eq(schema.problem.id, schema.problemTags.problemId),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
))
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
.orderBy(asc(schema.problemTag.name))
|
||||
return success(c, rows satisfies Tag[])
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
||||
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problemTag.id,
|
||||
name: schema.problemTag.name,
|
||||
problemCount: countDistinct(schema.problemTags.problemId),
|
||||
})
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(
|
||||
schema.problemTags,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
and(
|
||||
eq(schema.problem.id, schema.problemTags.problemId),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
.orderBy(asc(schema.problemTag.name))
|
||||
return success(c, rows satisfies Tag[])
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/random", async (c) => {
|
||||
const [row] = await db.select({ displayId: schema.problem.displayId }).from(schema.problem)
|
||||
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))).orderBy(sql`random()`).limit(1)
|
||||
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
|
||||
return success(c, row.displayId)
|
||||
const [row] = await db
|
||||
.select({ displayId: schema.problem.displayId })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)),
|
||||
)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(1)
|
||||
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
|
||||
return success(c, row.displayId)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-authors", async (c) => {
|
||||
const showAll = c.req.query("all") === "1"
|
||||
const rows = await db.select({ username: schema.user.username, problemCount: count(schema.problem.id) })
|
||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true)))
|
||||
.groupBy(schema.user.username).orderBy(desc(count(schema.problem.id)))
|
||||
return success(c, rows satisfies ProblemAuthor[])
|
||||
const showAll = c.req.query("all") === "1"
|
||||
const rows = await db
|
||||
.select({
|
||||
username: schema.user.username,
|
||||
problemCount: count(schema.problem.id),
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.user.isDisabled, false),
|
||||
showAll ? undefined : eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.username)
|
||||
.orderBy(desc(count(schema.problem.id)))
|
||||
return success(c, rows satisfies ProblemAuthor[])
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, "0")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [mine] = await db.select({ value: count() }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, id),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
))
|
||||
if (!mine?.value) return success(c, "0")
|
||||
// 「近两年」按东八区日历算到当天零点
|
||||
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||
const [active, accepted] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since))),
|
||||
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and(
|
||||
eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since),
|
||||
)),
|
||||
])
|
||||
const total = active[0]?.value ?? 0
|
||||
const solved = accepted[0]?.value ?? 0
|
||||
return success(c, total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0")
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, "0")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [mine] = await db
|
||||
.select({ value: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
eq(schema.submission.problemId, id),
|
||||
inArray(schema.submission.result, [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]),
|
||||
),
|
||||
)
|
||||
if (!mine?.value) return success(c, "0")
|
||||
// 「近两年」按东八区日历算到当天零点
|
||||
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||
const [active, accepted] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.isDisabled, false),
|
||||
gte(schema.user.lastLogin, since),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ value: countDistinct(schema.submission.userId) })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.problemId, id),
|
||||
inArray(schema.submission.result, [0, 10]),
|
||||
gte(schema.submission.createTime, since),
|
||||
),
|
||||
),
|
||||
])
|
||||
const total = active[0]?.value ?? 0
|
||||
const solved = accepted[0]?.value ?? 0
|
||||
return success(
|
||||
c,
|
||||
total > 0 && solved < total
|
||||
? (((total - solved) / total) * 100).toFixed(2)
|
||||
: "0",
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||
const [target] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId))).limit(1)
|
||||
if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
|
||||
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id))
|
||||
if (targetTags.length === 0) return success(c, [])
|
||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(
|
||||
eq(schema.problem.visible, true), isNull(schema.problem.contestId), sql`${schema.problem.id} <> ${target.id}`,
|
||||
inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
|
||||
.where(inArray(schema.problemTags.problemtagId, targetTags.map((tag) => tag.id)))),
|
||||
)).groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName).orderBy(asc(schema.problem.difficulty)).limit(5)
|
||||
const [tags, statuses] = await Promise.all([getProblemTags(rows.map((row) => row.problem.id)), getProblemStatuses(c.get("user")?.id)])
|
||||
const filtered = rows.filter((row) => toObject(statuses[String(row.problem.id)]).status !== JudgeStatus.ACCEPTED)
|
||||
return success(c, filtered.map((row) => listItem(row, tags, statuses)))
|
||||
const [target] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
|
||||
const targetTags = await db
|
||||
.select({ id: schema.problemTags.problemtagId })
|
||||
.from(schema.problemTags)
|
||||
.where(eq(schema.problemTags.problemId, target.id))
|
||||
if (targetTags.length === 0) return success(c, [])
|
||||
const rows = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
sql`${schema.problem.id} <> ${target.id}`,
|
||||
inArray(
|
||||
schema.problem.id,
|
||||
db
|
||||
.select({ id: schema.problemTags.problemId })
|
||||
.from(schema.problemTags)
|
||||
.where(
|
||||
inArray(
|
||||
schema.problemTags.problemtagId,
|
||||
targetTags.map((tag) => tag.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
|
||||
.orderBy(asc(schema.problem.difficulty))
|
||||
.limit(5)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
])
|
||||
const filtered = rows.filter(
|
||||
(row) =>
|
||||
toObject(statuses[String(row.problem.id)]).status !==
|
||||
JudgeStatus.ACCEPTED,
|
||||
)
|
||||
return success(
|
||||
c,
|
||||
filtered.map((row) => listItem(row, tags, statuses)),
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
|
||||
const rows = await db.select({
|
||||
year,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
|
||||
}).from(schema.submission).where(and(eq(schema.submission.problemId, problem.id), isNull(schema.submission.contestId), notInArray(schema.submission.result, [6, 7])))
|
||||
.groupBy(year).orderBy(year)
|
||||
return success(c, rows.map((row) => ({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 } satisfies YearlyAc)))
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||
isNull(schema.problem.contestId),
|
||||
eq(schema.problem.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
|
||||
const rows = await db
|
||||
.select({
|
||||
year,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.problemId, problem.id),
|
||||
isNull(schema.submission.contestId),
|
||||
notInArray(schema.submission.result, [6, 7]),
|
||||
),
|
||||
)
|
||||
.groupBy(year)
|
||||
.orderBy(year)
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
...row,
|
||||
acRate:
|
||||
row.total > 0
|
||||
? Math.round((row.accepted / row.total) * 10_000) / 100
|
||||
: 0,
|
||||
}) satisfies YearlyAc,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
creatorId: schema.user.id,
|
||||
creatorUsername: schema.user.username,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.displayId, c.req.param("displayId")),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
const [row] = await db
|
||||
.select({
|
||||
problem: schema.problem,
|
||||
creatorId: schema.user.id,
|
||||
creatorUsername: schema.user.username,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.displayId, c.req.param("displayId")),
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!row)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
|
||||
const tagRows = await db
|
||||
.select({ name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTags.problemId, row.problem.id))
|
||||
const tagRows = await db
|
||||
.select({ name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTags.problemId, row.problem.id))
|
||||
|
||||
const user = c.get("user")
|
||||
let myStatus: number | null = null
|
||||
let myFailedCount = 0
|
||||
if (user) {
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const statuses = objectValue(objectValue(profile?.status).problems)
|
||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||
const user = c.get("user")
|
||||
let myStatus: number | null = null
|
||||
let myFailedCount = 0
|
||||
if (user) {
|
||||
const [profile] = await db
|
||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id))
|
||||
.limit(1)
|
||||
const statuses = objectValue(objectValue(profile?.status).problems)
|
||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||
|
||||
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
|
||||
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
|
||||
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
|
||||
}
|
||||
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
|
||||
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
|
||||
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
|
||||
}
|
||||
|
||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||
const data = {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples,
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: row.problem.difficulty,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
statisticInfo: objectValue(row.problem.statisticInfo),
|
||||
contestId: row.problem.contestId,
|
||||
tags: tagRows.map((tag) => tag.name),
|
||||
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
|
||||
myStatus,
|
||||
myFailedCount,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail
|
||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||
const data = {
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples,
|
||||
hint: row.problem.hint,
|
||||
languages: row.problem.languages,
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: row.problem.difficulty,
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
statisticInfo: objectValue(row.problem.statisticInfo),
|
||||
contestId: row.problem.contestId,
|
||||
tags: tagRows.map((tag) => tag.name),
|
||||
createdBy: sampleUser(
|
||||
{ id: row.creatorId, username: row.creatorUsername },
|
||||
null,
|
||||
),
|
||||
myStatus,
|
||||
myFailedCount,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart
|
||||
? null
|
||||
: objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig,
|
||||
sqlDisplay: row.problem.sqlDisplay,
|
||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||
astRequirements: astRequirements(row.problem.astRules),
|
||||
} satisfies ProblemDetail
|
||||
|
||||
return success(c, data)
|
||||
return success(c, data)
|
||||
})
|
||||
+438
-169
@@ -24,7 +24,12 @@ import {
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import {
|
||||
optionalAuth,
|
||||
requireAuth,
|
||||
requireTeacher,
|
||||
type AppEnv,
|
||||
} from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { computeProgress } from "../services/problemset"
|
||||
@@ -34,33 +39,46 @@ export const problemsetRoutes = new Hono<AppEnv>()
|
||||
|
||||
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
||||
|
||||
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
|
||||
return progress ? {
|
||||
isJoined: true,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedCount: progress.completedProblemsCount,
|
||||
totalCount: progress.totalProblemsCount,
|
||||
isCompleted: progress.isCompleted,
|
||||
} : {
|
||||
isJoined: false,
|
||||
progressPercentage: 0,
|
||||
completedCount: 0,
|
||||
totalCount: 0,
|
||||
isCompleted: false,
|
||||
}
|
||||
function progressSummary(
|
||||
progress: typeof schema.problemsetProgress.$inferSelect | undefined,
|
||||
) {
|
||||
return progress
|
||||
? {
|
||||
isJoined: true,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedCount: progress.completedProblemsCount,
|
||||
totalCount: progress.totalProblemsCount,
|
||||
isCompleted: progress.isCompleted,
|
||||
}
|
||||
: {
|
||||
isJoined: false,
|
||||
progressPercentage: 0,
|
||||
completedCount: 0,
|
||||
totalCount: 0,
|
||||
isCompleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function problemSetCreators(ids: number[]) {
|
||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||
if (ids.length === 0) return map
|
||||
const rows = await 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))
|
||||
const rows = await 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, ids))
|
||||
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
||||
return map
|
||||
}
|
||||
|
||||
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
|
||||
function badgeData(
|
||||
badge: typeof schema.problemsetBadge.$inferSelect,
|
||||
earned?: boolean,
|
||||
) {
|
||||
return {
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
@@ -86,26 +104,68 @@ async function serializeProblemSets(
|
||||
) {
|
||||
if (rows.length === 0) return []
|
||||
const ids = rows.map((row) => row.id)
|
||||
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([
|
||||
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
|
||||
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||
.groupBy(schema.problemsetProblem.problemsetId),
|
||||
userId ? db.select().from(schema.problemsetProgress)
|
||||
.where(and(inArray(schema.problemsetProgress.problemsetId, ids), eq(schema.problemsetProgress.userId, userId)))
|
||||
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]),
|
||||
includeBadges ? db.select().from(schema.problemsetBadge)
|
||||
.where(inArray(schema.problemsetBadge.problemsetId, ids)).orderBy(asc(schema.problemsetBadge.id))
|
||||
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
|
||||
includeBadges && userId ? db.select({ id: schema.userBadge.badgeId }).from(schema.userBadge)
|
||||
.innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
|
||||
.where(and(eq(schema.userBadge.userId, userId), inArray(schema.problemsetBadge.problemsetId, ids)))
|
||||
: Promise.resolve([] as { id: number }[]),
|
||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
||||
])
|
||||
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value]))
|
||||
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
|
||||
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
|
||||
for (const badge of badges) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
|
||||
const [problemCounts, progresses, badges, earnedRows, creators] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
problemsetId: schema.problemsetProblem.problemsetId,
|
||||
value: count(),
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||
.groupBy(schema.problemsetProblem.problemsetId),
|
||||
userId
|
||||
? db
|
||||
.select()
|
||||
.from(schema.problemsetProgress)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.problemsetProgress.problemsetId, ids),
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
),
|
||||
)
|
||||
: Promise.resolve(
|
||||
[] as (typeof schema.problemsetProgress.$inferSelect)[],
|
||||
),
|
||||
includeBadges
|
||||
? db
|
||||
.select()
|
||||
.from(schema.problemsetBadge)
|
||||
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
||||
.orderBy(asc(schema.problemsetBadge.id))
|
||||
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
|
||||
includeBadges && userId
|
||||
? db
|
||||
.select({ id: schema.userBadge.badgeId })
|
||||
.from(schema.userBadge)
|
||||
.innerJoin(
|
||||
schema.problemsetBadge,
|
||||
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userBadge.userId, userId),
|
||||
inArray(schema.problemsetBadge.problemsetId, ids),
|
||||
),
|
||||
)
|
||||
: Promise.resolve([] as { id: number }[]),
|
||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
||||
])
|
||||
const countBySet = new Map(
|
||||
problemCounts.map((item) => [item.problemsetId, item.value]),
|
||||
)
|
||||
const progressBySet = new Map(
|
||||
progresses.map((item) => [item.problemsetId, item]),
|
||||
)
|
||||
const badgesBySet = new Map<
|
||||
number,
|
||||
(typeof schema.problemsetBadge.$inferSelect)[]
|
||||
>()
|
||||
for (const badge of badges)
|
||||
badgesBySet.set(badge.problemsetId, [
|
||||
...(badgesBySet.get(badge.problemsetId) ?? []),
|
||||
badge,
|
||||
])
|
||||
const earned = new Set(earnedRows.map((item) => item.id))
|
||||
return rows.map((row) => {
|
||||
const progress = progressBySet.get(row.id)
|
||||
@@ -113,7 +173,9 @@ async function serializeProblemSets(
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
createdBy: creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
||||
createdBy:
|
||||
creators.get(row.createdById) ??
|
||||
sampleUser({ id: row.createdById, username: "" }, null),
|
||||
createTime: row.createTime,
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
difficulty: row.difficulty,
|
||||
@@ -123,7 +185,11 @@ async function serializeProblemSets(
|
||||
problemsCount: countBySet.get(row.id) ?? 0,
|
||||
completedCount: progress?.completedProblemsCount ?? 0,
|
||||
userProgress: progressSummary(progress),
|
||||
badges: includeBadges ? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id))) : undefined,
|
||||
badges: includeBadges
|
||||
? (badgesBySet.get(row.id) ?? []).map((badge) =>
|
||||
badgeData(badge, earned.has(badge.id)),
|
||||
)
|
||||
: undefined,
|
||||
} satisfies ProblemSet
|
||||
})
|
||||
}
|
||||
@@ -131,17 +197,33 @@ async function serializeProblemSets(
|
||||
problemsetRoutes.get("/problem-sets", optionalAuth, 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 filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")]
|
||||
const filters = [
|
||||
eq(schema.problemset.visible, true),
|
||||
ne(schema.problemset.status, "draft"),
|
||||
]
|
||||
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}%`))!)
|
||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
|
||||
if (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 (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
|
||||
const where = and(...filters)
|
||||
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 serializeProblemSets(rows, c.get("user")?.id, true),
|
||||
@@ -151,8 +233,17 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select().from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.problemset)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemset.id, id),
|
||||
eq(schema.problemset.visible, true),
|
||||
ne(schema.problemset.status, "draft"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
||||
return success(c, data)
|
||||
@@ -160,8 +251,17 @@ problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
const [problemSet] = await db
|
||||
.select({ id: schema.problemset.id })
|
||||
.from(schema.problemset)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemset.id, id),
|
||||
eq(schema.problemset.visible, true),
|
||||
ne(schema.problemset.status, "draft"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
|
||||
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
|
||||
@@ -169,32 +269,53 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
|
||||
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
|
||||
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
|
||||
const rows = await db.select({
|
||||
link: schema.problemsetProblem,
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
difficulty: schema.problem.difficulty,
|
||||
})
|
||||
const rows = await db
|
||||
.select({
|
||||
link: schema.problemsetProblem,
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
difficulty: schema.problem.difficulty,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.problemsetProblem.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||
.orderBy(
|
||||
asc(schema.problemsetProblem.order),
|
||||
asc(schema.problemsetProblem.id),
|
||||
)
|
||||
const progressRows = c.get("user")
|
||||
? await db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1)
|
||||
? await db
|
||||
.select({ detail: schema.problemsetProgress.progressDetail })
|
||||
.from(schema.problemsetProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProgress.problemsetId, id),
|
||||
eq(schema.problemsetProgress.userId, c.get("user")!.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
: []
|
||||
const completed = objectValue(progressRows[0]?.detail)
|
||||
return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => ({
|
||||
id: link.id,
|
||||
problemsetId: link.problemsetId,
|
||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||
order: link.order,
|
||||
isRequired: link.isRequired,
|
||||
score: link.score,
|
||||
hint: link.hint,
|
||||
isCompleted: String(problemId) in completed,
|
||||
} satisfies ProblemSetProblem)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ link, problemId, displayId, title, difficulty }) =>
|
||||
({
|
||||
id: link.id,
|
||||
problemsetId: link.problemsetId,
|
||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||
order: link.order,
|
||||
isRequired: link.isRequired,
|
||||
score: link.score,
|
||||
hint: link.hint,
|
||||
isCompleted: String(problemId) in completed,
|
||||
}) satisfies ProblemSetProblem,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
async function recomputeProgress(
|
||||
@@ -202,41 +323,70 @@ async function recomputeProgress(
|
||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
const links = await tx.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
const links = await tx
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
||||
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
||||
const update = computeProgress(detail, links, progress.completeTime)
|
||||
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id))
|
||||
await tx
|
||||
.update(schema.problemsetProgress)
|
||||
.set(update)
|
||||
.where(eq(schema.problemsetProgress.id, progress.id))
|
||||
return { ...progress, ...update }
|
||||
}
|
||||
|
||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||
const parsed = joinProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||
const parsed = joinProblemSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||
const user = c.get("user")!
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
const [problemSet] = await db
|
||||
.select({ id: schema.problemset.id })
|
||||
.from(schema.problemset)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemset.id, parsed.data.problemSetId),
|
||||
eq(schema.problemset.visible, true),
|
||||
ne(schema.problemset.status, "draft"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const [existing] = await db.select({ id: schema.problemsetProgress.id }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))).limit(1)
|
||||
const [existing] = await db
|
||||
.select({ id: schema.problemsetProgress.id })
|
||||
.from(schema.problemsetProgress)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemsetProgress.problemsetId, problemSet.id),
|
||||
eq(schema.problemsetProgress.userId, user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
||||
await db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(schema.problemsetProgress).values({
|
||||
problemsetId: problemSet.id,
|
||||
userId: user.id,
|
||||
joinTime: new Date().toISOString(),
|
||||
completeTime: null,
|
||||
isCompleted: false,
|
||||
progressPercentage: 0,
|
||||
completedProblemsCount: 0,
|
||||
totalProblemsCount: 0,
|
||||
totalScore: 0,
|
||||
progressDetail: {},
|
||||
}).returning()
|
||||
const [created] = await tx
|
||||
.insert(schema.problemsetProgress)
|
||||
.values({
|
||||
problemsetId: problemSet.id,
|
||||
userId: user.id,
|
||||
joinTime: new Date().toISOString(),
|
||||
completeTime: null,
|
||||
isCompleted: false,
|
||||
progressPercentage: 0,
|
||||
completedProblemsCount: 0,
|
||||
totalProblemsCount: 0,
|
||||
totalScore: 0,
|
||||
progressDetail: {},
|
||||
})
|
||||
.returning()
|
||||
if (created) await recomputeProgress(tx, created, {})
|
||||
})
|
||||
return success(c, null, 201)
|
||||
@@ -245,87 +395,206 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
|
||||
const requested = c.req.param("username")
|
||||
const username = requested === "me" ? c.get("user")?.username : requested
|
||||
if (!username) return failure(c, 401, "login-required", "Authentication required")
|
||||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
||||
.where(and(eq(schema.user.username, username), eq(schema.user.isDisabled, false))).limit(1)
|
||||
if (!username)
|
||||
return failure(c, 401, "login-required", "Authentication required")
|
||||
const [target] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.username, username),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const rows = await db.select({ userBadge: schema.userBadge, badge: schema.problemsetBadge, problemSet: schema.problemset })
|
||||
.from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
|
||||
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id))
|
||||
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime))
|
||||
return success(c, rows.map(({ userBadge, badge, problemSet }) => ({
|
||||
id: userBadge.id,
|
||||
userId: userBadge.userId,
|
||||
badge: badgeData(badge),
|
||||
earnedTime: userBadge.earnedTime,
|
||||
problemset: { id: problemSet.id, title: problemSet.title },
|
||||
} satisfies UserBadge)))
|
||||
const rows = await db
|
||||
.select({
|
||||
userBadge: schema.userBadge,
|
||||
badge: schema.problemsetBadge,
|
||||
problemSet: schema.problemset,
|
||||
})
|
||||
.from(schema.userBadge)
|
||||
.innerJoin(
|
||||
schema.problemsetBadge,
|
||||
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problemset,
|
||||
eq(schema.problemsetBadge.problemsetId, schema.problemset.id),
|
||||
)
|
||||
.where(eq(schema.userBadge.userId, target.id))
|
||||
.orderBy(desc(schema.userBadge.earnedTime))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
({ userBadge, badge, problemSet }) =>
|
||||
({
|
||||
id: userBadge.id,
|
||||
userId: userBadge.userId,
|
||||
badge: badgeData(badge),
|
||||
earnedTime: userBadge.earnedTime,
|
||||
problemset: { id: problemSet.id, title: problemSet.title },
|
||||
}) satisfies UserBadge,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
const [problemSet] = await db
|
||||
.select({ id: schema.problemset.id })
|
||||
.from(schema.problemset)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemset.id, id),
|
||||
eq(schema.problemset.visible, true),
|
||||
ne(schema.problemset.status, "draft"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, id))
|
||||
return success(c, badges.map((badge) => badgeData(badge)))
|
||||
const badges = await db
|
||||
.select()
|
||||
.from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, id))
|
||||
return success(
|
||||
c,
|
||||
badges.map((badge) => badgeData(badge)),
|
||||
)
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id, createdById: schema.problemset.createdById })
|
||||
.from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
|
||||
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||
// 越权报「不存在」,不泄露题单存在与否。
|
||||
const user = c.get("user")!
|
||||
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
|
||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
}
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const className = c.req.query("className")?.trim()
|
||||
const completion = c.req.query("completionStatus")?.trim()
|
||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
||||
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
||||
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
||||
else if (completion === "in_progress") filters.push(and(eq(schema.problemsetProgress.isCompleted, false), gt(schema.problemsetProgress.completedProblemsCount, 0))!)
|
||||
else if (completion === "not_started") filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
||||
const where = and(...filters)
|
||||
const [statsRows, rows, problemRows] = await Promise.all([
|
||||
db.select({ total: count(), completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`, avgProgress: avg(schema.problemsetProgress.progressPercentage) })
|
||||
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)).where(where),
|
||||
db.select({ progress: schema.problemsetProgress, user: schema.user, 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(where)
|
||||
.orderBy(desc(schema.problemsetProgress.isCompleted), desc(schema.problemsetProgress.progressPercentage), asc(schema.problemsetProgress.joinTime)).limit(limit).offset(offset),
|
||||
db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
||||
])
|
||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
||||
const results = rows.map(({ progress, user: progressUser, realName }) => ({
|
||||
id: progress.id,
|
||||
problemsetId: progress.problemsetId,
|
||||
user: sampleUser(progressUser, realName),
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
|
||||
} satisfies ProblemSetProgress))
|
||||
const stats = statsRows[0]
|
||||
return success(c, {
|
||||
results,
|
||||
total: stats?.total ?? 0,
|
||||
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
|
||||
problems: problemRows,
|
||||
} satisfies ProblemSetProgressList)
|
||||
})
|
||||
problemsetRoutes.get(
|
||||
"/problem-sets/:id/user-progress",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db
|
||||
.select({
|
||||
id: schema.problemset.id,
|
||||
createdById: schema.problemset.createdById,
|
||||
})
|
||||
.from(schema.problemset)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problemset.id, id),
|
||||
eq(schema.problemset.visible, true),
|
||||
ne(schema.problemset.status, "draft"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
|
||||
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||
// 越权报「不存在」,不泄露题单存在与否。
|
||||
const user = c.get("user")!
|
||||
if (
|
||||
!problemSet ||
|
||||
(user.adminType !== "Super Admin" && problemSet.createdById !== user.id)
|
||||
) {
|
||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
}
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const className = c.req.query("className")?.trim()
|
||||
const completion = c.req.query("completionStatus")?.trim()
|
||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
||||
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
||||
if (completion === "completed")
|
||||
filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
||||
else if (completion === "in_progress")
|
||||
filters.push(
|
||||
and(
|
||||
eq(schema.problemsetProgress.isCompleted, false),
|
||||
gt(schema.problemsetProgress.completedProblemsCount, 0),
|
||||
)!,
|
||||
)
|
||||
else if (completion === "not_started")
|
||||
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
||||
const where = and(...filters)
|
||||
const [statsRows, rows, problemRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
|
||||
avgProgress: avg(schema.problemsetProgress.progressPercentage),
|
||||
})
|
||||
.from(schema.problemsetProgress)
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.problemsetProgress.userId, schema.user.id),
|
||||
)
|
||||
.where(where),
|
||||
db
|
||||
.select({
|
||||
progress: schema.problemsetProgress,
|
||||
user: schema.user,
|
||||
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(where)
|
||||
.orderBy(
|
||||
desc(schema.problemsetProgress.isCompleted),
|
||||
desc(schema.problemsetProgress.progressPercentage),
|
||||
asc(schema.problemsetProgress.joinTime),
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
_id: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.problemsetProblem.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||
.orderBy(
|
||||
asc(schema.problemsetProblem.order),
|
||||
asc(schema.problemsetProblem.id),
|
||||
),
|
||||
])
|
||||
const problemMap = new Map(
|
||||
problemRows.map((problem) => [String(problem.id), problem]),
|
||||
)
|
||||
const results = rows.map(
|
||||
({ progress, user: progressUser, realName }) =>
|
||||
({
|
||||
id: progress.id,
|
||||
problemsetId: progress.problemsetId,
|
||||
user: sampleUser(progressUser, realName),
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
completedProblems: Object.keys(
|
||||
objectValue(progress.progressDetail),
|
||||
).flatMap((key) => problemMap.get(key) ?? []),
|
||||
}) satisfies ProblemSetProgress,
|
||||
)
|
||||
const stats = statsRows[0]
|
||||
return success(c, {
|
||||
results,
|
||||
total: stats?.total ?? 0,
|
||||
statistics: {
|
||||
total: stats?.total ?? 0,
|
||||
completed: stats?.completed ?? 0,
|
||||
avgProgress: Number(stats?.avgProgress ?? 0),
|
||||
},
|
||||
problems: problemRows,
|
||||
} satisfies ProblemSetProgressList)
|
||||
},
|
||||
)
|
||||
@@ -36,7 +36,10 @@ siteRoutes.get("/site/online", async (c) => {
|
||||
|
||||
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
|
||||
const fallbackQuotes = [
|
||||
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
||||
{
|
||||
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
|
||||
from: "Structure and Interpretation of Computer Programs",
|
||||
},
|
||||
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
|
||||
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
|
||||
]
|
||||
@@ -50,10 +53,15 @@ const sentenceCache = new Map<string, Quote[]>()
|
||||
async function loadSentences(path: string) {
|
||||
const cached = sentenceCache.get(path)
|
||||
if (cached) return cached
|
||||
const raw = await Bun.file(resolve(config.hitokotoDirectory, path)).json() as { hitokoto?: unknown, from?: unknown }[]
|
||||
const raw = (await Bun.file(
|
||||
resolve(config.hitokotoDirectory, path),
|
||||
).json()) as { hitokoto?: unknown; from?: unknown }[]
|
||||
const rows = (Array.isArray(raw) ? raw : [])
|
||||
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
|
||||
.map((it) => ({ hitokoto: it.hitokoto as string, from: typeof it.from === "string" ? it.from : "佚名" }))
|
||||
.map((it) => ({
|
||||
hitokoto: it.hitokoto as string,
|
||||
from: typeof it.from === "string" ? it.from : "佚名",
|
||||
}))
|
||||
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
|
||||
sentenceCache.set(path, rows)
|
||||
return rows
|
||||
@@ -61,8 +69,12 @@ async function loadSentences(path: string) {
|
||||
|
||||
async function randomQuote() {
|
||||
if (!categoryPaths) {
|
||||
const categories = await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json() as { path?: string }[]
|
||||
const paths = categories.map((it) => it.path).filter((it): it is string => typeof it === "string")
|
||||
const categories = (await Bun.file(
|
||||
resolve(config.hitokotoDirectory, "categories.json"),
|
||||
).json()) as { path?: string }[]
|
||||
const paths = categories
|
||||
.map((it) => it.path)
|
||||
.filter((it): it is string => typeof it === "string")
|
||||
if (paths.length === 0) throw new Error("no hitokoto categories")
|
||||
categoryPaths = paths
|
||||
}
|
||||
@@ -75,7 +87,8 @@ siteRoutes.get("/quotes/random", async (c) => {
|
||||
try {
|
||||
return success(c, (await randomQuote()) satisfies Quote)
|
||||
} catch {
|
||||
const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
|
||||
const item =
|
||||
fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
|
||||
return success(c, item satisfies Quote)
|
||||
}
|
||||
})
|
||||
@@ -83,7 +96,12 @@ siteRoutes.get("/quotes/random", async (c) => {
|
||||
siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||
const className = c.req.param("className").trim()
|
||||
if (!/^\d{3,4}$/.test(className)) {
|
||||
return failure(c, 400, "invalid-class", "Class name must contain 3 or 4 digits")
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-class",
|
||||
"Class name must contain 3 or 4 digits",
|
||||
)
|
||||
}
|
||||
const rows = await db
|
||||
.select({ username: schema.user.username })
|
||||
@@ -91,5 +109,8 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||
.where(eq(schema.user.className, className))
|
||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
||||
return success(c, rows.map(({ username }) => stripClassPrefix(username, className)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(({ username }) => stripClassPrefix(username, className)),
|
||||
)
|
||||
})
|
||||
+536
-292
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user