Files
OJ2/apps/api/src/websocket.ts
yuetsh bc54fbff98 feat(阶段4): 站点配置 / 判题机 / 孤儿用例 / 概览 / 图片上传
GET/POST          admin/website
  GET               admin/judge-servers
  PUT               admin/judge-servers/:id
  DELETE            admin/judge-servers/:hostname
  GET/DELETE        admin/orphan-test-cases
  GET               admin/dashboard
  GET               admin/random-usernames
  POST              admin/upload-image

顺带补上配置广播:旧后端改配置会经 WebSocket 推给所有开着页面的人,改完立刻生效。
新后端只服务 /ws/submissions,前端的 ConfigWebSocket 还连着旧 Django Channels。
现在加了 /ws/config 通道(同一个 Bun.serve 只能挂一个 handler,用 socket data 上的
kind 区分),前端 ConfigWebSocket 改走 /ws2/config。

几处判断:

- **判活不能比字符串**。库里 timestamptz 形如 `2026-08-07 13:42:50+00`(空格分隔),
  toISOString() 是 `...T13:42:44.000Z`(T 分隔),字典序空格 < 'T',同一天的心跳永远
  小于阈值 —— 所有判题机都会显示离线。实测确实复现(dashboard 说 1 台在线、列表却
  两台全 abnormal),已改为 Date.parse 后比较。
- 删指定的孤儿用例时**先确认它确实是孤儿**。旧后端不校验,一个手抖的 id 就能删掉在用
  题目的测试数据,而测试数据没有别处备份。
- 图片上传的文件名完全由服务端生成,不带用户提供的任何一段;另加 10MB 上限 ——
  旧后端靠 nginx 兜,但机房那台机器盘写满之后判题也会一起挂。
- 停用判题机后不再 process_pending_task():任务在 BullMQ 里排着,worker 恢复自己接着
  消费,不存在旧自研分发器那种「没有新提交就一直 waiting」的问题。
- dashboard 不再下发 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过。

实测:学生 403;配置读写回读 + oj 侧 /site 同步生效 + 还原;判题机列表带 token、
状态判定正确(一台 normal 一台 abnormal,与 dashboard 计数一致);删不存在 404;
删非孤儿用例 404;随机点名缺班级号 400。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:43:38 -06:00

198 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { configTopic, configUpdateChannel, parseUserEvent, userEventChannel, userEventTopic } from "./events"
export interface SubmissionSocketData {
userId: number
username: string
/** 同一个 Bun.serve 只能挂一个 websocket handler用它区分两条通道 */
kind: "submissions" | "config"
}
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) {
if (ws.data.kind === "config") {
ws.subscribe(configTopic)
return
}
ws.subscribe(userSubmissionTopic(ws.data.userId))
ws.subscribe(userEventTopic(ws.data.userId))
},
message(ws, message) {
void handleMessage(ws, String(message))
},
close(ws) {
if (ws.data.kind === "config") {
ws.unsubscribe(configTopic)
return
}
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 === configUpdateChannel) {
// 配置广播不校验用户:内容就是站点公开配置本身,且所有连着的人都该收到
server.publish(configTopic, raw)
return
}
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, configUpdateChannel)
return subscriber
}