Files
OJ2/apps/api/src/websocket.ts
yuetsh 8c00cdc947 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>
2026-08-07 01:25:36 -06:00

183 lines
5.7 KiB
TypeScript

import { flowchartUpdateSchema, 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"
import { parseUserEvent, userEventChannel, userEventTopic } from "./events"
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))
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))
},
}
}
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) {
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
}
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 === 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
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, userEventChannel)
return subscriber
}