feat(阶段3): oj 侧端点铺开(基线提交,未经评审)

由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线,
后续修复单独成 commit,便于区分与回退。

覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/
content/contest/flowchart/problemset/site)与对应 Zod 契约。

已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据;
judge 与 flowchart worker 均 ready。
未核验:权限边界与数据泄露,评审进行中。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 01:25:36 -06:00
parent ec274419c3
commit 8c00cdc947
52 changed files with 4273 additions and 300 deletions

View File

@@ -13,6 +13,8 @@ const SESSION_PREFIX = "session:"
interface StoredSession { interface StoredSession {
userId: number userId: number
createdAt: string createdAt: string
previousLogin: string | null
contestPasswords: Record<string, string>
} }
export interface AuthUser { export interface AuthUser {
@@ -22,17 +24,24 @@ export interface AuthUser {
adminType: string adminType: string
problemPermission: string problemPermission: string
isDisabled: boolean isDisabled: boolean
className: string | null
} }
function sessionKey(token: string) { function sessionKey(token: string) {
return `${SESSION_PREFIX}${token}` return `${SESSION_PREFIX}${token}`
} }
export async function createSession(c: Context, userId: number) { export async function createSession(
c: Context,
userId: number,
previousLogin: string | null = null,
) {
const token = randomBytes(32).toString("base64url") const token = randomBytes(32).toString("base64url")
const value: StoredSession = { const value: StoredSession = {
userId, userId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
previousLogin,
contestPasswords: {},
} }
await redis.set( await redis.set(
sessionKey(token), sessionKey(token),
@@ -87,6 +96,7 @@ async function getUserByToken(token: string | undefined): Promise<AuthUser | nul
adminType: schema.user.adminType, adminType: schema.user.adminType,
problemPermission: schema.user.problemPermission, problemPermission: schema.user.problemPermission,
isDisabled: schema.user.isDisabled, isDisabled: schema.user.isDisabled,
className: schema.user.className,
}) })
.from(schema.user) .from(schema.user)
.where(eq(schema.user.id, session.userId)) .where(eq(schema.user.id, session.userId))
@@ -108,3 +118,41 @@ export function getSessionUser(c: Context) {
export function getRequestSessionUser(request: Request) { export function getRequestSessionUser(request: Request) {
return getUserByToken(readCookie(request, config.sessionCookie)) return getUserByToken(readCookie(request, config.sessionCookie))
} }
async function getStoredSession(c: Context) {
const token = getCookie(c, config.sessionCookie)
if (!token) return null
const raw = await redis.get(sessionKey(token))
if (!raw) return null
try {
const value = JSON.parse(raw) as StoredSession
value.contestPasswords ??= {}
value.previousLogin ??= null
return { token, value }
} catch {
return null
}
}
export async function setContestPassword(c: Context, contestId: number, password: string) {
const session = await getStoredSession(c)
if (!session) return false
session.value.contestPasswords[String(contestId)] = password
await redis.set(
sessionKey(session.token),
JSON.stringify(session.value),
"EX",
config.sessionTtlSeconds,
)
return true
}
export async function getContestPassword(c: Context, contestId: number) {
const session = await getStoredSession(c)
return session?.value.contestPasswords[String(contestId)] ?? null
}
export async function getPreviousLogin(c: Context) {
const session = await getStoredSession(c)
return session?.value.previousLogin ?? null
}

View File

@@ -7,4 +7,11 @@ export const config = {
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081", judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token", judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token",
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2), judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
avatarDirectory: process.env.AVATAR_DIRECTORY ?? "data/avatar",
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
aiKey: process.env.AI_KEY ?? "",
aiModel: process.env.AI_MODEL ?? "deepseek-v4-flash",
ruffPath: process.env.RUFF_PATH ?? "ruff",
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
} }

48
apps/api/src/events.ts Normal file
View File

@@ -0,0 +1,48 @@
import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
import { redis } from "./redis"
export const userEventChannel = "user:events"
interface UserEvent {
userId: number
data: FlowchartUpdate | Record<string, unknown>
}
interface AchievementNotification {
id: number
name: string
description: string
icon: string
rarity: string
kind: "achievement" | "badge"
}
export function userEventTopic(userId: number) {
return `events:user:${userId}`
}
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) }))
}
export async function publishAchievementNotification(
userId: number,
achievements: AchievementNotification[],
) {
if (!achievements.length) return
await redis.publish(userEventChannel, JSON.stringify({
userId,
data: { type: "achievement_unlocked", achievements },
}))
}
export function parseUserEvent(raw: string): UserEvent | null {
try {
const value = JSON.parse(raw) as UserEvent
if (!Number.isInteger(value.userId) || !value.data || typeof value.data !== "object") return null
return value
} catch {
return null
}
}

View File

@@ -0,0 +1,5 @@
export const flowchartQueueName = "evaluate-flowchart"
export interface FlowchartJobData {
submissionId: string
}

View File

@@ -0,0 +1,77 @@
import { flowchartUpdateSchema } from "@oj2/contract"
import { eq } from "drizzle-orm"
import { db, schema } from "../db"
import { publishFlowchartUpdate } from "../events"
import { completeChat } from "../services/ai"
import type { FlowchartJobData } from "./job"
function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
return `你是专业的编程教学助手,负责评估学生的 Mermaid 流程图。
评分逻辑正确性40分、完整性30分、规范性20分、清晰度10分。
不要评价系统生成的节点ID。feedback不超过100字suggestions最多3条且只针对真实问题。
返回纯 JSON{"score":85,"grade":"A","feedback":"...","suggestions":"...","criteria_details":{}}。
等级S=90-100A=80-89B=70-79C=0-69。
题目:${problem.title}\n${problem.description.slice(0, 2000)}`
}
function parseEvaluation(value: string) {
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
if (!json) throw new Error("AI response did not contain JSON")
const data = JSON.parse(json) as Record<string, unknown>
if (typeof data.score !== "number" || typeof data.grade !== "string") throw new Error("AI response is missing score or grade")
return {
score: Math.max(0, Math.min(100, data.score)),
grade: data.grade,
feedback: typeof data.feedback === "string" ? data.feedback : "",
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {},
}
}
export async function evaluateFlowchart(job: FlowchartJobData) {
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, job.submissionId)).limit(1)
if (!row || ![0, 1].includes(row.flowchart.status)) return
await db.update(schema.flowchartSubmission).set({ status: 1 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
const started = performance.now()
try {
const reference = row.problem.mermaidCode ? `\n标准答案参考\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。"
const result = parseEvaluation(await completeChat(
evaluationPrompt(row.problem),
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示${row.problem.flowchartHint ?? "无"}`,
))
await db.update(schema.flowchartSubmission).set({
status: 2,
aiScore: result.score,
aiGrade: result.grade,
aiFeedback: result.feedback,
aiSuggestions: result.suggestions,
aiCriteriaDetails: result.criteria,
aiProvider: "deepseek",
aiModel: process.env.AI_MODEL ?? "deepseek-v4-flash",
processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(),
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
type: "flowchart_evaluation_completed",
submission_id: row.flowchart.id,
score: result.score,
grade: result.grade,
feedback: result.feedback,
suggestions: result.suggestions,
criteriaDetails: result.criteria,
}))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
type: "flowchart_evaluation_failed",
submission_id: row.flowchart.id,
error: message,
}))
throw error
}
}

View File

@@ -1,11 +1,21 @@
import { Hono } from "hono" import { Hono } from "hono"
import { basename, resolve } from "node:path"
import { getRequestSessionUser } from "./auth/session" import { getRequestSessionUser } from "./auth/session"
import { config } from "./config" import { config } from "./config"
import { authRoutes } from "./routes/auth" import { authRoutes } from "./routes/auth"
import { accountRoutes } from "./routes/account"
import { judgeServerRoutes } from "./routes/judge-server" import { judgeServerRoutes } from "./routes/judge-server"
import { contestRoutes } from "./routes/contest"
import { contentRoutes } from "./routes/content"
import { classroomRoutes } from "./routes/classroom"
import { problemsetRoutes } from "./routes/problemset"
import { achievementRoutes } from "./routes/achievement"
import { aiRoutes } from "./routes/ai"
import { flowchartRoutes } from "./routes/flowchart"
import { problemRoutes } from "./routes/problem" import { problemRoutes } from "./routes/problem"
import { submissionRoutes } from "./routes/submission" import { submissionRoutes } from "./routes/submission"
import { siteRoutes } from "./routes/site"
import { import {
bridgeSubmissionEvents, bridgeSubmissionEvents,
submissionWebSocketHandler, submissionWebSocketHandler,
@@ -16,6 +26,15 @@ const app = new Hono()
app.get("/health", (c) => c.json({ ok: true })) app.get("/health", (c) => c.json({ ok: true }))
app.route("/api", authRoutes) app.route("/api", authRoutes)
app.route("/api", accountRoutes)
app.route("/api", siteRoutes)
app.route("/api", contestRoutes)
app.route("/api", contentRoutes)
app.route("/api", classroomRoutes)
app.route("/api", problemsetRoutes)
app.route("/api", achievementRoutes)
app.route("/api", aiRoutes)
app.route("/api", flowchartRoutes)
app.route("/api", problemRoutes) app.route("/api", problemRoutes)
app.route("/api", submissionRoutes) app.route("/api", submissionRoutes)
app.route("/api", judgeServerRoutes) app.route("/api", judgeServerRoutes)
@@ -32,6 +51,21 @@ const server = Bun.serve<SubmissionSocketData>({
port: config.port, port: config.port,
async fetch(request, bunServer) { async fetch(request, bunServer) {
const url = new URL(request.url) const url = new URL(request.url)
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
const filename = basename(decodeURIComponent(url.pathname))
if (filename !== decodeURIComponent(url.pathname).split("/").at(-1)) {
return new Response("Not found", { status: 404 })
}
const file = Bun.file(resolve(config.avatarDirectory, filename))
if (await file.exists()) return new Response(file)
if (filename === "default.png") {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>',
{ headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } },
)
}
return new Response("Not found", { status: 404 })
}
if (url.pathname === "/ws/submissions") { if (url.pathname === "/ws/submissions") {
const user = await getRequestSessionUser(request) const user = await getRequestSessionUser(request)
if (!user) return new Response("Unauthorized", { status: 401 }) if (!user) return new Response("Unauthorized", { status: 401 })

View File

@@ -4,6 +4,8 @@ import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { updateAchievementsForSubmission } from "../services/achievements"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job" import type { JudgeJobData } from "./job"
@@ -99,6 +101,8 @@ async function persistResult(
result: JudgeStatusValue, result: JudgeStatusValue,
info: unknown, info: unknown,
statisticInfo: Record<string, unknown>, statisticInfo: Record<string, unknown>,
contestId: number | null,
submissionCreateTime: string,
) { ) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [currentSubmission] = await tx const [currentSubmission] = await tx
@@ -158,7 +162,8 @@ async function persistResult(
.where(eq(schema.problem.id, problemId)) .where(eq(schema.problem.id, problemId))
const acmStatus = objectValue(profile.acmProblemsStatus) const acmStatus = objectValue(profile.acmProblemsStatus)
const problems = objectValue(acmStatus.problems) const statusKey = contestId === null ? "problems" : "contest_problems"
const problems = objectValue(acmStatus[statusKey])
const previous = objectValue(problems[String(problemId)]) const previous = objectValue(problems[String(problemId)])
const previousStatus = previous.status const previousStatus = previous.status
const wasAccepted = const wasAccepted =
@@ -177,18 +182,98 @@ async function persistResult(
_id: displayId, _id: displayId,
} }
} }
acmStatus.problems = problems acmStatus[statusKey] = problems
await tx await tx
.update(schema.userProfile) .update(schema.userProfile)
.set({ .set({
submissionNumber: profile.submissionNumber + 1, submissionNumber:
profile.submissionNumber + (contestId === null ? 1 : 0),
acceptedNumber: acceptedNumber:
profile.acceptedNumber + (acceptedNow && !wasAccepted ? 1 : 0), profile.acceptedNumber +
(contestId === null && acceptedNow && !wasAccepted ? 1 : 0),
acmProblemsStatus: acmStatus, acmProblemsStatus: acmStatus,
}) })
.where(eq(schema.userProfile.id, profile.id)) .where(eq(schema.userProfile.id, profile.id))
if (contestId !== null) {
const [contest] = await tx
.select({ startTime: schema.contest.startTime })
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.for("update")
if (!contest) throw new Error("Contest disappeared during judging")
await tx
.insert(schema.acmContestRank)
.values({
contestId,
userId,
submissionNumber: 0,
acceptedNumber: 0,
totalTime: 0,
submissionInfo: {},
})
.onConflictDoNothing({
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
})
const [rank] = await tx
.select()
.from(schema.acmContestRank)
.where(
and(
eq(schema.acmContestRank.contestId, contestId),
eq(schema.acmContestRank.userId, userId),
),
)
.for("update")
if (!rank) throw new Error("Contest rank could not be created")
const rankInfo = objectValue(rank.submissionInfo)
const previousInfo = objectValue(rankInfo[String(problemId)])
const alreadyAccepted = previousInfo.is_ac === true
if (!alreadyAccepted) {
const errorNumber =
typeof previousInfo.error_number === "number"
? previousInfo.error_number
: 0
const nextInfo: Record<string, unknown> = {
is_ac: acceptedNow,
ac_time: 0,
error_number:
errorNumber +
(!acceptedNow && result !== JudgeStatus.COMPILE_ERROR ? 1 : 0),
is_first_ac: false,
}
let totalTime = rank.totalTime
let acceptedNumber = rank.acceptedNumber
if (acceptedNow) {
const acTime = Math.max(
0,
Math.floor(
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) /
1000,
),
)
nextInfo.ac_time = acTime
nextInfo.is_first_ac = problem.acceptedNumber === 0
acceptedNumber += 1
totalTime += acTime + errorNumber * 20 * 60
}
rankInfo[String(problemId)] = nextInfo
await tx
.update(schema.acmContestRank)
.set({
submissionNumber: rank.submissionNumber + 1,
acceptedNumber,
totalTime,
submissionInfo: rankInfo,
})
.where(eq(schema.acmContestRank.id, rank.id))
}
}
return true return true
}) })
} }
@@ -329,9 +414,25 @@ export async function judgeSubmission(job: JudgeJobData) {
result, result,
info, info,
statisticInfo, statisticInfo,
row.submission.contestId,
row.submission.createTime,
) )
if (!saved) return if (!saved) return
try {
const unlocked = await updateAchievementsForSubmission(row.submission.id)
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})))
} catch (error) {
console.error(`Failed to update achievements for ${row.submission.id}`, error)
}
await publishSubmissionUpdate(row.submission.userId, { await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update", type: "submission_update",
submission_id: row.submission.id, submission_id: row.submission.id,

View File

@@ -1,6 +1,7 @@
import { Queue } from "bullmq" import { Queue } from "bullmq"
import { judgeQueueName, type JudgeJobData } from "./judge/job" import { judgeQueueName, type JudgeJobData } from "./judge/job"
import { flowchartQueueName, type FlowchartJobData } from "./flowchart/job"
import { createBlockingRedis } from "./redis" import { createBlockingRedis } from "./redis"
export const judgeQueue = new Queue<JudgeJobData>(judgeQueueName, { export const judgeQueue = new Queue<JudgeJobData>(judgeQueueName, {
@@ -10,3 +11,13 @@ export const judgeQueue = new Queue<JudgeJobData>(judgeQueueName, {
removeOnFail: 500, removeOnFail: 500,
}, },
}) })
export const flowchartQueue = new Queue<FlowchartJobData>(flowchartQueueName, {
connection: createBlockingRedis(),
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 2_000 },
removeOnComplete: 100,
removeOnFail: 500,
},
})

View File

@@ -0,0 +1,238 @@
import { randomBytes } from "node:crypto"
import { extname, resolve } from "node:path"
import {
activityRankItemSchema,
metricsSchema,
problemRankSchema,
rankProfileSchema,
registerRequestSchema,
updateProfileRequestSchema,
userRankSchema,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
desc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
min,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options"
import { getUserProfileById } from "../services/profile"
import { objectValue, queryInteger } 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")
if (!(await getBooleanOption("allow_register", true))) {
return failure(c, 403, "registration-disabled", "Register function has been disabled by admin")
}
const username = parsed.data.username.toLowerCase()
const email = parsed.data.email.toLowerCase()
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}`))
.limit(1)
if (duplicate?.username.toLowerCase() === username) {
return failure(c, 409, "username-exists", "Username already exists")
}
if (duplicate?.email?.toLowerCase() === email) {
return failure(c, 409, "email-exists", "Email already exists")
}
const now = new Date().toISOString()
const password = await Bun.password.hash(parsed.data.password, { algorithm: "argon2id" })
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",
authToken: null,
openApi: false,
openApiAppkey: null,
isDisabled: false,
problemPermission: "None",
sessionKeys: [],
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,
acmProblemsStatus: {},
avatar: `${config.avatarUriPrefix}/default.png`,
blog: null,
mood: null,
acceptedNumber: 0,
submissionNumber: 0,
github: null,
school: null,
major: null,
realName: null,
language: null,
})
})
return success(c, { ok: true }, 201)
})
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
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")
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]),
)
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")
return success(c, profile)
})
accountRoutes.post("/me/avatar", requireAuth, async (c) => {
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")
const extension = extname(image.name).toLowerCase()
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
return failure(c, 400, "unsupported-file", "Unsupported file format")
}
const filename = `${randomBytes(10).toString("hex")}${extension}`
const directory = resolve(config.avatarDirectory)
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))
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})` })
.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, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest }))
})
accountRoutes.get("/rankings/users", 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 top = queryInteger(c.req.query("top"), 0, { min: 0, max: 10_000 })
const username = c.req.query("username")?.trim() ?? ""
const where = and(
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
eq(schema.user.isDisabled, false),
gte(schema.userProfile.acceptedNumber, 0),
username ? ilike(schema.user.username, `%${username}%`) : undefined,
)
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
const rows = await 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))
.limit(top > 0 ? Math.min(top, 250) : limit).offset(top > 0 ? 0 : offset)
const results = rows.map(({ profile, user }) => rankProfileSchema.parse({
id: profile.id,
user: { id: user.id, username: user.username, realName: profile.realName },
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
}))
return success(c, userRankSchema.parse({ results, total: totalRow?.value ?? 0 }))
})
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")
const rows = await db.select({ username: schema.submission.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),
sql`${schema.user.adminType} <> 'Super Admin'`,
))
.groupBy(schema.submission.username).orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value })))
})
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 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)))))
: accepted
const [classCount] = className
? 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)
.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)))
rank = rankRow?.value ?? -1
}
return success(c, problemRankSchema.parse({ className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 }))
})
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))
}
return success(c, null)
})

View File

@@ -0,0 +1,121 @@
import {
achievementListSchema,
achievementSchema,
achievementSummarySchema,
markAchievementsReadSchema,
pendingAchievementSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { objectValue } from "./helpers"
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)
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)
return target ?? null
}
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
return pendingAchievementSchema.parse({
id: row.achievement.id,
name: row.achievement.name,
description: row.achievement.description,
icon: row.achievement.icon,
rarity: row.achievement.rarity,
})
}
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)),
])
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
const metrics = objectValue(statRows[0]?.metrics)
const active = activeRows[0]?.value ?? 0
const result = achievements.map((achievement) => {
const record = unlocked.get(achievement.id)
const masked = achievement.hidden && !record
const progress = metrics[achievement.metric]
return achievementSchema.parse({
id: achievement.id,
name: masked ? "???" : achievement.name,
description: masked ? "达成条件保密" : achievement.description,
icon: masked ? "noto:red-question-mark" : achievement.icon,
rarity: achievement.rarity,
hidden: achievement.hidden,
metric: masked ? null : achievement.metric,
operator: masked ? null : achievement.operator,
threshold: masked ? null : achievement.threshold,
unlocked: Boolean(record),
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,
})
})
return success(c, achievementListSchema.parse({ username: target.username, achievements: result }))
})
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)),
])
const labels = { bronze: "青铜", silver: "白银", gold: "黄金", platinum: "白金" }
const rarities = ["bronze", "silver", "gold", "platinum"] as const
const total = achievements.length
const unlocked = unlockedRows.length
return success(c, achievementSummarySchema.parse({
username: target.username,
total,
unlocked,
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,
})),
recent: unlockedRows.slice(0, 10).map(pendingData),
}))
})
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)))
.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")
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),
))
}
return success(c, null)
})

275
apps/api/src/routes/ai.ts Normal file
View File

@@ -0,0 +1,275 @@
import {
aiAnalysisRecordSchema,
aiAnalysisRequestSchema,
aiDetailSchema,
aiHintRequestSchema,
classAnalysisRequestSchema,
classPkAnalysisRequestSchema,
durationDataSchema,
heatmapItemSchema,
loginSummarySchema,
solvedProblemSchema,
} from "@oj2/contract"
import { and, asc, count, countDistinct, desc, eq, gte, inArray, isNull, lte, min, ne, sql } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin } from "../auth/session"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { completeChat, streamChat } from "../services/ai"
import { isTeacherOrAbove, objectValue, rounded } from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
const accepted = [0, 10]
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
function grade(rank: number | null, count: number, reference = count) {
if (!rank || count <= 0) return "C"
const percentile = (rank - 1) / count * 100
let value = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
return value
}
function averageGrade(grades: string[]) {
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
const values = grades.flatMap((item) => weights[item] ?? [])
if (!values.length) return ""
const average = values.reduce((sum, value) => sum + value, 0) / values.length
return average >= 3.5 ? "S" : average >= 2.5 ? "A" : average >= 1.5 ? "B" : "C"
}
async function targetUser(c: Context<AppEnv>) {
const current = c.get("user")!
const username = c.req.query("username")
if (!username || !isTeacherOrAbove(current)) return current
const [target] = await db.select({
id: schema.user.id,
username: schema.user.username,
email: schema.user.email,
adminType: schema.user.adminType,
problemPermission: schema.user.problemPermission,
isDisabled: schema.user.isDisabled,
className: schema.user.className,
}).from(schema.user).where(eq(schema.user.username, username)).limit(1)
return target ?? null
}
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const firstAc = await db.select({ problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
.from(schema.submission).where(and(
eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted),
gte(schema.submission.createTime, start), lte(schema.submission.createTime, end),
)).groupBy(schema.submission.problemId)
const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length) return success(c, aiDetailSchema.parse({
user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
}))
const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : []
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
const [problems, rankRows, periodRows, tagRows, flowRows] = await Promise.all([
db.select({ problem: schema.problem, contestTitle: schema.contest.title }).from(schema.problem)
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id)).where(inArray(schema.problem.id, problemIds)),
db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
.from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined))
.groupBy(schema.submission.userId, schema.submission.problemId),
db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
.from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined))
.groupBy(schema.submission.userId, schema.submission.problemId),
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)),
db.select({ flow: schema.flowchartSubmission, displayId: schema.problem.displayId, title: schema.problem.title })
.from(schema.flowchartSubmission).innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(and(eq(schema.flowchartSubmission.userId, user.id), eq(schema.flowchartSubmission.status, 2), gte(schema.flowchartSubmission.createTime, start), lte(schema.flowchartSubmission.createTime, end))),
])
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
function ranks(rows: typeof rankRows, problemId: number) {
return rows.filter((item) => item.problemId === problemId).sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId)
}
const solved = firstAc.flatMap((item) => {
const problem = byProblem.get(item.problemId)
if (!problem || !item.first) return []
const all = ranks(rankRows, item.problemId)
const period = ranks(periodRows, item.problemId)
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null
return solvedProblemSchema.parse({
problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId },
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
})
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
const tags: Record<string, number> = {}
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
const topTags = Object.fromEntries(Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 5))
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
for (const item of problems) {
const name = difficultyNames[item.problem.difficulty] ?? "中等"
difficulty[name] = (difficulty[name] ?? 0) + 1
}
const flowGroups = new Map<string, typeof flowRows>()
for (const flow of flowRows) flowGroups.set(flow.displayId, [...(flowGroups.get(flow.displayId) ?? []), flow])
const flowcharts = [...flowGroups].map(([displayId, rows]) => {
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
const best = Math.max(0, ...scores)
return {
problemId: displayId,
problemTitle: rows[0]?.title ?? "",
submissionCount: rows.length,
bestScore: best,
bestGrade: rows.find((row) => row.flow.aiScore === best)?.flow.aiGrade ?? "",
latestSubmissionTime: rows.map((row) => row.flow.createTime).sort().at(-1) ?? start,
avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0),
}
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
return success(c, aiDetailSchema.parse({
user: user.username, className: user.className, start, end, solved, flowcharts,
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
}))
})
function shiftMonths(date: Date, months: number) {
const result = new Date(date)
const day = result.getDate()
result.setDate(1)
result.setMonth(result.getMonth() + months)
result.setDate(Math.min(day, new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate()))
return result
}
aiRoutes.get("/ai/duration", requireAuth, async (c) => {
const endText = c.req.query("end")
if (!endText || Number.isNaN(Date.parse(endText))) return failure(c, 400, "invalid-end", "end must be an ISO timestamp")
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const duration = c.req.query("duration") ?? "months:1"
const config = duration === "months:2" ? { count: 8, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) }
: duration === "months:6" ? { count: 6, unit: "months", rewind: (date: Date) => shiftMonths(date, -7), advance: (date: Date) => shiftMonths(date, 1) }
: duration === "years:1" ? { count: 12, unit: "months", rewind: (date: Date) => shiftMonths(date, -13), advance: (date: Date) => shiftMonths(date, 1) }
: { count: 4, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) }
let cursor = config.rewind(new Date(endText))
const data = []
for (let index = 0; index < config.count; index++) {
const start = config.advance(cursor)
const end = config.advance(start)
cursor = start
const [submissions, solved] = await Promise.all([
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))),
db.select({ value: countDistinct(schema.submission.problemId) }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))),
])
data.push(durationDataSchema.parse({ unit: config.unit, index: config.count - 1 - index, start: start.toISOString(), end: end.toISOString(), grade: solved[0]?.value ? "B" : "", problemCount: solved[0]?.value ?? 0, submissionCount: submissions[0]?.value ?? 0 }))
}
return success(c, data)
})
aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
const end = new Date()
const start = new Date(end.getTime() - 365 * 864e5)
const date = sql<string>`date(${schema.submission.createTime})::text`
const rows = await db.select({ date, value: count() }).from(schema.submission)
.where(and(eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString())))
.groupBy(date).orderBy(date)
const counts = new Map(rows.map((row) => [row.date, row.value]))
return success(c, Array.from({ length: 365 }, (_, index) => {
const day = new Date(start.getTime() + index * 864e5)
return heatmapItemSchema.parse({ timestamp: new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime(), value: counts.get(day.toISOString().slice(0, 10)) ?? 0 })
}))
})
aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
const user = c.get("user")!
const end = new Date()
const [userRow] = await db.select({ createTime: schema.user.createTime, lastLogin: schema.user.lastLogin }).from(schema.user).where(eq(schema.user.id, user.id)).limit(1)
const previous = await getPreviousLogin(c)
let start = new Date(previous ?? userRow?.lastLogin ?? userRow?.createTime ?? end.getTime() - 7 * 864e5)
if (start >= end) start = new Date(end.getTime() - 864e5)
const range = and(gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] = await Promise.all([
db.select({ value: count() }).from(schema.problem).where(and(isNull(schema.problem.contestId), eq(schema.problem.visible, true), gte(schema.problem.createTime, start.toISOString()), lte(schema.problem.createTime, end.toISOString()))),
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), range)),
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), range)),
db.select({ value: countDistinct(schema.submission.problemId) }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), range)),
db.select({ value: count() }).from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, user.id), gte(schema.flowchartSubmission.createTime, start.toISOString()), lte(schema.flowchartSubmission.createTime, end.toISOString()))),
])
const summary = {
start: start.toISOString(), end: end.toISOString(), newProblemCount: newProblems[0]?.value ?? 0,
submissionCount: submissions[0]?.value ?? 0, acceptedCount: acceptedRows[0]?.value ?? 0,
solvedCount: solvedRows[0]?.value ?? 0, flowchartSubmissionCount: flowRows[0]?.value ?? 0,
}
let analysis = ""
let analysisError: string | undefined
if (summary.submissionCount >= 3) {
try {
analysis = await completeChat("你是 OnlineJudge 的学习助教。请根据统计数据给出简短分析(1-2句),再给出一行以“结论:”开头的结论。", JSON.stringify(summary))
} catch (error) {
analysisError = error instanceof Error ? error.message : String(error)
}
}
return success(c, loginSummarySchema.parse({ summary, analysis, analysisError }))
})
aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
const [row] = await db.select({ analysis: schema.aiAnalysis, username: schema.user.username }).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1)
if (!row) return success(c, null)
return success(c, aiAnalysisRecordSchema.parse({
id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data),
analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username,
}))
})
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = aiAnalysisRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "details and duration are required")
const user = c.get("user")!
const system = "你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown不要放在代码块中。"
const prompt = `详细数据: ${JSON.stringify(parsed.data.details)}\n每周或每月数据: ${JSON.stringify(parsed.data.duration)}`
return streamChat(system, prompt, async (analysis) => {
await db.insert(schema.aiAnalysis).values({
provider: "deepseek", model: config.aiModel, data: parsed.data, systemPrompt: system,
userPrompt: "学习详情与周期数据", analysis, createTime: new Date().toISOString(), userId: user.id, isPinned: false,
})
})
})
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = aiHintRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "submissionId is required")
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, c.get("user")!.id))).limit(1)
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
const answers = Array.isArray(row.problem.answers) ? row.problem.answers.filter((item): item is { language?: unknown; code?: unknown } => Boolean(item && typeof item === "object")) : []
const selected = answers.find((item) => item.language === row.submission.language) ?? answers[0]
const reference = typeof selected?.code === "string" ? selected.code : ""
const system = "你是编程助教。对照参考答案指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown不超过6句话。"
const prompt = `题目:${row.problem.title}\n描述${row.problem.description.slice(0, 500)}\n参考答案不可透露${reference.slice(0, 2000)}\n语言${row.submission.language}\n结果${row.submission.result}\n错误${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码${row.submission.code.slice(0, 2000)}`
return streamChat(system, prompt)
})
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
const parsed = classAnalysisRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Class data is required")
return streamChat("你是编程教育数据分析专家。根据班级 OJ 数据,从整体水平、参与积极性、均衡性、梯队和改进建议五方面输出中文 Markdown 报告。", JSON.stringify(parsed.data.comparison))
})
aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user"))) return failure(c, 403, "permission-denied", "Permission denied")
const parsed = classPkAnalysisRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "At least two classes are required")
return streamChat("你是编程教育数据分析专家。根据多个班级 OJ 对比数据,从排名、参与度、典型学生水平、均衡性、梯队、提交质量和教学建议七方面输出中文 Markdown 报告。", `${parsed.data.timeRangeLabel}\n${JSON.stringify(parsed.data.comparisons)}`)
})

View File

@@ -11,6 +11,7 @@ import { createSession, destroySession } from "../auth/session"
import { verifyPassword } from "../auth/password" import { verifyPassword } from "../auth/password"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { getUserProfileById } from "../services/profile"
export const authRoutes = new Hono<AppEnv>() export const authRoutes = new Hono<AppEnv>()
@@ -48,7 +49,7 @@ authRoutes.post("/auth/login", async (c) => {
}) })
} }
await db.update(schema.user).set(update).where(eq(schema.user.id, user.id)) await db.update(schema.user).set(update).where(eq(schema.user.id, user.id))
await createSession(c, user.id) await createSession(c, user.id, user.lastLogin)
return success(c, { ok: true }) return success(c, { ok: true })
}) })
@@ -62,49 +63,7 @@ authRoutes.get("/me", optionalAuth, async (c) => {
const authUser = c.get("user") const authUser = c.get("user")
if (!authUser) return success(c, null) if (!authUser) return success(c, null)
const [row] = await db const data = await getUserProfileById(authUser.id, true)
.select({ if (!data) return failure(c, 404, "profile-not-found", "User profile does not exist")
profile: schema.userProfile,
user: schema.user,
})
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.user.id, authUser.id),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!row) return failure(c, 404, "profile-not-found", "User profile does not exist")
const data = userProfileSchema.parse({
id: row.profile.id,
user: sessionUserSchema.parse({
id: row.user.id,
username: row.user.username,
email: row.user.email,
adminType: row.user.adminType,
problemPermission: row.user.problemPermission,
createTime: row.user.createTime,
lastLogin: row.user.lastLogin,
openApi: row.user.openApi,
isDisabled: row.user.isDisabled,
className: row.user.className,
}),
realName: row.profile.realName,
acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar,
blog: row.profile.blog,
mood: row.profile.mood,
github: row.profile.github,
school: row.profile.school,
major: row.profile.major,
language: row.profile.language,
acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber,
})
return success(c, data) return success(c, data)
}) })

View File

@@ -0,0 +1,210 @@
import {
classComparisonRequestSchema,
classComparisonResponseSchema,
classComparisonSchema,
classRankItemSchema,
classUserRankSchema,
} from "@oj2/contract"
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { queryInteger, rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>()
interface ClassUser {
userId: number
username: string
className: string
acceptedNumber: number
submissionNumber: number
}
async function loadClassUsers(classNames?: string[]) {
const filters = [
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
sql`${schema.user.className} is not null`,
]
if (classNames) filters.push(inArray(schema.user.className, classNames))
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
}
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
}
function quantile(values: number[], p: number) {
if (values.length <= 1) return values[0] ?? 0
const sorted = [...values].sort((a, b) => a - b)
const position = (sorted.length + 1) * p - 1
if (position <= 0) return sorted[0]!
if (position >= sorted.length - 1) return sorted.at(-1)!
const lower = Math.floor(position)
const fraction = position - lower
return sorted[lower]! + (sorted[lower + 1]! - sorted[lower]!) * fraction
}
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))
}
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")
const users = (await loadClassUsers()).filter((user) => user.className.startsWith(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) => classRankItemSchema.parse({ ...item, rank: index + 1 })))
})
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const user = c.get("user")!
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,
)
const ranks = members.map((member, index) => ({
userId: member.userId,
username: member.username,
acceptedNumber: member.acceptedNumber,
submissionNumber: member.submissionNumber,
rank: index + 1,
}))
const myRank = ranks.find((rank) => rank.userId === user.id)?.rank ?? -1
const showAll = c.req.query("scope") === "all"
let selected = ranks
if (showAll) {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
selected = ranks.slice(offset, offset + limit)
} else if (myRank > 0 && ranks.length > 10) {
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
selected = ranks.slice(start, start + 10)
}
return success(c, classUserRankSchema.parse({ className: user.className, myRank, total: ranks.length, ranks: selected }))
})
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 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])
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]))
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)) {
const set = recentByUser.get(row.userId) ?? new Set<number>()
set.add(row.problemId)
recentByUser.set(row.userId, set)
}
}
}
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 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 totalAc = ac.reduce((sum, value) => sum + value, 0)
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
const base: Record<string, number | string> = {
className,
userCount,
totalAc,
totalSubmission,
avgAc: rounded(mean(ac)),
medianAc: rounded(median(ac)),
q1Ac: rounded(quantile(ac, 0.25)),
q3Ac: rounded(quantile(ac, 0.75)),
iqr: rounded(quantile(ac, 0.75) - quantile(ac, 0.25)),
stdDev: rounded(sampleStdDev(ac)),
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,
compositeScore: 0,
}
if (hasTimeRange) {
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.recentActiveCount = recent.filter((value) => value > 0).length
}
return base
})
const maxMedian = Math.max(1, ...comparisons.map((item) => Number(item.medianAc)))
const maxMiddle = Math.max(1, ...comparisons.map((item) => Number(item.middle80Avg)))
for (const item of comparisons) {
item.compositeScore = rounded(
0.4 * (Number(item.medianAc) / maxMedian * 100) +
0.15 * (Number(item.middle80Avg) / maxMiddle * 100) +
0.2 * Number(item.activeRate) +
0.15 * Number(item.passRate) +
0.1 * Number(item.excellentRate),
1,
)
}
comparisons.sort((a, b) => Number(b.compositeScore) - Number(a.compositeScore) || Number(b.medianAc) - Number(a.medianAc))
return success(c, classComparisonResponseSchema.parse({
comparisons: comparisons.map((item) => classComparisonSchema.parse(item)),
hasTimeRange,
}))
})

View File

@@ -0,0 +1,209 @@
import {
announcementListSchema,
announcementSchema,
createMessageRequestSchema,
exerciseSchema,
messageListSchema,
messageSchema,
reactionKeySchema,
reactionStateSchema,
setReactionRequestSchema,
submissionDetailSchema,
tutorialSchema,
tutorialSummarySchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { isSuperAdmin, objectValue, queryInteger } from "./helpers"
export const contentRoutes = new Hono<AppEnv>()
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))
.where(eq(schema.announcement.visible, true))
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
])
return success(c, announcementListSchema.parse({
results: rows.map(({ announcement, user, realName }) => announcementSchema.parse({
id: announcement.id,
title: announcement.title,
tag: announcement.tag,
top: announcement.top,
createdBy: { id: user.id, username: user.username, realName },
createTime: announcement.createTime,
lastUpdateTime: announcement.lastUpdateTime,
})),
total: totalRows[0]?.value ?? 0,
}))
})
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))
.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")
return success(c, announcementSchema.parse({
id: row.announcement.id,
title: row.announcement.title,
tag: row.announcement.tag,
content: row.announcement.content,
top: row.announcement.top,
createdBy: { id: row.user.id, username: row.user.username, realName: row.realName },
createTime: row.announcement.createTime,
lastUpdateTime: row.announcement.lastUpdateTime,
}))
})
contentRoutes.get("/messages", requireAuth, async (c) => {
const user = c.get("user")!
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 })
.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))
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
])
return success(c, messageListSchema.parse({
results: rows.map(({ message, sender, realName, submission }) => messageSchema.parse({
id: message.id,
sender: { id: sender.id, username: sender.username, realName },
createTime: message.createTime,
message: message.message,
submission: submissionDetailSchema.parse({
id: submission.id,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
code: submission.code,
result: submission.result,
info: {},
language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo),
ip: null,
contestId: submission.contestId,
problemId: submission.problemId,
showLink: true,
canUnshare: false,
}),
})),
total: totalRows[0]?.value ?? 0,
}))
})
contentRoutes.post("/messages", requireAuth, async (c) => {
const user = c.get("user")!
if (!isSuperAdmin(user)) return failure(c, 403, "permission-denied", "Permission denied")
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),
])
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(),
recipientId: recipient.id,
senderId: user.id,
submissionId: submission.id,
})
return success(c, null, 201)
})
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)
if (!mine) return reactionStateSchema.parse({ mine: null, counts: null })
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 counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0]))
for (const row of rows) {
const key = reactionKeySchema.safeParse(row.type)
if (key.success) counts[key.data] = row.value
}
return reactionStateSchema.parse({ mine: mine.type, counts })
}
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
return success(c, await reactionState(problemId, c.get("user")!.id))
})
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 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),
])
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))
return success(c, rows.map((row) => tutorialSummarySchema.parse(row)))
})
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))
.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")
return success(c, tutorialSchema.parse({
id: row.tutorial.id,
title: row.tutorial.title,
content: row.tutorial.content,
code: row.tutorial.code,
isPublic: row.tutorial.isPublic,
order: row.tutorial.order,
type: row.tutorial.type,
createdBy: { id: row.user.id, username: row.user.username, realName: row.realName },
createdAt: row.tutorial.createdAt,
updatedAt: row.tutorial.updatedAt,
}))
})
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) => exerciseSchema.parse({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order })))
})

View File

@@ -0,0 +1,218 @@
import {
contestAccessSchema,
contestListSchema,
contestPasswordRequestSchema,
contestRankItemSchema,
contestRankSchema,
contestSchema,
problemDetailSchema,
problemListItemSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { setContestPassword } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import {
canAccessContest,
checkContestPassword,
contestDetailsAllowed,
contestStatus,
findVisibleContest,
isContestAdmin,
} from "../services/contest"
import { objectValue, publicTemplates, queryInteger, stringArray } from "./helpers"
export const contestRoutes = new Hono<AppEnv>()
async function creator(id: number) {
const [row] = 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(eq(schema.user.id, id)).limit(1)
return row ?? { id, username: "", realName: null }
}
async function serializeContest(contest: typeof schema.contest.$inferSelect, 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: await creator(contest.createdById),
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),
])
return success(c, contestListSchema.parse({
results: await Promise.all(rows.map((row) => serializeContest(row))),
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")
return success(c, await serializeContest(contest, 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, 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 access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
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: { id: user.id, username: user.username, 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, 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 access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
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: { id: row.user.id, username: row.user.username, realName: 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,
}))
})
contestRoutes.get("/contests/:id/rank", optionalAuth, 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 access = await canAccessContest(c, contest, "ranks")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
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,
user: { id: user.id, username: user.username, realName: admin ? realName : null },
submissionNumber: rank.submissionNumber,
acceptedNumber: rank.acceptedNumber,
totalTime: rank.totalTime,
submissionInfo: objectValue(rank.submissionInfo),
contestId: rank.contestId,
})),
total: totalRows[0]?.value ?? 0,
}))
})

View File

@@ -0,0 +1,171 @@
import { randomBytes } from "node:crypto"
import {
createFlowchartRequestSchema,
createFlowchartResponseSchema,
flowchartCurrentSchema,
flowchartDetailSchema,
flowchartListItemSchema,
flowchartListSchema,
flowchartSubmissionSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { flowchartQueue } from "../queue"
import { isAdminRole, objectValue, queryInteger, todayStart } from "./helpers"
export const flowchartRoutes = new Hono<AppEnv>()
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(
flowchart: typeof schema.flowchartSubmission.$inferSelect,
username: string,
) {
return flowchartSubmissionSchema.parse({
id: flowchart.id,
username,
problemId: flowchart.problemId,
mermaidCode: flowchart.mermaidCode,
flowchartData: objectValue(flowchart.flowchartData),
status: flowchart.status,
createTime: flowchart.createTime,
aiScore: flowchart.aiScore,
aiGrade: flowchart.aiGrade,
aiFeedback: flowchart.aiFeedback,
aiSuggestions: flowchart.aiSuggestions,
aiCriteriaDetails: objectValue(flowchart.aiCriteriaDetails),
aiProvider: flowchart.aiProvider,
aiModel: flowchart.aiModel,
processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime,
})
}
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 [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 id = randomBytes(16).toString("hex")
await db.insert(schema.flowchartSubmission).values({
id,
userId: c.get("user")!.id,
problemId: problem.id,
mermaidCode: parsed.data.mermaidCode,
flowchartData: parsed.data.flowchartData,
status: 0,
createTime: new Date().toISOString(),
aiScore: null,
aiGrade: null,
aiFeedback: null,
aiSuggestions: null,
aiCriteriaDetails: {},
aiProvider: "deepseek",
aiModel: config.aiModel,
processingTime: null,
evaluationTime: null,
})
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")
}
return success(c, createFlowchartResponseSchema.parse({ submissionId: id, status: "pending" }), 201)
})
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const user = c.get("user")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = []
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const grade = c.req.query("grade")
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
else if (username) filters.push(ilike(schema.user.username, `%${username}%`))
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 = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)).innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where),
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(where)
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
])
return success(c, flowchartListSchema.parse({
results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({
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),
})),
total: totalRows[0]?.value ?? 0,
}))
})
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")
return success(c, flowchartData(row.flowchart, row.username))
})
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
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(c.get("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")
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 flowchartQueue.add("evaluate", { submissionId: row.flowchart.id }, { jobId: `${row.flowchart.id}:${Date.now()}` })
return success(c, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" }))
})
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, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" }))
})
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, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length }))
})

View File

@@ -0,0 +1,62 @@
import type { AuthUser } from "../auth/session"
export function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
export function queryInteger(
value: string | undefined,
fallback: number,
options: { min?: number; max?: number } = {},
) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
if (options.min !== undefined && parsed < options.min) return fallback
if (options.max !== undefined && parsed > options.max) return fallback
return parsed
}
export function isRegularUser(user: AuthUser | null | undefined) {
return user?.adminType === "Regular User"
}
export function isAdminRole(user: AuthUser | null | undefined) {
return Boolean(user && user.adminType !== "Regular User")
}
export function isTeacherOrAbove(user: AuthUser | null | undefined) {
return user?.adminType === "Teacher Admin" || user?.adminType === "Super Admin"
}
export function isSuperAdmin(user: AuthUser | null | undefined) {
return user?.adminType === "Super Admin"
}
export 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
}
export function todayStart() {
const now = new Date()
now.setHours(0, 0, 0, 0)
return now.toISOString()
}
export function rounded(value: number, digits = 2) {
const factor = 10 ** digits
return Math.round(value * factor) / factor
}

View File

@@ -1,10 +1,34 @@
import { problemDetailSchema, problemSummarySchema } from "@oj2/contract" import {
import { and, count, desc, eq, isNull, notInArray } from "drizzle-orm" problemAuthorSchema,
problemDetailSchema,
problemListItemSchema,
problemListSchema,
problemSummarySchema,
tagSchema,
yearlyAcSchema,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
desc,
eq,
gte,
ilike,
inArray,
isNull,
notInArray,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware" import { optionalAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { objectValue as toObject, queryInteger } from "./helpers"
export const problemRoutes = new Hono<AppEnv>() export const problemRoutes = new Hono<AppEnv>()
@@ -28,7 +52,183 @@ function publicTemplates(value: unknown) {
return templates return templates
} }
problemRoutes.get("/problems", async (c) => { 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)
}
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
}
function listItem(
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 problemListItemSchema.parse({
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: { id: row.user.id, username: row.user.username, realName: 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,
})
}
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, 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, problemListSchema.parse({
results: rows.map((row) => listItem(row, tags, statuses)),
total: totalRow?.value ?? 0,
}))
})
problemRoutes.get("/problem-tags", 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)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.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.map((row) => tagSchema.parse(row)))
})
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)
})
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.map((row) => problemAuthorSchema.parse(row)))
})
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 = new Date(); since.setFullYear(since.getFullYear() - 2); since.setHours(0, 0, 0, 0)
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.toISOString()))),
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.toISOString()),
)),
])
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)))
})
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 ${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) => yearlyAcSchema.parse({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 })))
})
problemRoutes.get("/dev/problems", async (c) => {
const rows = await db const rows = await db
.select({ .select({
id: schema.problem.id, id: schema.problem.id,

View File

@@ -0,0 +1,415 @@
import {
problemListItemSchema,
problemSetBadgeSchema,
problemSetListSchema,
problemSetProblemSchema,
problemSetProgressListSchema,
problemSetProgressSchema,
problemSetSchema,
updateProblemSetProgressRequestSchema,
joinProblemSetRequestSchema,
userBadgeSchema,
} from "@oj2/contract"
import {
and,
asc,
avg,
count,
desc,
eq,
gt,
ilike,
inArray,
isNull,
ne,
or,
sql,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements"
import { isTeacherOrAbove, objectValue, queryInteger } from "./helpers"
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,
}
}
async function problemSetCreator(id: number) {
const [row] = 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(eq(schema.user.id, id)).limit(1)
return row ?? { id, username: "", realName: null }
}
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
return problemSetBadgeSchema.parse({
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
description: badge.description,
icon: badge.icon,
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
isEarned: earned,
})
}
async function serializeProblemSet(
row: ProblemSetRow,
userId?: number,
includeBadges = false,
) {
const [[problemCount], [progress], badges, earnedRows] = await Promise.all([
db.select({ value: count() }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id)),
userId ? db.select().from(schema.problemsetProgress).where(and(eq(schema.problemsetProgress.problemsetId, row.id), eq(schema.problemsetProgress.userId, userId))).limit(1) : Promise.resolve([]),
includeBadges ? db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)) : Promise.resolve([]),
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), eq(schema.problemsetBadge.problemsetId, row.id))) : Promise.resolve([]),
])
const earned = new Set(earnedRows.map((item) => item.id))
return problemSetSchema.parse({
id: row.id,
title: row.title,
description: row.description,
createdBy: await problemSetCreator(row.createdById),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
difficulty: row.difficulty,
status: row.status,
endTime: row.endTime,
visible: row.visible,
problemsCount: problemCount?.value ?? 0,
completedCount: progress?.completedProblemsCount ?? 0,
userProgress: progressSummary(progress),
badges: includeBadges ? badges.map((badge) => badgeData(badge, earned.has(badge.id))) : undefined,
})
}
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 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, difficulty))
if (status) filters.push(eq(schema.problemset.status, 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),
])
return success(c, problemSetListSchema.parse({
results: await Promise.all(rows.map((row) => serializeProblemSet(row, c.get("user")?.id, true))),
total: totalRows[0]?.value ?? 0,
}))
})
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)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
return success(c, await serializeProblemSet(row, c.get("user")?.id))
})
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)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db.select({ link: schema.problemsetProblem, problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problemsetProblem).innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order))
const problemIds = rows.map((row) => row.problem.id)
const [tagRows, progressRows] = await Promise.all([
problemIds.length ? 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)) : Promise.resolve([]),
c.get("user") ? 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) : Promise.resolve([]),
])
const tags = new Map<number, string[]>()
for (const tag of tagRows) tags.set(tag.problemId, [...(tags.get(tag.problemId) ?? []), tag.name])
const completed = objectValue(progressRows[0]?.detail)
return success(c, rows.map(({ link, problem, user, realName }) => problemSetProblemSchema.parse({
id: link.id,
problemsetId: link.problemsetId,
problem: problemListItemSchema.parse({
id: problem.id,
_id: problem.displayId,
title: problem.title,
submissionNumber: problem.submissionNumber,
acceptedNumber: problem.acceptedNumber,
difficulty: problem.difficulty,
createdBy: { id: user.id, username: user.username, realName },
tags: tags.get(problem.id) ?? [],
contestId: problem.contestId,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null,
myStatus: null,
}),
order: link.order,
isRequired: link.isRequired,
score: link.score,
hint: link.hint,
isCompleted: String(problem.id) in completed,
})))
})
async function recomputeProgress(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
progress: typeof schema.problemsetProgress.$inferSelect,
detail: Record<string, unknown>,
) {
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
const valid = new Map(links.map((link) => [String(link.problemId), link.score]))
for (const key of Object.keys(detail)) if (!valid.has(key)) delete detail[key]
let totalScore = 0
for (const [key, value] of Object.entries(detail)) {
const score = valid.get(key)
if (score === undefined) continue
totalScore += score
detail[key] = { ...objectValue(value), score }
}
const completed = Object.keys(detail).length
const total = links.length
const isCompleted = completed === total
const update = {
progressDetail: detail,
totalProblemsCount: total,
completedProblemsCount: completed,
totalScore,
progressPercentage: total > 0 ? completed / total * 100 : 0,
isCompleted,
completeTime: isCompleted ? progress.completeTime ?? new Date().toISOString() : null,
}
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 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)
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)
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()
if (created) await recomputeProgress(tx, created, {})
})
return success(c, null, 201)
})
problemsetRoutes.put("/problem-set-progress", requireAuth, async (c) => {
const parsed = updateProblemSetProgressRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
const user = c.get("user")!
const result = await db.transaction(async (tx) => {
const [problemSet] = await tx.select().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 { error: "problem-set-not-found" as const }
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id),
)).for("update").limit(1)
if (!progress) return { error: "not-joined" as const }
const [submission] = await tx.select().from(schema.submission).where(and(
eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, user.id), eq(schema.submission.problemId, parsed.data.problemId),
)).limit(1)
if (!submission) return { error: "submission-not-found" as const }
if (![JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(submission.result as 0 | 10)) return { error: "submission-not-accepted" as const }
const [link] = await tx.select().from(schema.problemsetProblem).where(and(
eq(schema.problemsetProblem.problemsetId, problemSet.id), eq(schema.problemsetProblem.problemId, parsed.data.problemId),
)).limit(1)
if (!link) return { error: "problem-not-in-set" as const }
const detail = objectValue(progress.progressDetail)
detail[String(parsed.data.problemId)] = { score: link.score, submit_time: new Date().toISOString() }
const updated = await recomputeProgress(tx, progress, detail)
const [existingSubmission] = await tx.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, problemSet.id),
eq(schema.problemsetSubmission.userId, user.id),
eq(schema.problemsetSubmission.problemId, parsed.data.problemId),
)).limit(1)
if (!existingSubmission) {
await tx.insert(schema.problemsetSubmission).values({
problemsetId: problemSet.id,
userId: user.id,
submissionId: submission.id,
problemId: parsed.data.problemId,
})
}
const badges = await tx.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, problemSet.id))
const earned: typeof schema.problemsetBadge.$inferSelect[] = []
for (const badge of badges) {
const hit = badge.conditionType === "all_problems"
? updated.totalProblemsCount > 0 && updated.completedProblemsCount === updated.totalProblemsCount
: badge.conditionType === "problem_count"
? updated.completedProblemsCount >= badge.conditionValue
: badge.conditionType === "score" && updated.totalScore >= badge.conditionValue
if (!hit) continue
const inserted = await tx.insert(schema.userBadge).values({
userId: user.id,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
}).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] }).returning({ id: schema.userBadge.id })
if (inserted.length) earned.push(badge)
}
return { earned }
})
if ("error" in result && result.error) {
const error = result.error
const messages = {
"problem-set-not-found": "题单不存在",
"not-joined": "未加入该题单",
"submission-not-found": "提交记录不存在",
"submission-not-accepted": "只有通过的提交才能更新进度",
"problem-not-in-set": "题目不在题单中",
}
return failure(c, error.endsWith("not-found") ? 404 : 400, error, messages[error])
}
const unlocked = await updateAchievementsForProblemSet(user.id)
await Promise.all([
publishAchievementNotification(user.id, result.earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
}))),
publishAchievementNotification(user.id, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
}))),
])
return success(c, { earnedBadges: result.earned.map((badge) => badgeData(badge)) })
})
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 }) => userBadgeSchema.parse({
id: userBadge.id,
userId: userBadge.userId,
badge: badgeData(badge),
earnedTime: userBadge.earnedTime,
problemset: { id: problemSet.id, title: problemSet.title },
})))
})
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)
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)))
})
problemsetRoutes.get("/problem-sets/:id/user-progress", requireAuth, async (c) => {
const user = c.get("user")!
if (!isTeacherOrAbove(user)) return failure(c, 403, "permission-denied", "Permission denied")
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)
if (!problemSet) 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)),
])
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({
id: progress.id,
problemsetId: progress.problemsetId,
user: { id: progressUser.id, username: progressUser.username, 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) ?? []),
}))
const stats = statsRows[0]
return success(c, problemSetProgressListSchema.parse({
results,
total: stats?.total ?? 0,
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
problems: problemRows,
}))
})

View File

@@ -0,0 +1,47 @@
import { quoteSchema, websiteConfigSchema } from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { getWebsiteOptions } from "../services/options"
export const siteRoutes = new Hono()
siteRoutes.get("/site", async (c) => {
const options = await getWebsiteOptions()
return success(c, websiteConfigSchema.parse({
websiteBaseUrl: options.website_base_url,
websiteName: options.website_name,
websiteNameShortcut: options.website_name_shortcut,
websiteFooter: options.website_footer,
allowRegister: options.allow_register,
submissionListShowAll: options.submission_list_show_all,
classList: options.class_list,
enableMaxkb: options.enable_maxkb,
}))
})
const quotes = [
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
]
siteRoutes.get("/quotes/random", (c) => {
const item = quotes[Math.floor(Math.random() * quotes.length)] ?? quotes[0]
return success(c, quoteSchema.parse(item))
})
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")
}
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
.where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
return success(c, rows.map(({ username }) => username.replace(`ks${className}`, "")))
})

View File

@@ -3,16 +3,32 @@ import { randomBytes } from "node:crypto"
import { import {
createSubmissionRequestSchema, createSubmissionRequestSchema,
createSubmissionResponseSchema, createSubmissionResponseSchema,
formatCodeRequestSchema,
formatCodeResponseSchema,
shareSubmissionRequestSchema,
submissionDetailSchema, submissionDetailSchema,
submissionListItemSchema,
submissionListSchema,
} from "@oj2/contract" } from "@oj2/contract"
import { and, eq, isNull } from "drizzle-orm" import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { judgeQueue } from "../queue" import { judgeQueue } from "../queue"
import {
canAccessContest,
contestStatus,
findVisibleContest,
ipAllowed,
isContestAdmin,
} from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options"
import { isAdminRole, isRegularUser, queryInteger, todayStart } from "./helpers"
export const submissionRoutes = new Hono<AppEnv>() export const submissionRoutes = new Hono<AppEnv>()
@@ -28,23 +44,29 @@ function objectValue(value: unknown): Record<string, unknown> {
: {} : {}
} }
submissionRoutes.use("/submissions", requireAuth) function requestIp(c: { req: { header(name: string): string | undefined } }) {
submissionRoutes.use("/submissions/*", requireAuth) const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim()
return forwarded || c.req.header("x-real-ip") || null
}
submissionRoutes.post("/submissions", async (c) => { submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = createSubmissionRequestSchema.safeParse( const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null), await c.req.json().catch(() => null),
) )
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", "Invalid submission payload") return failure(c, 400, "invalid-request", "Invalid submission payload")
} }
let contestId: number | null = null
if (parsed.data.contestId) { if (parsed.data.contestId) {
return failure( const contest = await findVisibleContest(parsed.data.contestId)
c, if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
400, const access = await canAccessContest(c, contest, "problems")
"contest-not-supported", if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
"Contest submissions are not part of the Phase 2 slice", if (contestStatus(contest) === "-1") return failure(c, 403, "contest-ended", "The contest has ended")
) if (!isContestAdmin(c.get("user"), contest) && !ipAllowed(requestIp(c), contest.allowedIpRanges)) {
return failure(c, 403, "ip-not-allowed", "Your IP is not allowed in this contest")
}
contestId = contest.id
} }
const [problem] = await db const [problem] = await db
@@ -57,7 +79,7 @@ submissionRoutes.post("/submissions", async (c) => {
and( and(
eq(schema.problem.id, parsed.data.problemId), eq(schema.problem.id, parsed.data.problemId),
eq(schema.problem.visible, true), eq(schema.problem.visible, true),
isNull(schema.problem.contestId), contestId === null ? isNull(schema.problem.contestId) : eq(schema.problem.contestId, contestId),
), ),
) )
.limit(1) .limit(1)
@@ -75,8 +97,7 @@ submissionRoutes.post("/submissions", async (c) => {
const user = c.get("user")! const user = c.get("user")!
const submissionId = randomBytes(16).toString("hex") const submissionId = randomBytes(16).toString("hex")
const createTime = new Date().toISOString() const createTime = new Date().toISOString()
const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() const ip = requestIp(c)
const ip = forwarded || c.req.header("x-real-ip") || null
await db.insert(schema.submission).values({ await db.insert(schema.submission).values({
id: submissionId, id: submissionId,
@@ -91,7 +112,7 @@ submissionRoutes.post("/submissions", async (c) => {
shared: false, shared: false,
statisticInfo: {}, statisticInfo: {},
ip, ip,
contestId: null, contestId,
}) })
try { try {
@@ -116,40 +137,180 @@ submissionRoutes.post("/submissions", async (c) => {
) )
}) })
submissionRoutes.get("/submissions/:id", async (c) => { submissionRoutes.get("/submissions/today-count", async (c) => {
const user = c.get("user")! const language = c.req.query("language")
const [row] = await db if (language === "Flowchart") {
.select() const [row] = await db.select({ value: count() }).from(schema.flowchartSubmission)
.from(schema.submission) .where(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
.where( return success(c, row?.value ?? 0)
and( }
eq(schema.submission.id, c.req.param("id")), const [row] = await db.select({ value: count() }).from(schema.submission)
eq(schema.submission.userId, user.id), .where(and(isNull(schema.submission.contestId), sql`${schema.submission.createTime} >= ${todayStart()}`))
), return success(c, row?.value ?? 0)
) })
.limit(1)
if (!row) { submissionRoutes.post("/code/format", requireAuth, async (c) => {
const parsed = formatCodeRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload")
try {
const code = await formatCode(parsed.data.code, parsed.data.language)
return success(c, formatCodeResponseSchema.parse({ code }))
} catch (error) {
if (error instanceof CodeFormatError) {
return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message)
}
throw error
}
})
function canViewSubmission(
user: AuthUser | null,
row: typeof schema.submission.$inferSelect,
problem: typeof schema.problem.$inferSelect,
contest: typeof schema.contest.$inferSelect | null,
allowShared = true,
) {
if (!user) return false
if (row.userId === user.id || isAdminRole(user) || problem.createdById === user.id) return true
if (!allowShared) return false
if (contest && contestStatus(contest) !== "-1") return false
return problem.shareSubmission || row.shared
}
async function submissionDetail(id: string, user: AuthUser) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, id)).limit(1)
if (!row || !canViewSubmission(user, row.submission, row.problem, row.contest)) return null
const full = isAdminRole(user) || row.submission.userId === user.id
return submissionDetailSchema.parse({
id: row.submission.id,
createTime: row.submission.createTime,
userId: row.submission.userId,
username: row.submission.username,
code: row.submission.code,
result: row.submission.result,
info: full ? row.submission.info : {},
language: row.submission.language,
shared: row.submission.shared,
statisticInfo: objectValue(row.submission.statisticInfo),
ip: full ? row.submission.ip : null,
contestId: row.submission.contestId,
problemId: row.submission.problemId,
showLink: true,
canUnshare: canViewSubmission(user, row.submission, row.problem, row.contest, false),
})
}
submissionRoutes.get("/submissions", 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 user = c.get("user")
if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) {
return success(c, submissionListSchema.parse({ results: [], total: 0 }))
}
const filters = [isNull(schema.submission.contestId)]
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const result = c.req.query("result")
const language = c.req.query("language")?.trim()
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
if (language) filters.push(eq(schema.submission.language, language))
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where),
db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
])
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, null) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
result: submission.result,
language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo),
})),
total: totalRows[0]?.value ?? 0,
}))
})
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("contestId"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "submissions")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
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.submission.contestId, contest.id)]
const user = c.get("user")
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const result = c.req.query("result")
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
const where = and(...filters)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where),
db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
])
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, contest) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
result: submission.result,
language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo),
})),
total: totalRows[0]?.value ?? 0,
}))
})
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {
const user = c.get("user")!
const data = await submissionDetail(c.req.param("id"), user)
if (!data) {
return failure(c, 404, "submission-not-found", "Submission does not exist") return failure(c, 404, "submission-not-found", "Submission does not exist")
} }
const data = submissionDetailSchema.parse({
id: row.id,
createTime: row.createTime,
userId: row.userId,
username: row.username,
code: row.code,
result: row.result,
info: row.info,
language: row.language,
shared: row.shared,
statisticInfo: objectValue(row.statisticInfo),
ip: row.ip,
contestId: row.contestId,
problemId: row.problemId,
showLink: true,
canUnshare: true,
})
return success(c, data) return success(c, data)
}) })
submissionRoutes.put("/submissions/:id", requireAuth, async (c) => {
const parsed = shareSubmissionRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid share payload")
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, c.req.param("id"))).limit(1)
if (!row || !canViewSubmission(c.get("user")!, row.submission, row.problem, row.contest, false)) {
return failure(c, 404, "submission-not-found", "Submission does not exist")
}
if (row.contest && contestStatus(row.contest) === "0") {
return failure(c, 403, "contest-underway", "Can not share submission now")
}
await db.update(schema.submission).set({ shared: parsed.data.shared }).where(eq(schema.submission.id, row.submission.id))
return success(c, null)
})

View File

@@ -0,0 +1,160 @@
import { and, count, eq, isNull, ne, notInArray, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { isAccepted, JudgeStatus } from "../judge/status"
import { objectValue } from "../routes/helpers"
function numberMetric(metrics: Record<string, unknown>, key: string) {
const value = metrics[key]
return typeof value === "number" ? value : 0
}
function localDate(value: string) {
const date = new Date(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) {
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, userId))
const filters = [eq(schema.achievement.visible, true)]
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
const candidates = await db.select().from(schema.achievement).where(and(...filters))
const created: typeof schema.achievement.$inferSelect[] = []
for (const achievement of candidates) {
const value = metrics[achievement.metric]
if (typeof value !== "number") continue
const hit = achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold
if (!hit) continue
const inserted = await db.insert(schema.userAchievement).values({
userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: false,
notified: false,
}).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }).returning({ id: schema.userAchievement.id })
if (inserted.length) {
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` }).where(eq(schema.achievement.id, achievement.id))
created.push(achievement)
}
}
return created
}
export async function updateAchievementsForSubmission(submissionId: string) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)).limit(1)
if (!row || row.submission.contestId !== null) return []
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
ne(schema.submission.id, row.submission.id),
))
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
const accepted = isAccepted(row.submission.result)
const firstAc = accepted && !priorAccepted
const firstTry = accepted && priorRows.length === 0
const date = localDate(row.submission.createTime)
const hour = new Date(row.submission.createTime).getHours()
const metrics = await db.transaction(async (tx) => {
await tx.insert(schema.userStat).values({
userId: row.submission.userId,
metrics: {},
updateTime: new Date().toISOString(),
}).onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update")
if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics)
value.submission_count = numberMetric(value, "submission_count") + 1
if (firstAc) {
value.accepted_count = numberMetric(value, "accepted_count") + 1
if (row.problem.difficulty === "Mid") value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
const perDay = objectValue(value._ac_per_day)
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
value._ac_per_day = perDay
value.max_ac_in_one_day = Math.max(...Object.values(perDay).filter((item): item is number => typeof item === "number"))
}
const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") : []
if (!activeDates.includes(date)) activeDates.push(date)
value._active_dates = activeDates
value.active_days = activeDates.length
if (accepted) {
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
if (last !== date) {
const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 1
? numberMetric(value, "_current_ac_streak") + 1
: 1
value._last_ac_date = date
value._current_ac_streak = current
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current)
}
}
const languages = Array.isArray(value._languages) ? value._languages.filter((item): item is string => typeof item === "string") : []
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
value._languages = languages
value.languages_used = languages.length
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length)
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(row.submission.userId, metrics)
if (!first.length) return []
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum")))
metrics.achievement_unlocked_count = meta?.value ?? 0
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }).where(eq(schema.userStat.userId, row.submission.userId))
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
}
export async function updateAchievementsForProblemSet(userId: number) {
const [[badgeRow], [completedRow]] = await Promise.all([
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
db.select({ value: count() }).from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.userId, userId),
eq(schema.problemsetProgress.isCompleted, true),
)),
])
const metrics = await db.transaction(async (tx) => {
await tx.insert(schema.userStat).values({
userId,
metrics: {},
updateTime: new Date().toISOString(),
}).onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx.select().from(schema.userStat)
.where(eq(schema.userStat.userId, userId)).for("update").limit(1)
if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics)
value.badge_count = badgeRow?.value ?? 0
value.problemset_completed = completedRow?.value ?? 0
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(userId, metrics)
if (!first.length) return []
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")))
metrics.achievement_unlocked_count = meta?.value ?? 0
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, userId))
return [...first, ...(await unlockAchievements(userId, metrics, true))]
}

105
apps/api/src/services/ai.ts Normal file
View File

@@ -0,0 +1,105 @@
import { config } from "../config"
interface ChatMessage {
role: "system" | "user"
content: string
}
function requestBody(messages: ChatMessage[], stream: boolean) {
return {
model: config.aiModel,
messages,
stream,
temperature: 0,
thinking: { type: "disabled" },
}
}
export async function completeChat(system: string, user: string) {
if (!config.aiKey) throw new Error("缺少 AI_KEY")
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },
{ role: "user", content: user },
], false)),
})
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
return payload.choices?.[0]?.message?.content?.trim() ?? ""
}
export function streamChat(
system: string,
user: string,
onComplete?: (value: string) => Promise<void>,
) {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value))
if (!config.aiKey) {
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`)
send("event: end\n\n")
controller.close()
return
}
try {
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },
{ role: "user", content: user },
], true)),
})
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
send("event: start\n\n")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
const chunks: string[] = []
while (true) {
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const raw of lines) {
const line = raw.trim()
if (!line.startsWith("data:")) continue
const data = line.slice(5).trim()
if (data === "[DONE]") continue
try {
const item = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }> }
const choice = item.choices?.[0]
const content = choice?.delta?.content
if (content) {
chunks.push(content)
send(`data: ${JSON.stringify({ type: "delta", content })}\n\n`)
}
} catch {
// Provider keepalive or a partial non-data line.
}
}
if (done) break
}
const full = chunks.join("").trim()
if (onComplete) await onComplete(full)
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
} catch (error) {
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`)
} finally {
send("event: end\n\n")
controller.close()
}
},
})
return new Response(body, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"x-accel-buffering": "no",
},
})
}

View File

@@ -0,0 +1,85 @@
import { createHash } from "node:crypto"
import { and, eq } from "drizzle-orm"
import type { Context } from "hono"
import type { AppEnv } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { getContestPassword } from "../auth/session"
import { db, schema } from "../db"
export type ContestRow = typeof schema.contest.$inferSelect
export function contestStatus(contest: ContestRow) {
const now = Date.now()
if (Date.parse(contest.startTime) > now) return "1" as const
if (Date.parse(contest.endTime) < now) return "-1" as const
return "0" as const
}
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
}
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
}
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
if (!candidate || !expected) return false
if (candidate === expected) return true
const parts = candidate.split("#")
if (parts.length !== 2) return false
const [signature, expiresAt] = parts
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
}
export async function findVisibleContest(id: number) {
const [contest] = await db.select().from(schema.contest)
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
return contest ?? null
}
export async function canAccessContest(
c: Context<AppEnv>,
contest: ContestRow,
checkType: "details" | "problems" | "ranks" | "submissions",
) {
const user = c.get("user")
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
if (isContestAdmin(user, contest)) return { ok: true as const }
if (contest.password) {
const stored = await getContestPassword(c, contest.id)
if (!checkContestPassword(stored, contest.password)) {
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
}
}
if (contestStatus(contest) === "1" && checkType !== "details") {
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
}
return { ok: true as const }
}
function ipv4Number(value: string) {
const parts = value.split(".").map(Number)
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
return parts.reduce((result, part) => (result * 256 + part) >>> 0, 0)
}
export function ipAllowed(ip: string | null, ranges: unknown) {
if (!Array.isArray(ranges) || ranges.length === 0) return true
if (!ip) return false
const target = ipv4Number(ip.replace(/^::ffff:/, ""))
if (target === null) return false
return ranges.some((raw) => {
const value = typeof raw === "string" ? raw : raw && typeof raw === "object" ? String((raw as { value?: unknown }).value ?? "") : ""
const [address, prefixText = "32"] = value.split("/")
const network = ipv4Number(address ?? "")
const prefix = Number(prefixText)
if (network === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
return (target & mask) === (network & mask)
})
}

View File

@@ -0,0 +1,75 @@
import { config } from "../config"
export class CodeFormatError extends Error {
constructor(
message: string,
readonly kind: "syntax" | "tool",
) {
super(message)
}
}
async function runFormatter(command: string[], code: string) {
let process: ReturnType<typeof Bun.spawn>
try {
process = Bun.spawn(command, {
stdin: new Blob([code]),
stdout: "pipe",
stderr: "pipe",
})
} catch (error) {
throw new CodeFormatError(String(error), "tool")
}
const timeout = setTimeout(() => process.kill(), 5_000)
const [exitCode, stdout, stderr] = await Promise.all([
process.exited,
new Response(process.stdout as ReadableStream<Uint8Array>).text(),
new Response(process.stderr as ReadableStream<Uint8Array>).text(),
])
clearTimeout(timeout)
return { exitCode, stdout, stderr }
}
function formatSql(code: string) {
return code
.split(";")
.map((statement) => statement.trim())
.filter(Boolean)
.map((statement) =>
statement.replace(
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
(keyword) => keyword.toUpperCase(),
),
)
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
}
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") {
if (language === "sql") return formatSql(code)
if (language === "python") {
const result = await runFormatter(
[config.ruffPath, "format", "--stdin-filename", "code.py", "-"],
code,
)
if (result.exitCode !== 0) {
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax")
}
return result.stdout
}
const filename = language === "c" ? "code.c" : "code.cpp"
const result = await runFormatter(
[
config.clangFormatPath,
`-assume-filename=${filename}`,
"-style={BasedOnStyle: LLVM, IndentWidth: 4, BreakBeforeBraces: Attach}",
],
code,
)
if (result.exitCode !== 0) {
throw new CodeFormatError(result.stderr || "Formatting failed", "tool")
}
return result.stdout
}

View File

@@ -0,0 +1,38 @@
import { inArray } from "drizzle-orm"
import { db, schema } from "../db"
export const websiteOptionDefaults = {
website_base_url: "http://127.0.0.1",
website_name: "Online Judge",
website_name_shortcut: "oj",
website_footer: "Online Judge Footer",
allow_register: true,
submission_list_show_all: true,
class_list: [] as string[],
enable_maxkb: true,
}
export async function getOptions<const T extends readonly string[]>(keys: T) {
const rows = await db
.select({ key: schema.optionsSysoptions.key, value: schema.optionsSysoptions.value })
.from(schema.optionsSysoptions)
.where(inArray(schema.optionsSysoptions.key, [...keys]))
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<T[number], unknown>
}
export async function getWebsiteOptions() {
const keys = Object.keys(websiteOptionDefaults) as Array<keyof typeof websiteOptionDefaults>
const values = await getOptions(keys)
return Object.fromEntries(
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
) as typeof websiteOptionDefaults
}
export async function getBooleanOption(
key: keyof typeof websiteOptionDefaults,
fallback: boolean,
) {
const values = await getOptions([key])
return typeof values[key] === "boolean" ? values[key] : fallback
}

View File

@@ -0,0 +1,41 @@
import { sessionUserSchema, userProfileSchema } from "@oj2/contract"
import { and, eq } from "drizzle-orm"
import { db, schema } from "../db"
export async function getUserProfileById(userId: number, showRealName: boolean) {
const [row] = await db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.user.id, userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!row) return null
return userProfileSchema.parse({
id: row.profile.id,
user: sessionUserSchema.parse({
id: row.user.id,
username: row.user.username,
email: row.user.email,
adminType: row.user.adminType,
problemPermission: row.user.problemPermission,
createTime: row.user.createTime,
lastLogin: row.user.lastLogin,
openApi: row.user.openApi,
isDisabled: row.user.isDisabled,
className: row.user.className,
}),
realName: showRealName ? row.profile.realName : null,
acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar,
blog: row.profile.blog,
mood: row.profile.mood,
github: row.profile.github,
school: row.profile.school,
major: row.profile.major,
language: row.profile.language,
acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber,
})
}

View File

@@ -1,4 +1,4 @@
import { submissionUpdateSchema } from "@oj2/contract" import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { db, schema } from "./db" import { db, schema } from "./db"
@@ -9,6 +9,7 @@ import {
} from "./judge/events" } from "./judge/events"
import { JudgeStatus } from "./judge/status" import { JudgeStatus } from "./judge/status"
import { createSubscriberRedis } from "./redis" import { createSubscriberRedis } from "./redis"
import { parseUserEvent, userEventChannel, userEventTopic } from "./events"
export interface SubmissionSocketData { export interface SubmissionSocketData {
userId: number userId: number
@@ -25,12 +26,14 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
return { return {
open(ws) { open(ws) {
ws.subscribe(userSubmissionTopic(ws.data.userId)) ws.subscribe(userSubmissionTopic(ws.data.userId))
ws.subscribe(userEventTopic(ws.data.userId))
}, },
message(ws, message) { message(ws, message) {
void handleMessage(ws, String(message)) void handleMessage(ws, String(message))
}, },
close(ws) { close(ws) {
ws.unsubscribe(userSubmissionTopic(ws.data.userId)) ws.unsubscribe(userSubmissionTopic(ws.data.userId))
ws.unsubscribe(userEventTopic(ws.data.userId))
}, },
} }
} }
@@ -87,9 +90,23 @@ async function handleMessage(
.limit(1) .limit(1)
if (!submission) { if (!submission) {
const [flowchart] = await db
.select({ id: schema.flowchartSubmission.id, status: schema.flowchartSubmission.status, score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
.from(schema.flowchartSubmission)
.where(and(eq(schema.flowchartSubmission.id, message.submission_id), eq(schema.flowchartSubmission.userId, ws.data.userId)))
.limit(1)
if (!flowchart) {
ws.send(JSON.stringify({ type: "error", message: "Submission not found" })) ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
return return
} }
const replay = flowchart.status === 2
? { type: "flowchart_evaluation_completed", submission_id: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined }
: flowchart.status === 3
? { type: "flowchart_evaluation_failed", submission_id: flowchart.id, error: "Evaluation failed" }
: { type: "flowchart_evaluation_update", submission_id: flowchart.id }
ws.send(JSON.stringify(flowchartUpdateSchema.parse(replay)))
return
}
const statistics = objectValue(submission.statisticInfo) const statistics = objectValue(submission.statisticInfo)
const status = const status =
@@ -118,6 +135,22 @@ export async function bridgeSubmissionEvents(
) { ) {
const subscriber = createSubscriberRedis() const subscriber = createSubscriberRedis()
subscriber.on("message", (channel, raw) => { subscriber.on("message", (channel, raw) => {
if (channel === userEventChannel) {
const event = parseUserEvent(raw)
if (!event) return
void (async () => {
const [activeUser] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(eq(schema.user.id, event.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!activeUser) return
server.publish(userEventTopic(event.userId), JSON.stringify(event.data))
})().catch((error) => {
console.error("Failed to bridge user event", error)
})
return
}
if (channel !== submissionUpdateChannel) return if (channel !== submissionUpdateChannel) return
const event = parseSubmissionEvent(raw) const event = parseSubmissionEvent(raw)
if (!event) return if (!event) return
@@ -144,6 +177,6 @@ export async function bridgeSubmissionEvents(
subscriber.on("error", (error) => { subscriber.on("error", (error) => {
console.error("Submission event subscriber error", error) console.error("Submission event subscriber error", error)
}) })
await subscriber.subscribe(submissionUpdateChannel) await subscriber.subscribe(submissionUpdateChannel, userEventChannel)
return subscriber return subscriber
} }

View File

@@ -3,6 +3,8 @@ import { Worker } from "bullmq"
import { config } from "./config" import { config } from "./config"
import { judgeQueueName, type JudgeJobData } from "./judge/job" import { judgeQueueName, type JudgeJobData } from "./judge/job"
import { judgeSubmission } from "./judge/run" import { judgeSubmission } from "./judge/run"
import { flowchartQueueName, type FlowchartJobData } from "./flowchart/job"
import { evaluateFlowchart } from "./flowchart/run"
import { createBlockingRedis } from "./redis" import { createBlockingRedis } from "./redis"
const worker = new Worker<JudgeJobData>( const worker = new Worker<JudgeJobData>(
@@ -14,6 +16,12 @@ const worker = new Worker<JudgeJobData>(
}, },
) )
const flowchartWorker = new Worker<FlowchartJobData>(
flowchartQueueName,
async (job) => evaluateFlowchart(job.data),
{ connection: createBlockingRedis(), concurrency: 2 },
)
worker.on("ready", () => { worker.on("ready", () => {
console.log(`Judge worker ready (concurrency=${config.judgeConcurrency})`) console.log(`Judge worker ready (concurrency=${config.judgeConcurrency})`)
}) })
@@ -23,9 +31,13 @@ worker.on("failed", (job, error) => {
worker.on("error", (error) => { worker.on("error", (error) => {
console.error("Judge worker error", error) console.error("Judge worker error", error)
}) })
flowchartWorker.on("ready", () => console.log("Flowchart worker ready (concurrency=2)"))
flowchartWorker.on("failed", (job, error) => console.error(`Flowchart job ${job?.id ?? "unknown"} failed`, error))
flowchartWorker.on("error", (error) => console.error("Flowchart worker error", error))
async function shutdown() { async function shutdown() {
await worker.close() await worker.close()
await flowchartWorker.close()
process.exit(0) process.exit(0)
} }

View File

@@ -1,4 +1,4 @@
import http from "utils/http" import api2 from "utils/api2"
import type { import type {
Achievement, Achievement,
AchievementSummary, AchievementSummary,
@@ -6,22 +6,30 @@ import type {
} from "utils/types" } from "utils/types"
export function getAchievements(name?: string) { export function getAchievements(name?: string) {
return http.get<{ username: string; achievements: Achievement[] }>( return api2.get<any>("achievements", { params: name ? { username: name } : {} })
"achievements", .then((response) => ({
{ params: name ? { name } : {} }, ...response,
) data: {
username: response.data.username,
achievements: response.data.achievements.map((item: any): Achievement => ({
...item,
unlock_time: item.unlockTime,
unlock_rate: item.unlockRate,
})),
},
}))
} }
export function getAchievementSummary(name?: string) { export function getAchievementSummary(name?: string) {
return http.get<AchievementSummary>("achievements/summary", { return api2.get<AchievementSummary>("achievements/summary", {
params: name ? { name } : {}, params: name ? { username: name } : {},
}) })
} }
export function getPendingAchievements() { export function getPendingAchievements() {
return http.get<PendingAchievement[]>("achievements/pending") return api2.get<PendingAchievement[]>("achievements/pending")
} }
export function markAchievementsRead(ids: number[]) { export function markAchievementsRead(ids: number[]) {
return http.post("achievements/pending", { ids }) return api2.post("achievements/pending/read", { ids })
} }

View File

@@ -5,6 +5,7 @@ import {
} from "@oj2/contract" } from "@oj2/contract"
import api2 from "utils/api2" import api2 from "utils/api2"
import http from "utils/http" import http from "utils/http"
import type { ApiResponse } from "utils/http"
import { filterResult } from "oj/transforms" import { filterResult } from "oj/transforms"
import type { import type {
Exercise, Exercise,
@@ -17,67 +18,44 @@ import type {
WebsiteConfig, WebsiteConfig,
} from "utils/types" } from "utils/types"
export function getWebsiteConfig() { function snakeKey(key: string) {
return Promise.resolve({ return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
error: null,
data: {
website_base_url: "",
website_name: "判题狗",
website_name_shortcut: "判题狗",
website_footer: "",
submission_list_show_all: true,
allow_register: false,
class_list: [],
enable_maxkb: false,
} as WebsiteConfig,
})
} }
export async function getProblemList( function toLegacy<T>(value: unknown): T {
offset = 0, if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T
limit = 10, if (!value || typeof value !== "object") return value as T
searchParams: any = {}, return Object.fromEntries(
) { Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]),
const res = await http.get<{ results: Problem[]; total: number }>("problem", { ) as T
params: { paging: true, offset, limit, ...searchParams }, }
})
async function legacyResponse<T>(request: Promise<ApiResponse<unknown>>): Promise<ApiResponse<T>> {
const response = await request
return { error: response.error, data: toLegacy<T>(response.data) }
}
function listProblem(value: any): Problem {
return { return {
results: res.data.results.map(filterResult), id: value.id,
total: res.data.total, _id: value._id,
} title: value.title,
difficulty: value.difficulty,
submission_number: value.submissionNumber,
accepted_number: value.acceptedNumber,
created_by: toLegacy(value.createdBy),
tags: value.tags,
contest: value.contestId,
allow_flowchart: value.allowFlowchart,
show_flowchart: value.showFlowchart,
has_ast_rules: value.hasAstRules,
my_status: value.myStatus,
} as Problem
} }
export function getAuthors(all = false) { function detailProblem(value: unknown): Problem {
return http.get("problem/author", { const problem = problemDetailSchema.parse(value)
params: {
all: all ? "1" : "0",
},
})
}
export function getRandomProblemID() {
return http.get("pickone")
}
export function getProblem(problemID: string, contestID: string) {
if (!contestID) return getPhase2Problem(problemID)
const endpoint = !!contestID ? "contest/problem" : "problem"
return http.get(endpoint, {
params: {
problem_id: problemID,
contest_id: contestID,
},
})
}
async function getPhase2Problem(problemID: string) {
const response = await api2.get<unknown>(
`problems/${encodeURIComponent(problemID)}`,
)
const problem = problemDetailSchema.parse(response.data)
return { return {
error: null,
data: {
id: problem.id, id: problem.id,
_id: problem._id, _id: problem._id,
title: problem.title, title: problem.title,
@@ -112,17 +90,56 @@ async function getPhase2Problem(problemID: string) {
visible: true, visible: true,
allow_flowchart: problem.allowFlowchart, allow_flowchart: problem.allowFlowchart,
show_flowchart: problem.showFlowchart, show_flowchart: problem.showFlowchart,
mermaid_code: problem.mermaidCode, mermaid_code: problem.mermaidCode ?? undefined,
flowchart_data: problem.flowchartData, flowchart_data: problem.flowchartData ?? undefined,
flowchart_hint: problem.flowchartHint, flowchart_hint: problem.flowchartHint ?? undefined,
sql_config: problem.sqlConfig, sql_config: problem.sqlConfig as Problem["sql_config"],
sql_display: problem.sqlDisplay, sql_display: problem.sqlDisplay as Problem["sql_display"],
} as Problem, } as Problem
}
export function getWebsiteConfig() {
return legacyResponse<WebsiteConfig>(api2.get("site"))
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: any = {},
) {
const res = await api2.get<{ results: any[]; total: number }>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.data.results.map(listProblem).map(filterResult),
total: res.data.total,
} }
} }
export function getAuthors(all = false) {
return legacyResponse(api2.get("problem-authors", {
params: {
all: all ? "1" : "0",
},
}))
}
export function getRandomProblemID() {
return api2.get("problems/random")
}
export async function getProblem(problemID: string, contestID: string) {
const endpoint = contestID
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
: `problems/${encodeURIComponent(problemID)}`
const response = await api2.get<unknown>(
endpoint,
)
return { error: null, data: detailProblem(response.data) }
}
export function getProblemBeatRate(problemID: number) { export function getProblemBeatRate(problemID: number) {
return http.get("problem/beat_count", { params: { problem_id: problemID } }) return api2.get(`problems/${problemID}/beat-count`)
} }
export async function getSubmission(id: string) { export async function getSubmission(id: string) {
@@ -167,21 +184,37 @@ export async function submitCode(data: SubmitCodePayload) {
} }
export function formatCode(data: { code: string; language: string }) { export function formatCode(data: { code: string; language: string }) {
// 格式化端点在 Phase 3 迁移Phase 2 保留原代码继续提交。 const languages: Record<string, string> = {
return Promise.resolve({ error: null, data: { code: data.code } }) Python3: "python",
C: "c",
"C++": "cpp",
SQL: "sql",
}
return api2.post("code/format", {
code: data.code,
language: languages[data.language] ?? data.language.toLowerCase(),
})
} }
export function getSubmissions(params: Partial<SubmissionListPayload>) { export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions" const endpoint = params.contest_id
return http.get(endpoint, { params }) ? `contests/${encodeURIComponent(params.contest_id)}/submissions`
: "submissions"
return legacyResponse(api2.get(endpoint, { params: {
...params,
problemId: params.problem_id,
contest_id: undefined,
problem_id: undefined,
page: undefined,
} }))
} }
export function getRankOfProblem(problem_id: string) { export function getRankOfProblem(problem_id: string) {
return http.get("user_problem_rank", { params: { problem_id: problem_id } }) return legacyResponse(api2.get(`problems/${encodeURIComponent(problem_id)}/rank`))
} }
export function getTodaySubmissionCount(language?: string) { export function getTodaySubmissionCount(language?: string) {
return http.get("submissions/today_count", { params: { language } }) return api2.get("submissions/today-count", { params: { language } })
} }
export function adminRejudge(id: string) { export function adminRejudge(id: string) {
@@ -210,21 +243,21 @@ export function getRank(
n: number, n: number,
username?: string, username?: string,
) { ) {
return http.get("user_rank", { return legacyResponse(api2.get("rankings/users", {
params: { offset, limit, rule: "acm", username, n }, params: { offset, limit, username, top: n },
}) }))
} }
export function getActivityRank(start: string) { export function getActivityRank(start: string) {
return http.get("user_activity_rank", { return api2.get("rankings/activity", {
params: { start }, params: { start },
}) })
} }
export function getClassRank(grade?: number | null) { export function getClassRank(grade?: number | null) {
return http.get("class_rank", { return legacyResponse(api2.get("rankings/classes", {
params: { grade }, params: { grade },
}) }))
} }
export function getUserClassRank( export function getUserClassRank(
@@ -232,7 +265,7 @@ export function getUserClassRank(
offset?: number, offset?: number,
limit?: number, limit?: number,
) { ) {
return http.get("user_class_rank", { params: { scope, offset, limit } }) return legacyResponse(api2.get("me/class-rank", { params: { scope, offset, limit } }))
} }
export function getClassPK( export function getClassPK(
@@ -241,15 +274,15 @@ export function getClassPK(
endTime?: string, endTime?: string,
) { ) {
const payload: any = { const payload: any = {
class_name: classNames, classNames,
} }
if (startTime) { if (startTime) {
payload.start_time = startTime payload.startTime = startTime
} }
if (endTime) { if (endTime) {
payload.end_time = endTime payload.endTime = endTime
} }
return http.post("class_pk", payload) return legacyResponse(api2.post("classes/comparison", payload))
} }
export function getContestList(query: { export function getContestList(query: {
@@ -259,61 +292,64 @@ export function getContestList(query: {
status: string status: string
tag: string tag: string
}) { }) {
return http.get("contests", { params: query }) return legacyResponse(api2.get("contests", { params: query }))
} }
export function getContest(id: string) { export function getContest(id: string) {
return http.get("contest", { params: { id } }) return legacyResponse(api2.get(`contests/${encodeURIComponent(id)}`))
} }
export function getContestAccess(id: string) { export function getContestAccess(id: string) {
return http.get("contest/access", { params: { contest_id: id } }) return api2.get(`contests/${encodeURIComponent(id)}/access`)
} }
export function checkContestPassword(contestID: string, password: string) { export function checkContestPassword(contestID: string, password: string) {
return http.post("contest/password", { return api2.post(`contests/${encodeURIComponent(contestID)}/access`, { password })
contest_id: contestID,
password,
})
} }
export async function getContestProblems(contestID: string) { export async function getContestProblems(contestID: string) {
const res = await http.get<Problem[]>("contest/problem", { const res = await api2.get<any[]>(`contests/${encodeURIComponent(contestID)}/problems`)
params: { contest_id: contestID }, return res.data.map(listProblem).map(filterResult)
})
return res.data.map(filterResult)
} }
export function getContestRank( export function getContestRank(
contestID: string, contestID: string,
query: { limit: number; offset: number }, query: { limit: number; offset: number },
) { ) {
return http.get("contest_rank", { return legacyResponse<any>(api2.get(`contests/${encodeURIComponent(contestID)}/rank`, { params: query }))
params: { .then((response) => ({
contest_id: contestID, ...response,
...query, data: {
...response.data,
results: response.data.results.map((item: any) => ({
...item,
contest: item.contest_id,
})),
}, },
}) }))
} }
export function uploadAvatar(file: File) { export function uploadAvatar(file: File) {
const form = new window.FormData() const form = new window.FormData()
form.append("image", file) form.append("image", file)
return http.post("upload_avatar", form, { return api2.post("me/avatar", form, {
headers: { "content-type": "multipart/form-data" }, headers: { "content-type": "multipart/form-data" },
}) })
} }
export function updateProfile(data: { real_name: string; mood: string }) { export function updateProfile(data: { real_name: string; mood: string }) {
return http.put("profile", data) return legacyResponse(api2.put("me/profile", {
realName: data.real_name,
mood: data.mood,
}))
} }
export function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
return http.get("announcement", { params: { limit, offset } }) return legacyResponse(api2.get("announcements", { params: { limit, offset } }))
} }
export function getAnnouncement(id: number) { export function getAnnouncement(id: number) {
return http.get("announcement", { params: { id } }) return legacyResponse(api2.get(`announcements/${id}`))
} }
export function createMessage(data: { export function createMessage(data: {
@@ -321,45 +357,54 @@ export function createMessage(data: {
message: string message: string
submission: string submission: string
}) { }) {
return http.post("message", data) return api2.post("messages", {
recipientId: data.recipient,
message: data.message,
submissionId: data.submission,
})
} }
export function getMessageList(offset = 0, limit = 10) { export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } }) return legacyResponse(api2.get("messages", { params: { limit, offset } }))
} }
export function getReaction(problemID: number) { export function getReaction(problemID: number) {
return http.get<ReactionState>("reaction", { return api2.get<ReactionState>(`problems/${problemID}/reaction`)
params: { problem_id: problemID },
})
} }
export function setReaction(problemID: number, type: ReactionKey) { export function setReaction(problemID: number, type: ReactionKey) {
return http.post<ReactionState>("reaction", { return api2.post<ReactionState>(`problems/${problemID}/reaction`, { type })
problem_id: problemID,
type,
})
} }
// TODO: 这个API有问题 // TODO: 这个API有问题
export function refreshUserProblemDisplayIds() { export function refreshUserProblemDisplayIds() {
return http.get("profile/fresh_display_id") return api2.post("me/problem-display-ids/refresh")
} }
export function getMetrics(userid: number) { export function getMetrics(userid: number) {
return http.get("metrics", { params: { userid } }) return api2.get(`users/${userid}/metrics`)
} }
export function getTutorial(id: number) { export function getTutorial(id: number) {
return http.get("tutorial", { params: { id } }) return legacyResponse(api2.get(`tutorials/${id}`))
} }
export function getTutorials(type: "python" | "c") { export function getTutorials(type: "python" | "c") {
return http.get("tutorials", { params: { type } }) return api2.get("tutorials", { params: { type } })
} }
export function getAIDetailData(start: string, end: string, username?: string) { export function getAIDetailData(start: string, end: string, username?: string) {
return http.get("ai/detail", { params: { start, end, username } }) return legacyResponse<any>(api2.get("ai/detail", { params: { start, end, username } }))
.then((response) => ({
...response,
data: {
...response.data,
flowcharts: response.data.flowcharts?.map((item: any) => ({
...item,
problem__id: item.problem_id,
})) ?? [],
},
}))
} }
export function getAIDurationData( export function getAIDurationData(
@@ -367,25 +412,26 @@ export function getAIDurationData(
duration: string, duration: string,
username?: string, username?: string,
) { ) {
return http.get("ai/duration", { params: { end, duration, username } }) return legacyResponse(api2.get("ai/duration", { params: { end, duration, username } }))
} }
export function getAIHeatmapData(username?: string) { export function getAIHeatmapData(username?: string) {
return http.get("ai/heatmap", { params: username ? { username } : {} }) return api2.get("ai/heatmap", { params: username ? { username } : {} })
} }
export function getAILoginSummary() { export function getAILoginSummary() {
return http.get("ai/login_summary") return legacyResponse(api2.get("ai/login-summary"))
} }
export function getAIPinnedReport() { export function getAIPinnedReport() {
return http.get("ai/pinned") return legacyResponse(api2.get("ai/pinned"))
} }
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
return http.get("problem/similar", { params: { problem_id: problemId } }) return api2.get<any[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({ ...response, data: response.data.map(listProblem).map(filterResult) }))
} }
export interface YearlyACData { export interface YearlyACData {
@@ -396,9 +442,7 @@ export interface YearlyACData {
} }
export function getProblemYearlyAC(problemId: string) { export function getProblemYearlyAC(problemId: string) {
return http.get<YearlyACData[]>("problem/yearly_ac", { return legacyResponse<YearlyACData[]>(api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`))
params: { problem_id: problemId },
})
} }
// ==================== 流程图相关API ==================== // ==================== 流程图相关API ====================
@@ -408,13 +452,25 @@ export function submitFlowchart(data: {
mermaid_code: string mermaid_code: string
flowchart_data: any // 这个是压缩之后的,元数据太长了 flowchart_data: any // 这个是压缩之后的,元数据太长了
}) { }) {
return http.post("flowchart/submission", data) return legacyResponse(api2.post("flowcharts", {
problemId: data.problem_id,
mermaidCode: data.mermaid_code,
flowchartData: data.flowchart_data,
}))
} }
export function getFlowchartSubmission(id: string) { function legacyFlowchart(value: unknown) {
return http.get("flowchart/submission", { const item = toLegacy<any>(value)
params: { id }, return {
}) ...item,
user: item.user_id ?? 0,
problem: item.problem_id,
}
}
export async function getFlowchartSubmission(id: string) {
const response = await api2.get(`flowcharts/${encodeURIComponent(id)}`)
return { ...response, data: legacyFlowchart(response.data) }
} }
export function getFlowchartSubmissions(params: { export function getFlowchartSubmissions(params: {
@@ -426,7 +482,11 @@ export function getFlowchartSubmissions(params: {
today?: string today?: string
grade?: string grade?: string
}) { }) {
return http.get("flowchart/submissions", { params }) return legacyResponse<any>(api2.get("flowcharts", { params: {
...params,
problemId: params.problem_id,
problem_id: undefined,
} }))
} }
export function getFlowchartStatistics( export function getFlowchartStatistics(
@@ -444,21 +504,22 @@ export function getFlowchartStatistics(
} }
export function retryFlowchartSubmission(submissionId: string) { export function retryFlowchartSubmission(submissionId: string) {
return http.post("flowchart/submission/retry", { return legacyResponse(api2.post(`flowcharts/${encodeURIComponent(submissionId)}/retry`))
submission_id: submissionId,
})
} }
export function getCurrentProblemFlowchartSubmission(problemId: number) { export function getCurrentProblemFlowchartSubmission(problemId: number) {
return http.get("flowchart/submission/current", { return api2.get(`problems/${problemId}/flowchart/current`)
params: { problem_id: problemId },
})
} }
export function getFlowchartSubmissionDetail(problemId: number, page = 0) { export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return http.get("flowchart/submission/detail", { const response = await api2.get<any>(`problems/${problemId}/flowchart/history`, { params: { page } })
params: { problem_id: problemId, page }, return {
}) ...response,
data: {
...response.data,
submission: response.data.submission ? legacyFlowchart(response.data.submission) : null,
},
}
} }
// ==================== 题单相关API ==================== // ==================== 题单相关API ====================
@@ -470,7 +531,7 @@ export function getProblemSetList(
difficulty = "", difficulty = "",
status = "", status = "",
) { ) {
return http.get("problemset", { return legacyResponse<any>(api2.get("problem-sets", {
params: { params: {
offset, offset,
limit, limit,
@@ -478,21 +539,54 @@ export function getProblemSetList(
difficulty, difficulty,
status, status,
}, },
}) })).then(mapProblemSetResponse)
} }
export function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
return http.get(`problemset/${id}`) return legacyResponse<any>(api2.get(`problem-sets/${id}`)).then((response) => ({
...response,
data: legacyProblemSet(response.data),
}))
} }
export function getProblemSetProblems(problemSetId: number) { function legacyBadge(value: any) {
return http.get(`problemset/${problemSetId}/problems`) return { ...value, problemset: value.problemset_id }
}
function legacyProblemSet(value: any) {
return {
...value,
badges: value.badges?.map(legacyBadge),
}
}
function mapProblemSetResponse(response: ApiResponse<any>) {
return {
...response,
data: {
...response.data,
results: response.data.results.map(legacyProblemSet),
},
}
}
export async function getProblemSetProblems(problemSetId: number) {
const response = await legacyResponse<any[]>(api2.get(`problem-sets/${problemSetId}/problems`))
return {
...response,
data: response.data.map((item) => ({
...item,
problemset: item.problemset_id,
problem: {
...item.problem,
contest: item.problem.contest_id,
},
})),
}
} }
export function joinProblemSet(problemSetId: number) { export function joinProblemSet(problemSetId: number) {
return http.post("problemset/progress", { return api2.post("problem-set-progress", { problemSetId })
problemset_id: problemSetId,
})
} }
export function updateProblemSetProgress( export function updateProblemSetProgress(
@@ -500,21 +594,33 @@ export function updateProblemSetProgress(
problemId: number, problemId: number,
submissionId: string, submissionId: string,
) { ) {
return http.put("problemset/progress", { return legacyResponse(api2.put("problem-set-progress", {
problemset_id: problemSetId, problemSetId,
problem_id: problemId, problemId,
submission_id: submissionId, submissionId,
}) })
)
} }
// 获取用户徽章列表 // 获取用户徽章列表
export function getUserBadges(username?: string) { export async function getUserBadges(username?: string) {
return http.get("user/badges", { params: username ? { username } : {} }) const response = await legacyResponse<any[]>(api2.get(
`users/${encodeURIComponent(username ?? "me")}/badges`,
))
return {
...response,
data: response.data.map((item) => ({
...item,
user: item.user_id,
badge: legacyBadge(item.badge),
})),
}
} }
// 获取题单徽章列表 // 获取题单徽章列表
export function getProblemSetBadges(problemSetId: number) { export async function getProblemSetBadges(problemSetId: number) {
return http.get(`problemset/${problemSetId}/badges`) const response = await legacyResponse<any[]>(api2.get(`problem-sets/${problemSetId}/badges`))
return { ...response, data: response.data.map(legacyBadge) }
} }
// 获取题单用户进度列表 // 获取题单用户进度列表
@@ -527,12 +633,17 @@ export function getProblemSetUserProgress(
completion_status?: "" | "completed" | "in_progress" | "not_started" completion_status?: "" | "completed" | "in_progress" | "not_started"
}, },
) { ) {
return http.get(`problemset/${problemSetId}/users_progress`, { params }) return legacyResponse(api2.get(`problem-sets/${problemSetId}/user-progress`, {
params: {
limit: params?.limit,
offset: params?.offset,
className: params?.class_name,
completionStatus: params?.completion_status,
},
}))
} }
export async function getExercises(tutorialId: number): Promise<Exercise[]> { export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await http.get<Exercise[]>("exercises", { const res = await api2.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
params: { tutorial_id: tutorialId },
})
return res.data return res.data
} }

View File

@@ -176,12 +176,12 @@ async function analyzeWithAI() {
if (csrfToken) headers["X-CSRFToken"] = csrfToken if (csrfToken) headers["X-CSRFToken"] = csrfToken
try { try {
const response = await fetch("/api/ai/class_pk", { const response = await fetch("/api2/ai/class-pk-analysis", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ body: JSON.stringify({
comparisons: comparisons.value, comparisons: comparisons.value,
time_range_label: timeRangeLabel, timeRangeLabel,
}), }),
signal: controller.signal, signal: controller.signal,
}) })

View File

@@ -83,10 +83,10 @@ async function fetchHint(submissionId: string) {
headers["X-CSRFToken"] = csrfToken headers["X-CSRFToken"] = csrfToken
} }
const response = await fetch("/api/ai/hint", { const response = await fetch("/api2/ai/hint", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ submission_id: submissionId }), body: JSON.stringify({ submissionId }),
}) })
await consumeJSONEventStream(response, { await consumeJSONEventStream(response, {

View File

@@ -95,7 +95,7 @@ async function analyzeSingleClassWithAI() {
if (csrfToken) headers["X-CSRFToken"] = csrfToken if (csrfToken) headers["X-CSRFToken"] = csrfToken
try { try {
const response = await fetch("/api/ai/class_single", { const response = await fetch("/api2/ai/class-analysis", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ comparison: classDetailData.value }), body: JSON.stringify({ comparison: classDetailData.value }),

View File

@@ -104,7 +104,7 @@ export const useAIStore = defineStore("ai", () => {
} }
try { try {
const response = await fetch("/api/ai/analysis", { const response = await fetch("/api2/ai/analysis", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,6 +1,5 @@
import { userProfileSchema } from "@oj2/contract" import { userProfileSchema } from "@oj2/contract"
import api2 from "utils/api2" import api2 from "utils/api2"
import http from "utils/http"
import type { ApiResponse } from "utils/http" import type { ApiResponse } from "utils/http"
import type { Profile, Tag } from "utils/types" import type { Profile, Tag } from "utils/types"
@@ -13,7 +12,7 @@ export function signup(data: {
email: string email: string
password: string password: string
}) { }) {
return http.post("register", data) return api2.post("users", data)
} }
export function logout() { export function logout() {
@@ -23,9 +22,9 @@ export function logout() {
export async function getProfile( export async function getProfile(
username: string = "", username: string = "",
): Promise<ApiResponse<Profile | null>> { ): Promise<ApiResponse<Profile | null>> {
if (username) return http.get<Profile>("profile", { params: { username } }) const response = await api2.get<unknown>(
username ? `profiles/${encodeURIComponent(username)}` : "me",
const response = await api2.get<unknown>("me") )
if (response.data === null) return { error: null, data: null } if (response.data === null) return { error: null, data: null }
const profile = userProfileSchema.parse(response.data) const profile = userProfileSchema.parse(response.data)
return { return {
@@ -61,13 +60,13 @@ export async function getProfile(
} }
export function getProblemTagList() { export function getProblemTagList() {
return http.get<Tag[]>("problem/tags") return api2.get<Array<Tag & { problemCount: number }>>("problem-tags")
} }
export function getHitokoto() { export function getHitokoto() {
return http.get("hitokoto") return api2.get("quotes/random")
} }
export function getClassUsernames(classroom: string) { export function getClassUsernames(classroom: string) {
return http.get("class_usernames", { params: { classroom: classroom } }) return api2.get(`classes/${encodeURIComponent(classroom)}/usernames`)
} }

View File

@@ -448,8 +448,10 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage {
*/ */
class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> { class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> {
constructor() { constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({ super({
path: "flowchart", // 使用专门的 flowchart WebSocket 路径 path: "flowchart",
url: `${protocol}//${window.location.host}/ws2/submissions`,
}) })
} }

View File

@@ -15,6 +15,11 @@ interface Api2Client {
data?: unknown, data?: unknown,
config?: AxiosRequestConfig, config?: AxiosRequestConfig,
): Promise<ApiResponse<T>> ): Promise<ApiResponse<T>>
put<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>>
} }
@@ -23,6 +28,17 @@ const instance = axios.create({
withCredentials: true, withCredentials: true,
}) })
instance.interceptors.request.use((config) => {
if (config.params) {
config.params = Object.fromEntries(
Object.entries(config.params).filter(
([, value]) => value !== "" && value !== null && value !== undefined,
),
)
}
return config
})
instance.interceptors.response.use( instance.interceptors.response.use(
(response) => Promise.resolve({ error: null, data: response.data.data }), (response) => Promise.resolve({ error: null, data: response.data.data }),
(error) => { (error) => {

View File

@@ -178,6 +178,10 @@ export default defineConfig(({ mode }) => {
rewrite: (path: string) => path.replace(/^\/ws2/, "/ws"), rewrite: (path: string) => path.replace(/^\/ws2/, "/ws"),
}, },
"/api": proxyConfig, "/api": proxyConfig,
"/public/avatar": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/public": proxyConfig, "/public": proxyConfig,
"/ws": wsProxyConfig, "/ws": wsProxyConfig,
}, },

View File

@@ -0,0 +1,54 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { userProfileSchema } from "./auth"
export const registerRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
email: z.email().max(64),
password: z.string().min(6).max(20),
})
export const updateProfileRequestSchema = z.object({
realName: z.string().max(32).nullable().optional(),
avatar: z.string().max(256).optional(),
blog: z.string().max(256).nullable().optional(),
mood: z.string().max(256).nullable().optional(),
github: z.string().max(256).nullable().optional(),
school: z.string().max(64).nullable().optional(),
major: z.string().max(64).nullable().optional(),
language: z.string().max(32).nullable().optional(),
})
export const metricsSchema = z.object({
now: z.string(),
latest: z.string(),
first: z.string(),
})
export const rankProfileSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
acceptedNumber: z.number().int(),
submissionNumber: z.number().int(),
mood: z.string().nullable(),
})
export const userRankSchema = paginatedSchema(rankProfileSchema)
export const activityRankItemSchema = z.object({
username: z.string(),
count: z.number().int().nonnegative(),
})
export const problemRankSchema = z.object({
className: z.string(),
rank: z.number().int(),
classAcCount: z.number().int().nonnegative(),
allAcCount: z.number().int().nonnegative(),
})
export const publicProfileSchema = userProfileSchema
export type RegisterRequest = z.infer<typeof registerRequestSchema>
export type UpdateProfileRequest = z.infer<typeof updateProfileRequestSchema>

View File

@@ -0,0 +1,51 @@
import { z } from "zod"
export const achievementRaritySchema = z.enum(["bronze", "silver", "gold", "platinum"])
export const achievementSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
hidden: z.boolean(),
metric: z.string().nullable(),
operator: z.enum(["gte", "lte"]).nullable(),
threshold: z.number().int().nullable(),
unlocked: z.boolean(),
unlockTime: z.string().nullable(),
backfilled: z.boolean(),
progress: z.number().nullable(),
unlockRate: z.number(),
})
export const achievementListSchema = z.object({
username: z.string(),
achievements: z.array(achievementSchema),
})
export const pendingAchievementSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
})
export const achievementSummarySchema = z.object({
username: z.string(),
total: z.number().int(),
unlocked: z.number().int(),
percent: z.number(),
rarity: z.array(z.object({
rarity: achievementRaritySchema,
label: z.string(),
total: z.number().int(),
unlocked: z.number().int(),
})),
recent: z.array(pendingAchievementSchema),
})
export const markAchievementsReadSchema = z.object({
ids: z.array(z.number().int()),
})

View File

@@ -0,0 +1,96 @@
import { z } from "zod"
export const durationDataSchema = z.object({
unit: z.string(),
index: z.number().int(),
start: z.string(),
end: z.string(),
grade: z.string(),
problemCount: z.number().int(),
submissionCount: z.number().int(),
})
export const solvedProblemSchema = z.object({
problem: z.object({
title: z.string(),
displayId: z.string(),
contestTitle: z.string(),
contestId: z.number().int().nullable(),
}),
acTime: z.string(),
rank: z.number().int().nullable(),
acCount: z.number().int(),
grade: z.string(),
periodRank: z.number().int().nullable(),
periodAcCount: z.number().int(),
difficulty: z.string(),
})
export const flowchartSummarySchema = z.object({
problemId: z.string(),
problemTitle: z.string(),
submissionCount: z.number().int(),
bestScore: z.number(),
bestGrade: z.string(),
latestSubmissionTime: z.string(),
avgScore: z.number(),
})
export const aiDetailSchema = z.object({
user: z.string(),
className: z.string().nullable(),
start: z.string(),
end: z.string(),
solved: z.array(solvedProblemSchema),
flowcharts: z.array(flowchartSummarySchema),
grade: z.string(),
tags: z.record(z.string(), z.number().int()),
difficulty: z.record(z.string(), z.number().int()),
contestCount: z.number().int(),
})
export const aiAnalysisRequestSchema = z.object({
details: z.unknown(),
duration: z.unknown(),
})
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
export const classAnalysisRequestSchema = z.object({
comparison: z.record(z.string(), z.unknown()),
})
export const classPkAnalysisRequestSchema = z.object({
comparisons: z.array(z.record(z.string(), z.unknown())).min(2),
timeRangeLabel: z.string().default("全部时间"),
})
export const heatmapItemSchema = z.object({
timestamp: z.number(),
value: z.number().int(),
})
export const aiAnalysisRecordSchema = z.object({
id: z.number().int(),
provider: z.string(),
model: z.string(),
data: z.record(z.string(), z.unknown()),
analysis: z.string(),
createTime: z.string(),
isPinned: z.boolean(),
username: z.string().optional(),
})
export const loginSummarySchema = z.object({
summary: z.object({
start: z.string(),
end: z.string(),
newProblemCount: z.number().int(),
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
solvedCount: z.number().int(),
flowchartSubmissionCount: z.number().int(),
}),
analysis: z.string(),
analysisError: z.string().optional(),
})

View File

@@ -0,0 +1,64 @@
import { z } from "zod"
export const classRankItemSchema = z.object({
className: z.string(),
userCount: z.number().int(),
totalAc: z.number().int(),
totalSubmission: z.number().int(),
avgAc: z.number(),
acRate: z.number(),
rank: z.number().int(),
})
export const classUserRankItemSchema = z.object({
userId: z.number().int(),
username: z.string(),
acceptedNumber: z.number().int(),
submissionNumber: z.number().int(),
rank: z.number().int(),
})
export const classUserRankSchema = z.object({
className: z.string(),
myRank: z.number().int(),
total: z.number().int(),
ranks: z.array(classUserRankItemSchema),
})
export const classComparisonSchema = z.object({
className: z.string(),
userCount: z.number().int(),
totalAc: z.number().int(),
totalSubmission: z.number().int(),
avgAc: z.number(),
medianAc: z.number(),
q1Ac: z.number(),
q3Ac: z.number(),
iqr: z.number(),
stdDev: z.number(),
top10Avg: z.number(),
middle80Avg: z.number(),
bottom10Avg: z.number(),
excellentRate: z.number(),
passRate: z.number(),
activeRate: z.number(),
acRate: z.number(),
compositeScore: z.number(),
recentTotalAc: z.number().int().optional(),
recentTotalSubmission: z.number().int().optional(),
recentAvgAc: z.number().optional(),
recentMedianAc: z.number().optional(),
recentTop10Avg: z.number().optional(),
recentActiveCount: z.number().int().optional(),
})
export const classComparisonRequestSchema = z.object({
classNames: z.array(z.string()).min(1),
startTime: z.string().optional(),
endTime: z.string().optional(),
})
export const classComparisonResponseSchema = z.object({
comparisons: z.array(classComparisonSchema),
hasTimeRange: z.boolean(),
})

View File

@@ -0,0 +1,21 @@
import { z } from "zod"
export const sampleUserSchema = z.object({
id: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
})
export const paginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(250).default(10),
offset: z.coerce.number().int().min(0).default(0),
})
export function paginatedSchema<T extends z.ZodType>(item: T) {
return z.object({
results: z.array(item),
total: z.number().int().nonnegative(),
})
}
export type SampleUser = z.infer<typeof sampleUserSchema>

View File

@@ -0,0 +1,74 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { submissionDetailSchema } from "./submission"
export const announcementSchema = z.object({
id: z.number().int(),
title: z.string(),
tag: z.string(),
content: z.string().optional(),
top: z.boolean(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string(),
})
export const announcementListSchema = paginatedSchema(announcementSchema)
export const messageSchema = z.object({
id: z.number().int(),
sender: sampleUserSchema,
createTime: z.string(),
message: z.string(),
submission: submissionDetailSchema,
})
export const messageListSchema = paginatedSchema(messageSchema)
export const createMessageRequestSchema = z.object({
recipientId: z.number().int().positive(),
submissionId: z.string().min(1),
message: z.string().min(1).max(1024 * 1024),
})
export const reactionKeySchema = z.enum([
"too_easy",
"too_hard",
"confusing",
"buggy",
"learned",
"interesting",
"want_explain",
])
export const reactionCountsSchema = z.record(reactionKeySchema, z.number().int())
export const reactionStateSchema = z.object({
mine: reactionKeySchema.nullable(),
counts: reactionCountsSchema.nullable(),
})
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
export const tutorialSummarySchema = z.object({
id: z.number().int(),
title: z.string(),
})
export const tutorialSchema = tutorialSummarySchema.extend({
content: z.string(),
code: z.string().nullable(),
isPublic: z.boolean(),
order: z.number().int(),
type: z.enum(["python", "c"]),
createdBy: sampleUserSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { problemDetailSchema, problemListItemSchema } from "./problem"
export const contestStatusSchema = z.enum(["-1", "0", "1"])
export const contestSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
tag: z.string(),
startTime: z.string(),
endTime: z.string(),
createTime: z.string(),
lastUpdateTime: z.string(),
createdBy: sampleUserSchema,
status: contestStatusSchema,
contestType: z.enum(["Public", "Password Protected"]),
now: z.string().optional(),
})
export const contestListSchema = paginatedSchema(contestSchema)
export const contestPasswordRequestSchema = z.object({
password: z.string().min(1).max(128),
})
export const contestAccessSchema = z.object({ access: z.boolean() })
export const contestProblemsSchema = z.array(z.union([problemListItemSchema, problemDetailSchema]))
export const contestRankItemSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
totalTime: z.number().int(),
submissionInfo: z.record(z.string(), z.unknown()),
contestId: z.number().int(),
})
export const contestRankSchema = paginatedSchema(contestRankItemSchema)

View File

@@ -0,0 +1,71 @@
import { z } from "zod"
import { paginatedSchema } from "./common"
export const flowchartStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])
export const createFlowchartRequestSchema = z.object({
problemId: z.number().int().positive(),
mermaidCode: z.string().trim().min(1).max(50_000).refine(
(value) => value.split("\n").filter((line) => line.trim()).length <= 200,
"Flowchart is too complex",
),
flowchartData: z.record(z.string(), z.unknown()).default({}),
})
export const flowchartSubmissionSchema = z.object({
id: z.string(),
username: z.string(),
problemId: z.number().int(),
mermaidCode: z.string(),
flowchartData: z.record(z.string(), z.unknown()),
status: flowchartStatusSchema,
createTime: z.string(),
aiScore: z.number().nullable(),
aiGrade: z.string().nullable(),
aiFeedback: z.string().nullable(),
aiSuggestions: z.string().nullable(),
aiCriteriaDetails: z.record(z.string(), z.unknown()),
aiProvider: z.string(),
aiModel: z.string(),
processingTime: z.number().nullable(),
evaluationTime: z.string().nullable(),
})
export const flowchartListItemSchema = z.object({
id: z.string(),
username: z.string(),
problem: z.string(),
problemTitle: z.string(),
status: flowchartStatusSchema,
createTime: z.string(),
aiScore: z.number().nullable(),
aiGrade: z.string().nullable(),
aiProvider: z.string(),
aiModel: z.string(),
processingTime: z.number().nullable(),
evaluationTime: z.string().nullable(),
showLink: z.boolean(),
})
export const flowchartListSchema = paginatedSchema(flowchartListItemSchema)
export const createFlowchartResponseSchema = z.object({ submissionId: z.string(), status: z.literal("pending") })
export const flowchartCurrentSchema = z.object({ count: z.number().int(), score: z.number(), grade: z.string() })
export const flowchartDetailSchema = z.object({ submission: flowchartSubmissionSchema.nullable(), count: z.number().int() })
export const flowchartUpdateSchema = z.object({
type: z.enum([
"flowchart_evaluation_completed",
"flowchart_evaluation_failed",
"flowchart_evaluation_update",
]),
submission_id: z.string(),
score: z.number().optional(),
grade: z.string().optional(),
feedback: z.string().optional(),
suggestions: z.string().optional(),
criteriaDetails: z.unknown().optional(),
error: z.string().optional(),
})
export type FlowchartUpdate = z.infer<typeof flowchartUpdateSchema>

View File

@@ -1,3 +1,13 @@
export * from "./auth" export * from "./auth"
export * from "./account"
export * from "./achievement"
export * from "./ai"
export * from "./classroom"
export * from "./common"
export * from "./content"
export * from "./contest"
export * from "./flowchart"
export * from "./problem" export * from "./problem"
export * from "./problemset"
export * from "./site"
export * from "./submission" export * from "./submission"

View File

@@ -1,5 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
/** 题目列表项。字段取自 problem 表,只含列表页需要的列。 */ /** 题目列表项。字段取自 problem 表,只含列表页需要的列。 */
export const problemSummarySchema = z.object({ export const problemSummarySchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -58,3 +60,39 @@ export const problemDetailSchema = z.object({
}) })
export type ProblemDetail = z.infer<typeof problemDetailSchema> export type ProblemDetail = z.infer<typeof problemDetailSchema>
export const problemListItemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
difficulty: z.string(),
createdBy: sampleUserSchema,
tags: z.array(z.string()),
contestId: z.number().int().nullable(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
hasAstRules: z.boolean(),
myStatus: z.number().int().nullable(),
})
export const problemListSchema = paginatedSchema(problemListItemSchema)
export const tagSchema = z.object({
id: z.number().int(),
name: z.string(),
problemCount: z.number().int().nonnegative(),
})
export const problemAuthorSchema = z.object({
username: z.string(),
problemCount: z.number().int().nonnegative(),
})
export const yearlyAcSchema = z.object({
year: z.number().int(),
total: z.number().int().nonnegative(),
accepted: z.number().int().nonnegative(),
acRate: z.number(),
})

View File

@@ -0,0 +1,100 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { problemListItemSchema } from "./problem"
export const problemSetUserProgressSummarySchema = z.object({
isJoined: z.boolean(),
progressPercentage: z.number(),
completedCount: z.number().int(),
totalCount: z.number().int(),
isCompleted: z.boolean(),
})
export const problemSetBadgeSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
conditionType: z.enum(["all_problems", "problem_count", "score"]),
conditionValue: z.number().int(),
isEarned: z.boolean().optional(),
})
export const problemSetSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string().optional(),
difficulty: z.enum(["Easy", "Medium", "Hard"]),
status: z.enum(["active", "archived", "draft"]),
endTime: z.string().nullable(),
visible: z.boolean(),
problemsCount: z.number().int(),
completedCount: z.number().int().optional(),
userProgress: problemSetUserProgressSummarySchema,
badges: z.array(problemSetBadgeSchema).optional(),
})
export const problemSetListSchema = paginatedSchema(problemSetSchema)
export const problemSetProblemSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
problem: problemListItemSchema,
order: z.number().int(),
isRequired: z.boolean(),
score: z.number().int(),
hint: z.string().nullable(),
isCompleted: z.boolean(),
})
export const joinProblemSetRequestSchema = z.object({
problemSetId: z.number().int().positive(),
})
export const updateProblemSetProgressRequestSchema = z.object({
problemSetId: z.number().int().positive(),
problemId: z.number().int().positive(),
submissionId: z.string().min(1),
})
export const completedProblemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
})
export const problemSetProgressSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
user: sampleUserSchema,
joinTime: z.string(),
completeTime: z.string().nullable(),
isCompleted: z.boolean(),
progressPercentage: z.number(),
completedProblemsCount: z.number().int(),
totalProblemsCount: z.number().int(),
totalScore: z.number().int(),
completedProblems: z.array(completedProblemSchema),
})
export const problemSetProgressListSchema = paginatedSchema(problemSetProgressSchema).extend({
statistics: z.object({
total: z.number().int(),
completed: z.number().int(),
avgProgress: z.number(),
}),
problems: z.array(completedProblemSchema),
})
export const userBadgeSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
badge: problemSetBadgeSchema,
earnedTime: z.string(),
problemset: z.object({ id: z.number().int(), title: z.string() }),
})

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
export const websiteConfigSchema = z.object({
websiteBaseUrl: z.string(),
websiteName: z.string(),
websiteNameShortcut: z.string(),
websiteFooter: z.string(),
allowRegister: z.boolean(),
submissionListShowAll: z.boolean(),
classList: z.array(z.string()),
enableMaxkb: z.boolean(),
})
export const quoteSchema = z.union([
z.string(),
z.record(z.string(), z.unknown()),
])
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>

View File

@@ -1,5 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema } from "./common"
export const judgeStatusSchema = z.union([ export const judgeStatusSchema = z.union([
z.literal(-2), z.literal(-2),
z.literal(-1), z.literal(-1),
@@ -55,6 +57,31 @@ export const submissionUpdateSchema = z.object({
err_info: z.string().optional(), err_info: z.string().optional(),
}) })
export const submissionListItemSchema = z.object({
id: z.string(),
problem: z.string(),
problemTitle: z.string(),
showLink: z.boolean(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
result: judgeStatusSchema,
language: z.string(),
shared: z.boolean(),
statisticInfo: z.record(z.string(), z.unknown()),
})
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
export const shareSubmissionRequestSchema = z.object({ shared: z.boolean() })
export const formatCodeRequestSchema = z.object({
code: z.string().max(1024 * 1024),
language: z.enum(["python", "c", "cpp", "sql"]),
})
export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema> export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type CreateSubmissionRequest = z.infer< export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema typeof createSubmissionRequestSchema