feat(阶段3): oj 侧端点铺开(基线提交,未经评审)
由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线, 后续修复单独成 commit,便于区分与回退。 覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/ content/contest/flowchart/problemset/site)与对应 Zod 契约。 已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据; judge 与 flowchart worker 均 ready。 未核验:权限边界与数据泄露,评审进行中。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ const SESSION_PREFIX = "session:"
|
||||
interface StoredSession {
|
||||
userId: number
|
||||
createdAt: string
|
||||
previousLogin: string | null
|
||||
contestPasswords: Record<string, string>
|
||||
}
|
||||
|
||||
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<AuthUser | nul
|
||||
adminType: schema.user.adminType,
|
||||
problemPermission: schema.user.problemPermission,
|
||||
isDisabled: schema.user.isDisabled,
|
||||
className: schema.user.className,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, session.userId))
|
||||
@@ -108,3 +118,41 @@ export function getSessionUser(c: Context) {
|
||||
export function getRequestSessionUser(request: Request) {
|
||||
return getUserByToken(readCookie(request, config.sessionCookie))
|
||||
}
|
||||
|
||||
async function getStoredSession(c: Context) {
|
||||
const token = getCookie(c, config.sessionCookie)
|
||||
if (!token) return null
|
||||
const raw = await redis.get(sessionKey(token))
|
||||
if (!raw) return null
|
||||
try {
|
||||
const value = JSON.parse(raw) as StoredSession
|
||||
value.contestPasswords ??= {}
|
||||
value.previousLogin ??= null
|
||||
return { token, value }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setContestPassword(c: Context, contestId: number, password: string) {
|
||||
const session = await getStoredSession(c)
|
||||
if (!session) return false
|
||||
session.value.contestPasswords[String(contestId)] = password
|
||||
await redis.set(
|
||||
sessionKey(session.token),
|
||||
JSON.stringify(session.value),
|
||||
"EX",
|
||||
config.sessionTtlSeconds,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function getContestPassword(c: Context, contestId: number) {
|
||||
const session = await getStoredSession(c)
|
||||
return session?.value.contestPasswords[String(contestId)] ?? null
|
||||
}
|
||||
|
||||
export async function getPreviousLogin(c: Context) {
|
||||
const session = await getStoredSession(c)
|
||||
return session?.value.previousLogin ?? null
|
||||
}
|
||||
|
||||
@@ -7,4 +7,11 @@ export const config = {
|
||||
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
||||
judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token",
|
||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||
avatarDirectory: process.env.AVATAR_DIRECTORY ?? "data/avatar",
|
||||
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
||||
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
||||
aiKey: process.env.AI_KEY ?? "",
|
||||
aiModel: process.env.AI_MODEL ?? "deepseek-v4-flash",
|
||||
ruffPath: process.env.RUFF_PATH ?? "ruff",
|
||||
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
|
||||
}
|
||||
|
||||
48
apps/api/src/events.ts
Normal file
48
apps/api/src/events.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
|
||||
|
||||
import { redis } from "./redis"
|
||||
|
||||
export const userEventChannel = "user:events"
|
||||
|
||||
interface UserEvent {
|
||||
userId: number
|
||||
data: FlowchartUpdate | Record<string, unknown>
|
||||
}
|
||||
|
||||
interface AchievementNotification {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
rarity: string
|
||||
kind: "achievement" | "badge"
|
||||
}
|
||||
|
||||
export function userEventTopic(userId: number) {
|
||||
return `events:user:${userId}`
|
||||
}
|
||||
|
||||
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
|
||||
await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) }))
|
||||
}
|
||||
|
||||
export async function publishAchievementNotification(
|
||||
userId: number,
|
||||
achievements: AchievementNotification[],
|
||||
) {
|
||||
if (!achievements.length) return
|
||||
await redis.publish(userEventChannel, JSON.stringify({
|
||||
userId,
|
||||
data: { type: "achievement_unlocked", achievements },
|
||||
}))
|
||||
}
|
||||
|
||||
export function parseUserEvent(raw: string): UserEvent | null {
|
||||
try {
|
||||
const value = JSON.parse(raw) as UserEvent
|
||||
if (!Number.isInteger(value.userId) || !value.data || typeof value.data !== "object") return null
|
||||
return value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
5
apps/api/src/flowchart/job.ts
Normal file
5
apps/api/src/flowchart/job.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const flowchartQueueName = "evaluate-flowchart"
|
||||
|
||||
export interface FlowchartJobData {
|
||||
submissionId: string
|
||||
}
|
||||
77
apps/api/src/flowchart/run.ts
Normal file
77
apps/api/src/flowchart/run.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { flowchartUpdateSchema } from "@oj2/contract"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { publishFlowchartUpdate } from "../events"
|
||||
import { completeChat } from "../services/ai"
|
||||
import type { FlowchartJobData } from "./job"
|
||||
|
||||
function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
||||
return `你是专业的编程教学助手,负责评估学生的 Mermaid 流程图。
|
||||
评分:逻辑正确性40分、完整性30分、规范性20分、清晰度10分。
|
||||
不要评价系统生成的节点ID。feedback不超过100字;suggestions最多3条且只针对真实问题。
|
||||
返回纯 JSON:{"score":85,"grade":"A","feedback":"...","suggestions":"...","criteria_details":{}}。
|
||||
等级:S=90-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<string, unknown>
|
||||
if (typeof data.score !== "number" || typeof data.grade !== "string") throw new Error("AI response is missing score or grade")
|
||||
return {
|
||||
score: Math.max(0, Math.min(100, data.score)),
|
||||
grade: data.grade,
|
||||
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
||||
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
|
||||
criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluateFlowchart(job: FlowchartJobData) {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1)
|
||||
if (!row || ![0, 1].includes(row.flowchart.status)) return
|
||||
await db.update(schema.flowchartSubmission).set({ status: 1 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
const started = performance.now()
|
||||
try {
|
||||
const reference = row.problem.mermaidCode ? `\n标准答案参考:\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。"
|
||||
const result = parseEvaluation(await completeChat(
|
||||
evaluationPrompt(row.problem),
|
||||
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
|
||||
))
|
||||
await db.update(schema.flowchartSubmission).set({
|
||||
status: 2,
|
||||
aiScore: result.score,
|
||||
aiGrade: result.grade,
|
||||
aiFeedback: result.feedback,
|
||||
aiSuggestions: result.suggestions,
|
||||
aiCriteriaDetails: result.criteria,
|
||||
aiProvider: "deepseek",
|
||||
aiModel: process.env.AI_MODEL ?? "deepseek-v4-flash",
|
||||
processingTime: (performance.now() - started) / 1000,
|
||||
evaluationTime: new Date().toISOString(),
|
||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
|
||||
type: "flowchart_evaluation_completed",
|
||||
submission_id: row.flowchart.id,
|
||||
score: result.score,
|
||||
grade: result.grade,
|
||||
feedback: result.feedback,
|
||||
suggestions: result.suggestions,
|
||||
criteriaDetails: result.criteria,
|
||||
}))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
|
||||
type: "flowchart_evaluation_failed",
|
||||
submission_id: row.flowchart.id,
|
||||
error: message,
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -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<SubmissionSocketData>({
|
||||
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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>',
|
||||
{ headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
if (url.pathname === "/ws/submissions") {
|
||||
const user = await getRequestSessionUser(request)
|
||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown> = {
|
||||
is_ac: acceptedNow,
|
||||
ac_time: 0,
|
||||
error_number:
|
||||
errorNumber +
|
||||
(!acceptedNow && result !== JudgeStatus.COMPILE_ERROR ? 1 : 0),
|
||||
is_first_ac: false,
|
||||
}
|
||||
let totalTime = rank.totalTime
|
||||
let acceptedNumber = rank.acceptedNumber
|
||||
if (acceptedNow) {
|
||||
const acTime = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) /
|
||||
1000,
|
||||
),
|
||||
)
|
||||
nextInfo.ac_time = acTime
|
||||
nextInfo.is_first_ac = problem.acceptedNumber === 0
|
||||
acceptedNumber += 1
|
||||
totalTime += acTime + errorNumber * 20 * 60
|
||||
}
|
||||
rankInfo[String(problemId)] = nextInfo
|
||||
await tx
|
||||
.update(schema.acmContestRank)
|
||||
.set({
|
||||
submissionNumber: rank.submissionNumber + 1,
|
||||
acceptedNumber,
|
||||
totalTime,
|
||||
submissionInfo: rankInfo,
|
||||
})
|
||||
.where(eq(schema.acmContestRank.id, rank.id))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<JudgeJobData>(judgeQueueName, {
|
||||
@@ -10,3 +11,13 @@ export const judgeQueue = new Queue<JudgeJobData>(judgeQueueName, {
|
||||
removeOnFail: 500,
|
||||
},
|
||||
})
|
||||
|
||||
export const flowchartQueue = new Queue<FlowchartJobData>(flowchartQueueName, {
|
||||
connection: createBlockingRedis(),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 2_000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
})
|
||||
|
||||
238
apps/api/src/routes/account.ts
Normal file
238
apps/api/src/routes/account.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { extname, resolve } from "node:path"
|
||||
|
||||
import {
|
||||
activityRankItemSchema,
|
||||
metricsSchema,
|
||||
problemRankSchema,
|
||||
rankProfileSchema,
|
||||
registerRequestSchema,
|
||||
updateProfileRequestSchema,
|
||||
userRankSchema,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
lte,
|
||||
min,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { getUserProfileById } from "../services/profile"
|
||||
import { objectValue, queryInteger } from "./helpers"
|
||||
|
||||
export const accountRoutes = new Hono<AppEnv>()
|
||||
|
||||
accountRoutes.post("/users", async (c) => {
|
||||
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||
if (!(await getBooleanOption("allow_register", true))) {
|
||||
return failure(c, 403, "registration-disabled", "Register function has been disabled by admin")
|
||||
}
|
||||
|
||||
const username = parsed.data.username.toLowerCase()
|
||||
const email = parsed.data.email.toLowerCase()
|
||||
const [duplicate] = await db
|
||||
.select({ username: schema.user.username, email: schema.user.email })
|
||||
.from(schema.user)
|
||||
.where(or(sql`lower(${schema.user.username}) = ${username}`, sql`lower(${schema.user.email}) = ${email}`))
|
||||
.limit(1)
|
||||
if (duplicate?.username.toLowerCase() === username) {
|
||||
return failure(c, 409, "username-exists", "Username already exists")
|
||||
}
|
||||
if (duplicate?.email?.toLowerCase() === email) {
|
||||
return failure(c, 409, "email-exists", "Email already exists")
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const password = await Bun.password.hash(parsed.data.password, { algorithm: "argon2id" })
|
||||
await db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(schema.user).values({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
rawPassword: parsed.data.password.slice(0, 20),
|
||||
lastLogin: null,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
authToken: null,
|
||||
openApi: false,
|
||||
openApiAppkey: null,
|
||||
isDisabled: false,
|
||||
problemPermission: "None",
|
||||
sessionKeys: [],
|
||||
className: null,
|
||||
}).returning({ id: schema.user.id })
|
||||
if (!created) throw new Error("User insert did not return an id")
|
||||
await tx.insert(schema.userProfile).values({
|
||||
userId: created.id,
|
||||
acmProblemsStatus: {},
|
||||
avatar: `${config.avatarUriPrefix}/default.png`,
|
||||
blog: null,
|
||||
mood: null,
|
||||
acceptedNumber: 0,
|
||||
submissionNumber: 0,
|
||||
github: null,
|
||||
school: null,
|
||||
major: null,
|
||||
realName: null,
|
||||
language: null,
|
||||
})
|
||||
})
|
||||
return success(c, { ok: true }, 201)
|
||||
})
|
||||
|
||||
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
||||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
||||
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1)
|
||||
if (!target) return failure(c, 404, "user-not-found", "User does not exist")
|
||||
const profile = await getUserProfileById(target.id, c.get("user")?.id === target.id)
|
||||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
return success(c, profile)
|
||||
})
|
||||
|
||||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||||
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid profile payload")
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(parsed.data).map(([key, value]) => [key, value === "" ? null : value]),
|
||||
)
|
||||
await db.update(schema.userProfile).set(values).where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
const profile = await getUserProfileById(c.get("user")!.id, true)
|
||||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
||||
return success(c, profile)
|
||||
})
|
||||
|
||||
accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
||||
const body: Record<string, string | File> = await c.req.parseBody().catch(() => ({}))
|
||||
const image = body.image
|
||||
if (!(image instanceof File)) return failure(c, 400, "invalid-file", "Invalid file content")
|
||||
if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large")
|
||||
const extension = extname(image.name).toLowerCase()
|
||||
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
||||
return failure(c, 400, "unsupported-file", "Unsupported file format")
|
||||
}
|
||||
const filename = `${randomBytes(10).toString("hex")}${extension}`
|
||||
const directory = resolve(config.avatarDirectory)
|
||||
await Bun.$`mkdir -p ${directory}`.quiet()
|
||||
await Bun.write(resolve(directory, filename), image)
|
||||
const avatar = `${config.avatarUriPrefix}/${filename}`
|
||||
await db.update(schema.userProfile).set({ avatar }).where(eq(schema.userProfile.userId, c.get("user")!.id))
|
||||
return success(c, { avatar })
|
||||
})
|
||||
|
||||
accountRoutes.get("/users/:id/metrics", async (c) => {
|
||||
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ total: count(), first: min(schema.submission.createTime), latest: sql<string>`max(${schema.submission.createTime})` })
|
||||
.from(schema.submission)
|
||||
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
|
||||
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
|
||||
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest }))
|
||||
})
|
||||
|
||||
accountRoutes.get("/rankings/users", async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const top = queryInteger(c.req.query("top"), 0, { min: 0, max: 10_000 })
|
||||
const username = c.req.query("username")?.trim() ?? ""
|
||||
const where = and(
|
||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
gte(schema.userProfile.acceptedNumber, 0),
|
||||
username ? ilike(schema.user.username, `%${username}%`) : undefined,
|
||||
)
|
||||
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
const rows = await db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.userProfile.acceptedNumber), asc(schema.userProfile.submissionNumber))
|
||||
.limit(top > 0 ? Math.min(top, 250) : limit).offset(top > 0 ? 0 : offset)
|
||||
const results = rows.map(({ profile, user }) => rankProfileSchema.parse({
|
||||
id: profile.id,
|
||||
user: { id: user.id, username: user.username, realName: profile.realName },
|
||||
acceptedNumber: profile.acceptedNumber,
|
||||
submissionNumber: profile.submissionNumber,
|
||||
mood: profile.mood,
|
||||
}))
|
||||
return success(c, userRankSchema.parse({ results, total: totalRow?.value ?? 0 }))
|
||||
})
|
||||
|
||||
accountRoutes.get("/rankings/activity", async (c) => {
|
||||
const start = c.req.query("start")
|
||||
if (!start || Number.isNaN(Date.parse(start))) return failure(c, 400, "invalid-start", "start time is required")
|
||||
const rows = await db.select({ username: schema.submission.username, value: countDistinct(schema.submission.problemId) })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
||||
.where(and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, start),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
sql`${schema.user.adminType} <> 'Super Admin'`,
|
||||
))
|
||||
.groupBy(schema.submission.username).orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
||||
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value })))
|
||||
})
|
||||
|
||||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10]))
|
||||
const [all] = await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(accepted)
|
||||
const className = user.className ?? ""
|
||||
const classWhere = className
|
||||
? and(accepted, inArray(schema.submission.userId, db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false)))))
|
||||
: accepted
|
||||
const [classCount] = className
|
||||
? await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(classWhere)
|
||||
: [{ value: 0 }]
|
||||
const [first] = await db.select({ value: min(schema.submission.createTime) }).from(schema.submission)
|
||||
.where(and(classWhere, eq(schema.submission.userId, user.id)))
|
||||
let rank = -1
|
||||
if (first?.value) {
|
||||
const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value)))
|
||||
rank = rankRow?.value ?? -1
|
||||
}
|
||||
return success(c, problemRankSchema.parse({ className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 }))
|
||||
})
|
||||
|
||||
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, user.id)).limit(1)
|
||||
const status = objectValue(profile?.value)
|
||||
const problems = objectValue(status.problems)
|
||||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||||
if (ids.length > 0) {
|
||||
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.displayId }).from(schema.problem)
|
||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true)))
|
||||
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId]))
|
||||
for (const [id, value] of Object.entries(problems)) {
|
||||
const item = objectValue(value)
|
||||
const displayId = displayIds.get(id)
|
||||
if (displayId) item._id = displayId
|
||||
problems[id] = item
|
||||
}
|
||||
status.problems = problems
|
||||
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
121
apps/api/src/routes/achievement.ts
Normal file
121
apps/api/src/routes/achievement.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
achievementListSchema,
|
||||
achievementSchema,
|
||||
achievementSummarySchema,
|
||||
markAchievementsReadSchema,
|
||||
pendingAchievementSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { objectValue } from "./helpers"
|
||||
|
||||
export const achievementRoutes = new Hono<AppEnv>()
|
||||
|
||||
async function resolveUser(requested: string | undefined, currentId: number) {
|
||||
if (!requested) {
|
||||
const [current] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
||||
.where(eq(schema.user.id, currentId)).limit(1)
|
||||
return current ?? null
|
||||
}
|
||||
const [target] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
||||
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false))).limit(1)
|
||||
return target ?? null
|
||||
}
|
||||
|
||||
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
|
||||
return pendingAchievementSchema.parse({
|
||||
id: row.achievement.id,
|
||||
name: row.achievement.name,
|
||||
description: row.achievement.description,
|
||||
icon: row.achievement.icon,
|
||||
rarity: row.achievement.rarity,
|
||||
})
|
||||
}
|
||||
|
||||
achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([
|
||||
db.select().from(schema.achievement).where(eq(schema.achievement.visible, true)).orderBy(asc(schema.achievement.order), asc(schema.achievement.id)),
|
||||
db.select().from(schema.userAchievement).where(eq(schema.userAchievement.userId, target.id)),
|
||||
db.select({ metrics: schema.userStat.metrics }).from(schema.userStat).where(eq(schema.userStat.userId, target.id)).limit(1),
|
||||
db.select({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)),
|
||||
])
|
||||
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
|
||||
const metrics = objectValue(statRows[0]?.metrics)
|
||||
const active = activeRows[0]?.value ?? 0
|
||||
const result = achievements.map((achievement) => {
|
||||
const record = unlocked.get(achievement.id)
|
||||
const masked = achievement.hidden && !record
|
||||
const progress = metrics[achievement.metric]
|
||||
return achievementSchema.parse({
|
||||
id: achievement.id,
|
||||
name: masked ? "???" : achievement.name,
|
||||
description: masked ? "达成条件保密" : achievement.description,
|
||||
icon: masked ? "noto:red-question-mark" : achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
hidden: achievement.hidden,
|
||||
metric: masked ? null : achievement.metric,
|
||||
operator: masked ? null : achievement.operator,
|
||||
threshold: masked ? null : achievement.threshold,
|
||||
unlocked: Boolean(record),
|
||||
unlockTime: record?.unlockTime ?? null,
|
||||
backfilled: record?.backfilled ?? false,
|
||||
progress: masked ? null : typeof progress === "number" ? progress : 0,
|
||||
unlockRate: active > 0 ? Math.round(achievement.unlockCount / active * 1000) / 10 : 0,
|
||||
})
|
||||
})
|
||||
return success(c, achievementListSchema.parse({ username: target.username, achievements: result }))
|
||||
})
|
||||
|
||||
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const [achievements, unlockedRows] = await Promise.all([
|
||||
db.select({ id: schema.achievement.id, rarity: schema.achievement.rarity }).from(schema.achievement).where(eq(schema.achievement.visible, true)),
|
||||
db.select({ record: schema.userAchievement, achievement: schema.achievement }).from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true))).orderBy(desc(schema.userAchievement.unlockTime)),
|
||||
])
|
||||
const labels = { bronze: "青铜", silver: "白银", gold: "黄金", platinum: "白金" }
|
||||
const rarities = ["bronze", "silver", "gold", "platinum"] as const
|
||||
const total = achievements.length
|
||||
const unlocked = unlockedRows.length
|
||||
return success(c, achievementSummarySchema.parse({
|
||||
username: target.username,
|
||||
total,
|
||||
unlocked,
|
||||
percent: total > 0 ? Math.round(unlocked / total * 1000) / 10 : 0,
|
||||
rarity: rarities.map((rarity) => ({
|
||||
rarity,
|
||||
label: labels[rarity],
|
||||
total: achievements.filter((item) => item.rarity === rarity).length,
|
||||
unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length,
|
||||
})),
|
||||
recent: unlockedRows.slice(0, 10).map(pendingData),
|
||||
}))
|
||||
})
|
||||
|
||||
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||
const rows = await db.select({ record: schema.userAchievement, achievement: schema.achievement })
|
||||
.from(schema.userAchievement).innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, c.get("user")!.id), eq(schema.userAchievement.notified, false), eq(schema.achievement.visible, true)))
|
||||
.orderBy(asc(schema.userAchievement.unlockTime))
|
||||
return success(c, rows.map(pendingData))
|
||||
})
|
||||
|
||||
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
|
||||
const parsed = markAchievementsReadSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||
if (parsed.data.ids.length > 0) {
|
||||
await db.update(schema.userAchievement).set({ notified: true }).where(and(
|
||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
||||
))
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
275
apps/api/src/routes/ai.ts
Normal file
275
apps/api/src/routes/ai.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
aiAnalysisRecordSchema,
|
||||
aiAnalysisRequestSchema,
|
||||
aiDetailSchema,
|
||||
aiHintRequestSchema,
|
||||
classAnalysisRequestSchema,
|
||||
classPkAnalysisRequestSchema,
|
||||
durationDataSchema,
|
||||
heatmapItemSchema,
|
||||
loginSummarySchema,
|
||||
solvedProblemSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, countDistinct, desc, eq, gte, inArray, isNull, lte, min, ne, sql } from "drizzle-orm"
|
||||
import { Hono, type Context } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { getPreviousLogin } from "../auth/session"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { completeChat, streamChat } from "../services/ai"
|
||||
import { isTeacherOrAbove, objectValue, rounded } from "./helpers"
|
||||
|
||||
export const aiRoutes = new Hono<AppEnv>()
|
||||
|
||||
const accepted = [0, 10]
|
||||
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
|
||||
|
||||
function grade(rank: number | null, count: number, reference = count) {
|
||||
if (!rank || count <= 0) return "C"
|
||||
const percentile = (rank - 1) / count * 100
|
||||
let value = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
|
||||
return value
|
||||
}
|
||||
|
||||
function averageGrade(grades: string[]) {
|
||||
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
|
||||
const values = grades.flatMap((item) => weights[item] ?? [])
|
||||
if (!values.length) return ""
|
||||
const average = values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
return average >= 3.5 ? "S" : average >= 2.5 ? "A" : average >= 1.5 ? "B" : "C"
|
||||
}
|
||||
|
||||
async function targetUser(c: Context<AppEnv>) {
|
||||
const current = c.get("user")!
|
||||
const username = c.req.query("username")
|
||||
if (!username || !isTeacherOrAbove(current)) return current
|
||||
const [target] = await db.select({
|
||||
id: schema.user.id,
|
||||
username: schema.user.username,
|
||||
email: schema.user.email,
|
||||
adminType: schema.user.adminType,
|
||||
problemPermission: schema.user.problemPermission,
|
||||
isDisabled: schema.user.isDisabled,
|
||||
className: schema.user.className,
|
||||
}).from(schema.user).where(eq(schema.user.username, username)).limit(1)
|
||||
return target ?? null
|
||||
}
|
||||
|
||||
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
|
||||
const start = c.req.query("start")
|
||||
const end = c.req.query("end")
|
||||
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) {
|
||||
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
|
||||
}
|
||||
const user = await targetUser(c)
|
||||
if (!user) return failure(c, 404, "user-not-found", "User not found")
|
||||
const firstAc = await db.select({ problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
|
||||
.from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted),
|
||||
gte(schema.submission.createTime, start), lte(schema.submission.createTime, end),
|
||||
)).groupBy(schema.submission.problemId)
|
||||
const problemIds = firstAc.map((item) => item.problemId)
|
||||
if (!problemIds.length) return success(c, aiDetailSchema.parse({
|
||||
user: user.username, className: user.className, start, end, solved: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
|
||||
}))
|
||||
const classUsers = user.className ? await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, user.className)) : []
|
||||
const scopeIds = classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const [problems, rankRows, periodRows, tagRows, flowRows] = await Promise.all([
|
||||
db.select({ problem: schema.problem, contestTitle: schema.contest.title }).from(schema.problem)
|
||||
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id)).where(inArray(schema.problem.id, problemIds)),
|
||||
db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
|
||||
.from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined))
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||
db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, first: min(schema.submission.createTime) })
|
||||
.from(schema.submission).where(and(inArray(schema.submission.result, accepted), inArray(schema.submission.problemId, problemIds), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined))
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||
db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)).where(inArray(schema.problemTags.problemId, problemIds)),
|
||||
db.select({ flow: schema.flowchartSubmission, displayId: schema.problem.displayId, title: schema.problem.title })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(and(eq(schema.flowchartSubmission.userId, user.id), eq(schema.flowchartSubmission.status, 2), gte(schema.flowchartSubmission.createTime, start), lte(schema.flowchartSubmission.createTime, end))),
|
||||
])
|
||||
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
|
||||
function ranks(rows: typeof rankRows, problemId: number) {
|
||||
return rows.filter((item) => item.problemId === problemId).sort((a, b) => Date.parse(a.first ?? "") - Date.parse(b.first ?? "") || a.userId - b.userId)
|
||||
}
|
||||
const solved = firstAc.flatMap((item) => {
|
||||
const problem = byProblem.get(item.problemId)
|
||||
if (!problem || !item.first) return []
|
||||
const all = ranks(rankRows, item.problemId)
|
||||
const period = ranks(periodRows, item.problemId)
|
||||
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
return solvedProblemSchema.parse({
|
||||
problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId },
|
||||
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
|
||||
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
|
||||
})
|
||||
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
|
||||
const tags: Record<string, number> = {}
|
||||
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
|
||||
const topTags = Object.fromEntries(Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 5))
|
||||
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
|
||||
for (const item of problems) {
|
||||
const name = difficultyNames[item.problem.difficulty] ?? "中等"
|
||||
difficulty[name] = (difficulty[name] ?? 0) + 1
|
||||
}
|
||||
const flowGroups = new Map<string, typeof flowRows>()
|
||||
for (const flow of flowRows) flowGroups.set(flow.displayId, [...(flowGroups.get(flow.displayId) ?? []), flow])
|
||||
const flowcharts = [...flowGroups].map(([displayId, rows]) => {
|
||||
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
|
||||
const best = Math.max(0, ...scores)
|
||||
return {
|
||||
problemId: displayId,
|
||||
problemTitle: rows[0]?.title ?? "",
|
||||
submissionCount: rows.length,
|
||||
bestScore: best,
|
||||
bestGrade: rows.find((row) => row.flow.aiScore === best)?.flow.aiGrade ?? "",
|
||||
latestSubmissionTime: rows.map((row) => row.flow.createTime).sort().at(-1) ?? start,
|
||||
avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0),
|
||||
}
|
||||
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
|
||||
return success(c, aiDetailSchema.parse({
|
||||
user: user.username, className: user.className, start, end, solved, flowcharts,
|
||||
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
|
||||
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
|
||||
}))
|
||||
})
|
||||
|
||||
function shiftMonths(date: Date, months: number) {
|
||||
const result = new Date(date)
|
||||
const day = result.getDate()
|
||||
result.setDate(1)
|
||||
result.setMonth(result.getMonth() + months)
|
||||
result.setDate(Math.min(day, new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate()))
|
||||
return result
|
||||
}
|
||||
|
||||
aiRoutes.get("/ai/duration", requireAuth, async (c) => {
|
||||
const endText = c.req.query("end")
|
||||
if (!endText || Number.isNaN(Date.parse(endText))) return failure(c, 400, "invalid-end", "end must be an ISO timestamp")
|
||||
const user = await targetUser(c)
|
||||
if (!user) return failure(c, 404, "user-not-found", "User not found")
|
||||
const duration = c.req.query("duration") ?? "months:1"
|
||||
const config = duration === "months:2" ? { count: 8, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) }
|
||||
: duration === "months:6" ? { count: 6, unit: "months", rewind: (date: Date) => shiftMonths(date, -7), advance: (date: Date) => shiftMonths(date, 1) }
|
||||
: duration === "years:1" ? { count: 12, unit: "months", rewind: (date: Date) => shiftMonths(date, -13), advance: (date: Date) => shiftMonths(date, 1) }
|
||||
: { count: 4, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) }
|
||||
let cursor = config.rewind(new Date(endText))
|
||||
const data = []
|
||||
for (let index = 0; index < config.count; index++) {
|
||||
const start = config.advance(cursor)
|
||||
const end = config.advance(start)
|
||||
cursor = start
|
||||
const [submissions, solved] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))),
|
||||
db.select({ value: countDistinct(schema.submission.problemId) }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))),
|
||||
])
|
||||
data.push(durationDataSchema.parse({ unit: config.unit, index: config.count - 1 - index, start: start.toISOString(), end: end.toISOString(), grade: solved[0]?.value ? "B" : "", problemCount: solved[0]?.value ?? 0, submissionCount: submissions[0]?.value ?? 0 }))
|
||||
}
|
||||
return success(c, data)
|
||||
})
|
||||
|
||||
aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
|
||||
const user = await targetUser(c)
|
||||
if (!user) return failure(c, 404, "user-not-found", "User not found")
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 365 * 864e5)
|
||||
const date = sql<string>`date(${schema.submission.createTime})::text`
|
||||
const rows = await db.select({ date, value: count() }).from(schema.submission)
|
||||
.where(and(eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString())))
|
||||
.groupBy(date).orderBy(date)
|
||||
const counts = new Map(rows.map((row) => [row.date, row.value]))
|
||||
return success(c, Array.from({ length: 365 }, (_, index) => {
|
||||
const day = new Date(start.getTime() + index * 864e5)
|
||||
return heatmapItemSchema.parse({ timestamp: new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime(), value: counts.get(day.toISOString().slice(0, 10)) ?? 0 })
|
||||
}))
|
||||
})
|
||||
|
||||
aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const end = new Date()
|
||||
const [userRow] = await db.select({ createTime: schema.user.createTime, lastLogin: schema.user.lastLogin }).from(schema.user).where(eq(schema.user.id, user.id)).limit(1)
|
||||
const previous = await getPreviousLogin(c)
|
||||
let start = new Date(previous ?? userRow?.lastLogin ?? userRow?.createTime ?? end.getTime() - 7 * 864e5)
|
||||
if (start >= end) start = new Date(end.getTime() - 864e5)
|
||||
const range = and(gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))
|
||||
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.problem).where(and(isNull(schema.problem.contestId), eq(schema.problem.visible, true), gte(schema.problem.createTime, start.toISOString()), lte(schema.problem.createTime, end.toISOString()))),
|
||||
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), range)),
|
||||
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), range)),
|
||||
db.select({ value: countDistinct(schema.submission.problemId) }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), range)),
|
||||
db.select({ value: count() }).from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, user.id), gte(schema.flowchartSubmission.createTime, start.toISOString()), lte(schema.flowchartSubmission.createTime, end.toISOString()))),
|
||||
])
|
||||
const summary = {
|
||||
start: start.toISOString(), end: end.toISOString(), newProblemCount: newProblems[0]?.value ?? 0,
|
||||
submissionCount: submissions[0]?.value ?? 0, acceptedCount: acceptedRows[0]?.value ?? 0,
|
||||
solvedCount: solvedRows[0]?.value ?? 0, flowchartSubmissionCount: flowRows[0]?.value ?? 0,
|
||||
}
|
||||
let analysis = ""
|
||||
let analysisError: string | undefined
|
||||
if (summary.submissionCount >= 3) {
|
||||
try {
|
||||
analysis = await completeChat("你是 OnlineJudge 的学习助教。请根据统计数据给出简短分析(1-2句),再给出一行以“结论:”开头的结论。", JSON.stringify(summary))
|
||||
} catch (error) {
|
||||
analysisError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
return success(c, loginSummarySchema.parse({ summary, analysis, analysisError }))
|
||||
})
|
||||
|
||||
aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ analysis: schema.aiAnalysis, username: schema.user.username }).from(schema.aiAnalysis)
|
||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
||||
.where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1)
|
||||
if (!row) return success(c, null)
|
||||
return success(c, aiAnalysisRecordSchema.parse({
|
||||
id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data),
|
||||
analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username,
|
||||
}))
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
|
||||
const parsed = aiAnalysisRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "details and duration are required")
|
||||
const user = c.get("user")!
|
||||
const system = "你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
|
||||
const prompt = `详细数据: ${JSON.stringify(parsed.data.details)}\n每周或每月数据: ${JSON.stringify(parsed.data.duration)}`
|
||||
return streamChat(system, prompt, async (analysis) => {
|
||||
await db.insert(schema.aiAnalysis).values({
|
||||
provider: "deepseek", model: config.aiModel, data: parsed.data, systemPrompt: system,
|
||||
userPrompt: "学习详情与周期数据", analysis, createTime: new Date().toISOString(), userId: user.id, isPinned: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
||||
const parsed = aiHintRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "submissionId is required")
|
||||
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(and(eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, c.get("user")!.id))).limit(1)
|
||||
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
|
||||
const answers = Array.isArray(row.problem.answers) ? row.problem.answers.filter((item): item is { language?: unknown; code?: unknown } => Boolean(item && typeof item === "object")) : []
|
||||
const selected = answers.find((item) => item.language === row.submission.language) ?? answers[0]
|
||||
const reference = typeof selected?.code === "string" ? selected.code : ""
|
||||
const system = "你是编程助教。对照参考答案指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
|
||||
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 500)}\n参考答案(不可透露):${reference.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${row.submission.result}\n错误:${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码:${row.submission.code.slice(0, 2000)}`
|
||||
return streamChat(system, prompt)
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
|
||||
const parsed = classAnalysisRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Class data is required")
|
||||
return streamChat("你是编程教育数据分析专家。根据班级 OJ 数据,从整体水平、参与积极性、均衡性、梯队和改进建议五方面输出中文 Markdown 报告。", JSON.stringify(parsed.data.comparison))
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => {
|
||||
if (!isTeacherOrAbove(c.get("user"))) return failure(c, 403, "permission-denied", "Permission denied")
|
||||
const parsed = classPkAnalysisRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "At least two classes are required")
|
||||
return streamChat("你是编程教育数据分析专家。根据多个班级 OJ 对比数据,从排名、参与度、典型学生水平、均衡性、梯队、提交质量和教学建议七方面输出中文 Markdown 报告。", `${parsed.data.timeRangeLabel}\n${JSON.stringify(parsed.data.comparisons)}`)
|
||||
})
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
@@ -48,7 +49,7 @@ authRoutes.post("/auth/login", async (c) => {
|
||||
})
|
||||
}
|
||||
await db.update(schema.user).set(update).where(eq(schema.user.id, user.id))
|
||||
await 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)
|
||||
})
|
||||
|
||||
210
apps/api/src/routes/classroom.ts
Normal file
210
apps/api/src/routes/classroom.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
classComparisonRequestSchema,
|
||||
classComparisonResponseSchema,
|
||||
classComparisonSchema,
|
||||
classRankItemSchema,
|
||||
classUserRankSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { queryInteger, rounded } from "./helpers"
|
||||
|
||||
export const classroomRoutes = new Hono<AppEnv>()
|
||||
|
||||
interface ClassUser {
|
||||
userId: number
|
||||
username: string
|
||||
className: string
|
||||
acceptedNumber: number
|
||||
submissionNumber: number
|
||||
}
|
||||
|
||||
async function loadClassUsers(classNames?: string[]) {
|
||||
const filters = [
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||
sql`${schema.user.className} is not null`,
|
||||
]
|
||||
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
||||
const rows = await db.select({
|
||||
userId: schema.user.id,
|
||||
username: schema.user.username,
|
||||
className: schema.user.className,
|
||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||
submissionNumber: schema.userProfile.submissionNumber,
|
||||
}).from(schema.user).innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(and(...filters))
|
||||
return rows.filter((row): row is ClassUser => row.className !== null)
|
||||
}
|
||||
|
||||
function mean(values: number[]) {
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
if (!values.length) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||
}
|
||||
|
||||
function quantile(values: number[], p: number) {
|
||||
if (values.length <= 1) return values[0] ?? 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const position = (sorted.length + 1) * p - 1
|
||||
if (position <= 0) return sorted[0]!
|
||||
if (position >= sorted.length - 1) return sorted.at(-1)!
|
||||
const lower = Math.floor(position)
|
||||
const fraction = position - lower
|
||||
return sorted[lower]! + (sorted[lower + 1]! - sorted[lower]!) * fraction
|
||||
}
|
||||
|
||||
function sampleStdDev(values: number[]) {
|
||||
if (values.length <= 1) return 0
|
||||
const average = mean(values)
|
||||
return Math.sqrt(values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1))
|
||||
}
|
||||
|
||||
classroomRoutes.get("/rankings/classes", async (c) => {
|
||||
const grade = c.req.query("grade")?.trim()
|
||||
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
|
||||
const users = (await loadClassUsers()).filter((user) => user.className.startsWith(grade))
|
||||
const groups = new Map<string, ClassUser[]>()
|
||||
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||
const result = [...groups].map(([className, members]) => {
|
||||
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
|
||||
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
|
||||
return {
|
||||
className,
|
||||
userCount: members.length,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(totalAc / members.length),
|
||||
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
||||
}
|
||||
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
|
||||
return success(c, result.map((item, index) => classRankItemSchema.parse({ ...item, rank: index + 1 })))
|
||||
})
|
||||
|
||||
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息")
|
||||
const members = (await loadClassUsers([user.className])).sort(
|
||||
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber,
|
||||
)
|
||||
const ranks = members.map((member, index) => ({
|
||||
userId: member.userId,
|
||||
username: member.username,
|
||||
acceptedNumber: member.acceptedNumber,
|
||||
submissionNumber: member.submissionNumber,
|
||||
rank: index + 1,
|
||||
}))
|
||||
const myRank = ranks.find((rank) => rank.userId === user.id)?.rank ?? -1
|
||||
const showAll = c.req.query("scope") === "all"
|
||||
let selected = ranks
|
||||
if (showAll) {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
selected = ranks.slice(offset, offset + limit)
|
||||
} else if (myRank > 0 && ranks.length > 10) {
|
||||
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
|
||||
selected = ranks.slice(start, start + 10)
|
||||
}
|
||||
return success(c, classUserRankSchema.parse({ className: user.className, myRank, total: ranks.length, ranks: selected }))
|
||||
})
|
||||
|
||||
classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required")
|
||||
const users = await loadClassUsers(parsed.data.classNames)
|
||||
const allAc = users.map((user) => user.acceptedNumber)
|
||||
const globalQ1 = quantile(allAc, 0.25)
|
||||
const globalQ3 = quantile(allAc, 0.75)
|
||||
const byClass = new Map<string, ClassUser[]>()
|
||||
for (const user of users) byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
|
||||
|
||||
let recentByUser = new Map<number, Set<number>>()
|
||||
let recentSubmissionCount = new Map<string, number>()
|
||||
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
|
||||
if (hasTimeRange) {
|
||||
const rows = await db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, result: schema.submission.result })
|
||||
.from(schema.submission).where(and(
|
||||
inArray(schema.submission.userId, users.map((user) => user.userId)),
|
||||
gte(schema.submission.createTime, parsed.data.startTime!),
|
||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
||||
))
|
||||
const userClass = new Map(users.map((user) => [user.userId, user.className]))
|
||||
for (const row of rows) {
|
||||
const className = userClass.get(row.userId)
|
||||
if (!className) continue
|
||||
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
|
||||
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
|
||||
const set = recentByUser.get(row.userId) ?? new Set<number>()
|
||||
set.add(row.problemId)
|
||||
recentByUser.set(row.userId, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comparisons = [...byClass].map(([className, members]) => {
|
||||
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
|
||||
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
|
||||
const userCount = members.length
|
||||
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
|
||||
const bottomCount = topCount
|
||||
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
|
||||
const totalAc = ac.reduce((sum, value) => sum + value, 0)
|
||||
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
|
||||
const base: Record<string, number | string> = {
|
||||
className,
|
||||
userCount,
|
||||
totalAc,
|
||||
totalSubmission,
|
||||
avgAc: rounded(mean(ac)),
|
||||
medianAc: rounded(median(ac)),
|
||||
q1Ac: rounded(quantile(ac, 0.25)),
|
||||
q3Ac: rounded(quantile(ac, 0.75)),
|
||||
iqr: rounded(quantile(ac, 0.75) - quantile(ac, 0.25)),
|
||||
stdDev: rounded(sampleStdDev(ac)),
|
||||
top10Avg: rounded(mean(ac.slice(0, topCount))),
|
||||
middle80Avg: rounded(mean(middle)),
|
||||
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
|
||||
excellentRate: rounded(ac.filter((value) => value >= globalQ3).length / userCount * 100),
|
||||
passRate: rounded(ac.filter((value) => value >= globalQ1).length / userCount * 100),
|
||||
activeRate: rounded(submissions.filter((value) => value > 0).length / userCount * 100),
|
||||
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
||||
compositeScore: 0,
|
||||
}
|
||||
if (hasTimeRange) {
|
||||
const recent = members.map((member) => recentByUser.get(member.userId)?.size ?? 0).sort((a, b) => b - a)
|
||||
base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
|
||||
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
|
||||
base.recentAvgAc = rounded(mean(recent))
|
||||
base.recentMedianAc = rounded(median(recent))
|
||||
base.recentTop10Avg = rounded(mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))))
|
||||
base.recentActiveCount = recent.filter((value) => value > 0).length
|
||||
}
|
||||
return base
|
||||
})
|
||||
const maxMedian = Math.max(1, ...comparisons.map((item) => Number(item.medianAc)))
|
||||
const maxMiddle = Math.max(1, ...comparisons.map((item) => Number(item.middle80Avg)))
|
||||
for (const item of comparisons) {
|
||||
item.compositeScore = rounded(
|
||||
0.4 * (Number(item.medianAc) / maxMedian * 100) +
|
||||
0.15 * (Number(item.middle80Avg) / maxMiddle * 100) +
|
||||
0.2 * Number(item.activeRate) +
|
||||
0.15 * Number(item.passRate) +
|
||||
0.1 * Number(item.excellentRate),
|
||||
1,
|
||||
)
|
||||
}
|
||||
comparisons.sort((a, b) => Number(b.compositeScore) - Number(a.compositeScore) || Number(b.medianAc) - Number(a.medianAc))
|
||||
return success(c, classComparisonResponseSchema.parse({
|
||||
comparisons: comparisons.map((item) => classComparisonSchema.parse(item)),
|
||||
hasTimeRange,
|
||||
}))
|
||||
})
|
||||
209
apps/api/src/routes/content.ts
Normal file
209
apps/api/src/routes/content.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
announcementListSchema,
|
||||
announcementSchema,
|
||||
createMessageRequestSchema,
|
||||
exerciseSchema,
|
||||
messageListSchema,
|
||||
messageSchema,
|
||||
reactionKeySchema,
|
||||
reactionStateSchema,
|
||||
setReactionRequestSchema,
|
||||
submissionDetailSchema,
|
||||
tutorialSchema,
|
||||
tutorialSummarySchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { isSuperAdmin, objectValue, queryInteger } from "./helpers"
|
||||
|
||||
export const contentRoutes = new Hono<AppEnv>()
|
||||
|
||||
contentRoutes.get("/announcements", async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.announcement).where(eq(schema.announcement.visible, true)),
|
||||
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(eq(schema.announcement.visible, true))
|
||||
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, announcementListSchema.parse({
|
||||
results: rows.map(({ announcement, user, realName }) => announcementSchema.parse({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: { id: user.id, username: user.username, realName },
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
})),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
contentRoutes.get("/announcements/:id", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||||
return success(c, announcementSchema.parse({
|
||||
id: row.announcement.id,
|
||||
title: row.announcement.title,
|
||||
tag: row.announcement.tag,
|
||||
content: row.announcement.content,
|
||||
top: row.announcement.top,
|
||||
createdBy: { id: row.user.id, username: row.user.username, realName: row.realName },
|
||||
createTime: row.announcement.createTime,
|
||||
lastUpdateTime: row.announcement.lastUpdateTime,
|
||||
}))
|
||||
})
|
||||
|
||||
contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
|
||||
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission })
|
||||
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
|
||||
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, messageListSchema.parse({
|
||||
results: rows.map(({ message, sender, realName, submission }) => messageSchema.parse({
|
||||
id: message.id,
|
||||
sender: { id: sender.id, username: sender.username, realName },
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: submissionDetailSchema.parse({
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
info: {},
|
||||
language: submission.language,
|
||||
shared: submission.shared,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
ip: null,
|
||||
contestId: submission.contestId,
|
||||
problemId: submission.problemId,
|
||||
showLink: true,
|
||||
canUnshare: false,
|
||||
}),
|
||||
})),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
contentRoutes.post("/messages", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
if (!isSuperAdmin(user)) return failure(c, 403, "permission-denied", "Permission denied")
|
||||
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
|
||||
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
|
||||
const [[recipient], [submission]] = await Promise.all([
|
||||
db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false))).limit(1),
|
||||
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
|
||||
])
|
||||
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist")
|
||||
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
await db.insert(schema.message).values({
|
||||
message: parsed.data.message,
|
||||
createTime: new Date().toISOString(),
|
||||
recipientId: recipient.id,
|
||||
senderId: user.id,
|
||||
submissionId: submission.id,
|
||||
})
|
||||
return success(c, null, 201)
|
||||
})
|
||||
|
||||
async function reactionState(problemId: number, userId: number) {
|
||||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
||||
if (!mine) return reactionStateSchema.parse({ mine: null, counts: null })
|
||||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
||||
const counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0]))
|
||||
for (const row of rows) {
|
||||
const key = reactionKeySchema.safeParse(row.type)
|
||||
if (key.success) counts[key.data] = row.value
|
||||
}
|
||||
return reactionStateSchema.parse({ mine: mine.type, counts })
|
||||
}
|
||||
|
||||
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
return success(c, await reactionState(problemId, c.get("user")!.id))
|
||||
})
|
||||
|
||||
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const user = c.get("user")!
|
||||
const [[problem], [solved]] = await Promise.all([
|
||||
db.select({ id: schema.problem.id }).from(schema.problem).where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true))).limit(1),
|
||||
db.select({ id: schema.submission.id }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
)).limit(1),
|
||||
])
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
|
||||
await db.insert(schema.reaction).values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
|
||||
return success(c, await reactionState(problemId, user.id))
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials", async (c) => {
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
||||
return success(c, rows.map((row) => tutorialSummarySchema.parse(row)))
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
return success(c, tutorialSchema.parse({
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
content: row.tutorial.content,
|
||||
code: row.tutorial.code,
|
||||
isPublic: row.tutorial.isPublic,
|
||||
order: row.tutorial.order,
|
||||
type: row.tutorial.type,
|
||||
createdBy: { id: row.user.id, username: row.user.username, realName: row.realName },
|
||||
createdAt: row.tutorial.createdAt,
|
||||
updatedAt: row.tutorial.updatedAt,
|
||||
}))
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
|
||||
return success(c, rows.map((row) => exerciseSchema.parse({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order })))
|
||||
})
|
||||
218
apps/api/src/routes/contest.ts
Normal file
218
apps/api/src/routes/contest.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
contestAccessSchema,
|
||||
contestListSchema,
|
||||
contestPasswordRequestSchema,
|
||||
contestRankItemSchema,
|
||||
contestRankSchema,
|
||||
contestSchema,
|
||||
problemDetailSchema,
|
||||
problemListItemSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { setContestPassword } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import {
|
||||
canAccessContest,
|
||||
checkContestPassword,
|
||||
contestDetailsAllowed,
|
||||
contestStatus,
|
||||
findVisibleContest,
|
||||
isContestAdmin,
|
||||
} from "../services/contest"
|
||||
import { objectValue, publicTemplates, queryInteger, stringArray } from "./helpers"
|
||||
|
||||
export const contestRoutes = new Hono<AppEnv>()
|
||||
|
||||
async function creator(id: number) {
|
||||
const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
|
||||
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(eq(schema.user.id, id)).limit(1)
|
||||
return row ?? { id, username: "", realName: null }
|
||||
}
|
||||
|
||||
async function serializeContest(contest: typeof schema.contest.$inferSelect, includeNow = false) {
|
||||
return contestSchema.parse({
|
||||
id: contest.id,
|
||||
title: contest.title,
|
||||
description: contest.description,
|
||||
tag: contest.tag,
|
||||
startTime: contest.startTime,
|
||||
endTime: contest.endTime,
|
||||
createTime: contest.createTime,
|
||||
lastUpdateTime: contest.lastUpdateTime,
|
||||
createdBy: await creator(contest.createdById),
|
||||
status: contestStatus(contest),
|
||||
contestType: contest.password ? "Password Protected" : "Public",
|
||||
now: includeNow ? new Date().toISOString() : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
contestRoutes.get("/contests", async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
const status = c.req.query("status")
|
||||
const now = new Date().toISOString()
|
||||
const filters = [eq(schema.contest.visible, true)]
|
||||
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
||||
if (tag) filters.push(eq(schema.contest.tag, tag))
|
||||
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
||||
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
|
||||
else if (status === "0") filters.push(and(lte(schema.contest.startTime, now), gte(schema.contest.endTime, now))!)
|
||||
const where = and(...filters)
|
||||
const [totalRow, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.contest).where(where),
|
||||
db.select().from(schema.contest).where(where).orderBy(desc(schema.contest.startTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, contestListSchema.parse({
|
||||
results: await Promise.all(rows.map((row) => serializeContest(row))),
|
||||
total: totalRow[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
contestRoutes.get("/contests/:id", async (c) => {
|
||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
return success(c, await serializeContest(contest, true))
|
||||
})
|
||||
|
||||
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
|
||||
if (!checkContestPassword(parsed.data.password, contest.password)) {
|
||||
return failure(c, 403, "wrong-password", "Wrong password or password expired")
|
||||
}
|
||||
await setContestPassword(c, contest.id, parsed.data.password)
|
||||
return success(c, true)
|
||||
})
|
||||
|
||||
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const access = await canAccessContest(c, contest, "details")
|
||||
return success(c, contestAccessSchema.parse({ access: access.ok }))
|
||||
})
|
||||
|
||||
async function contestProblemTags(problemIds: number[]) {
|
||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags).innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const map = new Map<number, string[]>()
|
||||
for (const row of rows) map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name])
|
||||
return map
|
||||
}
|
||||
|
||||
contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => {
|
||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const access = await canAccessContest(c, contest, "problems")
|
||||
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
|
||||
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||
difficulty: allowed ? problem.difficulty : "",
|
||||
createdBy: { id: user.id, username: user.username, realName },
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: contest.id,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: null,
|
||||
})))
|
||||
})
|
||||
|
||||
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) => {
|
||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const access = await canAccessContest(c, contest, "problems")
|
||||
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||||
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true), sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`)).limit(1)
|
||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const tags = await contestProblemTags([row.problem.id])
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
return success(c, problemDetailSchema.parse({
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
description: row.problem.description,
|
||||
inputDescription: row.problem.inputDescription,
|
||||
outputDescription: row.problem.outputDescription,
|
||||
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
||||
hint: row.problem.hint,
|
||||
languages: stringArray(row.problem.languages),
|
||||
template: publicTemplates(row.problem.template),
|
||||
createTime: row.problem.createTime,
|
||||
lastUpdateTime: row.problem.lastUpdateTime,
|
||||
timeLimit: row.problem.timeLimit,
|
||||
memoryLimit: row.problem.memoryLimit,
|
||||
difficulty: allowed ? row.problem.difficulty : "",
|
||||
source: row.problem.source,
|
||||
prompt: row.problem.prompt,
|
||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
||||
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
||||
shareSubmission: row.problem.shareSubmission,
|
||||
contestId: contest.id,
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
createdBy: { id: row.user.id, username: row.user.username, realName: row.realName },
|
||||
myStatus: null,
|
||||
myFailedCount: 0,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
|
||||
flowchartHint: row.problem.flowchartHint,
|
||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
||||
}))
|
||||
})
|
||||
|
||||
contestRoutes.get("/contests/:id/rank", optionalAuth, async (c) => {
|
||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const access = await canAccessContest(c, contest, "ranks")
|
||||
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false))
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where),
|
||||
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime)).limit(limit).offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, contestRankSchema.parse({
|
||||
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
|
||||
id: rank.id,
|
||||
user: { id: user.id, username: user.username, realName: admin ? realName : null },
|
||||
submissionNumber: rank.submissionNumber,
|
||||
acceptedNumber: rank.acceptedNumber,
|
||||
totalTime: rank.totalTime,
|
||||
submissionInfo: objectValue(rank.submissionInfo),
|
||||
contestId: rank.contestId,
|
||||
})),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
171
apps/api/src/routes/flowchart.ts
Normal file
171
apps/api/src/routes/flowchart.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
|
||||
import {
|
||||
createFlowchartRequestSchema,
|
||||
createFlowchartResponseSchema,
|
||||
flowchartCurrentSchema,
|
||||
flowchartDetailSchema,
|
||||
flowchartListItemSchema,
|
||||
flowchartListSchema,
|
||||
flowchartSubmissionSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { flowchartQueue } from "../queue"
|
||||
import { isAdminRole, objectValue, queryInteger, todayStart } from "./helpers"
|
||||
|
||||
export const flowchartRoutes = new Hono<AppEnv>()
|
||||
|
||||
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
|
||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
||||
}
|
||||
|
||||
function flowchartData(
|
||||
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
||||
username: string,
|
||||
) {
|
||||
return flowchartSubmissionSchema.parse({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problemId: flowchart.problemId,
|
||||
mermaidCode: flowchart.mermaidCode,
|
||||
flowchartData: objectValue(flowchart.flowchartData),
|
||||
status: flowchart.status,
|
||||
createTime: flowchart.createTime,
|
||||
aiScore: flowchart.aiScore,
|
||||
aiGrade: flowchart.aiGrade,
|
||||
aiFeedback: flowchart.aiFeedback,
|
||||
aiSuggestions: flowchart.aiSuggestions,
|
||||
aiCriteriaDetails: objectValue(flowchart.aiCriteriaDetails),
|
||||
aiProvider: flowchart.aiProvider,
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
})
|
||||
}
|
||||
|
||||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
const parsed = createFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) {
|
||||
return failure(c, 400, "invalid-request", parsed.error?.issues[0]?.message ?? "Flowchart data is too large")
|
||||
}
|
||||
const [problem] = await db.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart }).from(schema.problem)
|
||||
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission")
|
||||
const id = randomBytes(16).toString("hex")
|
||||
await db.insert(schema.flowchartSubmission).values({
|
||||
id,
|
||||
userId: c.get("user")!.id,
|
||||
problemId: problem.id,
|
||||
mermaidCode: parsed.data.mermaidCode,
|
||||
flowchartData: parsed.data.flowchartData,
|
||||
status: 0,
|
||||
createTime: new Date().toISOString(),
|
||||
aiScore: null,
|
||||
aiGrade: null,
|
||||
aiFeedback: null,
|
||||
aiSuggestions: null,
|
||||
aiCriteriaDetails: {},
|
||||
aiProvider: "deepseek",
|
||||
aiModel: config.aiModel,
|
||||
processingTime: null,
|
||||
evaluationTime: null,
|
||||
})
|
||||
try {
|
||||
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
||||
} catch (error) {
|
||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
|
||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
||||
}
|
||||
return success(c, createFlowchartResponseSchema.parse({ submissionId: id, status: "pending" }), 201)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = []
|
||||
const displayId = c.req.query("problemId")?.trim()
|
||||
const username = c.req.query("username")?.trim()
|
||||
const grade = c.req.query("grade")
|
||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
||||
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
|
||||
else if (username) filters.push(ilike(schema.user.username, `%${username}%`))
|
||||
if (c.req.query("today") === "1") filters.push(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
|
||||
if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
|
||||
const where = filters.length ? and(...filters) : undefined
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)).innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where),
|
||||
db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where)
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, flowchartListSchema.parse({
|
||||
results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({
|
||||
id: flowchart.id,
|
||||
username,
|
||||
problem: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
status: flowchart.status,
|
||||
createTime: flowchart.createTime,
|
||||
aiScore: flowchart.aiScore,
|
||||
aiGrade: flowchart.aiGrade,
|
||||
aiProvider: flowchart.aiProvider,
|
||||
aiModel: flowchart.aiModel,
|
||||
processingTime: flowchart.processingTime,
|
||||
evaluationTime: flowchart.evaluationTime,
|
||||
showLink: canView(user, flowchart, problem),
|
||||
})),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
return success(c, flowchartData(row.flowchart, row.username))
|
||||
})
|
||||
|
||||
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
||||
await db.update(schema.flowchartSubmission).set({
|
||||
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
|
||||
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
|
||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
await flowchartQueue.add("evaluate", { submissionId: row.flowchart.id }, { jobId: `${row.flowchart.id}:${Date.now()}` })
|
||||
return success(c, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" }))
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
|
||||
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||
return success(c, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" }))
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const rows = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime))
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
return success(c, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length }))
|
||||
})
|
||||
62
apps/api/src/routes/helpers.ts
Normal file
62
apps/api/src/routes/helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { AuthUser } from "../auth/session"
|
||||
|
||||
export function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
export function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
}
|
||||
|
||||
export function queryInteger(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
options: { min?: number; max?: number } = {},
|
||||
) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
if (options.min !== undefined && parsed < options.min) return fallback
|
||||
if (options.max !== undefined && parsed > options.max) return fallback
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function isRegularUser(user: AuthUser | null | undefined) {
|
||||
return user?.adminType === "Regular User"
|
||||
}
|
||||
|
||||
export function isAdminRole(user: AuthUser | null | undefined) {
|
||||
return Boolean(user && user.adminType !== "Regular User")
|
||||
}
|
||||
|
||||
export function isTeacherOrAbove(user: AuthUser | null | undefined) {
|
||||
return user?.adminType === "Teacher Admin" || user?.adminType === "Super Admin"
|
||||
}
|
||||
|
||||
export function isSuperAdmin(user: AuthUser | null | undefined) {
|
||||
return user?.adminType === "Super Admin"
|
||||
}
|
||||
|
||||
export function publicTemplates(value: unknown) {
|
||||
const templates: Record<string, string> = {}
|
||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||
if (typeof raw !== "string") continue
|
||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||
templates[language] = match?.[1] ?? ""
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
export function todayStart() {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
return now.toISOString()
|
||||
}
|
||||
|
||||
export function rounded(value: number, digits = 2) {
|
||||
const factor = 10 ** digits
|
||||
return Math.round(value * factor) / factor
|
||||
}
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
@@ -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<number, string[]>()
|
||||
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||
const result = new Map<number, string[]>()
|
||||
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
|
||||
return result
|
||||
}
|
||||
|
||||
function listItem(
|
||||
row: { problem: typeof schema.problem.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null },
|
||||
tags: Map<number, string[]>,
|
||||
statuses: Record<string, unknown>,
|
||||
) {
|
||||
const status = toObject(statuses[String(row.problem.id)]).status
|
||||
return problemListItemSchema.parse({
|
||||
id: row.problem.id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
difficulty: row.problem.difficulty,
|
||||
createdBy: { id: row.user.id, username: row.user.username, realName: row.realName },
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
contestId: row.problem.contestId,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
myStatus: typeof status === "number" ? status : null,
|
||||
})
|
||||
}
|
||||
|
||||
problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
|
||||
const author = c.req.query("author")?.trim()
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
if (author) filters.push(eq(schema.user.username, author))
|
||||
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
|
||||
if (difficulty) filters.push(eq(schema.problem.difficulty, difficulty))
|
||||
if (tag) {
|
||||
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(eq(schema.problemTag.name, tag))))
|
||||
}
|
||||
|
||||
const where = and(...filters)
|
||||
const sort = c.req.query("sort")
|
||||
const order = sort === "flowchart"
|
||||
? [desc(schema.problem.allowFlowchart), desc(schema.problem.showFlowchart), desc(schema.problem.createTime)]
|
||||
: sort === "ast"
|
||||
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
|
||||
: sort === "-accepted_number"
|
||||
? [desc(schema.problem.acceptedNumber)]
|
||||
: sort === "accepted_number"
|
||||
? [asc(schema.problem.acceptedNumber)]
|
||||
: sort === "-submission_number"
|
||||
? [desc(schema.problem.submissionNumber)]
|
||||
: sort === "submission_number"
|
||||
? [asc(schema.problem.submissionNumber)]
|
||||
: sort === "difficulty"
|
||||
? [asc(schema.problem.difficulty)]
|
||||
: sort === "create_time"
|
||||
? [asc(schema.problem.createTime)]
|
||||
: [desc(schema.problem.createTime)]
|
||||
const [totalRow] = await db.select({ value: countDistinct(schema.problem.id) }).from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)).where(where)
|
||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where).orderBy(...order).limit(limit).offset(offset)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
])
|
||||
return success(c, problemListSchema.parse({
|
||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||
total: totalRow?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-tags", async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const rows = await db.select({ id: schema.problemTag.id, name: schema.problemTag.name, problemCount: countDistinct(schema.problemTags.problemId) })
|
||||
.from(schema.problemTag)
|
||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
.orderBy(asc(schema.problemTag.name))
|
||||
return success(c, rows.map((row) => tagSchema.parse(row)))
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/random", async (c) => {
|
||||
const [row] = await db.select({ displayId: schema.problem.displayId }).from(schema.problem)
|
||||
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))).orderBy(sql`random()`).limit(1)
|
||||
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
|
||||
return success(c, row.displayId)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-authors", async (c) => {
|
||||
const showAll = c.req.query("all") === "1"
|
||||
const rows = await db.select({ username: schema.user.username, problemCount: count(schema.problem.id) })
|
||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true)))
|
||||
.groupBy(schema.user.username).orderBy(desc(count(schema.problem.id)))
|
||||
return success(c, rows.map((row) => problemAuthorSchema.parse(row)))
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, "0")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [mine] = await db.select({ value: count() }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, id),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
))
|
||||
if (!mine?.value) return success(c, "0")
|
||||
const since = new Date(); since.setFullYear(since.getFullYear() - 2); since.setHours(0, 0, 0, 0)
|
||||
const [active, accepted] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since.toISOString()))),
|
||||
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and(
|
||||
eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since.toISOString()),
|
||||
)),
|
||||
])
|
||||
const total = active[0]?.value ?? 0
|
||||
const solved = accepted[0]?.value ?? 0
|
||||
return success(c, total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0")
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||
const [target] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId))).limit(1)
|
||||
if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
|
||||
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id))
|
||||
if (targetTags.length === 0) return success(c, [])
|
||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problem)
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(
|
||||
eq(schema.problem.visible, true), isNull(schema.problem.contestId), sql`${schema.problem.id} <> ${target.id}`,
|
||||
inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
|
||||
.where(inArray(schema.problemTags.problemtagId, targetTags.map((tag) => tag.id)))),
|
||||
)).groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName).orderBy(asc(schema.problem.difficulty)).limit(5)
|
||||
const [tags, statuses] = await Promise.all([getProblemTags(rows.map((row) => row.problem.id)), getProblemStatuses(c.get("user")?.id)])
|
||||
const filtered = rows.filter((row) => toObject(statuses[String(row.problem.id)]).status !== JudgeStatus.ACCEPTED)
|
||||
return success(c, filtered.map((row) => listItem(row, tags, statuses)))
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
const year = sql<number>`extract(year from ${schema.submission.createTime})::int`
|
||||
const rows = await db.select({
|
||||
year,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
|
||||
}).from(schema.submission).where(and(eq(schema.submission.problemId, problem.id), isNull(schema.submission.contestId), notInArray(schema.submission.result, [6, 7])))
|
||||
.groupBy(year).orderBy(year)
|
||||
return success(c, rows.map((row) => yearlyAcSchema.parse({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 })))
|
||||
})
|
||||
|
||||
problemRoutes.get("/dev/problems", async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
|
||||
415
apps/api/src/routes/problemset.ts
Normal file
415
apps/api/src/routes/problemset.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
problemListItemSchema,
|
||||
problemSetBadgeSchema,
|
||||
problemSetListSchema,
|
||||
problemSetProblemSchema,
|
||||
problemSetProgressListSchema,
|
||||
problemSetProgressSchema,
|
||||
problemSetSchema,
|
||||
updateProblemSetProgressRequestSchema,
|
||||
joinProblemSetRequestSchema,
|
||||
userBadgeSchema,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
avg,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { publishAchievementNotification } from "../events"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { updateAchievementsForProblemSet } from "../services/achievements"
|
||||
import { isTeacherOrAbove, objectValue, queryInteger } from "./helpers"
|
||||
|
||||
export const problemsetRoutes = new Hono<AppEnv>()
|
||||
|
||||
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
||||
|
||||
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
|
||||
return progress ? {
|
||||
isJoined: true,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedCount: progress.completedProblemsCount,
|
||||
totalCount: progress.totalProblemsCount,
|
||||
isCompleted: progress.isCompleted,
|
||||
} : {
|
||||
isJoined: false,
|
||||
progressPercentage: 0,
|
||||
completedCount: 0,
|
||||
totalCount: 0,
|
||||
isCompleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function problemSetCreator(id: number) {
|
||||
const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
|
||||
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(eq(schema.user.id, id)).limit(1)
|
||||
return row ?? { id, username: "", realName: null }
|
||||
}
|
||||
|
||||
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
|
||||
return problemSetBadgeSchema.parse({
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
conditionType: badge.conditionType,
|
||||
conditionValue: badge.conditionValue,
|
||||
isEarned: earned,
|
||||
})
|
||||
}
|
||||
|
||||
async function serializeProblemSet(
|
||||
row: ProblemSetRow,
|
||||
userId?: number,
|
||||
includeBadges = false,
|
||||
) {
|
||||
const [[problemCount], [progress], badges, earnedRows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id)),
|
||||
userId ? db.select().from(schema.problemsetProgress).where(and(eq(schema.problemsetProgress.problemsetId, row.id), eq(schema.problemsetProgress.userId, userId))).limit(1) : Promise.resolve([]),
|
||||
includeBadges ? db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)) : Promise.resolve([]),
|
||||
includeBadges && userId ? db.select({ id: schema.userBadge.badgeId }).from(schema.userBadge)
|
||||
.innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
|
||||
.where(and(eq(schema.userBadge.userId, userId), eq(schema.problemsetBadge.problemsetId, row.id))) : Promise.resolve([]),
|
||||
])
|
||||
const earned = new Set(earnedRows.map((item) => item.id))
|
||||
return problemSetSchema.parse({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
createdBy: await problemSetCreator(row.createdById),
|
||||
createTime: row.createTime,
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
difficulty: row.difficulty,
|
||||
status: row.status,
|
||||
endTime: row.endTime,
|
||||
visible: row.visible,
|
||||
problemsCount: problemCount?.value ?? 0,
|
||||
completedCount: progress?.completedProblemsCount ?? 0,
|
||||
userProgress: progressSummary(progress),
|
||||
badges: includeBadges ? badges.map((badge) => badgeData(badge, earned.has(badge.id))) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")]
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const status = c.req.query("status")?.trim()
|
||||
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
|
||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty))
|
||||
if (status) filters.push(eq(schema.problemset.status, status))
|
||||
const where = and(...filters)
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.problemset).where(where),
|
||||
db.select().from(schema.problemset).where(where).orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, problemSetListSchema.parse({
|
||||
results: await Promise.all(rows.map((row) => serializeProblemSet(row, c.get("user")?.id, true))),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select().from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
return success(c, await serializeProblemSet(row, c.get("user")?.id))
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const rows = await db.select({ link: schema.problemsetProblem, problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problemsetProblem).innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order))
|
||||
const problemIds = rows.map((row) => row.problem.id)
|
||||
const [tagRows, progressRows] = await Promise.all([
|
||||
problemIds.length ? db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)).where(inArray(schema.problemTags.problemId, problemIds)) : Promise.resolve([]),
|
||||
c.get("user") ? db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) : Promise.resolve([]),
|
||||
])
|
||||
const tags = new Map<number, string[]>()
|
||||
for (const tag of tagRows) tags.set(tag.problemId, [...(tags.get(tag.problemId) ?? []), tag.name])
|
||||
const completed = objectValue(progressRows[0]?.detail)
|
||||
return success(c, rows.map(({ link, problem, user, realName }) => problemSetProblemSchema.parse({
|
||||
id: link.id,
|
||||
problemsetId: link.problemsetId,
|
||||
problem: problemListItemSchema.parse({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: problem.submissionNumber,
|
||||
acceptedNumber: problem.acceptedNumber,
|
||||
difficulty: problem.difficulty,
|
||||
createdBy: { id: user.id, username: user.username, realName },
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: problem.contestId,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: null,
|
||||
}),
|
||||
order: link.order,
|
||||
isRequired: link.isRequired,
|
||||
score: link.score,
|
||||
hint: link.hint,
|
||||
isCompleted: String(problem.id) in completed,
|
||||
})))
|
||||
})
|
||||
|
||||
async function recomputeProgress(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
|
||||
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
const valid = new Map(links.map((link) => [String(link.problemId), link.score]))
|
||||
for (const key of Object.keys(detail)) if (!valid.has(key)) delete detail[key]
|
||||
let totalScore = 0
|
||||
for (const [key, value] of Object.entries(detail)) {
|
||||
const score = valid.get(key)
|
||||
if (score === undefined) continue
|
||||
totalScore += score
|
||||
detail[key] = { ...objectValue(value), score }
|
||||
}
|
||||
const completed = Object.keys(detail).length
|
||||
const total = links.length
|
||||
const isCompleted = completed === total
|
||||
const update = {
|
||||
progressDetail: detail,
|
||||
totalProblemsCount: total,
|
||||
completedProblemsCount: completed,
|
||||
totalScore,
|
||||
progressPercentage: total > 0 ? completed / total * 100 : 0,
|
||||
isCompleted,
|
||||
completeTime: isCompleted ? progress.completeTime ?? new Date().toISOString() : null,
|
||||
}
|
||||
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id))
|
||||
return { ...progress, ...update }
|
||||
}
|
||||
|
||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||
const parsed = joinProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||
const user = c.get("user")!
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const [existing] = await db.select({ id: schema.problemsetProgress.id }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))).limit(1)
|
||||
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
||||
await db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(schema.problemsetProgress).values({
|
||||
problemsetId: problemSet.id,
|
||||
userId: user.id,
|
||||
joinTime: new Date().toISOString(),
|
||||
completeTime: null,
|
||||
isCompleted: false,
|
||||
progressPercentage: 0,
|
||||
completedProblemsCount: 0,
|
||||
totalProblemsCount: 0,
|
||||
totalScore: 0,
|
||||
progressDetail: {},
|
||||
}).returning()
|
||||
if (created) await recomputeProgress(tx, created, {})
|
||||
})
|
||||
return success(c, null, 201)
|
||||
})
|
||||
|
||||
problemsetRoutes.put("/problem-set-progress", requireAuth, async (c) => {
|
||||
const parsed = updateProblemSetProgressRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
|
||||
const user = c.get("user")!
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [problemSet] = await tx.select().from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
if (!problemSet) return { error: "problem-set-not-found" as const }
|
||||
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
|
||||
eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id),
|
||||
)).for("update").limit(1)
|
||||
if (!progress) return { error: "not-joined" as const }
|
||||
const [submission] = await tx.select().from(schema.submission).where(and(
|
||||
eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, user.id), eq(schema.submission.problemId, parsed.data.problemId),
|
||||
)).limit(1)
|
||||
if (!submission) return { error: "submission-not-found" as const }
|
||||
if (![JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(submission.result as 0 | 10)) return { error: "submission-not-accepted" as const }
|
||||
const [link] = await tx.select().from(schema.problemsetProblem).where(and(
|
||||
eq(schema.problemsetProblem.problemsetId, problemSet.id), eq(schema.problemsetProblem.problemId, parsed.data.problemId),
|
||||
)).limit(1)
|
||||
if (!link) return { error: "problem-not-in-set" as const }
|
||||
const detail = objectValue(progress.progressDetail)
|
||||
detail[String(parsed.data.problemId)] = { score: link.score, submit_time: new Date().toISOString() }
|
||||
const updated = await recomputeProgress(tx, progress, detail)
|
||||
const [existingSubmission] = await tx.select({ id: schema.problemsetSubmission.id })
|
||||
.from(schema.problemsetSubmission).where(and(
|
||||
eq(schema.problemsetSubmission.problemsetId, problemSet.id),
|
||||
eq(schema.problemsetSubmission.userId, user.id),
|
||||
eq(schema.problemsetSubmission.problemId, parsed.data.problemId),
|
||||
)).limit(1)
|
||||
if (!existingSubmission) {
|
||||
await tx.insert(schema.problemsetSubmission).values({
|
||||
problemsetId: problemSet.id,
|
||||
userId: user.id,
|
||||
submissionId: submission.id,
|
||||
problemId: parsed.data.problemId,
|
||||
})
|
||||
}
|
||||
const badges = await tx.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, problemSet.id))
|
||||
const earned: typeof schema.problemsetBadge.$inferSelect[] = []
|
||||
for (const badge of badges) {
|
||||
const hit = badge.conditionType === "all_problems"
|
||||
? updated.totalProblemsCount > 0 && updated.completedProblemsCount === updated.totalProblemsCount
|
||||
: badge.conditionType === "problem_count"
|
||||
? updated.completedProblemsCount >= badge.conditionValue
|
||||
: badge.conditionType === "score" && updated.totalScore >= badge.conditionValue
|
||||
if (!hit) continue
|
||||
const inserted = await tx.insert(schema.userBadge).values({
|
||||
userId: user.id,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] }).returning({ id: schema.userBadge.id })
|
||||
if (inserted.length) earned.push(badge)
|
||||
}
|
||||
return { earned }
|
||||
})
|
||||
if ("error" in result && result.error) {
|
||||
const error = result.error
|
||||
const messages = {
|
||||
"problem-set-not-found": "题单不存在",
|
||||
"not-joined": "未加入该题单",
|
||||
"submission-not-found": "提交记录不存在",
|
||||
"submission-not-accepted": "只有通过的提交才能更新进度",
|
||||
"problem-not-in-set": "题目不在题单中",
|
||||
}
|
||||
return failure(c, error.endsWith("not-found") ? 404 : 400, error, messages[error])
|
||||
}
|
||||
const unlocked = await updateAchievementsForProblemSet(user.id)
|
||||
await Promise.all([
|
||||
publishAchievementNotification(user.id, result.earned.map((badge) => ({
|
||||
id: badge.id,
|
||||
name: badge.name,
|
||||
description: badge.description,
|
||||
icon: badge.icon,
|
||||
rarity: "bronze",
|
||||
kind: "badge",
|
||||
}))),
|
||||
publishAchievementNotification(user.id, unlocked.map((achievement) => ({
|
||||
id: achievement.id,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
kind: "achievement",
|
||||
}))),
|
||||
])
|
||||
return success(c, { earnedBadges: result.earned.map((badge) => badgeData(badge)) })
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
|
||||
const requested = c.req.param("username")
|
||||
const username = requested === "me" ? c.get("user")?.username : requested
|
||||
if (!username) return failure(c, 401, "login-required", "Authentication required")
|
||||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
||||
.where(and(eq(schema.user.username, username), eq(schema.user.isDisabled, false))).limit(1)
|
||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||
const rows = await db.select({ userBadge: schema.userBadge, badge: schema.problemsetBadge, problemSet: schema.problemset })
|
||||
.from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
|
||||
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id))
|
||||
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime))
|
||||
return success(c, rows.map(({ userBadge, badge, problemSet }) => userBadgeSchema.parse({
|
||||
id: userBadge.id,
|
||||
userId: userBadge.userId,
|
||||
badge: badgeData(badge),
|
||||
earnedTime: userBadge.earnedTime,
|
||||
problemset: { id: problemSet.id, title: problemSet.title },
|
||||
})))
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, id))
|
||||
return success(c, badges.map((badge) => badgeData(badge)))
|
||||
})
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
if (!isTeacherOrAbove(user)) return failure(c, 403, "permission-denied", "Permission denied")
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const className = c.req.query("className")?.trim()
|
||||
const completion = c.req.query("completionStatus")?.trim()
|
||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
||||
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
||||
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
||||
else if (completion === "in_progress") filters.push(and(eq(schema.problemsetProgress.isCompleted, false), gt(schema.problemsetProgress.completedProblemsCount, 0))!)
|
||||
else if (completion === "not_started") filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
||||
const where = and(...filters)
|
||||
const [statsRows, rows, problemRows] = await Promise.all([
|
||||
db.select({ total: count(), completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`, avgProgress: avg(schema.problemsetProgress.progressPercentage) })
|
||||
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)).where(where),
|
||||
db.select({ progress: schema.problemsetProgress, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.problemsetProgress.isCompleted), desc(schema.problemsetProgress.progressPercentage), asc(schema.problemsetProgress.joinTime)).limit(limit).offset(offset),
|
||||
db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order)),
|
||||
])
|
||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
||||
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({
|
||||
id: progress.id,
|
||||
problemsetId: progress.problemsetId,
|
||||
user: { id: progressUser.id, username: progressUser.username, realName },
|
||||
joinTime: progress.joinTime,
|
||||
completeTime: progress.completeTime,
|
||||
isCompleted: progress.isCompleted,
|
||||
progressPercentage: progress.progressPercentage,
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
|
||||
}))
|
||||
const stats = statsRows[0]
|
||||
return success(c, problemSetProgressListSchema.parse({
|
||||
results,
|
||||
total: stats?.total ?? 0,
|
||||
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
|
||||
problems: problemRows,
|
||||
}))
|
||||
})
|
||||
47
apps/api/src/routes/site.ts
Normal file
47
apps/api/src/routes/site.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { quoteSchema, websiteConfigSchema } from "@oj2/contract"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { getWebsiteOptions } from "../services/options"
|
||||
|
||||
export const siteRoutes = new Hono()
|
||||
|
||||
siteRoutes.get("/site", async (c) => {
|
||||
const options = await getWebsiteOptions()
|
||||
return success(c, websiteConfigSchema.parse({
|
||||
websiteBaseUrl: options.website_base_url,
|
||||
websiteName: options.website_name,
|
||||
websiteNameShortcut: options.website_name_shortcut,
|
||||
websiteFooter: options.website_footer,
|
||||
allowRegister: options.allow_register,
|
||||
submissionListShowAll: options.submission_list_show_all,
|
||||
classList: options.class_list,
|
||||
enableMaxkb: options.enable_maxkb,
|
||||
}))
|
||||
})
|
||||
|
||||
const quotes = [
|
||||
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
||||
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
|
||||
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
|
||||
]
|
||||
|
||||
siteRoutes.get("/quotes/random", (c) => {
|
||||
const item = quotes[Math.floor(Math.random() * quotes.length)] ?? quotes[0]
|
||||
return success(c, quoteSchema.parse(item))
|
||||
})
|
||||
|
||||
siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||
const className = c.req.param("className").trim()
|
||||
if (!/^\d{3,4}$/.test(className)) {
|
||||
return failure(c, 400, "invalid-class", "Class name must contain 3 or 4 digits")
|
||||
}
|
||||
const rows = await db
|
||||
.select({ username: schema.user.username })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, className))
|
||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||
return success(c, rows.map(({ username }) => username.replace(`ks${className}`, "")))
|
||||
})
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
@@ -28,23 +44,29 @@ function objectValue(value: unknown): Record<string, unknown> {
|
||||
: {}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
160
apps/api/src/services/achievements.ts
Normal file
160
apps/api/src/services/achievements.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { and, count, eq, isNull, ne, notInArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { isAccepted, JudgeStatus } from "../judge/status"
|
||||
import { objectValue } from "../routes/helpers"
|
||||
|
||||
function numberMetric(metrics: Record<string, unknown>, key: string) {
|
||||
const value = metrics[key]
|
||||
return typeof value === "number" ? value : 0
|
||||
}
|
||||
|
||||
function localDate(value: string) {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) {
|
||||
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement)
|
||||
.where(eq(schema.userAchievement.userId, userId))
|
||||
const filters = [eq(schema.achievement.visible, true)]
|
||||
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
|
||||
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
|
||||
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
|
||||
const candidates = await db.select().from(schema.achievement).where(and(...filters))
|
||||
const created: typeof schema.achievement.$inferSelect[] = []
|
||||
for (const achievement of candidates) {
|
||||
const value = metrics[achievement.metric]
|
||||
if (typeof value !== "number") continue
|
||||
const hit = achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold
|
||||
if (!hit) continue
|
||||
const inserted = await db.insert(schema.userAchievement).values({
|
||||
userId,
|
||||
achievementId: achievement.id,
|
||||
unlockTime: new Date().toISOString(),
|
||||
backfilled: false,
|
||||
notified: false,
|
||||
}).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] }).returning({ id: schema.userAchievement.id })
|
||||
if (inserted.length) {
|
||||
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` }).where(eq(schema.achievement.id, achievement.id))
|
||||
created.push(achievement)
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
export async function updateAchievementsForSubmission(submissionId: string) {
|
||||
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.submission.id, submissionId)).limit(1)
|
||||
if (!row || row.submission.contestId !== null) return []
|
||||
|
||||
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, row.submission.userId),
|
||||
eq(schema.submission.problemId, row.submission.problemId),
|
||||
isNull(schema.submission.contestId),
|
||||
ne(schema.submission.id, row.submission.id),
|
||||
))
|
||||
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
|
||||
const accepted = isAccepted(row.submission.result)
|
||||
const firstAc = accepted && !priorAccepted
|
||||
const firstTry = accepted && priorRows.length === 0
|
||||
const date = localDate(row.submission.createTime)
|
||||
const hour = new Date(row.submission.createTime).getHours()
|
||||
|
||||
const metrics = await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.userStat).values({
|
||||
userId: row.submission.userId,
|
||||
metrics: {},
|
||||
updateTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
||||
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update")
|
||||
if (!stat) throw new Error("User achievement stat could not be created")
|
||||
const value = objectValue(stat.metrics)
|
||||
value.submission_count = numberMetric(value, "submission_count") + 1
|
||||
if (firstAc) {
|
||||
value.accepted_count = numberMetric(value, "accepted_count") + 1
|
||||
if (row.problem.difficulty === "Mid") value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
|
||||
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
|
||||
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
|
||||
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
|
||||
const perDay = objectValue(value._ac_per_day)
|
||||
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
|
||||
value._ac_per_day = perDay
|
||||
value.max_ac_in_one_day = Math.max(...Object.values(perDay).filter((item): item is number => typeof item === "number"))
|
||||
}
|
||||
const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") : []
|
||||
if (!activeDates.includes(date)) activeDates.push(date)
|
||||
value._active_dates = activeDates
|
||||
value.active_days = activeDates.length
|
||||
if (accepted) {
|
||||
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
|
||||
if (last !== date) {
|
||||
const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 1
|
||||
? numberMetric(value, "_current_ac_streak") + 1
|
||||
: 1
|
||||
value._last_ac_date = date
|
||||
value._current_ac_streak = current
|
||||
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current)
|
||||
}
|
||||
}
|
||||
const languages = Array.isArray(value._languages) ? value._languages.filter((item): item is string => typeof item === "string") : []
|
||||
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
|
||||
value._languages = languages
|
||||
value.languages_used = languages.length
|
||||
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
|
||||
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
||||
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
||||
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length)
|
||||
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id))
|
||||
return value
|
||||
})
|
||||
|
||||
const first = await unlockAchievements(row.submission.userId, metrics)
|
||||
if (!first.length) return []
|
||||
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum")))
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }).where(eq(schema.userStat.userId, row.submission.userId))
|
||||
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
|
||||
}
|
||||
|
||||
export async function updateAchievementsForProblemSet(userId: number) {
|
||||
const [[badgeRow], [completedRow]] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
|
||||
db.select({ value: count() }).from(schema.problemsetProgress).where(and(
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
eq(schema.problemsetProgress.isCompleted, true),
|
||||
)),
|
||||
])
|
||||
const metrics = await db.transaction(async (tx) => {
|
||||
await tx.insert(schema.userStat).values({
|
||||
userId,
|
||||
metrics: {},
|
||||
updateTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
||||
const [stat] = await tx.select().from(schema.userStat)
|
||||
.where(eq(schema.userStat.userId, userId)).for("update").limit(1)
|
||||
if (!stat) throw new Error("User achievement stat could not be created")
|
||||
const value = objectValue(stat.metrics)
|
||||
value.badge_count = badgeRow?.value ?? 0
|
||||
value.problemset_completed = completedRow?.value ?? 0
|
||||
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() })
|
||||
.where(eq(schema.userStat.id, stat.id))
|
||||
return value
|
||||
})
|
||||
|
||||
const first = await unlockAchievements(userId, metrics)
|
||||
if (!first.length) return []
|
||||
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
|
||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
||||
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")))
|
||||
metrics.achievement_unlocked_count = meta?.value ?? 0
|
||||
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() })
|
||||
.where(eq(schema.userStat.userId, userId))
|
||||
return [...first, ...(await unlockAchievements(userId, metrics, true))]
|
||||
}
|
||||
105
apps/api/src/services/ai.ts
Normal file
105
apps/api/src/services/ai.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { config } from "../config"
|
||||
|
||||
interface ChatMessage {
|
||||
role: "system" | "user"
|
||||
content: string
|
||||
}
|
||||
|
||||
function requestBody(messages: ChatMessage[], stream: boolean) {
|
||||
return {
|
||||
model: config.aiModel,
|
||||
messages,
|
||||
stream,
|
||||
temperature: 0,
|
||||
thinking: { type: "disabled" },
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeChat(system: string, user: string) {
|
||||
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
], false)),
|
||||
})
|
||||
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
||||
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||
return payload.choices?.[0]?.message?.content?.trim() ?? ""
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
system: string,
|
||||
user: string,
|
||||
onComplete?: (value: string) => Promise<void>,
|
||||
) {
|
||||
const encoder = new TextEncoder()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||
if (!config.aiKey) {
|
||||
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`)
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
], true)),
|
||||
})
|
||||
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
||||
send("event: start\n\n")
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
const chunks: string[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line.startsWith("data:")) continue
|
||||
const data = line.slice(5).trim()
|
||||
if (data === "[DONE]") continue
|
||||
try {
|
||||
const item = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }> }
|
||||
const choice = item.choices?.[0]
|
||||
const content = choice?.delta?.content
|
||||
if (content) {
|
||||
chunks.push(content)
|
||||
send(`data: ${JSON.stringify({ type: "delta", content })}\n\n`)
|
||||
}
|
||||
} catch {
|
||||
// Provider keepalive or a partial non-data line.
|
||||
}
|
||||
}
|
||||
if (done) break
|
||||
}
|
||||
const full = chunks.join("").trim()
|
||||
if (onComplete) await onComplete(full)
|
||||
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
||||
} catch (error) {
|
||||
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`)
|
||||
} finally {
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
"x-accel-buffering": "no",
|
||||
},
|
||||
})
|
||||
}
|
||||
85
apps/api/src/services/contest.ts
Normal file
85
apps/api/src/services/contest.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import type { Context } from "hono"
|
||||
|
||||
import type { AppEnv } from "../auth/middleware"
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { getContestPassword } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
|
||||
export type ContestRow = typeof schema.contest.$inferSelect
|
||||
|
||||
export function contestStatus(contest: ContestRow) {
|
||||
const now = Date.now()
|
||||
if (Date.parse(contest.startTime) > now) return "1" as const
|
||||
if (Date.parse(contest.endTime) < now) return "-1" as const
|
||||
return "0" as const
|
||||
}
|
||||
|
||||
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
|
||||
}
|
||||
|
||||
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
|
||||
}
|
||||
|
||||
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
|
||||
if (!candidate || !expected) return false
|
||||
if (candidate === expected) return true
|
||||
const parts = candidate.split("#")
|
||||
if (parts.length !== 2) return false
|
||||
const [signature, expiresAt] = parts
|
||||
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
|
||||
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
|
||||
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
||||
}
|
||||
|
||||
export async function findVisibleContest(id: number) {
|
||||
const [contest] = await db.select().from(schema.contest)
|
||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
||||
return contest ?? null
|
||||
}
|
||||
|
||||
export async function canAccessContest(
|
||||
c: Context<AppEnv>,
|
||||
contest: ContestRow,
|
||||
checkType: "details" | "problems" | "ranks" | "submissions",
|
||||
) {
|
||||
const user = c.get("user")
|
||||
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
|
||||
if (isContestAdmin(user, contest)) return { ok: true as const }
|
||||
if (contest.password) {
|
||||
const stored = await getContestPassword(c, contest.id)
|
||||
if (!checkContestPassword(stored, contest.password)) {
|
||||
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
|
||||
}
|
||||
}
|
||||
if (contestStatus(contest) === "1" && checkType !== "details") {
|
||||
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
|
||||
}
|
||||
return { ok: true as const }
|
||||
}
|
||||
|
||||
function ipv4Number(value: string) {
|
||||
const parts = value.split(".").map(Number)
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
||||
return parts.reduce((result, part) => (result * 256 + part) >>> 0, 0)
|
||||
}
|
||||
|
||||
export function ipAllowed(ip: string | null, ranges: unknown) {
|
||||
if (!Array.isArray(ranges) || ranges.length === 0) return true
|
||||
if (!ip) return false
|
||||
const target = ipv4Number(ip.replace(/^::ffff:/, ""))
|
||||
if (target === null) return false
|
||||
return ranges.some((raw) => {
|
||||
const value = typeof raw === "string" ? raw : raw && typeof raw === "object" ? String((raw as { value?: unknown }).value ?? "") : ""
|
||||
const [address, prefixText = "32"] = value.split("/")
|
||||
const network = ipv4Number(address ?? "")
|
||||
const prefix = Number(prefixText)
|
||||
if (network === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
|
||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
|
||||
return (target & mask) === (network & mask)
|
||||
})
|
||||
}
|
||||
75
apps/api/src/services/format-code.ts
Normal file
75
apps/api/src/services/format-code.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { config } from "../config"
|
||||
|
||||
export class CodeFormatError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly kind: "syntax" | "tool",
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function runFormatter(command: string[], code: string) {
|
||||
let process: ReturnType<typeof Bun.spawn>
|
||||
try {
|
||||
process = Bun.spawn(command, {
|
||||
stdin: new Blob([code]),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
} catch (error) {
|
||||
throw new CodeFormatError(String(error), "tool")
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => process.kill(), 5_000)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
process.exited,
|
||||
new Response(process.stdout as ReadableStream<Uint8Array>).text(),
|
||||
new Response(process.stderr as ReadableStream<Uint8Array>).text(),
|
||||
])
|
||||
clearTimeout(timeout)
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
function formatSql(code: string) {
|
||||
return code
|
||||
.split(";")
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean)
|
||||
.map((statement) =>
|
||||
statement.replace(
|
||||
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
|
||||
(keyword) => keyword.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
|
||||
}
|
||||
|
||||
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") {
|
||||
if (language === "sql") return formatSql(code)
|
||||
|
||||
if (language === "python") {
|
||||
const result = await runFormatter(
|
||||
[config.ruffPath, "format", "--stdin-filename", "code.py", "-"],
|
||||
code,
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax")
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
const filename = language === "c" ? "code.c" : "code.cpp"
|
||||
const result = await runFormatter(
|
||||
[
|
||||
config.clangFormatPath,
|
||||
`-assume-filename=${filename}`,
|
||||
"-style={BasedOnStyle: LLVM, IndentWidth: 4, BreakBeforeBraces: Attach}",
|
||||
],
|
||||
code,
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new CodeFormatError(result.stderr || "Formatting failed", "tool")
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
38
apps/api/src/services/options.ts
Normal file
38
apps/api/src/services/options.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { inArray } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
|
||||
export const websiteOptionDefaults = {
|
||||
website_base_url: "http://127.0.0.1",
|
||||
website_name: "Online Judge",
|
||||
website_name_shortcut: "oj",
|
||||
website_footer: "Online Judge Footer",
|
||||
allow_register: true,
|
||||
submission_list_show_all: true,
|
||||
class_list: [] as string[],
|
||||
enable_maxkb: true,
|
||||
}
|
||||
|
||||
export async function getOptions<const T extends readonly string[]>(keys: T) {
|
||||
const rows = await db
|
||||
.select({ key: schema.optionsSysoptions.key, value: schema.optionsSysoptions.value })
|
||||
.from(schema.optionsSysoptions)
|
||||
.where(inArray(schema.optionsSysoptions.key, [...keys]))
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<T[number], unknown>
|
||||
}
|
||||
|
||||
export async function getWebsiteOptions() {
|
||||
const keys = Object.keys(websiteOptionDefaults) as Array<keyof typeof websiteOptionDefaults>
|
||||
const values = await getOptions(keys)
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
|
||||
) as typeof websiteOptionDefaults
|
||||
}
|
||||
|
||||
export async function getBooleanOption(
|
||||
key: keyof typeof websiteOptionDefaults,
|
||||
fallback: boolean,
|
||||
) {
|
||||
const values = await getOptions([key])
|
||||
return typeof values[key] === "boolean" ? values[key] : fallback
|
||||
}
|
||||
41
apps/api/src/services/profile.ts
Normal file
41
apps/api/src/services/profile.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { sessionUserSchema, userProfileSchema } from "@oj2/contract"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
|
||||
export async function getUserProfileById(userId: number, showRealName: boolean) {
|
||||
const [row] = await db
|
||||
.select({ profile: schema.userProfile, user: schema.user })
|
||||
.from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.user.id, userId), eq(schema.user.isDisabled, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return null
|
||||
return userProfileSchema.parse({
|
||||
id: row.profile.id,
|
||||
user: sessionUserSchema.parse({
|
||||
id: row.user.id,
|
||||
username: row.user.username,
|
||||
email: row.user.email,
|
||||
adminType: row.user.adminType,
|
||||
problemPermission: row.user.problemPermission,
|
||||
createTime: row.user.createTime,
|
||||
lastLogin: row.user.lastLogin,
|
||||
openApi: row.user.openApi,
|
||||
isDisabled: row.user.isDisabled,
|
||||
className: row.user.className,
|
||||
}),
|
||||
realName: showRealName ? row.profile.realName : null,
|
||||
acmProblemsStatus: row.profile.acmProblemsStatus,
|
||||
avatar: row.profile.avatar,
|
||||
blog: row.profile.blog,
|
||||
mood: row.profile.mood,
|
||||
github: row.profile.github,
|
||||
school: row.profile.school,
|
||||
major: row.profile.major,
|
||||
language: row.profile.language,
|
||||
acceptedNumber: row.profile.acceptedNumber,
|
||||
submissionNumber: row.profile.submissionNumber,
|
||||
})
|
||||
}
|
||||
@@ -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<SubmissionSoc
|
||||
return {
|
||||
open(ws) {
|
||||
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
||||
ws.subscribe(userEventTopic(ws.data.userId))
|
||||
},
|
||||
message(ws, message) {
|
||||
void handleMessage(ws, String(message))
|
||||
},
|
||||
close(ws) {
|
||||
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
||||
ws.unsubscribe(userEventTopic(ws.data.userId))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -87,7 +90,21 @@ async function handleMessage(
|
||||
.limit(1)
|
||||
|
||||
if (!submission) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
|
||||
const [flowchart] = await db
|
||||
.select({ id: schema.flowchartSubmission.id, status: schema.flowchartSubmission.status, score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(and(eq(schema.flowchartSubmission.id, message.submission_id), eq(schema.flowchartSubmission.userId, ws.data.userId)))
|
||||
.limit(1)
|
||||
if (!flowchart) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
|
||||
return
|
||||
}
|
||||
const replay = flowchart.status === 2
|
||||
? { type: "flowchart_evaluation_completed", submission_id: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined }
|
||||
: flowchart.status === 3
|
||||
? { type: "flowchart_evaluation_failed", submission_id: flowchart.id, error: "Evaluation failed" }
|
||||
: { type: "flowchart_evaluation_update", submission_id: flowchart.id }
|
||||
ws.send(JSON.stringify(flowchartUpdateSchema.parse(replay)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,6 +135,22 @@ export async function bridgeSubmissionEvents(
|
||||
) {
|
||||
const subscriber = createSubscriberRedis()
|
||||
subscriber.on("message", (channel, raw) => {
|
||||
if (channel === userEventChannel) {
|
||||
const event = parseUserEvent(raw)
|
||||
if (!event) return
|
||||
void (async () => {
|
||||
const [activeUser] = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(and(eq(schema.user.id, event.userId), eq(schema.user.isDisabled, false)))
|
||||
.limit(1)
|
||||
if (!activeUser) return
|
||||
server.publish(userEventTopic(event.userId), JSON.stringify(event.data))
|
||||
})().catch((error) => {
|
||||
console.error("Failed to bridge user event", error)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (channel !== submissionUpdateChannel) return
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<JudgeJobData>(
|
||||
@@ -14,6 +16,12 @@ const worker = new Worker<JudgeJobData>(
|
||||
},
|
||||
)
|
||||
|
||||
const flowchartWorker = new Worker<FlowchartJobData>(
|
||||
flowchartQueueName,
|
||||
async (job) => evaluateFlowchart(job.data),
|
||||
{ connection: createBlockingRedis(), concurrency: 2 },
|
||||
)
|
||||
|
||||
worker.on("ready", () => {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any>("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<AchievementSummary>("achievements/summary", {
|
||||
params: name ? { name } : {},
|
||||
return api2.get<AchievementSummary>("achievements/summary", {
|
||||
params: name ? { username: name } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getPendingAchievements() {
|
||||
return http.get<PendingAchievement[]>("achievements/pending")
|
||||
return api2.get<PendingAchievement[]>("achievements/pending")
|
||||
}
|
||||
|
||||
export function markAchievementsRead(ids: number[]) {
|
||||
return http.post("achievements/pending", { ids })
|
||||
return api2.post("achievements/pending/read", { ids })
|
||||
}
|
||||
|
||||
@@ -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<T>(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<T>(request: Promise<ApiResponse<unknown>>): Promise<ApiResponse<T>> {
|
||||
const response = await request
|
||||
return { error: response.error, data: toLegacy<T>(response.data) }
|
||||
}
|
||||
|
||||
function listProblem(value: any): Problem {
|
||||
return {
|
||||
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<WebsiteConfig>(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<unknown>(
|
||||
`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<string, string> = {
|
||||
Python3: "python",
|
||||
C: "c",
|
||||
"C++": "cpp",
|
||||
SQL: "sql",
|
||||
}
|
||||
return api2.post("code/format", {
|
||||
code: data.code,
|
||||
language: languages[data.language] ?? data.language.toLowerCase(),
|
||||
})
|
||||
}
|
||||
|
||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
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<Problem[]>("contest/problem", {
|
||||
params: { contest_id: contestID },
|
||||
})
|
||||
return res.data.map(filterResult)
|
||||
const res = await api2.get<any[]>(`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<any>(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<ReactionState>("reaction", {
|
||||
params: { problem_id: problemID },
|
||||
})
|
||||
return api2.get<ReactionState>(`problems/${problemID}/reaction`)
|
||||
}
|
||||
|
||||
export function setReaction(problemID: number, type: ReactionKey) {
|
||||
return http.post<ReactionState>("reaction", {
|
||||
problem_id: problemID,
|
||||
type,
|
||||
})
|
||||
return api2.post<ReactionState>(`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<any>(api2.get("ai/detail", { params: { start, end, username } }))
|
||||
.then((response) => ({
|
||||
...response,
|
||||
data: {
|
||||
...response.data,
|
||||
flowcharts: response.data.flowcharts?.map((item: any) => ({
|
||||
...item,
|
||||
problem__id: item.problem_id,
|
||||
})) ?? [],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function getAIDurationData(
|
||||
@@ -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<any[]>(`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<YearlyACData[]>("problem/yearly_ac", {
|
||||
params: { problem_id: problemId },
|
||||
})
|
||||
return legacyResponse<YearlyACData[]>(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<any>(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<any>(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<any>(`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<any>(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<any>(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<any>) {
|
||||
return {
|
||||
...response,
|
||||
data: {
|
||||
...response.data,
|
||||
results: response.data.results.map(legacyProblemSet),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProblemSetProblems(problemSetId: number) {
|
||||
const response = await legacyResponse<any[]>(api2.get(`problem-sets/${problemSetId}/problems`))
|
||||
return {
|
||||
...response,
|
||||
data: response.data.map((item) => ({
|
||||
...item,
|
||||
problemset: item.problemset_id,
|
||||
problem: {
|
||||
...item.problem,
|
||||
contest: item.problem.contest_id,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function joinProblemSet(problemSetId: number) {
|
||||
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<any[]>(api2.get(
|
||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||||
))
|
||||
return {
|
||||
...response,
|
||||
data: response.data.map((item) => ({
|
||||
...item,
|
||||
user: item.user_id,
|
||||
badge: legacyBadge(item.badge),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取题单徽章列表
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return http.get(`problemset/${problemSetId}/badges`)
|
||||
export async function getProblemSetBadges(problemSetId: number) {
|
||||
const response = await legacyResponse<any[]>(api2.get(`problem-sets/${problemSetId}/badges`))
|
||||
return { ...response, data: response.data.map(legacyBadge) }
|
||||
}
|
||||
|
||||
// 获取题单用户进度列表
|
||||
@@ -527,12 +633,17 @@ export function getProblemSetUserProgress(
|
||||
completion_status?: "" | "completed" | "in_progress" | "not_started"
|
||||
},
|
||||
) {
|
||||
return http.get(`problemset/${problemSetId}/users_progress`, { params })
|
||||
return legacyResponse(api2.get(`problem-sets/${problemSetId}/user-progress`, {
|
||||
params: {
|
||||
limit: params?.limit,
|
||||
offset: params?.offset,
|
||||
className: params?.class_name,
|
||||
completionStatus: params?.completion_status,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
const res = await http.get<Exercise[]>("exercises", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
const res = await api2.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ApiResponse<Profile | null>> {
|
||||
if (username) return http.get<Profile>("profile", { params: { username } })
|
||||
|
||||
const response = await api2.get<unknown>("me")
|
||||
const response = await api2.get<unknown>(
|
||||
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<Tag[]>("problem/tags")
|
||||
return api2.get<Array<Tag & { problemCount: number }>>("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`)
|
||||
}
|
||||
|
||||
@@ -448,8 +448,10 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage {
|
||||
*/
|
||||
class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> {
|
||||
constructor() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
super({
|
||||
path: "flowchart", // 使用专门的 flowchart WebSocket 路径
|
||||
path: "flowchart",
|
||||
url: `${protocol}//${window.location.host}/ws2/submissions`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ interface Api2Client {
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>>
|
||||
put<T>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>>
|
||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>>
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user