chore: initial import

This commit is contained in:
2026-04-19 14:05:40 +08:00
commit 89e9eae36e
81 changed files with 37363 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
const express = require('express');
const http = require('http');
const https = require('https');
const { URL } = require('url');
const router = express.Router();
const db = require('../db');
const logger = require('../utils/logger');
const { verifyToken } = require('../middleware/auth');
const normalizePositiveInt = (value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) => {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
const intValue = Math.trunc(n);
if (intValue < min) return min;
if (intValue > max) return max;
return intValue;
};
const AI_TIMEOUT_MS = Number(process.env.AI_TIMEOUT_MS || 45000);
const AI_MAX_TOKENS = normalizePositiveInt(process.env.AI_MAX_TOKENS, 4096, 256, 8192);
const AI_DEFAULT_PROVIDER = String(process.env.AI_DEFAULT_PROVIDER || 'deepseek').trim().toLowerCase();
const AI_PROVIDER_PRIORITY = String(process.env.AI_PROVIDER_PRIORITY || 'deepseek').trim();
const AI_NAME_MAX_ITEMS_DEFAULT = normalizePositiveInt(process.env.AI_NAME_MAX_ITEMS_DEFAULT, 100, 20, 500);
const AI_NAME_MAX_ITEMS_HARD_LIMIT = normalizePositiveInt(process.env.AI_NAME_MAX_ITEMS_HARD_LIMIT, 300, 50, 1000);
const ADMIN_ROLES = new Set(['admin', 'super_admin', 'senior_user', 'intermediate_user']);
const SUPPORTED_PROVIDERS = new Set(['auto', 'deepseek']);
const SUPPORTED_MODES = new Set(['standard', 'deep']);
const SUPPORTED_NAME_STRATEGIES = new Set(['latest', 'balanced']);
const TYPE_LABEL_MAP = {
paper: 'Paper',
project: 'Project',
award: 'Award',
standard: 'Standard',
monograph: 'Monograph',
report: 'Technical Report',
plan: 'Plan',
patent: 'Patent',
transformation: 'Transformation',
software: 'Software Copyright'
};
const STATUS_LABEL_MAP = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected'
};
const clampText = (value, maxLen = 600) => {
return String(value || '').trim().slice(0, maxLen);
};
const normalizeFilter = (value) => {
if (value === undefined || value === null) return null;
const s = String(value).trim();
if (!s || s.toLowerCase() === 'all') return null;
return s;
};
const toSafeInt = (value, fallback = null) => {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.trunc(n);
};
const normalizeProvider = (value) => {
const provider = String(value || '').trim().toLowerCase();
if (!provider) return 'auto';
if (!SUPPORTED_PROVIDERS.has(provider)) return 'auto';
return provider;
};
const normalizeMode = (value) => {
const mode = String(value || '').trim().toLowerCase();
if (!mode || !SUPPORTED_MODES.has(mode)) return 'standard';
return mode;
};
const normalizeNameStrategy = (value) => {
const strategy = String(value || '').trim().toLowerCase();
if (!strategy || !SUPPORTED_NAME_STRATEGIES.has(strategy)) return 'latest';
return strategy;
};
const normalizeNameLimit = (value) => {
return normalizePositiveInt(value, AI_NAME_MAX_ITEMS_DEFAULT, 20, AI_NAME_MAX_ITEMS_HARD_LIMIT);
};
const toIsoDate = (value) => {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return null;
return date.toISOString().slice(0, 10);
};
const getProviderConfigs = () => {
return {
deepseek: {
baseUrl: String(process.env.DEEPSEEK_API_BASE_URL || '').trim(),
apiKey: String(process.env.DEEPSEEK_API_KEY || '').trim(),
model: String(process.env.DEEPSEEK_MODEL || 'deepseek-chat').trim() || 'deepseek-chat'
}
};
};
const getAvailableProviders = (configs) => {
return Object.keys(configs).filter((provider) => {
const cfg = configs[provider];
return !!(cfg && cfg.baseUrl && cfg.apiKey && cfg.model);
});
};
const parsePriority = () => {
const tokens = AI_PROVIDER_PRIORITY
.split(',')
.map((p) => normalizeProvider(p))
.filter((p) => p !== 'auto');
return Array.from(new Set(tokens));
};
const resolveProviderOrder = (requestedProvider, availableProviders) => {
if (requestedProvider !== 'auto') {
return availableProviders.includes(requestedProvider) ? [requestedProvider] : [];
}
const priority = parsePriority();
const ordered = [
...priority.filter((p) => availableProviders.includes(p)),
...availableProviders.filter((p) => !priority.includes(p))
];
const defaultProvider = normalizeProvider(AI_DEFAULT_PROVIDER);
if (defaultProvider !== 'auto' && ordered.includes(defaultProvider)) {
return [defaultProvider, ...ordered.filter((p) => p !== defaultProvider)];
}
return ordered;
};
const postJson = (urlString, payload, headers = {}, timeoutMs = 30000) => {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const body = JSON.stringify(payload);
const requestLib = url.protocol === 'http:' ? http : https;
const defaultPort = url.protocol === 'http:' ? 80 : 443;
const req = requestLib.request({
protocol: url.protocol,
hostname: url.hostname,
port: url.port || defaultPort,
path: `${url.pathname}${url.search}`,
method: 'POST',
timeout: timeoutMs,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...headers
}
}, (res) => {
let raw = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
raw += chunk;
});
res.on('end', () => {
const statusCode = res.statusCode || 500;
let parsed = {};
try {
parsed = raw ? JSON.parse(raw) : {};
} catch (e) {
parsed = { raw };
}
if (statusCode >= 200 && statusCode < 300) {
resolve(parsed);
} else {
reject(new Error(parsed?.error?.message || parsed?.message || `AI provider error: ${statusCode}`));
}
});
});
req.on('timeout', () => {
req.destroy(new Error('AI request timeout'));
});
req.on('error', reject);
req.write(body);
req.end();
});
};
const writeSse = (res, event, payload) => {
if (res.writableEnded) return;
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const extractChoiceText = (parsed) => {
const choice = parsed?.choices?.[0] || {};
if (typeof choice?.delta?.content === 'string') return choice.delta.content;
if (typeof choice?.message?.content === 'string') return choice.message.content;
return '';
};
const streamProviderToClient = ({ req, res, provider, config, systemPrompt, userPrompt, maxTokens = AI_MAX_TOKENS, timeoutMs = 30000 }) => {
return new Promise((resolve, reject) => {
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
const url = new URL(endpoint);
const requestLib = url.protocol === 'http:' ? http : https;
const defaultPort = url.protocol === 'http:' ? 80 : 443;
const payload = JSON.stringify({
model: config.model,
temperature: 0.2,
max_tokens: maxTokens,
stream: true,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
]
});
let settled = false;
let tokenChars = 0;
let lineBuffer = '';
let rawFallbackBuffer = '';
let metaSent = false;
const onClientClose = () => {
if (!upstreamReq.destroyed) {
upstreamReq.destroy(new Error('client closed connection'));
}
};
const settle = (fn, value) => {
if (settled) return;
settled = true;
req.off('close', onClientClose);
fn(value);
};
const upstreamReq = requestLib.request({
protocol: url.protocol,
hostname: url.hostname,
port: url.port || defaultPort,
path: `${url.pathname}${url.search}`,
method: 'POST',
timeout: timeoutMs,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
Accept: 'text/event-stream',
Authorization: `Bearer ${config.apiKey}`
}
}, (upstreamRes) => {
const statusCode = upstreamRes.statusCode || 500;
upstreamRes.setEncoding('utf8');
if (statusCode < 200 || statusCode >= 300) {
let errRaw = '';
upstreamRes.on('data', (chunk) => {
errRaw += chunk;
});
upstreamRes.on('end', () => {
try {
const parsed = errRaw ? JSON.parse(errRaw) : {};
settle(reject, new Error(parsed?.error?.message || parsed?.message || `AI provider error: ${statusCode}`));
} catch (e) {
settle(reject, new Error(`AI provider error: ${statusCode}`));
}
});
return;
}
if (!metaSent) {
writeSse(res, 'meta', {
provider,
model: config.model,
generatedAt: new Date().toISOString()
});
metaSent = true;
}
const flushLine = (line) => {
const trimmed = String(line || '').trim();
if (!trimmed || trimmed.startsWith(':') || !trimmed.startsWith('data:')) return;
const data = trimmed.slice(5).trim();
if (!data || data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
const deltaText = extractChoiceText(parsed);
if (deltaText) {
tokenChars += deltaText.length;
writeSse(res, 'token', { content: deltaText });
}
} catch (e) {
// Ignore malformed stream chunks.
}
};
upstreamRes.on('data', (chunk) => {
rawFallbackBuffer += chunk;
lineBuffer += chunk.replace(/\r/g, '');
let idx = lineBuffer.indexOf('\n');
while (idx !== -1) {
const line = lineBuffer.slice(0, idx);
lineBuffer = lineBuffer.slice(idx + 1);
flushLine(line);
idx = lineBuffer.indexOf('\n');
}
});
upstreamRes.on('end', () => {
if (lineBuffer) flushLine(lineBuffer);
if (tokenChars === 0 && rawFallbackBuffer.trim()) {
try {
const parsed = JSON.parse(rawFallbackBuffer);
const wholeText = extractChoiceText(parsed);
if (wholeText) {
tokenChars += wholeText.length;
writeSse(res, 'token', { content: wholeText });
}
} catch (e) {
// Ignore fallback parse errors.
}
}
if (tokenChars === 0) {
settle(reject, new Error('provider returned empty analysis'));
return;
}
settle(resolve, { provider, model: config.model, tokenChars });
});
upstreamRes.on('error', (err) => {
settle(reject, err);
});
});
upstreamReq.on('timeout', () => {
upstreamReq.destroy(new Error('AI request timeout'));
});
upstreamReq.on('error', (err) => {
settle(reject, err);
});
req.once('close', onClientClose);
upstreamReq.write(payload);
upstreamReq.end();
});
};
const buildWhereClause = (filters = {}) => {
const where = ['1=1'];
const params = [];
const year = toSafeInt(normalizeFilter(filters.year));
const type = normalizeFilter(filters.type);
const status = normalizeFilter(filters.status);
const department = normalizeFilter(filters.department);
if (year && year >= 2000 && year <= 2100) {
where.push(`EXTRACT(YEAR FROM achievement_date) = $${params.length + 1}`);
params.push(year);
}
if (type) {
where.push(`type = $${params.length + 1}`);
params.push(type);
}
if (status) {
where.push(`status = $${params.length + 1}`);
params.push(status);
}
return { whereSql: where.join(' AND '), params, department };
};
const toNumberRows = (rows = []) => {
return rows.map((row) => ({
...row,
count: Number(row.count || 0)
}));
};
const toPercent = (count, total) => {
if (!total) return 0;
return Number(((Number(count || 0) / Number(total || 1)) * 100).toFixed(1));
};
const formatFilterValue = (key, value) => {
if (!value || value === 'all') return '鍏ㄩ儴';
if (key === 'type') return TYPE_LABEL_MAP[value] || value;
if (key === 'status') return STATUS_LABEL_MAP[value] || value;
return value;
};
const buildPromptDataset = (dataset) => {
const total = Number(dataset?.overview?.total || 0);
const rawFilters = dataset?.filters || {};
const mode = dataset?.mode === 'deep' ? 'deep' : 'standard';
const rawNameSamples = dataset?.nameSamples || {};
return {
mode,
filters: {
year: formatFilterValue('year', rawFilters.year),
type: formatFilterValue('type', rawFilters.type),
status: formatFilterValue('status', rawFilters.status),
department: formatFilterValue('department', rawFilters.department)
},
overview: { total },
byType: (dataset?.byType || []).slice(0, 8).map((item) => ({
type: TYPE_LABEL_MAP[item.type] || item.type,
count: Number(item.count || 0),
ratio: toPercent(item.count, total)
})),
byStatus: (dataset?.byStatus || []).map((item) => ({
status: STATUS_LABEL_MAP[item.status] || item.status,
count: Number(item.count || 0),
ratio: toPercent(item.count, total)
})),
byDepartmentTop: (dataset?.byDepartment || []).slice(0, 10).map((item) => ({
department: item.department,
count: Number(item.count || 0),
ratio: toPercent(item.count, total)
})),
monthlyRecent: (dataset?.byMonth || []).slice(-24).map((item) => ({
month: item.month,
count: Number(item.count || 0)
})),
quality: dataset?.quality || {},
nameSamples: {
mode,
strategy: rawNameSamples?.strategy || 'latest',
sent: Number(rawNameSamples?.sent || 0),
total: Number(rawNameSamples?.total || 0),
truncated: Boolean(rawNameSamples?.truncated),
items: (rawNameSamples?.items || []).map((item) => ({
name: clampText(item?.name, 120),
type: TYPE_LABEL_MAP[item?.type] || item?.type || 'unknown',
department: item?.department || 'unknown',
date: item?.date || null
}))
}
};
};
const gatherAnalysisDataset = async (filters = {}, options = {}) => {
const { whereSql, params, department } = buildWhereClause(filters);
const scopedParams = department ? [...params, department] : params;
const scopedWhere = department ? `${whereSql} AND owner_department = $${params.length + 1}` : whereSql;
const mode = normalizeMode(options?.mode);
const nameStrategy = normalizeNameStrategy(options?.nameStrategy);
const nameLimit = normalizeNameLimit(options?.nameLimit);
const ownerAchievementsCte = `
WITH leader_depts AS (
SELECT name FROM dict_leader_departments
),
leader_dept_array AS (
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
),
owner_achievements AS (
SELECT
a.id,
a.name,
a.type,
a.status,
a.achievement_date,
a.contributors,
a.assigned_departments,
a.remarks,
COALESCE(non_leader.dept, first_any.dept) AS owner_department
FROM achievements a
CROSS JOIN leader_dept_array lda
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
AND u.dept <> ALL(lda.names)
ORDER BY u.ord
LIMIT 1
) non_leader ON TRUE
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
ORDER BY u.ord
LIMIT 1
) first_any ON TRUE
)
`;
const totalRes = await db.query(
`${ownerAchievementsCte}
SELECT COUNT(*)::int AS count
FROM owner_achievements
WHERE ${scopedWhere}`,
scopedParams
);
const typeRes = await db.query(
`${ownerAchievementsCte}
SELECT type, COUNT(*)::int AS count
FROM owner_achievements
WHERE ${scopedWhere}
GROUP BY type
ORDER BY count DESC`,
scopedParams
);
const statusRes = await db.query(
`${ownerAchievementsCte}
SELECT status, COUNT(*)::int AS count
FROM owner_achievements
WHERE ${scopedWhere}
GROUP BY status
ORDER BY count DESC`,
scopedParams
);
const deptRes = await db.query(
`${ownerAchievementsCte},
department_scope AS (
SELECT d.name AS department
FROM departments d, leader_dept_array lda
WHERE d.name::text <> ALL(lda.names)
)
SELECT ds.department, COUNT(oa.id)::int AS count
FROM department_scope ds
LEFT JOIN owner_achievements oa
ON ds.department = oa.owner_department
AND (${scopedWhere})
GROUP BY ds.department
ORDER BY count DESC
LIMIT 20`,
scopedParams
);
const monthlyRes = await db.query(
`${ownerAchievementsCte}
SELECT month, count
FROM (
SELECT TO_CHAR(achievement_date, 'YYYY-MM') AS month, COUNT(*)::int AS count
FROM owner_achievements
WHERE ${scopedWhere} AND achievement_date IS NOT NULL
GROUP BY month
ORDER BY month DESC
LIMIT 48
) t
ORDER BY month`,
scopedParams
);
const qualityRes = await db.query(
`${ownerAchievementsCte}
SELECT
COUNT(*) FILTER (WHERE contributors IS NULL OR jsonb_array_length(contributors) = 0)::int AS empty_contributors,
COUNT(*) FILTER (WHERE owner_department IS NULL)::int AS empty_departments,
COUNT(*) FILTER (WHERE remarks IS NULL OR LENGTH(BTRIM(remarks)) = 0)::int AS empty_remarks
FROM owner_achievements
WHERE ${scopedWhere}`,
scopedParams
);
const total = Number(totalRes.rows[0]?.count || 0);
const byType = toNumberRows(typeRes.rows);
const byStatus = toNumberRows(statusRes.rows);
const byDepartment = toNumberRows(deptRes.rows);
const byMonth = toNumberRows(monthlyRes.rows);
const quality = {
emptyContributors: Number(qualityRes.rows[0]?.empty_contributors || 0),
emptyDepartments: Number(qualityRes.rows[0]?.empty_departments || 0),
emptyRemarks: Number(qualityRes.rows[0]?.empty_remarks || 0)
};
let nameSamples = {
mode,
strategy: nameStrategy,
sent: 0,
total: 0,
truncated: false,
items: []
};
if (mode === 'deep') {
const nameCountRes = await db.query(
`${ownerAchievementsCte}
SELECT COUNT(*)::int AS count
FROM owner_achievements
WHERE ${scopedWhere}
AND name IS NOT NULL
AND LENGTH(BTRIM(name)) > 0`,
scopedParams
);
const totalNameCount = Number(nameCountRes.rows[0]?.count || 0);
let sampleRows = [];
if (totalNameCount > 0) {
if (nameStrategy === 'balanced') {
const distinctTypeCount = Math.max(byType.filter((item) => !!item.type).length, 1);
const perTypeCap = Math.max(2, Math.ceil(nameLimit / distinctTypeCount));
const balancedParams = [...scopedParams, perTypeCap, nameLimit];
const balancedRes = await db.query(
`${ownerAchievementsCte}
SELECT id, name, type, owner_department AS department, achievement_date
FROM (
SELECT
id,
name,
type,
owner_department,
achievement_date,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(type, 'unknown')
ORDER BY achievement_date DESC NULLS LAST, id DESC
) AS rn
FROM owner_achievements
WHERE ${scopedWhere}
AND name IS NOT NULL
AND LENGTH(BTRIM(name)) > 0
) t
WHERE rn <= $${scopedParams.length + 1}
ORDER BY achievement_date DESC NULLS LAST, id DESC
LIMIT $${scopedParams.length + 2}`,
balancedParams
);
sampleRows = balancedRes.rows;
} else {
const latestParams = [...scopedParams, nameLimit];
const latestRes = await db.query(
`${ownerAchievementsCte}
SELECT id, name, type, owner_department AS department, achievement_date
FROM owner_achievements
WHERE ${scopedWhere}
AND name IS NOT NULL
AND LENGTH(BTRIM(name)) > 0
ORDER BY achievement_date DESC NULLS LAST, id DESC
LIMIT $${scopedParams.length + 1}`,
latestParams
);
sampleRows = latestRes.rows;
}
}
const items = sampleRows
.map((row) => ({
name: clampText(row?.name, 120),
type: row?.type || null,
department: row?.department || null,
date: toIsoDate(row?.achievement_date)
}))
.filter((item) => !!item.name);
nameSamples = {
mode,
strategy: nameStrategy,
sent: items.length,
total: totalNameCount,
truncated: items.length < totalNameCount,
items
};
}
return {
mode,
filters: {
year: normalizeFilter(filters.year) || 'all',
type: normalizeFilter(filters.type) || 'all',
status: normalizeFilter(filters.status) || 'all',
department: normalizeFilter(filters.department) || 'all'
},
overview: {
total
},
byType,
byStatus,
byDepartment,
byMonth,
quality,
nameSamples,
protection: {
attachmentContentSent: false,
attachmentPathSent: false,
attachmentFilenameSent: false,
contributorPhoneSent: false,
contributorNameSent: false,
achievementNameSent: mode === 'deep',
note: mode === 'deep'
? 'Deep mode sends aggregated statistics and achievement names only. No personal phone, contributor name, or attachment data is sent.'
: 'Standard mode sends aggregated statistics only. No names, phones, or attachment data is sent.'
}
};
};
const buildPrompt = (question, dataset) => {
const promptDataset = buildPromptDataset(dataset);
const mode = dataset?.mode === 'deep' ? 'deep' : 'standard';
const modeNote = mode === 'deep'
? '当前为深度分析模式:会额外提供成果名称样本(不含个人姓名与手机号)。'
: '当前为标准分析模式:仅提供聚合统计数据,不提供成果名称。';
const systemPrompt = `
你是科研成果统计分析助手。只能基于输入数据给出结论,不得编造事实。
要求:
1) 明确统计口径与边界;
2) 关键判断尽量给出数量/占比/趋势证据;
3) 证据不足时直接说明“数据不足以支持该结论”;
4) 建议必须可执行、可落地。
请使用中文 Markdown 输出,结构如下:
## 执行摘要
## 关键发现
## 风险与数据质量
## 优先行动(表格)
## 一句话结论
`.trim();
const userPrompt = `
用户问题:${question || '请给出整体分析、风险和改进建议。'}
模式说明:${modeNote}
隐私口径:
- 不包含附件内容、文件路径、文件名;
- 不包含个人手机号、贡献者姓名;
- 部门统计已排除院领导部门,每条成果仅计入一个归属部门。
请先结论后证据再建议。以下是可用数据 JSON:
${JSON.stringify(promptDataset)}
`.trim();
return {
systemPrompt,
userPrompt
};
};
const callProvider = async (provider, config, systemPrompt, userPrompt, maxTokens = AI_MAX_TOKENS) => {
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
const aiResp = await postJson(
endpoint,
{
model: config.model,
temperature: 0.2,
max_tokens: maxTokens,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
]
},
{
Authorization: `Bearer ${config.apiKey}`
},
AI_TIMEOUT_MS
);
const analysis = aiResp?.choices?.[0]?.message?.content || '';
if (!analysis) {
throw new Error('provider returned empty analysis');
}
return {
provider,
model: config.model,
analysis
};
};
router.post('/achievements', verifyToken, async (req, res) => {
if (!ADMIN_ROLES.has(req.user.role)) {
return res.status(403).json({ message: 'No permission to use AI analysis.' });
}
const requestedProvider = normalizeProvider(req.body?.provider || 'auto');
const providerConfigs = getProviderConfigs();
const availableProviders = getAvailableProviders(providerConfigs);
if (availableProviders.length === 0) {
return res.status(503).json({ message: 'AI service is not configured. Please configure DeepSeek API settings.' });
}
const providerOrder = resolveProviderOrder(requestedProvider, availableProviders);
if (providerOrder.length === 0) {
return res.status(503).json({
message: `Requested provider '${requestedProvider}' is unavailable. Available providers: ${availableProviders.join(', ')}`
});
}
const question = clampText(req.body?.question, 600);
const filters = req.body?.filters || {};
const mode = normalizeMode(req.body?.mode);
const nameLimit = normalizeNameLimit(req.body?.nameLimit);
const nameStrategy = normalizeNameStrategy(req.body?.nameStrategy);
const analysisOptions = { mode, nameLimit, nameStrategy };
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
try {
const dataset = await gatherAnalysisDataset(filters, analysisOptions);
const { systemPrompt, userPrompt } = buildPrompt(question, dataset);
const triedProviders = [];
const errors = [];
let aiResult = null;
for (const provider of providerOrder) {
triedProviders.push(provider);
try {
aiResult = await callProvider(provider, providerConfigs[provider], systemPrompt, userPrompt, AI_MAX_TOKENS);
break;
} catch (providerErr) {
errors.push(`${provider}: ${providerErr.message}`);
}
}
if (!aiResult) {
throw new Error(`All AI providers failed: ${errors.join(' | ')}`);
}
logger.info(
`AI analysis generated by user ${req.user.username} (role=${req.user.role}, provider=${aiResult.provider}, ip=${clientIp})`
);
res.json({
provider: aiResult.provider,
model: aiResult.model,
generatedAt: new Date().toISOString(),
analysis: aiResult.analysis,
triedProviders,
mode: dataset.mode,
nameSamples: dataset.nameSamples,
datasetSummary: {
filters: dataset.filters,
total: dataset.overview.total,
byTypeCount: dataset.byType.length,
byDepartmentCount: dataset.byDepartment.length,
byMonthCount: dataset.byMonth.length
},
protection: dataset.protection
});
} catch (err) {
logger.error(`AI analysis failed for user ${req.user.username}: ${err.message}`);
res.status(500).json({ message: `AI analysis failed: ${err.message}` });
}
});
router.post('/achievements/stream', verifyToken, async (req, res) => {
if (!ADMIN_ROLES.has(req.user.role)) {
return res.status(403).json({ message: 'No permission to use AI analysis.' });
}
const requestedProvider = normalizeProvider(req.body?.provider || 'auto');
const providerConfigs = getProviderConfigs();
const availableProviders = getAvailableProviders(providerConfigs);
if (availableProviders.length === 0) {
return res.status(503).json({ message: 'AI service is not configured. Please configure DeepSeek API settings.' });
}
const providerOrder = resolveProviderOrder(requestedProvider, availableProviders);
if (providerOrder.length === 0) {
return res.status(503).json({
message: `Requested provider '${requestedProvider}' is unavailable. Available providers: ${availableProviders.join(', ')}`
});
}
const question = clampText(req.body?.question, 600);
const filters = req.body?.filters || {};
const mode = normalizeMode(req.body?.mode);
const nameLimit = normalizeNameLimit(req.body?.nameLimit);
const nameStrategy = normalizeNameStrategy(req.body?.nameStrategy);
const analysisOptions = { mode, nameLimit, nameStrategy };
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
try {
const dataset = await gatherAnalysisDataset(filters, analysisOptions);
const { systemPrompt, userPrompt } = buildPrompt(question, dataset);
const triedProviders = [];
const errors = [];
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
let streamResult = null;
for (const provider of providerOrder) {
triedProviders.push(provider);
try {
streamResult = await streamProviderToClient({
req,
res,
provider,
config: providerConfigs[provider],
systemPrompt,
userPrompt,
maxTokens: AI_MAX_TOKENS,
timeoutMs: AI_TIMEOUT_MS
});
break;
} catch (providerErr) {
errors.push(`${provider}: ${providerErr.message}`);
writeSse(res, 'provider_error', {
provider,
message: providerErr.message
});
}
}
if (!streamResult) {
writeSse(res, 'error', { message: `All AI providers failed: ${errors.join(' | ')}` });
writeSse(res, 'done', { success: false, triedProviders });
res.end();
return;
}
logger.info(
`AI stream analysis generated by user ${req.user.username} (role=${req.user.role}, provider=${streamResult.provider}, ip=${clientIp})`
);
writeSse(res, 'done', {
success: true,
provider: streamResult.provider,
model: streamResult.model,
generatedAt: new Date().toISOString(),
triedProviders,
mode: dataset.mode,
nameSamples: dataset.nameSamples,
datasetSummary: {
filters: dataset.filters,
total: dataset.overview.total,
byTypeCount: dataset.byType.length,
byDepartmentCount: dataset.byDepartment.length,
byMonthCount: dataset.byMonth.length
},
protection: dataset.protection
});
res.end();
} catch (err) {
logger.error(`AI stream analysis failed for user ${req.user.username}: ${err.message}`);
if (res.headersSent) {
writeSse(res, 'error', { message: `AI analysis failed: ${err.message}` });
writeSse(res, 'done', { success: false });
res.end();
return;
}
res.status(500).json({ message: `AI analysis failed: ${err.message}` });
}
});
module.exports = router;
+220
View File
@@ -0,0 +1,220 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../db');
const SmsClient = require('../utils/sms');
const logger = require('../utils/logger');
require('dotenv').config();
// 内存存储验证码 (手机号 -> {code, expire, lastSentTime, ip})
const verifyCodes = new Map();
// 定时清理过期验证码 (每5分钟执行一次)
setInterval(() => {
const now = Date.now();
for (const [phone, data] of verifyCodes.entries()) {
if (now > data.expire) {
verifyCodes.delete(phone);
}
}
}, 5 * 60 * 1000);
// 发送验证码
router.post('/send-code', async (req, res) => {
const { phoneNumber, type } = req.body; // type: 'register' | 'reset'
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
if (!/^1[3-9]\d{9}$/.test(phoneNumber)) {
return res.status(400).json({ message: '手机号格式不正确' });
}
// 检查发送频率 (60s 冷却)
const existingRecord = verifyCodes.get(phoneNumber);
if (existingRecord) {
const timeSinceLastSent = Date.now() - existingRecord.lastSentTime;
if (timeSinceLastSent < 60 * 1000) {
const remainingSeconds = Math.ceil((60000 - timeSinceLastSent) / 1000);
return res.status(429).json({ message: `请等待 ${remainingSeconds} 秒后再试` });
}
}
try {
// 如果是重置密码,检查用户是否存在
if (type === 'reset') {
const userCheck = await db.query('SELECT id FROM users WHERE username = $1', [phoneNumber]);
if (userCheck.rows.length === 0) {
return res.status(404).json({ message: '该手机号未注册' });
}
}
// 如果是注册,检查用户是否已存在
else if (type === 'register') {
const userCheck = await db.query('SELECT id FROM users WHERE username = $1', [phoneNumber]);
if (userCheck.rows.length > 0) {
return res.status(400).json({ message: '该手机号已注册' });
}
}
const code = Math.floor(100000 + Math.random() * 900000).toString();
await SmsClient.sendVerifyCode(phoneNumber, code);
verifyCodes.set(phoneNumber, {
code,
expire: Date.now() + 2 * 60 * 1000, // 2分钟有效
lastSentTime: Date.now(),
ip: clientIp
});
logger.info(`验证码已发送至 ${phoneNumber} (类型: ${type}, IP: ${clientIp})`);
res.json({ message: '验证码已发送' });
} catch (error) {
logger.error(`发送验证码失败: ${error.message}`);
res.status(500).json({ message: '发送失败,请稍后再试' });
}
});
// 重置密码
router.post('/reset-password', async (req, res) => {
const { phoneNumber, code, newPassword } = req.body;
try {
// 1. 校验验证码
const record = verifyCodes.get(phoneNumber);
if (!record || record.code !== code || Date.now() > record.expire) {
return res.status(400).json({ message: '验证码无效或已过期' });
}
verifyCodes.delete(phoneNumber);
// 2. 更新密码
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(newPassword, salt);
// 更新密码同时增加 token_version,使所有旧设备下线 (增加溢出保护)
await db.query(
'UPDATE users SET password_hash = $1, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE username = $2',
[passwordHash, phoneNumber]
);
logger.info(`用户 ${phoneNumber} 重置密码成功`);
res.json({ message: '密码重置成功' });
} catch (err) {
logger.error(`重置密码失败: ${err.message}`);
res.status(500).json({ message: '服务器错误' });
}
});
// 登录
router.post('/login', async (req, res) => {
const { username, password } = req.body;
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
try {
const result = await db.query('SELECT * FROM users WHERE username = $1', [username]);
const user = result.rows[0];
if (!user) {
logger.warn(`登录失败: 用户名 ${username} 不存在 (IP: ${clientIp})`);
return res.status(401).json({ message: '用户名或密码错误' });
}
const isMatch = await bcrypt.compare(password, user.password_hash);
if (!isMatch) {
logger.warn(`登录失败: 用户 ${username} 密码错误 (IP: ${clientIp})`);
return res.status(401).json({ message: '用户名或密码错误' });
}
// 登录成功,更新 token_version 实现单点登录 (增加溢出保护)
const updateRes = await db.query(
'UPDATE users SET token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE id = $1 RETURNING token_version',
[user.id]
);
const tokenVersion = updateRes.rows[0].token_version;
logger.info(`用户 ${username} 登录成功 (IP: ${clientIp}, Version: ${tokenVersion})`);
const token = jwt.sign(
{
id: user.id,
username: user.username,
role: user.role,
real_name: user.real_name,
token_version: tokenVersion // 将版本号放入 Token
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
user: {
id: user.id,
username: user.username,
role: user.role,
real_name: user.real_name
}
});
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 注册
router.post('/register', async (req, res) => {
const { username, password, role: requestedRole, real_name, department, code } = req.body;
try {
// 1. 校验验证码
const record = verifyCodes.get(username);
if (!record || record.code !== code || Date.now() > record.expire) {
return res.status(400).json({ message: '验证码无效或已过期' });
}
verifyCodes.delete(username); // 校验成功后删除
// 2. 手机号格式校验
if (!/^1[3-9]\d{9}$/.test(username)) {
return res.status(400).json({ message: '手机号格式不正确' });
}
const userExists = await db.query('SELECT * FROM users WHERE username = $1', [username]);
if (userExists.rows.length > 0) {
return res.status(400).json({ message: '该手机号已注册' });
}
// 校验部门
const deptCheck = await db.query('SELECT * FROM departments WHERE name = $1', [department]);
let finalDepartment = department;
let deptStatus = 'verified';
if (deptCheck.rows.length === 0) {
finalDepartment = '待定';
deptStatus = 'pending';
}
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
if (requestedRole && requestedRole !== 'user') {
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
logger.warn(`娉ㄥ唽 role 瓒婃潈灏濊瘯: username=${username}, requestedRole=${requestedRole}, IP=${clientIp}`);
}
const result = await db.query(
'INSERT INTO users (username, password_hash, role, real_name, department, dept_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, username, role, real_name, department, dept_status',
[username, passwordHash, 'user', real_name, finalDepartment, deptStatus]
);
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
logger.info(`新用户注册成功: ${username} (${real_name}) (IP: ${clientIp})`);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
module.exports = router;
+165
View File
@@ -0,0 +1,165 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const { verifyToken, isSystemAdmin } = require('../middleware/auth');
// 定义允许管理的字典表映射
const tableMap = {
'departments': 'departments',
'organizations': 'dict_organizations',
'award-types': 'dict_award_types',
'award-levels': 'dict_award_levels',
'paper-types': 'dict_paper_types',
'standard-types': 'dict_standard_types',
'project-categories': 'dict_project_categories',
'leader-departments': 'dict_leader_departments'
};
// 特殊接口:公开获取部门列表 (用于注册页面,无需登录)
router.get('/departments', async (req, res) => {
try {
const result = await db.query('SELECT * FROM departments ORDER BY id ASC');
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 通用获取接口 (登录用户即可访问,用于下拉框)
router.get('/:type', verifyToken, async (req, res) => {
const tableName = tableMap[req.params.type];
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
try {
const result = await db.query(`SELECT * FROM ${tableName} ORDER BY id ASC`);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 通用添加接口 (系统管理员:超管或维护员)
router.post('/:type', verifyToken, isSystemAdmin, async (req, res) => {
const tableName = tableMap[req.params.type];
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
const { name } = req.body;
if (!name) return res.status(400).json({ message: '名称不能为空' });
try {
const result = await db.query(
`INSERT INTO ${tableName} (name) VALUES ($1) RETURNING *`,
[name]
);
res.status(201).json(result.rows[0]);
} catch (err) {
if (err.code === '23505') {
return res.status(400).json({ message: '该项已存在' });
}
res.status(500).json({ message: '服务器错误' });
}
});
// 通用修改接口 (系统管理员:超管或维护员)
router.put('/:type/:id', verifyToken, isSystemAdmin, async (req, res) => {
const tableName = tableMap[req.params.type];
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
const { id } = req.params;
const { name } = req.body;
if (!name) return res.status(400).json({ message: '名称不能为空' });
try {
const result = await db.query(
`UPDATE ${tableName} SET name = $1 WHERE id = $2 RETURNING *`,
[name, id]
);
if (result.rows.length === 0) return res.status(404).json({ message: '记录不存在' });
res.json(result.rows[0]);
} catch (err) {
if (err.code === '23505') {
return res.status(400).json({ message: '名称已存在' });
}
res.status(500).json({ message: '服务器错误' });
}
});
// 通用删除接口 (系统管理员:超管或维护员)
router.delete('/:type/:id', verifyToken, isSystemAdmin, async (req, res) => {
const type = req.params.type;
const tableName = tableMap[type];
if (!tableName) return res.status(404).json({ message: '未知的字典类型' });
const { id } = req.params;
try {
// 获取名称用于后续逻辑或检查
const checkResult = await db.query(`SELECT name FROM ${tableName} WHERE id = $1`, [id]);
if (checkResult.rows.length === 0) return res.status(404).json({ message: '记录不存在' });
const name = checkResult.rows[0].name;
// 检查是否是“待定”
if (name === '待定') {
return res.status(400).json({ message: '系统保留项“待定”不能删除' });
}
// 开启事务处理级联更新
const client = await db.pool.connect();
try {
await client.query('BEGIN');
// 1. 执行级联更新逻辑
if (type === 'departments') {
// 更新用户表:部门设为待定,状态设为 pending
await client.query(
"UPDATE users SET department = '待定', dept_status = 'pending' WHERE department = $1",
[name]
);
// 更新成果表:替换归属部门数组中的项
await client.query(
"UPDATE achievements SET assigned_departments = array_replace(assigned_departments, $1, '待定') WHERE $1 = ANY(assigned_departments)",
[name]
);
} else if (type === 'organizations') {
await client.query("UPDATE achievement_award SET award_unit = '待定' WHERE award_unit = $1", [name]);
await client.query("UPDATE achievement_project SET source = '待定' WHERE source = $1", [name]);
await client.query("UPDATE achievement_monograph SET publisher = '待定' WHERE publisher = $1", [name]);
// achievement_plan 表没有 unit 字段,跳过
// achievement_transformation 表没有 receiver_unit 字段,跳过
await client.query("UPDATE achievement_software SET owner_unit = '待定' WHERE owner_unit = $1", [name]); // 修正字段名 owner -> owner_unit
await client.query("UPDATE achievement_paper SET first_unit = '待定' WHERE first_unit = $1", [name]);
} else if (type === 'award-types') {
await client.query("UPDATE achievement_award SET award_type = '待定' WHERE award_type = $1", [name]);
} else if (type === 'award-levels') {
await client.query("UPDATE achievement_award SET award_level = '待定' WHERE award_level = $1", [name]);
} else if (type === 'paper-types') {
await client.query("UPDATE achievement_paper SET paper_type = '待定' WHERE paper_type = $1", [name]);
} else if (type === 'standard-types') {
await client.query("UPDATE achievement_standard SET standard_type = '待定' WHERE standard_type = $1", [name]);
} else if (type === 'project-categories') {
await client.query("UPDATE achievement_project SET project_category = '待定' WHERE project_category = $1", [name]);
} else if (type === 'leader-departments') {
// 领导层部门字典仅用于筛选,删除时不影响其他表数据
}
// 2. 执行删除
await client.query(`DELETE FROM ${tableName} WHERE id = $1`, [id]);
await client.query('COMMIT');
res.json({ message: '删除成功,关联数据已同步为“待定”' });
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
module.exports = router;
+73
View File
@@ -0,0 +1,73 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const { verifyToken, isSystemAdmin } = require('../middleware/auth');
// 获取所有操作说明列表
router.get('/descriptions', verifyToken, isSystemAdmin, async (req, res) => {
try {
const result = await db.query('SELECT DISTINCT description FROM audit_logs WHERE description IS NOT NULL AND description != \'\' ORDER BY description');
res.json(result.rows.map(row => row.description));
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 获取系统日志 (超管 + 维护员)
router.get('/', verifyToken, isSystemAdmin, async (req, res) => {
const { page = 1, limit = 20, username, startDate, endDate, description } = req.query;
const offset = (page - 1) * limit;
let query = 'SELECT * FROM audit_logs WHERE 1=1';
let countQuery = 'SELECT COUNT(*) FROM audit_logs WHERE 1=1';
const params = [];
if (username) {
query += ` AND (username ILIKE $${params.length + 1} OR real_name ILIKE $${params.length + 1})`;
countQuery += ` AND (username ILIKE $${params.length + 1} OR real_name ILIKE $${params.length + 1})`;
params.push(`%${username}%`);
}
if (startDate) {
query += ` AND created_at >= $${params.length + 1}`;
countQuery += ` AND created_at >= $${params.length + 1}`;
params.push(startDate);
}
if (endDate) {
// 结束日期通常需要加一天或者设为当天的 23:59:59,这里假设前端传的是日期字符串
// 简单处理:如果传的是 YYYY-MM-DD,则数据库比较时会自动转为 00:00:00,所以可能需要处理
// 这里假设前端传的是完整时间或者后端直接比较
query += ` AND created_at <= $${params.length + 1}`;
countQuery += ` AND created_at <= $${params.length + 1}`;
params.push(endDate);
}
if (description) {
query += ` AND description = $${params.length + 1}`;
countQuery += ` AND description = $${params.length + 1}`;
params.push(description);
}
query += ` ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
try {
const countRes = await db.query(countQuery, params);
const total = parseInt(countRes.rows[0].count);
const result = await db.query(query, [...params, limit, offset]);
res.json({
logs: result.rows,
total,
page: parseInt(page),
totalPages: Math.ceil(total / limit)
});
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
module.exports = router;
+293
View File
@@ -0,0 +1,293 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../db');
const { verifyToken, isSystemAdmin } = require('../middleware/auth');
// 辅助函数:获取所有搜索路径(主目录 + 备用目录)
const getSearchPaths = () => {
const paths = [];
// 1. 主目录
const mainDir = process.env.UPLOAD_DIR || 'uploads';
paths.push(path.isAbsolute(mainDir) ? mainDir : path.join(__dirname, '..', mainDir));
// 2. 备用目录
if (process.env.UPLOAD_FALLBACK_DIRS) {
const fallbacks = process.env.UPLOAD_FALLBACK_DIRS.split(',').map(p => p.trim()).filter(p => p);
fallbacks.forEach(p => {
const absPath = path.isAbsolute(p) ? p : path.join(__dirname, '..', p);
// 避免重复
if (!paths.includes(absPath)) {
paths.push(absPath);
}
});
}
// 3. 默认 uploads 目录 (自动兜底)
// 即使未配置 UPLOAD_FALLBACK_DIRS,也自动查找默认的 uploads 目录,确保旧文件可访问
const defaultUploads = path.join(__dirname, '..', 'uploads');
const isDefaultIncluded = paths.some(p => path.resolve(p) === path.resolve(defaultUploads));
if (!isDefaultIncluded) {
paths.push(defaultUploads);
}
return paths;
};
// 辅助函数:查找文件(支持多路径回退)
const findFile = (relativePath) => {
const searchPaths = getSearchPaths();
for (const rootDir of searchPaths) {
const fullPath = path.join(rootDir, relativePath);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
return null;
};
// 辅助函数:获取上传根目录的绝对路径 (仅用于写入新文件)
const getUploadRootDir = () => {
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
if (path.isAbsolute(uploadDir)) {
return uploadDir;
} else {
return path.join(__dirname, '..', uploadDir);
}
};
const cleanupUploadedFiles = (files = []) => {
for (const file of files) {
if (!file || !file.path) continue;
try {
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
} catch (e) {
// Ignore cleanup failure
}
}
};
const sanitizeNotificationHtml = (html = '') => {
return String(html)
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/\son\w+\s*=\s*(['"]).*?\1/gi, '')
.replace(/\son\w+\s*=\s*[^\s>]+/gi, '')
.replace(/javascript:/gi, '');
};
// 配置 Multer 用于文件上传
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = getUploadRootDir();
// 确保目录存在
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'notify-' + uniqueSuffix + path.extname(file.originalname));
}
});
const allowedExtensions = [
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
'.txt', '.jpg', '.jpeg', '.png', '.zip', '.rar', '.7z'
];
const MAX_NOTIFICATION_FILE_SIZE = Number(process.env.NOTIFY_MAX_FILE_SIZE || 20 * 1024 * 1024);
const MAX_NOTIFICATION_FILES = Number(process.env.NOTIFY_MAX_FILES || 10);
const upload = multer({
storage: storage,
limits: {
fileSize: MAX_NOTIFICATION_FILE_SIZE,
files: MAX_NOTIFICATION_FILES
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExtensions.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type (${ext}).`));
}
}
});
// 获取通知列表 (所有登录用户)
router.get('/', verifyToken, async (req, res) => {
const { page = 1, limit = 10, keyword } = req.query;
const offset = (page - 1) * limit;
try {
let query = 'SELECT n.*, u.real_name as publisher_real_name FROM notifications n LEFT JOIN users u ON n.publisher_id = u.id WHERE 1=1';
let countQuery = 'SELECT COUNT(*) FROM notifications WHERE 1=1';
const params = [];
if (keyword) {
query += ` AND (title ILIKE $${params.length + 1} OR content ILIKE $${params.length + 1})`;
countQuery += ` AND (title ILIKE $${params.length + 1} OR content ILIKE $${params.length + 1})`;
params.push(`%${keyword}%`);
}
query += ` ORDER BY is_top DESC, created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
const countRes = await db.query(countQuery, params);
const total = parseInt(countRes.rows[0].count);
const result = await db.query(query, [...params, limit, offset]);
res.json({
list: result.rows,
total,
page: parseInt(page),
totalPages: Math.ceil(total / limit)
});
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 获取通知详情 (所有登录用户)
router.get('/:id', verifyToken, async (req, res) => {
const { id } = req.params;
try {
const result = await db.query('SELECT n.*, u.real_name as publisher_real_name FROM notifications n LEFT JOIN users u ON n.publisher_id = u.id WHERE n.id = $1', [id]);
if (result.rows.length === 0) return res.status(404).json({ message: '通知不存在' });
const notification = result.rows[0];
// 获取附件
const attachmentsRes = await db.query('SELECT * FROM notification_attachments WHERE notification_id = $1', [id]);
notification.attachments = attachmentsRes.rows;
res.json(notification);
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 发布通知 (仅超管和维护员)
router.post('/', verifyToken, isSystemAdmin, (req, res, next) => {
upload.array('files')(req, res, (err) => {
if (err) {
cleanupUploadedFiles(req.files || []);
return res.status(400).json({ message: err.message });
}
next();
});
}, async (req, res) => {
const { title, content, is_top } = req.body;
const files = req.files || [];
const publisher_id = req.user.id;
const publisher_name = req.user.real_name || req.user.username;
const safeContent = sanitizeNotificationHtml(content);
if (!title || !content || !String(safeContent).trim()) {
cleanupUploadedFiles(files);
return res.status(400).json({ message: '标题和内容不能为空' });
}
const client = await db.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(
'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top) VALUES ($1, $2, $3, $4, $5) RETURNING id',
[title, safeContent, publisher_id, publisher_name, is_top === 'true']
);
const notificationId = result.rows[0].id;
// 保存附件
for (const file of files) {
let originalName = file.originalname;
// 简单处理文件名编码问题
if (!/[^\u0000-\u00ff]/.test(originalName)) {
try {
const decodedName = Buffer.from(originalName, 'latin1').toString('utf8');
if (/[\u4e00-\u9fa5]/.test(decodedName)) {
originalName = decodedName;
}
} catch (e) {}
}
await client.query(
'INSERT INTO notification_attachments (notification_id, file_name, file_path) VALUES ($1, $2, $3)',
[notificationId, originalName, file.filename]
);
}
await client.query('COMMIT');
res.status(201).json({ message: '发布成功', id: notificationId });
} catch (err) {
await client.query('ROLLBACK');
cleanupUploadedFiles(files);
console.error(err);
res.status(500).json({ message: '服务器错误' });
} finally {
client.release();
}
});
// 删除通知 (仅超管和维护员)
router.delete('/:id', verifyToken, isSystemAdmin, async (req, res) => {
const { id } = req.params;
const client = await db.pool.connect();
try {
await client.query('BEGIN');
// 获取附件并删除文件
const filesRes = await client.query('SELECT file_path FROM notification_attachments WHERE notification_id = $1', [id]);
for (const row of filesRes.rows) {
const filePath = findFile(row.file_path);
if (filePath) {
try { fs.unlinkSync(filePath); } catch (e) {}
}
}
// 级联删除会自动删除 notification_attachments 表中的记录
await client.query('DELETE FROM notifications WHERE id = $1', [id]);
await client.query('COMMIT');
res.json({ message: '删除成功' });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ message: '服务器错误' });
} finally {
client.release();
}
});
// 下载附件
router.get('/attachments/:id/download', verifyToken, async (req, res) => {
const { id } = req.params;
try {
const result = await db.query('SELECT * FROM notification_attachments WHERE id = $1', [id]);
if (result.rows.length === 0) return res.status(404).json({ message: '附件不存在' });
const attachment = result.rows[0];
const filePath = findFile(attachment.file_path);
if (!filePath) return res.status(404).json({ message: '文件不存在' });
const encodedFileName = encodeURIComponent(attachment.file_name);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
res.sendFile(filePath);
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
module.exports = router;
+262
View File
@@ -0,0 +1,262 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const { verifyToken } = require('../middleware/auth');
// 获取统计数据 (所有登录用户均可访问)
router.get('/', verifyToken, async (req, res) => {
try {
const { role } = req.user;
// 权限控制:
// 1. 业务统计图表(雷达图、趋势图、部门分布等):统一只统计“已通过”的数据,反映真实有效的成果。
// 2. 状态分布图:管理员可以看到所有状态,普通用户只能看到已通过。
const approvedCondition = "status = 'approved'";
let allStatusCondition = "status = 'approved'";
if (['super_admin', 'maintainer'].includes(role)) {
allStatusCondition = "1=1"; // 允许所有状态
}
// 1. 按类型统计 (雷达图) - 仅统计已通过
const typeStats = await db.query(`
SELECT type, COUNT(*) as count
FROM achievements
WHERE ${approvedCondition}
GROUP BY type
`);
// 2. 按状态统计 (饼图) - 管理员看全量,用户看已通过
const statusStats = await db.query(`
SELECT status, COUNT(*) as count
FROM achievements
WHERE ${allStatusCondition}
GROUP BY status
`);
// 3. 按部门统计成果总数 (部门排名) - 仅统计已通过
// 口径:每条成果只归属一个部门(优先首个非院领导部门,否则回退首个部门)
const departmentStats = await db.query(`
WITH leader_depts AS (
SELECT name FROM dict_leader_departments
),
leader_dept_array AS (
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
),
department_scope AS (
SELECT d.name AS department
FROM departments d, leader_dept_array lda
WHERE d.name::text <> ALL(lda.names)
),
achievement_owner AS (
SELECT
a.id,
COALESCE(non_leader.dept, first_any.dept) AS owner_department
FROM achievements a
CROSS JOIN leader_dept_array lda
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
AND u.dept <> ALL(lda.names)
ORDER BY u.ord
LIMIT 1
) non_leader ON TRUE
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
ORDER BY u.ord
LIMIT 1
) first_any ON TRUE
WHERE ${approvedCondition}
)
SELECT ds.department, COUNT(ao.id)::int as count
FROM department_scope ds
LEFT JOIN achievement_owner ao ON ds.department = ao.owner_department
GROUP BY ds.department
ORDER BY count DESC
`);
// 4. 部门成果种类交叉统计 (堆叠柱状图) - 仅统计已通过
const deptTypeStats = await db.query(`
WITH leader_depts AS (
SELECT name FROM dict_leader_departments
),
leader_dept_array AS (
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
),
department_scope AS (
SELECT d.name AS department
FROM departments d, leader_dept_array lda
WHERE d.name::text <> ALL(lda.names)
),
achievement_owner AS (
SELECT
a.id,
a.type,
a.achievement_date,
COALESCE(non_leader.dept, first_any.dept) AS owner_department
FROM achievements a
CROSS JOIN leader_dept_array lda
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
AND u.dept <> ALL(lda.names)
ORDER BY u.ord
LIMIT 1
) non_leader ON TRUE
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
ORDER BY u.ord
LIMIT 1
) first_any ON TRUE
WHERE ${approvedCondition}
)
SELECT
ds.department as department,
ao.type,
EXTRACT(YEAR FROM ao.achievement_date) as year,
COUNT(*)::int as count
FROM department_scope ds
JOIN achievement_owner ao ON ds.department = ao.owner_department
WHERE ao.achievement_date IS NOT NULL
GROUP BY ds.department, ao.type, EXTRACT(YEAR FROM ao.achievement_date)
ORDER BY ds.department
`);
// 5. 全单位月度趋势统计 (累计折线图) - 仅统计已通过
// 前端会进行累计计算,后端只需返回每月增量
const trendStats = await db.query(`
SELECT TO_CHAR(achievement_date, 'YYYY-MM') as month, COUNT(*) as count
FROM achievements
WHERE ${approvedCondition} AND achievement_date IS NOT NULL
GROUP BY month
ORDER BY month
`);
// 6. 各部门月度趋势统计 (累计面积图) - 仅统计已通过
// 前端会进行累计计算,后端只需返回每月增量
const deptTrendStats = await db.query(`
WITH leader_depts AS (
SELECT name FROM dict_leader_departments
),
leader_dept_array AS (
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
),
department_scope AS (
SELECT d.name AS department
FROM departments d, leader_dept_array lda
WHERE d.name::text <> ALL(lda.names)
),
achievement_owner AS (
SELECT
a.id,
a.achievement_date,
COALESCE(non_leader.dept, first_any.dept) AS owner_department
FROM achievements a
CROSS JOIN leader_dept_array lda
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
AND u.dept <> ALL(lda.names)
ORDER BY u.ord
LIMIT 1
) non_leader ON TRUE
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
ORDER BY u.ord
LIMIT 1
) first_any ON TRUE
WHERE ${approvedCondition}
)
SELECT
TO_CHAR(ao.achievement_date, 'YYYY-MM') as month,
ds.department as department,
COUNT(*)::int as count
FROM department_scope ds
JOIN achievement_owner ao ON ds.department = ao.owner_department
WHERE ao.achievement_date IS NOT NULL
GROUP BY TO_CHAR(ao.achievement_date, 'YYYY-MM'), ds.department
ORDER BY month
`);
// 7. 按部门统计“独立完成 / 合作项目”
// 当前口径下每条成果仅归属一个部门,因此合作计数恒为 0。
const projectModeStats = await db.query(`
WITH leader_depts AS (
SELECT name FROM dict_leader_departments
),
leader_dept_array AS (
SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts
),
department_scope AS (
SELECT d.name AS department
FROM departments d, leader_dept_array lda
WHERE d.name::text <> ALL(lda.names)
),
achievement_owner AS (
SELECT
a.id,
COALESCE(non_leader.dept, first_any.dept) AS owner_department
FROM achievements a
CROSS JOIN leader_dept_array lda
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
AND u.dept <> ALL(lda.names)
ORDER BY u.ord
LIMIT 1
) non_leader ON TRUE
LEFT JOIN LATERAL (
SELECT u.dept
FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord)
WHERE u.dept IS NOT NULL
AND BTRIM(u.dept) <> ''
ORDER BY u.ord
LIMIT 1
) first_any ON TRUE
WHERE ${approvedCondition}
)
SELECT
ds.department,
COUNT(ao.id)::int AS independent_count,
0::int AS collaboration_count,
COUNT(ao.id)::int AS total_count
FROM department_scope ds
LEFT JOIN achievement_owner ao ON ao.owner_department = ds.department
GROUP BY ds.department
ORDER BY total_count DESC, ds.department
`);
res.json({
byType: typeStats.rows,
byStatus: statusStats.rows,
byDepartment: departmentStats.rows,
deptTypeDetails: deptTypeStats.rows,
byMonth: trendStats.rows,
deptTrendDetails: deptTrendStats.rows,
projectModeByDepartment: projectModeStats.rows
});
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
module.exports = router;
+204
View File
@@ -0,0 +1,204 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const db = require('../db');
const { verifyToken, isSuperAdmin, isSystemAdmin } = require('../middleware/auth');
// 搜索用户 (所有登录用户可用,用于成果录入选择作者)
router.get('/search', verifyToken, async (req, res) => {
const { keyword, leaderOnly } = req.query;
if (!keyword) return res.json([]);
try {
let query = `
SELECT id, username as phone, real_name, department
FROM users
WHERE (username ILIKE $1 OR real_name ILIKE $1)
`;
const params = [`%${keyword}%`];
if (leaderOnly === 'true') {
// 获取所有领导层部门
const leaderDeptsRes = await db.query('SELECT name FROM dict_leader_departments');
const leaderDepts = leaderDeptsRes.rows.map(r => r.name);
if (leaderDepts.length > 0) {
query += ` AND department = ANY($${params.length + 1})`;
params.push(leaderDepts);
} else {
// 如果没有配置领导层部门,则返回空
return res.json([]);
}
}
query += ` LIMIT 20`;
const result = await db.query(query, params);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
// 获取所有待定状态用户的手机号列表 (用于前端标红显示)
router.get('/pending-list', verifyToken, async (req, res) => {
try {
const result = await db.query('SELECT username FROM users WHERE dept_status = \'pending\'');
const phones = result.rows.map(row => row.username);
res.json(phones);
} catch (err) {
res.status(500).json({ message: '服务器错误' });
}
});
// 获取所有用户列表 (系统管理员:超管或维护员)
router.get('/', verifyToken, isSystemAdmin, async (req, res) => {
const { keyword } = req.query;
try {
let query = 'SELECT id, username, role, real_name, department, dept_status, created_at FROM users WHERE 1=1';
const params = [];
if (keyword) {
query += ` AND (username ILIKE $1 OR real_name ILIKE $1 OR department ILIKE $1)`;
params.push(`%${keyword}%`);
}
query += ' ORDER BY created_at DESC';
const result = await db.query(query, params);
res.json(result.rows);
} catch (err) {
res.status(500).json({ message: '服务器错误' });
}
});
// 创建新用户 (仅超管)
router.post('/', verifyToken, isSuperAdmin, async (req, res) => {
const { username, password, role, real_name, department: inputDepartment } = req.body;
let department = inputDepartment;
if (!username || !password || !role) {
return res.status(400).json({ message: '缺少必填字段 (username, password, role)' });
}
try {
// 手机号格式校验
if (!/^1[3-9]\d{9}$/.test(username)) {
return res.status(400).json({ message: '手机号格式不正确' });
}
const userExists = await db.query('SELECT * FROM users WHERE username = $1', [username]);
if (userExists.rows.length > 0) {
return res.status(400).json({ message: '该手机号已注册' });
}
// 校验部门
let deptStatus = 'verified';
if (['admin', 'user'].includes(role)) {
if (!department) {
return res.status(400).json({ message: '该角色必须指定部门' });
}
const deptCheck = await db.query('SELECT * FROM departments WHERE name = $1', [department]);
deptStatus = deptCheck.rows.length > 0 ? 'verified' : 'pending';
} else {
// super_admin 或 maintainer 可以没有部门
if (!department) department = null;
}
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
const result = await db.query(
'INSERT INTO users (username, password_hash, role, real_name, department, dept_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, username, role, real_name, department, dept_status',
[username, passwordHash, role, real_name, department, deptStatus]
);
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ message: '服务器错误' });
}
});
// 修改用户信息 (系统管理员:超管或维护员)
router.put('/:id', verifyToken, isSystemAdmin, async (req, res) => {
const { id } = req.params;
let { username, role, real_name, department, password } = req.body;
// 如果是维护员,只能修改密码,忽略其他字段
if (req.user.role === 'maintainer') {
if (!password) {
return res.json({ message: '未提供新密码,无任何修改' });
}
try {
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
await db.query(
'UPDATE users SET password_hash=$1, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE id=$2',
[passwordHash, id]
);
return res.json({ message: '密码重置成功' });
} catch (err) {
return res.status(500).json({ message: '服务器错误' });
}
}
// 以下逻辑仅适用于超级管理员 (super_admin)
try {
// 手机号格式校验
if (!/^1[3-9]\d{9}$/.test(username)) {
return res.status(400).json({ message: '手机号格式不正确' });
}
// 校验部门
let deptStatus = 'verified';
if (['admin', 'user'].includes(role)) {
if (!department) {
return res.status(400).json({ message: '该角色必须指定部门' });
}
const deptCheck = await db.query('SELECT * FROM departments WHERE name = $1', [department]);
deptStatus = deptCheck.rows.length > 0 ? 'verified' : 'pending';
} else {
// super_admin 或 maintainer 可以没有部门
if (!department) department = null;
}
let query = 'UPDATE users SET username=$1, role=$2, real_name=$3, department=$4, dept_status=$5';
let params = [username, role, real_name, department, deptStatus, id];
if (password) {
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
query += ', password_hash=$6, token_version = CASE WHEN token_version >= 2000000000 THEN 1 ELSE token_version + 1 END WHERE id=$7';
params = [username, role, real_name, department, deptStatus, passwordHash, id];
} else {
query += ' WHERE id=$6';
}
await db.query(query, params);
res.json({ message: '修改成功' });
} catch (err) {
res.status(500).json({ message: '服务器错误' });
}
});
// 删除用户 (仅超管)
router.delete('/:id', verifyToken, isSuperAdmin, async (req, res) => {
const { id } = req.params;
try {
// 防止删除自己
if (parseInt(id) === req.user.id) {
return res.status(400).json({ message: '不能删除当前登录账号' });
}
// 检查是否有关联的成果
const achievementCheck = await db.query('SELECT id FROM achievements WHERE user_id = $1 LIMIT 1', [id]);
if (achievementCheck.rows.length > 0) {
return res.status(400).json({ message: '该用户有关联的成果数据,无法直接删除。请先处理关联数据。' });
}
await db.query('DELETE FROM users WHERE id = $1', [id]);
res.json({ message: '删除成功' });
} catch (err) {
console.error(err);
res.status(500).json({ message: '服务器错误' });
}
});
module.exports = router;