index.ts 60 KB

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