index.ts 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  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 latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
  103. row = { ...row, ...(latest.rows[0] || {}) }
  104. const [a, i, instructorImages, d, e, progress] = await Promise.all([
  105. pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
  106. pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
  107. pool.query(`SELECT ii.* FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id
  108. WHERE i.course_id = $1 ORDER BY ii.sort_order, ii.created_at`, [row.id]),
  109. pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
  110. pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id]),
  111. pool.query('SELECT * FROM course_progress_events WHERE course_id = $1 ORDER BY created_at DESC', [row.id])
  112. ])
  113. let episodesRows = e.rows
  114. // 确保课程永远至少有 1 个分集(自愈保底)
  115. if (episodesRows.length === 0) {
  116. const defaultEpId = randomUUID()
  117. const defaultTitle = '第 1 集:' + (row.name || '核心内容讲解')
  118. const insertRes = await pool.query(
  119. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5) RETURNING *',
  120. [defaultEpId, row.id, defaultTitle, '本集核心内容与制作说明', '']
  121. )
  122. episodesRows = insertRes.rows
  123. }
  124. let creatorUser = null
  125. if (includeCreator && row.user_id) {
  126. const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
  127. creatorUser = u.rows[0] || null
  128. }
  129. const instructors = i.rows.map((instructor) => ({
  130. ...instructor,
  131. image_urls: instructorImages.rows
  132. .filter((image) => image.instructor_id === instructor.id)
  133. .map((image) => `/uploads/${image.stored_name}`)
  134. }))
  135. return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
  136. }
  137. const addProgressEvent = (courseId: string, actorType: 'USER' | 'ADMIN' | 'SYSTEM', eventType: string, title: string, description = '', metadata: any = {}) =>
  138. pool.query(
  139. 'INSERT INTO course_progress_events(id, course_id, actor_type, event_type, title, description, metadata) VALUES($1,$2,$3,$4,$5,$6,$7)',
  140. [randomUUID(), courseId, actorType, eventType, title, description, JSON.stringify(metadata)]
  141. )
  142. async function refreshCoursePoints(courseId: string) {
  143. const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [courseId])
  144. const episodeCount = Math.max(1, Number(rows[0]?.count) || 0)
  145. const points = episodeCount * 1000
  146. await pool.query(
  147. 'UPDATE courses SET estimated_points = $1, updated_at = NOW() WHERE id = $2',
  148. [points, courseId]
  149. )
  150. return { episodeCount, points }
  151. }
  152. const ownedCourse = async (id: string, uid: string) =>
  153. (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0]
  154. app.get('/api/health', async () => ({ ok: true }))
  155. // ==================== 认证相关 ====================
  156. app.post('/api/auth/register', async (req, reply) => {
  157. const p = phonePassword.safeParse(req.body)
  158. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  159. try {
  160. const id = randomUUID()
  161. const hash = await hashPassword(p.data.password)
  162. const { rows } = await pool.query(
  163. 'INSERT INTO users(id, phone, password_hash, role, points_balance) VALUES($1, $2, $3, $4, 10000) RETURNING *',
  164. [id, p.data.phone, hash, 'USER']
  165. )
  166. return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
  167. } catch (e: any) {
  168. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  169. throw e
  170. }
  171. })
  172. app.post('/api/auth/login', async (req, reply) => {
  173. const p = phonePassword.safeParse(req.body)
  174. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  175. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  176. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  177. return reply.code(401).send({ message: '手机号或密码错误' })
  178. }
  179. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  180. })
  181. app.post('/api/admin/auth/login', async (req, reply) => {
  182. const p = phonePassword.safeParse(req.body)
  183. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  184. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  185. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  186. return reply.code(401).send({ message: '管理员账号或密码错误' })
  187. }
  188. if (rows[0].role !== 'ADMIN') {
  189. return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' })
  190. }
  191. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  192. })
  193. app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
  194. const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
  195. await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
  196. return { ok: true }
  197. })
  198. app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
  199. app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
  200. const { rows } = await pool.query(
  201. `SELECT id, name, COALESCE(actual_points, estimated_points) AS points, completed_at
  202. FROM courses
  203. WHERE user_id = $1 AND points_charged = TRUE
  204. ORDER BY completed_at DESC NULLS LAST, updated_at DESC`,
  205. [userId(req)]
  206. )
  207. return {
  208. balance: Number((req as any).user.points_balance) || 0,
  209. transactions: rows.map((row) => ({
  210. id: `course-${row.id}`,
  211. courseId: row.id,
  212. courseName: row.name,
  213. points: -Math.abs(Number(row.points) || 0),
  214. createdAt: row.completed_at
  215. }))
  216. }
  217. })
  218. app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
  219. const p = z
  220. .object({
  221. organization: z.string().max(120),
  222. wechat: z.string().max(80),
  223. contactName: z.string().max(80),
  224. bio: z.string().max(500)
  225. })
  226. .safeParse(req.body)
  227. if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
  228. const d = p.data
  229. const { rows } = await pool.query(
  230. 'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
  231. [d.organization, d.wechat, d.contactName, d.bio, userId(req)]
  232. )
  233. return { user: mapUser(rows[0]) }
  234. })
  235. app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
  236. const p = z
  237. .object({
  238. currentPassword: z.string(),
  239. newPassword: z.string().min(6).max(72)
  240. })
  241. .safeParse(req.body)
  242. if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' })
  243. if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
  244. return reply.code(400).send({ message: '当前密码不正确' })
  245. }
  246. await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
  247. await hashPassword(p.data.newPassword),
  248. userId(req)
  249. ])
  250. return { ok: true }
  251. })
  252. // ==================== 普通用户课程操作 ====================
  253. const courseInput = z.object({
  254. name: z.string().trim().min(1, '请输入课程名称').max(100),
  255. category: z.string().max(50).default(''),
  256. description: z.string().max(500).default('')
  257. })
  258. app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
  259. app.get('/api/courses', { preHandler: auth }, async (req) => {
  260. const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
  261. userId(req)
  262. ])
  263. return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
  264. })
  265. app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
  266. const p = courseInput.safeParse(req.body)
  267. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
  268. const id = randomUUID()
  269. const d = p.data
  270. const { rows } = await pool.query(
  271. 'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
  272. [id, userId(req), d.name, d.category, d.description]
  273. )
  274. // 课程创建后默认创建第 1 集内容,确保分集不为空
  275. const defaultEpId = randomUUID()
  276. const defaultTitle = '第 1 集:' + d.name
  277. await pool.query(
  278. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5)',
  279. [defaultEpId, id, defaultTitle, '', '']
  280. )
  281. await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
  282. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  283. })
  284. app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
  285. const p = z.object({
  286. templateId: z.string().default('party-building-standard')
  287. }).safeParse(req.body || {})
  288. if (!p.success) return reply.code(400).send({ message: '模板参数不正确' })
  289. const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
  290. const courseId = randomUUID()
  291. const { rows } = await pool.query(
  292. 'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
  293. [courseId, userId(req), template.name, template.category, template.description]
  294. )
  295. await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
  296. await refreshCoursePoints(courseId)
  297. await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
  298. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  299. })
  300. app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  301. const { id } = req.params as any
  302. const row = await ownedCourse(id, userId(req))
  303. if (!row) return reply.code(404).send({ message: '课程不存在' })
  304. return { course: await fullCourse(row) }
  305. })
  306. app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  307. const { id } = req.params as any
  308. const row = await ownedCourse(id, userId(req))
  309. if (!row) return reply.code(404).send({ message: '课程不存在' })
  310. const p = courseInput.pick({ name: true, category: true, description: true }).safeParse(req.body)
  311. if (!p.success) {
  312. return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
  313. }
  314. const { rows } = await pool.query(
  315. 'UPDATE courses SET name = $1, category = $2, description = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5 RETURNING *',
  316. [p.data.name, p.data.category, p.data.description, id, userId(req)]
  317. )
  318. await refreshCoursePoints(id)
  319. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、分类或需求说明已更新。')
  320. return { course: await fullCourse(rows[0]) }
  321. })
  322. app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req, reply) => {
  323. const { id } = req.params as any
  324. const row = await ownedCourse(id, userId(req))
  325. if (!row) return reply.code(404).send({ message: '课程不存在' })
  326. if (row.status !== 'WAITING_PRODUCTION') {
  327. return reply.code(409).send({ message: '只有等待制作的课程可以恢复为草稿' })
  328. }
  329. const { rows } = await pool.query(
  330. "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING *",
  331. [id, userId(req)]
  332. )
  333. await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
  334. return { course: await fullCourse(rows[0]) }
  335. })
  336. app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  337. const { id } = req.params as any
  338. const row = await ownedCourse(id, userId(req))
  339. if (!row) return reply.code(404).send({ message: '课程不存在' })
  340. // 已经等待制作、制作中或者制作完成的课程不能删除
  341. if (row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED') {
  342. return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
  343. }
  344. // 1. 清理该课程关联的所有素材文件
  345. const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
  346. for (const ast of assetsRes.rows) {
  347. if (ast.stored_name) await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  348. }
  349. // 2. 清理讲师头像图片
  350. const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
  351. const instImagesRes = await pool.query(
  352. 'SELECT ii.stored_name FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id WHERE i.course_id = $1',
  353. [id]
  354. )
  355. const instructorStoredNames = new Set(instImagesRes.rows.map((image) => image.stored_name))
  356. for (const ins of instRes.rows) {
  357. if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
  358. }
  359. for (const storedName of instructorStoredNames) {
  360. await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
  361. }
  362. // 3. 清理交付成品文件
  363. const delivRes = await pool.query('SELECT stored_name FROM course_deliverables WHERE course_id = $1', [id])
  364. for (const del of delivRes.rows) {
  365. if (del.stored_name) await unlink(resolve(uploadDir, del.stored_name)).catch(() => {})
  366. }
  367. // 4. 清理 PPT 文件
  368. if (row.ppt_stored_name) {
  369. await unlink(resolve(uploadDir, row.ppt_stored_name)).catch(() => {})
  370. }
  371. // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
  372. await pool.query('DELETE FROM courses WHERE id = $1 AND user_id = $2', [id, userId(req)])
  373. return { ok: true, message: '课程已成功删除' }
  374. })
  375. app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
  376. const { id } = req.params as any
  377. const row = await ownedCourse(id, userId(req))
  378. if (!row) return reply.code(404).send({ message: '课程不存在' })
  379. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
  380. let episodeId: string | null = (req.query as any)?.episodeId || null
  381. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  382. for await (const part of req.parts()) {
  383. if (part.type === 'file') {
  384. const ext = extname(part.filename).slice(0, 16)
  385. const stored = id + '-' + randomUUID() + ext
  386. const target = resolve(uploadDir, stored)
  387. await pipeline(part.file, createWriteStream(target))
  388. if (part.file.truncated) {
  389. await unlink(target).catch(() => {})
  390. return reply.code(413).send({ message: '文件超过大小限制' })
  391. }
  392. fileList.push({
  393. filename: part.filename,
  394. stored,
  395. bytes: part.file.bytesRead,
  396. mimetype: part.mimetype
  397. })
  398. } else if (part.fieldname === 'episodeId' && part.value) {
  399. episodeId = String(part.value)
  400. }
  401. }
  402. if (episodeId) {
  403. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  404. if (!epCheck.rows[0]) episodeId = null
  405. }
  406. if (!fileList.length) return reply.code(400).send({ message: '请选择课程资产文件' })
  407. for (const f of fileList) {
  408. await pool.query(
  409. 'INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  410. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  411. )
  412. }
  413. return { course: await fullCourse(row) }
  414. })
  415. app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
  416. const { id, assetId } = req.params as any
  417. const row = await ownedCourse(id, userId(req))
  418. if (!row) return reply.code(404).send({ message: '课程不存在' })
  419. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' })
  420. const { rows } = await pool.query(
  421. 'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  422. [assetId, id]
  423. )
  424. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  425. return { course: await fullCourse(row) }
  426. })
  427. // ==================== 课程分集操作 (Episodes) ====================
  428. // 1. 创建单集
  429. app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) => {
  430. const { id } = req.params as any
  431. const row = await ownedCourse(id, userId(req))
  432. if (!row) return reply.code(404).send({ message: '课程不存在' })
  433. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  434. const p = z.object({
  435. title: z.string().trim().min(1, '请输入分集标题').max(200),
  436. episodeNumber: z.number().int().min(1).optional(),
  437. summary: z.string().max(500).default(''),
  438. lectureNotes: z.string().default('')
  439. }).safeParse(req.body)
  440. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '分集信息填写不完整' })
  441. let epNumber = p.data.episodeNumber
  442. if (!epNumber) {
  443. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  444. epNumber = (Number(maxRes.rows[0].max_num) || 0) + 1
  445. }
  446. const epId = randomUUID()
  447. await pool.query(
  448. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  449. [epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
  450. )
  451. await refreshCoursePoints(id)
  452. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '新增课程分集', '课程结构已更新。')
  453. return reply.code(201).send({ course: await fullCourse(row) })
  454. })
  455. // 2. 批量创建分集(如快速生成 12 集)
  456. app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, reply) => {
  457. const { id } = req.params as any
  458. const row = await ownedCourse(id, userId(req))
  459. if (!row) return reply.code(404).send({ message: '课程不存在' })
  460. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  461. const p = z.object({
  462. count: z.number().int().min(1).max(100).optional(),
  463. episodes: z.array(z.object({
  464. title: z.string().trim().min(1).max(200),
  465. episodeNumber: z.number().int().min(1).optional(),
  466. summary: z.string().max(500).default(''),
  467. lectureNotes: z.string().default('')
  468. })).optional()
  469. }).safeParse(req.body)
  470. if (!p.success) return reply.code(400).send({ message: '批量创建参数不正确' })
  471. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  472. let currentMax = Number(maxRes.rows[0].max_num) || 0
  473. if (p.data.episodes && p.data.episodes.length > 0) {
  474. for (const ep of p.data.episodes) {
  475. currentMax++
  476. await pool.query(
  477. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  478. [randomUUID(), id, ep.episodeNumber || currentMax, ep.title, ep.summary, ep.lectureNotes]
  479. )
  480. }
  481. } else if (p.data.count) {
  482. for (let i = 1; i <= p.data.count; i++) {
  483. currentMax++
  484. const title = `第 ${currentMax} 集:课程知识点精讲`
  485. await pool.query(
  486. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  487. [randomUUID(), id, currentMax, title, '', '']
  488. )
  489. }
  490. } else {
  491. return reply.code(400).send({ message: '请指定集数或分集列表' })
  492. }
  493. await refreshCoursePoints(id)
  494. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '批量新增课程分集', '课程结构已更新。')
  495. return reply.code(201).send({ course: await fullCourse(row) })
  496. })
  497. // 3. 更新分集信息(标题、序号、讲稿文本等)
  498. app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  499. const { id, episodeId } = req.params as any
  500. const row = await ownedCourse(id, userId(req))
  501. if (!row) return reply.code(404).send({ message: '课程不存在' })
  502. if (!['DRAFT', 'WAITING_PRODUCTION'].includes(row.status)) {
  503. return reply.code(409).send({ message: '课程已进入制作或完成归档,无法修改分集' })
  504. }
  505. const p = z.object({
  506. title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
  507. episodeNumber: z.number().int().min(1).optional(),
  508. summary: z.string().max(500).optional(),
  509. lectureNotes: z.string().optional()
  510. }).safeParse(req.body)
  511. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '参数错误' })
  512. const existing = await pool.query('SELECT * FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  513. if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
  514. const cur = existing.rows[0]
  515. const newTitle = p.data.title !== undefined ? p.data.title : cur.title
  516. const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
  517. const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
  518. const newNotes = p.data.lectureNotes !== undefined ? p.data.lectureNotes : cur.lecture_notes
  519. await pool.query(
  520. 'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
  521. [newTitle, newNumber, newSummary, newNotes, episodeId, id]
  522. )
  523. await refreshCoursePoints(id)
  524. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程内容已修改', '分集内容已更新。')
  525. return { course: await fullCourse(row) }
  526. })
  527. // 4. 删除分集及其关联的物理素材文件
  528. app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  529. const { id, episodeId } = req.params as any
  530. const row = await ownedCourse(id, userId(req))
  531. if (!row) return reply.code(404).send({ message: '课程不存在' })
  532. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除分集' })
  533. // 课程至少需要保留一个分集,不可删除唯一分集
  534. const countRes = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [id])
  535. if ((countRes.rows[0]?.count || 0) <= 1) {
  536. return reply.code(400).send({ message: '课程至少需要保留一个分集,不可删除唯一分集' })
  537. }
  538. // 查出该集下所有素材文件并清理磁盘
  539. const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
  540. for (const ast of astRes.rows) {
  541. await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  542. }
  543. await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  544. await refreshCoursePoints(id)
  545. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '删除课程分集', '课程结构已更新。')
  546. return { course: await fullCourse(row) }
  547. })
  548. app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
  549. const { id } = req.params as any
  550. const row = await ownedCourse(id, userId(req))
  551. if (!row) return reply.code(404).send({ message: '课程不存在' })
  552. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
  553. const fields: any = {}
  554. const images: any[] = []
  555. for await (const part of req.parts()) {
  556. if (part.type === 'file') {
  557. if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
  558. const ext = extname(part.filename).slice(0, 12)
  559. const stored = 'instructor-' + randomUUID() + ext
  560. await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
  561. images.push({ original: part.filename, stored, mime: part.mimetype })
  562. } else {
  563. fields[part.fieldname] = part.value
  564. }
  565. }
  566. const p = z
  567. .object({
  568. name: z.string().trim().min(1, '请填写讲师姓名').max(80),
  569. organization: z.string().max(120).default(''),
  570. introduction: z.string().max(1000).default('')
  571. })
  572. .safeParse(fields)
  573. if (!p.success) {
  574. await Promise.all(images.map((image) => unlink(resolve(uploadDir, image.stored)).catch(() => {})))
  575. return reply.code(400).send({ message: '请填写讲师姓名' })
  576. }
  577. const d = p.data
  578. const instructorId = randomUUID()
  579. const image = images[0]
  580. const { rows } = await pool.query(
  581. '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 *',
  582. [
  583. instructorId,
  584. id,
  585. d.name,
  586. d.organization,
  587. d.introduction,
  588. image?.original || null,
  589. image?.stored || null,
  590. image?.mime || null
  591. ]
  592. )
  593. for (const [index, uploadedImage] of images.entries()) {
  594. await pool.query(
  595. 'INSERT INTO instructor_images(id, instructor_id, original_name, stored_name, mime_type, sort_order) VALUES($1, $2, $3, $4, $5, $6)',
  596. [randomUUID(), instructorId, uploadedImage.original, uploadedImage.stored, uploadedImage.mime, index]
  597. )
  598. }
  599. return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
  600. })
  601. app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => {
  602. const { id, instructorId } = req.params as any
  603. const row = await ownedCourse(id, userId(req))
  604. if (!row) return reply.code(404).send({ message: '课程不存在' })
  605. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
  606. const imageRows = await pool.query('SELECT stored_name FROM instructor_images WHERE instructor_id = $1', [instructorId])
  607. const { rows } = await pool.query(
  608. 'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
  609. [instructorId, id]
  610. )
  611. const storedNames = new Set(imageRows.rows.map((image) => image.stored_name))
  612. if (rows[0]?.image_stored_name) storedNames.add(rows[0].image_stored_name)
  613. await Promise.all([...storedNames].map((storedName) => unlink(resolve(uploadDir, storedName as string)).catch(() => {})))
  614. return { course: await fullCourse(row) }
  615. })
  616. app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
  617. const { id } = req.params as any
  618. const estimate = await refreshCoursePoints(id)
  619. const balance = Number((req as any).user.points_balance) || 0
  620. const estimatedPoints = estimate.points
  621. if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
  622. const { rows } = await pool.query(
  623. "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
  624. [id, userId(req)]
  625. )
  626. if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
  627. await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
  628. return { course: await fullCourse(rows[0]) }
  629. })
  630. // ==================== 管理员专属 ADMIN API ====================
  631. // 1. 统计概览数据
  632. app.get('/api/admin/stats', { preHandler: adminAuth }, async () => {
  633. const [uRes, cRes, sRes] = await Promise.all([
  634. pool.query('SELECT COUNT(*)::int AS count FROM users'),
  635. pool.query('SELECT COUNT(*)::int AS count FROM courses'),
  636. pool.query(`
  637. SELECT
  638. COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count,
  639. COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count,
  640. COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count,
  641. COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count
  642. FROM courses
  643. `)
  644. ])
  645. return {
  646. totalUsers: uRes.rows[0].count,
  647. totalCourses: cRes.rows[0].count,
  648. waitingCount: sRes.rows[0].waiting_count || 0,
  649. inProductionCount: sRes.rows[0].in_production_count || 0,
  650. completedCount: sRes.rows[0].completed_count || 0,
  651. draftCount: sRes.rows[0].draft_count || 0
  652. }
  653. })
  654. // 2. 获取全量课程列表(支持状态筛选与搜索)
  655. app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
  656. const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string }
  657. const params: any[] = []
  658. const conditions: string[] = []
  659. if (query.status && query.status !== 'ALL') {
  660. params.push(query.status)
  661. conditions.push(`c.status = $${params.length}`)
  662. }
  663. if (query.keyword && query.keyword.trim()) {
  664. params.push(`%${query.keyword.trim()}%`)
  665. const idx = params.length
  666. 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})`)
  667. }
  668. const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
  669. const sql = `
  670. SELECT
  671. c.*,
  672. u.phone AS user_phone,
  673. u.role AS user_role,
  674. u.organization AS user_organization,
  675. u.wechat AS user_wechat,
  676. u.contact_name AS user_contact_name,
  677. u.bio AS user_bio,
  678. u.created_at AS user_created_at
  679. FROM courses c
  680. LEFT JOIN users u ON u.id = c.user_id
  681. ${whereClause}
  682. ORDER BY
  683. CASE
  684. WHEN c.status = 'WAITING_PRODUCTION' THEN 1
  685. WHEN c.status = 'IN_PRODUCTION' THEN 2
  686. WHEN c.status = 'DRAFT' THEN 3
  687. WHEN c.status = 'COMPLETED' THEN 4
  688. ELSE 5
  689. END,
  690. c.submitted_at DESC NULLS LAST,
  691. c.created_at DESC
  692. `
  693. const { rows } = await pool.query(sql, params)
  694. const courses = await Promise.all(rows.map((r) => fullCourse(r, true)))
  695. return { courses, total: courses.length }
  696. })
  697. // 3. 获取特定课程完整详情(供制作工作台使用)
  698. app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => {
  699. const { id } = req.params as any
  700. const sql = `
  701. SELECT
  702. c.*,
  703. u.phone AS user_phone,
  704. u.role AS user_role,
  705. u.organization AS user_organization,
  706. u.wechat AS user_wechat,
  707. u.contact_name AS user_contact_name,
  708. u.bio AS user_bio,
  709. u.created_at AS user_created_at
  710. FROM courses c
  711. LEFT JOIN users u ON u.id = c.user_id
  712. WHERE c.id = $1
  713. `
  714. const { rows } = await pool.query(sql, [id])
  715. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  716. const course = await fullCourse(rows[0], true)
  717. return { course }
  718. })
  719. // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
  720. app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => {
  721. const { id } = req.params as any
  722. const p = z.object({
  723. status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
  724. }).safeParse(req.body)
  725. if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
  726. if (p.data.status === 'COMPLETED') return reply.code(400).send({ message: '请通过“确认交付”完成课程并结算积分' })
  727. const { rows } = await pool.query(
  728. 'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  729. [p.data.status, id]
  730. )
  731. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  732. const statusNames: Record<string, string> = { DRAFT: '草稿', WAITING_PRODUCTION: '等待制作', IN_PRODUCTION: '正在制作', COMPLETED: '已完成', REJECTED: '已驳回' }
  733. await addProgressEvent(id, 'ADMIN', 'STATUS_CHANGED', '制作状态已更新', `管理员将课程状态更新为“${statusNames[p.data.status]}”。`, { status: p.data.status })
  734. return { course: await fullCourse(rows[0], true) }
  735. })
  736. app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req, reply) => {
  737. const { id } = req.params as any
  738. const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
  739. if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
  740. const previous = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  741. if (!previous.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  742. if (previous.rows[0].points_charged) return reply.code(409).send({ message: '课程已完成扣费,不能再修改积分' })
  743. const { rows } = await pool.query('UPDATE courses SET actual_points = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.points, id])
  744. const oldPoints = previous.rows[0].actual_points ?? previous.rows[0].estimated_points
  745. await addProgressEvent(id, 'ADMIN', 'POINTS_UPDATED', '积分消耗已调整', `管理员将制作消耗从 ${oldPoints} 积分调整为 ${p.data.points} 积分。${p.data.reason}`, { previousPoints: Number(oldPoints), points: p.data.points })
  746. return { course: await fullCourse(rows[0], true) }
  747. })
  748. // 5. 管理员上传制作交付成品文件
  749. app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => {
  750. const { id } = req.params as any
  751. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  752. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  753. let episodeId: string | null = (req.query as any)?.episodeId || null
  754. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  755. for await (const part of req.parts()) {
  756. if (part.type === 'file') {
  757. const ext = extname(part.filename).slice(0, 16)
  758. const stored = 'deliverable-' + id + '-' + randomUUID() + ext
  759. const target = resolve(uploadDir, stored)
  760. await pipeline(part.file, createWriteStream(target))
  761. if (part.file.truncated) {
  762. await unlink(target).catch(() => {})
  763. return reply.code(413).send({ message: '文件超过大小限制' })
  764. }
  765. fileList.push({
  766. filename: part.filename,
  767. stored,
  768. bytes: part.file.bytesRead,
  769. mimetype: part.mimetype
  770. })
  771. } else if (part.fieldname === 'episodeId' && part.value) {
  772. episodeId = String(part.value)
  773. }
  774. }
  775. if (episodeId) {
  776. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  777. if (!epCheck.rows[0]) episodeId = null
  778. }
  779. if (!fileList.length) return reply.code(400).send({ message: '请选择交付成果文件' })
  780. for (const f of fileList) {
  781. await pool.query(
  782. 'INSERT INTO course_deliverables(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  783. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  784. )
  785. }
  786. await addProgressEvent(id, 'ADMIN', 'DELIVERABLE_UPLOADED', '管理员上传了制作成果', `新增 ${fileList.length} 个交付文件。`)
  787. return { course: await fullCourse(check.rows[0], true) }
  788. })
  789. // 6. 删除已上传的交付成果文件
  790. app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => {
  791. const { id, delivId } = req.params as any
  792. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  793. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  794. const { rows } = await pool.query(
  795. 'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  796. [delivId, id]
  797. )
  798. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  799. return { course: await fullCourse(check.rows[0], true) }
  800. })
  801. // 7. 提交制作成果,结单交付(流转为 COMPLETED)
  802. app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => {
  803. const { id } = req.params as any
  804. const p = z.object({
  805. productionNotes: z.string().max(2000).default('')
  806. }).safeParse(req.body || {})
  807. if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
  808. const client = await pool.connect()
  809. let rows: any[] = []
  810. try {
  811. await client.query('BEGIN')
  812. const current = await client.query('SELECT * FROM courses WHERE id = $1 FOR UPDATE', [id])
  813. if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
  814. const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
  815. if (!current.rows[0].points_charged) {
  816. const charged = await client.query('UPDATE users SET points_balance = points_balance - $1, updated_at = NOW() WHERE id = $2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
  817. if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `用户积分不足,完成制作需要扣除 ${cost} 积分` }) }
  818. }
  819. const updated = await client.query("UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), points_charged = TRUE, updated_at = NOW() WHERE id = $2 RETURNING *", [p.data.productionNotes, id])
  820. rows = updated.rows
  821. await client.query('COMMIT')
  822. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  823. const cost = Number(rows[0].actual_points ?? rows[0].estimated_points) || 100
  824. await addProgressEvent(id, 'ADMIN', 'COMPLETED', '课程制作完成', `课程已交付,实际扣除 ${cost} 积分。`, { points: cost })
  825. return { course: await fullCourse(rows[0], true) }
  826. })
  827. // 8. 获取全平台用户列表及统计
  828. app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
  829. const query = (req.query || {}) as { keyword?: string }
  830. const params: any[] = []
  831. let where = ''
  832. if (query.keyword && query.keyword.trim()) {
  833. params.push(`%${query.keyword.trim()}%`)
  834. where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)'
  835. }
  836. const sql = `
  837. SELECT
  838. u.*,
  839. COUNT(c.id)::int AS courses_count,
  840. COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
  841. COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
  842. COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count
  843. FROM users u
  844. LEFT JOIN courses c ON c.user_id = u.id
  845. ${where}
  846. GROUP BY u.id
  847. ORDER BY u.created_at DESC
  848. `
  849. const { rows } = await pool.query(sql, params)
  850. const users = rows.map((r) => ({
  851. ...mapUser(r),
  852. coursesCount: r.courses_count || 0,
  853. waitingCoursesCount: r.waiting_courses_count || 0,
  854. inProductionCoursesCount: r.in_production_courses_count || 0,
  855. completedCoursesCount: r.completed_courses_count || 0
  856. }))
  857. return { users }
  858. })
  859. // 9. 管理员创建普通用户
  860. app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
  861. const p = z.object({
  862. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  863. password: z.string().min(6, '密码至少 6 位').max(72),
  864. contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default(''),
  865. organization: z.string().trim().max(100, '单位不能超过 100 个字').optional().default('')
  866. }).safeParse(req.body)
  867. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  868. try {
  869. const id = randomUUID()
  870. const passwordHash = await hashPassword(p.data.password)
  871. const { rows } = await pool.query(
  872. `INSERT INTO users(id, phone, password_hash, role, contact_name, organization, points_balance)
  873. VALUES($1, $2, $3, 'USER', $4, $5, 10000) RETURNING *`,
  874. [id, p.data.phone, passwordHash, p.data.contactName, p.data.organization]
  875. )
  876. return reply.code(201).send({ user: mapUser(rows[0]) })
  877. } catch (e: any) {
  878. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  879. throw e
  880. }
  881. })
  882. // 10. 管理员重置用户密码;同时注销该用户已有会话
  883. app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req, reply) => {
  884. const { id } = req.params as any
  885. const p = z.object({
  886. password: z.string().min(6, '密码至少 6 位').max(72)
  887. }).safeParse(req.body)
  888. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  889. const client = await pool.connect()
  890. try {
  891. await client.query('BEGIN')
  892. const { rowCount } = await client.query(
  893. 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
  894. [await hashPassword(p.data.password), id]
  895. )
  896. if (!rowCount) {
  897. await client.query('ROLLBACK')
  898. return reply.code(404).send({ message: '用户不存在' })
  899. }
  900. await client.query('DELETE FROM sessions WHERE user_id = $1', [id])
  901. await client.query('COMMIT')
  902. return { ok: true }
  903. } catch (error) {
  904. await client.query('ROLLBACK')
  905. throw error
  906. } finally {
  907. client.release()
  908. }
  909. })
  910. // 11. 修改用户角色(赋予或撤销管理员权限)
  911. app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
  912. const { id } = req.params as any
  913. const currentAdmin = (req as any).user
  914. const p = z.object({
  915. role: z.enum(['USER', 'ADMIN'])
  916. }).safeParse(req.body)
  917. if (!p.success) return reply.code(400).send({ message: '角色类型不正确' })
  918. if (id === currentAdmin.id && p.data.role !== 'ADMIN') {
  919. return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' })
  920. }
  921. const { rows } = await pool.query(
  922. 'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  923. [p.data.role, id]
  924. )
  925. if (!rows[0]) return reply.code(404).send({ message: '用户不存在' })
  926. return { user: mapUser(rows[0]) }
  927. })
  928. // ==================== 静态托管与错误处理 ====================
  929. if (process.env.NODE_ENV === 'production') {
  930. const dist = resolve('./dist')
  931. await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false })
  932. app.setNotFoundHandler((req, reply) =>
  933. req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html')
  934. )
  935. }
  936. app.setErrorHandler((error: FastifyError, _req, reply) => {
  937. app.log.error(error)
  938. const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
  939. reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message })
  940. })
  941. await app.listen({ port, host: process.env.HOST || '0.0.0.0' })