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:
1 parent
9a6c3ba225
commit
af9cb796bd
5 files changed
+441
-19
No files matched your search
@@ -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 }))
|
||||
})
|
||||
Reference in new issue
Block a user