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>
This commit is contained in:
2026-08-07 07:43:38 -06:00
parent 7e1a782747
commit bc54fbff98
9 changed files with 363 additions and 16 deletions

View File

@@ -9,11 +9,13 @@ import {
} from "./judge/events"
import { JudgeStatus } from "./judge/status"
import { createSubscriberRedis } from "./redis"
import { parseUserEvent, userEventChannel, userEventTopic } from "./events"
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> {
@@ -25,6 +27,10 @@ function objectValue(value: unknown): 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))
},
@@ -32,6 +38,10 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
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))
},
@@ -135,6 +145,11 @@ export async function bridgeSubmissionEvents(
) {
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
@@ -177,6 +192,6 @@ export async function bridgeSubmissionEvents(
subscriber.on("error", (error) => {
console.error("Submission event subscriber error", error)
})
await subscriber.subscribe(submissionUpdateChannel, userEventChannel)
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel)
return subscriber
}