Files
OJ2/apps/api/src/routes/admin/tutorial.ts
T
xuyueandClaude Opus 5 ed56a209ea
Deploy / deploy (push) Has been cancelled
chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00

276 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createExerciseRequestSchema,
createTutorialRequestSchema,
setTutorialVisibilityRequestSchema,
updateExerciseRequestSchema,
updateTutorialRequestSchema,
type AdminExercise,
type AdminTutorial,
type AdminTutorialGroups,
} 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 { exerciseDataError } from "../../services/exercise"
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 {
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,
} satisfies AdminTutorial
}
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, {
python: all.filter((item) => item.type === "python"),
c: all.filter((item) => item.type === "c"),
} satisfies AdminTutorialGroups)
})
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 })
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
const deleted = await db
.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 {
id: row.id,
type: row.type,
data: objectValue(row.data),
order: row.order,
} satisfies AdminExercise
}
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?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 dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
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 dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
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)
})