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

@@ -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 ?? "",

View File

@@ -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>

View File

@@ -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

View 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("")
}

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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) {

View File

@@ -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`,
}) })
} }

View File

@@ -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(),
})