## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
(error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
problemDisplayId 收到 undefined,改动后同样两个响应均通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
1 parent
b9a80d62bc
commit
ab47e71d6f
36 files changed
+562
-524
No files matched your search
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
STUDENT_ROLES,
|
||||
adminTypeSchema,
|
||||
adminUserListSchema,
|
||||
adminUserRankSchema,
|
||||
adminUserSchema,
|
||||
deleteUsersRequestSchema,
|
||||
importUsersRequestSchema,
|
||||
rankProfileSchema,
|
||||
resetPasswordResponseSchema,
|
||||
STUDENT_ROLES,
|
||||
updateUserRequestSchema,
|
||||
type AdminType,
|
||||
type AdminUser,
|
||||
type AdminUserList,
|
||||
type AdminUserRank,
|
||||
type ProblemPermission,
|
||||
type RankProfile,
|
||||
type ResetPasswordResponse,
|
||||
} from "@oj2/contract"
|
||||
import { randomInt } from "node:crypto"
|
||||
import { z } from "zod"
|
||||
@@ -66,7 +66,7 @@ function serialize(row: {
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
}, isOnline: boolean) {
|
||||
return adminUserSchema.parse({
|
||||
return {
|
||||
id: row.user.id,
|
||||
username: row.user.username,
|
||||
email: row.user.email,
|
||||
@@ -79,7 +79,7 @@ function serialize(row: {
|
||||
isOnline,
|
||||
rawPassword: row.user.rawPassword,
|
||||
className: row.user.className,
|
||||
})
|
||||
} satisfies AdminUser
|
||||
}
|
||||
|
||||
function selectUser(id: number) {
|
||||
@@ -122,16 +122,19 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
|
||||
.limit(limit).offset(offset),
|
||||
])
|
||||
|
||||
return success(c, adminUserRankSchema.parse({
|
||||
results: rows.map(({ profile, user }) => rankProfileSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ profile, user }) => ({
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
acceptedNumber: profile.acceptedNumber,
|
||||
submissionNumber: profile.submissionNumber,
|
||||
mood: profile.mood,
|
||||
})),
|
||||
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
|
||||
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
|
||||
isOnline: null,
|
||||
} satisfies RankProfile)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminUserRank)
|
||||
})
|
||||
|
||||
adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
||||
@@ -181,10 +184,10 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, adminUserListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map((row) => serialize(row, online.has(row.user.id))),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminUserList)
|
||||
})
|
||||
|
||||
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
|
||||
@@ -447,5 +450,5 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c
|
||||
}).where(eq(schema.user.id, id))
|
||||
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
|
||||
await revokeUserSessions(id, "session-ended")
|
||||
return success(c, resetPasswordResponseSchema.parse({ password }))
|
||||
return success(c, { password } satisfies ResetPasswordResponse)
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
achievementMetricSchema,
|
||||
adminAchievementSchema,
|
||||
createAchievementRequestSchema,
|
||||
updateAchievementRequestSchema,
|
||||
type AchievementMetric,
|
||||
type AdminAchievement,
|
||||
} from "@oj2/contract"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -17,7 +17,7 @@ import { queryInteger } from "../helpers"
|
||||
export const adminAchievementRoutes = new Hono<AppEnv>()
|
||||
|
||||
function serialize(row: typeof schema.achievement.$inferSelect) {
|
||||
return adminAchievementSchema.parse({
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
@@ -32,12 +32,12 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
|
||||
unlockCount: row.unlockCount,
|
||||
order: row.order,
|
||||
createTime: row.createTime,
|
||||
})
|
||||
} satisfies AdminAchievement
|
||||
}
|
||||
|
||||
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
|
||||
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
|
||||
success(c, ACHIEVEMENT_METRICS.map((item) => achievementMetricSchema.parse(item))))
|
||||
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]))
|
||||
|
||||
adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
|
||||
const rows = await db.select().from(schema.achievement)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
adminAiReportListSchema,
|
||||
adminAiReportListItemSchema,
|
||||
adminAiReportSchema,
|
||||
toggleAiReportPinResponseSchema,
|
||||
import type {
|
||||
AdminAiReport,
|
||||
AdminAiReportList,
|
||||
AdminAiReportListItem,
|
||||
ToggleAiReportPinResponse,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -22,13 +22,13 @@ function excerpt(analysis: string | null) {
|
||||
}
|
||||
|
||||
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) {
|
||||
return adminAiReportListItemSchema.parse({
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
createTime: row.createTime,
|
||||
analysisExcerpt: excerpt(row.analysis),
|
||||
isPinned: row.isPinned,
|
||||
})
|
||||
} satisfies AdminAiReportListItem
|
||||
}
|
||||
|
||||
const listColumns = {
|
||||
@@ -53,10 +53,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
||||
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
|
||||
.orderBy(desc(schema.aiAnalysis.createTime))
|
||||
return success(c, adminAiReportListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(listItem),
|
||||
total: rows.length,
|
||||
}))
|
||||
} satisfies AdminAiReportList)
|
||||
}
|
||||
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
@@ -68,10 +68,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, adminAiReportListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(listItem),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminAiReportList)
|
||||
})
|
||||
|
||||
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
|
||||
@@ -86,7 +86,7 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
|
||||
.where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
|
||||
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
|
||||
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
|
||||
return success(c, adminAiReportSchema.parse(row))
|
||||
return success(c, row satisfies AdminAiReport)
|
||||
})
|
||||
|
||||
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
|
||||
@@ -104,5 +104,5 @@ adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
|
||||
}
|
||||
await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id))
|
||||
})
|
||||
return success(c, toggleAiReportPinResponseSchema.parse({ isPinned: next }))
|
||||
return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
adminAnnouncementListSchema,
|
||||
adminAnnouncementSchema,
|
||||
createAnnouncementRequestSchema,
|
||||
updateAnnouncementRequestSchema,
|
||||
type AdminAnnouncement,
|
||||
type AdminAnnouncementList,
|
||||
} from "@oj2/contract"
|
||||
import { count, desc, eq } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -19,7 +19,7 @@ function serialize(row: {
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
}) {
|
||||
return adminAnnouncementSchema.parse({
|
||||
return {
|
||||
id: row.announcement.id,
|
||||
title: row.announcement.title,
|
||||
tag: row.announcement.tag,
|
||||
@@ -29,7 +29,7 @@ function serialize(row: {
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
createTime: row.announcement.createTime,
|
||||
lastUpdateTime: row.announcement.lastUpdateTime,
|
||||
})
|
||||
} satisfies AdminAnnouncement
|
||||
}
|
||||
|
||||
function selectOne(id: number) {
|
||||
@@ -55,11 +55,11 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, adminAnnouncementListSchema.parse({
|
||||
return success(c, {
|
||||
// 列表 schema omit 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍
|
||||
results: rows.map(serialize),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminAnnouncementList)
|
||||
})
|
||||
|
||||
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
dashboardInfoSchema,
|
||||
judgeServerListSchema,
|
||||
judgeServerSchema,
|
||||
orphanTestCaseSchema,
|
||||
updateJudgeServerRequestSchema,
|
||||
updateWebsiteConfigRequestSchema,
|
||||
uploadImageResponseSchema,
|
||||
websiteConfigSchema,
|
||||
type DashboardInfo,
|
||||
type JudgeServer,
|
||||
type JudgeServerList,
|
||||
type OrphanTestCase,
|
||||
type UploadImageResponse,
|
||||
type WebsiteConfig,
|
||||
} from "@oj2/contract"
|
||||
import { randomInt } from "node:crypto"
|
||||
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
||||
@@ -57,7 +57,7 @@ const OPTION_KEYS = {
|
||||
|
||||
adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
||||
const options = await getWebsiteOptions()
|
||||
return success(c, websiteConfigSchema.parse({
|
||||
return success(c, {
|
||||
websiteBaseUrl: options.website_base_url,
|
||||
websiteName: options.website_name,
|
||||
websiteNameShortcut: options.website_name_shortcut,
|
||||
@@ -66,7 +66,7 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
||||
submissionListShowAll: options.submission_list_show_all,
|
||||
classList: options.class_list,
|
||||
enableMaxkb: options.enable_maxkb,
|
||||
}))
|
||||
} satisfies WebsiteConfig)
|
||||
})
|
||||
|
||||
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
|
||||
@@ -96,14 +96,14 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
|
||||
|
||||
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
|
||||
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
|
||||
return success(c, judgeServerListSchema.parse({
|
||||
return success(c, {
|
||||
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
|
||||
token: config.judgeServerToken,
|
||||
servers: rows.map((row) => judgeServerSchema.parse({
|
||||
servers: rows.map((row) => ({
|
||||
...row,
|
||||
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
|
||||
})),
|
||||
}))
|
||||
} satisfies JudgeServer)),
|
||||
} satisfies JudgeServerList)
|
||||
})
|
||||
|
||||
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
|
||||
@@ -146,7 +146,7 @@ adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
||||
const ids = await orphanTestCaseIds()
|
||||
const rows = await Promise.all(ids.map(async (id) => {
|
||||
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
|
||||
return orphanTestCaseSchema.parse({ id, createTime: info ? info.mtimeMs / 1000 : 0 })
|
||||
return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase
|
||||
}))
|
||||
return success(c, rows)
|
||||
})
|
||||
@@ -180,12 +180,12 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
||||
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
||||
])
|
||||
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
||||
return success(c, dashboardInfoSchema.parse({
|
||||
return success(c, {
|
||||
userCount: users?.value ?? 0,
|
||||
todaySubmissionCount: submissions?.value ?? 0,
|
||||
recentContestCount: contests?.value ?? 0,
|
||||
judgeServerCount: servers?.value ?? 0,
|
||||
}))
|
||||
} satisfies DashboardInfo)
|
||||
})
|
||||
|
||||
adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
|
||||
@@ -218,16 +218,16 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
||||
const form = await c.req.formData().catch(() => null)
|
||||
const image = form?.get("image")
|
||||
if (!(image instanceof File)) {
|
||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload failed", filePath: "" }))
|
||||
return success(c, { success: false, msg: "Upload failed", filePath: "" } satisfies UploadImageResponse)
|
||||
}
|
||||
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
|
||||
if (!IMAGE_SUFFIXES.includes(suffix)) {
|
||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Unsupported file format", filePath: "" }))
|
||||
return success(c, { success: false, msg: "Unsupported file format", filePath: "" } satisfies UploadImageResponse)
|
||||
}
|
||||
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
|
||||
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
|
||||
if (image.size > MAX_IMAGE_BYTES) {
|
||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "图片不能超过 10MB", filePath: "" }))
|
||||
return success(c, { success: false, msg: "图片不能超过 10MB", filePath: "" } satisfies UploadImageResponse)
|
||||
}
|
||||
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
|
||||
const name = `${randomFileName()}${suffix}`
|
||||
@@ -236,13 +236,13 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
||||
await Bun.write(resolve(config.uploadDirectory, name), image)
|
||||
} catch (error) {
|
||||
console.error("Failed to save uploaded image", error)
|
||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload Error", filePath: "" }))
|
||||
return success(c, { success: false, msg: "Upload Error", filePath: "" } satisfies UploadImageResponse)
|
||||
}
|
||||
return success(c, uploadImageResponseSchema.parse({
|
||||
return success(c, {
|
||||
success: true,
|
||||
msg: "Success",
|
||||
filePath: `${config.uploadUriPrefix}/${name}`,
|
||||
}))
|
||||
} satisfies UploadImageResponse)
|
||||
})
|
||||
|
||||
function randomFileName() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
acmHelperItemSchema,
|
||||
adminContestListSchema,
|
||||
adminContestSchema,
|
||||
createContestRequestSchema,
|
||||
updateAcmHelperRequestSchema,
|
||||
updateContestRequestSchema,
|
||||
type AcmHelperItem,
|
||||
type AdminContest,
|
||||
type AdminContestList,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike, inArray } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -31,7 +31,7 @@ async function serialize(row: {
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
}) {
|
||||
return adminContestSchema.parse({
|
||||
return {
|
||||
id: row.contest.id,
|
||||
title: row.contest.title,
|
||||
description: row.contest.description,
|
||||
@@ -45,7 +45,7 @@ async function serialize(row: {
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
status: contestStatus(row.contest),
|
||||
contestType: row.contest.password ? "Password Protected" : "Public",
|
||||
})
|
||||
} satisfies AdminContest
|
||||
}
|
||||
|
||||
function selectContest(id: number) {
|
||||
@@ -84,10 +84,10 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, adminContestListSchema.parse({
|
||||
return success(c, {
|
||||
results: await Promise.all(rows.map(serialize)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminContestList)
|
||||
})
|
||||
|
||||
adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
|
||||
@@ -266,8 +266,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
|
||||
const results = []
|
||||
for (const rank of ranks) {
|
||||
if (rank.acceptedNumber <= 0) continue
|
||||
for (const [problemId, raw] of Object.entries(objectValue(rank.submissionInfo))) {
|
||||
const info = objectValue(raw)
|
||||
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
|
||||
if (info.is_ac !== true) continue
|
||||
results.push({
|
||||
id: rank.id,
|
||||
@@ -285,7 +284,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
|
||||
}
|
||||
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
|
||||
results.sort((left, right) => right._acTime - left._acTime)
|
||||
return success(c, results.map(({ _acTime, ...item }) => acmHelperItemSchema.parse(item)))
|
||||
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
|
||||
})
|
||||
|
||||
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||
@@ -306,9 +305,9 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) =>
|
||||
)).limit(1)
|
||||
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
|
||||
|
||||
const info = objectValue(rank.submissionInfo)
|
||||
const entry = objectValue(info[parsed.data.problemId])
|
||||
if (!info[parsed.data.problemId]) {
|
||||
const info = rank.submissionInfo
|
||||
const entry = info[parsed.data.problemId]
|
||||
if (!entry) {
|
||||
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
|
||||
}
|
||||
entry.checked = parsed.data.checked
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
STUDENT_ROLES,
|
||||
TUTORIAL_READ_SECONDS,
|
||||
learnExerciseAttemptSchema,
|
||||
learnExerciseProgressListSchema,
|
||||
learnExerciseProgressSchema,
|
||||
learnStudentProgressListSchema,
|
||||
learnStudentProgressSchema,
|
||||
learnTutorialProgressListSchema,
|
||||
learnTutorialProgressSchema,
|
||||
type LearnExerciseAttempt,
|
||||
type LearnExerciseProgress,
|
||||
type LearnExerciseProgressList,
|
||||
type LearnStudentProgress,
|
||||
type LearnStudentProgressList,
|
||||
type LearnTutorialProgress,
|
||||
type LearnTutorialProgressList,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -120,16 +120,16 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
|
||||
.where(inArray(schema.exercise.tutorialId, tutorialIds))
|
||||
: [{ value: 0 }]
|
||||
|
||||
return success(c, learnStudentProgressListSchema.parse({
|
||||
return success(c, {
|
||||
tutorialCount: tutorialIds.length,
|
||||
exerciseCount: exerciseCountRow?.value ?? 0,
|
||||
results: rows.map((row) => learnStudentProgressSchema.parse({
|
||||
results: rows.map((row) => ({
|
||||
...row,
|
||||
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
|
||||
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
|
||||
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
|
||||
})),
|
||||
}))
|
||||
} satisfies LearnStudentProgress)),
|
||||
} satisfies LearnStudentProgressList)
|
||||
})
|
||||
|
||||
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
|
||||
@@ -166,13 +166,13 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
|
||||
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
|
||||
return success(c, learnTutorialProgressListSchema.parse({
|
||||
return success(c, {
|
||||
studentCount,
|
||||
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({
|
||||
results: rows.map(({ readSeconds, ...row }) => ({
|
||||
...row,
|
||||
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||
})),
|
||||
}))
|
||||
} satisfies LearnTutorialProgress)),
|
||||
} satisfies LearnTutorialProgressList)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -216,13 +216,13 @@ adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) =>
|
||||
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
|
||||
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
|
||||
|
||||
return success(c, learnExerciseProgressListSchema.parse({
|
||||
return success(c, {
|
||||
studentCount: studentCountRow?.value ?? 0,
|
||||
results: rows.map((row) => learnExerciseProgressSchema.parse({
|
||||
results: rows.map((row) => ({
|
||||
...row,
|
||||
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
|
||||
})),
|
||||
}))
|
||||
} satisfies LearnExerciseProgress)),
|
||||
} satisfies LearnExerciseProgressList)
|
||||
})
|
||||
|
||||
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
|
||||
@@ -249,5 +249,5 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher,
|
||||
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
|
||||
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
|
||||
|
||||
return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row)))
|
||||
return success(c, rows satisfies LearnExerciseAttempt[])
|
||||
})
|
||||
@@ -1,18 +1,19 @@
|
||||
import {
|
||||
addContestProblemRequestSchema,
|
||||
adminProblemListItemSchema,
|
||||
adminProblemListSchema,
|
||||
adminProblemSchema,
|
||||
createProblemRequestSchema,
|
||||
makeProblemPublicRequestSchema,
|
||||
updateProblemRequestSchema,
|
||||
generateSqlTestCaseRequestSchema,
|
||||
generateSqlTestCaseResponseSchema,
|
||||
makeProblemPublicRequestSchema,
|
||||
sqlPreviewRequestSchema,
|
||||
sqlTestCaseScriptSchema,
|
||||
uploadTestCaseResponseSchema,
|
||||
updateProblemRequestSchema,
|
||||
type AdminProblem,
|
||||
type AdminProblemList,
|
||||
type AdminProblemListItem,
|
||||
type AstRules,
|
||||
type GenerateSqlTestCaseResponse,
|
||||
type SqlConfig,
|
||||
type SqlDisplay,
|
||||
type SqlTestCaseScript,
|
||||
type UploadTestCaseResponse,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -30,7 +31,7 @@ import { config } from "../../config"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
import { getTopReactions } from "../../services/reaction"
|
||||
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
|
||||
import { objectValue, queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminProblemRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -130,7 +131,7 @@ async function serialize(row: ProblemRow) {
|
||||
.where(eq(schema.user.id, row.createdById)).limit(1),
|
||||
tagNames(row.id),
|
||||
])
|
||||
return adminProblemSchema.parse({
|
||||
return {
|
||||
id: row.id,
|
||||
_id: row.displayId,
|
||||
title: row.title,
|
||||
@@ -141,8 +142,8 @@ async function serialize(row: ProblemRow) {
|
||||
testCaseId: row.testCaseId,
|
||||
testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [],
|
||||
hint: row.hint,
|
||||
languages: stringArray(row.languages),
|
||||
template: objectValue(row.template),
|
||||
languages: row.languages,
|
||||
template: row.template,
|
||||
createTime: row.createTime,
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
timeLimit: row.timeLimit,
|
||||
@@ -164,9 +165,9 @@ async function serialize(row: ProblemRow) {
|
||||
astRules: row.astRules,
|
||||
answers: Array.isArray(row.answers) ? row.answers : [],
|
||||
prompt: row.prompt,
|
||||
sqlConfig: row.sqlConfig ? objectValue(row.sqlConfig) : null,
|
||||
sqlDisplay: row.sqlDisplay ? objectValue(row.sqlDisplay) : null,
|
||||
})
|
||||
sqlConfig: row.sqlConfig,
|
||||
sqlDisplay: row.sqlDisplay,
|
||||
} satisfies AdminProblem
|
||||
}
|
||||
|
||||
/** 公共校验,对齐旧 `ProblemBase.common_checks` */
|
||||
@@ -205,7 +206,7 @@ async function generateSqlDisplay(
|
||||
testCaseId: string,
|
||||
answers: Record<string, unknown>[],
|
||||
sqlConfig: SqlConfig,
|
||||
): Promise<{ error: string } | { display: unknown }> {
|
||||
): Promise<{ error: string } | { display: SqlDisplay }> {
|
||||
const info = await readInfo(testCaseId)
|
||||
if (!info) return { error: "测试点信息读取失败,请重新上传测试点" }
|
||||
if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" }
|
||||
@@ -294,9 +295,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
||||
// 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致
|
||||
const problemIds = rows.map(({ problem }) => problem.id)
|
||||
const [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)])
|
||||
return success(c, adminProblemListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ problem, user: creator, realName }) =>
|
||||
adminProblemListItemSchema.parse({
|
||||
({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
@@ -309,9 +310,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
topReaction: topReactions.get(problem.id) ?? null,
|
||||
})),
|
||||
} satisfies AdminProblemListItem)),
|
||||
total: totalRow[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminProblemList)
|
||||
})
|
||||
|
||||
adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
|
||||
@@ -331,7 +332,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
|
||||
let sqlDisplay: unknown = null
|
||||
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)
|
||||
@@ -388,7 +389,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
||||
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||
|
||||
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
|
||||
let sqlDisplay: unknown = null
|
||||
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)
|
||||
@@ -467,9 +468,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
|
||||
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
|
||||
return success(c, adminProblemListSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ problem, user: creator, realName }) =>
|
||||
adminProblemListItemSchema.parse({
|
||||
({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
@@ -482,9 +483,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
topReaction: null,
|
||||
})),
|
||||
} satisfies AdminProblemListItem)),
|
||||
total: totalRow[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminProblemList)
|
||||
})
|
||||
|
||||
adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => {
|
||||
@@ -500,7 +501,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
|
||||
}
|
||||
const checked = commonChecks(parsed.data)
|
||||
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
|
||||
let sqlDisplay: unknown = null
|
||||
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)
|
||||
@@ -652,10 +653,10 @@ adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
|
||||
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
|
||||
try {
|
||||
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
|
||||
return success(c, uploadTestCaseResponseSchema.parse({
|
||||
return success(c, {
|
||||
id: result.testCaseId,
|
||||
info: result.info,
|
||||
}), 201)
|
||||
} satisfies UploadTestCaseResponse, 201)
|
||||
} catch (error) {
|
||||
if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message)
|
||||
console.error("Failed to process test case zip", error)
|
||||
@@ -701,7 +702,7 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
|
||||
if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型")
|
||||
try {
|
||||
const scripts = await readSqlScripts(problem.testCaseId)
|
||||
return success(c, scripts.map((script) => sqlTestCaseScriptSchema.parse(script)))
|
||||
return success(c, scripts satisfies SqlTestCaseScript[])
|
||||
} catch (error) {
|
||||
console.error("Failed to read SQL test case scripts", error)
|
||||
return failure(c, 500, "test-case-error", "测试点脚本读取失败")
|
||||
@@ -735,7 +736,7 @@ SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
|
||||
请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`,
|
||||
`题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`,
|
||||
)
|
||||
return success(c, generateSqlTestCaseResponseSchema.parse({ sql }))
|
||||
return success(c, { sql } satisfies GenerateSqlTestCaseResponse)
|
||||
} catch (error) {
|
||||
console.error("SQL test case generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
addProblemToSetRequestSchema,
|
||||
adminProblemSetBadgeSchema,
|
||||
adminProblemSetListSchema,
|
||||
adminProblemSetProblemSchema,
|
||||
adminProblemSetProgressSchema,
|
||||
adminProblemSetSchema,
|
||||
createProblemSetBadgeRequestSchema,
|
||||
createProblemSetRequestSchema,
|
||||
updateProblemInSetRequestSchema,
|
||||
updateProblemSetBadgeRequestSchema,
|
||||
updateProblemSetRequestSchema,
|
||||
updateProblemSetStatusRequestSchema,
|
||||
type AdminProblemSet,
|
||||
type AdminProblemSetBadge,
|
||||
type AdminProblemSetList,
|
||||
type AdminProblemSetProblem,
|
||||
type AdminProblemSetProgress,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
||||
import { queryInteger, sampleUser } from "../helpers"
|
||||
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -65,7 +65,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||||
const creatorById = new Map(creators.map((item) => [item.id, item]))
|
||||
return rows.map((row) => {
|
||||
const creator = creatorById.get(row.createdById)
|
||||
return adminProblemSetSchema.parse({
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
@@ -78,7 +78,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||||
lastUpdateTime: row.lastUpdateTime,
|
||||
problemsCount: problemsBySet.get(row.id) ?? 0,
|
||||
participantCount: participantsBySet.get(row.id) ?? 0,
|
||||
})
|
||||
} satisfies AdminProblemSet
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||||
ilike(schema.problemset.description, `%${keyword}%`),
|
||||
)!)
|
||||
}
|
||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty))
|
||||
if (status) filters.push(eq(schema.problemset.status, status))
|
||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
|
||||
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
|
||||
const where = filters.length ? and(...filters) : undefined
|
||||
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
@@ -111,10 +111,10 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||||
db.select().from(schema.problemset).where(where)
|
||||
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, adminProblemSetListSchema.parse({
|
||||
return success(c, {
|
||||
results: await serializeMany(rows),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminProblemSetList)
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
||||
@@ -194,7 +194,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||
return success(c, rows.map(({ item, problem }) => adminProblemSetProblemSchema.parse({
|
||||
return success(c, rows.map(({ item, problem }) => ({
|
||||
id: item.id,
|
||||
problemsetId: item.problemsetId,
|
||||
problemId: item.problemId,
|
||||
@@ -205,7 +205,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
|
||||
isRequired: item.isRequired,
|
||||
score: item.score,
|
||||
hint: item.hint,
|
||||
})))
|
||||
} satisfies AdminProblemSetProblem)))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
||||
@@ -289,7 +289,7 @@ async function badgesWithCount(badges: BadgeRow[]) {
|
||||
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)))
|
||||
.groupBy(schema.userBadge.badgeId)
|
||||
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
|
||||
return badges.map((badge) => adminProblemSetBadgeSchema.parse({
|
||||
return badges.map((badge) => ({
|
||||
id: badge.id,
|
||||
problemsetId: badge.problemsetId,
|
||||
name: badge.name,
|
||||
@@ -298,7 +298,7 @@ async function badgesWithCount(badges: BadgeRow[]) {
|
||||
conditionType: badge.conditionType,
|
||||
conditionValue: badge.conditionValue,
|
||||
earnedCount: countByBadge.get(badge.id) ?? 0,
|
||||
}))
|
||||
} satisfies AdminProblemSetBadge))
|
||||
}
|
||||
|
||||
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => {
|
||||
@@ -375,7 +375,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c
|
||||
.where(eq(schema.problemsetProgress.problemsetId, row.id))
|
||||
.orderBy(desc(schema.problemsetProgress.joinTime))
|
||||
return success(c, rows.map(({ progress, username, realName }) =>
|
||||
adminProblemSetProgressSchema.parse({
|
||||
({
|
||||
id: progress.id,
|
||||
userId: progress.userId,
|
||||
username,
|
||||
@@ -388,7 +388,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c
|
||||
completedProblemsCount: progress.completedProblemsCount,
|
||||
totalProblemsCount: progress.totalProblemsCount,
|
||||
totalScore: progress.totalScore,
|
||||
})))
|
||||
} satisfies AdminProblemSetProgress)))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
acTrendSchema,
|
||||
adminTagSchema,
|
||||
batchProblemTagRequestSchema,
|
||||
batchProblemTagResponseSchema,
|
||||
generateFlowchartRequestSchema,
|
||||
generateFlowchartResponseSchema,
|
||||
renameTagRequestSchema,
|
||||
renameTagResponseSchema,
|
||||
stuckProblemSchema,
|
||||
type AcTrend,
|
||||
type AdminTag,
|
||||
type BatchProblemTagResponse,
|
||||
type GenerateFlowchartResponse,
|
||||
type RenameTagResponse,
|
||||
type StuckProblem,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -46,7 +46,7 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
||||
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
|
||||
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
|
||||
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
|
||||
return success(c, rows.map((row) => adminTagSchema.parse(row)))
|
||||
return success(c, rows satisfies AdminTag[])
|
||||
})
|
||||
|
||||
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
@@ -63,7 +63,7 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
|
||||
if (!target) {
|
||||
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
|
||||
return success(c, renameTagResponseSchema.parse({ merged: false, id, name, affectedCount: 0 }))
|
||||
return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse)
|
||||
}
|
||||
|
||||
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
|
||||
@@ -86,9 +86,9 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||
return links.length
|
||||
})
|
||||
return success(c, renameTagResponseSchema.parse({
|
||||
return success(c, {
|
||||
merged: true, id: target.id, name: target.name, affectedCount: affected,
|
||||
}))
|
||||
} satisfies RenameTagResponse)
|
||||
})
|
||||
|
||||
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
@@ -152,10 +152,10 @@ adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) =
|
||||
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
||||
})
|
||||
|
||||
return success(c, batchProblemTagResponseSchema.parse({
|
||||
return success(c, {
|
||||
problemCount: problems.length,
|
||||
tagCount: tagIds.length,
|
||||
}))
|
||||
} satisfies BatchProblemTagResponse)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 题目可见性
|
||||
@@ -209,14 +209,14 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
|
||||
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
|
||||
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
|
||||
.limit(40)
|
||||
return success(c, rows.map((row) => stuckProblemSchema.parse({
|
||||
return success(c, rows.map((row) => ({
|
||||
problemId: row.displayId,
|
||||
problemTitle: row.title,
|
||||
total: row.total,
|
||||
failed: row.failed,
|
||||
failedUsers: row.failedUsers,
|
||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||
})))
|
||||
} satisfies StuckProblem)))
|
||||
})
|
||||
|
||||
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||
@@ -263,7 +263,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
|
||||
if (![...required].every((y) => years.has(y))) continue
|
||||
if (!entry.yearly.every((row) => row.total > minPerYear)) continue
|
||||
result.push(acTrendSchema.parse({
|
||||
result.push({
|
||||
problemId: entry.displayId,
|
||||
problemTitle: entry.title,
|
||||
yearly: entry.yearly
|
||||
@@ -274,7 +274,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||
}))
|
||||
.sort((left, right) => left.year - right.year),
|
||||
}))
|
||||
} satisfies AcTrend)
|
||||
}
|
||||
return success(c, result)
|
||||
})
|
||||
@@ -292,7 +292,7 @@ adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) =
|
||||
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
|
||||
parsed.data.python,
|
||||
)
|
||||
return success(c, generateFlowchartResponseSchema.parse({ flowchart }))
|
||||
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
|
||||
} catch (error) {
|
||||
console.error("Flowchart generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
adminExerciseSchema,
|
||||
adminTutorialGroupsSchema,
|
||||
adminTutorialSchema,
|
||||
createExerciseRequestSchema,
|
||||
createTutorialRequestSchema,
|
||||
setTutorialVisibilityRequestSchema,
|
||||
updateExerciseRequestSchema,
|
||||
updateTutorialRequestSchema,
|
||||
type AdminExercise,
|
||||
type AdminTutorial,
|
||||
type AdminTutorialGroups,
|
||||
} from "@oj2/contract"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -24,7 +24,7 @@ function serializeTutorial(row: {
|
||||
user: typeof schema.user.$inferSelect
|
||||
realName: string | null
|
||||
}) {
|
||||
return adminTutorialSchema.parse({
|
||||
return {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
content: row.tutorial.content,
|
||||
@@ -35,7 +35,7 @@ function serializeTutorial(row: {
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
createdAt: row.tutorial.createdAt,
|
||||
updatedAt: row.tutorial.updatedAt,
|
||||
})
|
||||
} satisfies AdminTutorial
|
||||
}
|
||||
|
||||
function selectTutorial(id: number) {
|
||||
@@ -57,10 +57,10 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
|
||||
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
|
||||
const all = rows.map(serializeTutorial)
|
||||
// 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip
|
||||
return success(c, adminTutorialGroupsSchema.parse({
|
||||
return success(c, {
|
||||
python: all.filter((item) => item.type === "python"),
|
||||
c: all.filter((item) => item.type === "c"),
|
||||
}))
|
||||
} satisfies AdminTutorialGroups)
|
||||
})
|
||||
|
||||
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
|
||||
@@ -126,12 +126,12 @@ adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||
// ---------------------------------------------------------------- 练习
|
||||
|
||||
function serializeExercise(row: typeof schema.exercise.$inferSelect) {
|
||||
return adminExerciseSchema.parse({
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
data: objectValue(row.data),
|
||||
order: row.order,
|
||||
})
|
||||
} satisfies AdminExercise
|
||||
}
|
||||
|
||||
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
|
||||
|
||||
Reference in new issue
Block a user