index.ts 46 KB

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