index.ts 88 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692
  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. }
  111. async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
  112. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  113. if (!token) return reply.code(401).send({ message: '请先登录管理后台' })
  114. 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)])
  115. if (!rows[0]) return reply.code(401).send({ message: '管理员登录已过期' })
  116. ;(req as any).user = { ...rows[0], role: 'ADMIN' }
  117. }
  118. async function accountAuth(req: FastifyRequest, reply: FastifyReply) {
  119. const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  120. if (token) {
  121. 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)])
  122. if (rows[0]) { (req as any).user = { ...rows[0], role: 'ADMIN' }; return }
  123. }
  124. return auth(req, reply)
  125. }
  126. const userId = (r: any) => r.user.id
  127. const phonePassword = z.object({
  128. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  129. password: z.string().min(6, '密码至少 6 位').max(72)
  130. })
  131. const fullCourse = async (row: any, includeCreator = false) => {
  132. const latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
  133. row = { ...row, ...(latest.rows[0] || {}) }
  134. if (row.organization_id) {
  135. const organization = await pool.query('SELECT name FROM organizations WHERE id=$1', [row.organization_id])
  136. row.organization_name = organization.rows[0]?.name || ''
  137. }
  138. const [a, i, instructorImages, d, e, progress] = await Promise.all([
  139. pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
  140. pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
  141. pool.query(`SELECT ii.* FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id
  142. WHERE i.course_id = $1 ORDER BY ii.sort_order, ii.created_at`, [row.id]),
  143. pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
  144. pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id]),
  145. pool.query('SELECT * FROM course_progress_events WHERE course_id = $1 ORDER BY created_at DESC', [row.id])
  146. ])
  147. let episodesRows = e.rows
  148. // 确保课程永远至少有 1 个分集(自愈保底)
  149. if (episodesRows.length === 0) {
  150. const defaultEpId = randomUUID()
  151. const defaultTitle = '第 1 集:' + (row.name || '核心内容讲解')
  152. const insertRes = await pool.query(
  153. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5) RETURNING *',
  154. [defaultEpId, row.id, defaultTitle, '本集核心内容与制作说明', '']
  155. )
  156. episodesRows = insertRes.rows
  157. }
  158. let creatorUser = null
  159. if (includeCreator && row.user_id) {
  160. const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
  161. creatorUser = u.rows[0] || null
  162. }
  163. let instructors = i.rows.map((instructor) => ({
  164. ...instructor,
  165. image_urls: instructorImages.rows
  166. .filter((image) => image.instructor_id === instructor.id)
  167. .map((image) => `/uploads/${image.stored_name}`)
  168. }))
  169. if (row.digital_instructor_id) {
  170. const digital = await pool.query('SELECT * FROM digital_instructors WHERE id=$1', [row.digital_instructor_id])
  171. if (digital.rows[0]) {
  172. 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])
  173. 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])
  174. instructors = [{
  175. ...digital.rows[0],
  176. image_urls: imageRows.rows.map(f => `/uploads/${f.stored_name}`),
  177. audio_urls: audioRows.rows.map(f => `/uploads/${f.stored_name}`)
  178. }]
  179. }
  180. }
  181. if (row.status !== 'DRAFT' && row.digital_instructor_snapshot) {
  182. const snapshot = row.digital_instructor_snapshot
  183. instructors = [{
  184. ...snapshot,
  185. image_urls: (snapshot.images || []).map((image: any) => image.url),
  186. audio_urls: (snapshot.audio || []).map((audio: any) => audio.url)
  187. }]
  188. }
  189. return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
  190. }
  191. const addProgressEvent = (courseId: string, actorType: 'USER' | 'ADMIN' | 'SYSTEM', eventType: string, title: string, description = '', metadata: any = {}) =>
  192. pool.query(
  193. 'INSERT INTO course_progress_events(id, course_id, actor_type, event_type, title, description, metadata) VALUES($1,$2,$3,$4,$5,$6,$7)',
  194. [randomUUID(), courseId, actorType, eventType, title, description, JSON.stringify(metadata)]
  195. )
  196. async function refreshCoursePoints(courseId: string) {
  197. const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [courseId])
  198. const episodeCount = Math.max(1, Number(rows[0]?.count) || 0)
  199. const points = episodeCount * 1000
  200. await pool.query(
  201. 'UPDATE courses SET estimated_points = $1, updated_at = NOW() WHERE id = $2',
  202. [points, courseId]
  203. )
  204. return { episodeCount, points }
  205. }
  206. const ownedCourse = async (id: string, uid: string) =>
  207. (await pool.query(`SELECT c.* FROM courses c WHERE c.id = $1 AND
  208. ((c.organization_id IS NULL AND c.user_id=$2) OR (c.organization_id IS NOT NULL AND EXISTS (
  209. SELECT 1 FROM organization_members m WHERE m.organization_id=c.organization_id AND m.user_id=$2
  210. AND (c.user_id=$2 OR m.role='MANAGER'))))`, [id, uid])).rows[0]
  211. registerOrganizations(app, { pool, auth, adminAuth, hashPassword, mapUser })
  212. const digitalInstructors = registerDigitalInstructors(app, { pool, auth: accountAuth, uploadDir })
  213. async function courseSnapshot(id: string) {
  214. const { rows: [course] } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
  215. if (!course) return null
  216. const [episodes, assets, instructors, deliverables] = await Promise.all([
  217. pool.query('SELECT * FROM episodes WHERE course_id=$1 ORDER BY episode_number', [id]),
  218. pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_assets WHERE course_id=$1 ORDER BY created_at', [id]),
  219. pool.query('SELECT id,name,organization,introduction FROM instructors WHERE course_id=$1 ORDER BY created_at', [id]),
  220. pool.query('SELECT id,episode_id,original_name,size,mime_type FROM course_deliverables WHERE course_id=$1 ORDER BY created_at', [id])
  221. ])
  222. return { course, episodes: episodes.rows, assets: assets.rows, instructors: instructors.rows, deliverables: deliverables.rows }
  223. }
  224. app.addHook('onRequest', async (req) => {
  225. if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return
  226. const match = req.url.match(/^\/api\/(?:admin\/)?courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
  227. if (!match) return
  228. ;(req as any).auditCourseId = match[1]
  229. ;(req as any).auditBefore = await courseSnapshot(match[1])
  230. })
  231. app.addHook('onSend', async (req, reply, payload) => {
  232. const id = (req as any).auditCourseId as string | undefined
  233. const before = (req as any).auditBefore
  234. if (!id || !before || reply.statusCode >= 400) return payload
  235. const authorization = req.headers.authorization?.replace(/^Bearer\s+/i, '')
  236. if (!authorization) return payload
  237. const actor = await pool.query(`SELECT s.user_id FROM sessions s WHERE s.token_hash=$1 AND s.expires_at>NOW()`, [sha(authorization)])
  238. 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]
  239. if (!actor.rows[0] && !adminActor) return payload
  240. const after = await courseSnapshot(id)
  241. await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,admin_actor_id,subject_user_id,action,details)
  242. VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), before.course.organization_id, id, actor.rows[0]?.user_id || null,
  243. adminActor?.admin_id || null, before.course.user_id, `${req.method} ${req.routeOptions.url}`, JSON.stringify({ before, after })])
  244. return payload
  245. })
  246. app.addHook('preHandler', async (req, reply) => {
  247. // 只读 GET 请求不在此处因工作区暂不匹配而强阻断,以便前端可感知课程真实工作区并自动校准对齐(后续业务层已由 ownedCourse 确保访问安全)
  248. if (req.method === 'GET') return
  249. const match = req.url.match(/^\/api\/courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
  250. if (!match) return
  251. const { rows } = await pool.query('SELECT organization_id FROM courses WHERE id=$1', [match[1]])
  252. if (!rows[0]) return
  253. const selected = req.headers['x-workspace-id']
  254. const workspace = typeof selected === 'string' && selected !== 'personal' ? selected : null
  255. if (rows[0].organization_id !== workspace) return reply.code(404).send({ message: '请切换到课程所属工作区' })
  256. })
  257. async function courseOperator(req: FastifyRequest, reply: FastifyReply) {
  258. await accountAuth(req, reply)
  259. if (reply.sent) return
  260. if ((req as any).user.role === 'ADMIN') return
  261. const id = (req.params as any).id
  262. const { rows } = await pool.query(`SELECT 1 FROM courses c JOIN organization_members m ON m.organization_id=c.organization_id
  263. WHERE c.id=$1 AND m.user_id=$2 AND m.role='MANAGER'`, [id, userId(req)])
  264. if (!rows[0]) return reply.code(403).send({ message: '没有该课程的组织管理权限' })
  265. }
  266. app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) => {
  267. const stored = (req.params as any).stored as string
  268. 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])
  269. if (digitalFile.rows[0]) {
  270. const file = digitalFile.rows[0]
  271. const permitted = (req as any).user.role === 'ADMIN' || file.personal_user_id === userId(req) || (file.organization_id && (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0])
  272. if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
  273. return reply.sendFile(stored, uploadDir)
  274. }
  275. const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
  276. EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
  277. OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
  278. OR EXISTS(SELECT 1 FROM course_instructor_files f WHERE f.course_id=c.id AND f.stored_name=$1)
  279. OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
  280. 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)
  281. LIMIT 1`, [stored])
  282. const course = rows[0]
  283. if (!course) return reply.code(404).send({ message: '文件不存在' })
  284. if ((req as any).user.role !== 'ADMIN') {
  285. const member = await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
  286. [course.organization_id, userId(req)])
  287. const ownerAllowed = course.user_id === userId(req) && (!course.organization_id ||
  288. (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [course.organization_id, userId(req)])).rows[0])
  289. if (!member.rows[0] && !ownerAllowed) return reply.code(403).send({ message: '无权访问该文件' })
  290. }
  291. return reply.sendFile(stored, uploadDir)
  292. })
  293. const officePreviewExtensions = new Set(['.pptx', '.ppt', '.docx', '.doc'])
  294. const previewJobs = new Map<string, Promise<string>>()
  295. const previewCacheVersion = 'v2'
  296. const bundledSoffice = join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice')
  297. const sofficeCommand = process.env.SOFFICE_PATH || (existsSync(bundledSoffice) ? bundledSoffice : 'soffice')
  298. async function officePreview(stored: string) {
  299. const previewDir = resolve(uploadDir, '.previews')
  300. const destination = resolve(previewDir, `${stored}.${previewCacheVersion}.pdf`)
  301. if (await stat(destination).then((s) => s.size > 0).catch(() => false)) return destination
  302. const existing = previewJobs.get(stored)
  303. if (existing) return existing
  304. const job = (async () => {
  305. await mkdir(previewDir, { recursive: true })
  306. const workDir = await mkdtemp(join(tmpdir(), 'xinghen-preview-'))
  307. const staging = resolve(previewDir, stored + '.' + randomUUID() + '.tmp')
  308. try {
  309. const input = join(workDir, basename(stored))
  310. await copyFile(resolve(uploadDir, stored), input)
  311. let conversionEnv = process.env
  312. if (process.platform === 'darwin' && !process.env.FONTCONFIG_FILE) {
  313. const fontConfig = join(workDir, 'fonts.conf')
  314. await writeFile(fontConfig, `<?xml version="1.0"?>
  315. <!DOCTYPE fontconfig SYSTEM "fonts.dtd">
  316. <fontconfig>
  317. <dir>/System/Library/Fonts</dir><dir>/System/Library/Fonts/Supplemental</dir><dir>/Library/Fonts</dir>
  318. <cachedir>${join(workDir, 'font-cache')}</cachedir>
  319. <match target="pattern"><test name="family" compare="eq"><string>微软雅黑</string></test><edit name="family" mode="assign"><string>Hiragino Sans GB</string></edit></match>
  320. </fontconfig>`)
  321. conversionEnv = { ...process.env, FONTCONFIG_FILE: fontConfig }
  322. }
  323. await execFileAsync(sofficeCommand, [
  324. '-env:UserInstallation=' + new URL('profile/', `file://${workDir}/`).href,
  325. '--headless', '--convert-to', 'pdf', '--outdir', workDir, input
  326. ], { timeout: 120_000, maxBuffer: 1024 * 1024, env: conversionEnv })
  327. const converted = join(workDir, basename(stored, extname(stored)) + '.pdf')
  328. if (!(await stat(converted).then((s) => s.size > 0).catch(() => false))) throw new Error('文档转换没有生成预览文件')
  329. await copyFile(converted, staging)
  330. await rename(staging, destination)
  331. return destination
  332. } finally {
  333. await unlink(staging).catch(() => {})
  334. await rm(workDir, { recursive: true, force: true })
  335. }
  336. })()
  337. previewJobs.set(stored, job)
  338. try { return await job } finally { previewJobs.delete(stored) }
  339. }
  340. app.get('/api/courses/:id/assets/:assetId/preview', { preHandler: accountAuth }, async (req, reply) => {
  341. const { id, assetId } = req.params as { id: string; assetId: string }
  342. const { rows } = await pool.query(`SELECT a.stored_name, a.original_name, c.user_id, c.organization_id
  343. FROM course_assets a JOIN courses c ON c.id=a.course_id WHERE a.id=$1 AND a.course_id=$2`, [assetId, id])
  344. const asset = rows[0]
  345. if (!asset) return reply.code(404).send({ message: '素材不存在' })
  346. if ((req as any).user.role !== 'ADMIN') {
  347. const membership = asset.organization_id ? await pool.query(
  348. 'SELECT role FROM organization_members WHERE organization_id=$1 AND user_id=$2',
  349. [asset.organization_id, userId(req)]
  350. ) : null
  351. const member = membership?.rows[0]
  352. const allowed = asset.organization_id
  353. ? !!member && (member.role === 'MANAGER' || asset.user_id === userId(req))
  354. : asset.user_id === userId(req)
  355. if (!allowed) return reply.code(403).send({ message: '无权预览该素材' })
  356. }
  357. if (!officePreviewExtensions.has(extname(asset.original_name).toLowerCase())) {
  358. return reply.code(415).send({ message: '该文件无需转换预览' })
  359. }
  360. try {
  361. const path = await officePreview(asset.stored_name)
  362. return reply.type('application/pdf').header('Cache-Control', 'private, no-store').send(createReadStream(path))
  363. } catch (error) {
  364. req.log.error({ err: error, assetId }, 'office preview failed')
  365. return reply.code(503).send({ message: '预览生成失败,请下载原文件查看' })
  366. }
  367. })
  368. app.get('/api/health', async () => ({ ok: true }))
  369. // ==================== 认证相关 ====================
  370. app.post('/api/auth/register', async (req, reply) => {
  371. const p = phonePassword.extend({
  372. contactName: z.string().trim().min(1, '请填写姓名').max(80, '姓名不能超过 80 个字'),
  373. inviteToken: z.string().optional()
  374. }).safeParse(req.body)
  375. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  376. if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号不可注册普通用户' })
  377. let organizationId: string | null = null
  378. if (p.data.inviteToken) {
  379. const invite = await pool.query('SELECT id FROM organizations WHERE invite_token_hash=$1 AND is_active=TRUE', [sha(p.data.inviteToken)])
  380. if (!invite.rows[0]) return reply.code(404).send({ message: '注册链接无效或已停用' })
  381. organizationId = invite.rows[0].id
  382. }
  383. try {
  384. const id = randomUUID()
  385. const hash = await hashPassword(p.data.password)
  386. const client = await pool.connect()
  387. let user: any
  388. try {
  389. await client.query('BEGIN')
  390. const { rows } = await client.query(
  391. 'INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance) VALUES($1, $2, $3, $4, $5, 10000) RETURNING *',
  392. [id, p.data.phone, hash, 'USER', p.data.contactName]
  393. )
  394. user = rows[0]
  395. if (organizationId) await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [organizationId, id])
  396. await client.query('COMMIT')
  397. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  398. return reply.code(201).send({ token: await session(id), user: mapUser(user) })
  399. } catch (e: any) {
  400. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  401. throw e
  402. }
  403. })
  404. app.post('/api/auth/login', async (req, reply) => {
  405. const p = phonePassword.safeParse(req.body)
  406. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  407. const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
  408. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  409. return reply.code(401).send({ message: '手机号或密码错误' })
  410. }
  411. return { token: await session(rows[0].id), user: mapUser(rows[0]) }
  412. })
  413. app.post('/api/admin/auth/login', async (req, reply) => {
  414. const p = phonePassword.safeParse(req.body)
  415. if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
  416. const { rows } = await pool.query('SELECT * FROM platform_admins WHERE phone = $1', [p.data.phone])
  417. if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
  418. return reply.code(401).send({ message: '管理员账号或密码错误' })
  419. }
  420. return { token: await session(rows[0].id, true), user: mapUser({ ...rows[0], role: 'ADMIN' }) }
  421. })
  422. app.post('/api/auth/logout', { preHandler: accountAuth }, async (req) => {
  423. const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
  424. await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
  425. await pool.query('DELETE FROM platform_admin_sessions WHERE token_hash = $1', [sha(token)])
  426. return { ok: true }
  427. })
  428. app.get('/api/me', { preHandler: accountAuth }, async (req) => ({ user: mapUser((req as any).user) }))
  429. app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
  430. const { rows } = await pool.query(
  431. `SELECT l.id,l.course_id,COALESCE(c.name,l.course_name_snapshot) AS course_name,l.amount,l.reason,l.created_at
  432. FROM points_ledger l LEFT JOIN courses c ON c.id=l.course_id
  433. WHERE l.owner_type='PERSONAL' AND l.owner_id=$1 ORDER BY l.created_at DESC`,
  434. [userId(req)]
  435. )
  436. return {
  437. balance: Number((req as any).user.points_balance) || 0,
  438. transactions: rows.map((row) => ({
  439. id: row.id,
  440. courseId: row.course_id,
  441. courseName: row.course_name || row.reason,
  442. points: Number(row.amount),
  443. createdAt: row.created_at
  444. }))
  445. }
  446. })
  447. app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
  448. const p = z
  449. .object({
  450. wechat: z.string().max(80),
  451. contactName: z.string().max(80),
  452. bio: z.string().max(500)
  453. })
  454. .safeParse(req.body)
  455. if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
  456. const d = p.data
  457. const { rows } = await pool.query(
  458. 'UPDATE users SET wechat = $1, contact_name = $2, bio = $3, updated_at = NOW() WHERE id = $4 RETURNING *',
  459. [d.wechat, d.contactName, d.bio, userId(req)]
  460. )
  461. return { user: mapUser(rows[0]) }
  462. })
  463. app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
  464. const p = z
  465. .object({
  466. currentPassword: z.string(),
  467. newPassword: z.string().min(6).max(72)
  468. })
  469. .safeParse(req.body)
  470. if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' })
  471. if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
  472. return reply.code(400).send({ message: '当前密码不正确' })
  473. }
  474. await pool.query('UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2', [
  475. await hashPassword(p.data.newPassword),
  476. userId(req)
  477. ])
  478. return { ok: true }
  479. })
  480. // ==================== 普通用户课程操作 ====================
  481. const courseInput = z.object({
  482. name: z.string().trim().min(1, '请输入课程名称').max(100),
  483. description: z.string().max(500).default(''),
  484. assetNotes: z.string().max(5000).default(''),
  485. digitalInstructorId: z.string().uuid().nullable().optional()
  486. })
  487. async function validDigitalInstructor(id: string, organizationId: string | null, personalUserId: string) {
  488. const row = await digitalInstructors.get(id)
  489. if (!row || row.organization_id !== organizationId || (organizationId === null && row.personal_user_id !== personalUserId)) return false
  490. return true
  491. }
  492. async function readyDigitalInstructor(id: string) {
  493. const files = await digitalInstructors.files(id)
  494. return files.some(f => f.kind === 'IMAGE') && files.some(f => f.kind === 'AUDIO')
  495. }
  496. app.get('/api/course-templates', async () => ({ templates: COURSE_TEMPLATES }))
  497. app.get('/api/courses', { preHandler: auth }, async (req, reply) => {
  498. const workspace = req.headers['x-workspace-id'] as string | undefined
  499. if (workspace && workspace !== 'personal') {
  500. const member = await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [workspace, userId(req)])
  501. if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
  502. }
  503. const organizationWorkspace = workspace && workspace !== 'personal'
  504. const { rows } = organizationWorkspace
  505. ? await pool.query('SELECT * FROM courses WHERE organization_id=$1 AND user_id=$2 ORDER BY created_at DESC', [workspace, userId(req)])
  506. : await pool.query('SELECT * FROM courses WHERE organization_id IS NULL AND user_id=$1 ORDER BY created_at DESC', [userId(req)])
  507. return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
  508. })
  509. app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
  510. const p = courseInput.safeParse(req.body)
  511. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
  512. const id = randomUUID()
  513. const d = p.data
  514. const workspace = req.headers['x-workspace-id'] as string | undefined
  515. const organizationId = workspace && workspace !== 'personal' ? workspace : null
  516. if (organizationId) {
  517. const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
  518. WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
  519. if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
  520. }
  521. if (d.digitalInstructorId && !await validDigitalInstructor(d.digitalInstructorId, organizationId, userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
  522. const { rows } = await pool.query(
  523. 'INSERT INTO courses(id, user_id, organization_id, name, description, digital_instructor_id) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
  524. [id, userId(req), organizationId, d.name, d.description, d.digitalInstructorId || null]
  525. )
  526. // 课程创建后默认创建第 1 集内容,确保分集不为空
  527. const defaultEpId = randomUUID()
  528. const defaultTitle = '第 1 集:' + d.name
  529. await pool.query(
  530. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, 1, $3, $4, $5)',
  531. [defaultEpId, id, defaultTitle, '', '']
  532. )
  533. await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
  534. await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
  535. VALUES($1,$2,$3,$4,$4,'COURSE_CREATED',$5)`, [randomUUID(), organizationId, id, userId(req), JSON.stringify({ name: d.name })])
  536. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  537. })
  538. app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
  539. const p = z.object({
  540. templateId: z.string().default('party-building-standard')
  541. }).safeParse(req.body || {})
  542. if (!p.success) return reply.code(400).send({ message: '模板参数不正确' })
  543. const template = COURSE_TEMPLATES.find((t) => t.id === p.data.templateId) || COURSE_TEMPLATES[0]
  544. const courseId = randomUUID()
  545. const workspace = req.headers['x-workspace-id'] as string | undefined
  546. const organizationId = workspace && workspace !== 'personal' ? workspace : null
  547. if (organizationId) {
  548. const member = await pool.query(`SELECT 1 FROM organization_members m JOIN organizations o ON o.id=m.organization_id
  549. WHERE m.organization_id=$1 AND m.user_id=$2 AND o.is_active=TRUE`, [organizationId, userId(req)])
  550. if (!member.rows[0]) return reply.code(403).send({ message: '您不属于该组织' })
  551. }
  552. const { rows } = await pool.query(
  553. 'INSERT INTO courses(id, user_id, organization_id, name, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
  554. [courseId, userId(req), organizationId, template.name, template.description]
  555. )
  556. await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
  557. await refreshCoursePoints(courseId)
  558. await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
  559. await pool.query(`INSERT INTO audit_events(id,organization_id,course_id,actor_id,subject_user_id,action,details)
  560. VALUES($1,$2,$3,$4,$4,'COURSE_CREATED_FROM_TEMPLATE',$5)`, [randomUUID(), organizationId, courseId, userId(req), JSON.stringify({ templateId: template.id })])
  561. return reply.code(201).send({ course: await fullCourse(rows[0]) })
  562. })
  563. app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  564. const { id } = req.params as any
  565. const row = await ownedCourse(id, userId(req))
  566. if (!row) return reply.code(404).send({ message: '课程不存在' })
  567. return { course: await fullCourse(row) }
  568. })
  569. app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  570. const { id } = req.params as any
  571. const row = await ownedCourse(id, userId(req))
  572. if (!row) return reply.code(404).send({ message: '课程不存在' })
  573. const p = z.object({
  574. name: z.string().trim().min(1, '请输入课程名称').max(100).optional(),
  575. description: z.string().max(500).optional(),
  576. assetNotes: z.string().max(5000).optional(),
  577. digitalInstructorId: z.string().uuid().nullable().optional()
  578. }).safeParse(req.body)
  579. if (!p.success) {
  580. return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
  581. }
  582. if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
  583. if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
  584. const newName = p.data.name !== undefined ? p.data.name : row.name
  585. const newDesc = p.data.description !== undefined ? p.data.description : row.description
  586. const newAssetNotes = p.data.assetNotes !== undefined ? p.data.assetNotes : (row.asset_notes || '')
  587. const newDigitalInstructorId = p.data.digitalInstructorId !== undefined ? (p.data.digitalInstructorId || null) : row.digital_instructor_id
  588. const { rows } = await pool.query(
  589. 'UPDATE courses SET name = $1, description = $2, asset_notes = $3, digital_instructor_id = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
  590. [newName, newDesc, newAssetNotes, newDigitalInstructorId, id]
  591. )
  592. await refreshCoursePoints(id)
  593. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、需求说明或通用资产备注已更新。')
  594. return { course: await fullCourse(rows[0]) }
  595. })
  596. app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req, reply) => {
  597. const { id } = req.params as any
  598. const row = await ownedCourse(id, userId(req))
  599. if (!row) return reply.code(404).send({ message: '课程不存在' })
  600. if (row.status !== 'WAITING_PRODUCTION') {
  601. return reply.code(409).send({ message: '只有等待制作的课程可以恢复为草稿' })
  602. }
  603. const { rows } = await pool.query(
  604. "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 RETURNING *",
  605. [id]
  606. )
  607. await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
  608. return { course: await fullCourse(rows[0]) }
  609. })
  610. app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
  611. const { id } = req.params as any
  612. const row = await ownedCourse(id, userId(req))
  613. if (!row) return reply.code(404).send({ message: '课程不存在' })
  614. // 已经等待制作、制作中或者制作完成的课程不能删除
  615. const manager = row.organization_id && (await pool.query(`SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2 AND role='MANAGER'`,
  616. [row.organization_id, userId(req)])).rows[0]
  617. if (!manager && (row.points_charged || row.status === 'WAITING_PRODUCTION' || row.status === 'IN_PRODUCTION' || row.status === 'COMPLETED')) {
  618. return reply.code(409).send({ message: '课程已进入制作排期或已制作完成,不可删除' })
  619. }
  620. // 1. 清理该课程关联的所有素材文件
  621. const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
  622. for (const ast of assetsRes.rows) {
  623. if (ast.stored_name) {
  624. await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  625. await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
  626. await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  627. }
  628. }
  629. // 2. 清理讲师头像图片
  630. const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
  631. const instImagesRes = await pool.query(
  632. 'SELECT ii.stored_name FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id WHERE i.course_id = $1',
  633. [id]
  634. )
  635. const instructorStoredNames = new Set(instImagesRes.rows.map((image) => image.stored_name))
  636. for (const ins of instRes.rows) {
  637. if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
  638. }
  639. for (const storedName of instructorStoredNames) {
  640. const shared = await pool.query('SELECT 1 FROM digital_instructor_files WHERE stored_name=$1', [storedName])
  641. if (!shared.rows[0]) await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
  642. }
  643. // 3. 清理交付成品文件
  644. const delivRes = await pool.query('SELECT stored_name FROM course_deliverables WHERE course_id = $1', [id])
  645. for (const del of delivRes.rows) {
  646. if (del.stored_name) await unlink(resolve(uploadDir, del.stored_name)).catch(() => {})
  647. }
  648. // 4. 清理 PPT 文件
  649. if (row.ppt_stored_name) {
  650. await unlink(resolve(uploadDir, row.ppt_stored_name)).catch(() => {})
  651. }
  652. // 5. 从数据库中删除课程(级联删除 episodes, course_assets, instructors 等)
  653. await pool.query('DELETE FROM courses WHERE id = $1', [id])
  654. return { ok: true, message: '课程已成功删除' }
  655. })
  656. app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
  657. const { id } = req.params as any
  658. const row = await ownedCourse(id, userId(req))
  659. if (!row) return reply.code(404).send({ message: '课程不存在' })
  660. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
  661. let episodeId: string | null = (req.query as any)?.episodeId || null
  662. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  663. for await (const part of req.parts()) {
  664. if (part.type === 'file') {
  665. const ext = extname(part.filename).slice(0, 16)
  666. const stored = id + '-' + randomUUID() + ext
  667. const target = resolve(uploadDir, stored)
  668. await pipeline(part.file, createWriteStream(target))
  669. if (part.file.truncated) {
  670. await unlink(target).catch(() => {})
  671. return reply.code(413).send({ message: '文件超过大小限制' })
  672. }
  673. fileList.push({
  674. filename: part.filename,
  675. stored,
  676. bytes: part.file.bytesRead,
  677. mimetype: part.mimetype
  678. })
  679. } else if (part.fieldname === 'episodeId' && part.value) {
  680. episodeId = String(part.value)
  681. }
  682. }
  683. if (episodeId) {
  684. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  685. if (!epCheck.rows[0]) episodeId = null
  686. }
  687. if (!fileList.length) return reply.code(400).send({ message: '请选择课程资产文件' })
  688. for (const f of fileList) {
  689. await pool.query(
  690. 'INSERT INTO course_assets(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  691. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  692. )
  693. }
  694. let notesImported = 0
  695. const pptxFiles = fileList.filter(f => extname(f.filename).toLowerCase() === '.pptx')
  696. if (pptxFiles.length === 1) {
  697. const episodes = await pool.query('SELECT id,lecture_notes FROM episodes WHERE course_id=$1 ORDER BY episode_number,created_at', [id])
  698. const target = episodeId ? episodes.rows.find(ep => ep.id === episodeId) : episodes.rows.length === 1 ? episodes.rows[0] : null
  699. if (target && !target.lecture_notes.trim()) {
  700. try {
  701. const notes = (await extractPptxSpeakerNotes(resolve(uploadDir, pptxFiles[0].stored)))
  702. .map((note, index) => note ? `第 ${index + 1} 页\n${note}` : '')
  703. .filter(Boolean)
  704. if (notes.length) {
  705. const combined = notes.join('\n\n')
  706. 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])
  707. if (updated.rows[0]) notesImported = notes.length
  708. }
  709. } catch (error) {
  710. req.log.warn({ err: error }, 'pptx speaker notes extraction failed')
  711. }
  712. }
  713. }
  714. await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
  715. return { course: await fullCourse(row), notesImported }
  716. })
  717. app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
  718. const { id, assetId } = req.params as any
  719. const row = await ownedCourse(id, userId(req))
  720. if (!row) return reply.code(404).send({ message: '课程不存在' })
  721. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' })
  722. const { rows } = await pool.query(
  723. 'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  724. [assetId, id]
  725. )
  726. if (rows[0]) {
  727. await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  728. await unlink(resolve(uploadDir, '.previews', rows[0].stored_name + '.pdf')).catch(() => {})
  729. await unlink(resolve(uploadDir, '.previews', `${rows[0].stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  730. }
  731. await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
  732. return { course: await fullCourse(row) }
  733. })
  734. // ==================== 课程分集操作 (Episodes) ====================
  735. // 1. 创建单集
  736. app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) => {
  737. const { id } = req.params as any
  738. const row = await ownedCourse(id, userId(req))
  739. if (!row) return reply.code(404).send({ message: '课程不存在' })
  740. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  741. const p = z.object({
  742. title: z.string().trim().min(1, '请输入分集标题').max(200),
  743. episodeNumber: z.number().int().min(1).optional(),
  744. summary: z.string().max(500).default(''),
  745. lectureNotes: z.string().default('')
  746. }).safeParse(req.body)
  747. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '分集信息填写不完整' })
  748. let epNumber = p.data.episodeNumber
  749. if (!epNumber) {
  750. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  751. epNumber = (Number(maxRes.rows[0].max_num) || 0) + 1
  752. }
  753. const epId = randomUUID()
  754. await pool.query(
  755. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  756. [epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
  757. )
  758. await refreshCoursePoints(id)
  759. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '新增课程分集', '课程结构已更新。')
  760. return reply.code(201).send({ course: await fullCourse(row) })
  761. })
  762. // 2. 批量创建分集(如快速生成 12 集)
  763. app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, reply) => {
  764. const { id } = req.params as any
  765. const row = await ownedCourse(id, userId(req))
  766. if (!row) return reply.code(404).send({ message: '课程不存在' })
  767. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再添加分集' })
  768. const p = z.object({
  769. count: z.number().int().min(1).max(100).optional(),
  770. episodes: z.array(z.object({
  771. title: z.string().trim().min(1).max(200),
  772. episodeNumber: z.number().int().min(1).optional(),
  773. summary: z.string().max(500).default(''),
  774. lectureNotes: z.string().default('')
  775. })).optional()
  776. }).safeParse(req.body)
  777. if (!p.success) return reply.code(400).send({ message: '批量创建参数不正确' })
  778. const maxRes = await pool.query('SELECT COALESCE(MAX(episode_number), 0) AS max_num FROM episodes WHERE course_id = $1', [id])
  779. let currentMax = Number(maxRes.rows[0].max_num) || 0
  780. if (p.data.episodes && p.data.episodes.length > 0) {
  781. for (const ep of p.data.episodes) {
  782. currentMax++
  783. await pool.query(
  784. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  785. [randomUUID(), id, ep.episodeNumber || currentMax, ep.title, ep.summary, ep.lectureNotes]
  786. )
  787. }
  788. } else if (p.data.count) {
  789. for (let i = 1; i <= p.data.count; i++) {
  790. currentMax++
  791. const title = `第 ${currentMax} 集:课程知识点精讲`
  792. await pool.query(
  793. 'INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes) VALUES($1, $2, $3, $4, $5, $6)',
  794. [randomUUID(), id, currentMax, title, '', '']
  795. )
  796. }
  797. } else {
  798. return reply.code(400).send({ message: '请指定集数或分集列表' })
  799. }
  800. await refreshCoursePoints(id)
  801. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '批量新增课程分集', '课程结构已更新。')
  802. return reply.code(201).send({ course: await fullCourse(row) })
  803. })
  804. // 3. 更新分集信息(标题、序号、讲稿文本等)
  805. app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  806. const { id, episodeId } = req.params as any
  807. const row = await ownedCourse(id, userId(req))
  808. if (!row) return reply.code(404).send({ message: '课程不存在' })
  809. if (!['DRAFT', 'WAITING_PRODUCTION'].includes(row.status)) {
  810. return reply.code(409).send({ message: '课程已进入制作或完成归档,无法修改分集' })
  811. }
  812. const p = z.object({
  813. title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
  814. episodeNumber: z.number().int().min(1).optional(),
  815. summary: z.string().max(500).optional(),
  816. lectureNotes: z.string().optional()
  817. }).safeParse(req.body)
  818. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '参数错误' })
  819. const existing = await pool.query('SELECT * FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  820. if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
  821. const cur = existing.rows[0]
  822. const newTitle = p.data.title !== undefined ? p.data.title : cur.title
  823. const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
  824. const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
  825. const richPrefix = '<!--xinghen-richtext-->'
  826. const submittedNotes = p.data.lectureNotes
  827. const newNotes = submittedNotes === undefined ? cur.lecture_notes : submittedNotes.startsWith(richPrefix)
  828. ? richPrefix + sanitizeHtml(submittedNotes.slice(richPrefix.length), {
  829. allowedTags: ['p', 'div', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li'],
  830. allowedAttributes: {}
  831. })
  832. : submittedNotes
  833. await pool.query(
  834. 'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
  835. [newTitle, newNumber, newSummary, newNotes, episodeId, id]
  836. )
  837. await refreshCoursePoints(id)
  838. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程内容已修改', '分集内容已更新。')
  839. return { course: await fullCourse(row) }
  840. })
  841. // 4. 删除分集及其关联的物理素材文件
  842. app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (req, reply) => {
  843. const { id, episodeId } = req.params as any
  844. const row = await ownedCourse(id, userId(req))
  845. if (!row) return reply.code(404).send({ message: '课程不存在' })
  846. if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除分集' })
  847. // 课程至少需要保留一个分集,不可删除唯一分集
  848. const countRes = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [id])
  849. if ((countRes.rows[0]?.count || 0) <= 1) {
  850. return reply.code(400).send({ message: '课程至少需要保留一个分集,不可删除唯一分集' })
  851. }
  852. // 查出该集下所有素材文件并清理磁盘
  853. const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
  854. for (const ast of astRes.rows) {
  855. await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
  856. await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
  857. await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  858. }
  859. await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  860. await refreshCoursePoints(id)
  861. await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '删除课程分集', '课程结构已更新。')
  862. return { course: await fullCourse(row) }
  863. })
  864. app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
  865. const { id } = req.params as any
  866. const row = await ownedCourse(id, userId(req))
  867. if (!row) return reply.code(404).send({ message: '课程不存在' })
  868. 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: '提交前请选择讲师,并补齐照片和录音' })
  869. const snapshot = await digitalInstructors.dto(await digitalInstructors.get(row.digital_instructor_id))
  870. const estimate = await refreshCoursePoints(id)
  871. const balance = row.organization_id
  872. ? Number((await pool.query('SELECT points_balance FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.points_balance) || 0
  873. : Number((req as any).user.points_balance) || 0
  874. const estimatedPoints = estimate.points
  875. if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
  876. const { rows } = await pool.query(
  877. "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 *",
  878. [id,JSON.stringify(snapshot),row.digital_instructor_id]
  879. )
  880. if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
  881. await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
  882. return { course: await fullCourse(rows[0]) }
  883. })
  884. // ==================== 管理员专属 ADMIN API ====================
  885. // 1. 统计概览数据
  886. app.get('/api/admin/stats', { preHandler: adminAuth }, async () => {
  887. const [uRes, cRes, sRes] = await Promise.all([
  888. pool.query('SELECT COUNT(*)::int AS count FROM users'),
  889. pool.query('SELECT COUNT(*)::int AS count FROM courses'),
  890. pool.query(`
  891. SELECT
  892. COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count,
  893. COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count,
  894. COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count,
  895. COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count
  896. FROM courses
  897. `)
  898. ])
  899. return {
  900. totalUsers: uRes.rows[0].count,
  901. totalCourses: cRes.rows[0].count,
  902. waitingCount: sRes.rows[0].waiting_count || 0,
  903. inProductionCount: sRes.rows[0].in_production_count || 0,
  904. completedCount: sRes.rows[0].completed_count || 0,
  905. draftCount: sRes.rows[0].draft_count || 0
  906. }
  907. })
  908. // 2. 获取全量课程列表(支持状态筛选与搜索)
  909. app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
  910. const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string }
  911. const params: any[] = []
  912. const conditions: string[] = []
  913. if (query.status && query.status !== 'ALL') {
  914. params.push(query.status)
  915. conditions.push(`c.status = $${params.length}`)
  916. }
  917. if (query.keyword && query.keyword.trim()) {
  918. params.push(`%${query.keyword.trim()}%`)
  919. const idx = params.length
  920. conditions.push(`(c.name ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx})`)
  921. }
  922. const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
  923. const sql = `
  924. SELECT
  925. c.*,
  926. u.phone AS user_phone,
  927. u.role AS user_role,
  928. u.wechat AS user_wechat,
  929. u.contact_name AS user_contact_name,
  930. u.bio AS user_bio,
  931. u.created_at AS user_created_at
  932. FROM courses c
  933. LEFT JOIN users u ON u.id = c.user_id
  934. ${whereClause}
  935. ORDER BY
  936. CASE
  937. WHEN c.status = 'WAITING_PRODUCTION' THEN 1
  938. WHEN c.status = 'IN_PRODUCTION' THEN 2
  939. WHEN c.status = 'DRAFT' THEN 3
  940. WHEN c.status = 'COMPLETED' THEN 4
  941. ELSE 5
  942. END,
  943. c.submitted_at DESC NULLS LAST,
  944. c.created_at DESC
  945. `
  946. const { rows } = await pool.query(sql, params)
  947. const courses = await Promise.all(rows.map((r) => fullCourse(r, true)))
  948. return { courses, total: courses.length }
  949. })
  950. // 3. 获取特定课程完整详情(供制作工作台使用)
  951. app.get('/api/admin/courses/:id', { preHandler: courseOperator }, async (req, reply) => {
  952. const { id } = req.params as any
  953. const sql = `
  954. SELECT
  955. c.*,
  956. u.phone AS user_phone,
  957. u.role AS user_role,
  958. u.wechat AS user_wechat,
  959. u.contact_name AS user_contact_name,
  960. u.bio AS user_bio,
  961. u.created_at AS user_created_at
  962. FROM courses c
  963. LEFT JOIN users u ON u.id = c.user_id
  964. WHERE c.id = $1
  965. `
  966. const { rows } = await pool.query(sql, [id])
  967. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  968. const course = await fullCourse(rows[0], true)
  969. return { course }
  970. })
  971. // 制作中的课程由平台管理员维护;成员端仍读取同一份课程内容。
  972. async function productionCourse(id: string, reply: FastifyReply) {
  973. const { rows } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
  974. if (!rows[0]) { reply.code(404).send({ message: '课程不存在' }); return null }
  975. if (rows[0].status !== 'IN_PRODUCTION') { reply.code(409).send({ message: '只有制作中的课程可以编辑素材' }); return null }
  976. return rows[0]
  977. }
  978. async function pruneCourseInstructorFiles(id: string) {
  979. const latest = await pool.query('SELECT digital_instructor_snapshot FROM courses WHERE id=$1', [id])
  980. const snapshot = latest.rows[0]?.digital_instructor_snapshot || {}
  981. const referenced = new Set([...(snapshot.images || []), ...(snapshot.audio || [])].map((file: any) => file.id))
  982. const files = await pool.query('SELECT id,stored_name FROM course_instructor_files WHERE course_id=$1', [id])
  983. for (const file of files.rows) if (!referenced.has(file.id)) {
  984. await pool.query('DELETE FROM course_instructor_files WHERE id=$1 AND course_id=$2', [file.id,id])
  985. await unlink(resolve(uploadDir,file.stored_name)).catch(() => {})
  986. }
  987. }
  988. app.patch('/api/admin/courses/:id/content', { preHandler: adminAuth }, async (req, reply) => {
  989. const { id } = req.params as { id: string }
  990. const row = await productionCourse(id, reply)
  991. if (!row) return
  992. const p = z.object({ name: z.string().trim().min(1).max(100), description: z.string().max(500), assetNotes: z.string().max(5000) }).safeParse(req.body)
  993. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
  994. await pool.query('UPDATE courses SET name=$1,description=$2,asset_notes=$3,updated_at=NOW() WHERE id=$4', [p.data.name,p.data.description,p.data.assetNotes,id])
  995. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台更新课程资料', '课程名称、需求说明或通用备注已更新。')
  996. return { course: await fullCourse(row, true) }
  997. })
  998. app.post('/api/admin/courses/:id/instructor-sync', { preHandler: adminAuth }, async (req, reply) => {
  999. const { id } = req.params as { id: string }
  1000. const row = await productionCourse(id, reply)
  1001. if (!row) return
  1002. if (!row.digital_instructor_id) return reply.code(409).send({ message: '课程尚未选择数字讲师' })
  1003. const instructor = await digitalInstructors.get(row.digital_instructor_id)
  1004. if (!instructor || (row.organization_id ? instructor.organization_id !== row.organization_id : instructor.personal_user_id !== row.user_id)) {
  1005. return reply.code(409).send({ message: '课程讲师与所属工作区不一致' })
  1006. }
  1007. if (!await readyDigitalInstructor(instructor.id)) return reply.code(409).send({ message: '讲师照片和录音尚未齐备' })
  1008. const snapshot = await digitalInstructors.dto(instructor)
  1009. await pool.query('UPDATE courses SET digital_instructor_snapshot=$1,updated_at=NOW() WHERE id=$2', [JSON.stringify(snapshot),id])
  1010. await pruneCourseInstructorFiles(id)
  1011. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台同步数字讲师资料', '本课程的数字讲师快照已更新。')
  1012. return { course: await fullCourse(row, true) }
  1013. })
  1014. app.post('/api/admin/courses/:id/instructor', { preHandler: adminAuth }, async (req, reply) => {
  1015. const { id } = req.params as { id: string }
  1016. const row = await productionCourse(id, reply)
  1017. if (!row) return
  1018. if (!row.digital_instructor_snapshot) return reply.code(409).send({ message: '课程没有可编辑的数字讲师快照' })
  1019. const fields: Record<string,string> = {}
  1020. const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
  1021. let committed = false
  1022. try {
  1023. for await (const part of req.parts()) {
  1024. if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
  1025. const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
  1026. const mime = part.mimetype.toLowerCase()
  1027. if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
  1028. if (kind === 'AUDIO' && pending.some((file) => file.kind === 'AUDIO')) throw new Error('一次只能上传一份录音')
  1029. const buffer = await part.toBuffer()
  1030. if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('照片或录音为空或超过大小限制')
  1031. const stored = `course-instructor-${id}-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
  1032. await writeFile(resolve(uploadDir,stored),buffer)
  1033. pending.push({ id:randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
  1034. }
  1035. const parsed = z.object({ name: z.string().trim().min(1).max(80), note: z.string().max(1000), removeImageIds: z.string().default('[]') }).safeParse(fields)
  1036. if (!parsed.success) throw new Error(parsed.error.issues[0]?.message || '请检查讲师资料')
  1037. const removeIds: unknown = JSON.parse(parsed.data.removeImageIds)
  1038. if (!Array.isArray(removeIds) || removeIds.some((value) => typeof value !== 'string')) throw new Error('照片移除参数不正确')
  1039. const client = await pool.connect()
  1040. let snapshot: any
  1041. try {
  1042. await client.query('BEGIN')
  1043. const current = await client.query('SELECT status,digital_instructor_snapshot FROM courses WHERE id=$1 FOR UPDATE', [id])
  1044. if (current.rows[0]?.status !== 'IN_PRODUCTION') throw new Error('只有制作中的课程可以编辑讲师')
  1045. snapshot = current.rows[0].digital_instructor_snapshot
  1046. if (!snapshot) throw new Error('课程没有可编辑的数字讲师快照')
  1047. const originalImages = snapshot.images || []
  1048. if (removeIds.some((value) => !originalImages.some((image: any) => image.id === value))) throw new Error('要移除的照片不存在')
  1049. const images = [...originalImages.filter((image: any) => !removeIds.includes(image.id)), ...pending.filter((file) => file.kind === 'IMAGE').map((file) => ({ id:file.id,name:file.original,url:`/uploads/${file.stored}` }))]
  1050. const newAudio = pending.find((file) => file.kind === 'AUDIO')
  1051. const audio = newAudio ? [{ id:newAudio.id,name:newAudio.original,url:`/uploads/${newAudio.stored}` }] : (snapshot.audio || [])
  1052. if (!images.length || !audio.length || images.length > 19) throw new Error('请保留 1 至 19 张照片和一份录音')
  1053. snapshot = { ...snapshot, name:parsed.data.name, note:parsed.data.note, images, audio, ready:true }
  1054. for (const file of pending) await client.query('INSERT INTO course_instructor_files(id,course_id,kind,original_name,stored_name,size,mime_type) VALUES($1,$2,$3,$4,$5,$6,$7)', [file.id,id,file.kind,file.original,file.stored,file.size,file.mime])
  1055. await client.query('UPDATE courses SET digital_instructor_snapshot=$1,updated_at=NOW() WHERE id=$2', [JSON.stringify(snapshot),id])
  1056. await client.query('COMMIT')
  1057. committed = true
  1058. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  1059. await pruneCourseInstructorFiles(id)
  1060. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台修改本课数字讲师', '本课程的数字讲师资料或素材已更新。')
  1061. return { course: await fullCourse(row,true) }
  1062. } catch (error) {
  1063. return reply.code(400).send({ message:(error as Error).message || '讲师资料保存失败' })
  1064. } finally {
  1065. if (!committed) for (const file of pending) await unlink(resolve(uploadDir,file.stored)).catch(() => {})
  1066. }
  1067. })
  1068. app.post('/api/admin/courses/:id/episodes', { preHandler: adminAuth }, async (req, reply) => {
  1069. const { id } = req.params as { id: string }
  1070. const row = await productionCourse(id, reply)
  1071. if (!row) return
  1072. const p = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().max(500).default(''), lectureNotes: z.string().default('') }).safeParse(req.body)
  1073. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查分集信息' })
  1074. const max = await pool.query('SELECT COALESCE(MAX(episode_number),0)::int AS value FROM episodes WHERE course_id=$1', [id])
  1075. await pool.query('INSERT INTO episodes(id,course_id,episode_number,title,summary,lecture_notes) VALUES($1,$2,$3,$4,$5,$6)', [randomUUID(),id,max.rows[0].value+1,p.data.title,p.data.summary,p.data.lectureNotes])
  1076. await refreshCoursePoints(id)
  1077. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台新增课程分集', '制作中的课程增加了分集。')
  1078. return reply.code(201).send({ course: await fullCourse(row, true) })
  1079. })
  1080. app.patch('/api/admin/courses/:id/episodes/:episodeId', { preHandler: adminAuth }, async (req, reply) => {
  1081. const { id, episodeId } = req.params as { id: string; episodeId: string }
  1082. const row = await productionCourse(id, reply)
  1083. if (!row) return
  1084. const p = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().max(500), lectureNotes: z.string() }).safeParse(req.body)
  1085. if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查分集信息' })
  1086. const richPrefix = '<!--xinghen-richtext-->'
  1087. const notes = p.data.lectureNotes.startsWith(richPrefix) ? richPrefix + sanitizeHtml(p.data.lectureNotes.slice(richPrefix.length), {
  1088. allowedTags: ['p','div','br','strong','b','em','i','ul','ol','li'], allowedAttributes: {}
  1089. }) : p.data.lectureNotes
  1090. const updated = await pool.query('UPDATE episodes SET title=$1,summary=$2,lecture_notes=$3,updated_at=NOW() WHERE id=$4 AND course_id=$5 RETURNING id', [p.data.title,p.data.summary,notes,episodeId,id])
  1091. if (!updated.rows[0]) return reply.code(404).send({ message: '分集不存在' })
  1092. await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
  1093. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台修改课程分集', '制作中的分集标题、简介或讲稿已更新。')
  1094. return { course: await fullCourse(row, true) }
  1095. })
  1096. app.delete('/api/admin/courses/:id/episodes/:episodeId', { preHandler: adminAuth }, async (req, reply) => {
  1097. const { id, episodeId } = req.params as { id: string; episodeId: string }
  1098. const row = await productionCourse(id, reply)
  1099. if (!row) return
  1100. const existing = await pool.query('SELECT id FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])
  1101. if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
  1102. const count = await pool.query('SELECT COUNT(*)::int AS value FROM episodes WHERE course_id=$1', [id])
  1103. if (count.rows[0].value <= 1) return reply.code(409).send({ message: '课程至少保留一个分集' })
  1104. const delivered = await pool.query('SELECT 1 FROM course_deliverables WHERE course_id=$1 AND episode_id=$2 LIMIT 1', [id,episodeId])
  1105. if (delivered.rows[0]) return reply.code(409).send({ message: '该集已有交付文件,请先处理交付文件' })
  1106. const assets = await pool.query('SELECT stored_name FROM course_assets WHERE course_id=$1 AND episode_id=$2', [id,episodeId])
  1107. await pool.query('DELETE FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])
  1108. for (const asset of assets.rows) {
  1109. await unlink(resolve(uploadDir,asset.stored_name)).catch(() => {})
  1110. await unlink(resolve(uploadDir,'.previews',asset.stored_name+'.pdf')).catch(() => {})
  1111. await unlink(resolve(uploadDir,'.previews',`${asset.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
  1112. }
  1113. await refreshCoursePoints(id)
  1114. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台删除课程分集', '制作中的课程删除了一个分集及其素材。')
  1115. return { course: await fullCourse(row, true) }
  1116. })
  1117. app.post('/api/admin/courses/:id/assets', { preHandler: adminAuth }, async (req, reply) => {
  1118. const { id } = req.params as { id: string }
  1119. const row = await productionCourse(id, reply)
  1120. if (!row) return
  1121. const pending: Array<{ original: string; stored: string; size: number; mime: string }> = []
  1122. let episodeId: string | null = null
  1123. let committed = false
  1124. try {
  1125. for await (const part of req.parts()) {
  1126. if (part.type === 'file') {
  1127. const stored = `${id}-${randomUUID()}${extname(part.filename).slice(0,16)}`
  1128. await pipeline(part.file,createWriteStream(resolve(uploadDir,stored)))
  1129. if (part.file.truncated) { await unlink(resolve(uploadDir,stored)).catch(() => {}); return reply.code(413).send({ message: '文件超过大小限制' }) }
  1130. pending.push({ original: part.filename, stored, size: part.file.bytesRead, mime: part.mimetype })
  1131. } else if (part.fieldname === 'episodeId' && part.value) episodeId = String(part.value)
  1132. }
  1133. if (!pending.length) return reply.code(400).send({ message: '请选择课程素材文件' })
  1134. if (episodeId && !(await pool.query('SELECT 1 FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])).rows[0]) return reply.code(400).send({ message: '分集不存在' })
  1135. const client = await pool.connect()
  1136. try {
  1137. await client.query('BEGIN')
  1138. for (const file of pending) await client.query('INSERT INTO course_assets(id,course_id,episode_id,original_name,stored_name,size,mime_type) VALUES($1,$2,$3,$4,$5,$6,$7)', [randomUUID(),id,episodeId,file.original,file.stored,file.size,file.mime])
  1139. await client.query('COMMIT')
  1140. committed = true
  1141. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  1142. await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
  1143. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台上传课程素材', `新增 ${pending.length} 份课程素材。`)
  1144. return { course: await fullCourse(row, true) }
  1145. } finally {
  1146. if (!committed) for (const file of pending) await unlink(resolve(uploadDir,file.stored)).catch(() => {})
  1147. }
  1148. })
  1149. app.delete('/api/admin/courses/:id/assets/:assetId', { preHandler: adminAuth }, async (req, reply) => {
  1150. const { id, assetId } = req.params as { id: string; assetId: string }
  1151. const row = await productionCourse(id, reply)
  1152. if (!row) return
  1153. const deleted = await pool.query('DELETE FROM course_assets WHERE id=$1 AND course_id=$2 RETURNING stored_name', [assetId,id])
  1154. if (!deleted.rows[0]) return reply.code(404).send({ message: '素材不存在' })
  1155. const stored = deleted.rows[0].stored_name
  1156. await unlink(resolve(uploadDir,stored)).catch(() => {})
  1157. await unlink(resolve(uploadDir,'.previews',stored+'.pdf')).catch(() => {})
  1158. await unlink(resolve(uploadDir,'.previews',`${stored}.${previewCacheVersion}.pdf`)).catch(() => {})
  1159. await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
  1160. await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台删除课程素材', '制作中的课程移除了一份素材。')
  1161. return { course: await fullCourse(row, true) }
  1162. })
  1163. // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
  1164. app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
  1165. const { id } = req.params as any
  1166. const p = z.object({
  1167. status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
  1168. }).safeParse(req.body)
  1169. if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
  1170. if (p.data.status === 'COMPLETED') return reply.code(400).send({ message: '请通过“确认交付”完成课程并结算积分' })
  1171. const { rows } = await pool.query(
  1172. 'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
  1173. [p.data.status, id]
  1174. )
  1175. if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
  1176. const statusNames: Record<string, string> = { DRAFT: '草稿', WAITING_PRODUCTION: '等待制作', IN_PRODUCTION: '正在制作', COMPLETED: '已完成', REJECTED: '已驳回' }
  1177. await addProgressEvent(id, 'ADMIN', 'STATUS_CHANGED', '制作状态已更新', `管理员将课程状态更新为“${statusNames[p.data.status]}”。`, { status: p.data.status })
  1178. return { course: await fullCourse(rows[0], true) }
  1179. })
  1180. app.patch('/api/admin/courses/:id/points', { preHandler: courseOperator }, async (req, reply) => {
  1181. const { id } = req.params as any
  1182. const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
  1183. if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
  1184. const previous = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  1185. if (!previous.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  1186. if (previous.rows[0].points_charged) return reply.code(409).send({ message: '课程已完成扣费,不能再修改积分' })
  1187. const { rows } = await pool.query('UPDATE courses SET actual_points = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.points, id])
  1188. const oldPoints = previous.rows[0].actual_points ?? previous.rows[0].estimated_points
  1189. await addProgressEvent(id, 'ADMIN', 'POINTS_UPDATED', '积分消耗已调整', `管理员将制作消耗从 ${oldPoints} 积分调整为 ${p.data.points} 积分。${p.data.reason}`, { previousPoints: Number(oldPoints), points: p.data.points })
  1190. return { course: await fullCourse(rows[0], true) }
  1191. })
  1192. // 5. 管理员上传制作交付成品文件
  1193. app.post('/api/admin/courses/:id/deliverables', { preHandler: courseOperator }, async (req, reply) => {
  1194. const { id } = req.params as any
  1195. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  1196. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  1197. let episodeId: string | null = (req.query as any)?.episodeId || null
  1198. const fileList: Array<{ filename: string; stored: string; bytes: number; mimetype: string }> = []
  1199. for await (const part of req.parts()) {
  1200. if (part.type === 'file') {
  1201. const ext = extname(part.filename).slice(0, 16)
  1202. const stored = 'deliverable-' + id + '-' + randomUUID() + ext
  1203. const target = resolve(uploadDir, stored)
  1204. await pipeline(part.file, createWriteStream(target))
  1205. if (part.file.truncated) {
  1206. await unlink(target).catch(() => {})
  1207. return reply.code(413).send({ message: '文件超过大小限制' })
  1208. }
  1209. fileList.push({
  1210. filename: part.filename,
  1211. stored,
  1212. bytes: part.file.bytesRead,
  1213. mimetype: part.mimetype
  1214. })
  1215. } else if (part.fieldname === 'episodeId' && part.value) {
  1216. episodeId = String(part.value)
  1217. }
  1218. }
  1219. if (episodeId) {
  1220. const epCheck = await pool.query('SELECT id FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
  1221. if (!epCheck.rows[0]) episodeId = null
  1222. }
  1223. if (!fileList.length) return reply.code(400).send({ message: '请选择交付成果文件' })
  1224. for (const f of fileList) {
  1225. await pool.query(
  1226. 'INSERT INTO course_deliverables(id, course_id, episode_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6, $7)',
  1227. [randomUUID(), id, episodeId, f.filename, f.stored, f.bytes, f.mimetype]
  1228. )
  1229. }
  1230. await addProgressEvent(id, 'ADMIN', 'DELIVERABLE_UPLOADED', '管理员上传了制作成果', `新增 ${fileList.length} 个交付文件。`)
  1231. return { course: await fullCourse(check.rows[0], true) }
  1232. })
  1233. // 6. 删除已上传的交付成果文件
  1234. app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: courseOperator }, async (req, reply) => {
  1235. const { id, delivId } = req.params as any
  1236. const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
  1237. if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
  1238. const { rows } = await pool.query(
  1239. 'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name',
  1240. [delivId, id]
  1241. )
  1242. if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
  1243. return { course: await fullCourse(check.rows[0], true) }
  1244. })
  1245. // 7. 提交制作成果,结单交付(流转为 COMPLETED)
  1246. app.post('/api/admin/courses/:id/complete', { preHandler: courseOperator }, async (req, reply) => {
  1247. const { id } = req.params as any
  1248. const p = z.object({
  1249. productionNotes: z.string().max(2000).default('')
  1250. }).safeParse(req.body || {})
  1251. if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
  1252. const client = await pool.connect()
  1253. let rows: any[] = []
  1254. try {
  1255. await client.query('BEGIN')
  1256. const current = await client.query('SELECT * FROM courses WHERE id = $1 FOR UPDATE', [id])
  1257. if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
  1258. const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
  1259. if (!current.rows[0].points_charged) {
  1260. const organizationId = current.rows[0].organization_id
  1261. const charged = organizationId
  1262. ? 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])
  1263. : 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])
  1264. if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `工作区积分不足,完成制作需要扣除 ${cost} 积分` }) }
  1265. 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)
  1266. VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [randomUUID(), organizationId ? 'ORGANIZATION' : 'PERSONAL', organizationId || current.rows[0].user_id,
  1267. id, current.rows[0].name, current.rows[0].user_id, (req as any).user.role === 'ADMIN' ? userId(req) : null, -cost, '课程完成扣费'])
  1268. }
  1269. 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])
  1270. rows = updated.rows
  1271. await client.query('COMMIT')
  1272. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  1273. const cost = Number(rows[0].actual_points ?? rows[0].estimated_points) || 100
  1274. await addProgressEvent(id, 'ADMIN', 'COMPLETED', '课程制作完成', `课程已交付,实际扣除 ${cost} 积分。`, { points: cost })
  1275. return { course: await fullCourse(rows[0], true) }
  1276. })
  1277. // 8. 获取全平台用户列表及统计
  1278. app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
  1279. const query = (req.query || {}) as { keyword?: string }
  1280. const params: any[] = []
  1281. let where = ''
  1282. if (query.keyword && query.keyword.trim()) {
  1283. params.push(`%${query.keyword.trim()}%`)
  1284. where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.wechat ILIKE $1)'
  1285. }
  1286. const sql = `
  1287. SELECT
  1288. u.*,
  1289. (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,
  1290. COUNT(c.id)::int AS courses_count,
  1291. COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
  1292. COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
  1293. COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count
  1294. FROM users u
  1295. LEFT JOIN courses c ON c.user_id = u.id
  1296. ${where}
  1297. GROUP BY u.id
  1298. ORDER BY u.created_at DESC
  1299. `
  1300. const { rows } = await pool.query(sql, params)
  1301. const users = rows.map((r) => ({
  1302. ...mapUser(r),
  1303. organization: r.organization_names || '',
  1304. coursesCount: r.courses_count || 0,
  1305. waitingCoursesCount: r.waiting_courses_count || 0,
  1306. inProductionCoursesCount: r.in_production_courses_count || 0,
  1307. completedCoursesCount: r.completed_courses_count || 0
  1308. }))
  1309. return { users }
  1310. })
  1311. // 9. 管理员创建普通用户
  1312. app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
  1313. const p = z.object({
  1314. phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
  1315. password: z.string().min(6, '密码至少 6 位').max(72),
  1316. contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default('')
  1317. }).safeParse(req.body)
  1318. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  1319. if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) return reply.code(409).send({ message: '该手机号属于平台管理员' })
  1320. try {
  1321. const id = randomUUID()
  1322. const passwordHash = await hashPassword(p.data.password)
  1323. const { rows } = await pool.query(
  1324. `INSERT INTO users(id, phone, password_hash, role, contact_name, points_balance)
  1325. VALUES($1, $2, $3, 'USER', $4, 10000) RETURNING *`,
  1326. [id, p.data.phone, passwordHash, p.data.contactName]
  1327. )
  1328. return reply.code(201).send({ user: mapUser(rows[0]) })
  1329. } catch (e: any) {
  1330. if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
  1331. throw e
  1332. }
  1333. })
  1334. // 10. 管理员重置用户密码;同时注销该用户已有会话
  1335. app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req, reply) => {
  1336. const { id } = req.params as any
  1337. const p = z.object({
  1338. password: z.string().min(6, '密码至少 6 位').max(72)
  1339. }).safeParse(req.body)
  1340. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  1341. const client = await pool.connect()
  1342. try {
  1343. await client.query('BEGIN')
  1344. const { rowCount } = await client.query(
  1345. 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
  1346. [await hashPassword(p.data.password), id]
  1347. )
  1348. if (!rowCount) {
  1349. await client.query('ROLLBACK')
  1350. return reply.code(404).send({ message: '用户不存在' })
  1351. }
  1352. await client.query('DELETE FROM sessions WHERE user_id = $1', [id])
  1353. await client.query('COMMIT')
  1354. return { ok: true }
  1355. } catch (error) {
  1356. await client.query('ROLLBACK')
  1357. throw error
  1358. } finally {
  1359. client.release()
  1360. }
  1361. })
  1362. // 平台管理员与普通用户永不互转。
  1363. app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
  1364. return reply.code(403).send({ message: '平台管理员与普通用户不能互相转化' })
  1365. })
  1366. // ==================== 广告落地页线索(公开提交 / 管理员跟进) ====================
  1367. const LEAD_STATUSES = ['PENDING', 'CONTACTED', 'WON', 'INVALID'] as const
  1368. const LEAD_WINDOW_MS = 60_000
  1369. const leadSubmitWindow = new Map<string, number>()
  1370. const leadSchema = z.object({
  1371. name: z.string().trim().min(1, '请填写您的姓名').max(80, '姓名不能超过 80 个字'),
  1372. phone: z.string().trim().regex(/^1\d{10}$/, '请输入正确的手机号'),
  1373. wechat: z.string().trim().min(1, '请填写微信号,方便顾问联系您').max(80, '微信号不能超过 80 个字'),
  1374. needType: z.string().trim().max(40).default('OTHER'),
  1375. courseScale: z.string().trim().max(40).default(''),
  1376. message: z.string().trim().max(1000, '需求描述不能超过 1000 字').default(''),
  1377. source: z.string().trim().max(120).default(''),
  1378. password: z.string().min(6, '密码至少 6 位').max(72).optional()
  1379. })
  1380. function mapLead(row: any) {
  1381. return {
  1382. id: row.id,
  1383. name: row.name,
  1384. phone: row.phone,
  1385. wechat: row.wechat,
  1386. needType: row.need_type,
  1387. courseScale: row.course_scale,
  1388. message: row.message,
  1389. source: row.source,
  1390. status: row.status,
  1391. note: row.note,
  1392. userId: row.user_id,
  1393. userName: row.user_name || '',
  1394. createdAt: row.created_at,
  1395. updatedAt: row.updated_at
  1396. }
  1397. }
  1398. // 公开提交:无需登录。用于广告落地页收集联系方式与简单需求。
  1399. app.post('/api/public/leads', async (req, reply) => {
  1400. const ip = (String(req.headers['x-forwarded-for'] || '').split(',')[0] || req.ip || 'unknown').trim()
  1401. const now = Date.now()
  1402. if (leadSubmitWindow.size > 2000) {
  1403. for (const [key, at] of leadSubmitWindow) if (now - at > LEAD_WINDOW_MS) leadSubmitWindow.delete(key)
  1404. }
  1405. const last = leadSubmitWindow.get(ip)
  1406. if (last && now - last < LEAD_WINDOW_MS) return reply.code(429).send({ message: '提交过于频繁,请稍后再试' })
  1407. const p = leadSchema.safeParse(req.body)
  1408. if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
  1409. // 可选注册:填了密码才创建账号,不填只留线索。
  1410. let newUserId: string | null = null
  1411. let token: string | null = null
  1412. if (p.data.password) {
  1413. if ((await pool.query('SELECT 1 FROM users WHERE phone=$1', [p.data.phone])).rows[0]) {
  1414. return reply.code(409).send({ message: '该手机号已注册,请直接登录' })
  1415. }
  1416. if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) {
  1417. return reply.code(409).send({ message: '该手机号不可用于注册普通用户' })
  1418. }
  1419. newUserId = randomUUID()
  1420. await pool.query(
  1421. 'INSERT INTO users(id, phone, password_hash, role, contact_name, wechat, points_balance) VALUES($1, $2, $3, $4, $5, $6, 10000)',
  1422. [newUserId, p.data.phone, await hashPassword(p.data.password), 'USER', p.data.name, p.data.wechat]
  1423. )
  1424. token = await session(newUserId)
  1425. }
  1426. const client = await pool.connect()
  1427. try {
  1428. await client.query('BEGIN')
  1429. let userId = newUserId
  1430. if (!userId) {
  1431. const existing = await client.query('SELECT id FROM users WHERE phone=$1', [p.data.phone])
  1432. if (existing.rows[0]) userId = existing.rows[0].id
  1433. }
  1434. const message = sanitizeHtml(p.data.message, { allowedTags: [], allowedAttributes: {} })
  1435. const recent = await client.query(
  1436. `SELECT id FROM lead_requests WHERE phone=$1 AND created_at > NOW() - INTERVAL '24 hours' ORDER BY created_at DESC LIMIT 1`,
  1437. [p.data.phone]
  1438. )
  1439. if (recent.rows[0]) {
  1440. // 同一手机号 24 小时内重复提交视为补充需求,更新而非新增,避免线索重复。
  1441. await client.query(
  1442. `UPDATE lead_requests SET name=$1, wechat=$2, need_type=$3, course_scale=$4, message=$5, source=$6,
  1443. user_id = COALESCE($7, user_id), updated_at = NOW() WHERE id=$8`,
  1444. [p.data.name, p.data.wechat, p.data.needType, p.data.courseScale, message, p.data.source, userId, recent.rows[0].id]
  1445. )
  1446. } else {
  1447. await client.query(
  1448. `INSERT INTO lead_requests(id, name, phone, wechat, need_type, course_scale, message, source, user_id)
  1449. VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
  1450. [randomUUID(), p.data.name, p.data.phone, p.data.wechat, p.data.needType, p.data.courseScale, message, p.data.source, userId]
  1451. )
  1452. }
  1453. await client.query('COMMIT')
  1454. leadSubmitWindow.set(ip, now)
  1455. return reply.code(201).send({ ok: true, token })
  1456. } catch (error) {
  1457. await client.query('ROLLBACK')
  1458. throw error
  1459. } finally {
  1460. client.release()
  1461. }
  1462. })
  1463. app.get('/api/admin/leads', { preHandler: adminAuth }, async (req) => {
  1464. const { status, keyword } = (req.query || {}) as { status?: string; keyword?: string }
  1465. const where: string[] = []
  1466. const params: any[] = []
  1467. if (status && (LEAD_STATUSES as readonly string[]).includes(status)) {
  1468. params.push(status)
  1469. where.push(`l.status = $${params.length}`)
  1470. }
  1471. if (keyword && keyword.trim()) {
  1472. params.push(`%${keyword.trim()}%`)
  1473. where.push(`(l.name ILIKE $${params.length} OR l.phone ILIKE $${params.length} OR l.wechat ILIKE $${params.length} OR l.message ILIKE $${params.length})`)
  1474. }
  1475. const { rows } = await pool.query(
  1476. `SELECT l.*, u.contact_name AS user_name
  1477. FROM lead_requests l LEFT JOIN users u ON u.id = l.user_id
  1478. ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
  1479. ORDER BY l.created_at DESC LIMIT 500`,
  1480. params
  1481. )
  1482. return { leads: rows.map(mapLead) }
  1483. })
  1484. app.patch('/api/admin/leads/:id', { preHandler: adminAuth }, async (req, reply) => {
  1485. const { id } = req.params as any
  1486. const p = z.object({
  1487. status: z.enum(LEAD_STATUSES).optional(),
  1488. note: z.string().max(1000, '备注不能超过 1000 字').optional()
  1489. }).safeParse(req.body)
  1490. if (!p.success) return reply.code(400).send({ message: '线索状态或备注格式不正确' })
  1491. const sets = ['updated_at = NOW()']
  1492. const params: any[] = []
  1493. if (p.data.status) {
  1494. params.push(p.data.status)
  1495. sets.push(`status = $${params.length}`)
  1496. }
  1497. if (p.data.note !== undefined) {
  1498. params.push(p.data.note)
  1499. sets.push(`note = $${params.length}`)
  1500. }
  1501. params.push(id)
  1502. const { rows } = await pool.query(
  1503. `UPDATE lead_requests SET ${sets.join(', ')} WHERE id = $${params.length}
  1504. RETURNING *, (SELECT contact_name FROM users WHERE id = lead_requests.user_id) AS user_name`,
  1505. params
  1506. )
  1507. if (!rows[0]) return reply.code(404).send({ message: '线索不存在' })
  1508. return { lead: mapLead(rows[0]) }
  1509. })
  1510. // ==================== 静态托管与错误处理 ====================
  1511. if (process.env.NODE_ENV === 'production') {
  1512. const dist = resolve('./dist')
  1513. // 广告落地页独立入口,不与主站 SPA 兜底路由混用。
  1514. const sendLanding = async (_req: FastifyRequest, reply: FastifyReply) => reply.sendFile('landing.html', dist)
  1515. app.get('/lp', sendLanding)
  1516. app.get('/lp/*', sendLanding)
  1517. await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false, decorateReply: false })
  1518. app.setNotFoundHandler((req, reply) =>
  1519. req.url.startsWith('/api/') || req.url.startsWith('/uploads/')
  1520. ? reply.code(404).send({ message: '接口或文件不存在' })
  1521. : reply.sendFile('index.html', dist)
  1522. )
  1523. }
  1524. app.setErrorHandler((error: FastifyError, _req, reply) => {
  1525. app.log.error(error)
  1526. const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
  1527. reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message })
  1528. })
  1529. await app.listen({ port, host: process.env.HOST || '0.0.0.0' })