Compare commits

..
19 Commits
Author SHA1 Message Date
wuxu 1bed4c73eb docs: 补充 ML307A USB 识别说明 2026-06-28 08:51:25 +08:00
wuxu 78f7a5f46e feat: 调整 SIM 切换入口 2026-06-27 21:56:41 +08:00
wuxu 05a4fd960d fix: 修复单 SIM 卡逻辑 2026-06-27 21:56:41 +08:00
wuxu ea6afcc08c docs: 补充配置说明 2026-06-27 21:56:41 +08:00
wuxu dd4a156b9c refactor: 缩减 Docker 镜像体积 2026-06-27 16:57:48 +08:00
wuxu e03a7a141c feat: 取消 Web 挑战头鉴权 2026-06-27 16:57:48 +08:00
wuxu ecca396792 feat: 添加双卡支持 2026-06-27 16:57:48 +08:00
wuxu 57ca2f1ea3 fix: 调整 ICCID 查询方式 2026-06-27 09:31:09 +08:00
wuxu c395c3fc4e feat: 按 SIM ICCID 隔离短信 2026-06-27 09:27:16 +08:00
wuxu d8ef61adec feat: 添加低流量保活 2026-06-27 09:16:25 +08:00
wuxu 5d7ca83162 fix: 优化断网处理 2026-06-27 09:10:01 +08:00
wuxu 993b5ee4c0 refactor: 重构前端 UI 2026-06-27 08:42:27 +08:00
wuxu ae27984fb0 feat: 添加移动数据控制 2026-06-26 16:48:40 +08:00
wuxu 4aa93d8a9d feat: 添加串口自动探测 2026-06-26 16:34:27 +08:00
wuxu d1a87556c5 feat: 添加 Docker 部署和短信持久化 2026-06-26 16:29:19 +08:00
wuxu 557049b308 feat: 支持选择推送通道类型 2026-06-24 23:34:33 +08:00
wuxu 15b15b79c9 feat: 添加推送通道管理 2026-06-24 22:39:12 +08:00
wuxu deafe6b1c4 feat: 添加 Web 管理台 2026-06-24 22:24:43 +08:00
wuxu db3c32a871 feat: 初始化短信网关核心 2026-06-24 22:24:43 +08:00
19 changed files with 8791 additions and 963 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.env
.npm
.npm-cache
.vscode
.idea
node_modules
logs
data
config.json
*.log
*.tgz
*.swp
*.swo
*~
AGENTS.md
DESIGN.md
ML307A_*.md
+3
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
logs/ logs/
data/
*.log *.log
config.json config.json
.DS_Store .DS_Store
@@ -10,4 +11,6 @@ config.json
*.swo *.swo
*~ *~
.npm .npm
.npm-cache/
*.tgz
.eslintcache .eslintcache
-310
View File
@@ -1,310 +0,0 @@
# 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
View File
@@ -0,0 +1,34 @@
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
View File
@@ -1,222 +0,0 @@
# 跨平台支持说明
本项目支持在 **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 反向代理
+315 -144
View File
@@ -1,212 +1,383 @@
# Node.js 4G SMS Gateway # SMS Forwarding
基于Node.js的4G短信网关,通过串口与4G模块通信,接收短信并转发到多种推送渠道。 一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。
## 功能特性 项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。
- ✅ 4G模块AT指令交互 ## 功能
- ✅ 自动接收短信(PDU模式)
- ✅ 长短信自动合并 - 通过 AT 指令和 4G 模组通信
- ✅ 多通道推送(邮件/钉钉/飞书/Telegram等) - 自动探测可用 AT 串口
- ✅ 短信发送功能 - PDU 模式接收短信
- ✅ 号码黑名单 - 长短信自动合并
- ✅ 管理员远程命令 - 收件箱持久化到本地文件
- ✅ RESTful API接口 - 多推送通道转发短信
- ✅ Web管理界面 - Web 管理台
- Token 鉴权的 HTTP API
- Web/API 发送短信
- 双 SIM 状态识别和发送前切卡
- 移动数据开关和启动断网保护
- AT 命令调试入口
## 环境要求 ## 环境要求
- Node.js >= 18.x - Node.js 18 或更高版本
- 4G模块通过USB转串口连接到服务器 - 可通过串口访问的 4G 模组
- 支持的4G模块:ML307R-DC、SIM7600等AT指令兼容模块 - 已插入可收发短信的 SIM 卡
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
## 安装 已主要围绕 ML307A 调试。其他 AT 指令兼容模组可以运行,但双卡、移动数据、URC 卡槽识别等能力取决于模组返回的指令结果。
## 快速启动
```bash ```bash
npm install npm install
```
## 配置
复制配置模板:
```bash
cp config.example.json config.json cp config.example.json config.json
``` ```
编辑 `config.json`: 编辑 `config.json`,至少确认:
```json ```json
{ {
"serial": { "serial": {
"path": "COM3", "path": "/dev/ttyUSB0",
"baudRate": 115200, "baudRate": 115200,
"_comment": "Windows使用COM1/COM2/COM3等; Linux使用/dev/ttyUSB0" "autoDetect": true
}, },
"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": { "api": {
"port": 3000, "port": 3000,
"auth": { "webToken": "change-this-token"
"username": "admin",
"password": "admin123"
}
} }
} }
``` ```
## 运行 启动:
### 开发模式
```bash
npm run dev
```
### 生产模式
```bash ```bash
npm start npm start
``` ```
### 使用PM2(推荐) 访问管理台:
```bash
pm2 start ecosystem.config.js ```text
pm2 save http://localhost:3000/admin
pm2 startup
``` ```
## API接口 第一次访问会要求输入 `api.webToken`。也可以临时通过 URL 传入:
```text
http://localhost:3000/admin?token=change-this-token
```
## Docker 运行
先准备配置:
### 查询状态
```bash ```bash
GET /api/status 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`。
### 必填项
| 配置项 | 说明 |
| --- | --- |
| `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。
如果只想给管理台显示手机号,可配置:
```json
{
"simCards": [
{ "slot": 0, "phoneNumber": "+8613800138000" },
{ "slot": 1, "phoneNumber": "+447700900000" }
]
}
```
## HTTP API
所有 `/api/*` 接口都需要 token。下面示例使用 `X-Web-Token`:
```bash
TOKEN=change-this-token
BASE=http://localhost:3000
```
### 状态和日志
```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"
```
### 收件箱
```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"
``` ```
### 发送短信 ### 发送短信
```bash ```bash
POST /api/sms/send curl -X POST "$BASE/api/sms/send" \
Content-Type: application/json -H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
{ -d '{"phone":"13800138000","message":"测试短信"}'
"phone": "13800138000",
"message": "测试短信"
}
``` ```
### 查询信号强度 ### SIM 状态和切换
```bash ```bash
GET /api/modem/signal 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}'
``` ```
### 查询模组信息 `slot=0` 表示 SIM1,`slot=1` 表示 SIM2。
### 移动数据
```bash ```bash
GET /api/modem/info 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"}'
``` ```
### 发送AT命令 ### AT 命令
```bash ```bash
POST /api/modem/at curl -X POST "$BASE/api/modem/at" \
Content-Type: application/json -H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
{ -d '{"command":"AT+CSQ"}'
"command": "AT+CSQ"
}
``` ```
### 获取日志 ### 推送通道管理
```bash ```bash
GET /api/logs 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"}'
``` ```
所有API均需要HTTP Basic认证。 ## 目录结构
## 项目结构 ```text
```
. .
├── src/ ├── src/
│ ├── index.js # 入口文件 │ ├── index.js # 程序入口
│ ├── modem.js # 4G模块通信 │ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制
│ ├── sms.js # 短信处理 │ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录
│ ├── push.js # 推送通道 │ ├── concat.js # 长短信分段合并
│ ├── concat.js # 长短信合并 │ ├── push.js # 推送通道
│ ├── api.js # REST API │ ├── api.js # Web 管理台和 HTTP API
│ └── logger.js # 日志系统 │ └── logger.js # 日志
├── config.json # 配置文件 ├── public/ # Web 管理台静态文件
├── package.json ├── data/ # 收件箱持久化数据
└── README.md ├── logs/ # 运行日志
├── config.example.json
├── config.json # 本地配置,不应提交
├── docker-compose.yml
├── Dockerfile
└── ecosystem.config.js
``` ```
## 推送通道类型 ## 常见问题
| 类型 | 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 ```bash
# Linux检查串口设备
ls -l /dev/ttyUSB* ls -l /dev/ttyUSB*
# 添加用户到dialout组
sudo usermod -a -G dialout $USER
``` ```
### 模块不响应 如果是权限问题,把运行用户加入串口权限组,重新登录后生效:
- 检查串口路径和波特率
- 检查USB连接 ```bash
- 检查SIM卡是否插好 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 规则。
### 收不到短信 ### 收不到短信
- 检查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 MIT
+30 -10
View File
@@ -2,20 +2,42 @@
"serial": { "serial": {
"path": "COM3", "path": "COM3",
"baudRate": 115200, "baudRate": 115200,
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0" "autoDetect": true,
"probeTimeout": 1200,
"_comment": "必填。path是手动串口或自动探测优先项;autoDetect=false时必须写真实串口。Windows使用COM1/COM2/COM3等,也可以直接写数字如3;Linux使用/dev/ttyUSB0。baudRate通常为115200,probeTimeout为AT探测超时毫秒。"
}, },
"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": { "smtp": {
"_comment": "可选。填写server后会启用邮件通知;启用时server、port、user、pass、sendTo都需要正确填写。不需要邮件可删除smtp或清空server。",
"server": "smtp.qq.com", "server": "smtp.qq.com",
"port": 465, "port": 465,
"user": "your@qq.com", "user": "your@qq.com",
"pass": "your_auth_code", "pass": "your_auth_code",
"sendTo": "recipient@example.com" "sendTo": "recipient@example.com"
}, },
"adminPhone": "13800138000", "inbox": {
"numberBlackList": [ "maxReceivedMessages": 200,
"10086", "receivedMessagesFile": "data/received-messages.json",
"10010" "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字符串。",
"pushChannels": [ "pushChannels": [
{ {
"enabled": true, "enabled": true,
@@ -70,9 +92,7 @@
], ],
"api": { "api": {
"port": 3000, "port": 3000,
"auth": { "webToken": "change-this-token",
"username": "admin", "_comment": "必填。port是API和管理台监听端口;webToken是唯一鉴权凭据,可通过Authorization: Bearer、X-Web-Token、token查询参数或管理台登录页提交。"
"password": "admin123"
}
} }
} }
+26
View File
@@ -0,0 +1,26 @@
# 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"
+2047
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -21,9 +21,8 @@
"dependencies": { "dependencies": {
"serialport": "^12.0.0", "serialport": "^12.0.0",
"@serialport/parser-readline": "^12.0.0", "@serialport/parser-readline": "^12.0.0",
"node-pdu": "^0.2.1", "node-pdu": "^2.1.2",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",
"axios": "^1.6.2", "axios": "^1.6.2",
"winston": "^3.11.0", "winston": "^3.11.0",
+424
View File
@@ -0,0 +1,424 @@
<!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
+552 -15
View File
@@ -1,13 +1,46 @@
import fs from 'fs';
import express from 'express'; import express from 'express';
import basicAuth from 'express-basic-auth'; import crypto from 'crypto';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from './logger.js'; 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 { class APIServer {
constructor(config, modem, smsProcessor) { constructor(config, modem, smsProcessor, pushManager) {
this.config = config; this.config = config;
this.modem = modem; this.modem = modem;
this.smsProcessor = smsProcessor; this.smsProcessor = smsProcessor;
this.pushManager = pushManager;
this.app = express(); this.app = express();
this.publicDir = path.join(__dirname, '../public');
this.configPath = path.join(__dirname, '../config.json');
this.setupMiddleware(); this.setupMiddleware();
this.setupRoutes(); this.setupRoutes();
@@ -17,29 +50,299 @@ class APIServer {
// JSON解析 // JSON解析
this.app.use(express.json()); this.app.use(express.json());
// HTTP Basic认证 // Web Token认证
const users = {}; this.app.use((req, res, next) => {
users[this.config.api.auth.username] = this.config.api.auth.password; this.authenticateRequest(req, res, next);
});
this.app.use(basicAuth({
users,
challenge: true,
realm: 'SMS Gateway'
}));
// 请求日志 // 请求日志
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`); logger.info(`${req.method} ${req.path}`);
next(); 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() { 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) => { this.app.get('/api/status', async (req, res) => {
try { try {
const signal = await this.modem.getSignalQuality(); const signal = await this.modem.getSignalQuality();
const operator = await this.modem.getOperator(); const operator = await this.modem.getOperator();
const mobileData = await this.modem.getMobileDataStatus();
res.json({ res.json({
success: true, success: true,
@@ -48,6 +351,8 @@ class APIServer {
model: this.modem.modelInfo, model: this.modem.modelInfo,
signal, signal,
operator, operator,
mobileData,
sim: this.modem.getCachedSimStatus?.() || null,
uptime: process.uptime() uptime: process.uptime()
} }
}); });
@@ -72,11 +377,14 @@ class APIServer {
}); });
} }
const success = await this.modem.sendSMS(phone, message); const result = await this.modem.sendSMS(phone, message);
const success = Boolean(result?.success);
const simLabel = result?.simLabel || '未知SIM';
res.json({ res.json({
success, success,
message: success ? '短信发送成功' : '短信发送失败' message: success ? `短信发送成功,发送SIM: ${simLabel}` : `短信发送失败,发送SIM: ${simLabel}`,
data: result
}); });
} catch (err) { } catch (err) {
logger.error('发送短信失败:', err); logger.error('发送短信失败:', err);
@@ -87,6 +395,31 @@ 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) => { this.app.get('/api/modem/signal', async (req, res) => {
try { try {
@@ -107,12 +440,19 @@ class APIServer {
// 查询模组信息 // 查询模组信息
this.app.get('/api/modem/info', async (req, res) => { this.app.get('/api/modem/info', async (req, res) => {
try { try {
const iccid = await this.modem.getICCID(); const simStatus = this.modem.getCachedSimStatus?.();
const currentSlot = simStatus?.slots?.find(slot => slot.active);
const simIdentity = this.smsProcessor.setCurrentSimIdentity(
this.smsProcessor.getCurrentModemSimIdentity()
);
res.json({ res.json({
success: true, success: true,
data: { data: {
model: this.modem.modelInfo, model: this.modem.modelInfo,
iccid, phoneNumber: currentSlot?.phoneNumber || '',
simId: simIdentity?.simId || null,
simLabel: simIdentity?.simLabel || '未知SIM',
ready: this.modem.ready ready: this.modem.ready
} }
}); });
@@ -125,6 +465,122 @@ 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命令 // 发送AT命令
this.app.post('/api/modem/at', async (req, res) => { this.app.post('/api/modem/at', async (req, res) => {
try { try {
@@ -172,6 +628,87 @@ 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) => { this.app.get('/health', (req, res) => {
res.json({ res.json({
+15 -8
View File
@@ -13,10 +13,12 @@ class ConcatManager extends EventEmitter {
/** /**
* 查找或创建长短信槽位 * 查找或创建长短信槽位
*/ */
findOrCreateSlot(refNumber, sender, totalParts) { findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
const simId = simIdentity?.simId || '';
// 先查找是否已存在 // 先查找是否已存在
let slot = this.buffer.find(s => let slot = this.buffer.find(s =>
s.inUse && s.refNumber === refNumber && s.sender === sender s.inUse && s.refNumber === refNumber && s.sender === sender && s.simId === simId
); );
if (slot) { if (slot) {
@@ -26,7 +28,7 @@ class ConcatManager extends EventEmitter {
// 查找空闲槽位 // 查找空闲槽位
slot = this.buffer.find(s => !s.inUse); slot = this.buffer.find(s => !s.inUse);
if (slot) { if (slot) {
this.initSlot(slot, refNumber, sender, totalParts); this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
return slot; return slot;
} }
@@ -44,17 +46,19 @@ class ConcatManager extends EventEmitter {
logger.warn('长短信缓存已满,覆盖最老的槽位'); logger.warn('长短信缓存已满,覆盖最老的槽位');
} }
this.initSlot(slot, refNumber, sender, totalParts); this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
return slot; return slot;
} }
/** /**
* 初始化槽位 * 初始化槽位
*/ */
initSlot(slot, refNumber, sender, totalParts) { initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
slot.inUse = true; slot.inUse = true;
slot.refNumber = refNumber; slot.refNumber = refNumber;
slot.sender = sender; slot.sender = sender;
slot.simId = simIdentity?.simId || '';
slot.simIdentity = simIdentity;
slot.totalParts = totalParts; slot.totalParts = totalParts;
slot.receivedParts = 0; slot.receivedParts = 0;
slot.firstPartTime = Date.now(); slot.firstPartTime = Date.now();
@@ -65,10 +69,10 @@ class ConcatManager extends EventEmitter {
/** /**
* 添加短信分段 * 添加短信分段
*/ */
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) { addPart(refNumber, sender, partNumber, totalParts, text, timestamp, options = {}) {
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`); logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
const slot = this.findOrCreateSlot(refNumber, sender, totalParts); const slot = this.findOrCreateSlot(refNumber, sender, totalParts, options.simIdentity);
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始 const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
if (partIndex >= 0 && partIndex < this.maxParts) { if (partIndex >= 0 && partIndex < this.maxParts) {
@@ -110,7 +114,8 @@ class ConcatManager extends EventEmitter {
this.emit('complete', { this.emit('complete', {
sender: slot.sender, sender: slot.sender,
text: fullText, text: fullText,
timestamp: slot.timestamp timestamp: slot.timestamp,
simIdentity: slot.simIdentity
}); });
// 清空槽位 // 清空槽位
@@ -124,6 +129,8 @@ class ConcatManager extends EventEmitter {
slot.inUse = false; slot.inUse = false;
slot.parts = []; slot.parts = [];
slot.receivedParts = 0; slot.receivedParts = 0;
slot.simId = '';
slot.simIdentity = null;
} }
/** /**
+21 -11
View File
@@ -46,7 +46,7 @@ async function main() {
logger.info('配置加载完成'); logger.info('配置加载完成');
// 创建模组管理器 // 创建模组管理器
const modem = new ModemManager(config.serial); const modem = new ModemManager(config.serial, config.mobileData, config);
// 创建长短信管理器 // 创建长短信管理器
const concatManager = new ConcatManager(); const concatManager = new ConcatManager();
@@ -60,7 +60,9 @@ async function main() {
// 监听长短信合并完成事件 // 监听长短信合并完成事件
concatManager.on('complete', async (sms) => { concatManager.on('complete', async (sms) => {
logger.info('收到长短信合并完成事件'); logger.info('收到长短信合并完成事件');
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp); await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp, {
simIdentity: sms.simIdentity
});
}); });
// 监听模组短信事件 // 监听模组短信事件
@@ -99,7 +101,7 @@ async function main() {
concatManager.startTimeoutChecker(); concatManager.startTimeoutChecker();
// 启动API服务器 // 启动API服务器
const apiServer = new APIServer(config, modem, smsProcessor); const apiServer = new APIServer(config, modem, smsProcessor, pushManager);
apiServer.start(); apiServer.start();
logger.info('========================================'); logger.info('========================================');
@@ -111,19 +113,27 @@ async function main() {
process.exit(1); process.exit(1);
} }
// 优雅退出 async function shutdown(signal) {
process.on('SIGINT', async () => { logger.info(`\n收到 ${signal} 信号,正在关闭...`);
logger.info('\n收到 SIGINT 信号,正在关闭...');
concatManager.stopTimeoutChecker(); concatManager.stopTimeoutChecker();
await modem.forceMobileDataOff(`${signal}退出保护`);
await modem.close(); await modem.close();
process.exit(0); process.exit(0);
}
// 优雅退出
process.on('SIGINT', () => {
shutdown('SIGINT').catch((err) => {
logger.error('关闭失败:', err);
process.exit(1);
});
}); });
process.on('SIGTERM', async () => { process.on('SIGTERM', () => {
logger.info('\n收到 SIGTERM 信号,正在关闭...'); shutdown('SIGTERM').catch((err) => {
concatManager.stopTimeoutChecker(); logger.error('关闭失败:', err);
await modem.close(); process.exit(1);
process.exit(0); });
}); });
// 捕获未处理的异常 // 捕获未处理的异常
+43 -3
View File
@@ -72,14 +72,54 @@ class LogBuffer {
// 创建全局日志缓冲区 // 创建全局日志缓冲区
const logBuffer = new LogBuffer(120); 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); const originalLog = logger.log.bind(logger);
logger.log = function(level, message, ...args) { logger.log = function(level, message, ...args) {
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); addToBuffer(level, message, args);
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${message}`);
return originalLog(level, message, ...args); 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; logger.getBuffer = () => logBuffer;
+2071 -140
View File
File diff suppressed because it is too large Load Diff
+490 -97
View File
@@ -1,55 +1,198 @@
import PDU from 'node-pdu'; import { parse } from 'node-pdu';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from './logger.js'; import logger from './logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class SMSProcessor { class SMSProcessor {
constructor(config, modem, concatManager, pushManager) { constructor(config, modem, concatManager, pushManager) {
this.config = config; this.config = config;
this.modem = modem; this.modem = modem;
this.concatManager = concatManager; this.concatManager = concatManager;
this.pushManager = pushManager; 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短信 * 处理接收到的PDU短信
*/ */
async processPDU(pduHex) { async processPDU(input) {
try { try {
logger.info('开始解析PDU数据...'); logger.info('开始解析PDU数据...');
const smsEvent = this.normalizeIncomingPduEvent(input);
const pduHex = smsEvent.pdu;
// 解析PDU // 解析PDU
const parsed = PDU.parse(pduHex); const parsed = parse(pduHex);
if (!parsed) { if (!parsed) {
logger.error('PDU解析失败'); logger.error('PDU解析失败');
return; 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('✓ PDU解析成功');
logger.info(`发送者: ${parsed.sender}`); logger.info(`发送者: ${sender}`);
logger.info(`时间戳: ${parsed.time}`); logger.info(`时间戳: ${timestamp}`);
logger.info(`内容: ${parsed.text}`); logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
logger.info(`内容: ${text}`);
// 检查是否为长短信 // 检查是否为长短信
if (parsed.udh && parsed.udh.iei === 0x00) { if (header && header.getType() !== undefined) {
// 长短信头部 // 长短信头部
const refNumber = parsed.udh.data.refNumber; const refNumber = header.getPointer();
const totalParts = parsed.udh.data.parts; const totalParts = header.getSegments();
const partNumber = parsed.udh.data.current; const partNumber = header.getCurrent();
logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`); logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`);
// 添加到长短信缓存 // 添加到长短信缓存
this.concatManager.addPart( this.concatManager.addPart(
refNumber, refNumber,
parsed.sender, sender,
partNumber, partNumber,
totalParts, totalParts,
parsed.text, text,
parsed.time timestamp,
{ simIdentity }
); );
} else { } else {
// 普通短信,直接处理 // 普通短信,直接处理
await this.processSmsContent(parsed.sender, parsed.text, parsed.time); await this.processSmsContent(sender, text, timestamp, { simIdentity });
} }
} catch (err) { } catch (err) {
logger.error('处理PDU失败:', err); logger.error('处理PDU失败:', err);
@@ -57,31 +200,22 @@ class SMSProcessor {
} }
/** /**
* 处理短信内容(管理员命令检查和转发) * 处理短信内容并转发
*/ */
async processSmsContent(sender, text, timestamp) { async processSmsContent(sender, text, timestamp, options = {}) {
const simIdentity = options.simIdentity !== undefined
? this.normalizeSimIdentity(options.simIdentity)
: await this.getCurrentSimIdentity();
logger.info('=== 处理短信内容 ==='); logger.info('=== 处理短信内容 ===');
logger.info(`发送者: ${sender}`); logger.info(`发送者: ${sender}`);
logger.info(`时间戳: ${timestamp}`); logger.info(`时间戳: ${timestamp}`);
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
logger.info(`内容: ${text}`); logger.info(`内容: ${text}`);
logger.info('===================='); 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); await this.pushManager.pushToAll(sender, text, timestamp);
@@ -90,103 +224,362 @@ class SMSProcessor {
const subject = `短信${sender},${text.substring(0, 20)}`; const subject = `短信${sender},${text.substring(0, 20)}`;
const body = `来自:${sender},时间:${timestamp},内容:${text}`; const body = `来自:${sender},时间:${timestamp},内容:${text}`;
await this.pushManager.sendEmail(subject, body); await this.pushManager.sendEmail(subject, body);
this.updateReceivedMessage(messageRecord.id, {
status: 'forwarded',
statusText: '已转发'
});
} }
/** /**
* 检查是否在黑名单中 * 记录收到的短信,供Web管理端展示。
*/ */
isInBlacklist(sender) { addReceivedMessage(sender, text, timestamp, simIdentity = null) {
const blacklist = this.config.numberBlackList || []; 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: '已接收'
};
// 检查完整号码 this.receivedMessages.unshift(message);
if (blacklist.includes(sender)) { if (this.receivedMessages.length > this.maxReceivedMessages) {
return true; this.receivedMessages.length = this.maxReceivedMessages;
} }
// 检查去除+86后的号码 this.persistReceivedMessages();
if (sender.startsWith('+86')) { logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
const stripped = sender.substring(3); return message;
if (blacklist.includes(stripped)) { }
return true;
/**
* 更新收件箱短信状态。
*/
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 false; 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') {
*/
isAdmin(sender) {
if (!this.config.adminPhone) {
return false; return false;
} }
let senderStr = sender; const simStatus = this.modem?.getCachedSimStatus?.();
let adminStr = this.config.adminPhone; return simStatus?.supported === true && (simStatus.mode === 0 || simStatus.mode === 3);
}
// 去除+86前缀 messageMatchesSim(message, currentSim) {
if (senderStr.startsWith('+86')) { if (!message || !currentSim) {
senderStr = senderStr.substring(3); return false;
}
if (adminStr.startsWith('+86')) {
adminStr = adminStr.substring(3);
} }
return senderStr === adminStr; if (message.simId && currentSim.simId && message.simId === currentSim.simId) {
return true;
}
const messageSlot = this.normalizeSimSlot(message.simSlot);
const currentSlot = this.normalizeSimSlot(currentSim.simSlot);
return messageSlot !== null && currentSlot !== null && messageSlot === currentSlot;
} }
/** /**
* 处理管理员命令 * 限制收件箱查询条数。
*/ */
async processAdminCommand(sender, command) { resolveReceivedMessagesLimit(limit = 50) {
logger.info(`处理管理员命令: ${command}`); return Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
}
// 处理 SMS:号码:内容 命令 /**
if (command.startsWith('SMS:')) { * 解析收件箱保留条数。
const parts = command.split(':'); */
if (parts.length >= 3) { resolveMaxReceivedMessages() {
const targetPhone = parts[1].trim(); const configured = Number(this.config.inbox?.maxReceivedMessages);
const smsContent = parts.slice(2).join(':').trim(); // 支持内容中包含冒号 if (!Number.isFinite(configured) || configured <= 0) {
return 200;
}
logger.info(`目标号码: ${targetPhone}`); return Math.min(Math.floor(configured), 5000);
logger.info(`短信内容: ${smsContent}`); }
const success = await this.modem.sendSMS(targetPhone, smsContent); /**
* 是否按当前SIM隔离Web收件箱。默认开启,避免换卡后读取其他SIM历史。
*/
resolvePartitionMessagesBySim() {
return this.config.inbox?.partitionBySim !== false;
}
// 发送邮件通知结果 /**
const subject = success ? '短信发送成功' : '短信发送失败'; * 是否允许显式查询全部SIM历史。默认关闭。
const body = `管理员命令执行结果:\n命令: ${command}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`; */
await this.pushManager.sendEmail(subject, body); resolveAllowAllSimMessages() {
} else { return this.config.inbox?.allowAllSimMessages === true;
logger.error('SMS命令格式错误'); }
await this.pushManager.sendEmail(
'命令执行失败', /**
'SMS命令格式错误,正确格式: SMS:号码:内容' * 解析收件箱持久化文件路径。
); */
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;
} }
}
// 处理 RESET 命令
else if (command === 'RESET') {
logger.info('执行RESET命令');
// 先发送邮件通知 const content = fs.readFileSync(this.receivedMessagesFile, 'utf8').trim();
await this.pushManager.sendEmail( if (!content) {
'重启命令已执行', return;
'收到RESET命令,即将重启系统...' }
);
// 等待邮件发送完成 const parsed = JSON.parse(content);
await new Promise(resolve => setTimeout(resolve, 2000)); if (!Array.isArray(parsed)) {
logger.warn('短信收件箱持久化文件格式不是数组,已忽略');
return;
}
// 重启进程 this.receivedMessages = parsed
logger.info('正在重启系统...'); .map(item => this.normalizeStoredMessage(item))
process.exit(0); // PM2会自动重启 .filter(Boolean)
} .slice(0, this.maxReceivedMessages);
else {
logger.warn(`未知命令: ${command}`); 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'
);
fs.renameSync(tmpFile, this.receivedMessagesFile);
} catch (err) {
logger.error('保存短信收件箱持久化文件失败:', err);
}
}
/**
* 规范化历史短信记录,避免坏数据影响Web展示。
*/
normalizeStoredMessage(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null;
}
const id = String(item.id || '').trim();
if (!id) {
return null;
}
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; export default SMSProcessor;