first commit
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user