index.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  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. const scrypt = promisify(scryptCb)
  16. const app = Fastify({ logger: true })
  17. const port = Number(process.env.PORT || 3001)
  18. const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
  19. const maxBytes = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024
  20. await mkdir(uploadDir, { recursive: true })
  21. const here = dirname(fileURLToPath(import.meta.url))
  22. await pool.query(await readFile(join(here, 'schema.sql'), 'utf8'))
  23. // 检查是否存在管理员账号,若不存在则创建默认演示管理员
  24. async function initDefaultAdmin() {
  25. try {
  26. const { rows } = await pool.query("SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1")
  27. if (rows.length === 0) {
  28. const defaultPhone = process.env.ADMIN_PHONE || '18888888888'
  29. const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123456'
  30. const existing = await pool.query('SELECT id FROM users WHERE phone = $1', [defaultPhone])
  31. const salt = randomBytes(16).toString('hex')
  32. const key = (await scrypt(defaultPassword, salt, 64)) as Buffer
  33. const hash = 'scrypt$' + salt + '$' + key.toString('hex')
  34. if (existing.rows.length > 0) {
  35. await pool.query("UPDATE users SET role = 'ADMIN' WHERE id = $1", [existing.rows[0].id])
  36. app.log.info(`Updated existing user ${defaultPhone} to ADMIN role`)
  37. } else {
  38. const id = randomUUID()
  39. await pool.query(
  40. "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)",
  41. [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号']
  42. )
  43. app.log.info(`Initialized default admin account: ${defaultPhone} / ${defaultPassword}`)
  44. }
  45. }
  46. } catch (err) {
  47. app.log.error(err, 'Failed to initialize default admin account')
  48. }
  49. }
  50. await initDefaultAdmin()
  51. await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
  52. await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
  53. await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false })
  54. const sha = (v: string) => createHash('sha256').update(v).digest('hex')
  55. async function hashPassword(p: string) {
  56. const salt = randomBytes(16).toString('hex')
  57. const key = (await scrypt(p, salt, 64)) as Buffer
  58. return 'scrypt$' + salt + '$' + key.toString('hex')
  59. }
  60. async function verifyPassword(p: string, stored: string) {
  61. const [, salt, hex] = stored.split('$')
  62. if (!salt || !hex) return false
  63. const a = (await scrypt(p, salt, 64)) as Buffer
  64. const b = Buffer.from(hex, 'hex')
  65. return a.length === b.length && timingSafeEqual(a, b)
  66. }
  67. async function session(uid: string) {
  68. const token = randomBytes(32).toString('hex')
  69. await pool.query("INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
  70. sha(token),
  71. uid
  72. ])
  73. return token
  74. }
  75. async function auth(req: FastifyRequest, reply: FastifyReply) {
  76. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  77. if (!token) return reply.code(401).send({ message: '请先登录' })
  78. const { rows } = await pool.query(
  79. 'SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()',
  80. [sha(token)]
  81. )
  82. if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
  83. ;(req as any).user = rows[0]
  84. }
  85. async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
  86. await auth(req, reply)
  87. if (reply.sent) return
  88. const user = (req as any).user
  89. if (!user || user.role !== 'ADMIN') {
  90. return reply.code(403).send({ message: '权限不足:仅管理员可访问此接口' })
  91. }
  92. }
  93. const userId = (r: any) => r.user.id
  94. const phonePassword = z.object({
  95. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  96. password: z.string().min(6, '密码至少 6 位').max(72)
  97. })
  98. const fullCourse = async (row: any, includeCreator = false) => {
  99. const [a, i, d] = await Promise.all([
  100. pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
  101. pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
  102. pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id])
  103. ])
  104. let creatorUser = null
  105. if (includeCreator && row.user_id) {
  106. const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
  107. creatorUser = u.rows[0] || null
  108. }
  109. return mapCourse(row, a.rows, i.rows, d.rows, creatorUser)
  110. }
  111. const ownedCourse = async (id: string, uid: string) =>
  112. (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0]
  113. app.get('/api/health', async () => ({ ok: true }))
  114. // ==================== 认证相关 ====================
  115. app.post('/api/auth/register', async (req, reply) => {
  116. const p = phonePassword.safeParse(req.body)
  117. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  118. try {
  119. const id = randomUUID()
  120. const hash = await hashPassword(p.data.password)
  121. const { rows } = await pool.query(
  122. 'INSERT INTO users(id, phone, password_hash, role) VALUES($1, $2, $3, $4) RETURNING *',
  123. [id, p.data.phone, hash, 'USER']
  124. )
  125. return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
  126. } catch (e: any) {
  127. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  128. throw e
  129. }
  130. })
  131. app.post('/api/auth/login', async (req, reply) => {
  132. const p = phonePassword.safeParse(req.body)
  133. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  134. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  135. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  136. return reply.code(401).send({ message: '手机号或密码错误' })
  137. }
  138. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  139. })
  140. app.post('/api/admin/auth/login', async (req, reply) => {
  141. const p = phonePassword.safeParse(req.body)
  142. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  143. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  144. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  145. return reply.code(401).send({ message: '管理员账号或密码错误' })
  146. }
  147. if (rows[0].role !== 'ADMIN') {
  148. return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' })
  149. }
  150. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  151. })
  152. app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
  153. const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
  154. await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
  155. return { ok: true }
  156. })
  157. app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
  158. app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
  159. const p = z
  160. .object({
  161. organization: z.string().max(120),
  162. wechat: z.string().max(80),
  163. contactName: z.string().max(80),
  164. bio: z.string().max(500)
  165. })
  166. .safeParse(req.body)
  167. if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
  168. const d = p.data
  169. const { rows } = await pool.query(
  170. 'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
  171. [d.organization, d.wechat, d.contactName, d.bio, userId(req)]
  172. )
  173. return { user: mapUser(rows[0]) }
  174. })
  175. app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
  176. const p = z
  177. .object({
  178. currentPassword: z.string(),
  179. newPassword: z.string().min(6).max(72)
  180. })
  181. .safeParse(req.body)
  182. if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' })
  183. if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
  184. return reply.code(400).send({ message: '当前密码不正确' })
  185. }
  186. await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
  187. await hashPassword(p.data.newPassword),
  188. userId(req)
  189. ])
  190. return { ok: true }
  191. })
  192. // ==================== 普通用户课程操作 ====================
  193. const courseInput = z.object({
  194. name: z.string().trim().min(1, '请输入课程名称').max(100),
  195. category: z.string().max(50).default(''),
  196. audience: z.string().max(100).default(''),
  197. description: z.string().max(500).default('')
  198. })
  199. app.get('/api/courses', { preHandler: auth }, async (req) => {
  200. const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
  201. userId(req)
  202. ])
  203. return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
  204. })
  205. app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
  206. const p = courseInput.safeParse(req.body)
  207. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
  208. const id = randomUUID()
  209. const d = p.data
  210. const { rows } = await pool.query(
  211. 'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
  212. [id, userId(req), d.name, d.category, d.audience, d.description]
  213. )
  214. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  215. })
  216. app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  217. const { id } = req.params as any
  218. const row = await ownedCourse(id, userId(req))
  219. if (!row) return reply.code(404).send({ message: '课程不存在' })
  220. return { course: await fullCourse(row) }
  221. })
  222. app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
  223. const { id } = req.params as any
  224. const row = await ownedCourse(id, userId(req))
  225. if (!row) return reply.code(404).send({ message: '课程不存在' })
  226. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
  227. let count = 0
  228. for await (const part of req.files()) {
  229. const ext = extname(part.filename).slice(0, 16)
  230. const stored = id + '-' + randomUUID() + ext
  231. const target = resolve(uploadDir, stored)
  232. await pipeline(part.file, createWriteStream(target))
  233. if (part.file.truncated) {
  234. await unlink(target).catch(() => {})
  235. return reply.code(413).send({ message: '文件超过大小限制' })
  236. }
  237. await pool.query(
  238. 'INSERT INTO course_assets(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)',
  239. [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype]
  240. )
  241. count++
  242. }
  243. if (!count) return reply.code(400).send({ message: '请选择课程资产文件' })
  244. return { course: await fullCourse(row) }
  245. })
  246. app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
  247. const { id, assetId } = req.params as any
  248. const row = await ownedCourse(id, userId(req))
  249. if (!row) return reply.code(404).send({ message: '课程不存在' })
  250. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' })
  251. const { rows } = await pool.query(
  252. 'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  253. [assetId, id]
  254. )
  255. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  256. return { course: await fullCourse(row) }
  257. })
  258. app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
  259. const { id } = req.params as any
  260. const row = await ownedCourse(id, userId(req))
  261. if (!row) return reply.code(404).send({ message: '课程不存在' })
  262. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
  263. const fields: any = {}
  264. let image: any = null
  265. for await (const part of req.parts()) {
  266. if (part.type === 'file') {
  267. if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
  268. const ext = extname(part.filename).slice(0, 12)
  269. const stored = 'instructor-' + randomUUID() + ext
  270. await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
  271. image = { original: part.filename, stored, mime: part.mimetype }
  272. } else {
  273. fields[part.fieldname] = part.value
  274. }
  275. }
  276. const p = z
  277. .object({
  278. name: z.string().trim().min(1, '请填写讲师姓名').max(80),
  279. organization: z.string().max(120).default(''),
  280. introduction: z.string().max(1000).default('')
  281. })
  282. .safeParse(fields)
  283. if (!p.success) {
  284. if (image) await unlink(resolve(uploadDir, image.stored)).catch(() => {})
  285. return reply.code(400).send({ message: '请填写讲师姓名' })
  286. }
  287. const d = p.data
  288. const { rows } = await pool.query(
  289. '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 *',
  290. [
  291. randomUUID(),
  292. id,
  293. d.name,
  294. d.organization,
  295. d.introduction,
  296. image?.original || null,
  297. image?.stored || null,
  298. image?.mime || null
  299. ]
  300. )
  301. return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
  302. })
  303. app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => {
  304. const { id, instructorId } = 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 !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
  308. const { rows } = await pool.query(
  309. 'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
  310. [instructorId, id]
  311. )
  312. if (rows[0]?.image_stored_name) await unlink(resolve(uploadDir, rows[0].image_stored_name)).catch(() => {})
  313. return { course: await fullCourse(row) }
  314. })
  315. app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
  316. const { id } = req.params as any
  317. const { rows } = await pool.query(
  318. "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
  319. [id, userId(req)]
  320. )
  321. if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
  322. return { course: await fullCourse(rows[0]) }
  323. })
  324. // ==================== 管理员专属 ADMIN API ====================
  325. // 1. 统计概览数据
  326. app.get('/api/admin/stats', { preHandler: adminAuth }, async () => {
  327. const [uRes, cRes, sRes] = await Promise.all([
  328. pool.query('SELECT COUNT(*)::int AS count FROM users'),
  329. pool.query('SELECT COUNT(*)::int AS count FROM courses'),
  330. pool.query(`
  331. SELECT
  332. COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count,
  333. COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count,
  334. COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count,
  335. COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count
  336. FROM courses
  337. `)
  338. ])
  339. return {
  340. totalUsers: uRes.rows[0].count,
  341. totalCourses: cRes.rows[0].count,
  342. waitingCount: sRes.rows[0].waiting_count || 0,
  343. inProductionCount: sRes.rows[0].in_production_count || 0,
  344. completedCount: sRes.rows[0].completed_count || 0,
  345. draftCount: sRes.rows[0].draft_count || 0
  346. }
  347. })
  348. // 2. 获取全量课程列表(支持状态筛选与搜索)
  349. app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
  350. const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string }
  351. const params: any[] = []
  352. const conditions: string[] = []
  353. if (query.status && query.status !== 'ALL') {
  354. params.push(query.status)
  355. conditions.push(`c.status = $${params.length}`)
  356. }
  357. if (query.keyword && query.keyword.trim()) {
  358. params.push(`%${query.keyword.trim()}%`)
  359. const idx = params.length
  360. 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})`)
  361. }
  362. const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
  363. const sql = `
  364. SELECT
  365. c.*,
  366. u.phone AS user_phone,
  367. u.role AS user_role,
  368. u.organization AS user_organization,
  369. u.wechat AS user_wechat,
  370. u.contact_name AS user_contact_name,
  371. u.bio AS user_bio,
  372. u.created_at AS user_created_at
  373. FROM courses c
  374. LEFT JOIN users u ON u.id = c.user_id
  375. ${whereClause}
  376. ORDER BY
  377. CASE
  378. WHEN c.status = 'WAITING_PRODUCTION' THEN 1
  379. WHEN c.status = 'IN_PRODUCTION' THEN 2
  380. WHEN c.status = 'DRAFT' THEN 3
  381. WHEN c.status = 'COMPLETED' THEN 4
  382. ELSE 5
  383. END,
  384. c.submitted_at DESC NULLS LAST,
  385. c.created_at DESC
  386. `
  387. const { rows } = await pool.query(sql, params)
  388. const courses = await Promise.all(rows.map((r) => fullCourse(r, true)))
  389. return { courses, total: courses.length }
  390. })
  391. // 3. 获取特定课程完整详情(供制作工作台使用)
  392. app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => {
  393. const { id } = req.params as any
  394. const sql = `
  395. SELECT
  396. c.*,
  397. u.phone AS user_phone,
  398. u.role AS user_role,
  399. u.organization AS user_organization,
  400. u.wechat AS user_wechat,
  401. u.contact_name AS user_contact_name,
  402. u.bio AS user_bio,
  403. u.created_at AS user_created_at
  404. FROM courses c
  405. LEFT JOIN users u ON u.id = c.user_id
  406. WHERE c.id = $1
  407. `
  408. const { rows } = await pool.query(sql, [id])
  409. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  410. const course = await fullCourse(rows[0], true)
  411. return { course }
  412. })
  413. // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
  414. app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => {
  415. const { id } = req.params as any
  416. const p = z.object({
  417. status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
  418. }).safeParse(req.body)
  419. if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
  420. const { rows } = await pool.query(
  421. 'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  422. [p.data.status, id]
  423. )
  424. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  425. return { course: await fullCourse(rows[0], true) }
  426. })
  427. // 5. 管理员上传制作交付成品文件
  428. app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => {
  429. const { id } = req.params as any
  430. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  431. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  432. let count = 0
  433. for await (const part of req.files()) {
  434. const ext = extname(part.filename).slice(0, 16)
  435. const stored = 'deliverable-' + id + '-' + randomUUID() + ext
  436. const target = resolve(uploadDir, stored)
  437. await pipeline(part.file, createWriteStream(target))
  438. if (part.file.truncated) {
  439. await unlink(target).catch(() => {})
  440. return reply.code(413).send({ message: '文件超过大小限制' })
  441. }
  442. await pool.query(
  443. 'INSERT INTO course_deliverables(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)',
  444. [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype]
  445. )
  446. count++
  447. }
  448. if (!count) return reply.code(400).send({ message: '请选择交付成果文件' })
  449. return { course: await fullCourse(check.rows[0], true) }
  450. })
  451. // 6. 删除已上传的交付成果文件
  452. app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => {
  453. const { id, delivId } = req.params as any
  454. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  455. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  456. const { rows } = await pool.query(
  457. 'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  458. [delivId, id]
  459. )
  460. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  461. return { course: await fullCourse(check.rows[0], true) }
  462. })
  463. // 7. 提交制作成果,结单交付(流转为 COMPLETED)
  464. app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => {
  465. const { id } = req.params as any
  466. const p = z.object({
  467. productionNotes: z.string().max(2000).default('')
  468. }).safeParse(req.body || {})
  469. if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
  470. const { rows } = await pool.query(
  471. "UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *",
  472. [p.data.productionNotes, id]
  473. )
  474. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  475. return { course: await fullCourse(rows[0], true) }
  476. })
  477. // 8. 获取全平台用户列表及统计
  478. app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
  479. const query = (req.query || {}) as { keyword?: string }
  480. const params: any[] = []
  481. let where = ''
  482. if (query.keyword && query.keyword.trim()) {
  483. params.push(`%${query.keyword.trim()}%`)
  484. where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)'
  485. }
  486. const sql = `
  487. SELECT
  488. u.*,
  489. COUNT(c.id)::int AS courses_count,
  490. COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
  491. COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
  492. COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count
  493. FROM users u
  494. LEFT JOIN courses c ON c.user_id = u.id
  495. ${where}
  496. GROUP BY u.id
  497. ORDER BY u.created_at DESC
  498. `
  499. const { rows } = await pool.query(sql, params)
  500. const users = rows.map((r) => ({
  501. ...mapUser(r),
  502. coursesCount: r.courses_count || 0,
  503. waitingCoursesCount: r.waiting_courses_count || 0,
  504. inProductionCoursesCount: r.in_production_courses_count || 0,
  505. completedCoursesCount: r.completed_courses_count || 0
  506. }))
  507. return { users }
  508. })
  509. // 9. 修改用户角色(赋予或撤销管理员权限)
  510. app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
  511. const { id } = req.params as any
  512. const currentAdmin = (req as any).user
  513. const p = z.object({
  514. role: z.enum(['USER', 'ADMIN'])
  515. }).safeParse(req.body)
  516. if (!p.success) return reply.code(400).send({ message: '角色类型不正确' })
  517. if (id === currentAdmin.id && p.data.role !== 'ADMIN') {
  518. return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' })
  519. }
  520. const { rows } = await pool.query(
  521. 'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  522. [p.data.role, id]
  523. )
  524. if (!rows[0]) return reply.code(404).send({ message: '用户不存在' })
  525. return { user: mapUser(rows[0]) }
  526. })
  527. // ==================== 静态托管与错误处理 ====================
  528. if (process.env.NODE_ENV === 'production') {
  529. const dist = resolve('./dist')
  530. await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false })
  531. app.setNotFoundHandler((req, reply) =>
  532. req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html')
  533. )
  534. }
  535. app.setErrorHandler((error: FastifyError, _req, reply) => {
  536. app.log.error(error)
  537. const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
  538. reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message })
  539. })
  540. await app.listen({ port, host: process.env.HOST || '0.0.0.0' })