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

1488 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;