feat(阶段3): oj 侧端点铺开(基线提交,未经评审)

由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线,
后续修复单独成 commit,便于区分与回退。

覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/
content/contest/flowchart/problemset/site)与对应 Zod 契约。

已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据;
judge 与 flowchart worker 均 ready。
未核验:权限边界与数据泄露,评审进行中。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 01:25:36 -06:00
parent ec274419c3
commit 8c00cdc947
52 changed files with 4273 additions and 300 deletions

View File

@@ -0,0 +1,54 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { userProfileSchema } from "./auth"
export const registerRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
email: z.email().max(64),
password: z.string().min(6).max(20),
})
export const updateProfileRequestSchema = z.object({
realName: z.string().max(32).nullable().optional(),
avatar: z.string().max(256).optional(),
blog: z.string().max(256).nullable().optional(),
mood: z.string().max(256).nullable().optional(),
github: z.string().max(256).nullable().optional(),
school: z.string().max(64).nullable().optional(),
major: z.string().max(64).nullable().optional(),
language: z.string().max(32).nullable().optional(),
})
export const metricsSchema = z.object({
now: z.string(),
latest: z.string(),
first: z.string(),
})
export const rankProfileSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
acceptedNumber: z.number().int(),
submissionNumber: z.number().int(),
mood: z.string().nullable(),
})
export const userRankSchema = paginatedSchema(rankProfileSchema)
export const activityRankItemSchema = z.object({
username: z.string(),
count: z.number().int().nonnegative(),
})
export const problemRankSchema = z.object({
className: z.string(),
rank: z.number().int(),
classAcCount: z.number().int().nonnegative(),
allAcCount: z.number().int().nonnegative(),
})
export const publicProfileSchema = userProfileSchema
export type RegisterRequest = z.infer<typeof registerRequestSchema>
export type UpdateProfileRequest = z.infer<typeof updateProfileRequestSchema>

View File

@@ -0,0 +1,51 @@
import { z } from "zod"
export const achievementRaritySchema = z.enum(["bronze", "silver", "gold", "platinum"])
export const achievementSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
hidden: z.boolean(),
metric: z.string().nullable(),
operator: z.enum(["gte", "lte"]).nullable(),
threshold: z.number().int().nullable(),
unlocked: z.boolean(),
unlockTime: z.string().nullable(),
backfilled: z.boolean(),
progress: z.number().nullable(),
unlockRate: z.number(),
})
export const achievementListSchema = z.object({
username: z.string(),
achievements: z.array(achievementSchema),
})
export const pendingAchievementSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
})
export const achievementSummarySchema = z.object({
username: z.string(),
total: z.number().int(),
unlocked: z.number().int(),
percent: z.number(),
rarity: z.array(z.object({
rarity: achievementRaritySchema,
label: z.string(),
total: z.number().int(),
unlocked: z.number().int(),
})),
recent: z.array(pendingAchievementSchema),
})
export const markAchievementsReadSchema = z.object({
ids: z.array(z.number().int()),
})

View File

@@ -0,0 +1,96 @@
import { z } from "zod"
export const durationDataSchema = z.object({
unit: z.string(),
index: z.number().int(),
start: z.string(),
end: z.string(),
grade: z.string(),
problemCount: z.number().int(),
submissionCount: z.number().int(),
})
export const solvedProblemSchema = z.object({
problem: z.object({
title: z.string(),
displayId: z.string(),
contestTitle: z.string(),
contestId: z.number().int().nullable(),
}),
acTime: z.string(),
rank: z.number().int().nullable(),
acCount: z.number().int(),
grade: z.string(),
periodRank: z.number().int().nullable(),
periodAcCount: z.number().int(),
difficulty: z.string(),
})
export const flowchartSummarySchema = z.object({
problemId: z.string(),
problemTitle: z.string(),
submissionCount: z.number().int(),
bestScore: z.number(),
bestGrade: z.string(),
latestSubmissionTime: z.string(),
avgScore: z.number(),
})
export const aiDetailSchema = z.object({
user: z.string(),
className: z.string().nullable(),
start: z.string(),
end: z.string(),
solved: z.array(solvedProblemSchema),
flowcharts: z.array(flowchartSummarySchema),
grade: z.string(),
tags: z.record(z.string(), z.number().int()),
difficulty: z.record(z.string(), z.number().int()),
contestCount: z.number().int(),
})
export const aiAnalysisRequestSchema = z.object({
details: z.unknown(),
duration: z.unknown(),
})
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
export const classAnalysisRequestSchema = z.object({
comparison: z.record(z.string(), z.unknown()),
})
export const classPkAnalysisRequestSchema = z.object({
comparisons: z.array(z.record(z.string(), z.unknown())).min(2),
timeRangeLabel: z.string().default("全部时间"),
})
export const heatmapItemSchema = z.object({
timestamp: z.number(),
value: z.number().int(),
})
export const aiAnalysisRecordSchema = z.object({
id: z.number().int(),
provider: z.string(),
model: z.string(),
data: z.record(z.string(), z.unknown()),
analysis: z.string(),
createTime: z.string(),
isPinned: z.boolean(),
username: z.string().optional(),
})
export const loginSummarySchema = z.object({
summary: z.object({
start: z.string(),
end: z.string(),
newProblemCount: z.number().int(),
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
solvedCount: z.number().int(),
flowchartSubmissionCount: z.number().int(),
}),
analysis: z.string(),
analysisError: z.string().optional(),
})

View File

@@ -0,0 +1,64 @@
import { z } from "zod"
export const classRankItemSchema = z.object({
className: z.string(),
userCount: z.number().int(),
totalAc: z.number().int(),
totalSubmission: z.number().int(),
avgAc: z.number(),
acRate: z.number(),
rank: z.number().int(),
})
export const classUserRankItemSchema = z.object({
userId: z.number().int(),
username: z.string(),
acceptedNumber: z.number().int(),
submissionNumber: z.number().int(),
rank: z.number().int(),
})
export const classUserRankSchema = z.object({
className: z.string(),
myRank: z.number().int(),
total: z.number().int(),
ranks: z.array(classUserRankItemSchema),
})
export const classComparisonSchema = z.object({
className: z.string(),
userCount: z.number().int(),
totalAc: z.number().int(),
totalSubmission: z.number().int(),
avgAc: z.number(),
medianAc: z.number(),
q1Ac: z.number(),
q3Ac: z.number(),
iqr: z.number(),
stdDev: z.number(),
top10Avg: z.number(),
middle80Avg: z.number(),
bottom10Avg: z.number(),
excellentRate: z.number(),
passRate: z.number(),
activeRate: z.number(),
acRate: z.number(),
compositeScore: z.number(),
recentTotalAc: z.number().int().optional(),
recentTotalSubmission: z.number().int().optional(),
recentAvgAc: z.number().optional(),
recentMedianAc: z.number().optional(),
recentTop10Avg: z.number().optional(),
recentActiveCount: z.number().int().optional(),
})
export const classComparisonRequestSchema = z.object({
classNames: z.array(z.string()).min(1),
startTime: z.string().optional(),
endTime: z.string().optional(),
})
export const classComparisonResponseSchema = z.object({
comparisons: z.array(classComparisonSchema),
hasTimeRange: z.boolean(),
})

View File

@@ -0,0 +1,21 @@
import { z } from "zod"
export const sampleUserSchema = z.object({
id: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
})
export const paginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(250).default(10),
offset: z.coerce.number().int().min(0).default(0),
})
export function paginatedSchema<T extends z.ZodType>(item: T) {
return z.object({
results: z.array(item),
total: z.number().int().nonnegative(),
})
}
export type SampleUser = z.infer<typeof sampleUserSchema>

View File

@@ -0,0 +1,74 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { submissionDetailSchema } from "./submission"
export const announcementSchema = z.object({
id: z.number().int(),
title: z.string(),
tag: z.string(),
content: z.string().optional(),
top: z.boolean(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string(),
})
export const announcementListSchema = paginatedSchema(announcementSchema)
export const messageSchema = z.object({
id: z.number().int(),
sender: sampleUserSchema,
createTime: z.string(),
message: z.string(),
submission: submissionDetailSchema,
})
export const messageListSchema = paginatedSchema(messageSchema)
export const createMessageRequestSchema = z.object({
recipientId: z.number().int().positive(),
submissionId: z.string().min(1),
message: z.string().min(1).max(1024 * 1024),
})
export const reactionKeySchema = z.enum([
"too_easy",
"too_hard",
"confusing",
"buggy",
"learned",
"interesting",
"want_explain",
])
export const reactionCountsSchema = z.record(reactionKeySchema, z.number().int())
export const reactionStateSchema = z.object({
mine: reactionKeySchema.nullable(),
counts: reactionCountsSchema.nullable(),
})
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
export const tutorialSummarySchema = z.object({
id: z.number().int(),
title: z.string(),
})
export const tutorialSchema = tutorialSummarySchema.extend({
content: z.string(),
code: z.string().nullable(),
isPublic: z.boolean(),
order: z.number().int(),
type: z.enum(["python", "c"]),
createdBy: sampleUserSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { problemDetailSchema, problemListItemSchema } from "./problem"
export const contestStatusSchema = z.enum(["-1", "0", "1"])
export const contestSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
tag: z.string(),
startTime: z.string(),
endTime: z.string(),
createTime: z.string(),
lastUpdateTime: z.string(),
createdBy: sampleUserSchema,
status: contestStatusSchema,
contestType: z.enum(["Public", "Password Protected"]),
now: z.string().optional(),
})
export const contestListSchema = paginatedSchema(contestSchema)
export const contestPasswordRequestSchema = z.object({
password: z.string().min(1).max(128),
})
export const contestAccessSchema = z.object({ access: z.boolean() })
export const contestProblemsSchema = z.array(z.union([problemListItemSchema, problemDetailSchema]))
export const contestRankItemSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
totalTime: z.number().int(),
submissionInfo: z.record(z.string(), z.unknown()),
contestId: z.number().int(),
})
export const contestRankSchema = paginatedSchema(contestRankItemSchema)

View File

@@ -0,0 +1,71 @@
import { z } from "zod"
import { paginatedSchema } from "./common"
export const flowchartStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])
export const createFlowchartRequestSchema = z.object({
problemId: z.number().int().positive(),
mermaidCode: z.string().trim().min(1).max(50_000).refine(
(value) => value.split("\n").filter((line) => line.trim()).length <= 200,
"Flowchart is too complex",
),
flowchartData: z.record(z.string(), z.unknown()).default({}),
})
export const flowchartSubmissionSchema = z.object({
id: z.string(),
username: z.string(),
problemId: z.number().int(),
mermaidCode: z.string(),
flowchartData: z.record(z.string(), z.unknown()),
status: flowchartStatusSchema,
createTime: z.string(),
aiScore: z.number().nullable(),
aiGrade: z.string().nullable(),
aiFeedback: z.string().nullable(),
aiSuggestions: z.string().nullable(),
aiCriteriaDetails: z.record(z.string(), z.unknown()),
aiProvider: z.string(),
aiModel: z.string(),
processingTime: z.number().nullable(),
evaluationTime: z.string().nullable(),
})
export const flowchartListItemSchema = z.object({
id: z.string(),
username: z.string(),
problem: z.string(),
problemTitle: z.string(),
status: flowchartStatusSchema,
createTime: z.string(),
aiScore: z.number().nullable(),
aiGrade: z.string().nullable(),
aiProvider: z.string(),
aiModel: z.string(),
processingTime: z.number().nullable(),
evaluationTime: z.string().nullable(),
showLink: z.boolean(),
})
export const flowchartListSchema = paginatedSchema(flowchartListItemSchema)
export const createFlowchartResponseSchema = z.object({ submissionId: z.string(), status: z.literal("pending") })
export const flowchartCurrentSchema = z.object({ count: z.number().int(), score: z.number(), grade: z.string() })
export const flowchartDetailSchema = z.object({ submission: flowchartSubmissionSchema.nullable(), count: z.number().int() })
export const flowchartUpdateSchema = z.object({
type: z.enum([
"flowchart_evaluation_completed",
"flowchart_evaluation_failed",
"flowchart_evaluation_update",
]),
submission_id: z.string(),
score: z.number().optional(),
grade: z.string().optional(),
feedback: z.string().optional(),
suggestions: z.string().optional(),
criteriaDetails: z.unknown().optional(),
error: z.string().optional(),
})
export type FlowchartUpdate = z.infer<typeof flowchartUpdateSchema>

View File

@@ -1,3 +1,13 @@
export * from "./auth"
export * from "./account"
export * from "./achievement"
export * from "./ai"
export * from "./classroom"
export * from "./common"
export * from "./content"
export * from "./contest"
export * from "./flowchart"
export * from "./problem"
export * from "./problemset"
export * from "./site"
export * from "./submission"

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
/** 题目列表项。字段取自 problem 表,只含列表页需要的列。 */
export const problemSummarySchema = z.object({
id: z.number().int(),
@@ -58,3 +60,39 @@ export const problemDetailSchema = z.object({
})
export type ProblemDetail = z.infer<typeof problemDetailSchema>
export const problemListItemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
difficulty: z.string(),
createdBy: sampleUserSchema,
tags: z.array(z.string()),
contestId: z.number().int().nullable(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
hasAstRules: z.boolean(),
myStatus: z.number().int().nullable(),
})
export const problemListSchema = paginatedSchema(problemListItemSchema)
export const tagSchema = z.object({
id: z.number().int(),
name: z.string(),
problemCount: z.number().int().nonnegative(),
})
export const problemAuthorSchema = z.object({
username: z.string(),
problemCount: z.number().int().nonnegative(),
})
export const yearlyAcSchema = z.object({
year: z.number().int(),
total: z.number().int().nonnegative(),
accepted: z.number().int().nonnegative(),
acRate: z.number(),
})

View File

@@ -0,0 +1,100 @@
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { problemListItemSchema } from "./problem"
export const problemSetUserProgressSummarySchema = z.object({
isJoined: z.boolean(),
progressPercentage: z.number(),
completedCount: z.number().int(),
totalCount: z.number().int(),
isCompleted: z.boolean(),
})
export const problemSetBadgeSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
conditionType: z.enum(["all_problems", "problem_count", "score"]),
conditionValue: z.number().int(),
isEarned: z.boolean().optional(),
})
export const problemSetSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string().optional(),
difficulty: z.enum(["Easy", "Medium", "Hard"]),
status: z.enum(["active", "archived", "draft"]),
endTime: z.string().nullable(),
visible: z.boolean(),
problemsCount: z.number().int(),
completedCount: z.number().int().optional(),
userProgress: problemSetUserProgressSummarySchema,
badges: z.array(problemSetBadgeSchema).optional(),
})
export const problemSetListSchema = paginatedSchema(problemSetSchema)
export const problemSetProblemSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
problem: problemListItemSchema,
order: z.number().int(),
isRequired: z.boolean(),
score: z.number().int(),
hint: z.string().nullable(),
isCompleted: z.boolean(),
})
export const joinProblemSetRequestSchema = z.object({
problemSetId: z.number().int().positive(),
})
export const updateProblemSetProgressRequestSchema = z.object({
problemSetId: z.number().int().positive(),
problemId: z.number().int().positive(),
submissionId: z.string().min(1),
})
export const completedProblemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
})
export const problemSetProgressSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
user: sampleUserSchema,
joinTime: z.string(),
completeTime: z.string().nullable(),
isCompleted: z.boolean(),
progressPercentage: z.number(),
completedProblemsCount: z.number().int(),
totalProblemsCount: z.number().int(),
totalScore: z.number().int(),
completedProblems: z.array(completedProblemSchema),
})
export const problemSetProgressListSchema = paginatedSchema(problemSetProgressSchema).extend({
statistics: z.object({
total: z.number().int(),
completed: z.number().int(),
avgProgress: z.number(),
}),
problems: z.array(completedProblemSchema),
})
export const userBadgeSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
badge: problemSetBadgeSchema,
earnedTime: z.string(),
problemset: z.object({ id: z.number().int(), title: z.string() }),
})

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
export const websiteConfigSchema = z.object({
websiteBaseUrl: z.string(),
websiteName: z.string(),
websiteNameShortcut: z.string(),
websiteFooter: z.string(),
allowRegister: z.boolean(),
submissionListShowAll: z.boolean(),
classList: z.array(z.string()),
enableMaxkb: z.boolean(),
})
export const quoteSchema = z.union([
z.string(),
z.record(z.string(), z.unknown()),
])
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { paginatedSchema } from "./common"
export const judgeStatusSchema = z.union([
z.literal(-2),
z.literal(-1),
@@ -55,6 +57,31 @@ export const submissionUpdateSchema = z.object({
err_info: z.string().optional(),
})
export const submissionListItemSchema = z.object({
id: z.string(),
problem: z.string(),
problemTitle: z.string(),
showLink: z.boolean(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
result: judgeStatusSchema,
language: z.string(),
shared: z.boolean(),
statisticInfo: z.record(z.string(), z.unknown()),
})
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
export const shareSubmissionRequestSchema = z.object({ shared: z.boolean() })
export const formatCodeRequestSchema = z.object({
code: z.string().max(1024 * 1024),
language: z.enum(["python", "c", "cpp", "sql"]),
})
export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema