index.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  1. import Fastify from 'fastify'
  2. import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
  3. import cors from '@fastify/cors'
  4. import multipart from '@fastify/multipart'
  5. import staticPlugin from '@fastify/static'
  6. import { createWriteStream } from 'node:fs'
  7. import { mkdir, readFile, unlink } from 'node:fs/promises'
  8. import { pipeline } from 'node:stream/promises'
  9. import { resolve, extname, dirname, join } from 'node:path'
  10. import { fileURLToPath } from 'node:url'
  11. import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'
  12. import { promisify } from 'node:util'
  13. import { z } from 'zod'
  14. import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
  15. import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
  16. const scrypt = promisify(scryptCb)
  17. const app = Fastify({ logger: true })
  18. const port = Number(process.env.PORT || 3001)
  19. const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
  20. const sampleDir = resolve(process.env.SAMPLE_DIR || './sample')
  21. const maxBytes = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024
  22. await mkdir(uploadDir, { recursive: true })
  23. const here = dirname(fileURLToPath(import.meta.url))
  24. await pool.query(await readFile(join(here, 'schema.sql'), 'utf8'))
  25. // 检查是否存在管理员账号,若不存在则创建默认演示管理员
  26. async function initDefaultAdmin() {
  27. try {
  28. const { rows } = await pool.query("SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1")
  29. if (rows.length === 0) {
  30. const defaultPhone = process.env.ADMIN_PHONE || '18888888888'
  31. const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123456'
  32. const existing = await pool.query('SELECT id FROM users WHERE phone = $1', [defaultPhone])
  33. const salt = randomBytes(16).toString('hex')
  34. const key = (await scrypt(defaultPassword, salt, 64)) as Buffer
  35. const hash = 'scrypt$' + salt + '$' + key.toString('hex')
  36. if (existing.rows.length > 0) {
  37. await pool.query("UPDATE users SET role = 'ADMIN' WHERE id = $1", [existing.rows[0].id])
  38. app.log.info(`Updated existing user ${defaultPhone} to ADMIN role`)
  39. } else {
  40. const id = randomUUID()
  41. await pool.query(
  42. "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)",
  43. [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号']
  44. )
  45. app.log.info(`Initialized default admin account for ${defaultPhone}`)
  46. }
  47. }
  48. } catch (err) {
  49. app.log.error(err, 'Failed to initialize default admin account')
  50. }
  51. }
  52. await initDefaultAdmin()
  53. await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
  54. await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
  55. await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false })
  56. await app.register(staticPlugin, { root: sampleDir, prefix: '/sample/', decorateReply: false })
  57. const sha = (v: string) => createHash('sha256').update(v).digest('hex')
  58. async function hashPassword(p: string) {
  59. const salt = randomBytes(16).toString('hex')
  60. const key = (await scrypt(p, salt, 64)) as Buffer
  61. return 'scrypt$' + salt + '$' + key.toString('hex')
  62. }
  63. async function verifyPassword(p: string, stored: string) {
  64. const [, salt, hex] = stored.split('$')
  65. if (!salt || !hex) return false
  66. const a = (await scrypt(p, salt, 64)) as Buffer
  67. const b = Buffer.from(hex, 'hex')
  68. return a.length === b.length && timingSafeEqual(a, b)
  69. }
  70. async function session(uid: string) {
  71. const token = randomBytes(32).toString('hex')
  72. await pool.query("INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
  73. sha(token),
  74. uid
  75. ])
  76. return token
  77. }
  78. async function auth(req: FastifyRequest, reply: FastifyReply) {
  79. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  80. if (!token) return reply.code(401).send({ message: '请先登录' })
  81. const { rows } = await pool.query(
  82. 'SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()',
  83. [sha(token)]
  84. )
  85. if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
  86. ;(req as any).user = rows[0]
  87. }
  88. async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
  89. await auth(req, reply)
  90. if (reply.sent) return
  91. const user = (req as any).user
  92. if (!user || user.role !== 'ADMIN') {
  93. return reply.code(403).send({ message: '权限不足:仅管理员可访问此接口' })
  94. }
  95. }
  96. const userId = (r: any) => r.user.id
  97. const phonePassword = z.object({
  98. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  99. password: z.string().min(6, '密码至少 6 位').max(72)
  100. })
  101. const fullCourse = async (row: any, includeCreator = false) => {
  102. const [a, i, d, e] = await Promise.all([
  103. pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
  104. pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
  105. pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
  106. pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id])
  107. ])
  108. let episodesRows = e.rows
  109. // 确保课程永远至少有 1 个分集(自愈保底)
  110. if (episodesRows.length === 0) {
  111. const defaultEpId = randomUUID()
  112. const defaultTitle = '第 1 集:' + (row.name || '核心内容讲解')
  113. const insertRes = await pool.query(
  114. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5) RETURNING *',
  115. [defaultEpId, row.id, defaultTitle, '本集核心内容与制作说明', '']
  116. )
  117. episodesRows = insertRes.rows
  118. }
  119. let creatorUser = null
  120. if (includeCreator && row.user_id) {
  121. const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
  122. creatorUser = u.rows[0] || null
  123. }
  124. return mapCourse(row, a.rows, i.rows, d.rows, creatorUser, episodesRows)
  125. }
  126. const ownedCourse = async (id: string, uid: string) =>
  127. (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0]
  128. app.get('/api/health', async () => ({ ok: true }))
  129. // ==================== 认证相关 ====================
  130. app.post('/api/auth/register', async (req, reply) => {
  131. const p = phonePassword.safeParse(req.body)
  132. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  133. try {
  134. const id = randomUUID()
  135. const hash = await hashPassword(p.data.password)
  136. const { rows } = await pool.query(
  137. 'INSERT INTO users(id, phone, password_hash, role) VALUES($1, $2, $3, $4) RETURNING *',
  138. [id, p.data.phone, hash, 'USER']
  139. )
  140. return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
  141. } catch (e: any) {
  142. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  143. throw e
  144. }
  145. })
  146. app.post('/api/auth/login', async (req, reply) => {
  147. const p = phonePassword.safeParse(req.body)
  148. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  149. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  150. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  151. return reply.code(401).send({ message: '手机号或密码错误' })
  152. }
  153. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  154. })
  155. app.post('/api/admin/auth/login', async (req, reply) => {
  156. const p = phonePassword.safeParse(req.body)
  157. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  158. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  159. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  160. return reply.code(401).send({ message: '管理员账号或密码错误' })
  161. }
  162. if (rows[0].role !== 'ADMIN') {
  163. return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' })
  164. }
  165. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  166. })
  167. app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
  168. const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
  169. await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
  170. return { ok: true }
  171. })
  172. app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
  173. app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
  174. const p = z
  175. .object({
  176. organization: z.string().max(120),
  177. wechat: z.string().max(80),
  178. contactName: z.string().max(80),
  179. bio: z.string().max(500)
  180. })
  181. .safeParse(req.body)
  182. if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
  183. const d = p.data
  184. const { rows } = await pool.query(
  185. 'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
  186. [d.organization, d.wechat, d.contactName, d.bio, userId(req)]
  187. )
  188. return { user: mapUser(rows[0]) }
  189. })
  190. app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
  191. const p = z
  192. .object({
  193. currentPassword: z.string(),
  194. newPassword: z.string().min(6).max(72)
  195. })
  196. .safeParse(req.body)
  197. if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' })
  198. if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
  199. return reply.code(400).send({ message: '当前密码不正确' })
  200. }
  201. await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
  202. await hashPassword(p.data.newPassword),
  203. userId(req)
  204. ])
  205. return { ok: true }
  206. })
  207. // ==================== 普通用户课程操作 ====================
  208. const courseInput = z.object({
  209. name: z.string().trim().min(1, '请输入课程名称').max(100),
  210. category: z.string().max(50).default(''),
  211. audience: z.string().max(100).default(''),
  212. description: z.string().max(500).default('')
  213. })
  214. app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
  215. app.get('/api/courses', { preHandler: auth }, async (req) => {
  216. const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
  217. userId(req)
  218. ])
  219. return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
  220. })
  221. app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
  222. const p = courseInput.safeParse(req.body)
  223. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
  224. const id = randomUUID()
  225. const d = p.data
  226. const { rows } = await pool.query(
  227. 'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
  228. [id, userId(req), d.name, d.category, d.audience, d.description]
  229. )
  230. // 课程创建后默认创建第 1 集内容,确保分集不为空
  231. const defaultEpId = randomUUID()
  232. const defaultTitle = '第 1 集:' + d.name
  233. await pool.query(
  234. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5)',
  235. [defaultEpId, id, defaultTitle, '', '']
  236. )
  237. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  238. })
  239. app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
  240. const p = z.object({
  241. templateId: z.string().default('party-building-standard')
  242. }).safeParse(req.body || {})
  243. if (!p.success) return reply.code(400).send({ message: '模板参数不正确' })
  244. const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
  245. const courseId = randomUUID()
  246. const { rows } = await pool.query(
  247. 'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
  248. [courseId, userId(req), template.name, template.category, template.audience || '', template.description]
  249. )
  250. await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
  251. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  252. })
  253. app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  254. const { id } = req.params as any
  255. const row = await ownedCourse(id, userId(req))
  256. if (!row) return reply.code(404).send({ message: '课程不存在' })
  257. return { course: await fullCourse(row) }
  258. })
  259. app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  260. const { id } = req.params as any
  261. const row = await ownedCourse(id, userId(req))
  262. if (!row) return reply.code(404).send({ message: '课程不存在' })
  263. // 已经等待制作、制作中或者制作完成的课程不能删除
  264. if (row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED') {
  265. return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
  266. }
  267. // 1. 清理该课程关联的所有素材文件
  268. const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
  269. for (const ast of assetsRes.rows) {
  270. if (ast.stored_name) await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  271. }
  272. // 2. 清理讲师头像图片
  273. const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
  274. for (const ins of instRes.rows) {
  275. if (ins.image_stored_name) await unlink(resolve(uploadDir, ins.image_stored_name)).catch(() => {})
  276. }
  277. // 3. 清理交付成品文件
  278. const delivRes = await pool.query('SELECT stored_name FROM course_deliverables WHERE course_id = $1', [id])
  279. for (const del of delivRes.rows) {
  280. if (del.stored_name) await unlink(resolve(uploadDir, del.stored_name)).catch(() => {})
  281. }
  282. // 4. 清理 PPT 文件
  283. if (row.ppt_stored_name) {
  284. await unlink(resolve(uploadDir, row.ppt_stored_name)).catch(() => {})
  285. }
  286. // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
  287. await pool.query('DELETE FROM courses WHERE id = $1 AND user_id = $2', [id, userId(req)])
  288. return { ok: true, message: '课程已成功删除' }
  289. })
  290. app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
  291. const { id } = req.params as any
  292. const row = await ownedCourse(id, userId(req))
  293. if (!row) return reply.code(404).send({ message: '课程不存在' })
  294. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
  295. let episodeId: string | null = (req.query as any)?.episodeId || null
  296. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  297. for await (const part of req.parts()) {
  298. if (part.type === 'file') {
  299. const ext = extname(part.filename).slice(0, 16)
  300. const stored = id + '-' + randomUUID() + ext
  301. const target = resolve(uploadDir, stored)
  302. await pipeline(part.file, createWriteStream(target))
  303. if (part.file.truncated) {
  304. await unlink(target).catch(() => {})
  305. return reply.code(413).send({ message: '文件超过大小限制' })
  306. }
  307. fileList.push({
  308. filename: part.filename,
  309. stored,
  310. bytes: part.file.bytesRead,
  311. mimetype: part.mimetype
  312. })
  313. } else if (part.fieldname === 'episodeId' && part.value) {
  314. episodeId = String(part.value)
  315. }
  316. }
  317. if (episodeId) {
  318. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  319. if (!epCheck.rows[0]) episodeId = null
  320. }
  321. if (!fileList.length) return reply.code(400).send({ message: '请选择课程资产文件' })
  322. for (const f of fileList) {
  323. await pool.query(
  324. 'INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  325. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  326. )
  327. }
  328. return { course: await fullCourse(row) }
  329. })
  330. app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
  331. const { id, assetId } = req.params as any
  332. const row = await ownedCourse(id, userId(req))
  333. if (!row) return reply.code(404).send({ message: '课程不存在' })
  334. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' })
  335. const { rows } = await pool.query(
  336. 'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  337. [assetId, id]
  338. )
  339. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  340. return { course: await fullCourse(row) }
  341. })
  342. // ==================== 课程分集操作 (Episodes) ====================
  343. // 1. 创建单集
  344. app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) => {
  345. const { id } = req.params as any
  346. const row = await ownedCourse(id, userId(req))
  347. if (!row) return reply.code(404).send({ message: '课程不存在' })
  348. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  349. const p = z.object({
  350. title: z.string().trim().min(1, '请输入分集标题').max(200),
  351. episodeNumber: z.number().int().min(1).optional(),
  352. summary: z.string().max(500).default(''),
  353. lectureNotes: z.string().default('')
  354. }).safeParse(req.body)
  355. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '分集信息填写不完整' })
  356. let epNumber = p.data.episodeNumber
  357. if (!epNumber) {
  358. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  359. epNumber = (Number(maxRes.rows[0].max_num) || 0) + 1
  360. }
  361. const epId = randomUUID()
  362. await pool.query(
  363. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  364. [epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
  365. )
  366. return reply.code(201).send({ course: await fullCourse(row) })
  367. })
  368. // 2. 批量创建分集(如快速生成 12 集)
  369. app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, reply) => {
  370. const { id } = req.params as any
  371. const row = await ownedCourse(id, userId(req))
  372. if (!row) return reply.code(404).send({ message: '课程不存在' })
  373. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  374. const p = z.object({
  375. count: z.number().int().min(1).max(100).optional(),
  376. episodes: z.array(z.object({
  377. title: z.string().trim().min(1).max(200),
  378. episodeNumber: z.number().int().min(1).optional(),
  379. summary: z.string().max(500).default(''),
  380. lectureNotes: z.string().default('')
  381. })).optional()
  382. }).safeParse(req.body)
  383. if (!p.success) return reply.code(400).send({ message: '批量创建参数不正确' })
  384. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  385. let currentMax = Number(maxRes.rows[0].max_num) || 0
  386. if (p.data.episodes && p.data.episodes.length > 0) {
  387. for (const ep of p.data.episodes) {
  388. currentMax++
  389. await pool.query(
  390. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  391. [randomUUID(), id, ep.episodeNumber || currentMax, ep.title, ep.summary, ep.lectureNotes]
  392. )
  393. }
  394. } else if (p.data.count) {
  395. for (let i = 1; i <= p.data.count; i++) {
  396. currentMax++
  397. const title = `第 ${currentMax} 集:课程知识点精讲`
  398. await pool.query(
  399. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  400. [randomUUID(), id, currentMax, title, '', '']
  401. )
  402. }
  403. } else {
  404. return reply.code(400).send({ message: '请指定集数或分集列表' })
  405. }
  406. return reply.code(201).send({ course: await fullCourse(row) })
  407. })
  408. // 3. 更新分集信息(标题、序号、讲稿文本等)
  409. app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  410. const { id, episodeId } = req.params as any
  411. const row = await ownedCourse(id, userId(req))
  412. if (!row) return reply.code(404).send({ message: '课程不存在' })
  413. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改分集' })
  414. const p = z.object({
  415. title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
  416. episodeNumber: z.number().int().min(1).optional(),
  417. summary: z.string().max(500).optional(),
  418. lectureNotes: z.string().optional()
  419. }).safeParse(req.body)
  420. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '参数错误' })
  421. const existing = await pool.query('SELECT * FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  422. if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
  423. const cur = existing.rows[0]
  424. const newTitle = p.data.title !== undefined ? p.data.title : cur.title
  425. const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
  426. const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
  427. const newNotes = p.data.lectureNotes !== undefined ? p.data.lectureNotes : cur.lecture_notes
  428. await pool.query(
  429. 'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
  430. [newTitle, newNumber, newSummary, newNotes, episodeId, id]
  431. )
  432. return { course: await fullCourse(row) }
  433. })
  434. // 4. 删除分集及其关联的物理素材文件
  435. app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  436. const { id, episodeId } = req.params as any
  437. const row = await ownedCourse(id, userId(req))
  438. if (!row) return reply.code(404).send({ message: '课程不存在' })
  439. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除分集' })
  440. // 课程至少需要保留一个分集,不可删除唯一分集
  441. const countRes = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [id])
  442. if ((countRes.rows[0]?.count || 0) <= 1) {
  443. return reply.code(400).send({ message: '课程至少需要保留一个分集,不可删除唯一分集' })
  444. }
  445. // 查出该集下所有素材文件并清理磁盘
  446. const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
  447. for (const ast of astRes.rows) {
  448. await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  449. }
  450. await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  451. return { course: await fullCourse(row) }
  452. })
  453. app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
  454. const { id } = req.params as any
  455. const row = await ownedCourse(id, userId(req))
  456. if (!row) return reply.code(404).send({ message: '课程不存在' })
  457. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
  458. const fields: any = {}
  459. let image: any = null
  460. for await (const part of req.parts()) {
  461. if (part.type === 'file') {
  462. if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
  463. const ext = extname(part.filename).slice(0, 12)
  464. const stored = 'instructor-' + randomUUID() + ext
  465. await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
  466. image = { original: part.filename, stored, mime: part.mimetype }
  467. } else {
  468. fields[part.fieldname] = part.value
  469. }
  470. }
  471. const p = z
  472. .object({
  473. name: z.string().trim().min(1, '请填写讲师姓名').max(80),
  474. organization: z.string().max(120).default(''),
  475. introduction: z.string().max(1000).default('')
  476. })
  477. .safeParse(fields)
  478. if (!p.success) {
  479. if (image) await unlink(resolve(uploadDir, image.stored)).catch(() => {})
  480. return reply.code(400).send({ message: '请填写讲师姓名' })
  481. }
  482. const d = p.data
  483. const { rows } = await pool.query(
  484. 'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
  485. [
  486. randomUUID(),
  487. id,
  488. d.name,
  489. d.organization,
  490. d.introduction,
  491. image?.original || null,
  492. image?.stored || null,
  493. image?.mime || null
  494. ]
  495. )
  496. return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
  497. })
  498. app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => {
  499. const { id, instructorId } = req.params as any
  500. const row = await ownedCourse(id, userId(req))
  501. if (!row) return reply.code(404).send({ message: '课程不存在' })
  502. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
  503. const { rows } = await pool.query(
  504. 'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
  505. [instructorId, id]
  506. )
  507. if (rows[0]?.image_stored_name) await unlink(resolve(uploadDir, rows[0].image_stored_name)).catch(() => {})
  508. return { course: await fullCourse(row) }
  509. })
  510. app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
  511. const { id } = req.params as any
  512. const { rows } = await pool.query(
  513. "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
  514. [id, userId(req)]
  515. )
  516. if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
  517. return { course: await fullCourse(rows[0]) }
  518. })
  519. // ==================== 管理员专属 ADMIN API ====================
  520. // 1. 统计概览数据
  521. app.get('/api/admin/stats', { preHandler: adminAuth }, async () => {
  522. const [uRes, cRes, sRes] = await Promise.all([
  523. pool.query('SELECT COUNT(*)::int AS count FROM users'),
  524. pool.query('SELECT COUNT(*)::int AS count FROM courses'),
  525. pool.query(`
  526. SELECT
  527. COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count,
  528. COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count,
  529. COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count,
  530. COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count
  531. FROM courses
  532. `)
  533. ])
  534. return {
  535. totalUsers: uRes.rows[0].count,
  536. totalCourses: cRes.rows[0].count,
  537. waitingCount: sRes.rows[0].waiting_count || 0,
  538. inProductionCount: sRes.rows[0].in_production_count || 0,
  539. completedCount: sRes.rows[0].completed_count || 0,
  540. draftCount: sRes.rows[0].draft_count || 0
  541. }
  542. })
  543. // 2. 获取全量课程列表(支持状态筛选与搜索)
  544. app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
  545. const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string }
  546. const params: any[] = []
  547. const conditions: string[] = []
  548. if (query.status && query.status !== 'ALL') {
  549. params.push(query.status)
  550. conditions.push(`c.status = $${params.length}`)
  551. }
  552. if (query.keyword && query.keyword.trim()) {
  553. params.push(`%${query.keyword.trim()}%`)
  554. const idx = params.length
  555. conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx} OR u.organization ILIKE $${idx})`)
  556. }
  557. const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
  558. const sql = `
  559. SELECT
  560. c.*,
  561. u.phone AS user_phone,
  562. u.role AS user_role,
  563. u.organization AS user_organization,
  564. u.wechat AS user_wechat,
  565. u.contact_name AS user_contact_name,
  566. u.bio AS user_bio,
  567. u.created_at AS user_created_at
  568. FROM courses c
  569. LEFT JOIN users u ON u.id = c.user_id
  570. ${whereClause}
  571. ORDER BY
  572. CASE
  573. WHEN c.status = 'WAITING_PRODUCTION' THEN 1
  574. WHEN c.status = 'IN_PRODUCTION' THEN 2
  575. WHEN c.status = 'DRAFT' THEN 3
  576. WHEN c.status = 'COMPLETED' THEN 4
  577. ELSE 5
  578. END,
  579. c.submitted_at DESC NULLS LAST,
  580. c.created_at DESC
  581. `
  582. const { rows } = await pool.query(sql, params)
  583. const courses = await Promise.all(rows.map((r) => fullCourse(r, true)))
  584. return { courses, total: courses.length }
  585. })
  586. // 3. 获取特定课程完整详情(供制作工作台使用)
  587. app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => {
  588. const { id } = req.params as any
  589. const sql = `
  590. SELECT
  591. c.*,
  592. u.phone AS user_phone,
  593. u.role AS user_role,
  594. u.organization AS user_organization,
  595. u.wechat AS user_wechat,
  596. u.contact_name AS user_contact_name,
  597. u.bio AS user_bio,
  598. u.created_at AS user_created_at
  599. FROM courses c
  600. LEFT JOIN users u ON u.id = c.user_id
  601. WHERE c.id = $1
  602. `
  603. const { rows } = await pool.query(sql, [id])
  604. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  605. const course = await fullCourse(rows[0], true)
  606. return { course }
  607. })
  608. // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
  609. app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => {
  610. const { id } = req.params as any
  611. const p = z.object({
  612. status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
  613. }).safeParse(req.body)
  614. if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
  615. const { rows } = await pool.query(
  616. 'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  617. [p.data.status, id]
  618. )
  619. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  620. return { course: await fullCourse(rows[0], true) }
  621. })
  622. // 5. 管理员上传制作交付成品文件
  623. app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => {
  624. const { id } = req.params as any
  625. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  626. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  627. let episodeId: string | null = (req.query as any)?.episodeId || null
  628. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  629. for await (const part of req.parts()) {
  630. if (part.type === 'file') {
  631. const ext = extname(part.filename).slice(0, 16)
  632. const stored = 'deliverable-' + id + '-' + randomUUID() + ext
  633. const target = resolve(uploadDir, stored)
  634. await pipeline(part.file, createWriteStream(target))
  635. if (part.file.truncated) {
  636. await unlink(target).catch(() => {})
  637. return reply.code(413).send({ message: '文件超过大小限制' })
  638. }
  639. fileList.push({
  640. filename: part.filename,
  641. stored,
  642. bytes: part.file.bytesRead,
  643. mimetype: part.mimetype
  644. })
  645. } else if (part.fieldname === 'episodeId' && part.value) {
  646. episodeId = String(part.value)
  647. }
  648. }
  649. if (episodeId) {
  650. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  651. if (!epCheck.rows[0]) episodeId = null
  652. }
  653. if (!fileList.length) return reply.code(400).send({ message: '请选择交付成果文件' })
  654. for (const f of fileList) {
  655. await pool.query(
  656. 'INSERT INTO course_deliverables(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  657. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  658. )
  659. }
  660. return { course: await fullCourse(check.rows[0], true) }
  661. })
  662. // 6. 删除已上传的交付成果文件
  663. app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => {
  664. const { id, delivId } = req.params as any
  665. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  666. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  667. const { rows } = await pool.query(
  668. 'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  669. [delivId, id]
  670. )
  671. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  672. return { course: await fullCourse(check.rows[0], true) }
  673. })
  674. // 7. 提交制作成果,结单交付(流转为 COMPLETED)
  675. app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => {
  676. const { id } = req.params as any
  677. const p = z.object({
  678. productionNotes: z.string().max(2000).default('')
  679. }).safeParse(req.body || {})
  680. if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
  681. const { rows } = await pool.query(
  682. "UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *",
  683. [p.data.productionNotes, id]
  684. )
  685. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  686. return { course: await fullCourse(rows[0], true) }
  687. })
  688. // 8. 获取全平台用户列表及统计
  689. app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
  690. const query = (req.query || {}) as { keyword?: string }
  691. const params: any[] = []
  692. let where = ''
  693. if (query.keyword && query.keyword.trim()) {
  694. params.push(`%${query.keyword.trim()}%`)
  695. where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)'
  696. }
  697. const sql = `
  698. SELECT
  699. u.*,
  700. COUNT(c.id)::int AS courses_count,
  701. COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
  702. COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
  703. COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count
  704. FROM users u
  705. LEFT JOIN courses c ON c.user_id = u.id
  706. ${where}
  707. GROUP BY u.id
  708. ORDER BY u.created_at DESC
  709. `
  710. const { rows } = await pool.query(sql, params)
  711. const users = rows.map((r) => ({
  712. ...mapUser(r),
  713. coursesCount: r.courses_count || 0,
  714. waitingCoursesCount: r.waiting_courses_count || 0,
  715. inProductionCoursesCount: r.in_production_courses_count || 0,
  716. completedCoursesCount: r.completed_courses_count || 0
  717. }))
  718. return { users }
  719. })
  720. // 9. 修改用户角色(赋予或撤销管理员权限)
  721. app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
  722. const { id } = req.params as any
  723. const currentAdmin = (req as any).user
  724. const p = z.object({
  725. role: z.enum(['USER', 'ADMIN'])
  726. }).safeParse(req.body)
  727. if (!p.success) return reply.code(400).send({ message: '角色类型不正确' })
  728. if (id === currentAdmin.id && p.data.role !== 'ADMIN') {
  729. return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' })
  730. }
  731. const { rows } = await pool.query(
  732. 'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  733. [p.data.role, id]
  734. )
  735. if (!rows[0]) return reply.code(404).send({ message: '用户不存在' })
  736. return { user: mapUser(rows[0]) }
  737. })
  738. // ==================== 静态托管与错误处理 ====================
  739. if (process.env.NODE_ENV === 'production') {
  740. const dist = resolve('./dist')
  741. await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false })
  742. app.setNotFoundHandler((req, reply) =>
  743. req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html')
  744. )
  745. }
  746. app.setErrorHandler((error: FastifyError, _req, reply) => {
  747. app.log.error(error)
  748. const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
  749. reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message })
  750. })
  751. await app.listen({ port, host: process.env.HOST || '0.0.0.0' })