Compare commits
17
Commits
9d271942aa
...
f8b04af4ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b04af4ea | ||
|
|
83b9d23c68 | ||
|
|
6b2da41882 | ||
|
|
f49834f19f | ||
|
|
e17da63fc6 | ||
|
|
b105fe3cbd | ||
|
|
7a390323fe | ||
|
|
6406794971 | ||
|
|
925cecb389 | ||
|
|
f2ca34fd4b | ||
|
|
c43da01ff2 | ||
|
|
09030240b8 | ||
|
|
04c0ccd304 | ||
|
|
b38ad8ccee | ||
|
|
ca0dbbf0c5 | ||
|
|
fb240d4254 | ||
|
|
5813981bb1 |
No files matched your search
@@ -58,6 +58,7 @@ bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级
|
||||
cd apps/web && bun run type-check # 前端类型检查
|
||||
cd apps/web && bun run build # 前端构建
|
||||
bun run fmt # Prettier,全仓一把(只在根目录有)
|
||||
bun run lint # oxlint,全仓一把,有一条 warning 就算失败
|
||||
```
|
||||
|
||||
**格式化是全仓一套 Prettier**,配置只有根目录的 `.prettierrc.toml`(`semi=false`,
|
||||
|
||||
+108
-33
@@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
import { isCompiled, pathBase } from "./runtime"
|
||||
|
||||
/**
|
||||
@@ -38,11 +40,79 @@ function loadRepoRootEnv() {
|
||||
loadRepoRootEnv()
|
||||
|
||||
/**
|
||||
* 判题机 token。对齐旧后端 `options/options.py:93`:
|
||||
* token = os.environ.get("JUDGE_SERVER_TOKEN"); return token if token else rand_str()
|
||||
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
|
||||
* 也不要在仓库里写死一个人人都知道的弱默认值。
|
||||
* 环境变量在**启动时**一次解析完,有错就列出全部问题退出,不带病起来。
|
||||
*
|
||||
* 原来是 `Number(env ?? 默认值)` 散写:`JUDGE_CONCURRENCY=two` 得到 NaN,BullMQ 拿着
|
||||
* NaN 当并发数照样起;`DATABASE_URL` 漏配就静默连上开发库的默认账号。这类错都不在
|
||||
* 启动时报,而是到第一个请求、第一条提交才以莫名其妙的形式冒出来。
|
||||
*
|
||||
* **编译形态(线上)下,库、Redis、判题机 token 必须显式给**:dev 的默认值指向本机
|
||||
* 5433 / 6380,放到容器里要么连不上、要么连错,都不如当场报错。
|
||||
* 空字符串一律按「没设」处理 —— compose 里 `${X:-}` 展开出来就是空串。
|
||||
*/
|
||||
const blankAsUnset = (value: unknown) => (value === "" ? undefined : value)
|
||||
const text = (fallback: string) =>
|
||||
z.preprocess(blankAsUnset, z.string().default(fallback))
|
||||
const positiveInt = (fallback: number) =>
|
||||
z.preprocess(
|
||||
blankAsUnset,
|
||||
z.coerce.number().int().positive().default(fallback),
|
||||
)
|
||||
const requiredWhenCompiled = (devDefault: string) =>
|
||||
isCompiled
|
||||
? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" }))
|
||||
: text(devDefault)
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: positiveInt(3000),
|
||||
DATABASE_URL: requiredWhenCompiled(
|
||||
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
|
||||
),
|
||||
REDIS_URL: requiredWhenCompiled("redis://localhost:6380"),
|
||||
SESSION_TTL_SECONDS: positiveInt(7 * 24 * 60 * 60),
|
||||
COOKIE_SECURE: z.preprocess(
|
||||
blankAsUnset,
|
||||
z.enum(["true", "false"]).optional(),
|
||||
),
|
||||
JUDGE_SERVER_URL: text("http://localhost:8081"),
|
||||
// dev 不设时生成一次性随机值,见 judgeServerToken()
|
||||
JUDGE_SERVER_TOKEN: isCompiled
|
||||
? z.preprocess(blankAsUnset, z.string({ error: "编译形态下必须设置" }))
|
||||
: z.preprocess(blankAsUnset, z.string().optional()),
|
||||
JUDGE_CONCURRENCY: positiveInt(2),
|
||||
AVATAR_DIRECTORY: text("data/avatar"),
|
||||
TEST_CASE_DIRECTORY: text("data/test_case"),
|
||||
UPLOAD_DIRECTORY: text("data/upload"),
|
||||
HITOKOTO_DIRECTORY: text("data/hitokoto"),
|
||||
ALLOWED_WS_ORIGINS: text(""),
|
||||
UPLOAD_URI_PREFIX: text("/public/upload"),
|
||||
AVATAR_URI_PREFIX: text("/public/avatar"),
|
||||
AI_BASE_URL: z.preprocess(
|
||||
blankAsUnset,
|
||||
z.url().default("https://api.deepseek.com"),
|
||||
),
|
||||
AI_PROVIDER: text("deepseek"),
|
||||
AI_KEY: text(""),
|
||||
AI_MODEL: text("deepseek-flash"),
|
||||
AI_HINT_DIAGNOSE: z.preprocess(blankAsUnset, z.enum(["1", "0"]).optional()),
|
||||
RUFF_PATH: text("ruff"),
|
||||
CLANG_FORMAT_PATH: text("clang-format"),
|
||||
})
|
||||
|
||||
function parseEnv() {
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
if (parsed.success) return parsed.data
|
||||
console.error(
|
||||
"[config] 环境变量有误,拒绝启动:\n" +
|
||||
parsed.error.issues
|
||||
.map((issue) => ` ${issue.path.join(".")}: ${issue.message}`)
|
||||
.join("\n"),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const env = parseEnv()
|
||||
|
||||
/**
|
||||
* 相对路径按 `pathBase` 解析(开发时是仓库根,编译后是 cwd),基准的取舍见 runtime.ts。
|
||||
*
|
||||
@@ -52,9 +122,14 @@ function repoPath(value: string) {
|
||||
return isAbsolute(value) ? value : resolve(pathBase, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题机 token。对齐旧后端 `options/options.py:93`:
|
||||
* token = os.environ.get("JUDGE_SERVER_TOKEN"); return token if token else rand_str()
|
||||
* env 缺失时生成随机值 fail-safe —— 宁可判题机连不上(启动时有明显告警),
|
||||
* 也不要在仓库里写死一个人人都知道的弱默认值。编译形态下缺了直接拒绝启动(见上面)。
|
||||
*/
|
||||
function judgeServerToken() {
|
||||
const fromEnv = process.env.JUDGE_SERVER_TOKEN
|
||||
if (fromEnv) return fromEnv
|
||||
if (env.JUDGE_SERVER_TOKEN) return env.JUDGE_SERVER_TOKEN
|
||||
console.warn(
|
||||
"[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。" +
|
||||
"判题机将无法通过鉴权,本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN," +
|
||||
@@ -63,49 +138,49 @@ function judgeServerToken() {
|
||||
return randomBytes(32).toString("hex")
|
||||
}
|
||||
|
||||
// AI 是可选功能(.env.example 写明留空即关),所以只告警不退出 —— 但要在启动时说,
|
||||
// 别等到学生点了「AI 提示」才从一条 401 里看出来
|
||||
if (!env.AI_KEY && process.argv[2] !== "sql-child") {
|
||||
console.warn("[config] AI_KEY 未设置,AI 分析 / 提示 / 流程图评分都不可用。")
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
|
||||
port: env.PORT,
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
redisUrl: env.REDIS_URL,
|
||||
sessionCookie: "oj2_session",
|
||||
sessionTtlSeconds: Number(
|
||||
process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60,
|
||||
),
|
||||
secureCookies: process.env.COOKIE_SECURE === "true",
|
||||
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
||||
sessionTtlSeconds: env.SESSION_TTL_SECONDS,
|
||||
secureCookies: env.COOKIE_SECURE === "true",
|
||||
judgeServerUrl: env.JUDGE_SERVER_URL,
|
||||
judgeServerToken: judgeServerToken(),
|
||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
|
||||
judgeConcurrency: env.JUDGE_CONCURRENCY,
|
||||
avatarDirectory: repoPath(env.AVATAR_DIRECTORY),
|
||||
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
|
||||
testCaseDirectory: repoPath(
|
||||
process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
|
||||
),
|
||||
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
|
||||
testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY),
|
||||
uploadDirectory: repoPath(env.UPLOAD_DIRECTORY),
|
||||
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
|
||||
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
|
||||
hitokotoDirectory: repoPath(
|
||||
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
|
||||
),
|
||||
hitokotoDirectory: repoPath(env.HITOKOTO_DIRECTORY),
|
||||
/**
|
||||
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
|
||||
* 同源本来就放行,只有前后端分处不同域名时才需要配。
|
||||
*/
|
||||
allowedWebSocketOrigins: (process.env.ALLOWED_WS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
allowedWebSocketOrigins: env.ALLOWED_WS_ORIGINS.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
|
||||
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
||||
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
||||
uploadUriPrefix: env.UPLOAD_URI_PREFIX,
|
||||
avatarUriPrefix: env.AVATAR_URI_PREFIX,
|
||||
aiBaseUrl: env.AI_BASE_URL,
|
||||
/** 只用来写 ai_analysis.provider 这一列,换 provider 时和 AI_BASE_URL 一起改 */
|
||||
aiProvider: process.env.AI_PROVIDER ?? "deepseek",
|
||||
aiKey: process.env.AI_KEY ?? "",
|
||||
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
|
||||
aiProvider: env.AI_PROVIDER,
|
||||
aiKey: env.AI_KEY,
|
||||
aiModel: env.AI_MODEL,
|
||||
/**
|
||||
* AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**:
|
||||
* 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。
|
||||
* 设成 "1" 打开。
|
||||
*/
|
||||
aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1",
|
||||
ruffPath: process.env.RUFF_PATH ?? "ruff",
|
||||
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
|
||||
aiHintDiagnose: env.AI_HINT_DIAGNOSE === "1",
|
||||
ruffPath: env.RUFF_PATH,
|
||||
clangFormatPath: env.CLANG_FORMAT_PATH,
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import postgres from "postgres"
|
||||
|
||||
import { config } from "../config"
|
||||
import * as schema from "./schema"
|
||||
|
||||
const url =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
|
||||
|
||||
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone`(`../time` 的 localTime),
|
||||
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。
|
||||
const client = postgres(url)
|
||||
const client = postgres(config.databaseUrl)
|
||||
|
||||
export const db = drizzle(client, { schema })
|
||||
|
||||
/**
|
||||
* 「库或事务」都能接的参数类型。辅助函数既要能单独调、又要能在事务里调时用它,
|
||||
* 别再写 `tx as unknown as typeof db` —— 那个断言把两者的差异(事务上没有
|
||||
* `$client`、`batch` 等)一并抹掉了。
|
||||
*/
|
||||
export type DbOrTx =
|
||||
typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
/**
|
||||
* 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。
|
||||
*
|
||||
|
||||
@@ -755,7 +755,7 @@ export const problemTag = pgTable(
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
() => [
|
||||
uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FlowchartUpdate } from "@oj2/contract"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { publishFlowchartUpdate } from "../events"
|
||||
import { completeChat } from "../services/ai"
|
||||
@@ -85,8 +86,8 @@ export async function evaluateFlowchart(
|
||||
aiFeedback: result.feedback,
|
||||
aiSuggestions: result.suggestions,
|
||||
aiCriteriaDetails: result.criteria,
|
||||
aiProvider: "deepseek",
|
||||
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
|
||||
aiProvider: config.aiProvider,
|
||||
aiModel: config.aiModel,
|
||||
processingTime: (performance.now() - started) / 1000,
|
||||
evaluationTime: new Date().toISOString(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Context } from "hono"
|
||||
import type { z } from "zod"
|
||||
|
||||
export function success<T>(c: Context, data: T, status = 200) {
|
||||
return c.json({ data }, status as 200)
|
||||
@@ -12,3 +13,42 @@ export function failure(
|
||||
) {
|
||||
return c.json({ error: { code, message } }, status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 JSON 请求体并按契约校验。body 不是合法 JSON 时按 `null` 交给 schema,
|
||||
* 由 schema 统一判失败 —— 原来每个 handler 都手写一遍 `c.req.json().catch(() => null)`,
|
||||
* 漏写 `.catch` 就是一个畸形请求打出 500。
|
||||
*/
|
||||
export async function readJson<S extends z.ZodType>(c: Context, schema: S) {
|
||||
return schema.safeParse(await c.req.json().catch(() => null))
|
||||
}
|
||||
|
||||
/**
|
||||
* `readJson` + 校验失败时的 400。绝大多数入参端点就是这个形状:
|
||||
*
|
||||
* const parsed = await parseBody(c, schema, "标签名不能为空")
|
||||
* if (!parsed.success) return parsed.response
|
||||
*
|
||||
* 不给 `message` 就回 zod 的第一条 issue(后台表单要告诉老师具体哪一栏不对)。
|
||||
* 失败时要带别的错误码、或者和路径参数一起判的,用 `readJson` 自己写。
|
||||
*/
|
||||
export async function parseBody<S extends z.ZodType>(
|
||||
c: Context,
|
||||
schema: S,
|
||||
message?: string,
|
||||
): Promise<
|
||||
{ success: true; data: z.output<S> } | { success: false; response: Response }
|
||||
> {
|
||||
const parsed = await readJson(c, schema)
|
||||
if (parsed.success) return { success: true, data: parsed.data }
|
||||
return {
|
||||
success: false,
|
||||
response: failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
// issues 不会是空的,?? 只是给类型一个交代
|
||||
message ?? parsed.error.issues[0]?.message ?? "参数错误",
|
||||
),
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -78,7 +78,12 @@ async function serveUpload(
|
||||
if (await file.exists()) {
|
||||
// 文件名由后端生成且内容不变,可以放心长缓存
|
||||
return new Response(file, {
|
||||
headers: { "cache-control": "public, max-age=86400" },
|
||||
headers: {
|
||||
"cache-control": "public, max-age=86400",
|
||||
// 线上 Caddy 也加了这个头,这里再加一道:content-type 按扩展名配,
|
||||
// 不让浏览器自己嗅探成 HTML 去执行
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
})
|
||||
}
|
||||
return null
|
||||
@@ -86,6 +91,11 @@ async function serveUpload(
|
||||
|
||||
const server = Bun.serve<SubmissionSocketData>({
|
||||
port: config.port,
|
||||
// Bun 默认 10 秒没有字节往来就掐连接,而且不报任何错。AI 的 SSE 两条路都会静默
|
||||
// 超过 10 秒:streamWhole 在 produce 期间每 15 秒才发一次心跳,streamChat 等首
|
||||
// token 也常过 10 秒 —— 学生看到的就是「AI 提示生成失败」。对齐反代链上最短的
|
||||
// 那一环(NPM 的 proxy_read_timeout 60s),两边的心跳 / 空闲超时都在它之内。
|
||||
idleTimeout: 60,
|
||||
async fetch(request, bunServer) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
|
||||
|
||||
@@ -159,7 +159,7 @@ function iterate(db: Database, script: string): Iterable<PreparedStatement> {
|
||||
|
||||
/**
|
||||
* 取语句的首关键字。优先用 sqlite3_normalized_sql —— 归一化由 SQLite 自己做,
|
||||
* 注释、大小写、空白都已抹平(`/*x*/ pragma Query_Only = 0` → `PRAGMA query_only=?`),
|
||||
* 注释、大小写、空白都已抹平(`/*x*\/ pragma Query_Only = 0` → `PRAGMA query_only=?`),
|
||||
* 比在原文上自己做词法猜测可靠得多。
|
||||
*/
|
||||
function leadingKeyword(statement: PreparedStatement) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { extname, resolve } from "node:path"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
import {
|
||||
rankProfileSchema,
|
||||
registerRequestSchema,
|
||||
STUDENT_ROLES,
|
||||
updateProfileRequestSchema,
|
||||
@@ -43,10 +42,17 @@ import { onlineUserIds } from "../auth/presence"
|
||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { sniffImageExtension } from "../services/image"
|
||||
import { getUserProfileById } from "../services/profile"
|
||||
import {
|
||||
clientIp,
|
||||
countAttempt,
|
||||
lockoutRemaining,
|
||||
type AttemptRule,
|
||||
} from "../services/throttling"
|
||||
import { localTime, weekStart } from "../time"
|
||||
import {
|
||||
isTeacherOrAbove,
|
||||
@@ -57,12 +63,19 @@ import {
|
||||
|
||||
export const accountRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 注册限流:按 IP 每小时 100 个号,只数成功的。拦的是脚本批量造号;
|
||||
* 机房一个班共用一个出口 IP,一节课全班现场注册也够用。
|
||||
*/
|
||||
const REGISTER_PER_IP: AttemptRule = { limit: 100, windowSeconds: 60 * 60 }
|
||||
|
||||
accountRoutes.post("/users", async (c) => {
|
||||
const parsed = registerRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
registerRequestSchema,
|
||||
"Invalid registration payload",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
if (!(await getBooleanOption("allow_register", true))) {
|
||||
return failure(
|
||||
c,
|
||||
@@ -72,6 +85,17 @@ accountRoutes.post("/users", async (c) => {
|
||||
)
|
||||
}
|
||||
|
||||
const registerKey = `register:ip:${clientIp(c)}`
|
||||
const wait = await lockoutRemaining(registerKey, REGISTER_PER_IP)
|
||||
if (wait !== null) {
|
||||
return failure(
|
||||
c,
|
||||
429,
|
||||
"too-many-registrations",
|
||||
`Too many registrations, please wait ${wait} seconds`,
|
||||
)
|
||||
}
|
||||
|
||||
const username = parsed.data.username.toLowerCase()
|
||||
const email = parsed.data.email.toLowerCase()
|
||||
const [duplicate] = await db
|
||||
@@ -120,6 +144,7 @@ accountRoutes.post("/users", async (c) => {
|
||||
realName: null,
|
||||
})
|
||||
})
|
||||
await countAttempt(registerKey, REGISTER_PER_IP)
|
||||
return success(c, { ok: true }, 201)
|
||||
})
|
||||
|
||||
@@ -149,11 +174,12 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
||||
})
|
||||
|
||||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||||
const parsed = updateProfileRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
updateProfileRequestSchema,
|
||||
"Invalid profile payload",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid profile payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(parsed.data).map(([key, value]) => [
|
||||
key,
|
||||
@@ -179,8 +205,8 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
||||
return failure(c, 400, "invalid-file", "Invalid file content")
|
||||
if (image.size > 2 * 1024 * 1024)
|
||||
return failure(c, 400, "file-too-large", "Picture is too large")
|
||||
const extension = extname(image.name).toLowerCase()
|
||||
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
||||
const extension = await sniffImageExtension(image)
|
||||
if (!extension) {
|
||||
return failure(c, 400, "unsupported-file", "Unsupported file format")
|
||||
}
|
||||
const filename = `${randomBytes(10).toString("hex")}${extension}`
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { objectValue } from "./helpers"
|
||||
|
||||
export const achievementRoutes = new Hono<AppEnv>()
|
||||
@@ -177,11 +177,12 @@ achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||
})
|
||||
|
||||
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
|
||||
const parsed = markAchievementsReadSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
markAchievementsReadSchema,
|
||||
"Invalid achievement ids",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||
if (!parsed.success) return parsed.response
|
||||
if (parsed.data.ids.length > 0) {
|
||||
await db
|
||||
.update(schema.userAchievement)
|
||||
|
||||
@@ -33,7 +33,7 @@ import { isUserOnline, onlineUserIds } from "../../auth/presence"
|
||||
import { revokeUserSessions } from "../../auth/session"
|
||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminAccountRoutes = new Hono<AppEnv>()
|
||||
@@ -252,17 +252,8 @@ adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
|
||||
|
||||
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateUserRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateUserRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data
|
||||
const [existing] = await selectUser(id)
|
||||
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
|
||||
@@ -350,17 +341,8 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
|
||||
const parsed = importUsersRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, importUsersRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const rows = parsed.data.users
|
||||
type Prepared = {
|
||||
username: string
|
||||
@@ -544,11 +526,8 @@ function isForeignKeyViolation(error: unknown) {
|
||||
class UserHasSubmissionsError extends Error {}
|
||||
|
||||
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
||||
const parsed = deleteUsersRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "ids is required")
|
||||
const parsed = await parseBody(c, deleteUsersRequestSchema, "ids is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
const me = c.get("user")!.id
|
||||
if (parsed.data.ids.includes(me)) {
|
||||
return failure(
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import {
|
||||
ACHIEVEMENT_METRICS,
|
||||
findMetric,
|
||||
@@ -72,17 +72,8 @@ adminAchievementRoutes.get(
|
||||
)
|
||||
|
||||
adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
|
||||
const parsed = createAchievementRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createAchievementRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
if (!findMetric(parsed.data.metric))
|
||||
return failure(c, 400, "invalid-metric", "指标不存在")
|
||||
|
||||
@@ -111,17 +102,8 @@ adminAchievementRoutes.put(
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAchievementRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateAchievementRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
if (!findMetric(parsed.data.metric))
|
||||
return failure(c, 400, "invalid-metric", "指标不存在")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminAnnouncementRoutes = new Hono<AppEnv>()
|
||||
@@ -78,17 +78,8 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
|
||||
const parsed = createAnnouncementRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createAnnouncementRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const now = new Date().toISOString()
|
||||
const [created] = await db
|
||||
.insert(schema.announcement)
|
||||
@@ -126,17 +117,8 @@ adminAnnouncementRoutes.put(
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAnnouncementRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateAnnouncementRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const updated = await db
|
||||
.update(schema.announcement)
|
||||
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
import { config } from "../../config"
|
||||
import { db, schema } from "../../db"
|
||||
import { publishConfigUpdate } from "../../events"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { sniffImageExtension } from "../../services/image"
|
||||
import { getWebsiteOptions } from "../../services/options"
|
||||
import { todayStart } from "../../time"
|
||||
import { queryInteger } from "../helpers"
|
||||
@@ -77,17 +78,8 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateWebsiteConfigRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const entries = (
|
||||
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
|
||||
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
|
||||
@@ -131,11 +123,12 @@ adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
|
||||
const parsed = updateJudgeServerRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
updateJudgeServerRequestSchema,
|
||||
"isDisabled is required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "isDisabled is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
const updated = await db
|
||||
.update(schema.judgeServer)
|
||||
.set({ isDisabled: parsed.data.isDisabled })
|
||||
@@ -300,8 +293,12 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
||||
filePath: "",
|
||||
} satisfies UploadImageResponse)
|
||||
}
|
||||
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
|
||||
if (!IMAGE_SUFFIXES.includes(suffix)) {
|
||||
// 以文件头为准,扩展名只是第一道快速筛
|
||||
const claimed = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
|
||||
const suffix = IMAGE_SUFFIXES.includes(claimed)
|
||||
? await sniffImageExtension(image)
|
||||
: null
|
||||
if (!suffix) {
|
||||
return success(c, {
|
||||
success: false,
|
||||
msg: "Unsupported file format",
|
||||
|
||||
@@ -12,9 +12,9 @@ import { Hono } from "hono"
|
||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { contestStatus } from "../../services/contest"
|
||||
import { objectValue, queryInteger, sampleUser } from "../helpers"
|
||||
import { queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminContestRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -120,17 +120,8 @@ adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
adminContestRoutes.post("/contests", requireTeacher, async (c) => {
|
||||
const parsed = createContestRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createContestRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const error = validatePayload(parsed.data)
|
||||
if (error) return failure(c, 400, "invalid-contest", error)
|
||||
|
||||
@@ -157,17 +148,8 @@ adminContestRoutes.post("/contests", requireTeacher, async (c) => {
|
||||
|
||||
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateContestRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateContestRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [existing] = await selectContest(id)
|
||||
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
@@ -386,17 +368,8 @@ adminContestRoutes.put(
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateAcmHelperRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateAcmHelperRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [contest] = await db
|
||||
.select()
|
||||
.from(schema.contest)
|
||||
|
||||
@@ -31,8 +31,8 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { db, schema, type DbOrTx } from "../../db"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { astRulesError, pickAstRules } from "../../judge/ast"
|
||||
import { buildSqlDisplay } from "../../judge/sql"
|
||||
import { completeChat } from "../../services/ai"
|
||||
@@ -120,7 +120,7 @@ export function normalizeTagNames(names: string[]) {
|
||||
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
|
||||
* 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。
|
||||
*/
|
||||
export async function findTagsByName(tx: typeof db, names: string[]) {
|
||||
export async function findTagsByName(tx: DbOrTx, names: string[]) {
|
||||
const map = new Map<string, number>()
|
||||
if (names.length === 0) return map
|
||||
const rows = await tx
|
||||
@@ -137,7 +137,7 @@ export async function findTagsByName(tx: typeof db, names: string[]) {
|
||||
}
|
||||
|
||||
/** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */
|
||||
async function resolveTags(tx: typeof db, names: string[]) {
|
||||
async function resolveTags(tx: DbOrTx, names: string[]) {
|
||||
const wanted = normalizeTagNames(names)
|
||||
if (wanted.length === 0) return []
|
||||
const existing = await findTagsByName(tx, wanted)
|
||||
@@ -154,7 +154,7 @@ async function resolveTags(tx: typeof db, names: string[]) {
|
||||
.filter((id) => id !== undefined)
|
||||
}
|
||||
|
||||
async function setTags(tx: typeof db, problemId: number, names: string[]) {
|
||||
async function setTags(tx: DbOrTx, problemId: number, names: string[]) {
|
||||
const ids = await resolveTags(tx, names)
|
||||
await tx
|
||||
.delete(schema.problemTags)
|
||||
@@ -332,6 +332,99 @@ function problemValues(
|
||||
}
|
||||
}
|
||||
|
||||
type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
|
||||
|
||||
/**
|
||||
* 保存前的全部校验:公共校验 + SQL 题的展示数据。新建公开题、新建比赛题、编辑题目
|
||||
* 三条路径共用 —— 原来各抄一份,比赛题那份还把算好的 sqlDisplay 丢了(见 insertProblem)。
|
||||
*
|
||||
* SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上。
|
||||
*/
|
||||
async function validateProblem(
|
||||
data: ProblemInput,
|
||||
): Promise<
|
||||
{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }
|
||||
> {
|
||||
const checked = commonChecks(data)
|
||||
if ("error" in checked) return checked
|
||||
if (!checked.sql) return { sql: false, sqlDisplay: null }
|
||||
const built = await generateSqlDisplay(
|
||||
data.testCaseId,
|
||||
data.answers,
|
||||
data.sqlConfig!,
|
||||
)
|
||||
if ("error" in built) return built
|
||||
return { sql: true, sqlDisplay: built.display }
|
||||
}
|
||||
|
||||
/** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */
|
||||
async function insertProblem(
|
||||
data: ProblemInput,
|
||||
validated: { sql: boolean; sqlDisplay: SqlDisplay | null },
|
||||
owner: { contestId: number | null; createdById: number },
|
||||
) {
|
||||
const now = new Date().toISOString()
|
||||
return db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...problemValues(data, validated.sql),
|
||||
...owner,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
isPublic: false,
|
||||
// 比赛题原来这里写死 null,结果比赛里的 SQL 题打开后看不到示例数据表和期望结果
|
||||
sqlDisplay: validated.sqlDisplay,
|
||||
})
|
||||
.returning()
|
||||
await setTags(tx, row!.id, data.tags)
|
||||
return row!
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一道已有的题复制一份(连同标签),统计清零。比赛题转公开、从公开题库拉进比赛
|
||||
* 都是这个动作,只是落到哪、可见性怎么设不同,由 `overrides` 给。
|
||||
*/
|
||||
async function copyProblem(
|
||||
tx: DbOrTx,
|
||||
source: ProblemRow,
|
||||
overrides: Pick<
|
||||
typeof schema.problem.$inferInsert,
|
||||
"contestId" | "displayId" | "visible" | "isPublic"
|
||||
>,
|
||||
) {
|
||||
const now = new Date().toISOString()
|
||||
const { id: _old, ...rest } = source
|
||||
const [copy] = await tx
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...rest,
|
||||
...overrides,
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
.returning()
|
||||
const tags = await tx
|
||||
.select({ tagId: schema.problemTags.problemtagId })
|
||||
.from(schema.problemTags)
|
||||
.where(eq(schema.problemTags.problemId, source.id))
|
||||
if (tags.length) {
|
||||
await tx
|
||||
.insert(schema.problemTags)
|
||||
.values(
|
||||
tags.map((tag) => ({ problemId: copy!.id, problemtagId: tag.tagId })),
|
||||
)
|
||||
}
|
||||
return copy!
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 公开题目
|
||||
|
||||
adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
||||
@@ -432,30 +525,11 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
|
||||
})
|
||||
|
||||
adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
const parsed = createProblemRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked)
|
||||
return failure(c, 400, "invalid-problem", checked.error)
|
||||
let sqlDisplay: SqlDisplay | null = null
|
||||
if (checked.sql) {
|
||||
const built = await generateSqlDisplay(
|
||||
parsed.data.testCaseId,
|
||||
parsed.data.answers,
|
||||
parsed.data.sqlConfig!,
|
||||
)
|
||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
||||
sqlDisplay = built.display
|
||||
}
|
||||
const parsed = await parseBody(c, createProblemRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const validated = await validateProblem(parsed.data)
|
||||
if ("error" in validated)
|
||||
return failure(c, 400, "invalid-problem", validated.error)
|
||||
|
||||
const [duplicate] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
@@ -470,42 +544,17 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
if (duplicate)
|
||||
return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...problemValues(parsed.data, checked.sql),
|
||||
contestId: null,
|
||||
createdById: c.get("user")!.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
isPublic: false,
|
||||
sqlDisplay,
|
||||
})
|
||||
.returning()
|
||||
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
||||
return row!
|
||||
const created = await insertProblem(parsed.data, validated, {
|
||||
contestId: null,
|
||||
createdById: c.get("user")!.id,
|
||||
})
|
||||
return success(c, await serialize(created), 201)
|
||||
})
|
||||
|
||||
adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateProblemRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateProblemRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.problem)
|
||||
@@ -516,9 +565,9 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
if (!(await canEdit(c.get("user")!, existing))) {
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked)
|
||||
return failure(c, 400, "invalid-problem", checked.error)
|
||||
const validated = await validateProblem(parsed.data)
|
||||
if ("error" in validated)
|
||||
return failure(c, 400, "invalid-problem", validated.error)
|
||||
|
||||
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
|
||||
const [duplicate] = await db
|
||||
@@ -537,29 +586,17 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
if (duplicate)
|
||||
return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||
|
||||
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
|
||||
let sqlDisplay: SqlDisplay | null = null
|
||||
if (checked.sql) {
|
||||
const built = await generateSqlDisplay(
|
||||
parsed.data.testCaseId,
|
||||
parsed.data.answers,
|
||||
parsed.data.sqlConfig!,
|
||||
)
|
||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
||||
sqlDisplay = built.display
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(schema.problem)
|
||||
.set({
|
||||
...problemValues(parsed.data, checked.sql),
|
||||
sqlDisplay,
|
||||
...problemValues(parsed.data, validated.sql),
|
||||
sqlDisplay: validated.sqlDisplay,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.problem.id, id))
|
||||
.returning()
|
||||
await setTags(tx as unknown as typeof db, id, parsed.data.tags)
|
||||
await setTags(tx, id, parsed.data.tags)
|
||||
return row!
|
||||
})
|
||||
return success(c, await serialize(updated))
|
||||
@@ -702,31 +739,11 @@ adminProblemRoutes.post(
|
||||
) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
const parsed = createProblemRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked)
|
||||
return failure(c, 400, "invalid-problem", checked.error)
|
||||
let sqlDisplay: SqlDisplay | null = null
|
||||
if (checked.sql) {
|
||||
const built = await generateSqlDisplay(
|
||||
parsed.data.testCaseId,
|
||||
parsed.data.answers,
|
||||
parsed.data.sqlConfig!,
|
||||
)
|
||||
if ("error" in built)
|
||||
return failure(c, 400, "invalid-problem", built.error)
|
||||
sqlDisplay = built.display
|
||||
}
|
||||
const parsed = await parseBody(c, createProblemRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const validated = await validateProblem(parsed.data)
|
||||
if ("error" in validated)
|
||||
return failure(c, 400, "invalid-problem", validated.error)
|
||||
|
||||
const [duplicate] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
@@ -741,27 +758,9 @@ adminProblemRoutes.post(
|
||||
if (duplicate)
|
||||
return failure(c, 409, "display-id-exists", "Duplicate Display id")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...problemValues(parsed.data, checked.sql),
|
||||
contestId,
|
||||
createdById: user.id,
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
isPublic: false,
|
||||
// 上面 generateSqlDisplay 已经把展示数据算好了,之前这里写死 null,
|
||||
// 结果比赛里的 SQL 题打开后看不到示例数据表和期望结果(公开题那两条路径都是对的)
|
||||
sqlDisplay,
|
||||
})
|
||||
.returning()
|
||||
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
||||
return row!
|
||||
const created = await insertProblem(parsed.data, validated, {
|
||||
contestId,
|
||||
createdById: user.id,
|
||||
})
|
||||
return success(c, await serialize(created), 201)
|
||||
},
|
||||
@@ -774,12 +773,12 @@ adminProblemRoutes.post(
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = makeProblemPublicRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
makeProblemPublicRequestSchema,
|
||||
"displayId 不能为空",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "displayId 不能为空")
|
||||
|
||||
if (!parsed.success) return parsed.response
|
||||
const [problem] = await db
|
||||
.select()
|
||||
.from(schema.problem)
|
||||
@@ -809,43 +808,19 @@ adminProblemRoutes.post(
|
||||
if (duplicate)
|
||||
return failure(c, 409, "display-id-exists", "Duplicate display ID")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const created = await db.transaction(async (tx) => {
|
||||
// 原比赛题标记成「已转公开」,避免同一道题被转两次
|
||||
await tx
|
||||
.update(schema.problem)
|
||||
.set({ isPublic: true })
|
||||
.where(eq(schema.problem.id, id))
|
||||
const { id: _old, ...rest } = problem
|
||||
const [copy] = await tx
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...rest,
|
||||
contestId: null,
|
||||
displayId: parsed.data.displayId,
|
||||
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
|
||||
visible: false,
|
||||
isPublic: true,
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
.returning()
|
||||
const tags = await tx
|
||||
.select({ tagId: schema.problemTags.problemtagId })
|
||||
.from(schema.problemTags)
|
||||
.where(eq(schema.problemTags.problemId, id))
|
||||
if (tags.length) {
|
||||
await tx.insert(schema.problemTags).values(
|
||||
tags.map((tag) => ({
|
||||
problemId: copy!.id,
|
||||
problemtagId: tag.tagId,
|
||||
})),
|
||||
)
|
||||
}
|
||||
return copy!
|
||||
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
|
||||
return copyProblem(tx, problem, {
|
||||
contestId: null,
|
||||
displayId: parsed.data.displayId,
|
||||
visible: false,
|
||||
isPublic: true,
|
||||
})
|
||||
})
|
||||
return success(c, await serialize(created), 201)
|
||||
},
|
||||
@@ -856,17 +831,8 @@ adminProblemRoutes.post(
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
|
||||
const parsed = addContestProblemRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, addContestProblemRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [contest] = await db
|
||||
.select()
|
||||
.from(schema.contest)
|
||||
@@ -917,38 +883,14 @@ adminProblemRoutes.post(
|
||||
"Duplicate display id in this contest",
|
||||
)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const { id: _old, ...rest } = problem
|
||||
const [copy] = await tx
|
||||
.insert(schema.problem)
|
||||
.values({
|
||||
...rest,
|
||||
contestId,
|
||||
isPublic: true,
|
||||
visible: true,
|
||||
displayId: parsed.data.displayId,
|
||||
submissionNumber: 0,
|
||||
acceptedNumber: 0,
|
||||
statisticInfo: {},
|
||||
createTime: now,
|
||||
lastUpdateTime: now,
|
||||
})
|
||||
.returning()
|
||||
const tags = await tx
|
||||
.select({ tagId: schema.problemTags.problemtagId })
|
||||
.from(schema.problemTags)
|
||||
.where(eq(schema.problemTags.problemId, problem.id))
|
||||
if (tags.length) {
|
||||
await tx.insert(schema.problemTags).values(
|
||||
tags.map((tag) => ({
|
||||
problemId: copy!.id,
|
||||
problemtagId: tag.tagId,
|
||||
})),
|
||||
)
|
||||
}
|
||||
return copy!
|
||||
})
|
||||
const created = await db.transaction((tx) =>
|
||||
copyProblem(tx, problem, {
|
||||
contestId,
|
||||
displayId: parsed.data.displayId,
|
||||
visible: true,
|
||||
isPublic: true,
|
||||
}),
|
||||
)
|
||||
return success(c, await serialize(created), 201)
|
||||
},
|
||||
)
|
||||
@@ -1055,17 +997,8 @@ adminProblemRoutes.post(
|
||||
"/sql-test-cases/preview",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const parsed = sqlPreviewRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, sqlPreviewRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const outcome = await buildSqlDisplay(
|
||||
parsed.data.initSql,
|
||||
parsed.data.refSql,
|
||||
@@ -1082,17 +1015,8 @@ adminProblemRoutes.post(
|
||||
"/sql-test-cases/generate",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const parsed = generateSqlTestCaseRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, generateSqlTestCaseRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
try {
|
||||
const sql = await completeChat(
|
||||
`你是一个 SQL 出题助手。用户会给你一道 SQL 题的标准答案(查询题的
|
||||
|
||||
@@ -29,7 +29,7 @@ import { Hono } from "hono"
|
||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
||||
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
@@ -175,17 +175,8 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
||||
const parsed = createProblemSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createProblemSetRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const now = new Date().toISOString()
|
||||
const [created] = await db
|
||||
.insert(schema.problemset)
|
||||
@@ -211,17 +202,8 @@ adminProblemSetRoutes.get("/problem-sets/:id", requireTeacher, async (c) => {
|
||||
adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [updated] = await db
|
||||
.update(schema.problemset)
|
||||
.set({
|
||||
@@ -258,11 +240,12 @@ adminProblemSetRoutes.put(
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetStatusRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
updateProblemSetStatusRequestSchema,
|
||||
"status 不合法",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "status 不合法")
|
||||
if (!parsed.success) return parsed.response
|
||||
const [updated] = await db
|
||||
.update(schema.problemset)
|
||||
.set({
|
||||
@@ -331,17 +314,8 @@ adminProblemSetRoutes.post(
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = addProblemToSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, addProblemToSetRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
@@ -394,10 +368,12 @@ adminProblemSetRoutes.put(
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemInSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
updateProblemInSetRequestSchema,
|
||||
"参数错误",
|
||||
)
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
|
||||
if (!parsed.success) return parsed.response
|
||||
const updated = await db
|
||||
.update(schema.problemsetProblem)
|
||||
.set(parsed.data)
|
||||
@@ -512,17 +488,8 @@ adminProblemSetRoutes.post(
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = createProblemSetBadgeRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [created] = await db
|
||||
.insert(schema.problemsetBadge)
|
||||
.values({
|
||||
@@ -542,17 +509,8 @@ adminProblemSetRoutes.put(
|
||||
async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const parsed = updateProblemSetBadgeRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [updated] = await db
|
||||
.update(schema.problemsetBadge)
|
||||
.set(parsed.data)
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { JudgeStatus } from "../../judge/status"
|
||||
import { completeChat } from "../../services/ai"
|
||||
import { localTime, localYear } from "../../time"
|
||||
@@ -82,11 +82,8 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
||||
|
||||
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = renameTagRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "标签名不能为空")
|
||||
const parsed = await parseBody(c, renameTagRequestSchema, "标签名不能为空")
|
||||
if (!parsed.success) return parsed.response
|
||||
const name = parsed.data.name
|
||||
|
||||
const [tag] = await db
|
||||
@@ -177,17 +174,8 @@ adminTagRoutes.post(
|
||||
"/problems/batch-tag",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const parsed = batchProblemTagRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "参数错误",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, batchProblemTagRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get("user")!
|
||||
const filters = [
|
||||
inArray(schema.problem.id, parsed.data.problemIds),
|
||||
@@ -206,7 +194,7 @@ adminTagRoutes.post(
|
||||
const wanted = normalizeTagNames(parsed.data.tagNames)
|
||||
|
||||
const tagIds = await db.transaction(async (tx) => {
|
||||
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
|
||||
const existing = await findTagsByName(tx, wanted)
|
||||
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||
if (parsed.data.action === "add") {
|
||||
const missing = wanted.filter(
|
||||
@@ -462,11 +450,12 @@ adminTagRoutes.post(
|
||||
"/problems/flowchart",
|
||||
requireProblemPermission,
|
||||
async (c) => {
|
||||
const parsed = generateFlowchartRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
generateFlowchartRequestSchema,
|
||||
"python 代码不能为空",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "python 代码不能为空")
|
||||
if (!parsed.success) return parsed.response
|
||||
try {
|
||||
const flowchart = await completeChat(
|
||||
`你是一个可以将Python代码转换为mermaid的助手。
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { failure, parseBody, success } from "../../http"
|
||||
import { exerciseDataError } from "../../services/exercise"
|
||||
import { objectValue, queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
@@ -72,17 +72,8 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
|
||||
const parsed = createTutorialRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createTutorialRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const now = new Date().toISOString()
|
||||
const [created] = await db
|
||||
.insert(schema.tutorial)
|
||||
@@ -108,17 +99,8 @@ adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
|
||||
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = updateTutorialRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateTutorialRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const updated = await db
|
||||
.update(schema.tutorial)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
@@ -135,11 +117,12 @@ adminTutorialRoutes.put(
|
||||
requireSuperAdmin,
|
||||
async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setTutorialVisibilityRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
setTutorialVisibilityRequestSchema,
|
||||
"isPublic is required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "isPublic is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
|
||||
const updated = await db
|
||||
.update(schema.tutorial)
|
||||
@@ -199,17 +182,8 @@ adminTutorialRoutes.get(
|
||||
)
|
||||
|
||||
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
|
||||
const parsed = createExerciseRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, createExerciseRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
@@ -233,17 +207,8 @@ adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
|
||||
const parsed = updateExerciseRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
)
|
||||
}
|
||||
const parsed = await parseBody(c, updateExerciseRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
|
||||
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
||||
const [updated] = await db
|
||||
|
||||
+30
-594
@@ -7,18 +7,12 @@ import {
|
||||
HINT_MIN_FAILURES,
|
||||
type AiAnalysisRecord,
|
||||
type AiHintDone,
|
||||
type AiDetail,
|
||||
type DurationData,
|
||||
type HintDiagnosis,
|
||||
type Grade,
|
||||
type HeatmapItem,
|
||||
type LoginSummary,
|
||||
type SolvedList,
|
||||
type SolvedProblem,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
eq,
|
||||
@@ -26,19 +20,24 @@ import {
|
||||
inArray,
|
||||
isNull,
|
||||
lte,
|
||||
min,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono, type Context } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { getPreviousLogin, type AuthUser } from "../auth/session"
|
||||
import { getPreviousLogin } from "../auth/session"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { failure, parseBody, readJson, success } from "../http"
|
||||
import { completeChat, streamChat, streamWhole } from "../services/ai"
|
||||
import { generateFilteredHint } from "../services/hint-filter"
|
||||
import {
|
||||
accepted,
|
||||
buildDetail,
|
||||
buildDuration,
|
||||
listSolved,
|
||||
} from "../services/learning-stats"
|
||||
import { decideHintLevel } from "../services/hint-level"
|
||||
import {
|
||||
hintDiagnosis,
|
||||
@@ -52,28 +51,16 @@ import {
|
||||
dayText,
|
||||
localTime,
|
||||
localWeekday,
|
||||
shiftMonthsByCalendar,
|
||||
} from "../time"
|
||||
import {
|
||||
countFailedSubmissions,
|
||||
isTeacherOrAbove,
|
||||
objectValue,
|
||||
queryInteger,
|
||||
rounded,
|
||||
} from "./helpers"
|
||||
|
||||
export const aiRoutes = new Hono<AppEnv>()
|
||||
|
||||
const accepted: JudgeStatusValue[] = [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]
|
||||
const difficultyNames: Record<string, string> = {
|
||||
Low: "简单",
|
||||
Mid: "中等",
|
||||
High: "困难",
|
||||
}
|
||||
|
||||
/**
|
||||
* 每次 AI 调用都过一遍令牌桶,复用 services/throttling 的那只桶(capacity 20 / 0.03 每秒)。
|
||||
* key 与代码提交的 `throttling:user:<id>`、流程图评分的 `flowchart:<id>` 分开计数 ——
|
||||
@@ -94,29 +81,6 @@ async function throttleAi(c: Context<AppEnv>) {
|
||||
)
|
||||
}
|
||||
|
||||
function grade(rank: number | null, count: number, reference = count): Grade {
|
||||
if (!rank || count <= 0) return "C"
|
||||
const percentile = ((rank - 1) / count) * 100
|
||||
let value: Grade =
|
||||
percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
|
||||
return value
|
||||
}
|
||||
|
||||
function averageGrade(grades: Grade[]): Grade {
|
||||
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
|
||||
const values = grades.flatMap((item) => weights[item] ?? [])
|
||||
if (!values.length) return ""
|
||||
const average = values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
return average >= 3.5
|
||||
? "S"
|
||||
: average >= 2.5
|
||||
? "A"
|
||||
: average >= 1.5
|
||||
? "B"
|
||||
: "C"
|
||||
}
|
||||
|
||||
async function targetUser(c: Context<AppEnv>, override?: string) {
|
||||
const current = c.get("user")!
|
||||
const username = override ?? c.req.query("username")
|
||||
@@ -137,378 +101,6 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
|
||||
return target ?? null
|
||||
}
|
||||
|
||||
type FirstAcRow = { problemId: number; first: string | null }
|
||||
|
||||
/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */
|
||||
function firstAcQuery(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) {
|
||||
const first = min(schema.submission.createTime)
|
||||
const query = db
|
||||
.select({ problemId: schema.submission.problemId, first })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.result, accepted),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId)
|
||||
.orderBy(asc(first))
|
||||
return limit === undefined ? query : query.limit(limit).offset(offset ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。
|
||||
* 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。
|
||||
*/
|
||||
async function buildSolved(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
firstAc: FirstAcRow[],
|
||||
) {
|
||||
const problemIds = firstAc.map((item) => item.problemId)
|
||||
if (!problemIds.length)
|
||||
return {
|
||||
solved: [],
|
||||
problems: [] as {
|
||||
problem: typeof schema.problem.$inferSelect
|
||||
contestTitle: string | null
|
||||
}[],
|
||||
scopeIds: null as number[] | null,
|
||||
}
|
||||
const classUsers = user.className
|
||||
? await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, user.className))
|
||||
: []
|
||||
const scopeIds =
|
||||
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const [problems, rankRows, periodRows, attemptRows] = await Promise.all([
|
||||
db
|
||||
.select({ problem: schema.problem, contestTitle: schema.contest.title })
|
||||
.from(schema.problem)
|
||||
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id))
|
||||
.where(inArray(schema.problem.id, problemIds)),
|
||||
db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
first: min(schema.submission.createTime),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, problemIds),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||
db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
first: min(schema.submission.createTime),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, problemIds),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
time: schema.submission.createTime,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.problemId, problemIds),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
),
|
||||
])
|
||||
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
|
||||
// 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交
|
||||
const firstAcTime = new Map(
|
||||
firstAc.flatMap((item) =>
|
||||
item.first ? ([[item.problemId, Date.parse(item.first)]] as const) : [],
|
||||
),
|
||||
)
|
||||
const attemptsByProblem = new Map<number, number>()
|
||||
for (const row of attemptRows) {
|
||||
const deadline = firstAcTime.get(row.problemId)
|
||||
if (deadline === undefined || Date.parse(row.time) > deadline) continue
|
||||
attemptsByProblem.set(
|
||||
row.problemId,
|
||||
(attemptsByProblem.get(row.problemId) ?? 0) + 1,
|
||||
)
|
||||
}
|
||||
function ranks(rows: typeof rankRows, problemId: number) {
|
||||
return rows
|
||||
.filter((item) => item.problemId === problemId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Date.parse(a.first ?? "") - Date.parse(b.first ?? "") ||
|
||||
a.userId - b.userId,
|
||||
)
|
||||
}
|
||||
const solved = firstAc
|
||||
.flatMap((item) => {
|
||||
const problem = byProblem.get(item.problemId)
|
||||
if (!problem || !item.first) return []
|
||||
const all = ranks(rankRows, item.problemId)
|
||||
const period = ranks(periodRows, item.problemId)
|
||||
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
const periodRank =
|
||||
period.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
return {
|
||||
problem: {
|
||||
title: problem.problem.title,
|
||||
displayId: problem.problem.displayId,
|
||||
contestTitle: problem.contestTitle ?? "",
|
||||
contestId: problem.problem.contestId,
|
||||
},
|
||||
acTime: item.first,
|
||||
rank,
|
||||
acCount: all.length,
|
||||
grade: grade(periodRank, period.length, all.length),
|
||||
periodRank,
|
||||
periodAcCount: period.length,
|
||||
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
|
||||
attempts: attemptsByProblem.get(item.problemId) ?? 1,
|
||||
} satisfies SolvedProblem
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
|
||||
return { solved, problems, scopeIds }
|
||||
}
|
||||
|
||||
/** 分页版:只算这一页的题 */
|
||||
async function listSolved(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) {
|
||||
const [firstAc, totalRows] = await Promise.all([
|
||||
firstAcQuery(user, start, end, limit, offset),
|
||||
db
|
||||
.select({ value: countDistinct(schema.submission.problemId) })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.result, accepted),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
),
|
||||
])
|
||||
const { solved } = await buildSolved(user, start, end, firstAc)
|
||||
return {
|
||||
results: solved,
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies SolvedList
|
||||
}
|
||||
|
||||
async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
|
||||
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
||||
// 星期和小时都按东八区取,和热力图同口径
|
||||
const weekday =
|
||||
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(
|
||||
Number,
|
||||
)
|
||||
const period =
|
||||
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
|
||||
Number,
|
||||
)
|
||||
const activityRows = await db
|
||||
.select({ weekday, period, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
)
|
||||
.groupBy(weekday, period)
|
||||
const activity = activityRows.map((row) => ({
|
||||
weekday: row.weekday,
|
||||
period: row.period,
|
||||
count: row.value,
|
||||
}))
|
||||
// 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。
|
||||
// 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义
|
||||
const submissions = await db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
time: schema.submission.createTime,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
)
|
||||
const settledFail = (result: JudgeStatusValue) =>
|
||||
!accepted.includes(result) &&
|
||||
result !== JudgeStatus.PENDING &&
|
||||
result !== JudgeStatus.JUDGING
|
||||
const errorCounts = new Map<number, number>()
|
||||
for (const row of submissions) {
|
||||
if (!settledFail(row.result)) continue
|
||||
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
|
||||
}
|
||||
const errors = [...errorCounts]
|
||||
.map(([result, count]) => ({ result, count }))
|
||||
.sort((a, b) => b.count - a.count || a.result - b.result)
|
||||
const firstAc = await firstAcQuery(user, start, end)
|
||||
const problemIds = firstAc.map((item) => item.problemId)
|
||||
if (!problemIds.length)
|
||||
return {
|
||||
user: user.username,
|
||||
className: user.className,
|
||||
start,
|
||||
end,
|
||||
solvedCount: 0,
|
||||
attempts: [],
|
||||
flowcharts: [],
|
||||
grade: "",
|
||||
tags: {},
|
||||
difficulty: {},
|
||||
contestCount: 0,
|
||||
activity,
|
||||
errors,
|
||||
rankScope: "global",
|
||||
} satisfies AiDetail
|
||||
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all(
|
||||
[
|
||||
buildSolved(user, start, end, firstAc),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds)),
|
||||
db
|
||||
.select({
|
||||
flow: schema.flowchartSubmission,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
gte(schema.flowchartSubmission.createTime, start),
|
||||
lte(schema.flowchartSubmission.createTime, end),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
const tags: Record<string, number> = {}
|
||||
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
|
||||
const topTags = Object.fromEntries(
|
||||
Object.entries(tags)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5),
|
||||
)
|
||||
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
|
||||
for (const item of problems) {
|
||||
const name = difficultyNames[item.problem.difficulty] ?? "中等"
|
||||
difficulty[name] = (difficulty[name] ?? 0) + 1
|
||||
}
|
||||
const flowGroups = new Map<string, typeof flowRows>()
|
||||
for (const flow of flowRows)
|
||||
flowGroups.set(flow.displayId, [
|
||||
...(flowGroups.get(flow.displayId) ?? []),
|
||||
flow,
|
||||
])
|
||||
const flowcharts = [...flowGroups]
|
||||
.map(([displayId, rows]) => {
|
||||
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
|
||||
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
|
||||
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
|
||||
const top = rows.reduce(
|
||||
(best, row) =>
|
||||
(row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best,
|
||||
rows[0]!,
|
||||
)
|
||||
return {
|
||||
problemId: displayId,
|
||||
problemTitle: rows[0]?.title ?? "",
|
||||
submissionCount: rows.length,
|
||||
bestScore: Math.max(0, top.flow.aiScore ?? 0),
|
||||
bestGrade: top.flow.aiGrade ?? "",
|
||||
latestSubmissionTime:
|
||||
rows
|
||||
.map((row) => row.flow.createTime)
|
||||
.sort()
|
||||
.at(-1) ?? start,
|
||||
avgScore: rounded(
|
||||
scores.length
|
||||
? scores.reduce((sum, value) => sum + value, 0) / scores.length
|
||||
: 0,
|
||||
0,
|
||||
),
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.latestSubmissionTime.localeCompare(a.latestSubmissionTime),
|
||||
)
|
||||
return {
|
||||
user: user.username,
|
||||
className: user.className,
|
||||
start,
|
||||
end,
|
||||
flowcharts,
|
||||
solvedCount: solved.length,
|
||||
attempts: solved.map((item) => item.attempts),
|
||||
grade: averageGrade(solved.map((item) => item.grade)),
|
||||
tags: topTags,
|
||||
difficulty,
|
||||
contestCount: new Set(
|
||||
solved.flatMap((item) => item.problem.contestId ?? []),
|
||||
).size,
|
||||
activity,
|
||||
errors,
|
||||
rankScope: scopeIds ? "class" : "global",
|
||||
} satisfies AiDetail
|
||||
}
|
||||
|
||||
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
|
||||
const start = c.req.query("start")
|
||||
const end = c.req.query("end")
|
||||
@@ -553,154 +145,6 @@ aiRoutes.get("/ai/solved", requireAuth, async (c) => {
|
||||
return success(c, await listSolved(user, start, end, limit, offset))
|
||||
})
|
||||
|
||||
async function buildDuration(
|
||||
user: AuthUser,
|
||||
endText: string,
|
||||
duration: string,
|
||||
) {
|
||||
const config =
|
||||
duration === "months:2"
|
||||
? {
|
||||
count: 8,
|
||||
unit: "weeks",
|
||||
rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5),
|
||||
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
|
||||
}
|
||||
: duration === "months:6"
|
||||
? {
|
||||
count: 6,
|
||||
unit: "months",
|
||||
rewind: (date: Date) => shiftMonthsByCalendar(date, -7),
|
||||
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
|
||||
}
|
||||
: duration === "years:1"
|
||||
? {
|
||||
count: 12,
|
||||
unit: "months",
|
||||
rewind: (date: Date) => shiftMonthsByCalendar(date, -13),
|
||||
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
|
||||
}
|
||||
: {
|
||||
count: 4,
|
||||
unit: "weeks",
|
||||
rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5),
|
||||
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
|
||||
}
|
||||
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
|
||||
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
|
||||
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
|
||||
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
|
||||
let cursor = config.rewind(new Date(endText))
|
||||
const buckets: { start: Date; end: Date }[] = []
|
||||
for (let index = 0; index < config.count; index++) {
|
||||
const start = config.advance(cursor)
|
||||
buckets.push({ start, end: config.advance(start) })
|
||||
cursor = start
|
||||
}
|
||||
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
|
||||
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
|
||||
const rows = await db
|
||||
.select({
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
|
||||
Number,
|
||||
),
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
|
||||
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
|
||||
),
|
||||
)
|
||||
// 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里
|
||||
// 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里
|
||||
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
|
||||
// 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。
|
||||
const solvedIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.filter((row) => accepted.includes(row.result))
|
||||
.map((row) => row.problemId),
|
||||
),
|
||||
]
|
||||
const classUsers = user.className
|
||||
? await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, user.className))
|
||||
: []
|
||||
const scopeIds =
|
||||
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const peers = solvedIds.length
|
||||
? await db
|
||||
.select({
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
|
||||
Number,
|
||||
),
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, solvedIds),
|
||||
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
|
||||
lte(
|
||||
schema.submission.createTime,
|
||||
buckets.at(-1)!.end.toISOString(),
|
||||
),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
: []
|
||||
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题
|
||||
const peersByProblem = new Map<number, typeof peers>()
|
||||
for (const row of peers)
|
||||
peersByProblem.set(row.problemId, [
|
||||
...(peersByProblem.get(row.problemId) ?? []),
|
||||
row,
|
||||
])
|
||||
|
||||
function bucketGrade(problemIds: number[], from: number, to: number) {
|
||||
return averageGrade(
|
||||
problemIds.map((problemId) => {
|
||||
const firstAc = new Map<number, number>()
|
||||
for (const row of peersByProblem.get(problemId) ?? []) {
|
||||
if (row.time < from || row.time > to) continue
|
||||
const seen = firstAc.get(row.userId)
|
||||
if (seen === undefined || row.time < seen)
|
||||
firstAc.set(row.userId, row.time)
|
||||
}
|
||||
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0])
|
||||
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null
|
||||
return grade(rank, ordered.length)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return buckets.map((bucket, index) => {
|
||||
const from = bucket.start.getTime()
|
||||
const to = bucket.end.getTime()
|
||||
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
|
||||
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
|
||||
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
|
||||
return {
|
||||
unit: config.unit,
|
||||
index: config.count - 1 - index,
|
||||
start: bucket.start.toISOString(),
|
||||
end: bucket.end.toISOString(),
|
||||
grade: solved.length ? bucketGrade(solved, from, to) : "",
|
||||
problemCount: solved.length,
|
||||
acceptedCount: acceptedRows.length,
|
||||
submissionCount: inRange.length,
|
||||
} satisfies DurationData
|
||||
})
|
||||
}
|
||||
|
||||
aiRoutes.get("/ai/duration", requireAuth, async (c) => {
|
||||
const endText = c.req.query("end")
|
||||
if (!endText || Number.isNaN(Date.parse(endText)))
|
||||
@@ -882,16 +326,12 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
|
||||
})
|
||||
|
||||
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
|
||||
const parsed = aiAnalysisRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
aiAnalysisRequestSchema,
|
||||
"start, end and duration are required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
"start, end and duration are required",
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
if (
|
||||
Number.isNaN(Date.parse(parsed.data.start)) ||
|
||||
Number.isNaN(Date.parse(parsed.data.end))
|
||||
@@ -983,11 +423,12 @@ async function recordHint(
|
||||
}
|
||||
|
||||
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
||||
const parsed = aiHintRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
aiHintRequestSchema,
|
||||
"submissionId is required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "submissionId is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
const [row] = await db
|
||||
.select({ submission: schema.submission, problem: schema.problem })
|
||||
.from(schema.submission)
|
||||
@@ -1081,9 +522,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
|
||||
|
||||
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = aiHintFeedbackRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
const parsed = await readJson(c, aiHintFeedbackRequestSchema)
|
||||
if (!id || !parsed.success)
|
||||
return failure(c, 400, "invalid-request", "helpful is required")
|
||||
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
|
||||
@@ -1114,11 +553,12 @@ aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
|
||||
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
|
||||
if (!isTeacherOrAbove(c.get("user")))
|
||||
return failure(c, 403, "permission-denied", "Permission denied")
|
||||
const parsed = classAnalysisRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
classAnalysisRequestSchema,
|
||||
"Class data is required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Class data is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
const limited = await throttleAi(c)
|
||||
if (limited) return limited
|
||||
return streamChat(
|
||||
@@ -1130,16 +570,12 @@ aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
|
||||
aiRoutes.post("/ai/class-pk-analysis", requireAuth, async (c) => {
|
||||
if (!isTeacherOrAbove(c.get("user")))
|
||||
return failure(c, 403, "permission-denied", "Permission denied")
|
||||
const parsed = classPkAnalysisRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
classPkAnalysisRequestSchema,
|
||||
"At least two classes are required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
"At least two classes are required",
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const limited = await throttleAi(c)
|
||||
if (limited) return limited
|
||||
return streamChat(
|
||||
|
||||
+50
-23
@@ -7,21 +7,61 @@ import { createSession, destroySession } from "../auth/session"
|
||||
import { publishSessionRevoked } from "../events"
|
||||
import { hashPassword, verifyPassword } from "../auth/password"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { getUserProfileById } from "../services/profile"
|
||||
import {
|
||||
clientIp,
|
||||
countAttempt,
|
||||
lockoutRemaining,
|
||||
resetAttempts,
|
||||
type AttemptRule,
|
||||
} from "../services/throttling"
|
||||
|
||||
export const authRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 登录防爆破,两道都只数失败:
|
||||
*
|
||||
* - **按用户名**:盯着一个账号猜。学生的初始密码弱、`/classes/:name/usernames` 又能
|
||||
* 公开列出整班用户名,这是最现实的攻击。15 分钟 10 次,登录成功清零。
|
||||
* 代价是别人能故意输错把某个学生锁 15 分钟 —— 比被猜中密码好得多。
|
||||
* - **按 IP**:换着用户名猜(每个号只试几次常见密码,绕开上一道)。机房一个班共用一个
|
||||
* 出口 IP,所以放得很宽,而且成功**不清零**,否则攻击者拿自己的号登一次就能重置。
|
||||
*/
|
||||
const LOGIN_PER_USERNAME: AttemptRule = { limit: 10, windowSeconds: 15 * 60 }
|
||||
const LOGIN_PER_IP: AttemptRule = { limit: 100, windowSeconds: 15 * 60 }
|
||||
|
||||
authRoutes.post("/auth/login", async (c) => {
|
||||
const parsed = loginRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
loginRequestSchema,
|
||||
"Username and password are required",
|
||||
)
|
||||
if (!parsed.success) {
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const usernameKey = `login:user:${parsed.data.username.toLowerCase()}`
|
||||
const ipKey = `login:ip:${clientIp(c)}`
|
||||
const wait =
|
||||
(await lockoutRemaining(usernameKey, LOGIN_PER_USERNAME)) ??
|
||||
(await lockoutRemaining(ipKey, LOGIN_PER_IP))
|
||||
if (wait !== null) {
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-request",
|
||||
"Username and password are required",
|
||||
429,
|
||||
"too-many-login-attempts",
|
||||
`Too many failed attempts, please wait ${wait} seconds`,
|
||||
)
|
||||
}
|
||||
const loginFailed = async () => {
|
||||
await Promise.all([
|
||||
countAttempt(usernameKey, LOGIN_PER_USERNAME),
|
||||
countAttempt(ipKey, LOGIN_PER_IP),
|
||||
])
|
||||
return failure(
|
||||
c,
|
||||
401,
|
||||
"invalid-credentials",
|
||||
"Invalid username or password",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,27 +71,14 @@ authRoutes.post("/auth/login", async (c) => {
|
||||
.where(sql`lower(${schema.user.username}) = lower(${parsed.data.username})`)
|
||||
.limit(1)
|
||||
|
||||
if (!user) {
|
||||
return failure(
|
||||
c,
|
||||
401,
|
||||
"invalid-credentials",
|
||||
"Invalid username or password",
|
||||
)
|
||||
}
|
||||
if (!user) return loginFailed()
|
||||
if (user.isDisabled) {
|
||||
return failure(c, 403, "account-disabled", "Your account has been disabled")
|
||||
}
|
||||
|
||||
const password = await verifyPassword(parsed.data.password, user.password)
|
||||
if (!password.valid) {
|
||||
return failure(
|
||||
c,
|
||||
401,
|
||||
"invalid-credentials",
|
||||
"Invalid username or password",
|
||||
)
|
||||
}
|
||||
if (!password.valid) return loginFailed()
|
||||
await resetAttempts(usernameKey)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const update: { lastLogin: string; password?: string } = { lastLogin: now }
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { queryInteger, rounded } from "./helpers"
|
||||
|
||||
@@ -165,11 +165,12 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||
})
|
||||
|
||||
classroomRoutes.post("/classes/comparison", async (c) => {
|
||||
const parsed = classComparisonRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
classComparisonRequestSchema,
|
||||
"At least one class is required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "At least one class is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
const users = await loadClassUsers(parsed.data.classNames)
|
||||
const allAc = users.map((user) => user.acceptedNumber)
|
||||
const globalQ1 = quantile(allAc, 0.25)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createMessageRequestSchema,
|
||||
embeddedSubmissionSchema,
|
||||
exerciseAttemptRequestSchema,
|
||||
reactionKeySchema,
|
||||
setReactionRequestSchema,
|
||||
@@ -23,7 +22,7 @@ import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
@@ -187,11 +186,12 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
*/
|
||||
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const parsed = createMessageRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
createMessageRequestSchema,
|
||||
"Invalid message payload",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid message payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
if (parsed.data.recipientId === user.id)
|
||||
return failure(
|
||||
c,
|
||||
@@ -263,11 +263,12 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
|
||||
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setReactionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
setReactionRequestSchema,
|
||||
"Invalid reaction",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get("user")!
|
||||
const [[problem], [solved]] = await Promise.all([
|
||||
db
|
||||
@@ -447,11 +448,12 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = tutorialProgressPingSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
tutorialProgressPingSchema,
|
||||
"Invalid progress payload",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid progress payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
@@ -501,11 +503,12 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
exerciseAttemptRequestSchema,
|
||||
"Invalid attempt payload",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid attempt payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
|
||||
const [exercise] = await db
|
||||
.select({ id: schema.exercise.id })
|
||||
|
||||
@@ -27,7 +27,7 @@ import { optionalAuth, requireAuth } from "../auth/middleware"
|
||||
import { setContestPassword } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import {
|
||||
canAccessContest,
|
||||
checkContestPassword,
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
requireContestAccess,
|
||||
type ContestEnv,
|
||||
} from "../services/contest"
|
||||
import {
|
||||
countAttempt,
|
||||
lockoutRemaining,
|
||||
type AttemptRule,
|
||||
} from "../services/throttling"
|
||||
import {
|
||||
objectValue,
|
||||
publicTemplates,
|
||||
@@ -149,6 +154,8 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
|
||||
)
|
||||
})
|
||||
|
||||
const CONTEST_PASSWORD_RULE: AttemptRule = { limit: 10, windowSeconds: 10 * 60 }
|
||||
|
||||
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
const contest = await findAccessibleContest(
|
||||
c.get("user"),
|
||||
@@ -156,12 +163,26 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
)
|
||||
if (!contest || !contest.password)
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const parsed = contestPasswordRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
contestPasswordRequestSchema,
|
||||
"Password is required",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Password is required")
|
||||
if (!parsed.success) return parsed.response
|
||||
// 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。
|
||||
// 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。
|
||||
const attemptKey = `contest-password:${contest.id}:${c.get("user")!.id}`
|
||||
const wait = await lockoutRemaining(attemptKey, CONTEST_PASSWORD_RULE)
|
||||
if (wait !== null) {
|
||||
return failure(
|
||||
c,
|
||||
429,
|
||||
"too-many-password-attempts",
|
||||
`Too many wrong passwords, please wait ${wait} seconds`,
|
||||
)
|
||||
}
|
||||
if (!checkContestPassword(parsed.data.password, contest.password)) {
|
||||
await countAttempt(attemptKey, CONTEST_PASSWORD_RULE)
|
||||
return failure(
|
||||
c,
|
||||
403,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Hono } from "hono"
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, readJson, success } from "../http"
|
||||
import { flowchartQueue } from "../queue"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
@@ -85,9 +85,7 @@ function flowchartData(
|
||||
}
|
||||
|
||||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
const parsed = createFlowchartRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
const parsed = await readJson(c, createFlowchartRequestSchema)
|
||||
if (
|
||||
!parsed.success ||
|
||||
JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024
|
||||
@@ -149,7 +147,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||
})
|
||||
try {
|
||||
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
||||
} catch (error) {
|
||||
} catch {
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
.set({ status: 3 })
|
||||
@@ -644,7 +642,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
{ submissionId: row.flowchart.id },
|
||||
{ jobId: `${row.flowchart.id}:retry:${Date.now()}` },
|
||||
)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
|
||||
await db
|
||||
.update(schema.flowchartSubmission)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod"
|
||||
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure } from "../http"
|
||||
import { failure, readJson } from "../http"
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
hostname: z.string().min(1).max(128),
|
||||
@@ -38,7 +38,7 @@ async function heartbeat(c: Context) {
|
||||
return failure(c, 403, "invalid-judge-token", "Invalid token")
|
||||
}
|
||||
|
||||
const parsed = heartbeatSchema.safeParse(await c.req.json().catch(() => null))
|
||||
const parsed = await readJson(c, heartbeatSchema)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-heartbeat", "Invalid heartbeat payload")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type AppEnv,
|
||||
} from "../auth/middleware"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { computeProgress } from "../services/problemset"
|
||||
import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
@@ -342,11 +342,12 @@ async function recomputeProgress(
|
||||
}
|
||||
|
||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||
const parsed = joinProblemSetRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
joinProblemSetRequestSchema,
|
||||
"Invalid problem set",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get("user")!
|
||||
const [problemSet] = await db
|
||||
.select({ id: schema.problemset.id })
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { optionalAuth, requireTeacher } from "../auth/middleware"
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "../auth/middleware"
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { judgeQueue } from "../queue"
|
||||
import {
|
||||
@@ -93,12 +93,12 @@ async function saveTrace(
|
||||
}
|
||||
|
||||
submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
||||
const parsed = createSubmissionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
createSubmissionRequestSchema,
|
||||
"Invalid submission payload",
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return failure(c, 400, "invalid-request", "Invalid submission payload")
|
||||
}
|
||||
if (!parsed.success) return parsed.response
|
||||
let contestId: number | null = null
|
||||
if (parsed.data.contestId) {
|
||||
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
|
||||
@@ -293,11 +293,12 @@ submissionRoutes.post(
|
||||
)
|
||||
|
||||
submissionRoutes.post("/code/format", requireAuth, async (c) => {
|
||||
const parsed = formatCodeRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
const parsed = await parseBody(
|
||||
c,
|
||||
formatCodeRequestSchema,
|
||||
"Invalid format payload",
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid format payload")
|
||||
if (!parsed.success) return parsed.response
|
||||
try {
|
||||
const code = await formatCode(parsed.data.code, parsed.data.language)
|
||||
return success(c, { code } satisfies FormatCodeResponse)
|
||||
|
||||
+68
-13
@@ -19,11 +19,29 @@ function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
|
||||
|
||||
/**
|
||||
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
|
||||
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
|
||||
* 客户端断开本来就能收尾。
|
||||
* 学生那边的按钮也就一直转。
|
||||
*/
|
||||
const COMPLETE_TIMEOUT_MS = 60_000
|
||||
|
||||
/**
|
||||
* 流式调用的**空闲**超时:连续这么久没收到一个字节就放弃。不用总时长超时 —— 那会把
|
||||
* 正在推的长回答直接掐断;空闲超时每收到一块就重新计时,只拦「provider 卡住不动」。
|
||||
* 首字节也算在内(DeepSeek 首 token 通常几秒,给足余量)。
|
||||
*/
|
||||
const STREAM_IDLE_TIMEOUT_MS = 45_000
|
||||
|
||||
/**
|
||||
* 推给前端的错误文案。真实原因(provider 的 HTTP 响应体、数据库报错……)只进日志和
|
||||
* `onError` 留痕,**不下发**:学生看不懂,也不该看到上游的响应内容。
|
||||
*/
|
||||
const CLIENT_ERROR_MESSAGE = "AI 服务暂时不可用,请稍后再试"
|
||||
const MISSING_KEY_MESSAGE = "AI 功能未开启"
|
||||
|
||||
/** 给客户端的错误事件;原因另行记日志 */
|
||||
function errorEvent(message: string) {
|
||||
return `data: ${JSON.stringify({ type: "error", message })}\n\n`
|
||||
}
|
||||
|
||||
export async function completeChat(
|
||||
system: string,
|
||||
user: string,
|
||||
@@ -81,23 +99,42 @@ export function streamChat(
|
||||
hooks.onError?.(message).catch((error) => {
|
||||
console.error("streamChat onError hook failed", error)
|
||||
})
|
||||
// 客户端断开(cancel)和空闲超时都走这一个 controller,把上游的 fetch / read 一起停掉。
|
||||
// 原来客户端走了之后这里还在把 provider 的回答一块块读完,白烧 token
|
||||
const upstream = new AbortController()
|
||||
let closed = false
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const touch = () => {
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(
|
||||
() => upstream.abort(new Error("AI provider stream idle timeout")),
|
||||
STREAM_IDLE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||
const send = (value: string) => {
|
||||
if (closed) return
|
||||
try {
|
||||
controller.enqueue(encoder.encode(value))
|
||||
} catch {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
if (!config.aiKey) {
|
||||
await reportError("缺少 AI_KEY")
|
||||
send(
|
||||
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
|
||||
)
|
||||
send(errorEvent(MISSING_KEY_MESSAGE))
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
if (!closed) controller.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
touch()
|
||||
const response = await fetch(
|
||||
new URL("/chat/completions", config.aiBaseUrl),
|
||||
{
|
||||
method: "POST",
|
||||
signal: upstream.signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${config.aiKey}`,
|
||||
@@ -123,6 +160,7 @@ export function streamChat(
|
||||
let buffer = ""
|
||||
const chunks: string[] = []
|
||||
while (true) {
|
||||
touch()
|
||||
const { done, value } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
const lines = buffer.split("\n")
|
||||
@@ -157,15 +195,25 @@ export function streamChat(
|
||||
: undefined
|
||||
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
|
||||
// 客户端主动走掉不算 AI 失败,但照样留痕:这条分析没有落库
|
||||
const message = closed
|
||||
? "客户端已断开"
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
if (!closed) console.error("streamChat failed", error)
|
||||
await reportError(message)
|
||||
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
||||
send(errorEvent(CLIENT_ERROR_MESSAGE))
|
||||
} finally {
|
||||
clearTimeout(idleTimer)
|
||||
send("event: end\n\n")
|
||||
controller.close()
|
||||
if (!closed) controller.close()
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true
|
||||
upstream.abort(new Error("client disconnected"))
|
||||
},
|
||||
})
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
@@ -222,11 +270,18 @@ export function streamWhole(
|
||||
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// 先留痕再回前端
|
||||
console.error("streamWhole failed", error)
|
||||
// 先留痕再回前端;前端只拿固定文案,原因见 CLIENT_ERROR_MESSAGE
|
||||
await hooks.onError?.(message).catch((e) => {
|
||||
console.error("streamWhole onError hook failed", e)
|
||||
})
|
||||
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
||||
send(
|
||||
errorEvent(
|
||||
message === "缺少 AI_KEY"
|
||||
? MISSING_KEY_MESSAGE
|
||||
: CLIENT_ERROR_MESSAGE,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
send("event: end\n\n")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 按文件头认图片格式,不信文件名。头像和题面插图原来只看扩展名:改个后缀什么都能
|
||||
* 落盘,再由 /public/* 按扩展名配 content-type 发出去。
|
||||
*
|
||||
* 返回值就是落盘该用的扩展名 —— 以内容为准,一张改名成 .png 的 JPEG 会存成 .jpg,
|
||||
* 浏览器拿到的 content-type 才对得上。
|
||||
*/
|
||||
const SIGNATURES: [extension: string, bytes: number[]][] = [
|
||||
[".png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
||||
[".jpg", [0xff, 0xd8, 0xff]],
|
||||
[".gif", [0x47, 0x49, 0x46, 0x38]], // GIF8(GIF87a / GIF89a)
|
||||
[".bmp", [0x42, 0x4d]], // BM
|
||||
]
|
||||
|
||||
export async function sniffImageExtension(file: Blob) {
|
||||
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer())
|
||||
for (const [extension, bytes] of SIGNATURES) {
|
||||
if (bytes.every((byte, index) => head[index] === byte)) return extension
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* 学情统计:AI 学情分析页(/ai/detail、/ai/duration、/ai/solved)和喂给模型的
|
||||
* 数据都从这里算。原来整段放在 routes/ai.ts 里,占了那个文件六成;它们只是查询,
|
||||
* 不碰 Context,挪出来 route 文件只剩「鉴权 → 取参 → 调这里 → 回包」。
|
||||
*/
|
||||
import {
|
||||
type AiDetail,
|
||||
type DurationData,
|
||||
type Grade,
|
||||
type SolvedList,
|
||||
type SolvedProblem,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
countDistinct,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
lte,
|
||||
min,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import type { AuthUser } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus, type JudgeStatusValue } from "../judge/status"
|
||||
import { rounded } from "../routes/helpers"
|
||||
import { localTime, shiftMonthsByCalendar } from "../time"
|
||||
|
||||
export const accepted: JudgeStatusValue[] = [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]
|
||||
const difficultyNames: Record<string, string> = {
|
||||
Low: "简单",
|
||||
Mid: "中等",
|
||||
High: "困难",
|
||||
}
|
||||
|
||||
function grade(rank: number | null, count: number, reference = count): Grade {
|
||||
if (!rank || count <= 0) return "C"
|
||||
const percentile = ((rank - 1) / count) * 100
|
||||
let value: Grade =
|
||||
percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
|
||||
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
|
||||
return value
|
||||
}
|
||||
|
||||
function averageGrade(grades: Grade[]): Grade {
|
||||
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
|
||||
const values = grades.flatMap((item) => weights[item] ?? [])
|
||||
if (!values.length) return ""
|
||||
const average = values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
return average >= 3.5
|
||||
? "S"
|
||||
: average >= 2.5
|
||||
? "A"
|
||||
: average >= 1.5
|
||||
? "B"
|
||||
: "C"
|
||||
}
|
||||
|
||||
type FirstAcRow = { problemId: number; first: string | null }
|
||||
|
||||
/** 区间内首次 AC 的题,按通过时间升序。limit/offset 给分页用,不传就是全部 */
|
||||
function firstAcQuery(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) {
|
||||
const first = min(schema.submission.createTime)
|
||||
const query = db
|
||||
.select({ problemId: schema.submission.problemId, first })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.result, accepted),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId)
|
||||
.orderBy(asc(first))
|
||||
return limit === undefined ? query : query.limit(limit).offset(offset ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一批「首次 AC」的题算成逐题明细(排名、等级、尝试次数)。
|
||||
* 排名只跟这批题有关,所以分页那支只需要给一页的 problemIds,不必把整年算一遍。
|
||||
*/
|
||||
export async function buildSolved(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
firstAc: FirstAcRow[],
|
||||
) {
|
||||
const problemIds = firstAc.map((item) => item.problemId)
|
||||
if (!problemIds.length)
|
||||
return {
|
||||
solved: [],
|
||||
problems: [] as {
|
||||
problem: typeof schema.problem.$inferSelect
|
||||
contestTitle: string | null
|
||||
}[],
|
||||
scopeIds: null as number[] | null,
|
||||
}
|
||||
const classUsers = user.className
|
||||
? await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, user.className))
|
||||
: []
|
||||
const scopeIds =
|
||||
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const [problems, rankRows, periodRows, attemptRows] = await Promise.all([
|
||||
db
|
||||
.select({ problem: schema.problem, contestTitle: schema.contest.title })
|
||||
.from(schema.problem)
|
||||
.leftJoin(schema.contest, eq(schema.problem.contestId, schema.contest.id))
|
||||
.where(inArray(schema.problem.id, problemIds)),
|
||||
db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
first: min(schema.submission.createTime),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, problemIds),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||
db
|
||||
.select({
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
first: min(schema.submission.createTime),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, problemIds),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
time: schema.submission.createTime,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.problemId, problemIds),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
),
|
||||
])
|
||||
const byProblem = new Map(problems.map((item) => [item.problem.id, item]))
|
||||
// 到首次通过为止提交了几次:只数首次 AC 那一刻(含)之前的提交
|
||||
const firstAcTime = new Map(
|
||||
firstAc.flatMap((item) =>
|
||||
item.first ? ([[item.problemId, Date.parse(item.first)]] as const) : [],
|
||||
),
|
||||
)
|
||||
const attemptsByProblem = new Map<number, number>()
|
||||
for (const row of attemptRows) {
|
||||
const deadline = firstAcTime.get(row.problemId)
|
||||
if (deadline === undefined || Date.parse(row.time) > deadline) continue
|
||||
attemptsByProblem.set(
|
||||
row.problemId,
|
||||
(attemptsByProblem.get(row.problemId) ?? 0) + 1,
|
||||
)
|
||||
}
|
||||
function ranks(rows: typeof rankRows, problemId: number) {
|
||||
return rows
|
||||
.filter((item) => item.problemId === problemId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Date.parse(a.first ?? "") - Date.parse(b.first ?? "") ||
|
||||
a.userId - b.userId,
|
||||
)
|
||||
}
|
||||
const solved = firstAc
|
||||
.flatMap((item) => {
|
||||
const problem = byProblem.get(item.problemId)
|
||||
if (!problem || !item.first) return []
|
||||
const all = ranks(rankRows, item.problemId)
|
||||
const period = ranks(periodRows, item.problemId)
|
||||
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
const periodRank =
|
||||
period.findIndex((row) => row.userId === user.id) + 1 || null
|
||||
return {
|
||||
problem: {
|
||||
title: problem.problem.title,
|
||||
displayId: problem.problem.displayId,
|
||||
contestTitle: problem.contestTitle ?? "",
|
||||
contestId: problem.problem.contestId,
|
||||
},
|
||||
acTime: item.first,
|
||||
rank,
|
||||
acCount: all.length,
|
||||
grade: grade(periodRank, period.length, all.length),
|
||||
periodRank,
|
||||
periodAcCount: period.length,
|
||||
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
|
||||
attempts: attemptsByProblem.get(item.problemId) ?? 1,
|
||||
} satisfies SolvedProblem
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
|
||||
return { solved, problems, scopeIds }
|
||||
}
|
||||
|
||||
/** 分页版:只算这一页的题 */
|
||||
export async function listSolved(
|
||||
user: AuthUser,
|
||||
start: string,
|
||||
end: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) {
|
||||
const [firstAc, totalRows] = await Promise.all([
|
||||
firstAcQuery(user, start, end, limit, offset),
|
||||
db
|
||||
.select({ value: countDistinct(schema.submission.problemId) })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
inArray(schema.submission.result, accepted),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
),
|
||||
])
|
||||
const { solved } = await buildSolved(user, start, end, firstAc)
|
||||
return {
|
||||
results: solved,
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies SolvedList
|
||||
}
|
||||
|
||||
export async function buildDetail(user: AuthUser, start: string, end: string) {
|
||||
// 时间活跃度按**全部提交**统计,不是只按 AC。只看 AC 的话,一个学生两个月十来次
|
||||
// 通过撒进 7×4 的格子里几乎全是空的,"高峰时段"根本看不出来。
|
||||
// 星期和小时都按东八区取,和热力图同口径
|
||||
const weekday =
|
||||
sql<number>`extract(dow from ${localTime(schema.submission.createTime)})::int`.mapWith(
|
||||
Number,
|
||||
)
|
||||
const period =
|
||||
sql<number>`floor(extract(hour from ${localTime(schema.submission.createTime)}) / 6)::int`.mapWith(
|
||||
Number,
|
||||
)
|
||||
const activityRows = await db
|
||||
.select({ weekday, period, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
)
|
||||
.groupBy(weekday, period)
|
||||
const activity = activityRows.map((row) => ({
|
||||
weekday: row.weekday,
|
||||
period: row.period,
|
||||
count: row.value,
|
||||
}))
|
||||
// 区间内该用户的全部提交,一次拉回来喂两处:错题类型分布、每题到首次通过的尝试次数。
|
||||
// 放在 problemIds 的空判断之前 —— 一道题都没做出来的学生,错题分布照样有意义
|
||||
const submissions = await db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
time: schema.submission.createTime,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, start),
|
||||
lte(schema.submission.createTime, end),
|
||||
),
|
||||
)
|
||||
const settledFail = (result: JudgeStatusValue) =>
|
||||
!accepted.includes(result) &&
|
||||
result !== JudgeStatus.PENDING &&
|
||||
result !== JudgeStatus.JUDGING
|
||||
const errorCounts = new Map<number, number>()
|
||||
for (const row of submissions) {
|
||||
if (!settledFail(row.result)) continue
|
||||
errorCounts.set(row.result, (errorCounts.get(row.result) ?? 0) + 1)
|
||||
}
|
||||
const errors = [...errorCounts]
|
||||
.map(([result, count]) => ({ result, count }))
|
||||
.sort((a, b) => b.count - a.count || a.result - b.result)
|
||||
const firstAc = await firstAcQuery(user, start, end)
|
||||
const problemIds = firstAc.map((item) => item.problemId)
|
||||
if (!problemIds.length)
|
||||
return {
|
||||
user: user.username,
|
||||
className: user.className,
|
||||
start,
|
||||
end,
|
||||
solvedCount: 0,
|
||||
attempts: [],
|
||||
flowcharts: [],
|
||||
grade: "",
|
||||
tags: {},
|
||||
difficulty: {},
|
||||
contestCount: 0,
|
||||
activity,
|
||||
errors,
|
||||
rankScope: "global",
|
||||
} satisfies AiDetail
|
||||
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all(
|
||||
[
|
||||
buildSolved(user, start, end, firstAc),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.problemTags.problemId,
|
||||
name: schema.problemTag.name,
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(inArray(schema.problemTags.problemId, problemIds)),
|
||||
db
|
||||
.select({
|
||||
flow: schema.flowchartSubmission,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.flowchartSubmission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
eq(schema.flowchartSubmission.status, 2),
|
||||
gte(schema.flowchartSubmission.createTime, start),
|
||||
lte(schema.flowchartSubmission.createTime, end),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
const tags: Record<string, number> = {}
|
||||
for (const tag of tagRows) tags[tag.name] = (tags[tag.name] ?? 0) + 1
|
||||
const topTags = Object.fromEntries(
|
||||
Object.entries(tags)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5),
|
||||
)
|
||||
const difficulty: Record<string, number> = { 简单: 0, 中等: 0, 困难: 0 }
|
||||
for (const item of problems) {
|
||||
const name = difficultyNames[item.problem.difficulty] ?? "中等"
|
||||
difficulty[name] = (difficulty[name] ?? 0) + 1
|
||||
}
|
||||
const flowGroups = new Map<string, typeof flowRows>()
|
||||
for (const flow of flowRows)
|
||||
flowGroups.set(flow.displayId, [
|
||||
...(flowGroups.get(flow.displayId) ?? []),
|
||||
flow,
|
||||
])
|
||||
const flowcharts = [...flowGroups]
|
||||
.map(([displayId, rows]) => {
|
||||
const scores = rows.flatMap((row) => row.flow.aiScore ?? [])
|
||||
// 直接留住得分最高的那一次,等级读它。原来是拿 max 回头 find 分数相等的行 ——
|
||||
// ai_score 是 double,相等比较本就不可靠;全是 null 时 max 退成 0,更是谁都匹配不上
|
||||
const top = rows.reduce(
|
||||
(best, row) =>
|
||||
(row.flow.aiScore ?? -1) > (best.flow.aiScore ?? -1) ? row : best,
|
||||
rows[0]!,
|
||||
)
|
||||
return {
|
||||
problemId: displayId,
|
||||
problemTitle: rows[0]?.title ?? "",
|
||||
submissionCount: rows.length,
|
||||
bestScore: Math.max(0, top.flow.aiScore ?? 0),
|
||||
bestGrade: top.flow.aiGrade ?? "",
|
||||
latestSubmissionTime:
|
||||
rows
|
||||
.map((row) => row.flow.createTime)
|
||||
.sort()
|
||||
.at(-1) ?? start,
|
||||
avgScore: rounded(
|
||||
scores.length
|
||||
? scores.reduce((sum, value) => sum + value, 0) / scores.length
|
||||
: 0,
|
||||
0,
|
||||
),
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.latestSubmissionTime.localeCompare(a.latestSubmissionTime),
|
||||
)
|
||||
return {
|
||||
user: user.username,
|
||||
className: user.className,
|
||||
start,
|
||||
end,
|
||||
flowcharts,
|
||||
solvedCount: solved.length,
|
||||
attempts: solved.map((item) => item.attempts),
|
||||
grade: averageGrade(solved.map((item) => item.grade)),
|
||||
tags: topTags,
|
||||
difficulty,
|
||||
contestCount: new Set(
|
||||
solved.flatMap((item) => item.problem.contestId ?? []),
|
||||
).size,
|
||||
activity,
|
||||
errors,
|
||||
rankScope: scopeIds ? "class" : "global",
|
||||
} satisfies AiDetail
|
||||
}
|
||||
|
||||
export async function buildDuration(
|
||||
user: AuthUser,
|
||||
endText: string,
|
||||
duration: string,
|
||||
) {
|
||||
const config =
|
||||
duration === "months:2"
|
||||
? {
|
||||
count: 8,
|
||||
unit: "weeks",
|
||||
rewind: (date: Date) => new Date(date.getTime() - 9 * 7 * 864e5),
|
||||
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
|
||||
}
|
||||
: duration === "months:6"
|
||||
? {
|
||||
count: 6,
|
||||
unit: "months",
|
||||
rewind: (date: Date) => shiftMonthsByCalendar(date, -7),
|
||||
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
|
||||
}
|
||||
: duration === "years:1"
|
||||
? {
|
||||
count: 12,
|
||||
unit: "months",
|
||||
rewind: (date: Date) => shiftMonthsByCalendar(date, -13),
|
||||
advance: (date: Date) => shiftMonthsByCalendar(date, 1),
|
||||
}
|
||||
: {
|
||||
count: 4,
|
||||
unit: "weeks",
|
||||
rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5),
|
||||
advance: (date: Date) => new Date(date.getTime() + 7 * 864e5),
|
||||
}
|
||||
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
|
||||
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
|
||||
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
|
||||
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
|
||||
let cursor = config.rewind(new Date(endText))
|
||||
const buckets: { start: Date; end: Date }[] = []
|
||||
for (let index = 0; index < config.count; index++) {
|
||||
const start = config.advance(cursor)
|
||||
buckets.push({ start, end: config.advance(start) })
|
||||
cursor = start
|
||||
}
|
||||
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
|
||||
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
|
||||
const rows = await db
|
||||
.select({
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
|
||||
Number,
|
||||
),
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
|
||||
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
|
||||
),
|
||||
)
|
||||
// 每个桶的等级 = 桶内解出的每道题各算一个等级再取平均,排名按「同班同学在这个桶里
|
||||
// 解出该题的先后」。和旧后端 OnlineJudge/ai/views/oj.py:484 一条一条对齐,包括这里
|
||||
// 不传 reference(不打小规模折扣)—— 那个折扣只在 /ai/detail 那支用。
|
||||
// 迁移时这里被写死成 `solved ? "B" : ""`,DurationChart 上那条等级折线因此恒定在 B。
|
||||
const solvedIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.filter((row) => accepted.includes(row.result))
|
||||
.map((row) => row.problemId),
|
||||
),
|
||||
]
|
||||
const classUsers = user.className
|
||||
? await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.className, user.className))
|
||||
: []
|
||||
const scopeIds =
|
||||
classUsers.length > 1 ? classUsers.map((item) => item.id) : null
|
||||
const peers = solvedIds.length
|
||||
? await db
|
||||
.select({
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(
|
||||
Number,
|
||||
),
|
||||
userId: schema.submission.userId,
|
||||
problemId: schema.submission.problemId,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.submission.result, accepted),
|
||||
inArray(schema.submission.problemId, solvedIds),
|
||||
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
|
||||
lte(
|
||||
schema.submission.createTime,
|
||||
buckets.at(-1)!.end.toISOString(),
|
||||
),
|
||||
scopeIds ? inArray(schema.submission.userId, scopeIds) : undefined,
|
||||
),
|
||||
)
|
||||
: []
|
||||
// 一次查回来在内存里按题分组再按桶切,别在循环里发查询:一年 12 个桶 × 几十道题
|
||||
const peersByProblem = new Map<number, typeof peers>()
|
||||
for (const row of peers)
|
||||
peersByProblem.set(row.problemId, [
|
||||
...(peersByProblem.get(row.problemId) ?? []),
|
||||
row,
|
||||
])
|
||||
|
||||
function bucketGrade(problemIds: number[], from: number, to: number) {
|
||||
return averageGrade(
|
||||
problemIds.map((problemId) => {
|
||||
const firstAc = new Map<number, number>()
|
||||
for (const row of peersByProblem.get(problemId) ?? []) {
|
||||
if (row.time < from || row.time > to) continue
|
||||
const seen = firstAc.get(row.userId)
|
||||
if (seen === undefined || row.time < seen)
|
||||
firstAc.set(row.userId, row.time)
|
||||
}
|
||||
const ordered = [...firstAc].sort((a, b) => a[1] - b[1] || a[0] - b[0])
|
||||
const rank = ordered.findIndex(([id]) => id === user.id) + 1 || null
|
||||
return grade(rank, ordered.length)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return buckets.map((bucket, index) => {
|
||||
const from = bucket.start.getTime()
|
||||
const to = bucket.end.getTime()
|
||||
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
|
||||
const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
|
||||
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
|
||||
return {
|
||||
unit: config.unit,
|
||||
index: config.count - 1 - index,
|
||||
start: bucket.start.toISOString(),
|
||||
end: bucket.end.toISOString(),
|
||||
grade: solved.length ? bucketGrade(solved, from, to) : "",
|
||||
problemCount: solved.length,
|
||||
acceptedCount: acceptedRows.length,
|
||||
submissionCount: inRange.length,
|
||||
} satisfies DurationData
|
||||
})
|
||||
}
|
||||
@@ -77,6 +77,38 @@ function collectSqlScripts(names: Set<string>) {
|
||||
return scripts
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压**之前**按条目头拦大小。原来是先 `unzipSync` 整包解进内存、再逐个查大小 ——
|
||||
* 一个几十 KB 的 zip bomb 在检查之前就能把 512MB 的容器撑爆。
|
||||
*
|
||||
* 条目头里的 `originalSize` 是压缩包自己声明的,可以造假,但 fflate 按这个值分配
|
||||
* 输出缓冲、不会扩容(`inflateSync(..., { out: new u8(su) })`,解多了只会截断),
|
||||
* 所以按它限额就是按实际内存限额。顺带只解需要的文件名,压缩包里别的东西一律不解。
|
||||
*/
|
||||
function wantedEntry(file: { name: string }) {
|
||||
const match = /^(\d+)\.(in|out|sql)$/.exec(file.name)
|
||||
return !!match && Number(match[1]) >= 1 && Number(match[1]) <= MAX_CASES
|
||||
}
|
||||
|
||||
function entryFilter() {
|
||||
let total = 0
|
||||
return (file: { name: string; originalSize: number }) => {
|
||||
if (!wantedEntry(file)) return false
|
||||
if (file.originalSize > MAX_ENTRY_BYTES) {
|
||||
throw new TestCaseError(
|
||||
`测试点 ${file.name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`,
|
||||
)
|
||||
}
|
||||
total += file.originalSize
|
||||
if (total > MAX_TOTAL_BYTES) {
|
||||
throw new TestCaseError(
|
||||
`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProcessedTestCase {
|
||||
testCaseId: string
|
||||
info: TestCaseEntry[]
|
||||
@@ -88,16 +120,19 @@ export async function processTestCaseZip(
|
||||
): Promise<ProcessedTestCase> {
|
||||
let files: Record<string, Uint8Array>
|
||||
try {
|
||||
files = unzipSync(archive)
|
||||
} catch {
|
||||
// 第一遍只读目录、一个字节都不解(filter 恒返回 false),超限就在这里抛;
|
||||
// 第二遍才真解。否则 fflate 边过滤边解,超限之前已经解了上百 MB
|
||||
const check = entryFilter()
|
||||
unzipSync(archive, { filter: (file) => (check(file), false) })
|
||||
files = unzipSync(archive, { filter: wantedEntry })
|
||||
} catch (error) {
|
||||
if (error instanceof TestCaseError) throw error
|
||||
throw new TestCaseError("压缩包损坏或不是 zip 格式")
|
||||
}
|
||||
|
||||
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
|
||||
// 条目名一律不参与路径拼接,zip slip(`../../etc/passwd` 这类条目名)从设计上就进不来。
|
||||
const names = new Set(
|
||||
Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)),
|
||||
)
|
||||
const names = new Set(Object.keys(files))
|
||||
|
||||
const selected = options.sql
|
||||
? collectSqlScripts(names)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Context } from "hono"
|
||||
|
||||
import { redis } from "../redis"
|
||||
import { getOptions } from "./options"
|
||||
|
||||
@@ -166,3 +168,50 @@ export async function consumeToken(
|
||||
if (Number(result[0]) === 1) return { allowed: true }
|
||||
return { allowed: false, wait: Number(result[1]) || 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计数锁定:固定窗口内计满 `limit` 次就锁到窗口结束。**什么时候计数由调用方决定**:
|
||||
*
|
||||
* - 登录、比赛密码只在**失败**时计数 —— 正常用户几乎不会连续输错,暴力猜解全是失败,
|
||||
* 只数失败的话机房一个班同时登录也不会误伤;
|
||||
* - 注册在**成功**时计数 —— 限的是批量造号。
|
||||
*
|
||||
* 和上面的令牌桶是两回事:令牌桶限的是资源消耗(判题沙箱、AI),带匀速回填;
|
||||
* 这里是「窗口内满多少次就停」。用固定窗口而不是滑动窗口:一个 INCR + EXPIRE NX 就够,
|
||||
* 锁定时长就是 key 的剩余 TTL,精度对防爆破足够了。
|
||||
*/
|
||||
export type AttemptRule = { limit: number; windowSeconds: number }
|
||||
|
||||
/** 已锁定时返回还要等几秒,否则 null。只读,不计数。 */
|
||||
export async function lockoutRemaining(key: string, rule: AttemptRule) {
|
||||
const [count, ttl] = await Promise.all([
|
||||
redis.get(`attempts:${key}`),
|
||||
redis.ttl(`attempts:${key}`),
|
||||
])
|
||||
if (Number(count) < rule.limit) return null
|
||||
// ttl 为 -1(没有过期时间)理论上不会出现,EXPIRE NX 总会补上;兜底按整窗算
|
||||
return ttl > 0 ? ttl : rule.windowSeconds
|
||||
}
|
||||
|
||||
export async function countAttempt(key: string, rule: AttemptRule) {
|
||||
await redis
|
||||
.multi()
|
||||
.incr(`attempts:${key}`)
|
||||
// NX:只在窗口里第一次计数时设过期,之后不续期 —— 否则攻击者持续猜就永远锁着,
|
||||
// 真正的主人过了窗口也登不进来
|
||||
.expire(`attempts:${key}`, rule.windowSeconds, "NX")
|
||||
.exec()
|
||||
}
|
||||
|
||||
export async function resetAttempts(key: string) {
|
||||
await redis.del(`attempts:${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端 IP。线上 api 端口不对外发布,只有 Caddy 连得到它,Caddy 用 `{client_ip}`
|
||||
* **覆盖**写入 X-Real-IP(它只信任内网来源的 X-Forwarded-For,见 docker/Caddyfile),
|
||||
* 所以这个头是可信的。本机 dev 走 Vite 代理没有这个头,统一落到一个桶里,无所谓。
|
||||
*/
|
||||
export function clientIp(c: Context) {
|
||||
return c.req.header("x-real-ip") || "unknown"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorMessage } from "utils/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import type { AnnouncementEdit } from "utils/types"
|
||||
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
|
||||
@@ -63,8 +64,8 @@ async function submit() {
|
||||
message.success("修改已保存")
|
||||
}
|
||||
router.push({ name: "admin announcement list" })
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { formatISO } from "date-fns"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
|
||||
@@ -102,8 +103,8 @@ async function submit() {
|
||||
message.success("修改已保存")
|
||||
}
|
||||
router.push({ name: "admin contest list" })
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
|
||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
@@ -70,8 +71,8 @@ async function toggleChecked(item: HelperItem) {
|
||||
submissions.value = [...submissions.value]
|
||||
|
||||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "操作失败")
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "操作失败"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +102,9 @@ async function markAllAsChecked() {
|
||||
|
||||
loadingMsg.destroy()
|
||||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
loadingMsg.destroy()
|
||||
message.error(err.data || "批量操作失败")
|
||||
message.error(errorMessage(err, "批量操作失败"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +160,8 @@ async function viewSubmission(item: HelperItem) {
|
||||
}
|
||||
|
||||
toggleCodePanel(true)
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载提交失败")
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "加载提交失败"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,8 +175,8 @@ async function loadData() {
|
||||
// 再获取 AC 提交列表
|
||||
const data = await getACMHelperList(Number(props.contestID))
|
||||
submissions.value = data
|
||||
} catch (err: any) {
|
||||
message.error(err.data || "加载失败")
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
@@ -33,8 +34,8 @@ async function handleDeleteProblem() {
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.error === "problem-has-submissions") {
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "problem-has-submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
} else {
|
||||
message.error("删除失败")
|
||||
@@ -81,13 +82,13 @@ async function handleMakePublic() {
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.error === "display-id-exists") {
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.error === "already-public") {
|
||||
} else if (errorCode(err) === "already-public") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
message.error("转换失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { addProblemForContest } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
@@ -22,13 +23,13 @@ async function addProblem() {
|
||||
displayID.value,
|
||||
)
|
||||
emit("added")
|
||||
} catch (err: any) {
|
||||
if (err.error === "display-id-exists") {
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("显示编号重复了,请重新写一个")
|
||||
} else if (err.error === "contest-ended") {
|
||||
} else if (errorCode(err) === "contest-ended") {
|
||||
message.error("这场比赛已经结束了,不能添加题目")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ function getRulesForLang(lang: string): AstRule[] {
|
||||
}
|
||||
|
||||
function updateRules(lang: string, rules: AstRule[]) {
|
||||
const current = { ...(props.modelValue || {}) }
|
||||
const current = { ...props.modelValue }
|
||||
if (rules.length === 0) {
|
||||
delete current[lang]
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode, isApiError } from "utils/api"
|
||||
import type { LANGUAGE, SqlDisplay, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
|
||||
@@ -81,15 +82,15 @@ onMounted(async () => {
|
||||
if (res.length) {
|
||||
scripts.value = res.map((f) => ({ ...blankEntry(), sql: f.content }))
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
// 新题、以及旧格式(非 SQL)测试点,后端回 404/409,保持空白就是对的,不该报错。
|
||||
// 但**其它**失败必须说出来:读不出来时编辑器长得和「这题本来就没测试点」一模一样,
|
||||
// 教师看不出区别,会以为要自己重新填。保存本身是安全的(后端读不到测试点信息会拒绝
|
||||
// 整个保存),所以这里只提示、不阻塞。
|
||||
const code = err?.error
|
||||
const code = errorCode(err)
|
||||
if (code && code !== "problem-not-found" && code !== "not-sql-test-case") {
|
||||
// 后端那句话(如"测试点信息读取失败")只说了现象,教师需要的是「下面是空的、别存」
|
||||
const detail = err?.data ? `(${err.data})` : ""
|
||||
const detail = isApiError(err) && err.data ? `(${err.data})` : ""
|
||||
loadError.value =
|
||||
`已有测试点脚本没能读出来${detail}。下方是空白模板,不是本题真实的测试点 —— ` +
|
||||
`直接保存不会丢数据(后端会拒绝),但也不会生效。请先排查测试点文件,或重新上传。`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
@@ -265,9 +266,6 @@ async function getProblemDetail() {
|
||||
code: "",
|
||||
}))
|
||||
}
|
||||
if (problem.value.contestId) {
|
||||
problem.value.contestId = problem.value.contestId
|
||||
}
|
||||
|
||||
// 下面是用来显示的:
|
||||
// 代码模板 和 模板开关
|
||||
@@ -484,11 +482,11 @@ async function submit() {
|
||||
params: { contestID: props.contestID },
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.error === "display-id-exists") {
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("显示编号重复了,请换一个显示编号")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,9 +513,9 @@ async function generateMermaid() {
|
||||
)
|
||||
problem.value.mermaidCode = res.flowchart
|
||||
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
// 没有 finally 的话,AI 接口回 502 时按钮永远卡在 loading,只能刷页面
|
||||
message.error(err.data || "生成失败,请稍后再试")
|
||||
message.error(errorMessage(err, "生成失败,请稍后再试"))
|
||||
} finally {
|
||||
isAIGenerating.value = false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorMessage } from "utils/api"
|
||||
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
@@ -24,8 +25,8 @@ async function handleDeleteProblemSet() {
|
||||
await deleteProblemSet(props.problemSetId)
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
message.error("删除失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("删除失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +54,8 @@ async function handleUpdateStatus() {
|
||||
message.success("状态更新成功")
|
||||
showStatusModal.value = false
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
message.error("状态更新失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("状态更新失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
conditionType: "all_problems" | "problem_count" | "score"
|
||||
conditionValue?: number
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const newBadgeName = ref("")
|
||||
const newBadgeDescription = ref("")
|
||||
const newBadgeIcon = ref("")
|
||||
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const newBadgeConditionValue = ref(1)
|
||||
|
||||
const BADGE_LEN = 6
|
||||
const badgeIconOptions = []
|
||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
||||
badgeIconOptions.push({
|
||||
label: `奖章${i}`,
|
||||
value: `/badge-${i}.png`,
|
||||
icon: `/badge-${i}.png`,
|
||||
})
|
||||
}
|
||||
|
||||
const conditionTypeOptions = [
|
||||
{ label: "完成所有题目", value: "all_problems" },
|
||||
{ label: "完成指定数量题目", value: "problem_count" },
|
||||
{ label: "达到指定分数", value: "score" },
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
name: newBadgeName.value,
|
||||
description: newBadgeDescription.value,
|
||||
icon: newBadgeIcon.value,
|
||||
conditionType: newBadgeConditionType.value,
|
||||
// 只有非"完成所有题目"时才带条件值
|
||||
...(newBadgeConditionType.value === "all_problems"
|
||||
? {}
|
||||
: { conditionValue: newBadgeConditionValue.value }),
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
newBadgeName.value = ""
|
||||
newBadgeDescription.value = ""
|
||||
newBadgeIcon.value = ""
|
||||
newBadgeConditionType.value = "all_problems"
|
||||
newBadgeConditionValue.value = 1
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="添加奖章"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="奖章名称" required>
|
||||
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="newBadgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="newBadgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
newBadgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
:src="option.icon"
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
style="border-radius: 2px"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
<n-flex align="center">
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="newBadgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="newBadgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="newBadgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-flex>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
problemId: string
|
||||
order: number
|
||||
isRequired: boolean
|
||||
score: number
|
||||
hint: string
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const newProblemId = ref("")
|
||||
const newProblemOrder = ref(0)
|
||||
const newProblemRequired = ref(true)
|
||||
const newProblemScore = ref(0)
|
||||
const newProblemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
problemId: newProblemId.value,
|
||||
order: newProblemOrder.value,
|
||||
isRequired: newProblemRequired.value,
|
||||
score: newProblemScore.value,
|
||||
hint: newProblemHint.value,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
newProblemId.value = ""
|
||||
newProblemOrder.value = 0
|
||||
newProblemRequired.value = true
|
||||
newProblemScore.value = 0
|
||||
newProblemHint.value = ""
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="添加题目"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="题目ID" required>
|
||||
<n-input
|
||||
v-model:value="newProblemId"
|
||||
placeholder="请输入题目的显示ID(如:1001)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="newProblemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="newProblemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number
|
||||
v-model:value="newProblemScore"
|
||||
placeholder="题目分数"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="newProblemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
+49
-44
@@ -1,35 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminProblemSetBadge } from "utils/types"
|
||||
|
||||
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。
|
||||
// 原来是两份八成相同的组件,改一处表单项另一处总要漏
|
||||
interface Props {
|
||||
show: boolean
|
||||
badge: AdminProblemSetBadge | null
|
||||
}
|
||||
|
||||
type BadgeFormData = {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
conditionType: "all_problems" | "problem_count" | "score"
|
||||
conditionValue?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
conditionType: "all_problems" | "problem_count" | "score"
|
||||
conditionValue?: number
|
||||
},
|
||||
): void
|
||||
(e: "create", data: BadgeFormData): void
|
||||
(e: "update", data: BadgeFormData): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editBadgeName = ref("")
|
||||
const editBadgeDescription = ref("")
|
||||
const editBadgeIcon = ref("")
|
||||
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
const badgeName = ref("")
|
||||
const badgeDescription = ref("")
|
||||
const badgeIcon = ref("")
|
||||
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const editBadgeConditionValue = ref(1)
|
||||
const badgeConditionValue = ref(1)
|
||||
|
||||
// 预设奖章图标选项
|
||||
const BADGE_LEN = 6
|
||||
@@ -49,33 +51,36 @@ const conditionTypeOptions = [
|
||||
]
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
name: editBadgeName.value,
|
||||
description: editBadgeDescription.value,
|
||||
icon: editBadgeIcon.value,
|
||||
conditionType: editBadgeConditionType.value,
|
||||
const data: BadgeFormData = {
|
||||
name: badgeName.value,
|
||||
description: badgeDescription.value,
|
||||
icon: badgeIcon.value,
|
||||
conditionType: badgeConditionType.value,
|
||||
// 只有非"完成所有题目"时才带条件值
|
||||
...(editBadgeConditionType.value === "all_problems"
|
||||
...(badgeConditionType.value === "all_problems"
|
||||
? {}
|
||||
: { conditionValue: editBadgeConditionValue.value }),
|
||||
})
|
||||
: { conditionValue: badgeConditionValue.value }),
|
||||
}
|
||||
if (props.badge) emit("update", data)
|
||||
else emit("create", data)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 当奖章数据变化时,更新表单数据
|
||||
// 每次打开都按当前模式重新填表:编辑就填这枚奖章的值,添加就清空。
|
||||
// 编辑取消后再打开同一枚,看到的是库里的值,而不是上次没保存的改动
|
||||
watch(
|
||||
() => props.badge,
|
||||
(newBadge) => {
|
||||
if (newBadge) {
|
||||
editBadgeName.value = newBadge.name
|
||||
editBadgeDescription.value = newBadge.description
|
||||
editBadgeIcon.value = newBadge.icon
|
||||
editBadgeConditionType.value = newBadge.conditionType
|
||||
editBadgeConditionValue.value = newBadge.conditionValue
|
||||
}
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
const badge = props.badge
|
||||
badgeName.value = badge?.name ?? ""
|
||||
badgeDescription.value = badge?.description ?? ""
|
||||
badgeIcon.value = badge?.icon ?? ""
|
||||
badgeConditionType.value = badge?.conditionType ?? "all_problems"
|
||||
badgeConditionValue.value = badge?.conditionValue ?? 1
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -85,17 +90,17 @@ watch(
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="编辑奖章"
|
||||
:title="badge ? '编辑奖章' : '添加奖章'"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form v-if="badge">
|
||||
<n-form>
|
||||
<n-form-item label="奖章名称" required>
|
||||
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
|
||||
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="editBadgeDescription"
|
||||
v-model:value="badgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
@@ -106,12 +111,12 @@ watch(
|
||||
<div
|
||||
v-for="option in badgeIconOptions"
|
||||
:key="option.value"
|
||||
@click="editBadgeIcon = option.value"
|
||||
@click="badgeIcon = option.value"
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
editBadgeIcon === option.value
|
||||
badgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
@@ -120,7 +125,7 @@ watch(
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
badgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
@@ -128,8 +133,8 @@ watch(
|
||||
width="50"
|
||||
height="50"
|
||||
object-fit="cover"
|
||||
style="border-radius: 2px"
|
||||
preview-disabled
|
||||
style="border-radius: 2px"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
@@ -138,17 +143,17 @@ watch(
|
||||
<n-form-item label="获得条件">
|
||||
<n-select
|
||||
style="width: 200px"
|
||||
v-model:value="editBadgeConditionType"
|
||||
v-model:value="badgeConditionType"
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="editBadgeConditionType !== 'all_problems'"
|
||||
v-if="badgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="editBadgeConditionValue"
|
||||
v-model:value="badgeConditionValue"
|
||||
placeholder="条件值"
|
||||
/>
|
||||
</n-form-item>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminProblemSetProblem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problem: AdminProblemSetProblem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(
|
||||
e: "confirm",
|
||||
data: {
|
||||
order: number
|
||||
isRequired: boolean
|
||||
score: number
|
||||
hint: string
|
||||
},
|
||||
): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editProblemOrder = ref(0)
|
||||
const editProblemRequired = ref(true)
|
||||
const editProblemScore = ref(0)
|
||||
const editProblemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
emit("confirm", {
|
||||
order: editProblemOrder.value,
|
||||
isRequired: editProblemRequired.value,
|
||||
score: editProblemScore.value,
|
||||
hint: editProblemHint.value || "",
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 当问题数据变化时,更新表单数据
|
||||
watch(
|
||||
() => props.problem,
|
||||
(newProblem) => {
|
||||
if (newProblem) {
|
||||
editProblemOrder.value = newProblem.order
|
||||
editProblemRequired.value = newProblem.isRequired
|
||||
editProblemScore.value = newProblem.score
|
||||
editProblemHint.value = newProblem.hint || ""
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
title="编辑题目"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form v-if="problem">
|
||||
<n-form-item label="题目标题">
|
||||
<n-input :value="problem.title" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="editProblemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="editProblemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number
|
||||
v-model:value="editProblemScore"
|
||||
placeholder="题目分数"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="editProblemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminProblemSetProblem } from "utils/types"
|
||||
|
||||
// 添加和编辑共用一个弹窗:传了 problem 就是编辑,null 就是添加。
|
||||
// 两种模式只差第一栏 —— 添加时填题目 ID,编辑时题目已定、只显示标题
|
||||
interface Props {
|
||||
show: boolean
|
||||
problem: AdminProblemSetProblem | null
|
||||
}
|
||||
|
||||
type ProblemFormData = {
|
||||
order: number
|
||||
isRequired: boolean
|
||||
score: number
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "create", data: ProblemFormData & { problemId: string }): void
|
||||
(e: "update", data: ProblemFormData): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const problemId = ref("")
|
||||
const problemOrder = ref(0)
|
||||
const problemRequired = ref(true)
|
||||
const problemScore = ref(0)
|
||||
const problemHint = ref("")
|
||||
|
||||
function handleConfirm() {
|
||||
const data: ProblemFormData = {
|
||||
order: problemOrder.value,
|
||||
isRequired: problemRequired.value,
|
||||
score: problemScore.value,
|
||||
hint: problemHint.value,
|
||||
}
|
||||
if (props.problem) emit("update", data)
|
||||
else emit("create", { problemId: problemId.value, ...data })
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
// 每次打开都按当前模式重新填表:编辑就填这道题的值,添加就清空。
|
||||
// 编辑取消后再打开同一道,看到的是库里的值,而不是上次没保存的改动
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
const problem = props.problem
|
||||
problemId.value = ""
|
||||
problemOrder.value = problem?.order ?? 0
|
||||
problemRequired.value = problem?.isRequired ?? true
|
||||
problemScore.value = problem?.score ?? 0
|
||||
problemHint.value = problem?.hint || ""
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="problem ? '编辑题目' : '添加题目'"
|
||||
style="width: 500px"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item v-if="problem" label="题目标题">
|
||||
<n-input :value="problem.title" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item v-else label="题目ID" required>
|
||||
<n-input
|
||||
v-model:value="problemId"
|
||||
placeholder="请输入题目的显示ID(如:1001)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="problemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="problemRequired" />
|
||||
</n-form-item>
|
||||
<n-form-item label="分数">
|
||||
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="problemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="handleCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
|
||||
import type {
|
||||
AdminProblemSet,
|
||||
@@ -26,10 +27,8 @@ import ProblemSetInfo from "./components/ProblemSetInfo.vue"
|
||||
import ProblemManagement from "./components/ProblemManagement.vue"
|
||||
import BadgeManagement from "./components/BadgeManagement.vue"
|
||||
import ProgressManagement from "./components/ProgressManagement.vue"
|
||||
import AddProblemModal from "./components/AddProblemModal.vue"
|
||||
import EditProblemModal from "./components/EditProblemModal.vue"
|
||||
import AddBadgeModal from "./components/AddBadgeModal.vue"
|
||||
import EditBadgeModal from "./components/EditBadgeModal.vue"
|
||||
import ProblemModal from "./components/ProblemModal.vue"
|
||||
import BadgeModal from "./components/BadgeModal.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -47,11 +46,9 @@ const problems = ref<AdminProblemSetProblem[]>([])
|
||||
const badges = ref<AdminProblemSetBadge[]>([])
|
||||
const progress = ref<AdminProblemSetProgress[]>([])
|
||||
|
||||
// 模态框状态
|
||||
const showAddProblemModal = ref(false)
|
||||
const showEditProblemModal = ref(false)
|
||||
const showAddBadgeModal = ref(false)
|
||||
const showEditBadgeModal = ref(false)
|
||||
// 模态框状态。添加和编辑共用一个弹窗,editing* 为 null 就是添加
|
||||
const showProblemModal = ref(false)
|
||||
const showBadgeModal = ref(false)
|
||||
|
||||
// 编辑数据
|
||||
const editingProblem = ref<AdminProblemSetProblem | null>(null)
|
||||
@@ -61,8 +58,8 @@ async function loadProblemSetDetail() {
|
||||
try {
|
||||
const res = await getProblemSetDetail(problemSetId.value)
|
||||
problemSet.value = res
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +67,8 @@ async function loadProblems() {
|
||||
try {
|
||||
const res = await getProblemSetProblems(problemSetId.value)
|
||||
problems.value = res
|
||||
} catch (err: any) {
|
||||
message.error("加载题目列表失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("加载题目列表失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +76,8 @@ async function loadBadges() {
|
||||
try {
|
||||
const res = await getProblemSetBadges(problemSetId.value)
|
||||
badges.value = res
|
||||
} catch (err: any) {
|
||||
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("加载奖章列表失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +85,8 @@ async function loadProgress() {
|
||||
try {
|
||||
const res = await getProblemSetProgress(problemSetId.value)
|
||||
progress.value = res
|
||||
} catch (err: any) {
|
||||
message.error("加载进度列表失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("加载进度列表失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +94,11 @@ async function handleAddProblem(data: AddProblemToSetRequest) {
|
||||
try {
|
||||
await addProblemToSet(problemSetId.value, data)
|
||||
message.success("题目添加成功")
|
||||
showAddProblemModal.value = false
|
||||
showProblemModal.value = false
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err: any) {
|
||||
message.error("添加题目失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("添加题目失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +108,8 @@ async function handleRemoveProblem(problemSetProblemId: number) {
|
||||
message.success("题目移除成功")
|
||||
loadProblems()
|
||||
loadProblemSetDetail() // 刷新题目数量
|
||||
} catch (err: any) {
|
||||
message.error("移除题目失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("移除题目失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +119,10 @@ async function handleEditProblem(data: UpdateProblemInSetRequest) {
|
||||
try {
|
||||
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
|
||||
message.success("题目编辑成功")
|
||||
showEditProblemModal.value = false
|
||||
showProblemModal.value = false
|
||||
loadProblems()
|
||||
} catch (err: any) {
|
||||
message.error("编辑题目失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,10 +130,10 @@ async function handleAddBadge(data: BadgeFormData) {
|
||||
try {
|
||||
await createProblemSetBadge(problemSetId.value, data)
|
||||
message.success("奖章创建成功")
|
||||
showAddBadgeModal.value = false
|
||||
showBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("创建奖章失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +142,8 @@ async function handleDeleteBadge(badgeId: number) {
|
||||
await deleteProblemSetBadge(problemSetId.value, badgeId)
|
||||
message.success("奖章删除成功")
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("删除奖章失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("删除奖章失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +153,10 @@ async function handleEditBadge(data: BadgeFormData) {
|
||||
try {
|
||||
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
|
||||
message.success("奖章编辑成功")
|
||||
showEditBadgeModal.value = false
|
||||
showBadgeModal.value = false
|
||||
loadBadges()
|
||||
} catch (err: any) {
|
||||
message.error("编辑奖章失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,27 +165,29 @@ async function handleRemoveUser(userId: number) {
|
||||
await removeUserFromProblemSet(problemSetId.value, userId)
|
||||
message.success("用户移除成功")
|
||||
loadProgress()
|
||||
} catch (err: any) {
|
||||
message.error("移除用户失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("移除用户失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
function openAddProblemModal() {
|
||||
showAddProblemModal.value = true
|
||||
editingProblem.value = null
|
||||
showProblemModal.value = true
|
||||
}
|
||||
|
||||
function openAddBadgeModal() {
|
||||
showAddBadgeModal.value = true
|
||||
editingBadge.value = null
|
||||
showBadgeModal.value = true
|
||||
}
|
||||
|
||||
function openEditProblemModal(problem: AdminProblemSetProblem) {
|
||||
editingProblem.value = problem
|
||||
showEditProblemModal.value = true
|
||||
showProblemModal.value = true
|
||||
}
|
||||
|
||||
function openEditBadgeModal(badge: AdminProblemSetBadge) {
|
||||
editingBadge.value = badge
|
||||
showEditBadgeModal.value = true
|
||||
showBadgeModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -246,23 +245,18 @@ onMounted(() => {
|
||||
</n-tabs>
|
||||
|
||||
<!-- 模态框组件 -->
|
||||
<AddProblemModal
|
||||
v-model:show="showAddProblemModal"
|
||||
@confirm="handleAddProblem"
|
||||
/>
|
||||
|
||||
<EditProblemModal
|
||||
v-model:show="showEditProblemModal"
|
||||
<ProblemModal
|
||||
v-model:show="showProblemModal"
|
||||
:problem="editingProblem"
|
||||
@confirm="handleEditProblem"
|
||||
@create="handleAddProblem"
|
||||
@update="handleEditProblem"
|
||||
/>
|
||||
|
||||
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
|
||||
|
||||
<EditBadgeModal
|
||||
v-model:show="showEditBadgeModal"
|
||||
<BadgeModal
|
||||
v-model:show="showBadgeModal"
|
||||
:badge="editingBadge"
|
||||
@confirm="handleEditBadge"
|
||||
@create="handleAddBadge"
|
||||
@update="handleEditBadge"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
||||
import { fromPickerValue, toPickerValue } from "utils/functions"
|
||||
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
||||
@@ -60,8 +61,8 @@ async function loadProblemSetDetail() {
|
||||
visible: data.visible,
|
||||
endTime: data.endTime ? new Date(data.endTime) : null,
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +88,11 @@ async function handleSubmit() {
|
||||
message.success("题单创建成功")
|
||||
}
|
||||
router.push({ name: "admin problemset list" })
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
message.error(
|
||||
(isEdit.value ? "更新" : "创建") +
|
||||
"题单失败:" +
|
||||
(err.data || "未知错误"),
|
||||
errorMessage(err, "未知错误"),
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import {
|
||||
CLASS_NAME_MAX_DIGITS,
|
||||
@@ -210,8 +211,8 @@ async function saveWebsiteConfig() {
|
||||
}
|
||||
try {
|
||||
await editWebsite(websiteConfig)
|
||||
} catch (err: any) {
|
||||
message.error("保存失败:" + err.data)
|
||||
} catch (err) {
|
||||
message.error("保存失败:" + errorMessage(err))
|
||||
return
|
||||
}
|
||||
message.success("网站配置保存成功")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorMessage } from "utils/api"
|
||||
import { deleteTutorial } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
@@ -22,8 +23,8 @@ async function handleDelete() {
|
||||
await deleteTutorial(props.tutorialID)
|
||||
message.success("删除成功")
|
||||
emit("deleted")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import type {
|
||||
Exercise,
|
||||
ExerciseType,
|
||||
@@ -317,8 +318,8 @@ async function save() {
|
||||
}
|
||||
showForm.value = false
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
message.error(e.data ?? "保存失败")
|
||||
} catch (e) {
|
||||
message.error(errorMessage(e, "保存失败"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorMessage } from "utils/api"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
|
||||
import type { TutorialEdit } from "utils/types"
|
||||
@@ -63,8 +64,8 @@ async function submit() {
|
||||
await updateTutorial(tutorial)
|
||||
message.success("修改已保存")
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { TutorialListItem } from "utils/types"
|
||||
@@ -63,8 +64,8 @@ async function toggleVisible(tutorial: TutorialListItem) {
|
||||
try {
|
||||
await setTutorialVisibility(tutorial.id, tutorial.isPublic)
|
||||
message.success("更新成功")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err))
|
||||
tutorial.isPublic = !tutorial.isPublic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import {
|
||||
CLASS_NAME_MAX_DIGITS,
|
||||
CLASS_NAME_MAX_VALUE,
|
||||
@@ -29,7 +30,7 @@ function generateUsers() {
|
||||
)
|
||||
return false
|
||||
}
|
||||
let className = !!prefix.value ? `ks${prefix.value}` : ""
|
||||
let className = prefix.value ? `ks${prefix.value}` : ""
|
||||
// 占位邮箱必须全站唯一:注册、编辑用户、导入三条路都查重,而且库里同一个邮箱
|
||||
// 出现两次的话,那两个账号在「编辑用户」里保存一次就撞 409,从此改不动。
|
||||
// 原来只按「班级 + 批内序号」拼,同一个班分两批导入必然重号 —— 加一段每批随机的
|
||||
@@ -66,8 +67,8 @@ async function uploadUsers() {
|
||||
hiddenElement.download = prefix.value + ".csv"
|
||||
hiddenElement.click()
|
||||
hiddenElement.remove()
|
||||
} catch (err: any) {
|
||||
message.error("上传失败:" + err.data)
|
||||
} catch (err) {
|
||||
message.error("上传失败:" + errorMessage(err))
|
||||
} finally {
|
||||
toggleLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
@@ -250,8 +251,8 @@ async function handleEditUser() {
|
||||
})
|
||||
await editUser(user)
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("保存失败:" + err.data)
|
||||
} catch (err) {
|
||||
message.error("保存失败:" + errorMessage(err))
|
||||
return
|
||||
}
|
||||
userEditing.value = null
|
||||
|
||||
@@ -55,7 +55,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
if (!userStore.user) {
|
||||
try {
|
||||
await userStore.getMyProfile()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
@@ -85,7 +85,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
|
||||
app.mount("#app")
|
||||
|
||||
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||
if (import.meta.env.PUBLIC_ICONIFY_URL) {
|
||||
addAPIProvider("", {
|
||||
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
type AiDetail,
|
||||
type DurationData,
|
||||
type HeatmapItem,
|
||||
type LoginSummary,
|
||||
type SolvedList,
|
||||
type ProblemSet,
|
||||
type ProblemSetBadge,
|
||||
@@ -60,7 +59,6 @@ import type {
|
||||
SubmissionListPayload,
|
||||
SubmitCodePayload,
|
||||
OnlineCount,
|
||||
WebsiteConfig,
|
||||
Tutorial,
|
||||
TutorialProgress,
|
||||
} from "utils/types"
|
||||
@@ -77,10 +75,6 @@ function detailProblem(value: unknown): Problem {
|
||||
return contract("GET /problems/:id", problemDetailSchema, value)
|
||||
}
|
||||
|
||||
export function getWebsiteConfig() {
|
||||
return api.get<WebsiteConfig>("site")
|
||||
}
|
||||
|
||||
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
|
||||
export function getOnlineCount() {
|
||||
return api.get<OnlineCount>("site/online")
|
||||
@@ -373,10 +367,6 @@ export function getAIHeatmapData(username?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getAILoginSummary() {
|
||||
return api.get<LoginSummary>("ai/login-summary")
|
||||
}
|
||||
|
||||
export function getAIPinnedReport() {
|
||||
return api.get<AiAnalysisRecord | null>("ai/pinned")
|
||||
}
|
||||
|
||||
+41
-230
@@ -12,7 +12,7 @@ import { Bar, Radar } from "vue-chartjs"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||
import { useAIStream } from "shared/composables/aiStream"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
@@ -55,10 +55,10 @@ const duration = ref<string>("")
|
||||
const loading = ref(false)
|
||||
const hasTimeRange = ref(false)
|
||||
|
||||
const aiLoading = ref(false)
|
||||
const aiStream = useAIStream()
|
||||
const aiLoading = aiStream.waiting
|
||||
const aiContent = ref("")
|
||||
const showAIModal = ref(false)
|
||||
let aiController: AbortController | null = null
|
||||
|
||||
// 长时段和榜单页同一份(LONG_DURATION_OPTIONS),外加一个「全部时间」
|
||||
const timeRangeOptions: SelectOption[] = [
|
||||
@@ -120,65 +120,20 @@ async function compare() {
|
||||
}
|
||||
|
||||
async function analyzeWithAI() {
|
||||
if (aiController) {
|
||||
aiController.abort()
|
||||
}
|
||||
const controller = new AbortController()
|
||||
aiController = controller
|
||||
|
||||
const timeRangeLabel =
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||
"全部时间"
|
||||
|
||||
showAIModal.value = true
|
||||
aiContent.value = ""
|
||||
aiLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/class-pk-analysis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
comparisons: comparisons.value,
|
||||
timeRangeLabel,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) throw await aiStreamError(response)
|
||||
|
||||
let hasStarted = false
|
||||
|
||||
await consumeJSONEventStream(response, {
|
||||
signal: controller.signal,
|
||||
onEvent(event) {
|
||||
if (event === "end" && !hasStarted) aiLoading.value = false
|
||||
},
|
||||
onMessage(payload) {
|
||||
const parsed = payload as {
|
||||
type?: string
|
||||
content?: string
|
||||
message?: string
|
||||
}
|
||||
if (parsed.type === "delta" && parsed.content) {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true
|
||||
aiLoading.value = false
|
||||
}
|
||||
aiContent.value += parsed.content
|
||||
} else if (parsed.type === "error") {
|
||||
throw new Error(parsed.message || "AI 服务异常")
|
||||
} else if (parsed.type === "done" && !hasStarted) {
|
||||
aiLoading.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (controller.signal.aborted) return
|
||||
message.error(error?.message || "AI 分析失败,请稍后再试")
|
||||
aiLoading.value = false
|
||||
} finally {
|
||||
if (aiController === controller) aiController = null
|
||||
await aiStream.run(
|
||||
"ai/class-pk-analysis",
|
||||
{ comparisons: comparisons.value, timeRangeLabel },
|
||||
{ onDelta: (content) => (aiContent.value += content) },
|
||||
)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,185 +162,41 @@ function getClassColor(index: number) {
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 综合分对比图
|
||||
const compositeScoreChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
// 值一定是数字的那些指标(recent* 是可选字段,不在其列)
|
||||
type BarMetric = {
|
||||
[K in keyof ClassComparison]-?: ClassComparison[K] extends number ? K : never
|
||||
}[keyof ClassComparison]
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "综合分",
|
||||
data: comparisons.value.map((c) => c.compositeScore),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
// 十张柱状对比图只差标题和取哪一列:每个班级一种颜色,和雷达图用同一套配色
|
||||
function barChart(label: string, key: BarMetric) {
|
||||
return computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label,
|
||||
data: comparisons.value.map((c) => c[key]),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
// 总AC数对比图 - 每个班级用不同颜色
|
||||
const totalAcChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
return { labels, datasets }
|
||||
})
|
||||
}
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "总AC数",
|
||||
data: comparisons.value.map((c) => c.totalAc),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 平均AC数对比图
|
||||
const avgAcChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "平均AC数",
|
||||
data: comparisons.value.map((c) => c.avgAc),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 中位数AC数对比图
|
||||
const medianAcChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "中位数AC数",
|
||||
data: comparisons.value.map((c) => c.medianAc),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 优秀率对比图
|
||||
const excellentRateChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "优秀率",
|
||||
data: comparisons.value.map((c) => c.excellentRate),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 及格率对比图
|
||||
const passRateChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "及格率",
|
||||
data: comparisons.value.map((c) => c.passRate),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 参与度对比图
|
||||
const activeRateChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "参与度",
|
||||
data: comparisons.value.map((c) => c.activeRate),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 前10%平均对比图
|
||||
const top10AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "前10%平均",
|
||||
data: comparisons.value.map((c) => c.top10Avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 后10%平均对比图
|
||||
const bottom10AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "后10%平均",
|
||||
data: comparisons.value.map((c) => c.bottom10Avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 中间80%均值对比图
|
||||
const middle80AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.className)
|
||||
const datasets = [
|
||||
{
|
||||
label: "中间80%均值",
|
||||
data: comparisons.value.map((c) => c.middle80Avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
const compositeScoreChartData = barChart("综合分", "compositeScore")
|
||||
const totalAcChartData = barChart("总AC数", "totalAc")
|
||||
const avgAcChartData = barChart("平均AC数", "avgAc")
|
||||
const medianAcChartData = barChart("中位数AC数", "medianAc")
|
||||
const excellentRateChartData = barChart("优秀率", "excellentRate")
|
||||
const passRateChartData = barChart("及格率", "passRate")
|
||||
const activeRateChartData = barChart("参与度", "activeRate")
|
||||
const top10AvgChartData = barChart("前10%平均", "top10Avg")
|
||||
const bottom10AvgChartData = barChart("后10%平均", "bottom10Avg")
|
||||
const middle80AvgChartData = barChart("中间80%均值", "middle80Avg")
|
||||
|
||||
// 雷达图数据 - 多维度综合对比
|
||||
const radarChartData = computed(() => {
|
||||
|
||||
@@ -113,7 +113,7 @@ const chartData = computed(() => {
|
||||
if (b.solved !== a.solved) return b.solved - a.solved
|
||||
return a.penalty - b.penalty
|
||||
})
|
||||
const ranks = new Array(topUsers.length).fill(0)
|
||||
const ranks = Array.from({ length: topUsers.length }, () => 0)
|
||||
indexed.forEach((item, pos) => {
|
||||
ranks[item.i] = pos + 1
|
||||
})
|
||||
|
||||
@@ -17,8 +17,13 @@ const { isDesktop } = useBreakpoints()
|
||||
const password = ref("")
|
||||
|
||||
async function check() {
|
||||
await contestStore.checkPassword(props.contestID, password.value)
|
||||
if (!contestStore.access) {
|
||||
const error = await contestStore.checkPassword(
|
||||
props.contestID,
|
||||
password.value,
|
||||
)
|
||||
if (error === "too-many-password-attempts") {
|
||||
message.error("密码错误次数太多,请 10 分钟后再试")
|
||||
} else if (!contestStore.access) {
|
||||
message.error("密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { copyToClipboard } from "utils/functions"
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const contestID = !!route.params.contestID ? route.params.contestID : null
|
||||
const contestID = route.params.contestID ? route.params.contestID : null
|
||||
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract"
|
||||
import type { JudgeCaseResult } from "@oj2/contract"
|
||||
import type { AiHintDone, JudgeCaseResult } from "@oj2/contract"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import {
|
||||
submissionCaseResults,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { Submission } from "utils/types"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||
import { useAIStream } from "shared/composables/aiStream"
|
||||
import { submitHintFeedback } from "oj/api"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
@@ -34,7 +34,9 @@ const theme = useThemeVars()
|
||||
// 逐字显示改在这边模拟。
|
||||
const hintTarget = ref("")
|
||||
const hintContent = ref("")
|
||||
const hintLoading = ref(false)
|
||||
const hintStream = useAIStream()
|
||||
// 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态
|
||||
const hintLoading = hintStream.running
|
||||
const hintError = ref("")
|
||||
// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
|
||||
const hintId = ref<number | null>(null)
|
||||
@@ -147,51 +149,34 @@ const showAIHint = computed(() => {
|
||||
watch(
|
||||
() => props.submission?.id,
|
||||
() => {
|
||||
// 上一次提交的提示还在生成的话掐掉,不然它会写进新提交的面板里
|
||||
hintStream.abort()
|
||||
resetHint()
|
||||
hintLoading.value = false
|
||||
},
|
||||
)
|
||||
|
||||
// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看
|
||||
// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准
|
||||
async function fetchHint(submissionId: string, more = false) {
|
||||
hintLoading.value = true
|
||||
resetHint()
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/hint", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submissionId, more }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw await aiStreamError(response)
|
||||
|
||||
await consumeJSONEventStream(response, {
|
||||
onMessage: (data: {
|
||||
type: string
|
||||
content?: string
|
||||
message?: string
|
||||
hintId?: number | null
|
||||
level?: number
|
||||
canEscalate?: boolean
|
||||
}) => {
|
||||
if (data.type === "delta" && data.content) {
|
||||
hintTarget.value += data.content
|
||||
await hintStream.run<AiHintDone>(
|
||||
"ai/hint",
|
||||
{ submissionId, more },
|
||||
{
|
||||
onDelta(content) {
|
||||
hintTarget.value += content
|
||||
startTyping()
|
||||
} else if (data.type === "done") {
|
||||
},
|
||||
onDone(data) {
|
||||
hintId.value = data.hintId ?? null
|
||||
hintLevel.value = data.level ?? null
|
||||
hintCanEscalate.value = data.canEscalate === true
|
||||
} else if (data.type === "error") {
|
||||
hintError.value = data.message || "AI 提示生成失败"
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
hintError.value = e.message || "请求失败"
|
||||
} finally {
|
||||
hintLoading.value = false
|
||||
)
|
||||
} catch (error) {
|
||||
hintError.value = (error as Error).message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { formatCode, getReaction, submitCode } from "oj/api"
|
||||
@@ -133,10 +134,10 @@ async function submit() {
|
||||
language: formatLang,
|
||||
})
|
||||
codeStore.setCode(res.code)
|
||||
} catch (e: any) {
|
||||
if (e?.error === "format-error") {
|
||||
} catch (e) {
|
||||
if (errorCode(e) === "format-error") {
|
||||
// 仅 Python 会出现:代码本身存在语法错误
|
||||
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
|
||||
message.warning(`代码格式化失败:${errorMessage(e)},请检查代码后重试`)
|
||||
return
|
||||
}
|
||||
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorCode } from "utils/api"
|
||||
import { toRefs } from "vue"
|
||||
|
||||
// 工具函数
|
||||
@@ -256,13 +257,13 @@ async function submitFlowchartData() {
|
||||
}
|
||||
|
||||
message.success("流程图已提交,请耐心等待评分")
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
|
||||
// 只说「提交失败」的话,学生会以为是自己的图有问题,然后反复点,越点越久
|
||||
if (error?.error === "too-many-submissions") {
|
||||
if (errorCode(error) === "too-many-submissions") {
|
||||
message.warning("提交太频繁了,缓一会儿再交")
|
||||
} else if (error?.error === "flowchart-not-allowed") {
|
||||
} else if (errorCode(error) === "flowchart-not-allowed") {
|
||||
message.error("这道题不接受流程图提交")
|
||||
} else {
|
||||
message.error("流程图提交失败")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode } from "utils/api"
|
||||
import { getProblem } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { storeToRefs } from "pinia"
|
||||
@@ -120,9 +121,9 @@ async function init() {
|
||||
try {
|
||||
const res = await getProblem(problemID, contestID)
|
||||
problem.value = res
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
problem.value = null
|
||||
if (err.error === "contest-not-started") {
|
||||
if (errorCode(err) === "contest-not-started") {
|
||||
errMsg.value = "比赛还没有开始"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ export async function checkPythonSyntax(
|
||||
try {
|
||||
Sk.compile(code, "prog.py", "exec")
|
||||
return null
|
||||
} catch (e: any) {
|
||||
const line: number = e?.traceback?.[0]?.lineno ?? 1
|
||||
} catch (e) {
|
||||
// Skulpt 的语法错误对象,不是 Error 的子类
|
||||
const traceback = (e as { traceback?: { lineno?: number }[] }).traceback
|
||||
const line = traceback?.[0]?.lineno ?? 1
|
||||
return { line }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { h, computed, ref, onMounted, watch } from "vue"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { parseTime } from "utils/functions"
|
||||
@@ -68,10 +69,10 @@ async function loadUserProgress() {
|
||||
if (res.problems) {
|
||||
allProblems.value = res.problems
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
|
||||
// 请求一失败(403、断网)转圈就永远停不下来
|
||||
message.error("加载用户进度失败:" + (err.data || "未知错误"))
|
||||
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import {
|
||||
getProblemSetDetail,
|
||||
getProblemSetProblems,
|
||||
@@ -90,8 +91,8 @@ async function handleJoinProblemSet() {
|
||||
message.success("成功加入题单!")
|
||||
// 加入题单后加载用户徽章
|
||||
await loadUserBadges()
|
||||
} catch (err: any) {
|
||||
message.error("加入题单失败:" + (err.data || "未知错误"))
|
||||
} catch (err) {
|
||||
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
|
||||
} finally {
|
||||
isJoining.value = false
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useUserStore } from "shared/store/user"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||
import { useAIStream } from "shared/composables/aiStream"
|
||||
|
||||
const GRADES = [20, 25]
|
||||
|
||||
@@ -102,10 +102,10 @@ const showClassDetailModal = ref(false)
|
||||
const classDetailData = ref<ClassComparison | null>(null)
|
||||
const classDetailLoading = ref(false)
|
||||
|
||||
const classDetailAiLoading = ref(false)
|
||||
const classDetailAiStream = useAIStream()
|
||||
const classDetailAiLoading = classDetailAiStream.waiting
|
||||
const classDetailAiContent = ref("")
|
||||
const showClassDetailAiModal = ref(false)
|
||||
let classDetailAiController: AbortController | null = null
|
||||
|
||||
async function loadClassDetail(className: string) {
|
||||
showClassDetailModal.value = true
|
||||
@@ -123,55 +123,17 @@ async function loadClassDetail(className: string) {
|
||||
|
||||
async function analyzeSingleClassWithAI() {
|
||||
if (!classDetailData.value) return
|
||||
if (classDetailAiController) classDetailAiController.abort()
|
||||
const controller = new AbortController()
|
||||
classDetailAiController = controller
|
||||
|
||||
showClassDetailModal.value = false
|
||||
showClassDetailAiModal.value = true
|
||||
classDetailAiContent.value = ""
|
||||
classDetailAiLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/class-analysis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ comparison: classDetailData.value }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw await aiStreamError(response)
|
||||
|
||||
let hasStarted = false
|
||||
await consumeJSONEventStream(response, {
|
||||
signal: controller.signal,
|
||||
onEvent(event) {
|
||||
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
|
||||
},
|
||||
onMessage(payload) {
|
||||
const parsed = payload as {
|
||||
type?: string
|
||||
content?: string
|
||||
message?: string
|
||||
}
|
||||
if (parsed.type === "delta" && parsed.content) {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true
|
||||
classDetailAiLoading.value = false
|
||||
}
|
||||
classDetailAiContent.value += parsed.content
|
||||
} else if (parsed.type === "error") {
|
||||
throw new Error(parsed.message || "AI 服务异常")
|
||||
} else if (parsed.type === "done" && !hasStarted) {
|
||||
classDetailAiLoading.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (controller.signal.aborted) return
|
||||
message.error(error?.message || "AI 分析失败,请稍后再试")
|
||||
classDetailAiLoading.value = false
|
||||
} finally {
|
||||
if (classDetailAiController === controller) classDetailAiController = null
|
||||
await classDetailAiStream.run(
|
||||
"ai/class-analysis",
|
||||
{ comparison: classDetailData.value },
|
||||
{ onDelta: (content) => (classDetailAiContent.value += content) },
|
||||
)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +505,7 @@ async function listMyClassRank() {
|
||||
if (myClassScope.value === "window") {
|
||||
myClassQuery.page = 1
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
@@ -555,7 +517,7 @@ async function listWeeklyRank() {
|
||||
weeklyMe.value = res.me
|
||||
weeklyTotal.value = res.total
|
||||
weeklyStart.value = res.start
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-49
@@ -1,5 +1,5 @@
|
||||
import type { DetailsData, DurationData, SolvedProblem } from "utils/types"
|
||||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||||
import { streamAI } from "utils/stream"
|
||||
import {
|
||||
getAIDetailData,
|
||||
getAIDurationData,
|
||||
@@ -128,10 +128,10 @@ export const useAIStore = defineStore("ai", () => {
|
||||
|
||||
let aiController: AbortController | null = null
|
||||
|
||||
// 不用 useAIStream:store 没有会卸载的作用域,而且 loading.ai 要和其它几个
|
||||
// loading 放在同一个 reactive 里给页面用
|
||||
async function fetchAIAnalysis() {
|
||||
if (aiController) {
|
||||
aiController.abort()
|
||||
}
|
||||
aiController?.abort()
|
||||
const controller = new AbortController()
|
||||
aiController = controller
|
||||
|
||||
@@ -139,58 +139,25 @@ export const useAIStore = defineStore("ai", () => {
|
||||
mdContent.value = ""
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/analysis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
await streamAI(
|
||||
"ai/analysis",
|
||||
{
|
||||
start: rangeStart.value,
|
||||
end: rangeEnd.value,
|
||||
duration: duration.value,
|
||||
username: targetUsername.value || undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw await aiStreamError(response)
|
||||
}
|
||||
|
||||
let hasStarted = false
|
||||
|
||||
await consumeJSONEventStream(response, {
|
||||
signal: controller.signal,
|
||||
onEvent(event) {
|
||||
if (event === "end" && !hasStarted) {
|
||||
loading.ai = false
|
||||
}
|
||||
},
|
||||
onMessage(payload) {
|
||||
const parsed = payload as {
|
||||
type?: string
|
||||
content?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
if (parsed.type === "delta" && parsed.content) {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true
|
||||
loading.ai = false
|
||||
}
|
||||
mdContent.value += parsed.content
|
||||
} else if (parsed.type === "error") {
|
||||
throw new Error(parsed.message || "AI 服务异常")
|
||||
} else if (parsed.type === "done" && !hasStarted) {
|
||||
{
|
||||
signal: controller.signal,
|
||||
onDelta(content) {
|
||||
loading.ai = false
|
||||
}
|
||||
mdContent.value += content
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
console.error("生成 AI 分析失败", error)
|
||||
const message = error?.message || "生成失败,请稍后再试"
|
||||
mdContent.value = `生成失败:${message}`
|
||||
)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
mdContent.value = `生成失败:${(error as Error).message}`
|
||||
} finally {
|
||||
if (aiController === controller) {
|
||||
aiController = null
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { errorCode } from "utils/api"
|
||||
import { formatISO, getTime, parseISO } from "date-fns"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { ContestStatus, ContestType } from "utils/constants"
|
||||
@@ -87,6 +88,7 @@ export const useContestStore = defineStore("contest", () => {
|
||||
timer = 0
|
||||
}
|
||||
|
||||
/** 返回失败时的错误码(成功为 null),调用方据此给提示 */
|
||||
async function checkPassword(contestID: string, password: string) {
|
||||
try {
|
||||
const res = await checkContestPassword(contestID, password)
|
||||
@@ -94,15 +96,17 @@ export const useContestStore = defineStore("contest", () => {
|
||||
if (res) {
|
||||
_getProblems(contestID)
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
toggleAccess(false)
|
||||
return errorCode(err) ?? "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
async function _getProblems(contestID: string) {
|
||||
try {
|
||||
problems.value = await getContestProblems(contestID)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
problems.value = []
|
||||
toggleAccess(false)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useProblemStore = defineStore("problem", () => {
|
||||
|
||||
const languages = computed<LANGUAGE[]>(() => {
|
||||
if (route.name === "problem" && problem.value?.allowFlowchart) {
|
||||
return ["Flowchart", ...problem.value?.languages]
|
||||
return ["Flowchart", ...problem.value.languages]
|
||||
}
|
||||
return problem.value?.languages ?? []
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode } from "utils/api"
|
||||
import { NButton, NFlex, NTag, NText } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import {
|
||||
@@ -225,8 +226,8 @@ async function retryFlowchart(submissionId: string) {
|
||||
}
|
||||
try {
|
||||
await retryFlowchartSubmission(submissionId)
|
||||
} catch (err: any) {
|
||||
message.error(retryTips[err?.error] ?? "重新评分失败")
|
||||
} catch (err) {
|
||||
message.error(retryTips[errorCode(err) ?? ""] ?? "重新评分失败")
|
||||
return
|
||||
}
|
||||
message.success("重新评分已提交")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { userProfileSchema, type Quote } from "@oj2/contract"
|
||||
import type { LoginSummary, Quote, WebsiteConfig } from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { contract } from "utils/contract"
|
||||
import { contractDeferred } from "utils/contract"
|
||||
import type { Profile, Tag } from "utils/types"
|
||||
|
||||
export function login(data: { username: string; password: string }) {
|
||||
@@ -29,7 +29,12 @@ export async function getProfile(
|
||||
// `userProfileSchema.parse(response) as Profile`,`as` 把校验结果又断言回本地
|
||||
// 类型(Profile 把 user 收窄成 SessionUser、acmProblemsStatus 收窄成具体形状),
|
||||
// 形状对不上时页面白屏。现在记一条分歧日志后放行原始数据。
|
||||
return contract("GET /profiles/:username", userProfileSchema, response)
|
||||
// 用 deferred 版:启动就要拉 /me,同步校验会把 zod 整个打进首屏包
|
||||
return contractDeferred(
|
||||
"GET /profiles/:username",
|
||||
() => import("./profileSchema").then((m) => m.userProfileSchema),
|
||||
response,
|
||||
)
|
||||
}
|
||||
|
||||
export function getProblemTagList() {
|
||||
@@ -43,3 +48,15 @@ export function getHitokoto() {
|
||||
export function getClassUsernames(classroom: string) {
|
||||
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
|
||||
}
|
||||
|
||||
// 下面两个是启动就要调的(config / loginSummary 两个 store),所以放在 shared 而不是
|
||||
// oj/api.ts:oj/api.ts 同步 import 了题目详情、提交详情两个 schema(它们的契约闸门必须
|
||||
// 同步,submissionDetailSchema 里有 `.catch()`,放行原文和解析结果不等价),入口一旦
|
||||
// 引到它,zod 整个就进了首屏包。
|
||||
export function getWebsiteConfig() {
|
||||
return api.get<WebsiteConfig>("site")
|
||||
}
|
||||
|
||||
export function getAILoginSummary() {
|
||||
return api.get<LoginSummary>("ai/login-summary")
|
||||
}
|
||||
@@ -90,7 +90,9 @@ const active = computed(() => route.path.split("/")[1] || "problem")
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.signOut()
|
||||
router.replace("/")
|
||||
// 整页跳转而不是 router.replace:AI 分析、学情小结这些 store 还装着这个人的数据,
|
||||
// 机房下一个学生坐下来就能看到。重载一次把内存清干净,比挨个 reset 可靠。
|
||||
window.location.replace("/")
|
||||
}
|
||||
|
||||
function handleToggleDemoMode() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode } from "utils/api"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import { getClassUsernames, login } from "../api"
|
||||
@@ -124,12 +125,14 @@ function submit() {
|
||||
merged.username = form.value.class + merged.username
|
||||
}
|
||||
await login(merged)
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
// 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」
|
||||
if (err.error === "account-disabled") {
|
||||
if (errorCode(err) === "account-disabled") {
|
||||
authStore.setLoginError("此账号已被封禁")
|
||||
} else if (err.error === "invalid-credentials") {
|
||||
} else if (errorCode(err) === "invalid-credentials") {
|
||||
authStore.setLoginError("用户名或密码不正确")
|
||||
} else if (errorCode(err) === "too-many-login-attempts") {
|
||||
authStore.setLoginError("密码错误次数太多,请 15 分钟后再试")
|
||||
} else {
|
||||
authStore.setLoginError("无法登录")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode } from "utils/api"
|
||||
import { signup } from "../api"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useAuthModalStore } from "../store/authModal"
|
||||
@@ -47,11 +48,13 @@ function submit() {
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (err.error === "username-exists") {
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "username-exists") {
|
||||
authStore.setSignupError("用户名已存在")
|
||||
} else if (err.error === "email-exists") {
|
||||
} else if (errorCode(err) === "email-exists") {
|
||||
authStore.setSignupError("邮箱已存在")
|
||||
} else if (errorCode(err) === "too-many-registrations") {
|
||||
authStore.setSignupError("注册太频繁,请稍后再试")
|
||||
} else {
|
||||
authStore.setSignupError("无法注册")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { streamAI } from "utils/stream"
|
||||
|
||||
/**
|
||||
* 组件里发 AI 流式请求:同一时刻只跑一条(再点一次会先掐掉上一条),组件卸载时
|
||||
* 自动中止 —— 原来班级分析、班级 PK 两处离开页面后流还在跑,白占一个 AI 名额。
|
||||
*
|
||||
* - `waiting`:请求发出、第一个字还没到。按钮 loading、占位转圈用它
|
||||
* - `running`:整条流还没结束
|
||||
*
|
||||
* `run` 失败时抛 Error(message 可直接给用户看);被中止时静默返回,不抛。
|
||||
*/
|
||||
export function useAIStream() {
|
||||
const waiting = ref(false)
|
||||
const running = ref(false)
|
||||
let controller: AbortController | null = null
|
||||
|
||||
function abort() {
|
||||
controller?.abort()
|
||||
controller = null
|
||||
waiting.value = false
|
||||
running.value = false
|
||||
}
|
||||
|
||||
async function run<Done = Record<string, unknown>>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
handlers: {
|
||||
onDelta: (content: string) => void
|
||||
onDone?: (data: Done) => void
|
||||
},
|
||||
) {
|
||||
abort()
|
||||
const own = new AbortController()
|
||||
controller = own
|
||||
waiting.value = true
|
||||
running.value = true
|
||||
try {
|
||||
await streamAI<Done>(path, body, {
|
||||
...handlers,
|
||||
signal: own.signal,
|
||||
onDelta(content) {
|
||||
waiting.value = false
|
||||
handlers.onDelta(content)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (own.signal.aborted) return
|
||||
throw error
|
||||
} finally {
|
||||
// 被下一次 run 顶掉的那条不碰状态,状态归新的那条管
|
||||
if (controller === own) {
|
||||
controller = null
|
||||
waiting.value = false
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(abort)
|
||||
|
||||
return { waiting, running, run, abort }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 只给 shared/api.ts 的 getProfile 动态 import 用,见那边的注释。
|
||||
// 单独一个模块而不是直接 import("@oj2/contract"):动态引入整个契约桶会让 rolldown
|
||||
// 重排共享 chunk,把一批原本懒加载的页面依赖提进首屏预加载(实测首屏 703KB → 1811KB)。
|
||||
export { userProfileSchema } from "@oj2/contract"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getWebsiteConfig } from "oj/api"
|
||||
import { getWebsiteConfig } from "shared/api"
|
||||
import type { WebsiteConfig } from "utils/types"
|
||||
|
||||
export const useConfigStore = defineStore("config", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoginSummary as ContractLoginSummary } from "@oj2/contract"
|
||||
import { getAILoginSummary } from "oj/api"
|
||||
import { getAILoginSummary } from "shared/api"
|
||||
|
||||
type LoginSummary = ContractLoginSummary["summary"]
|
||||
|
||||
@@ -34,7 +34,7 @@ export const useLoginSummaryStore = defineStore("loginSummary", () => {
|
||||
summary.value = res.summary
|
||||
analysis.value = res.analysis || ""
|
||||
analysisError.value = res.analysisError || ""
|
||||
} catch (err) {
|
||||
} catch {
|
||||
analysisError.value = "获取登录统计失败,请稍后再试"
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -66,11 +66,24 @@ export const useUserStore = defineStore("user", () => {
|
||||
// 登录后和改完设置仍然要能重新拉一份。
|
||||
let inflight: Promise<void> | null = null
|
||||
|
||||
// 这个页面生命周期里最后一个登录过的人。**clearProfile 不清它**,就是为了下面那道闸。
|
||||
let lastUserId: number | null = null
|
||||
|
||||
function getMyProfile() {
|
||||
if (inflight) return inflight
|
||||
isFinished.value = false
|
||||
inflight = getProfile()
|
||||
.then((res) => {
|
||||
// 换了人就整页重载。会话过期、别的标签页登出这类被动下线是**原地**弹登录框
|
||||
// 的(学生正在写的代码不能丢),可内存里的 store 全是上一个人的:AI 分析、
|
||||
// 学情小结、协作状态……机房一台机器轮着用,下一个学生登进来就能看到。
|
||||
// 挨个 store 去 reset 迟早漏一个,重载一次最彻底;同一个人重新登录不受影响。
|
||||
const id = res?.user?.id ?? null
|
||||
if (id !== null && lastUserId !== null && id !== lastUserId) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
if (id !== null) lastUserId = id
|
||||
profile.value = res
|
||||
isFinished.value = true
|
||||
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
|
||||
@@ -90,7 +103,8 @@ export const useUserStore = defineStore("user", () => {
|
||||
}
|
||||
|
||||
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
|
||||
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由。
|
||||
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由 ——
|
||||
// 调用方要**整页**跳走,别用 router:各个 store 里还留着这个人的数据。
|
||||
async function signOut() {
|
||||
await logout()
|
||||
clearProfile()
|
||||
|
||||
+61
-20
@@ -6,13 +6,45 @@ import storage from "./storage"
|
||||
|
||||
const { message: toast } = createDiscreteApi(["message"])
|
||||
|
||||
interface ApiError {
|
||||
/** 后端失败响应体的形状,见 apps/api/src/http.ts 的 failure */
|
||||
interface ErrorPayload {
|
||||
error?: {
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截器 reject 出来的就是它:`error` 是错误码,`data` 是后端文案。
|
||||
*
|
||||
* 字段名沿用原来那个裸对象 `{ error, data }`,存量调用点读 `err.error` / `err.data`
|
||||
* 的语义不变;做成 Error 的子类是为了在 catch 里能和代码 bug(TypeError 之类)分开,
|
||||
* 也顺带有了堆栈。catch 块里别写 `err: any`,用下面的 errorCode / errorMessage。
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly error: string,
|
||||
readonly data: string,
|
||||
) {
|
||||
super(data)
|
||||
this.name = "ApiError"
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiError(err: unknown): err is ApiError {
|
||||
return err instanceof ApiError
|
||||
}
|
||||
|
||||
/** 接口错误的错误码;不是接口错误(代码 bug、被中止……)就是 undefined */
|
||||
export function errorCode(err: unknown) {
|
||||
return isApiError(err) ? err.error : undefined
|
||||
}
|
||||
|
||||
/** 给用户看的文案:接口错误用后端文案,其它一律 fallback —— 别把 TypeError 的英文甩给学生 */
|
||||
export function errorMessage(err: unknown, fallback = "未知错误") {
|
||||
return isApiError(err) && err.data ? err.data : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功时直接拿到业务数据本身。后端成功响应是 `{ data }`(见 apps/api/src/http.ts
|
||||
* 的 success),拦截器把 axios 外层和这层信封一起剥掉。
|
||||
@@ -27,6 +59,29 @@ interface ApiClient {
|
||||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
|
||||
* 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
|
||||
* 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
|
||||
*
|
||||
* 导出给不走 axios 的请求用(AI 的流式接口是裸 fetch,见 utils/stream.ts)——
|
||||
* 同一个错误码从两条路进来,学生看到的结果不该有两个样子。
|
||||
*/
|
||||
export function handleGlobalApiError(code: string, message?: string) {
|
||||
if (code === "login-required") {
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
useAuthModalStore().openLoginModal()
|
||||
} else if (code === "account-disabled") {
|
||||
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
|
||||
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
|
||||
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
toast.error("账号已被禁用,请联系老师")
|
||||
} else if (code === "permission-denied") {
|
||||
toast.error(message || "权限不足")
|
||||
}
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: "/api",
|
||||
withCredentials: true,
|
||||
@@ -51,27 +106,13 @@ instance.interceptors.response.use(
|
||||
response: AxiosResponse,
|
||||
) => AxiosResponse,
|
||||
(error) => {
|
||||
const payload = error.response?.data as ApiError | undefined
|
||||
const payload = error.response?.data as ErrorPayload | undefined
|
||||
const code = payload?.error?.code ?? "network-error"
|
||||
const message = payload?.error?.message ?? "Request failed"
|
||||
// 没有响应体 = 请求根本没到后端(断网、反代挂了、超时),学生看到的就是这一句
|
||||
const message = payload?.error?.message ?? "网络异常,请检查网络后重试"
|
||||
|
||||
// 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch,
|
||||
// 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
|
||||
// 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
|
||||
if (code === "login-required") {
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
useAuthModalStore().openLoginModal()
|
||||
} else if (code === "account-disabled") {
|
||||
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
|
||||
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
|
||||
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
toast.error("账号已被禁用,请联系老师")
|
||||
} else if (code === "permission-denied") {
|
||||
toast.error(message || "权限不足")
|
||||
}
|
||||
|
||||
return Promise.reject({ error: code, data: message })
|
||||
handleGlobalApiError(code, message)
|
||||
return Promise.reject(new ApiError(code, message))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -45,3 +45,24 @@ export function contract<T extends z.ZodType>(
|
||||
// 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。
|
||||
return value as z.infer<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 `contract`,但校验挪到后台:先原样交出数据,schema 按需加载之后再核、只记日志。
|
||||
*
|
||||
* 给**首屏路径**用(启动就要拉的 /me)。同步校验得在入口包里带上 schema,也就带上
|
||||
* 整个 zod 运行时(~100KB);而这道闸本来就只记日志、不改数据 —— schema 里没有
|
||||
* default / transform 时,放行原文和放行解析结果只差 zod 剥掉的多余字段,前端用不上。
|
||||
* 给 schema 加 default / transform 之前,得先把调用方从这里挪回 `contract`。
|
||||
*/
|
||||
export function contractDeferred<T extends z.ZodType>(
|
||||
endpoint: string,
|
||||
loadSchema: () => Promise<T>,
|
||||
value: unknown,
|
||||
): z.infer<T> {
|
||||
loadSchema()
|
||||
.then((schema) => contract(endpoint, schema, value))
|
||||
.catch(() => {
|
||||
// schema 那个 chunk 加载失败(部署换了哈希之类)不影响页面,闸门本来就只记日志
|
||||
})
|
||||
return value as z.infer<T>
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { handleGlobalApiError } from "./api"
|
||||
|
||||
export interface JSONEventStreamHandlers<T = any> {
|
||||
onMessage: (data: T, event?: string) => void
|
||||
onEvent?: (event: string) => void
|
||||
@@ -45,7 +47,7 @@ export async function consumeJSONEventStream<T = any>(
|
||||
let parsed: T
|
||||
try {
|
||||
parsed = JSON.parse(payloadStr)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new Error(`无法解析服务端事件数据: ${payloadStr}`)
|
||||
}
|
||||
|
||||
@@ -96,12 +98,21 @@ export async function consumeJSONEventStream<T = any>(
|
||||
/**
|
||||
* AI 端点的非 2xx 响应体是 JSON 不是 SSE,直接丢给上面的解析器只会抛
|
||||
* 「无法解析服务端事件数据: {...}」。后端 error.message 是英文的,按 code 换成中文。
|
||||
*
|
||||
* 登录失效 / 账号禁用走和 axios 拦截器同一套处理(弹登录框 / 提示禁用)——
|
||||
* 原来这里裸 fetch 绕过了拦截器,未登录时只显示一句「AI 分析生成失败」。
|
||||
*/
|
||||
export async function aiStreamError(response: Response) {
|
||||
async function aiStreamError(response: Response) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: { code?: string }
|
||||
error?: { code?: string; message?: string }
|
||||
} | null
|
||||
switch (body?.error?.code) {
|
||||
const code = body?.error?.code ?? "network-error"
|
||||
handleGlobalApiError(code, body?.error?.message)
|
||||
switch (code) {
|
||||
case "login-required":
|
||||
return new Error("请先登录")
|
||||
case "account-disabled":
|
||||
return new Error("账号已被禁用,请联系老师")
|
||||
case "too-many-requests":
|
||||
return new Error("AI 请求太频繁了,歇一会儿再试")
|
||||
case "hint-locked":
|
||||
@@ -114,3 +125,50 @@ export async function aiStreamError(response: Response) {
|
||||
return new Error("AI 分析生成失败")
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 流的事件形状,和 apps/api/src/services/ai.ts 的 streamChat / streamWhole 一致 */
|
||||
type AIStreamEvent =
|
||||
| { type: "delta"; content?: string }
|
||||
| { type: "error"; message?: string }
|
||||
| ({ type: "done" } & Record<string, unknown>)
|
||||
|
||||
export interface AIStreamHandlers<Done> {
|
||||
onDelta: (content: string) => void
|
||||
/** done 事件里后端并进来的额外字段(比如 AI 提示的 hintId / level) */
|
||||
onDone?: (data: Done) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一次 AI 流式请求并读到结束。学情分析、班级分析、班级 PK、AI 提示四个入口共用 ——
|
||||
* 原来是四份几乎一样的 fetch + SSE 解析,错误处理各漏各的。
|
||||
*
|
||||
* 失败抛 Error,`message` 是能直接给用户看的中文(后端下发的错误文案本来就是固定的
|
||||
* 中文,见 services/ai.ts 的 CLIENT_ERROR_MESSAGE)。被 abort 时抛 AbortError,
|
||||
* 调用方一般用 `useAIStream` 就不用自己分辨。
|
||||
*/
|
||||
export async function streamAI<Done = Record<string, unknown>>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
handlers: AIStreamHandlers<Done>,
|
||||
) {
|
||||
const response = await fetch(`/api/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: handlers.signal,
|
||||
})
|
||||
if (!response.ok) throw await aiStreamError(response)
|
||||
await consumeJSONEventStream<AIStreamEvent>(response, {
|
||||
signal: handlers.signal,
|
||||
onMessage(event) {
|
||||
if (event.type === "delta") {
|
||||
if (event.content) handlers.onDelta(event.content)
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(event.message || "AI 服务异常")
|
||||
} else if (event.type === "done") {
|
||||
handlers.onDone?.(event as Done)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "oj2",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"oxlint": "^1.85.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
@@ -531,6 +532,44 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.146.0", "https://registry.npmjs.com/@oxc-project/types/-/types-0.146.0.tgz", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "https://registry.npmjs.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "https://registry.npmjs.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
|
||||
@@ -1215,6 +1254,8 @@
|
||||
|
||||
"obug": ["obug@2.1.4", "https://registry.npmjs.com/obug/-/obug-2.1.4.tgz", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||
|
||||
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
|
||||
|
||||
"p-retry": ["p-retry@6.2.1", "https://registry.npmjs.com/p-retry/-/p-retry-6.2.1.tgz", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.8.0", "https://registry.npmjs.com/package-manager-detector/-/package-manager-detector-1.8.0.tgz", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
|
||||
|
||||
@@ -12,6 +12,14 @@ POSTGRES_PASSWORD=
|
||||
# openssl rand -hex 32
|
||||
OJ2_JUDGE_TOKEN=
|
||||
|
||||
# Redis 口令。会话存在 Redis 里,能连上它就能伪造任意用户(包括超管)的登录态,
|
||||
# 所以必须设。它会拼进 REDIS_URL,只能用 URL 安全的字符,自己生成:
|
||||
# openssl rand -hex 32
|
||||
# 自带数据形态下本栈的 oj-redis 直接用它;外接旧栈 redis 时,那边的 redis-server
|
||||
# 也要带上同一个 --requirepass(见 docs/deploy.md「Redis 口令」)。
|
||||
# 机房的 redis 不发布端口,只在 compose 网络里,不需要设。
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# 判题并发。受判题沙箱能力限制,不是越大越好。
|
||||
# 服务器 2,机房机器宽裕可以给 4。
|
||||
JUDGE_CONCURRENCY=2
|
||||
|
||||
@@ -40,23 +40,35 @@ set -euo pipefail
|
||||
|
||||
CONTAINER="${CONTAINER:-oj-redis}"
|
||||
|
||||
# redis 带口令时(服务器那台),redis-cli 靠 REDISCLI_AUTH 认证。没显式给就从
|
||||
# docker/.env 里读;机房和本机 dev 的 redis 不带口令,读不到就留空,不影响。
|
||||
if [ -z "${REDIS_PASSWORD:-}" ] && [ -f "$(dirname "$0")/.env" ]; then
|
||||
REDIS_PASSWORD=$(grep -E '^REDIS_PASSWORD=' "$(dirname "$0")/.env" | tail -1 | cut -d= -f2- || true)
|
||||
fi
|
||||
# 口令为空时连 -e 都不能带:空的 REDISCLI_AUTH 会让 redis-cli 发一条 AUTH "",
|
||||
# 在不带口令的 redis 上报一行 AUTH failed(实测,虽然随后命令照常执行)。
|
||||
EXEC=(docker exec)
|
||||
[ -z "${REDIS_PASSWORD:-}" ] || EXEC+=(-e REDISCLI_AUTH="$REDIS_PASSWORD")
|
||||
EXEC+=("$CONTAINER" redis-cli)
|
||||
cli() { "${EXEC[@]}" "$@"; }
|
||||
|
||||
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
|
||||
|
||||
docker exec "$CONTAINER" redis-cli ping >/dev/null 2>&1 \
|
||||
|| die "连不上容器 $CONTAINER 里的 redis(用 CONTAINER=... 指定容器名)"
|
||||
[ "$(cli ping 2>/dev/null)" = PONG ] \
|
||||
|| die "连不上容器 $CONTAINER 里的 redis(用 CONTAINER=... 指定容器名;带口令的话检查 REDIS_PASSWORD)"
|
||||
|
||||
# 用 SCAN 而不是 KEYS:KEYS 会阻塞住整个 Redis,而这上面还挂着判题队列和所有人的
|
||||
# 会话读写。xargs 分批是因为单条 DEL 传太多 key 会顶到命令行长度上限;每批 DEL 返回
|
||||
# 删掉的个数,累加起来就是总数。
|
||||
purge() {
|
||||
docker exec "$CONTAINER" redis-cli --scan --pattern "$1" 2>/dev/null \
|
||||
| xargs -r -n 400 docker exec "$CONTAINER" redis-cli del \
|
||||
cli --scan --pattern "$1" 2>/dev/null \
|
||||
| xargs -r -n 400 "${EXEC[@]}" del \
|
||||
| awk '{ sum += $1 } END { print sum + 0 }'
|
||||
}
|
||||
|
||||
count() { docker exec "$CONTAINER" redis-cli --scan --pattern "$1" 2>/dev/null | wc -l; }
|
||||
count() { cli --scan --pattern "$1" 2>/dev/null | wc -l; }
|
||||
|
||||
say "容器 $CONTAINER"
|
||||
before_sessions=$(count 'session:*')
|
||||
|
||||
@@ -61,10 +61,16 @@ services:
|
||||
# redis 里没有非丢不可的东西(会话、判题队列),用旧的那个也无所谓 ——
|
||||
# 旧后端已经停了,新后端独占它,剩下的 Django / dramatiq 残留键前缀不同,互不干扰。
|
||||
profiles: ["local-data"]
|
||||
# 会话存在这里:谁能连上它,谁就能写一条 session:<token> 冒充超管。所以必须带密码,
|
||||
# 端口也只发布在本机(api / worker 走 compose 网络连 oj-redis,用不着宿主机端口)。
|
||||
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?}"]
|
||||
environment:
|
||||
# redis-cli 认这个变量,healthcheck 和 docker exec 进来的 redis-cli 都不用再带 -a
|
||||
REDISCLI_AUTH: ${REDIS_PASSWORD:?}
|
||||
volumes:
|
||||
- ${DATA_DIR:-../data}/redis:/data
|
||||
ports:
|
||||
- "5446:6379"
|
||||
- "127.0.0.1:5446:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
@@ -134,7 +140,8 @@ services:
|
||||
- ${DATA_DIR:-../data}/backend:/data
|
||||
environment: &api-env
|
||||
DATABASE_URL: postgres://onlinejudge:${POSTGRES_PASSWORD:?}@${DB_HOST:-oj-postgres}:${DB_PORT:-5432}/onlinejudge
|
||||
REDIS_URL: redis://${REDIS_HOST:-oj-redis}:${REDIS_PORT:-6379}
|
||||
# 密码拼进 URL,所以只能用 URL 安全的字符 —— 用 openssl rand -hex 32 生成
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:?请在 docker/.env 里设置 REDIS_PASSWORD}@${REDIS_HOST:-oj-redis}:${REDIS_PORT:-6379}
|
||||
JUDGE_SERVER_URL: http://oj-judge:8080
|
||||
JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?}
|
||||
JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2}
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
POSTGRES_USER: onlinejudge
|
||||
POSTGRES_PASSWORD: onlinejudge
|
||||
ports:
|
||||
- "5433:5432" # 5433 避开可能存在的本机 5432
|
||||
- "127.0.0.1:5433:5432" # 5433 避开可能存在的本机 5432
|
||||
volumes:
|
||||
- oj2-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
container_name: oj2-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6380:6379" # 6380 避开可能存在的本机 6379
|
||||
- "127.0.0.1:6380:6379" # 6380 避开可能存在的本机 6379
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
@@ -75,7 +75,7 @@ services:
|
||||
tmpfs:
|
||||
- /tmp
|
||||
ports:
|
||||
- "8081:8080"
|
||||
- "127.0.0.1:8081:8080"
|
||||
volumes:
|
||||
- ../data/test_case:/test_case:ro
|
||||
- ../data/judge_server/log:/log
|
||||
|
||||
@@ -175,6 +175,17 @@ if [ "$LOCAL_DATA" -eq 0 ]; then
|
||||
|| die "外接形态依赖的 $c 没在跑 —— 先 docker compose -f /root/OJDeploy/docker-compose.yml up -d $c"
|
||||
done
|
||||
ok "外接的 postgres / redis 都在跑"
|
||||
|
||||
# 外接的 redis 不归本栈管,口令是否真的生效只能在这里查。旧栈那份 compose 的 redis
|
||||
# 原本没有口令、端口发布在公网上 —— 会话存在里面,等于人人可以伪造超管登录态。
|
||||
# 不带口令能 PING 通就说明那边还没加 --requirepass,这种状态不许上线。
|
||||
redis_pw=$(grep -E '^REDIS_PASSWORD=' docker/.env | tail -1 | cut -d= -f2- || true)
|
||||
[ -n "$redis_pw" ] || die "docker/.env 里没有 REDIS_PASSWORD(openssl rand -hex 32 生成)"
|
||||
[ "$(docker exec -e REDISCLI_AUTH= oj-redis redis-cli ping 2>/dev/null)" != PONG ] \
|
||||
|| die "外接的 oj-redis 不带口令也能连上 —— 先给它加 --requirepass,见 docs/deploy.md「Redis 口令」"
|
||||
[ "$(docker exec -e REDISCLI_AUTH="$redis_pw" oj-redis redis-cli ping 2>/dev/null)" = PONG ] \
|
||||
|| die "用 docker/.env 的 REDIS_PASSWORD 连不上外接的 oj-redis —— 两边口令不一致"
|
||||
ok "外接的 redis 要求口令,且和 docker/.env 一致"
|
||||
else
|
||||
ok "postgres / redis 由本栈起,不预检"
|
||||
fi
|
||||
|
||||
@@ -33,6 +33,31 @@ Redis)和 WebSocket 推送(本地 pub/sub)是每站独立的 —— 学生
|
||||
沿用旧数据却忘了设它,会静默挂上一堆空目录:空库、没测试点、题面图片 404,
|
||||
**而且不报错**。这是整个部署里唯一会静默走歪的地方,`deploy.sh` 专门为它设了一道自检。
|
||||
|
||||
## Redis 口令
|
||||
|
||||
**会话存在 Redis 里**(`session:<token>` → 用户 id),谁能连上它,谁就能写一条会话
|
||||
冒充任意用户,包括超管。所以服务器那台的 Redis **必须带口令**:`compose.debian.yml`
|
||||
的 `REDIS_URL` 用 `${REDIS_PASSWORD:?}` 强制要求,`deploy.sh` 在外接形态下还会实测
|
||||
「不带口令连不上、带 `docker/.env` 的口令连得上」,两条有一条不满足就中止部署。
|
||||
|
||||
2026-09-27 以前,旧栈 compose 起的那个 `oj-redis` 发布在 `0.0.0.0:5446`、不带口令,
|
||||
**从公网直接 PING 得通**(实测过)。给它加口令的一次性步骤,在服务器上:
|
||||
|
||||
```bash
|
||||
cd /root/OJDeploy
|
||||
PW=$(openssl rand -hex 32)
|
||||
echo "REDIS_PASSWORD=$PW" >> OJ2/docker/.env
|
||||
# 编辑 docker-compose.yml 的 oj-redis,加一行($PW 换成上面的值,别提交到任何仓库):
|
||||
# command: ["redis-server", "--requirepass", "<PW>"]
|
||||
docker compose up -d oj-redis # 重建 redis,此刻起旧口令(无)的连接全部失败
|
||||
cd OJ2 && docker/deploy.sh # 马上重新部署,api / worker 带上口令
|
||||
```
|
||||
|
||||
两步之间 api 连不上 Redis,站点会 500 一两分钟,挑没人用的时候做。
|
||||
会话会保留(redis 退出时落盘 RDB),学生不用重新登录。
|
||||
|
||||
机房那台的 `oj-redis` 不发布端口、只在 compose 网络里,不需要口令。
|
||||
|
||||
## 上线
|
||||
|
||||
### 服务器:push 就部署
|
||||
|
||||
+4
-2
@@ -13,11 +13,13 @@
|
||||
"db:generate": "bun run --filter '@oj2/api' db:generate",
|
||||
"db:migrate": "bun run --filter '@oj2/api' db:migrate",
|
||||
"db:down": "docker compose -f docker/compose.dev.yml down",
|
||||
"lint": "oxlint --deny-warnings apps/api/src apps/web/src packages/contract/src",
|
||||
"fmt": "prettier --write apps/api/src apps/web/src apps/web/tests packages/contract/src apps/api/drizzle.config.ts apps/web/vite.config.ts docker/judge/smoke.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"oxlint": "^1.85.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/bun": "latest"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { adminTypeSchema, problemPermissionSchema } from "./roles"
|
||||
import { ADMIN_TYPES, PROBLEM_PERMISSIONS } from "./roles"
|
||||
|
||||
import { achievementRaritySchema } from "./achievement"
|
||||
import { rankProfileSchema } from "./account"
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
sqlDisplaySchema,
|
||||
} from "./problem"
|
||||
|
||||
// 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头
|
||||
export const adminTypeSchema = z.enum(ADMIN_TYPES)
|
||||
export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS)
|
||||
|
||||
/**
|
||||
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
|
||||
* (后台要 `visible` 这类管理字段,oj 侧连键都不该出现),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* 角色与题目权限的字面量。**全仓唯一的定义处。**
|
||||
*
|
||||
@@ -10,6 +8,9 @@ import { z } from "zod"
|
||||
* ADMIN_ROLES / TEACHER_ROLES,`["Regular User", "Student Admin"]` 这个学生口径
|
||||
* 在四个文件里各写一遍,另有二十多处散落的 `=== "Super Admin"`。拼错一个字母
|
||||
* TypeScript 一个字都不会说,只会在运行时静默放行或静默拒绝。
|
||||
*
|
||||
* **这个文件不能 import zod。** 前端入口要用这里的 USER_TYPE / toAdminType,
|
||||
* 一 import 就把整个 zod 运行时(~100KB)拖进首屏包。对应的 zod schema 在 admin.ts。
|
||||
*/
|
||||
export const ADMIN_TYPES = [
|
||||
"Regular User",
|
||||
@@ -18,11 +19,9 @@ export const ADMIN_TYPES = [
|
||||
"Super Admin",
|
||||
] as const
|
||||
export type AdminType = (typeof ADMIN_TYPES)[number]
|
||||
export const adminTypeSchema = z.enum(ADMIN_TYPES)
|
||||
|
||||
export const PROBLEM_PERMISSIONS = ["None", "Own", "All"] as const
|
||||
export type ProblemPermission = (typeof PROBLEM_PERMISSIONS)[number]
|
||||
export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS)
|
||||
|
||||
/**
|
||||
* 三个分组一律是**白名单**,对齐旧后端 `account/models.py:65-73` 的显式列举写法。
|
||||
|
||||
Reference in new issue
Block a user