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>
61 lines
1.7 KiB
TypeScript
61 lines
1.7 KiB
TypeScript
import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
|
||
|
||
import { redis } from "./redis"
|
||
|
||
export const userEventChannel = "user:events"
|
||
|
||
/**
|
||
* 站点配置变更广播。旧后端的 `utils/websocket.push_config_update`:
|
||
* 超管改了配置,所有开着页面的人立刻生效,不必刷新。
|
||
* 这是全站广播,不分用户,所以是一个固定 topic 而不是 per-user。
|
||
*/
|
||
export const configUpdateChannel = "config:updates"
|
||
export const configTopic = "events:config"
|
||
|
||
export async function publishConfigUpdate(key: string, value: unknown) {
|
||
await redis.publish(configUpdateChannel, JSON.stringify({ type: "config_update", key, value }))
|
||
}
|
||
|
||
interface UserEvent {
|
||
userId: number
|
||
data: FlowchartUpdate | Record<string, unknown>
|
||
}
|
||
|
||
interface AchievementNotification {
|
||
id: number
|
||
name: string
|
||
description: string
|
||
icon: string
|
||
rarity: string
|
||
kind: "achievement" | "badge"
|
||
}
|
||
|
||
export function userEventTopic(userId: number) {
|
||
return `events:user:${userId}`
|
||
}
|
||
|
||
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
|
||
await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) }))
|
||
}
|
||
|
||
export async function publishAchievementNotification(
|
||
userId: number,
|
||
achievements: AchievementNotification[],
|
||
) {
|
||
if (!achievements.length) return
|
||
await redis.publish(userEventChannel, JSON.stringify({
|
||
userId,
|
||
data: { type: "achievement_unlocked", achievements },
|
||
}))
|
||
}
|
||
|
||
export function parseUserEvent(raw: string): UserEvent | null {
|
||
try {
|
||
const value = JSON.parse(raw) as UserEvent
|
||
if (!Number.isInteger(value.userId) || !value.data || typeof value.data !== "object") return null
|
||
return value
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|