Files
tech-achievement-management…/server/routes/notifications.js
T
2026-04-19 14:05:40 +08:00

294 lines
10 KiB
JavaScript

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\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/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;