Build Phase 2 judge vertical slice

This commit is contained in:
2026-08-06 22:42:39 -06:00
parent e6329ecabb
commit ec274419c3
41 changed files with 2945 additions and 669 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
DATABASE_URL=postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge
REDIS_URL=redis://localhost:6380
JUDGE_SERVER_URL=http://localhost:8081
JUDGE_SERVER_TOKEN=oj2-dev-token
JUDGE_CONCURRENCY=2
OJ2_DEV_USERNAME=student
OJ2_DEV_PASSWORD=student123

4
.gitignore vendored
View File

@@ -20,5 +20,9 @@ build/
docs/spikes/node_modules/
docs/spikes/endpoints-*.json
# 本地判题验收数据来自生产测试点,只读挂载,不进入版本库
data/test_case/
data/judge_server/
# 生产题目样本只用于本地验收,不进入 git
*.csv

View File

@@ -4,14 +4,25 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"dev": "bun run --parallel dev:http dev:worker",
"dev:http": "bun --watch src/index.ts",
"dev:worker": "bun --watch src/worker.ts",
"start": "bun src/index.ts",
"worker": "bun src/worker.ts",
"seed:dev": "bun src/scripts/seed-dev.ts",
"typecheck": "tsc --noEmit",
"db:pull": "drizzle-kit pull"
},
"dependencies": {
"@oj2/contract": "workspace:*",
"bullmq": "^6.0.9",
"drizzle-orm": "^0.45.2",
"hono": "^4.0.0",
"ioredis": "^6.0.0",
"postgres": "^3.4.0",
"tree-sitter-c": "^0.24.1",
"tree-sitter-python": "^0.25.0",
"web-tree-sitter": "^0.26.11",
"zod": "^4.0.0"
},
"devDependencies": {

View File

@@ -0,0 +1,24 @@
import type { MiddlewareHandler } from "hono"
import { failure } from "../http"
import { getSessionUser, type AuthUser } from "./session"
export interface AppEnv {
Variables: {
user: AuthUser | null
}
}
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
c.set("user", await getSessionUser(c))
await next()
}
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
const user = await getSessionUser(c)
if (!user) {
return failure(c, 401, "login-required", "Authentication required")
}
c.set("user", user)
await next()
}

View File

@@ -0,0 +1,49 @@
import { pbkdf2, timingSafeEqual } from "node:crypto"
import { promisify } from "node:util"
const pbkdf2Async = promisify(pbkdf2)
async function verifyDjangoPbkdf2(password: string, encoded: string) {
const [algorithm, iterationsText, salt, digestText] = encoded.split("$")
if (
algorithm !== "pbkdf2_sha256" ||
!iterationsText ||
!salt ||
!digestText
) {
return false
}
const iterations = Number(iterationsText)
const expected = Buffer.from(digestText, "base64")
if (!Number.isSafeInteger(iterations) || iterations <= 0 || expected.length === 0) {
return false
}
const actual = await pbkdf2Async(
password,
salt,
iterations,
expected.length,
"sha256",
)
return timingSafeEqual(actual, expected)
}
export async function verifyPassword(password: string, encoded: string) {
if (encoded.startsWith("pbkdf2_sha256$")) {
return {
valid: await verifyDjangoPbkdf2(password, encoded),
needsUpgrade: true,
}
}
if (encoded.startsWith("$argon2")) {
return {
valid: await Bun.password.verify(password, encoded),
needsUpgrade: false,
}
}
return { valid: false, needsUpgrade: false }
}

View File

@@ -0,0 +1,110 @@
import { randomBytes } from "node:crypto"
import { eq } from "drizzle-orm"
import type { Context } from "hono"
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
import { config } from "../config"
import { db, schema } from "../db"
import { redis } from "../redis"
const SESSION_PREFIX = "session:"
interface StoredSession {
userId: number
createdAt: string
}
export interface AuthUser {
id: number
username: string
email: string | null
adminType: string
problemPermission: string
isDisabled: boolean
}
function sessionKey(token: string) {
return `${SESSION_PREFIX}${token}`
}
export async function createSession(c: Context, userId: number) {
const token = randomBytes(32).toString("base64url")
const value: StoredSession = {
userId,
createdAt: new Date().toISOString(),
}
await redis.set(
sessionKey(token),
JSON.stringify(value),
"EX",
config.sessionTtlSeconds,
)
setCookie(c, config.sessionCookie, token, {
httpOnly: true,
sameSite: "Lax",
secure: config.secureCookies,
path: "/",
maxAge: config.sessionTtlSeconds,
})
}
export async function destroySession(c: Context) {
const token = getCookie(c, config.sessionCookie)
if (token) await redis.del(sessionKey(token))
deleteCookie(c, config.sessionCookie, { path: "/" })
}
function readCookie(request: Request, name: string) {
const header = request.headers.get("cookie")
if (!header) return undefined
for (const part of header.split(";")) {
const [key, ...value] = part.trim().split("=")
if (key === name) return decodeURIComponent(value.join("="))
}
return undefined
}
async function getUserByToken(token: string | undefined): Promise<AuthUser | null> {
if (!token) return null
const raw = await redis.get(sessionKey(token))
if (!raw) return null
let session: StoredSession
try {
session = JSON.parse(raw) as StoredSession
} catch {
await redis.del(sessionKey(token))
return null
}
const [user] = 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,
})
.from(schema.user)
.where(eq(schema.user.id, session.userId))
.limit(1)
if (!user || user.isDisabled) {
await redis.del(sessionKey(token))
return null
}
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
return user
}
export function getSessionUser(c: Context) {
return getUserByToken(getCookie(c, config.sessionCookie))
}
export function getRequestSessionUser(request: Request) {
return getUserByToken(readCookie(request, config.sessionCookie))
}

10
apps/api/src/config.ts Normal file
View File

@@ -0,0 +1,10 @@
export const config = {
port: Number(process.env.PORT ?? 3000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
sessionCookie: "oj2_session",
sessionTtlSeconds: Number(process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60),
secureCookies: process.env.COOKIE_SECURE === "true",
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),
}

14
apps/api/src/http.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Context } from "hono"
export function success<T>(c: Context, data: T, status = 200) {
return c.json({ data }, status as 200)
}
export function failure(
c: Context,
status: 400 | 401 | 403 | 404 | 409 | 500 | 502,
code: string,
message: string,
) {
return c.json({ error: { code, message } }, status)
}

View File

@@ -1,13 +1,53 @@
import { Hono } from "hono"
import { getRequestSessionUser } from "./auth/session"
import { config } from "./config"
import { authRoutes } from "./routes/auth"
import { judgeServerRoutes } from "./routes/judge-server"
import { problemRoutes } from "./routes/problem"
import { submissionRoutes } from "./routes/submission"
import {
bridgeSubmissionEvents,
submissionWebSocketHandler,
type SubmissionSocketData,
} from "./websocket"
const app = new Hono()
app.get("/health", (c) => c.json({ ok: true }))
app.route("/api", authRoutes)
app.route("/api", problemRoutes)
app.route("/api", submissionRoutes)
app.route("/api", judgeServerRoutes)
export default {
port: 3000,
fetch: app.fetch,
}
app.onError((error, c) => {
console.error(error)
return c.json(
{ error: { code: "internal-error", message: "Internal server error" } },
500,
)
})
const server = Bun.serve<SubmissionSocketData>({
port: config.port,
async fetch(request, bunServer) {
const url = new URL(request.url)
if (url.pathname === "/ws/submissions") {
const user = await getRequestSessionUser(request)
if (!user) return new Response("Unauthorized", { status: 401 })
if (
bunServer.upgrade(request, {
data: { userId: user.id, username: user.username },
})
) {
return undefined
}
return new Response("WebSocket upgrade failed", { status: 400 })
}
return app.fetch(request)
},
websocket: submissionWebSocketHandler(),
})
await bridgeSubmissionEvents(server)
console.log(`OJ2 API listening on http://localhost:${server.port}`)

243
apps/api/src/judge/ast.ts Normal file
View File

@@ -0,0 +1,243 @@
import { Language, Parser, type Node } from "web-tree-sitter"
export interface AstRule {
engine?: string
target?: string
outer?: string
inner?: string
label?: string
message?: string
exact?: number
min?: number
max?: number
}
export interface AstResult {
description: string
passed: boolean
}
const mappings: Record<string, Record<string, string>> = {
C: {
for_loop: "for_statement",
while_loop: "while_statement",
do_while: "do_statement",
if_statement: "if_statement",
else_clause: "else_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
switch_statement: "switch_statement",
case_statement: "case_statement",
assignment: "assignment_expression",
struct: "struct_specifier",
include: "preproc_include",
and: "&&",
or: "||",
not: "!",
},
Python3: {
for_loop: "for_statement",
while_loop: "while_statement",
if_statement: "if_statement",
else_clause: "else_clause",
elif_clause: "elif_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
try_except: "try_statement",
with_statement: "with_statement",
list_comprehension: "list_comprehension",
list_literal: "list",
dict_literal: "dictionary",
set_literal: "set",
f_string: "format_string",
import: "import_statement",
import_from: "import_from_statement",
assignment: "assignment",
class_definition: "class_definition",
},
}
let initPromise: Promise<void> | undefined
const languages = new Map<string, Language>()
async function loadLanguage(language: string) {
if (!(language in mappings)) return null
if (!initPromise) initPromise = Parser.init()
await initPromise
const cached = languages.get(language)
if (cached) return cached
const packageName = language === "C" ? "tree-sitter-c" : "tree-sitter-python"
const wasmName = language === "C" ? "tree-sitter-c.wasm" : "tree-sitter-python.wasm"
const wasmPath = Bun.resolveSync(`${packageName}/${wasmName}`, import.meta.dir)
const loaded = await Language.load(wasmPath)
languages.set(language, loaded)
return loaded
}
function collectNodes(root: Node, type: string, result: Node[] = []) {
if (root.type === type) result.push(root)
for (const child of root.children) collectNodes(child, type, result)
return result
}
function hasNode(root: Node, type: string): boolean {
if (root.type === type) return true
return root.children.some((child) => hasNode(child, type))
}
function targetName(rule: AstRule) {
return rule.label || rule.target || "指定语法"
}
function rangeDescription(subject: string, rule: AstRule) {
if (rule.message) return rule.message
if (rule.exact !== undefined) return `${subject} 出现 ${rule.exact}`
const parts: string[] = []
if (rule.min !== undefined) parts.push(`至少 ${rule.min}`)
if (rule.max !== undefined) parts.push(`至多 ${rule.max}`)
return `${subject} ${parts.join("、")}`.trim()
}
function rangePassed(count: number, rule: AstRule) {
if (rule.exact !== undefined && count !== rule.exact) return false
if (rule.min !== undefined && count < rule.min) return false
if (rule.max !== undefined && count > rule.max) return false
return true
}
function functionCalls(root: Node, target: string, language: string) {
const callType = language === "C" ? "call_expression" : "call"
return collectNodes(root, callType).filter((call) => {
const fn = call.childForFieldName("function")
return fn?.type === "identifier" && fn.text === target
})
}
function methodCalls(root: Node, target: string, language: string) {
if (language === "C") return []
return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function")
return (
fn?.type === "attribute" &&
fn.childForFieldName("attribute")?.text === target
)
})
}
function evaluateRule(
root: Node,
rule: AstRule,
language: string,
mapping: Record<string, string>,
): AstResult | null {
const target = rule.target ?? ""
const nodeType = mapping[target] ?? target
switch (rule.engine) {
case "must_exist_node":
return {
description: rule.message || `必须使用 ${targetName(rule)}`,
passed: hasNode(root, nodeType),
}
case "must_not_exist_node":
return {
description: rule.message || `不能使用 ${targetName(rule)}`,
passed: !hasNode(root, nodeType),
}
case "count_node": {
const count = collectNodes(root, nodeType).length
return {
description: rangeDescription(targetName(rule), rule),
passed: rangePassed(count, rule),
}
}
case "must_call_function":
return {
description: rule.message || `必须调用 ${target}()`,
passed: functionCalls(root, target, language).length > 0,
}
case "must_not_call_function":
return {
description: rule.message || `不能调用 ${target}()`,
passed: functionCalls(root, target, language).length === 0,
}
case "count_function_call": {
const count = functionCalls(root, target, language).length
return {
description: rangeDescription(`${target}()`, rule),
passed: rangePassed(count, rule),
}
}
case "must_call_method":
return {
description: rule.message || `必须调用 .${target}()`,
passed: methodCalls(root, target, language).length > 0,
}
case "must_not_call_method":
return {
description: rule.message || `不能调用 .${target}()`,
passed: methodCalls(root, target, language).length === 0,
}
case "must_use_operator":
return {
description: rule.message || `必须使用 ${target} 运算符`,
passed: hasNode(root, nodeType),
}
case "must_have_nesting": {
const outer = rule.outer ?? ""
const inner = rule.inner ?? ""
const outerType = mapping[outer] ?? outer
const innerType = mapping[inner] ?? inner
const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)),
)
return {
description:
rule.message ||
(outer === inner
? `必须使用 ${outer} 嵌套`
: `必须在 ${outer} 中嵌套使用 ${inner}`),
passed,
}
}
default:
return null
}
}
export async function checkAst(
code: string,
language: string,
rules: AstRule[],
): Promise<{ passed: boolean; results: AstResult[] }> {
if (rules.length === 0) return { passed: true, results: [] }
const treeSitterLanguage = await loadLanguage(language)
if (!treeSitterLanguage) return { passed: true, results: [] }
const parser = new Parser()
try {
parser.setLanguage(treeSitterLanguage)
const tree = parser.parse(code)
if (!tree) return { passed: true, results: [] }
try {
const mapping = mappings[language] ?? {}
const results = rules
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
.filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results }
} finally {
tree.delete()
}
} catch {
return { passed: true, results: [] }
} finally {
parser.delete()
}
}

View File

@@ -0,0 +1,41 @@
import {
submissionUpdateSchema,
type SubmissionUpdate,
} from "@oj2/contract"
import { redis } from "../redis"
export const submissionUpdateChannel = "submission:updates"
interface SubmissionEvent {
userId: number
data: SubmissionUpdate
}
export function userSubmissionTopic(userId: number) {
return `submission:user:${userId}`
}
export async function publishSubmissionUpdate(
userId: number,
data: SubmissionUpdate,
) {
const event: SubmissionEvent = {
userId,
data: submissionUpdateSchema.parse(data),
}
await redis.publish(submissionUpdateChannel, JSON.stringify(event))
}
export function parseSubmissionEvent(raw: string): SubmissionEvent | null {
try {
const value = JSON.parse(raw) as { userId?: unknown; data?: unknown }
if (typeof value.userId !== "number") return null
return {
userId: value.userId,
data: submissionUpdateSchema.parse(value.data),
}
} catch {
return null
}
}

View File

@@ -0,0 +1,6 @@
export const judgeQueueName = "judge-submission"
export interface JudgeJobData {
submissionId: string
problemId: number
}

View File

@@ -0,0 +1,107 @@
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"]
export const languageConfigs: Record<string, Record<string, unknown>> = {
C: {
template: "",
compile: {
src_name: "main.c",
exe_name: "main",
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 256 * 1024 * 1024,
compile_command:
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
},
run: {
command: "{exe_path}",
seccomp_rule: { "Standard IO": "c_cpp", "File IO": "c_cpp_file_io" },
env: defaultEnv,
},
},
"C++": {
template: "",
compile: {
src_name: "main.cpp",
exe_name: "main",
max_cpu_time: 10000,
max_real_time: 20000,
max_memory: 1024 * 1024 * 1024,
compile_command:
"/usr/bin/g++ -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c++20 {src_path} -lm -o {exe_path}",
},
run: {
command: "{exe_path}",
seccomp_rule: { "Standard IO": "c_cpp", "File IO": "c_cpp_file_io" },
env: defaultEnv,
},
},
Java: {
template: "",
compile: {
src_name: "Main.java",
exe_name: "Main",
max_cpu_time: 5000,
max_real_time: 10000,
max_memory: -1,
compile_command: "/usr/bin/javac {src_path} -d {exe_dir}",
},
run: {
command: "/usr/bin/java -cp {exe_dir} -XX:MaxRAM={max_memory}k Main",
seccomp_rule: null,
env: defaultEnv,
memory_limit_check_only: 1,
},
},
Python3: {
template: "",
compile: {
src_name: "solution.py",
exe_name: "solution.py",
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 128 * 1024 * 1024,
compile_command: "/usr/bin/python3 -m py_compile {src_path}",
},
run: {
command: "/usr/bin/python3 -BS {exe_path}",
seccomp_rule: "general",
env: defaultEnv,
},
},
Golang: {
template: "",
compile: {
src_name: "main.go",
exe_name: "main",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/go build -o {exe_path} {src_path}",
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv],
},
run: {
command: "{exe_path}",
seccomp_rule: "golang",
env: ["GOMAXPROCS=1", ...defaultEnv],
memory_limit_check_only: 1,
},
},
JavaScript: {
template: "",
compile: {
src_name: "main.js",
exe_name: "main.js",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/node --check {src_path}",
env: defaultEnv,
},
run: {
command: "/usr/bin/node {exe_path}",
seccomp_rule: "node",
env: defaultEnv,
memory_limit_check_only: 1,
},
},
}

355
apps/api/src/judge/run.ts Normal file
View File

@@ -0,0 +1,355 @@
import { createHash } from "node:crypto"
import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages"
import {
isAccepted,
JudgeStatus,
type JudgeStatusValue,
} from "./status"
import { parseProblemTemplate } from "./template"
interface JudgeCase {
cpu_time: number
memory: number
result: number
test_case: string
[key: string]: unknown
}
interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function statusValue(value: number): JudgeStatusValue {
const statuses = new Set<number>(Object.values(JudgeStatus))
return statuses.has(value)
? (value as JudgeStatusValue)
: JudgeStatus.SYSTEM_ERROR
}
function templateForLanguage(value: unknown, language: string) {
const template = objectValue(value)[language]
return typeof template === "string" ? template : null
}
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = objectValue(value)[language]
return Array.isArray(rules) ? (rules as AstRule[]) : []
}
async function requestJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCaseId: string,
) {
const languageConfig = languageConfigs[language]
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
const token = createHash("sha256")
.update(config.judgeServerToken)
.digest("hex")
const response = await fetch(new URL("/judge", config.judgeServerUrl), {
method: "POST",
headers: {
"content-type": "application/json",
"X-Judge-Server-Token": token,
},
body: JSON.stringify({
language_config: languageConfig,
src: code,
max_cpu_time: timeLimit,
max_memory: 1024 * 1024 * memoryLimit,
test_case_id: testCaseId,
output: false,
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
}),
})
if (!response.ok) {
throw new Error(`JudgeServer returned HTTP ${response.status}`)
}
return (await response.json()) as JudgeResponse
}
async function persistResult(
submissionId: string,
problemId: number,
userId: number,
displayId: string,
result: JudgeStatusValue,
info: unknown,
statisticInfo: Record<string, unknown>,
) {
return db.transaction(async (tx) => {
const [currentSubmission] = await tx
.select({ result: schema.submission.result })
.from(schema.submission)
.where(eq(schema.submission.id, submissionId))
.for("update")
if (
!currentSubmission ||
![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(
currentSubmission.result as 6 | 7,
)
) {
return false
}
const [problem] = await tx
.select({
submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber,
statisticInfo: schema.problem.statisticInfo,
})
.from(schema.problem)
.where(eq(schema.problem.id, problemId))
.for("update")
const [profile] = await tx
.select()
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.for("update")
if (!problem || !profile) {
throw new Error("Submission dependencies disappeared during judging")
}
await tx
.update(schema.submission)
.set({ result, info, statisticInfo })
.where(eq(schema.submission.id, submissionId))
const problemStatistics = objectValue(problem.statisticInfo)
const resultKey = String(result)
const previousResultCount = problemStatistics[resultKey]
problemStatistics[resultKey] =
(typeof previousResultCount === "number" ? previousResultCount : 0) + 1
await tx
.update(schema.problem)
.set({
submissionNumber: problem.submissionNumber + 1,
acceptedNumber:
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
statisticInfo: problemStatistics,
})
.where(eq(schema.problem.id, problemId))
const acmStatus = objectValue(profile.acmProblemsStatus)
const problems = objectValue(acmStatus.problems)
const previous = objectValue(problems[String(problemId)])
const previousStatus = previous.status
const wasAccepted =
typeof previousStatus === "number" && isAccepted(previousStatus)
const acceptedNow = isAccepted(result)
if (previousStatus === undefined) {
problems[String(problemId)] = {
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
_id: displayId,
}
} else if (!wasAccepted) {
problems[String(problemId)] = {
...previous,
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
_id: displayId,
}
}
acmStatus.problems = problems
await tx
.update(schema.userProfile)
.set({
submissionNumber: profile.submissionNumber + 1,
acceptedNumber:
profile.acceptedNumber + (acceptedNow && !wasAccepted ? 1 : 0),
acmProblemsStatus: acmStatus,
})
.where(eq(schema.userProfile.id, profile.id))
return true
})
}
async function markSystemError(submissionId: string, userId: number, error: unknown) {
const message = error instanceof Error ? error.message : String(error)
const updated = await db
.update(schema.submission)
.set({
result: JudgeStatus.SYSTEM_ERROR,
statisticInfo: { err_info: message, score: 0 },
})
.where(
and(
eq(schema.submission.id, submissionId),
inArray(schema.submission.result, [
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]),
),
)
.returning({ id: schema.submission.id })
if (updated.length > 0) {
await publishSubmissionUpdate(userId, {
type: "submission_update",
submission_id: submissionId,
result: JudgeStatus.SYSTEM_ERROR,
status: "error",
err_info: message,
})
}
}
export async function judgeSubmission(job: JudgeJobData) {
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, job.submissionId),
eq(schema.problem.id, job.problemId),
),
)
.limit(1)
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) {
return
}
try {
await db
.update(schema.submission)
.set({ result: JudgeStatus.JUDGING })
.where(eq(schema.submission.id, row.submission.id))
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submission_id: row.submission.id,
result: JudgeStatus.JUDGING,
status: "judging",
})
const rawTemplate = templateForLanguage(
row.problem.template,
row.submission.language,
)
const template = rawTemplate ? parseProblemTemplate(rawTemplate) : null
const source = template
? `${template.prepend}\n${row.submission.code}\n${template.append}`
: row.submission.code
const response = await requestJudge(
row.submission.language,
source,
row.problem.timeLimit,
row.problem.memoryLimit,
row.problem.testCaseId,
)
let result: JudgeStatusValue
let info: unknown = {}
let statisticInfo: Record<string, unknown> = {}
if (response.err) {
result = JudgeStatus.COMPILE_ERROR
statisticInfo = {
err_info:
typeof response.data === "string"
? response.data
: JSON.stringify(response.data),
score: 0,
}
} else {
if (!Array.isArray(response.data)) {
throw new Error("JudgeServer returned an invalid result payload")
}
const cases = [...response.data].sort(
(left, right) => Number(left.test_case) - Number(right.test_case),
)
info = { err: null, data: cases }
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED)
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
statisticInfo = {
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
score: 0,
}
if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(
row.problem.astRules,
row.submission.language,
)
if (rules.length > 0) {
const ast = await checkAst(
row.submission.code,
row.submission.language,
rules,
)
if (!ast.passed) {
result = JudgeStatus.AST_CHECK_FAILED
statisticInfo.ast_results = ast.results
}
}
}
}
const saved = await persistResult(
row.submission.id,
row.problem.id,
row.submission.userId,
row.problem.displayId,
result,
info,
statisticInfo,
)
if (!saved) return
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submission_id: row.submission.id,
result,
status: "finished",
time_cost:
typeof statisticInfo.time_cost === "number"
? statisticInfo.time_cost
: undefined,
memory_cost:
typeof statisticInfo.memory_cost === "number"
? statisticInfo.memory_cost
: undefined,
score:
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
})
} catch (error) {
console.error(`Failed to judge submission ${row.submission.id}`, error)
await markSystemError(row.submission.id, row.submission.userId, error)
}
}

View File

@@ -0,0 +1,20 @@
export const JudgeStatus = {
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
export function isAccepted(result: number) {
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
}

View File

@@ -0,0 +1,12 @@
export function parseProblemTemplate(template: string) {
const section = (name: string) =>
template.match(
new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`),
)?.[1] ?? ""
return {
prepend: section("PREPEND"),
template: section("TEMPLATE"),
append: section("APPEND"),
}
}

12
apps/api/src/queue.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Queue } from "bullmq"
import { judgeQueueName, type JudgeJobData } from "./judge/job"
import { createBlockingRedis } from "./redis"
export const judgeQueue = new Queue<JudgeJobData>(judgeQueueName, {
connection: createBlockingRedis(),
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 500,
},
})

19
apps/api/src/redis.ts Normal file
View File

@@ -0,0 +1,19 @@
import Redis from "ioredis"
import { config } from "./config"
export const redis = new Redis(config.redisUrl, {
maxRetriesPerRequest: 1,
})
export function createBlockingRedis() {
return new Redis(config.redisUrl, {
maxRetriesPerRequest: null,
})
}
export function createSubscriberRedis() {
return new Redis(config.redisUrl, {
maxRetriesPerRequest: null,
})
}

110
apps/api/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,110 @@
import {
loginRequestSchema,
sessionUserSchema,
userProfileSchema,
} from "@oj2/contract"
import { and, eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { createSession, destroySession } from "../auth/session"
import { verifyPassword } from "../auth/password"
import { db, schema } from "../db"
import { failure, success } from "../http"
export const authRoutes = new Hono<AppEnv>()
authRoutes.post("/auth/login", async (c) => {
const parsed = loginRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", "Username and password are required")
}
const [user] = await db
.select()
.from(schema.user)
.where(
sql`lower(${schema.user.username}) = lower(${parsed.data.username})`,
)
.limit(1)
if (!user) {
return failure(c, 401, "invalid-credentials", "Invalid username or password")
}
if (user.isDisabled) {
return failure(c, 403, "account-disabled", "Your account has been disabled")
}
const password = await verifyPassword(parsed.data.password, user.password)
if (!password.valid) {
return failure(c, 401, "invalid-credentials", "Invalid username or password")
}
const now = new Date().toISOString()
const update: { lastLogin: string; password?: string } = { lastLogin: now }
if (password.needsUpgrade) {
update.password = await Bun.password.hash(parsed.data.password, {
algorithm: "argon2id",
})
}
await db.update(schema.user).set(update).where(eq(schema.user.id, user.id))
await createSession(c, user.id)
return success(c, { ok: true })
})
authRoutes.delete("/auth/session", async (c) => {
await destroySession(c)
return success(c, null)
})
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,
})
return success(c, data)
})

View File

@@ -0,0 +1,86 @@
import { createHash, timingSafeEqual } from "node:crypto"
import { eq } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { z } from "zod"
import { config } from "../config"
import { db, schema } from "../db"
import { failure } from "../http"
const heartbeatSchema = z.object({
hostname: z.string().min(1).max(128),
judger_version: z.string().min(1).max(32),
cpu_core: z.number().int().positive(),
memory: z.number().min(0).max(100),
cpu: z.number().min(0).max(100),
action: z.literal("heartbeat"),
service_url: z.string().min(1).max(256),
})
export const judgeServerRoutes = new Hono()
function tokenMatches(value: string | undefined) {
if (!value) return false
const expected = createHash("sha256")
.update(config.judgeServerToken)
.digest("hex")
const actualBuffer = Buffer.from(value)
const expectedBuffer = Buffer.from(expected)
return (
actualBuffer.length === expectedBuffer.length &&
timingSafeEqual(actualBuffer, expectedBuffer)
)
}
async function heartbeat(c: Context) {
if (!tokenMatches(c.req.header("X-Judge-Server-Token"))) {
return failure(c, 403, "invalid-judge-token", "Invalid token")
}
const parsed = heartbeatSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-heartbeat", "Invalid heartbeat payload")
}
const now = new Date().toISOString()
const [existing] = await db
.select({ id: schema.judgeServer.id })
.from(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, parsed.data.hostname))
.limit(1)
const common = {
ip:
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
c.req.header("x-real-ip") ||
null,
judgerVersion: parsed.data.judger_version,
cpuCore: parsed.data.cpu_core,
memoryUsage: parsed.data.memory,
cpuUsage: parsed.data.cpu,
lastHeartbeat: now,
serviceUrl: parsed.data.service_url,
}
if (existing) {
await db
.update(schema.judgeServer)
.set(common)
.where(eq(schema.judgeServer.id, existing.id))
} else {
await db.insert(schema.judgeServer).values({
...common,
hostname: parsed.data.hostname,
createTime: now,
taskNumber: 0,
isDisabled: false,
})
}
// JudgeServer 1.6.1 healthcheck still reads the legacy { error, data } envelope.
return c.json({ error: null, data: null })
}
judgeServerRoutes.post("/judge-server/heartbeat", heartbeat)
judgeServerRoutes.post("/judge-server/heartbeat/", heartbeat)

View File

@@ -1,10 +1,32 @@
import { problemSummarySchema } from "@oj2/contract"
import { desc } from "drizzle-orm"
import { problemDetailSchema, problemSummarySchema } from "@oj2/contract"
import { and, count, desc, eq, isNull, notInArray } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
export const problemRoutes = new Hono()
export const problemRoutes = new Hono<AppEnv>()
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
}
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
}
problemRoutes.get("/problems", async (c) => {
const rows = await db
@@ -17,9 +39,111 @@ problemRoutes.get("/problems", async (c) => {
acceptedNumber: schema.problem.acceptedNumber,
})
.from(schema.problem)
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.orderBy(desc(schema.problem.id))
.limit(20)
const data = rows.map((row) => problemSummarySchema.parse(row))
return c.json({ data })
return success(c, data)
})
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
const [row] = await db
.select({
problem: schema.problem,
creatorId: schema.user.id,
creatorUsername: schema.user.username,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(
and(
eq(schema.problem.displayId, c.req.param("displayId")),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
const tagRows = await db
.select({ name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(eq(schema.problemTags.problemId, row.problem.id))
const user = c.get("user")
let myStatus: number | null = null
let myFailedCount = 0
if (user) {
const [profile] = await db
.select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
const statuses = objectValue(objectValue(profile?.status).problems)
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
if (typeof problemStatus === "number") myStatus = problemStatus
const [failed] = await db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, row.problem.id),
notInArray(schema.submission.result, [0, 10]),
),
)
myFailedCount = failed?.value ?? 0
}
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
const data = 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,
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: row.problem.difficulty,
source: row.problem.source,
prompt: row.problem.prompt,
submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber,
statisticInfo: objectValue(row.problem.statisticInfo),
shareSubmission: row.problem.shareSubmission,
contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name),
createdBy: {
id: row.creatorId,
username: row.creatorUsername,
realName: null,
},
myStatus,
myFailedCount,
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,
})
return success(c, data)
})

View File

@@ -0,0 +1,155 @@
import { randomBytes } from "node:crypto"
import {
createSubmissionRequestSchema,
createSubmissionResponseSchema,
submissionDetailSchema,
} from "@oj2/contract"
import { and, eq, isNull } 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 { judgeQueue } from "../queue"
export const submissionRoutes = new Hono<AppEnv>()
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
submissionRoutes.use("/submissions", requireAuth)
submissionRoutes.use("/submissions/*", requireAuth)
submissionRoutes.post("/submissions", async (c) => {
const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", "Invalid submission payload")
}
if (parsed.data.contestId) {
return failure(
c,
400,
"contest-not-supported",
"Contest submissions are not part of the Phase 2 slice",
)
}
const [problem] = await db
.select({
id: schema.problem.id,
languages: schema.problem.languages,
})
.from(schema.problem)
.where(
and(
eq(schema.problem.id, parsed.data.problemId),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!stringArray(problem.languages).includes(parsed.data.language)) {
return failure(
c,
400,
"language-not-allowed",
`${parsed.data.language} is not allowed in the problem`,
)
}
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
await db.insert(schema.submission).values({
id: submissionId,
problemId: problem.id,
createTime,
userId: user.id,
username: user.username,
code: parsed.data.code,
result: JudgeStatus.PENDING,
info: {},
language: parsed.data.language,
shared: false,
statisticInfo: {},
ip,
contestId: null,
})
try {
await judgeQueue.add(
"judge",
{ submissionId, problemId: problem.id },
{ jobId: submissionId },
)
} catch (error) {
await db
.update(schema.submission)
.set({ result: JudgeStatus.SYSTEM_ERROR })
.where(eq(schema.submission.id, submissionId))
console.error("Failed to enqueue submission", error)
return failure(c, 502, "queue-unavailable", "Judge queue is unavailable")
}
return success(
c,
createSubmissionResponseSchema.parse({ submissionId }),
201,
)
})
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)
if (!row) {
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)
})

View File

@@ -0,0 +1,58 @@
import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db"
const username = process.env.OJ2_DEV_USERNAME ?? "student"
const password = process.env.OJ2_DEV_PASSWORD ?? "student123"
const passwordHash = await Bun.password.hash(password, { algorithm: "argon2id" })
const now = new Date().toISOString()
// Phase 1 imports rows with explicit ids, so PostgreSQL's sequence has not moved.
await db.execute(
sql`select setval(pg_get_serial_sequence('"user"', 'id'), coalesce(max(${schema.user.id}), 1), true) from ${schema.user}`,
)
const [user] = await db
.insert(schema.user)
.values({
username,
password: passwordHash,
rawPassword: password,
email: `${username}@example.test`,
createTime: now,
adminType: "Regular User",
problemPermission: "None",
openApi: false,
isDisabled: false,
sessionKeys: [],
})
.onConflictDoUpdate({
target: schema.user.username,
set: {
password: passwordHash,
rawPassword: password,
email: `${username}@example.test`,
isDisabled: false,
},
})
.returning({ id: schema.user.id, username: schema.user.username })
if (!user) throw new Error("Failed to seed development user")
const [profile] = await db
.select({ id: schema.userProfile.id })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
if (!profile) {
await db.insert(schema.userProfile).values({
userId: user.id,
acmProblemsStatus: { problems: {}, contest_problems: {} },
avatar: "/public/avatar/default.png",
realName: "Phase 2 Student",
})
}
console.log(`Seeded development login: ${user.username} / ${password}`)
process.exit(0)

149
apps/api/src/websocket.ts Normal file
View File

@@ -0,0 +1,149 @@
import { submissionUpdateSchema } from "@oj2/contract"
import { and, eq } from "drizzle-orm"
import { db, schema } from "./db"
import {
parseSubmissionEvent,
submissionUpdateChannel,
userSubmissionTopic,
} from "./judge/events"
import { JudgeStatus } from "./judge/status"
import { createSubscriberRedis } from "./redis"
export interface SubmissionSocketData {
userId: number
username: string
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
return {
open(ws) {
ws.subscribe(userSubmissionTopic(ws.data.userId))
},
message(ws, message) {
void handleMessage(ws, String(message))
},
close(ws) {
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
},
}
}
async function handleMessage(
ws: Bun.ServerWebSocket<SubmissionSocketData>,
raw: string,
) {
const [activeUser] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.id, ws.data.userId),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!activeUser) {
ws.close(1008, "Account disabled")
return
}
let message: { type?: unknown; timestamp?: unknown; submission_id?: unknown }
try {
message = JSON.parse(raw) as typeof message
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
return
}
if (message.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
return
}
if (message.type !== "subscribe" || typeof message.submission_id !== "string") {
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
return
}
const [submission] = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
statisticInfo: schema.submission.statisticInfo,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.id, message.submission_id),
eq(schema.submission.userId, ws.data.userId),
),
)
.limit(1)
if (!submission) {
ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
return
}
const statistics = objectValue(submission.statisticInfo)
const status =
submission.result === JudgeStatus.PENDING
? "pending"
: submission.result === JudgeStatus.JUDGING
? "judging"
: submission.result === JudgeStatus.SYSTEM_ERROR
? "error"
: "finished"
const parsed = submissionUpdateSchema.safeParse({
type: "submission_update",
submission_id: submission.id,
result: submission.result,
status,
time_cost: statistics.time_cost,
memory_cost: statistics.memory_cost,
score: statistics.score,
err_info: statistics.err_info,
})
if (parsed.success) ws.send(JSON.stringify(parsed.data))
}
export async function bridgeSubmissionEvents(
server: Bun.Server<SubmissionSocketData>,
) {
const subscriber = createSubscriberRedis()
subscriber.on("message", (channel, raw) => {
if (channel !== submissionUpdateChannel) return
const event = parseSubmissionEvent(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(
userSubmissionTopic(event.userId),
JSON.stringify(event.data),
)
})().catch((error) => {
console.error("Failed to bridge submission event", error)
})
})
subscriber.on("error", (error) => {
console.error("Submission event subscriber error", error)
})
await subscriber.subscribe(submissionUpdateChannel)
return subscriber
}

33
apps/api/src/worker.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Worker } from "bullmq"
import { config } from "./config"
import { judgeQueueName, type JudgeJobData } from "./judge/job"
import { judgeSubmission } from "./judge/run"
import { createBlockingRedis } from "./redis"
const worker = new Worker<JudgeJobData>(
judgeQueueName,
async (job) => judgeSubmission(job.data),
{
connection: createBlockingRedis(),
concurrency: config.judgeConcurrency,
},
)
worker.on("ready", () => {
console.log(`Judge worker ready (concurrency=${config.judgeConcurrency})`)
})
worker.on("failed", (job, error) => {
console.error(`Judge job ${job?.id ?? "unknown"} failed`, error)
})
worker.on("error", (error) => {
console.error("Judge worker error", error)
})
async function shutdown() {
await worker.close()
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)

View File

@@ -3,8 +3,6 @@ import { darkTheme, dateZhCN, zhCN } from "naive-ui"
import "normalize.css"
import "./index.css"
import { useConfigStore } from "shared/store/config"
import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
const isDark = useDark()
@@ -17,9 +15,7 @@ onMounted(() => {
userStore.getMyProfile()
})
// 使用配置更新和 MaxKB 功能
useConfigUpdate()
useMaxKB()
// 配置推送和 MaxKB 仍属于 Phase 3在它们迁入前不连接旧 WebSocket。
// 延迟加载 highlight.js避免阻塞首屏
const hljsInstance = ref<any>(null)

View File

@@ -1,3 +1,9 @@
import {
createSubmissionResponseSchema,
problemDetailSchema,
submissionDetailSchema,
} from "@oj2/contract"
import api2 from "utils/api2"
import http from "utils/http"
import { filterResult } from "oj/transforms"
import type {
@@ -8,10 +14,23 @@ import type {
Submission,
SubmissionListPayload,
SubmitCodePayload,
WebsiteConfig,
} from "utils/types"
export function getWebsiteConfig() {
return http.get("website")
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,
})
}
export async function getProblemList(
@@ -41,6 +60,7 @@ export function getRandomProblemID() {
}
export function getProblem(problemID: string, contestID: string) {
if (!contestID) return getPhase2Problem(problemID)
const endpoint = !!contestID ? "contest/problem" : "problem"
return http.get(endpoint, {
params: {
@@ -50,22 +70,105 @@ export function getProblem(problemID: string, contestID: string) {
})
}
async function getPhase2Problem(problemID: string) {
const response = await api2.get<unknown>(
`problems/${encodeURIComponent(problemID)}`,
)
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,
}
}
export function getProblemBeatRate(problemID: number) {
return http.get("problem/beat_count", { params: { problem_id: problemID } })
}
export function getSubmission(id: string) {
return http.get<Submission>("submission", {
params: { id },
})
export async function getSubmission(id: string) {
const response = await api2.get<unknown>(
`submissions/${encodeURIComponent(id)}`,
)
const submission = submissionDetailSchema.parse(response.data)
return {
error: null,
data: {
id: submission.id,
create_time: submission.createTime,
user_id: submission.userId,
username: submission.username,
code: submission.code,
result: submission.result,
info: submission.info,
language: submission.language,
shared: submission.shared,
show_link: submission.showLink,
statistic_info: submission.statisticInfo,
ip: submission.ip,
contest: submission.contestId,
problem: submission.problemId,
can_unshare: submission.canUnshare,
} as Submission,
}
}
export function submitCode(data: SubmitCodePayload) {
return http.post("submission", data)
export async function submitCode(data: SubmitCodePayload) {
const response = await api2.post<unknown>("submissions", {
problemId: data.problem_id,
language: data.language,
code: data.code,
contestId: data.contest_id,
})
const created = createSubmissionResponseSchema.parse(response.data)
return {
error: null,
data: { submission_id: created.submissionId },
}
}
export function formatCode(data: { code: string; language: string }) {
return http.post<{ code: string }>("format_code", data)
// 格式化端点在 Phase 3 迁移Phase 2 保留原代码继续提交。
return Promise.resolve({ error: null, data: { code: data.code } })
}
export function getSubmissions(params: Partial<SubmissionListPayload>) {

View File

@@ -3,7 +3,6 @@ import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import {
formatCode,
getReaction,
submitCode,
updateProblemSetProgress,
} from "oj/api"
@@ -73,18 +72,6 @@ const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
immediate: false,
})
// ==================== AC后显示评论框 ====================
const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => {
const res = await getReaction(problem.value!.id)
if (res.data.mine === null) {
commentPanel.value = true
}
},
1500,
{ immediate: false },
)
const { start: goToProblemSetDelayed } = useTimeoutFn(
() => {
router.push({
@@ -216,11 +203,6 @@ watch(
// 3. 放烟花
celebrate()
// 4. 显示评价框
if (!contestID && !problemSetId) {
showCommentPanelDelayed()
}
if (problemSetId) {
// 延迟回到题单页面
goToProblemSetDelayed()

View File

@@ -1,8 +1,11 @@
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"
export function login(data: { username: string; password: string }) {
return http.post("login", data)
return api2.post("auth/login", data)
}
export function signup(data: {
@@ -14,11 +17,47 @@ export function signup(data: {
}
export function logout() {
return http.get("logout")
return api2.delete("auth/session")
}
export function getProfile(username: string = "") {
return http.get<Profile>("profile", { params: { username } })
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")
if (response.data === null) return { error: null, data: null }
const profile = userProfileSchema.parse(response.data)
return {
error: null,
data: {
id: profile.id,
user: {
id: profile.user.id,
username: profile.user.username,
real_name: profile.realName ?? "",
email: profile.user.email ?? "",
admin_type: profile.user.adminType as Profile["user"]["admin_type"],
problem_permission: profile.user.problemPermission,
create_time: profile.user.createTime as unknown as Date,
last_login: profile.user.lastLogin as unknown as Date,
open_api: profile.user.openApi,
is_disabled: profile.user.isDisabled,
class_name: profile.user.className,
},
real_name: profile.realName ?? "",
acm_problems_status: profile.acmProblemsStatus as Profile["acm_problems_status"],
avatar: profile.avatar,
blog: profile.blog as null,
mood: profile.mood ?? "",
github: profile.github ?? "",
school: profile.school ?? "",
major: profile.major ?? "",
language: profile.language ?? "",
accepted_number: profile.acceptedNumber,
submission_number: profile.submissionNumber,
},
}
}
export function getProblemTagList() {

View File

@@ -4,12 +4,10 @@ import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
import { useLoginSummaryStore } from "../store/loginSummary"
const userStore = useUserStore()
const configStore = useConfigStore()
const authStore = useAuthModalStore()
const loginSummaryStore = useLoginSummaryStore()
const {
loginModalOpen,
@@ -68,7 +66,6 @@ async function submit() {
if (!msg.value) {
authStore.closeLoginModal()
await userStore.getMyProfile()
loginSummaryStore.open()
}
}
})

View File

@@ -20,6 +20,8 @@ export interface WebSocketMessage {
export interface WebSocketConfig {
/** WebSocket 路径(如 '/ws/submission/' */
path: string
/** 完整 URL提供后覆盖 PUBLIC_WS_URL + path */
url?: string
/** 最大重连次数,默认 5 */
maxReconnectAttempts?: number
/** 重连延迟(毫秒),默认 1000 */
@@ -59,7 +61,8 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
constructor(config: WebSocketConfig) {
this.url = `${import.meta.env.PUBLIC_WS_URL}/${config.path}/`
this.url =
config.url ?? `${import.meta.env.PUBLIC_WS_URL}/${config.path}/`
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5
this.reconnectDelay = config.reconnectDelay ?? 1000
@@ -300,9 +303,13 @@ export interface SubmissionUpdate extends WebSocketMessage {
* 提交 WebSocket 连接管理类
*/
class SubmissionWebSocket extends BaseWebSocket<SubmissionUpdate> {
private pendingSubmissionId = ""
constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({
path: "submission",
url: `${protocol}//${window.location.host}/ws2/submissions`,
})
}
@@ -310,12 +317,24 @@ class SubmissionWebSocket extends BaseWebSocket<SubmissionUpdate> {
* 订阅特定提交的更新
*/
subscribe(submissionId: string) {
this.pendingSubmissionId = submissionId
const success = this.send({
type: "subscribe",
submission_id: submissionId,
})
if (!success) {
console.error("[WebSocket] 订阅失败: 连接未就绪")
if (success) this.pendingSubmissionId = ""
}
protected onConnected() {
if (!this.pendingSubmissionId) return
const submissionId = this.pendingSubmissionId
if (
this.send({
type: "subscribe",
submission_id: submissionId,
})
) {
this.pendingSubmissionId = ""
}
}
}

View File

@@ -0,0 +1,42 @@
import axios, { type AxiosRequestConfig } from "axios"
import type { ApiResponse } from "./http"
interface Api2Error {
error?: {
code?: string
message?: string
}
}
interface Api2Client {
get<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>>
post<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>>
}
const instance = axios.create({
baseURL: "/api2",
withCredentials: true,
})
instance.interceptors.response.use(
(response) => Promise.resolve({ error: null, data: response.data.data }),
(error) => {
const payload = error.response?.data as Api2Error | undefined
const code = payload?.error?.code ?? "network-error"
const message = payload?.error?.message ?? "Request failed"
const legacyMessage =
code === "invalid-credentials"
? "Invalid username or password"
: code === "account-disabled"
? "Your account has been disabled"
: message
return Promise.reject({ error: code, data: legacyMessage })
},
)
export default instance as unknown as Api2Client

View File

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

1301
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -40,5 +40,33 @@ services:
timeout: 3s
retries: 10
judge:
image: registry.cn-hongkong.aliyuncs.com/oj-image/judge:1.6.1
container_name: oj2-judge
restart: unless-stopped
read_only: true
cap_drop:
- SETPCAP
- MKNOD
- NET_BIND_SERVICE
- SYS_CHROOT
- SETFCAP
- FSETID
tmpfs:
- /tmp
ports:
- "8081:8080"
volumes:
- ../data/test_case:/test_case:ro
- ../data/judge_server/log:/log
- ../data/judge_server/run:/judger
environment:
SERVICE_URL: http://oj2-judge:8080
BACKEND_URL: http://host.docker.internal:3000/api/judge-server/heartbeat
TOKEN: ${OJ2_JUDGE_TOKEN:-oj2-dev-token}
extra_hosts:
- "host.docker.internal:host-gateway"
mem_limit: 512m
volumes:
oj2-pgdata:

View File

@@ -0,0 +1,45 @@
# Phase 2判题竖线
## 出口标准
一名学生可以在 OJ2 前端登录、读取公开题、提交代码,并通过 WebSocket 看到真实 JudgeServer 返回的判题结果。
## 已实现链路
```text
Vue → Hono → PostgreSQL submission → BullMQ/Redis → worker
→ QDU JudgeServer → PostgreSQL 事务写回 → Redis pub/sub
→ Bun WebSocket → Vue SubmissionMonitor
```
- 认证使用 Redis 中的 32 字节随机会话令牌和 `HttpOnly``SameSite=Lax` Cookie。
- 每个受保护请求都重新读取用户表;账号禁用会立即让 HTTP/WS 鉴权失效。
- 兼容 Django `pbkdf2_sha256`,验证走异步 `node:crypto.pbkdf2`;成功登录后透明改存 Bun `argon2id`
- 公开题详情隐藏测试点、答案、AST 规则,只返回模板中的学生可编辑区。
- BullMQ worker 负责模板拼接、JudgeServer HTTP 调用、C/Python AST 检查、结果聚合和统计事务。
- WebSocket 在订阅时重放数据库现状,覆盖“判题先完成、浏览器后连上”的竞态;轮询仍作为前端保底。
- JudgeServer 心跳保留镜像要求的旧 `{ error, data }` 信封,其余新接口使用 `{ data }` / `{ error: { code, message } }`
## 本地启动
```bash
docker compose -f docker/compose.dev.yml up -d
bun run seed:dev
bun run dev
```
默认开发账号是 `student / student123`,可用 `OJ2_DEV_USERNAME``OJ2_DEV_PASSWORD` 覆盖。真实测试点放在 `data/test_case/`,该目录只读挂载给 JudgeServer 且不进入 Git。
## 验收记录
- API 与共享契约 TypeScript 静态检查通过。
- Vue 生产构建通过,保留 Chrome 90 兼容构建。
- Django PBKDF2 正确/错误密码兼容检查通过AST C/Python WASM 加载与规则判定通过。
- 使用生产样本题 `1004` 的真实测试点完成 AC 和 WA题目计数、用户提交数和首次 AC 状态在同一事务中更新。
- WebSocket 实测收到 `pending → judging → finished`,并验证完成后再订阅仍会重放最终结果。
- 真实浏览器完成登录、读题、编辑、提交并显示“答案正确”。
- Compose 配置有效PostgreSQL、Redis 和 JudgeServer 健康检查均通过。
## Phase 3 边界
本阶段仅切换登录、本人资料、非比赛公开题详情、普通提交与本人提交详情。比赛、题目统计/点评、登录速报、成就、代码格式化等端点继续留给 Phase 3前端对应实时配置连接暂不启动。

View File

@@ -4,9 +4,11 @@
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "bun run --filter '*' dev",
"dev:api": "bun run --filter '@oj2/api' dev",
"dev": "bun run --parallel dev:api dev:worker dev:web",
"dev:api": "bun run --filter '@oj2/api' dev:http",
"dev:worker": "bun run --filter '@oj2/api' dev:worker",
"dev:web": "bun run --filter '@oj2/web' dev",
"seed:dev": "bun run --filter '@oj2/api' seed:dev",
"db:up": "docker compose -f docker/compose.dev.yml up -d",
"db:down": "docker compose -f docker/compose.dev.yml down"
},

View File

@@ -0,0 +1,39 @@
import { z } from "zod"
export const loginRequestSchema = z.object({
username: z.string().trim().min(1).max(150),
password: z.string().min(1).max(1024),
})
export const sessionUserSchema = z.object({
id: z.number().int(),
username: z.string(),
email: z.string().nullable(),
adminType: z.string(),
problemPermission: z.string(),
createTime: z.string().nullable(),
lastLogin: z.string().nullable(),
openApi: z.boolean(),
isDisabled: z.boolean(),
className: z.string().nullable(),
})
export const userProfileSchema = z.object({
id: z.number().int(),
user: sessionUserSchema,
realName: z.string().nullable(),
acmProblemsStatus: z.record(z.string(), z.unknown()),
avatar: z.string(),
blog: z.string().nullable(),
mood: z.string().nullable(),
github: z.string().nullable(),
school: z.string().nullable(),
major: z.string().nullable(),
language: z.string().nullable(),
acceptedNumber: z.number().int(),
submissionNumber: z.number().int(),
})
export type LoginRequest = z.infer<typeof loginRequestSchema>
export type SessionUser = z.infer<typeof sessionUserSchema>
export type UserProfile = z.infer<typeof userProfileSchema>

View File

@@ -1 +1,3 @@
export * from "./auth"
export * from "./problem"
export * from "./submission"

View File

@@ -11,3 +11,50 @@ export const problemSummarySchema = z.object({
})
export type ProblemSummary = z.infer<typeof problemSummarySchema>
export const problemDetailSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
description: z.string(),
inputDescription: z.string(),
outputDescription: z.string(),
samples: z.array(
z.object({
input: z.string(),
output: z.string(),
}),
),
hint: z.string().nullable(),
languages: z.array(z.string()),
template: z.record(z.string(), z.string()),
createTime: z.string(),
lastUpdateTime: z.string().nullable(),
timeLimit: z.number().int(),
memoryLimit: z.number().int(),
difficulty: z.string(),
source: z.string().nullable(),
prompt: z.string().nullable(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
statisticInfo: z.record(z.string(), z.unknown()),
shareSubmission: z.boolean(),
contestId: z.number().int().nullable(),
tags: z.array(z.string()),
createdBy: z.object({
id: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
}),
myStatus: z.number().int().nullable(),
myFailedCount: z.number().int(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
mermaidCode: z.string().nullable(),
flowchartData: z.record(z.string(), z.unknown()).nullable(),
flowchartHint: z.string().nullable(),
sqlConfig: z.record(z.string(), z.unknown()).nullable(),
sqlDisplay: z.record(z.string(), z.unknown()).nullable(),
})
export type ProblemDetail = z.infer<typeof problemDetailSchema>

View File

@@ -0,0 +1,63 @@
import { z } from "zod"
export const judgeStatusSchema = z.union([
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
z.literal(10),
])
export const createSubmissionRequestSchema = z.object({
problemId: z.number().int().positive(),
language: z.string().min(1).max(32),
code: z.string().min(1).max(1024 * 1024),
contestId: z.number().int().positive().optional(),
})
export const createSubmissionResponseSchema = z.object({
submissionId: z.string(),
})
export const submissionDetailSchema = z.object({
id: z.string(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
code: z.string(),
result: judgeStatusSchema,
info: z.unknown(),
language: z.string(),
shared: z.boolean(),
statisticInfo: z.record(z.string(), z.unknown()),
ip: z.string().nullable(),
contestId: z.number().int().nullable(),
problemId: z.number().int(),
showLink: z.boolean(),
canUnshare: z.boolean(),
})
export const submissionUpdateSchema = z.object({
type: z.literal("submission_update"),
submission_id: z.string(),
result: judgeStatusSchema,
status: z.enum(["pending", "judging", "finished", "error"]),
time_cost: z.number().optional(),
memory_cost: z.number().optional(),
score: z.number().optional(),
err_info: z.string().optional(),
})
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema
>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>