import Fastify from 'fastify' 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 { pipeline } from 'node:stream/promises' import { resolve, extname, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto' import { promisify } from 'node:util' import { z } from 'zod' import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js' const scrypt = promisify(scryptCb) const app = Fastify({ logger: true }) const port = Number(process.env.PORT || 3001) const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads') const maxBytes = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024 await mkdir(uploadDir, { recursive: true }) const here = dirname(fileURLToPath(import.meta.url)) await pool.query(await readFile(join(here, 'schema.sql'), 'utf8')) // 检查是否存在管理员账号,若不存在则创建默认演示管理员 async function initDefaultAdmin() { try { const { rows } = await pool.query("SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1") if (rows.length === 0) { const defaultPhone = process.env.ADMIN_PHONE || '18888888888' const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123456' const existing = await pool.query('SELECT id FROM users WHERE phone = $1', [defaultPhone]) const salt = randomBytes(16).toString('hex') const key = (await scrypt(defaultPassword, salt, 64)) as Buffer const hash = 'scrypt$' + salt + '$' + key.toString('hex') if (existing.rows.length > 0) { await pool.query("UPDATE users SET role = 'ADMIN' WHERE id = $1", [existing.rows[0].id]) app.log.info(`Updated existing user ${defaultPhone} to ADMIN role`) } else { const id = randomUUID() await pool.query( "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)", [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号'] ) app.log.info(`Initialized default admin account: ${defaultPhone} / ${defaultPassword}`) } } } catch (err) { app.log.error(err, 'Failed to initialize default admin account') } } await initDefaultAdmin() await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true }) await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } }) await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false }) const sha = (v: string) => createHash('sha256').update(v).digest('hex') async function hashPassword(p: string) { const salt = randomBytes(16).toString('hex') const key = (await scrypt(p, salt, 64)) as Buffer return 'scrypt$' + salt + '$' + key.toString('hex') } async function verifyPassword(p: string, stored: string) { const [, salt, hex] = stored.split('$') if (!salt || !hex) return false const a = (await scrypt(p, salt, 64)) as Buffer const b = Buffer.from(hex, 'hex') return a.length === b.length && timingSafeEqual(a, b) } async function session(uid: string) { const token = randomBytes(32).toString('hex') await pool.query("INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [ sha(token), uid ]) return token } async function auth(req: FastifyRequest, reply: FastifyReply) { const token = req.headers.authorization?.replace(/^Bearer\s+/i, '') if (!token) return reply.code(401).send({ message: '请先登录' }) const { rows } = await pool.query( 'SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()', [sha(token)] ) if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' }) ;(req as any).user = rows[0] } async function adminAuth(req: FastifyRequest, reply: FastifyReply) { await auth(req, reply) if (reply.sent) return const user = (req as any).user if (!user || user.role !== 'ADMIN') { return reply.code(403).send({ message: '权限不足:仅管理员可访问此接口' }) } } const userId = (r: any) => r.user.id const phonePassword = z.object({ phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'), password: z.string().min(6, '密码至少 6 位').max(72) }) const fullCourse = async (row: any, includeCreator = false) => { const [a, i, d] = await Promise.all([ pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]), pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]), pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]) ]) let creatorUser = null if (includeCreator && row.user_id) { const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id]) creatorUser = u.rows[0] || null } return mapCourse(row, a.rows, i.rows, d.rows, creatorUser) } const ownedCourse = async (id: string, uid: string) => (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0] app.get('/api/health', async () => ({ ok: true })) // ==================== 认证相关 ==================== app.post('/api/auth/register', async (req, reply) => { const p = phonePassword.safeParse(req.body) if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message }) try { const id = randomUUID() const hash = await hashPassword(p.data.password) const { rows } = await pool.query( 'INSERT INTO users(id, phone, password_hash, role) VALUES($1, $2, $3, $4) RETURNING *', [id, p.data.phone, hash, 'USER'] ) return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) }) } catch (e: any) { if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' }) throw e } }) app.post('/api/auth/login', async (req, reply) => { const p = phonePassword.safeParse(req.body) if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' }) const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone]) if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) { return reply.code(401).send({ message: '手机号或密码错误' }) } return { token: await session(rows[0].id), user: mapUser(rows[0]) } }) app.post('/api/admin/auth/login', async (req, reply) => { const p = phonePassword.safeParse(req.body) if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' }) const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone]) if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) { return reply.code(401).send({ message: '管理员账号或密码错误' }) } if (rows[0].role !== 'ADMIN') { return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' }) } return { token: await session(rows[0].id), user: mapUser(rows[0]) } }) app.post('/api/auth/logout', { preHandler: auth }, async (req) => { const token = req.headers.authorization!.replace(/^Bearer\s+/i, '') await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)]) return { ok: true } }) app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) })) app.patch('/api/me', { preHandler: auth }, async (req, reply) => { const p = z .object({ organization: z.string().max(120), wechat: z.string().max(80), contactName: z.string().max(80), bio: z.string().max(500) }) .safeParse(req.body) if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' }) const d = p.data const { rows } = await pool.query( 'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *', [d.organization, d.wechat, d.contactName, d.bio, userId(req)] ) return { user: mapUser(rows[0]) } }) app.post('/api/me/password', { preHandler: auth }, async (req, reply) => { const p = z .object({ currentPassword: z.string(), newPassword: z.string().min(6).max(72) }) .safeParse(req.body) if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' }) if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) { return reply.code(400).send({ message: '当前密码不正确' }) } await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [ await hashPassword(p.data.newPassword), userId(req) ]) return { ok: true } }) // ==================== 普通用户课程操作 ==================== const courseInput = z.object({ name: z.string().trim().min(1, '请输入课程名称').max(100), category: z.string().max(50).default(''), audience: z.string().max(100).default(''), description: z.string().max(500).default('') }) app.get('/api/courses', { preHandler: auth }, async (req) => { const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [ userId(req) ]) return { courses: await Promise.all(rows.map((r) => fullCourse(r))) } }) app.post('/api/courses', { preHandler: auth }, async (req, reply) => { const p = courseInput.safeParse(req.body) if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' }) const id = randomUUID() const d = p.data const { rows } = await pool.query( 'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *', [id, userId(req), d.name, d.category, d.audience, d.description] ) return reply.code(201).send({ course: await fullCourse(rows[0]) }) }) app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => { const { id } = req.params as any const row = await ownedCourse(id, userId(req)) if (!row) return reply.code(404).send({ message: '课程不存在' }) return { course: await fullCourse(row) } }) app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => { const { id } = req.params as any const row = await ownedCourse(id, userId(req)) if (!row) return reply.code(404).send({ message: '课程不存在' }) if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' }) let count = 0 for await (const part of req.files()) { const ext = extname(part.filename).slice(0, 16) const stored = id + '-' + randomUUID() + ext const target = resolve(uploadDir, stored) await pipeline(part.file, createWriteStream(target)) if (part.file.truncated) { await unlink(target).catch(() => {}) return reply.code(413).send({ message: '文件超过大小限制' }) } await pool.query( 'INSERT INTO course_assets(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)', [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype] ) count++ } if (!count) return reply.code(400).send({ message: '请选择课程资产文件' }) return { course: await fullCourse(row) } }) app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => { const { id, assetId } = req.params as any const row = await ownedCourse(id, userId(req)) if (!row) return reply.code(404).send({ message: '课程不存在' }) if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' }) const { rows } = await pool.query( '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(() => {}) return { course: await fullCourse(row) } }) app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => { const { id } = req.params as any const row = await ownedCourse(id, userId(req)) if (!row) return reply.code(404).send({ message: '课程不存在' }) if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' }) const fields: any = {} let image: any = null for await (const part of req.parts()) { if (part.type === 'file') { if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' }) const ext = extname(part.filename).slice(0, 12) const stored = 'instructor-' + randomUUID() + ext await pipeline(part.file, createWriteStream(resolve(uploadDir, stored))) image = { original: part.filename, stored, mime: part.mimetype } } else { fields[part.fieldname] = part.value } } const p = z .object({ name: z.string().trim().min(1, '请填写讲师姓名').max(80), organization: z.string().max(120).default(''), introduction: z.string().max(1000).default('') }) .safeParse(fields) if (!p.success) { if (image) await unlink(resolve(uploadDir, image.stored)).catch(() => {}) return reply.code(400).send({ message: '请填写讲师姓名' }) } const d = p.data const { rows } = await pool.query( 'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *', [ randomUUID(), id, d.name, d.organization, d.introduction, image?.original || null, image?.stored || null, image?.mime || null ] ) return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) }) }) app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => { const { id, instructorId } = req.params as any const row = await ownedCourse(id, userId(req)) if (!row) return reply.code(404).send({ message: '课程不存在' }) if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' }) const { rows } = await pool.query( 'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name', [instructorId, id] ) if (rows[0]?.image_stored_name) await unlink(resolve(uploadDir, rows[0].image_stored_name)).catch(() => {}) return { course: await fullCourse(row) } }) app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => { const { id } = req.params as any const { rows } = await pool.query( "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *", [id, userId(req)] ) if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' }) return { course: await fullCourse(rows[0]) } }) // ==================== 管理员专属 ADMIN API ==================== // 1. 统计概览数据 app.get('/api/admin/stats', { preHandler: adminAuth }, async () => { const [uRes, cRes, sRes] = await Promise.all([ pool.query('SELECT COUNT(*)::int AS count FROM users'), pool.query('SELECT COUNT(*)::int AS count FROM courses'), pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count, COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count, COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count, COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count FROM courses `) ]) return { totalUsers: uRes.rows[0].count, totalCourses: cRes.rows[0].count, waitingCount: sRes.rows[0].waiting_count || 0, inProductionCount: sRes.rows[0].in_production_count || 0, completedCount: sRes.rows[0].completed_count || 0, draftCount: sRes.rows[0].draft_count || 0 } }) // 2. 获取全量课程列表(支持状态筛选与搜索) app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => { const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string } const params: any[] = [] const conditions: string[] = [] if (query.status && query.status !== 'ALL') { params.push(query.status) conditions.push(`c.status = $${params.length}`) } if (query.keyword && query.keyword.trim()) { params.push(`%${query.keyword.trim()}%`) const idx = params.length conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx} OR u.organization ILIKE $${idx})`) } const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '' const sql = ` SELECT c.*, u.phone AS user_phone, u.role AS user_role, u.organization AS user_organization, u.wechat AS user_wechat, u.contact_name AS user_contact_name, u.bio AS user_bio, u.created_at AS user_created_at FROM courses c LEFT JOIN users u ON u.id = c.user_id ${whereClause} ORDER BY CASE WHEN c.status = 'WAITING_PRODUCTION' THEN 1 WHEN c.status = 'IN_PRODUCTION' THEN 2 WHEN c.status = 'DRAFT' THEN 3 WHEN c.status = 'COMPLETED' THEN 4 ELSE 5 END, c.submitted_at DESC NULLS LAST, c.created_at DESC ` const { rows } = await pool.query(sql, params) const courses = await Promise.all(rows.map((r) => fullCourse(r, true))) return { courses, total: courses.length } }) // 3. 获取特定课程完整详情(供制作工作台使用) app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => { const { id } = req.params as any const sql = ` SELECT c.*, u.phone AS user_phone, u.role AS user_role, u.organization AS user_organization, u.wechat AS user_wechat, u.contact_name AS user_contact_name, u.bio AS user_bio, u.created_at AS user_created_at FROM courses c LEFT JOIN users u ON u.id = c.user_id WHERE c.id = $1 ` const { rows } = await pool.query(sql, [id]) if (!rows[0]) return reply.code(404).send({ message: '课程不存在' }) const course = await fullCourse(rows[0], true) return { course } }) // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转) app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => { const { id } = req.params as any const p = z.object({ status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED']) }).safeParse(req.body) if (!p.success) return reply.code(400).send({ message: '无效的课程状态' }) const { rows } = await pool.query( 'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.status, id] ) if (!rows[0]) return reply.code(404).send({ message: '课程不存在' }) return { course: await fullCourse(rows[0], true) } }) // 5. 管理员上传制作交付成品文件 app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => { const { id } = req.params as any const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id]) if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' }) let count = 0 for await (const part of req.files()) { const ext = extname(part.filename).slice(0, 16) const stored = 'deliverable-' + id + '-' + randomUUID() + ext const target = resolve(uploadDir, stored) await pipeline(part.file, createWriteStream(target)) if (part.file.truncated) { await unlink(target).catch(() => {}) return reply.code(413).send({ message: '文件超过大小限制' }) } await pool.query( 'INSERT INTO course_deliverables(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)', [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype] ) count++ } if (!count) return reply.code(400).send({ message: '请选择交付成果文件' }) return { course: await fullCourse(check.rows[0], true) } }) // 6. 删除已上传的交付成果文件 app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => { const { id, delivId } = req.params as any const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id]) if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' }) const { rows } = await pool.query( 'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name', [delivId, id] ) if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {}) return { course: await fullCourse(check.rows[0], true) } }) // 7. 提交制作成果,结单交付(流转为 COMPLETED) app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => { const { id } = req.params as any const p = z.object({ productionNotes: z.string().max(2000).default('') }).safeParse(req.body || {}) if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' }) const { rows } = await pool.query( "UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *", [p.data.productionNotes, id] ) if (!rows[0]) return reply.code(404).send({ message: '课程不存在' }) return { course: await fullCourse(rows[0], true) } }) // 8. 获取全平台用户列表及统计 app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => { const query = (req.query || {}) as { keyword?: string } const params: any[] = [] let where = '' if (query.keyword && query.keyword.trim()) { params.push(`%${query.keyword.trim()}%`) where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)' } const sql = ` SELECT u.*, COUNT(c.id)::int AS courses_count, COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count, COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count, COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count FROM users u LEFT JOIN courses c ON c.user_id = u.id ${where} GROUP BY u.id ORDER BY u.created_at DESC ` const { rows } = await pool.query(sql, params) const users = rows.map((r) => ({ ...mapUser(r), coursesCount: r.courses_count || 0, waitingCoursesCount: r.waiting_courses_count || 0, inProductionCoursesCount: r.in_production_courses_count || 0, completedCoursesCount: r.completed_courses_count || 0 })) return { users } }) // 9. 修改用户角色(赋予或撤销管理员权限) app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => { const { id } = req.params as any const currentAdmin = (req as any).user const p = z.object({ role: z.enum(['USER', 'ADMIN']) }).safeParse(req.body) if (!p.success) return reply.code(400).send({ message: '角色类型不正确' }) if (id === currentAdmin.id && p.data.role !== 'ADMIN') { return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' }) } const { rows } = await pool.query( 'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.role, id] ) if (!rows[0]) return reply.code(404).send({ message: '用户不存在' }) return { user: mapUser(rows[0]) } }) // ==================== 静态托管与错误处理 ==================== if (process.env.NODE_ENV === 'production') { const dist = resolve('./dist') await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false }) app.setNotFoundHandler((req, reply) => req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html') ) } app.setErrorHandler((error: FastifyError, _req, reply) => { app.log.error(error) const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500 reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message }) }) await app.listen({ port, host: process.env.HOST || '0.0.0.0' })