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 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NPM_CONFIG_AUDIT=false \
|
||||
NPM_CONFIG_FUND=false \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache nodejs npm python3 make g++
|
||||
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 ./
|
||||
RUN npm ci --omit=dev \
|
||||
&& 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
|
||||
|
||||
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 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": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200,
|
||||
"autoDetect": true,
|
||||
"probeTimeout": 1200,
|
||||
"_comment": "必填。path是手动串口或自动探测优先项;autoDetect=false时必须写真实串口。Windows使用COM1/COM2/COM3等,也可以直接写数字如3;Linux使用/dev/ttyUSB0。baudRate通常为115200,probeTimeout为AT探测超时毫秒。"
|
||||
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
|
||||
},
|
||||
"mobileData": {
|
||||
"cid": 1,
|
||||
"_comment": "可选,默认cid=1。有效范围1-15,cid8为IMS专用,请勿使用。ML307每次启动都会强制断开MIPCALL应用层拨号,并保护性尝试CGACT;管理台开关只影响当前运行期。"
|
||||
},
|
||||
"_simCards_comment": "可选。用于给SIM1/SIM2显示手机号;slot=0是SIM1,slot=1是SIM2。也兼容旧字段名simSlots、sims、sim。",
|
||||
"simCards": [
|
||||
{
|
||||
"slot": 0,
|
||||
"phoneNumber": "+8613800138000"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"phoneNumber": "+447700900000"
|
||||
}
|
||||
],
|
||||
"smtp": {
|
||||
"_comment": "可选。填写server后会启用邮件通知;启用时server、port、user、pass、sendTo都需要正确填写。不需要邮件可删除smtp或清空server。",
|
||||
"server": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"user": "your@qq.com",
|
||||
"pass": "your_auth_code",
|
||||
"sendTo": "recipient@example.com"
|
||||
},
|
||||
"inbox": {
|
||||
"maxReceivedMessages": 200,
|
||||
"receivedMessagesFile": "data/received-messages.json",
|
||||
"partitionBySim": true,
|
||||
"allowAllSimMessages": false,
|
||||
"dualSimDefaultScope": "all",
|
||||
"_comment": "全部可选。maxReceivedMessages默认200且最大5000;receivedMessagesFile默认data/received-messages.json;partitionBySim默认true;allowAllSimMessages默认false;dualSimDefaultScope可为all或current。默认显示全部短信;直出URC未携带卡槽时,新短信不区分SIM1/SIM2收件归属。"
|
||||
},
|
||||
"_pushChannels_comment": "可选。enabled=false时目标字段可以为空;enabled=true时按type要求填写目标:post_json/bark/get/dingtalk/custom/feishu需要url,telegram需要url(bot token)+key1(chat id),pushplus需要key1(token),serverchan需要key1(sendkey)。customBody如果填写必须是合法JSON字符串。",
|
||||
"adminPhone": "13800138000",
|
||||
"numberBlackList": [
|
||||
"10086",
|
||||
"10010"
|
||||
],
|
||||
"pushChannels": [
|
||||
{
|
||||
"enabled": true,
|
||||
@@ -93,6 +71,9 @@
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"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",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"node-pdu": "^2.1.2",
|
||||
"nodemailer": "^6.9.7",
|
||||
"serialport": "^12.0.0",
|
||||
@@ -2044,4 +2045,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@
|
||||
"@serialport/parser-readline": "^12.0.0",
|
||||
"node-pdu": "^2.1.2",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"axios": "^1.6.2",
|
||||
"winston": "^3.11.0",
|
||||
@@ -31,4 +32,4 @@
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-233
@@ -1,30 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMS Gateway · 管理台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<title>SMS Gateway 管理台</title>
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
<div class="top-nav__inner">
|
||||
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
|
||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
<span class="brand__name">SMS Gateway</span>
|
||||
<span class="brand__mark" aria-hidden="true">✣</span>
|
||||
<span>SMS Gateway</span>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="管理导航">
|
||||
<a href="#dashboard">状态</a>
|
||||
<a href="#sim">SIM</a>
|
||||
<a href="#status">状态</a>
|
||||
<a href="#inbox">收件箱</a>
|
||||
<a href="#send">短信</a>
|
||||
<a href="#console">AT 控制台</a>
|
||||
@@ -32,172 +22,91 @@
|
||||
</nav>
|
||||
<div class="top-nav__actions">
|
||||
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
|
||||
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ HERO ============ -->
|
||||
<section class="hero-band" id="status">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-copy">
|
||||
<span class="eyebrow eyebrow--coral">
|
||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
||||
<span id="readyBadge">连接检测中</span>
|
||||
</span>
|
||||
<h1>短信<em>网关</em><br>管理台</h1>
|
||||
<p class="lede">集中查看 ML307A 模组状态、收发短信、执行 AT 命令、检查运行日志。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="text-link" href="#send">发送短信</a>
|
||||
<span class="hero-divider" aria-hidden="true">·</span>
|
||||
<a class="text-link" href="#console">查看 AT 控制台</a>
|
||||
</div>
|
||||
<span class="badge badge--coral" id="readyBadge">连接检测中</span>
|
||||
<h1>短信网关管理台</h1>
|
||||
<p>集中查看 ML307A 模组状态、发送短信、执行 AT 命令,并检查运行日志。</p>
|
||||
</div>
|
||||
|
||||
<aside class="runtime-card" aria-label="运行摘要">
|
||||
<header class="runtime-card__header">
|
||||
<span class="runtime-card__label">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
runtime
|
||||
</span>
|
||||
<span class="runtime-card__status">
|
||||
<span class="status-dot" id="panelStatusDot" aria-hidden="true"></span>
|
||||
<strong id="panelStatus">loading</strong>
|
||||
</span>
|
||||
</header>
|
||||
<div class="runtime-card__hero">
|
||||
<span class="runtime-card__caption">uptime</span>
|
||||
<span class="runtime-card__value" id="uptime">--</span>
|
||||
<div class="product-panel" aria-label="运行摘要">
|
||||
<div class="panel-header">
|
||||
<span>runtime</span>
|
||||
<strong id="panelStatus">loading</strong>
|
||||
</div>
|
||||
<dl class="runtime-card__meta">
|
||||
<dl class="runtime-list">
|
||||
<div>
|
||||
<dt>operator</dt>
|
||||
<dt>模组</dt>
|
||||
<dd id="modelName">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运营商</dt>
|
||||
<dd id="operator">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>signal</dt>
|
||||
<dt>信号</dt>
|
||||
<dd id="signalQuality">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行时长</dt>
|
||||
<dd id="uptime">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ STATUS BENTO ============ -->
|
||||
<section class="section-band section-band--soft" id="dashboard">
|
||||
<section class="section-band section-band--soft">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
status board
|
||||
</span>
|
||||
<h2>当前<em>状态</em></h2>
|
||||
</div>
|
||||
<button class="button button--primary" id="refreshButton" type="button">刷新状态</button>
|
||||
<h2>当前状态</h2>
|
||||
<p>这些数据来自模组实时 AT 查询,刷新时会串行读取,避免同时发送多条 AT 命令。</p>
|
||||
</div>
|
||||
|
||||
<div class="bento-grid">
|
||||
<article class="bento-hero">
|
||||
<span class="eyebrow eyebrow--coral">
|
||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
||||
connection
|
||||
</span>
|
||||
<div class="status-grid">
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">connection</span>
|
||||
<h3 id="connectionState">--</h3>
|
||||
<p id="connectionHint">等待状态刷新。</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-control">
|
||||
<span class="eyebrow">mobile data</span>
|
||||
<h3 id="mobileDataState">--</h3>
|
||||
<p id="mobileDataHint">启动时会自动断开应用层拨号。</p>
|
||||
<div class="bento-control__actions">
|
||||
<button class="button button--secondary button--compact" id="mobileDataToggle" type="button"
|
||||
data-action="disable">强制关闭流量</button>
|
||||
<button class="button button--primary button--compact is-hidden" id="mobileDataConsume"
|
||||
type="button">消耗一点流量</button>
|
||||
<span class="form-status mobile-data-traffic-hint" id="mobileDataTrafficHint">开启流量后才会显示消耗流量按钮。</span>
|
||||
<p class="form-status mobile-data-status" id="mobileDataStatus" role="status"></p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="sim-manager" id="sim">
|
||||
<div class="sim-manager__header">
|
||||
<div>
|
||||
<span class="eyebrow">sim manager</span>
|
||||
<h3 id="simMode">--</h3>
|
||||
</div>
|
||||
<p class="form-status sim-manager__status" id="simStatus" role="status"></p>
|
||||
</div>
|
||||
|
||||
<div class="sim-slot-grid" aria-label="SIM卡槽">
|
||||
<article class="sim-slot" id="simSlot0">
|
||||
<div class="sim-slot__topline">
|
||||
<span class="sim-slot__name">SIM1</span>
|
||||
<span class="sim-slot__state" id="simSlot0State">--</span>
|
||||
</div>
|
||||
<span class="sim-slot__phone" id="simSlot0Phone">--</span>
|
||||
</article>
|
||||
|
||||
<article class="sim-slot" id="simSlot1">
|
||||
<div class="sim-slot__topline">
|
||||
<span class="sim-slot__name">SIM2</span>
|
||||
<span class="sim-slot__state" id="simSlot1State">--</span>
|
||||
</div>
|
||||
<span class="sim-slot__phone" id="simSlot1Phone">--</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-strip">
|
||||
<article class="stat-tile">
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">signal</span>
|
||||
<span class="stat-tile__value" id="signalValue">--</span>
|
||||
<span class="stat-tile__hint" id="signalHint">RSSI 与误码率将在刷新后显示。</span>
|
||||
<h3 id="signalValue">--</h3>
|
||||
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">module</span>
|
||||
<span class="stat-tile__value" id="moduleModel">--</span>
|
||||
<span class="stat-tile__hint" id="moduleVersion">--</span>
|
||||
<h3 id="moduleModel">--</h3>
|
||||
<p id="moduleVersion">--</p>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">current sim</span>
|
||||
<span class="stat-tile__value" id="currentSim">--</span>
|
||||
<span class="stat-tile__hint">发送短信会使用当前绑定的 SIM。</span>
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">sim</span>
|
||||
<h3 id="iccid">--</h3>
|
||||
<p>ICCID 查询来自当前串口模组。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ INBOX ============ -->
|
||||
<section class="section-band" id="inbox">
|
||||
<div class="container">
|
||||
<section class="section-band">
|
||||
<div class="container" id="inbox">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
inbox
|
||||
</span>
|
||||
<h2>收到的<em>短信</em></h2>
|
||||
</div>
|
||||
<div class="inbox-actions">
|
||||
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
|
||||
<span class="badge badge--soft">inbox</span>
|
||||
<h2>收到的短信</h2>
|
||||
</div>
|
||||
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
|
||||
</div>
|
||||
<div class="inbox-panel">
|
||||
<div class="inbox-meta">
|
||||
<span id="messageCount">最近 0 条</span>
|
||||
<span class="inbox-meta__hint" id="messageScope">新短信会自动出现在这里</span>
|
||||
<span>新短信会自动出现在这里</span>
|
||||
</div>
|
||||
<div class="message-list" id="messageList" aria-live="polite">
|
||||
<p class="empty-state empty-state--light">暂无收到的短信。</p>
|
||||
@@ -206,38 +115,21 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WORKBENCH ============ -->
|
||||
<section class="section-band section-band--soft">
|
||||
<div class="container workbench-grid">
|
||||
<section class="form-card" id="send">
|
||||
<div class="section-heading section-heading--compact">
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
compose
|
||||
</span>
|
||||
<h2>发送<em>短信</em></h2>
|
||||
<p>PDU 模式发送,提交后等待模组返回结果。</p>
|
||||
</div>
|
||||
<p class="send-sim-banner" aria-live="polite">
|
||||
<span>当前发送SIM</span>
|
||||
<strong id="sendSimLabel">--</strong>
|
||||
</p>
|
||||
<div class="send-sim-picker" aria-label="选择发送SIM">
|
||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">SIM1</button>
|
||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">SIM2</button>
|
||||
<h2>发送短信</h2>
|
||||
<p>使用当前 PDU 模式发送短信,提交后等待模组返回发送结果。</p>
|
||||
</div>
|
||||
<form id="smsForm" class="stack-form">
|
||||
<label class="field">
|
||||
<span class="field__label">目标号码</span>
|
||||
<label>
|
||||
<span>目标号码</span>
|
||||
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">短信内容</span>
|
||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容"
|
||||
required></textarea>
|
||||
<label>
|
||||
<span>短信内容</span>
|
||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容" required></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="button button--primary" type="submit">发送短信</button>
|
||||
@@ -247,44 +139,27 @@
|
||||
</section>
|
||||
|
||||
<section class="console-card" id="console">
|
||||
<header class="panel-header">
|
||||
<span class="panel-header__label">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
at console
|
||||
</span>
|
||||
<span class="panel-header__status">
|
||||
<span class="status-dot status-dot--amber" aria-hidden="true"></span>
|
||||
<strong id="consoleState">idle</strong>
|
||||
</span>
|
||||
</header>
|
||||
<div class="panel-header">
|
||||
<span>AT console</span>
|
||||
<strong id="consoleState">idle</strong>
|
||||
</div>
|
||||
<form id="atForm" class="at-form">
|
||||
<input class="text-input text-input--mono" name="command" value="AT+CSQ" autocomplete="off"
|
||||
spellcheck="false" required>
|
||||
<button class="button button--primary" type="submit">执行</button>
|
||||
<input class="text-input text-input--dark" name="command" value="AT+CSQ" autocomplete="off" spellcheck="false" required>
|
||||
<button class="button button--secondary-on-dark" type="submit">执行</button>
|
||||
</form>
|
||||
<pre class="console-output" id="atOutput">› 等待命令...</pre>
|
||||
<pre class="console-output" id="atOutput">等待命令...</pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<section class="section-band" id="logs">
|
||||
<section class="section-band section-band--dark" id="logs">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div class="dark-heading">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
logs
|
||||
</span>
|
||||
<h2>运行<em>日志</em></h2>
|
||||
<span class="badge badge--dark">logs</span>
|
||||
<h2>运行日志</h2>
|
||||
</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 class="log-window" id="logWindow" aria-live="polite">
|
||||
<p class="empty-state">等待日志加载。</p>
|
||||
@@ -293,32 +168,21 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ============ PUSH MODAL ============ -->
|
||||
<div class="modal" id="pushModal" aria-hidden="true">
|
||||
<div class="modal__backdrop" data-close-push-modal></div>
|
||||
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
|
||||
<header class="modal__header">
|
||||
<div class="modal__header">
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
push channels
|
||||
</span>
|
||||
<h2 id="pushModalTitle">推送渠道<em>设置</em></h2>
|
||||
<span class="badge badge--soft">push</span>
|
||||
<h2 id="pushModalTitle">推送渠道设置</h2>
|
||||
</div>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button>
|
||||
</div>
|
||||
|
||||
<div class="push-settings">
|
||||
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
||||
<div class="panel-header panel-header--light">
|
||||
<span class="panel-header__label">channels</span>
|
||||
<span>channels</span>
|
||||
<div class="channel-add">
|
||||
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
@@ -345,13 +209,13 @@
|
||||
<span>启用通道</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">通道名称</span>
|
||||
<label>
|
||||
<span>通道名称</span>
|
||||
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">推送类型</span>
|
||||
<label>
|
||||
<span>推送类型</span>
|
||||
<select class="text-input select-input" name="type">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
@@ -365,30 +229,29 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field field-row field-row--wide" data-push-field="url">
|
||||
<span class="field__label" id="pushUrlLabel">Webhook URL</span>
|
||||
<label class="field-row field-row--wide" data-push-field="url">
|
||||
<span id="pushUrlLabel">Webhook URL</span>
|
||||
<input class="text-input" type="text" name="url" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field field-row field-row--wide" data-push-field="secret">
|
||||
<span class="field__label" id="pushSecretLabel">加签密钥</span>
|
||||
<label class="field-row field-row--wide" data-push-field="secret">
|
||||
<span id="pushSecretLabel">加签密钥</span>
|
||||
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label class="field field-row" data-push-field="key1">
|
||||
<span class="field__label" id="pushKey1Label">Key1</span>
|
||||
<label class="field-row" data-push-field="key1">
|
||||
<span id="pushKey1Label">Key1</span>
|
||||
<input class="text-input" type="text" name="key1" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field field-row" data-push-field="key2">
|
||||
<span class="field__label" id="pushKey2Label">Key2</span>
|
||||
<label class="field-row" data-push-field="key2">
|
||||
<span id="pushKey2Label">Key2</span>
|
||||
<input class="text-input" type="text" name="key2" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field field-row field-row--wide" data-push-field="customBody">
|
||||
<span class="field__label">自定义请求体 JSON</span>
|
||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7"
|
||||
spellcheck="false"></textarea>
|
||||
<label class="field-row field-row--wide" data-push-field="customBody">
|
||||
<span>自定义请求体 JSON</span>
|
||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7" spellcheck="false"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -406,19 +269,11 @@
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<span class="brand brand--footer">
|
||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
<span>SMS Gateway</span>
|
||||
</span>
|
||||
<span class="footer__meta">© <span id="footerYear">2026</span> · Token protected · Built on Node.js +
|
||||
ML307A</span>
|
||||
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span>
|
||||
<span>Basic Auth protected management surface</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/assets/admin.js" type="module"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
+320
-952
File diff suppressed because it is too large
Load Diff
+12
-383
@@ -1,10 +1,5 @@
|
||||
const state = {
|
||||
busy: false,
|
||||
mobileDataBusy: false,
|
||||
trafficBusy: false,
|
||||
simBusy: false,
|
||||
currentMobileData: null,
|
||||
currentSim: null,
|
||||
pushChannels: [],
|
||||
selectedPushChannel: -1,
|
||||
pushLoaded: false
|
||||
@@ -34,37 +29,18 @@ const elements = {
|
||||
modelName: document.querySelector('#modelName'),
|
||||
operator: document.querySelector('#operator'),
|
||||
signalQuality: document.querySelector('#signalQuality'),
|
||||
mobileDataSummary: document.querySelector('#mobileDataSummary'),
|
||||
uptime: document.querySelector('#uptime'),
|
||||
connectionState: document.querySelector('#connectionState'),
|
||||
connectionHint: document.querySelector('#connectionHint'),
|
||||
signalValue: document.querySelector('#signalValue'),
|
||||
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'),
|
||||
moduleVersion: document.querySelector('#moduleVersion'),
|
||||
currentSim: document.querySelector('#currentSim'),
|
||||
iccid: document.querySelector('#iccid'),
|
||||
smsForm: document.querySelector('#smsForm'),
|
||||
sendSimLabel: document.querySelector('#sendSimLabel'),
|
||||
smsStatus: document.querySelector('#smsStatus'),
|
||||
messagesButton: document.querySelector('#messagesButton'),
|
||||
messageCount: document.querySelector('#messageCount'),
|
||||
messageScope: document.querySelector('#messageScope'),
|
||||
messageList: document.querySelector('#messageList'),
|
||||
atForm: document.querySelector('#atForm'),
|
||||
atOutput: document.querySelector('#atOutput'),
|
||||
@@ -127,12 +103,10 @@ const PUSH_TYPE_META = {
|
||||
};
|
||||
|
||||
function setText(node, value) {
|
||||
if (!node) return;
|
||||
node.textContent = value || '--';
|
||||
}
|
||||
|
||||
function setStatusMessage(node, message, type = '') {
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.classList.toggle('is-success', type === 'success');
|
||||
node.classList.toggle('is-error', type === 'error');
|
||||
@@ -178,225 +152,12 @@ function formatModel(model = {}) {
|
||||
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
|
||||
}
|
||||
|
||||
function formatMobileDataMode(mode) {
|
||||
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) {
|
||||
function renderStatus(status, info) {
|
||||
const data = status.data || {};
|
||||
const infoData = info.data || {};
|
||||
const model = data.model || infoData.model || {};
|
||||
const signal = data.signal || {};
|
||||
const ready = Boolean(data.ready);
|
||||
const simData = sim?.data || data.sim || {};
|
||||
const activeSim = getActiveSimSlot(simData);
|
||||
|
||||
setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪');
|
||||
setText(elements.panelStatus, ready ? 'ready' : 'not ready');
|
||||
@@ -404,7 +165,6 @@ function renderStatus(status, info, sim) {
|
||||
setText(elements.operator, data.operator || '未知');
|
||||
setText(elements.signalQuality, signal.quality || '未知');
|
||||
setText(elements.uptime, formatUptime(data.uptime));
|
||||
renderMobileData(data.mobileData || {});
|
||||
|
||||
setText(elements.connectionState, ready ? '运行中' : '未就绪');
|
||||
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.moduleModel, model.model || '--');
|
||||
setText(elements.moduleVersion, model.version || '--');
|
||||
setText(elements.currentSim, getSimDisplayLabel(activeSim) || infoData.simLabel || '--');
|
||||
renderSimStatus(simData);
|
||||
setText(elements.iccid, infoData.iccid || '--');
|
||||
}
|
||||
|
||||
function parseLogLine(line) {
|
||||
@@ -479,13 +238,9 @@ function formatDateTime(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(messages, meta = {}) {
|
||||
function renderMessages(messages) {
|
||||
elements.messageList.replaceChildren();
|
||||
const countLabel = `最近 ${messages.length} 条`;
|
||||
const scopeLabel = '新短信自动显示';
|
||||
|
||||
setText(elements.messageCount, countLabel);
|
||||
setText(elements.messageScope, scopeLabel);
|
||||
setText(elements.messageCount, `最近 ${messages.length} 条`);
|
||||
|
||||
if (!messages.length) {
|
||||
const empty = document.createElement('p');
|
||||
@@ -835,111 +590,8 @@ async function refreshLogs() {
|
||||
}
|
||||
|
||||
async function refreshMessages() {
|
||||
const params = new URLSearchParams({ limit: '50', scope: 'all' });
|
||||
const messages = await requestJSON(`/api/sms/received?${params.toString()}`);
|
||||
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 || {});
|
||||
}
|
||||
const messages = await requestJSON('/api/sms/received?limit=50');
|
||||
renderMessages(messages.data || []);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -954,9 +606,7 @@ async function refreshAll() {
|
||||
try {
|
||||
const status = await requestJSON('/api/status');
|
||||
const info = await requestJSON('/api/modem/info');
|
||||
const sim = await requestJSON('/api/modem/sim?refresh=true');
|
||||
renderStatus(status, info, sim);
|
||||
setStatusMessage(elements.mobileDataStatus, '');
|
||||
renderStatus(status, info);
|
||||
await refreshMessages();
|
||||
await refreshLogs();
|
||||
} catch (err) {
|
||||
@@ -985,8 +635,7 @@ async function sendSMS(event) {
|
||||
}
|
||||
|
||||
submit.disabled = true;
|
||||
renderSendSimLabel();
|
||||
setStatusMessage(elements.smsStatus, `正在使用 ${getActiveSimLabel()} 发送...`);
|
||||
setStatusMessage(elements.smsStatus, '发送中...');
|
||||
|
||||
try {
|
||||
const result = await requestJSON('/api/sms/send', {
|
||||
@@ -1014,18 +663,18 @@ async function runATCommand(event) {
|
||||
|
||||
submit.disabled = true;
|
||||
setText(elements.consoleState, 'running');
|
||||
elements.atOutput.textContent = `› ${command}\n等待响应...`;
|
||||
elements.atOutput.textContent = `> ${command}\n等待响应...`;
|
||||
|
||||
try {
|
||||
const result = await requestJSON('/api/modem/at', {
|
||||
method: 'POST',
|
||||
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');
|
||||
await refreshLogs();
|
||||
} catch (err) {
|
||||
elements.atOutput.textContent = `› ${command}\nERROR: ${err.message}`;
|
||||
elements.atOutput.textContent = `> ${command}\nERROR: ${err.message}`;
|
||||
setText(elements.consoleState, 'error');
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
@@ -1045,30 +694,10 @@ elements.pushChannelForm.addEventListener('submit', savePushChannels);
|
||||
elements.pushTestButton.addEventListener('click', testPushChannel);
|
||||
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
|
||||
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.messagesButton.addEventListener('click', refreshMessages);
|
||||
elements.smsForm.addEventListener('submit', sendSMS);
|
||||
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) => {
|
||||
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
|
||||
closePushModal();
|
||||
|
||||
+38
-153
@@ -50,7 +50,7 @@ class APIServer {
|
||||
// JSON解析
|
||||
this.app.use(express.json());
|
||||
|
||||
// Web Token认证
|
||||
// Web Token认证,API保留Basic Auth兼容
|
||||
this.app.use((req, res, next) => {
|
||||
this.authenticateRequest(req, res, next);
|
||||
});
|
||||
@@ -75,23 +75,22 @@ class APIServer {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.isWebRoute(req)) {
|
||||
const webToken = this.getConfiguredWebToken();
|
||||
if (webToken) {
|
||||
return this.sendTokenGate(res);
|
||||
}
|
||||
if (this.isWebRoute(req) && this.getConfiguredWebToken()) {
|
||||
return this.sendTokenGate(res);
|
||||
}
|
||||
|
||||
return res.status(401).send('未配置 webToken');
|
||||
if (this.hasValidBasicAuth(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(401).json({
|
||||
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() {
|
||||
@@ -144,6 +143,24 @@ class APIServer {
|
||||
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) {
|
||||
const actualBuffer = Buffer.from(String(actual));
|
||||
const expectedBuffer = Buffer.from(String(expected));
|
||||
@@ -159,6 +176,11 @@ class APIServer {
|
||||
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) {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.status(401).type('html').send(`<!doctype html>
|
||||
@@ -342,7 +364,6 @@ class APIServer {
|
||||
try {
|
||||
const signal = await this.modem.getSignalQuality();
|
||||
const operator = await this.modem.getOperator();
|
||||
const mobileData = await this.modem.getMobileDataStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -351,8 +372,6 @@ class APIServer {
|
||||
model: this.modem.modelInfo,
|
||||
signal,
|
||||
operator,
|
||||
mobileData,
|
||||
sim: this.modem.getCachedSimStatus?.() || null,
|
||||
uptime: process.uptime()
|
||||
}
|
||||
});
|
||||
@@ -377,14 +396,11 @@ class APIServer {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.modem.sendSMS(phone, message);
|
||||
const success = Boolean(result?.success);
|
||||
const simLabel = result?.simLabel || '未知SIM';
|
||||
const success = await this.modem.sendSMS(phone, message);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? `短信发送成功,发送SIM: ${simLabel}` : `短信发送失败,发送SIM: ${simLabel}`,
|
||||
data: result
|
||||
message: success ? '短信发送成功' : '短信发送失败'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('发送短信失败:', err);
|
||||
@@ -396,20 +412,12 @@ class APIServer {
|
||||
});
|
||||
|
||||
// 查询收到的短信
|
||||
this.app.get('/api/sms/received', async (req, res) => {
|
||||
this.app.get('/api/sms/received', (req, res) => {
|
||||
try {
|
||||
await this.smsProcessor.getCurrentSimIdentity();
|
||||
const currentSimIdentity = this.smsProcessor.getCurrentModemSimIdentity();
|
||||
const result = this.smsProcessor.listReceivedMessages(req.query.limit, {
|
||||
currentSimIdentity,
|
||||
scope: req.query.scope,
|
||||
simSlot: req.query.simSlot ?? req.query.slot
|
||||
});
|
||||
|
||||
const messages = this.smsProcessor.getReceivedMessages(req.query.limit);
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.messages,
|
||||
meta: result.meta
|
||||
data: messages
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询收到短信失败:', err);
|
||||
@@ -440,19 +448,12 @@ class APIServer {
|
||||
// 查询模组信息
|
||||
this.app.get('/api/modem/info', async (req, res) => {
|
||||
try {
|
||||
const simStatus = this.modem.getCachedSimStatus?.();
|
||||
const currentSlot = simStatus?.slots?.find(slot => slot.active);
|
||||
const simIdentity = this.smsProcessor.setCurrentSimIdentity(
|
||||
this.smsProcessor.getCurrentModemSimIdentity()
|
||||
);
|
||||
|
||||
const iccid = await this.modem.getICCID();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
model: this.modem.modelInfo,
|
||||
phoneNumber: currentSlot?.phoneNumber || '',
|
||||
simId: simIdentity?.simId || null,
|
||||
simLabel: simIdentity?.simLabel || '未知SIM',
|
||||
iccid,
|
||||
ready: this.modem.ready
|
||||
}
|
||||
});
|
||||
@@ -465,122 +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命令
|
||||
this.app.post('/api/modem/at', async (req, res) => {
|
||||
try {
|
||||
|
||||
+8
-15
@@ -13,12 +13,10 @@ class ConcatManager extends EventEmitter {
|
||||
/**
|
||||
* 查找或创建长短信槽位
|
||||
*/
|
||||
findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
|
||||
const simId = simIdentity?.simId || '';
|
||||
|
||||
findOrCreateSlot(refNumber, sender, totalParts) {
|
||||
// 先查找是否已存在
|
||||
let slot = this.buffer.find(s =>
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender && s.simId === simId
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender
|
||||
);
|
||||
|
||||
if (slot) {
|
||||
@@ -28,7 +26,7 @@ class ConcatManager extends EventEmitter {
|
||||
// 查找空闲槽位
|
||||
slot = this.buffer.find(s => !s.inUse);
|
||||
if (slot) {
|
||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
return slot;
|
||||
}
|
||||
|
||||
@@ -46,19 +44,17 @@ class ConcatManager extends EventEmitter {
|
||||
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
||||
}
|
||||
|
||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化槽位
|
||||
*/
|
||||
initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
|
||||
initSlot(slot, refNumber, sender, totalParts) {
|
||||
slot.inUse = true;
|
||||
slot.refNumber = refNumber;
|
||||
slot.sender = sender;
|
||||
slot.simId = simIdentity?.simId || '';
|
||||
slot.simIdentity = simIdentity;
|
||||
slot.totalParts = totalParts;
|
||||
slot.receivedParts = 0;
|
||||
slot.firstPartTime = Date.now();
|
||||
@@ -69,10 +65,10 @@ class ConcatManager extends EventEmitter {
|
||||
/**
|
||||
* 添加短信分段
|
||||
*/
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp, options = {}) {
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) {
|
||||
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
|
||||
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts, options.simIdentity);
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts);
|
||||
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
|
||||
|
||||
if (partIndex >= 0 && partIndex < this.maxParts) {
|
||||
@@ -114,8 +110,7 @@ class ConcatManager extends EventEmitter {
|
||||
this.emit('complete', {
|
||||
sender: slot.sender,
|
||||
text: fullText,
|
||||
timestamp: slot.timestamp,
|
||||
simIdentity: slot.simIdentity
|
||||
timestamp: slot.timestamp
|
||||
});
|
||||
|
||||
// 清空槽位
|
||||
@@ -129,8 +124,6 @@ class ConcatManager extends EventEmitter {
|
||||
slot.inUse = false;
|
||||
slot.parts = [];
|
||||
slot.receivedParts = 0;
|
||||
slot.simId = '';
|
||||
slot.simIdentity = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-20
@@ -46,7 +46,7 @@ async function main() {
|
||||
logger.info('配置加载完成');
|
||||
|
||||
// 创建模组管理器
|
||||
const modem = new ModemManager(config.serial, config.mobileData, config);
|
||||
const modem = new ModemManager(config.serial);
|
||||
|
||||
// 创建长短信管理器
|
||||
const concatManager = new ConcatManager();
|
||||
@@ -60,9 +60,7 @@ async function main() {
|
||||
// 监听长短信合并完成事件
|
||||
concatManager.on('complete', async (sms) => {
|
||||
logger.info('收到长短信合并完成事件');
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp, {
|
||||
simIdentity: sms.simIdentity
|
||||
});
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp);
|
||||
});
|
||||
|
||||
// 监听模组短信事件
|
||||
@@ -113,27 +111,19 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
logger.info(`\n收到 ${signal} 信号,正在关闭...`);
|
||||
// 优雅退出
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n收到 SIGINT 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.forceMobileDataOff(`${signal}退出保护`);
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', () => {
|
||||
shutdown('SIGINT').catch((err) => {
|
||||
logger.error('关闭失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown('SIGTERM').catch((err) => {
|
||||
logger.error('关闭失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('\n收到 SIGTERM 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 捕获未处理的异常
|
||||
|
||||
+84
-1832
File diff suppressed because it is too large
Load Diff
+111
-328
@@ -1,5 +1,4 @@
|
||||
import { parse } from 'node-pdu';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -18,135 +17,16 @@ class SMSProcessor {
|
||||
this.receivedMessageSeq = 0;
|
||||
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
|
||||
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
|
||||
this.partitionMessagesBySim = this.resolvePartitionMessagesBySim();
|
||||
this.allowAllSimMessages = this.resolveAllowAllSimMessages();
|
||||
this.currentSimIdentity = null;
|
||||
this.currentSimCheckedAt = 0;
|
||||
this.currentSimLookup = null;
|
||||
this.simIdentityTtlMs = 5 * 1000;
|
||||
this.loadReceivedMessages();
|
||||
}
|
||||
|
||||
normalizeIncomingPduEvent(input) {
|
||||
if (typeof input === 'string') {
|
||||
return {
|
||||
pdu: input,
|
||||
metadata: {
|
||||
type: 'CMT',
|
||||
raw: '',
|
||||
simSlot: null,
|
||||
receivedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object') {
|
||||
return {
|
||||
pdu: String(input.pdu || input.pduHex || ''),
|
||||
metadata: {
|
||||
...(input.metadata || {}),
|
||||
simSlot: this.normalizeSimSlot(input.metadata?.simSlot ?? input.simSlot),
|
||||
receivedAt: input.metadata?.receivedAt || input.receivedAt || new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pdu: '',
|
||||
metadata: {
|
||||
type: 'CMT',
|
||||
raw: '',
|
||||
simSlot: null,
|
||||
receivedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resolveIncomingSimIdentity({ metadata = {} } = {}) {
|
||||
const urcSlot = this.normalizeSimSlot(metadata.simSlot);
|
||||
if (urcSlot !== null) {
|
||||
return this.createSimIdentityFromSlot(urcSlot, {
|
||||
simSource: metadata.simSource || 'urc'
|
||||
});
|
||||
}
|
||||
|
||||
const identified = this.modem?.identifyIncomingSms?.(metadata);
|
||||
if (identified?.slot !== undefined && identified?.slot !== null) {
|
||||
return this.createSimIdentityFromSlot(identified.slot, {
|
||||
simSource: identified.source || 'slot'
|
||||
});
|
||||
}
|
||||
|
||||
const directSource = metadata.simSource || (metadata.type === 'CMT' || metadata.type === 'CMTI' ? 'urc' : '');
|
||||
if (directSource) {
|
||||
return {
|
||||
simId: crypto.createHash('sha256').update(`unknown-${directSource}`).digest('hex').slice(0, 24),
|
||||
simLabel: '未知SIM',
|
||||
simSlot: null,
|
||||
simSource: directSource,
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
createSimIdentityFromSlot(slotNumber, extra = {}) {
|
||||
const slot = this.normalizeSimSlot(slotNumber);
|
||||
if (slot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
const slotInfo = simStatus?.slots?.find(item => item.slot === slot);
|
||||
const name = slotInfo?.phoneLabel || slotInfo?.phoneNumber || slotInfo?.name || `SIM${slot + 1}`;
|
||||
|
||||
return {
|
||||
simId: crypto.createHash('sha256').update(`slot:${slot}`).digest('hex').slice(0, 24),
|
||||
simLabel: name,
|
||||
simSlot: slot,
|
||||
simSource: extra.simSource || 'slot',
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
normalizeSimSlot(value) {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (normalized === 'SIM1') {
|
||||
return 0;
|
||||
}
|
||||
if (normalized === 'SIM2') {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const slot = Number.parseInt(value, 10);
|
||||
return slot === 0 || slot === 1 ? slot : null;
|
||||
}
|
||||
|
||||
formatSimSource(source) {
|
||||
const labels = {
|
||||
urc: 'URC上报',
|
||||
bind_context: '绑定上下文',
|
||||
active_context: '当前卡槽',
|
||||
slot: '卡槽',
|
||||
current_slot: '当前卡槽'
|
||||
};
|
||||
|
||||
return labels[source] || '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的PDU短信
|
||||
*/
|
||||
async processPDU(input) {
|
||||
async processPDU(pduHex) {
|
||||
try {
|
||||
logger.info('开始解析PDU数据...');
|
||||
|
||||
const smsEvent = this.normalizeIncomingPduEvent(input);
|
||||
const pduHex = smsEvent.pdu;
|
||||
|
||||
// 解析PDU
|
||||
const parsed = parse(pduHex);
|
||||
|
||||
@@ -160,15 +40,10 @@ class SMSProcessor {
|
||||
const text = parsed.data?.getText?.() || '';
|
||||
const part = parsed.data?.parts?.[0];
|
||||
const header = part?.header;
|
||||
const simIdentity = this.resolveIncomingSimIdentity({
|
||||
metadata: smsEvent.metadata
|
||||
});
|
||||
|
||||
logger.info('✓ PDU解析成功');
|
||||
logger.info(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
|
||||
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
|
||||
// 检查是否为长短信
|
||||
@@ -187,12 +62,11 @@ class SMSProcessor {
|
||||
partNumber,
|
||||
totalParts,
|
||||
text,
|
||||
timestamp,
|
||||
{ simIdentity }
|
||||
timestamp
|
||||
);
|
||||
} else {
|
||||
// 普通短信,直接处理
|
||||
await this.processSmsContent(sender, text, timestamp, { simIdentity });
|
||||
await this.processSmsContent(sender, text, timestamp);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('处理PDU失败:', err);
|
||||
@@ -200,22 +74,41 @@ class SMSProcessor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理短信内容并转发
|
||||
* 处理短信内容(管理员命令检查和转发)
|
||||
*/
|
||||
async processSmsContent(sender, text, timestamp, options = {}) {
|
||||
const simIdentity = options.simIdentity !== undefined
|
||||
? this.normalizeSimIdentity(options.simIdentity)
|
||||
: await this.getCurrentSimIdentity();
|
||||
|
||||
async processSmsContent(sender, text, timestamp) {
|
||||
logger.info('=== 处理短信内容 ===');
|
||||
logger.info(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
|
||||
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
logger.info('====================');
|
||||
|
||||
const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity);
|
||||
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);
|
||||
@@ -234,19 +127,13 @@ class SMSProcessor {
|
||||
/**
|
||||
* 记录收到的短信,供Web管理端展示。
|
||||
*/
|
||||
addReceivedMessage(sender, text, timestamp, simIdentity = null) {
|
||||
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
|
||||
addReceivedMessage(sender, text, timestamp) {
|
||||
const message = {
|
||||
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
||||
sender,
|
||||
text,
|
||||
timestamp,
|
||||
receivedAt: new Date().toISOString(),
|
||||
simId: normalizedSimIdentity?.simId || '',
|
||||
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
|
||||
simSlot: normalizedSimIdentity?.simSlot ?? null,
|
||||
simSource: normalizedSimIdentity?.simSource || 'unknown',
|
||||
smsCenter: normalizedSimIdentity?.smsCenter || '',
|
||||
status: 'received',
|
||||
statusText: '已接收'
|
||||
};
|
||||
@@ -257,7 +144,7 @@ class SMSProcessor {
|
||||
}
|
||||
|
||||
this.persistReceivedMessages();
|
||||
logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
|
||||
logger.info(`短信已进入Web收件箱: ${sender}`);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -276,87 +163,8 @@ class SMSProcessor {
|
||||
* 获取最近收到的短信。
|
||||
*/
|
||||
getReceivedMessages(limit = 50) {
|
||||
return this.receivedMessages.slice(0, this.resolveReceivedMessagesLimit(limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按SIM作用域过滤后的短信。
|
||||
*/
|
||||
listReceivedMessages(limit = 50, options = {}) {
|
||||
const safeLimit = this.resolveReceivedMessagesLimit(limit);
|
||||
const requestedSimSlot = this.normalizeSimSlot(options.simSlot);
|
||||
const hasRequestedScope = options.scope !== undefined && options.scope !== null && String(options.scope).trim() !== '';
|
||||
const requestedScope = requestedSimSlot !== null
|
||||
? `sim${requestedSimSlot + 1}`
|
||||
: hasRequestedScope
|
||||
? String(options.scope).trim().toLowerCase()
|
||||
: (this.shouldDefaultToAllSimMessages() ? 'all' : 'current');
|
||||
const currentSim = this.normalizeSimIdentity(options.currentSimIdentity);
|
||||
const autoAllSims = !hasRequestedScope && requestedScope === 'all';
|
||||
const includeAllSims = requestedSimSlot === null && requestedScope === 'all';
|
||||
|
||||
let effectiveScope = includeAllSims ? 'all' : 'current';
|
||||
let messages = this.receivedMessages;
|
||||
|
||||
if (requestedSimSlot !== null) {
|
||||
messages = this.receivedMessages.filter(message => this.normalizeSimSlot(message.simSlot) === requestedSimSlot);
|
||||
effectiveScope = `sim${requestedSimSlot + 1}`;
|
||||
} else if (this.partitionMessagesBySim && !includeAllSims) {
|
||||
if (currentSim?.simId) {
|
||||
messages = this.receivedMessages.filter(message => this.messageMatchesSim(message, currentSim));
|
||||
} else {
|
||||
messages = [];
|
||||
effectiveScope = 'unavailable';
|
||||
}
|
||||
} else if (!this.partitionMessagesBySim) {
|
||||
effectiveScope = 'all';
|
||||
}
|
||||
|
||||
return {
|
||||
messages: messages.slice(0, safeLimit),
|
||||
meta: {
|
||||
limit: safeLimit,
|
||||
total: messages.length,
|
||||
scope: effectiveScope,
|
||||
requestedScope,
|
||||
partitionBySim: this.partitionMessagesBySim,
|
||||
allSimMessagesAllowed: this.allowAllSimMessages || includeAllSims || autoAllSims,
|
||||
allSimMessagesDenied: false,
|
||||
simSlot: requestedSimSlot,
|
||||
currentSim,
|
||||
currentSimKnown: Boolean(currentSim?.simId)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
shouldDefaultToAllSimMessages() {
|
||||
if (this.config.inbox?.dualSimDefaultScope === 'current') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
return simStatus?.supported === true && (simStatus.mode === 0 || simStatus.mode === 3);
|
||||
}
|
||||
|
||||
messageMatchesSim(message, currentSim) {
|
||||
if (!message || !currentSim) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.simId && currentSim.simId && message.simId === currentSim.simId) {
|
||||
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);
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
||||
return this.receivedMessages.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,20 +179,6 @@ class SMSProcessor {
|
||||
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;
|
||||
}
|
||||
|
||||
const simIdentity = this.normalizeStoredSimIdentity(item);
|
||||
|
||||
return {
|
||||
id,
|
||||
sender: String(item.sender || '未知号码'),
|
||||
text: String(item.text || ''),
|
||||
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
|
||||
receivedAt: String(item.receivedAt || new Date().toISOString()),
|
||||
simId: simIdentity?.simId || '',
|
||||
simLabel: simIdentity?.simLabel || '未知SIM',
|
||||
simSlot: simIdentity?.simSlot ?? null,
|
||||
simSource: simIdentity?.simSource || item.simSource || 'unknown',
|
||||
smsCenter: '',
|
||||
status: String(item.status || 'received'),
|
||||
statusText: String(item.statusText || '已接收')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从历史记录中恢复SIM身份。旧记录没有SIM字段,会归到未知SIM。
|
||||
* 检查是否在黑名单中
|
||||
*/
|
||||
normalizeStoredSimIdentity(item) {
|
||||
if (item.simId || item.simLabel) {
|
||||
return this.normalizeSimIdentity({
|
||||
simId: item.simId,
|
||||
simLabel: item.simLabel,
|
||||
simSlot: item.simSlot,
|
||||
simSource: item.simSource
|
||||
});
|
||||
isInBlacklist(sender) {
|
||||
const blacklist = this.config.numberBlackList || [];
|
||||
|
||||
// 检查完整号码
|
||||
if (blacklist.includes(sender)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.sim && typeof item.sim === 'object') {
|
||||
return this.normalizeSimIdentity(item.sim);
|
||||
// 检查去除+86后的号码
|
||||
if (sender.startsWith('+86')) {
|
||||
const stripped = sender.substring(3);
|
||||
if (blacklist.includes(stripped)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
|
||||
* 检查是否为管理员
|
||||
*/
|
||||
async getCurrentSimIdentity(options = {}) {
|
||||
const refresh = options.refresh === true;
|
||||
const now = Date.now();
|
||||
|
||||
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
|
||||
return this.currentSimIdentity;
|
||||
isAdmin(sender) {
|
||||
if (!this.config.adminPhone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.currentSimLookup) {
|
||||
return this.currentSimLookup;
|
||||
let senderStr = sender;
|
||||
let adminStr = this.config.adminPhone;
|
||||
|
||||
// 去除+86前缀
|
||||
if (senderStr.startsWith('+86')) {
|
||||
senderStr = senderStr.substring(3);
|
||||
}
|
||||
if (adminStr.startsWith('+86')) {
|
||||
adminStr = adminStr.substring(3);
|
||||
}
|
||||
|
||||
if (this.modem?.isSimSwitching?.()) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
const modemSim = this.getCurrentModemSimIdentity();
|
||||
if (modemSim?.simId) {
|
||||
return this.setCurrentSimIdentity(modemSim);
|
||||
}
|
||||
|
||||
this.currentSimIdentity = null;
|
||||
this.currentSimCheckedAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
getCurrentModemSimIdentity() {
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
const activeSlot = simStatus?.activeSlot;
|
||||
if (activeSlot === null || activeSlot === undefined) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
const identity = this.createSimIdentityFromSlot(activeSlot, {
|
||||
simSource: 'current_slot'
|
||||
});
|
||||
return identity || this.currentSimIdentity;
|
||||
return senderStr === adminStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前SIM身份缓存。
|
||||
* 处理管理员命令
|
||||
*/
|
||||
setCurrentSimIdentity(identity) {
|
||||
this.currentSimIdentity = this.normalizeSimIdentity(identity);
|
||||
this.currentSimCheckedAt = Date.now();
|
||||
return this.currentSimIdentity;
|
||||
async processAdminCommand(sender, command) {
|
||||
logger.info(`处理管理员命令: ${command}`);
|
||||
|
||||
// 处理 SMS:号码:内容 命令
|
||||
if (command.startsWith('SMS:')) {
|
||||
const parts = command.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const targetPhone = parts[1].trim();
|
||||
const smsContent = parts.slice(2).join(':').trim(); // 支持内容中包含冒号
|
||||
|
||||
logger.info(`目标号码: ${targetPhone}`);
|
||||
logger.info(`短信内容: ${smsContent}`);
|
||||
|
||||
const success = await this.modem.sendSMS(targetPhone, smsContent);
|
||||
|
||||
// 发送邮件通知结果
|
||||
const subject = success ? '短信发送成功' : '短信发送失败';
|
||||
const body = `管理员命令执行结果:\n命令: ${command}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`;
|
||||
await this.pushManager.sendEmail(subject, body);
|
||||
} else {
|
||||
logger.error('SMS命令格式错误');
|
||||
await this.pushManager.sendEmail(
|
||||
'命令执行失败',
|
||||
'SMS命令格式错误,正确格式: SMS:号码:内容'
|
||||
);
|
||||
}
|
||||
}
|
||||
// 处理 RESET 命令
|
||||
else if (command === 'RESET') {
|
||||
logger.info('执行RESET命令');
|
||||
|
||||
// 先发送邮件通知
|
||||
await this.pushManager.sendEmail(
|
||||
'重启命令已执行',
|
||||
'收到RESET命令,即将重启系统...'
|
||||
);
|
||||
|
||||
// 等待邮件发送完成
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 重启进程
|
||||
logger.info('正在重启系统...');
|
||||
process.exit(0); // PM2会自动重启
|
||||
}
|
||||
else {
|
||||
logger.warn(`未知命令: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
normalizeSimIdentity(identity) {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof identity === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof identity !== 'object' || Array.isArray(identity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simSlot = this.normalizeSimSlot(identity.simSlot ?? identity.slot);
|
||||
const simId = String(identity.simId || identity.id || '').trim();
|
||||
if (!simId && simSlot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
simId: simId || crypto.createHash('sha256').update(`slot:${simSlot}`).digest('hex').slice(0, 24),
|
||||
simLabel: String(identity.simLabel || identity.label || (simSlot === null ? '未知SIM' : `SIM${simSlot + 1}`)),
|
||||
simSlot,
|
||||
simSource: String(identity.simSource || identity.source || 'unknown'),
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SMSProcessor;
|
||||
|
||||
Reference in New Issue
Block a user