|
|
@@ -3,20 +3,25 @@ import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
|
|
|
import cors from '@fastify/cors'
|
|
|
import multipart from '@fastify/multipart'
|
|
|
import staticPlugin from '@fastify/static'
|
|
|
-import { createWriteStream } from 'node:fs'
|
|
|
-import { mkdir, readFile, unlink } from 'node:fs/promises'
|
|
|
+import { createReadStream, createWriteStream, existsSync } from 'node:fs'
|
|
|
+import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
|
import { pipeline } from 'node:stream/promises'
|
|
|
-import { resolve, extname, dirname, join } from 'node:path'
|
|
|
+import { resolve, extname, dirname, join, basename } from 'node:path'
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'
|
|
|
+import { homedir, tmpdir } from 'node:os'
|
|
|
+import { execFile } from 'node:child_process'
|
|
|
import { promisify } from 'node:util'
|
|
|
import { z } from 'zod'
|
|
|
+import sanitizeHtml from 'sanitize-html'
|
|
|
import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
|
|
|
import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
|
|
|
import { registerOrganizations } from './organizations.js'
|
|
|
import { registerDigitalInstructors } from './digital-instructors.js'
|
|
|
+import { extractPptxSpeakerNotes } from './pptx-notes.js'
|
|
|
|
|
|
const scrypt = promisify(scryptCb)
|
|
|
+const execFileAsync = promisify(execFile)
|
|
|
const app = Fastify({ logger: true })
|
|
|
const port = Number(process.env.PORT || 3001)
|
|
|
const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
|
|
|
@@ -264,6 +269,9 @@ app.addHook('onSend', async (req, reply, payload) => {
|
|
|
})
|
|
|
|
|
|
app.addHook('preHandler', async (req, reply) => {
|
|
|
+ // 只读 GET 请求不在此处因工作区暂不匹配而强阻断,以便前端可感知课程真实工作区并自动校准对齐(后续业务层已由 ownedCourse 确保访问安全)
|
|
|
+ if (req.method === 'GET') return
|
|
|
+
|
|
|
const match = req.url.match(/^\/api\/courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
|
|
|
if (!match) return
|
|
|
const { rows } = await pool.query('SELECT organization_id FROM courses WHERE id=$1', [match[1]])
|
|
|
@@ -310,6 +318,84 @@ app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) =>
|
|
|
return reply.sendFile(stored, uploadDir)
|
|
|
})
|
|
|
|
|
|
+const officePreviewExtensions = new Set(['.pptx', '.ppt', '.docx', '.doc'])
|
|
|
+const previewJobs = new Map<string, Promise<string>>()
|
|
|
+const previewCacheVersion = 'v2'
|
|
|
+const bundledSoffice = join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice')
|
|
|
+const sofficeCommand = process.env.SOFFICE_PATH || (existsSync(bundledSoffice) ? bundledSoffice : 'soffice')
|
|
|
+
|
|
|
+async function officePreview(stored: string) {
|
|
|
+ const previewDir = resolve(uploadDir, '.previews')
|
|
|
+ const destination = resolve(previewDir, `${stored}.${previewCacheVersion}.pdf`)
|
|
|
+ if (await stat(destination).then((s) => s.size > 0).catch(() => false)) return destination
|
|
|
+ const existing = previewJobs.get(stored)
|
|
|
+ if (existing) return existing
|
|
|
+ const job = (async () => {
|
|
|
+ await mkdir(previewDir, { recursive: true })
|
|
|
+ const workDir = await mkdtemp(join(tmpdir(), 'xinghen-preview-'))
|
|
|
+ const staging = resolve(previewDir, stored + '.' + randomUUID() + '.tmp')
|
|
|
+ try {
|
|
|
+ const input = join(workDir, basename(stored))
|
|
|
+ await copyFile(resolve(uploadDir, stored), input)
|
|
|
+ let conversionEnv = process.env
|
|
|
+ if (process.platform === 'darwin' && !process.env.FONTCONFIG_FILE) {
|
|
|
+ const fontConfig = join(workDir, 'fonts.conf')
|
|
|
+ await writeFile(fontConfig, `<?xml version="1.0"?>
|
|
|
+<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
|
|
+<fontconfig>
|
|
|
+ <dir>/System/Library/Fonts</dir><dir>/System/Library/Fonts/Supplemental</dir><dir>/Library/Fonts</dir>
|
|
|
+ <cachedir>${join(workDir, 'font-cache')}</cachedir>
|
|
|
+ <match target="pattern"><test name="family" compare="eq"><string>微软雅黑</string></test><edit name="family" mode="assign"><string>Hiragino Sans GB</string></edit></match>
|
|
|
+</fontconfig>`)
|
|
|
+ conversionEnv = { ...process.env, FONTCONFIG_FILE: fontConfig }
|
|
|
+ }
|
|
|
+ await execFileAsync(sofficeCommand, [
|
|
|
+ '-env:UserInstallation=' + new URL('profile/', `file://${workDir}/`).href,
|
|
|
+ '--headless', '--convert-to', 'pdf', '--outdir', workDir, input
|
|
|
+ ], { timeout: 120_000, maxBuffer: 1024 * 1024, env: conversionEnv })
|
|
|
+ const converted = join(workDir, basename(stored, extname(stored)) + '.pdf')
|
|
|
+ if (!(await stat(converted).then((s) => s.size > 0).catch(() => false))) throw new Error('文档转换没有生成预览文件')
|
|
|
+ await copyFile(converted, staging)
|
|
|
+ await rename(staging, destination)
|
|
|
+ return destination
|
|
|
+ } finally {
|
|
|
+ await unlink(staging).catch(() => {})
|
|
|
+ await rm(workDir, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+ })()
|
|
|
+ previewJobs.set(stored, job)
|
|
|
+ try { return await job } finally { previewJobs.delete(stored) }
|
|
|
+}
|
|
|
+
|
|
|
+app.get('/api/courses/:id/assets/:assetId/preview', { preHandler: accountAuth }, async (req, reply) => {
|
|
|
+ const { id, assetId } = req.params as { id: string; assetId: string }
|
|
|
+ const { rows } = await pool.query(`SELECT a.stored_name, a.original_name, c.user_id, c.organization_id
|
|
|
+ FROM course_assets a JOIN courses c ON c.id=a.course_id WHERE a.id=$1 AND a.course_id=$2`, [assetId, id])
|
|
|
+ const asset = rows[0]
|
|
|
+ if (!asset) return reply.code(404).send({ message: '素材不存在' })
|
|
|
+ if ((req as any).user.role !== 'ADMIN') {
|
|
|
+ const membership = asset.organization_id ? await pool.query(
|
|
|
+ 'SELECT role FROM organization_members WHERE organization_id=$1 AND user_id=$2',
|
|
|
+ [asset.organization_id, userId(req)]
|
|
|
+ ) : null
|
|
|
+ const member = membership?.rows[0]
|
|
|
+ const allowed = asset.organization_id
|
|
|
+ ? !!member && (member.role === 'MANAGER' || asset.user_id === userId(req))
|
|
|
+ : asset.user_id === userId(req)
|
|
|
+ if (!allowed) return reply.code(403).send({ message: '无权预览该素材' })
|
|
|
+ }
|
|
|
+ if (!officePreviewExtensions.has(extname(asset.original_name).toLowerCase())) {
|
|
|
+ return reply.code(415).send({ message: '该文件无需转换预览' })
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const path = await officePreview(asset.stored_name)
|
|
|
+ return reply.type('application/pdf').header('Cache-Control', 'private, no-store').send(createReadStream(path))
|
|
|
+ } catch (error) {
|
|
|
+ req.log.error({ err: error, assetId }, 'office preview failed')
|
|
|
+ return reply.code(503).send({ message: '预览生成失败,请下载原文件查看' })
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
app.get('/api/health', async () => ({ ok: true }))
|
|
|
|
|
|
// ==================== 认证相关 ====================
|
|
|
@@ -437,6 +523,7 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
|
|
|
const courseInput = z.object({
|
|
|
name: z.string().trim().min(1, '请输入课程名称').max(100),
|
|
|
description: z.string().max(500).default(''),
|
|
|
+ assetNotes: z.string().max(5000).default(''),
|
|
|
digitalInstructorId: z.string().uuid().nullable().optional()
|
|
|
})
|
|
|
|
|
|
@@ -541,19 +628,31 @@ app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
|
|
|
const row = await ownedCourse(id, userId(req))
|
|
|
if (!row) return reply.code(404).send({ message: '课程不存在' })
|
|
|
|
|
|
- const p = courseInput.pick({ name: true, description: true, digitalInstructorId: true }).safeParse(req.body)
|
|
|
+ const p = z.object({
|
|
|
+ name: z.string().trim().min(1, '请输入课程名称').max(100).optional(),
|
|
|
+ description: z.string().max(500).optional(),
|
|
|
+ assetNotes: z.string().max(5000).optional(),
|
|
|
+ digitalInstructorId: z.string().uuid().nullable().optional()
|
|
|
+ }).safeParse(req.body)
|
|
|
+
|
|
|
if (!p.success) {
|
|
|
return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
|
|
|
}
|
|
|
|
|
|
if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
|
|
|
if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
|
|
|
+
|
|
|
+ const newName = p.data.name !== undefined ? p.data.name : row.name
|
|
|
+ const newDesc = p.data.description !== undefined ? p.data.description : row.description
|
|
|
+ const newAssetNotes = p.data.assetNotes !== undefined ? p.data.assetNotes : (row.asset_notes || '')
|
|
|
+ const newDigitalInstructorId = p.data.digitalInstructorId !== undefined ? (p.data.digitalInstructorId || null) : row.digital_instructor_id
|
|
|
+
|
|
|
const { rows } = await pool.query(
|
|
|
- 'UPDATE courses SET name = $1, description = $2, digital_instructor_id=COALESCE($4,digital_instructor_id), updated_at = NOW() WHERE id = $3 RETURNING *',
|
|
|
- [p.data.name, p.data.description, id, p.data.digitalInstructorId || null]
|
|
|
+ 'UPDATE courses SET name = $1, description = $2, asset_notes = $3, digital_instructor_id = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
|
|
|
+ [newName, newDesc, newAssetNotes, newDigitalInstructorId, id]
|
|
|
)
|
|
|
await refreshCoursePoints(id)
|
|
|
- await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称或需求说明已更新。')
|
|
|
+ await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、需求说明或通用资产备注已更新。')
|
|
|
return { course: await fullCourse(rows[0]) }
|
|
|
})
|
|
|
|
|
|
@@ -588,7 +687,11 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
|
|
|
// 1. 清理该课程关联的所有素材文件
|
|
|
const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
|
|
|
for (const ast of assetsRes.rows) {
|
|
|
- if (ast.stored_name) await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
|
|
|
+ if (ast.stored_name) {
|
|
|
+ await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
|
|
|
+ await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
|
|
|
+ await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 2. 清理讲师头像图片
|
|
|
@@ -667,8 +770,29 @@ app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) =>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ let notesImported = 0
|
|
|
+ const pptxFiles = fileList.filter(f => extname(f.filename).toLowerCase() === '.pptx')
|
|
|
+ if (pptxFiles.length === 1) {
|
|
|
+ const episodes = await pool.query('SELECT id,lecture_notes FROM episodes WHERE course_id=$1 ORDER BY episode_number,created_at', [id])
|
|
|
+ const target = episodeId ? episodes.rows.find(ep => ep.id === episodeId) : episodes.rows.length === 1 ? episodes.rows[0] : null
|
|
|
+ if (target && !target.lecture_notes.trim()) {
|
|
|
+ try {
|
|
|
+ const notes = (await extractPptxSpeakerNotes(resolve(uploadDir, pptxFiles[0].stored)))
|
|
|
+ .map((note, index) => note ? `第 ${index + 1} 页\n${note}` : '')
|
|
|
+ .filter(Boolean)
|
|
|
+ if (notes.length) {
|
|
|
+ const combined = notes.join('\n\n')
|
|
|
+ const updated = await pool.query("UPDATE episodes SET lecture_notes=$1,updated_at=NOW() WHERE id=$2 AND course_id=$3 AND TRIM(lecture_notes)='' RETURNING id", [combined, target.id, id])
|
|
|
+ if (updated.rows[0]) notesImported = notes.length
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ req.log.warn({ err: error }, 'pptx speaker notes extraction failed')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
|
|
|
- return { course: await fullCourse(row) }
|
|
|
+ return { course: await fullCourse(row), notesImported }
|
|
|
})
|
|
|
|
|
|
app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
|
|
|
@@ -680,7 +804,11 @@ app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req,
|
|
|
'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
|
|
|
[assetId, id]
|
|
|
)
|
|
|
- if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
|
|
|
+ if (rows[0]) {
|
|
|
+ await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
|
|
|
+ await unlink(resolve(uploadDir, '.previews', rows[0].stored_name + '.pdf')).catch(() => {})
|
|
|
+ await unlink(resolve(uploadDir, '.previews', `${rows[0].stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
|
|
|
+ }
|
|
|
await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
|
|
|
return { course: await fullCourse(row) }
|
|
|
})
|
|
|
@@ -795,7 +923,14 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
|
|
|
const newTitle = p.data.title !== undefined ? p.data.title : cur.title
|
|
|
const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
|
|
|
const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
|
|
|
- const newNotes = p.data.lectureNotes !== undefined ? p.data.lectureNotes : cur.lecture_notes
|
|
|
+ const richPrefix = '<!--xinghen-richtext-->'
|
|
|
+ const submittedNotes = p.data.lectureNotes
|
|
|
+ const newNotes = submittedNotes === undefined ? cur.lecture_notes : submittedNotes.startsWith(richPrefix)
|
|
|
+ ? richPrefix + sanitizeHtml(submittedNotes.slice(richPrefix.length), {
|
|
|
+ allowedTags: ['p', 'div', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li'],
|
|
|
+ allowedAttributes: {}
|
|
|
+ })
|
|
|
+ : submittedNotes
|
|
|
|
|
|
await pool.query(
|
|
|
'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
|
|
|
@@ -825,6 +960,8 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
|
|
|
const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
|
|
|
for (const ast of astRes.rows) {
|
|
|
await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
|
|
|
+ await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
|
|
|
+ await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
|
|
|
}
|
|
|
|
|
|
await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
|