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
+48
View File
@@ -0,0 +1,48 @@
# ===== 服务配置 =====
PORT=3000
NODE_ENV=production
# 管理后台密码(首次部署务必修改)
ADMIN_PASSWORD=change-me-to-strong-password
# ===== 飞书配置 =====
# 在飞书开放平台创建企业自建应用后获取
FEISHU_APP_ID=cli_xxxxxxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 事件订阅的 Verification Token 和 Encrypt Key
FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxx
FEISHU_ENCRYPT_KEY=
# 飞书审批定义code(在飞书管理后台查看)
FEISHU_APPROVAL_FILE_IN=approval-code-file-in
FEISHU_APPROVAL_FILE_OUT=approval-code-file-out
FEISHU_APPROVAL_GROUP_PERM=approval-code-group-perm
# 告警通知群 webhook(群机器人)
FEISHU_ALERT_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx
# ===== 群晖 NAS 配置 =====
SYNOLOGY_HOST=https://192.168.1.100:5001
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your-nas-password
# 是否跳过TLS证书验证(自签证书时设为true)
SYNOLOGY_SKIP_TLS=true
# ===== JumpServer 配置 =====
JUMPSERVER_HOST=https://jumpserver.yourcompany.com
JUMPSERVER_KEY_ID=your-key-id
JUMPSERVER_KEY_SECRET=your-key-secret
# ic1 资产的 asset_id(在JumpServer控制台查看)
JUMPSERVER_ASSET_IC1=asset-id-of-ic1
# 系统用户(用于连接ic1的账号)
JUMPSERVER_SYSTEM_USER=root
# ===== Nextcloud 配置 =====
NEXTCLOUD_HOST=https://nextcloud.yourcompany.com
NEXTCLOUD_USERNAME=automation
NEXTCLOUD_PASSWORD=your-nextcloud-password
# 共享默认权限(1=读, 15=读写删, 17=读+分享)
NEXTCLOUD_SHARE_PERMISSIONS=1
# ===== 安全配置 =====
# 白名单目录(逗号分隔)
WHITELIST_DIRS=/IN_R,/OUT_R,/OUT,/wingsemi,/WCPS-Files,/OUT-RED
# 操作日志保留天数
LOG_RETENTION_DAYS=30
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log
+18
View File
@@ -0,0 +1,18 @@
FROM node:18-alpine
WORKDIR /app
# 安装 better-sqlite3 需要的构建依赖
RUN apk add --no-cache python3 make g++
COPY package.json ./
RUN npm install --production
COPY . .
# 创建数据和日志目录
RUN mkdir -p data/logs
EXPOSE 3000
CMD ["node", "src/index.js"]
+316
View File
@@ -0,0 +1,316 @@
# 文件传输自动化系统
飞书审批驱动的企业文件传输与权限自动化管理系统。
## 系统架构
```
飞书审批事件 ──→ Express 服务 ──→ 群晖 NAS API(文件复制)
│ ──→ JumpServer API(远程命令)
│ ──→ Nextcloud API(文件共享)
├── SQLite(操作日志)
├── 管理后台(Web UI)
└── 飞书机器人(告警通知)
```
## 快速开始
### 1. 环境要求
- Node.js >= 18
- 可访问群晖 NAS、JumpServer、Nextcloud 的网络
- 公网 IP 或域名(用于飞书事件回调)
### 2. 安装部署
```bash
# 克隆项目
git clone <your-repo> file-transfer-automation
cd file-transfer-automation
# 安装依赖
npm install
# 复制配置文件并填写
cp .env.example .env
# 编辑 .env 填入实际的 API 密钥和地址
# 启动
npm start
```
或使用 Docker
```bash
cp .env.example .env
# 编辑 .env
docker-compose up -d
```
### 3. 验证
访问 `http://你的服务器IP:3000/health` 确认服务正常。
---
## 飞书应用创建指南(从零开始)
### 第一步:创建企业自建应用
1. 打开 [飞书开放平台](https://open.feishu.cn/app),用管理员账号登录
2. 点击「创建企业自建应用」
3. 填写应用名称(如"文件传输自动化")和描述
4. 创建后进入应用详情页,记录 **App ID****App Secret**
### 第二步:配置权限
在应用详情页 →「权限管理」中,搜索并开通以下权限:
| 权限名称 | 权限标识 | 用途 |
|---------|---------|------|
| 获取审批实例详情 | approval:instance:read | 读取审批表单内容 |
| 同意审批 | approval:instance:approve | 自动提交审批意见 |
| 查看审批定义 | approval:approval:read | 获取审批配置 |
| 添加审批评论 | approval:instance:comment | 添加审批评论 |
### 第三步:配置事件订阅
1. 进入应用详情页 →「事件订阅」
2. 请求地址填写:`http://你的公网IP:3000/api/feishu/event`
3. 系统会发送验证请求,确保服务已启动
4. 添加事件:搜索「审批实例状态变更」(`approval_instance`)
5. 记录 **Verification Token**(页面顶部显示)
> 注意:飞书要求回调地址必须是公网可访问的 HTTP/HTTPS 地址。
### 第四步:发布应用
1. 进入「版本管理与发布」
2. 创建版本 → 提交审核
3. 管理员在飞书管理后台审核通过
### 第五步:配置审批流程
在飞书管理后台 → 审批管理中:
1. 创建/编辑三个审批表单(文件传入、文件传出、红区组权限)
2. 在审批流程设计中,添加「机器人审批」节点(让应用自动处理)
3. 记录每个审批的 **审批定义 Code**(在审批设置页面的 URL 中可以看到)
### 第六步:配置告警机器人
1. 在飞书中创建一个告警通知群
2. 群设置 → 群机器人 → 添加机器人 → 自定义机器人
3. 记录 Webhook 地址,填入 `.env``FEISHU_ALERT_WEBHOOK`
---
## API 接入说明
### 群晖 NAS (Synology DSM)
**前提条件:**
- DSM 6.0+ 或 7.0+
- 开启 FileStation API(默认开启)
- 准备一个有文件管理权限的账号
**获取配置:**
- `SYNOLOGY_HOST`: NAS 地址,如 `https://192.168.1.100:5001`
- 使用自签证书时设 `SYNOLOGY_SKIP_TLS=true`
- 确保运行本服务的服务器能访问 NAS 的 5001 端口
**验证连接:**
```bash
curl -k "https://NAS_IP:5001/webapi/auth.cgi?api=SYNO.API.Auth&version=6&method=login&account=admin&passwd=xxx&format=sid"
```
### JumpServer 3.x
**前提条件:**
- JumpServer 3.x 版本
- 有 API Key 权限(在 JumpServer 控制台 → 系统设置 → API Key 中创建)
- ic1 服务器已作为资产录入 JumpServer
**获取配置:**
- `JUMPSERVER_HOST`: JumpServer 地址
- `JUMPSERVER_KEY_ID` / `JUMPSERVER_KEY_SECRET`: API Key
- `JUMPSERVER_ASSET_IC1`: 在 JumpServer 控制台 → 资产管理 → 找到 ic1 → URL 中的 UUID
- `JUMPSERVER_SYSTEM_USER`: 连接 ic1 使用的系统用户(如 root)
**验证连接:**
```bash
curl -H "Authorization: Token KEY_ID:KEY_SECRET" https://JUMPSERVER_HOST/api/v1/assets/hosts/
```
### Nextcloud
**前提条件:**
- Nextcloud 已启用 WebDAV(默认启用)
- 准备一个有文件管理权限的账号
- 如需共享功能,确保 files_sharing 应用已启用
**获取配置:**
- `NEXTCLOUD_HOST`: Nextcloud 地址
- 用户名/密码用于 WebDAV 认证
**验证连接:**
```bash
curl -u user:pass https://NEXTCLOUD_HOST/remote.php/dav/files/user/ -X PROPFIND
```
---
## 部署方案
### 方案 A:公网 IP 服务器(推荐)
本服务非常轻量(Node.js + SQLite,内存占用约 50-100MB),适合部署在有公网 IP 的服务器上。
```bash
# 使用 Docker 部署
docker-compose up -d
# 或直接运行
npm install --production
NODE_ENV=production node src/index.js
```
配合 Nginx 反向代理(可选,用于 HTTPS):
```nginx
server {
listen 443 ssl;
server_name automation.yourcompany.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### 方案 B:无公网 IP 的内网服务器
如果服务必须部署在内网(比如需要直接访问群晖 NAS),有两种方式接收飞书事件:
**方式 1:内网穿透(frp**
在公网服务器上部署 frps,内网服务器部署 frpc:
```ini
# frpc.ini(内网服务器)
[server]
server_addr = 公网服务器IP
server_port = 7000
[file-automation]
type = http
local_port = 3000
custom_domains = automation.yourcompany.com
```
飞书事件回调地址填:`http://automation.yourcompany.com/api/feishu/event`
**方式 2:飞书长连接模式(推荐)**
飞书支持 WebSocket 长连接模式,不需要公网回调地址。修改事件接收方式:
```javascript
// 使用 @larksuiteoapi/node-sdk 的长连接模式
const { Client, EventDispatcher } = require('@larksuiteoapi/node-sdk');
const client = new Client({ appId, appSecret });
const wsClient = client.event.wsClient; // WebSocket 长连接
wsClient.start(); // 主动连接飞书,无需公网IP
```
> 长连接模式需要安装 `@larksuiteoapi/node-sdk` 包,适合内网部署场景。
### 方案 C:混合部署
公网服务器放一个轻量转发层(只接收飞书回调),内网服务器跑主业务逻辑:
```
飞书 ──→ 公网服务器(转发) ──→ 内网服务器(业务处理) ──→ 群晖/JumpServer/Nextcloud
```
---
## 审批表单字段设计
为了让自动化系统正确解析,审批表单字段名需要与代码中的映射一致:
### 文件传入审批
| 字段名 | 类型 | 说明 |
|--------|------|------|
| 申请人 | 文本/人员 | 申请人姓名 |
| 文件名称 | 文本 | 要传入的文件名 |
### 文件传出审批
| 字段名 | 类型 | 说明 |
|--------|------|------|
| 申请人 | 文本/人员 | 申请人姓名 |
| 文件路径 | 文本 | 文件在服务器上的完整路径 |
| 传出目的 | 单选 | 内部使用 / 客户发布 |
| 客户名缩写 | 文本 | 仅客户发布时填写 |
| 文件过期日期 | 日期 | 仅客户发布时填写 |
### 红区组权限审批
| 字段名 | 类型 | 说明 |
|--------|------|------|
| 待添加人员 | 文本/人员 | 人员姓名 |
| 申请类型 | 单选 | 添加至已有group / 新建group |
| group名称 | 文本 | Linux 组名 |
> 如果你的表单字段名不同,修改 `src/routes/feishu-event.js` 中的字段映射即可。
---
## 管理后台
访问 `http://服务器IP:3000/admin`,使用 `.env` 中配置的 `ADMIN_PASSWORD` 登录。
功能包括:
- 仪表盘:今日操作统计、系统状态
- 操作日志:按类型/状态/时间/关键词筛选,保留30天
- 预设命令:管理可在 ic1 上执行的预设命令
- API配置:动态管理配置项
---
## 安全机制
1. **白名单目录**:所有文件操作路径必须在白名单内(`/IN_R`, `/OUT_R`, `/OUT`, `/wingsemi`, `/WCPS-Files`, `/OUT-RED`
2. **路径穿越防护**:自动规范化路径,阻止 `../` 攻击
3. **命令注入防护**:禁止 `;``$(`、反引号、`rm -rf` 等危险模式
4. **审批绑定**:每次操作记录审批单号、申请人、时间,保留一个月
5. **防重复处理**:同一审批实例不会重复执行
6. **管理接口限速**15分钟内最多100次请求
---
## 日志
- 运行日志:`data/logs/combined.log`(自动轮转,单文件10MB
- 错误日志:`data/logs/error.log`
- 操作记录:SQLite 数据库 `data/automation.db`(可通过管理后台查看)
---
## 故障排查
| 问题 | 可能原因 | 解决方法 |
|------|---------|---------|
| 飞书事件收不到 | 回调地址不通/应用未发布 | 检查公网访问、确认应用已审核通过 |
| 群晖操作失败 | Session 过期/权限不足 | 检查账号权限、确认 FileStation API 开启 |
| JumpServer 命令超时 | 资产连接慢/网络问题 | 增加 timeout、检查 ic1 连通性 |
| Nextcloud 403 | 权限不足/共享功能未启用 | 检查用户权限、启用 files_sharing |
| 审批意见提交失败 | 无待处理任务 | 确认审批流程中有机器人审批节点 |
+267
View File
@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件传输自动化 - 管理后台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f6fa; color: #2d3436; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { font-size: 24px; margin-bottom: 20px; color: #2d3436; }
.tabs { display: flex; gap: 8px; margin-bottom: 20px; }
.tab { padding: 8px 16px; border: none; background: #dfe6e9; border-radius: 6px; cursor: pointer; font-size: 14px; }
.tab.active { background: #0984e3; color: white; }
.panel { display: none; }
.panel.active { display: block; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h3 { margin-bottom: 12px; font-size: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; font-weight: 600; }
.status-success { color: #00b894; }
.status-failed { color: #d63031; }
.status-running { color: #fdcb6e; }
.filters { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.filters input, .filters select { padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; }
.btn { padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.btn-primary { background: #0984e3; color: white; }
.btn-danger { background: #d63031; color: white; }
.btn-sm { padding: 4px 8px; font-size: 12px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
.stat-item { background: white; padding: 16px; border-radius: 8px; text-align: center; }
.stat-item .num { font-size: 28px; font-weight: 700; color: #0984e3; }
.stat-item .label { font-size: 12px; color: #636e72; margin-top: 4px; }
.login-box { max-width: 320px; margin: 100px auto; }
.login-box input { width: 100%; padding: 10px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 6px; }
.login-box button { width: 100%; padding: 10px; background: #0984e3; color: white; border: none; border-radius: 6px; cursor: pointer; }
.form-row { display: flex; gap: 8px; margin-bottom: 8px; }
.form-row input { flex: 1; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; }
.pagination { display: flex; gap: 8px; justify-content: center; margin-top: 12px; }
</style>
</head>
<body>
<div class="container" id="app" style="display:none">
<h1>文件传输自动化 · 管理后台</h1>
<div class="tabs">
<button class="tab active" onclick="switchTab('dashboard')">仪表盘</button>
<button class="tab" onclick="switchTab('logs')">操作日志</button>
<button class="tab" onclick="switchTab('commands')">预设命令</button>
<button class="tab" onclick="switchTab('config')">API配置</button>
</div>
<!-- 仪表盘 -->
<div class="panel active" id="panel-dashboard">
<div class="stats" id="stats"></div>
<div class="card">
<h3>系统信息</h3>
<div id="sysinfo"></div>
</div>
</div>
<!-- 操作日志 -->
<div class="panel" id="panel-logs">
<div class="card">
<div class="filters">
<select id="filter-type">
<option value="">全部类型</option>
<option value="file_in">文件传入</option>
<option value="file_out">文件传出</option>
<option value="group_permission">红区组权限</option>
</select>
<select id="filter-status">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="running">执行中</option>
</select>
<input type="date" id="filter-start">
<input type="date" id="filter-end">
<input type="text" id="filter-keyword" placeholder="搜索关键词">
<button class="btn btn-primary" onclick="loadLogs()">查询</button>
</div>
<table>
<thead><tr><th>时间</th><th>类型</th><th>申请人</th><th>操作</th><th>状态</th><th>详情/错误</th></tr></thead>
<tbody id="logs-body"></tbody>
</table>
<div class="pagination" id="logs-pagination"></div>
</div>
</div>
<!-- 预设命令 -->
<div class="panel" id="panel-commands">
<div class="card">
<h3>添加预设命令</h3>
<div class="form-row">
<input type="text" id="cmd-name" placeholder="命令名称">
<input type="text" id="cmd-command" placeholder="命令内容">
<input type="text" id="cmd-target" placeholder="目标(默认ic1)" value="ic1">
<button class="btn btn-primary" onclick="addCommand()">添加</button>
</div>
</div>
<div class="card">
<table>
<thead><tr><th>ID</th><th>名称</th><th>命令</th><th>目标</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="commands-body"></tbody>
</table>
</div>
</div>
<!-- API配置 -->
<div class="panel" id="panel-config">
<div class="card">
<h3>添加/更新配置</h3>
<div class="form-row">
<input type="text" id="cfg-key" placeholder="配置键名">
<input type="text" id="cfg-value" placeholder="配置值">
<input type="text" id="cfg-desc" placeholder="说明">
<button class="btn btn-primary" onclick="saveConfig()">保存</button>
</div>
</div>
<div class="card">
<table>
<thead><tr><th>键名</th><th></th><th>说明</th><th>更新时间</th></tr></thead>
<tbody id="config-body"></tbody>
</table>
</div>
</div>
</div>
<!-- 登录框 -->
<div class="login-box" id="login-box">
<h2 style="text-align:center;margin-bottom:20px">管理后台登录</h2>
<input type="password" id="login-password" placeholder="输入管理密码" onkeydown="if(event.key==='Enter')doLogin()">
<button onclick="doLogin()">登录</button>
</div>
<script>
let adminPassword = '';
let currentPage = 1;
function api(path, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json', 'X-Admin-Password': adminPassword },
};
if (body) opts.body = JSON.stringify(body);
return fetch(`/api/admin${path}`, opts).then(r => {
if (r.status === 401) { alert('密码错误'); location.reload(); }
return r.json();
});
}
function doLogin() {
adminPassword = document.getElementById('login-password').value;
api('/status').then(data => {
if (data.uptime) {
document.getElementById('login-box').style.display = 'none';
document.getElementById('app').style.display = 'block';
loadDashboard();
}
});
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', t.textContent.includes({dashboard:'仪表盘',logs:'操作日志',commands:'预设命令',config:'API配置'}[name])));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.getElementById(`panel-${name}`).classList.add('active');
if (name === 'dashboard') loadDashboard();
if (name === 'logs') loadLogs();
if (name === 'commands') loadCommands();
if (name === 'config') loadConfig();
}
async function loadDashboard() {
const data = await api('/status');
const s = data.todayStats || {};
document.getElementById('stats').innerHTML = `
<div class="stat-item"><div class="num">${s.total||0}</div><div class="label">今日总操作</div></div>
<div class="stat-item"><div class="num" style="color:#00b894">${s.success||0}</div><div class="label">成功</div></div>
<div class="stat-item"><div class="num" style="color:#d63031">${s.failed||0}</div><div class="label">失败</div></div>
<div class="stat-item"><div class="num" style="color:#fdcb6e">${s.running||0}</div><div class="label">执行中</div></div>
`;
document.getElementById('sysinfo').innerHTML = `
<p>运行时间: ${Math.floor(data.uptime/3600)}小时${Math.floor((data.uptime%3600)/60)}分钟</p>
<p>内存使用: ${Math.round(data.memory?.heapUsed/1024/1024||0)}MB</p>
<p>版本: ${data.version}</p>
`;
}
async function loadLogs(page = 1) {
currentPage = page;
const params = new URLSearchParams({ page, pageSize: 15 });
const type = document.getElementById('filter-type').value;
const status = document.getElementById('filter-status').value;
const start = document.getElementById('filter-start').value;
const end = document.getElementById('filter-end').value;
const keyword = document.getElementById('filter-keyword').value;
if (type) params.set('workflowType', type);
if (status) params.set('status', status);
if (start) params.set('startDate', start);
if (end) params.set('endDate', end);
if (keyword) params.set('keyword', keyword);
const data = await api(`/logs?${params}`);
const typeMap = { file_in: '文件传入', file_out: '文件传出', group_permission: '红区组权限' };
document.getElementById('logs-body').innerHTML = (data.rows || []).map(r => `
<tr>
<td>${r.created_at}</td>
<td>${typeMap[r.workflow_type] || r.workflow_type}</td>
<td>${r.applicant_name || '-'}</td>
<td>${r.action}</td>
<td class="status-${r.status}">${r.status === 'success' ? '成功' : r.status === 'failed' ? '失败' : '执行中'}</td>
<td title="${r.error_message || r.detail || ''}">${(r.error_message || r.detail || '').substring(0, 50)}</td>
</tr>
`).join('');
const totalPages = Math.ceil((data.total || 0) / 15);
document.getElementById('logs-pagination').innerHTML = Array.from({length: Math.min(totalPages, 7)}, (_, i) =>
`<button class="btn btn-sm ${i+1===page?'btn-primary':''}" onclick="loadLogs(${i+1})">${i+1}</button>`
).join('');
}
async function loadCommands() {
const data = await api('/commands');
document.getElementById('commands-body').innerHTML = (data || []).map(c => `
<tr>
<td>${c.id}</td><td>${c.name}</td><td><code>${c.command}</code></td><td>${c.target}</td>
<td>${c.enabled ? '启用' : '禁用'}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteCommand(${c.id})">删除</button></td>
</tr>
`).join('');
}
async function addCommand() {
const name = document.getElementById('cmd-name').value;
const command = document.getElementById('cmd-command').value;
const target = document.getElementById('cmd-target').value;
if (!name || !command) return alert('请填写名称和命令');
await api('/commands', 'POST', { name, command, target });
loadCommands();
}
async function deleteCommand(id) {
if (!confirm('确认删除?')) return;
await api(`/commands/${id}`, 'DELETE');
loadCommands();
}
async function loadConfig() {
const data = await api('/config');
document.getElementById('config-body').innerHTML = (data || []).map(c => `
<tr><td>${c.key}</td><td>${c.value}</td><td>${c.description || '-'}</td><td>${c.updated_at}</td></tr>
`).join('');
}
async function saveConfig() {
const key = document.getElementById('cfg-key').value;
const value = document.getElementById('cfg-value').value;
const description = document.getElementById('cfg-desc').value;
if (!key || !value) return alert('请填写键名和值');
await api('/config', 'PUT', { key, value, description });
loadConfig();
}
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
version: '3.8'
services:
file-transfer-automation:
build: .
container_name: file-transfer-automation
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env
volumes:
- ./data:/app/data # 持久化数据库和日志
# 如果需要访问内网设备(群晖、JumpServer),确保网络互通
# network_mode: host # 可选:直接使用宿主机网络
+25
View File
@@ -0,0 +1,25 @@
{
"name": "file-transfer-automation",
"version": "1.0.0",
"description": "企业文件传输与权限自动化管理系统 - 飞书审批驱动",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"migrate": "node src/db/migrate.js"
},
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0",
"better-sqlite3": "^9.4.3",
"pinyin-pro": "^3.18.0",
"crypto-js": "^4.2.0",
"dotenv": "^16.3.1",
"winston": "^3.11.0",
"dayjs": "^1.11.10",
"express-rate-limit": "^7.1.4"
},
"engines": {
"node": ">=18.0.0"
}
}
+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 };