Deploy / deploy (push) Canceled after 0s
上游 QingdaoU/JudgeServer 停更在 2024-04(registry 上的 latest 和 1.6.1 是同一份 镜像,编译器停在 gcc-13),没有新版可拉,所以自己重编。docker/judge/ 是只改工具链 的 Dockerfile 分叉,server/ 和 Judger/ 从上游固定 commit b28aa56 拉,一行没动。 镜像 oj2-judge-2(不在任何 registry 上:本机 build.sh --save → scp → docker load) - gcc/g++ 13 → 14.2,Python 3.12 → 3.13.5,都是 trixie 默认 - Go / JDK / Node 整套删掉:前端的题目语言复选框从来只给 C / C++ / Python / SQL, 12 万条提交里 Java 44 条、Golang 15、JavaScript 3,全是很早以前的 - 体积 1.1GB → 433MB;默认走清华源,构建 12 分钟 → 40 秒(--no-mirror 换回官方) - deploy.sh 加一道自检:镜像不在本机就中止,并打印该跑的三条命令 C 的编译参数加三个 -Wno-error(implicit-function-declaration / int-conversion / incompatible-pointer-types):gcc-14 把它们从 warning 提成了 error,而 -w 压不住。 语言值统一成 Python(迁移 0019 / 0020) - 0019:Python3(104527 条提交)与 Python2(3 条)并成 Python,一并改掉 937 道题的 languages、75 个 template 键、15 个 ast_rules 键、257 条 answers、1235 个用户的 成就指标 _languages(languages_used 重算,总和 1928 → 1925,少的 3 个是同时用过 两种 Python 的人) - 0020:把 Java / JavaScript / Golang 从 84 道题的可选语言里摘掉 —— 不摘的话那些题 的语言下拉还能选 Java,提交必 SYSTEM_ERROR - 契约新增 normalizeLanguage() 别名表,判题侧一律走 judgeConfigFor():旧客户端 localStorage 里的 Python3、迁移前排进队列的任务都还能判;协作的语言归一也走它, 否则上线那一刻学生页面里的 Python3 会静默落到 C - 回滚要连数据一起回,只滚代码会让所有 Python 提交变 SYSTEM_ERROR 实跑 - 判题冒烟 docker/judge/smoke.ts 13 条全过:三种语言、六种状态码、gcc 宽松度 - 拿备份里的真实代码逐文件比对新旧镜像的编译结果,0 差异:C 提交 1951 份 (1725 过 / 226 CE)、C++ 882 份、Python 2000 份、20 篇 C 教程的 93 个代码块。 不加那三个 -Wno-error 的话,C 有 26 份会从能过变成 CE - 迁移在灌了 12.4 万行真实数据的一次性库里跑过:0 残留、没有题目被清空; dev 库用真正的执行器跑通 - check:ast 56 个 target 全过,前后端 typecheck 均 0 顺带记下一个升级之前就有的坑(现在随 Go 一起消失,写在 README 里):GOCACHE 指向 容器的 tmpfs,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒 的编译预算,于是重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
560 lines
16 KiB
TypeScript
560 lines
16 KiB
TypeScript
import {
|
||
type AiAnalysisRecord,
|
||
type AiHintFeedbackRequest,
|
||
type Contest as OjContest,
|
||
type ContestAccess,
|
||
type ContestList,
|
||
type ActivityRankItem,
|
||
type FormatCodeResponse,
|
||
type Metrics,
|
||
type TutorialSummary,
|
||
type ClassComparisonResponse,
|
||
type ClassRankItem,
|
||
type ClassUserRank,
|
||
type UserRank,
|
||
type WeeklyRank,
|
||
type ProblemRank,
|
||
type CreateSubmissionResponse,
|
||
type ProblemAuthor,
|
||
type ProblemListItem,
|
||
type YearlyAc,
|
||
type ProblemList,
|
||
type CreateFlowchartResponse,
|
||
type FlowchartCurrent,
|
||
type FlowchartDetail,
|
||
type FlowchartList,
|
||
type FlowchartSubmission,
|
||
type AiDetail,
|
||
type DurationData,
|
||
type HeatmapItem,
|
||
type LoginSummary,
|
||
type SolvedList,
|
||
type ProblemSet,
|
||
type ProblemSetBadge,
|
||
type ProblemSetList,
|
||
type ProblemSetProblem,
|
||
type ProblemSetProgressList,
|
||
type UserBadge,
|
||
problemDetailSchema,
|
||
submissionDetailSchema,
|
||
type FlowchartStatistics,
|
||
type SubmissionStatistics,
|
||
type SubmissionStatisticsItems,
|
||
type TodaySubmissionStatistics,
|
||
} from "@oj2/contract"
|
||
import api from "utils/api"
|
||
import { contract } from "utils/contract"
|
||
import { filterResult } from "oj/transforms"
|
||
import type {
|
||
Announcement,
|
||
AnnouncementListItem,
|
||
ContestRank,
|
||
Profile,
|
||
Message,
|
||
SubmissionListItem,
|
||
Exercise,
|
||
Problem,
|
||
ReactionKey,
|
||
ReactionState,
|
||
Submission,
|
||
SubmissionListPayload,
|
||
SubmitCodePayload,
|
||
OnlineCount,
|
||
WebsiteConfig,
|
||
Tutorial,
|
||
TutorialProgress,
|
||
} from "utils/types"
|
||
|
||
/**
|
||
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
|
||
* 键名映射,抄漏一个字段就是静默 undefined。
|
||
*
|
||
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`,
|
||
* `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。
|
||
* 现在形状不符时记一条控制台分歧再放行原始数据。
|
||
*/
|
||
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")
|
||
}
|
||
|
||
export async function getProblemList(
|
||
offset = 0,
|
||
limit = 10,
|
||
searchParams: Record<string, unknown> = {},
|
||
) {
|
||
const res = await api.get<ProblemList>("problems", {
|
||
params: { paging: true, offset, limit, ...searchParams },
|
||
})
|
||
return {
|
||
results: res.results.map(filterResult),
|
||
total: res.total,
|
||
}
|
||
}
|
||
|
||
export function getAuthors(all = false) {
|
||
return api.get<ProblemAuthor[]>("problem-authors", {
|
||
params: { all: all ? "1" : "0" },
|
||
})
|
||
}
|
||
|
||
export async function getProblem(problemID: string, contestID: string) {
|
||
const endpoint = contestID
|
||
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
|
||
: `problems/${encodeURIComponent(problemID)}`
|
||
return detailProblem(await api.get<unknown>(endpoint))
|
||
}
|
||
|
||
// 未登录返回 "0",登录后返回百分比字符串
|
||
export function getProblemBeatRate(problemID: number) {
|
||
return api.get<string>(`problems/${problemID}/beat-count`)
|
||
}
|
||
|
||
export async function getSubmission(id: string): Promise<Submission> {
|
||
const response = await api.get<unknown>(
|
||
`submissions/${encodeURIComponent(id)}`,
|
||
)
|
||
return contract("GET /submissions/:id", submissionDetailSchema, response)
|
||
}
|
||
|
||
export function submitCode(data: SubmitCodePayload) {
|
||
return api.post<CreateSubmissionResponse>("submissions", data)
|
||
}
|
||
|
||
export function formatCode(data: { code: string; language: string }) {
|
||
const languages: Record<string, string> = {
|
||
Python: "python",
|
||
C: "c",
|
||
"C++": "cpp",
|
||
SQL: "sql",
|
||
}
|
||
return api.post<FormatCodeResponse>("code/format", {
|
||
code: data.code,
|
||
language: languages[data.language] ?? data.language.toLowerCase(),
|
||
})
|
||
}
|
||
|
||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||
const endpoint = params.contestId
|
||
? `contests/${encodeURIComponent(params.contestId)}/submissions`
|
||
: "submissions"
|
||
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
|
||
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
|
||
return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
|
||
// contestId 走的是路径,page 只有前端分页器用
|
||
params: { ...params, contestId: undefined, page: undefined },
|
||
})
|
||
}
|
||
|
||
export function getRankOfProblem(problemId: string) {
|
||
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
|
||
}
|
||
|
||
export function getTodaySubmissionCount(language?: string) {
|
||
return api.get<number>("submissions/today-count", { params: { language } })
|
||
}
|
||
|
||
/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */
|
||
export function getTodaySubmissionStatistics() {
|
||
return api.get<TodaySubmissionStatistics>("submissions/today-statistics")
|
||
}
|
||
|
||
export function adminRejudge(id: string) {
|
||
return api.post<{ ok: boolean }>(
|
||
`submissions/${encodeURIComponent(id)}/rejudge`,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
||
* 和上面那个按班级模糊匹配的不是一回事。
|
||
*/
|
||
export function getSubmissionStatisticsItems(
|
||
duration: { start?: string; end: string },
|
||
username: string,
|
||
problemID?: string,
|
||
) {
|
||
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
|
||
params: { ...duration, problemId: problemID, username },
|
||
})
|
||
}
|
||
|
||
export function getSubmissionStatistics(
|
||
duration: { start?: string; end: string },
|
||
problemID?: string,
|
||
username?: string,
|
||
) {
|
||
return api.get<SubmissionStatistics>("submissions/statistics", {
|
||
params: { ...duration, problemId: problemID, username },
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
|
||
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
|
||
*/
|
||
export function getRank(offset: number, limit: number) {
|
||
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
|
||
}
|
||
|
||
export function getActivityRank(start: string) {
|
||
return api.get<ActivityRankItem[]>("rankings/activity", {
|
||
params: { start },
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
|
||
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
|
||
*/
|
||
export function getWeeklyRank(scope: "global" | "class") {
|
||
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
|
||
}
|
||
|
||
export function getClassRank(grade?: number | null) {
|
||
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
|
||
}
|
||
|
||
export function getUserClassRank(
|
||
scope?: "all" | "window",
|
||
offset?: number,
|
||
limit?: number,
|
||
) {
|
||
return api.get<ClassUserRank>("me/class-rank", {
|
||
params: { scope, offset, limit },
|
||
})
|
||
}
|
||
|
||
export function getClassPK(
|
||
classNames: string[],
|
||
startTime?: string,
|
||
endTime?: string,
|
||
) {
|
||
return api.post<ClassComparisonResponse>("classes/comparison", {
|
||
classNames,
|
||
...(startTime ? { startTime } : {}),
|
||
...(endTime ? { endTime } : {}),
|
||
})
|
||
}
|
||
|
||
export function getContestList(query: {
|
||
offset: number
|
||
limit: number
|
||
keyword: string
|
||
status: string
|
||
tag: string
|
||
}) {
|
||
return api.get<ContestList>("contests", { params: query })
|
||
}
|
||
|
||
export function getContest(id: string) {
|
||
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
|
||
}
|
||
|
||
export function getContestAccess(id: string) {
|
||
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
|
||
}
|
||
|
||
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
|
||
export function checkContestPassword(contestID: string, password: string) {
|
||
return api.post<boolean>(`contests/${encodeURIComponent(contestID)}/access`, {
|
||
password,
|
||
})
|
||
}
|
||
|
||
export async function getContestProblems(contestID: string) {
|
||
const res = await api.get<ProblemListItem[]>(
|
||
`contests/${encodeURIComponent(contestID)}/problems`,
|
||
)
|
||
return res.map(filterResult)
|
||
}
|
||
|
||
export function getContestRank(
|
||
contestID: string,
|
||
query: { limit: number; offset: number },
|
||
) {
|
||
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
||
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
|
||
return api.get<{ results: ContestRank[]; total: number }>(
|
||
`contests/${encodeURIComponent(contestID)}/rank`,
|
||
{ params: query },
|
||
)
|
||
}
|
||
|
||
export function uploadAvatar(file: File) {
|
||
const form = new window.FormData()
|
||
form.append("image", file)
|
||
return api.post("me/avatar", form, {
|
||
headers: { "content-type": "multipart/form-data" },
|
||
})
|
||
}
|
||
|
||
export function updateProfile(data: { realName: string; mood: string }) {
|
||
return api.put<Profile>("me/profile", data)
|
||
}
|
||
|
||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||
return api.get<{ results: AnnouncementListItem[]; total: number }>(
|
||
"announcements",
|
||
{
|
||
params: { limit, offset },
|
||
},
|
||
)
|
||
}
|
||
|
||
export function getAnnouncement(id: number) {
|
||
return api.get<Announcement>(`announcements/${id}`)
|
||
}
|
||
|
||
export function getMessageList(offset = 0, limit = 10) {
|
||
// language 的收窄同 getSubmissions,见那里的说明
|
||
return api.get<{ results: Message[]; total: number }>("messages", {
|
||
params: { limit, offset },
|
||
})
|
||
}
|
||
|
||
export function getReaction(problemID: number) {
|
||
return api.get<ReactionState>(`problems/${problemID}/reaction`)
|
||
}
|
||
|
||
export function setReaction(problemID: number, type: ReactionKey) {
|
||
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
|
||
}
|
||
|
||
export function getMetrics(userid: number) {
|
||
return api.get<Metrics>(`users/${userid}/metrics`)
|
||
}
|
||
|
||
export function getTutorial(id: number) {
|
||
return api.get<Tutorial>(`tutorials/${id}`)
|
||
}
|
||
|
||
export function getTutorials(type: "python" | "c") {
|
||
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
|
||
}
|
||
|
||
export function getAIDetailData(start: string, end: string, username?: string) {
|
||
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
|
||
}
|
||
|
||
export function getAISolved(
|
||
start: string,
|
||
end: string,
|
||
offset: number,
|
||
limit: number,
|
||
username?: string,
|
||
) {
|
||
return api.get<SolvedList>("ai/solved", {
|
||
params: { start, end, offset, limit, username },
|
||
})
|
||
}
|
||
|
||
export function getAIDurationData(
|
||
end: string,
|
||
duration: string,
|
||
username?: string,
|
||
) {
|
||
return api.get<DurationData[]>("ai/duration", {
|
||
params: { end, duration, username },
|
||
})
|
||
}
|
||
|
||
export function getAIHeatmapData(username?: string) {
|
||
return api.get<HeatmapItem[]>("ai/heatmap", {
|
||
params: username ? { username } : {},
|
||
})
|
||
}
|
||
|
||
export function getAILoginSummary() {
|
||
return api.get<LoginSummary>("ai/login-summary")
|
||
}
|
||
|
||
export function getAIPinnedReport() {
|
||
return api.get<AiAnalysisRecord | null>("ai/pinned")
|
||
}
|
||
|
||
/** 学生评价一条 AI 提示。id 来自 /ai/hint 流的 done 事件,可以改票 */
|
||
export function submitHintFeedback(hintId: number, helpful: boolean) {
|
||
return api.post<null>(`ai/hint/${hintId}/feedback`, {
|
||
helpful,
|
||
} satisfies AiHintFeedbackRequest)
|
||
}
|
||
|
||
// ==================== 相似题目推荐 ====================
|
||
|
||
export function getSimilarProblems(problemId: string) {
|
||
return api
|
||
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
|
||
.then((response) => response.map(filterResult))
|
||
}
|
||
|
||
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
||
|
||
export function getProblemYearlyAC(problemId: string) {
|
||
return api.get<YearlyAc[]>(
|
||
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
|
||
)
|
||
}
|
||
|
||
// ==================== 流程图相关API ====================
|
||
|
||
export function submitFlowchart(data: {
|
||
problemId: number
|
||
mermaidCode: string
|
||
flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
|
||
}) {
|
||
return api.post<CreateFlowchartResponse>("flowcharts", data)
|
||
}
|
||
|
||
export function getFlowchartSubmission(id: string) {
|
||
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
|
||
}
|
||
|
||
export function getFlowchartSubmissions(params: {
|
||
username?: string
|
||
problemId?: string
|
||
myself?: string
|
||
offset?: number
|
||
limit?: number
|
||
today?: string
|
||
grade?: string
|
||
}) {
|
||
return api.get<FlowchartList>("flowcharts", { params })
|
||
}
|
||
|
||
export function getFlowchartStatistics(
|
||
duration: { start?: string; end: string },
|
||
problemID?: string,
|
||
username?: string,
|
||
) {
|
||
return api.get<FlowchartStatistics>("flowcharts/statistics", {
|
||
params: { ...duration, problemId: problemID, username },
|
||
})
|
||
}
|
||
|
||
export function retryFlowchartSubmission(submissionId: string) {
|
||
return api.post<{ status: string }>(
|
||
`flowcharts/${encodeURIComponent(submissionId)}/retry`,
|
||
)
|
||
}
|
||
|
||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
|
||
}
|
||
|
||
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
||
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
|
||
params: { page },
|
||
})
|
||
}
|
||
|
||
// ==================== 题单相关API ====================
|
||
|
||
export function getProblemSetList(
|
||
offset = 0,
|
||
limit = 10,
|
||
keyword = "",
|
||
difficulty = "",
|
||
status = "",
|
||
) {
|
||
return api.get<ProblemSetList>("problem-sets", {
|
||
params: { offset, limit, keyword, difficulty, status },
|
||
})
|
||
}
|
||
|
||
export function getProblemSetDetail(id: number) {
|
||
return api.get<ProblemSet>(`problem-sets/${id}`)
|
||
}
|
||
|
||
export function getProblemSetProblems(problemSetId: number) {
|
||
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
|
||
}
|
||
|
||
export function joinProblemSet(problemSetId: number) {
|
||
return api.post("problem-set-progress", { problemSetId })
|
||
}
|
||
|
||
export function getUserBadges(username?: string) {
|
||
return api.get<UserBadge[]>(
|
||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||
)
|
||
}
|
||
|
||
export function getProblemSetBadges(problemSetId: number) {
|
||
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
|
||
}
|
||
|
||
export function getProblemSetUserProgress(
|
||
problemSetId: number,
|
||
params?: {
|
||
limit?: number
|
||
offset?: number
|
||
className?: string
|
||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
||
},
|
||
) {
|
||
return api.get<ProblemSetProgressList>(
|
||
`problem-sets/${problemSetId}/user-progress`,
|
||
{ params },
|
||
)
|
||
}
|
||
|
||
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
|
||
}
|
||
|
||
/**
|
||
* 上报一次练一练的作答。`answer` 是给老师看的一句人话(「选了 A、C」),
|
||
* 只在做错时才有意义,做对了不用带。
|
||
*
|
||
* 截到 200 字符再发:后端契约卡的就是 200,填空题填了一整段的话,
|
||
* 不截就是一个 400,而学生这边什么都看不见 —— 留痕失败得静悄悄的。
|
||
*/
|
||
export function reportExerciseAttempt(
|
||
exerciseId: number,
|
||
payload: { correct: boolean; answer?: string },
|
||
) {
|
||
return fetch(`/api/exercises/${exerciseId}/attempts`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
keepalive: true,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
correct: payload.correct,
|
||
answer: payload.answer?.slice(0, 200),
|
||
}),
|
||
}).catch(() => undefined)
|
||
}
|
||
|
||
export function getLearnProgress(type: "python" | "c") {
|
||
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
|
||
}
|
||
|
||
/**
|
||
* 上报自学留痕。`opened` 为真表示刚进这一课,否则只是补停留时长。
|
||
*
|
||
* 走裸 fetch 而不是 axios,是为了 `keepalive`:离开页面那一下的最后一次上报,
|
||
* axios 发出去也会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
|
||
* 失败一律吞掉 —— 留痕是旁路,不该让学生看到任何报错。
|
||
*/
|
||
export function reportLearnProgress(
|
||
tutorialId: number,
|
||
payload: { seconds: number; opened: boolean },
|
||
) {
|
||
return fetch(`/api/tutorials/${tutorialId}/progress`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
keepalive: true,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => undefined)
|
||
}
|