index.ts 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333
  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 { createReadStream, createWriteStream, existsSync } from 'node:fs'
  7. import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
  8. import { pipeline } from 'node:stream/promises'
  9. import { resolve, extname, dirname, join, basename } from 'node:path'
  10. import { fileURLToPath } from 'node:url'
  11. import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'
  12. import { homedir, tmpdir } from 'node:os'
  13. import { execFile } from 'node:child_process'
  14. import { promisify } from 'node:util'
  15. import { z } from 'zod'
  16. import sanitizeHtml from 'sanitize-html'
  17. import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
  18. import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
  19. import { registerOrganizations } from './organizations.js'
  20. import { registerDigitalInstructors } from './digital-instructors.js'
  21. import { extractPptxSpeakerNotes } from './pptx-notes.js'
  22. const scrypt = promisify(scryptCb)
  23. const execFileAsync = promisify(execFile)
  24. const app = Fastify({ logger: true })
  25. const port = Number(process.env.PORT || 3001)
  26. const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
  27. const sampleDir = resolve(process.env.SAMPLE_DIR || './sample')
  28. const maxBytes = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024
  29. await mkdir(uploadDir, { recursive: true })
  30. const here = dirname(fileURLToPath(import.meta.url))
  31. await pool.query(await readFile(join(here, 'schema.sql'), 'utf8'))
  32. // 旧版管理员从 users 迁出。若曾拥有业务数据,停止启动并要求人工处理,避免级联删除。
  33. async function initDefaultAdmin() {
  34. const client = await pool.connect()
  35. try {
  36. await client.query('BEGIN')
  37. const legacy = await client.query("SELECT * FROM users WHERE role = 'ADMIN' FOR UPDATE")
  38. for (const row of legacy.rows) {
  39. const owned = await client.query(`SELECT
  40. EXISTS(SELECT 1 FROM courses WHERE user_id=$1) OR
  41. EXISTS(SELECT 1 FROM organization_members WHERE user_id=$1) OR
  42. EXISTS(SELECT 1 FROM digital_instructors WHERE owner_id=$1 OR creator_id=$1 OR personal_user_id=$1) AS has_business_data`, [row.id])
  43. if (owned.rows[0].has_business_data) throw new Error(`Legacy admin ${row.id} owns business data; manual migration required`)
  44. const auditIds = (await client.query('SELECT id FROM audit_events WHERE actor_id=$1', [row.id])).rows.map(r => r.id)
  45. const ledgerIds = (await client.query("SELECT id FROM points_ledger WHERE actor_id=$1 AND NOT (owner_type='PERSONAL' AND owner_id=$1)", [row.id])).rows.map(r => r.id)
  46. const applicationIds = (await client.query('SELECT id FROM organization_applications WHERE reviewed_by=$1', [row.id])).rows.map(r => r.id)
  47. const instructorEventIds = (await client.query('SELECT id FROM digital_instructor_events WHERE actor_id=$1', [row.id])).rows.map(r => r.id)
  48. // 外键先释放,再用新的管理员外键恢复历史操作人的身份。
  49. await client.query('UPDATE audit_events SET actor_id=NULL WHERE actor_id=$1', [row.id])
  50. await client.query('UPDATE points_ledger SET actor_id=NULL WHERE actor_id=$1', [row.id])
  51. await client.query('UPDATE organization_applications SET reviewed_by=NULL WHERE reviewed_by=$1', [row.id])
  52. await client.query('UPDATE digital_instructor_events SET actor_id=NULL WHERE actor_id=$1', [row.id])
  53. await client.query("DELETE FROM points_ledger WHERE owner_type='PERSONAL' AND owner_id=$1", [row.id])
  54. await client.query('DELETE FROM users WHERE id=$1', [row.id])
  55. await client.query('INSERT INTO platform_admins(id,phone,password_hash,contact_name,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6)',
  56. [row.id,row.phone,row.password_hash,row.contact_name,row.created_at,row.updated_at])
  57. await client.query('UPDATE audit_events SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,auditIds])
  58. await client.query('UPDATE points_ledger SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,ledgerIds])
  59. await client.query('UPDATE organization_applications SET reviewed_by_admin=$1 WHERE id=ANY($2::uuid[])', [row.id,applicationIds])
  60. await client.query('UPDATE digital_instructor_events SET admin_actor_id=$1 WHERE id=ANY($2::uuid[])', [row.id,instructorEventIds])
  61. }
  62. const count = await client.query('SELECT 1 FROM platform_admins LIMIT 1')
  63. if (!count.rows[0]) {
  64. const phone = process.env.ADMIN_PHONE
  65. const password = process.env.ADMIN_PASSWORD
  66. if (!phone || !password) throw new Error('ADMIN_PHONE and ADMIN_PASSWORD are required to create the first platform administrator')
  67. const existing = await client.query('SELECT 1 FROM users WHERE phone=$1', [phone])
  68. if (existing.rows[0]) throw new Error('ADMIN_PHONE already belongs to an ordinary user')
  69. await client.query('INSERT INTO platform_admins(id,phone,password_hash,contact_name) VALUES($1,$2,$3,$4)',
  70. [randomUUID(),phone,await hashPassword(password),'系统管理员'])
  71. }
  72. await client.query('COMMIT')
  73. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  74. }
  75. await initDefaultAdmin()
  76. await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
  77. await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
  78. await app.register(staticPlugin, { root: uploadDir, serve: false })
  79. await app.register(staticPlugin, { root: sampleDir, prefix: '/sample/', decorateReply: false })
  80. const sha = (v: string) => createHash('sha256').update(v).digest('hex')
  81. async function hashPassword(p: string) {
  82. const salt = randomBytes(16).toString('hex')
  83. const key = (await scrypt(p, salt, 64)) as Buffer
  84. return 'scrypt$' + salt + '$' + key.toString('hex')
  85. }
  86. async function verifyPassword(p: string, stored: string) {
  87. const [, salt, hex] = stored.split('$')
  88. if (!salt || !hex) return false
  89. const a = (await scrypt(p, salt, 64)) as Buffer
  90. const b = Buffer.from(hex, 'hex')
  91. return a.length === b.length && timingSafeEqual(a, b)
  92. }
  93. async function session(uid: string, admin = false) {
  94. const token = randomBytes(32).toString('hex')
  95. await pool.query(admin ? "INSERT INTO platform_admin_sessions(token_hash, admin_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')" : "INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
  96. sha(token),
  97. uid
  98. ])
  99. return token
  100. }
  101. async function auth(req: FastifyRequest, reply: FastifyReply) {
  102. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  103. if (!token) return reply.code(401).send({ message: '请先登录' })
  104. const { rows } = await pool.query(
  105. 'SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()',
  106. [sha(token)]
  107. )
  108. if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
  109. ;(req as any).user = rows[0]
  110. if (rows[0].must_change_password && !['/api/me', '/api/me/password', '/api/auth/logout'].includes(req.url.split('?')[0])) {
  111. return reply.code(403).send({ message: '请先修改初始密码' })
  112. }
  113. }
  114. async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
  115. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  116. if (!token) return reply.code(401).send({ message: '请先登录管理后台' })
  117. const { rows } = await pool.query('SELECT a.* FROM platform_admin_sessions s JOIN platform_admins a ON a.id=s.admin_id WHERE s.token_hash=$1 AND s.expires_at>NOW()', [sha(token)])
  118. if (!rows[0]) return reply.code(401).send({ message: '管理员登录已过期' })
  119. ;(req as any).user = { ...rows[0], role: 'ADMIN' }
  120. }
  121. async function accountAuth(req: FastifyRequest, reply: FastifyReply) {
  122. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  123. if (token) {
  124. const { rows } = await pool.query('SELECT a.* FROM platform_admin_sessions s JOIN platform_admins a ON a.id=s.admin_id WHERE s.token_hash=$1 AND s.expires_at>NOW()', [sha(token)])
  125. if (rows[0]) { (req as any).user = { ...rows[0], role: 'ADMIN' }; return }
  126. }
  127. return auth(req, reply)
  128. }
  129. const userId = (r: any) => r.user.id
  130. const phonePassword = z.object({
  131. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  132. password: z.string().min(6, '密码至少 6 位').max(72)
  133. })
  134. const fullCourse = async (row: any, includeCreator = false) => {
  135. const latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
  136. row = { ...row, ...(latest.rows[0] || {}) }
  137. if (row.organization_id) {
  138. const organization = await pool.query('SELECT name FROM organizations WHERE id=$1', [row.organization_id])
  139. row.organization_name = organization.rows[0]?.name || ''
  140. }
  141. const [a, i, instructorImages, d, e, progress] = await Promise.all([
  142. pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
  143. pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
  144. pool.query(`SELECT ii.* FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id
  145. WHERE i.course_id = $1 ORDER BY ii.sort_order, ii.created_at`, [row.id]),
  146. pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
  147. pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id]),
  148. pool.query('SELECT * FROM course_progress_events WHERE course_id = $1 ORDER BY created_at DESC', [row.id])
  149. ])
  150. let episodesRows = e.rows
  151. // 确保课程永远至少有 1 个分集(自愈保底)
  152. if (episodesRows.length === 0) {
  153. const defaultEpId = randomUUID()
  154. const defaultTitle = '第 1 集:' + (row.name || '核心内容讲解')
  155. const insertRes = await pool.query(
  156. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5) RETURNING *',
  157. [defaultEpId, row.id, defaultTitle, '本集核心内容与制作说明', '']
  158. )
  159. episodesRows = insertRes.rows
  160. }
  161. let creatorUser = null
  162. if (includeCreator && row.user_id) {
  163. const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
  164. creatorUser = u.rows[0] || null
  165. }
  166. let instructors = i.rows.map((instructor) => ({
  167. ...instructor,
  168. image_urls: instructorImages.rows
  169. .filter((image) => image.instructor_id === instructor.id)
  170. .map((image) => `/uploads/${image.stored_name}`)
  171. }))
  172. if (row.digital_instructor_id) {
  173. const digital = await pool.query('SELECT * FROM digital_instructors WHERE id=$1', [row.digital_instructor_id])
  174. if (digital.rows[0]) {
  175. const imageRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' AND is_active=TRUE ORDER BY sort_order", [row.digital_instructor_id])
  176. const audioRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='AUDIO' AND is_active=TRUE ORDER BY sort_order", [row.digital_instructor_id])
  177. instructors = [{
  178. ...digital.rows[0],
  179. image_urls: imageRows.rows.map(f => `/uploads/${f.stored_name}`),
  180. audio_urls: audioRows.rows.map(f => `/uploads/${f.stored_name}`)
  181. }]
  182. }
  183. }
  184. if (row.status !== 'DRAFT' && row.digital_instructor_snapshot) {
  185. const snapshot = row.digital_instructor_snapshot
  186. instructors = [{
  187. ...snapshot,
  188. image_urls: (snapshot.images || []).map((image: any) => image.url),
  189. audio_urls: (snapshot.audio || []).map((audio: any) => audio.url)
  190. }]
  191. }
  192. return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
  193. }
  194. const addProgressEvent = (courseId: string, actorType: 'USER' | 'ADMIN' | 'SYSTEM', eventType: string, title: string, description = '', metadata: any = {}) =>
  195. pool.query(
  196. 'INSERT INTO course_progress_events(id, course_id, actor_type, event_type, title, description, metadata) VALUES($1,$2,$3,$4,$5,$6,$7)',
  197. [randomUUID(), courseId, actorType, eventType, title, description, JSON.stringify(metadata)]
  198. )
  199. async function refreshCoursePoints(courseId: string) {
  200. const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [courseId])
  201. const episodeCount = Math.max(1, Number(rows[0]?.count) || 0)
  202. const points = episodeCount * 1000
  203. await pool.query(
  204. 'UPDATE courses SET estimated_points = $1, updated_at = NOW() WHERE id = $2',
  205. [points, courseId]
  206. )
  207. return { episodeCount, points }
  208. }
  209. const ownedCourse = async (id: string, uid: string) =>
  210. (await pool.query(`SELECT c.* FROM courses c WHERE c.id = $1 AND
  211. ((c.organization_id IS NULL AND c.user_id=$2) OR (c.organization_id IS NOT NULL AND EXISTS (
  212. SELECT 1 FROM organization_members m WHERE m.organization_id=c.organization_id AND m.user_id=$2
  213. AND (c.user_id=$2 OR m.role='MANAGER'))))`, [id, uid])).rows[0]
  214. registerOrganizations(app, { pool, auth, adminAuth, hashPassword, mapUser })
  215. const digitalInstructors = registerDigitalInstructors(app, { pool, auth: accountAuth, uploadDir })
  216. async function courseSnapshot(id: string) {
  217. const { rows: [course] } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
  218. if (!course) return null
  219. const [episodes, assets, instructors, deliverables] = await Promise.all([
  220. pool.query('SELECT * FROM episodes WHERE course_id=$1 ORDER BY episode_number', [id]),
  221. pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_assets WHERE course_id=$1 ORDER BY created_at', [id]),
  222. pool.query('SELECT id,name,organization,introduction FROM instructors WHERE course_id=$1 ORDER BY created_at', [id]),
  223. pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_deliverables WHERE course_id=$1 ORDER BY created_at', [id])
  224. ])
  225. return { course, episodes: episodes.rows, assets: assets.rows, instructors: instructors.rows, deliverables: deliverables.rows }
  226. }
  227. app.addHook('onRequest', async (req) => {
  228. if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return
  229. const match = req.url.match(/^\/api\/(?:admin\/)?courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
  230. if (!match) return
  231. ;(req as any).auditCourseId = match[1]
  232. ;(req as any).auditBefore = await courseSnapshot(match[1])
  233. })
  234. app.addHook('onSend', async (req, reply, payload) => {
  235. const id = (req as any).auditCourseId as string | undefined
  236. const before = (req as any).auditBefore
  237. if (!id || !before || reply.statusCode >= 400) return payload
  238. const authorization = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  239. if (!authorization) return payload
  240. const actor = await pool.query(`SELECT s.user_id FROM sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])
  241. const adminActor = actor.rows[0] ? null : (await pool.query(`SELECT s.admin_id FROM platform_admin_sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])).rows[0]
  242. if (!actor.rows[0] && !adminActor) return payload
  243. const after = await courseSnapshot(id)
  244. await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,admin_actor_id,subject_user_id,action,details)
  245. VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), before.course.organization_id, id, actor.rows[0]?.user_id || null,
  246. adminActor?.admin_id || null, before.course.user_id, `${req.method} ${req.routeOptions.url}`, JSON.stringify({ before, after })])
  247. return payload
  248. })
  249. app.addHook('preHandler', async (req, reply) => {
  250. // 只读 GET 请求不在此处因工作区暂不匹配而强阻断,以便前端可感知课程真实工作区并自动校准对齐(后续业务层已由 ownedCourse 确保访问安全)
  251. if (req.method === 'GET') return
  252. const match = req.url.match(/^\/api\/courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
  253. if (!match) return
  254. const { rows } = await pool.query('SELECT organization_id FROM courses WHERE id=$1', [match[1]])
  255. if (!rows[0]) return
  256. const selected = req.headers['x-workspace-id']
  257. const workspace = typeof selected === 'string' && selected !== 'personal' ? selected : null
  258. if (rows[0].organization_id !== workspace) return reply.code(404).send({ message: '请切换到课程所属工作区' })
  259. })
  260. async function courseOperator(req: FastifyRequest, reply: FastifyReply) {
  261. await accountAuth(req, reply)
  262. if (reply.sent) return
  263. if ((req as any).user.role === 'ADMIN') return
  264. const id = (req.params as any).id
  265. const { rows } = await pool.query(`SELECT 1 FROM courses c JOIN organization_members m ON m.organization_id=c.organization_id
  266. WHERE c.id=$1 AND m.user_id=$2 AND m.role='MANAGER'`, [id, userId(req)])
  267. if (!rows[0]) return reply.code(403).send({ message: '没有该课程的组织管理权限' })
  268. }
  269. app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) => {
  270. const stored = (req.params as any).stored as string
  271. const digitalFile = await pool.query(`SELECT f.stored_name,d.organization_id,d.personal_user_id FROM digital_instructor_files f JOIN digital_instructors d ON d.id=f.instructor_id WHERE f.stored_name=$1`, [stored])
  272. if (digitalFile.rows[0]) {
  273. const file = digitalFile.rows[0]
  274. const permitted = file.personal_user_id === userId(req) || (file.organization_id && ((req as any).user.role === 'ADMIN' || (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0]))
  275. if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
  276. return reply.sendFile(stored, uploadDir)
  277. }
  278. const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
  279. EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
  280. OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
  281. OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
  282. OR EXISTS(SELECT 1 FROM instructor_images ii JOIN instructors i ON i.id=ii.instructor_id WHERE i.course_id=c.id AND ii.stored_name=$1)
  283. LIMIT 1`, [stored])
  284. const course = rows[0]
  285. if (!course) return reply.code(404).send({ message: '文件不存在' })
  286. if ((req as any).user.role !== 'ADMIN') {
  287. const member = await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
  288. [course.organization_id, userId(req)])
  289. const ownerAllowed = course.user_id === userId(req) && (!course.organization_id ||
  290. (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [course.organization_id, userId(req)])).rows[0])
  291. if (!member.rows[0] && !ownerAllowed) return reply.code(403).send({ message: '无权访问该文件' })
  292. }
  293. return reply.sendFile(stored, uploadDir)
  294. })
  295. const officePreviewExtensions = new Set(['.pptx', '.ppt', '.docx', '.doc'])
  296. const previewJobs = new Map<string, Promise<string>>()
  297. const previewCacheVersion = 'v2'
  298. const bundledSoffice = join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice')
  299. const sofficeCommand = process.env.SOFFICE_PATH || (existsSync(bundledSoffice) ? bundledSoffice : 'soffice')
  300. async function officePreview(stored: string) {
  301. const previewDir = resolve(uploadDir, '.previews')
  302. const destination = resolve(previewDir, `${stored}.${previewCacheVersion}.pdf`)
  303. if (await stat(destination).then((s) => s.size > 0).catch(() => false)) return destination
  304. const existing = previewJobs.get(stored)
  305. if (existing) return existing
  306. const job = (async () => {
  307. await mkdir(previewDir, { recursive: true })
  308. const workDir = await mkdtemp(join(tmpdir(), 'xinghen-preview-'))
  309. const staging = resolve(previewDir, stored + '.' + randomUUID() + '.tmp')
  310. try {
  311. const input = join(workDir, basename(stored))
  312. await copyFile(resolve(uploadDir, stored), input)
  313. let conversionEnv = process.env
  314. if (process.platform === 'darwin' && !process.env.FONTCONFIG_FILE) {
  315. const fontConfig = join(workDir, 'fonts.conf')
  316. await writeFile(fontConfig, `<?xml version="1.0"?>
  317. <!DOCTYPE fontconfig SYSTEM "fonts.dtd">
  318. <fontconfig>
  319. <dir>/System/Library/Fonts</dir><dir>/System/Library/Fonts/Supplemental</dir><dir>/Library/Fonts</dir>
  320. <cachedir>${join(workDir, 'font-cache')}</cachedir>
  321. <match target="pattern"><test name="family" compare="eq"><string>微软雅黑</string></test><edit name="family" mode="assign"><string>Hiragino Sans GB</string></edit></match>
  322. </fontconfig>`)
  323. conversionEnv = { ...process.env, FONTCONFIG_FILE: fontConfig }
  324. }
  325. await execFileAsync(sofficeCommand, [
  326. '-env:UserInstallation=' + new URL('profile/', `file://${workDir}/`).href,
  327. '--headless', '--convert-to', 'pdf', '--outdir', workDir, input
  328. ], { timeout: 120_000, maxBuffer: 1024 * 1024, env: conversionEnv })
  329. const converted = join(workDir, basename(stored, extname(stored)) + '.pdf')
  330. if (!(await stat(converted).then((s) => s.size > 0).catch(() => false))) throw new Error('文档转换没有生成预览文件')
  331. await copyFile(converted, staging)
  332. await rename(staging, destination)
  333. return destination
  334. } finally {
  335. await unlink(staging).catch(() => {})
  336. await rm(workDir, { recursive: true, force: true })
  337. }
  338. })()
  339. previewJobs.set(stored, job)
  340. try { return await job } finally { previewJobs.delete(stored) }
  341. }
  342. app.get('/api/courses/:id/assets/:assetId/preview', { preHandler: accountAuth }, async (req, reply) => {
  343. const { id, assetId } = req.params as { id: string; assetId: string }
  344. const { rows } = await pool.query(`SELECT a.stored_name, a.original_name, c.user_id, c.organization_id
  345. FROM course_assets a JOIN courses c ON c.id=a.course_id WHERE a.id=$1 AND a.course_id=$2`, [assetId, id])
  346. const asset = rows[0]
  347. if (!asset) return reply.code(404).send({ message: '素材不存在' })
  348. if ((req as any).user.role !== 'ADMIN') {
  349. const membership = asset.organization_id ? await pool.query(
  350. 'SELECT role FROM organization_members WHERE organization_id=$1 AND user_id=$2',
  351. [asset.organization_id, userId(req)]
  352. ) : null
  353. const member = membership?.rows[0]
  354. const allowed = asset.organization_id
  355. ? !!member && (member.role === 'MANAGER' || asset.user_id === userId(req))
  356. : asset.user_id === userId(req)
  357. if (!allowed) return reply.code(403).send({ message: '无权预览该素材' })
  358. }
  359. if (!officePreviewExtensions.has(extname(asset.original_name).toLowerCase())) {
  360. return reply.code(415).send({ message: '该文件无需转换预览' })
  361. }
  362. try {
  363. const path = await officePreview(asset.stored_name)
  364. return reply.type('application/pdf').header('Cache-Control', 'private, no-store').send(createReadStream(path))
  365. } catch (error) {
  366. req.log.error({ err: error, assetId }, 'office preview failed')
  367. return reply.code(503).send({ message: '预览生成失败,请下载原文件查看' })
  368. }
  369. })
  370. app.get('/api/health', async () => ({ ok: true }))
  371. // ==================== 认证相关 ====================
  372. app.post('/api/auth/register', async (req, reply) => {
  373. const p = phonePassword.extend({
  374. contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字'),
  375. inviteToken: z.string().optional()
  376. }).safeParse(req.body)
  377. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  378. if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号不可注册普通用户' })
  379. let organizationId: string | null = null
  380. if (p.data.inviteToken) {
  381. const invite = await pool.query('SELECT id FROM organizations WHERE invite_token_hash=$1 AND is_active=TRUE', [sha(p.data.inviteToken)])
  382. if (!invite.rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
  383. organizationId = invite.rows[0].id
  384. }
  385. try {
  386. const id = randomUUID()
  387. const hash = await hashPassword(p.data.password)
  388. const client = await pool.connect()
  389. let user: any
  390. try {
  391. await client.query('BEGIN')
  392. const { rows } = await client.query(
  393. 'INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance) VALUES($1, $2, $3, $4, $5, 10000) RETURNING *',
  394. [id, p.data.phone, hash, 'USER', p.data.contactName]
  395. )
  396. user = rows[0]
  397. if (organizationId) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [organizationId, id])
  398. await client.query('COMMIT')
  399. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  400. return reply.code(201).send({ token: await session(id), user: mapUser(user) })
  401. } catch (e: any) {
  402. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  403. throw e
  404. }
  405. })
  406. app.post('/api/auth/login', async (req, reply) => {
  407. const p = phonePassword.safeParse(req.body)
  408. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  409. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  410. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  411. return reply.code(401).send({ message: '手机号或密码错误' })
  412. }
  413. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  414. })
  415. app.post('/api/admin/auth/login', async (req, reply) => {
  416. const p = phonePassword.safeParse(req.body)
  417. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  418. const { rows } = await pool.query('SELECT * FROM platform_admins WHERE phone = $1', [p.data.phone])
  419. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  420. return reply.code(401).send({ message: '管理员账号或密码错误' })
  421. }
  422. return { token: await session(rows[0].id, true), user: mapUser({ ...rows[0], role: 'ADMIN' }) }
  423. })
  424. app.post('/api/auth/logout', { preHandler: accountAuth }, async (req) => {
  425. const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
  426. await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
  427. await pool.query('DELETE FROM platform_admin_sessions WHERE token_hash = $1', [sha(token)])
  428. return { ok: true }
  429. })
  430. app.get('/api/me', { preHandler: accountAuth }, async (req) => ({ user: mapUser((req as any).user) }))
  431. app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
  432. const { rows } = await pool.query(
  433. `SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
  434. FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
  435. WHERE l.owner_type='PERSONAL' AND l.owner_id=$1 ORDER BY l.created_at DESC`,
  436. [userId(req)]
  437. )
  438. return {
  439. balance: Number((req as any).user.points_balance) || 0,
  440. transactions: rows.map((row) => ({
  441. id: row.id,
  442. courseId: row.course_id,
  443. courseName: row.course_name || row.reason,
  444. points: Number(row.amount),
  445. createdAt: row.created_at
  446. }))
  447. }
  448. })
  449. app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
  450. const p = z
  451. .object({
  452. wechat: z.string().max(80),
  453. contactName: z.string().max(80),
  454. bio: z.string().max(500)
  455. })
  456. .safeParse(req.body)
  457. if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
  458. const d = p.data
  459. const { rows } = await pool.query(
  460. 'UPDATE users SET wechat = $1, contact_name = $2, bio = $3, updated_at = NOW() WHERE id = $4 RETURNING *',
  461. [d.wechat, d.contactName, d.bio, userId(req)]
  462. )
  463. return { user: mapUser(rows[0]) }
  464. })
  465. app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
  466. const p = z
  467. .object({
  468. currentPassword: z.string(),
  469. newPassword: z.string().min(6).max(72)
  470. })
  471. .safeParse(req.body)
  472. if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' })
  473. if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
  474. return reply.code(400).send({ message: '当前密码不正确' })
  475. }
  476. await pool.query('UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2', [
  477. await hashPassword(p.data.newPassword),
  478. userId(req)
  479. ])
  480. return { ok: true }
  481. })
  482. // ==================== 普通用户课程操作 ====================
  483. const courseInput = z.object({
  484. name: z.string().trim().min(1, '请输入课程名称').max(100),
  485. description: z.string().max(500).default(''),
  486. assetNotes: z.string().max(5000).default(''),
  487. digitalInstructorId: z.string().uuid().nullable().optional()
  488. })
  489. async function validDigitalInstructor(id: string, organizationId: string | null, personalUserId: string) {
  490. const row = await digitalInstructors.get(id)
  491. if (!row || row.organization_id !== organizationId || (organizationId === null && row.personal_user_id !== personalUserId)) return false
  492. return true
  493. }
  494. async function readyDigitalInstructor(id: string) {
  495. const files = await digitalInstructors.files(id)
  496. return files.some(f => f.kind === 'IMAGE') && files.some(f => f.kind === 'AUDIO')
  497. }
  498. app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
  499. app.get('/api/courses', { preHandler: auth }, async (req, reply) => {
  500. const workspace = req.headers['x-workspace-id'] as string | undefined
  501. if (workspace && workspace !== 'personal') {
  502. const member = await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [workspace, userId(req)])
  503. if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
  504. }
  505. const organizationWorkspace = workspace && workspace !== 'personal'
  506. const { rows } = organizationWorkspace
  507. ? await pool.query('SELECT * FROM courses WHERE organization_id=$1 AND user_id=$2 ORDER BY created_at DESC', [workspace, userId(req)])
  508. : await pool.query('SELECT * FROM courses WHERE organization_id IS NULL AND user_id=$1 ORDER BY created_at DESC', [userId(req)])
  509. return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
  510. })
  511. app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
  512. const p = courseInput.safeParse(req.body)
  513. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
  514. const id = randomUUID()
  515. const d = p.data
  516. const workspace = req.headers['x-workspace-id'] as string | undefined
  517. const organizationId = workspace && workspace !== 'personal' ? workspace : null
  518. if (organizationId) {
  519. const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
  520. WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
  521. if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
  522. }
  523. if (d.digitalInstructorId && !await validDigitalInstructor(d.digitalInstructorId, organizationId, userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
  524. const { rows } = await pool.query(
  525. 'INSERT INTO courses(id, user_id, organization_id, name, description, digital_instructor_id) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
  526. [id, userId(req), organizationId, d.name, d.description, d.digitalInstructorId || null]
  527. )
  528. // 课程创建后默认创建第 1 集内容,确保分集不为空
  529. const defaultEpId = randomUUID()
  530. const defaultTitle = '第 1 集:' + d.name
  531. await pool.query(
  532. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5)',
  533. [defaultEpId, id, defaultTitle, '', '']
  534. )
  535. await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
  536. await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
  537. VALUES($1,$2,$3,$4,$4,'COURSE_CREATED',$5)`, [randomUUID(), organizationId, id, userId(req), JSON.stringify({ name: d.name })])
  538. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  539. })
  540. app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
  541. const p = z.object({
  542. templateId: z.string().default('party-building-standard')
  543. }).safeParse(req.body || {})
  544. if (!p.success) return reply.code(400).send({ message: '模板参数不正确' })
  545. const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
  546. const courseId = randomUUID()
  547. const workspace = req.headers['x-workspace-id'] as string | undefined
  548. const organizationId = workspace && workspace !== 'personal' ? workspace : null
  549. if (organizationId) {
  550. const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
  551. WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
  552. if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
  553. }
  554. const { rows } = await pool.query(
  555. 'INSERT INTO courses(id, user_id, organization_id, name, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
  556. [courseId, userId(req), organizationId, template.name, template.description]
  557. )
  558. await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
  559. await refreshCoursePoints(courseId)
  560. await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
  561. await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
  562. VALUES($1,$2,$3,$4,$4,'COURSE_CREATED_FROM_TEMPLATE',$5)`, [randomUUID(), organizationId, courseId, userId(req), JSON.stringify({ templateId: template.id })])
  563. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  564. })
  565. app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  566. const { id } = req.params as any
  567. const row = await ownedCourse(id, userId(req))
  568. if (!row) return reply.code(404).send({ message: '课程不存在' })
  569. return { course: await fullCourse(row) }
  570. })
  571. app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  572. const { id } = req.params as any
  573. const row = await ownedCourse(id, userId(req))
  574. if (!row) return reply.code(404).send({ message: '课程不存在' })
  575. const p = z.object({
  576. name: z.string().trim().min(1, '请输入课程名称').max(100).optional(),
  577. description: z.string().max(500).optional(),
  578. assetNotes: z.string().max(5000).optional(),
  579. digitalInstructorId: z.string().uuid().nullable().optional()
  580. }).safeParse(req.body)
  581. if (!p.success) {
  582. return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
  583. }
  584. if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
  585. if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
  586. const newName = p.data.name !== undefined ? p.data.name : row.name
  587. const newDesc = p.data.description !== undefined ? p.data.description : row.description
  588. const newAssetNotes = p.data.assetNotes !== undefined ? p.data.assetNotes : (row.asset_notes || '')
  589. const newDigitalInstructorId = p.data.digitalInstructorId !== undefined ? (p.data.digitalInstructorId || null) : row.digital_instructor_id
  590. const { rows } = await pool.query(
  591. 'UPDATE courses SET name = $1, description = $2, asset_notes = $3, digital_instructor_id = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
  592. [newName, newDesc, newAssetNotes, newDigitalInstructorId, id]
  593. )
  594. await refreshCoursePoints(id)
  595. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、需求说明或通用资产备注已更新。')
  596. return { course: await fullCourse(rows[0]) }
  597. })
  598. app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req, reply) => {
  599. const { id } = req.params as any
  600. const row = await ownedCourse(id, userId(req))
  601. if (!row) return reply.code(404).send({ message: '课程不存在' })
  602. if (row.status !== 'WAITING_PRODUCTION') {
  603. return reply.code(409).send({ message: '只有等待制作的课程可以恢复为草稿' })
  604. }
  605. const { rows } = await pool.query(
  606. "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 RETURNING *",
  607. [id]
  608. )
  609. await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
  610. return { course: await fullCourse(rows[0]) }
  611. })
  612. app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  613. const { id } = req.params as any
  614. const row = await ownedCourse(id, userId(req))
  615. if (!row) return reply.code(404).send({ message: '课程不存在' })
  616. // 已经等待制作、制作中或者制作完成的课程不能删除
  617. const manager = row.organization_id && (await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
  618. [row.organization_id, userId(req)])).rows[0]
  619. if (!manager && (row.points_charged || row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED')) {
  620. return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
  621. }
  622. // 1. 清理该课程关联的所有素材文件
  623. const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
  624. for (const ast of assetsRes.rows) {
  625. if (ast.stored_name) {
  626. await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  627. await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
  628. await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  629. }
  630. }
  631. // 2. 清理讲师头像图片
  632. const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
  633. const instImagesRes = await pool.query(
  634. 'SELECT ii.stored_name FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id WHERE i.course_id = $1',
  635. [id]
  636. )
  637. const instructorStoredNames = new Set(instImagesRes.rows.map((image) => image.stored_name))
  638. for (const ins of instRes.rows) {
  639. if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
  640. }
  641. for (const storedName of instructorStoredNames) {
  642. const shared = await pool.query('SELECT 1 FROM digital_instructor_files WHERE stored_name=$1', [storedName])
  643. if (!shared.rows[0]) await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
  644. }
  645. // 3. 清理交付成品文件
  646. const delivRes = await pool.query('SELECT stored_name FROM course_deliverables WHERE course_id = $1', [id])
  647. for (const del of delivRes.rows) {
  648. if (del.stored_name) await unlink(resolve(uploadDir, del.stored_name)).catch(() => {})
  649. }
  650. // 4. 清理 PPT 文件
  651. if (row.ppt_stored_name) {
  652. await unlink(resolve(uploadDir, row.ppt_stored_name)).catch(() => {})
  653. }
  654. // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
  655. await pool.query('DELETE FROM courses WHERE id = $1', [id])
  656. return { ok: true, message: '课程已成功删除' }
  657. })
  658. app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
  659. const { id } = req.params as any
  660. const row = await ownedCourse(id, userId(req))
  661. if (!row) return reply.code(404).send({ message: '课程不存在' })
  662. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
  663. let episodeId: string | null = (req.query as any)?.episodeId || null
  664. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  665. for await (const part of req.parts()) {
  666. if (part.type === 'file') {
  667. const ext = extname(part.filename).slice(0, 16)
  668. const stored = id + '-' + randomUUID() + ext
  669. const target = resolve(uploadDir, stored)
  670. await pipeline(part.file, createWriteStream(target))
  671. if (part.file.truncated) {
  672. await unlink(target).catch(() => {})
  673. return reply.code(413).send({ message: '文件超过大小限制' })
  674. }
  675. fileList.push({
  676. filename: part.filename,
  677. stored,
  678. bytes: part.file.bytesRead,
  679. mimetype: part.mimetype
  680. })
  681. } else if (part.fieldname === 'episodeId' && part.value) {
  682. episodeId = String(part.value)
  683. }
  684. }
  685. if (episodeId) {
  686. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  687. if (!epCheck.rows[0]) episodeId = null
  688. }
  689. if (!fileList.length) return reply.code(400).send({ message: '请选择课程资产文件' })
  690. for (const f of fileList) {
  691. await pool.query(
  692. 'INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  693. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  694. )
  695. }
  696. let notesImported = 0
  697. const pptxFiles = fileList.filter(f => extname(f.filename).toLowerCase() === '.pptx')
  698. if (pptxFiles.length === 1) {
  699. const episodes = await pool.query('SELECT id,lecture_notes FROM episodes WHERE course_id=$1 ORDER BY episode_number,created_at', [id])
  700. const target = episodeId ? episodes.rows.find(ep => ep.id === episodeId) : episodes.rows.length === 1 ? episodes.rows[0] : null
  701. if (target && !target.lecture_notes.trim()) {
  702. try {
  703. const notes = (await extractPptxSpeakerNotes(resolve(uploadDir, pptxFiles[0].stored)))
  704. .map((note, index) => note ? `第 ${index + 1} 页\n${note}` : '')
  705. .filter(Boolean)
  706. if (notes.length) {
  707. const combined = notes.join('\n\n')
  708. const updated = await pool.query("UPDATE episodes SET lecture_notes=$1,updated_at=NOW() WHERE id=$2 AND course_id=$3 AND TRIM(lecture_notes)='' RETURNING id", [combined, target.id, id])
  709. if (updated.rows[0]) notesImported = notes.length
  710. }
  711. } catch (error) {
  712. req.log.warn({ err: error }, 'pptx speaker notes extraction failed')
  713. }
  714. }
  715. }
  716. await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
  717. return { course: await fullCourse(row), notesImported }
  718. })
  719. app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
  720. const { id, assetId } = req.params as any
  721. const row = await ownedCourse(id, userId(req))
  722. if (!row) return reply.code(404).send({ message: '课程不存在' })
  723. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' })
  724. const { rows } = await pool.query(
  725. 'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  726. [assetId, id]
  727. )
  728. if (rows[0]) {
  729. await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  730. await unlink(resolve(uploadDir, '.previews', rows[0].stored_name + '.pdf')).catch(() => {})
  731. await unlink(resolve(uploadDir, '.previews', `${rows[0].stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  732. }
  733. await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
  734. return { course: await fullCourse(row) }
  735. })
  736. // ==================== 课程分集操作 (Episodes) ====================
  737. // 1. 创建单集
  738. app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) => {
  739. const { id } = req.params as any
  740. const row = await ownedCourse(id, userId(req))
  741. if (!row) return reply.code(404).send({ message: '课程不存在' })
  742. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  743. const p = z.object({
  744. title: z.string().trim().min(1, '请输入分集标题').max(200),
  745. episodeNumber: z.number().int().min(1).optional(),
  746. summary: z.string().max(500).default(''),
  747. lectureNotes: z.string().default('')
  748. }).safeParse(req.body)
  749. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '分集信息填写不完整' })
  750. let epNumber = p.data.episodeNumber
  751. if (!epNumber) {
  752. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  753. epNumber = (Number(maxRes.rows[0].max_num) || 0) + 1
  754. }
  755. const epId = randomUUID()
  756. await pool.query(
  757. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  758. [epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
  759. )
  760. await refreshCoursePoints(id)
  761. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '新增课程分集', '课程结构已更新。')
  762. return reply.code(201).send({ course: await fullCourse(row) })
  763. })
  764. // 2. 批量创建分集(如快速生成 12 集)
  765. app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, reply) => {
  766. const { id } = req.params as any
  767. const row = await ownedCourse(id, userId(req))
  768. if (!row) return reply.code(404).send({ message: '课程不存在' })
  769. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  770. const p = z.object({
  771. count: z.number().int().min(1).max(100).optional(),
  772. episodes: z.array(z.object({
  773. title: z.string().trim().min(1).max(200),
  774. episodeNumber: z.number().int().min(1).optional(),
  775. summary: z.string().max(500).default(''),
  776. lectureNotes: z.string().default('')
  777. })).optional()
  778. }).safeParse(req.body)
  779. if (!p.success) return reply.code(400).send({ message: '批量创建参数不正确' })
  780. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  781. let currentMax = Number(maxRes.rows[0].max_num) || 0
  782. if (p.data.episodes && p.data.episodes.length > 0) {
  783. for (const ep of p.data.episodes) {
  784. currentMax++
  785. await pool.query(
  786. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  787. [randomUUID(), id, ep.episodeNumber || currentMax, ep.title, ep.summary, ep.lectureNotes]
  788. )
  789. }
  790. } else if (p.data.count) {
  791. for (let i = 1; i <= p.data.count; i++) {
  792. currentMax++
  793. const title = `第 ${currentMax} 集:课程知识点精讲`
  794. await pool.query(
  795. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  796. [randomUUID(), id, currentMax, title, '', '']
  797. )
  798. }
  799. } else {
  800. return reply.code(400).send({ message: '请指定集数或分集列表' })
  801. }
  802. await refreshCoursePoints(id)
  803. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '批量新增课程分集', '课程结构已更新。')
  804. return reply.code(201).send({ course: await fullCourse(row) })
  805. })
  806. // 3. 更新分集信息(标题、序号、讲稿文本等)
  807. app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  808. const { id, episodeId } = req.params as any
  809. const row = await ownedCourse(id, userId(req))
  810. if (!row) return reply.code(404).send({ message: '课程不存在' })
  811. if (!['DRAFT', 'WAITING_PRODUCTION'].includes(row.status)) {
  812. return reply.code(409).send({ message: '课程已进入制作或完成归档,无法修改分集' })
  813. }
  814. const p = z.object({
  815. title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
  816. episodeNumber: z.number().int().min(1).optional(),
  817. summary: z.string().max(500).optional(),
  818. lectureNotes: z.string().optional()
  819. }).safeParse(req.body)
  820. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '参数错误' })
  821. const existing = await pool.query('SELECT * FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  822. if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
  823. const cur = existing.rows[0]
  824. const newTitle = p.data.title !== undefined ? p.data.title : cur.title
  825. const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
  826. const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
  827. const richPrefix = '<!--xinghen-richtext-->'
  828. const submittedNotes = p.data.lectureNotes
  829. const newNotes = submittedNotes === undefined ? cur.lecture_notes : submittedNotes.startsWith(richPrefix)
  830. ? richPrefix + sanitizeHtml(submittedNotes.slice(richPrefix.length), {
  831. allowedTags: ['p', 'div', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li'],
  832. allowedAttributes: {}
  833. })
  834. : submittedNotes
  835. await pool.query(
  836. 'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
  837. [newTitle, newNumber, newSummary, newNotes, episodeId, id]
  838. )
  839. await refreshCoursePoints(id)
  840. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程内容已修改', '分集内容已更新。')
  841. return { course: await fullCourse(row) }
  842. })
  843. // 4. 删除分集及其关联的物理素材文件
  844. app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  845. const { id, episodeId } = req.params as any
  846. const row = await ownedCourse(id, userId(req))
  847. if (!row) return reply.code(404).send({ message: '课程不存在' })
  848. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除分集' })
  849. // 课程至少需要保留一个分集,不可删除唯一分集
  850. const countRes = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [id])
  851. if ((countRes.rows[0]?.count || 0) <= 1) {
  852. return reply.code(400).send({ message: '课程至少需要保留一个分集,不可删除唯一分集' })
  853. }
  854. // 查出该集下所有素材文件并清理磁盘
  855. const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
  856. for (const ast of astRes.rows) {
  857. await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  858. await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
  859. await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  860. }
  861. await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  862. await refreshCoursePoints(id)
  863. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '删除课程分集', '课程结构已更新。')
  864. return { course: await fullCourse(row) }
  865. })
  866. app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
  867. const { id } = req.params as any
  868. const row = await ownedCourse(id, userId(req))
  869. if (!row) return reply.code(404).send({ message: '课程不存在' })
  870. if (!row.digital_instructor_id || !await validDigitalInstructor(row.digital_instructor_id,row.organization_id,userId(req)) || !await readyDigitalInstructor(row.digital_instructor_id)) return reply.code(409).send({ message: '提交前请选择讲师,并补齐照片和录音' })
  871. const snapshot = await digitalInstructors.dto(await digitalInstructors.get(row.digital_instructor_id))
  872. const estimate = await refreshCoursePoints(id)
  873. const balance = row.organization_id
  874. ? Number((await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.points_balance) || 0
  875. : Number((req as any).user.points_balance) || 0
  876. const estimatedPoints = estimate.points
  877. if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
  878. const { rows } = await pool.query(
  879. "UPDATE courses SET status = 'WAITING_PRODUCTION', digital_instructor_snapshot=$2, submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'DRAFT' AND digital_instructor_id=$3 RETURNING *",
  880. [id,JSON.stringify(snapshot),row.digital_instructor_id]
  881. )
  882. if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
  883. await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
  884. return { course: await fullCourse(rows[0]) }
  885. })
  886. // ==================== 管理员专属 ADMIN API ====================
  887. // 1. 统计概览数据
  888. app.get('/api/admin/stats', { preHandler: adminAuth }, async () => {
  889. const [uRes, cRes, sRes] = await Promise.all([
  890. pool.query('SELECT COUNT(*)::int AS count FROM users'),
  891. pool.query('SELECT COUNT(*)::int AS count FROM courses'),
  892. pool.query(`
  893. SELECT
  894. COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count,
  895. COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count,
  896. COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count,
  897. COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count
  898. FROM courses
  899. `)
  900. ])
  901. return {
  902. totalUsers: uRes.rows[0].count,
  903. totalCourses: cRes.rows[0].count,
  904. waitingCount: sRes.rows[0].waiting_count || 0,
  905. inProductionCount: sRes.rows[0].in_production_count || 0,
  906. completedCount: sRes.rows[0].completed_count || 0,
  907. draftCount: sRes.rows[0].draft_count || 0
  908. }
  909. })
  910. // 2. 获取全量课程列表(支持状态筛选与搜索)
  911. app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
  912. const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string }
  913. const params: any[] = []
  914. const conditions: string[] = []
  915. if (query.status && query.status !== 'ALL') {
  916. params.push(query.status)
  917. conditions.push(`c.status = $${params.length}`)
  918. }
  919. if (query.keyword && query.keyword.trim()) {
  920. params.push(`%${query.keyword.trim()}%`)
  921. const idx = params.length
  922. conditions.push(`(c.name ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx})`)
  923. }
  924. const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
  925. const sql = `
  926. SELECT
  927. c.*,
  928. u.phone AS user_phone,
  929. u.role AS user_role,
  930. u.wechat AS user_wechat,
  931. u.contact_name AS user_contact_name,
  932. u.bio AS user_bio,
  933. u.created_at AS user_created_at
  934. FROM courses c
  935. LEFT JOIN users u ON u.id = c.user_id
  936. ${whereClause}
  937. ORDER BY
  938. CASE
  939. WHEN c.status = 'WAITING_PRODUCTION' THEN 1
  940. WHEN c.status = 'IN_PRODUCTION' THEN 2
  941. WHEN c.status = 'DRAFT' THEN 3
  942. WHEN c.status = 'COMPLETED' THEN 4
  943. ELSE 5
  944. END,
  945. c.submitted_at DESC NULLS LAST,
  946. c.created_at DESC
  947. `
  948. const { rows } = await pool.query(sql, params)
  949. const courses = await Promise.all(rows.map((r) => fullCourse(r, true)))
  950. return { courses, total: courses.length }
  951. })
  952. // 3. 获取特定课程完整详情(供制作工作台使用)
  953. app.get('/api/admin/courses/:id', { preHandler: courseOperator }, async (req, reply) => {
  954. const { id } = req.params as any
  955. const sql = `
  956. SELECT
  957. c.*,
  958. u.phone AS user_phone,
  959. u.role AS user_role,
  960. u.wechat AS user_wechat,
  961. u.contact_name AS user_contact_name,
  962. u.bio AS user_bio,
  963. u.created_at AS user_created_at
  964. FROM courses c
  965. LEFT JOIN users u ON u.id = c.user_id
  966. WHERE c.id = $1
  967. `
  968. const { rows } = await pool.query(sql, [id])
  969. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  970. const course = await fullCourse(rows[0], true)
  971. return { course }
  972. })
  973. // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
  974. app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
  975. const { id } = req.params as any
  976. const p = z.object({
  977. status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
  978. }).safeParse(req.body)
  979. if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
  980. if (p.data.status === 'COMPLETED') return reply.code(400).send({ message: '请通过“确认交付”完成课程并结算积分' })
  981. const { rows } = await pool.query(
  982. 'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  983. [p.data.status, id]
  984. )
  985. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  986. const statusNames: Record<string, string> = { DRAFT: '草稿', WAITING_PRODUCTION: '等待制作', IN_PRODUCTION: '正在制作', COMPLETED: '已完成', REJECTED: '已驳回' }
  987. await addProgressEvent(id, 'ADMIN', 'STATUS_CHANGED', '制作状态已更新', `管理员将课程状态更新为“${statusNames[p.data.status]}”。`, { status: p.data.status })
  988. return { course: await fullCourse(rows[0], true) }
  989. })
  990. app.patch('/api/admin/courses/:id/points', { preHandler: courseOperator }, async (req, reply) => {
  991. const { id } = req.params as any
  992. const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
  993. if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
  994. const previous = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  995. if (!previous.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  996. if (previous.rows[0].points_charged) return reply.code(409).send({ message: '课程已完成扣费,不能再修改积分' })
  997. const { rows } = await pool.query('UPDATE courses SET actual_points = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.points, id])
  998. const oldPoints = previous.rows[0].actual_points ?? previous.rows[0].estimated_points
  999. await addProgressEvent(id, 'ADMIN', 'POINTS_UPDATED', '积分消耗已调整', `管理员将制作消耗从 ${oldPoints} 积分调整为 ${p.data.points} 积分。${p.data.reason}`, { previousPoints: Number(oldPoints), points: p.data.points })
  1000. return { course: await fullCourse(rows[0], true) }
  1001. })
  1002. // 5. 管理员上传制作交付成品文件
  1003. app.post('/api/admin/courses/:id/deliverables', { preHandler: courseOperator }, async (req, reply) => {
  1004. const { id } = req.params as any
  1005. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  1006. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  1007. let episodeId: string | null = (req.query as any)?.episodeId || null
  1008. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  1009. for await (const part of req.parts()) {
  1010. if (part.type === 'file') {
  1011. const ext = extname(part.filename).slice(0, 16)
  1012. const stored = 'deliverable-' + id + '-' + randomUUID() + ext
  1013. const target = resolve(uploadDir, stored)
  1014. await pipeline(part.file, createWriteStream(target))
  1015. if (part.file.truncated) {
  1016. await unlink(target).catch(() => {})
  1017. return reply.code(413).send({ message: '文件超过大小限制' })
  1018. }
  1019. fileList.push({
  1020. filename: part.filename,
  1021. stored,
  1022. bytes: part.file.bytesRead,
  1023. mimetype: part.mimetype
  1024. })
  1025. } else if (part.fieldname === 'episodeId' && part.value) {
  1026. episodeId = String(part.value)
  1027. }
  1028. }
  1029. if (episodeId) {
  1030. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  1031. if (!epCheck.rows[0]) episodeId = null
  1032. }
  1033. if (!fileList.length) return reply.code(400).send({ message: '请选择交付成果文件' })
  1034. for (const f of fileList) {
  1035. await pool.query(
  1036. 'INSERT INTO course_deliverables(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  1037. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  1038. )
  1039. }
  1040. await addProgressEvent(id, 'ADMIN', 'DELIVERABLE_UPLOADED', '管理员上传了制作成果', `新增 ${fileList.length} 个交付文件。`)
  1041. return { course: await fullCourse(check.rows[0], true) }
  1042. })
  1043. // 6. 删除已上传的交付成果文件
  1044. app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: courseOperator }, async (req, reply) => {
  1045. const { id, delivId } = req.params as any
  1046. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  1047. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  1048. const { rows } = await pool.query(
  1049. 'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  1050. [delivId, id]
  1051. )
  1052. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  1053. return { course: await fullCourse(check.rows[0], true) }
  1054. })
  1055. // 7. 提交制作成果,结单交付(流转为 COMPLETED)
  1056. app.post('/api/admin/courses/:id/complete', { preHandler: courseOperator }, async (req, reply) => {
  1057. const { id } = req.params as any
  1058. const p = z.object({
  1059. productionNotes: z.string().max(2000).default('')
  1060. }).safeParse(req.body || {})
  1061. if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
  1062. const client = await pool.connect()
  1063. let rows: any[] = []
  1064. try {
  1065. await client.query('BEGIN')
  1066. const current = await client.query('SELECT * FROM courses WHERE id = $1 FOR UPDATE', [id])
  1067. if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
  1068. const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
  1069. if (!current.rows[0].points_charged) {
  1070. const organizationId = current.rows[0].organization_id
  1071. const charged = organizationId
  1072. ? await client.query('UPDATE organizations SET points_balance=points_balance-$1,updated_at=NOW() WHERE id=$2 AND points_balance >= $1 RETURNING points_balance', [cost, organizationId])
  1073. : 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])
  1074. if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `工作区积分不足,完成制作需要扣除 ${cost} 积分` }) }
  1075. await client.query(`INSERT INTO points_ledger(id,owner_type,owner_id,course_id,course_name_snapshot,beneficiary_user_id,admin_actor_id,amount,reason)
  1076. VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [randomUUID(), organizationId ? 'ORGANIZATION' : 'PERSONAL', organizationId || current.rows[0].user_id,
  1077. id, current.rows[0].name, current.rows[0].user_id, (req as any).user.role === 'ADMIN' ? userId(req) : null, -cost, '课程完成扣费'])
  1078. }
  1079. 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])
  1080. rows = updated.rows
  1081. await client.query('COMMIT')
  1082. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  1083. const cost = Number(rows[0].actual_points ?? rows[0].estimated_points) || 100
  1084. await addProgressEvent(id, 'ADMIN', 'COMPLETED', '课程制作完成', `课程已交付,实际扣除 ${cost} 积分。`, { points: cost })
  1085. return { course: await fullCourse(rows[0], true) }
  1086. })
  1087. // 8. 获取全平台用户列表及统计
  1088. app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
  1089. const query = (req.query || {}) as { keyword?: string }
  1090. const params: any[] = []
  1091. let where = ''
  1092. if (query.keyword && query.keyword.trim()) {
  1093. params.push(`%${query.keyword.trim()}%`)
  1094. where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.wechat ILIKE $1)'
  1095. }
  1096. const sql = `
  1097. SELECT
  1098. u.*,
  1099. (SELECT string_agg(o.name, '、') FROM organization_members m JOIN organizations o ON o.id=m.organization_id WHERE m.user_id=u.id) AS organization_names,
  1100. COUNT(c.id)::int AS courses_count,
  1101. COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
  1102. COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
  1103. COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count
  1104. FROM users u
  1105. LEFT JOIN courses c ON c.user_id = u.id
  1106. ${where}
  1107. GROUP BY u.id
  1108. ORDER BY u.created_at DESC
  1109. `
  1110. const { rows } = await pool.query(sql, params)
  1111. const users = rows.map((r) => ({
  1112. ...mapUser(r),
  1113. organization: r.organization_names || '',
  1114. coursesCount: r.courses_count || 0,
  1115. waitingCoursesCount: r.waiting_courses_count || 0,
  1116. inProductionCoursesCount: r.in_production_courses_count || 0,
  1117. completedCoursesCount: r.completed_courses_count || 0
  1118. }))
  1119. return { users }
  1120. })
  1121. // 9. 管理员创建普通用户
  1122. app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
  1123. const p = z.object({
  1124. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  1125. password: z.string().min(6, '密码至少 6 位').max(72),
  1126. contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default('')
  1127. }).safeParse(req.body)
  1128. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  1129. if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号属于平台管理员' })
  1130. try {
  1131. const id = randomUUID()
  1132. const passwordHash = await hashPassword(p.data.password)
  1133. const { rows } = await pool.query(
  1134. `INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance)
  1135. VALUES($1, $2, $3, 'USER', $4, 10000) RETURNING *`,
  1136. [id, p.data.phone, passwordHash, p.data.contactName]
  1137. )
  1138. return reply.code(201).send({ user: mapUser(rows[0]) })
  1139. } catch (e: any) {
  1140. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  1141. throw e
  1142. }
  1143. })
  1144. // 10. 管理员重置用户密码;同时注销该用户已有会话
  1145. app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req, reply) => {
  1146. const { id } = req.params as any
  1147. const p = z.object({
  1148. password: z.string().min(6, '密码至少 6 位').max(72)
  1149. }).safeParse(req.body)
  1150. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  1151. const client = await pool.connect()
  1152. try {
  1153. await client.query('BEGIN')
  1154. const { rowCount } = await client.query(
  1155. 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
  1156. [await hashPassword(p.data.password), id]
  1157. )
  1158. if (!rowCount) {
  1159. await client.query('ROLLBACK')
  1160. return reply.code(404).send({ message: '用户不存在' })
  1161. }
  1162. await client.query('DELETE FROM sessions WHERE user_id = $1', [id])
  1163. await client.query('COMMIT')
  1164. return { ok: true }
  1165. } catch (error) {
  1166. await client.query('ROLLBACK')
  1167. throw error
  1168. } finally {
  1169. client.release()
  1170. }
  1171. })
  1172. // 平台管理员与普通用户永不互转。
  1173. app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
  1174. return reply.code(403).send({ message: '平台管理员与普通用户不能互相转化' })
  1175. })
  1176. // ==================== 静态托管与错误处理 ====================
  1177. if (process.env.NODE_ENV === 'production') {
  1178. const dist = resolve('./dist')
  1179. await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false, decorateReply: false })
  1180. app.setNotFoundHandler((req, reply) =>
  1181. req.url.startsWith('/api/') || req.url.startsWith('/uploads/')
  1182. ? reply.code(404).send({ message: '接口或文件不存在' })
  1183. : reply.sendFile('index.html', dist)
  1184. )
  1185. }
  1186. app.setErrorHandler((error: FastifyError, _req, reply) => {
  1187. app.log.error(error)
  1188. const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
  1189. reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message })
  1190. })
  1191. await app.listen({ port, host: process.env.HOST || '0.0.0.0' })