feat: 初始化Node.js 4G短信网关项目
- 实现AT指令通信和4G模组管理 - 支持PDU模式短信接收和发送 - 长短信自动合并功能 - 多通道推送支持(钉钉/飞书/Telegram等) - 管理员短信远程控制 - RESTful API接口 - 完整的日志系统
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
config.json
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
+310
@@ -0,0 +1,310 @@
|
|||||||
|
# 4G模块AT指令交互文档
|
||||||
|
|
||||||
|
## 串口配置
|
||||||
|
|
||||||
|
- **波特率**: 115200
|
||||||
|
- **数据位**: 8
|
||||||
|
- **停止位**: 1
|
||||||
|
- **校验**: None
|
||||||
|
- **流控**: None
|
||||||
|
|
||||||
|
## 初始化流程
|
||||||
|
|
||||||
|
### 1. 基础握手
|
||||||
|
```
|
||||||
|
AT
|
||||||
|
响应: OK
|
||||||
|
作用: 测试模组是否响应
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 查询模组信息
|
||||||
|
```
|
||||||
|
ATI
|
||||||
|
响应:
|
||||||
|
厂商名称
|
||||||
|
型号
|
||||||
|
固件版本
|
||||||
|
OK
|
||||||
|
作用: 获取模组厂商、型号、固件版本
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 禁用数据连接(防止流量消耗)
|
||||||
|
```
|
||||||
|
AT+CGACT=0,1
|
||||||
|
响应: OK
|
||||||
|
作用: 关闭4G数据连接,防止自动联网消耗流量
|
||||||
|
注意: ML307Y型号此命令有bug,需跳过
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置短信URC上报
|
||||||
|
```
|
||||||
|
AT+CNMI=2,2,0,0,0
|
||||||
|
响应: OK
|
||||||
|
作用: 配置短信到达时主动上报(URC模式)
|
||||||
|
参数说明:
|
||||||
|
- 第1参数=2: 缓冲URC到TE
|
||||||
|
- 第2参数=2: 短信直接上报+CMT URC(PDU模式)
|
||||||
|
- 其余参数=0: 禁用状态报告、广播消息等
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 设置PDU模式
|
||||||
|
```
|
||||||
|
AT+CMGF=0
|
||||||
|
响应: OK
|
||||||
|
作用: 设置短信为PDU模式(支持中文、长短信)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 等待网络注册
|
||||||
|
```
|
||||||
|
AT+CEREG?
|
||||||
|
响应: +CEREG: <n>,<stat>
|
||||||
|
OK
|
||||||
|
作用: 查询LTE网络注册状态
|
||||||
|
<stat>值:
|
||||||
|
- 0: 未注册,未搜索
|
||||||
|
- 1: 已注册,本地网络
|
||||||
|
- 2: 未注册,正在搜索
|
||||||
|
- 3: 注册被拒绝
|
||||||
|
- 4: 未知状态
|
||||||
|
- 5: 已注册,漫游网络
|
||||||
|
成功条件: <stat>=1 或 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## 短信接收(URC自动上报)
|
||||||
|
|
||||||
|
当收到短信时,模组会主动发送:
|
||||||
|
|
||||||
|
```
|
||||||
|
+CMT: ,<length>
|
||||||
|
<PDU_HEX_STRING>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```
|
||||||
|
+CMT: ,26
|
||||||
|
0891683108200005F0040D91683158148764F30000222151114270802C4F60597D
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDU解析
|
||||||
|
|
||||||
|
PDU是十六进制字符串,需要使用PDU解析库(如 `node-pdu`)解析:
|
||||||
|
- **发送者号码**
|
||||||
|
- **时间戳**(YYMMDDHHMMSS格式,带时区)
|
||||||
|
- **短信内容**(支持中文UCS2编码)
|
||||||
|
- **长短信信息**(参考号、当前段号、总段数)
|
||||||
|
|
||||||
|
### 长短信处理
|
||||||
|
|
||||||
|
长短信PDU包含额外头部(UDH):
|
||||||
|
- **参考号** (Reference Number): 用于标识同一条长短信的不同分段
|
||||||
|
- **总段数** (Total Parts): 该长短信分为几段
|
||||||
|
- **当前段号** (Part Number): 当前是第几段(从1开始)
|
||||||
|
|
||||||
|
**合并逻辑**:
|
||||||
|
1. 检测到长短信(总段数 > 1)时,创建缓存槽位
|
||||||
|
2. 使用 `(参考号 + 发送者号码)` 作为唯一标识
|
||||||
|
3. 收到每一段后,存入对应位置
|
||||||
|
4. 收齐所有分段后,按段号顺序拼接内容
|
||||||
|
5. 30秒超时保护:未收齐也强制转发已收到的部分
|
||||||
|
|
||||||
|
## 短信发送(PDU模式)
|
||||||
|
|
||||||
|
### 1. 编码PDU
|
||||||
|
使用PDU编码库将目标号码和短信内容编码为PDU十六进制字符串。
|
||||||
|
|
||||||
|
### 2. 发送AT+CMGS命令
|
||||||
|
```
|
||||||
|
AT+CMGS=<length>
|
||||||
|
响应: > (提示符)
|
||||||
|
作用: 准备发送短信,<length>为PDU数据长度(字节数,不含SMSC)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送PDU数据
|
||||||
|
```
|
||||||
|
<PDU_HEX_STRING><Ctrl+Z>
|
||||||
|
响应: +CMGS: <mr>
|
||||||
|
OK
|
||||||
|
作用: 发送PDU数据,以Ctrl+Z(0x1A)结束
|
||||||
|
<mr>: 消息参考号
|
||||||
|
```
|
||||||
|
|
||||||
|
**完整流程示例**:
|
||||||
|
```
|
||||||
|
-> AT+CMGS=23
|
||||||
|
<- >
|
||||||
|
-> 0011000D91683158148764F30000AA05E4BDA0E5A5BD1A
|
||||||
|
<- +CMGS: 123
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## 其他常用命令
|
||||||
|
|
||||||
|
### 查询信号强度
|
||||||
|
```
|
||||||
|
AT+CSQ
|
||||||
|
响应: +CSQ: <rssi>,<ber>
|
||||||
|
OK
|
||||||
|
<rssi>: 信号强度 (0-31, 99=未知)
|
||||||
|
- 0-9: 弱
|
||||||
|
- 10-14: 一般
|
||||||
|
- 15-19: 好
|
||||||
|
- 20-31: 很好
|
||||||
|
- 99: 未知或不可检测
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询SIM卡状态
|
||||||
|
```
|
||||||
|
AT+CPIN?
|
||||||
|
响应: +CPIN: READY (或其他状态)
|
||||||
|
OK
|
||||||
|
状态:
|
||||||
|
- READY: SIM卡已就绪
|
||||||
|
- SIM PIN: 需要PIN码
|
||||||
|
- SIM PUK: 需要PUK码
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询ICCID
|
||||||
|
```
|
||||||
|
AT+CCID
|
||||||
|
响应: +CCID: <iccid>
|
||||||
|
OK
|
||||||
|
作用: 获取SIM卡ICCID(集成电路卡识别码)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询IMEI
|
||||||
|
```
|
||||||
|
AT+GSN
|
||||||
|
响应: <imei>
|
||||||
|
OK
|
||||||
|
作用: 获取模组IMEI号
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询运营商
|
||||||
|
```
|
||||||
|
AT+COPS?
|
||||||
|
响应: +COPS: <mode>,<format>,<oper>
|
||||||
|
OK
|
||||||
|
示例: +COPS: 0,0,"CHINA MOBILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 临时激活数据连接(用于Ping)
|
||||||
|
```
|
||||||
|
AT+CGACT=1,1
|
||||||
|
响应: OK
|
||||||
|
作用: 激活数据连接(用于网络测试后需关闭)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ping测试
|
||||||
|
```
|
||||||
|
AT+CPING="www.baidu.com",1,4,64,1000,10000,0
|
||||||
|
响应: +CPING: 1,<ip>,<time>,<ttl>
|
||||||
|
+CPING: 2,0,0,0,0
|
||||||
|
OK
|
||||||
|
参数: 域名,回显次数,数据包大小,超时,间隔,最大等待,保留
|
||||||
|
注意: 需要先激活数据连接(AT+CGACT=1,1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除所有短信(清理存储空间)
|
||||||
|
```
|
||||||
|
AT+CMGD=1,4
|
||||||
|
响应: OK
|
||||||
|
作用: 删除所有短信(参数4=删除所有)
|
||||||
|
```
|
||||||
|
|
||||||
|
## URC(主动上报)消息
|
||||||
|
|
||||||
|
### 短信到达
|
||||||
|
```
|
||||||
|
+CMT: ,<length>
|
||||||
|
<PDU_HEX_STRING>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网络注册状态变化
|
||||||
|
```
|
||||||
|
+CEREG: <stat>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 常见ERROR原因
|
||||||
|
- **命令格式错误**: 检查AT命令语法
|
||||||
|
- **参数错误**: 检查参数范围和类型
|
||||||
|
- **模组未就绪**: 确保模组已完成初始化
|
||||||
|
- **网络未注册**: 等待CEREG状态变为1或5
|
||||||
|
- **SIM卡未就绪**: 检查SIM卡是否插好
|
||||||
|
|
||||||
|
### 超时处理
|
||||||
|
- **AT命令超时**: 一般1-2秒,网络相关命令5-10秒
|
||||||
|
- **发送短信超时**: 30秒
|
||||||
|
- **网络注册超时**: 30次重试(约30-60秒)
|
||||||
|
|
||||||
|
### 重试策略
|
||||||
|
- 初始化命令失败:立即重试
|
||||||
|
- 网络注册失败:等待后重试
|
||||||
|
- 发送短信失败:记录日志,不重试
|
||||||
|
|
||||||
|
## Node.js实现要点
|
||||||
|
|
||||||
|
### 串口读取
|
||||||
|
- 使用 `serialport` 库
|
||||||
|
- 设置行解析器 (`@serialport/parser-readline`)
|
||||||
|
- 监听数据事件,逐行处理
|
||||||
|
|
||||||
|
### AT命令发送
|
||||||
|
```javascript
|
||||||
|
async function sendATCommand(cmd, timeout = 2000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
port.write(cmd + '\r\n');
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error('Timeout'));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const handler = (data) => {
|
||||||
|
buffer += data;
|
||||||
|
if (buffer.includes('OK') || buffer.includes('ERROR')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
port.removeListener('data', handler);
|
||||||
|
resolve(buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
port.on('data', handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDU解析
|
||||||
|
使用 `node-pdu` 库:
|
||||||
|
```javascript
|
||||||
|
const PDU = require('node-pdu');
|
||||||
|
|
||||||
|
const parsed = PDU.parse(pduHexString);
|
||||||
|
// parsed.sender: 发送者号码
|
||||||
|
// parsed.text: 短信内容
|
||||||
|
// parsed.time: 时间戳
|
||||||
|
```
|
||||||
|
|
||||||
|
### URC监听
|
||||||
|
```javascript
|
||||||
|
parser.on('data', (line) => {
|
||||||
|
if (line.startsWith('+CMT:')) {
|
||||||
|
// 下一行是PDU数据
|
||||||
|
isWaitingPDU = true;
|
||||||
|
} else if (isWaitingPDU && /^[0-9A-Fa-f]+$/.test(line)) {
|
||||||
|
// 收到PDU数据
|
||||||
|
handleSMS(line);
|
||||||
|
isWaitingPDU = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 状态机设计
|
||||||
|
|
||||||
|
```
|
||||||
|
IDLE状态
|
||||||
|
└─ 收到 +CMT: → WAIT_PDU状态
|
||||||
|
└─ 收到十六进制行 → 解析PDU → 处理短信 → IDLE状态
|
||||||
|
└─ 收到非十六进制行 → IDLE状态
|
||||||
|
```
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Node.js 4G SMS Gateway
|
||||||
|
|
||||||
|
基于Node.js的4G短信网关,通过串口与4G模块通信,接收短信并转发到多种推送渠道。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 4G模块AT指令交互
|
||||||
|
- ✅ 自动接收短信(PDU模式)
|
||||||
|
- ✅ 长短信自动合并
|
||||||
|
- ✅ 多通道推送(邮件/钉钉/飞书/Telegram等)
|
||||||
|
- ✅ 短信发送功能
|
||||||
|
- ✅ 号码黑名单
|
||||||
|
- ✅ 管理员远程命令
|
||||||
|
- ✅ RESTful API接口
|
||||||
|
- ✅ Web管理界面
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 18.x
|
||||||
|
- 4G模块通过USB转串口连接到服务器
|
||||||
|
- 支持的4G模块:ML307R-DC、SIM7600等AT指令兼容模块
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
复制配置模板:
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"serial": {
|
||||||
|
"path": "/dev/ttyUSB0",
|
||||||
|
"baudRate": 115200
|
||||||
|
},
|
||||||
|
"smtp": {
|
||||||
|
"server": "smtp.qq.com",
|
||||||
|
"port": 465,
|
||||||
|
"user": "your@qq.com",
|
||||||
|
"pass": "your_auth_code",
|
||||||
|
"sendTo": "recipient@example.com"
|
||||||
|
},
|
||||||
|
"adminPhone": "13800138000",
|
||||||
|
"numberBlackList": [],
|
||||||
|
"pushChannels": [
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
|
"name": "钉钉通知",
|
||||||
|
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||||
|
"secret": "SECxxx"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"port": 3000,
|
||||||
|
"auth": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产模式
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用PM2(推荐)
|
||||||
|
```bash
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
## API接口
|
||||||
|
|
||||||
|
### 查询状态
|
||||||
|
```bash
|
||||||
|
GET /api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送短信
|
||||||
|
```bash
|
||||||
|
POST /api/sms/send
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"phone": "13800138000",
|
||||||
|
"message": "测试短信"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询信号强度
|
||||||
|
```bash
|
||||||
|
GET /api/modem/signal
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询模组信息
|
||||||
|
```bash
|
||||||
|
GET /api/modem/info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送AT命令
|
||||||
|
```bash
|
||||||
|
POST /api/modem/at
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"command": "AT+CSQ"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取日志
|
||||||
|
```bash
|
||||||
|
GET /api/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
所有API均需要HTTP Basic认证。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── src/
|
||||||
|
│ ├── index.js # 入口文件
|
||||||
|
│ ├── modem.js # 4G模块通信
|
||||||
|
│ ├── sms.js # 短信处理
|
||||||
|
│ ├── push.js # 推送通道
|
||||||
|
│ ├── concat.js # 长短信合并
|
||||||
|
│ ├── api.js # REST API
|
||||||
|
│ └── logger.js # 日志系统
|
||||||
|
├── config.json # 配置文件
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推送通道类型
|
||||||
|
|
||||||
|
| 类型 | type值 | 配置项 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 钉钉机器人 | `dingtalk` | url, secret(可选) |
|
||||||
|
| 飞书机器人 | `feishu` | url, secret(可选) |
|
||||||
|
| Telegram Bot | `telegram` | url(bot token), key1(chat_id) |
|
||||||
|
| PushPlus | `pushplus` | key1(token) |
|
||||||
|
| Server酱 | `serverchan` | key1(sendkey) |
|
||||||
|
| Bark | `bark` | url(bark服务器) |
|
||||||
|
| POST JSON | `post_json` | url |
|
||||||
|
| GET请求 | `get` | url |
|
||||||
|
| 自定义模板 | `custom` | url, customBody |
|
||||||
|
|
||||||
|
## 管理员命令
|
||||||
|
|
||||||
|
通过短信发送命令(仅管理员号码):
|
||||||
|
|
||||||
|
### 远程发送短信
|
||||||
|
```
|
||||||
|
SMS:目标号码:短信内容
|
||||||
|
```
|
||||||
|
示例:`SMS:13800138000:测试消息`
|
||||||
|
|
||||||
|
### 重启系统
|
||||||
|
```
|
||||||
|
RESET
|
||||||
|
```
|
||||||
|
|
||||||
|
## 长短信处理
|
||||||
|
|
||||||
|
- 自动检测长短信(分段数 > 1)
|
||||||
|
- 使用参考号和发送者号码标识同一条长短信
|
||||||
|
- 收齐所有分段后自动合并
|
||||||
|
- 30秒超时保护(未收齐也转发)
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 串口打不开
|
||||||
|
```bash
|
||||||
|
# Linux检查串口设备
|
||||||
|
ls -l /dev/ttyUSB*
|
||||||
|
# 添加用户到dialout组
|
||||||
|
sudo usermod -a -G dialout $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块不响应
|
||||||
|
- 检查串口路径和波特率
|
||||||
|
- 检查USB连接
|
||||||
|
- 检查SIM卡是否插好
|
||||||
|
|
||||||
|
### 收不到短信
|
||||||
|
- 检查AT+CEREG?网络注册状态
|
||||||
|
- 检查AT+CNMI配置是否正确
|
||||||
|
- 检查SIM卡余额和服务状态
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"serial": {
|
||||||
|
"path": "/dev/ttyUSB0",
|
||||||
|
"baudRate": 115200
|
||||||
|
},
|
||||||
|
"smtp": {
|
||||||
|
"server": "smtp.qq.com",
|
||||||
|
"port": 465,
|
||||||
|
"user": "your@qq.com",
|
||||||
|
"pass": "your_auth_code",
|
||||||
|
"sendTo": "recipient@example.com"
|
||||||
|
},
|
||||||
|
"adminPhone": "13800138000",
|
||||||
|
"numberBlackList": [
|
||||||
|
"10086",
|
||||||
|
"10010"
|
||||||
|
],
|
||||||
|
"pushChannels": [
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"type": "dingtalk",
|
||||||
|
"name": "钉钉通知",
|
||||||
|
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||||
|
"secret": "SECxxx",
|
||||||
|
"key1": "",
|
||||||
|
"key2": "",
|
||||||
|
"customBody": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"type": "feishu",
|
||||||
|
"name": "飞书通知",
|
||||||
|
"url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx",
|
||||||
|
"secret": "",
|
||||||
|
"key1": "",
|
||||||
|
"key2": "",
|
||||||
|
"customBody": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"type": "telegram",
|
||||||
|
"name": "Telegram通知",
|
||||||
|
"url": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
|
"secret": "",
|
||||||
|
"key1": "987654321",
|
||||||
|
"key2": "",
|
||||||
|
"customBody": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"type": "pushplus",
|
||||||
|
"name": "PushPlus通知",
|
||||||
|
"url": "",
|
||||||
|
"secret": "",
|
||||||
|
"key1": "your_pushplus_token",
|
||||||
|
"key2": "",
|
||||||
|
"customBody": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"type": "custom",
|
||||||
|
"name": "自定义推送",
|
||||||
|
"url": "https://your-webhook.com/api/notify",
|
||||||
|
"secret": "",
|
||||||
|
"key1": "",
|
||||||
|
"key2": "",
|
||||||
|
"customBody": "{\"from\":\"{sender}\",\"msg\":\"{message}\",\"ts\":\"{timestamp}\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api": {
|
||||||
|
"port": 3000,
|
||||||
|
"auth": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'sms-gateway',
|
||||||
|
script: './src/index.js',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '200M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
LOG_LEVEL: 'info'
|
||||||
|
},
|
||||||
|
error_file: './logs/pm2-error.log',
|
||||||
|
out_file: './logs/pm2-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
merge_logs: true,
|
||||||
|
min_uptime: '10s',
|
||||||
|
max_restarts: 10,
|
||||||
|
restart_delay: 5000
|
||||||
|
}]
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "nodejs-sms-gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "4G SMS Gateway with AT command communication",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"sms",
|
||||||
|
"4g",
|
||||||
|
"at-commands",
|
||||||
|
"gateway",
|
||||||
|
"modem"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"serialport": "^12.0.0",
|
||||||
|
"@serialport/parser-readline": "^12.0.0",
|
||||||
|
"node-pdu": "^0.2.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import basicAuth from 'express-basic-auth';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
class APIServer {
|
||||||
|
constructor(config, modem, smsProcessor) {
|
||||||
|
this.config = config;
|
||||||
|
this.modem = modem;
|
||||||
|
this.smsProcessor = smsProcessor;
|
||||||
|
this.app = express();
|
||||||
|
|
||||||
|
this.setupMiddleware();
|
||||||
|
this.setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMiddleware() {
|
||||||
|
// JSON解析
|
||||||
|
this.app.use(express.json());
|
||||||
|
|
||||||
|
// HTTP Basic认证
|
||||||
|
const users = {};
|
||||||
|
users[this.config.api.auth.username] = this.config.api.auth.password;
|
||||||
|
|
||||||
|
this.app.use(basicAuth({
|
||||||
|
users,
|
||||||
|
challenge: true,
|
||||||
|
realm: 'SMS Gateway'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 请求日志
|
||||||
|
this.app.use((req, res, next) => {
|
||||||
|
logger.info(`${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRoutes() {
|
||||||
|
// 状态查询
|
||||||
|
this.app.get('/api/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const signal = await this.modem.getSignalQuality();
|
||||||
|
const operator = await this.modem.getOperator();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
ready: this.modem.ready,
|
||||||
|
model: this.modem.modelInfo,
|
||||||
|
signal,
|
||||||
|
operator,
|
||||||
|
uptime: process.uptime()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('查询状态失败:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送短信
|
||||||
|
this.app.post('/api/sms/send', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { phone, message } = req.body;
|
||||||
|
|
||||||
|
if (!phone || !message) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少必要参数: phone, message'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await this.modem.sendSMS(phone, message);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success,
|
||||||
|
message: success ? '短信发送成功' : '短信发送失败'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('发送短信失败:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询信号强度
|
||||||
|
this.app.get('/api/modem/signal', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const signal = await this.modem.getSignalQuality();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: signal
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('查询信号失败:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询模组信息
|
||||||
|
this.app.get('/api/modem/info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const iccid = await this.modem.getICCID();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
model: this.modem.modelInfo,
|
||||||
|
iccid,
|
||||||
|
ready: this.modem.ready
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('查询模组信息失败:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送AT命令
|
||||||
|
this.app.post('/api/modem/at', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { command } = req.body;
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少必要参数: command'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.modem.sendATCommand(command, 5000);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
command,
|
||||||
|
response
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('发送AT命令失败:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志
|
||||||
|
this.app.get('/api/logs', (req, res) => {
|
||||||
|
try {
|
||||||
|
const logs = logger.getBuffer().getAll();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: logs
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('获取日志失败:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
this.app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404处理
|
||||||
|
this.app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Not Found'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
this.app.use((err, req, res, next) => {
|
||||||
|
logger.error('API错误:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const port = this.config.api.port;
|
||||||
|
this.app.listen(port, () => {
|
||||||
|
logger.info(`API服务器已启动,监听端口: ${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default APIServer;
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
import EventEmitter from 'events';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
class ConcatManager extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.buffer = [];
|
||||||
|
this.maxSlots = 5;
|
||||||
|
this.maxParts = 10;
|
||||||
|
this.timeoutMs = 30000; // 30秒超时
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找或创建长短信槽位
|
||||||
|
*/
|
||||||
|
findOrCreateSlot(refNumber, sender, totalParts) {
|
||||||
|
// 先查找是否已存在
|
||||||
|
let slot = this.buffer.find(s =>
|
||||||
|
s.inUse && s.refNumber === refNumber && s.sender === sender
|
||||||
|
);
|
||||||
|
|
||||||
|
if (slot) {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找空闲槽位
|
||||||
|
slot = this.buffer.find(s => !s.inUse);
|
||||||
|
if (slot) {
|
||||||
|
this.initSlot(slot, refNumber, sender, totalParts);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有空闲槽位,查找最老的槽位覆盖
|
||||||
|
if (this.buffer.length < this.maxSlots) {
|
||||||
|
slot = {
|
||||||
|
inUse: false,
|
||||||
|
parts: []
|
||||||
|
};
|
||||||
|
this.buffer.push(slot);
|
||||||
|
} else {
|
||||||
|
slot = this.buffer.reduce((oldest, current) =>
|
||||||
|
current.firstPartTime < oldest.firstPartTime ? current : oldest
|
||||||
|
);
|
||||||
|
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initSlot(slot, refNumber, sender, totalParts);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化槽位
|
||||||
|
*/
|
||||||
|
initSlot(slot, refNumber, sender, totalParts) {
|
||||||
|
slot.inUse = true;
|
||||||
|
slot.refNumber = refNumber;
|
||||||
|
slot.sender = sender;
|
||||||
|
slot.totalParts = totalParts;
|
||||||
|
slot.receivedParts = 0;
|
||||||
|
slot.firstPartTime = Date.now();
|
||||||
|
slot.timestamp = null;
|
||||||
|
slot.parts = new Array(totalParts).fill(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加短信分段
|
||||||
|
*/
|
||||||
|
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) {
|
||||||
|
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
|
||||||
|
|
||||||
|
const slot = this.findOrCreateSlot(refNumber, sender, totalParts);
|
||||||
|
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
|
||||||
|
|
||||||
|
if (partIndex >= 0 && partIndex < this.maxParts) {
|
||||||
|
if (!slot.parts[partIndex]) {
|
||||||
|
slot.parts[partIndex] = text;
|
||||||
|
slot.receivedParts++;
|
||||||
|
|
||||||
|
// 保存第一个收到的分段的时间戳
|
||||||
|
if (slot.receivedParts === 1) {
|
||||||
|
slot.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`已缓存分段 ${partNumber},当前已收到 ${slot.receivedParts}/${totalParts}`);
|
||||||
|
|
||||||
|
// 检查是否已收齐
|
||||||
|
if (slot.receivedParts >= totalParts) {
|
||||||
|
logger.info('✅ 长短信已收齐,开始合并转发');
|
||||||
|
this.assembleAndEmit(slot);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ 分段 ${partNumber} 已存在,跳过`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并并发射完整短信
|
||||||
|
*/
|
||||||
|
assembleAndEmit(slot) {
|
||||||
|
let fullText = '';
|
||||||
|
for (let i = 0; i < slot.totalParts; i++) {
|
||||||
|
if (slot.parts[i]) {
|
||||||
|
fullText += slot.parts[i];
|
||||||
|
} else {
|
||||||
|
fullText += `[缺失分段${i + 1}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('complete', {
|
||||||
|
sender: slot.sender,
|
||||||
|
text: fullText,
|
||||||
|
timestamp: slot.timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空槽位
|
||||||
|
this.clearSlot(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空槽位
|
||||||
|
*/
|
||||||
|
clearSlot(slot) {
|
||||||
|
slot.inUse = false;
|
||||||
|
slot.parts = [];
|
||||||
|
slot.receivedParts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查超时
|
||||||
|
*/
|
||||||
|
checkTimeout() {
|
||||||
|
const now = Date.now();
|
||||||
|
this.buffer.forEach(slot => {
|
||||||
|
if (slot.inUse && (now - slot.firstPartTime) >= this.timeoutMs) {
|
||||||
|
logger.warn(`⏰ 长短信超时,强制转发不完整消息`);
|
||||||
|
logger.warn(` 参考号: ${slot.refNumber}, 已收到: ${slot.receivedParts}/${slot.totalParts}`);
|
||||||
|
this.assembleAndEmit(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动超时检查定时器
|
||||||
|
*/
|
||||||
|
startTimeoutChecker() {
|
||||||
|
this.timeoutChecker = setInterval(() => {
|
||||||
|
this.checkTimeout();
|
||||||
|
}, 5000); // 每5秒检查一次
|
||||||
|
logger.info('长短信超时检查器已启动');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止超时检查定时器
|
||||||
|
*/
|
||||||
|
stopTimeoutChecker() {
|
||||||
|
if (this.timeoutChecker) {
|
||||||
|
clearInterval(this.timeoutChecker);
|
||||||
|
this.timeoutChecker = null;
|
||||||
|
logger.info('长短信超时检查器已停止');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConcatManager;
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import logger from './logger.js';
|
||||||
|
import ModemManager from './modem.js';
|
||||||
|
import ConcatManager from './concat.js';
|
||||||
|
import PushManager from './push.js';
|
||||||
|
import SMSProcessor from './sms.js';
|
||||||
|
import APIServer from './api.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
function loadConfig() {
|
||||||
|
const configPath = path.join(__dirname, '../config.json');
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
logger.error('配置文件不存在: config.json');
|
||||||
|
logger.error('请复制 config.example.json 为 config.json 并修改配置');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(configContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建日志目录
|
||||||
|
function ensureLogDirectory() {
|
||||||
|
const logDir = path.join(__dirname, '../logs');
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主函数
|
||||||
|
async function main() {
|
||||||
|
// 确保日志目录存在
|
||||||
|
ensureLogDirectory();
|
||||||
|
|
||||||
|
logger.info('========================================');
|
||||||
|
logger.info(' 4G SMS Gateway 启动中... ');
|
||||||
|
logger.info('========================================');
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
const config = loadConfig();
|
||||||
|
logger.info('配置加载完成');
|
||||||
|
|
||||||
|
// 创建模组管理器
|
||||||
|
const modem = new ModemManager(config.serial);
|
||||||
|
|
||||||
|
// 创建长短信管理器
|
||||||
|
const concatManager = new ConcatManager();
|
||||||
|
|
||||||
|
// 创建推送管理器
|
||||||
|
const pushManager = new PushManager(config);
|
||||||
|
|
||||||
|
// 创建短信处理器
|
||||||
|
const smsProcessor = new SMSProcessor(config, modem, concatManager, pushManager);
|
||||||
|
|
||||||
|
// 监听长短信合并完成事件
|
||||||
|
concatManager.on('complete', async (sms) => {
|
||||||
|
logger.info('收到长短信合并完成事件');
|
||||||
|
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听模组短信事件
|
||||||
|
modem.on('sms', async (pduHex) => {
|
||||||
|
await smsProcessor.processPDU(pduHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听模组错误事件
|
||||||
|
modem.on('error', (err) => {
|
||||||
|
logger.error('模组错误:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听模组关闭事件
|
||||||
|
modem.on('close', () => {
|
||||||
|
logger.warn('模组连接已关闭');
|
||||||
|
process.exit(1); // PM2会自动重启
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听模组就绪事件
|
||||||
|
modem.on('ready', async () => {
|
||||||
|
logger.info('✓ 模组已就绪');
|
||||||
|
|
||||||
|
// 发送启动通知邮件
|
||||||
|
if (config.smtp && config.smtp.server) {
|
||||||
|
const subject = '短信网关已启动';
|
||||||
|
const body = `4G SMS Gateway 已成功启动\n\n模组信息:\n- 厂商: ${modem.modelInfo.manufacturer}\n- 型号: ${modem.modelInfo.model}\n- 版本: ${modem.modelInfo.version}\n\nAPI地址: http://your-server:${config.api.port}`;
|
||||||
|
await pushManager.sendEmail(subject, body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 打开串口并初始化模组
|
||||||
|
await modem.open();
|
||||||
|
|
||||||
|
// 启动长短信超时检查器
|
||||||
|
concatManager.startTimeoutChecker();
|
||||||
|
|
||||||
|
// 启动API服务器
|
||||||
|
const apiServer = new APIServer(config, modem, smsProcessor);
|
||||||
|
apiServer.start();
|
||||||
|
|
||||||
|
logger.info('========================================');
|
||||||
|
logger.info(' 4G SMS Gateway 运行中 ');
|
||||||
|
logger.info('========================================');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('启动失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优雅退出
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.info('\n收到 SIGINT 信号,正在关闭...');
|
||||||
|
concatManager.stopTimeoutChecker();
|
||||||
|
await modem.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
logger.info('\n收到 SIGTERM 信号,正在关闭...');
|
||||||
|
concatManager.stopTimeoutChecker();
|
||||||
|
await modem.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 捕获未处理的异常
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
logger.error('未捕获的异常:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
logger.error('未处理的Promise拒绝:', reason);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动应用
|
||||||
|
main().catch(err => {
|
||||||
|
logger.error('应用启动失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||||
|
if (stack) {
|
||||||
|
return `${timestamp} [${level.toUpperCase()}] ${message}\n${stack}`;
|
||||||
|
}
|
||||||
|
return `${timestamp} [${level.toUpperCase()}] ${message}`;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.printf(({ level, message, timestamp }) => {
|
||||||
|
return `${timestamp} ${level}: ${message}`;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/error.log',
|
||||||
|
level: 'error',
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/combined.log',
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建循环日志缓冲区(用于API查询)
|
||||||
|
class LogBuffer {
|
||||||
|
constructor(maxLines = 120) {
|
||||||
|
this.buffer = [];
|
||||||
|
this.maxLines = maxLines;
|
||||||
|
this.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(line) {
|
||||||
|
if (this.buffer.length < this.maxLines) {
|
||||||
|
this.buffer.push(line);
|
||||||
|
} else {
|
||||||
|
this.buffer[this.index] = line;
|
||||||
|
this.index = (this.index + 1) % this.maxLines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll() {
|
||||||
|
if (this.buffer.length < this.maxLines) {
|
||||||
|
return this.buffer.slice();
|
||||||
|
}
|
||||||
|
// 从index位置开始重新排序,保持时间顺序
|
||||||
|
return [
|
||||||
|
...this.buffer.slice(this.index),
|
||||||
|
...this.buffer.slice(0, this.index)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.buffer = [];
|
||||||
|
this.index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建全局日志缓冲区
|
||||||
|
const logBuffer = new LogBuffer(120);
|
||||||
|
|
||||||
|
// 拦截日志输出,同时写入缓冲区
|
||||||
|
const originalLog = logger.log.bind(logger);
|
||||||
|
logger.log = function(level, message, ...args) {
|
||||||
|
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${message}`);
|
||||||
|
return originalLog(level, message, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出日志缓冲区
|
||||||
|
logger.getBuffer = () => logBuffer;
|
||||||
|
|
||||||
|
export default logger;
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
import { SerialPort } from 'serialport';
|
||||||
|
import { ReadlineParser } from '@serialport/parser-readline';
|
||||||
|
import EventEmitter from 'events';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
class ModemManager extends EventEmitter {
|
||||||
|
constructor(config) {
|
||||||
|
super();
|
||||||
|
this.config = config;
|
||||||
|
this.port = null;
|
||||||
|
this.parser = null;
|
||||||
|
this.ready = false;
|
||||||
|
this.modelInfo = {
|
||||||
|
manufacturer: '未知',
|
||||||
|
model: '未知',
|
||||||
|
version: '未知'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开串口并初始化模组
|
||||||
|
*/
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
// 打开串口
|
||||||
|
this.port = new SerialPort({
|
||||||
|
path: this.config.path,
|
||||||
|
baudRate: this.config.baudRate,
|
||||||
|
dataBits: 8,
|
||||||
|
stopBits: 1,
|
||||||
|
parity: 'none'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置行解析器
|
||||||
|
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r\n' }));
|
||||||
|
|
||||||
|
// 监听串口事件
|
||||||
|
this.port.on('error', (err) => {
|
||||||
|
logger.error('串口错误:', err);
|
||||||
|
this.emit('error', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.port.on('close', () => {
|
||||||
|
logger.warn('串口已关闭');
|
||||||
|
this.ready = false;
|
||||||
|
this.emit('close');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听URC(主动上报)消息
|
||||||
|
this.setupURCListener();
|
||||||
|
|
||||||
|
// 等待串口打开
|
||||||
|
await new Promise((resolve) => this.port.once('open', resolve));
|
||||||
|
logger.info(`串口已打开: ${this.config.path}`);
|
||||||
|
|
||||||
|
// 初始化模组
|
||||||
|
await this.init();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('打开串口失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置URC监听器
|
||||||
|
*/
|
||||||
|
setupURCListener() {
|
||||||
|
let waitingPDU = false;
|
||||||
|
|
||||||
|
this.parser.on('data', (line) => {
|
||||||
|
line = line.trim();
|
||||||
|
|
||||||
|
// 调试输出
|
||||||
|
if (line.length > 0 && !line.startsWith('AT')) {
|
||||||
|
logger.debug(`<< ${line}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测短信URC
|
||||||
|
if (line.startsWith('+CMT:')) {
|
||||||
|
logger.info('检测到短信URC,等待PDU数据...');
|
||||||
|
waitingPDU = true;
|
||||||
|
} else if (waitingPDU && this.isHexString(line)) {
|
||||||
|
logger.info('收到PDU数据');
|
||||||
|
waitingPDU = false;
|
||||||
|
this.emit('sms', line); // 发射短信事件
|
||||||
|
} else if (waitingPDU && line.length === 0) {
|
||||||
|
// 跳过空行
|
||||||
|
} else if (waitingPDU) {
|
||||||
|
// 收到非PDU数据,返回等待状态
|
||||||
|
waitingPDU = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测网络注册状态变化
|
||||||
|
if (line.startsWith('+CEREG:')) {
|
||||||
|
this.emit('cereg', line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为十六进制字符串
|
||||||
|
*/
|
||||||
|
isHexString(str) {
|
||||||
|
return /^[0-9A-Fa-f]+$/.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送AT命令并等待响应
|
||||||
|
*/
|
||||||
|
async sendATCommand(cmd, timeout = 2000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.parser.removeListener('data', handler);
|
||||||
|
reject(new Error(`AT命令超时: ${cmd}`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const handler = (line) => {
|
||||||
|
buffer += line + '\n';
|
||||||
|
if (line.includes('OK') || line.includes('ERROR')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.parser.removeListener('data', handler);
|
||||||
|
resolve(buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.parser.on('data', handler);
|
||||||
|
|
||||||
|
logger.debug(`>> ${cmd}`);
|
||||||
|
this.port.write(cmd + '\r\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送AT命令并等待OK
|
||||||
|
*/
|
||||||
|
async sendATandWaitOK(cmd, timeout = 2000) {
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand(cmd, timeout);
|
||||||
|
return resp.includes('OK');
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化模组
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
logger.info('开始初始化4G模组...');
|
||||||
|
|
||||||
|
// 1. AT握手
|
||||||
|
let retries = 0;
|
||||||
|
while (!(await this.sendATandWaitOK('AT', 1000)) && retries < 10) {
|
||||||
|
logger.warn('AT未响应,重试...');
|
||||||
|
retries++;
|
||||||
|
await this.sleep(1000);
|
||||||
|
}
|
||||||
|
if (retries >= 10) {
|
||||||
|
throw new Error('模组AT握手失败');
|
||||||
|
}
|
||||||
|
logger.info('✓ 模组AT响应正常');
|
||||||
|
|
||||||
|
// 2. 查询模组信息
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand('ATI', 2000);
|
||||||
|
const lines = resp.split('\n').map(l => l.trim()).filter(l => l && l !== 'ATI' && l !== 'OK');
|
||||||
|
if (lines.length >= 3) {
|
||||||
|
this.modelInfo.manufacturer = lines[0];
|
||||||
|
this.modelInfo.model = lines[1];
|
||||||
|
this.modelInfo.version = lines[2];
|
||||||
|
logger.info(`模组信息: ${this.modelInfo.manufacturer} ${this.modelInfo.model} ${this.modelInfo.version}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('查询模组信息失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 禁用数据连接(ML307Y跳过)
|
||||||
|
if (this.modelInfo.model !== 'ML307Y') {
|
||||||
|
retries = 0;
|
||||||
|
while (!(await this.sendATandWaitOK('AT+CGACT=0,1', 5000)) && retries < 3) {
|
||||||
|
logger.warn('设置CGACT失败,重试...');
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
if (retries < 3) {
|
||||||
|
logger.info('✓ 已禁用数据连接(AT+CGACT=0,1),防止流量消耗');
|
||||||
|
} else {
|
||||||
|
logger.warn('设置CGACT失败,可能会消耗流量');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('ML307Y型号跳过CGACT配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 配置短信URC上报
|
||||||
|
retries = 0;
|
||||||
|
while (!(await this.sendATandWaitOK('AT+CNMI=2,2,0,0,0', 1000)) && retries < 3) {
|
||||||
|
logger.warn('设置CNMI失败,重试...');
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
if (retries >= 3) {
|
||||||
|
throw new Error('CNMI配置失败');
|
||||||
|
}
|
||||||
|
logger.info('✓ CNMI参数设置完成');
|
||||||
|
|
||||||
|
// 5. 设置PDU模式
|
||||||
|
retries = 0;
|
||||||
|
while (!(await this.sendATandWaitOK('AT+CMGF=0', 1000)) && retries < 3) {
|
||||||
|
logger.warn('设置PDU模式失败,重试...');
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
if (retries >= 3) {
|
||||||
|
throw new Error('PDU模式设置失败');
|
||||||
|
}
|
||||||
|
logger.info('✓ PDU模式设置完成');
|
||||||
|
|
||||||
|
// 6. 等待网络注册
|
||||||
|
retries = 0;
|
||||||
|
while (!(await this.waitCEREG()) && retries < 30) {
|
||||||
|
logger.info('等待网络注册...');
|
||||||
|
retries++;
|
||||||
|
await this.sleep(2000);
|
||||||
|
}
|
||||||
|
if (retries < 30) {
|
||||||
|
logger.info('✓ 网络已注册');
|
||||||
|
this.ready = true;
|
||||||
|
} else {
|
||||||
|
logger.error('⚠️ 网络注册超时(无SIM卡或信号差)');
|
||||||
|
this.ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('模组初始化完成');
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测网络注册状态
|
||||||
|
*/
|
||||||
|
async waitCEREG() {
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand('AT+CEREG?', 2000);
|
||||||
|
// +CEREG: 0,1 或 +CEREG: 0,5 表示已注册
|
||||||
|
if (resp.includes('+CEREG:')) {
|
||||||
|
if (resp.includes(',1') || resp.includes(',5')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信(PDU模式)
|
||||||
|
*/
|
||||||
|
async sendSMS(phoneNumber, message) {
|
||||||
|
logger.info(`准备发送短信到: ${phoneNumber}`);
|
||||||
|
logger.info(`短信内容: ${message}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 注意: node-pdu的API与C++版本不同,这里需要根据实际库调整
|
||||||
|
const PDU = (await import('node-pdu')).default;
|
||||||
|
|
||||||
|
// 编码PDU
|
||||||
|
const submit = PDU.Submit(phoneNumber, message);
|
||||||
|
const parts = submit.getParts();
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
throw new Error('PDU编码失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送第一段(简化处理,实际可能需要处理多段)
|
||||||
|
const pduData = parts[0];
|
||||||
|
const pduLength = Math.floor((pduData.length - parts[0].smscLength * 2) / 2);
|
||||||
|
|
||||||
|
logger.debug(`PDU数据: ${pduData}`);
|
||||||
|
logger.debug(`PDU长度: ${pduLength}`);
|
||||||
|
|
||||||
|
// 发送AT+CMGS命令
|
||||||
|
const cmgsCmd = `AT+CMGS=${pduLength}`;
|
||||||
|
this.port.write(cmgsCmd + '\r\n');
|
||||||
|
|
||||||
|
// 等待 > 提示符
|
||||||
|
const gotPrompt = await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(false), 5000);
|
||||||
|
const handler = (line) => {
|
||||||
|
if (line.includes('>')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.parser.removeListener('data', handler);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.parser.on('data', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gotPrompt) {
|
||||||
|
throw new Error('未收到>提示符');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('收到>提示符,发送PDU数据...');
|
||||||
|
|
||||||
|
// 发送PDU数据 + Ctrl+Z
|
||||||
|
this.port.write(pduData + String.fromCharCode(0x1A));
|
||||||
|
|
||||||
|
// 等待响应
|
||||||
|
const success = await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(false), 30000);
|
||||||
|
let buffer = '';
|
||||||
|
const handler = (line) => {
|
||||||
|
buffer += line;
|
||||||
|
if (line.includes('OK')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.parser.removeListener('data', handler);
|
||||||
|
resolve(true);
|
||||||
|
} else if (line.includes('ERROR')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.parser.removeListener('data', handler);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.parser.on('data', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
logger.info('✓ 短信发送成功');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.error('✗ 短信发送失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('发送短信异常:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询信号强度
|
||||||
|
*/
|
||||||
|
async getSignalQuality() {
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand('AT+CSQ', 2000);
|
||||||
|
const match = resp.match(/\+CSQ:\s*(\d+),(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const rssi = parseInt(match[1]);
|
||||||
|
const ber = parseInt(match[2]);
|
||||||
|
let quality = '未知';
|
||||||
|
if (rssi === 99) {
|
||||||
|
quality = '未知或不可检测';
|
||||||
|
} else if (rssi >= 20) {
|
||||||
|
quality = '很好';
|
||||||
|
} else if (rssi >= 15) {
|
||||||
|
quality = '好';
|
||||||
|
} else if (rssi >= 10) {
|
||||||
|
quality = '一般';
|
||||||
|
} else {
|
||||||
|
quality = '弱';
|
||||||
|
}
|
||||||
|
return { rssi, ber, quality };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('查询信号强度失败:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询运营商
|
||||||
|
*/
|
||||||
|
async getOperator() {
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand('AT+COPS?', 2000);
|
||||||
|
const match = resp.match(/\+COPS:\s*\d+,\d+,"([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return '未知';
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('查询运营商失败:', err);
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询ICCID
|
||||||
|
*/
|
||||||
|
async getICCID() {
|
||||||
|
try {
|
||||||
|
const resp = await this.sendATCommand('AT+CCID', 2000);
|
||||||
|
const match = resp.match(/\+CCID:\s*(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('查询ICCID失败:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭串口
|
||||||
|
*/
|
||||||
|
async close() {
|
||||||
|
if (this.port && this.port.isOpen) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
this.port.close(resolve);
|
||||||
|
});
|
||||||
|
logger.info('串口已关闭');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助函数:延时
|
||||||
|
*/
|
||||||
|
sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModemManager;
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import axios from 'axios';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
class PushManager {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.smtpTransporter = null;
|
||||||
|
|
||||||
|
// 初始化SMTP
|
||||||
|
if (config.smtp && config.smtp.server) {
|
||||||
|
this.smtpTransporter = nodemailer.createTransport({
|
||||||
|
host: config.smtp.server,
|
||||||
|
port: config.smtp.port,
|
||||||
|
secure: config.smtp.port === 465,
|
||||||
|
auth: {
|
||||||
|
user: config.smtp.user,
|
||||||
|
pass: config.smtp.pass
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮件通知
|
||||||
|
*/
|
||||||
|
async sendEmail(subject, body) {
|
||||||
|
if (!this.smtpTransporter) {
|
||||||
|
logger.warn('邮件配置不完整,跳过发送');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.smtpTransporter.sendMail({
|
||||||
|
from: `"SMS Notify" <${this.config.smtp.user}>`,
|
||||||
|
to: this.config.smtp.sendTo,
|
||||||
|
subject: subject,
|
||||||
|
text: body
|
||||||
|
});
|
||||||
|
logger.info('✓ 邮件发送成功');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('邮件发送失败:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送到所有启用的推送通道
|
||||||
|
*/
|
||||||
|
async pushToAll(sender, message, timestamp) {
|
||||||
|
const channels = this.config.pushChannels || [];
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (channel.enabled) {
|
||||||
|
promises.push(this.pushToChannel(channel, sender, message, timestamp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送到单个推送通道
|
||||||
|
*/
|
||||||
|
async pushToChannel(channel, sender, message, timestamp) {
|
||||||
|
logger.info(`推送到通道: ${channel.name} (${channel.type})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (channel.type) {
|
||||||
|
case 'post_json':
|
||||||
|
return await this.pushPostJSON(channel, sender, message, timestamp);
|
||||||
|
case 'bark':
|
||||||
|
return await this.pushBark(channel, sender, message);
|
||||||
|
case 'get':
|
||||||
|
return await this.pushGET(channel, sender, message, timestamp);
|
||||||
|
case 'dingtalk':
|
||||||
|
return await this.pushDingTalk(channel, sender, message);
|
||||||
|
case 'pushplus':
|
||||||
|
return await this.pushPushPlus(channel, sender, message);
|
||||||
|
case 'serverchan':
|
||||||
|
return await this.pushServerChan(channel, sender, message);
|
||||||
|
case 'custom':
|
||||||
|
return await this.pushCustom(channel, sender, message, timestamp);
|
||||||
|
case 'feishu':
|
||||||
|
return await this.pushFeishu(channel, sender, message);
|
||||||
|
case 'telegram':
|
||||||
|
return await this.pushTelegram(channel, sender, message);
|
||||||
|
default:
|
||||||
|
logger.warn(`未知的推送类型: ${channel.type}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`推送到 ${channel.name} 失败:`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST JSON 推送
|
||||||
|
*/
|
||||||
|
async pushPostJSON(channel, sender, message, timestamp) {
|
||||||
|
const response = await axios.post(channel.url, {
|
||||||
|
sender,
|
||||||
|
message,
|
||||||
|
timestamp
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ POST JSON推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bark 推送
|
||||||
|
*/
|
||||||
|
async pushBark(channel, sender, message) {
|
||||||
|
const response = await axios.post(channel.url, {
|
||||||
|
title: sender,
|
||||||
|
body: message
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ Bark推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 推送
|
||||||
|
*/
|
||||||
|
async pushGET(channel, sender, message, timestamp) {
|
||||||
|
const url = new URL(channel.url);
|
||||||
|
url.searchParams.set('sender', sender);
|
||||||
|
url.searchParams.set('message', message);
|
||||||
|
url.searchParams.set('timestamp', timestamp);
|
||||||
|
|
||||||
|
const response = await axios.get(url.toString(), {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ GET推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉机器人推送
|
||||||
|
*/
|
||||||
|
async pushDingTalk(channel, sender, message) {
|
||||||
|
let url = channel.url;
|
||||||
|
|
||||||
|
// 如果配置了secret,进行签名
|
||||||
|
if (channel.secret) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sign = this.dingtalkSign(channel.secret, timestamp);
|
||||||
|
url += `×tamp=${timestamp}&sign=${sign}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(url, {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: `来自: ${sender}\n\n${message}`
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ 钉钉推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钉钉签名
|
||||||
|
*/
|
||||||
|
dingtalkSign(secret, timestamp) {
|
||||||
|
const stringToSign = `${timestamp}\n${secret}`;
|
||||||
|
const hmac = crypto.createHmac('sha256', secret);
|
||||||
|
hmac.update(stringToSign);
|
||||||
|
const sign = hmac.digest('base64');
|
||||||
|
return encodeURIComponent(sign);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PushPlus 推送
|
||||||
|
*/
|
||||||
|
async pushPushPlus(channel, sender, message) {
|
||||||
|
const response = await axios.post('http://www.pushplus.plus/send', {
|
||||||
|
token: channel.key1,
|
||||||
|
title: `来自 ${sender}`,
|
||||||
|
content: message,
|
||||||
|
template: 'html'
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ PushPlus推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server酱 推送
|
||||||
|
*/
|
||||||
|
async pushServerChan(channel, sender, message) {
|
||||||
|
const url = `https://sctapi.ftqq.com/${channel.key1}.send`;
|
||||||
|
const response = await axios.post(url, {
|
||||||
|
title: `来自 ${sender}`,
|
||||||
|
desp: message
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ Server酱推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义模板推送
|
||||||
|
*/
|
||||||
|
async pushCustom(channel, sender, message, timestamp) {
|
||||||
|
let body = channel.customBody || '{}';
|
||||||
|
body = body.replace('{sender}', sender);
|
||||||
|
body = body.replace('{message}', message);
|
||||||
|
body = body.replace('{timestamp}', timestamp);
|
||||||
|
|
||||||
|
const response = await axios.post(channel.url, JSON.parse(body), {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ 自定义推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞书机器人推送
|
||||||
|
*/
|
||||||
|
async pushFeishu(channel, sender, message) {
|
||||||
|
let url = channel.url;
|
||||||
|
|
||||||
|
// 如果配置了secret,进行签名
|
||||||
|
if (channel.secret) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const sign = this.feishuSign(channel.secret, timestamp);
|
||||||
|
url += `×tamp=${timestamp}&sign=${sign}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(url, {
|
||||||
|
msg_type: 'text',
|
||||||
|
content: {
|
||||||
|
text: `来自: ${sender}\n\n${message}`
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ 飞书推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞书签名
|
||||||
|
*/
|
||||||
|
feishuSign(secret, timestamp) {
|
||||||
|
const stringToSign = `${timestamp}\n${secret}`;
|
||||||
|
const hmac = crypto.createHmac('sha256', secret);
|
||||||
|
hmac.update(stringToSign);
|
||||||
|
return hmac.digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram Bot 推送
|
||||||
|
*/
|
||||||
|
async pushTelegram(channel, sender, message) {
|
||||||
|
const botToken = channel.url; // url字段存储bot token
|
||||||
|
const chatId = channel.key1;
|
||||||
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||||
|
|
||||||
|
const response = await axios.post(url, {
|
||||||
|
chat_id: chatId,
|
||||||
|
text: `来自: ${sender}\n\n${message}`
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
logger.info(`✓ Telegram推送成功: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PushManager;
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
import PDU from 'node-pdu';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
class SMSProcessor {
|
||||||
|
constructor(config, modem, concatManager, pushManager) {
|
||||||
|
this.config = config;
|
||||||
|
this.modem = modem;
|
||||||
|
this.concatManager = concatManager;
|
||||||
|
this.pushManager = pushManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理接收到的PDU短信
|
||||||
|
*/
|
||||||
|
async processPDU(pduHex) {
|
||||||
|
try {
|
||||||
|
logger.info('开始解析PDU数据...');
|
||||||
|
|
||||||
|
// 解析PDU
|
||||||
|
const parsed = PDU.parse(pduHex);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
logger.error('PDU解析失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('✓ PDU解析成功');
|
||||||
|
logger.info(`发送者: ${parsed.sender}`);
|
||||||
|
logger.info(`时间戳: ${parsed.time}`);
|
||||||
|
logger.info(`内容: ${parsed.text}`);
|
||||||
|
|
||||||
|
// 检查是否为长短信
|
||||||
|
if (parsed.udh && parsed.udh.iei === 0x00) {
|
||||||
|
// 长短信头部
|
||||||
|
const refNumber = parsed.udh.data.refNumber;
|
||||||
|
const totalParts = parsed.udh.data.parts;
|
||||||
|
const partNumber = parsed.udh.data.current;
|
||||||
|
|
||||||
|
logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`);
|
||||||
|
|
||||||
|
// 添加到长短信缓存
|
||||||
|
this.concatManager.addPart(
|
||||||
|
refNumber,
|
||||||
|
parsed.sender,
|
||||||
|
partNumber,
|
||||||
|
totalParts,
|
||||||
|
parsed.text,
|
||||||
|
parsed.time
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 普通短信,直接处理
|
||||||
|
await this.processSmsContent(parsed.sender, parsed.text, parsed.time);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('处理PDU失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理短信内容(管理员命令检查和转发)
|
||||||
|
*/
|
||||||
|
async processSmsContent(sender, text, timestamp) {
|
||||||
|
logger.info('=== 处理短信内容 ===');
|
||||||
|
logger.info(`发送者: ${sender}`);
|
||||||
|
logger.info(`时间戳: ${timestamp}`);
|
||||||
|
logger.info(`内容: ${text}`);
|
||||||
|
logger.info('====================');
|
||||||
|
|
||||||
|
// 检查是否在黑名单中
|
||||||
|
if (this.isInBlacklist(sender)) {
|
||||||
|
logger.info('发送者在黑名单中,忽略该短信');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为管理员命令
|
||||||
|
if (this.isAdmin(sender)) {
|
||||||
|
logger.info('收到管理员短信,检查命令...');
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
|
if (trimmedText.startsWith('SMS:') || trimmedText === 'RESET') {
|
||||||
|
await this.processAdminCommand(sender, trimmedText);
|
||||||
|
return; // 命令已处理,不再发送普通通知
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送到所有通道
|
||||||
|
await this.pushManager.pushToAll(sender, text, timestamp);
|
||||||
|
|
||||||
|
// 发送邮件通知
|
||||||
|
const subject = `短信${sender},${text.substring(0, 20)}`;
|
||||||
|
const body = `来自:${sender},时间:${timestamp},内容:${text}`;
|
||||||
|
await this.pushManager.sendEmail(subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否在黑名单中
|
||||||
|
*/
|
||||||
|
isInBlacklist(sender) {
|
||||||
|
const blacklist = this.config.numberBlackList || [];
|
||||||
|
|
||||||
|
// 检查完整号码
|
||||||
|
if (blacklist.includes(sender)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查去除+86后的号码
|
||||||
|
if (sender.startsWith('+86')) {
|
||||||
|
const stripped = sender.substring(3);
|
||||||
|
if (blacklist.includes(stripped)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为管理员
|
||||||
|
*/
|
||||||
|
isAdmin(sender) {
|
||||||
|
if (!this.config.adminPhone) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderStr = sender;
|
||||||
|
let adminStr = this.config.adminPhone;
|
||||||
|
|
||||||
|
// 去除+86前缀
|
||||||
|
if (senderStr.startsWith('+86')) {
|
||||||
|
senderStr = senderStr.substring(3);
|
||||||
|
}
|
||||||
|
if (adminStr.startsWith('+86')) {
|
||||||
|
adminStr = adminStr.substring(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return senderStr === adminStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理管理员命令
|
||||||
|
*/
|
||||||
|
async processAdminCommand(sender, command) {
|
||||||
|
logger.info(`处理管理员命令: ${command}`);
|
||||||
|
|
||||||
|
// 处理 SMS:号码:内容 命令
|
||||||
|
if (command.startsWith('SMS:')) {
|
||||||
|
const parts = command.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const targetPhone = parts[1].trim();
|
||||||
|
const smsContent = parts.slice(2).join(':').trim(); // 支持内容中包含冒号
|
||||||
|
|
||||||
|
logger.info(`目标号码: ${targetPhone}`);
|
||||||
|
logger.info(`短信内容: ${smsContent}`);
|
||||||
|
|
||||||
|
const success = await this.modem.sendSMS(targetPhone, smsContent);
|
||||||
|
|
||||||
|
// 发送邮件通知结果
|
||||||
|
const subject = success ? '短信发送成功' : '短信发送失败';
|
||||||
|
const body = `管理员命令执行结果:\n命令: ${command}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`;
|
||||||
|
await this.pushManager.sendEmail(subject, body);
|
||||||
|
} else {
|
||||||
|
logger.error('SMS命令格式错误');
|
||||||
|
await this.pushManager.sendEmail(
|
||||||
|
'命令执行失败',
|
||||||
|
'SMS命令格式错误,正确格式: SMS:号码:内容'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 处理 RESET 命令
|
||||||
|
else if (command === 'RESET') {
|
||||||
|
logger.info('执行RESET命令');
|
||||||
|
|
||||||
|
// 先发送邮件通知
|
||||||
|
await this.pushManager.sendEmail(
|
||||||
|
'重启命令已执行',
|
||||||
|
'收到RESET命令,即将重启系统...'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 等待邮件发送完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// 重启进程
|
||||||
|
logger.info('正在重启系统...');
|
||||||
|
process.exit(0); // PM2会自动重启
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.warn(`未知命令: ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SMSProcessor;
|
||||||
Reference in New Issue
Block a user