Compare commits
5
Commits
main
..
11a52add4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a52add4e | ||
|
|
f63fd6bc75 | ||
|
|
038668b47e | ||
|
|
cba3973632 | ||
|
|
83118db289 |
+14
-5
@@ -1,25 +1,34 @@
|
|||||||
FROM alpine:3.22 AS deps
|
FROM node:20-bookworm-slim AS deps
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
NPM_CONFIG_AUDIT=false \
|
NPM_CONFIG_AUDIT=false \
|
||||||
NPM_CONFIG_FUND=false \
|
NPM_CONFIG_FUND=false \
|
||||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache nodejs npm python3 make g++
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev \
|
RUN npm ci --omit=dev \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
FROM alpine:3.22 AS runtime
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production \
|
||||||
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
|
NPM_CONFIG_AUDIT=false \
|
||||||
|
NPM_CONFIG_FUND=false \
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache nodejs eudev
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends udev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
# SMS Forwarding
|
|
||||||
|
|
||||||
一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。
|
|
||||||
|
|
||||||
项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。
|
|
||||||
|
|
||||||
## 功能
|
|
||||||
|
|
||||||
- 通过 AT 指令和 4G 模组通信
|
|
||||||
- 自动探测可用 AT 串口
|
|
||||||
- PDU 模式接收短信
|
|
||||||
- 长短信自动合并
|
|
||||||
- 收件箱持久化到本地文件
|
|
||||||
- 多推送通道转发短信
|
|
||||||
- Web 管理台
|
|
||||||
- Token 鉴权的 HTTP API
|
|
||||||
- Web/API 发送短信
|
|
||||||
- 双 SIM 状态识别和发送前切卡
|
|
||||||
- 移动数据开关和启动断网保护
|
|
||||||
- AT 命令调试入口
|
|
||||||
|
|
||||||
## 环境要求
|
|
||||||
|
|
||||||
- Node.js 18 或更高版本
|
|
||||||
- 可通过串口访问的 4G 模组
|
|
||||||
- 已插入可收发短信的 SIM 卡
|
|
||||||
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
|
|
||||||
|
|
||||||
已主要围绕 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`。
|
|
||||||
|
|
||||||
### 必填项
|
|
||||||
|
|
||||||
| 配置项 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `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
|
|
||||||
curl -X POST "$BASE/api/sms/send" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Web-Token: $TOKEN" \
|
|
||||||
-d '{"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}'
|
|
||||||
```
|
|
||||||
|
|
||||||
`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"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### AT 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$BASE/api/modem/at" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Web-Token: $TOKEN" \
|
|
||||||
-d '{"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"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
.
|
|
||||||
├── src/
|
|
||||||
│ ├── index.js # 程序入口
|
|
||||||
│ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制
|
|
||||||
│ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录
|
|
||||||
│ ├── concat.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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 串口打不开
|
|
||||||
|
|
||||||
Linux 下先确认设备存在:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -l /dev/ttyUSB*
|
|
||||||
```
|
|
||||||
|
|
||||||
如果是权限问题,把运行用户加入串口权限组,重新登录后生效:
|
|
||||||
|
|
||||||
```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 规则。
|
|
||||||
|
|
||||||
### 收不到短信
|
|
||||||
|
|
||||||
- 确认 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
-29
@@ -2,42 +2,20 @@
|
|||||||
"serial": {
|
"serial": {
|
||||||
"path": "COM3",
|
"path": "COM3",
|
||||||
"baudRate": 115200,
|
"baudRate": 115200,
|
||||||
"autoDetect": true,
|
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"inbox": {
|
"adminPhone": "13800138000",
|
||||||
"maxReceivedMessages": 200,
|
"numberBlackList": [
|
||||||
"receivedMessagesFile": "data/received-messages.json",
|
"10086",
|
||||||
"partitionBySim": true,
|
"10010"
|
||||||
"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,
|
||||||
@@ -93,6 +71,9 @@
|
|||||||
"api": {
|
"api": {
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"webToken": "change-this-token",
|
"webToken": "change-this-token",
|
||||||
"_comment": "必填。port是API和管理台监听端口;webToken是唯一鉴权凭据,可通过Authorization: Bearer、X-Web-Token、token查询参数或管理台登录页提交。"
|
"auth": {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-1
@@ -13,6 +13,7 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-basic-auth": "^1.2.1",
|
||||||
"node-pdu": "^2.1.2",
|
"node-pdu": "^2.1.2",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"serialport": "^12.0.0",
|
"serialport": "^12.0.0",
|
||||||
@@ -2044,4 +2045,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@
|
|||||||
"@serialport/parser-readline": "^12.0.0",
|
"@serialport/parser-readline": "^12.0.0",
|
||||||
"node-pdu": "^2.1.2",
|
"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",
|
||||||
@@ -31,4 +32,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-233
@@ -1,30 +1,20 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>SMS Gateway · 管理台</title>
|
<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">
|
<link rel="stylesheet" href="/assets/admin.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
<div class="top-nav__inner">
|
<div class="top-nav__inner">
|
||||||
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
|
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
|
||||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
<span class="brand__mark" aria-hidden="true">✣</span>
|
||||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
<span>SMS Gateway</span>
|
||||||
stroke-linecap="round" fill="none" />
|
|
||||||
</svg>
|
|
||||||
<span class="brand__name">SMS Gateway</span>
|
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav-links" aria-label="管理导航">
|
<nav class="nav-links" aria-label="管理导航">
|
||||||
<a href="#dashboard">状态</a>
|
<a href="#status">状态</a>
|
||||||
<a href="#sim">SIM</a>
|
|
||||||
<a href="#inbox">收件箱</a>
|
<a href="#inbox">收件箱</a>
|
||||||
<a href="#send">短信</a>
|
<a href="#send">短信</a>
|
||||||
<a href="#console">AT 控制台</a>
|
<a href="#console">AT 控制台</a>
|
||||||
@@ -32,172 +22,91 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="top-nav__actions">
|
<div class="top-nav__actions">
|
||||||
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
|
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
|
||||||
|
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<!-- ============ HERO ============ -->
|
|
||||||
<section class="hero-band" id="status">
|
<section class="hero-band" id="status">
|
||||||
<div class="container hero-grid">
|
<div class="container hero-grid">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<span class="eyebrow eyebrow--coral">
|
<span class="badge badge--coral" id="readyBadge">连接检测中</span>
|
||||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
<h1>短信网关管理台</h1>
|
||||||
<span id="readyBadge">连接检测中</span>
|
<p>集中查看 ML307A 模组状态、发送短信、执行 AT 命令,并检查运行日志。</p>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<aside class="runtime-card" aria-label="运行摘要">
|
<div class="product-panel" aria-label="运行摘要">
|
||||||
<header class="runtime-card__header">
|
<div class="panel-header">
|
||||||
<span class="runtime-card__label">
|
<span>runtime</span>
|
||||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
<strong id="panelStatus">loading</strong>
|
||||||
<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>
|
</div>
|
||||||
<dl class="runtime-card__meta">
|
<dl class="runtime-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>operator</dt>
|
<dt>模组</dt>
|
||||||
|
<dd id="modelName">--</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>运营商</dt>
|
||||||
<dd id="operator">--</dd>
|
<dd id="operator">--</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>signal</dt>
|
<dt>信号</dt>
|
||||||
<dd id="signalQuality">--</dd>
|
<dd id="signalQuality">--</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>运行时长</dt>
|
||||||
|
<dd id="uptime">--</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</aside>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ STATUS BENTO ============ -->
|
<section class="section-band section-band--soft">
|
||||||
<section class="section-band section-band--soft" id="dashboard">
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<h2>当前状态</h2>
|
||||||
<span class="eyebrow eyebrow--spike">
|
<p>这些数据来自模组实时 AT 查询,刷新时会串行读取,避免同时发送多条 AT 命令。</p>
|
||||||
<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>
|
||||||
|
<div class="status-grid">
|
||||||
<div class="bento-grid">
|
<article class="feature-card">
|
||||||
<article class="bento-hero">
|
<span class="eyebrow">connection</span>
|
||||||
<span class="eyebrow eyebrow--coral">
|
|
||||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
|
||||||
connection
|
|
||||||
</span>
|
|
||||||
<h3 id="connectionState">--</h3>
|
<h3 id="connectionState">--</h3>
|
||||||
<p id="connectionHint">等待状态刷新。</p>
|
<p id="connectionHint">等待状态刷新。</p>
|
||||||
</article>
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
<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="eyebrow">signal</span>
|
||||||
<span class="stat-tile__value" id="signalValue">--</span>
|
<h3 id="signalValue">--</h3>
|
||||||
<span class="stat-tile__hint" id="signalHint">RSSI 与误码率将在刷新后显示。</span>
|
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-tile">
|
<article class="feature-card">
|
||||||
<span class="eyebrow">module</span>
|
<span class="eyebrow">module</span>
|
||||||
<span class="stat-tile__value" id="moduleModel">--</span>
|
<h3 id="moduleModel">--</h3>
|
||||||
<span class="stat-tile__hint" id="moduleVersion">--</span>
|
<p id="moduleVersion">--</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-tile">
|
<article class="feature-card">
|
||||||
<span class="eyebrow">current sim</span>
|
<span class="eyebrow">sim</span>
|
||||||
<span class="stat-tile__value" id="currentSim">--</span>
|
<h3 id="iccid">--</h3>
|
||||||
<span class="stat-tile__hint">发送短信会使用当前绑定的 SIM。</span>
|
<p>ICCID 查询来自当前串口模组。</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ INBOX ============ -->
|
<section class="section-band">
|
||||||
<section class="section-band" id="inbox">
|
<div class="container" id="inbox">
|
||||||
<div class="container">
|
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow eyebrow--spike">
|
<span class="badge badge--soft">inbox</span>
|
||||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
<h2>收到的短信</h2>
|
||||||
<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>
|
||||||
|
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="inbox-panel">
|
<div class="inbox-panel">
|
||||||
<div class="inbox-meta">
|
<div class="inbox-meta">
|
||||||
<span id="messageCount">最近 0 条</span>
|
<span id="messageCount">最近 0 条</span>
|
||||||
<span class="inbox-meta__hint" id="messageScope">新短信会自动出现在这里</span>
|
<span>新短信会自动出现在这里</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-list" id="messageList" aria-live="polite">
|
<div class="message-list" id="messageList" aria-live="polite">
|
||||||
<p class="empty-state empty-state--light">暂无收到的短信。</p>
|
<p class="empty-state empty-state--light">暂无收到的短信。</p>
|
||||||
@@ -206,38 +115,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ WORKBENCH ============ -->
|
|
||||||
<section class="section-band section-band--soft">
|
<section class="section-band section-band--soft">
|
||||||
<div class="container workbench-grid">
|
<div class="container workbench-grid">
|
||||||
<section class="form-card" id="send">
|
<section class="form-card" id="send">
|
||||||
<div class="section-heading section-heading--compact">
|
<div class="section-heading section-heading--compact">
|
||||||
<span class="eyebrow eyebrow--spike">
|
<h2>发送短信</h2>
|
||||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
<p>使用当前 PDU 模式发送短信,提交后等待模组返回发送结果。</p>
|
||||||
<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>
|
</div>
|
||||||
<form id="smsForm" class="stack-form">
|
<form id="smsForm" class="stack-form">
|
||||||
<label class="field">
|
<label>
|
||||||
<span class="field__label">目标号码</span>
|
<span>目标号码</span>
|
||||||
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
|
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label>
|
||||||
<span class="field__label">短信内容</span>
|
<span>短信内容</span>
|
||||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容"
|
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容" required></textarea>
|
||||||
required></textarea>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="button button--primary" type="submit">发送短信</button>
|
<button class="button button--primary" type="submit">发送短信</button>
|
||||||
@@ -247,44 +139,27 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="console-card" id="console">
|
<section class="console-card" id="console">
|
||||||
<header class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-header__label">
|
<span>AT console</span>
|
||||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
<strong id="consoleState">idle</strong>
|
||||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
</div>
|
||||||
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">
|
<form id="atForm" class="at-form">
|
||||||
<input class="text-input text-input--mono" name="command" value="AT+CSQ" autocomplete="off"
|
<input class="text-input text-input--dark" name="command" value="AT+CSQ" autocomplete="off" spellcheck="false" required>
|
||||||
spellcheck="false" required>
|
<button class="button button--secondary-on-dark" type="submit">执行</button>
|
||||||
<button class="button button--primary" type="submit">执行</button>
|
|
||||||
</form>
|
</form>
|
||||||
<pre class="console-output" id="atOutput">› 等待命令...</pre>
|
<pre class="console-output" id="atOutput">等待命令...</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ LOGS ============ -->
|
<section class="section-band section-band--dark" id="logs">
|
||||||
<section class="section-band" id="logs">
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-heading">
|
<div class="dark-heading">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow eyebrow--spike">
|
<span class="badge badge--dark">logs</span>
|
||||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
<h2>运行日志</h2>
|
||||||
<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>
|
</div>
|
||||||
<button class="button button--secondary" id="logsButton" type="button">刷新日志</button>
|
<button class="button button--secondary-on-dark" id="logsButton" type="button">刷新日志</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-window" id="logWindow" aria-live="polite">
|
<div class="log-window" id="logWindow" aria-live="polite">
|
||||||
<p class="empty-state">等待日志加载。</p>
|
<p class="empty-state">等待日志加载。</p>
|
||||||
@@ -293,32 +168,21 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ============ PUSH MODAL ============ -->
|
|
||||||
<div class="modal" id="pushModal" aria-hidden="true">
|
<div class="modal" id="pushModal" aria-hidden="true">
|
||||||
<div class="modal__backdrop" data-close-push-modal></div>
|
<div class="modal__backdrop" data-close-push-modal></div>
|
||||||
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
|
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
|
||||||
<header class="modal__header">
|
<div class="modal__header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow eyebrow--spike">
|
<span class="badge badge--soft">push</span>
|
||||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
<h2 id="pushModalTitle">推送渠道设置</h2>
|
||||||
<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>
|
</div>
|
||||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">
|
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
</div>
|
||||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="push-settings">
|
<div class="push-settings">
|
||||||
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
||||||
<div class="panel-header panel-header--light">
|
<div class="panel-header panel-header--light">
|
||||||
<span class="panel-header__label">channels</span>
|
<span>channels</span>
|
||||||
<div class="channel-add">
|
<div class="channel-add">
|
||||||
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
|
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
|
||||||
<option value="dingtalk">钉钉机器人</option>
|
<option value="dingtalk">钉钉机器人</option>
|
||||||
@@ -345,13 +209,13 @@
|
|||||||
<span>启用通道</span>
|
<span>启用通道</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label>
|
||||||
<span class="field__label">通道名称</span>
|
<span>通道名称</span>
|
||||||
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label>
|
||||||
<span class="field__label">推送类型</span>
|
<span>推送类型</span>
|
||||||
<select class="text-input select-input" name="type">
|
<select class="text-input select-input" name="type">
|
||||||
<option value="dingtalk">钉钉机器人</option>
|
<option value="dingtalk">钉钉机器人</option>
|
||||||
<option value="feishu">飞书机器人</option>
|
<option value="feishu">飞书机器人</option>
|
||||||
@@ -365,30 +229,29 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-row field-row--wide" data-push-field="url">
|
<label class="field-row field-row--wide" data-push-field="url">
|
||||||
<span class="field__label" id="pushUrlLabel">Webhook URL</span>
|
<span id="pushUrlLabel">Webhook URL</span>
|
||||||
<input class="text-input" type="text" name="url" autocomplete="off">
|
<input class="text-input" type="text" name="url" autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-row field-row--wide" data-push-field="secret">
|
<label class="field-row field-row--wide" data-push-field="secret">
|
||||||
<span class="field__label" id="pushSecretLabel">加签密钥</span>
|
<span id="pushSecretLabel">加签密钥</span>
|
||||||
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-row" data-push-field="key1">
|
<label class="field-row" data-push-field="key1">
|
||||||
<span class="field__label" id="pushKey1Label">Key1</span>
|
<span id="pushKey1Label">Key1</span>
|
||||||
<input class="text-input" type="text" name="key1" autocomplete="off">
|
<input class="text-input" type="text" name="key1" autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-row" data-push-field="key2">
|
<label class="field-row" data-push-field="key2">
|
||||||
<span class="field__label" id="pushKey2Label">Key2</span>
|
<span id="pushKey2Label">Key2</span>
|
||||||
<input class="text-input" type="text" name="key2" autocomplete="off">
|
<input class="text-input" type="text" name="key2" autocomplete="off">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field field-row field-row--wide" data-push-field="customBody">
|
<label class="field-row field-row--wide" data-push-field="customBody">
|
||||||
<span class="field__label">自定义请求体 JSON</span>
|
<span>自定义请求体 JSON</span>
|
||||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7"
|
<textarea class="text-input text-area text-area--code" name="customBody" rows="7" spellcheck="false"></textarea>
|
||||||
spellcheck="false"></textarea>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -406,19 +269,11 @@
|
|||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer__inner">
|
<div class="container footer__inner">
|
||||||
<span class="brand brand--footer">
|
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span>
|
||||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
<span>Basic Auth protected management surface</span>
|
||||||
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/assets/admin.js" type="module"></script>
|
<script src="/assets/admin.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+320
-952
File diff suppressed because it is too large
Load Diff
+12
-383
@@ -1,10 +1,5 @@
|
|||||||
const state = {
|
const state = {
|
||||||
busy: false,
|
busy: false,
|
||||||
mobileDataBusy: false,
|
|
||||||
trafficBusy: false,
|
|
||||||
simBusy: false,
|
|
||||||
currentMobileData: null,
|
|
||||||
currentSim: null,
|
|
||||||
pushChannels: [],
|
pushChannels: [],
|
||||||
selectedPushChannel: -1,
|
selectedPushChannel: -1,
|
||||||
pushLoaded: false
|
pushLoaded: false
|
||||||
@@ -34,37 +29,18 @@ const elements = {
|
|||||||
modelName: document.querySelector('#modelName'),
|
modelName: document.querySelector('#modelName'),
|
||||||
operator: document.querySelector('#operator'),
|
operator: document.querySelector('#operator'),
|
||||||
signalQuality: document.querySelector('#signalQuality'),
|
signalQuality: document.querySelector('#signalQuality'),
|
||||||
mobileDataSummary: document.querySelector('#mobileDataSummary'),
|
|
||||||
uptime: document.querySelector('#uptime'),
|
uptime: document.querySelector('#uptime'),
|
||||||
connectionState: document.querySelector('#connectionState'),
|
connectionState: document.querySelector('#connectionState'),
|
||||||
connectionHint: document.querySelector('#connectionHint'),
|
connectionHint: document.querySelector('#connectionHint'),
|
||||||
signalValue: document.querySelector('#signalValue'),
|
signalValue: document.querySelector('#signalValue'),
|
||||||
signalHint: document.querySelector('#signalHint'),
|
signalHint: document.querySelector('#signalHint'),
|
||||||
mobileDataState: document.querySelector('#mobileDataState'),
|
|
||||||
mobileDataHint: document.querySelector('#mobileDataHint'),
|
|
||||||
mobileDataToggle: document.querySelector('#mobileDataToggle'),
|
|
||||||
mobileDataConsume: document.querySelector('#mobileDataConsume'),
|
|
||||||
mobileDataTrafficHint: document.querySelector('#mobileDataTrafficHint'),
|
|
||||||
mobileDataStatus: document.querySelector('#mobileDataStatus'),
|
|
||||||
simMode: document.querySelector('#simMode'),
|
|
||||||
simStatus: document.querySelector('#simStatus'),
|
|
||||||
simSlot0: document.querySelector('#simSlot0'),
|
|
||||||
simSlot1: document.querySelector('#simSlot1'),
|
|
||||||
simSlotGrid: document.querySelector('.sim-slot-grid'),
|
|
||||||
simSlot0State: document.querySelector('#simSlot0State'),
|
|
||||||
simSlot1State: document.querySelector('#simSlot1State'),
|
|
||||||
simSlot0Phone: document.querySelector('#simSlot0Phone'),
|
|
||||||
simSlot1Phone: document.querySelector('#simSlot1Phone'),
|
|
||||||
simSwitchButtons: [...document.querySelectorAll('[data-sim-switch]')],
|
|
||||||
moduleModel: document.querySelector('#moduleModel'),
|
moduleModel: document.querySelector('#moduleModel'),
|
||||||
moduleVersion: document.querySelector('#moduleVersion'),
|
moduleVersion: document.querySelector('#moduleVersion'),
|
||||||
currentSim: document.querySelector('#currentSim'),
|
iccid: document.querySelector('#iccid'),
|
||||||
smsForm: document.querySelector('#smsForm'),
|
smsForm: document.querySelector('#smsForm'),
|
||||||
sendSimLabel: document.querySelector('#sendSimLabel'),
|
|
||||||
smsStatus: document.querySelector('#smsStatus'),
|
smsStatus: document.querySelector('#smsStatus'),
|
||||||
messagesButton: document.querySelector('#messagesButton'),
|
messagesButton: document.querySelector('#messagesButton'),
|
||||||
messageCount: document.querySelector('#messageCount'),
|
messageCount: document.querySelector('#messageCount'),
|
||||||
messageScope: document.querySelector('#messageScope'),
|
|
||||||
messageList: document.querySelector('#messageList'),
|
messageList: document.querySelector('#messageList'),
|
||||||
atForm: document.querySelector('#atForm'),
|
atForm: document.querySelector('#atForm'),
|
||||||
atOutput: document.querySelector('#atOutput'),
|
atOutput: document.querySelector('#atOutput'),
|
||||||
@@ -127,12 +103,10 @@ const PUSH_TYPE_META = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setText(node, value) {
|
function setText(node, value) {
|
||||||
if (!node) return;
|
|
||||||
node.textContent = value || '--';
|
node.textContent = value || '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatusMessage(node, message, type = '') {
|
function setStatusMessage(node, message, type = '') {
|
||||||
if (!node) return;
|
|
||||||
node.textContent = message;
|
node.textContent = message;
|
||||||
node.classList.toggle('is-success', type === 'success');
|
node.classList.toggle('is-success', type === 'success');
|
||||||
node.classList.toggle('is-error', type === 'error');
|
node.classList.toggle('is-error', type === 'error');
|
||||||
@@ -178,225 +152,12 @@ function formatModel(model = {}) {
|
|||||||
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
|
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMobileDataMode(mode) {
|
function renderStatus(status, info) {
|
||||||
if (mode === 'mipcall') {
|
|
||||||
return 'MIPCALL';
|
|
||||||
}
|
|
||||||
if (mode === 'cgact') {
|
|
||||||
return 'CGACT';
|
|
||||||
}
|
|
||||||
return 'AT';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMobileDataContexts(contexts = []) {
|
|
||||||
const active = contexts
|
|
||||||
.filter(item => item.active)
|
|
||||||
.map(item => `CID ${item.cid}`)
|
|
||||||
.join('、');
|
|
||||||
|
|
||||||
return active || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMobileData(mobileData = {}) {
|
|
||||||
state.currentMobileData = mobileData;
|
|
||||||
const unknown = mobileData.status === 'unknown';
|
|
||||||
const enabled = Boolean(mobileData.enabled || mobileData.anyActive);
|
|
||||||
const cid = mobileData.cid || 1;
|
|
||||||
const mode = formatMobileDataMode(mobileData.mode);
|
|
||||||
const isMipcall = mobileData.mode === 'mipcall';
|
|
||||||
const activeContexts = formatMobileDataContexts(mobileData.contexts || []);
|
|
||||||
|
|
||||||
if (unknown) {
|
|
||||||
setText(elements.mobileDataSummary, '未知');
|
|
||||||
setText(elements.mobileDataState, '状态未知');
|
|
||||||
setText(elements.mobileDataHint, mobileData.error || (isMipcall ? '未能确认应用层拨号状态,可先强制断开。' : '未能确认移动数据状态,可先强制关闭。'));
|
|
||||||
elements.mobileDataToggle.textContent = isMipcall ? '强制断开拨号' : '强制关闭流量';
|
|
||||||
elements.mobileDataToggle.dataset.action = 'disable';
|
|
||||||
elements.mobileDataConsume.classList.add('is-hidden');
|
|
||||||
elements.mobileDataTrafficHint.classList.remove('is-hidden');
|
|
||||||
elements.mobileDataTrafficHint.textContent = '状态确认后,开启流量才会显示消耗流量按钮。';
|
|
||||||
} else if (enabled) {
|
|
||||||
setText(elements.mobileDataSummary, isMipcall ? '已连接' : '已开启');
|
|
||||||
setText(elements.mobileDataState, isMipcall ? '应用拨号已连接' : '流量已开启');
|
|
||||||
setText(elements.mobileDataHint, activeContexts ? `${mode} 检测到 ${activeContexts} 处于活动状态。` : `CID ${cid} 处于活动状态。`);
|
|
||||||
elements.mobileDataToggle.textContent = isMipcall ? '断开拨号' : '关闭流量';
|
|
||||||
elements.mobileDataToggle.dataset.action = 'disable';
|
|
||||||
elements.mobileDataConsume.classList.remove('is-hidden');
|
|
||||||
elements.mobileDataTrafficHint.classList.add('is-hidden');
|
|
||||||
} else {
|
|
||||||
setText(elements.mobileDataSummary, isMipcall ? '已断开' : '已关闭');
|
|
||||||
setText(elements.mobileDataState, isMipcall ? '应用拨号已断开' : '流量已关闭');
|
|
||||||
setText(elements.mobileDataHint, isMipcall ? `${mode} CID ${cid} 未建立应用层拨号连接;已保护性尝试停用PDP。` : `${mode} CID ${cid} 未建立移动数据连接,重启后也会先保持关闭。`);
|
|
||||||
elements.mobileDataToggle.textContent = isMipcall ? '开启拨号' : '开启流量';
|
|
||||||
elements.mobileDataToggle.dataset.action = 'enable';
|
|
||||||
elements.mobileDataConsume.classList.add('is-hidden');
|
|
||||||
elements.mobileDataTrafficHint.classList.remove('is-hidden');
|
|
||||||
elements.mobileDataTrafficHint.textContent = isMipcall ? '先开启拨号后,才可以执行一次MPING消耗少量流量。' : '先开启流量后,才可以执行一次MPING消耗少量流量。';
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.mobileDataToggle.disabled = state.mobileDataBusy;
|
|
||||||
elements.mobileDataConsume.disabled = state.mobileDataBusy || state.trafficBusy || !enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveSimSlot(sim = {}) {
|
|
||||||
return (sim.slots || []).find(slot => slot.active) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSimDisplayLabel(slot) {
|
|
||||||
return slot?.phoneLabel || slot?.phoneNumber || slot?.name || 'SIM';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveSimLabel() {
|
|
||||||
return getSimDisplayLabel(getActiveSimSlot(state.currentSim || {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSendSimLabel() {
|
|
||||||
setText(elements.sendSimLabel, getActiveSimLabel());
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSimSwitchStatus(message, type = '') {
|
|
||||||
setStatusMessage(elements.simStatus, message);
|
|
||||||
setStatusMessage(elements.smsStatus, message, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSimSlot(sim = {}, slotNumber) {
|
|
||||||
return (sim.slots || []).find(slot => slot.slot === slotNumber) || {
|
|
||||||
slot: slotNumber,
|
|
||||||
name: `SIM${slotNumber + 1}`,
|
|
||||||
active: false,
|
|
||||||
phoneNumber: '',
|
|
||||||
phoneLabel: `SIM${slotNumber + 1}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSimSlotPhone(slot) {
|
|
||||||
if (slot?.phoneNumber) {
|
|
||||||
return slot.phoneNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slot?.phoneLabel && slot.phoneLabel !== slot.name) {
|
|
||||||
return slot.phoneLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSimSlot(slot, card, stateNode, phoneNode) {
|
|
||||||
if (!card) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
card.classList.toggle('is-active', Boolean(slot.active));
|
|
||||||
if (slot.active && slot.present === false) {
|
|
||||||
setText(stateNode, '当前未就绪');
|
|
||||||
} else if (slot.active) {
|
|
||||||
setText(stateNode, '当前使用');
|
|
||||||
} else if (slot.present === true) {
|
|
||||||
setText(stateNode, '可用');
|
|
||||||
} else if (slot.present === false) {
|
|
||||||
setText(stateNode, '未检测到SIM');
|
|
||||||
} else {
|
|
||||||
setText(stateNode, '未确认');
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneText = formatSimSlotPhone(slot);
|
|
||||||
if (phoneNode) {
|
|
||||||
phoneNode.textContent = phoneText;
|
|
||||||
phoneNode.hidden = !phoneText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisibleSimSlots(sim = {}, activeSlot = null) {
|
|
||||||
const slots = sim.slots || [];
|
|
||||||
const presentSlots = slots.filter(slot => slot.present === true);
|
|
||||||
|
|
||||||
if (presentSlots.length >= 2) {
|
|
||||||
return presentSlots;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSlot?.present === true) {
|
|
||||||
return [activeSlot];
|
|
||||||
}
|
|
||||||
|
|
||||||
return slots.filter(slot => slot.active || slot.present !== false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSimModeLabel(sim = {}, activeSlot = null) {
|
|
||||||
const hardwareSwitchable = Boolean(sim.supported && sim.canSwitch);
|
|
||||||
if (!hardwareSwitchable) {
|
|
||||||
return '单卡/未检测到双卡';
|
|
||||||
}
|
|
||||||
|
|
||||||
const presentSlots = (sim.slots || []).filter(slot => slot.present === true);
|
|
||||||
if (presentSlots.length >= 2) {
|
|
||||||
return sim.modeLabel || '双卡';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSlot?.present === true) {
|
|
||||||
return `仅检测到 ${getSimDisplayLabel(activeSlot)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '未检测到可用SIM';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSimStatus(sim = {}) {
|
|
||||||
state.currentSim = sim;
|
|
||||||
|
|
||||||
const supported = Boolean(sim.supported && sim.canSwitch);
|
|
||||||
const activeSlot = getActiveSimSlot(sim);
|
|
||||||
const modeLabel = getSimModeLabel(sim, activeSlot);
|
|
||||||
|
|
||||||
setText(elements.simMode, modeLabel);
|
|
||||||
|
|
||||||
const visibleSlots = getVisibleSimSlots(sim, activeSlot);
|
|
||||||
const singleSlot = visibleSlots.length === 1;
|
|
||||||
const visibleSlotNumbers = new Set(visibleSlots.map(slot => slot.slot));
|
|
||||||
|
|
||||||
elements.simSlotGrid?.classList.toggle('is-single', singleSlot);
|
|
||||||
|
|
||||||
[
|
|
||||||
[getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone],
|
|
||||||
[getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone]
|
|
||||||
].forEach(([slot, card, stateNode, phoneNode]) => {
|
|
||||||
if (card) {
|
|
||||||
card.hidden = !visibleSlotNumbers.has(slot.slot);
|
|
||||||
card.classList.toggle('is-single', singleSlot && visibleSlotNumbers.has(slot.slot));
|
|
||||||
}
|
|
||||||
renderSimSlot(slot, card, stateNode, phoneNode);
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.simSwitchButtons.forEach((button) => {
|
|
||||||
const slot = Number.parseInt(button.dataset.simSwitch, 10);
|
|
||||||
const slotInfo = getSimSlot(sim, slot);
|
|
||||||
const isActive = activeSlot?.slot === slot;
|
|
||||||
const unavailable = slotInfo.present === false;
|
|
||||||
button.hidden = singleSlot;
|
|
||||||
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive || unavailable;
|
|
||||||
button.textContent = isActive ? `SIM${slot + 1} 当前` : `切到SIM${slot + 1}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.simBusy || sim.switching) {
|
|
||||||
setSimSwitchStatus('正在切换SIM...');
|
|
||||||
} else if (supported && activeSlot?.present === true) {
|
|
||||||
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`);
|
|
||||||
} else if (supported && activeSlot?.present === false) {
|
|
||||||
setStatusMessage(elements.simStatus, `${getSimDisplayLabel(activeSlot)} 未就绪,请检查是否插卡`);
|
|
||||||
} else if (!supported) {
|
|
||||||
setStatusMessage(elements.simStatus, '双卡不可用');
|
|
||||||
} else {
|
|
||||||
setStatusMessage(elements.simStatus, '');
|
|
||||||
}
|
|
||||||
renderSendSimLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatus(status, info, sim) {
|
|
||||||
const data = status.data || {};
|
const data = status.data || {};
|
||||||
const infoData = info.data || {};
|
const infoData = info.data || {};
|
||||||
const model = data.model || infoData.model || {};
|
const model = data.model || infoData.model || {};
|
||||||
const signal = data.signal || {};
|
const signal = data.signal || {};
|
||||||
const ready = Boolean(data.ready);
|
const ready = Boolean(data.ready);
|
||||||
const simData = sim?.data || data.sim || {};
|
|
||||||
const activeSim = getActiveSimSlot(simData);
|
|
||||||
|
|
||||||
setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪');
|
setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪');
|
||||||
setText(elements.panelStatus, ready ? 'ready' : 'not ready');
|
setText(elements.panelStatus, ready ? 'ready' : 'not ready');
|
||||||
@@ -404,7 +165,6 @@ function renderStatus(status, info, sim) {
|
|||||||
setText(elements.operator, data.operator || '未知');
|
setText(elements.operator, data.operator || '未知');
|
||||||
setText(elements.signalQuality, signal.quality || '未知');
|
setText(elements.signalQuality, signal.quality || '未知');
|
||||||
setText(elements.uptime, formatUptime(data.uptime));
|
setText(elements.uptime, formatUptime(data.uptime));
|
||||||
renderMobileData(data.mobileData || {});
|
|
||||||
|
|
||||||
setText(elements.connectionState, ready ? '运行中' : '未就绪');
|
setText(elements.connectionState, ready ? '运行中' : '未就绪');
|
||||||
setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
|
setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
|
||||||
@@ -412,8 +172,7 @@ function renderStatus(status, info, sim) {
|
|||||||
setText(elements.signalHint, signal.ber === undefined ? 'RSSI 与误码率将在刷新后显示。' : `误码率 ${signal.ber},质量 ${signal.quality || '未知'}。`);
|
setText(elements.signalHint, signal.ber === undefined ? 'RSSI 与误码率将在刷新后显示。' : `误码率 ${signal.ber},质量 ${signal.quality || '未知'}。`);
|
||||||
setText(elements.moduleModel, model.model || '--');
|
setText(elements.moduleModel, model.model || '--');
|
||||||
setText(elements.moduleVersion, model.version || '--');
|
setText(elements.moduleVersion, model.version || '--');
|
||||||
setText(elements.currentSim, getSimDisplayLabel(activeSim) || infoData.simLabel || '--');
|
setText(elements.iccid, infoData.iccid || '--');
|
||||||
renderSimStatus(simData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseLogLine(line) {
|
function parseLogLine(line) {
|
||||||
@@ -479,13 +238,9 @@ function formatDateTime(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages(messages, meta = {}) {
|
function renderMessages(messages) {
|
||||||
elements.messageList.replaceChildren();
|
elements.messageList.replaceChildren();
|
||||||
const countLabel = `最近 ${messages.length} 条`;
|
setText(elements.messageCount, `最近 ${messages.length} 条`);
|
||||||
const scopeLabel = '新短信自动显示';
|
|
||||||
|
|
||||||
setText(elements.messageCount, countLabel);
|
|
||||||
setText(elements.messageScope, scopeLabel);
|
|
||||||
|
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
@@ -835,111 +590,8 @@ async function refreshLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshMessages() {
|
async function refreshMessages() {
|
||||||
const params = new URLSearchParams({ limit: '50', scope: 'all' });
|
const messages = await requestJSON('/api/sms/received?limit=50');
|
||||||
const messages = await requestJSON(`/api/sms/received?${params.toString()}`);
|
renderMessages(messages.data || []);
|
||||||
renderMessages(messages.data || [], messages.meta || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSim() {
|
|
||||||
const sim = await requestJSON('/api/modem/sim?refresh=true');
|
|
||||||
renderSimStatus(sim.data || {});
|
|
||||||
return sim;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchSim(slot) {
|
|
||||||
if (state.simBusy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slotNumber = Number.parseInt(slot, 10);
|
|
||||||
if (!Number.isInteger(slotNumber)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.confirm(`切换到SIM${slotNumber + 1}会重新初始化SIM、驻网和短信功能,期间可能短暂收不到短信。确定继续吗?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.simBusy = true;
|
|
||||||
renderSimStatus({
|
|
||||||
...(state.currentSim || {}),
|
|
||||||
switching: true
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await requestJSON('/api/modem/sim/switch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ slot: slotNumber })
|
|
||||||
});
|
|
||||||
renderSimStatus(result.data || {});
|
|
||||||
setSimSwitchStatus(result.message || `已切换到SIM${slotNumber + 1}`, 'success');
|
|
||||||
await refreshAll();
|
|
||||||
} catch (err) {
|
|
||||||
setSimSwitchStatus(err.message, 'error');
|
|
||||||
await refreshSim().catch(() => {});
|
|
||||||
} finally {
|
|
||||||
state.simBusy = false;
|
|
||||||
renderSimStatus(state.currentSim || {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleMobileData() {
|
|
||||||
if (state.mobileDataBusy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = elements.mobileDataToggle.dataset.action || 'disable';
|
|
||||||
const enabled = action === 'enable';
|
|
||||||
|
|
||||||
if (enabled && !window.confirm('开启移动数据可能产生流量费用,确定要开启吗?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.mobileDataBusy = true;
|
|
||||||
elements.mobileDataToggle.disabled = true;
|
|
||||||
setStatusMessage(elements.mobileDataStatus, enabled ? '正在开启...' : '正在关闭...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await requestJSON('/api/modem/mobile-data', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ enabled })
|
|
||||||
});
|
|
||||||
renderMobileData(result.data || {});
|
|
||||||
setStatusMessage(elements.mobileDataStatus, result.message || (enabled ? '已开启。' : '已关闭。'), 'success');
|
|
||||||
await refreshLogs();
|
|
||||||
} catch (err) {
|
|
||||||
setStatusMessage(elements.mobileDataStatus, err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
state.mobileDataBusy = false;
|
|
||||||
renderMobileData(state.currentMobileData || {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function consumeMobileDataTraffic() {
|
|
||||||
if (state.mobileDataBusy || state.trafficBusy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.trafficBusy = true;
|
|
||||||
elements.mobileDataToggle.disabled = true;
|
|
||||||
elements.mobileDataConsume.disabled = true;
|
|
||||||
setStatusMessage(elements.mobileDataStatus, '正在执行MPING,会消耗少量流量...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await requestJSON('/api/modem/mobile-data/consume', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ target: '8.8.8.8' })
|
|
||||||
});
|
|
||||||
renderMobileData(result.data?.mobileData || state.currentMobileData || {});
|
|
||||||
const pingMessage = result.data?.ping?.message || result.message || '已完成流量消耗测试。';
|
|
||||||
setStatusMessage(elements.mobileDataStatus, pingMessage, 'success');
|
|
||||||
await refreshLogs();
|
|
||||||
} catch (err) {
|
|
||||||
setStatusMessage(elements.mobileDataStatus, err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
state.trafficBusy = false;
|
|
||||||
renderMobileData(state.currentMobileData || {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -954,9 +606,7 @@ async function refreshAll() {
|
|||||||
try {
|
try {
|
||||||
const status = await requestJSON('/api/status');
|
const status = await requestJSON('/api/status');
|
||||||
const info = await requestJSON('/api/modem/info');
|
const info = await requestJSON('/api/modem/info');
|
||||||
const sim = await requestJSON('/api/modem/sim?refresh=true');
|
renderStatus(status, info);
|
||||||
renderStatus(status, info, sim);
|
|
||||||
setStatusMessage(elements.mobileDataStatus, '');
|
|
||||||
await refreshMessages();
|
await refreshMessages();
|
||||||
await refreshLogs();
|
await refreshLogs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -985,8 +635,7 @@ async function sendSMS(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
renderSendSimLabel();
|
setStatusMessage(elements.smsStatus, '发送中...');
|
||||||
setStatusMessage(elements.smsStatus, `正在使用 ${getActiveSimLabel()} 发送...`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await requestJSON('/api/sms/send', {
|
const result = await requestJSON('/api/sms/send', {
|
||||||
@@ -1014,18 +663,18 @@ async function runATCommand(event) {
|
|||||||
|
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
setText(elements.consoleState, 'running');
|
setText(elements.consoleState, 'running');
|
||||||
elements.atOutput.textContent = `› ${command}\n等待响应...`;
|
elements.atOutput.textContent = `> ${command}\n等待响应...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await requestJSON('/api/modem/at', {
|
const result = await requestJSON('/api/modem/at', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ command })
|
body: JSON.stringify({ command })
|
||||||
});
|
});
|
||||||
elements.atOutput.textContent = `› ${command}\n${result.data.response || ''}`.trim();
|
elements.atOutput.textContent = `> ${command}\n${result.data.response || ''}`.trim();
|
||||||
setText(elements.consoleState, 'done');
|
setText(elements.consoleState, 'done');
|
||||||
await refreshLogs();
|
await refreshLogs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
elements.atOutput.textContent = `› ${command}\nERROR: ${err.message}`;
|
elements.atOutput.textContent = `> ${command}\nERROR: ${err.message}`;
|
||||||
setText(elements.consoleState, 'error');
|
setText(elements.consoleState, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
@@ -1045,30 +694,10 @@ elements.pushChannelForm.addEventListener('submit', savePushChannels);
|
|||||||
elements.pushTestButton.addEventListener('click', testPushChannel);
|
elements.pushTestButton.addEventListener('click', testPushChannel);
|
||||||
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
|
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
|
||||||
elements.refreshButton.addEventListener('click', refreshAll);
|
elements.refreshButton.addEventListener('click', refreshAll);
|
||||||
elements.mobileDataToggle.addEventListener('click', toggleMobileData);
|
|
||||||
elements.mobileDataConsume.addEventListener('click', consumeMobileDataTraffic);
|
|
||||||
elements.simSwitchButtons.forEach((button) => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
switchSim(button.dataset.simSwitch);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
elements.logsButton.addEventListener('click', refreshLogs);
|
elements.logsButton.addEventListener('click', refreshLogs);
|
||||||
elements.messagesButton.addEventListener('click', refreshMessages);
|
elements.messagesButton.addEventListener('click', refreshMessages);
|
||||||
elements.smsForm.addEventListener('submit', sendSMS);
|
elements.smsForm.addEventListener('submit', sendSMS);
|
||||||
elements.atForm.addEventListener('submit', runATCommand);
|
elements.atForm.addEventListener('submit', runATCommand);
|
||||||
|
|
||||||
const ctaPushButton = document.querySelector('#ctaPushButton');
|
|
||||||
const ctaRefreshButton = document.querySelector('#ctaRefreshButton');
|
|
||||||
const footerPushLink = document.querySelector('#footerPushLink');
|
|
||||||
const footerYear = document.querySelector('#footerYear');
|
|
||||||
|
|
||||||
if (ctaPushButton) ctaPushButton.addEventListener('click', openPushModal);
|
|
||||||
if (ctaRefreshButton) ctaRefreshButton.addEventListener('click', refreshAll);
|
|
||||||
if (footerPushLink) footerPushLink.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
openPushModal();
|
|
||||||
});
|
|
||||||
if (footerYear) footerYear.textContent = String(new Date().getFullYear());
|
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
|
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
|
||||||
closePushModal();
|
closePushModal();
|
||||||
|
|||||||
+38
-153
@@ -50,7 +50,7 @@ class APIServer {
|
|||||||
// JSON解析
|
// JSON解析
|
||||||
this.app.use(express.json());
|
this.app.use(express.json());
|
||||||
|
|
||||||
// Web Token认证
|
// Web Token认证,API保留Basic Auth兼容
|
||||||
this.app.use((req, res, next) => {
|
this.app.use((req, res, next) => {
|
||||||
this.authenticateRequest(req, res, next);
|
this.authenticateRequest(req, res, next);
|
||||||
});
|
});
|
||||||
@@ -75,23 +75,22 @@ class APIServer {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isWebRoute(req)) {
|
if (this.isWebRoute(req) && this.getConfiguredWebToken()) {
|
||||||
const webToken = this.getConfiguredWebToken();
|
return this.sendTokenGate(res);
|
||||||
if (webToken) {
|
}
|
||||||
return this.sendTokenGate(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(401).send('未配置 webToken');
|
if (this.hasValidBasicAuth(req)) {
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.path.startsWith('/api/')) {
|
if (req.path.startsWith('/api/')) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: this.getConfiguredWebToken() ? '需要有效token' : '未配置webToken'
|
error: this.getConfiguredWebToken() ? '需要有效token或Basic Auth' : '需要Basic Auth'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(401).send(this.getConfiguredWebToken() ? '需要有效token' : '未配置 webToken');
|
return this.sendBasicAuthChallenge(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfiguredWebToken() {
|
getConfiguredWebToken() {
|
||||||
@@ -144,6 +143,24 @@ class APIServer {
|
|||||||
return `${url.pathname}${url.search}`;
|
return `${url.pathname}${url.search}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasValidBasicAuth(req) {
|
||||||
|
const auth = req.get('authorization') || '';
|
||||||
|
if (!auth.startsWith('Basic ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
||||||
|
const separatorIndex = decoded.indexOf(':');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = decoded.slice(0, separatorIndex);
|
||||||
|
const password = decoded.slice(separatorIndex + 1);
|
||||||
|
return this.safeEqual(username, this.config.api.auth.username) &&
|
||||||
|
this.safeEqual(password, this.config.api.auth.password);
|
||||||
|
}
|
||||||
|
|
||||||
safeEqual(actual, expected) {
|
safeEqual(actual, expected) {
|
||||||
const actualBuffer = Buffer.from(String(actual));
|
const actualBuffer = Buffer.from(String(actual));
|
||||||
const expectedBuffer = Buffer.from(String(expected));
|
const expectedBuffer = Buffer.from(String(expected));
|
||||||
@@ -159,6 +176,11 @@ class APIServer {
|
|||||||
return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/');
|
return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendBasicAuthChallenge(res) {
|
||||||
|
res.set('WWW-Authenticate', 'Basic realm="SMS Gateway"');
|
||||||
|
return res.status(401).send('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
sendTokenGate(res) {
|
sendTokenGate(res) {
|
||||||
res.set('Cache-Control', 'no-store');
|
res.set('Cache-Control', 'no-store');
|
||||||
return res.status(401).type('html').send(`<!doctype html>
|
return res.status(401).type('html').send(`<!doctype html>
|
||||||
@@ -342,7 +364,6 @@ class APIServer {
|
|||||||
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,
|
||||||
@@ -351,8 +372,6 @@ 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()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -377,14 +396,11 @@ class APIServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.modem.sendSMS(phone, message);
|
const success = await this.modem.sendSMS(phone, message);
|
||||||
const success = Boolean(result?.success);
|
|
||||||
const simLabel = result?.simLabel || '未知SIM';
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success,
|
success,
|
||||||
message: success ? `短信发送成功,发送SIM: ${simLabel}` : `短信发送失败,发送SIM: ${simLabel}`,
|
message: success ? '短信发送成功' : '短信发送失败'
|
||||||
data: result
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('发送短信失败:', err);
|
logger.error('发送短信失败:', err);
|
||||||
@@ -396,20 +412,12 @@ class APIServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 查询收到的短信
|
// 查询收到的短信
|
||||||
this.app.get('/api/sms/received', async (req, res) => {
|
this.app.get('/api/sms/received', (req, res) => {
|
||||||
try {
|
try {
|
||||||
await this.smsProcessor.getCurrentSimIdentity();
|
const messages = this.smsProcessor.getReceivedMessages(req.query.limit);
|
||||||
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.messages,
|
data: messages
|
||||||
meta: result.meta
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('查询收到短信失败:', err);
|
logger.error('查询收到短信失败:', err);
|
||||||
@@ -440,19 +448,12 @@ class APIServer {
|
|||||||
// 查询模组信息
|
// 查询模组信息
|
||||||
this.app.get('/api/modem/info', async (req, res) => {
|
this.app.get('/api/modem/info', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const simStatus = this.modem.getCachedSimStatus?.();
|
const iccid = await this.modem.getICCID();
|
||||||
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,
|
||||||
phoneNumber: currentSlot?.phoneNumber || '',
|
iccid,
|
||||||
simId: simIdentity?.simId || null,
|
|
||||||
simLabel: simIdentity?.simLabel || '未知SIM',
|
|
||||||
ready: this.modem.ready
|
ready: this.modem.ready
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -465,122 +466,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命令
|
// 发送AT命令
|
||||||
this.app.post('/api/modem/at', async (req, res) => {
|
this.app.post('/api/modem/at', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+8
-15
@@ -13,12 +13,10 @@ class ConcatManager extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* 查找或创建长短信槽位
|
* 查找或创建长短信槽位
|
||||||
*/
|
*/
|
||||||
findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
|
findOrCreateSlot(refNumber, sender, totalParts) {
|
||||||
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.simId === simId
|
s.inUse && s.refNumber === refNumber && s.sender === sender
|
||||||
);
|
);
|
||||||
|
|
||||||
if (slot) {
|
if (slot) {
|
||||||
@@ -28,7 +26,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, simIdentity);
|
this.initSlot(slot, refNumber, sender, totalParts);
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,19 +44,17 @@ class ConcatManager extends EventEmitter {
|
|||||||
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
this.initSlot(slot, refNumber, sender, totalParts);
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化槽位
|
* 初始化槽位
|
||||||
*/
|
*/
|
||||||
initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
|
initSlot(slot, refNumber, sender, totalParts) {
|
||||||
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();
|
||||||
@@ -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}`);
|
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开始
|
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
|
||||||
|
|
||||||
if (partIndex >= 0 && partIndex < this.maxParts) {
|
if (partIndex >= 0 && partIndex < this.maxParts) {
|
||||||
@@ -114,8 +110,7 @@ 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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清空槽位
|
// 清空槽位
|
||||||
@@ -129,8 +124,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+10
-20
@@ -46,7 +46,7 @@ async function main() {
|
|||||||
logger.info('配置加载完成');
|
logger.info('配置加载完成');
|
||||||
|
|
||||||
// 创建模组管理器
|
// 创建模组管理器
|
||||||
const modem = new ModemManager(config.serial, config.mobileData, config);
|
const modem = new ModemManager(config.serial);
|
||||||
|
|
||||||
// 创建长短信管理器
|
// 创建长短信管理器
|
||||||
const concatManager = new ConcatManager();
|
const concatManager = new ConcatManager();
|
||||||
@@ -60,9 +60,7 @@ 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
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听模组短信事件
|
// 监听模组短信事件
|
||||||
@@ -113,27 +111,19 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shutdown(signal) {
|
// 优雅退出
|
||||||
logger.info(`\n收到 ${signal} 信号,正在关闭...`);
|
process.on('SIGINT', async () => {
|
||||||
|
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', () => {
|
process.on('SIGTERM', async () => {
|
||||||
shutdown('SIGTERM').catch((err) => {
|
logger.info('\n收到 SIGTERM 信号,正在关闭...');
|
||||||
logger.error('关闭失败:', err);
|
concatManager.stopTimeoutChecker();
|
||||||
process.exit(1);
|
await modem.close();
|
||||||
});
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 捕获未处理的异常
|
// 捕获未处理的异常
|
||||||
|
|||||||
+84
-1832
File diff suppressed because it is too large
Load Diff
+111
-328
@@ -1,5 +1,4 @@
|
|||||||
import { parse } from 'node-pdu';
|
import { parse } from 'node-pdu';
|
||||||
import crypto from 'crypto';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
@@ -18,135 +17,16 @@ class SMSProcessor {
|
|||||||
this.receivedMessageSeq = 0;
|
this.receivedMessageSeq = 0;
|
||||||
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
|
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
|
||||||
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
|
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();
|
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(input) {
|
async processPDU(pduHex) {
|
||||||
try {
|
try {
|
||||||
logger.info('开始解析PDU数据...');
|
logger.info('开始解析PDU数据...');
|
||||||
|
|
||||||
const smsEvent = this.normalizeIncomingPduEvent(input);
|
|
||||||
const pduHex = smsEvent.pdu;
|
|
||||||
|
|
||||||
// 解析PDU
|
// 解析PDU
|
||||||
const parsed = parse(pduHex);
|
const parsed = parse(pduHex);
|
||||||
|
|
||||||
@@ -160,15 +40,10 @@ class SMSProcessor {
|
|||||||
const text = parsed.data?.getText?.() || '';
|
const text = parsed.data?.getText?.() || '';
|
||||||
const part = parsed.data?.parts?.[0];
|
const part = parsed.data?.parts?.[0];
|
||||||
const header = part?.header;
|
const header = part?.header;
|
||||||
const simIdentity = this.resolveIncomingSimIdentity({
|
|
||||||
metadata: smsEvent.metadata
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('✓ PDU解析成功');
|
logger.info('✓ PDU解析成功');
|
||||||
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}`);
|
||||||
|
|
||||||
// 检查是否为长短信
|
// 检查是否为长短信
|
||||||
@@ -187,12 +62,11 @@ class SMSProcessor {
|
|||||||
partNumber,
|
partNumber,
|
||||||
totalParts,
|
totalParts,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp
|
||||||
{ simIdentity }
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 普通短信,直接处理
|
// 普通短信,直接处理
|
||||||
await this.processSmsContent(sender, text, timestamp, { simIdentity });
|
await this.processSmsContent(sender, text, timestamp);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('处理PDU失败:', err);
|
logger.error('处理PDU失败:', err);
|
||||||
@@ -200,22 +74,41 @@ class SMSProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理短信内容并转发
|
* 处理短信内容(管理员命令检查和转发)
|
||||||
*/
|
*/
|
||||||
async processSmsContent(sender, text, timestamp, options = {}) {
|
async processSmsContent(sender, text, timestamp) {
|
||||||
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);
|
const messageRecord = this.addReceivedMessage(sender, text, timestamp);
|
||||||
|
|
||||||
|
// 检查是否在黑名单中
|
||||||
|
if (this.isInBlacklist(sender)) {
|
||||||
|
this.updateReceivedMessage(messageRecord.id, {
|
||||||
|
status: 'ignored',
|
||||||
|
statusText: '黑名单忽略'
|
||||||
|
});
|
||||||
|
logger.info('发送者在黑名单中,忽略该短信');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为管理员命令
|
||||||
|
if (this.isAdmin(sender)) {
|
||||||
|
logger.info('收到管理员短信,检查命令...');
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
|
if (trimmedText.startsWith('SMS:') || trimmedText === 'RESET') {
|
||||||
|
this.updateReceivedMessage(messageRecord.id, {
|
||||||
|
status: 'admin',
|
||||||
|
statusText: '管理员命令'
|
||||||
|
});
|
||||||
|
await this.processAdminCommand(sender, trimmedText);
|
||||||
|
return; // 命令已处理,不再发送普通通知
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 推送到所有通道
|
// 推送到所有通道
|
||||||
await this.pushManager.pushToAll(sender, text, timestamp);
|
await this.pushManager.pushToAll(sender, text, timestamp);
|
||||||
@@ -234,19 +127,13 @@ class SMSProcessor {
|
|||||||
/**
|
/**
|
||||||
* 记录收到的短信,供Web管理端展示。
|
* 记录收到的短信,供Web管理端展示。
|
||||||
*/
|
*/
|
||||||
addReceivedMessage(sender, text, timestamp, simIdentity = null) {
|
addReceivedMessage(sender, text, timestamp) {
|
||||||
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
|
|
||||||
const message = {
|
const message = {
|
||||||
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
||||||
sender,
|
sender,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
simId: normalizedSimIdentity?.simId || '',
|
|
||||||
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
|
|
||||||
simSlot: normalizedSimIdentity?.simSlot ?? null,
|
|
||||||
simSource: normalizedSimIdentity?.simSource || 'unknown',
|
|
||||||
smsCenter: normalizedSimIdentity?.smsCenter || '',
|
|
||||||
status: 'received',
|
status: 'received',
|
||||||
statusText: '已接收'
|
statusText: '已接收'
|
||||||
};
|
};
|
||||||
@@ -257,7 +144,7 @@ class SMSProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.persistReceivedMessages();
|
this.persistReceivedMessages();
|
||||||
logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
|
logger.info(`短信已进入Web收件箱: ${sender}`);
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,87 +163,8 @@ class SMSProcessor {
|
|||||||
* 获取最近收到的短信。
|
* 获取最近收到的短信。
|
||||||
*/
|
*/
|
||||||
getReceivedMessages(limit = 50) {
|
getReceivedMessages(limit = 50) {
|
||||||
return this.receivedMessages.slice(0, this.resolveReceivedMessagesLimit(limit));
|
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
||||||
}
|
return this.receivedMessages.slice(0, safeLimit);
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取按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) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageSlot = this.normalizeSimSlot(message.simSlot);
|
|
||||||
const currentSlot = this.normalizeSimSlot(currentSim.simSlot);
|
|
||||||
return messageSlot !== null && currentSlot !== null && messageSlot === currentSlot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 限制收件箱查询条数。
|
|
||||||
*/
|
|
||||||
resolveReceivedMessagesLimit(limit = 50) {
|
|
||||||
return Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -371,20 +179,6 @@ class SMSProcessor {
|
|||||||
return Math.min(Math.floor(configured), 5000);
|
return Math.min(Math.floor(configured), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否按当前SIM隔离Web收件箱。默认开启,避免换卡后读取其他SIM历史。
|
|
||||||
*/
|
|
||||||
resolvePartitionMessagesBySim() {
|
|
||||||
return this.config.inbox?.partitionBySim !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否允许显式查询全部SIM历史。默认关闭。
|
|
||||||
*/
|
|
||||||
resolveAllowAllSimMessages() {
|
|
||||||
return this.config.inbox?.allowAllSimMessages === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析收件箱持久化文件路径。
|
* 解析收件箱持久化文件路径。
|
||||||
*/
|
*/
|
||||||
@@ -463,123 +257,112 @@ class SMSProcessor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const simIdentity = this.normalizeStoredSimIdentity(item);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sender: String(item.sender || '未知号码'),
|
sender: String(item.sender || '未知号码'),
|
||||||
text: String(item.text || ''),
|
text: String(item.text || ''),
|
||||||
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
|
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
|
||||||
receivedAt: String(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'),
|
status: String(item.status || 'received'),
|
||||||
statusText: String(item.statusText || '已接收')
|
statusText: String(item.statusText || '已接收')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从历史记录中恢复SIM身份。旧记录没有SIM字段,会归到未知SIM。
|
* 检查是否在黑名单中
|
||||||
*/
|
*/
|
||||||
normalizeStoredSimIdentity(item) {
|
isInBlacklist(sender) {
|
||||||
if (item.simId || item.simLabel) {
|
const blacklist = this.config.numberBlackList || [];
|
||||||
return this.normalizeSimIdentity({
|
|
||||||
simId: item.simId,
|
// 检查完整号码
|
||||||
simLabel: item.simLabel,
|
if (blacklist.includes(sender)) {
|
||||||
simSlot: item.simSlot,
|
return true;
|
||||||
simSource: item.simSource
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.sim && typeof item.sim === 'object') {
|
// 检查去除+86后的号码
|
||||||
return this.normalizeSimIdentity(item.sim);
|
if (sender.startsWith('+86')) {
|
||||||
|
const stripped = sender.substring(3);
|
||||||
|
if (blacklist.includes(stripped)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
|
* 检查是否为管理员
|
||||||
*/
|
*/
|
||||||
async getCurrentSimIdentity(options = {}) {
|
isAdmin(sender) {
|
||||||
const refresh = options.refresh === true;
|
if (!this.config.adminPhone) {
|
||||||
const now = Date.now();
|
return false;
|
||||||
|
|
||||||
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
|
|
||||||
return this.currentSimIdentity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentSimLookup) {
|
let senderStr = sender;
|
||||||
return this.currentSimLookup;
|
let adminStr = this.config.adminPhone;
|
||||||
|
|
||||||
|
// 去除+86前缀
|
||||||
|
if (senderStr.startsWith('+86')) {
|
||||||
|
senderStr = senderStr.substring(3);
|
||||||
|
}
|
||||||
|
if (adminStr.startsWith('+86')) {
|
||||||
|
adminStr = adminStr.substring(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.modem?.isSimSwitching?.()) {
|
return senderStr === adminStr;
|
||||||
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) {
|
async processAdminCommand(sender, command) {
|
||||||
this.currentSimIdentity = this.normalizeSimIdentity(identity);
|
logger.info(`处理管理员命令: ${command}`);
|
||||||
this.currentSimCheckedAt = Date.now();
|
|
||||||
return this.currentSimIdentity;
|
// 处理 SMS:号码:内容 命令
|
||||||
|
if (command.startsWith('SMS:')) {
|
||||||
|
const parts = command.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const targetPhone = parts[1].trim();
|
||||||
|
const smsContent = parts.slice(2).join(':').trim(); // 支持内容中包含冒号
|
||||||
|
|
||||||
|
logger.info(`目标号码: ${targetPhone}`);
|
||||||
|
logger.info(`短信内容: ${smsContent}`);
|
||||||
|
|
||||||
|
const success = await this.modem.sendSMS(targetPhone, smsContent);
|
||||||
|
|
||||||
|
// 发送邮件通知结果
|
||||||
|
const subject = success ? '短信发送成功' : '短信发送失败';
|
||||||
|
const body = `管理员命令执行结果:\n命令: ${command}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`;
|
||||||
|
await this.pushManager.sendEmail(subject, body);
|
||||||
|
} else {
|
||||||
|
logger.error('SMS命令格式错误');
|
||||||
|
await this.pushManager.sendEmail(
|
||||||
|
'命令执行失败',
|
||||||
|
'SMS命令格式错误,正确格式: SMS:号码:内容'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 处理 RESET 命令
|
||||||
|
else if (command === 'RESET') {
|
||||||
|
logger.info('执行RESET命令');
|
||||||
|
|
||||||
|
// 先发送邮件通知
|
||||||
|
await this.pushManager.sendEmail(
|
||||||
|
'重启命令已执行',
|
||||||
|
'收到RESET命令,即将重启系统...'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 等待邮件发送完成
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// 重启进程
|
||||||
|
logger.info('正在重启系统...');
|
||||||
|
process.exit(0); // PM2会自动重启
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.warn(`未知命令: ${command}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user