feat(阶段4): 教程 / 练习 / AI 学情报告的后台接口

GET/POST                admin/tutorials
  GET/PUT/DELETE          admin/tutorials/:id
  PUT                     admin/tutorials/:id/visibility
  GET                     admin/tutorials/:id/exercises
  POST                    admin/exercises
  PUT/DELETE              admin/exercises/:id
  GET                     admin/ai/reports          (?pinnedOnly=true 不分页)
  GET                     admin/ai/reports/:id
  POST                    admin/ai/reports/:id/pin

几处判断:

- 练习改成挂在教程下的嵌套路径,旧后端是 ?tutorial_id= 查询参数。本来就是一对多的
  从属关系,嵌套更贴事实,也省掉「忘了传 tutorial_id」这类错误。
- 删教程必须先删练习。Django 的 on_delete=CASCADE 是应用层实现的,库里外键实际是
  NO ACTION(核对过 pg_constraint.confdeltype='a'),直接删会撞外键变 500。
  已实测:带 2 个练习的教程能正常删掉且练习一并清除。**后台每个 DELETE 都要照此
  核一遍子表**,这是本阶段的通用陷阱。
- 改可见性不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉。
- AI 报告的 data / systemPrompt / userPrompt 一律不下发,里面是喂给模型的原始
  学情数据与提示词。列表只给 120 字摘要,与旧 AIAnalysisListSerializer 一致。

实测:匿名 401 / 学生 403 / 超管 200;教程练习增删改查、非法 type 400、
挂到不存在的教程 404、置顶互斥(同一学生至多一份)、再钉一次取消,全部符合预期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 07:10:34 -06:00
parent 9a6c3ba225
commit af9cb796bd
5 changed files with 441 additions and 19 deletions

View File

@@ -0,0 +1,101 @@
import {
adminAiReportListSchema,
adminAiReportListItemSchema,
adminAiReportSchema,
toggleAiReportPinResponseSchema,
} from "@oj2/contract"
import { and, count, desc, eq, ilike } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { queryInteger } from "../helpers"
export const adminAiRoutes = new Hono<AppEnv>()
/** 对齐旧 AIAnalysisListSerializer.get_analysis_excerpt压掉空白后截 120 字,超出加省略号 */
function excerpt(analysis: string | null) {
if (!analysis) return ""
const text = analysis.split(/\s+/).filter(Boolean).join(" ")
return text.length <= 120 ? text : `${text.slice(0, 120)}`
}
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) {
return adminAiReportListItemSchema.parse({
id: row.id,
username: row.username,
createTime: row.createTime,
analysisExcerpt: excerpt(row.analysis),
isPinned: row.isPinned,
})
}
const listColumns = {
id: schema.aiAnalysis.id,
username: schema.user.username,
createTime: schema.aiAnalysis.createTime,
analysis: schema.aiAnalysis.analysis,
isPinned: schema.aiAnalysis.isPinned,
}
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim()
const where = username ? ilike(schema.user.username, `%${username}%`) : undefined
// 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走
if (c.req.query("pinnedOnly") === "true") {
const rows = await db.select(listColumns).from(schema.aiAnalysis)
.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, rows.map(listItem))
}
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where),
db.select(listColumns).from(schema.aiAnalysis)
.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({
results: rows.map(listItem),
total: totalRows[0]?.value ?? 0,
}))
})
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
const [row] = await db.select({
id: schema.aiAnalysis.id,
username: schema.user.username,
className: schema.user.className,
createTime: schema.aiAnalysis.createTime,
analysis: schema.aiAnalysis.analysis,
}).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.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))
})
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [report] = await db.select({ id: schema.aiAnalysis.id, userId: schema.aiAnalysis.userId, isPinned: schema.aiAnalysis.isPinned })
.from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1)
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份
const next = !report.isPinned
await db.transaction(async (tx) => {
if (next) {
await tx.update(schema.aiAnalysis).set({ isPinned: false })
.where(and(eq(schema.aiAnalysis.userId, report.userId), eq(schema.aiAnalysis.isPinned, true)))
}
await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id))
})
return success(c, toggleAiReportPinResponseSchema.parse({ isPinned: next }))
})

View File

@@ -1,7 +1,9 @@
import { Hono } from "hono"
import type { AppEnv } from "../../auth/middleware"
import { adminAiRoutes } from "./ai"
import { adminAnnouncementRoutes } from "./announcement"
import { adminTutorialRoutes } from "./tutorial"
/**
* 后台路由总入口,挂在 `/api/admin` 下。
@@ -13,4 +15,6 @@ import { adminAnnouncementRoutes } from "./announcement"
*/
export const adminRoutes = new Hono<AppEnv>()
adminRoutes.route("/", adminAiRoutes)
adminRoutes.route("/", adminAnnouncementRoutes)
adminRoutes.route("/", adminTutorialRoutes)

View File

@@ -0,0 +1,185 @@
import {
adminExerciseSchema,
adminTutorialGroupsSchema,
adminTutorialSchema,
createExerciseRequestSchema,
createTutorialRequestSchema,
setTutorialVisibilityRequestSchema,
updateExerciseRequestSchema,
updateTutorialRequestSchema,
} from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { objectValue, queryInteger, sampleUser } from "../helpers"
export const adminTutorialRoutes = new Hono<AppEnv>()
function serializeTutorial(row: {
tutorial: typeof schema.tutorial.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
}) {
return adminTutorialSchema.parse({
id: row.tutorial.id,
title: row.tutorial.title,
content: row.tutorial.content,
code: row.tutorial.code,
isPublic: row.tutorial.isPublic,
order: row.tutorial.order,
type: row.tutorial.type,
createdBy: sampleUser(row.user, row.realName),
createdAt: row.tutorial.createdAt,
updatedAt: row.tutorial.updatedAt,
})
}
function selectTutorial(id: number) {
return db
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.tutorial.id, id))
.limit(1)
}
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
const rows = await db
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
const all = rows.map(serializeTutorial)
// 分组返回,形状对齐旧 TutorialAdminAPI.get列表 schema omit 掉了 content/codeZod 会 strip
return success(c, adminTutorialGroupsSchema.parse({
python: all.filter((item) => item.type === "python"),
c: all.filter((item) => item.type === "c"),
}))
})
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
const parsed = createTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const now = new Date().toISOString()
const [created] = await db.insert(schema.tutorial).values({
...parsed.data,
createdAt: now,
updatedAt: now,
createdById: c.get("user")!.id,
}).returning({ id: schema.tutorial.id })
const [row] = await selectTutorial(created!.id)
return success(c, serializeTutorial(row!), 201)
})
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, serializeTutorial(row))
})
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const updated = await db.update(schema.tutorial)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
})
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required")
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
const updated = await db.update(schema.tutorial)
.set({ isPublic: parsed.data.isPublic })
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
})
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 必须先删练习。Django 的 on_delete=CASCADE 是**应用层**实现的,
// 库里的外键实际是 NO ACTION已核对 pg_constraint.confdeltype='a'
// 直接删教程会撞外键约束、变成 500。后台每个 DELETE 都要照此核一遍子表。
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.exercise).where(eq(schema.exercise.tutorialId, id))
return tx.delete(schema.tutorial).where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
})
if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, null)
})
// ---------------------------------------------------------------- 练习
function serializeExercise(row: typeof schema.exercise.$inferSelect) {
return adminExerciseSchema.parse({
id: row.id,
type: row.type,
data: objectValue(row.data),
order: row.order,
})
}
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.exercise)
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 })))
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
return success(c, rows.map(serializeExercise))
})
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
const parsed = createExerciseRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [created] = await db.insert(schema.exercise).values({
tutorialId: parsed.data.tutorialId,
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
createdAt: new Date().toISOString(),
}).returning()
return success(c, serializeExercise(created!), 201)
})
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
const parsed = updateExerciseRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const [updated] = await db.update(schema.exercise)
.set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order })
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning()
if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, serializeExercise(updated))
})
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
const deleted = await db.delete(schema.exercise)
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.exercise.id })
if (deleted.length === 0) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, null)
})

View File

@@ -293,38 +293,58 @@ export function createAnnouncement(announcement: AnnouncementEdit) {
return legacyResponse(api2.post("admin/announcements", body))
}
/** 组件里的 Tutorial 仍是 snake_case出站时转成新后端要的 camelCase */
function toTutorialBody(data: Partial<Tutorial>) {
return {
title: data.title,
content: data.content,
code: data.code ?? null,
isPublic: data.is_public ?? false,
order: data.order ?? 0,
type: data.type,
}
}
export async function getTutorialList() {
const res = await http.get<Tutorial[]>("admin/tutorial")
const res = await legacyResponse<{ [key: string]: Tutorial[] }>(
api2.get("admin/tutorials"),
)
return res.data
}
export async function getTutorial(id: number) {
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
const res = await legacyResponse<Tutorial>(api2.get(`admin/tutorials/${id}`))
return res.data
}
export async function createTutorial(data: Partial<Tutorial>) {
const res = await http.post<Tutorial>("admin/tutorial", data)
const res = await legacyResponse<Tutorial>(
api2.post("admin/tutorials", toTutorialBody(data)),
)
return res.data
}
export async function updateTutorial(data: Partial<Tutorial>) {
const res = await http.put("admin/tutorial", data)
const res = await legacyResponse<Tutorial>(
api2.put(`admin/tutorials/${data.id}`, toTutorialBody(data)),
)
return res.data
}
export function deleteTutorial(id: number) {
return http.delete("admin/tutorial", { params: { id } })
return api2.delete(`admin/tutorials/${id}`)
}
export function setTutorialVisibility(id: number, is_public: boolean) {
return http.put("admin/tutorial/visibility", { id, is_public })
return legacyResponse(
api2.put(`admin/tutorials/${id}/visibility`, { isPublic: is_public }),
)
}
export async function getAdminExercises(tutorialId: number) {
const res = await http.get<Exercise[]>("admin/exercise", {
params: { tutorial_id: tutorialId },
})
const res = await legacyResponse<Exercise[]>(
api2.get(`admin/tutorials/${tutorialId}/exercises`),
)
return res.data
}
@@ -334,7 +354,14 @@ export async function createExercise(data: {
data: object
order: number
}) {
const res = await http.post<Exercise>("admin/exercise", data)
const res = await legacyResponse<Exercise>(
api2.post("admin/exercises", {
tutorialId: data.tutorial_id,
type: data.type,
data: data.data,
order: data.order,
}),
)
return res.data
}
@@ -344,12 +371,18 @@ export async function updateExercise(data: {
data: object
order: number
}) {
const res = await http.put("admin/exercise", data)
return res.data as Exercise
const res = await legacyResponse<Exercise>(
api2.put(`admin/exercises/${data.id}`, {
type: data.type,
data: data.data,
order: data.order,
}),
)
return res.data
}
export function deleteExercise(id: number) {
return http.delete("admin/exercise", { params: { id } })
return api2.delete(`admin/exercises/${id}`)
}
// 将竞赛题目转为公开题目
@@ -543,21 +576,25 @@ export function getTopACTrend(params: {
// AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") {
return http.get("admin/ai/reports", {
params: { paging: true, offset, limit, username: username || undefined },
})
return legacyResponse(
api2.get("admin/ai/reports", {
params: { offset, limit, username: username || undefined },
}),
)
}
export function getAIReportDetail(id: number) {
return http.get("admin/ai/reports", { params: { id } })
return legacyResponse(api2.get(`admin/ai/reports/${id}`))
}
export function pinAIReport(id: number) {
return http.post("admin/ai/reports", { id })
return legacyResponse(api2.post(`admin/ai/reports/${id}/pin`))
}
export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
return legacyResponse(
api2.get("admin/ai/reports", { params: { pinnedOnly: "true" } }),
)
}
// ==================== 成就 ====================

View File

@@ -36,3 +36,98 @@ export const createAnnouncementRequestSchema = z.object({
export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema
export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema>
// ---------------------------------------------------------------- 教程 / 练习
export const tutorialTypeSchema = z.enum(["python", "c"])
export const adminTutorialSchema = z.object({
id: z.number().int(),
title: z.string(),
content: z.string(),
code: z.string().nullable(),
isPublic: z.boolean(),
order: z.number().int(),
type: tutorialTypeSchema,
createdBy: sampleUserSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
export const adminTutorialListItemSchema = adminTutorialSchema.omit({
// 教程正文是整篇 markdown列表页只排序和切换可见性不需要它
content: true,
code: true,
})
/** 后台教程列表按语言分组返回,对齐旧 TutorialAdminAPI.get 的 `{python, c}` 形状 */
export const adminTutorialGroupsSchema = z.object({
python: z.array(adminTutorialListItemSchema),
c: z.array(adminTutorialListItemSchema),
})
export const createTutorialRequestSchema = z.object({
title: z.string().trim().min(1).max(128),
content: z.string(),
code: z.string().nullable().default(null),
isPublic: z.boolean().default(false),
order: z.number().int().default(0),
type: tutorialTypeSchema,
})
export const updateTutorialRequestSchema = createTutorialRequestSchema
export const setTutorialVisibilityRequestSchema = z.object({ isPublic: z.boolean() })
export const exerciseTypeSchema = z.enum([
"mcq",
"sort",
"fill",
"match",
"predict",
"debug",
"group",
])
export const adminExerciseSchema = z.object({
id: z.number().int(),
type: exerciseTypeSchema,
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
export const createExerciseRequestSchema = z.object({
tutorialId: z.number().int().positive(),
type: exerciseTypeSchema,
data: z.record(z.string(), z.unknown()),
order: z.number().int().default(0),
})
export const updateExerciseRequestSchema = createExerciseRequestSchema.omit({
// 练习不支持改挂到别的教程下 —— 旧 EditExerciseSerializer 也没有 tutorial_id
tutorialId: true,
})
// ---------------------------------------------------------------- AI 报告
/**
* 列表只给摘要,正文要点开详情才拿 —— 与旧 AIAnalysisListSerializer 一致。
* `data`(原始 prompt 与结构化输入)和 `system_prompt` / `user_prompt` 两侧都不下发。
*/
export const adminAiReportListItemSchema = z.object({
id: z.number().int(),
username: z.string(),
createTime: z.string(),
analysisExcerpt: z.string(),
isPinned: z.boolean(),
})
export const adminAiReportSchema = z.object({
id: z.number().int(),
username: z.string(),
className: z.string().nullable(),
createTime: z.string(),
analysis: z.string(),
})
export const adminAiReportListSchema = paginatedSchema(adminAiReportListItemSchema)
export const toggleAiReportPinResponseSchema = z.object({ isPinned: z.boolean() })