import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import type pg from 'pg' import { randomUUID } from 'node:crypto' import { copyFile, unlink, writeFile } from 'node:fs/promises' import { extname, resolve } from 'node:path' import { z } from 'zod' type Dependencies = { pool: pg.Pool; auth: (req: FastifyRequest, reply: FastifyReply) => Promise; uploadDir: string } const input = z.object({ name: z.string().trim().min(1).max(80), note: z.string().trim().max(1000).default('') }) const uid = (req: FastifyRequest) => (req as any).user.id as string const selected = (req: FastifyRequest) => String(req.headers['x-workspace-id'] || 'personal') export function registerDigitalInstructors(app: FastifyInstance, { pool, auth, uploadDir }: Dependencies) { async function membership(req: FastifyRequest, organizationId: string) { 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 } async function access(req: FastifyRequest, row: any) { if ((req as any).user.role === 'ADMIN' && row.organization_id && selected(req) === row.organization_id) return 'MANAGER' if (row.personal_user_id) return row.personal_user_id === uid(req) && selected(req) === 'personal' ? 'OWNER' : null if (selected(req) !== row.organization_id) return null const member = await membership(req, row.organization_id) if (!member) return null return member.role === 'MANAGER' ? 'MANAGER' : row.owner_id === uid(req) ? 'OWNER' : 'VIEWER' } async function get(id: string) { return (await pool.query('SELECT * FROM digital_instructors WHERE id=$1 AND deleted_at IS NULL', [id])).rows[0] } 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 } async function dto(row: any) { const fileRows = await files(row.id) const usageCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM courses WHERE digital_instructor_id=$1', [row.id])).rows[0].count) const creator = row.admin_creator_id ? (await pool.query('SELECT contact_name FROM platform_admins WHERE id=$1', [row.admin_creator_id])).rows[0] : (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.creator_id])).rows[0] const owner = (await pool.query('SELECT contact_name FROM users WHERE id=$1', [row.owner_id])).rows[0] return { id: row.id, name: row.name, note: row.note, organizationId: row.organization_id, personalUserId: row.personal_user_id, ownerId: row.owner_id, ownerName: owner?.contact_name || '未知用户', creatorId: row.admin_creator_id || row.creator_id, creatorName: creator?.contact_name || '未知用户', createdAt: row.created_at, updatedAt: row.updated_at, sourceId: row.source_id, qualityStatus: row.quality_status, usageCount, images: fileRows.filter(f => f.kind === 'IMAGE').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })), audio: fileRows.filter(f => f.kind === 'AUDIO').map(f => ({ id: f.id, name: f.original_name, url: `/uploads/${f.stored_name}` })), ready: fileRows.some(f => f.kind === 'IMAGE') && fileRows.some(f => f.kind === 'AUDIO') } } async function event(id: string, actor: string, action: string, details: object = {}, client: pg.PoolClient | pg.Pool = pool) { const admin = (await client.query('SELECT 1 FROM platform_admins WHERE id=$1', [actor])).rows[0] 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)', [randomUUID(), id, admin ? null : actor, admin ? actor : null, action, JSON.stringify(details)]) } app.get('/api/digital-instructors', { preHandler: auth }, async (req, reply) => { const workspace = selected(req) if (workspace !== 'personal' && (req as any).user.role !== 'ADMIN' && !await membership(req, workspace)) return reply.code(403).send({ message: '无权访问该组织资产库' }) const rows = workspace === 'personal' ? (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 : (await pool.query('SELECT * FROM digital_instructors WHERE organization_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC', [workspace])).rows return { instructors: await Promise.all(rows.map(dto)) } }) app.get('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' }) return { instructor: await dto(row) } }) app.get('/api/digital-instructors/:id/usage', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' }) const { rows } = await pool.query('SELECT id,name,status FROM courses WHERE digital_instructor_id=$1 ORDER BY created_at DESC', [row.id]) return { courses: rows } }) app.post('/api/digital-instructors', { preHandler: auth }, async (req, reply) => { if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员不能创建普通用户资产' }) const workspace = selected(req) if (workspace !== 'personal') { const member = await membership(req, workspace) if (!member || !member.is_active) return reply.code(403).send({ message: '无权在该组织创建讲师' }) } const fields: Record = {} const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = [] let committed = false try { for await (const part of req.parts()) { if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue } const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null const mime = part.mimetype.toLowerCase() 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('照片或录音格式不支持') if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音') const buffer = await part.toBuffer() if (!buffer.length) throw new Error('上传文件不能为空') if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB') const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}` await writeFile(resolve(uploadDir, stored), buffer) pending.push({ kind, original: part.filename.slice(0,255), stored, size: buffer.length, mime }) } const parsed = input.safeParse(fields) if (!parsed.success) throw new Error('请填写讲师姓名') const id = randomUUID() const client = await pool.connect() try { await client.query('BEGIN') 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]) const coverNewIndex = fields.coverNewIndex === undefined ? -1 : Number(fields.coverNewIndex) 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]) await event(id, uid(req), 'CREATED', {}, client) await client.query('COMMIT') committed = true } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return reply.code(201).send({ instructor: await dto(await get(id)) }) } catch (error: any) { if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {}))) if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' }) return reply.code(400).send({ message: (error as Error).message || '创建失败' }) } }) app.patch('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) const permission = row && await access(req, row) if (!permission) return reply.code(404).send({ message: '讲师不存在' }) if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' }) const parsed = input.safeParse(req.body) if (!parsed.success) return reply.code(400).send({ message: '讲师资料不完整' }) try { 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]) } catch (error: any) { if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' }) throw error } await event(row.id,uid(req),'UPDATED') return { instructor: await dto(await get(row.id)) } }) app.post('/api/digital-instructors/:id/manage', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) const permission = row && await access(req, row) if (!permission) return reply.code(404).send({ message: '讲师不存在' }) if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' }) const fields: Record = {} const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = [] let committed = false try { for await (const part of req.parts()) { if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue } const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null const mime = part.mimetype.toLowerCase() 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('照片或录音格式不支持') if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音') const buffer = await part.toBuffer() if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('文件为空或超过大小限制') const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}` await writeFile(resolve(uploadDir,stored),buffer) pending.push({ id: randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime }) } const parsed = input.safeParse(fields) if (!parsed.success) throw new Error('请填写正确的讲师名称和备注') const removeIds = fields.removeImageIds ? JSON.parse(fields.removeImageIds) : [] if (!Array.isArray(removeIds) || removeIds.some((id: unknown) => typeof id !== 'string' || !z.string().uuid().safeParse(id).success)) throw new Error('照片移除参数不正确') const ownerId = fields.ownerId || row.owner_id if (ownerId !== row.owner_id && permission !== 'MANAGER') { const forbidden = new Error('只有组织管理员可以更换所有者') as Error & { statusCode?: number }; forbidden.statusCode = 403; throw forbidden } 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('新所有者必须是本组织成员') const client = await pool.connect() try { await client.query('BEGIN') await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id]) 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 }[] if (removeIds.some((id: string) => !activeImages.some(image => image.id === id))) throw new Error('要移除的照片不存在') const retained = activeImages.filter(image => !removeIds.includes(image.id)).map(image => image.id) const newImages = pending.filter(file => file.kind === 'IMAGE') if (retained.length + newImages.length > 19) throw new Error('最多保留 19 张照片') const coverId = fields.coverImageId || (fields.coverNewIndex !== undefined ? newImages[Number(fields.coverNewIndex)]?.id : '') if (coverId && ![...retained,...newImages.map(file => file.id)].includes(coverId)) throw new Error('封面照片不存在') 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]) 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]) 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]) 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) 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]) const order = [...retained,...newImages.map(file => file.id)] if (coverId) order.unshift(...order.splice(order.indexOf(coverId),1)) for (const [index,id] of order.entries()) await client.query('UPDATE digital_instructor_files SET sort_order=$1 WHERE id=$2', [index,id]) 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) await client.query('COMMIT') committed = true } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return { instructor: await dto(await get(row.id)) } } catch (error: any) { if (!committed) await Promise.all(pending.map(file => unlink(resolve(uploadDir,file.stored)).catch(() => {}))) if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请换一个名字' }) return reply.code(error.statusCode || 400).send({ message: error.message || '保存失败' }) } }) app.patch('/api/digital-instructors/:id/owner', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) if (!row || await access(req,row) !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以更换所有者' }) const parsed = z.object({ ownerId: z.string().uuid() }).safeParse(req.body) 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: '新所有者必须是本组织成员' }) await pool.query('UPDATE digital_instructors SET owner_id=$1,updated_at=NOW() WHERE id=$2', [parsed.data.ownerId,row.id]) await event(row.id,uid(req),'OWNER_CHANGED',{ from: row.owner_id, to: parsed.data.ownerId }) return { instructor: await dto(await get(row.id)) } }) app.post('/api/digital-instructors/:id/copy-to-organization', { preHandler: auth }, async (req, reply) => { if ((req as any).user.role === 'ADMIN') return reply.code(403).send({ message: '平台管理员没有个人资产' }) const row = await get((req.params as any).id) if (!row || row.personal_user_id !== uid(req) || selected(req) !== 'personal') return reply.code(404).send({ message: '个人讲师不存在' }) const parsed = z.object({ organizationId: z.string().uuid() }).safeParse(req.body) if (!parsed.success) return reply.code(400).send({ message: '请选择组织' }) const member = await membership(req,parsed.data.organizationId) if (!member || !member.is_active) return reply.code(403).send({ message: '您不属于该组织或组织已停用' }) const originalFiles = await files(row.id) const copies: Array<{ original: any; stored: string }> = [] let committed = false try { for (const f of originalFiles) { const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}` await copyFile(resolve(uploadDir,f.stored_name),resolve(uploadDir,stored)) copies.push({ original: f, stored }) } const id = randomUUID() const client = await pool.connect() try { await client.query('BEGIN') 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]) 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]) await event(id,uid(req),'COPIED_FROM_PERSONAL',{ sourceId: row.id },client) await client.query('COMMIT') committed = true } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return reply.code(201).send({ instructor: await dto(await get(id)) }) } catch (error: any) { if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {}))) if (error.code === '23505' && error.constraint === 'digital_instructors_org_name_unique') return reply.code(409).send({ message: '这个组织已有同名讲师,请先修改讲师姓名' }) return reply.code(500).send({ message: '复制失败,请重试' }) } }) app.post('/api/digital-instructors/:id/duplicate', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) if (!row || !await access(req, row)) return reply.code(404).send({ message: '讲师不存在' }) if (row.organization_id && !(await pool.query('SELECT is_active FROM organizations WHERE id=$1', [row.organization_id])).rows[0]?.is_active) { return reply.code(409).send({ message: '组织已停用,不能复制讲师' }) } const originalFiles = await files(row.id) const copies: Array<{ original: any; stored: string }> = [] let committed = false try { for (const f of originalFiles) { const stored = `digital-instructor-${randomUUID()}${extname(f.stored_name)}` await copyFile(resolve(uploadDir, f.stored_name), resolve(uploadDir, stored)) copies.push({ original: f, stored }) } const id = randomUUID() const client = await pool.connect() try { await client.query('BEGIN') const base = row.name.slice(0, 72) let copyName = `${base}(副本)` if (row.organization_id) { await client.query('SELECT id FROM organizations WHERE id=$1 FOR UPDATE', [row.organization_id]) 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++) { copyName = `${base}(副本${number})` } } const admin = (req as any).user.role === 'ADMIN' await client.query(`INSERT INTO digital_instructors(id,organization_id,personal_user_id,owner_id,creator_id,admin_creator_id,source_id,name,note) 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]) 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]) await event(id,uid(req),'DUPLICATED',{ sourceId: row.id },client) await client.query('COMMIT') committed = true } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return reply.code(201).send({ instructor: await dto(await get(id)) }) } catch { if (!committed) await Promise.all(copies.map(c => unlink(resolve(uploadDir,c.stored)).catch(() => {}))) return reply.code(500).send({ message: '复制失败,请重试' }) } }) app.post('/api/digital-instructors/:id/files', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) const permission = row && await access(req,row) if (!permission) return reply.code(404).send({ message: '讲师不存在' }) if (permission === 'VIEWER') return reply.code(403).send({ message: '只有所有者或组织管理员可以编辑' }) const pending: Array<{ kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = [] let committed = false try { for await (const part of req.parts()) { if (part.type !== 'file') continue const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null const mime = part.mimetype.toLowerCase() 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('照片或录音格式不支持') if (kind === 'AUDIO' && pending.some(f => f.kind === 'AUDIO')) throw new Error('每位讲师只能上传一份录音') const buffer = await part.toBuffer() if (!buffer.length) throw new Error('文件不能为空') if (buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error(kind === 'IMAGE' ? '单张照片不能超过 20 MB' : '录音不能超过 50 MB') const stored = `digital-instructor-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}` await writeFile(resolve(uploadDir,stored),buffer) pending.push({ kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime }) } if (!pending.length) throw new Error('请选择新文件') const client = await pool.connect() try { await client.query('BEGIN') 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]) 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]) await client.query('UPDATE digital_instructors SET updated_at=NOW() WHERE id=$1', [row.id]) await event(row.id,uid(req),'FILES_REPLACED',{ kinds:[...new Set(pending.map(f => f.kind))] },client) await client.query('COMMIT') committed = true } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return { instructor: await dto(await get(row.id)) } } catch (error) { if (!committed) await Promise.all(pending.map(f => unlink(resolve(uploadDir,f.stored)).catch(() => {}))) return reply.code(400).send({ message:(error as Error).message || '上传失败' }) } }) app.delete('/api/digital-instructors/:id', { preHandler: auth }, async (req, reply) => { const row = await get((req.params as any).id) const permission = row && await access(req,row) if (!permission) return reply.code(404).send({ message: '讲师不存在' }) if (row.organization_id && permission !== 'MANAGER') return reply.code(403).send({ message: '只有组织管理员可以删除' }) const client = await pool.connect() try { await client.query('BEGIN') await client.query('SELECT id FROM digital_instructors WHERE id=$1 FOR UPDATE', [row.id]) const used = (await client.query('SELECT 1 FROM courses WHERE digital_instructor_id=$1 LIMIT 1', [row.id])).rows[0] if (used) { await client.query('ROLLBACK'); return reply.code(409).send({ message: '已有课程使用该讲师,请先为相关课程更换讲师' }) } await event(row.id,uid(req),'DELETED',{ name: row.name },client) await client.query('UPDATE digital_instructors SET deleted_at=NOW(),updated_at=NOW() WHERE id=$1', [row.id]) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } return { ok: true } }) return { dto, get, files } }