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>
198 lines
6.2 KiB
TypeScript
198 lines
6.2 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 { 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
|
||
}
|