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:
@@ -55,6 +55,10 @@ export const config = {
|
|||||||
judgeServerToken: judgeServerToken(),
|
judgeServerToken: judgeServerToken(),
|
||||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||||
avatarDirectory: process.env.AVATAR_DIRECTORY ?? "data/avatar",
|
avatarDirectory: process.env.AVATAR_DIRECTORY ?? "data/avatar",
|
||||||
|
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
|
||||||
|
testCaseDirectory: process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
|
||||||
|
uploadDirectory: process.env.UPLOAD_DIRECTORY ?? "data/upload",
|
||||||
|
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
|
||||||
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
||||||
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
||||||
aiKey: process.env.AI_KEY ?? "",
|
aiKey: process.env.AI_KEY ?? "",
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import { redis } from "./redis"
|
|||||||
|
|
||||||
export const userEventChannel = "user:events"
|
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 {
|
interface UserEvent {
|
||||||
userId: number
|
userId: number
|
||||||
data: FlowchartUpdate | Record<string, unknown>
|
data: FlowchartUpdate | Record<string, unknown>
|
||||||
|
|||||||
@@ -68,12 +68,13 @@ const server = Bun.serve<SubmissionSocketData>({
|
|||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 })
|
return new Response("Not found", { status: 404 })
|
||||||
}
|
}
|
||||||
if (url.pathname === "/ws/submissions") {
|
if (url.pathname === "/ws/submissions" || url.pathname === "/ws/config") {
|
||||||
const user = await getRequestSessionUser(request)
|
const user = await getRequestSessionUser(request)
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||||
|
const kind = url.pathname === "/ws/config" ? "config" : "submissions"
|
||||||
if (
|
if (
|
||||||
bunServer.upgrade(request, {
|
bunServer.upgrade(request, {
|
||||||
data: { userId: user.id, username: user.username },
|
data: { userId: user.id, username: user.username, kind },
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
240
apps/api/src/routes/admin/conf.ts
Normal file
240
apps/api/src/routes/admin/conf.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import {
|
||||||
|
dashboardInfoSchema,
|
||||||
|
judgeServerListSchema,
|
||||||
|
judgeServerSchema,
|
||||||
|
orphanTestCaseSchema,
|
||||||
|
updateJudgeServerRequestSchema,
|
||||||
|
updateWebsiteConfigRequestSchema,
|
||||||
|
uploadImageResponseSchema,
|
||||||
|
websiteConfigSchema,
|
||||||
|
} from "@oj2/contract"
|
||||||
|
import { randomInt } from "node:crypto"
|
||||||
|
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
||||||
|
import { resolve } from "node:path"
|
||||||
|
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
|
import { config } from "../../config"
|
||||||
|
import { db, schema } from "../../db"
|
||||||
|
import { publishConfigUpdate } from "../../events"
|
||||||
|
import { failure, success } from "../../http"
|
||||||
|
import { getWebsiteOptions } from "../../services/options"
|
||||||
|
import { todayStart } from "../helpers"
|
||||||
|
|
||||||
|
export const adminConfRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
/** 心跳 6 秒内算在线,与旧 DashboardInfoAPI 的判活口径一致 */
|
||||||
|
const HEARTBEAT_ALIVE_SECONDS = 6
|
||||||
|
|
||||||
|
function aliveSince() {
|
||||||
|
return new Date(Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判活必须解析成时间再比,不能直接比字符串。
|
||||||
|
* 库里取出来的 timestamptz 形如 `2026-08-07 13:42:50.729+00`(空格分隔),
|
||||||
|
* 而 toISOString() 是 `2026-08-07T13:42:44.000Z`(T 分隔)。按字典序空格(0x20) < 'T'(0x54),
|
||||||
|
* 于是同一天的心跳永远小于阈值,**所有判题机都会被标成离线**。
|
||||||
|
*/
|
||||||
|
function isAlive(lastHeartbeat: string) {
|
||||||
|
return Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 网站配置
|
||||||
|
|
||||||
|
/** camelCase 契约 ←→ options 表里的 snake_case key */
|
||||||
|
const OPTION_KEYS = {
|
||||||
|
websiteBaseUrl: "website_base_url",
|
||||||
|
websiteName: "website_name",
|
||||||
|
websiteNameShortcut: "website_name_shortcut",
|
||||||
|
websiteFooter: "website_footer",
|
||||||
|
allowRegister: "allow_register",
|
||||||
|
submissionListShowAll: "submission_list_show_all",
|
||||||
|
classList: "class_list",
|
||||||
|
enableMaxkb: "enable_maxkb",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
||||||
|
const options = await getWebsiteOptions()
|
||||||
|
return success(c, websiteConfigSchema.parse({
|
||||||
|
websiteBaseUrl: options.website_base_url,
|
||||||
|
websiteName: options.website_name,
|
||||||
|
websiteNameShortcut: options.website_name_shortcut,
|
||||||
|
websiteFooter: options.website_footer,
|
||||||
|
allowRegister: options.allow_register,
|
||||||
|
submissionListShowAll: options.submission_list_show_all,
|
||||||
|
classList: options.class_list,
|
||||||
|
enableMaxkb: options.enable_maxkb,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
|
||||||
|
const parsed = updateWebsiteConfigRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!parsed.success) {
|
||||||
|
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
||||||
|
}
|
||||||
|
for (const [field, key] of Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]) {
|
||||||
|
const value = parsed.data[field]
|
||||||
|
await db.insert(schema.optionsSysoptions).values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: schema.optionsSysoptions.key, set: { value } })
|
||||||
|
// 广播给所有开着页面的人,改完立刻生效不必刷新,对齐旧 push_config_update。
|
||||||
|
// 推的是 options 表里的 snake_case key —— 前端 configStore.config 用的就是这套键名。
|
||||||
|
await publishConfigUpdate(key, value)
|
||||||
|
}
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 判题机
|
||||||
|
|
||||||
|
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
|
||||||
|
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
|
||||||
|
return success(c, judgeServerListSchema.parse({
|
||||||
|
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
|
||||||
|
token: config.judgeServerToken,
|
||||||
|
servers: rows.map((row) => judgeServerSchema.parse({
|
||||||
|
...row,
|
||||||
|
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
|
||||||
|
const parsed = updateJudgeServerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required")
|
||||||
|
const updated = await db.update(schema.judgeServer)
|
||||||
|
.set({ isDisabled: parsed.data.isDisabled })
|
||||||
|
.where(eq(schema.judgeServer.id, Number(c.req.param("id"))))
|
||||||
|
.returning({ id: schema.judgeServer.id })
|
||||||
|
if (updated.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
|
||||||
|
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。
|
||||||
|
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
|
||||||
|
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminConfRoutes.delete("/judge-servers/:hostname", requireSuperAdmin, async (c) => {
|
||||||
|
const deleted = await db.delete(schema.judgeServer)
|
||||||
|
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
|
||||||
|
.returning({ id: schema.judgeServer.id })
|
||||||
|
if (deleted.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 孤儿测试用例
|
||||||
|
|
||||||
|
const TEST_CASE_ID_RE = /^[a-zA-Z0-9]{32}$/
|
||||||
|
|
||||||
|
/** 磁盘上有、但没有任何题目引用的用例目录 */
|
||||||
|
async function orphanTestCaseIds() {
|
||||||
|
const [onDisk, inDb] = await Promise.all([
|
||||||
|
readdir(config.testCaseDirectory).catch(() => [] as string[]),
|
||||||
|
db.select({ id: schema.problem.testCaseId }).from(schema.problem),
|
||||||
|
])
|
||||||
|
const referenced = new Set(inDb.map((row) => row.id))
|
||||||
|
return onDisk.filter((name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
||||||
|
const ids = await orphanTestCaseIds()
|
||||||
|
const rows = await Promise.all(ids.map(async (id) => {
|
||||||
|
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
|
||||||
|
return orphanTestCaseSchema.parse({ id, createTime: info ? info.mtimeMs / 1000 : 0 })
|
||||||
|
}))
|
||||||
|
return success(c, rows)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
||||||
|
const requested = c.req.query("id")
|
||||||
|
const orphans = await orphanTestCaseIds()
|
||||||
|
// 指定 id 时也必须先确认它确实是孤儿。否则一个手抖的 id 就能删掉在用题目的测试数据,
|
||||||
|
// 而测试数据没有别处备份 —— 旧后端这里是不校验的。
|
||||||
|
const targets = requested ? orphans.filter((id) => id === requested) : orphans
|
||||||
|
if (requested && targets.length === 0) {
|
||||||
|
return failure(c, 404, "not-an-orphan", "该用例目录不存在或仍被题目引用,未删除")
|
||||||
|
}
|
||||||
|
for (const id of targets) {
|
||||||
|
await rm(resolve(config.testCaseDirectory, id), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
return success(c, { deleted: targets.length })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 概览 / 随机点名
|
||||||
|
|
||||||
|
adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const [[users], [submissions], [contests], [servers]] = await Promise.all([
|
||||||
|
db.select({ value: count() }).from(schema.user),
|
||||||
|
db.select({ value: count() }).from(schema.submission)
|
||||||
|
.where(gte(schema.submission.createTime, todayStart())),
|
||||||
|
db.select({ value: count() }).from(schema.contest)
|
||||||
|
.where(not(sql`${schema.contest.endTime} < ${now}`)),
|
||||||
|
db.select({ value: count() }).from(schema.judgeServer)
|
||||||
|
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
||||||
|
])
|
||||||
|
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
||||||
|
return success(c, dashboardInfoSchema.parse({
|
||||||
|
userCount: users?.value ?? 0,
|
||||||
|
todaySubmissionCount: submissions?.value ?? 0,
|
||||||
|
recentContestCount: contests?.value ?? 0,
|
||||||
|
judgeServerCount: servers?.value ?? 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
|
||||||
|
// 传的是**班级前缀**(形如 ks251),不是班级号 —— 前端输入框写的就是「班级前缀」,
|
||||||
|
// 拿到结果后按这个前缀 split 取姓名。这里按前缀匹配,与旧 istartswith 一致,
|
||||||
|
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
|
||||||
|
const classroom = c.req.query("classroom")?.trim()
|
||||||
|
if (!classroom) return failure(c, 400, "invalid-request", "需要班级号")
|
||||||
|
const rows = await db.select({ username: schema.user.username }).from(schema.user)
|
||||||
|
.where(ilike(schema.user.username, `${classroom}%`))
|
||||||
|
.orderBy(sql`random()`).limit(10)
|
||||||
|
return success(c, rows.map((row) => row.username))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 富文本图片上传
|
||||||
|
|
||||||
|
const IMAGE_SUFFIXES = [".gif", ".jpg", ".jpeg", ".bmp", ".png"]
|
||||||
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simditor 富文本编辑器的图片上传。响应形状是编辑器约定的
|
||||||
|
* `{success, msg, filePath}`,不是本项目的 `{data}` 信封 —— 但外面仍然包一层 data,
|
||||||
|
* 由前端 api 层解包,这样它和其它接口共用同一个错误处理拦截器。
|
||||||
|
*/
|
||||||
|
adminConfRoutes.post("/upload-image", requireSuperAdmin, async (c) => {
|
||||||
|
const form = await c.req.formData().catch(() => null)
|
||||||
|
const image = form?.get("image")
|
||||||
|
if (!(image instanceof File)) {
|
||||||
|
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload failed", filePath: "" }))
|
||||||
|
}
|
||||||
|
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
|
||||||
|
if (!IMAGE_SUFFIXES.includes(suffix)) {
|
||||||
|
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Unsupported file format", filePath: "" }))
|
||||||
|
}
|
||||||
|
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
|
||||||
|
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
|
||||||
|
if (image.size > MAX_IMAGE_BYTES) {
|
||||||
|
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "图片不能超过 10MB", filePath: "" }))
|
||||||
|
}
|
||||||
|
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
|
||||||
|
const name = `${randomFileName()}${suffix}`
|
||||||
|
try {
|
||||||
|
await mkdir(config.uploadDirectory, { recursive: true })
|
||||||
|
await Bun.write(resolve(config.uploadDirectory, name), image)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save uploaded image", error)
|
||||||
|
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload Error", filePath: "" }))
|
||||||
|
}
|
||||||
|
return success(c, uploadImageResponseSchema.parse({
|
||||||
|
success: true,
|
||||||
|
msg: "Success",
|
||||||
|
filePath: `${config.uploadUriPrefix}/${name}`,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function randomFileName() {
|
||||||
|
return Array.from({ length: 10 }, () =>
|
||||||
|
"abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { AppEnv } from "../../auth/middleware"
|
|||||||
import { adminAccountRoutes } from "./account"
|
import { adminAccountRoutes } from "./account"
|
||||||
import { adminAchievementRoutes } from "./achievement"
|
import { adminAchievementRoutes } from "./achievement"
|
||||||
import { adminAiRoutes } from "./ai"
|
import { adminAiRoutes } from "./ai"
|
||||||
|
import { adminConfRoutes } from "./conf"
|
||||||
import { adminAnnouncementRoutes } from "./announcement"
|
import { adminAnnouncementRoutes } from "./announcement"
|
||||||
import { adminTutorialRoutes } from "./tutorial"
|
import { adminTutorialRoutes } from "./tutorial"
|
||||||
|
|
||||||
@@ -20,5 +21,6 @@ export const adminRoutes = new Hono<AppEnv>()
|
|||||||
adminRoutes.route("/", adminAccountRoutes)
|
adminRoutes.route("/", adminAccountRoutes)
|
||||||
adminRoutes.route("/", adminAchievementRoutes)
|
adminRoutes.route("/", adminAchievementRoutes)
|
||||||
adminRoutes.route("/", adminAiRoutes)
|
adminRoutes.route("/", adminAiRoutes)
|
||||||
|
adminRoutes.route("/", adminConfRoutes)
|
||||||
adminRoutes.route("/", adminAnnouncementRoutes)
|
adminRoutes.route("/", adminAnnouncementRoutes)
|
||||||
adminRoutes.route("/", adminTutorialRoutes)
|
adminRoutes.route("/", adminTutorialRoutes)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
} from "./judge/events"
|
} from "./judge/events"
|
||||||
import { JudgeStatus } from "./judge/status"
|
import { JudgeStatus } from "./judge/status"
|
||||||
import { createSubscriberRedis } from "./redis"
|
import { createSubscriberRedis } from "./redis"
|
||||||
import { parseUserEvent, userEventChannel, userEventTopic } from "./events"
|
import { configTopic, configUpdateChannel, parseUserEvent, userEventChannel, userEventTopic } from "./events"
|
||||||
|
|
||||||
export interface SubmissionSocketData {
|
export interface SubmissionSocketData {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
|
/** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分两条通道 */
|
||||||
|
kind: "submissions" | "config"
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectValue(value: unknown): Record<string, unknown> {
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
@@ -25,6 +27,10 @@ function objectValue(value: unknown): Record<string, unknown> {
|
|||||||
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
|
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
|
||||||
return {
|
return {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
|
if (ws.data.kind === "config") {
|
||||||
|
ws.subscribe(configTopic)
|
||||||
|
return
|
||||||
|
}
|
||||||
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
||||||
ws.subscribe(userEventTopic(ws.data.userId))
|
ws.subscribe(userEventTopic(ws.data.userId))
|
||||||
},
|
},
|
||||||
@@ -32,6 +38,10 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
|||||||
void handleMessage(ws, String(message))
|
void handleMessage(ws, String(message))
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
|
if (ws.data.kind === "config") {
|
||||||
|
ws.unsubscribe(configTopic)
|
||||||
|
return
|
||||||
|
}
|
||||||
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
||||||
ws.unsubscribe(userEventTopic(ws.data.userId))
|
ws.unsubscribe(userEventTopic(ws.data.userId))
|
||||||
},
|
},
|
||||||
@@ -135,6 +145,11 @@ export async function bridgeSubmissionEvents(
|
|||||||
) {
|
) {
|
||||||
const subscriber = createSubscriberRedis()
|
const subscriber = createSubscriberRedis()
|
||||||
subscriber.on("message", (channel, raw) => {
|
subscriber.on("message", (channel, raw) => {
|
||||||
|
if (channel === configUpdateChannel) {
|
||||||
|
// 配置广播不校验用户:内容就是站点公开配置本身,且所有连着的人都该收到
|
||||||
|
server.publish(configTopic, raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (channel === userEventChannel) {
|
if (channel === userEventChannel) {
|
||||||
const event = parseUserEvent(raw)
|
const event = parseUserEvent(raw)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
@@ -177,6 +192,6 @@ export async function bridgeSubmissionEvents(
|
|||||||
subscriber.on("error", (error) => {
|
subscriber.on("error", (error) => {
|
||||||
console.error("Submission event subscriber error", error)
|
console.error("Submission event subscriber error", error)
|
||||||
})
|
})
|
||||||
await subscriber.subscribe(submissionUpdateChannel, userEventChannel)
|
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel)
|
||||||
return subscriber
|
return subscriber
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import type {
|
|||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
export function getBaseInfo() {
|
export function getBaseInfo() {
|
||||||
return http.get("admin/dashboard_info")
|
return legacyResponse(api2.get("admin/dashboard"))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function randomUser10(classroom: string) {
|
export function randomUser10(classroom: string) {
|
||||||
return http.get("admin/random_user", { params: { classroom } })
|
return legacyResponse(
|
||||||
|
api2.get("admin/random-usernames", { params: { classroom } }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProblemList(
|
export async function getProblemList(
|
||||||
@@ -185,11 +187,14 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
|
|||||||
export async function uploadImage(file: File): Promise<string> {
|
export async function uploadImage(file: File): Promise<string> {
|
||||||
const form = new window.FormData()
|
const form = new window.FormData()
|
||||||
form.append("image", file)
|
form.append("image", file)
|
||||||
// 该端点不走 { error, data } 信封,直接返回上传结果
|
const res = await api2.post<{
|
||||||
const res = (await http.post("admin/upload_image", form, {
|
success: boolean
|
||||||
|
filePath: string
|
||||||
|
msg: string
|
||||||
|
}>("admin/upload-image", form, {
|
||||||
headers: { "content-type": "multipart/form-data" },
|
headers: { "content-type": "multipart/form-data" },
|
||||||
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
|
})
|
||||||
return res.success ? res.file_path : ""
|
return res.data.success ? res.data.filePath : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传测试用例;SQL 题的压缩包是 1.sql..N.sql(每个文件一个测试点的建表+数据脚本)
|
// 上传测试用例;SQL 题的压缩包是 1.sql..N.sql(每个文件一个测试点的建表+数据脚本)
|
||||||
@@ -268,27 +273,38 @@ export function addProblemForContest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWebsite() {
|
export function getWebsite() {
|
||||||
return http.get<WebsiteConfig>("admin/website")
|
return legacyResponse<WebsiteConfig>(api2.get("admin/website"))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editWebsite(data: WebsiteConfig) {
|
export function editWebsite(data: WebsiteConfig) {
|
||||||
return http.post("admin/website", data)
|
return api2.post("admin/website", {
|
||||||
|
websiteBaseUrl: data.website_base_url,
|
||||||
|
websiteName: data.website_name,
|
||||||
|
websiteNameShortcut: data.website_name_shortcut,
|
||||||
|
websiteFooter: data.website_footer,
|
||||||
|
allowRegister: data.allow_register,
|
||||||
|
submissionListShowAll: data.submission_list_show_all,
|
||||||
|
classList: data.class_list,
|
||||||
|
enableMaxkb: data.enable_maxkb,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listInvalidTestcases() {
|
export function listInvalidTestcases() {
|
||||||
return http.get("admin/prune_test_case")
|
return legacyResponse(api2.get("admin/orphan-test-cases"))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pruneInvalidTestcases(id?: string) {
|
export function pruneInvalidTestcases(id?: string) {
|
||||||
return http.delete("admin/prune_test_case", { params: { id } })
|
return api2.delete("admin/orphan-test-cases", { params: { id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getJudgeServer() {
|
export function getJudgeServer() {
|
||||||
return http.get<{ token: string; servers: Server[] }>("admin/judge_server")
|
return legacyResponse<{ token: string; servers: Server[] }>(
|
||||||
|
api2.get("admin/judge-servers"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteJudgeServer(hostname: string) {
|
export function deleteJudgeServer(hostname: string) {
|
||||||
return http.delete("admin/judge_server", { params: { hostname } })
|
return api2.delete(`admin/judge-servers/${encodeURIComponent(hostname)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||||
|
|||||||
@@ -517,8 +517,10 @@ export interface ConfigUpdate extends WebSocketMessage {
|
|||||||
*/
|
*/
|
||||||
class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
|
class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
super({
|
super({
|
||||||
path: "config",
|
path: "config",
|
||||||
|
url: `${protocol}//${window.location.host}/ws2/config`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,3 +225,58 @@ export const deleteUsersRequestSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const resetPasswordResponseSchema = z.object({ password: z.string() })
|
export const resetPasswordResponseSchema = z.object({ password: z.string() })
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 站点配置 / 运维
|
||||||
|
|
||||||
|
export const updateWebsiteConfigRequestSchema = z.object({
|
||||||
|
websiteBaseUrl: z.string().max(256),
|
||||||
|
websiteName: z.string().trim().min(1).max(64),
|
||||||
|
websiteNameShortcut: z.string().trim().min(1).max(32),
|
||||||
|
websiteFooter: z.string().max(1024 * 64),
|
||||||
|
allowRegister: z.boolean(),
|
||||||
|
submissionListShowAll: z.boolean(),
|
||||||
|
classList: z.array(z.string()),
|
||||||
|
enableMaxkb: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const judgeServerSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
hostname: z.string(),
|
||||||
|
ip: z.string().nullable(),
|
||||||
|
judgerVersion: z.string(),
|
||||||
|
cpuCore: z.number().int(),
|
||||||
|
memoryUsage: z.number(),
|
||||||
|
cpuUsage: z.number(),
|
||||||
|
lastHeartbeat: z.string(),
|
||||||
|
createTime: z.string(),
|
||||||
|
taskNumber: z.number().int(),
|
||||||
|
serviceUrl: z.string().nullable(),
|
||||||
|
isDisabled: z.boolean(),
|
||||||
|
/** 心跳在 6 秒内才算在线,与 dashboard 的判活口径一致 */
|
||||||
|
status: z.enum(["normal", "abnormal"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const judgeServerListSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
servers: z.array(judgeServerSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateJudgeServerRequestSchema = z.object({ isDisabled: z.boolean() })
|
||||||
|
|
||||||
|
export const orphanTestCaseSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
createTime: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const dashboardInfoSchema = z.object({
|
||||||
|
userCount: z.number().int(),
|
||||||
|
recentContestCount: z.number().int(),
|
||||||
|
todaySubmissionCount: z.number().int(),
|
||||||
|
judgeServerCount: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const uploadImageResponseSchema = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
msg: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user