Deploy / deploy (push) Canceled after 0s
只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。 设计稿页名「题单重设计」和已执行的迁移文件保留原样。 线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
524 lines
22 KiB
TypeScript
524 lines
22 KiB
TypeScript
import {
|
|
joinProblemSetRequestSchema,
|
|
STUDENT_ROLES,
|
|
type ProblemSet,
|
|
type ProblemSetBadge,
|
|
type ProblemSetClassView,
|
|
type ProblemSetList,
|
|
type ProblemSetLock,
|
|
type ProblemSetProblem,
|
|
type UserBadge,
|
|
} from '@oj2/contract'
|
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from 'drizzle-orm'
|
|
import { Hono } from 'hono'
|
|
|
|
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from '../auth/middleware'
|
|
import type { AuthUser } from '../auth/session'
|
|
import { db, schema } from '../db'
|
|
import { failure, parseBody, success } from '../http'
|
|
import { JudgeStatus, NON_FAILURE_RESULTS } from '../judge/status'
|
|
import { assigningSql, computeProgress, problemSetLockCutoffs, problemSetLocksFor } from '../services/problemset'
|
|
import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from './helpers'
|
|
|
|
export const problemsetRoutes = new Hono<AppEnv>()
|
|
|
|
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
|
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
|
|
|
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
|
|
|
/** 必做题的 problemId;一道必做都没标的专题退回全部(和 computeProgress 同一个口径) */
|
|
function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
|
|
const required = links.filter((link) => link.isRequired)
|
|
return new Set((required.length ? required : links).map((link) => link.problemId))
|
|
}
|
|
|
|
function progressSummary(progress: ProgressRow | undefined) {
|
|
return progress
|
|
? {
|
|
isJoined: true,
|
|
joinTime: progress.joinTime,
|
|
completedCount: progress.completedProblemsCount,
|
|
totalCount: progress.totalProblemsCount,
|
|
solvedCount: Object.keys(asRecord(progress.progressDetail)).length,
|
|
isCompleted: progress.isCompleted,
|
|
completeTime: progress.completeTime,
|
|
}
|
|
: {
|
|
isJoined: false,
|
|
joinTime: null,
|
|
completedCount: 0,
|
|
totalCount: 0,
|
|
solvedCount: 0,
|
|
isCompleted: false,
|
|
completeTime: null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 学生能看到的专题:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
|
* 看到的样子、看全班情况,不该被 404 挡在外面。
|
|
*/
|
|
async function loadVisible(id: number, user: AuthUser | null | undefined) {
|
|
const [row] = await db
|
|
.select({ set: schema.problemset, assigning: assigningSql })
|
|
.from(schema.problemset)
|
|
.where(and(eq(schema.problemset.id, id), isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true)))
|
|
.limit(1)
|
|
return row ?? null
|
|
}
|
|
|
|
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))
|
|
.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, earnedTime?: string | null) {
|
|
return {
|
|
id: badge.id,
|
|
problemSetId: badge.problemsetId,
|
|
name: badge.name,
|
|
description: badge.description,
|
|
icon: badge.icon,
|
|
conditionType: badge.conditionType,
|
|
conditionValue: badge.conditionValue,
|
|
isEarned: earnedTime === undefined ? undefined : earnedTime !== null,
|
|
earnedTime,
|
|
} satisfies ProblemSetBadge
|
|
}
|
|
|
|
/**
|
|
* 一次把整页专题的附属数据全查回来,再在内存里按 problemsetId 分组。
|
|
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
|
|
*/
|
|
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) {
|
|
if (rows.length === 0) return []
|
|
const ids = rows.map((row) => row.set.id)
|
|
const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all([
|
|
db
|
|
.select({
|
|
problemsetId: schema.problemsetProblem.problemsetId,
|
|
value: count(),
|
|
required: sql<number>`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`,
|
|
})
|
|
.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))
|
|
.groupBy(schema.problemsetProgress.problemsetId),
|
|
userId
|
|
? db
|
|
.select()
|
|
.from(schema.problemsetProgress)
|
|
.where(and(inArray(schema.problemsetProgress.problemsetId, ids), eq(schema.problemsetProgress.userId, userId)))
|
|
: Promise.resolve([] as ProgressRow[]),
|
|
db.select().from(schema.problemsetBadge).where(inArray(schema.problemsetBadge.problemsetId, ids)).orderBy(asc(schema.problemsetBadge.id)),
|
|
userId
|
|
? db
|
|
.select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime })
|
|
.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; earnedTime: string }[]),
|
|
problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]),
|
|
])
|
|
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item]))
|
|
const joinedBySet = new Map(joinedCounts.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 Map(earnedRows.map((item) => [item.id, item.earnedTime]))
|
|
return rows.map(({ set: row, assigning }) => {
|
|
const progress = progressBySet.get(row.id)
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
description: row.description,
|
|
createdBy: creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: '' }, null),
|
|
createTime: row.createTime,
|
|
assignedUntil: row.assignedUntil,
|
|
assigning,
|
|
problemsCount: countBySet.get(row.id)?.value ?? 0,
|
|
// 一道必做都没标的专题退回「全部都算必做」,和 computeProgress 同一个口径
|
|
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
|
|
joinedCount: joinedBySet.get(row.id) ?? 0,
|
|
userProgress: progressSummary(progress),
|
|
badges: (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined)),
|
|
} satisfies ProblemSet
|
|
})
|
|
}
|
|
|
|
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)]
|
|
const keyword = c.req.query('keyword')?.trim()
|
|
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
|
|
const where = and(...filters)
|
|
const [totalRows, rows] = await Promise.all([
|
|
db.select({ value: count() }).from(schema.problemset).where(where),
|
|
db
|
|
.select({ set: schema.problemset, assigning: assigningSql })
|
|
.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),
|
|
total: totalRows[0]?.value ?? 0,
|
|
} satisfies ProblemSetList)
|
|
})
|
|
|
|
// 字面量路由要排在 /problem-sets/:id 前面:Hono 按注册顺序匹配,不是静态优先
|
|
// 题号(problemDisplayId)和主键(problemId)都认:提交列表那边手里只有题号
|
|
problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => {
|
|
const user = c.get('user')!
|
|
if (isTeacherOrAbove(user)) return success(c, [] as ProblemSetLock[])
|
|
let problemId = queryInteger(c.req.query('problemId'), 0, { min: 1 })
|
|
const displayId = c.req.query('problemDisplayId')?.trim()
|
|
if (!problemId && displayId) {
|
|
const [problem] = await db
|
|
.select({ id: schema.problem.id })
|
|
.from(schema.problem)
|
|
.where(and(eq(schema.problem.displayId, displayId), isNull(schema.problem.contestId)))
|
|
.limit(1)
|
|
problemId = problem?.id ?? 0
|
|
}
|
|
if (!problemId) return success(c, [] as ProblemSetLock[])
|
|
const rows = await problemSetLocksFor(user.id, problemId)
|
|
return success(c, rows satisfies ProblemSetLock[])
|
|
})
|
|
|
|
problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
|
|
const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
|
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
|
const [data] = await serializeProblemSets([row], c.get('user')?.id)
|
|
return success(c, data)
|
|
})
|
|
|
|
problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
|
|
const user = c.get('user')
|
|
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user)
|
|
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
|
const id = found.set.id
|
|
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
|
|
// 编号的(1、2、3),老专题里 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,
|
|
})
|
|
.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 problemIds = rows.map((row) => row.problemId)
|
|
const [progressRows, wrongRows, cutoffs] = user
|
|
? await Promise.all([
|
|
db
|
|
.select({ detail: schema.problemsetProgress.progressDetail })
|
|
.from(schema.problemsetProgress)
|
|
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, user.id)))
|
|
.limit(1),
|
|
db
|
|
.select({ problemId: schema.submission.problemId, value: count() })
|
|
.from(schema.submission)
|
|
.where(and(eq(schema.submission.userId, user.id), eq(schema.submission.problemsetId, id), notInArray(schema.submission.result, NON_FAILURE_RESULTS)))
|
|
.groupBy(schema.submission.problemId),
|
|
isTeacherOrAbove(user) ? new Map<number, string>() : problemSetLockCutoffs(user.id, problemIds),
|
|
])
|
|
: [[], [], new Map<number, string>()]
|
|
// 「以前做对过 · 旧代码先藏着」:这道题正被闸门挡着,而且分界之前确实做对过
|
|
const hiddenSolved =
|
|
user && cutoffs.size
|
|
? await db
|
|
.select({ problemId: schema.submission.problemId })
|
|
.from(schema.submission)
|
|
.where(
|
|
and(
|
|
eq(schema.submission.userId, user.id),
|
|
inArray(schema.submission.problemId, [...cutoffs.keys()]),
|
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
|
or(
|
|
...[...cutoffs].map(
|
|
([problemId, cutoff]) => sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.groupBy(schema.submission.problemId)
|
|
: []
|
|
const hidden = new Set(hiddenSolved.map((row) => row.problemId))
|
|
const solved = asRecord(progressRows[0]?.detail)
|
|
const wrong = new Map(wrongRows.map((row) => [row.problemId, row.value]))
|
|
return success(
|
|
c,
|
|
rows.map(({ link, problemId, displayId, title, difficulty }) => {
|
|
const entry = solved[String(problemId)]
|
|
const solvedTime = entry === undefined ? null : asRecord(entry).submit_time
|
|
return {
|
|
id: link.id,
|
|
problemSetId: link.problemsetId,
|
|
problem: { id: problemId, _id: displayId, title, difficulty },
|
|
order: link.order,
|
|
isRequired: link.isRequired,
|
|
isCompleted: entry !== undefined,
|
|
solvedTime: typeof solvedTime === 'string' ? solvedTime : null,
|
|
wrongCount: wrong.get(problemId) ?? 0,
|
|
oldCodeHidden: hidden.has(problemId),
|
|
} satisfies ProblemSetProblem
|
|
}),
|
|
)
|
|
})
|
|
|
|
problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => {
|
|
const parsed = await parseBody(c, joinProblemSetRequestSchema, 'Invalid problem set')
|
|
if (!parsed.success) return parsed.response
|
|
const user = c.get('user')!
|
|
const found = await loadVisible(parsed.data.problemSetId, user)
|
|
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
|
const problemSet = found.set
|
|
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 links = await tx
|
|
.select({
|
|
problemId: schema.problemsetProblem.problemId,
|
|
isRequired: schema.problemsetProblem.isRequired,
|
|
})
|
|
.from(schema.problemsetProblem)
|
|
.where(eq(schema.problemsetProblem.problemsetId, problemSet.id))
|
|
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份
|
|
await tx.insert(schema.problemsetProgress).values({
|
|
problemsetId: problemSet.id,
|
|
userId: user.id,
|
|
joinTime: new Date().toISOString(),
|
|
...computeProgress({}, links, null),
|
|
})
|
|
})
|
|
return success(c, null, 201)
|
|
})
|
|
|
|
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 (!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,
|
|
),
|
|
)
|
|
})
|
|
|
|
/**
|
|
* 老师看一个班在这个专题里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
|
*
|
|
* 所有老师都能看,不只是专题的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
|
|
* 也是任何老师看任何班,这里和它们一致。
|
|
*
|
|
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
|
* 想看别的班再换(和比赛「全班情况」按参赛者推断班级是一个思路)。名单是那个班的全部学生,
|
|
* 没加入的也列出来,好点名。
|
|
*/
|
|
problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => {
|
|
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
|
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
|
const id = found.set.id
|
|
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
|
|
const [links, joinedClasses] = await Promise.all([
|
|
db
|
|
.select({
|
|
problemId: schema.problem.id,
|
|
_id: schema.problem.displayId,
|
|
title: schema.problem.title,
|
|
isRequired: schema.problemsetProblem.isRequired,
|
|
})
|
|
.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)),
|
|
db
|
|
.select({
|
|
className: sql<string>`${schema.user.className}`,
|
|
joined: count(),
|
|
last: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
|
})
|
|
.from(schema.problemsetProgress)
|
|
.innerJoin(schema.user, eq(schema.user.id, schema.problemsetProgress.userId))
|
|
.where(and(eq(schema.problemsetProgress.problemsetId, id), studentRole, sql`coalesce(${schema.user.className}, '') <> ''`))
|
|
.groupBy(schema.user.className)
|
|
.orderBy(sql`max(${schema.problemsetProgress.joinTime}) desc`),
|
|
])
|
|
const sizes = joinedClasses.length
|
|
? await db
|
|
.select({ className: sql<string>`${schema.user.className}`, value: count() })
|
|
.from(schema.user)
|
|
.where(
|
|
and(
|
|
inArray(
|
|
schema.user.className,
|
|
joinedClasses.map((row) => row.className),
|
|
),
|
|
studentRole,
|
|
eq(schema.user.isDisabled, false),
|
|
),
|
|
)
|
|
.groupBy(schema.user.className)
|
|
: []
|
|
const sizeOf = new Map(sizes.map((row) => [row.className, row.value]))
|
|
// 只有零星几个人加入的班(别的班的学生自己点进来的)排到后面去:至少 5 个、三分之一的人加入了
|
|
// 才算「这个班在做」,这样的班按最近一次有人加入排,默认看第一个
|
|
const classes = joinedClasses
|
|
.map((row) => ({
|
|
className: row.className,
|
|
joined: row.joined,
|
|
size: sizeOf.get(row.className) ?? row.joined,
|
|
}))
|
|
.map((row, recency) => ({ row, recency, doing: row.joined >= 5 && row.joined * 3 >= row.size }))
|
|
.sort((a, b) => (a.doing !== b.doing ? Number(b.doing) - Number(a.doing) : a.doing ? a.recency - b.recency : b.row.joined - a.row.joined))
|
|
.map((item) => item.row)
|
|
const wanted = c.req.query('className')?.trim()
|
|
const className = wanted || classes[0]?.className || null
|
|
const graded = gradedIds(links)
|
|
const empty: ProblemSetClassView = {
|
|
classes,
|
|
className,
|
|
problems: links.map((link) => ({
|
|
id: link.problemId,
|
|
_id: link._id,
|
|
title: link.title,
|
|
isRequired: link.isRequired,
|
|
solved: 0,
|
|
})),
|
|
totalCount: graded.size,
|
|
students: [],
|
|
}
|
|
if (!className || links.length === 0) return success(c, empty)
|
|
|
|
const roster = await db
|
|
.select({
|
|
userId: schema.user.id,
|
|
username: schema.user.username,
|
|
realName: schema.userProfile.realName,
|
|
progress: schema.problemsetProgress,
|
|
})
|
|
.from(schema.user)
|
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
.leftJoin(schema.problemsetProgress, and(eq(schema.problemsetProgress.userId, schema.user.id), eq(schema.problemsetProgress.problemsetId, id)))
|
|
.where(and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)))
|
|
.orderBy(asc(schema.user.username))
|
|
if (roster.length === 0) return success(c, empty)
|
|
const userIds = roster.map((row) => row.userId)
|
|
const problemIds = links.map((link) => link.problemId)
|
|
const [wrongRows, beforeRows] = await Promise.all([
|
|
db
|
|
.select({
|
|
userId: schema.submission.userId,
|
|
problemId: schema.submission.problemId,
|
|
value: count(),
|
|
})
|
|
.from(schema.submission)
|
|
.where(and(eq(schema.submission.problemsetId, id), inArray(schema.submission.userId, userIds), notInArray(schema.submission.result, NON_FAILURE_RESULTS)))
|
|
.groupBy(schema.submission.userId, schema.submission.problemId),
|
|
// 加入之前就做对过(哪个入口都算):老师据此知道谁可能是凭记忆默写的
|
|
db
|
|
.select({ userId: schema.submission.userId, problemId: schema.submission.problemId })
|
|
.from(schema.submission)
|
|
.innerJoin(schema.problemsetProgress, and(eq(schema.problemsetProgress.userId, schema.submission.userId), eq(schema.problemsetProgress.problemsetId, id)))
|
|
.where(
|
|
and(
|
|
inArray(schema.submission.userId, userIds),
|
|
inArray(schema.submission.problemId, problemIds),
|
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
|
sql`${schema.submission.createTime} < ${schema.problemsetProgress.joinTime}`,
|
|
),
|
|
)
|
|
.groupBy(schema.submission.userId, schema.submission.problemId),
|
|
])
|
|
const key = (userId: number, problemId: number) => `${userId}:${problemId}`
|
|
const wrong = new Map(wrongRows.map((row) => [key(row.userId, row.problemId), row.value]))
|
|
const before = new Set(beforeRows.map((row) => key(row.userId, row.problemId)))
|
|
const solvedCount = new Map<number, number>()
|
|
const students = roster.map((row) => {
|
|
const detail = asRecord(row.progress?.progressDetail)
|
|
let completed = 0
|
|
const cells = links.map((link) => {
|
|
const entry = detail[String(link.problemId)]
|
|
const time = entry === undefined ? null : asRecord(entry).submit_time
|
|
if (entry !== undefined) {
|
|
solvedCount.set(link.problemId, (solvedCount.get(link.problemId) ?? 0) + 1)
|
|
if (graded.has(link.problemId)) completed += 1
|
|
}
|
|
return {
|
|
solvedTime: typeof time === 'string' ? time : null,
|
|
wrongCount: wrong.get(key(row.userId, link.problemId)) ?? 0,
|
|
solvedBefore: before.has(key(row.userId, link.problemId)),
|
|
}
|
|
})
|
|
return {
|
|
userId: row.userId,
|
|
username: row.username,
|
|
realName: row.realName,
|
|
joinTime: row.progress?.joinTime ?? null,
|
|
completedCount: completed,
|
|
cells,
|
|
}
|
|
})
|
|
return success(c, {
|
|
...empty,
|
|
problems: empty.problems.map((problem) => ({
|
|
...problem,
|
|
solved: solvedCount.get(problem.id) ?? 0,
|
|
})),
|
|
students,
|
|
} satisfies ProblemSetClassView)
|
|
})
|