const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const archiver = require('archiver'); const contentDisposition = require('content-disposition'); const FileType = require('file-type'); const db = require('../db'); const logger = require('../utils/logger'); const { verifyToken, isAdmin, isSuperAdmin } = require('../middleware/auth'); const getLeaderDepartmentSet = async () => { try { const res = await db.query('SELECT name FROM dict_leader_departments'); return new Set((res.rows || []).map((row) => row.name).filter(Boolean)); } catch (err) { logger.warn(`Failed to load leader departments, fallback to no-filter: ${err.message}`); return new Set(); } }; const toOrderedDepartmentList = (assignedDepartments) => { const source = Array.isArray(assignedDepartments) ? assignedDepartments : []; const seen = new Set(); const result = []; for (const item of source) { const dept = String(item || '').trim(); if (!dept || seen.has(dept)) continue; seen.add(dept); result.push(dept); } return result; }; const filterAssignedDepartments = (assignedDepartments, leaderDeptSet) => { const normalized = toOrderedDepartmentList(assignedDepartments); if (normalized.length === 0) return []; if (!leaderDeptSet || leaderDeptSet.size === 0) { return [normalized[0]]; } for (const dept of normalized) { if (!leaderDeptSet.has(dept)) { return [dept]; } } // 全部都是院领导部门时,回退首个部门,保证单归属 return [normalized[0]]; }; const normalizeAchievementDepartments = (row, leaderDeptSet) => { if (!row || !Object.prototype.hasOwnProperty.call(row, 'assigned_departments')) { return row; } return { ...row, assigned_departments: filterAssignedDepartments(row.assigned_departments, leaderDeptSet) }; }; const resolveAssignedDepartmentsFromContributors = async (client, contributors = []) => { const list = Array.isArray(contributors) ? contributors : []; const orderedPhones = []; const seenPhone = new Set(); const pushPhone = (phone) => { const value = String(phone || '').trim(); if (!value || seenPhone.has(value)) return; seenPhone.add(value); orderedPhones.push(value); }; // 优先主要完成人,再按排序后的其余完成人顺序回退 list.forEach((c) => { if (c?.isMain) pushPhone(c?.phone); }); list.forEach((c) => { if (!c?.isMain) pushPhone(c?.phone); }); if (orderedPhones.length === 0) return []; const deptRes = await client.query( 'SELECT username, department FROM users WHERE username = ANY($1) AND department IS NOT NULL AND department <> \'\'', [orderedPhones] ); const phoneDeptMap = new Map(); deptRes.rows.forEach((row) => { const phone = String(row.username || '').trim(); const dept = String(row.department || '').trim(); if (phone && dept && !phoneDeptMap.has(phone)) { phoneDeptMap.set(phone, dept); } }); let leaderDeptSet = new Set(); try { const leaderRes = await client.query('SELECT name FROM dict_leader_departments'); leaderDeptSet = new Set((leaderRes.rows || []).map((row) => row.name).filter(Boolean)); } catch (err) { logger.warn(`Failed to load leader departments while resolving contributor departments: ${err.message}`); } let leaderFallback = null; for (const phone of orderedPhones) { const dept = phoneDeptMap.get(phone); if (!dept) continue; if (!leaderDeptSet.has(dept)) { return [dept]; } if (!leaderFallback) { leaderFallback = dept; } } // 全部都是院领导部门时,回退首个部门,保证“单归属部门” return leaderFallback ? [leaderFallback] : []; }; 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 鐩綍 (鑷姩鍏滃簳) 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) { logger.warn(`Failed to cleanup uploaded file: ${file.path}`); } } }; // 閰嶇疆 Multer 鐢ㄤ簬鏂囦欢涓婁紶 const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadRoot = getUploadRootDir(); const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const subDir = path.join(String(year), month); const fullPath = path.join(uploadRoot, subDir); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); } cb(null, fullPath); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, uniqueSuffix + path.extname(file.originalname)); } }); // 文件类型白名单 const allowedExtensions = [ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.jpg', '.jpeg', '.png', '.zip', '.rar', '.7z' ]; const MAX_UPLOAD_FILE_SIZE = Number(process.env.UPLOAD_MAX_FILE_SIZE || 50 * 1024 * 1024); const MAX_UPLOAD_FILES = Number(process.env.UPLOAD_MAX_FILES || 20); const upload = multer({ storage: storage, limits: { fileSize: MAX_UPLOAD_FILE_SIZE, files: MAX_UPLOAD_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.post('/', verifyToken, (req, res, next) => { upload.any()(req, res, async (err) => { if (err) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: err.message }); } if (req.files && req.files.length > 0) { for (const file of req.files) { try { const fileType = await FileType.fromFile(file.path); const ext = path.extname(file.originalname).toLowerCase(); // 允许的 MIME 类型前缀 const allowedMimes = [ 'image/', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint', 'application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/x-7z-compressed' ]; if (fileType && !allowedMimes.some(mime => fileType.mime.startsWith(mime))) { // 鍒犻櫎闈炴硶鏂囦欢 cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: `File type not allowed: ${file.originalname} (${fileType.mime})` }); } if (fileType && (fileType.mime === 'application/x-msdownload' || fileType.mime === 'application/x-executable')) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: `检测到非法可执行文件: ${file.originalname}` }); } } catch (checkErr) { console.error('文件类型检查失败:', checkErr); } } } next(); }); }, async (req, res) => { if (!req.body || !req.body.type) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: '请求参数错误:缺少成果类型(type)' }); } const { type, details, name, contributors, achievement_date, remarks } = req.body; let parsedDetails = {}; let parsedContributors = []; try { parsedDetails = typeof details === 'string' ? JSON.parse(details) : (details || {}); } catch (e) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: 'details 字段格式错误(应为 JSON)' }); } try { parsedContributors = typeof contributors === 'string' ? JSON.parse(contributors) : (contributors || []); } catch (e) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: 'contributors 字段格式错误(应为 JSON)' }); } if (type === 'report') { if ((parsedDetails.approver_id === undefined || parsedDetails.approver_id === null || parsedDetails.approver_id === '') && req.body?.approver_id) { const approverId = Number(req.body.approver_id); parsedDetails.approver_id = Number.isNaN(approverId) ? null : approverId; } if ((!parsedDetails.approver_name || parsedDetails.approver_name === '') && req.body?.approver_name) { parsedDetails.approver_name = req.body.approver_name; } } const phoneSet = new Set(); for (const c of parsedContributors) { if (c.phone) { if (phoneSet.has(c.phone)) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: `Duplicate contributor phone: ${c.phone} (${c.name})` }); } phoneSet.add(c.phone); } } // 检查重复排名(仅针对支持排名的类型) if (!['project', 'transformation'].includes(type)) { const rankingSet = new Set(); for (const c of parsedContributors) { if (c.ranking) { const r = parseInt(c.ranking); if (rankingSet.has(r)) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: `Duplicate ranking: #${r}` }); } rankingSet.add(r); } } } const userId = req.user.id; const files = req.files || []; const client = await db.pool.connect(); try { await client.query('BEGIN'); // --- 鏌ラ噸閫昏緫 --- let duplicateCheckQuery = ''; let duplicateCheckParams = []; if (type === 'patent' && parsedDetails.patent_no) { duplicateCheckQuery = 'SELECT a.id, u.real_name, a.created_at FROM achievement_patent d JOIN achievements a ON d.achievement_id = a.id JOIN users u ON a.user_id = u.id WHERE d.patent_no = $1 AND a.status != \'rejected\''; duplicateCheckParams = [parsedDetails.patent_no]; } else if (type === 'software' && parsedDetails.reg_no) { duplicateCheckQuery = 'SELECT a.id, u.real_name, a.created_at FROM achievement_software d JOIN achievements a ON d.achievement_id = a.id JOIN users u ON a.user_id = u.id WHERE d.reg_no = $1 AND a.status != \'rejected\''; duplicateCheckParams = [parsedDetails.reg_no]; } else if (type === 'standard' && parsedDetails.standard_no) { duplicateCheckQuery = 'SELECT a.id, u.real_name, a.created_at FROM achievement_standard d JOIN achievements a ON d.achievement_id = a.id JOIN users u ON a.user_id = u.id WHERE d.standard_no = $1 AND a.status != \'rejected\''; duplicateCheckParams = [parsedDetails.standard_no]; } else if (type === 'paper' && parsedDetails.doi) { duplicateCheckQuery = 'SELECT a.id, u.real_name, a.created_at FROM achievement_paper d JOIN achievements a ON d.achievement_id = a.id JOIN users u ON a.user_id = u.id WHERE d.doi = $1 AND a.status != \'rejected\''; duplicateCheckParams = [parsedDetails.doi]; } else { duplicateCheckQuery = 'SELECT a.id, u.real_name, a.created_at FROM achievements a JOIN users u ON a.user_id = u.id WHERE a.name = $1 AND a.type = $2 AND a.achievement_date = $3 AND a.status != \'rejected\''; duplicateCheckParams = [name, type, achievement_date]; } if (duplicateCheckQuery) { const dupRes = await client.query(duplicateCheckQuery, duplicateCheckParams); if (dupRes.rows.length > 0) { const dup = dupRes.rows[0]; await client.query('ROLLBACK'); cleanupUploadedFiles(files); return res.status(409).json({ message: `Duplicate submission detected. Existing submitter: ${dup.real_name}, time: ${new Date(dup.created_at).toLocaleString()}.` }); } } let processedContributors = parsedContributors.map(c => ({ ...c, ranking: c.ranking ? parseInt(c.ranking) : null })); if (type === 'paper') { const mainRoles = ['第一作者', '共同第一作者', '通讯作者', '共同通讯作者']; processedContributors = processedContributors.map(c => ({ ...c, isMain: mainRoles.includes(c.role) })); } else if (['project', 'transformation'].includes(type)) { } else { const rankings = processedContributors.map(c => c.ranking).filter(r => r !== null && !isNaN(r)); const minRanking = rankings.length > 0 ? Math.min(...rankings) : null; processedContributors = processedContributors.map(c => ({ ...c, isMain: minRanking !== null && c.ranking === minRanking })); } // 鎸夋帓鍚嶆帓搴? processedContributors.sort((a, b) => { if (a.ranking && b.ranking) return a.ranking - b.ranking; if (a.ranking) return -1; if (b.ranking) return 1; return 0; }); const main_contributors_str = processedContributors.filter(c => c.isMain).map(c => c.name).join(' '); const all_contributors_str = processedContributors.map(c => c.name).join(' '); const contributor_phones_str = processedContributors.map(c => c.phone).join(' '); const assignedDepartments = await resolveAssignedDepartmentsFromContributors(client, processedContributors); const baseRes = await client.query( 'INSERT INTO achievements (user_id, type, name, main_contributors, all_contributors, contributor_phones, achievement_date, remarks, contributors, assigned_departments) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id', [userId, type, name, main_contributors_str, all_contributors_str, contributor_phones_str, achievement_date, remarks, JSON.stringify(processedContributors), assignedDepartments] ); const achievementId = baseRes.rows[0].id; let detailQuery = ''; let params = []; switch (type) { case 'paper': detailQuery = 'INSERT INTO achievement_paper (achievement_id, paper_type, first_unit, journal_name, publish_date, doi) VALUES ($1, $2, $3, $4, $5, $6)'; params = [achievementId, parsedDetails.paper_type, parsedDetails.first_unit, parsedDetails.journal_name, parsedDetails.publish_date, parsedDetails.doi]; break; case 'project': detailQuery = 'INSERT INTO achievement_project (achievement_id, project_category, source) VALUES ($1, $2, $3)'; params = [achievementId, parsedDetails.project_category, parsedDetails.source]; break; case 'award': detailQuery = 'INSERT INTO achievement_award (achievement_id, award_type, award_level, award_unit) VALUES ($1, $2, $3, $4)'; params = [achievementId, parsedDetails.award_type, parsedDetails.award_level, parsedDetails.award_unit]; break; case 'standard': detailQuery = 'INSERT INTO achievement_standard (achievement_id, standard_type, standard_no, implement_date) VALUES ($1, $2, $3, $4)'; params = [achievementId, parsedDetails.standard_type, parsedDetails.standard_no, parsedDetails.implement_date]; break; case 'monograph': detailQuery = 'INSERT INTO achievement_monograph (achievement_id, publisher, isbn) VALUES ($1, $2, $3)'; params = [achievementId, parsedDetails.publisher, parsedDetails.isbn]; break; case 'report': detailQuery = 'INSERT INTO achievement_report (achievement_id, report_type, recipient, approver_id, approver_name) VALUES ($1, $2, $3, $4, $5)'; params = [achievementId, parsedDetails.report_type, parsedDetails.recipient, parsedDetails.approver_id, parsedDetails.approver_name]; break; case 'plan': detailQuery = 'INSERT INTO achievement_plan (achievement_id) VALUES ($1)'; params = [achievementId]; break; case 'patent': detailQuery = 'INSERT INTO achievement_patent (achievement_id, patent_no, patent_type, assignee) VALUES ($1, $2, $3, $4)'; params = [achievementId, parsedDetails.patent_no, parsedDetails.patent_type, parsedDetails.assignee]; break; case 'transformation': detailQuery = 'INSERT INTO achievement_transformation (achievement_id, trans_method, trans_amount, project_category) VALUES ($1, $2, $3, $4)'; params = [achievementId, parsedDetails.trans_method, parsedDetails.trans_amount, parsedDetails.project_category]; break; case 'software': detailQuery = 'INSERT INTO achievement_software (achievement_id, reg_no, acquisition_method, scope, owner_unit) VALUES ($1, $2, $3, $4, $5)'; params = [achievementId, parsedDetails.reg_no, parsedDetails.acquisition_method, parsedDetails.scope, parsedDetails.owner_unit]; break; default: throw new Error('Unknown achievement type'); } await client.query(detailQuery, params); // 插入多个附件 const uploadRootDir = getUploadRootDir(); 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) { console.error('鏂囦欢鍚嶈浆鐮佸け璐?', e); } } let relativePath = path.relative(uploadRootDir, file.path); relativePath = relativePath.split(path.sep).join('/'); await client.query( 'INSERT INTO achievement_attachments (achievement_id, file_name, file_path) VALUES ($1, $2, $3)', [achievementId, originalName, relativePath] ); } await client.query('COMMIT'); const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; logger.info(`用户 ${req.user.username} 上传了新成果: ${name} (类型: ${type}, ID: ${achievementId}) (IP: ${clientIp})`); res.status(201).json({ message: '上传成功', id: achievementId }); } catch (err) { await client.query('ROLLBACK'); cleanupUploadedFiles(files); console.error(err); res.status(500).json({ message: 'Server error' }); } finally { client.release(); } }); const buildExportQuery = (params) => { const { type, status, keyword, startDate, endDate } = params; let query = ` SELECT a.id, a.type, a.name, a.status, a.created_at, a.updated_at, u.username as submitter_username, u.real_name as submitter_real_name, a.main_contributors, a.all_contributors, a.contributor_phones, a.assigned_departments, a.achievement_date, a.remarks, -- Paper ap.paper_type, ap.first_unit, ap.journal_name, ap.publish_date as paper_publish_date, ap.doi, -- Project apr.project_category as project_category, apr.source as project_source, -- Award aa.award_type, aa.award_level, aa.award_unit, -- Standard as_std.standard_type, as_std.standard_no, as_std.implement_date as standard_implement_date, -- Monograph am.publisher, am.isbn, -- Report ar.report_type, ar.recipient, -- Patent apat.patent_no, apat.patent_type, apat.assignee, -- Transformation at.trans_method, at.trans_amount, at.project_category as trans_project_category, -- Software asw.reg_no, asw.acquisition_method, asw.scope, asw.owner_unit FROM achievements a JOIN users u ON a.user_id = u.id LEFT JOIN achievement_paper ap ON a.id = ap.achievement_id AND a.type = 'paper' LEFT JOIN achievement_project apr ON a.id = apr.achievement_id AND a.type = 'project' LEFT JOIN achievement_award aa ON a.id = aa.achievement_id AND a.type = 'award' LEFT JOIN achievement_standard as_std ON a.id = as_std.achievement_id AND a.type = 'standard' LEFT JOIN achievement_monograph am ON a.id = am.achievement_id AND a.type = 'monograph' LEFT JOIN achievement_report ar ON a.id = ar.achievement_id AND a.type = 'report' LEFT JOIN achievement_patent apat ON a.id = apat.achievement_id AND a.type = 'patent' LEFT JOIN achievement_transformation at ON a.id = at.achievement_id AND a.type = 'transformation' LEFT JOIN achievement_software asw ON a.id = asw.achievement_id AND a.type = 'software' WHERE 1=1 `; const sqlParams = []; if (type) { query += ' AND a.type = $' + (sqlParams.length + 1); sqlParams.push(type); } if (status) { query += ' AND a.status = $' + (sqlParams.length + 1); sqlParams.push(status); } if (keyword) { query += ` AND (a.name ILIKE $${sqlParams.length + 1} OR a.main_contributors ILIKE $${sqlParams.length + 1} OR a.all_contributors ILIKE $${sqlParams.length + 1} OR a.contributor_phones ILIKE $${sqlParams.length + 1})`; sqlParams.push(`%${keyword}%`); } if (startDate) { query += ` AND a.achievement_date >= $${sqlParams.length + 1}`; sqlParams.push(startDate); } if (endDate) { query += ` AND a.achievement_date <= $${sqlParams.length + 1}`; sqlParams.push(endDate); } return { query, sqlParams }; }; router.get('/export/csv', verifyToken, isSuperAdmin, async (req, res) => { try { const { query, sqlParams } = buildExportQuery(req.query); const result = await db.query(query, sqlParams); const leaderDeptSet = await getLeaderDepartmentSet(); const rows = result.rows.map((row) => normalizeAchievementDepartments(row, leaderDeptSet)); if (rows.length === 0) return res.status(404).json({ message: '没有可导出的数据' }); // 处理 CSV 内容 const header = Object.keys(rows[0]).join(','); const csvData = rows.map(row => { return Object.values(row).map(val => { if (val === null || val === undefined) return ''; const strVal = String(val); if (strVal.includes(',') || strVal.includes('\n') || strVal.includes('"')) { return `"${strVal.replace(/"/g, '""')}"`; } return strVal; }).join(','); }).join('\n'); const bom = '\uFEFF'; const fullCsv = bom + header + '\n' + csvData; res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename=achievements_export.csv'); res.status(200).send(fullCsv); } catch (err) { console.error(err); res.status(500).json({ message: '导出失败' }); } }); // 5. 瀵煎嚭鍘嬬缉鍖?(鍖呭惈绛涢€夊悗鐨勬暟鎹〃鍜岄檮浠? router.get('/export/zip', verifyToken, isSuperAdmin, async (req, res) => { const archive = archiver('zip', { zlib: { level: 9 } }); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', 'attachment; filename=achievements_export.zip'); archive.on('error', (err) => { if (!res.headersSent) res.status(500).send({ error: err.message }); }); archive.pipe(res); try { // 1. 鑾峰彇绛涢€夊悗鐨勬暟鎹? const { query, sqlParams } = buildExportQuery(req.query); const resultData = await db.query(query, sqlParams); const leaderDeptSet = await getLeaderDepartmentSet(); const normalizedRows = resultData.rows.map((row) => normalizeAchievementDepartments(row, leaderDeptSet)); if (normalizedRows.length > 0) { // 鐢熸垚 CSV const header = Object.keys(normalizedRows[0]).join(','); const csvData = normalizedRows.map(row => { return Object.values(row).map(val => { if (val === null || val === undefined) return ''; const strVal = String(val); if (strVal.includes(',') || strVal.includes('\n') || strVal.includes('"')) { return `"${strVal.replace(/"/g, '""')}"`; } return strVal; }).join(','); }).join('\n'); const bom = '\uFEFF'; archive.append(bom + header + '\n' + csvData, { name: 'achievements_list.csv' }); // 2. 获取相关附件 const achievementIds = normalizedRows.map(r => r.id); if (achievementIds.length > 0) { const fileQuery = 'SELECT aa.file_path, aa.file_name, a.name as achievement_name, a.type FROM achievement_attachments aa JOIN achievements a ON aa.achievement_id = a.id WHERE aa.achievement_id = ANY($1)'; const resultFiles = await db.query(fileQuery, [achievementIds]); resultFiles.rows.forEach(row => { const filePath = findFile(row.file_path); if (filePath) { // 处理文件名中的非法字符 const safeAchName = row.achievement_name.replace(/[\\/:*?"<>|]/g, '_'); const archivePath = `${row.type}/${safeAchName}/${row.file_name}`; archive.file(filePath, { name: archivePath }); } }); } } else { archive.append('No data matched current filters.', { name: 'readme.txt' }); } archive.finalize(); } catch (err) { console.error(err); if (!res.headersSent) res.status(500).json({ message: '压缩失败' }); } }); // 7. 下载单个附件 router.get('/attachments/:id/download', verifyToken, async (req, res) => { const { id } = req.params; try { console.log('正在下载附件 ID:', id); const result = await db.query( 'SELECT aa.file_name, aa.file_path, aa.achievement_id, a.user_id FROM achievement_attachments aa JOIN achievements a ON aa.achievement_id = a.id WHERE aa.id = $1', [id] ); if (result.rows.length === 0) return res.status(404).json({ message: 'Attachment not found' }); const attachment = result.rows[0]; const isSystemAdmin = ['admin', 'super_admin'].includes(req.user.role); if (!isSystemAdmin) { const achRes = await db.query('SELECT status, contributors FROM achievements WHERE id = $1', [attachment.achievement_id]); const achievement = achRes.rows[0]; if (attachment.user_id === req.user.id && ['pending', 'rejected'].includes(achievement.status)) { // 鍏佽 } else { const userPhone = req.user.username; const isContributor = achievement.contributors && Array.isArray(achievement.contributors) && achievement.contributors.some(c => c.phone === userPhone); if (!isContributor) return res.status(403).json({ message: '无权访问' }); } } const filePath = findFile(attachment.file_path); if (!filePath) return res.status(404).json({ message: 'File not found' }); const encodedFileName = encodeURIComponent(attachment.file_name); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); res.sendFile(filePath, (err) => { if (err && !res.headersSent) res.status(500).json({ message: '下载失败' }); }); } catch (err) { console.error(err); res.status(500).json({ message: 'Server error' }); } }); router.get('/', verifyToken, async (req, res) => { const { type, status, keyword, startDate, endDate, scope, department } = req.query; let query = 'SELECT a.*, u.username, u.real_name as submitter_name FROM achievements a JOIN users u ON a.user_id = u.id WHERE 1=1'; const params = []; let realName = req.user.real_name; let userDept = null; let deptPhones = []; // 鏌ヨ鏈€鏂扮殑 real_name 鍜?department const userRes = await db.query('SELECT real_name, department FROM users WHERE id = $1', [req.user.id]); if (userRes.rows.length > 0) { realName = userRes.rows[0].real_name; userDept = userRes.rows[0].department; } const phonePattern = '(^|[[:space:],])' + req.user.username + '([[:space:],]|$)'; const namePattern = realName || '__NO_NAME__'; const phoneParam = phonePattern; const nameParam = namePattern; const contributorJsonParam = JSON.stringify([{ phone: req.user.username }]); if (scope === 'my') { // 銆愭垜鐨勬垚鏋溿€戣鍥? query += ` AND ((a.contributors @> $${params.length + 1}) OR (a.user_id = $${params.length + 2} AND a.status IN ('pending', 'rejected')))`; params.push(contributorJsonParam); params.push(req.user.id); } else { // 銆愭垚鏋滃叕绀恒€戣鍥?(榛樿) if (req.user.role === 'maintainer') { return res.status(403).json({ message: '无权访问' }); } else if (req.user.role === 'admin' || req.user.role === 'super_admin') { // 绠$悊鍛?& 瓒呯骇绠$悊鍛橈細 } else if (req.user.role === 'senior_user') { query += ` AND (a.status = 'approved' OR (a.contributors @> $${params.length + 1}) OR (a.user_id = $${params.length + 2} AND a.status IN ('pending', 'rejected')))`; params.push(contributorJsonParam); params.push(req.user.id); } else if (req.user.role === 'intermediate_user') { // 1. 涓庢垜鐩稿叧 (contributors @> myPhone) if (userDept) { const deptUsersRes = await db.query('SELECT username FROM users WHERE department = $1', [userDept]); deptPhones = deptUsersRes.rows.map(u => u.username); } // 1. 涓庢垜鐩稿叧 const relatedCondition = `(a.contributors @> $${params.length + 1})`; params.push(contributorJsonParam); const myPendingCondition = `(a.user_id = $${params.length + 1} AND a.status IN ('pending', 'rejected'))`; params.push(req.user.id); let deptExtendedCondition = 'FALSE'; if (userDept && deptPhones.length > 0) { deptExtendedCondition = `( a.status = 'approved' AND ( $${params.length + 1} = ANY(a.assigned_departments) OR EXISTS ( SELECT 1 FROM jsonb_array_elements(a.contributors) c WHERE c->>'phone' = ANY($${params.length + 2}) ) ) )`; params.push(userDept); params.push(deptPhones); } query += ` AND (${relatedCondition} OR ${myPendingCondition} OR ${deptExtendedCondition})`; } else { // 1. 涓庢垜鐩稿叧 const relatedCondition = `(a.contributors @> $${params.length + 1})`; params.push(contributorJsonParam); let deptCondition = 'FALSE'; if (userDept) { deptCondition = `(a.status = 'approved' AND $${params.length + 1} = ANY(a.assigned_departments))`; params.push(userDept); } const myPendingCondition = `(a.user_id = $${params.length + 1} AND a.status IN ('pending', 'rejected'))`; params.push(req.user.id); query += ` AND (${relatedCondition} OR ${deptCondition} OR ${myPendingCondition})`; } } if (type) { query += ' AND a.type = $' + (params.length + 1); params.push(type); } if (status) { query += ' AND a.status = $' + (params.length + 1); params.push(status); } if (keyword) { query += ` AND (a.name ILIKE $${params.length + 1} OR a.main_contributors ILIKE $${params.length + 1} OR a.all_contributors ILIKE $${params.length + 1} OR a.contributor_phones ILIKE $${params.length + 1})`; params.push(`%${keyword}%`); } if (startDate) { query += ` AND a.achievement_date >= $${params.length + 1}`; params.push(startDate); } if (endDate) { query += ` AND a.achievement_date <= $${params.length + 1}`; params.push(endDate); } if (department) { query += ` AND $${params.length + 1} = ANY(a.assigned_departments)`; params.push(department); } query += ' ORDER BY a.achievement_date DESC, a.created_at DESC'; try { const result = await db.query(query, params); const leaderDeptSet = await getLeaderDepartmentSet(); // 妫€鏌ユ暟鎹槸鍚﹀寘鍚?'寰呭畾' 鍐呭 let rows = result.rows.map(row => { const normalizedRow = normalizeAchievementDepartments(row, leaderDeptSet); let hasIssue = false; // 1. 妫€鏌ュ綊灞為儴闂? if (normalizedRow.assigned_departments && normalizedRow.assigned_departments.includes('寰呭畾')) hasIssue = true; // 2. 检查各类型特有字段 const checkFields = [ normalizedRow.paper_type, normalizedRow.first_unit, // paper normalizedRow.project_category, normalizedRow.project_source, // project normalizedRow.award_type, normalizedRow.award_level, normalizedRow.award_unit, // award normalizedRow.standard_type, // standard normalizedRow.publisher, // monograph normalizedRow.trans_project_category, // transformation normalizedRow.owner_unit // software ]; if (checkFields.some(val => val === '寰呭畾')) hasIssue = true; return { ...normalizedRow, has_data_issue: hasIssue }; }); const getOwnerDepartment = (row) => { const depts = Array.isArray(row?.assigned_departments) ? row.assigned_departments : []; return String(depts[0] || '').trim(); }; const isRelatedToMe = (row) => { const myPhone = String(req.user.username || '').trim(); if (!myPhone || !Array.isArray(row?.contributors)) return false; return row.contributors.some((c) => String(c?.phone || '').trim() === myPhone); }; const isMyPending = (row) => { return Number(row?.user_id) === Number(req.user.id) && ['pending', 'rejected'].includes(row?.status); }; // 部门筛选统一按“单归属部门”口径,不按历史多部门数组匹配 if (department) { const targetDept = String(department || '').trim(); rows = rows.filter((row) => getOwnerDepartment(row) === targetDept); } // 对历史多部门数据做二次收敛,避免 ANY(a.assigned_departments) 带来的越权/误入 if (scope !== 'my') { if (req.user.role === 'user') { rows = rows.filter((row) => { if (isRelatedToMe(row) || isMyPending(row)) return true; return row.status === 'approved' && !!userDept && getOwnerDepartment(row) === userDept; }); } else if (req.user.role === 'intermediate_user') { const deptPhoneSet = new Set((deptPhones || []).map((p) => String(p || '').trim()).filter(Boolean)); rows = rows.filter((row) => { if (isRelatedToMe(row) || isMyPending(row)) return true; if (row.status !== 'approved') return false; if (userDept && getOwnerDepartment(row) === userDept) return true; if (deptPhoneSet.size === 0 || !Array.isArray(row?.contributors)) return false; return row.contributors.some((c) => deptPhoneSet.has(String(c?.phone || '').trim())); }); } } res.json(rows); } catch (err) { res.status(500).json({ message: 'Server error' }); } }); router.get('/validate', verifyToken, async (req, res) => { // 鏉冮檺妫€鏌? if (req.user.role !== 'super_admin') { return res.status(403).json({ message: '无权访问' }); } try { const achievementsRes = await db.query(` SELECT a.id, a.name, a.type, a.contributors, a.assigned_departments, u.username as submitter_username, u.real_name as submitter_name, -- Paper ap.paper_type, ap.first_unit, -- Project apr.project_category, -- Award aa.award_type, aa.award_level, aa.award_unit, -- Standard as_std.standard_type, -- Transformation at.project_category as trans_project_category, -- Software asw.owner_unit FROM achievements a JOIN users u ON a.user_id = u.id LEFT JOIN achievement_paper ap ON a.id = ap.achievement_id AND a.type = 'paper' LEFT JOIN achievement_project apr ON a.id = apr.achievement_id AND a.type = 'project' LEFT JOIN achievement_award aa ON a.id = aa.achievement_id AND a.type = 'award' LEFT JOIN achievement_standard as_std ON a.id = as_std.achievement_id AND a.type = 'standard' LEFT JOIN achievement_transformation at ON a.id = at.achievement_id AND a.type = 'transformation' LEFT JOIN achievement_software asw ON a.id = asw.achievement_id AND a.type = 'software' `); const achievements = achievementsRes.rows; const usersRes = await db.query('SELECT username, real_name, dept_status FROM users'); const userMap = new Map(); // username -> { real_name, dept_status } usersRes.rows.forEach(u => userMap.set(u.username, { real_name: u.real_name, dept_status: u.dept_status })); // 3. 鑾峰彇鎵€鏈夐儴闂ㄦ暟鎹?(鐢ㄤ簬姣斿) const deptsRes = await db.query('SELECT name FROM departments'); const deptSet = new Set(deptsRes.rows.map(d => d.name)); // 4. 鑾峰彇鎵€鏈夊瓧鍏歌〃鏁版嵁 (鐢ㄤ簬姣斿) const dicts = { orgs: new Set((await db.query('SELECT name FROM dict_organizations')).rows.map(r => r.name)), awardTypes: new Set((await db.query('SELECT name FROM dict_award_types')).rows.map(r => r.name)), awardLevels: new Set((await db.query('SELECT name FROM dict_award_levels')).rows.map(r => r.name)), paperTypes: new Set((await db.query('SELECT name FROM dict_paper_types')).rows.map(r => r.name)), standardTypes: new Set((await db.query('SELECT name FROM dict_standard_types')).rows.map(r => r.name)), projectCats: new Set((await db.query('SELECT name FROM dict_project_categories')).rows.map(r => r.name)), }; const validationResults = []; for (const ach of achievements) { const errors = []; const contributors = ach.contributors || []; const assignedDepts = ach.assigned_departments || []; for (const c of contributors) { if (c.phone) { const userInfo = userMap.get(c.phone); if (!userInfo) { errors.push(`完成人 ${c.name}(${c.phone}) 在用户表中不存在`); } else if (userInfo.dept_status === 'pending') { errors.push(`完成人 ${c.name}(${c.phone}) 的部门状态为待定,请核实`); } } } // 4.2 鏍¢獙閮ㄩ棬 for (const dept of assignedDepts) { if (!deptSet.has(dept)) { errors.push(`归属部门 '${dept}' 在部门表中不存在`); } } // 4.3 校验字典字段 switch (ach.type) { case 'paper': if (ach.paper_type && !dicts.paperTypes.has(ach.paper_type)) errors.push(`paper_type '${ach.paper_type}' not found in dictionary`); if (ach.first_unit && !dicts.orgs.has(ach.first_unit)) errors.push(`first_unit '${ach.first_unit}' not found in organization dictionary`); break; case 'project': if (ach.project_category && !dicts.projectCats.has(ach.project_category)) errors.push(`project_category '${ach.project_category}' not found in dictionary`); break; case 'award': if (ach.award_type && !dicts.awardTypes.has(ach.award_type)) errors.push(`award_type '${ach.award_type}' not found in dictionary`); if (ach.award_level && !dicts.awardLevels.has(ach.award_level)) errors.push(`award_level '${ach.award_level}' not found in dictionary`); if (ach.award_unit && !dicts.orgs.has(ach.award_unit)) errors.push(`award_unit '${ach.award_unit}' not found in organization dictionary`); break; case 'standard': if (ach.standard_type && !dicts.standardTypes.has(ach.standard_type)) errors.push(`standard_type '${ach.standard_type}' not found in dictionary`); break; case 'transformation': if (ach.trans_project_category && !dicts.projectCats.has(ach.trans_project_category)) errors.push(`trans_project_category '${ach.trans_project_category}' not found in dictionary`); break; } if (errors.length > 0) { validationResults.push({ id: ach.id, name: ach.name, type: ach.type, submitter: ach.submitter_name || ach.submitter_username, errors: errors }); } } res.json(validationResults); } catch (err) { console.error(err); res.status(500).json({ message: '校验失败' }); } }); router.get('/:id', verifyToken, async (req, res) => { const { id } = req.params; try { const baseRes = await db.query('SELECT * FROM achievements WHERE id = $1', [id]); if (baseRes.rows.length === 0) return res.status(404).json({ message: '未找到该成果' }); const achievement = baseRes.rows[0]; const leaderDeptSet = await getLeaderDepartmentSet(); const normalizedAchievement = normalizeAchievementDepartments(achievement, leaderDeptSet); const isSystemAdmin = ['admin', 'super_admin'].includes(req.user.role); if (!isSystemAdmin) { if (achievement.user_id === req.user.id && ['pending', 'rejected'].includes(achievement.status)) { } else { let userDept = null; const userRes = await db.query('SELECT department FROM users WHERE id = $1', [req.user.id]); if (userRes.rows.length > 0) { userDept = userRes.rows[0].department; } const userPhone = req.user.username; const isContributor = achievement.contributors && Array.isArray(achievement.contributors) && achievement.contributors.some(c => c.phone === userPhone); if (isContributor) { } else { if (achievement.status === 'approved') { if (req.user.role === 'senior_user') { } else if (req.user.role === 'user') { if (!userDept || !normalizedAchievement.assigned_departments || !normalizedAchievement.assigned_departments.includes(userDept)) { return res.status(403).json({ message: '无权访问(非本部门成果)' }); } } else { return res.status(403).json({ message: '无权访问' }); } } else { return res.status(403).json({ message: '无权访问' }); } } } } const detailTable = `achievement_${achievement.type}`; const detailRes = await db.query(`SELECT * FROM ${detailTable} WHERE achievement_id = $1`, [id]); const attachmentsRes = await db.query('SELECT * FROM achievement_attachments WHERE achievement_id = $1', [id]); res.json({ ...normalizedAchievement, details: detailRes.rows[0], attachments: attachmentsRes.rows }); } catch (err) { console.error(err); res.status(500).json({ message: 'Server error' }); } }); router.post('/audit/:id', verifyToken, isAdmin, async (req, res) => { const { id } = req.params; const { status, audit_comment } = req.body; const client = await db.pool.connect(); try { await client.query('BEGIN'); if (req.user.role === 'admin') { const checkRes = await client.query('SELECT contributor_phones FROM achievements WHERE id = $1', [id]); const contributorPhones = checkRes.rows[0]?.contributor_phones || ''; const selfPhone = req.user.username; const phonesArray = contributorPhones.split(/[ \t\n,]+/); if (phonesArray.includes(selfPhone)) { await client.query('ROLLBACK'); return res.status(403).json({ message: 'Conflict-of-interest rule: you cannot audit achievements you contributed to.' }); } } await client.query( 'UPDATE achievements SET status = $1, audit_comment = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3', [status, audit_comment, id] ); if (status === 'rejected') { const filesRes = await client.query('SELECT file_path FROM achievement_attachments WHERE achievement_id = $1', [id]); for (const row of filesRes.rows) { const filePath = findFile(row.file_path); if (filePath) { try { fs.unlinkSync(filePath); } catch (e) {} } } await client.query('DELETE FROM achievement_attachments WHERE achievement_id = $1', [id]); } await client.query('COMMIT'); const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; logger.info(`管理员 ${req.user.username} 审核了成果 ID ${id}: 状态变更为 ${status} (IP: ${clientIp})`); res.json({ message: status === 'rejected' ? 'Rejected and related attachments cleaned' : 'Audit completed' }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ message: 'Server error' }); } finally { client.release(); } }); router.put('/:id', verifyToken, async (req, res) => { const { id } = req.params; const { details, name, contributors, achievement_date, remarks } = req.body; let parsedDetails = details || {}; let parsedContributors = contributors || []; try { parsedDetails = typeof details === 'string' ? JSON.parse(details) : (details || {}); } catch (e) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: 'details 字段格式错误(应为 JSON)' }); } try { parsedContributors = typeof contributors === 'string' ? JSON.parse(contributors) : (contributors || []); } catch (e) { cleanupUploadedFiles(req.files || []); return res.status(400).json({ message: 'contributors 字段格式错误(应为 JSON)' }); } const phoneSet = new Set(); for (const c of parsedContributors) { if (c.phone) { if (phoneSet.has(c.phone)) { return res.status(400).json({ message: `Duplicate contributor phone: ${c.phone} (${c.name})` }); } phoneSet.add(c.phone); } } const client = await db.pool.connect(); try { await client.query('BEGIN'); const baseRes = await client.query('SELECT type, user_id, status FROM achievements WHERE id = $1', [id]); if (baseRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ message: '未找到该成果' }); } const achievement = baseRes.rows[0]; // ?????????????????? if (!['project', 'transformation'].includes(achievement.type)) { const rankingSet = new Set(); for (const c of parsedContributors) { if (c.ranking) { const r = parseInt(c.ranking); if (rankingSet.has(r)) { await client.query('ROLLBACK'); return res.status(400).json({ message: `Duplicate ranking: #${r}` }); } rankingSet.add(r); } } } let canEdit = false; if (req.user.role === 'super_admin') { canEdit = true; } else if (achievement.user_id === req.user.id && ['pending', 'rejected'].includes(achievement.status)) { canEdit = true; } if (!canEdit) { await client.query('ROLLBACK'); return res.status(403).json({ message: '无权修改(仅超级管理员,或成果提交人在待审核/已驳回状态下可修改)' }); } const type = achievement.type; let updateQuery = ''; let params = []; switch (type) { case 'paper': updateQuery = 'UPDATE achievement_paper SET paper_type=$2, first_unit=$3, journal_name=$4, publish_date=$5, doi=$6 WHERE achievement_id=$1'; params = [id, parsedDetails.paper_type, parsedDetails.first_unit, parsedDetails.journal_name, parsedDetails.publish_date, parsedDetails.doi]; break; case 'project': updateQuery = 'UPDATE achievement_project SET project_category=$2, source=$3 WHERE achievement_id=$1'; params = [id, parsedDetails.project_category, parsedDetails.source]; break; case 'award': updateQuery = 'UPDATE achievement_award SET award_type=$2, award_level=$3, award_unit=$4 WHERE achievement_id=$1'; params = [id, parsedDetails.award_type, parsedDetails.award_level, parsedDetails.award_unit]; break; case 'standard': updateQuery = 'UPDATE achievement_standard SET standard_type=$2, standard_no=$3, implement_date=$4 WHERE achievement_id=$1'; params = [id, parsedDetails.standard_type, parsedDetails.standard_no, parsedDetails.implement_date]; break; case 'monograph': updateQuery = 'UPDATE achievement_monograph SET publisher=$2, isbn=$3 WHERE achievement_id=$1'; params = [id, parsedDetails.publisher, parsedDetails.isbn]; break; case 'report': updateQuery = 'UPDATE achievement_report SET report_type=$2, recipient=$3, approver_id=$4, approver_name=$5 WHERE achievement_id=$1'; params = [id, parsedDetails.report_type, parsedDetails.recipient, parsedDetails.approver_id, parsedDetails.approver_name]; break; case 'plan': updateQuery = 'SELECT 1'; // 规划表目前没有额外字段需要更新 params = []; break; case 'patent': updateQuery = 'UPDATE achievement_patent SET patent_no=$2, patent_type=$3, assignee=$4 WHERE achievement_id=$1'; params = [id, parsedDetails.patent_no, parsedDetails.patent_type, parsedDetails.assignee]; break; case 'transformation': updateQuery = 'UPDATE achievement_transformation SET trans_method=$2, trans_amount=$3, project_category=$4 WHERE achievement_id=$1'; params = [id, parsedDetails.trans_method, parsedDetails.trans_amount, parsedDetails.project_category]; break; case 'software': updateQuery = 'UPDATE achievement_software SET reg_no=$2, acquisition_method=$3, scope=$4, owner_unit=$5 WHERE achievement_id=$1'; params = [id, parsedDetails.reg_no, parsedDetails.acquisition_method, parsedDetails.scope, parsedDetails.owner_unit]; break; default: throw new Error('Unknown achievement type'); } let processedContributors = parsedContributors.map(c => ({ ...c, ranking: c.ranking ? parseInt(c.ranking) : null })); if (type === 'paper') { const mainRoles = ['第一作者', '共同第一作者', '通讯作者', '共同通讯作者']; processedContributors = processedContributors.map(c => ({ ...c, isMain: mainRoles.includes(c.role) })); } else if (['project', 'transformation'].includes(type)) { } else { const rankings = processedContributors.map(c => c.ranking).filter(r => r !== null && !isNaN(r)); const minRanking = rankings.length > 0 ? Math.min(...rankings) : null; processedContributors = processedContributors.map(c => ({ ...c, isMain: minRanking !== null && c.ranking === minRanking })); } // 鎸夋帓鍚嶆帓搴? processedContributors.sort((a, b) => { if (a.ranking && b.ranking) return a.ranking - b.ranking; if (a.ranking) return -1; if (b.ranking) return 1; return 0; }); if (!processedContributors.some(c => c.isMain) && (type === 'paper' || ['project', 'transformation'].includes(type))) { processedContributors = processedContributors.map(c => ({ ...c, isMain: true })); } const main_contributors_str = processedContributors.filter(c => c.isMain).map(c => c.name).join(' '); const all_contributors_str = processedContributors.map(c => c.name).join(' '); const contributor_phones_str = processedContributors.map(c => c.phone).join(' '); const assignedDepartments = await resolveAssignedDepartmentsFromContributors(client, processedContributors); await client.query(updateQuery, params); await client.query( 'UPDATE achievements SET name=$2, main_contributors=$3, all_contributors=$4, contributor_phones=$5, achievement_date=$6, remarks=$7, contributors=$8, assigned_departments=$9, updated_at = CURRENT_TIMESTAMP WHERE id = $1', [id, name, main_contributors_str, all_contributors_str, contributor_phones_str, achievement_date, remarks, JSON.stringify(processedContributors), assignedDepartments] ); await client.query('COMMIT'); res.json({ message: '修改成功' }); } catch (err) { await client.query('ROLLBACK'); console.error(err); res.status(500).json({ message: 'Server error' }); } finally { client.release(); } }); router.delete('/:id', verifyToken, async (req, res) => { const { id } = req.params; const client = await db.pool.connect(); try { await client.query('BEGIN'); const checkRes = await client.query('SELECT * FROM achievements WHERE id = $1', [id]); if (checkRes.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ message: '未找到该成果' }); } const achievement = checkRes.rows[0]; let canDelete = false; if (req.user.role === 'super_admin') { canDelete = true; } else if (achievement.user_id === req.user.id && ['pending', 'rejected'].includes(achievement.status)) { canDelete = true; } if (!canDelete) { await client.query('ROLLBACK'); return res.status(403).json({ message: '无权删除(仅超级管理员,或成果提交人在待审核/已驳回状态下可删除)' }); } const filesRes = await client.query('SELECT file_path FROM achievement_attachments WHERE achievement_id = $1', [id]); for (const row of filesRes.rows) { const filePath = findFile(row.file_path); if (filePath) { try { fs.unlinkSync(filePath); } catch (e) {} } } await client.query('DELETE FROM achievements WHERE id = $1', [id]); await client.query('COMMIT'); const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; logger.info(`用户 ${req.user.username} 删除了成果 ID ${id} (IP: ${clientIp})`); res.json({ message: '删除成功,相关文件已清理' }); } catch (err) { await client.query('ROLLBACK'); res.status(500).json({ message: 'Server error' }); } finally { client.release(); } }); router.get('/:id/attachments/zip', verifyToken, async (req, res) => { const { id } = req.params; try { const baseRes = await db.query('SELECT user_id, name, contributors, status FROM achievements WHERE id = $1', [id]); if (baseRes.rows.length === 0) return res.status(404).json({ message: '未找到该成果' }); const achievement = baseRes.rows[0]; const isSystemAdmin = ['admin', 'super_admin'].includes(req.user.role); if (!isSystemAdmin) { if (achievement.user_id === req.user.id && ['pending', 'rejected'].includes(achievement.status)) { // 鍏佽 } else { const userPhone = req.user.username; const isContributor = achievement.contributors && Array.isArray(achievement.contributors) && achievement.contributors.some(c => c.phone === userPhone); if (!isContributor) { return res.status(403).json({ message: '无权访问' }); } } } const attachmentsRes = await db.query('SELECT * FROM achievement_attachments WHERE achievement_id = $1', [id]); if (attachmentsRes.rows.length === 0) return res.status(404).json({ message: 'No attachments found for this achievement' }); const archive = archiver('zip', { zlib: { level: 9 } }); const zipName = `成果附件_${achievement.name || id}.zip`; res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', contentDisposition(zipName)); archive.pipe(res); attachmentsRes.rows.forEach(file => { const filePath = findFile(file.file_path); if (filePath) archive.file(filePath, { name: file.file_name }); }); archive.finalize(); } catch (err) { console.error(err); res.status(500).json({ message: '压缩失败' }); } }); module.exports = router;