Some checks failed
Deploy / deploy (push) Has been cancelled
列表接口按行发查询是从阶段 3 一路带过来的写法:`Promise.all(rows.map(...))` 看着是并发的,但每行都往库里打一次,行数一多就是几百上千次往返。同样的模式 也散在几个后台批处理和写入路径里。全部改成「先收集 id,一条 inArray/group by 查回来建 Map」——`routes/problem.ts` 的 getProblemTags 早就是这么写的,这次 只是把剩下的地方对齐。 用户可见的列表: - `GET /problem-sets` 每行 5 条(题目数/我的进度/奖章/已获奖章/创建者), limit 上限 250 就是 1250 次往返。改成固定 5 条,与行数无关。 - `GET /contests` 每场比赛一条 creator 查询。后台的比赛列表本来就是 join 出来的,只有这条公开列表漏了。 - `GET /admin/problems`、`GET /admin/contests/:id/problems` 每题一条标签查询。 - `GET /admin/problem-sets` 每行 3 条;`.../badges` 每个奖章一条 count。 后台批处理: - `refreshContestJoinedForAll` 原来是每个用户 1 条 count 加一个独立事务里的 insert/select for update/update。改成一条 group by 出全部用户的场次,再分批 upsert,`metrics || excluded.metrics` 是 jsonb 浅合并,只覆盖 contest_joined 一个键,其余指标原样保留 —— 合并在一条语句里完成,for update 那把锁不再需要。 - `rescanAchievement` 补发循环、`unlockAchievements`:命中的一次插完, onConflictDoNothing 的 returning 就是真新解锁的那批,unlockCount 改成一次 +N。 - `resyncProgress` 逐行 UPDATE 改成一条,completed 用 least() 夹住。 写入路径: - 标签解析抽出 normalizeTagNames + findTagsByName(一条 lower(name) IN), 新建题、改题、批量打标签三条路共用。 - 克隆比赛:题面一条 INSERT、标签一条 SELECT 加一条 INSERT。新旧题的对应 关系靠 _id 认,不依赖 returning 的行序。 - `POST /admin/website` 8 个键一条多行 upsert。 - 题单奖章判定一次插完。 `/ai/duration`:原来每个时间桶两条查询、桶之间还串行,一年 12 个桶 24 次往返。 改成先算桶、再一条查询把整段区间拉回来在内存里分桶。时间戳用 `extract(epoch) * 1000` 取毫秒回来比,别指望 Date.parse 认 pg 那个 `2026-08-12 00:00:00+00` 格式。**相邻桶首尾相接、两端闭区间**(落在边界上的 提交两个桶都算)这条旧语义是照搬的,不要顺手改成半开区间。 验证:本机起 dev 栈,造了覆盖各分支的种子数据(创建者重复的题单、零题目/零 奖章的题单、completed > total 的脏进度、除不尽的百分比、大小写混写的已有标签、 带/不带标签的比赛题、正好落在分桶边界上的提交),旧代码跑一遍、新代码跑一遍: - 51 个接口响应逐字节一致 - 12 张表的快照逐行一致(唯一差别是 progress_detail 里的 submit_time 墙钟值) - 打开 log_statement=all 数过条数,例如 `GET /admin/problems` 20 道题 24 → 5,`GET /problem-sets` 28 → 8,`/ai/duration` years:1 28 → 5, 202 个用户的成就补发 1828 → 614 一处可观察的行为变化:补发现在整批共用一个 unlockTime,原来是每人一个 new Date()。rescanAchievement 上方的注释本来就写着补发会给几百人盖同一个 时间戳、前端据此只显示「已获得」不显示日期,所以这个方向是对的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
import {
|
|
contestAccessSchema,
|
|
contestListSchema,
|
|
contestPasswordRequestSchema,
|
|
contestRankItemSchema,
|
|
contestRankSchema,
|
|
contestSchema,
|
|
problemDetailSchema,
|
|
problemListItemSchema,
|
|
} from "@oj2/contract"
|
|
import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm"
|
|
import { Hono } from "hono"
|
|
|
|
import { optionalAuth, requireAuth } from "../auth/middleware"
|
|
import { setContestPassword } from "../auth/session"
|
|
import { db, schema } from "../db"
|
|
import { astRequirements } from "../judge/ast"
|
|
import { failure, success } from "../http"
|
|
import {
|
|
canAccessContest,
|
|
checkContestPassword,
|
|
contestDetailsAllowed,
|
|
contestStatus,
|
|
findVisibleContest,
|
|
isContestAdmin,
|
|
requireContestAccess,
|
|
type ContestEnv,
|
|
} from "../services/contest"
|
|
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers"
|
|
|
|
export const contestRoutes = new Hono<ContestEnv>()
|
|
|
|
/** 一次把这批比赛的创建者全查回来,按 userId 建 Map —— 比赛列表按行查会变成 N+1 */
|
|
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))
|
|
.where(inArray(schema.user.id, ids))
|
|
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
|
return map
|
|
}
|
|
|
|
function serializeContest(
|
|
contest: typeof schema.contest.$inferSelect,
|
|
createdBy: ReturnType<typeof sampleUser>,
|
|
includeNow = false,
|
|
) {
|
|
return contestSchema.parse({
|
|
id: contest.id,
|
|
title: contest.title,
|
|
description: contest.description,
|
|
tag: contest.tag,
|
|
startTime: contest.startTime,
|
|
endTime: contest.endTime,
|
|
createTime: contest.createTime,
|
|
lastUpdateTime: contest.lastUpdateTime,
|
|
createdBy,
|
|
status: contestStatus(contest),
|
|
contestType: contest.password ? "Password Protected" : "Public",
|
|
now: includeNow ? new Date().toISOString() : undefined,
|
|
})
|
|
}
|
|
|
|
contestRoutes.get("/contests", 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 keyword = c.req.query("keyword")?.trim()
|
|
const tag = c.req.query("tag")?.trim()
|
|
const status = c.req.query("status")
|
|
const now = new Date().toISOString()
|
|
const filters = [eq(schema.contest.visible, true)]
|
|
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
|
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))!)
|
|
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),
|
|
])
|
|
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
|
|
return success(c, contestListSchema.parse({
|
|
results: rows.map((row) => serializeContest(
|
|
row,
|
|
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
|
)),
|
|
total: totalRow[0]?.value ?? 0,
|
|
}))
|
|
})
|
|
|
|
contestRoutes.get("/contests/:id", async (c) => {
|
|
const contest = await findVisibleContest(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,
|
|
))
|
|
})
|
|
|
|
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
|
const contest = await findVisibleContest(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")
|
|
}
|
|
await setContestPassword(c, contest.id, parsed.data.password)
|
|
return success(c, true)
|
|
})
|
|
|
|
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
|
const contest = await findVisibleContest(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, contestAccessSchema.parse({ access: access.ok }))
|
|
})
|
|
|
|
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))
|
|
.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])
|
|
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)
|
|
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
|
|
id: problem.id,
|
|
_id: problem.displayId,
|
|
title: problem.title,
|
|
submissionNumber: allowed ? problem.submissionNumber : 0,
|
|
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
|
difficulty: allowed ? problem.difficulty : "",
|
|
createdBy: sampleUser(user, realName),
|
|
tags: tags.get(problem.id) ?? [],
|
|
contestId: contest.id,
|
|
allowFlowchart: problem.allowFlowchart,
|
|
showFlowchart: problem.showFlowchart,
|
|
hasAstRules: problem.astRules !== null,
|
|
myStatus: null,
|
|
})))
|
|
})
|
|
|
|
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)
|
|
return success(c, problemDetailSchema.parse({
|
|
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: stringArray(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 : "",
|
|
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) : {},
|
|
shareSubmission: row.problem.shareSubmission,
|
|
contestId: contest.id,
|
|
tags: tags.get(row.problem.id) ?? [],
|
|
createdBy: sampleUser(row.user, row.realName),
|
|
myStatus: null,
|
|
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 ? objectValue(row.problem.sqlConfig) : null,
|
|
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
|
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
|
astRequirements: astRequirements(row.problem.astRules),
|
|
}))
|
|
})
|
|
|
|
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, ["Regular User", "Student Admin"]), 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)
|
|
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime)).limit(limit).offset(offset),
|
|
])
|
|
const admin = isContestAdmin(c.get("user"), contest)
|
|
return success(c, contestRankSchema.parse({
|
|
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
|
|
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: objectValue(rank.submissionInfo),
|
|
contestId: rank.contestId,
|
|
})),
|
|
total: totalRows[0]?.value ?? 0,
|
|
}))
|
|
})
|