feat(阶段4): 题单管理(题目 / 奖章 / 进度三层)

GET/POST                admin/problem-sets
  GET/PUT/DELETE          admin/problem-sets/:id
  PUT                     admin/problem-sets/:id/visibility   (取反语义,与旧一致)
  PUT                     admin/problem-sets/:id/status
  GET/POST                admin/problem-sets/:id/problems
  PUT/DELETE              admin/problem-sets/:id/problems/:itemId
  GET/POST                admin/problem-sets/:id/badges
  PUT/DELETE              admin/problem-sets/:id/badges/:badgeId
  GET                     admin/problem-sets/:id/progress
  DELETE                  admin/problem-sets/:id/progress/:userId

修掉旧后端三个问题:

1. **后台列表写死了 visible=True,可它同时又提供「切换可见性」的接口** ——
   一旦把题单设成不可见,它就从后台列表消失,再也没法在界面上改回来。
   新实现不按 visible 过滤,后台能看见自己管的全部题单。实测取反两次仍在列表里。
2. **加题/删题/改分值后不重算学生进度**。往题单里加一道题,学生那边的
   totalProblemsCount 还是老数字,进度百分比因此偏高,甚至已「完成」的人分母变了
   却还标着完成。新实现加了 resyncProgress,实测 2/2 加一道题后变成 2/3 = 66.67%。
   只重算分母与百分比,不碰 completeTime —— 已完成过的事实不因加题而撤销。
3. 把人踢出题单时一并收回他基于这份题单拿到的奖章,否则奖章悬空。

奖章重算对齐旧 recalculate_user_badges(那边靠 post_save 信号,这里显式调):
只增删差集、保留已有记录的 earnedTime,否则每改一次条件所有人的获得时间都会
刷新成今天。实测 all_problems 建成时补发 1 人 → 加题后收回 → 改成
problem_count>=2 后重新发出 → 踢人后收回。

删题单要按序清五张子表(全是 NO ACTION 外键):user_badge 挂在 problemset_badge
上,得先于 badge 删。

实测:学生 403;重复加题 409、加不存在的题 404、野状态/野奖章条件 400;
级联删除后五张表全为 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:25:14 -06:00
parent 553c084579
commit 5e0229db12
4 changed files with 643 additions and 28 deletions

View File

@@ -332,3 +332,104 @@ export const updateAcmHelperRequestSchema = z.object({
problemId: z.string().min(1),
checked: z.boolean(),
})
// ---------------------------------------------------------------- 题单管理
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"])
export const adminProblemSetSchema = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
difficulty: problemSetDifficultySchema,
status: problemSetStatusSchema,
endTime: z.string().nullable(),
visible: z.boolean(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string(),
problemsCount: z.number().int(),
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */
participantCount: z.number().int(),
})
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
export const createProblemSetRequestSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string(),
difficulty: problemSetDifficultySchema.default("Easy"),
status: problemSetStatusSchema.default("active"),
endTime: z.string().nullable().default(null),
visible: z.boolean().default(true),
})
export const updateProblemSetRequestSchema = createProblemSetRequestSchema
export const updateProblemSetStatusRequestSchema = z.object({ status: problemSetStatusSchema })
export const adminProblemSetProblemSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
problemId: z.number().int(),
displayId: z.string(),
title: z.string(),
difficulty: z.string(),
order: z.number().int(),
isRequired: z.boolean(),
score: z.number().int(),
hint: z.string().nullable(),
})
export const addProblemToSetRequestSchema = z.object({
/** 展示用题号_id不是自增主键 —— 老师手里只有题号 */
problemId: z.string().trim().min(1),
order: z.number().int().default(0),
isRequired: z.boolean().default(true),
score: z.number().int().default(0),
hint: z.string().default(""),
})
export const updateProblemInSetRequestSchema = z.object({
order: z.number().int().optional(),
isRequired: z.boolean().optional(),
score: z.number().int().optional(),
hint: z.string().optional(),
})
export const adminProblemSetBadgeSchema = z.object({
id: z.number().int(),
problemsetId: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
conditionType: badgeConditionTypeSchema,
conditionValue: z.number().int(),
/** 已获得该奖章的人数,后台改条件前要能看到影响面 */
earnedCount: z.number().int(),
})
export const createProblemSetBadgeRequestSchema = z.object({
name: z.string().trim().min(1).max(100),
description: z.string(),
icon: z.string(),
conditionType: badgeConditionTypeSchema,
conditionValue: z.number().int().default(0),
})
export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema
export const adminProblemSetProgressSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
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(),
})