diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 29b9b1f..78f719c 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -13,6 +13,8 @@ const SESSION_PREFIX = "session:" interface StoredSession { userId: number createdAt: string + previousLogin: string | null + contestPasswords: Record } export interface AuthUser { @@ -22,17 +24,24 @@ export interface AuthUser { adminType: string problemPermission: string isDisabled: boolean + className: string | null } function sessionKey(token: string) { 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 value: StoredSession = { userId, createdAt: new Date().toISOString(), + previousLogin, + contestPasswords: {}, } await redis.set( sessionKey(token), @@ -87,6 +96,7 @@ async function getUserByToken(token: string | undefined): Promise +} + +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 + } +} diff --git a/apps/api/src/flowchart/job.ts b/apps/api/src/flowchart/job.ts new file mode 100644 index 0000000..cba949b --- /dev/null +++ b/apps/api/src/flowchart/job.ts @@ -0,0 +1,5 @@ +export const flowchartQueueName = "evaluate-flowchart" + +export interface FlowchartJobData { + submissionId: string +} diff --git a/apps/api/src/flowchart/run.ts b/apps/api/src/flowchart/run.ts new file mode 100644 index 0000000..223fb98 --- /dev/null +++ b/apps/api/src/flowchart/run.ts @@ -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-100,A=80-89,B=70-79,C=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 + 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 + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 174cd8d..496c816 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,11 +1,21 @@ import { Hono } from "hono" +import { basename, resolve } from "node:path" import { getRequestSessionUser } from "./auth/session" import { config } from "./config" import { authRoutes } from "./routes/auth" +import { accountRoutes } from "./routes/account" 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 { submissionRoutes } from "./routes/submission" +import { siteRoutes } from "./routes/site" import { bridgeSubmissionEvents, submissionWebSocketHandler, @@ -16,6 +26,15 @@ const app = new Hono() app.get("/health", (c) => c.json({ ok: true })) 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", submissionRoutes) app.route("/api", judgeServerRoutes) @@ -32,6 +51,21 @@ const server = Bun.serve({ port: config.port, async fetch(request, bunServer) { 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( + '', + { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } }, + ) + } + return new Response("Not found", { status: 404 }) + } if (url.pathname === "/ws/submissions") { const user = await getRequestSessionUser(request) if (!user) return new Response("Unauthorized", { status: 401 }) diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 4566528..f356d1b 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -4,6 +4,8 @@ import { and, eq, inArray } from "drizzle-orm" import { config } from "../config" import { db, schema } from "../db" +import { publishAchievementNotification } from "../events" +import { updateAchievementsForSubmission } from "../services/achievements" import { checkAst, type AstRule } from "./ast" import { publishSubmissionUpdate } from "./events" import type { JudgeJobData } from "./job" @@ -99,6 +101,8 @@ async function persistResult( result: JudgeStatusValue, info: unknown, statisticInfo: Record, + contestId: number | null, + submissionCreateTime: string, ) { return db.transaction(async (tx) => { const [currentSubmission] = await tx @@ -158,7 +162,8 @@ async function persistResult( .where(eq(schema.problem.id, problemId)) 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 previousStatus = previous.status const wasAccepted = @@ -177,18 +182,98 @@ async function persistResult( _id: displayId, } } - acmStatus.problems = problems + acmStatus[statusKey] = problems await tx .update(schema.userProfile) .set({ - submissionNumber: profile.submissionNumber + 1, + submissionNumber: + profile.submissionNumber + (contestId === null ? 1 : 0), acceptedNumber: - profile.acceptedNumber + (acceptedNow && !wasAccepted ? 1 : 0), + profile.acceptedNumber + + (contestId === null && acceptedNow && !wasAccepted ? 1 : 0), acmProblemsStatus: acmStatus, }) .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 = { + 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 }) } @@ -329,9 +414,25 @@ export async function judgeSubmission(job: JudgeJobData) { result, info, statisticInfo, + row.submission.contestId, + row.submission.createTime, ) 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, { type: "submission_update", submission_id: row.submission.id, diff --git a/apps/api/src/queue.ts b/apps/api/src/queue.ts index bf79634..7f6768e 100644 --- a/apps/api/src/queue.ts +++ b/apps/api/src/queue.ts @@ -1,6 +1,7 @@ import { Queue } from "bullmq" import { judgeQueueName, type JudgeJobData } from "./judge/job" +import { flowchartQueueName, type FlowchartJobData } from "./flowchart/job" import { createBlockingRedis } from "./redis" export const judgeQueue = new Queue(judgeQueueName, { @@ -10,3 +11,13 @@ export const judgeQueue = new Queue(judgeQueueName, { removeOnFail: 500, }, }) + +export const flowchartQueue = new Queue(flowchartQueueName, { + connection: createBlockingRedis(), + defaultJobOptions: { + attempts: 3, + backoff: { type: "exponential", delay: 2_000 }, + removeOnComplete: 100, + removeOnFail: 500, + }, +}) diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts new file mode 100644 index 0000000..7d12b76 --- /dev/null +++ b/apps/api/src/routes/account.ts @@ -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() + +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 = 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`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) +}) diff --git a/apps/api/src/routes/achievement.ts b/apps/api/src/routes/achievement.ts new file mode 100644 index 0000000..82db138 --- /dev/null +++ b/apps/api/src/routes/achievement.ts @@ -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() + +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) +}) diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts new file mode 100644 index 0000000..fea8f8c --- /dev/null +++ b/apps/api/src/routes/ai.ts @@ -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() + +const accepted = [0, 10] +const difficultyNames: Record = { 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 = { 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) { + 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 = {} + 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 = { 简单: 0, 中等: 0, 困难: 0 } + for (const item of problems) { + const name = difficultyNames[item.problem.difficulty] ?? "中等" + difficulty[name] = (difficulty[name] ?? 0) + 1 + } + const flowGroups = new Map() + 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`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)}`) +}) diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index ddd681f..4b6fefe 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -11,6 +11,7 @@ import { createSession, destroySession } from "../auth/session" import { verifyPassword } from "../auth/password" import { db, schema } from "../db" import { failure, success } from "../http" +import { getUserProfileById } from "../services/profile" export const authRoutes = new Hono() @@ -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 createSession(c, user.id) + await createSession(c, user.id, user.lastLogin) return success(c, { ok: true }) }) @@ -62,49 +63,7 @@ authRoutes.get("/me", optionalAuth, async (c) => { const authUser = c.get("user") if (!authUser) return success(c, null) - 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, 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, - }) - + const data = await getUserProfileById(authUser.id, true) + if (!data) return failure(c, 404, "profile-not-found", "User profile does not exist") return success(c, data) }) diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts new file mode 100644 index 0000000..f40ba7b --- /dev/null +++ b/apps/api/src/routes/classroom.ts @@ -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() + +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() + 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() + for (const user of users) byClass.set(user.className, [...(byClass.get(user.className) ?? []), user]) + + let recentByUser = new Map>() + let recentSubmissionCount = new Map() + 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() + 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 = { + 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, + })) +}) diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts new file mode 100644 index 0000000..121c592 --- /dev/null +++ b/apps/api/src/routes/content.ts @@ -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() + +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 }))) +}) diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts new file mode 100644 index 0000000..d5ddd3c --- /dev/null +++ b/apps/api/src/routes/contest.ts @@ -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() + +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() + 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() + 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, + })) +}) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts new file mode 100644 index 0000000..c589f70 --- /dev/null +++ b/apps/api/src/routes/flowchart.ts @@ -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() + +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 })) +}) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts new file mode 100644 index 0000000..fc8c29a --- /dev/null +++ b/apps/api/src/routes/helpers.ts @@ -0,0 +1,62 @@ +import type { AuthUser } from "../auth/session" + +export function objectValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {} +} + +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 = {} + 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 +} diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index ad8b9df..3abd3fe 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -1,10 +1,34 @@ -import { problemDetailSchema, problemSummarySchema } from "@oj2/contract" -import { and, count, desc, eq, isNull, notInArray } from "drizzle-orm" +import { + 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 { optionalAuth, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, success } from "../http" +import { JudgeStatus } from "../judge/status" +import { objectValue as toObject, queryInteger } from "./helpers" export const problemRoutes = new Hono() @@ -28,7 +52,183 @@ function publicTemplates(value: unknown) { 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() + 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() + 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, + statuses: Record, +) { + 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`extract(year from ${schema.submission.createTime})::int` + const rows = await db.select({ + year, + total: count(), + accepted: sql`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 .select({ id: schema.problem.id, diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts new file mode 100644 index 0000000..30800c5 --- /dev/null +++ b/apps/api/src/routes/problemset.ts @@ -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() + +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() + 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[0]>[0], + progress: typeof schema.problemsetProgress.$inferSelect, + detail: Record, +) { + 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`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, + })) +}) diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts new file mode 100644 index 0000000..e266341 --- /dev/null +++ b/apps/api/src/routes/site.ts @@ -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}`, ""))) +}) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 0dbcc0b..1be4a59 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -3,16 +3,32 @@ import { randomBytes } from "node:crypto" import { createSubmissionRequestSchema, createSubmissionResponseSchema, + formatCodeRequestSchema, + formatCodeResponseSchema, + shareSubmissionRequestSchema, submissionDetailSchema, + submissionListItemSchema, + submissionListSchema, } 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 { 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 { failure, success } from "../http" import { JudgeStatus } from "../judge/status" 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() @@ -28,23 +44,29 @@ function objectValue(value: unknown): Record { : {} } -submissionRoutes.use("/submissions", requireAuth) -submissionRoutes.use("/submissions/*", requireAuth) +function requestIp(c: { req: { header(name: string): string | undefined } }) { + 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( await c.req.json().catch(() => null), ) if (!parsed.success) { return failure(c, 400, "invalid-request", "Invalid submission payload") } + let contestId: number | null = null if (parsed.data.contestId) { - return failure( - c, - 400, - "contest-not-supported", - "Contest submissions are not part of the Phase 2 slice", - ) + const contest = await findVisibleContest(parsed.data.contestId) + 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) + 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 @@ -57,7 +79,7 @@ submissionRoutes.post("/submissions", async (c) => { and( eq(schema.problem.id, parsed.data.problemId), eq(schema.problem.visible, true), - isNull(schema.problem.contestId), + contestId === null ? isNull(schema.problem.contestId) : eq(schema.problem.contestId, contestId), ), ) .limit(1) @@ -75,8 +97,7 @@ submissionRoutes.post("/submissions", async (c) => { const user = c.get("user")! const submissionId = randomBytes(16).toString("hex") const createTime = new Date().toISOString() - const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() - const ip = forwarded || c.req.header("x-real-ip") || null + const ip = requestIp(c) await db.insert(schema.submission).values({ id: submissionId, @@ -91,7 +112,7 @@ submissionRoutes.post("/submissions", async (c) => { shared: false, statisticInfo: {}, ip, - contestId: null, + contestId, }) try { @@ -116,40 +137,180 @@ submissionRoutes.post("/submissions", async (c) => { ) }) -submissionRoutes.get("/submissions/:id", async (c) => { - const user = c.get("user")! - const [row] = await db - .select() - .from(schema.submission) - .where( - and( - eq(schema.submission.id, c.req.param("id")), - eq(schema.submission.userId, user.id), - ), - ) - .limit(1) +submissionRoutes.get("/submissions/today-count", async (c) => { + const language = c.req.query("language") + if (language === "Flowchart") { + const [row] = await db.select({ value: count() }).from(schema.flowchartSubmission) + .where(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`) + return success(c, row?.value ?? 0) + } + const [row] = await db.select({ value: count() }).from(schema.submission) + .where(and(isNull(schema.submission.contestId), sql`${schema.submission.createTime} >= ${todayStart()}`)) + return success(c, row?.value ?? 0) +}) - 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") } - - 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) }) + +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) +}) diff --git a/apps/api/src/services/achievements.ts b/apps/api/src/services/achievements.ts new file mode 100644 index 0000000..cd29fc6 --- /dev/null +++ b/apps/api/src/services/achievements.ts @@ -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, 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, 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))] +} diff --git a/apps/api/src/services/ai.ts b/apps/api/src/services/ai.ts new file mode 100644 index 0000000..9059881 --- /dev/null +++ b/apps/api/src/services/ai.ts @@ -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, +) { + const encoder = new TextEncoder() + const body = new ReadableStream({ + 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", + }, + }) +} diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts new file mode 100644 index 0000000..47c9cc6 --- /dev/null +++ b/apps/api/src/services/contest.ts @@ -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, + 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) + }) +} diff --git a/apps/api/src/services/format-code.ts b/apps/api/src/services/format-code.ts new file mode 100644 index 0000000..df854aa --- /dev/null +++ b/apps/api/src/services/format-code.ts @@ -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 + 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).text(), + new Response(process.stderr as ReadableStream).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 +} diff --git a/apps/api/src/services/options.ts b/apps/api/src/services/options.ts new file mode 100644 index 0000000..d2fd3d1 --- /dev/null +++ b/apps/api/src/services/options.ts @@ -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(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 +} + +export async function getWebsiteOptions() { + const keys = Object.keys(websiteOptionDefaults) as Array + 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 +} diff --git a/apps/api/src/services/profile.ts b/apps/api/src/services/profile.ts new file mode 100644 index 0000000..718a5d7 --- /dev/null +++ b/apps/api/src/services/profile.ts @@ -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, + }) +} diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index 40d53ac..0fe0286 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -1,4 +1,4 @@ -import { submissionUpdateSchema } from "@oj2/contract" +import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract" import { and, eq } from "drizzle-orm" import { db, schema } from "./db" @@ -9,6 +9,7 @@ import { } from "./judge/events" import { JudgeStatus } from "./judge/status" import { createSubscriberRedis } from "./redis" +import { parseUserEvent, userEventChannel, userEventTopic } from "./events" export interface SubmissionSocketData { userId: number @@ -25,12 +26,14 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler { + 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 const event = parseSubmissionEvent(raw) if (!event) return @@ -144,6 +177,6 @@ export async function bridgeSubmissionEvents( subscriber.on("error", (error) => { console.error("Submission event subscriber error", error) }) - await subscriber.subscribe(submissionUpdateChannel) + await subscriber.subscribe(submissionUpdateChannel, userEventChannel) return subscriber } diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index f6d0df5..60e6daa 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -3,6 +3,8 @@ import { Worker } from "bullmq" import { config } from "./config" import { judgeQueueName, type JudgeJobData } from "./judge/job" import { judgeSubmission } from "./judge/run" +import { flowchartQueueName, type FlowchartJobData } from "./flowchart/job" +import { evaluateFlowchart } from "./flowchart/run" import { createBlockingRedis } from "./redis" const worker = new Worker( @@ -14,6 +16,12 @@ const worker = new Worker( }, ) +const flowchartWorker = new Worker( + flowchartQueueName, + async (job) => evaluateFlowchart(job.data), + { connection: createBlockingRedis(), concurrency: 2 }, +) + worker.on("ready", () => { console.log(`Judge worker ready (concurrency=${config.judgeConcurrency})`) }) @@ -23,9 +31,13 @@ worker.on("failed", (job, error) => { worker.on("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() { await worker.close() + await flowchartWorker.close() process.exit(0) } diff --git a/apps/web/src/oj/achievement/api.ts b/apps/web/src/oj/achievement/api.ts index 8d682f5..f5e612f 100644 --- a/apps/web/src/oj/achievement/api.ts +++ b/apps/web/src/oj/achievement/api.ts @@ -1,4 +1,4 @@ -import http from "utils/http" +import api2 from "utils/api2" import type { Achievement, AchievementSummary, @@ -6,22 +6,30 @@ import type { } from "utils/types" export function getAchievements(name?: string) { - return http.get<{ username: string; achievements: Achievement[] }>( - "achievements", - { params: name ? { name } : {} }, - ) + return api2.get("achievements", { params: name ? { username: name } : {} }) + .then((response) => ({ + ...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) { - return http.get("achievements/summary", { - params: name ? { name } : {}, + return api2.get("achievements/summary", { + params: name ? { username: name } : {}, }) } export function getPendingAchievements() { - return http.get("achievements/pending") + return api2.get("achievements/pending") } export function markAchievementsRead(ids: number[]) { - return http.post("achievements/pending", { ids }) + return api2.post("achievements/pending/read", { ids }) } diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 0aec4de..d9a64cf 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -5,6 +5,7 @@ import { } from "@oj2/contract" import api2 from "utils/api2" import http from "utils/http" +import type { ApiResponse } from "utils/http" import { filterResult } from "oj/transforms" import type { Exercise, @@ -17,20 +18,88 @@ import type { WebsiteConfig, } from "utils/types" +function snakeKey(key: string) { + return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) +} + +function toLegacy(value: unknown): T { + if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T + if (!value || typeof value !== "object") return value as T + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]), + ) as T +} + +async function legacyResponse(request: Promise>): Promise> { + const response = await request + return { error: response.error, data: toLegacy(response.data) } +} + +function listProblem(value: any): Problem { + return { + id: value.id, + _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 +} + +function detailProblem(value: unknown): Problem { + const problem = problemDetailSchema.parse(value) + return { + id: problem.id, + _id: problem._id, + title: problem.title, + description: problem.description, + input_description: problem.inputDescription, + output_description: problem.outputDescription, + samples: problem.samples, + hint: problem.hint ?? "", + languages: problem.languages, + template: problem.template, + create_time: problem.createTime, + last_update_time: problem.lastUpdateTime, + time_limit: problem.timeLimit, + memory_limit: problem.memoryLimit, + difficulty: problem.difficulty, + source: problem.source ?? "", + prompt: problem.prompt ?? "", + answers: [], + submission_number: problem.submissionNumber, + accepted_number: problem.acceptedNumber, + statistic_info: problem.statisticInfo, + share_submission: problem.shareSubmission, + contest: problem.contestId, + tags: problem.tags, + created_by: { + id: problem.createdBy.id, + username: problem.createdBy.username, + real_name: problem.createdBy.realName, + }, + my_status: problem.myStatus, + my_failed_count: problem.myFailedCount, + visible: true, + allow_flowchart: problem.allowFlowchart, + show_flowchart: problem.showFlowchart, + mermaid_code: problem.mermaidCode ?? undefined, + flowchart_data: problem.flowchartData ?? undefined, + flowchart_hint: problem.flowchartHint ?? undefined, + sql_config: problem.sqlConfig as Problem["sql_config"], + sql_display: problem.sqlDisplay as Problem["sql_display"], + } as Problem +} + export function getWebsiteConfig() { - return Promise.resolve({ - 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, - }) + return legacyResponse(api2.get("site")) } export async function getProblemList( @@ -38,91 +107,39 @@ export async function getProblemList( limit = 10, searchParams: any = {}, ) { - const res = await http.get<{ results: Problem[]; total: number }>("problem", { + const res = await api2.get<{ results: any[]; total: number }>("problems", { params: { paging: true, offset, limit, ...searchParams }, }) return { - results: res.data.results.map(filterResult), + results: res.data.results.map(listProblem).map(filterResult), total: res.data.total, } } export function getAuthors(all = false) { - return http.get("problem/author", { + return legacyResponse(api2.get("problem-authors", { params: { all: all ? "1" : "0", }, - }) + })) } export function getRandomProblemID() { - return http.get("pickone") + return api2.get("problems/random") } -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) { +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( - `problems/${encodeURIComponent(problemID)}`, + endpoint, ) - const problem = problemDetailSchema.parse(response.data) - return { - error: null, - data: { - id: problem.id, - _id: problem._id, - title: problem.title, - description: problem.description, - input_description: problem.inputDescription, - output_description: problem.outputDescription, - samples: problem.samples, - hint: problem.hint ?? "", - languages: problem.languages, - template: problem.template, - create_time: problem.createTime, - last_update_time: problem.lastUpdateTime, - time_limit: problem.timeLimit, - memory_limit: problem.memoryLimit, - difficulty: problem.difficulty, - source: problem.source ?? "", - prompt: problem.prompt ?? "", - answers: [], - submission_number: problem.submissionNumber, - accepted_number: problem.acceptedNumber, - statistic_info: problem.statisticInfo, - share_submission: problem.shareSubmission, - contest: problem.contestId, - tags: problem.tags, - created_by: { - id: problem.createdBy.id, - username: problem.createdBy.username, - real_name: problem.createdBy.realName, - }, - my_status: problem.myStatus, - my_failed_count: problem.myFailedCount, - visible: true, - allow_flowchart: problem.allowFlowchart, - show_flowchart: problem.showFlowchart, - mermaid_code: problem.mermaidCode, - flowchart_data: problem.flowchartData, - flowchart_hint: problem.flowchartHint, - sql_config: problem.sqlConfig, - sql_display: problem.sqlDisplay, - } as Problem, - } + return { error: null, data: detailProblem(response.data) } } 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) { @@ -167,21 +184,37 @@ export async function submitCode(data: SubmitCodePayload) { } export function formatCode(data: { code: string; language: string }) { - // 格式化端点在 Phase 3 迁移;Phase 2 保留原代码继续提交。 - return Promise.resolve({ error: null, data: { code: data.code } }) + const languages: Record = { + 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) { - const endpoint = !!params.contest_id ? "contest_submissions" : "submissions" - return http.get(endpoint, { params }) + const endpoint = params.contest_id + ? `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) { - 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) { - return http.get("submissions/today_count", { params: { language } }) + return api2.get("submissions/today-count", { params: { language } }) } export function adminRejudge(id: string) { @@ -210,21 +243,21 @@ export function getRank( n: number, username?: string, ) { - return http.get("user_rank", { - params: { offset, limit, rule: "acm", username, n }, - }) + return legacyResponse(api2.get("rankings/users", { + params: { offset, limit, username, top: n }, + })) } export function getActivityRank(start: string) { - return http.get("user_activity_rank", { + return api2.get("rankings/activity", { params: { start }, }) } export function getClassRank(grade?: number | null) { - return http.get("class_rank", { + return legacyResponse(api2.get("rankings/classes", { params: { grade }, - }) + })) } export function getUserClassRank( @@ -232,7 +265,7 @@ export function getUserClassRank( offset?: 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( @@ -241,15 +274,15 @@ export function getClassPK( endTime?: string, ) { const payload: any = { - class_name: classNames, + classNames, } if (startTime) { - payload.start_time = startTime + payload.startTime = startTime } 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: { @@ -259,61 +292,64 @@ export function getContestList(query: { status: string tag: string }) { - return http.get("contests", { params: query }) + return legacyResponse(api2.get("contests", { params: query })) } export function getContest(id: string) { - return http.get("contest", { params: { id } }) + return legacyResponse(api2.get(`contests/${encodeURIComponent(id)}`)) } 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) { - return http.post("contest/password", { - contest_id: contestID, - password, - }) + return api2.post(`contests/${encodeURIComponent(contestID)}/access`, { password }) } export async function getContestProblems(contestID: string) { - const res = await http.get("contest/problem", { - params: { contest_id: contestID }, - }) - return res.data.map(filterResult) + const res = await api2.get(`contests/${encodeURIComponent(contestID)}/problems`) + return res.data.map(listProblem).map(filterResult) } export function getContestRank( contestID: string, query: { limit: number; offset: number }, ) { - return http.get("contest_rank", { - params: { - contest_id: contestID, - ...query, - }, - }) + return legacyResponse(api2.get(`contests/${encodeURIComponent(contestID)}/rank`, { params: query })) + .then((response) => ({ + ...response, + data: { + ...response.data, + results: response.data.results.map((item: any) => ({ + ...item, + contest: item.contest_id, + })), + }, + })) } export function uploadAvatar(file: File) { const form = new window.FormData() form.append("image", file) - return http.post("upload_avatar", form, { + return api2.post("me/avatar", form, { headers: { "content-type": "multipart/form-data" }, }) } 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) { - return http.get("announcement", { params: { limit, offset } }) + return legacyResponse(api2.get("announcements", { params: { limit, offset } })) } export function getAnnouncement(id: number) { - return http.get("announcement", { params: { id } }) + return legacyResponse(api2.get(`announcements/${id}`)) } export function createMessage(data: { @@ -321,45 +357,54 @@ export function createMessage(data: { message: 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) { - return http.get("message", { params: { limit, offset } }) + return legacyResponse(api2.get("messages", { params: { limit, offset } })) } export function getReaction(problemID: number) { - return http.get("reaction", { - params: { problem_id: problemID }, - }) + return api2.get(`problems/${problemID}/reaction`) } export function setReaction(problemID: number, type: ReactionKey) { - return http.post("reaction", { - problem_id: problemID, - type, - }) + return api2.post(`problems/${problemID}/reaction`, { type }) } // TODO: 这个API有问题 export function refreshUserProblemDisplayIds() { - return http.get("profile/fresh_display_id") + return api2.post("me/problem-display-ids/refresh") } export function getMetrics(userid: number) { - return http.get("metrics", { params: { userid } }) + return api2.get(`users/${userid}/metrics`) } export function getTutorial(id: number) { - return http.get("tutorial", { params: { id } }) + return legacyResponse(api2.get(`tutorials/${id}`)) } 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) { - return http.get("ai/detail", { params: { start, end, username } }) + return legacyResponse(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( @@ -367,25 +412,26 @@ export function getAIDurationData( duration: 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) { - return http.get("ai/heatmap", { params: username ? { username } : {} }) + return api2.get("ai/heatmap", { params: username ? { username } : {} }) } export function getAILoginSummary() { - return http.get("ai/login_summary") + return legacyResponse(api2.get("ai/login-summary")) } export function getAIPinnedReport() { - return http.get("ai/pinned") + return legacyResponse(api2.get("ai/pinned")) } // ==================== 相似题目推荐 ==================== export function getSimilarProblems(problemId: string) { - return http.get("problem/similar", { params: { problem_id: problemId } }) + return api2.get(`problems/${encodeURIComponent(problemId)}/similar`) + .then((response) => ({ ...response, data: response.data.map(listProblem).map(filterResult) })) } export interface YearlyACData { @@ -396,9 +442,7 @@ export interface YearlyACData { } export function getProblemYearlyAC(problemId: string) { - return http.get("problem/yearly_ac", { - params: { problem_id: problemId }, - }) + return legacyResponse(api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`)) } // ==================== 流程图相关API ==================== @@ -408,13 +452,25 @@ export function submitFlowchart(data: { mermaid_code: string 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) { - return http.get("flowchart/submission", { - params: { id }, - }) +function legacyFlowchart(value: unknown) { + const item = toLegacy(value) + 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: { @@ -426,7 +482,11 @@ export function getFlowchartSubmissions(params: { today?: string grade?: string }) { - return http.get("flowchart/submissions", { params }) + return legacyResponse(api2.get("flowcharts", { params: { + ...params, + problemId: params.problem_id, + problem_id: undefined, + } })) } export function getFlowchartStatistics( @@ -444,21 +504,22 @@ export function getFlowchartStatistics( } export function retryFlowchartSubmission(submissionId: string) { - return http.post("flowchart/submission/retry", { - submission_id: submissionId, - }) + return legacyResponse(api2.post(`flowcharts/${encodeURIComponent(submissionId)}/retry`)) } export function getCurrentProblemFlowchartSubmission(problemId: number) { - return http.get("flowchart/submission/current", { - params: { problem_id: problemId }, - }) + return api2.get(`problems/${problemId}/flowchart/current`) } -export function getFlowchartSubmissionDetail(problemId: number, page = 0) { - return http.get("flowchart/submission/detail", { - params: { problem_id: problemId, page }, - }) +export async function getFlowchartSubmissionDetail(problemId: number, page = 0) { + const response = await api2.get(`problems/${problemId}/flowchart/history`, { params: { page } }) + return { + ...response, + data: { + ...response.data, + submission: response.data.submission ? legacyFlowchart(response.data.submission) : null, + }, + } } // ==================== 题单相关API ==================== @@ -470,7 +531,7 @@ export function getProblemSetList( difficulty = "", status = "", ) { - return http.get("problemset", { + return legacyResponse(api2.get("problem-sets", { params: { offset, limit, @@ -478,21 +539,54 @@ export function getProblemSetList( difficulty, status, }, - }) + })).then(mapProblemSetResponse) } export function getProblemSetDetail(id: number) { - return http.get(`problemset/${id}`) + return legacyResponse(api2.get(`problem-sets/${id}`)).then((response) => ({ + ...response, + data: legacyProblemSet(response.data), + })) } -export function getProblemSetProblems(problemSetId: number) { - return http.get(`problemset/${problemSetId}/problems`) +function legacyBadge(value: any) { + return { ...value, problemset: value.problemset_id } +} + +function legacyProblemSet(value: any) { + return { + ...value, + badges: value.badges?.map(legacyBadge), + } +} + +function mapProblemSetResponse(response: ApiResponse) { + return { + ...response, + data: { + ...response.data, + results: response.data.results.map(legacyProblemSet), + }, + } +} + +export async function getProblemSetProblems(problemSetId: number) { + const response = await legacyResponse(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) { - return http.post("problemset/progress", { - problemset_id: problemSetId, - }) + return api2.post("problem-set-progress", { problemSetId }) } export function updateProblemSetProgress( @@ -500,21 +594,33 @@ export function updateProblemSetProgress( problemId: number, submissionId: string, ) { - return http.put("problemset/progress", { - problemset_id: problemSetId, - problem_id: problemId, - submission_id: submissionId, + return legacyResponse(api2.put("problem-set-progress", { + problemSetId, + problemId, + submissionId, }) + ) } // 获取用户徽章列表 -export function getUserBadges(username?: string) { - return http.get("user/badges", { params: username ? { username } : {} }) +export async function getUserBadges(username?: string) { + const response = await legacyResponse(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) { - return http.get(`problemset/${problemSetId}/badges`) +export async function getProblemSetBadges(problemSetId: number) { + const response = await legacyResponse(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" }, ) { - 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 { - const res = await http.get("exercises", { - params: { tutorial_id: tutorialId }, - }) + const res = await api2.get(`tutorials/${tutorialId}/exercises`) return res.data } diff --git a/apps/web/src/oj/class/pk.vue b/apps/web/src/oj/class/pk.vue index 264608b..76dbaec 100644 --- a/apps/web/src/oj/class/pk.vue +++ b/apps/web/src/oj/class/pk.vue @@ -176,12 +176,12 @@ async function analyzeWithAI() { if (csrfToken) headers["X-CSRFToken"] = csrfToken try { - const response = await fetch("/api/ai/class_pk", { + const response = await fetch("/api2/ai/class-pk-analysis", { method: "POST", headers, body: JSON.stringify({ comparisons: comparisons.value, - time_range_label: timeRangeLabel, + timeRangeLabel, }), signal: controller.signal, }) diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index cce77ba..25c6d4c 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -83,10 +83,10 @@ async function fetchHint(submissionId: string) { headers["X-CSRFToken"] = csrfToken } - const response = await fetch("/api/ai/hint", { + const response = await fetch("/api2/ai/hint", { method: "POST", headers, - body: JSON.stringify({ submission_id: submissionId }), + body: JSON.stringify({ submissionId }), }) await consumeJSONEventStream(response, { diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index 0b94a7e..505bc30 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -95,7 +95,7 @@ async function analyzeSingleClassWithAI() { if (csrfToken) headers["X-CSRFToken"] = csrfToken try { - const response = await fetch("/api/ai/class_single", { + const response = await fetch("/api2/ai/class-analysis", { method: "POST", headers, body: JSON.stringify({ comparison: classDetailData.value }), diff --git a/apps/web/src/oj/store/ai.ts b/apps/web/src/oj/store/ai.ts index 2e66c95..95ef9c5 100644 --- a/apps/web/src/oj/store/ai.ts +++ b/apps/web/src/oj/store/ai.ts @@ -104,7 +104,7 @@ export const useAIStore = defineStore("ai", () => { } try { - const response = await fetch("/api/ai/analysis", { + const response = await fetch("/api2/ai/analysis", { method: "POST", headers, body: JSON.stringify({ diff --git a/apps/web/src/shared/api.ts b/apps/web/src/shared/api.ts index 327afc9..e660a85 100644 --- a/apps/web/src/shared/api.ts +++ b/apps/web/src/shared/api.ts @@ -1,6 +1,5 @@ import { userProfileSchema } from "@oj2/contract" import api2 from "utils/api2" -import http from "utils/http" import type { ApiResponse } from "utils/http" import type { Profile, Tag } from "utils/types" @@ -13,7 +12,7 @@ export function signup(data: { email: string password: string }) { - return http.post("register", data) + return api2.post("users", data) } export function logout() { @@ -23,9 +22,9 @@ export function logout() { export async function getProfile( username: string = "", ): Promise> { - if (username) return http.get("profile", { params: { username } }) - - const response = await api2.get("me") + const response = await api2.get( + username ? `profiles/${encodeURIComponent(username)}` : "me", + ) if (response.data === null) return { error: null, data: null } const profile = userProfileSchema.parse(response.data) return { @@ -61,13 +60,13 @@ export async function getProfile( } export function getProblemTagList() { - return http.get("problem/tags") + return api2.get>("problem-tags") } export function getHitokoto() { - return http.get("hitokoto") + return api2.get("quotes/random") } export function getClassUsernames(classroom: string) { - return http.get("class_usernames", { params: { classroom: classroom } }) + return api2.get(`classes/${encodeURIComponent(classroom)}/usernames`) } diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index 0522c4e..766c485 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -448,8 +448,10 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage { */ class FlowchartWebSocket extends BaseWebSocket { constructor() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" super({ - path: "flowchart", // 使用专门的 flowchart WebSocket 路径 + path: "flowchart", + url: `${protocol}//${window.location.host}/ws2/submissions`, }) } diff --git a/apps/web/src/utils/api2.ts b/apps/web/src/utils/api2.ts index 429736f..a0ca9ba 100644 --- a/apps/web/src/utils/api2.ts +++ b/apps/web/src/utils/api2.ts @@ -15,6 +15,11 @@ interface Api2Client { data?: unknown, config?: AxiosRequestConfig, ): Promise> + put( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ): Promise> delete(url: string, config?: AxiosRequestConfig): Promise> } @@ -23,6 +28,17 @@ const instance = axios.create({ 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( (response) => Promise.resolve({ error: null, data: response.data.data }), (error) => { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 3476091..5b8e23e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -178,6 +178,10 @@ export default defineConfig(({ mode }) => { rewrite: (path: string) => path.replace(/^\/ws2/, "/ws"), }, "/api": proxyConfig, + "/public/avatar": { + target: "http://localhost:3000", + changeOrigin: true, + }, "/public": proxyConfig, "/ws": wsProxyConfig, }, diff --git a/packages/contract/src/account.ts b/packages/contract/src/account.ts new file mode 100644 index 0000000..d5a531c --- /dev/null +++ b/packages/contract/src/account.ts @@ -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 +export type UpdateProfileRequest = z.infer diff --git a/packages/contract/src/achievement.ts b/packages/contract/src/achievement.ts new file mode 100644 index 0000000..f29d7fa --- /dev/null +++ b/packages/contract/src/achievement.ts @@ -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()), +}) diff --git a/packages/contract/src/ai.ts b/packages/contract/src/ai.ts new file mode 100644 index 0000000..00c3167 --- /dev/null +++ b/packages/contract/src/ai.ts @@ -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(), +}) diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts new file mode 100644 index 0000000..0636c9f --- /dev/null +++ b/packages/contract/src/classroom.ts @@ -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(), +}) diff --git a/packages/contract/src/common.ts b/packages/contract/src/common.ts new file mode 100644 index 0000000..1a455b8 --- /dev/null +++ b/packages/contract/src/common.ts @@ -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(item: T) { + return z.object({ + results: z.array(item), + total: z.number().int().nonnegative(), + }) +} + +export type SampleUser = z.infer diff --git a/packages/contract/src/content.ts b/packages/contract/src/content.ts new file mode 100644 index 0000000..ee4a4be --- /dev/null +++ b/packages/contract/src/content.ts @@ -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(), +}) diff --git a/packages/contract/src/contest.ts b/packages/contract/src/contest.ts new file mode 100644 index 0000000..6fe9529 --- /dev/null +++ b/packages/contract/src/contest.ts @@ -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) diff --git a/packages/contract/src/flowchart.ts b/packages/contract/src/flowchart.ts new file mode 100644 index 0000000..09f0eb0 --- /dev/null +++ b/packages/contract/src/flowchart.ts @@ -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 diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 23f3e99..db03aee 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,3 +1,13 @@ 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 "./problemset" +export * from "./site" export * from "./submission" diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index b489c25..dd93fa8 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { paginatedSchema, sampleUserSchema } from "./common" + /** 题目列表项。字段取自 problem 表,只含列表页需要的列。 */ export const problemSummarySchema = z.object({ id: z.number().int(), @@ -58,3 +60,39 @@ export const problemDetailSchema = z.object({ }) export type ProblemDetail = z.infer + +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(), +}) diff --git a/packages/contract/src/problemset.ts b/packages/contract/src/problemset.ts new file mode 100644 index 0000000..fcb5025 --- /dev/null +++ b/packages/contract/src/problemset.ts @@ -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() }), +}) diff --git a/packages/contract/src/site.ts b/packages/contract/src/site.ts new file mode 100644 index 0000000..abc09d0 --- /dev/null +++ b/packages/contract/src/site.ts @@ -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 diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index ce46735..08c63a6 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { paginatedSchema } from "./common" + export const judgeStatusSchema = z.union([ z.literal(-2), z.literal(-1), @@ -55,6 +57,31 @@ export const submissionUpdateSchema = z.object({ 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 export type CreateSubmissionRequest = z.infer< typeof createSubmissionRequestSchema