chore: initial import
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user