Compare commits
2
Commits
main
..
a37344253b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37344253b | ||
|
|
3ab7a19874 |
@@ -1,18 +0,0 @@
|
||||
.git
|
||||
.env
|
||||
.npm
|
||||
.npm-cache
|
||||
.vscode
|
||||
.idea
|
||||
node_modules
|
||||
logs
|
||||
data
|
||||
config.json
|
||||
*.log
|
||||
*.tgz
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
AGENTS.md
|
||||
DESIGN.md
|
||||
ML307A_*.md
|
||||
@@ -1,6 +1,5 @@
|
||||
node_modules/
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
config.json
|
||||
.DS_Store
|
||||
@@ -11,6 +10,4 @@ config.json
|
||||
*.swo
|
||||
*~
|
||||
.npm
|
||||
.npm-cache/
|
||||
*.tgz
|
||||
.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状态
|
||||
```
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
FROM alpine:3.22 AS deps
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NPM_CONFIG_AUDIT=false \
|
||||
NPM_CONFIG_FUND=false \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache nodejs npm python3 make g++
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM alpine:3.22 AS runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache nodejs eudev
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY config.example.json ./
|
||||
|
||||
RUN mkdir -p logs data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
# 跨平台支持说明
|
||||
|
||||
本项目支持在 **Windows** 和 **Linux** 上运行。
|
||||
|
||||
## 串口配置
|
||||
|
||||
### Windows
|
||||
|
||||
Windows 使用 `COMx` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可以直接写数字,程序会自动添加 `COM` 前缀:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "3",
|
||||
"baudRate": 115200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**查看可用串口**:
|
||||
```powershell
|
||||
# 方法1: 设备管理器
|
||||
devmgmt.msc
|
||||
|
||||
# 方法2: PowerShell
|
||||
Get-WmiObject Win32_SerialPort | Select-Object Name, DeviceID
|
||||
|
||||
# 方法3: 使用 mode 命令
|
||||
mode
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 使用 `/dev/ttyUSBx` 或 `/dev/ttyACMx` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "/dev/ttyUSB0",
|
||||
"baudRate": 115200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**查看可用串口**:
|
||||
```bash
|
||||
# 查看所有USB串口设备
|
||||
ls -l /dev/ttyUSB*
|
||||
ls -l /dev/ttyACM*
|
||||
|
||||
# 查看详细信息
|
||||
dmesg | grep tty
|
||||
|
||||
# 使用 lsusb 查看USB设备
|
||||
lsusb
|
||||
```
|
||||
|
||||
**权限配置**(Linux必需):
|
||||
```bash
|
||||
# 将当前用户添加到 dialout 组
|
||||
sudo usermod -a -G dialout $USER
|
||||
|
||||
# 注销并重新登录后生效,或临时授权
|
||||
sudo chmod 666 /dev/ttyUSB0
|
||||
```
|
||||
|
||||
## 平台差异
|
||||
|
||||
### 文件路径
|
||||
- Windows: 使用反斜杠 `\` 或正斜杠 `/` (Node.js会自动处理)
|
||||
- Linux: 使用正斜杠 `/`
|
||||
|
||||
本项目使用 Node.js 的 `path` 模块,会自动处理平台差异。
|
||||
|
||||
### PM2 守护进程
|
||||
|
||||
Windows 和 Linux 都支持 PM2,但启动脚本略有不同:
|
||||
|
||||
**Windows**:
|
||||
```powershell
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
# 按提示执行生成的命令(需要管理员权限)
|
||||
```
|
||||
|
||||
**Linux**:
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
# 按提示执行生成的命令(使用 sudo)
|
||||
```
|
||||
|
||||
### 日志目录
|
||||
|
||||
两个平台都会在项目根目录下创建 `logs/` 目录:
|
||||
- Windows: `.\logs\`
|
||||
- Linux: `./logs/`
|
||||
|
||||
## 测试串口连接
|
||||
|
||||
可以使用以下工具测试串口是否正常:
|
||||
|
||||
### Windows
|
||||
- **PuTTY**: https://www.putty.org/
|
||||
- **Tera Term**: https://ttssh2.osdn.jp/
|
||||
- **串口调试助手**
|
||||
|
||||
### Linux
|
||||
- **minicom**: `sudo apt install minicom && sudo minicom -D /dev/ttyUSB0 -b 115200`
|
||||
- **screen**: `sudo screen /dev/ttyUSB0 115200`
|
||||
- **picocom**: `sudo apt install picocom && sudo picocom /dev/ttyUSB0 -b 115200`
|
||||
|
||||
测试方法:
|
||||
1. 打开串口工具,连接到对应COM口
|
||||
2. 发送 `AT` 命令
|
||||
3. 应该收到 `OK` 响应
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Windows
|
||||
|
||||
**Q: 提示串口被占用**
|
||||
```
|
||||
Error: Port is already open
|
||||
```
|
||||
A: 检查是否有其他程序(如Arduino IDE、串口调试助手)占用了该端口
|
||||
|
||||
**Q: 找不到COM口**
|
||||
A:
|
||||
1. 检查设备管理器中4G模块是否正确识别
|
||||
2. 确认驱动是否已安装
|
||||
3. 尝试拔插USB重新识别
|
||||
|
||||
**Q: 权限不足**
|
||||
A: Windows通常不需要特殊权限,如果遇到问题,尝试以管理员身份运行
|
||||
|
||||
### Linux
|
||||
|
||||
**Q: 权限不足**
|
||||
```
|
||||
Error: Opening COM1: Permission denied
|
||||
```
|
||||
A:
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
# 注销重新登录
|
||||
```
|
||||
|
||||
**Q: 串口不存在**
|
||||
```
|
||||
Error: No such file or directory, cannot open /dev/ttyUSB0
|
||||
```
|
||||
A:
|
||||
```bash
|
||||
# 检查设备是否识别
|
||||
dmesg | tail -20
|
||||
lsusb
|
||||
# 可能是 ttyACM0 而不是 ttyUSB0
|
||||
ls /dev/tty*
|
||||
```
|
||||
|
||||
**Q: ModemManager占用串口**
|
||||
A: 某些Linux发行版的ModemManager会自动占用4G模块
|
||||
```bash
|
||||
# 临时停用
|
||||
sudo systemctl stop ModemManager
|
||||
|
||||
# 永久禁用(不推荐,可能影响系统网络功能)
|
||||
sudo systemctl disable ModemManager
|
||||
```
|
||||
|
||||
## 开发环境
|
||||
|
||||
### Node.js 版本
|
||||
- 推荐: Node.js 18.x LTS 或更高
|
||||
- 最低: Node.js 16.x
|
||||
|
||||
### 依赖库
|
||||
所有依赖都是跨平台的:
|
||||
- `serialport` - 底层使用 C++ 绑定,支持 Windows/Linux/macOS
|
||||
- `express` - Web框架
|
||||
- `nodemailer` - 邮件发送
|
||||
- `axios` - HTTP客户端
|
||||
- `winston` - 日志库
|
||||
|
||||
## 性能建议
|
||||
|
||||
### Windows
|
||||
- 关闭Windows Defender实时扫描对 `node_modules/` 的监控
|
||||
- 使用SSD存储项目文件
|
||||
|
||||
### Linux
|
||||
- 使用 `noatime` 挂载选项减少磁盘IO
|
||||
- 调整串口缓冲区大小(如有需要):
|
||||
```bash
|
||||
setserial /dev/ttyUSB0 low_latency
|
||||
```
|
||||
|
||||
## 部署建议
|
||||
|
||||
### Windows Server
|
||||
- 使用 PM2 作为守护进程
|
||||
- 配置 Windows 防火墙允许 API 端口(默认3000)
|
||||
- 设置 PM2 开机自启动
|
||||
|
||||
### Linux Server
|
||||
- 使用 PM2 + systemd
|
||||
- 配置 UFW/iptables 防火墙
|
||||
- 考虑使用 Nginx 反向代理
|
||||
@@ -1,383 +1,212 @@
|
||||
# SMS Forwarding
|
||||
# Node.js 4G SMS Gateway
|
||||
|
||||
一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。
|
||||
基于Node.js的4G短信网关,通过串口与4G模块通信,接收短信并转发到多种推送渠道。
|
||||
|
||||
项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。
|
||||
## 功能特性
|
||||
|
||||
## 功能
|
||||
|
||||
- 通过 AT 指令和 4G 模组通信
|
||||
- 自动探测可用 AT 串口
|
||||
- PDU 模式接收短信
|
||||
- 长短信自动合并
|
||||
- 收件箱持久化到本地文件
|
||||
- 多推送通道转发短信
|
||||
- Web 管理台
|
||||
- Token 鉴权的 HTTP API
|
||||
- Web/API 发送短信
|
||||
- 双 SIM 状态识别和发送前切卡
|
||||
- 移动数据开关和启动断网保护
|
||||
- AT 命令调试入口
|
||||
- ✅ 4G模块AT指令交互
|
||||
- ✅ 自动接收短信(PDU模式)
|
||||
- ✅ 长短信自动合并
|
||||
- ✅ 多通道推送(邮件/钉钉/飞书/Telegram等)
|
||||
- ✅ 短信发送功能
|
||||
- ✅ 号码黑名单
|
||||
- ✅ 管理员远程命令
|
||||
- ✅ RESTful API接口
|
||||
- ✅ Web管理界面
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 18 或更高版本
|
||||
- 可通过串口访问的 4G 模组
|
||||
- 已插入可收发短信的 SIM 卡
|
||||
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
|
||||
- Node.js >= 18.x
|
||||
- 4G模块通过USB转串口连接到服务器
|
||||
- 支持的4G模块:ML307R-DC、SIM7600等AT指令兼容模块
|
||||
|
||||
已主要围绕 ML307A 调试。其他 AT 指令兼容模组可以运行,但双卡、移动数据、URC 卡槽识别等能力取决于模组返回的指令结果。
|
||||
|
||||
## 快速启动
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
编辑 `config.json`,至少确认:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "/dev/ttyUSB0",
|
||||
"baudRate": 115200,
|
||||
"autoDetect": true
|
||||
},
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
访问管理台:
|
||||
|
||||
```text
|
||||
http://localhost:3000/admin
|
||||
```
|
||||
|
||||
第一次访问会要求输入 `api.webToken`。也可以临时通过 URL 传入:
|
||||
|
||||
```text
|
||||
http://localhost:3000/admin?token=change-this-token
|
||||
```
|
||||
|
||||
## Docker 运行
|
||||
|
||||
先准备配置:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
然后启动:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
docker compose logs -f sms-gateway
|
||||
```
|
||||
|
||||
`docker-compose.yml` 会挂载:
|
||||
|
||||
- `./config.json:/app/config.json`
|
||||
- `./logs:/app/logs`
|
||||
- `./data:/app/data`
|
||||
- `/dev:/dev`
|
||||
- `/sys:/sys:ro`
|
||||
|
||||
默认端口是 `3000`。如需修改宿主机端口:
|
||||
|
||||
```bash
|
||||
SMS_GATEWAY_PORT=8080 docker compose up -d
|
||||
```
|
||||
|
||||
## PM2 运行
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp config.example.json config.json
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`config.json` 必须存在,否则程序会退出。完整模板见 `config.example.json`。
|
||||
复制配置模板:
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
### 必填项
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| `serial.path` | 串口路径。Linux 常见为 `/dev/ttyUSB0`,Windows 可写 `COM3`。`serial.autoDetect=true` 时它是优先探测项;`false` 时它就是实际打开的串口。 |
|
||||
| `serial.baudRate` | 串口波特率,通常是 `115200`。 |
|
||||
| `api.port` | Web 管理台和 HTTP API 监听端口。 |
|
||||
| `api.webToken` | 唯一访问凭据。项目没有默认账号密码,也没有 `admin/admin123` Basic Auth。 |
|
||||
|
||||
### 可选项
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `serial.autoDetect` | `true` | 是否自动探测可响应 `AT` 的串口。 |
|
||||
| `serial.probeTimeout` | `1200` | 单个串口探测超时,单位毫秒。 |
|
||||
| `mobileData.cid` | `1` | 移动数据 PDP CID。有效范围 `1-15`,不要使用 `8`。 |
|
||||
| `simCards` | `[]` | 只影响 Web 显示的 SIM 手机号或标签,不影响实际收发。`slot=0` 是 SIM1,`slot=1` 是 SIM2。 |
|
||||
| `smtp` | 未启用 | 配置后会额外发送邮件通知。填写 `smtp.server` 后需要同时正确填写 `port`、`user`、`pass`、`sendTo`。 |
|
||||
| `inbox.maxReceivedMessages` | `200` | 收件箱最多保留条数,代码上限是 `5000`。 |
|
||||
| `inbox.receivedMessagesFile` | `data/received-messages.json` | 收件箱持久化文件。相对路径按项目根目录解析。 |
|
||||
| `inbox.partitionBySim` | `true` | 查询收件箱时是否按 SIM 信息隔离。 |
|
||||
| `inbox.allowAllSimMessages` | `false` | 是否允许显式查询全部 SIM 历史。 |
|
||||
| `inbox.dualSimDefaultScope` | `all` | 双卡场景下默认收件箱范围,可选 `all` 或 `current`。 |
|
||||
| `pushChannels` | `[]` | 推送通道列表。通道 `enabled=true` 时必须填写该类型要求的目标字段。 |
|
||||
|
||||
`api.webToken` 支持以下传入方式:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `X-Web-Token: <token>`
|
||||
- `?token=<token>`
|
||||
- Web 管理台登录页
|
||||
|
||||
## 推送通道
|
||||
|
||||
支持的 `pushChannels[].type`:
|
||||
|
||||
| 类型 | 必填字段 |
|
||||
| --- | --- |
|
||||
| `dingtalk` | `url`,`secret` 可选 |
|
||||
| `feishu` | `url`,`secret` 可选 |
|
||||
| `telegram` | `url` 填 Bot Token,`key1` 填 Chat ID |
|
||||
| `pushplus` | `key1` 填 PushPlus Token |
|
||||
| `serverchan` | `key1` 填 Server 酱 SendKey |
|
||||
| `bark` | `url` |
|
||||
| `post_json` | `url` |
|
||||
| `get` | `url` |
|
||||
| `custom` | `url`,`customBody` 可选 |
|
||||
|
||||
`customBody` 必须是合法 JSON 字符串,支持以下占位符:
|
||||
|
||||
- `{sender}`
|
||||
- `{message}`
|
||||
- `{timestamp}`
|
||||
|
||||
`enabled=false` 的通道可以保留空字段;`enabled=true` 时会校验目标字段。
|
||||
|
||||
## SIM 行为
|
||||
|
||||
启动初始化时程序会探测 SIM 状态。运行期间的普通状态刷新不会为了探测卡槽而持续切换 SIM。
|
||||
|
||||
发送短信时使用当前绑定的发送 SIM。Web 管理台的切卡入口放在“发送短信”区域,切换会同时设置模组的 `SWITCHSIM` 和 `BINDSIM`,然后恢复短信直出配置。
|
||||
|
||||
短信接收使用 `CNMI` 直出模式。收到短信时,如果模组 URC 携带卡槽信息,会记录为 SIM1 或 SIM2;如果 URC 没有卡槽信息,收件箱会显示为未知 SIM。
|
||||
|
||||
如果只想给管理台显示手机号,可配置:
|
||||
编辑 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"simCards": [
|
||||
{ "slot": 0, "phoneNumber": "+8613800138000" },
|
||||
{ "slot": 1, "phoneNumber": "+447700900000" }
|
||||
]
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200,
|
||||
"_comment": "Windows使用COM1/COM2/COM3等; Linux使用/dev/ttyUSB0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP API
|
||||
|
||||
所有 `/api/*` 接口都需要 token。下面示例使用 `X-Web-Token`:
|
||||
## 运行
|
||||
|
||||
### 开发模式
|
||||
```bash
|
||||
TOKEN=change-this-token
|
||||
BASE=http://localhost:3000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 状态和日志
|
||||
|
||||
### 生产模式
|
||||
```bash
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/status"
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/info"
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/signal"
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/logs"
|
||||
npm start
|
||||
```
|
||||
|
||||
### 收件箱
|
||||
|
||||
### 使用PM2(推荐)
|
||||
```bash
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50"
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&scope=current"
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=0"
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=1"
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 查询状态
|
||||
```bash
|
||||
GET /api/status
|
||||
```
|
||||
|
||||
### 发送短信
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/sms/send" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"phone":"13800138000","message":"测试短信"}'
|
||||
POST /api/sms/send
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"phone": "13800138000",
|
||||
"message": "测试短信"
|
||||
}
|
||||
```
|
||||
|
||||
### SIM 状态和切换
|
||||
|
||||
### 查询信号强度
|
||||
```bash
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/sim"
|
||||
|
||||
curl -X POST "$BASE/api/modem/sim/switch" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"slot":1}'
|
||||
GET /api/modem/signal
|
||||
```
|
||||
|
||||
`slot=0` 表示 SIM1,`slot=1` 表示 SIM2。
|
||||
|
||||
### 移动数据
|
||||
|
||||
### 查询模组信息
|
||||
```bash
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/mobile-data"
|
||||
|
||||
curl -X POST "$BASE/api/modem/mobile-data" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"enabled":false}'
|
||||
|
||||
curl -X POST "$BASE/api/modem/mobile-data/consume" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"target":"8.8.8.8"}'
|
||||
GET /api/modem/info
|
||||
```
|
||||
|
||||
### AT 命令
|
||||
|
||||
### 发送AT命令
|
||||
```bash
|
||||
curl -X POST "$BASE/api/modem/at" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"command":"AT+CSQ"}'
|
||||
POST /api/modem/at
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "AT+CSQ"
|
||||
}
|
||||
```
|
||||
|
||||
### 推送通道管理
|
||||
|
||||
### 获取日志
|
||||
```bash
|
||||
curl -H "X-Web-Token: $TOKEN" "$BASE/api/push/channels"
|
||||
|
||||
curl -X PUT "$BASE/api/push/channels" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"channels":[]}'
|
||||
|
||||
curl -X POST "$BASE/api/push/test" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Web-Token: $TOKEN" \
|
||||
-d '{"enabled":true,"type":"post_json","name":"测试","url":"https://example.com/webhook"}'
|
||||
GET /api/logs
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
所有API均需要HTTP Basic认证。
|
||||
|
||||
```text
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── src/
|
||||
│ ├── index.js # 程序入口
|
||||
│ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制
|
||||
│ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录
|
||||
│ ├── concat.js # 长短信分段合并
|
||||
│ ├── index.js # 入口文件
|
||||
│ ├── modem.js # 4G模块通信
|
||||
│ ├── sms.js # 短信处理
|
||||
│ ├── push.js # 推送通道
|
||||
│ ├── api.js # Web 管理台和 HTTP API
|
||||
│ └── logger.js # 日志
|
||||
├── public/ # Web 管理台静态文件
|
||||
├── data/ # 收件箱持久化数据
|
||||
├── logs/ # 运行日志
|
||||
├── config.example.json
|
||||
├── config.json # 本地配置,不应提交
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── ecosystem.config.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秒超时保护(未收齐也转发)
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 串口打不开
|
||||
|
||||
Linux 下先确认设备存在:
|
||||
|
||||
```bash
|
||||
# Linux检查串口设备
|
||||
ls -l /dev/ttyUSB*
|
||||
# 添加用户到dialout组
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
|
||||
如果是权限问题,把运行用户加入串口权限组,重新登录后生效:
|
||||
|
||||
```bash
|
||||
sudo usermod -a -G dialout "$USER"
|
||||
```
|
||||
|
||||
Docker 场景下确认 `docker-compose.yml` 已挂载 `/dev`,并保留了 `device_cgroup_rules`。
|
||||
|
||||
### 自动探测不到模组
|
||||
|
||||
- 确认 USB 连接和供电。
|
||||
- 确认 `serial.baudRate` 正确。
|
||||
- 临时关闭自动探测,把 `serial.autoDetect` 设为 `false`,并把 `serial.path` 写成真实 AT 串口。
|
||||
- 查看日志中每个候选串口的探测结果。
|
||||
|
||||
### ML307A 首次上电被识别成网卡
|
||||
|
||||
ML307A 首次上电或恢复默认 USB 模式时,系统可能先把它识别为 RNDIS 网卡,而不是 USB 串口。此时 `lsusb` 能看到设备,但 `/dev/ttyUSB*` 里找不到可用 AT 串口。
|
||||
|
||||
可以用下面命令确认 USB 设备和内核识别结果:
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
dmesg | grep -E 'ML307A|2ecc|3012|rndis|ttyUSB'
|
||||
```
|
||||
|
||||
典型日志会包含类似内容:
|
||||
|
||||
```text
|
||||
usb 1-3: New USB device found, idVendor=2ecc, idProduct=3012
|
||||
usb 1-3: Product: ML307A
|
||||
usb 1-3: Manufacturer: CMIOT
|
||||
rndis_host 1-3:1.0: rndis media connect
|
||||
rndis_host 1-3:1.0 enxac0c29a39b6d: renamed from eth0
|
||||
```
|
||||
|
||||
如果出现上述 RNDIS 网卡日志,并且系统没有生成 USB 串口,可以把该 USB ID 添加到 `option` 串口驱动:
|
||||
|
||||
```bash
|
||||
sudo modprobe option
|
||||
echo 2ecc 3012 | sudo tee /sys/bus/usb-serial/drivers/option1/new_id
|
||||
```
|
||||
|
||||
执行后重新查看 `/dev/ttyUSB*`,再把 `config.json` 里的 `serial.path` 指向实际 AT 串口。不同内核、发行版或模组固件的枚举行为可能不完全一致;如果后续仍会被识别为网卡,可按系统发行版方式配置 udev/modprobe 规则。
|
||||
### 模块不响应
|
||||
- 检查串口路径和波特率
|
||||
- 检查USB连接
|
||||
- 检查SIM卡是否插好
|
||||
|
||||
### 收不到短信
|
||||
- 检查AT+CEREG?网络注册状态
|
||||
- 检查AT+CNMI配置是否正确
|
||||
- 检查SIM卡余额和服务状态
|
||||
|
||||
- 确认 SIM 卡已插好且可以注册网络。
|
||||
- 查看 `/api/status` 里的 `ready`、`operator`、`signal`。
|
||||
- 确认日志里有 `PDU模式设置完成` 和 `CNMI参数设置完成`。
|
||||
- 部分模组不会在 URC 里带卡槽信息,这只影响收件箱显示的 SIM 归属,不代表短信没有收到。
|
||||
|
||||
### 发送短信失败
|
||||
|
||||
- 先在管理台确认当前发送 SIM。
|
||||
- 确认目标号码格式正确。
|
||||
- 查看日志里的 AT 响应。
|
||||
- 如果刚切换 SIM,等待模组重新注册网络后再发。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 不要提交 `config.json`、token、手机号、Webhook URL、SMTP 密码和日志。
|
||||
- 管理台和 API 只使用 `api.webToken` 鉴权。
|
||||
- `POST /api/modem/at` 可以执行任意 AT 命令,只应暴露在可信网络内。
|
||||
|
||||
## License
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
+10
-30
@@ -2,42 +2,20 @@
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200,
|
||||
"autoDetect": true,
|
||||
"probeTimeout": 1200,
|
||||
"_comment": "必填。path是手动串口或自动探测优先项;autoDetect=false时必须写真实串口。Windows使用COM1/COM2/COM3等,也可以直接写数字如3;Linux使用/dev/ttyUSB0。baudRate通常为115200,probeTimeout为AT探测超时毫秒。"
|
||||
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
|
||||
},
|
||||
"mobileData": {
|
||||
"cid": 1,
|
||||
"_comment": "可选,默认cid=1。有效范围1-15,cid8为IMS专用,请勿使用。ML307每次启动都会强制断开MIPCALL应用层拨号,并保护性尝试CGACT;管理台开关只影响当前运行期。"
|
||||
},
|
||||
"_simCards_comment": "可选。用于给SIM1/SIM2显示手机号;slot=0是SIM1,slot=1是SIM2。也兼容旧字段名simSlots、sims、sim。",
|
||||
"simCards": [
|
||||
{
|
||||
"slot": 0,
|
||||
"phoneNumber": "+8613800138000"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"phoneNumber": "+447700900000"
|
||||
}
|
||||
],
|
||||
"smtp": {
|
||||
"_comment": "可选。填写server后会启用邮件通知;启用时server、port、user、pass、sendTo都需要正确填写。不需要邮件可删除smtp或清空server。",
|
||||
"server": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"user": "your@qq.com",
|
||||
"pass": "your_auth_code",
|
||||
"sendTo": "recipient@example.com"
|
||||
},
|
||||
"inbox": {
|
||||
"maxReceivedMessages": 200,
|
||||
"receivedMessagesFile": "data/received-messages.json",
|
||||
"partitionBySim": true,
|
||||
"allowAllSimMessages": false,
|
||||
"dualSimDefaultScope": "all",
|
||||
"_comment": "全部可选。maxReceivedMessages默认200且最大5000;receivedMessagesFile默认data/received-messages.json;partitionBySim默认true;allowAllSimMessages默认false;dualSimDefaultScope可为all或current。默认显示全部短信;直出URC未携带卡槽时,新短信不区分SIM1/SIM2收件归属。"
|
||||
},
|
||||
"_pushChannels_comment": "可选。enabled=false时目标字段可以为空;enabled=true时按type要求填写目标:post_json/bark/get/dingtalk/custom/feishu需要url,telegram需要url(bot token)+key1(chat id),pushplus需要key1(token),serverchan需要key1(sendkey)。customBody如果填写必须是合法JSON字符串。",
|
||||
"adminPhone": "13800138000",
|
||||
"numberBlackList": [
|
||||
"10086",
|
||||
"10010"
|
||||
],
|
||||
"pushChannels": [
|
||||
{
|
||||
"enabled": true,
|
||||
@@ -92,7 +70,9 @@
|
||||
],
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token",
|
||||
"_comment": "必填。port是API和管理台监听端口;webToken是唯一鉴权凭据,可通过Authorization: Bearer、X-Web-Token、token查询参数或管理台登录页提交。"
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Before starting:
|
||||
# cp config.example.json config.json
|
||||
# Keep serial.autoDetect enabled in config.json so the app can probe visible serial ports.
|
||||
services:
|
||||
sms-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: sms-forwarding:latest
|
||||
container_name: sms-forwarding
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "${SMS_GATEWAY_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
- /dev:/dev
|
||||
- /sys:/sys:ro
|
||||
device_cgroup_rules:
|
||||
- "c 188:* rwm"
|
||||
- "c 166:* rwm"
|
||||
Generated
-2047
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -21,8 +21,9 @@
|
||||
"dependencies": {
|
||||
"serialport": "^12.0.0",
|
||||
"@serialport/parser-readline": "^12.0.0",
|
||||
"node-pdu": "^2.1.2",
|
||||
"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",
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMS Gateway · 管理台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
<div class="top-nav__inner">
|
||||
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
|
||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
<span class="brand__name">SMS Gateway</span>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="管理导航">
|
||||
<a href="#dashboard">状态</a>
|
||||
<a href="#sim">SIM</a>
|
||||
<a href="#inbox">收件箱</a>
|
||||
<a href="#send">短信</a>
|
||||
<a href="#console">AT 控制台</a>
|
||||
<a href="#logs">日志</a>
|
||||
</nav>
|
||||
<div class="top-nav__actions">
|
||||
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ HERO ============ -->
|
||||
<section class="hero-band" id="status">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-copy">
|
||||
<span class="eyebrow eyebrow--coral">
|
||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
||||
<span id="readyBadge">连接检测中</span>
|
||||
</span>
|
||||
<h1>短信<em>网关</em><br>管理台</h1>
|
||||
<p class="lede">集中查看 ML307A 模组状态、收发短信、执行 AT 命令、检查运行日志。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="text-link" href="#send">发送短信</a>
|
||||
<span class="hero-divider" aria-hidden="true">·</span>
|
||||
<a class="text-link" href="#console">查看 AT 控制台</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="runtime-card" aria-label="运行摘要">
|
||||
<header class="runtime-card__header">
|
||||
<span class="runtime-card__label">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
runtime
|
||||
</span>
|
||||
<span class="runtime-card__status">
|
||||
<span class="status-dot" id="panelStatusDot" aria-hidden="true"></span>
|
||||
<strong id="panelStatus">loading</strong>
|
||||
</span>
|
||||
</header>
|
||||
<div class="runtime-card__hero">
|
||||
<span class="runtime-card__caption">uptime</span>
|
||||
<span class="runtime-card__value" id="uptime">--</span>
|
||||
</div>
|
||||
<dl class="runtime-card__meta">
|
||||
<div>
|
||||
<dt>operator</dt>
|
||||
<dd id="operator">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>signal</dt>
|
||||
<dd id="signalQuality">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ STATUS BENTO ============ -->
|
||||
<section class="section-band section-band--soft" id="dashboard">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
status board
|
||||
</span>
|
||||
<h2>当前<em>状态</em></h2>
|
||||
</div>
|
||||
<button class="button button--primary" id="refreshButton" type="button">刷新状态</button>
|
||||
</div>
|
||||
|
||||
<div class="bento-grid">
|
||||
<article class="bento-hero">
|
||||
<span class="eyebrow eyebrow--coral">
|
||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
||||
connection
|
||||
</span>
|
||||
<h3 id="connectionState">--</h3>
|
||||
<p id="connectionHint">等待状态刷新。</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-control">
|
||||
<span class="eyebrow">mobile data</span>
|
||||
<h3 id="mobileDataState">--</h3>
|
||||
<p id="mobileDataHint">启动时会自动断开应用层拨号。</p>
|
||||
<div class="bento-control__actions">
|
||||
<button class="button button--secondary button--compact" id="mobileDataToggle" type="button"
|
||||
data-action="disable">强制关闭流量</button>
|
||||
<button class="button button--primary button--compact is-hidden" id="mobileDataConsume"
|
||||
type="button">消耗一点流量</button>
|
||||
<span class="form-status mobile-data-traffic-hint" id="mobileDataTrafficHint">开启流量后才会显示消耗流量按钮。</span>
|
||||
<p class="form-status mobile-data-status" id="mobileDataStatus" role="status"></p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="sim-manager" id="sim">
|
||||
<div class="sim-manager__header">
|
||||
<div>
|
||||
<span class="eyebrow">sim manager</span>
|
||||
<h3 id="simMode">--</h3>
|
||||
</div>
|
||||
<p class="form-status sim-manager__status" id="simStatus" role="status"></p>
|
||||
</div>
|
||||
|
||||
<div class="sim-slot-grid" aria-label="SIM卡槽">
|
||||
<article class="sim-slot" id="simSlot0">
|
||||
<div class="sim-slot__topline">
|
||||
<span class="sim-slot__name">SIM1</span>
|
||||
<span class="sim-slot__state" id="simSlot0State">--</span>
|
||||
</div>
|
||||
<span class="sim-slot__phone" id="simSlot0Phone">--</span>
|
||||
</article>
|
||||
|
||||
<article class="sim-slot" id="simSlot1">
|
||||
<div class="sim-slot__topline">
|
||||
<span class="sim-slot__name">SIM2</span>
|
||||
<span class="sim-slot__state" id="simSlot1State">--</span>
|
||||
</div>
|
||||
<span class="sim-slot__phone" id="simSlot1Phone">--</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-strip">
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">signal</span>
|
||||
<span class="stat-tile__value" id="signalValue">--</span>
|
||||
<span class="stat-tile__hint" id="signalHint">RSSI 与误码率将在刷新后显示。</span>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">module</span>
|
||||
<span class="stat-tile__value" id="moduleModel">--</span>
|
||||
<span class="stat-tile__hint" id="moduleVersion">--</span>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">current sim</span>
|
||||
<span class="stat-tile__value" id="currentSim">--</span>
|
||||
<span class="stat-tile__hint">发送短信会使用当前绑定的 SIM。</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ INBOX ============ -->
|
||||
<section class="section-band" id="inbox">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
inbox
|
||||
</span>
|
||||
<h2>收到的<em>短信</em></h2>
|
||||
</div>
|
||||
<div class="inbox-actions">
|
||||
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inbox-panel">
|
||||
<div class="inbox-meta">
|
||||
<span id="messageCount">最近 0 条</span>
|
||||
<span class="inbox-meta__hint" id="messageScope">新短信会自动出现在这里</span>
|
||||
</div>
|
||||
<div class="message-list" id="messageList" aria-live="polite">
|
||||
<p class="empty-state empty-state--light">暂无收到的短信。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WORKBENCH ============ -->
|
||||
<section class="section-band section-band--soft">
|
||||
<div class="container workbench-grid">
|
||||
<section class="form-card" id="send">
|
||||
<div class="section-heading section-heading--compact">
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
compose
|
||||
</span>
|
||||
<h2>发送<em>短信</em></h2>
|
||||
<p>PDU 模式发送,提交后等待模组返回结果。</p>
|
||||
</div>
|
||||
<p class="send-sim-banner" aria-live="polite">
|
||||
<span>当前发送SIM</span>
|
||||
<strong id="sendSimLabel">--</strong>
|
||||
</p>
|
||||
<div class="send-sim-picker" aria-label="选择发送SIM">
|
||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">SIM1</button>
|
||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">SIM2</button>
|
||||
</div>
|
||||
<form id="smsForm" class="stack-form">
|
||||
<label class="field">
|
||||
<span class="field__label">目标号码</span>
|
||||
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">短信内容</span>
|
||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容"
|
||||
required></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="button button--primary" type="submit">发送短信</button>
|
||||
<p class="form-status" id="smsStatus" role="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="console-card" id="console">
|
||||
<header class="panel-header">
|
||||
<span class="panel-header__label">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
at console
|
||||
</span>
|
||||
<span class="panel-header__status">
|
||||
<span class="status-dot status-dot--amber" aria-hidden="true"></span>
|
||||
<strong id="consoleState">idle</strong>
|
||||
</span>
|
||||
</header>
|
||||
<form id="atForm" class="at-form">
|
||||
<input class="text-input text-input--mono" name="command" value="AT+CSQ" autocomplete="off"
|
||||
spellcheck="false" required>
|
||||
<button class="button button--primary" type="submit">执行</button>
|
||||
</form>
|
||||
<pre class="console-output" id="atOutput">› 等待命令...</pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<section class="section-band" id="logs">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
logs
|
||||
</span>
|
||||
<h2>运行<em>日志</em></h2>
|
||||
</div>
|
||||
<button class="button button--secondary" id="logsButton" type="button">刷新日志</button>
|
||||
</div>
|
||||
<div class="log-window" id="logWindow" aria-live="polite">
|
||||
<p class="empty-state">等待日志加载。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ============ PUSH MODAL ============ -->
|
||||
<div class="modal" id="pushModal" aria-hidden="true">
|
||||
<div class="modal__backdrop" data-close-push-modal></div>
|
||||
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
|
||||
<header class="modal__header">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
push channels
|
||||
</span>
|
||||
<h2 id="pushModalTitle">推送渠道<em>设置</em></h2>
|
||||
</div>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="push-settings">
|
||||
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
||||
<div class="panel-header panel-header--light">
|
||||
<span class="panel-header__label">channels</span>
|
||||
<div class="channel-add">
|
||||
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
<option value="telegram">Telegram Bot</option>
|
||||
<option value="pushplus">PushPlus</option>
|
||||
<option value="serverchan">Server酱</option>
|
||||
<option value="post_json">POST JSON</option>
|
||||
<option value="bark">Bark</option>
|
||||
<option value="get">GET</option>
|
||||
<option value="custom">自定义 POST</option>
|
||||
</select>
|
||||
<button class="button button--secondary button--compact" id="pushAddButton" type="button">新增</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-list" id="pushChannelList"></div>
|
||||
</aside>
|
||||
|
||||
<form class="channel-form" id="pushChannelForm">
|
||||
<fieldset class="form-fieldset" id="pushFormFields" disabled>
|
||||
<div class="channel-form__grid">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" name="enabled">
|
||||
<span>启用通道</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">通道名称</span>
|
||||
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">推送类型</span>
|
||||
<select class="text-input select-input" name="type">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
<option value="telegram">Telegram Bot</option>
|
||||
<option value="pushplus">PushPlus</option>
|
||||
<option value="serverchan">Server酱</option>
|
||||
<option value="post_json">POST JSON</option>
|
||||
<option value="bark">Bark</option>
|
||||
<option value="get">GET</option>
|
||||
<option value="custom">自定义 POST</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field field-row field-row--wide" data-push-field="url">
|
||||
<span class="field__label" id="pushUrlLabel">Webhook URL</span>
|
||||
<input class="text-input" type="text" name="url" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field field-row field-row--wide" data-push-field="secret">
|
||||
<span class="field__label" id="pushSecretLabel">加签密钥</span>
|
||||
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label class="field field-row" data-push-field="key1">
|
||||
<span class="field__label" id="pushKey1Label">Key1</span>
|
||||
<input class="text-input" type="text" name="key1" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field field-row" data-push-field="key2">
|
||||
<span class="field__label" id="pushKey2Label">Key2</span>
|
||||
<input class="text-input" type="text" name="key2" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field field-row field-row--wide" data-push-field="customBody">
|
||||
<span class="field__label">自定义请求体 JSON</span>
|
||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7"
|
||||
spellcheck="false"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions channel-form__actions">
|
||||
<button class="button button--primary" id="pushSaveButton" type="submit">保存设置</button>
|
||||
<button class="button button--secondary" id="pushTestButton" type="button">发送测试</button>
|
||||
<button class="button button--danger" id="pushDeleteButton" type="button">删除通道</button>
|
||||
<p class="form-status" id="pushStatus" role="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<span class="brand brand--footer">
|
||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
<span>SMS Gateway</span>
|
||||
</span>
|
||||
<span class="footer__meta">© <span id="footerYear">2026</span> · Token protected · Built on Node.js +
|
||||
ML307A</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/assets/admin.js" type="module"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+15
-552
@@ -1,46 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import basicAuth from 'express-basic-auth';
|
||||
import logger from './logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PUSH_CHANNEL_TYPES = new Set([
|
||||
'post_json',
|
||||
'bark',
|
||||
'get',
|
||||
'dingtalk',
|
||||
'pushplus',
|
||||
'serverchan',
|
||||
'custom',
|
||||
'feishu',
|
||||
'telegram'
|
||||
]);
|
||||
|
||||
const PUSH_CHANNEL_TYPE_LABELS = {
|
||||
post_json: 'POST JSON',
|
||||
bark: 'Bark',
|
||||
get: 'GET',
|
||||
dingtalk: '钉钉',
|
||||
pushplus: 'PushPlus',
|
||||
serverchan: 'Server酱',
|
||||
custom: '自定义',
|
||||
feishu: '飞书',
|
||||
telegram: 'Telegram'
|
||||
};
|
||||
|
||||
class APIServer {
|
||||
constructor(config, modem, smsProcessor, pushManager) {
|
||||
constructor(config, modem, smsProcessor) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.smsProcessor = smsProcessor;
|
||||
this.pushManager = pushManager;
|
||||
this.app = express();
|
||||
this.publicDir = path.join(__dirname, '../public');
|
||||
this.configPath = path.join(__dirname, '../config.json');
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
@@ -50,299 +17,29 @@ class APIServer {
|
||||
// JSON解析
|
||||
this.app.use(express.json());
|
||||
|
||||
// Web Token认证
|
||||
this.app.use((req, res, next) => {
|
||||
this.authenticateRequest(req, res, next);
|
||||
});
|
||||
// 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();
|
||||
});
|
||||
|
||||
this.app.use('/assets', express.static(path.join(this.publicDir, 'assets')));
|
||||
}
|
||||
|
||||
authenticateRequest(req, res, next) {
|
||||
if (this.hasValidWebToken(req)) {
|
||||
this.persistWebToken(req, res);
|
||||
|
||||
if (this.shouldCleanTokenFromUrl(req)) {
|
||||
return res.redirect(302, this.getCleanUrl(req));
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.isWebRoute(req)) {
|
||||
const webToken = this.getConfiguredWebToken();
|
||||
if (webToken) {
|
||||
return this.sendTokenGate(res);
|
||||
}
|
||||
|
||||
return res.status(401).send('未配置 webToken');
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: this.getConfiguredWebToken() ? '需要有效token' : '未配置webToken'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).send(this.getConfiguredWebToken() ? '需要有效token' : '未配置 webToken');
|
||||
}
|
||||
|
||||
getConfiguredWebToken() {
|
||||
return String(this.config.api.webToken || '').trim();
|
||||
}
|
||||
|
||||
hasValidWebToken(req) {
|
||||
const expected = this.getConfiguredWebToken();
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = this.getRequestToken(req);
|
||||
return this.safeEqual(token, expected);
|
||||
}
|
||||
|
||||
getRequestToken(req) {
|
||||
const bearer = req.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||
return req.query.token || req.get('x-web-token') || bearer || this.getCookie(req, 'sms_gateway_token') || '';
|
||||
}
|
||||
|
||||
getCookie(req, name) {
|
||||
const cookieHeader = req.get('cookie') || '';
|
||||
const cookies = cookieHeader.split(';').map(item => item.trim());
|
||||
const prefix = `${name}=`;
|
||||
const cookie = cookies.find(item => item.startsWith(prefix));
|
||||
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : '';
|
||||
}
|
||||
|
||||
persistWebToken(req, res) {
|
||||
if (!req.query.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.cookie('sms_gateway_token', req.query.token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
shouldCleanTokenFromUrl(req) {
|
||||
return this.isWebRoute(req) && Boolean(req.query.token);
|
||||
}
|
||||
|
||||
getCleanUrl(req) {
|
||||
const url = new URL(req.originalUrl, 'http://localhost');
|
||||
url.searchParams.delete('token');
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
safeEqual(actual, expected) {
|
||||
const actualBuffer = Buffer.from(String(actual));
|
||||
const expectedBuffer = Buffer.from(String(expected));
|
||||
|
||||
if (actualBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
isWebRoute(req) {
|
||||
return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/');
|
||||
}
|
||||
|
||||
sendTokenGate(res) {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.status(401).type('html').send(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMS Gateway Token</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --canvas:#faf9f5; --ink:#141413; --body:#3d3d3a; --muted:#6c6a64; --card:#efe9de; --primary:#cc785c; --primary-active:#a9583e; --hairline:#e6dfd8; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--canvas); color: var(--body); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; }
|
||||
main { width: min(480px, calc(100% - 32px)); border-radius: 12px; background: var(--card); padding: 32px; }
|
||||
.mark { color: var(--ink); font-size: 20px; }
|
||||
h1 { margin: 18px 0 10px; color: var(--ink); font-family: "Cormorant Garamond", "EB Garamond", Georgia, serif; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: 0; }
|
||||
p { margin: 0 0 24px; color: var(--muted); }
|
||||
label { display: grid; gap: 8px; color: var(--ink); font-size: 14px; font-weight: 500; }
|
||||
input { width: 100%; min-height: 40px; border: 1px solid var(--hairline); border-radius: 8px; background: var(--canvas); color: var(--ink); padding: 10px 14px; font: inherit; }
|
||||
input:focus { border-color: var(--primary); outline: 3px solid rgba(204, 120, 92, .15); }
|
||||
button { margin-top: 18px; width: 100%; min-height: 40px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; padding: 12px 20px; font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
button:active { background: var(--primary-active); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span class="mark">✣</span>
|
||||
<h1>输入访问 token</h1>
|
||||
<p>管理台需要有效 token 才能继续访问。</p>
|
||||
<form method="get" action="/admin">
|
||||
<label>
|
||||
Token
|
||||
<input name="token" type="password" autocomplete="current-password" autofocus required>
|
||||
</label>
|
||||
<button type="submit">进入管理台</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
}
|
||||
|
||||
async readConfigFile() {
|
||||
const configContent = await fs.promises.readFile(this.configPath, 'utf8');
|
||||
return JSON.parse(configContent);
|
||||
}
|
||||
|
||||
async savePushChannels(channels) {
|
||||
const diskConfig = await this.readConfigFile();
|
||||
diskConfig.pushChannels = channels;
|
||||
|
||||
await fs.promises.writeFile(
|
||||
this.configPath,
|
||||
`${JSON.stringify(diskConfig, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
this.config.pushChannels = channels;
|
||||
if (this.pushManager?.config) {
|
||||
this.pushManager.config.pushChannels = channels;
|
||||
}
|
||||
}
|
||||
|
||||
getPushChannelsFromConfig(config = this.config) {
|
||||
return Array.isArray(config.pushChannels) ? config.pushChannels : [];
|
||||
}
|
||||
|
||||
normalizePushChannels(channels) {
|
||||
if (!Array.isArray(channels)) {
|
||||
throw new Error('pushChannels 必须是数组');
|
||||
}
|
||||
|
||||
if (channels.length > 30) {
|
||||
throw new Error('推送通道数量不能超过 30 个');
|
||||
}
|
||||
|
||||
return channels.map((channel, index) => this.normalizePushChannel(channel, index));
|
||||
}
|
||||
|
||||
normalizePushChannel(channel, index = 0) {
|
||||
if (!channel || typeof channel !== 'object' || Array.isArray(channel)) {
|
||||
throw new Error(`第 ${index + 1} 个推送通道格式不正确`);
|
||||
}
|
||||
|
||||
const type = this.normalizeString(channel.type, '推送类型', 32);
|
||||
if (!PUSH_CHANNEL_TYPES.has(type)) {
|
||||
throw new Error(`第 ${index + 1} 个推送通道类型不支持`);
|
||||
}
|
||||
|
||||
const typeLabel = PUSH_CHANNEL_TYPE_LABELS[type] || type;
|
||||
const fallbackName = `${typeLabel}通知`;
|
||||
const name = this.normalizeString(channel.name || fallbackName, '通道名称', 80);
|
||||
|
||||
return {
|
||||
enabled: Boolean(channel.enabled),
|
||||
type,
|
||||
name: name || fallbackName,
|
||||
url: this.normalizeString(channel.url, 'URL', 2048),
|
||||
secret: this.normalizeString(channel.secret, '密钥', 2048),
|
||||
key1: this.normalizeString(channel.key1, 'Key1', 2048),
|
||||
key2: this.normalizeString(channel.key2, 'Key2', 2048),
|
||||
customBody: this.normalizeString(channel.customBody, '自定义请求体', 10000)
|
||||
};
|
||||
}
|
||||
|
||||
normalizeString(value, fieldName, maxLength) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > maxLength) {
|
||||
throw new Error(`${fieldName}长度不能超过 ${maxLength} 个字符`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
validatePushChannels(channels) {
|
||||
channels.forEach((channel, index) => {
|
||||
this.validatePushChannel(channel, { index });
|
||||
});
|
||||
}
|
||||
|
||||
validatePushChannel(channel, options = {}) {
|
||||
const index = options.index ?? 0;
|
||||
const requireDestination = Boolean(options.requireDestination || channel.enabled);
|
||||
const prefix = `第 ${index + 1} 个推送通道`;
|
||||
const urlTypes = new Set(['post_json', 'bark', 'get', 'dingtalk', 'custom', 'feishu']);
|
||||
|
||||
if (urlTypes.has(channel.type)) {
|
||||
if (requireDestination && !channel.url) {
|
||||
throw new Error(`${prefix}缺少 URL`);
|
||||
}
|
||||
|
||||
if (channel.url && !this.isHttpUrl(channel.url)) {
|
||||
throw new Error(`${prefix}的 URL 格式不正确`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === 'telegram') {
|
||||
if (requireDestination && !channel.url) {
|
||||
throw new Error(`${prefix}缺少 Bot Token`);
|
||||
}
|
||||
|
||||
if (requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 Chat ID`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === 'pushplus' && requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 PushPlus Token`);
|
||||
}
|
||||
|
||||
if (channel.type === 'serverchan' && requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 Server酱 SendKey`);
|
||||
}
|
||||
|
||||
if (channel.type === 'custom' && channel.customBody) {
|
||||
try {
|
||||
JSON.parse(channel.customBody);
|
||||
} catch (err) {
|
||||
throw new Error(`${prefix}的自定义请求体不是有效 JSON`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
this.app.get('/', (req, res) => {
|
||||
res.redirect('/admin');
|
||||
});
|
||||
|
||||
this.app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(this.publicDir, 'admin.html'));
|
||||
});
|
||||
|
||||
// 状态查询
|
||||
this.app.get('/api/status', async (req, res) => {
|
||||
try {
|
||||
const signal = await this.modem.getSignalQuality();
|
||||
const operator = await this.modem.getOperator();
|
||||
const mobileData = await this.modem.getMobileDataStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -351,8 +48,6 @@ class APIServer {
|
||||
model: this.modem.modelInfo,
|
||||
signal,
|
||||
operator,
|
||||
mobileData,
|
||||
sim: this.modem.getCachedSimStatus?.() || null,
|
||||
uptime: process.uptime()
|
||||
}
|
||||
});
|
||||
@@ -377,14 +72,11 @@ class APIServer {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.modem.sendSMS(phone, message);
|
||||
const success = Boolean(result?.success);
|
||||
const simLabel = result?.simLabel || '未知SIM';
|
||||
const success = await this.modem.sendSMS(phone, message);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? `短信发送成功,发送SIM: ${simLabel}` : `短信发送失败,发送SIM: ${simLabel}`,
|
||||
data: result
|
||||
message: success ? '短信发送成功' : '短信发送失败'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('发送短信失败:', err);
|
||||
@@ -395,31 +87,6 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询收到的短信
|
||||
this.app.get('/api/sms/received', async (req, res) => {
|
||||
try {
|
||||
await this.smsProcessor.getCurrentSimIdentity();
|
||||
const currentSimIdentity = this.smsProcessor.getCurrentModemSimIdentity();
|
||||
const result = this.smsProcessor.listReceivedMessages(req.query.limit, {
|
||||
currentSimIdentity,
|
||||
scope: req.query.scope,
|
||||
simSlot: req.query.simSlot ?? req.query.slot
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.messages,
|
||||
meta: result.meta
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询收到短信失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 查询信号强度
|
||||
this.app.get('/api/modem/signal', async (req, res) => {
|
||||
try {
|
||||
@@ -440,19 +107,12 @@ class APIServer {
|
||||
// 查询模组信息
|
||||
this.app.get('/api/modem/info', async (req, res) => {
|
||||
try {
|
||||
const simStatus = this.modem.getCachedSimStatus?.();
|
||||
const currentSlot = simStatus?.slots?.find(slot => slot.active);
|
||||
const simIdentity = this.smsProcessor.setCurrentSimIdentity(
|
||||
this.smsProcessor.getCurrentModemSimIdentity()
|
||||
);
|
||||
|
||||
const iccid = await this.modem.getICCID();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
model: this.modem.modelInfo,
|
||||
phoneNumber: currentSlot?.phoneNumber || '',
|
||||
simId: simIdentity?.simId || null,
|
||||
simLabel: simIdentity?.simLabel || '未知SIM',
|
||||
iccid,
|
||||
ready: this.modem.ready
|
||||
}
|
||||
});
|
||||
@@ -465,122 +125,6 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询SIM状态和双卡能力
|
||||
this.app.get('/api/modem/sim', async (req, res) => {
|
||||
try {
|
||||
const sim = await this.modem.getSimStatus({
|
||||
refreshIdentity: req.query.refresh !== 'false'
|
||||
});
|
||||
this.smsProcessor.setCurrentSimIdentity(this.smsProcessor.getCurrentModemSimIdentity());
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: sim
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询SIM状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 切换当前SIM卡槽
|
||||
this.app.post('/api/modem/sim/switch', async (req, res) => {
|
||||
try {
|
||||
const slot = req.body?.slot ?? req.body?.sim;
|
||||
const result = await this.modem.switchSim(slot);
|
||||
const currentSlot = result.status.slots.find(item => item.active);
|
||||
this.smsProcessor.setCurrentSimIdentity(this.smsProcessor.getCurrentModemSimIdentity());
|
||||
const currentLabel = currentSlot?.phoneLabel || currentSlot?.phoneNumber || currentSlot?.name || '目标SIM';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.changed ? `已绑定并切到${currentLabel}` : `${currentLabel}已绑定并切换`,
|
||||
data: result.status
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('切换SIM失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 查询移动数据连接状态
|
||||
this.app.get('/api/modem/mobile-data', async (req, res) => {
|
||||
try {
|
||||
const mobileData = await this.modem.getMobileDataStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data: mobileData
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询移动数据状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 开启或关闭移动数据连接
|
||||
this.app.post('/api/modem/mobile-data', async (req, res) => {
|
||||
try {
|
||||
if (typeof req.body?.enabled !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必要参数: enabled'
|
||||
});
|
||||
}
|
||||
|
||||
const mobileData = await this.modem.setMobileDataEnabled(req.body.enabled);
|
||||
const message = mobileData.mode === 'mipcall'
|
||||
? (req.body.enabled ? '应用层拨号已开启' : '应用层拨号已断开')
|
||||
: (req.body.enabled ? '移动数据已开启' : '移动数据已关闭');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message,
|
||||
data: mobileData
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('设置移动数据状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 通过模组执行一次MPING,消耗少量流量验证连通性
|
||||
this.app.post('/api/modem/mobile-data/consume', async (req, res) => {
|
||||
try {
|
||||
const ping = await this.modem.consumeMobileDataTraffic({
|
||||
target: req.body?.target || '8.8.8.8'
|
||||
});
|
||||
const mobileData = await this.modem.getMobileDataStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '已消耗少量流量完成连通性测试',
|
||||
data: {
|
||||
ping,
|
||||
mobileData
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('流量消耗测试失败:', err);
|
||||
res.status(err.statusCode || 500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
data: err.data || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 发送AT命令
|
||||
this.app.post('/api/modem/at', async (req, res) => {
|
||||
try {
|
||||
@@ -628,87 +172,6 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询推送通道配置
|
||||
this.app.get('/api/push/channels', async (req, res) => {
|
||||
try {
|
||||
const diskConfig = await this.readConfigFile();
|
||||
const channels = this.normalizePushChannels(this.getPushChannelsFromConfig(diskConfig));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: channels
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('读取推送通道配置失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 保存推送通道配置
|
||||
this.app.put('/api/push/channels', async (req, res) => {
|
||||
try {
|
||||
const rawChannels = Array.isArray(req.body) ? req.body : req.body?.channels;
|
||||
const channels = this.normalizePushChannels(rawChannels);
|
||||
this.validatePushChannels(channels);
|
||||
await this.savePushChannels(channels);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '推送通道配置已保存',
|
||||
data: channels
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('保存推送通道配置失败:', err);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 测试推送通道
|
||||
this.app.post('/api/push/test', async (req, res) => {
|
||||
try {
|
||||
if (!this.pushManager) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '推送管理器未初始化'
|
||||
});
|
||||
}
|
||||
|
||||
const channel = this.normalizePushChannel(req.body?.channel || req.body);
|
||||
this.validatePushChannel(channel, { requireDestination: true });
|
||||
|
||||
const success = await this.pushManager.pushToChannel(
|
||||
{ ...channel, enabled: true },
|
||||
'测试号码',
|
||||
'这是一条短信网关推送测试消息',
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error: '测试推送发送失败,请查看运行日志'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '测试推送已发送'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('测试推送失败:', err);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
|
||||
+8
-15
@@ -13,12 +13,10 @@ class ConcatManager extends EventEmitter {
|
||||
/**
|
||||
* 查找或创建长短信槽位
|
||||
*/
|
||||
findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
|
||||
const simId = simIdentity?.simId || '';
|
||||
|
||||
findOrCreateSlot(refNumber, sender, totalParts) {
|
||||
// 先查找是否已存在
|
||||
let slot = this.buffer.find(s =>
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender && s.simId === simId
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender
|
||||
);
|
||||
|
||||
if (slot) {
|
||||
@@ -28,7 +26,7 @@ class ConcatManager extends EventEmitter {
|
||||
// 查找空闲槽位
|
||||
slot = this.buffer.find(s => !s.inUse);
|
||||
if (slot) {
|
||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
return slot;
|
||||
}
|
||||
|
||||
@@ -46,19 +44,17 @@ class ConcatManager extends EventEmitter {
|
||||
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
||||
}
|
||||
|
||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化槽位
|
||||
*/
|
||||
initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
|
||||
initSlot(slot, refNumber, sender, totalParts) {
|
||||
slot.inUse = true;
|
||||
slot.refNumber = refNumber;
|
||||
slot.sender = sender;
|
||||
slot.simId = simIdentity?.simId || '';
|
||||
slot.simIdentity = simIdentity;
|
||||
slot.totalParts = totalParts;
|
||||
slot.receivedParts = 0;
|
||||
slot.firstPartTime = Date.now();
|
||||
@@ -69,10 +65,10 @@ class ConcatManager extends EventEmitter {
|
||||
/**
|
||||
* 添加短信分段
|
||||
*/
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp, options = {}) {
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) {
|
||||
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
|
||||
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts, options.simIdentity);
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts);
|
||||
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
|
||||
|
||||
if (partIndex >= 0 && partIndex < this.maxParts) {
|
||||
@@ -114,8 +110,7 @@ class ConcatManager extends EventEmitter {
|
||||
this.emit('complete', {
|
||||
sender: slot.sender,
|
||||
text: fullText,
|
||||
timestamp: slot.timestamp,
|
||||
simIdentity: slot.simIdentity
|
||||
timestamp: slot.timestamp
|
||||
});
|
||||
|
||||
// 清空槽位
|
||||
@@ -129,8 +124,6 @@ class ConcatManager extends EventEmitter {
|
||||
slot.inUse = false;
|
||||
slot.parts = [];
|
||||
slot.receivedParts = 0;
|
||||
slot.simId = '';
|
||||
slot.simIdentity = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-21
@@ -46,7 +46,7 @@ async function main() {
|
||||
logger.info('配置加载完成');
|
||||
|
||||
// 创建模组管理器
|
||||
const modem = new ModemManager(config.serial, config.mobileData, config);
|
||||
const modem = new ModemManager(config.serial);
|
||||
|
||||
// 创建长短信管理器
|
||||
const concatManager = new ConcatManager();
|
||||
@@ -60,9 +60,7 @@ async function main() {
|
||||
// 监听长短信合并完成事件
|
||||
concatManager.on('complete', async (sms) => {
|
||||
logger.info('收到长短信合并完成事件');
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp, {
|
||||
simIdentity: sms.simIdentity
|
||||
});
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp);
|
||||
});
|
||||
|
||||
// 监听模组短信事件
|
||||
@@ -101,7 +99,7 @@ async function main() {
|
||||
concatManager.startTimeoutChecker();
|
||||
|
||||
// 启动API服务器
|
||||
const apiServer = new APIServer(config, modem, smsProcessor, pushManager);
|
||||
const apiServer = new APIServer(config, modem, smsProcessor);
|
||||
apiServer.start();
|
||||
|
||||
logger.info('========================================');
|
||||
@@ -113,27 +111,19 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
logger.info(`\n收到 ${signal} 信号,正在关闭...`);
|
||||
// 优雅退出
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n收到 SIGINT 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.forceMobileDataOff(`${signal}退出保护`);
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', () => {
|
||||
shutdown('SIGINT').catch((err) => {
|
||||
logger.error('关闭失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown('SIGTERM').catch((err) => {
|
||||
logger.error('关闭失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('\n收到 SIGTERM 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 捕获未处理的异常
|
||||
|
||||
+3
-43
@@ -72,54 +72,14 @@ class LogBuffer {
|
||||
// 创建全局日志缓冲区
|
||||
const logBuffer = new LogBuffer(120);
|
||||
|
||||
function formatLogPart(part) {
|
||||
if (part instanceof Error) {
|
||||
return part.stack || part.message;
|
||||
}
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
try {
|
||||
return JSON.stringify(part);
|
||||
} catch (err) {
|
||||
return String(part);
|
||||
}
|
||||
}
|
||||
return String(part);
|
||||
}
|
||||
|
||||
function formatLocalTimestamp(date = new Date()) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate())
|
||||
].join('-') + ' ' + [
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds())
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function addToBuffer(level, message, args = []) {
|
||||
const timestamp = formatLocalTimestamp();
|
||||
const details = [message, ...args].map(formatLogPart).join(' ');
|
||||
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${details}`);
|
||||
}
|
||||
|
||||
// 拦截日志输出,同时写入缓冲区。winston的info/warn/error便捷方法不会走自定义log方法。
|
||||
// 拦截日志输出,同时写入缓冲区
|
||||
const originalLog = logger.log.bind(logger);
|
||||
logger.log = function(level, message, ...args) {
|
||||
addToBuffer(level, message, args);
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${message}`);
|
||||
return originalLog(level, message, ...args);
|
||||
};
|
||||
|
||||
['error', 'warn', 'info', 'debug'].forEach((level) => {
|
||||
const original = logger[level].bind(logger);
|
||||
logger[level] = function(message, ...args) {
|
||||
addToBuffer(level, message, args);
|
||||
return original(message, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
// 导出日志缓冲区
|
||||
logger.getBuffer = () => logBuffer;
|
||||
|
||||
|
||||
+119
-2050
File diff suppressed because it is too large
Load Diff
+102
-495
@@ -1,198 +1,55 @@
|
||||
import { parse } from 'node-pdu';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import PDU from 'node-pdu';
|
||||
import logger from './logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class SMSProcessor {
|
||||
constructor(config, modem, concatManager, pushManager) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.concatManager = concatManager;
|
||||
this.pushManager = pushManager;
|
||||
this.receivedMessages = [];
|
||||
this.receivedMessageSeq = 0;
|
||||
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
|
||||
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
|
||||
this.partitionMessagesBySim = this.resolvePartitionMessagesBySim();
|
||||
this.allowAllSimMessages = this.resolveAllowAllSimMessages();
|
||||
this.currentSimIdentity = null;
|
||||
this.currentSimCheckedAt = 0;
|
||||
this.currentSimLookup = null;
|
||||
this.simIdentityTtlMs = 5 * 1000;
|
||||
this.loadReceivedMessages();
|
||||
}
|
||||
|
||||
normalizeIncomingPduEvent(input) {
|
||||
if (typeof input === 'string') {
|
||||
return {
|
||||
pdu: input,
|
||||
metadata: {
|
||||
type: 'CMT',
|
||||
raw: '',
|
||||
simSlot: null,
|
||||
receivedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object') {
|
||||
return {
|
||||
pdu: String(input.pdu || input.pduHex || ''),
|
||||
metadata: {
|
||||
...(input.metadata || {}),
|
||||
simSlot: this.normalizeSimSlot(input.metadata?.simSlot ?? input.simSlot),
|
||||
receivedAt: input.metadata?.receivedAt || input.receivedAt || new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pdu: '',
|
||||
metadata: {
|
||||
type: 'CMT',
|
||||
raw: '',
|
||||
simSlot: null,
|
||||
receivedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resolveIncomingSimIdentity({ metadata = {} } = {}) {
|
||||
const urcSlot = this.normalizeSimSlot(metadata.simSlot);
|
||||
if (urcSlot !== null) {
|
||||
return this.createSimIdentityFromSlot(urcSlot, {
|
||||
simSource: metadata.simSource || 'urc'
|
||||
});
|
||||
}
|
||||
|
||||
const identified = this.modem?.identifyIncomingSms?.(metadata);
|
||||
if (identified?.slot !== undefined && identified?.slot !== null) {
|
||||
return this.createSimIdentityFromSlot(identified.slot, {
|
||||
simSource: identified.source || 'slot'
|
||||
});
|
||||
}
|
||||
|
||||
const directSource = metadata.simSource || (metadata.type === 'CMT' || metadata.type === 'CMTI' ? 'urc' : '');
|
||||
if (directSource) {
|
||||
return {
|
||||
simId: crypto.createHash('sha256').update(`unknown-${directSource}`).digest('hex').slice(0, 24),
|
||||
simLabel: '未知SIM',
|
||||
simSlot: null,
|
||||
simSource: directSource,
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
createSimIdentityFromSlot(slotNumber, extra = {}) {
|
||||
const slot = this.normalizeSimSlot(slotNumber);
|
||||
if (slot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
const slotInfo = simStatus?.slots?.find(item => item.slot === slot);
|
||||
const name = slotInfo?.phoneLabel || slotInfo?.phoneNumber || slotInfo?.name || `SIM${slot + 1}`;
|
||||
|
||||
return {
|
||||
simId: crypto.createHash('sha256').update(`slot:${slot}`).digest('hex').slice(0, 24),
|
||||
simLabel: name,
|
||||
simSlot: slot,
|
||||
simSource: extra.simSource || 'slot',
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
normalizeSimSlot(value) {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === 'SIM1') {
|
||||
return 0;
|
||||
}
|
||||
if (normalized === 'SIM2') {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const slot = Number.parseInt(value, 10);
|
||||
return slot === 0 || slot === 1 ? slot : null;
|
||||
}
|
||||
|
||||
formatSimSource(source) {
|
||||
const labels = {
|
||||
urc: 'URC上报',
|
||||
bind_context: '绑定上下文',
|
||||
active_context: '当前卡槽',
|
||||
slot: '卡槽',
|
||||
current_slot: '当前卡槽'
|
||||
};
|
||||
|
||||
return labels[source] || '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的PDU短信
|
||||
*/
|
||||
async processPDU(input) {
|
||||
async processPDU(pduHex) {
|
||||
try {
|
||||
logger.info('开始解析PDU数据...');
|
||||
|
||||
const smsEvent = this.normalizeIncomingPduEvent(input);
|
||||
const pduHex = smsEvent.pdu;
|
||||
|
||||
// 解析PDU
|
||||
const parsed = parse(pduHex);
|
||||
const parsed = PDU.parse(pduHex);
|
||||
|
||||
if (!parsed) {
|
||||
logger.error('PDU解析失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = parsed.address?.phone || '未知号码';
|
||||
const timestamp = parsed.serviceCenterTimeStamp?.getIsoString?.() || new Date().toISOString();
|
||||
const text = parsed.data?.getText?.() || '';
|
||||
const part = parsed.data?.parts?.[0];
|
||||
const header = part?.header;
|
||||
const simIdentity = this.resolveIncomingSimIdentity({
|
||||
metadata: smsEvent.metadata
|
||||
});
|
||||
|
||||
logger.info('✓ PDU解析成功');
|
||||
logger.info(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
|
||||
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
logger.info(`发送者: ${parsed.sender}`);
|
||||
logger.info(`时间戳: ${parsed.time}`);
|
||||
logger.info(`内容: ${parsed.text}`);
|
||||
|
||||
// 检查是否为长短信
|
||||
if (header && header.getType() !== undefined) {
|
||||
if (parsed.udh && parsed.udh.iei === 0x00) {
|
||||
// 长短信头部
|
||||
const refNumber = header.getPointer();
|
||||
const totalParts = header.getSegments();
|
||||
const partNumber = header.getCurrent();
|
||||
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,
|
||||
sender,
|
||||
parsed.sender,
|
||||
partNumber,
|
||||
totalParts,
|
||||
text,
|
||||
timestamp,
|
||||
{ simIdentity }
|
||||
parsed.text,
|
||||
parsed.time
|
||||
);
|
||||
} else {
|
||||
// 普通短信,直接处理
|
||||
await this.processSmsContent(sender, text, timestamp, { simIdentity });
|
||||
await this.processSmsContent(parsed.sender, parsed.text, parsed.time);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('处理PDU失败:', err);
|
||||
@@ -200,22 +57,31 @@ class SMSProcessor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理短信内容并转发
|
||||
* 处理短信内容(管理员命令检查和转发)
|
||||
*/
|
||||
async processSmsContent(sender, text, timestamp, options = {}) {
|
||||
const simIdentity = options.simIdentity !== undefined
|
||||
? this.normalizeSimIdentity(options.simIdentity)
|
||||
: await this.getCurrentSimIdentity();
|
||||
|
||||
async processSmsContent(sender, text, timestamp) {
|
||||
logger.info('=== 处理短信内容 ===');
|
||||
logger.info(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
|
||||
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
logger.info('====================');
|
||||
|
||||
const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity);
|
||||
// 检查是否在黑名单中
|
||||
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);
|
||||
@@ -224,362 +90,103 @@ class SMSProcessor {
|
||||
const subject = `短信${sender},${text.substring(0, 20)}`;
|
||||
const body = `来自:${sender},时间:${timestamp},内容:${text}`;
|
||||
await this.pushManager.sendEmail(subject, body);
|
||||
|
||||
this.updateReceivedMessage(messageRecord.id, {
|
||||
status: 'forwarded',
|
||||
statusText: '已转发'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录收到的短信,供Web管理端展示。
|
||||
* 检查是否在黑名单中
|
||||
*/
|
||||
addReceivedMessage(sender, text, timestamp, simIdentity = null) {
|
||||
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
|
||||
const message = {
|
||||
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
||||
sender,
|
||||
text,
|
||||
timestamp,
|
||||
receivedAt: new Date().toISOString(),
|
||||
simId: normalizedSimIdentity?.simId || '',
|
||||
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
|
||||
simSlot: normalizedSimIdentity?.simSlot ?? null,
|
||||
simSource: normalizedSimIdentity?.simSource || 'unknown',
|
||||
smsCenter: normalizedSimIdentity?.smsCenter || '',
|
||||
status: 'received',
|
||||
statusText: '已接收'
|
||||
};
|
||||
isInBlacklist(sender) {
|
||||
const blacklist = this.config.numberBlackList || [];
|
||||
|
||||
this.receivedMessages.unshift(message);
|
||||
if (this.receivedMessages.length > this.maxReceivedMessages) {
|
||||
this.receivedMessages.length = this.maxReceivedMessages;
|
||||
}
|
||||
|
||||
this.persistReceivedMessages();
|
||||
logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收件箱短信状态。
|
||||
*/
|
||||
updateReceivedMessage(id, patch) {
|
||||
const message = this.receivedMessages.find(item => item.id === id);
|
||||
if (message) {
|
||||
Object.assign(message, patch);
|
||||
this.persistReceivedMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近收到的短信。
|
||||
*/
|
||||
getReceivedMessages(limit = 50) {
|
||||
return this.receivedMessages.slice(0, this.resolveReceivedMessagesLimit(limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按SIM作用域过滤后的短信。
|
||||
*/
|
||||
listReceivedMessages(limit = 50, options = {}) {
|
||||
const safeLimit = this.resolveReceivedMessagesLimit(limit);
|
||||
const requestedSimSlot = this.normalizeSimSlot(options.simSlot);
|
||||
const hasRequestedScope = options.scope !== undefined && options.scope !== null && String(options.scope).trim() !== '';
|
||||
const requestedScope = requestedSimSlot !== null
|
||||
? `sim${requestedSimSlot + 1}`
|
||||
: hasRequestedScope
|
||||
? String(options.scope).trim().toLowerCase()
|
||||
: (this.shouldDefaultToAllSimMessages() ? 'all' : 'current');
|
||||
const currentSim = this.normalizeSimIdentity(options.currentSimIdentity);
|
||||
const autoAllSims = !hasRequestedScope && requestedScope === 'all';
|
||||
const includeAllSims = requestedSimSlot === null && requestedScope === 'all';
|
||||
|
||||
let effectiveScope = includeAllSims ? 'all' : 'current';
|
||||
let messages = this.receivedMessages;
|
||||
|
||||
if (requestedSimSlot !== null) {
|
||||
messages = this.receivedMessages.filter(message => this.normalizeSimSlot(message.simSlot) === requestedSimSlot);
|
||||
effectiveScope = `sim${requestedSimSlot + 1}`;
|
||||
} else if (this.partitionMessagesBySim && !includeAllSims) {
|
||||
if (currentSim?.simId) {
|
||||
messages = this.receivedMessages.filter(message => this.messageMatchesSim(message, currentSim));
|
||||
} else {
|
||||
messages = [];
|
||||
effectiveScope = 'unavailable';
|
||||
}
|
||||
} else if (!this.partitionMessagesBySim) {
|
||||
effectiveScope = 'all';
|
||||
}
|
||||
|
||||
return {
|
||||
messages: messages.slice(0, safeLimit),
|
||||
meta: {
|
||||
limit: safeLimit,
|
||||
total: messages.length,
|
||||
scope: effectiveScope,
|
||||
requestedScope,
|
||||
partitionBySim: this.partitionMessagesBySim,
|
||||
allSimMessagesAllowed: this.allowAllSimMessages || includeAllSims || autoAllSims,
|
||||
allSimMessagesDenied: false,
|
||||
simSlot: requestedSimSlot,
|
||||
currentSim,
|
||||
currentSimKnown: Boolean(currentSim?.simId)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
shouldDefaultToAllSimMessages() {
|
||||
if (this.config.inbox?.dualSimDefaultScope === 'current') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
return simStatus?.supported === true && (simStatus.mode === 0 || simStatus.mode === 3);
|
||||
}
|
||||
|
||||
messageMatchesSim(message, currentSim) {
|
||||
if (!message || !currentSim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.simId && currentSim.simId && message.simId === currentSim.simId) {
|
||||
// 检查完整号码
|
||||
if (blacklist.includes(sender)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const messageSlot = this.normalizeSimSlot(message.simSlot);
|
||||
const currentSlot = this.normalizeSimSlot(currentSim.simSlot);
|
||||
return messageSlot !== null && currentSlot !== null && messageSlot === currentSlot;
|
||||
// 检查去除+86后的号码
|
||||
if (sender.startsWith('+86')) {
|
||||
const stripped = sender.substring(3);
|
||||
if (blacklist.includes(stripped)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制收件箱查询条数。
|
||||
* 检查是否为管理员
|
||||
*/
|
||||
resolveReceivedMessagesLimit(limit = 50) {
|
||||
return Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析收件箱保留条数。
|
||||
* 处理管理员命令
|
||||
*/
|
||||
resolveMaxReceivedMessages() {
|
||||
const configured = Number(this.config.inbox?.maxReceivedMessages);
|
||||
if (!Number.isFinite(configured) || configured <= 0) {
|
||||
return 200;
|
||||
}
|
||||
async processAdminCommand(sender, command) {
|
||||
logger.info(`处理管理员命令: ${command}`);
|
||||
|
||||
return Math.min(Math.floor(configured), 5000);
|
||||
}
|
||||
// 处理 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(); // 支持内容中包含冒号
|
||||
|
||||
/**
|
||||
* 是否按当前SIM隔离Web收件箱。默认开启,避免换卡后读取其他SIM历史。
|
||||
*/
|
||||
resolvePartitionMessagesBySim() {
|
||||
return this.config.inbox?.partitionBySim !== false;
|
||||
}
|
||||
logger.info(`目标号码: ${targetPhone}`);
|
||||
logger.info(`短信内容: ${smsContent}`);
|
||||
|
||||
/**
|
||||
* 是否允许显式查询全部SIM历史。默认关闭。
|
||||
*/
|
||||
resolveAllowAllSimMessages() {
|
||||
return this.config.inbox?.allowAllSimMessages === true;
|
||||
}
|
||||
const success = await this.modem.sendSMS(targetPhone, smsContent);
|
||||
|
||||
/**
|
||||
* 解析收件箱持久化文件路径。
|
||||
*/
|
||||
resolveReceivedMessagesFile() {
|
||||
const configuredPath = this.config.inbox?.receivedMessagesFile || 'data/received-messages.json';
|
||||
if (path.isAbsolute(configuredPath)) {
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
return path.join(__dirname, '..', configuredPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时加载持久化收件箱。
|
||||
*/
|
||||
loadReceivedMessages() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
|
||||
|
||||
if (!fs.existsSync(this.receivedMessagesFile)) {
|
||||
logger.info(`短信收件箱持久化文件不存在,将在收到短信后创建: ${this.receivedMessagesFile}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(this.receivedMessagesFile, 'utf8').trim();
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) {
|
||||
logger.warn('短信收件箱持久化文件格式不是数组,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
this.receivedMessages = parsed
|
||||
.map(item => this.normalizeStoredMessage(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, this.maxReceivedMessages);
|
||||
|
||||
logger.info(`已加载持久化短信收件箱: ${this.receivedMessages.length} 条`);
|
||||
} catch (err) {
|
||||
logger.error('加载短信收件箱持久化文件失败:', err);
|
||||
this.receivedMessages = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前收件箱到磁盘。
|
||||
*/
|
||||
persistReceivedMessages() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
|
||||
const tmpFile = `${this.receivedMessagesFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
`${JSON.stringify(this.receivedMessages, null, 2)}\n`,
|
||||
'utf8'
|
||||
// 发送邮件通知结果
|
||||
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:号码:内容'
|
||||
);
|
||||
fs.renameSync(tmpFile, this.receivedMessagesFile);
|
||||
} catch (err) {
|
||||
logger.error('保存短信收件箱持久化文件失败:', err);
|
||||
}
|
||||
}
|
||||
// 处理 RESET 命令
|
||||
else if (command === 'RESET') {
|
||||
logger.info('执行RESET命令');
|
||||
|
||||
/**
|
||||
* 规范化历史短信记录,避免坏数据影响Web展示。
|
||||
*/
|
||||
normalizeStoredMessage(item) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return null;
|
||||
// 先发送邮件通知
|
||||
await this.pushManager.sendEmail(
|
||||
'重启命令已执行',
|
||||
'收到RESET命令,即将重启系统...'
|
||||
);
|
||||
|
||||
// 等待邮件发送完成
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 重启进程
|
||||
logger.info('正在重启系统...');
|
||||
process.exit(0); // PM2会自动重启
|
||||
}
|
||||
|
||||
const id = String(item.id || '').trim();
|
||||
if (!id) {
|
||||
return null;
|
||||
else {
|
||||
logger.warn(`未知命令: ${command}`);
|
||||
}
|
||||
|
||||
const simIdentity = this.normalizeStoredSimIdentity(item);
|
||||
|
||||
return {
|
||||
id,
|
||||
sender: String(item.sender || '未知号码'),
|
||||
text: String(item.text || ''),
|
||||
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
|
||||
receivedAt: String(item.receivedAt || new Date().toISOString()),
|
||||
simId: simIdentity?.simId || '',
|
||||
simLabel: simIdentity?.simLabel || '未知SIM',
|
||||
simSlot: simIdentity?.simSlot ?? null,
|
||||
simSource: simIdentity?.simSource || item.simSource || 'unknown',
|
||||
smsCenter: '',
|
||||
status: String(item.status || 'received'),
|
||||
statusText: String(item.statusText || '已接收')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从历史记录中恢复SIM身份。旧记录没有SIM字段,会归到未知SIM。
|
||||
*/
|
||||
normalizeStoredSimIdentity(item) {
|
||||
if (item.simId || item.simLabel) {
|
||||
return this.normalizeSimIdentity({
|
||||
simId: item.simId,
|
||||
simLabel: item.simLabel,
|
||||
simSlot: item.simSlot,
|
||||
simSource: item.simSource
|
||||
});
|
||||
}
|
||||
|
||||
if (item.sim && typeof item.sim === 'object') {
|
||||
return this.normalizeSimIdentity(item.sim);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
|
||||
*/
|
||||
async getCurrentSimIdentity(options = {}) {
|
||||
const refresh = options.refresh === true;
|
||||
const now = Date.now();
|
||||
|
||||
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
if (this.currentSimLookup) {
|
||||
return this.currentSimLookup;
|
||||
}
|
||||
|
||||
if (this.modem?.isSimSwitching?.()) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
const modemSim = this.getCurrentModemSimIdentity();
|
||||
if (modemSim?.simId) {
|
||||
return this.setCurrentSimIdentity(modemSim);
|
||||
}
|
||||
|
||||
this.currentSimIdentity = null;
|
||||
this.currentSimCheckedAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
getCurrentModemSimIdentity() {
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
const activeSlot = simStatus?.activeSlot;
|
||||
if (activeSlot === null || activeSlot === undefined) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
const identity = this.createSimIdentityFromSlot(activeSlot, {
|
||||
simSource: 'current_slot'
|
||||
});
|
||||
return identity || this.currentSimIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前SIM身份缓存。
|
||||
*/
|
||||
setCurrentSimIdentity(identity) {
|
||||
this.currentSimIdentity = this.normalizeSimIdentity(identity);
|
||||
this.currentSimCheckedAt = Date.now();
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
normalizeSimIdentity(identity) {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof identity === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof identity !== 'object' || Array.isArray(identity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simSlot = this.normalizeSimSlot(identity.simSlot ?? identity.slot);
|
||||
const simId = String(identity.simId || identity.id || '').trim();
|
||||
if (!simId && simSlot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
simId: simId || crypto.createHash('sha256').update(`slot:${simSlot}`).digest('hex').slice(0, 24),
|
||||
simLabel: String(identity.simLabel || identity.label || (simSlot === null ? '未知SIM' : `SIM${simSlot + 1}`)),
|
||||
simSlot,
|
||||
simSource: String(identity.simSource || identity.source || 'unknown'),
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SMSProcessor;
|
||||
|
||||
Reference in New Issue
Block a user