digital-instructors.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
  2. import type pg from 'pg'
  3. import { randomUUID } from 'node:crypto'
  4. import { copyFile, unlink, writeFile } from 'node:fs/promises'
  5. import { extname, resolve } from 'node:path'
  6. import { z } from 'zod'
  7. type Dependencies = { pool: pg.Pool; auth: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>; uploadDir: string }
  8. const input = z.object({ name: z.string().trim().min(1).max(80), note: z.string().trim().max(1000).default('') })
  9. const uid = (req: FastifyRequest) => (req as any).user.id as string
  10. const selected = (req: FastifyRequest) => String(req.headers['x-workspace-id'] || 'personal')
  11. export function registerDigitalInstructors(app: FastifyInstance, { pool, auth, uploadDir }: Dependencies) {
  12. async function membership(req: FastifyRequest, organizationId: string) {
  13. return (await pool.query(`SELECT m.role,o.is_active FROM organization_members m JOIN organizations o ON o.id=m.organization_id WHERE m.organization_id=$1 AND m.user_id=$2`, [organizationId, uid(req)])).rows[0] as { role: string; is_active: boolean } | undefined
  14. }
  15. async function access(req: FastifyRequest, row: any) {
  16. if ((req as any).user.role === 'ADMIN' && row.organization_id && selected(req) === row.organization_id) return 'MANAGER'
  17. if (row.personal_user_id) return row.personal_user_id === uid(req) && selected(req) === 'personal' ? 'OWNER' : null
  18. if (selected(req) !== row.organization_id) return null
  19. const member = await membership(req, row.organization_id)
  20. if (!member) return null
  21. return member.role === 'MANAGER' ? 'MANAGER' : row.owner_id === uid(req) ? 'OWNER' : 'VIEWER'
  22. }
  23. async function get(id: string) { return (await pool.query('SELECT * FROM digital_instructors WHERE id=$1 AND deleted_at IS NULL', [id])).rows[0] }
  24. async function files(id: string) { return (await pool.query('SELECT * FROM digital_instructor_files WHERE instructor_id=$1 AND is_active=TRUE ORDER BY kind,sort_order,created_at', [id])).rows }
  25. async function dto(row: any) {
  26. const fileRows = await files(row.id)
  27. const usageCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM courses WHERE digital_instructor_id=$1', [row.id])).rows[0].count)
  28. const creator = row.admin_creator_id
  29. ? (await pool.query('SELECT contact_name FROM platform_admins WHERE id=$1', [row.admin_creator_id])).rows[0]
  30. : (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.creator_id])).rows[0]
  31. const owner = (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.owner_id])).rows[0]
  32. return { id: row.id, name: row.name, note: row.note,
  33. organizationId: row.organization_id, personalUserId: row.personal_user_id, ownerId: row.owner_id,
  34. ownerName: owner?.contact_name || '未知用户', creatorId: row.admin_creator_id || row.creator_id, creatorName: creator?.contact_name || '未知用户',
  35. createdAt: row.created_at, updatedAt: row.updated_at, sourceId: row.source_id, qualityStatus: row.quality_status, usageCount,
  36. images: fileRows.filter(f => f.kind === 'IMAGE').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })),
  37. audio: fileRows.filter(f => f.kind === 'AUDIO').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })),
  38. ready: fileRows.some(f => f.kind === 'IMAGE') && fileRows.some(f => f.kind === 'AUDIO') }
  39. }
  40. async function event(id: string, actor: string, action: string, details: object = {}, client: pg.PoolClient | pg.Pool = pool) {
  41. const admin = (await client.query('SELECT 1 FROM platform_admins WHERE id=$1', [actor])).rows[0]
  42. await client.query('INSERT INTO digital_instructor_events(id,instructor_id,actor_id,admin_actor_id,action,details) VALUES($1,$2,$3,$4,$5,$6)',
  43. [randomUUID(), id, admin ? null : actor, admin ? actor : null, action, JSON.stringify(details)])
  44. }
  45. app.get('/api/digital-instructors', { preHandler: auth }, async (req, reply) => {
  46. const workspace = selected(req)
  47. if (workspace !== 'personal' && (req as any).user.role !== 'ADMIN' && !await membership(req, workspace)) return reply.code(403).send({ message: '无权访问该组织资产库' })
  48. const rows = workspace === 'personal'
  49. ? (await pool.query('SELECT * FROM digital_instructors WHERE personal_user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [uid(req)])).rows
  50. : (await pool.query('SELECT * FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [workspace])).rows
  51. return { instructors: await Promise.all(rows.map(dto)) }
  52. })
  53. app.get('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
  54. const row = await get((req.params as any).id)
  55. if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
  56. return { instructor: await dto(row) }
  57. })
  58. app.get('/api/digital-instructors/:id/usage', { preHandler: auth }, async (req, reply) => {
  59. const row = await get((req.params as any).id)
  60. if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
  61. const { rows } = await pool.query('SELECT id,name,status FROM courses WHERE digital_instructor_id=$1 ORDER BY created_at DESC', [row.id])
  62. return { courses: rows }
  63. })
  64. app.post('/api/digital-instructors', { preHandler: auth }, async (req, reply) => {
  65. if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员不能创建普通用户资产' })
  66. const workspace = selected(req)
  67. if (workspace !== 'personal') {
  68. const member = await membership(req, workspace)
  69. if (!member || !member.is_active) return reply.code(403).send({ message: '无权在该组织创建讲师' })
  70. }
  71. const fields: Record<string, string> = {}
  72. const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
  73. let committed = false
  74. try {
  75. for await (const part of req.parts()) {
  76. if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
  77. const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
  78. const mime = part.mimetype.toLowerCase()
  79. 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('照片或录音格式不支持')
  80. if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
  81. const buffer = await part.toBuffer()
  82. if (!buffer.length) throw new Error('上传文件不能为空')
  83. if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB')
  84. const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
  85. await writeFile(resolve(uploadDir, stored), buffer)
  86. pending.push({ kind, original: part.filename.slice(0,255), stored, size: buffer.length, mime })
  87. }
  88. const parsed = input.safeParse(fields)
  89. if (!parsed.success) throw new Error('请填写讲师姓名')
  90. const id = randomUUID()
  91. const client = await pool.connect()
  92. try {
  93. await client.query('BEGIN')
  94. await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,name,note) VALUES($1,$2,$3,$4,$4,$5,$6)`, [id, workspace === 'personal' ? null : workspace, workspace === 'personal' ? uid(req) : null, uid(req), parsed.data.name, parsed.data.note])
  95. const coverNewIndex = fields.coverNewIndex === undefined ? -1 : Number(fields.coverNewIndex)
  96. for (const [i, f] of pending.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(), id, f.kind, f.original, f.stored, f.size, f.mime, f.kind === 'IMAGE' && i === coverNewIndex ? -1 : i])
  97. await event(id, uid(req), 'CREATED', {}, client)
  98. await client.query('COMMIT')
  99. committed = true
  100. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  101. return reply.code(201).send({ instructor: await dto(await get(id)) })
  102. } catch (error: any) {
  103. if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {})))
  104. if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
  105. return reply.code(400).send({ message: (error as Error).message || '创建失败' })
  106. }
  107. })
  108. app.patch('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
  109. const row = await get((req.params as any).id)
  110. const permission = row && await access(req, row)
  111. if (!permission) return reply.code(404).send({ message: '讲师不存在' })
  112. if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
  113. const parsed = input.safeParse(req.body)
  114. if (!parsed.success) return reply.code(400).send({ message: '讲师资料不完整' })
  115. try {
  116. await pool.query('UPDATE digital_instructors SET name=$1,note=$2,updated_at=NOW() WHERE id=$3', [parsed.data.name,parsed.data.note,row.id])
  117. } catch (error: any) {
  118. if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
  119. throw error
  120. }
  121. await event(row.id,uid(req),'UPDATED')
  122. return { instructor: await dto(await get(row.id)) }
  123. })
  124. app.post('/api/digital-instructors/:id/manage', { preHandler: auth }, async (req, reply) => {
  125. const row = await get((req.params as any).id)
  126. const permission = row && await access(req, row)
  127. if (!permission) return reply.code(404).send({ message: '讲师不存在' })
  128. if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
  129. const fields: Record<string, string> = {}
  130. const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
  131. let committed = false
  132. try {
  133. for await (const part of req.parts()) {
  134. if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
  135. const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
  136. const mime = part.mimetype.toLowerCase()
  137. 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('照片或录音格式不支持')
  138. if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
  139. const buffer = await part.toBuffer()
  140. if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('文件为空或超过大小限制')
  141. const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
  142. await writeFile(resolve(uploadDir,stored),buffer)
  143. pending.push({ id: randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
  144. }
  145. const parsed = input.safeParse(fields)
  146. if (!parsed.success) throw new Error('请填写正确的讲师名称和备注')
  147. const removeIds = fields.removeImageIds ? JSON.parse(fields.removeImageIds) : []
  148. if (!Array.isArray(removeIds) || removeIds.some((id: unknown) => typeof id !== 'string' || !z.string().uuid().safeParse(id).success)) throw new Error('照片移除参数不正确')
  149. const ownerId = fields.ownerId || row.owner_id
  150. if (ownerId !== row.owner_id && permission !== 'MANAGER') { const forbidden = new Error('只有组织管理员可以更换所有者') as Error & { statusCode?: number }; forbidden.statusCode = 403; throw forbidden }
  151. if (ownerId !== row.owner_id && !(await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [row.organization_id,ownerId])).rows[0]) throw new Error('新所有者必须是本组织成员')
  152. const client = await pool.connect()
  153. try {
  154. await client.query('BEGIN')
  155. await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id])
  156. const activeImages = (await client.query("SELECT id FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' AND is_active=TRUE ORDER BY sort_order,created_at", [row.id])).rows as { id: string }[]
  157. if (removeIds.some((id: string) => !activeImages.some(image => image.id === id))) throw new Error('要移除的照片不存在')
  158. const retained = activeImages.filter(image => !removeIds.includes(image.id)).map(image => image.id)
  159. const newImages = pending.filter(file => file.kind === 'IMAGE')
  160. if (retained.length + newImages.length > 19) throw new Error('最多保留 19 张照片')
  161. const coverId = fields.coverImageId || (fields.coverNewIndex !== undefined ? newImages[Number(fields.coverNewIndex)]?.id : '')
  162. if (coverId && ![...retained,...newImages.map(file => file.id)].includes(coverId)) throw new Error('封面照片不存在')
  163. await client.query('UPDATE digital_instructors SET name=$1,note=$2,owner_id=$3,updated_at=NOW() WHERE id=$4', [parsed.data.name,parsed.data.note,ownerId,row.id])
  164. if (removeIds.length) await client.query("UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND id=ANY($2::uuid[]) AND kind='IMAGE'", [row.id,removeIds])
  165. if (pending.some(file => file.kind === 'AUDIO')) await client.query("UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND kind='AUDIO'", [row.id])
  166. for (const [index,file] of pending.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order)
  167. VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [file.id,row.id,file.kind,file.original,file.stored,file.size,file.mime,index+activeImages.length])
  168. const order = [...retained,...newImages.map(file => file.id)]
  169. if (coverId) order.unshift(...order.splice(order.indexOf(coverId),1))
  170. for (const [index,id] of order.entries()) await client.query('UPDATE digital_instructor_files SET sort_order=$1 WHERE id=$2', [index,id])
  171. await event(row.id,uid(req),'MANAGED',{ removedImageIds: removeIds, addedImageCount: newImages.length, replacedAudio: pending.some(file => file.kind === 'AUDIO'), ownerChanged: ownerId !== row.owner_id },client)
  172. await client.query('COMMIT')
  173. committed = true
  174. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  175. return { instructor: await dto(await get(row.id)) }
  176. } catch (error: any) {
  177. if (!committed) await Promise.all(pending.map(file => unlink(resolve(uploadDir,file.stored)).catch(() => {})))
  178. if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' })
  179. return reply.code(error.statusCode || 400).send({ message: error.message || '保存失败' })
  180. }
  181. })
  182. app.patch('/api/digital-instructors/:id/owner', { preHandler: auth }, async (req, reply) => {
  183. const row = await get((req.params as any).id)
  184. if (!row || await access(req,row) !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以更换所有者' })
  185. const parsed = z.object({ ownerId: z.string().uuid() }).safeParse(req.body)
  186. if (!parsed.success || !(await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [row.organization_id,parsed.data.ownerId])).rows[0]) return reply.code(400).send({ message: '新所有者必须是本组织成员' })
  187. await pool.query('UPDATE digital_instructors SET owner_id=$1,updated_at=NOW() WHERE id=$2', [parsed.data.ownerId,row.id])
  188. await event(row.id,uid(req),'OWNER_CHANGED',{ from: row.owner_id, to: parsed.data.ownerId })
  189. return { instructor: await dto(await get(row.id)) }
  190. })
  191. app.post('/api/digital-instructors/:id/copy-to-organization', { preHandler: auth }, async (req, reply) => {
  192. if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员没有个人资产' })
  193. const row = await get((req.params as any).id)
  194. if (!row || row.personal_user_id !== uid(req) || selected(req) !== 'personal') return reply.code(404).send({ message: '个人讲师不存在' })
  195. const parsed = z.object({ organizationId: z.string().uuid() }).safeParse(req.body)
  196. if (!parsed.success) return reply.code(400).send({ message: '请选择组织' })
  197. const member = await membership(req,parsed.data.organizationId)
  198. if (!member || !member.is_active) return reply.code(403).send({ message: '您不属于该组织或组织已停用' })
  199. const originalFiles = await files(row.id)
  200. const copies: Array<{ original: any; stored: string }> = []
  201. let committed = false
  202. try {
  203. for (const f of originalFiles) {
  204. const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}`
  205. await copyFile(resolve(uploadDir,f.stored_name),resolve(uploadDir,stored))
  206. copies.push({ original: f, stored })
  207. }
  208. const id = randomUUID()
  209. const client = await pool.connect()
  210. try {
  211. await client.query('BEGIN')
  212. await client.query(`INSERT INTO digital_instructors(id,organization_id,owner_id,creator_id,source_id,name,note) VALUES($1,$2,$3,$3,$4,$5,$6)`, [id,parsed.data.organizationId,uid(req),row.id,row.name,row.note])
  213. for (const [i, c] of copies.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(),id,c.original.kind,c.original.original_name,c.stored,c.original.size,c.original.mime_type,i])
  214. await event(id,uid(req),'COPIED_FROM_PERSONAL',{ sourceId: row.id },client)
  215. await client.query('COMMIT')
  216. committed = true
  217. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  218. return reply.code(201).send({ instructor: await dto(await get(id)) })
  219. } catch (error: any) {
  220. if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {})))
  221. if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请先修改讲师姓名' })
  222. return reply.code(500).send({ message: '复制失败,请重试' })
  223. }
  224. })
  225. app.post('/api/digital-instructors/:id/duplicate', { preHandler: auth }, async (req, reply) => {
  226. const row = await get((req.params as any).id)
  227. if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' })
  228. if (row.organization_id && !(await pool.query('SELECT is_active FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.is_active) {
  229. return reply.code(409).send({ message: '组织已停用,不能复制讲师' })
  230. }
  231. const originalFiles = await files(row.id)
  232. const copies: Array<{ original: any; stored: string }> = []
  233. let committed = false
  234. try {
  235. for (const f of originalFiles) {
  236. const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}`
  237. await copyFile(resolve(uploadDir, f.stored_name), resolve(uploadDir, stored))
  238. copies.push({ original: f, stored })
  239. }
  240. const id = randomUUID()
  241. const client = await pool.connect()
  242. try {
  243. await client.query('BEGIN')
  244. const base = row.name.slice(0, 72)
  245. let copyName = `${base}(副本)`
  246. if (row.organization_id) {
  247. await client.query('SELECT id FROM organizations WHERE id=$1 FOR UPDATE', [row.organization_id])
  248. for (let number = 2; (await client.query(`SELECT 1 FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL AND lower(btrim(name))=lower(btrim($2)) LIMIT 1`, [row.organization_id,copyName])).rows[0]; number++) {
  249. copyName = `${base}(副本${number})`
  250. }
  251. }
  252. const admin = (req as any).user.role === 'ADMIN'
  253. await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,admin_creator_id,source_id,name,note)
  254. VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [id,row.organization_id,row.personal_user_id ? uid(req) : null,admin ? row.owner_id : uid(req),admin ? null : uid(req),admin ? uid(req) : null,row.id,copyName,row.note])
  255. for (const [i,c] of copies.entries()) await client.query(`INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order)
  256. VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [randomUUID(),id,c.original.kind,c.original.original_name,c.stored,c.original.size,c.original.mime_type,i])
  257. await event(id,uid(req),'DUPLICATED',{ sourceId: row.id },client)
  258. await client.query('COMMIT')
  259. committed = true
  260. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  261. return reply.code(201).send({ instructor: await dto(await get(id)) })
  262. } catch {
  263. if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {})))
  264. return reply.code(500).send({ message: '复制失败,请重试' })
  265. }
  266. })
  267. app.post('/api/digital-instructors/:id/files', { preHandler: auth }, async (req, reply) => {
  268. const row = await get((req.params as any).id)
  269. const permission = row && await access(req,row)
  270. if (!permission) return reply.code(404).send({ message: '讲师不存在' })
  271. if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' })
  272. const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
  273. let committed = false
  274. try {
  275. for await (const part of req.parts()) {
  276. if (part.type !== 'file') continue
  277. const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
  278. const mime = part.mimetype.toLowerCase()
  279. 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('照片或录音格式不支持')
  280. if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音')
  281. const buffer = await part.toBuffer()
  282. if (!buffer.length) throw new Error('文件不能为空')
  283. if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB')
  284. const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
  285. await writeFile(resolve(uploadDir,stored),buffer)
  286. pending.push({ kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
  287. }
  288. if (!pending.length) throw new Error('请选择新文件')
  289. const client = await pool.connect()
  290. try {
  291. await client.query('BEGIN')
  292. for (const kind of new Set(pending.map(f => f.kind))) await client.query('UPDATE digital_instructor_files SET is_active=FALSE WHERE instructor_id=$1 AND kind=$2', [row.id,kind])
  293. for (const [i,f] of pending.entries()) await client.query('INSERT INTO digital_instructor_files(id,instructor_id,kind,original_name,stored_name,size,mime_type,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7,$8)', [randomUUID(),row.id,f.kind,f.original,f.stored,f.size,f.mime,i])
  294. await client.query('UPDATE digital_instructors SET updated_at=NOW() WHERE id=$1', [row.id])
  295. await event(row.id,uid(req),'FILES_REPLACED',{ kinds:[...new Set(pending.map(f => f.kind))] },client)
  296. await client.query('COMMIT')
  297. committed = true
  298. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  299. return { instructor: await dto(await get(row.id)) }
  300. } catch (error) {
  301. if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {})))
  302. return reply.code(400).send({ message:(error as Error).message || '上传失败' })
  303. }
  304. })
  305. app.delete('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => {
  306. const row = await get((req.params as any).id)
  307. const permission = row && await access(req,row)
  308. if (!permission) return reply.code(404).send({ message: '讲师不存在' })
  309. if (row.organization_id && permission !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以删除' })
  310. const client = await pool.connect()
  311. try {
  312. await client.query('BEGIN')
  313. await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id])
  314. const used = (await client.query('SELECT 1 FROM courses WHERE digital_instructor_id=$1 LIMIT 1', [row.id])).rows[0]
  315. if (used) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '已有课程使用该讲师,请先为相关课程更换讲师' }) }
  316. await event(row.id,uid(req),'DELETED',{ name: row.name },client)
  317. await client.query('UPDATE digital_instructors SET deleted_at=NOW(),updated_at=NOW() WHERE id=$1', [row.id])
  318. await client.query('COMMIT')
  319. } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
  320. return { ok: true }
  321. })
  322. return { dto, get, files }
  323. }