chore: initial import
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
PORT=5000
|
||||
DB_USER=postgres
|
||||
DB_HOST=localhost
|
||||
DB_NAME=stsystem
|
||||
DB_PASSWORD=postgres
|
||||
DB_PORT=5432
|
||||
JWT_SECRET=st_system_secret_2026
|
||||
|
||||
# 文件存储配置
|
||||
# 主上传目录 (支持绝对路径或相对路径)
|
||||
UPLOAD_DIR=uploads
|
||||
# Disk monitor config (optional)
|
||||
# DISK_MONITOR_ENABLED=true
|
||||
# DISK_ALERT_THRESHOLD_GB=10
|
||||
# DISK_ALERT_COOLDOWN_HOURS=24
|
||||
# DISK_CHECK_INTERVAL_MINUTES=60
|
||||
# 历史归档目录 (可选,多个路径用逗号分隔,用于多盘存储或历史数据迁移)
|
||||
# UPLOAD_FALLBACK_DIRS="D:\old_uploads,E:\archive"
|
||||
|
||||
# AI 分析配置:支持多提供方(OpenAI 兼容接口)
|
||||
AI_DEFAULT_PROVIDER=deepseek
|
||||
AI_PROVIDER_PRIORITY=deepseek
|
||||
|
||||
# 千问(当前已停用,如需恢复可自行启用)
|
||||
# QWEN_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
# QWEN_API_KEY=
|
||||
# QWEN_MODEL=qwen-plus
|
||||
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
|
||||
# custom:兼容旧配置,可用于企业内网代理
|
||||
AI_API_BASE_URL=
|
||||
AI_API_KEY=
|
||||
AI_MODEL=
|
||||
AI_TIMEOUT_MS=45000
|
||||
AI_MAX_TOKENS=4096
|
||||
AI_NAME_MAX_ITEMS_DEFAULT=100
|
||||
AI_NAME_MAX_ITEMS_HARD_LIMIT=300
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_DATABASE || process.env.DB_NAME,
|
||||
password: String(process.env.DB_PASSWORD || ''),
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
// 监听连接池错误,防止空闲客户端错误导致程序崩溃
|
||||
pool.on('error', (err, client) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
query: (text, params) => pool.query(text, params),
|
||||
pool
|
||||
};
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const logger = require('./utils/logger');
|
||||
const auditLogger = require('./middleware/auditLogger');
|
||||
const { checkDiskSpace } = require('./utils/diskMonitor');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 信任反向代理 (Nginx)
|
||||
// 必须设置此项,否则在 Nginx 后端运行时,req.ip 永远是 127.0.0.1,导致限流策略对所有用户生效(误伤)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// 中间件
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// 定义脱敏的 URL token
|
||||
morgan.token('safe-url', (req) => {
|
||||
let url = req.originalUrl || req.url;
|
||||
try {
|
||||
// 隐藏 token, password, code 等敏感参数
|
||||
url = url.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
return url;
|
||||
});
|
||||
|
||||
// HTTP 请求日志 (使用自定义格式,替换 :url 为 :safe-url)
|
||||
app.use(morgan(':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"', { stream: logger.stream }));
|
||||
|
||||
// 全局审计日志
|
||||
app.use(auditLogger);
|
||||
|
||||
// 限流策略配置
|
||||
// 1. 全局限流:每个IP每分钟最多100次请求
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
max: 100, // 限制每个IP 100次请求
|
||||
standardHeaders: true, // 返回 `RateLimit-*` 头信息
|
||||
legacyHeaders: false, // 禁用 `X-RateLimit-*` 头信息
|
||||
message: { message: '请求过于频繁,请稍后再试' }
|
||||
});
|
||||
|
||||
// 2. 认证接口限流:每个IP每分钟最多10次请求 (防暴力破解)
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1分钟
|
||||
max: 10, // 限制每个IP 10次请求
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: '登录/注册尝试过于频繁,请1分钟后再试' }
|
||||
});
|
||||
|
||||
// 应用全局限流
|
||||
app.use('/api/', globalLimiter);
|
||||
|
||||
// 安全策略:禁止移动端访问
|
||||
app.use((req, res, next) => {
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
// 匹配常见的移动设备标识
|
||||
const isMobile = /mobile|android|iphone|ipad|phone/i.test(userAgent);
|
||||
|
||||
if (isMobile) {
|
||||
res.status(403).send(`
|
||||
<html>
|
||||
<head><title>访问被拒绝</title></head>
|
||||
<body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;background:#f0f2f5;">
|
||||
<div style="text-align:center;padding:40px;background:white;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.15);">
|
||||
<h1 style="color:#ff4d4f;">🚫 访问被拒绝</h1>
|
||||
<p style="font-size:18px;color:#333;">根据本单位安全规定,本系统<strong>禁止使用移动设备</strong>访问。</p>
|
||||
<p style="color:#666;">请使用办公电脑访问。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 确保上传目录存在
|
||||
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
||||
let absoluteUploadDir;
|
||||
if (path.isAbsolute(uploadDir)) {
|
||||
absoluteUploadDir = uploadDir;
|
||||
} else {
|
||||
absoluteUploadDir = path.join(__dirname, uploadDir);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absoluteUploadDir)) {
|
||||
fs.mkdirSync(absoluteUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const DISK_MONITOR_ENABLED = String(process.env.DISK_MONITOR_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const DISK_CHECK_INTERVAL_MINUTES = Number(process.env.DISK_CHECK_INTERVAL_MINUTES || 60);
|
||||
const DISK_CHECK_INTERVAL_MS = Number.isFinite(DISK_CHECK_INTERVAL_MINUTES) && DISK_CHECK_INTERVAL_MINUTES > 0
|
||||
? DISK_CHECK_INTERVAL_MINUTES * 60 * 1000
|
||||
: 60 * 60 * 1000;
|
||||
|
||||
// 启动磁盘监控(默认每 60 分钟检查一次)
|
||||
if (DISK_MONITOR_ENABLED) {
|
||||
setInterval(() => {
|
||||
checkDiskSpace();
|
||||
}, DISK_CHECK_INTERVAL_MS);
|
||||
|
||||
// 启动时立即检查一次
|
||||
checkDiskSpace();
|
||||
}
|
||||
|
||||
// 导入路由
|
||||
const authRoutes = require('./routes/auth');
|
||||
const achievementRoutes = require('./routes/achievements');
|
||||
const userRoutes = require('./routes/users');
|
||||
const statisticsRoutes = require('./routes/statistics');
|
||||
const dictionaryRoutes = require('./routes/dictionaries');
|
||||
const logRoutes = require('./routes/logs');
|
||||
const notificationRoutes = require('./routes/notifications');
|
||||
const aiAnalysisRoutes = require('./routes/aiAnalysis');
|
||||
|
||||
// 对认证路由应用更严格的限流
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/achievements', achievementRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/statistics', statisticsRoutes);
|
||||
app.use('/api/dictionaries', dictionaryRoutes);
|
||||
app.use('/api/logs', logRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/ai-analysis', aiAnalysisRoutes);
|
||||
|
||||
// 静态文件服务 (用于开发环境查看附件,生产环境建议用 Nginx)
|
||||
app.use('/uploads', express.static(absoluteUploadDir, {
|
||||
setHeaders: (res, path) => {
|
||||
// 强制下载,防止浏览器直接预览 HTML/SVG 等可能包含脚本的文件
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
}
|
||||
}));
|
||||
|
||||
// 托管前端静态文件 (生产环境)
|
||||
const clientBuildPath = path.join(__dirname, '../client/build');
|
||||
if (fs.existsSync(clientBuildPath)) {
|
||||
app.use(express.static(clientBuildPath));
|
||||
// 使用正则表达式排除以 /api/ 开头的路径
|
||||
app.get(/^(?!\/api\/).*$/, (req, res) => {
|
||||
res.sendFile(path.join(clientBuildPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// 尝试读取 SSL 证书 (用于 HTTPS)
|
||||
const keyPath = path.join(__dirname, 'server.key');
|
||||
const certPath = path.join(__dirname, 'server.cert');
|
||||
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||
// 如果存在证书,启动 HTTPS 服务器
|
||||
const options = {
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath)
|
||||
};
|
||||
https.createServer(options, app).listen(PORT, () => {
|
||||
const msg = `HTTPS 安全服务器运行在 https://localhost:${PORT}`;
|
||||
console.log(msg);
|
||||
logger.info(msg);
|
||||
console.log(`局域网访问请使用: https://<你的IP>:${PORT}`);
|
||||
});
|
||||
} else {
|
||||
// 否则启动标准 HTTP 服务器
|
||||
app.listen(PORT, () => {
|
||||
const msg = `HTTP 服务器运行在 http://localhost:${PORT}`;
|
||||
console.log(msg);
|
||||
logger.info(msg);
|
||||
console.log(`提示: 未检测到 server.key 和 server.cert,已降级为 HTTP 模式。`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
const logger = require('../utils/logger');
|
||||
const db = require('../db');
|
||||
|
||||
/**
|
||||
* 根据请求方法和 URL 获取操作说明
|
||||
*/
|
||||
const getActionDescription = (method, url) => {
|
||||
// 移除查询参数
|
||||
const path = url.split('?')[0];
|
||||
|
||||
// --- Auth ---
|
||||
if (path === '/api/auth/login' && method === 'POST') return '用户登录';
|
||||
if (path === '/api/auth/register' && method === 'POST') return '用户注册';
|
||||
if (path === '/api/auth/send-code' && method === 'POST') return '发送验证码';
|
||||
if (path === '/api/auth/reset-password' && method === 'POST') return '重置密码';
|
||||
|
||||
// --- Achievements ---
|
||||
if (path === '/api/achievements' && method === 'POST') return '上传成果';
|
||||
if (path === '/api/achievements' && method === 'GET') {
|
||||
if (url.includes('export_action=true')) return '导出用户成果数据';
|
||||
return '查看成果列表';
|
||||
}
|
||||
if (path === '/api/achievements/export/csv' && method === 'GET') return '导出成果(CSV)';
|
||||
if (path === '/api/achievements/export/zip' && method === 'GET') return '导出成果(ZIP)';
|
||||
if (path === '/api/achievements/validate' && method === 'GET') return '数据校验';
|
||||
|
||||
// 动态路径匹配
|
||||
if (path.match(/^\/api\/achievements\/audit\/\d+$/) && method === 'POST') return '审核成果';
|
||||
if (path.match(/^\/api\/achievements\/\d+\/attachments\/zip$/) && method === 'GET') return '下载成果附件包';
|
||||
if (path.match(/^\/api\/achievements\/attachments\/\d+\/download$/) && method === 'GET') return '下载单个附件';
|
||||
if (path.match(/^\/api\/achievements\/\d+$/)) {
|
||||
if (method === 'GET') return '查看成果详情';
|
||||
if (method === 'PUT') return '修改成果';
|
||||
if (method === 'DELETE') return '删除成果';
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
if (path === '/api/users' && method === 'POST') return '创建用户';
|
||||
if (path === '/api/users' && method === 'GET') return '查看用户列表';
|
||||
if (path === '/api/users/search' && method === 'GET') return '搜索用户';
|
||||
if (path === '/api/users/pending-list' && method === 'GET') return '查看待定用户';
|
||||
if (path.match(/^\/api\/users\/\d+$/)) {
|
||||
if (method === 'PUT') return '修改用户';
|
||||
if (method === 'DELETE') return '删除用户';
|
||||
}
|
||||
|
||||
// --- Dictionaries ---
|
||||
if (path === '/api/dictionaries/departments' && method === 'GET') return '查看部门列表(公开)';
|
||||
if (path.match(/^\/api\/dictionaries\/[^/]+$/)) {
|
||||
if (method === 'GET') return '查看字典列表';
|
||||
if (method === 'POST') return '添加字典项';
|
||||
}
|
||||
if (path.match(/^\/api\/dictionaries\/[^/]+\/\d+$/)) {
|
||||
if (method === 'PUT') return '修改字典项';
|
||||
if (method === 'DELETE') return '删除字典项';
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
if (path === '/api/notifications' && method === 'GET') return '查看通知列表';
|
||||
if (path === '/api/notifications' && method === 'POST') return '发布通知';
|
||||
if (path.match(/^\/api\/notifications\/attachments\/\d+\/download$/) && method === 'GET') return '下载通知附件';
|
||||
if (path.match(/^\/api\/notifications\/\d+$/)) {
|
||||
if (method === 'GET') return '查看通知详情';
|
||||
if (method === 'DELETE') return '删除通知';
|
||||
}
|
||||
|
||||
// --- Statistics ---
|
||||
if (path === '/api/statistics' && method === 'GET') return '查看统计数据';
|
||||
|
||||
// --- Logs ---
|
||||
if (path === '/api/logs' && method === 'GET') return '查看系统日志';
|
||||
|
||||
// 默认
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局审计日志中间件
|
||||
* 记录所有 API 请求的操作人、IP、方法、URL 和 结果状态
|
||||
*/
|
||||
const auditLogger = (req, res, next) => {
|
||||
// 记录请求开始时间
|
||||
const start = Date.now();
|
||||
|
||||
// 监听请求完成事件
|
||||
res.on('finish', async () => {
|
||||
const duration = Date.now() - start;
|
||||
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||
const method = req.method;
|
||||
const url = req.originalUrl || req.url;
|
||||
const status = res.statusCode;
|
||||
|
||||
// 忽略非 API 请求 (虽然通常挂载在 /api 下,但双重保险)
|
||||
if (!url.startsWith('/api/')) return;
|
||||
|
||||
// 忽略 OPTIONS 预检请求,避免日志刷屏
|
||||
if (method === 'OPTIONS') return;
|
||||
|
||||
// URL 脱敏处理:隐藏 token, password, code 等敏感参数
|
||||
let safeUrl = url;
|
||||
try {
|
||||
safeUrl = safeUrl.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
||||
} catch (e) {
|
||||
// 忽略正则错误,保持原样
|
||||
}
|
||||
|
||||
let userInfo = 'Guest';
|
||||
let userId = null;
|
||||
let username = null;
|
||||
let realName = null;
|
||||
|
||||
// 1. 尝试获取已登录用户信息
|
||||
// 注意:req.user 由 verifyToken 中间件挂载。
|
||||
// 由于我们在 res 'finish' 事件中读取,此时路由处理已完成,req.user 应该已存在(如果通过了认证)。
|
||||
if (req.user) {
|
||||
userInfo = `${req.user.real_name || req.user.username} (ID:${req.user.id})`;
|
||||
userId = req.user.id;
|
||||
username = req.user.username;
|
||||
realName = req.user.real_name;
|
||||
}
|
||||
// 2. 对于未登录的关键接口,尝试从 body 中提取身份信息
|
||||
else if (req.body) {
|
||||
if (url.includes('/login')) {
|
||||
userInfo = `[尝试登录: ${req.body.username || '未知'}]`;
|
||||
username = req.body.username;
|
||||
} else if (url.includes('/register')) {
|
||||
userInfo = `[尝试注册: ${req.body.username || '未知'}]`;
|
||||
username = req.body.username;
|
||||
} else if (url.includes('/send-code')) {
|
||||
userInfo = `[请求验证码: ${req.body.phoneNumber || '未知'}]`;
|
||||
username = req.body.phoneNumber;
|
||||
} else if (url.includes('/reset-password')) {
|
||||
userInfo = `[重置密码: ${req.body.phoneNumber || '未知'}]`;
|
||||
username = req.body.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取操作说明
|
||||
const description = getActionDescription(method, url);
|
||||
|
||||
// 构建日志消息
|
||||
const logMessage = `[AUDIT] IP:${clientIp} | User:${userInfo} | ${method} ${safeUrl} | Action:${description || 'Unknown'} | Status:${status} | Time:${duration}ms`;
|
||||
|
||||
// 根据状态码记录不同级别的日志
|
||||
if (status >= 500) {
|
||||
logger.error(logMessage);
|
||||
} else if (status >= 400) {
|
||||
logger.warn(logMessage);
|
||||
} else {
|
||||
logger.info(logMessage);
|
||||
}
|
||||
|
||||
// 写入数据库
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO audit_logs (user_id, username, real_name, ip_address, method, url, description, status, duration) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
|
||||
[userId, username, realName, clientIp, method, safeUrl, description, status, duration]
|
||||
);
|
||||
} catch (err) {
|
||||
// 数据库写入失败不应影响主流程,仅记录错误
|
||||
console.error('审计日志写入数据库失败:', err);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = auditLogger;
|
||||
@@ -0,0 +1,74 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
require('dotenv').config();
|
||||
|
||||
const verifyToken = async (req, res, next) => {
|
||||
// 优先从 Header 获取,如果没有则从 Query String 获取(用于下载接口)
|
||||
const token = req.headers['authorization']?.split(' ')[1] || req.query.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: '未提供认证令牌' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// 单点登录检查:比对 Token 中的版本号与数据库中的版本号
|
||||
// 如果 Token 中没有 version (旧 Token),视为 0
|
||||
const tokenVersion = decoded.token_version || 0;
|
||||
|
||||
const result = await db.query('SELECT token_version FROM users WHERE id = $1', [decoded.id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
const dbVersion = result.rows[0].token_version || 0;
|
||||
|
||||
if (tokenVersion !== dbVersion) {
|
||||
return res.status(401).json({ message: '您的账号已在其他设备登录,请重新登录' });
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ message: '登录已过期,请重新登录' });
|
||||
}
|
||||
return res.status(401).json({ message: '无效的令牌' });
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'super_admin')) {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要管理员权限' });
|
||||
}
|
||||
};
|
||||
|
||||
const isSuperAdmin = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'super_admin') {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要超级管理员权限' });
|
||||
}
|
||||
};
|
||||
|
||||
const isMaintainer = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'maintainer') {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要系统维护员权限' });
|
||||
}
|
||||
};
|
||||
|
||||
// 系统管理员权限:超级管理员 OR 系统维护员
|
||||
const isSystemAdmin = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'super_admin' || req.user.role === 'maintainer')) {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要系统管理权限' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { verifyToken, isAdmin, isSuperAdmin, isMaintainer, isSystemAdmin };
|
||||
Generated
+3000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"init-db": "node scripts/create_db.js && node scripts/setup.js",
|
||||
"seed-500k": "node scripts/seed_data_limited_500k.js",
|
||||
"seed-500k:clean": "node scripts/seed_data_limited_500k.js --clean",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.4",
|
||||
"@alicloud/dypnsapi20170525": "^2.0.0",
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@alicloud/tea-util": "^1.4.11",
|
||||
"archiver": "^7.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"content-disposition": "^1.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"file-type": "^16.5.4",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"morgan": "^1.10.1",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.16.3",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,17 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
const db = require('../db');
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
console.log('正在添加 token_version 字段...');
|
||||
await db.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER DEFAULT 0');
|
||||
console.log('成功添加 token_version 字段');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('执行失败:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,38 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
const db = require('../db');
|
||||
|
||||
async function createAuditTable() {
|
||||
const client = await db.pool.connect();
|
||||
try {
|
||||
console.log('正在创建 audit_logs 表...');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
username VARCHAR(50),
|
||||
real_name VARCHAR(50),
|
||||
ip_address VARCHAR(50),
|
||||
method VARCHAR(10),
|
||||
url TEXT,
|
||||
description VARCHAR(255),
|
||||
status INTEGER,
|
||||
duration INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('正在创建索引...');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);');
|
||||
await client.query('CREATE INDEX IF NOT EXISTS idx_audit_logs_username ON audit_logs(username);');
|
||||
|
||||
console.log('audit_logs 表创建成功!');
|
||||
} catch (err) {
|
||||
console.error('创建表失败:', err);
|
||||
} finally {
|
||||
client.release();
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
createAuditTable();
|
||||
@@ -0,0 +1,42 @@
|
||||
const { Client } = require('pg');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const createDb = async () => {
|
||||
const dbName = process.env.DB_DATABASE || 'stsystem';
|
||||
|
||||
// 连接到默认的 postgres 数据库
|
||||
const client = new Client({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: 'postgres', // 连接到默认数据库
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log(`已连接到 postgres 数据库,正在检查 ${dbName} 是否存在...`);
|
||||
|
||||
const res = await client.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [dbName]);
|
||||
|
||||
if (res.rows.length === 0) {
|
||||
console.log(`数据库 ${dbName} 不存在,正在创建...`);
|
||||
// CREATE DATABASE 不能在事务块中运行,也不能使用参数化查询
|
||||
await client.query(`CREATE DATABASE "${dbName}"`);
|
||||
console.log(`数据库 ${dbName} 创建成功!`);
|
||||
} else {
|
||||
console.log(`数据库 ${dbName} 已存在,跳过创建。`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建数据库失败:', err);
|
||||
// 如果是因为 postgres 数据库不存在(极少见)或者密码错误,这里会报错
|
||||
// 但我们不应该阻塞后续流程,也许用户已经手动创建了 stsystem
|
||||
// 不过为了安全起见,如果这一步失败,通常意味着配置错误,还是退出比较好
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
};
|
||||
|
||||
createDb();
|
||||
@@ -0,0 +1,48 @@
|
||||
const db = require('../db');
|
||||
|
||||
async function createTables() {
|
||||
const client = await db.pool.connect();
|
||||
try {
|
||||
console.log('开始创建通知相关表...');
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 创建 notifications 表
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
publisher_id INTEGER REFERENCES users(id),
|
||||
publisher_name VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_top BOOLEAN DEFAULT FALSE,
|
||||
status VARCHAR(20) DEFAULT 'published' -- published, draft, archived
|
||||
);
|
||||
`);
|
||||
console.log('notifications 表创建成功');
|
||||
|
||||
// 创建 notification_attachments 表
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS notification_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
console.log('notification_attachments 表创建成功');
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log('所有表创建完成');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('创建表失败:', err);
|
||||
} finally {
|
||||
client.release();
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
createTables();
|
||||
@@ -0,0 +1,63 @@
|
||||
const { Pool } = require('pg');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_DATABASE || process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
async function debug() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
console.log('🔍 开始调试统计数据...');
|
||||
|
||||
// 1. 检查状态分布
|
||||
console.log('\n📊 1. 成果状态分布:');
|
||||
const statusRes = await client.query('SELECT status, COUNT(*) FROM achievements GROUP BY status');
|
||||
console.table(statusRes.rows);
|
||||
|
||||
// 2. 检查部门字段样本
|
||||
console.log('\n📋 2. 成果部门字段样本 (前5条):');
|
||||
const deptRes = await client.query('SELECT id, assigned_departments FROM achievements LIMIT 5');
|
||||
console.table(deptRes.rows);
|
||||
|
||||
// 3. 检查部门表
|
||||
console.log('\n🏢 3. 部门表数据:');
|
||||
const deptsRes = await client.query('SELECT name FROM departments');
|
||||
console.table(deptsRes.rows);
|
||||
|
||||
// 4. 运行统计查询 (模拟 statistics.js 中的查询)
|
||||
console.log('\n📈 4. 运行统计查询 (status=\'approved\'):');
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
d.name as department,
|
||||
a.type,
|
||||
EXTRACT(YEAR FROM a.achievement_date) as year,
|
||||
COUNT(*) as count
|
||||
FROM departments d
|
||||
JOIN achievements a ON d.name = ANY(a.assigned_departments)
|
||||
WHERE a.status = 'approved' AND a.achievement_date IS NOT NULL
|
||||
GROUP BY d.name, a.type, year
|
||||
ORDER BY d.name
|
||||
`;
|
||||
const statsRes = await client.query(statsQuery);
|
||||
console.log(`查询返回 ${statsRes.rows.length} 行数据`);
|
||||
if (statsRes.rows.length > 0) {
|
||||
console.table(statsRes.rows.slice(0, 10)); // 只显示前10条
|
||||
} else {
|
||||
console.log('⚠️ 查询结果为空!');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('❌ 调试出错:', e);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
debug();
|
||||
@@ -0,0 +1,45 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
const db = require('../db');
|
||||
|
||||
async function listSchema() {
|
||||
try {
|
||||
const res = await db.query(`
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_schema = 'public'
|
||||
ORDER BY
|
||||
table_name, ordinal_position
|
||||
`);
|
||||
|
||||
const tables = {};
|
||||
res.rows.forEach(row => {
|
||||
if (!tables[row.table_name]) {
|
||||
tables[row.table_name] = [];
|
||||
}
|
||||
tables[row.table_name].push({
|
||||
column: row.column_name,
|
||||
type: row.data_type,
|
||||
nullable: row.is_nullable
|
||||
});
|
||||
});
|
||||
|
||||
console.log('--- 数据库全量表结构 ---');
|
||||
for (const [tableName, columns] of Object.entries(tables)) {
|
||||
console.log(`\n表名: ${tableName}`);
|
||||
console.table(columns);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('查询失败:', err);
|
||||
} finally {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
listSchema();
|
||||
@@ -0,0 +1,212 @@
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_DATABASE || process.env.DB_NAME, // 兼容 .env 中的不同命名
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
// 随机数据源
|
||||
const DEPARTMENTS = ['研发部', '市场部', '人事部', '财务部', '销售部', '运维部'];
|
||||
const SURNAMES = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨'];
|
||||
const NAMES = ['伟', '芳', '娜', '敏', '静', '强', '磊', '军', '洋', '勇', '艳', '杰', '娟', '涛', '明', '超'];
|
||||
const TITLES_PREFIX = ['基于AI的', '高性能', '分布式', '新一代', '云原生', '跨平台', '智能', '自动化'];
|
||||
const TITLES_SUFFIX = ['系统设计', '算法研究', '平台开发', '应用实践', '优化方案', '技术白皮书', '分析报告'];
|
||||
|
||||
// 字典数据
|
||||
const DICTS = {
|
||||
dict_organizations: ['科技大学', '软件研究所', '创新中心', '数据实验室'],
|
||||
dict_paper_types: ['SCI', 'EI', '核心期刊', '会议论文'],
|
||||
dict_award_types: ['科技进步奖', '技术发明奖', '自然科学奖'],
|
||||
dict_award_levels: ['一等奖', '二等奖', '三等奖', '特等奖'],
|
||||
dict_project_categories: ['国家重点研发计划', '自然科学基金', '省部级项目', '横向课题'],
|
||||
dict_standard_types: ['国家标准', '行业标准', '团体标准', '企业标准']
|
||||
};
|
||||
|
||||
const ACHIEVEMENT_TYPES = [
|
||||
'paper', 'project', 'award', 'standard', 'monograph', 'report', 'plan', 'patent', 'transformation', 'software'
|
||||
];
|
||||
|
||||
const STATUSES = ['pending', 'approved', 'rejected'];
|
||||
|
||||
// 辅助函数
|
||||
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const randomChoice = (arr) => arr[randomInt(0, arr.length - 1)];
|
||||
const generatePhone = () => `1${randomChoice(['3', '5', '7', '8', '9'])}${randomInt(100000000, 999999999)}`;
|
||||
const generateName = () => `${randomChoice(SURNAMES)}${randomChoice(NAMES)}${Math.random() > 0.5 ? randomChoice(NAMES) : ''}`;
|
||||
const generateTitle = () => `${randomChoice(TITLES_PREFIX)}${randomChoice(TITLES_SUFFIX)}`;
|
||||
const generateDate = (startYear, endYear) => {
|
||||
const year = randomInt(startYear, endYear);
|
||||
const month = randomInt(0, 11);
|
||||
const day = randomInt(1, 28);
|
||||
return new Date(year, month, day);
|
||||
};
|
||||
|
||||
async function seed() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
console.log('🚀 开始注入测试数据...');
|
||||
|
||||
// 0. 初始化数据库架构
|
||||
console.log('🏗️ 初始化数据库架构...');
|
||||
const schemaPath = path.join(__dirname, '../../database/schema.sql');
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
|
||||
await client.query(schemaSql);
|
||||
console.log('✅ 数据库架构已应用');
|
||||
} else {
|
||||
console.warn('⚠️ 未找到 schema.sql,跳过架构初始化');
|
||||
}
|
||||
|
||||
// 1. 清理数据 (保留特定账号)
|
||||
console.log('🧹 清理旧数据...');
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 级联删除所有成果 (这将自动删除详情表和附件表)
|
||||
await client.query('TRUNCATE achievements CASCADE');
|
||||
|
||||
// 删除除保留账号外的所有用户
|
||||
const keepUsers = ['18888888888', '19999999999'];
|
||||
await client.query('DELETE FROM users WHERE username != $1 AND username != $2', [keepUsers[0], keepUsers[1]]);
|
||||
|
||||
console.log('✅ 旧数据清理完成');
|
||||
|
||||
// 2. 填充字典和部门
|
||||
console.log('📚 填充字典数据...');
|
||||
for (const [table, values] of Object.entries(DICTS)) {
|
||||
for (const val of values) {
|
||||
await client.query(`INSERT INTO ${table} (name) VALUES ($1) ON CONFLICT (name) DO NOTHING`, [val]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dept of DEPARTMENTS) {
|
||||
await client.query(
|
||||
'INSERT INTO departments (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
|
||||
[dept]
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 生成新用户
|
||||
console.log('👥 生成测试用户...');
|
||||
const passwordHash = await bcrypt.hash('123456', 10);
|
||||
const userIds = [];
|
||||
|
||||
// 获取保留用户的ID
|
||||
const existingUsers = await client.query('SELECT id FROM users WHERE username = $1 OR username = $2', [keepUsers[0], keepUsers[1]]);
|
||||
existingUsers.rows.forEach(row => userIds.push(row.id));
|
||||
|
||||
// 生成 20 个新用户
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const username = generatePhone();
|
||||
const realName = generateName();
|
||||
const dept = randomChoice(DEPARTMENTS);
|
||||
|
||||
try {
|
||||
const res = await client.query(
|
||||
'INSERT INTO users (username, password_hash, role, real_name, department, dept_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id',
|
||||
[username, passwordHash, 'user', realName, dept, 'verified']
|
||||
);
|
||||
userIds.push(res.rows[0].id);
|
||||
} catch (e) {
|
||||
if (e.code !== '23505') console.error(e);
|
||||
}
|
||||
}
|
||||
console.log(`✅ 已生成 ${userIds.length} 个用户`);
|
||||
|
||||
// 4. 生成成果数据
|
||||
console.log('📊 生成成果数据 (目标 500+ 条)...');
|
||||
const targetCount = 550;
|
||||
let insertedCount = 0;
|
||||
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const userId = randomChoice(userIds);
|
||||
const type = randomChoice(ACHIEVEMENT_TYPES);
|
||||
const status = randomChoice(STATUSES);
|
||||
const date = generateDate(2023, 2026);
|
||||
const title = generateTitle();
|
||||
const dept = randomChoice(DEPARTMENTS);
|
||||
|
||||
// 构造 contributors JSONB
|
||||
const contributors = [
|
||||
{ name: generateName(), phone: generatePhone(), isMain: true },
|
||||
{ name: generateName(), phone: generatePhone(), isMain: false }
|
||||
];
|
||||
|
||||
// 插入主表
|
||||
const res = await client.query(
|
||||
`INSERT INTO achievements
|
||||
(user_id, type, name, contributors, assigned_departments, achievement_date, status, remarks, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||
[
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
JSON.stringify(contributors),
|
||||
[dept], // 数组格式
|
||||
date,
|
||||
status,
|
||||
'自动生成的数据',
|
||||
date
|
||||
]
|
||||
);
|
||||
const achievementId = res.rows[0].id;
|
||||
|
||||
// 插入附件
|
||||
await client.query(
|
||||
'INSERT INTO achievement_attachments (achievement_id, file_name, file_path) VALUES ($1, $2, $3)',
|
||||
[achievementId, 'test_report.pdf', 'uploads/mock_file.pdf']
|
||||
);
|
||||
|
||||
// 插入详情表 (根据类型)
|
||||
switch (type) {
|
||||
case 'paper':
|
||||
await client.query(
|
||||
'INSERT INTO achievement_paper (achievement_id, paper_type, journal_name, publish_date) VALUES ($1, $2, $3, $4)',
|
||||
[achievementId, randomChoice(DICTS.dict_paper_types), '计算机学报', date]
|
||||
);
|
||||
break;
|
||||
case 'award':
|
||||
await client.query(
|
||||
'INSERT INTO achievement_award (achievement_id, award_type, award_level, award_unit) VALUES ($1, $2, $3, $4)',
|
||||
[achievementId, randomChoice(DICTS.dict_award_types), randomChoice(DICTS.dict_award_levels), '科技部']
|
||||
);
|
||||
break;
|
||||
case 'project':
|
||||
await client.query(
|
||||
'INSERT INTO achievement_project (achievement_id, project_category, source) VALUES ($1, $2, $3)',
|
||||
[achievementId, randomChoice(DICTS.dict_project_categories), '国家自然科学基金委员会']
|
||||
);
|
||||
break;
|
||||
case 'standard':
|
||||
await client.query(
|
||||
'INSERT INTO achievement_standard (achievement_id, standard_type, standard_no, implement_date) VALUES ($1, $2, $3, $4)',
|
||||
[achievementId, randomChoice(DICTS.dict_standard_types), `GB/T ${randomInt(1000, 9999)}-2025`, date]
|
||||
);
|
||||
break;
|
||||
// 其他类型暂略,或插入空记录
|
||||
}
|
||||
|
||||
insertedCount++;
|
||||
if (insertedCount % 100 === 0) process.stdout.write('.');
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log(`\n✅ 成功注入 ${insertedCount} 条成果数据!`);
|
||||
console.log('🎉 数据注入完成!');
|
||||
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('❌ 数据注入失败:', e);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
@@ -0,0 +1,412 @@
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_DATABASE || process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
const USER_COUNT = 20000;
|
||||
const ACHIEVEMENT_COUNT = 500000;
|
||||
const BATCH_SIZE = 1000;
|
||||
const RUN_CLEAN = process.argv.includes('--clean');
|
||||
|
||||
const dictTables = [
|
||||
'dict_organizations',
|
||||
'dict_award_types',
|
||||
'dict_award_levels',
|
||||
'dict_paper_types',
|
||||
'dict_standard_types',
|
||||
'dict_project_categories',
|
||||
];
|
||||
|
||||
const achievementTypes = [
|
||||
'paper',
|
||||
'award',
|
||||
'project',
|
||||
'standard',
|
||||
'monograph',
|
||||
'report',
|
||||
'plan',
|
||||
'patent',
|
||||
'transformation',
|
||||
'software',
|
||||
];
|
||||
const USERNAME_PREFIX = '176';
|
||||
const SEED_REMARK = 'seed_500k';
|
||||
const STATUSES = ['pending', 'approved', 'rejected'];
|
||||
const PATENT_TYPES = ['invention', 'utility', 'design'];
|
||||
const REPORT_TYPES = ['technical', 'inspection', 'analysis', 'feasibility'];
|
||||
const TRANS_METHODS = ['transfer', 'license', 'cooperation', 'self-use'];
|
||||
const ACQUISITION_METHODS = ['independent', 'co-development', 'transfer'];
|
||||
const SOFTWARE_SCOPES = ['national', 'industry', 'department'];
|
||||
|
||||
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const randomChoice = (arr) => arr[randomInt(0, arr.length - 1)];
|
||||
const pad = (num, size) => String(num).padStart(size, '0');
|
||||
const randomDateInYears = (years) =>
|
||||
new Date(randomChoice(years), randomInt(0, 11), randomInt(1, 28));
|
||||
|
||||
function buildInsert(table, columns, rows) {
|
||||
const values = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const row of rows) {
|
||||
const placeholders = [];
|
||||
for (const value of row) {
|
||||
params.push(value);
|
||||
placeholders.push(`$${paramIndex++}`);
|
||||
}
|
||||
values.push(`(${placeholders.join(', ')})`);
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${values.join(', ')}`;
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
async function fetchNames(client, table) {
|
||||
const res = await client.query(`SELECT name FROM ${table} ORDER BY id`);
|
||||
return res.rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
async function seedUsers(client, departments) {
|
||||
const passwordHash = await bcrypt.hash('123456', 10);
|
||||
let created = 0;
|
||||
|
||||
for (let offset = 0; offset < USER_COUNT; offset += BATCH_SIZE) {
|
||||
const batchSize = Math.min(BATCH_SIZE, USER_COUNT - offset);
|
||||
const rows = [];
|
||||
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const index = offset + i;
|
||||
const username = `${USERNAME_PREFIX}${pad(index, 8)}`; // 11 digits, deterministic
|
||||
const realName = `Perf User ${index + 1}`;
|
||||
const department = randomChoice(departments);
|
||||
rows.push([username, passwordHash, 'user', realName, department, 'verified']);
|
||||
}
|
||||
|
||||
const { sql, params } = buildInsert(
|
||||
'users',
|
||||
['username', 'password_hash', 'role', 'real_name', 'department', 'dept_status'],
|
||||
rows
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`${sql} ON CONFLICT (username) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role,
|
||||
real_name = EXCLUDED.real_name,
|
||||
department = EXCLUDED.department,
|
||||
dept_status = EXCLUDED.dept_status`
|
||||
, params);
|
||||
|
||||
created += batchSize;
|
||||
if (created % 5000 === 0) {
|
||||
console.log(`[seed-data-500k] users upserted: ${created}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAchievements(client, departments, dicts, userIds) {
|
||||
let inserted = 0;
|
||||
const dateYears = [2023, 2024, 2026];
|
||||
|
||||
for (let offset = 0; offset < ACHIEVEMENT_COUNT; offset += BATCH_SIZE) {
|
||||
const batchSize = Math.min(BATCH_SIZE, ACHIEVEMENT_COUNT - offset);
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
const rows = [];
|
||||
const achievementDates = [];
|
||||
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const index = offset + i;
|
||||
const userId = randomChoice(userIds);
|
||||
const type = randomChoice(achievementTypes);
|
||||
const name = `Perf Achievement ${index + 1}`;
|
||||
const assignedDept = randomChoice(departments);
|
||||
const date = randomDateInYears(dateYears);
|
||||
achievementDates.push(date);
|
||||
const contributors = [
|
||||
{ name: `Contributor ${index + 1}A`, phone: `130${pad(index % 100000000, 8)}`, isMain: true },
|
||||
{ name: `Contributor ${index + 1}B`, phone: `131${pad(index % 100000000, 8)}`, isMain: false },
|
||||
];
|
||||
rows.push([
|
||||
userId,
|
||||
type,
|
||||
name,
|
||||
JSON.stringify(contributors),
|
||||
[assignedDept],
|
||||
date,
|
||||
randomChoice(STATUSES),
|
||||
SEED_REMARK,
|
||||
date,
|
||||
]);
|
||||
}
|
||||
|
||||
const { sql, params } = buildInsert(
|
||||
'achievements',
|
||||
[
|
||||
'user_id',
|
||||
'type',
|
||||
'name',
|
||||
'contributors',
|
||||
'assigned_departments',
|
||||
'achievement_date',
|
||||
'status',
|
||||
'remarks',
|
||||
'created_at',
|
||||
],
|
||||
rows
|
||||
);
|
||||
|
||||
const res = await client.query(`${sql} RETURNING id, type`, params);
|
||||
|
||||
const attachmentsRows = [];
|
||||
const paperRows = [];
|
||||
const awardRows = [];
|
||||
const projectRows = [];
|
||||
const standardRows = [];
|
||||
const monographRows = [];
|
||||
const reportRows = [];
|
||||
const planRows = [];
|
||||
const patentRows = [];
|
||||
const transformationRows = [];
|
||||
const softwareRows = [];
|
||||
|
||||
for (let i = 0; i < res.rows.length; i++) {
|
||||
const { id, type } = res.rows[i];
|
||||
const date = achievementDates[i];
|
||||
const idx = offset + i + 1;
|
||||
attachmentsRows.push([id, `attachment_${idx}.pdf`, 'uploads/mock_file.pdf']);
|
||||
|
||||
if (type === 'paper') {
|
||||
paperRows.push([id, randomChoice(dicts.dict_paper_types), 'Perf Journal', randomDateInYears(dateYears)]);
|
||||
} else if (type === 'award') {
|
||||
awardRows.push([
|
||||
id,
|
||||
randomChoice(dicts.dict_award_types),
|
||||
randomChoice(dicts.dict_award_levels),
|
||||
randomChoice(dicts.dict_organizations),
|
||||
]);
|
||||
} else if (type === 'project') {
|
||||
projectRows.push([id, randomChoice(dicts.dict_project_categories), 'Perf Project Source']);
|
||||
} else if (type === 'standard') {
|
||||
const standardYear = date.getFullYear();
|
||||
standardRows.push([
|
||||
id,
|
||||
randomChoice(dicts.dict_standard_types),
|
||||
`STD-${standardYear}-${pad(idx, 6)}`,
|
||||
date,
|
||||
]);
|
||||
} else if (type === 'monograph') {
|
||||
monographRows.push([id, 'Perf Press', `ISBN-${pad(idx, 9)}`]);
|
||||
} else if (type === 'report') {
|
||||
reportRows.push([id, randomChoice(REPORT_TYPES), 'Recipient Unit', null, `Approver ${idx}`]);
|
||||
} else if (type === 'plan') {
|
||||
planRows.push([id]);
|
||||
} else if (type === 'patent') {
|
||||
patentRows.push([id, `PAT-${pad(idx, 8)}`, randomChoice(PATENT_TYPES), 'Perf Assignee']);
|
||||
} else if (type === 'transformation') {
|
||||
transformationRows.push([
|
||||
id,
|
||||
randomChoice(TRANS_METHODS),
|
||||
randomInt(50, 5000) * 1000 + randomInt(0, 99) / 100,
|
||||
randomChoice(dicts.dict_project_categories),
|
||||
]);
|
||||
} else if (type === 'software') {
|
||||
softwareRows.push([
|
||||
id,
|
||||
`SW-${pad(idx, 8)}`,
|
||||
randomChoice(ACQUISITION_METHODS),
|
||||
randomChoice(SOFTWARE_SCOPES),
|
||||
'Perf Owner Unit',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentsRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_attachments',
|
||||
['achievement_id', 'file_name', 'file_path'],
|
||||
attachmentsRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
|
||||
if (paperRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_paper',
|
||||
['achievement_id', 'paper_type', 'journal_name', 'publish_date'],
|
||||
paperRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (awardRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_award',
|
||||
['achievement_id', 'award_type', 'award_level', 'award_unit'],
|
||||
awardRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (projectRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_project',
|
||||
['achievement_id', 'project_category', 'source'],
|
||||
projectRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (standardRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_standard',
|
||||
['achievement_id', 'standard_type', 'standard_no', 'implement_date'],
|
||||
standardRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (monographRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_monograph',
|
||||
['achievement_id', 'publisher', 'isbn'],
|
||||
monographRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (reportRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_report',
|
||||
['achievement_id', 'report_type', 'recipient', 'approver_id', 'approver_name'],
|
||||
reportRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (planRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_plan',
|
||||
['achievement_id'],
|
||||
planRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (patentRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_patent',
|
||||
['achievement_id', 'patent_no', 'patent_type', 'assignee'],
|
||||
patentRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (transformationRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_transformation',
|
||||
['achievement_id', 'trans_method', 'trans_amount', 'project_category'],
|
||||
transformationRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
if (softwareRows.length) {
|
||||
const insert = buildInsert(
|
||||
'achievement_software',
|
||||
['achievement_id', 'reg_no', 'acquisition_method', 'scope', 'owner_unit'],
|
||||
softwareRows
|
||||
);
|
||||
await client.query(insert.sql, insert.params);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
inserted += batchSize;
|
||||
if (inserted % 10000 === 0) {
|
||||
console.log(`[seed-data-500k] achievements inserted: ${inserted}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanSeedData(client) {
|
||||
console.log('[seed-data-500k] cleaning existing seeded data...');
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
const usersRes = await client.query(
|
||||
'SELECT id FROM users WHERE username LIKE $1',
|
||||
[`${USERNAME_PREFIX}%`]
|
||||
);
|
||||
const userIds = usersRes.rows.map((row) => row.id);
|
||||
|
||||
await client.query(
|
||||
'DELETE FROM achievements WHERE remarks = $1 OR user_id = ANY($2)',
|
||||
[SEED_REMARK, userIds.length ? userIds : [0]]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
'DELETE FROM users WHERE username LIKE $1',
|
||||
[`${USERNAME_PREFIX}%`]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log('[seed-data-500k] clean done');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
console.log('[seed-data-500k] start');
|
||||
|
||||
if (RUN_CLEAN) {
|
||||
await cleanSeedData(client);
|
||||
}
|
||||
|
||||
const departments = await fetchNames(client, 'departments');
|
||||
if (departments.length === 0) {
|
||||
throw new Error('No departments found. Run "npm run init-db" first.');
|
||||
}
|
||||
|
||||
const dicts = {};
|
||||
for (const table of dictTables) {
|
||||
const values = await fetchNames(client, table);
|
||||
if (values.length === 0) {
|
||||
throw new Error(`No data in ${table}. Run "npm run init-db" first.`);
|
||||
}
|
||||
dicts[table] = values;
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
await seedUsers(client, departments);
|
||||
await client.query('COMMIT');
|
||||
|
||||
const userIdsRes = await client.query(
|
||||
'SELECT id FROM users WHERE username LIKE $1 ORDER BY id',
|
||||
[`${USERNAME_PREFIX}%`]
|
||||
);
|
||||
const userIds = userIdsRes.rows.map((row) => row.id);
|
||||
if (userIds.length === 0) {
|
||||
throw new Error('No seeded users found.');
|
||||
}
|
||||
|
||||
await seedAchievements(client, departments, dicts, userIds);
|
||||
|
||||
console.log('[seed-data-500k] done');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('[seed-data-500k] failed:', err.message || err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,103 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
const db = require('../db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const fs = require('fs');
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
console.log('--- 科技成果转化系统 部署工具 ---');
|
||||
|
||||
// 1. 执行完整建表语句
|
||||
console.log('正在初始化数据库表结构...');
|
||||
const sqlPath = path.join(__dirname, '../../database/schema.sql');
|
||||
if (!fs.existsSync(sqlPath)) {
|
||||
throw new Error(`找不到 SQL 文件: ${sqlPath}`);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
// 简单的 SQL 分割逻辑,按分号分割
|
||||
const statements = sql.split(';').filter(s => s.trim());
|
||||
|
||||
for (let statement of statements) {
|
||||
try {
|
||||
await db.query(statement);
|
||||
} catch (e) {
|
||||
// 忽略“已存在”类的错误
|
||||
if (!e.message.includes('already exists')) {
|
||||
console.log(`执行语句时提示: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 初始化默认部门
|
||||
console.log('正在初始化默认部门...');
|
||||
const defaultDepts = [
|
||||
'院领导', '卫星中心综合部', '卫星中心生产部', '卫星中心研发部', '大数据中心',
|
||||
'生产保障部', '生产技术部', '土地调查监测室', '森林调查监测室', '湿地调查监测室',
|
||||
'专业调查室', '权籍调查室', '碳汇研究中心', '权益研究中心', '科技与档案室',
|
||||
'办公室', '党群工作部', '待定'
|
||||
];
|
||||
for (const dept of defaultDepts) {
|
||||
await db.query('INSERT INTO departments (name) VALUES ($1) ON CONFLICT (name) DO NOTHING', [dept]);
|
||||
}
|
||||
|
||||
// 3. 初始化字典表数据
|
||||
console.log('正在初始化字典表数据...');
|
||||
|
||||
const dictData = {
|
||||
dict_organizations: ['黑龙江省自然资源权益调查监测院','黑龙江省自然资源卫星应用技术中心','黑龙江省自然资源权益调查监测院(黑龙江省自然资源卫星应用技术中心)','待定'],
|
||||
dict_award_types: ['国家级', '省部级', '社会力量', '其他', '待定'],
|
||||
dict_award_levels: ['一等奖', '二等奖', '三等奖', '其他', '待定'],
|
||||
dict_paper_types: ['Nature/Science/Cell', 'CCF A类会议','SCI一区期刊','SCI二区期刊', 'CCF B类会议','CCF C类会议','SCI三区期刊','SCI四区期刊','EI源刊', '中文核心期刊', 'EI会议', '普通期刊', '普通会议', '待定'],
|
||||
dict_standard_types: ['国家标准', '国际标准', '行业标准', '地方标准', '待定'],
|
||||
dict_project_categories: ['科技', '财政', '横向', '成果转化', '待定']
|
||||
};
|
||||
|
||||
for (const [tableName, values] of Object.entries(dictData)) {
|
||||
for (const val of values) {
|
||||
await db.query(`INSERT INTO ${tableName} (name) VALUES ($1) ON CONFLICT (name) DO NOTHING`, [val]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建初始超级管理员
|
||||
const adminPhone = '18888888888';
|
||||
const adminPass = 'admin123';
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const passwordHash = await bcrypt.hash(adminPass, salt);
|
||||
|
||||
console.log('正在创建初始超级管理员...');
|
||||
await db.query(
|
||||
`INSERT INTO users (username, password_hash, role, real_name, department, dept_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (username) DO UPDATE SET role = 'super_admin'`,
|
||||
[adminPhone, passwordHash, 'super_admin', '系统管理员', null, 'verified']
|
||||
);
|
||||
|
||||
// 5. 创建初始系统维护员
|
||||
const maintainerPhone = '19999999999';
|
||||
const maintainerPass = 'maintainer123';
|
||||
const maintainerHash = await bcrypt.hash(maintainerPass, salt);
|
||||
|
||||
console.log('正在创建初始系统维护员...');
|
||||
await db.query(
|
||||
`INSERT INTO users (username, password_hash, role, real_name, department, dept_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (username) DO UPDATE SET role = 'maintainer'`,
|
||||
[maintainerPhone, maintainerHash, 'maintainer', '系统维护员', null, 'verified']
|
||||
);
|
||||
|
||||
console.log('\n-----------------------------------');
|
||||
console.log('部署成功!');
|
||||
console.log(`初始超管账号: ${adminPhone} / ${adminPass}`);
|
||||
console.log(`初始维护员账号: ${maintainerPhone} / ${maintainerPass}`);
|
||||
console.log('-----------------------------------');
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('\n部署失败:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
setup();
|
||||
@@ -0,0 +1,93 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../db');
|
||||
const logger = require('./logger');
|
||||
|
||||
const parsePositiveNumber = (value, fallback) => {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) && num > 0 ? num : fallback;
|
||||
};
|
||||
|
||||
// 磁盘告警阈值(GB),默认 10 GB
|
||||
const THRESHOLD_GB = parsePositiveNumber(process.env.DISK_ALERT_THRESHOLD_GB, 10);
|
||||
const THRESHOLD_BYTES = THRESHOLD_GB * 1024 * 1024 * 1024;
|
||||
// 告警冷却时间(小时),默认 24 小时
|
||||
const ALERT_COOLDOWN_HOURS = parsePositiveNumber(process.env.DISK_ALERT_COOLDOWN_HOURS, 24);
|
||||
|
||||
const getDiskSpace = async (directoryPath) => {
|
||||
try {
|
||||
// 仅在支持 statfs 的 Node 版本启用
|
||||
if (fs.promises.statfs) {
|
||||
const stats = await fs.promises.statfs(directoryPath);
|
||||
const FreeSpace = stats.bfree * stats.bsize;
|
||||
const Size = stats.blocks * stats.bsize;
|
||||
return { FreeSpace, Size };
|
||||
}
|
||||
|
||||
logger.warn('当前 Node.js 版本不支持 fs.promises.statfs,跳过磁盘检查');
|
||||
return { FreeSpace: Number.MAX_SAFE_INTEGER, Size: Number.MAX_SAFE_INTEGER };
|
||||
} catch (error) {
|
||||
logger.error(`获取磁盘空间失败 [${directoryPath}]: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const checkDiskSpace = async () => {
|
||||
try {
|
||||
// 1. 获取上传目录绝对路径
|
||||
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
||||
const absolutePath = path.resolve(uploadDir);
|
||||
|
||||
// 目录不存在时,回退到所在盘符根目录进行检测
|
||||
let targetPath = absolutePath;
|
||||
try {
|
||||
await fs.promises.access(targetPath);
|
||||
} catch {
|
||||
targetPath = path.parse(absolutePath).root;
|
||||
}
|
||||
|
||||
// 2. 获取磁盘空间
|
||||
const { FreeSpace } = await getDiskSpace(targetPath);
|
||||
|
||||
// 3. 判断是否低于阈值
|
||||
if (FreeSpace < THRESHOLD_BYTES) {
|
||||
const root = path.parse(absolutePath).root;
|
||||
const freeGB = (FreeSpace / (1024 * 1024 * 1024)).toFixed(2);
|
||||
const title = `【系统警告】磁盘空间不足 (${root})`;
|
||||
|
||||
// 4. 冷却期内同标题只发一次
|
||||
const cooldownTime = new Date(Date.now() - ALERT_COOLDOWN_HOURS * 60 * 60 * 1000);
|
||||
const recentAlert = await db.query(
|
||||
`SELECT id FROM notifications
|
||||
WHERE title = $1
|
||||
AND created_at > $2
|
||||
LIMIT 1`,
|
||||
[title, cooldownTime]
|
||||
);
|
||||
|
||||
if (recentAlert.rows.length === 0) {
|
||||
logger.warn(`磁盘空间不足警告: ${root} 剩余 ${freeGB} GB`);
|
||||
|
||||
const content = `系统检测到存储磁盘 (${root}) 剩余空间仅剩 ${freeGB} GB,低于警告阈值 (${THRESHOLD_GB} GB)。\n\n请系统管理员尽快处理:\n1. 清理磁盘空间。\n2. 或配置新的存储路径(如挂载大容量磁盘),并更新配置文件中的 UPLOAD_DIR 参数。`;
|
||||
|
||||
// 尝试获取 ID 最小的 super_admin 作为发布者
|
||||
let publisherId = 1;
|
||||
const adminRes = await db.query("SELECT id FROM users WHERE role = 'super_admin' ORDER BY id ASC LIMIT 1");
|
||||
if (adminRes.rows.length > 0) {
|
||||
publisherId = adminRes.rows[0].id;
|
||||
}
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top, created_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)',
|
||||
[title, content, publisherId, '系统监控', true]
|
||||
);
|
||||
|
||||
logger.info('已发布磁盘空间不足警告通知');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`磁盘空间检查失败: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { checkDiskSpace };
|
||||
@@ -0,0 +1,55 @@
|
||||
const winston = require('winston');
|
||||
require('winston-daily-rotate-file');
|
||||
const path = require('path');
|
||||
|
||||
// 定义日志目录
|
||||
const logDir = path.join(__dirname, '../logs');
|
||||
|
||||
// 定义日志格式
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message }) => {
|
||||
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||
})
|
||||
);
|
||||
|
||||
// 创建 Logger 实例
|
||||
const logger = winston.createLogger({
|
||||
format: logFormat,
|
||||
transports: [
|
||||
// 1. 错误日志:只记录 error 级别
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: path.join(logDir, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d', // 保留14天
|
||||
}),
|
||||
// 2. 综合日志:记录所有级别
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: path.join(logDir, 'combined-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 如果不是生产环境,也输出到控制台
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
// 创建一个流对象,供 morgan 使用
|
||||
logger.stream = {
|
||||
write: (message) => {
|
||||
logger.info(message.trim());
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
@@ -0,0 +1,33 @@
|
||||
const Dypnsapi20170525 = require('@alicloud/dypnsapi20170525');
|
||||
const OpenApi = require('@alicloud/openapi-client');
|
||||
const Util = require('@alicloud/tea-util');
|
||||
|
||||
class SmsClient {
|
||||
static createClient() {
|
||||
let config = new OpenApi.Config({
|
||||
accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
|
||||
accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
|
||||
});
|
||||
config.endpoint = `dypnsapi.aliyuncs.com`;
|
||||
return new Dypnsapi20170525.default(config);
|
||||
}
|
||||
|
||||
static async sendVerifyCode(phoneNumber, code) {
|
||||
let client = this.createClient();
|
||||
let sendSmsVerifyCodeRequest = new Dypnsapi20170525.SendSmsVerifyCodeRequest({
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||
phoneNumber: phoneNumber,
|
||||
templateParam: `{"code":"${code}","min":"5"}`,
|
||||
});
|
||||
let runtime = new Util.RuntimeOptions({});
|
||||
try {
|
||||
return await client.sendSmsVerifyCodeWithOptions(sendSmsVerifyCodeRequest, runtime);
|
||||
} catch (error) {
|
||||
console.error('短信发送失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SmsClient;
|
||||
Reference in New Issue
Block a user