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)
})