feat(阶段4): 标签管理 / 批量打标签 / 题目可见性 / 卡点与 AC 趋势 / 流程图 AI
GET/PUT/DELETE admin/problem-tags[/:id] POST admin/problems/batch-tag PUT admin/problems/:id/visibility GET admin/problems/stuck GET admin/problems/ac-trend POST admin/problems/flowchart 要点: - 后台标签列表用 leftJoin 且不加 having,能看到 problemCount=0 的标签 —— 那正是要清理的那些。oj 侧的 /problem-tags 才过滤 >0。 - 标签改名撞上已有标签视为合并:只给「还没挂目标标签」的题目补关系, 否则会撞 (problem_id, problemtag_id) 唯一约束。 - 批量打标签:add 时按需新建标签、remove 时只认已有标签 —— 否则「移除」会顺手造出一堆空标签。名字去重且大小写不敏感。 - **旧 ProblemVisibleAPI 的 `self.error(...)` 少写了 return**,题目不存在时会继续 往下跑并抛 AttributeError(500)。这里正常返回 404。 顺带记下一条阶段 5 的必做项(见 phase3-coverage.md 文末):本地库按显式 id 从生产 导入,序列没跟着走,第一次新建标签就撞 problem_tag_pkey。只要切换流程里有 「导出→导入到新库」这一步,就必须重置全部序列,否则读全正常、第一次写才炸。 实测:学生 403;大小写重复与空白名去重后 tagCount=1、重复 add 幂等、 remove 不存在的标签 404 且不新建;纯改名 merged=false、合并 merged=true affectedCount=1 且只剩一个标签;可见性取反两次复原、不存在的题 404。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { adminAiRoutes } from "./ai"
|
||||
import { adminConfRoutes } from "./conf"
|
||||
import { adminContestRoutes } from "./contest"
|
||||
import { adminProblemSetRoutes } from "./problemset"
|
||||
import { adminTagRoutes } from "./tag"
|
||||
import { adminAnnouncementRoutes } from "./announcement"
|
||||
import { adminTutorialRoutes } from "./tutorial"
|
||||
|
||||
@@ -26,5 +27,6 @@ adminRoutes.route("/", adminAiRoutes)
|
||||
adminRoutes.route("/", adminConfRoutes)
|
||||
adminRoutes.route("/", adminContestRoutes)
|
||||
adminRoutes.route("/", adminProblemSetRoutes)
|
||||
adminRoutes.route("/", adminTagRoutes)
|
||||
adminRoutes.route("/", adminAnnouncementRoutes)
|
||||
adminRoutes.route("/", adminTutorialRoutes)
|
||||
|
||||
293
apps/api/src/routes/admin/tag.ts
Normal file
293
apps/api/src/routes/admin/tag.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
acTrendSchema,
|
||||
adminTagSchema,
|
||||
batchProblemTagRequestSchema,
|
||||
batchProblemTagResponseSchema,
|
||||
generateFlowchartRequestSchema,
|
||||
generateFlowchartResponseSchema,
|
||||
renameTagRequestSchema,
|
||||
renameTagResponseSchema,
|
||||
stuckProblemSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { JudgeStatus } from "../../judge/status"
|
||||
import { completeChat } from "../../services/ai"
|
||||
import { queryInteger, rounded } from "../helpers"
|
||||
|
||||
export const adminTagRoutes = new Hono<AppEnv>()
|
||||
|
||||
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR]
|
||||
|
||||
/** 能管所有题目:超管,或 problemPermission 为 All */
|
||||
function canManageAllProblems(user: AuthUser) {
|
||||
return user.adminType === "Super Admin" || user.problemPermission === "All"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 标签
|
||||
|
||||
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const rows = await db.select({
|
||||
id: schema.problemTag.id,
|
||||
name: schema.problemTag.name,
|
||||
problemCount: countDistinct(schema.problemTags.problemId),
|
||||
}).from(schema.problemTag)
|
||||
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||
// 后台标签管理要看到 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)))
|
||||
})
|
||||
|
||||
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 name = parsed.data.name
|
||||
|
||||
const [tag] = await db.select().from(schema.problemTag).where(eq(schema.problemTag.id, id)).limit(1)
|
||||
if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||
|
||||
const [target] = await db.select().from(schema.problemTag)
|
||||
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1)
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
|
||||
const affected = await db.transaction(async (tx) => {
|
||||
const links = await tx.select({ problemId: schema.problemTags.problemId })
|
||||
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||
const already = new Set((await tx.select({ problemId: schema.problemTags.problemId })
|
||||
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, target.id)))
|
||||
.map((row) => row.problemId))
|
||||
// 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束
|
||||
const missing = links.filter((link) => !already.has(link.problemId))
|
||||
if (missing.length) {
|
||||
await tx.insert(schema.problemTags).values(missing.map((link) => ({
|
||||
problemId: link.problemId,
|
||||
problemtagId: target.id,
|
||||
})))
|
||||
}
|
||||
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||
return links.length
|
||||
})
|
||||
return success(c, renameTagResponseSchema.parse({
|
||||
merged: true, id: target.id, name: target.name, affectedCount: affected,
|
||||
}))
|
||||
})
|
||||
|
||||
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 中间表是 NO ACTION 外键,得先清关系再删标签
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||
return tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||
.returning({ id: schema.problemTag.id })
|
||||
})
|
||||
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
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 user = c.get("user")!
|
||||
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)]
|
||||
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id))
|
||||
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(...filters))
|
||||
if (problems.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目")
|
||||
|
||||
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
|
||||
const wanted: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const raw of parsed.data.tagNames) {
|
||||
const name = raw.trim()
|
||||
if (!name || seen.has(name.toLowerCase())) continue
|
||||
seen.add(name.toLowerCase())
|
||||
wanted.push(name)
|
||||
}
|
||||
|
||||
const tagIds = await db.transaction(async (tx) => {
|
||||
const ids: number[] = []
|
||||
for (const name of wanted) {
|
||||
const [existing] = await tx.select({ id: schema.problemTag.id }).from(schema.problemTag)
|
||||
.where(sql`lower(${schema.problemTag.name}) = lower(${name})`).limit(1)
|
||||
if (existing) { ids.push(existing.id); continue }
|
||||
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||
if (parsed.data.action === "add") {
|
||||
const [created] = await tx.insert(schema.problemTag).values({ name })
|
||||
.returning({ id: schema.problemTag.id })
|
||||
ids.push(created!.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
|
||||
|
||||
const problemIds = problems.map((problem) => problem.id)
|
||||
await db.transaction(async (tx) => {
|
||||
if (parsed.data.action === "remove") {
|
||||
await tx.delete(schema.problemTags).where(and(
|
||||
inArray(schema.problemTags.problemId, problemIds),
|
||||
inArray(schema.problemTags.problemtagId, tagIds),
|
||||
))
|
||||
return
|
||||
}
|
||||
const existing = await tx.select().from(schema.problemTags).where(and(
|
||||
inArray(schema.problemTags.problemId, problemIds),
|
||||
inArray(schema.problemTags.problemtagId, tagIds),
|
||||
))
|
||||
const have = new Set(existing.map((row) => `${row.problemId}:${row.problemtagId}`))
|
||||
const rows = []
|
||||
for (const problemId of problemIds) {
|
||||
for (const tagId of tagIds) {
|
||||
if (!have.has(`${problemId}:${tagId}`)) rows.push({ problemId, problemtagId: tagId })
|
||||
}
|
||||
}
|
||||
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
||||
})
|
||||
|
||||
return success(c, batchProblemTagResponseSchema.parse({
|
||||
problemCount: problems.length,
|
||||
tagCount: tagIds.length,
|
||||
}))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 题目可见性
|
||||
|
||||
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problem] = await db.select({ id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById })
|
||||
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
|
||||
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
|
||||
// AttributeError(500)。这里正常返回 404。
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
const user = c.get("user")!
|
||||
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
||||
return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
}
|
||||
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id))
|
||||
return success(c, { visible: !problem.visible })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
|
||||
|
||||
adminTagRoutes.get("/problems/stuck", requireTeacher, async (c) => {
|
||||
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
|
||||
const rows = await db.select({
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
||||
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
|
||||
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
|
||||
}).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||
.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({
|
||||
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,
|
||||
})))
|
||||
})
|
||||
|
||||
adminTagRoutes.get("/problems/ac-trend", requireTeacher, async (c) => {
|
||||
const currentYear = new Date().getFullYear()
|
||||
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
|
||||
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
|
||||
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
|
||||
let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
|
||||
if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1
|
||||
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
|
||||
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
|
||||
|
||||
const year = sql<number>`extract(year from ${schema.submission.createTime})`.mapWith(Number)
|
||||
const rows = await db.select({
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
year,
|
||||
total: count(),
|
||||
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
||||
}).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(and(
|
||||
isNull(schema.submission.contestId),
|
||||
gte(sql`extract(year from ${schema.submission.createTime})`, sinceYear),
|
||||
lte(sql`extract(year from ${schema.submission.createTime})`, untilYear),
|
||||
))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
|
||||
.orderBy(asc(schema.problem.id), asc(year))
|
||||
|
||||
const required = new Set<number>()
|
||||
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
|
||||
|
||||
const grouped = new Map<number, { displayId: string; title: string; yearly: typeof rows }>()
|
||||
for (const row of rows) {
|
||||
const bucket = grouped.get(row.problemId)
|
||||
if (bucket) bucket.yearly.push(row)
|
||||
else grouped.set(row.problemId, { displayId: row.displayId, title: row.title, yearly: [row] })
|
||||
}
|
||||
|
||||
const result = []
|
||||
for (const entry of grouped.values()) {
|
||||
const years = new Set(entry.yearly.map((row) => row.year))
|
||||
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
|
||||
if (![...required].every((y) => years.has(y))) continue
|
||||
if (!entry.yearly.every((row) => row.total > minPerYear)) continue
|
||||
result.push(acTrendSchema.parse({
|
||||
problemId: entry.displayId,
|
||||
problemTitle: entry.title,
|
||||
yearly: entry.yearly
|
||||
.map((row) => ({
|
||||
year: row.year,
|
||||
total: row.total,
|
||||
accepted: row.accepted,
|
||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||
}))
|
||||
.sort((left, right) => left.year - right.year),
|
||||
}))
|
||||
}
|
||||
return success(c, result)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Python → 流程图
|
||||
|
||||
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
|
||||
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空")
|
||||
try {
|
||||
const flowchart = await completeChat(
|
||||
`你是一个可以将Python代码转换为mermaid的助手。
|
||||
请将用户提供的Python代码转换为 Mermaid 纯文本。
|
||||
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
|
||||
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
|
||||
parsed.data.python,
|
||||
)
|
||||
return success(c, generateFlowchartResponseSchema.parse({ flowchart }))
|
||||
} catch (error) {
|
||||
console.error("Flowchart generation failed", error)
|
||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||
}
|
||||
})
|
||||
@@ -72,11 +72,11 @@ export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||
}
|
||||
|
||||
export function toggleProblemVisible(problemID: number) {
|
||||
return http.put("admin/problem/visible", { id: problemID })
|
||||
return legacyResponse(api2.put(`admin/problems/${problemID}/visibility`))
|
||||
}
|
||||
|
||||
export function generateFlowchartFromPythonCode(python: string) {
|
||||
return http.post("admin/problem/flowchart", { python })
|
||||
return legacyResponse(api2.post("admin/problems/flowchart", { python }))
|
||||
}
|
||||
|
||||
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
||||
@@ -93,20 +93,22 @@ export function getContestProblem(id: number) {
|
||||
|
||||
// 标签管理
|
||||
export function getTagAdminList(keyword = "") {
|
||||
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
|
||||
return legacyResponse<AdminTag[]>(
|
||||
api2.get("admin/problem-tags", { params: { keyword } }),
|
||||
)
|
||||
}
|
||||
|
||||
export function renameTag(id: number, name: string) {
|
||||
return http.put<{
|
||||
return legacyResponse<{
|
||||
merged: boolean
|
||||
id: number
|
||||
name: string
|
||||
affected_count: number
|
||||
}>("admin/problem/tag", { id, name })
|
||||
}>(api2.put(`admin/problem-tags/${id}`, { name }))
|
||||
}
|
||||
|
||||
export function deleteTag(id: number) {
|
||||
return http.delete("admin/problem/tag", { params: { id } })
|
||||
return api2.delete(`admin/problem-tags/${id}`)
|
||||
}
|
||||
|
||||
export function batchTagProblems(
|
||||
@@ -114,9 +116,12 @@ export function batchTagProblems(
|
||||
tagNames: string[],
|
||||
action: "add" | "remove",
|
||||
) {
|
||||
return http.post<{ problem_count: number; tag_count: number }>(
|
||||
"admin/problem/batch_tag",
|
||||
{ problem_ids: problemIds, tag_names: tagNames, action },
|
||||
return legacyResponse<{ problem_count: number; tag_count: number }>(
|
||||
api2.post("admin/problems/batch-tag", {
|
||||
problemIds,
|
||||
tagNames,
|
||||
action,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -670,7 +675,7 @@ export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
|
||||
// 学生卡点分析
|
||||
export function getStuckProblems() {
|
||||
return http.get("admin/problem/stuck")
|
||||
return legacyResponse(api2.get("admin/problems/stuck"))
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: {
|
||||
@@ -678,7 +683,15 @@ export function getTopACTrend(params: {
|
||||
until_year: number
|
||||
min_per_year: number
|
||||
}) {
|
||||
return http.get("admin/problem/top_ac_trend", { params })
|
||||
return legacyResponse(
|
||||
api2.get("admin/problems/ac-trend", {
|
||||
params: {
|
||||
sinceYear: params.since_year,
|
||||
untilYear: params.until_year,
|
||||
minPerYear: params.min_per_year,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// AI 学习分析报告
|
||||
|
||||
Reference in New Issue
Block a user