Some checks failed
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import {
|
||
contestPasswordRequestSchema,
|
||
STUDENT_ROLES,
|
||
type Contest,
|
||
type ContestAccess,
|
||
type ContestList,
|
||
type ContestRank,
|
||
type ContestRankItem,
|
||
type ProblemDetail,
|
||
type ProblemListItem,
|
||
} 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,
|
||
findAccessibleContest,
|
||
isContestAdmin,
|
||
requireContestAccess,
|
||
type ContestEnv,
|
||
} from "../services/contest"
|
||
import {
|
||
objectValue,
|
||
publicTemplates,
|
||
queryInteger,
|
||
sampleUser,
|
||
} 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 {
|
||
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,
|
||
} satisfies Contest
|
||
}
|
||
|
||
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, {
|
||
results: rows.map((row) =>
|
||
serializeContest(
|
||
row,
|
||
byId.get(row.createdById) ??
|
||
sampleUser({ id: row.createdById, username: "" }, null),
|
||
),
|
||
),
|
||
total: totalRow[0]?.value ?? 0,
|
||
} satisfies ContestList)
|
||
})
|
||
|
||
// 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 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 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",
|
||
)
|
||
}
|
||
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 access = await canAccessContest(c, contest, "details")
|
||
return success(c, { access: access.ok } satisfies ContestAccess)
|
||
})
|
||
|
||
/**
|
||
* 当前用户在**比赛题**上的做题状态。判题回写记在 user_profile 的
|
||
* `acm_problems_status.contest_problems`(judge/run.ts),公开题库那份记在 `problems`
|
||
* 下,两边互不干扰。
|
||
*
|
||
* 原来这两条路由一律下发空状态,于是比赛题目页的「状态」列永远是「未做」,赛后也不
|
||
* 恢复 —— 而库里其实一直记着。
|
||
*
|
||
* 不按「比赛结没结束」分档:这是学生自己的判题结果,赛中赛后都不泄露别人的任何信息
|
||
* (旧后端赛中不下发,纯粹是因为它整条路换了个 serializer,不是什么保密考虑)。
|
||
*/
|
||
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)
|
||
return objectValue(objectValue(profile?.status).contest_problems)
|
||
}
|
||
|
||
function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
|
||
const status = objectValue(statuses[String(problemId)]).status
|
||
return typeof status === "number" ? status : null
|
||
}
|
||
|
||
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)
|
||
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/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)
|
||
},
|
||
)
|