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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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