first commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
+18
@@ -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"]
|
||||
@@ -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 |
|
||||
| 审批意见提交失败 | 无待处理任务 | 确认审批流程中有机器人审批节点 |
|
||||
@@ -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>
|
||||
@@ -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 # 可选:直接使用宿主机网络
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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