const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const db = require('../db'); const { verifyToken, isSystemAdmin } = require('../middleware/auth'); // 辅助函数:获取所有搜索路径(主目录 + 备用目录) const getSearchPaths = () => { const paths = []; // 1. 主目录 const mainDir = process.env.UPLOAD_DIR || 'uploads'; paths.push(path.isAbsolute(mainDir) ? mainDir : path.join(__dirname, '..', mainDir)); // 2. 备用目录 if (process.env.UPLOAD_FALLBACK_DIRS) { const fallbacks = process.env.UPLOAD_FALLBACK_DIRS.split(',').map(p => p.trim()).filter(p => p); fallbacks.forEach(p => { const absPath = path.isAbsolute(p) ? p : path.join(__dirname, '..', p); // 避免重复 if (!paths.includes(absPath)) { paths.push(absPath); } }); } // 3. 默认 uploads 目录 (自动兜底) // 即使未配置 UPLOAD_FALLBACK_DIRS,也自动查找默认的 uploads 目录,确保旧文件可访问 const defaultUploads = path.join(__dirname, '..', 'uploads'); const isDefaultIncluded = paths.some(p => path.resolve(p) === path.resolve(defaultUploads)); if (!isDefaultIncluded) { paths.push(defaultUploads); } return paths; }; // 辅助函数:查找文件(支持多路径回退) const findFile = (relativePath) => { const searchPaths = getSearchPaths(); for (const rootDir of searchPaths) { const fullPath = path.join(rootDir, relativePath); if (fs.existsSync(fullPath)) { return fullPath; } } return null; }; // 辅助函数:获取上传根目录的绝对路径 (仅用于写入新文件) const getUploadRootDir = () => { const uploadDir = process.env.UPLOAD_DIR || 'uploads'; if (path.isAbsolute(uploadDir)) { return uploadDir; } else { return path.join(__dirname, '..', uploadDir); } }; const cleanupUploadedFiles = (files = []) => { for (const file of files) { if (!file || !file.path) continue; try { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } } catch (e) { // Ignore cleanup failure } } }; const sanitizeNotificationHtml = (html = '') => { return String(html) .replace(/)<[^<]*)*<\/script>/gi, '') .replace(/\son\w+\s*=\s*(['"]).*?\1/gi, '') .replace(/\son\w+\s*=\s*[^\s>]+/gi, '') .replace(/javascript:/gi, ''); }; // 配置 Multer 用于文件上传 const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = getUploadRootDir(); // 确保目录存在 if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } cb(null, uploadDir); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, 'notify-' + uniqueSuffix + path.extname(file.originalname)); } }); const allowedExtensions = [ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.jpg', '.jpeg', '.png', '.zip', '.rar', '.7z' ]; const MAX_NOTIFICATION_FILE_SIZE = Number(process.env.NOTIFY_MAX_FILE_SIZE || 20 * 1024 * 1024); const MAX_NOTIFICATION_FILES = Number(process.env.NOTIFY_MAX_FILES || 10); const upload = multer({ storage: storage, limits: { fileSize: MAX_NOTIFICATION_FILE_SIZE, files: MAX_NOTIFICATION_FILES }, fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (allowedExtensions.includes(ext)) { cb(null, true); } else { cb(new Error(`Unsupported file type (${ext}).`)); } } }); // 获取通知列表 (所有登录用户) router.get('/', verifyToken, async (req, res) => { const { page = 1, limit = 10, keyword } = req.query; const offset = (page - 1) * limit; try { let query = 'SELECT n.*, u.real_name as publisher_real_name FROM notifications n LEFT JOIN users u ON n.publisher_id = u.id WHERE 1=1'; let countQuery = 'SELECT COUNT(*) FROM notifications WHERE 1=1'; const params = []; if (keyword) { query += ` AND (title ILIKE $${params.length + 1} OR content ILIKE $${params.length + 1})`; countQuery += ` AND (title ILIKE $${params.length + 1} OR content ILIKE $${params.length + 1})`; params.push(`%${keyword}%`); } query += ` ORDER BY is_top DESC, created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; const countRes = await db.query(countQuery, params); const total = parseInt(countRes.rows[0].count); const result = await db.query(query, [...params, limit, offset]); res.json({ list: result.rows, total, page: parseInt(page), totalPages: Math.ceil(total / limit) }); } catch (err) { console.error(err); res.status(500).json({ message: '服务器错误' }); } }); // 获取通知详情 (所有登录用户) router.get('/:id', verifyToken, async (req, res) => { const { id } = req.params; try { const result = await db.query('SELECT n.*, u.real_name as publisher_real_name FROM notifications n LEFT JOIN users u ON n.publisher_id = u.id WHERE n.id = $1', [id]); if (result.rows.length === 0) return res.status(404).json({ message: '通知不存在' }); const notification = result.rows[0]; // 获取附件 const attachmentsRes = await db.query('SELECT * FROM notification_attachments WHERE notification_id = $1', [id]); notification.attachments = attachmentsRes.rows; res.json(notification); } catch (err) { console.error(err); res.status(500).json({ message: '服务器错误' }); } }); // 发布通知 (仅超管和维护员) router.post('/', verifyToken, isSystemAdmin, (req, res, next) => { upload.array('files')(req, res, (err) => { if (err) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: err.message }); } next(); }); }, async (req, res) => { const { title, content, is_top } = req.body; const files = req.files || []; const publisher_id = req.user.id; const publisher_name = req.user.real_name || req.user.username; const safeContent = sanitizeNotificationHtml(content); if (!title || !content || !String(safeContent).trim()) { cleanupUploadedFiles(files); return res.status(400).json({ message: '标题和内容不能为空' }); } const client = await db.pool.connect(); try { await client.query('BEGIN'); const result = await client.query( 'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top) VALUES ($1, $2, $3, $4, $5) RETURNING id', [title, safeContent, publisher_id, publisher_name, is_top === 'true'] ); const notificationId = result.rows[0].id; // 保存附件 for (const file of files) { let originalName = file.originalname; // 简单处理文件名编码问题 if (!/[^\u0000-\u00ff]/.test(originalName)) { try { const decodedName = Buffer.from(originalName, 'latin1').toString('utf8'); if (/[\u4e00-\u9fa5]/.test(decodedName)) { originalName = decodedName; } } catch (e) {} } await client.query( 'INSERT INTO notification_attachments (notification_id, file_name, file_path) VALUES ($1, $2, $3)', [notificationId, originalName, file.filename] ); } await client.query('COMMIT'); res.status(201).json({ message: '发布成功', id: notificationId }); } catch (err) { await client.query('ROLLBACK'); cleanupUploadedFiles(files); console.error(err); res.status(500).json({ message: '服务器错误' }); } finally { client.release(); } }); // 删除通知 (仅超管和维护员) router.delete('/:id', verifyToken, isSystemAdmin, async (req, res) => { const { id } = req.params; const client = await db.pool.connect(); try { await client.query('BEGIN'); // 获取附件并删除文件 const filesRes = await client.query('SELECT file_path FROM notification_attachments WHERE notification_id = $1', [id]); for (const row of filesRes.rows) { const filePath = findFile(row.file_path); if (filePath) { try { fs.unlinkSync(filePath); } catch (e) {} } } // 级联删除会自动删除 notification_attachments 表中的记录 await client.query('DELETE FROM notifications WHERE id = $1', [id]); await client.query('COMMIT'); res.json({ message: '删除成功' }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ message: '服务器错误' }); } finally { client.release(); } }); // 下载附件 router.get('/attachments/:id/download', verifyToken, async (req, res) => { const { id } = req.params; try { const result = await db.query('SELECT * FROM notification_attachments WHERE id = $1', [id]); if (result.rows.length === 0) return res.status(404).json({ message: '附件不存在' }); const attachment = result.rows[0]; const filePath = findFile(attachment.file_path); if (!filePath) return res.status(404).json({ message: '文件不存在' }); const encodedFileName = encodeURIComponent(attachment.file_name); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); res.sendFile(filePath); } catch (err) { console.error(err); res.status(500).json({ message: '服务器错误' }); } }); module.exports = router;