first commit

This commit is contained in:
qwq
2026-07-24 11:34:38 +08:00
commit 94f4f174f4
26 changed files with 2544 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
require('dotenv').config();
const config = {
port: parseInt(process.env.PORT) || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
adminPassword: process.env.ADMIN_PASSWORD || 'admin123',
feishu: {
appId: process.env.FEISHU_APP_ID,
appSecret: process.env.FEISHU_APP_SECRET,
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
encryptKey: process.env.FEISHU_ENCRYPT_KEY || '',
approvalCodes: {
fileIn: process.env.FEISHU_APPROVAL_FILE_IN,
fileOut: process.env.FEISHU_APPROVAL_FILE_OUT,
groupPerm: process.env.FEISHU_APPROVAL_GROUP_PERM,
},
alertWebhook: process.env.FEISHU_ALERT_WEBHOOK,
},
synology: {
host: process.env.SYNOLOGY_HOST,
username: process.env.SYNOLOGY_USERNAME,
password: process.env.SYNOLOGY_PASSWORD,
skipTls: process.env.SYNOLOGY_SKIP_TLS === 'true',
},
jumpserver: {
host: process.env.JUMPSERVER_HOST,
keyId: process.env.JUMPSERVER_KEY_ID,
keySecret: process.env.JUMPSERVER_KEY_SECRET,
assetIc1: process.env.JUMPSERVER_ASSET_IC1,
systemUser: process.env.JUMPSERVER_SYSTEM_USER || 'root',
},
nextcloud: {
host: process.env.NEXTCLOUD_HOST,
username: process.env.NEXTCLOUD_USERNAME,
password: process.env.NEXTCLOUD_PASSWORD,
sharePermissions: parseInt(process.env.NEXTCLOUD_SHARE_PERMISSIONS) || 1,
},
security: {
whitelistDirs: (process.env.WHITELIST_DIRS || '/IN_R,/OUT_R,/OUT,/wingsemi,/WCPS-Files,/OUT-RED').split(','),
logRetentionDays: parseInt(process.env.LOG_RETENTION_DAYS) || 30,
},
};
module.exports = config;
+54
View File
@@ -0,0 +1,54 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const DB_DIR = path.join(__dirname, '..', 'data');
if (!fs.existsSync(DB_DIR)) {
fs.mkdirSync(DB_DIR, { recursive: true });
}
const db = new Database(path.join(DB_DIR, 'automation.db'));
// 启用 WAL 模式提升并发性能
db.pragma('journal_mode = WAL');
// 初始化表结构
db.exec(`
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
approval_code TEXT NOT NULL,
approval_instance_id TEXT NOT NULL,
workflow_type TEXT NOT NULL,
applicant_name TEXT,
applicant_pinyin TEXT,
action TEXT NOT NULL,
detail TEXT,
status TEXT DEFAULT 'running',
error_message TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
finished_at TEXT
);
CREATE TABLE IF NOT EXISTS api_configs (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS preset_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
command TEXT NOT NULL,
target TEXT DEFAULT 'ic1',
description TEXT,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_logs_instance ON operation_logs(approval_instance_id);
CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_logs_type ON operation_logs(workflow_type);
`);
module.exports = db;
+45
View File
@@ -0,0 +1,45 @@
const app = require('./server');
const config = require('./config');
const logger = require('./utils/logger');
const synology = require('./services/synology');
async function start() {
logger.info('=== 文件传输自动化系统启动 ===');
logger.info(`环境: ${config.nodeEnv}, 端口: ${config.port}`);
// 预登录群晖(验证配置)
try {
await synology.login();
logger.info('群晖 NAS 连接验证通过');
} catch (e) {
logger.warn(`群晖 NAS 连接失败(将在首次使用时重试): ${e.message}`);
}
// 启动 HTTP 服务
app.listen(config.port, () => {
logger.info(`服务已启动: http://0.0.0.0:${config.port}`);
logger.info(`管理后台: http://0.0.0.0:${config.port}/admin`);
logger.info(`飞书事件回调: http://0.0.0.0:${config.port}/api/feishu/event`);
logger.info(`健康检查: http://0.0.0.0:${config.port}/health`);
});
}
// 优雅退出
process.on('SIGTERM', () => {
logger.info('收到 SIGTERM,正在关闭...');
process.exit(0);
});
process.on('SIGINT', () => {
logger.info('收到 SIGINT,正在关闭...');
process.exit(0);
});
process.on('unhandledRejection', (reason) => {
logger.error(`未处理的 Promise 拒绝: ${reason}`);
});
start().catch(err => {
logger.error(`启动失败: ${err.message}`);
process.exit(1);
});
+105
View File
@@ -0,0 +1,105 @@
const db = require('../db');
const config = require('../config');
const logger = require('../utils/logger');
/**
* 记录操作日志
*/
function createLog({ approvalCode, instanceId, workflowType, applicantName, applicantPinyin, action, detail }) {
const stmt = db.prepare(`
INSERT INTO operation_logs
(approval_code, approval_instance_id, workflow_type, applicant_name, applicant_pinyin, action, detail, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'running')
`);
const result = stmt.run(approvalCode, instanceId, workflowType, applicantName, applicantPinyin, action, detail);
return result.lastInsertRowid;
}
/**
* 更新日志状态
*/
function updateLog(logId, status, errorMessage = null) {
const stmt = db.prepare(`
UPDATE operation_logs
SET status = ?, error_message = ?, finished_at = datetime('now', 'localtime')
WHERE id = ?
`);
stmt.run(status, errorMessage, logId);
}
/**
* 查询日志(带分页和过滤)
*/
function queryLogs({ page = 1, pageSize = 20, workflowType, status, startDate, endDate, keyword } = {}) {
let where = 'WHERE 1=1';
const params = [];
if (workflowType) {
where += ' AND workflow_type = ?';
params.push(workflowType);
}
if (status) {
where += ' AND status = ?';
params.push(status);
}
if (startDate) {
where += ' AND created_at >= ?';
params.push(startDate);
}
if (endDate) {
where += ' AND created_at <= ?';
params.push(endDate + ' 23:59:59');
}
if (keyword) {
where += ' AND (applicant_name LIKE ? OR detail LIKE ? OR approval_instance_id LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
const countStmt = db.prepare(`SELECT COUNT(*) as total FROM operation_logs ${where}`);
const { total } = countStmt.get(...params);
const offset = (page - 1) * pageSize;
const dataStmt = db.prepare(`
SELECT * FROM operation_logs ${where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
const rows = dataStmt.all(...params, pageSize, offset);
return { total, page, pageSize, rows };
}
/**
* 清理过期日志(保留 N 天)
*/
function cleanupOldLogs() {
const retentionDays = config.security.logRetentionDays;
const stmt = db.prepare(`
DELETE FROM operation_logs
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
`);
const result = stmt.run(retentionDays);
if (result.changes > 0) {
logger.info(`已清理 ${result.changes} 条过期日志(保留${retentionDays}天)`);
}
}
/**
* 检查审批是否已处理(防重复)
*/
function isApprovalProcessed(instanceId) {
const stmt = db.prepare(`
SELECT COUNT(*) as count FROM operation_logs
WHERE approval_instance_id = ? AND status = 'success'
`);
const { count } = stmt.get(instanceId);
return count > 0;
}
module.exports = {
createLog,
updateLog,
queryLogs,
cleanupOldLogs,
isApprovalProcessed,
};
+104
View File
@@ -0,0 +1,104 @@
const config = require('../config');
const logger = require('../utils/logger');
/**
* 路径安全验证 - 白名单机制
* 只允许访问预设目录
*/
function validatePath(path) {
if (!path || typeof path !== 'string') {
throw new Error('路径不能为空');
}
// 规范化路径(防止 ../ 穿越)
const normalized = normalizePath(path);
// 检查是否在白名单内
const allowed = config.security.whitelistDirs.some(dir => {
const normalizedDir = normalizePath(dir);
return normalized === normalizedDir || normalized.startsWith(normalizedDir + '/');
});
if (!allowed) {
logger.warn(`[安全] 路径被拒绝: ${path} (规范化: ${normalized})`);
throw new Error(`路径不在白名单内: ${path}。允许的目录: ${config.security.whitelistDirs.join(', ')}`);
}
return normalized;
}
/**
* 路径规范化(防止路径穿越攻击)
*/
function normalizePath(path) {
// 移除多余的斜杠
let normalized = path.replace(/\/+/g, '/');
// 解析 .. 和 .
const parts = normalized.split('/');
const result = [];
for (const part of parts) {
if (part === '' || part === '.') continue;
if (part === '..') {
result.pop(); // 回退一级
} else {
result.push(part);
}
}
return '/' + result.join('/');
}
/**
* 命令安全验证
* 防止命令注入
*/
function validateCommand(command) {
if (!command || typeof command !== 'string') {
throw new Error('命令不能为空');
}
// 禁止的危险字符和模式
const dangerousPatterns = [
/;/, // 命令分隔
/&&/, // 命令链接(允许在预设命令中)
/\|\|/, // 或链接
/\$\(/, // 命令替换
/`/, // 反引号命令替换
/>\s*\//, // 重定向到绝对路径
/rm\s+-rf/, // 危险删除
/rm\s+-r/, // 递归删除
/dd\s+if=/, // dd 命令
/mkfs/, // 格式化
/shutdown/, // 关机
/reboot/, // 重启
/curl.*\|/, // 管道执行
/wget.*\|/, // 管道执行
];
for (const pattern of dangerousPatterns) {
if (pattern.test(command)) {
logger.warn(`[安全] 危险命令被拒绝: ${command}`);
throw new Error(`命令包含危险模式,已被拒绝: ${command}`);
}
}
return command;
}
/**
* 验证文件名安全性
*/
function validateFileName(fileName) {
if (!fileName || typeof fileName !== 'string') {
throw new Error('文件名不能为空');
}
// 禁止路径分隔符和特殊字符
if (/[/\\:*?"<>|]/.test(fileName)) {
throw new Error(`文件名包含非法字符: ${fileName}`);
}
if (fileName.includes('..')) {
throw new Error(`文件名包含路径穿越: ${fileName}`);
}
return fileName;
}
module.exports = { validatePath, validateCommand, validateFileName, normalizePath };
+163
View File
@@ -0,0 +1,163 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const config = require('../config');
const { queryLogs, cleanupOldLogs } = require('../middleware/operation-log');
const { sendAlert } = require('../utils/alert');
const logger = require('../utils/logger');
/**
* 管理后台鉴权中间件
*/
function adminAuth(req, res, next) {
const password = req.headers['x-admin-password'] || req.query.password;
if (password !== config.adminPassword) {
return res.status(401).json({ error: '管理密码错误' });
}
next();
}
router.use(adminAuth);
// ===== 操作日志 =====
/**
* GET /api/admin/logs - 查询操作日志
*/
router.get('/logs', (req, res) => {
const { page, pageSize, workflowType, status, startDate, endDate, keyword } = req.query;
const result = queryLogs({
page: parseInt(page) || 1,
pageSize: parseInt(pageSize) || 20,
workflowType,
status,
startDate,
endDate,
keyword,
});
res.json(result);
});
/**
* DELETE /api/admin/logs/cleanup - 手动清理过期日志
*/
router.delete('/logs/cleanup', (req, res) => {
cleanupOldLogs();
res.json({ message: '清理完成' });
});
// ===== API 配置管理 =====
/**
* GET /api/admin/config - 获取所有配置
*/
router.get('/config', (req, res) => {
const rows = db.prepare('SELECT * FROM api_configs ORDER BY key').all();
// 敏感字段脱敏
const masked = rows.map(r => {
if (r.key.toLowerCase().includes('password') || r.key.toLowerCase().includes('secret')) {
return { ...r, value: '******' };
}
return r;
});
res.json(masked);
});
/**
* PUT /api/admin/config - 更新配置
*/
router.put('/config', (req, res) => {
const { key, value, description } = req.body;
if (!key || !value) {
return res.status(400).json({ error: '缺少 key 或 value' });
}
const stmt = db.prepare(`
INSERT INTO api_configs (key, value, description, updated_at)
VALUES (?, ?, ?, datetime('now', 'localtime'))
ON CONFLICT(key) DO UPDATE SET value = ?, description = ?, updated_at = datetime('now', 'localtime')
`);
stmt.run(key, value, description || '', value, description || '');
logger.info(`[管理] 配置已更新: ${key}`);
res.json({ message: `配置 ${key} 已更新` });
});
// ===== 预设命令管理 =====
/**
* GET /api/admin/commands - 获取预设命令列表
*/
router.get('/commands', (req, res) => {
const rows = db.prepare('SELECT * FROM preset_commands ORDER BY id DESC').all();
res.json(rows);
});
/**
* POST /api/admin/commands - 添加预设命令
*/
router.post('/commands', (req, res) => {
const { name, command, target, description } = req.body;
if (!name || !command) {
return res.status(400).json({ error: '缺少 name 或 command' });
}
const stmt = db.prepare(`
INSERT INTO preset_commands (name, command, target, description) VALUES (?, ?, ?, ?)
`);
const result = stmt.run(name, command, target || 'ic1', description || '');
logger.info(`[管理] 预设命令已添加: ${name}`);
res.json({ id: result.lastInsertRowid, message: '命令已添加' });
});
/**
* PUT /api/admin/commands/:id - 更新预设命令
*/
router.put('/commands/:id', (req, res) => {
const { name, command, target, description, enabled } = req.body;
const stmt = db.prepare(`
UPDATE preset_commands SET name = ?, command = ?, target = ?, description = ?, enabled = ?
WHERE id = ?
`);
stmt.run(name, command, target || 'ic1', description || '', enabled !== undefined ? (enabled ? 1 : 0) : 1, req.params.id);
res.json({ message: '命令已更新' });
});
/**
* DELETE /api/admin/commands/:id - 删除预设命令
*/
router.delete('/commands/:id', (req, res) => {
db.prepare('DELETE FROM preset_commands WHERE id = ?').run(req.params.id);
res.json({ message: '命令已删除' });
});
// ===== 系统状态 =====
/**
* GET /api/admin/status - 系统状态
*/
router.get('/status', (req, res) => {
const todayLogs = db.prepare(`
SELECT COUNT(*) as total,
SUM(CASE WHEN status='success' THEN 1 ELSE 0 END) as success,
SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN status='running' THEN 1 ELSE 0 END) as running
FROM operation_logs WHERE date(created_at) = date('now', 'localtime')
`).get();
res.json({
uptime: process.uptime(),
memory: process.memoryUsage(),
todayStats: todayLogs,
version: require('../../package.json').version,
});
});
/**
* POST /api/admin/test-alert - 测试告警
*/
router.post('/test-alert', async (req, res) => {
await sendAlert('测试告警', '这是一条测试告警消息,系统运行正常。', 'info');
res.json({ message: '测试告警已发送' });
});
module.exports = router;
+210
View File
@@ -0,0 +1,210 @@
const express = require('express');
const router = express.Router();
const config = require('../config');
const feishu = require('../services/feishu');
const { handleFileIn } = require('../workflows/file-in');
const { handleFileOut } = require('../workflows/file-out');
const { handleGroupPermission } = require('../workflows/group-permission');
const { nameToPinyin } = require('../utils/pinyin');
const { createLog, updateLog, isApprovalProcessed } = require('../middleware/operation-log');
const { sendAlert } = require('../utils/alert');
const logger = require('../utils/logger');
/**
* POST /api/feishu/event
* 飞书事件订阅回调入口
*/
router.post('/event', async (req, res) => {
const body = req.body;
// 1. URL 验证挑战(首次配置事件订阅时飞书会发送)
if (body.type === 'url_verification') {
return res.json({ challenge: body.challenge });
}
// 2. 验证 token
const token = body.token || body.header?.token;
if (token && token !== config.feishu.verificationToken) {
logger.warn('[飞书事件] token 验证失败');
return res.status(403).json({ error: 'token mismatch' });
}
// 3. 立即返回 200(飞书要求3秒内响应)
res.json({ code: 0 });
// 4. 异步处理事件
try {
await processEvent(body);
} catch (err) {
logger.error(`[飞书事件] 处理失败: ${err.message}`, { stack: err.stack });
await sendAlert('事件处理失败', `错误: ${err.message}`, 'error');
}
});
/**
* 处理飞书事件
*/
async function processEvent(body) {
// 飞书事件订阅 v2 格式
const header = body.header || {};
const event = body.event || {};
const eventType = header.event_type;
// 只处理审批实例状态变更事件
if (eventType !== 'approval_instance') return;
const instanceCode = event.instance_code;
const approvalCode = event.approval_code;
const status = event.status; // APPROVED / REJECTED / PENDING
// 只处理"已通过"的审批(即审批人同意后触发自动化)
// 根据你的流程设计,也可能是 PENDING 时触发(机器人自动审批)
if (status !== 'APPROVED' && status !== 'PENDING') return;
// 防重复处理
if (isApprovalProcessed(instanceCode)) {
logger.info(`[飞书事件] 审批已处理过,跳过: ${instanceCode}`);
return;
}
logger.info(`[飞书事件] 收到审批事件: code=${approvalCode}, instance=${instanceCode}, status=${status}`);
// 根据审批定义 code 路由到不同工作流
if (approvalCode === config.feishu.approvalCodes.fileIn) {
await processFileIn(approvalCode, instanceCode);
} else if (approvalCode === config.feishu.approvalCodes.fileOut) {
await processFileOut(approvalCode, instanceCode);
} else if (approvalCode === config.feishu.approvalCodes.groupPerm) {
await processGroupPermission(approvalCode, instanceCode);
} else {
logger.info(`[飞书事件] 非目标审批类型,忽略: ${approvalCode}`);
}
}
/**
* 处理文件传入审批
*/
async function processFileIn(approvalCode, instanceId) {
// 获取审批详情
const instance = await feishu.getApprovalInstance(approvalCode, instanceId);
const form = feishu.parseApprovalForm(instance.form);
const approvalData = {
applicantName: form['申请人'] || instance.user_name || '',
fileName: form['文件名称'] || form['文件名'] || '',
};
if (!approvalData.applicantName || !approvalData.fileName) {
throw new Error(`审批表单缺少必要字段: 申请人=${approvalData.applicantName}, 文件=${approvalData.fileName}`);
}
const pinyin = nameToPinyin(approvalData.applicantName);
const logId = createLog({
approvalCode,
instanceId,
workflowType: 'file_in',
applicantName: approvalData.applicantName,
applicantPinyin: pinyin,
action: '文件传入',
detail: `文件: ${approvalData.fileName}`,
});
try {
const result = await handleFileIn(approvalData, logId);
updateLog(logId, 'success');
// 提交审批意见
await feishu.approveInstance(approvalCode, instanceId, result.message);
logger.info(`[文件传入] 完成: ${result.detail}`);
} catch (err) {
updateLog(logId, 'failed', err.message);
await sendAlert('文件传入失败', `审批单: ${instanceId}\n申请人: ${approvalData.applicantName}\n错误: ${err.message}`, 'error');
throw err;
}
}
/**
* 处理文件传出审批
*/
async function processFileOut(approvalCode, instanceId) {
const instance = await feishu.getApprovalInstance(approvalCode, instanceId);
const form = feishu.parseApprovalForm(instance.form);
const approvalData = {
applicantName: form['申请人'] || instance.user_name || '',
filePath: form['文件路径'] || form['文件位置'] || '',
destination: form['传出目的'] || form['用途'] || '',
customerAbbr: form['客户名缩写'] || form['客户简称'] || '',
expireDate: form['文件过期日期'] || form['过期日期'] || '',
};
if (!approvalData.applicantName || !approvalData.filePath || !approvalData.destination) {
throw new Error('审批表单缺少必要字段(申请人/文件路径/传出目的)');
}
const pinyin = nameToPinyin(approvalData.applicantName);
const logId = createLog({
approvalCode,
instanceId,
workflowType: 'file_out',
applicantName: approvalData.applicantName,
applicantPinyin: pinyin,
action: `文件传出-${approvalData.destination}`,
detail: `文件: ${approvalData.filePath}, 目的: ${approvalData.destination}`,
});
try {
const result = await handleFileOut(approvalData, logId);
updateLog(logId, 'success');
await feishu.approveInstance(approvalCode, instanceId, result.message);
logger.info(`[文件传出] 完成: ${result.detail}`);
} catch (err) {
updateLog(logId, 'failed', err.message);
await sendAlert('文件传出失败', `审批单: ${instanceId}\n申请人: ${approvalData.applicantName}\n错误: ${err.message}`, 'error');
throw err;
}
}
/**
* 处理红区组权限审批
*/
async function processGroupPermission(approvalCode, instanceId) {
const instance = await feishu.getApprovalInstance(approvalCode, instanceId);
const form = feishu.parseApprovalForm(instance.form);
const approvalData = {
personnelName: form['待添加人员'] || form['人员'] || '',
requestType: form['申请类型'] || form['类型'] || '',
groupName: form['group名称'] || form['组名'] || form['Group'] || '',
};
if (!approvalData.personnelName || !approvalData.requestType || !approvalData.groupName) {
throw new Error('审批表单缺少必要字段(待添加人员/申请类型/group名称)');
}
const pinyin = nameToPinyin(approvalData.personnelName);
const logId = createLog({
approvalCode,
instanceId,
workflowType: 'group_permission',
applicantName: approvalData.personnelName,
applicantPinyin: pinyin,
action: `红区组权限-${approvalData.requestType}`,
detail: `人员: ${approvalData.personnelName}, group: ${approvalData.groupName}`,
});
try {
const result = await handleGroupPermission(approvalData, logId);
updateLog(logId, 'success');
await feishu.approveInstance(approvalCode, instanceId, result.message);
logger.info(`[红区组权限] 完成: ${result.detail}`);
} catch (err) {
updateLog(logId, 'failed', err.message);
await sendAlert('红区组权限操作失败', `审批单: ${instanceId}\n人员: ${approvalData.personnelName}\n错误: ${err.message}`, 'error');
throw err;
}
}
module.exports = router;
+65
View File
@@ -0,0 +1,65 @@
const express = require('express');
const path = require('path');
const rateLimit = require('express-rate-limit');
const config = require('./config');
const logger = require('./utils/logger');
const feishuEventRouter = require('./routes/feishu-event');
const adminRouter = require('./routes/admin');
const { cleanupOldLogs } = require('./middleware/operation-log');
const app = express();
// ===== 中间件 =====
app.use(express.json({ limit: '1mb' }));
// 请求日志
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
next();
});
// 飞书事件回调不限速(飞书可能重试)
// 管理接口限速
const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100,
message: { error: '请求过于频繁,请稍后再试' },
});
// ===== 路由 =====
app.use('/api/feishu', feishuEventRouter);
app.use('/api/admin', adminLimiter, adminRouter);
// 健康检查
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() });
});
// 管理后台前端
app.use('/admin', express.static(path.join(__dirname, '..', 'admin-ui')));
// 404
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// 全局错误处理
app.use((err, req, res, next) => {
logger.error(`未捕获错误: ${err.message}`, { stack: err.stack });
res.status(500).json({ error: '服务器内部错误' });
});
// ===== 定时任务:清理过期日志 =====
setInterval(() => {
try {
cleanupOldLogs();
} catch (e) {
logger.error(`日志清理失败: ${e.message}`);
}
}, 24 * 60 * 60 * 1000); // 每24小时
module.exports = app;
+164
View File
@@ -0,0 +1,164 @@
const axios = require('axios');
const config = require('../config');
const logger = require('../utils/logger');
const BASE_URL = 'https://open.feishu.cn/open-apis';
let tenantAccessToken = null;
let tokenExpireAt = 0;
/**
* 获取 tenant_access_token(自动缓存和刷新)
*/
async function getTenantToken() {
const now = Date.now();
if (tenantAccessToken && now < tokenExpireAt) {
return tenantAccessToken;
}
const res = await axios.post(`${BASE_URL}/auth/v3/tenant_access_token/internal`, {
app_id: config.feishu.appId,
app_secret: config.feishu.appSecret,
});
if (res.data.code !== 0) {
throw new Error(`获取飞书token失败: ${res.data.msg}`);
}
tenantAccessToken = res.data.tenant_access_token;
// 提前5分钟过期
tokenExpireAt = now + (res.data.expire - 300) * 1000;
logger.info('飞书 tenant_access_token 已刷新');
return tenantAccessToken;
}
/**
* 通用飞书 API 请求
*/
async function feishuRequest(method, path, data = null, params = null) {
const token = await getTenantToken();
const res = await axios({
method,
url: `${BASE_URL}${path}`,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data,
params,
timeout: 30000,
});
if (res.data.code !== 0) {
throw new Error(`飞书API错误 [${path}]: code=${res.data.code}, msg=${res.data.msg}`);
}
return res.data.data;
}
/**
* 获取审批实例详情
* @param {string} approvalCode - 审批定义code
* @param {string} instanceId - 审批实例ID
*/
async function getApprovalInstance(approvalCode, instanceId) {
return feishuRequest('GET', `/approval/v4/instances/${instanceId}`, null, {
approval_code: approvalCode,
});
}
/**
* 解析审批表单字段
* 飞书审批表单是 JSON 数组格式: [{id, type, value, ...}]
*/
function parseApprovalForm(formJson) {
try {
const fields = JSON.parse(formJson);
const result = {};
for (const field of fields) {
// 字段名作为key,值作为value
if (field.name) {
result[field.name] = field.value;
}
}
return result;
} catch (e) {
logger.error(`解析审批表单失败: ${e.message}`);
return {};
}
}
/**
* 同意审批(通过)
* @param {string} approvalCode - 审批定义code
* @param {string} instanceId - 审批实例ID
* @param {string} userId - 操作人的 user_id(应用机器人则用 app_id 对应的 open_id
* @param {string} comment - 审批意见
*/
async function approveInstance(approvalCode, instanceId, comment) {
// 先获取审批实例中的当前审批节点 task_id
const instance = await getApprovalInstance(approvalCode, instanceId);
const taskList = instance.task_list || [];
// 找到 PENDING 状态的 task
const pendingTask = taskList.find(t => t.status === 'PENDING');
if (!pendingTask) {
throw new Error(`审批实例 ${instanceId} 无待处理任务`);
}
return feishuRequest('POST', '/approval/v4/instances/approve', {
approval_code: approvalCode,
instance_code: instanceId,
user_id: pendingTask.user_id,
task_id: pendingTask.task_id,
comment: comment,
});
}
/**
* 添加审批评论(不改变审批状态,仅添加意见)
* 如果流程设计是由机器人节点自动通过,可以用此方法
*/
async function addApprovalComment(approvalCode, instanceId, comment) {
return feishuRequest('POST', '/approval/v4/instances/comment', {
approval_code: approvalCode,
instance_code: instanceId,
comment,
});
}
/**
* 查询审批实例列表(用于轮询模式备用)
*/
async function listApprovalInstances(approvalCode, startTime, endTime) {
return feishuRequest('POST', '/approval/v4/instances/search', {
approval_code: approvalCode,
start_time: startTime,
end_time: endTime,
page_size: 50,
});
}
/**
* 验证飞书事件订阅请求
*/
function verifyEventRequest(body) {
// URL 验证挑战
if (body.type === 'url_verification') {
return { challenge: body.challenge };
}
// 验证 token
if (body.token && body.token !== config.feishu.verificationToken) {
return null; // token 不匹配
}
return body;
}
module.exports = {
getTenantToken,
feishuRequest,
getApprovalInstance,
parseApprovalForm,
approveInstance,
addApprovalComment,
listApprovalInstances,
verifyEventRequest,
};
+140
View File
@@ -0,0 +1,140 @@
const axios = require('axios');
const crypto = require('crypto');
const config = require('../config');
const logger = require('../utils/logger');
/**
* JumpServer 3.x API 客户端
* 通过 API Key 认证,使用作业执行(Job Execution)功能远程执行命令
*/
function getAuthHeaders() {
// JumpServer 3.x 使用 API Key 认证
// Header: Authorization: Token <key_id>:<key_secret>
// 或者使用 Signature 方式
return {
'Authorization': `Token ${config.jumpserver.keyId}:${config.jumpserver.keySecret}`,
'Content-Type': 'application/json',
'X-JMS-ORG': '00000000-0000-0000-0000-000000000002', // DEFAULT组织
};
}
function getClient() {
return axios.create({
baseURL: config.jumpserver.host,
timeout: 60000,
headers: getAuthHeaders(),
});
}
/**
* 在 ic1 服务器上执行命令
* 使用 JumpServer 的 Job API(作业管理)
* @param {string} command - 要执行的命令
* @param {number} timeout - 超时时间(秒)
* @returns {object} 执行结果 { output, exitCode }
*/
async function executeCommand(command, timeout = 60) {
const client = getClient();
logger.info(`[JumpServer] 准备执行命令: ${command}`);
// 1. 创建作业(Ad-hoc 命令)
const jobRes = await client.post('/api/v1/ops/jobs/', {
name: `auto_${Date.now()}`,
type: 'adhoc',
module: 'shell',
args: command,
runas: config.jumpserver.systemUser,
runas_policy: 'skip',
assets: [config.jumpserver.assetIc1],
timeout: timeout,
});
const jobId = jobRes.data.id;
logger.info(`[JumpServer] 作业已创建: ${jobId}`);
// 2. 执行作业
const execRes = await client.post(`/api/v1/ops/jobs/${jobId}/run/`);
const executionId = execRes.data.id;
logger.info(`[JumpServer] 作业执行中: execution=${executionId}`);
// 3. 轮询执行结果
const result = await waitForExecution(client, executionId, timeout);
return result;
}
/**
* 等待作业执行完成
*/
async function waitForExecution(client, executionId, timeout) {
const maxWait = (timeout + 30) * 1000; // 额外30秒缓冲
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const res = await client.get(`/api/v1/ops/executions/${executionId}/`);
const status = res.data.status;
if (status === 'success') {
// 获取详细输出
const resultRes = await client.get(
`/api/v1/ops/executions/${executionId}/result/`
);
const rawResult = resultRes.data;
// 解析各资产的执行结果
const assetResult = rawResult[config.jumpserver.assetIc1] || {};
return {
success: true,
exitCode: assetResult.rc ?? 0,
output: assetResult.result || JSON.stringify(rawResult),
};
}
if (status === 'failed' || status === 'error') {
const resultRes = await client.get(
`/api/v1/ops/executions/${executionId}/result/`
).catch(() => ({ data: {} }));
return {
success: false,
exitCode: -1,
output: JSON.stringify(resultRes.data),
};
}
// 等待2秒后重试
await new Promise(r => setTimeout(r, 2000));
}
throw new Error(`[JumpServer] 命令执行超时: executionId=${executionId}`);
}
/**
* 执行多条命令(顺序执行)
* @param {string[]} commands - 命令列表
*/
async function executeCommands(commands) {
const results = [];
for (const cmd of commands) {
const result = await executeCommand(cmd);
results.push({ command: cmd, ...result });
if (!result.success) {
throw new Error(`命令执行失败 [${cmd}]: ${result.output}`);
}
}
return results;
}
/**
* 获取资产信息(验证连接)
*/
async function getAssetInfo() {
const client = getClient();
const res = await client.get(`/api/v1/assets/hosts/${config.jumpserver.assetIc1}/`);
return res.data;
}
module.exports = {
executeCommand,
executeCommands,
getAssetInfo,
};
+151
View File
@@ -0,0 +1,151 @@
const axios = require('axios');
const config = require('../config');
const logger = require('../utils/logger');
/**
* Nextcloud WebDAV + OCS API 客户端
*/
function getDavClient() {
return axios.create({
baseURL: `${config.nextcloud.host}/remote.php/dav/files/${config.nextcloud.username}`,
auth: {
username: config.nextcloud.username,
password: config.nextcloud.password,
},
timeout: 60000,
});
}
function getOcsClient() {
return axios.create({
baseURL: `${config.nextcloud.host}/ocs/v2.php/apps/files_sharing/api/v1`,
auth: {
username: config.nextcloud.username,
password: config.nextcloud.password,
},
headers: {
'OCS-APIRequest': 'true',
'Content-Type': 'application/json',
},
timeout: 30000,
});
}
/**
* 检查文件/目录是否存在
*/
async function exists(path) {
const client = getDavClient();
try {
await client.request({ method: 'PROPFIND', url: encodeURI(path), maxRedirects: 0 });
return true;
} catch (e) {
if (e.response?.status === 404) return false;
throw e;
}
}
/**
* 创建目录(MKCOL
*/
async function createFolder(path) {
const client = getDavClient();
try {
await client.request({ method: 'MKCOL', url: encodeURI(path) });
logger.info(`[Nextcloud] 目录已创建: ${path}`);
} catch (e) {
// 405 表示已存在
if (e.response?.status !== 405) throw e;
}
}
/**
* 确保目录存在(逐级创建)
*/
async function ensureFolder(path) {
const parts = path.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current += `/${part}`;
await createFolder(current);
}
}
/**
* 复制文件(WebDAV COPY
* @param {string} srcPath - 源文件路径(相对于用户根目录)
* @param {string} destPath - 目标路径(含文件名)
*/
async function copyFile(srcPath, destPath) {
const client = getDavClient();
const destUrl = `${config.nextcloud.host}/remote.php/dav/files/${config.nextcloud.username}${encodeURI(destPath)}`;
await client.request({
method: 'COPY',
url: encodeURI(srcPath),
headers: {
Destination: destUrl,
Overwrite: 'T',
},
});
logger.info(`[Nextcloud] 文件复制完成: ${srcPath} -> ${destPath}`);
}
/**
* 创建共享链接或用户共享
* @param {string} path - 文件/目录路径
* @param {string} shareWith - 共享给谁(用户名/组名)
* @param {number} shareType - 共享类型: 0=用户, 1=组, 3=公开链接
* @param {string} expireDate - 过期日期 YYYY-MM-DD
* @param {number} permissions - 权限
*/
async function createShare(path, shareWith, shareType = 0, expireDate = null, permissions = null) {
const client = getOcsClient();
const shareData = {
path,
shareType,
shareWith,
permissions: permissions || config.nextcloud.sharePermissions,
};
if (expireDate) {
shareData.expireDate = expireDate;
}
const res = await client.post('/shares', shareData);
const shareId = res.data?.ocs?.data?.id;
logger.info(`[Nextcloud] 共享已创建: path=${path}, shareWith=${shareWith}, id=${shareId}`);
return res.data?.ocs?.data;
}
/**
* 复制文件并设置共享(组合操作)
* @param {string} srcPath - 源路径
* @param {string} destDir - 目标目录
* @param {string} fileName - 文件名
* @param {string} shareWith - 共享对象
* @param {string} expireDate - 过期日期 YYYY-MM-DD
*/
async function copyAndShare(srcPath, destDir, fileName, shareWith, expireDate) {
// 确保目标目录存在
await ensureFolder(destDir);
const destPath = `${destDir}/${fileName}`;
// 复制文件
await copyFile(srcPath, destPath);
// 创建共享
const share = await createShare(destPath, shareWith, 0, expireDate);
return { destPath, share };
}
module.exports = {
exists,
createFolder,
ensureFolder,
copyFile,
createShare,
copyAndShare,
};
+209
View File
@@ -0,0 +1,209 @@
const axios = require('axios');
const https = require('https');
const config = require('../config');
const logger = require('../utils/logger');
let sid = null; // Session ID
function getAxiosInstance() {
return axios.create({
baseURL: config.synology.host,
timeout: 30000,
httpsAgent: config.synology.skipTls
? new https.Agent({ rejectUnauthorized: false })
: undefined,
});
}
/**
* 登录群晖 DSM,获取 SID
*/
async function login() {
const client = getAxiosInstance();
const res = await client.get('/webapi/auth.cgi', {
params: {
api: 'SYNO.API.Auth',
version: 6,
method: 'login',
account: config.synology.username,
passwd: config.synology.password,
format: 'sid',
session: 'FileTransfer',
},
});
if (!res.data.success) {
throw new Error(`群晖登录失败: error code ${res.data.error?.code}`);
}
sid = res.data.data.sid;
logger.info('群晖 NAS 登录成功');
return sid;
}
/**
* 确保已登录
*/
async function ensureLogin() {
if (!sid) {
await login();
}
}
/**
* 列出目录内容
* @param {string} folderPath - 完整路径,如 /wingsemi/zhangsan
*/
async function listFiles(folderPath) {
await ensureLogin();
const client = getAxiosInstance();
const res = await client.get('/webapi/entry.cgi', {
params: {
api: 'SYNO.FileStation.List',
version: 2,
method: 'list',
folder_path: folderPath,
_sid: sid,
},
});
if (!res.data.success) {
// 如果是 session 过期,重新登录
if (res.data.error?.code === 105 || res.data.error?.code === 106) {
sid = null;
return listFiles(folderPath);
}
throw new Error(`群晖列目录失败 [${folderPath}]: error ${res.data.error?.code}`);
}
return res.data.data.files || [];
}
/**
* 在指定目录中查找文件
* @param {string} dirPath - 目录路径
* @param {string} fileName - 文件名(支持模糊匹配)
* @returns {object|null} 文件信息
*/
async function findFile(dirPath, fileName) {
const files = await listFiles(dirPath);
// 精确匹配优先
let found = files.find(f => f.name === fileName);
if (!found) {
// 模糊匹配(包含关系)
found = files.find(f => f.name.includes(fileName) || fileName.includes(f.name));
}
return found || null;
}
/**
* 复制文件/文件夹
* @param {string} srcPath - 源路径(完整路径含文件名)
* @param {string} destFolderPath - 目标文件夹路径
* @param {boolean} overwrite - 是否覆盖
*/
async function copyFile(srcPath, destFolderPath, overwrite = true) {
await ensureLogin();
const client = getAxiosInstance();
// 先确保目标文件夹存在
await createFolderIfNotExists(destFolderPath);
const res = await client.get('/webapi/entry.cgi', {
params: {
api: 'SYNO.FileStation.CopyMove',
version: 3,
method: 'start',
path: srcPath,
dest_folder_path: destFolderPath,
overwrite: overwrite,
_sid: sid,
},
});
if (!res.data.success) {
throw new Error(`群晖复制失败 [${srcPath} -> ${destFolderPath}]: error ${res.data.error?.code}`);
}
// 异步任务,需要轮询完成状态
const taskId = res.data.data.taskid;
await waitForTaskComplete(taskId);
logger.info(`群晖文件复制完成: ${srcPath} -> ${destFolderPath}`);
}
/**
* 等待异步任务完成
*/
async function waitForTaskComplete(taskId, maxRetries = 30) {
const client = getAxiosInstance();
for (let i = 0; i < maxRetries; i++) {
const res = await client.get('/webapi/entry.cgi', {
params: {
api: 'SYNO.FileStation.CopyMove',
version: 3,
method: 'status',
taskid: taskId,
_sid: sid,
},
});
if (res.data.success && res.data.data.finished) {
if (res.data.data.progress === 1) return;
throw new Error(`群晖任务异常终止: ${JSON.stringify(res.data.data)}`);
}
// 等待1秒后重试
await new Promise(r => setTimeout(r, 1000));
}
throw new Error(`群晖任务超时: taskId=${taskId}`);
}
/**
* 创建文件夹(如果不存在)
*/
async function createFolderIfNotExists(folderPath) {
await ensureLogin();
const client = getAxiosInstance();
// 逐级创建
const parts = folderPath.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
const parent = current || '/';
current = `${current}/${part}`;
try {
const res = await client.get('/webapi/entry.cgi', {
params: {
api: 'SYNO.FileStation.CreateFolder',
version: 2,
method: 'create',
folder_path: parent,
name: part,
force_parent: false,
_sid: sid,
},
});
// 如果已存在会返回错误,忽略即可
} catch (e) {
// 忽略已存在的错误
}
}
}
/**
* 检查文件/目录是否存在
*/
async function exists(path) {
try {
await listFiles(path);
return true;
} catch {
return false;
}
}
module.exports = {
login,
listFiles,
findFile,
copyFile,
createFolderIfNotExists,
exists,
};
+47
View File
@@ -0,0 +1,47 @@
const axios = require('axios');
const config = require('../config');
const logger = require('../utils/logger');
/**
* 通过飞书群机器人 Webhook 发送告警
*/
async function sendAlert(title, content, level = 'warning') {
const webhookUrl = config.feishu.alertWebhook;
if (!webhookUrl) {
logger.warn('未配置告警 Webhook,跳过告警发送');
return;
}
const colorMap = { info: 'blue', warning: 'orange', error: 'red' };
const card = {
msg_type: 'interactive',
card: {
header: {
title: { tag: 'plain_text', content: `[${level.toUpperCase()}] ${title}` },
template: colorMap[level] || 'orange',
},
elements: [
{
tag: 'div',
text: { tag: 'lark_md', content },
},
{
tag: 'note',
elements: [
{ tag: 'plain_text', content: `文件传输自动化系统 · ${new Date().toLocaleString('zh-CN')}` },
],
},
],
},
};
try {
await axios.post(webhookUrl, card, { timeout: 10000 });
logger.info(`告警已发送: ${title}`);
} catch (err) {
logger.error(`告警发送失败: ${err.message}`);
}
}
module.exports = { sendAlert };
+17
View File
@@ -0,0 +1,17 @@
const dayjs = require('dayjs');
/**
* 获取当天日期字符串,格式 YYYYMMDD
*/
function getTodayStr() {
return dayjs().format('YYYYMMDD');
}
/**
* 获取当前时间字符串
*/
function nowStr() {
return dayjs().format('YYYY-MM-DD HH:mm:ss');
}
module.exports = { getTodayStr, nowStr };
+45
View File
@@ -0,0 +1,45 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs');
const LOG_DIR = path.join(__dirname, '..', 'data', 'logs');
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'file-transfer-automation' },
transports: [
// 所有日志
new winston.transports.File({
filename: path.join(LOG_DIR, 'combined.log'),
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
}),
// 错误日志单独一份
new winston.transports.File({
filename: path.join(LOG_DIR, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024,
maxFiles: 5,
}),
],
});
// 开发环境输出到控制台
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
module.exports = logger;
+15
View File
@@ -0,0 +1,15 @@
const { pinyin } = require('pinyin-pro');
/**
* 将中文姓名转为拼音(小写、无声调、无空格)
* 例: "张三" -> "zhangsan"
*/
function nameToPinyin(name) {
if (!name) return '';
return pinyin(name, { toneType: 'none', type: 'array' })
.join('')
.toLowerCase()
.replace(/[^a-z]/g, '');
}
module.exports = { nameToPinyin };
+47
View File
@@ -0,0 +1,47 @@
const synology = require('../services/synology');
const feishu = require('../services/feishu');
const { nameToPinyin } = require('../utils/pinyin');
const { validatePath } = require('../middleware/security');
const logger = require('../utils/logger');
/**
* 文件传入工作流
*
* 流程:
* 1. 从审批中提取:申请人名称、文件名称
* 2. 在群晖 /wingsemi/{申请人拼音}/ 下找到文件
* 3. 复制到 /IN_R/{申请人拼音}/
* 4. 在飞书审批中提交意见"已传入"
*/
async function handleFileIn(approvalData, logId) {
const { applicantName, fileName } = approvalData;
const pinyin = nameToPinyin(applicantName);
logger.info(`[文件传入] 开始处理: 申请人=${applicantName}(${pinyin}), 文件=${fileName}`);
// 1. 验证路径安全
const srcDir = `/wingsemi/${pinyin}`;
const destDir = `/IN_R/${pinyin}`;
validatePath(srcDir);
validatePath(destDir);
// 2. 在群晖中查找文件
const file = await synology.findFile(srcDir, fileName);
if (!file) {
throw new Error(`${srcDir} 中未找到文件: ${fileName}`);
}
logger.info(`[文件传入] 找到文件: ${file.path}`);
// 3. 复制到 /IN_R/{pinyin}/
await synology.copyFile(file.path, destDir);
logger.info(`[文件传入] 文件已复制: ${file.path} -> ${destDir}`);
// 4. 提交审批意见
return {
success: true,
message: '已传入',
detail: `文件 ${fileName} 已从 ${srcDir} 复制到 ${destDir}`,
};
}
module.exports = { handleFileIn };
+118
View File
@@ -0,0 +1,118 @@
const jumpserver = require('../services/jumpserver');
const synology = require('../services/synology');
const nextcloud = require('../services/nextcloud');
const { nameToPinyin } = require('../utils/pinyin');
const { getTodayStr } = require('../utils/date');
const { validatePath, validateCommand } = require('../middleware/security');
const logger = require('../utils/logger');
/**
* 文件传出工作流
*
* 内部使用:
* 1. 提取:传出目的、申请人名称、文件路径
* 2. JumpServer 连接 ic1,执行 cp "文件路径" /OUT/{日期}/(无则创建)
* 3. 群晖:/OUT_R/{日期}/{文件名} 复制到 /wingsemi/{申请人拼音}/
* 4. 审批意见:"已传出至/wingsemi/{申请人拼音}"
*
* 客户发布:
* 1. 额外提取:客户名缩写、文件过期日期
* 2. JumpServer 连接 ic1,执行复制到 /OUT/{日期}/
* 3. Nextcloud/OUT-RED/{日期}/{文件名} 复制到 /WCPS-Files/{客户缩写}/{日期}/
* 4. 设置共享(联系人=客户缩写,过期日期)
* 5. 审批意见:"已传出至客户发布目录"
*/
async function handleFileOut(approvalData, logId) {
const { applicantName, filePath, destination, customerAbbr, expireDate } = approvalData;
const pinyin = nameToPinyin(applicantName);
const today = getTodayStr();
const fileName = filePath.split('/').pop();
logger.info(`[文件传出] 开始处理: 申请人=${applicantName}, 目的=${destination}, 文件=${filePath}`);
if (destination === '内部使用') {
return await handleInternalUse(applicantName, pinyin, filePath, fileName, today);
} else if (destination === '客户发布') {
return await handleCustomerRelease(applicantName, pinyin, filePath, fileName, today, customerAbbr, expireDate);
} else {
throw new Error(`未知的传出目的: ${destination}`);
}
}
/**
* 内部使用流程
*/
async function handleInternalUse(applicantName, pinyin, filePath, fileName, today) {
// 1. 验证路径安全
validatePath(filePath);
validatePath(`/OUT/${today}`);
// 2. JumpServer: 在 ic1 上创建目录并复制
const mkdirCmd = `mkdir -p /OUT/${today}`;
const cpCmd = `cp "${filePath}" /OUT/${today}/`;
// 安全校验命令
validateCommand(mkdirCmd);
validateCommand(cpCmd);
await jumpserver.executeCommands([mkdirCmd, cpCmd]);
logger.info(`[文件传出-内部] ic1 复制完成: ${filePath} -> /OUT/${today}/`);
// 3. 群晖: 从 /OUT_R/{日期}/{文件名} 复制到 /wingsemi/{拼音}/
const nasSrcPath = `/OUT_R/${today}/${fileName}`;
const nasDestDir = `/wingsemi/${pinyin}`;
validatePath(nasSrcPath);
validatePath(nasDestDir);
await synology.copyFile(nasSrcPath, nasDestDir);
logger.info(`[文件传出-内部] NAS复制完成: ${nasSrcPath} -> ${nasDestDir}`);
return {
success: true,
message: `已传出至/wingsemi/${pinyin}`,
detail: `文件 ${fileName} 已传出至 ${nasDestDir}`,
};
}
/**
* 客户发布流程
*/
async function handleCustomerRelease(applicantName, pinyin, filePath, fileName, today, customerAbbr, expireDate) {
if (!customerAbbr) throw new Error('客户发布缺少客户名缩写');
if (!expireDate) throw new Error('客户发布缺少文件过期日期');
// 1. 验证路径安全
validatePath(filePath);
validatePath(`/OUT/${today}`);
validatePath(`/WCPS-Files/${customerAbbr}`);
// 2. JumpServer: 在 ic1 上创建目录并复制
const mkdirCmd = `mkdir -p /OUT/${today}`;
const cpCmd = `cp "${filePath}" /OUT/${today}/`;
validateCommand(mkdirCmd);
validateCommand(cpCmd);
await jumpserver.executeCommands([mkdirCmd, cpCmd]);
logger.info(`[文件传出-客户] ic1 复制完成: ${filePath} -> /OUT/${today}/`);
// 3. Nextcloud: 复制到客户目录并设置共享
const ncSrcPath = `/OUT-RED/${today}/${fileName}`;
const ncDestDir = `/WCPS-Files/${customerAbbr}/${today}`;
const result = await nextcloud.copyAndShare(
ncSrcPath,
ncDestDir,
fileName,
customerAbbr,
expireDate // 格式 YYYY-MM-DD
);
logger.info(`[文件传出-客户] Nextcloud复制+共享完成: ${ncSrcPath} -> ${ncDestDir}`);
return {
success: true,
message: `已传出至客户发布目录 /WCPS-Files/${customerAbbr}/${today}`,
detail: `文件 ${fileName} 已发布至 ${ncDestDir},共享给 ${customerAbbr},过期日期 ${expireDate}`,
};
}
module.exports = { handleFileOut };
+103
View File
@@ -0,0 +1,103 @@
const jumpserver = require('../services/jumpserver');
const { nameToPinyin } = require('../utils/pinyin');
const { validateCommand } = require('../middleware/security');
const logger = require('../utils/logger');
/**
* 红区组权限工作流
*
* 添加至已有 group
* 1. 提取:待添加人员、group名称
* 2. JumpServer ic1: usermod -aG "{group}" "{人员拼音}"
* 3. JumpServer ic1: make -C /var/yp
*
* 新建 group
* 1. 提取:待添加人员、group名称
* 2. JumpServer ic1: 查询最大 group id+1 作为新 id
* 3. JumpServer ic1: groupadd -g "{new_id}" "{group}"
* 4. JumpServer ic1: usermod -aG "{group}" "{人员拼音}"
* 5. JumpServer ic1: make -C /var/yp
*/
async function handleGroupPermission(approvalData, logId) {
const { personnelName, requestType, groupName } = approvalData;
const userPinyin = nameToPinyin(personnelName);
logger.info(`[红区组权限] 开始处理: 人员=${personnelName}(${userPinyin}), 类型=${requestType}, group=${groupName}`);
// 安全校验 group 名称(只允许字母、数字、下划线、连字符)
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(groupName)) {
throw new Error(`非法的 group 名称: ${groupName}`);
}
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(userPinyin)) {
throw new Error(`非法的用户名: ${userPinyin}`);
}
if (requestType === '添加至已有group') {
return await addToExistingGroup(userPinyin, groupName);
} else if (requestType === '新建group') {
return await createNewGroup(userPinyin, groupName);
} else {
throw new Error(`未知的申请类型: ${requestType}`);
}
}
/**
* 添加用户到已有 group
*/
async function addToExistingGroup(userPinyin, groupName) {
const usermodCmd = `usermod -aG "${groupName}" "${userPinyin}"`;
const makeCmd = 'make -C /var/yp';
validateCommand(usermodCmd);
validateCommand(makeCmd);
await jumpserver.executeCommands([usermodCmd, makeCmd]);
logger.info(`[红区组权限] 用户 ${userPinyin} 已添加到 group ${groupName}`);
return {
success: true,
message: `已将 ${userPinyin} 添加至 ${groupName}`,
detail: `执行: ${usermodCmd} && ${makeCmd}`,
};
}
/**
* 新建 group 并添加用户
*/
async function createNewGroup(userPinyin, groupName) {
// 1. 查询当前最大 group id
const getMaxGidCmd = "getent group | awk -F: '{print $3}' | sort -n | tail -1";
validateCommand(getMaxGidCmd);
const gidResult = await jumpserver.executeCommand(getMaxGidCmd);
if (!gidResult.success) {
throw new Error(`获取最大 GID 失败: ${gidResult.output}`);
}
const maxGid = parseInt(gidResult.output.trim());
if (isNaN(maxGid)) {
throw new Error(`无法解析最大 GID: ${gidResult.output}`);
}
const newGid = maxGid + 1;
logger.info(`[红区组权限] 当前最大GID=${maxGid}, 新GID=${newGid}`);
// 2. 创建 group
const groupaddCmd = `groupadd -g ${newGid} "${groupName}"`;
const usermodCmd = `usermod -aG "${groupName}" "${userPinyin}"`;
const makeCmd = 'make -C /var/yp';
validateCommand(groupaddCmd);
validateCommand(usermodCmd);
validateCommand(makeCmd);
await jumpserver.executeCommands([groupaddCmd, usermodCmd, makeCmd]);
logger.info(`[红区组权限] 新 group ${groupName}(gid=${newGid}) 已创建,用户 ${userPinyin} 已添加`);
return {
success: true,
message: `已创建 ${groupName}(GID:${newGid}) 并添加 ${userPinyin}`,
detail: `执行: ${groupaddCmd} && ${usermodCmd} && ${makeCmd}`,
};
}
module.exports = { handleGroupPermission };