Compare commits
11
Commits
11a52add4e
...
ff599b8ce5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff599b8ce5 | ||
|
|
2410aa224c | ||
|
|
6c468dc10e | ||
|
|
5ad372590b | ||
|
|
a48cad170c | ||
|
|
d688dffef2 | ||
|
|
384f1be75b | ||
|
|
fce17ab148 | ||
|
|
1534c48d9e | ||
|
|
679737c737 | ||
|
|
69c49ab29b |
+5
-14
@@ -1,34 +1,25 @@
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
FROM alpine:3.22 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 apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache nodejs npm python3 make g++
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
FROM alpine:3.22 AS runtime
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NPM_CONFIG_AUDIT=false \
|
||||
NPM_CONFIG_FUND=false \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache nodejs eudev
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
# Node.js 4G SMS Gateway
|
||||
|
||||
基于Node.js的4G短信网关,通过串口与4G模块通信,接收短信并转发到多种推送渠道。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 4G模块AT指令交互
|
||||
- ✅ 自动接收短信(PDU模式)
|
||||
- ✅ 长短信自动合并
|
||||
- ✅ 多通道推送(邮件/钉钉/飞书/Telegram等)
|
||||
- ✅ 短信发送功能
|
||||
- ✅ 号码黑名单
|
||||
- ✅ 管理员远程命令
|
||||
- ✅ RESTful API接口
|
||||
- ✅ Web管理界面
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js >= 18.x
|
||||
- 4G模块通过USB转串口连接到服务器
|
||||
- 支持的4G模块:ML307R-DC、SIM7600等AT指令兼容模块
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
复制配置模板:
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
`config.json` 必须存在,否则程序会直接退出。最小可运行配置如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "/dev/ttyUSB0",
|
||||
"baudRate": 115200,
|
||||
"autoDetect": true,
|
||||
"probeTimeout": 1200
|
||||
},
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
完整配置可参考 `config.example.json`。主要配置项如下:
|
||||
|
||||
| 配置项 | 是否必填 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `serial.path` | 是 | 串口路径;Linux 常用 `/dev/ttyUSB0`,Windows 使用 `COM3` 等。`autoDetect=true` 时作为优先探测项,`autoDetect=false` 时作为实际串口。 |
|
||||
| `serial.baudRate` | 是 | 串口波特率,通常为 `115200`。 |
|
||||
| `serial.autoDetect` | 否 | 是否自动探测可响应 `AT` 的串口,默认按非 `false` 处理。 |
|
||||
| `serial.probeTimeout` | 否 | 自动探测单个串口的超时时间,单位毫秒,默认 `1200`。 |
|
||||
| `api.port` | 是 | API 和 Web 管理台监听端口。 |
|
||||
| `api.webToken` | 是 | 唯一鉴权凭据;已取消 `admin/admin123` Basic Auth。 |
|
||||
| `mobileData.cid` | 否 | 移动数据 PDP CID,默认 `1`;有效范围 `1-15`,不要使用 `8`。 |
|
||||
| `simCards` | 否 | SIM 卡显示手机号,`slot=0` 是 SIM1,`slot=1` 是 SIM2。 |
|
||||
| `smtp` | 否 | 邮件通知配置;填写 `smtp.server` 后启用。 |
|
||||
| `adminPhone` | 否 | 管理员手机号;只有该号码发来的短信会执行管理员命令。 |
|
||||
| `numberBlackList` | 否 | 黑名单号码列表,匹配后忽略短信。 |
|
||||
| `inbox` | 否 | Web 收件箱持久化和双卡显示策略。 |
|
||||
| `pushChannels` | 否 | 推送通道列表;通道开启后才要求目标字段。 |
|
||||
|
||||
`api.webToken` 可以通过以下方式传入:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
- `X-Web-Token: <token>`
|
||||
- `?token=<token>`
|
||||
- Web 管理台 token 登录页
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"serial": {
|
||||
"path": "/dev/ttyUSB0",
|
||||
"baudRate": 115200,
|
||||
"autoDetect": true,
|
||||
"probeTimeout": 1200
|
||||
},
|
||||
"mobileData": {
|
||||
"cid": 1
|
||||
},
|
||||
"simCards": [
|
||||
{ "slot": 0, "phoneNumber": "+8613800138000" },
|
||||
{ "slot": 1, "phoneNumber": "+447700900000" }
|
||||
],
|
||||
"smtp": {
|
||||
"server": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"user": "your@qq.com",
|
||||
"pass": "your_auth_code",
|
||||
"sendTo": "recipient@example.com"
|
||||
},
|
||||
"adminPhone": "13800138000",
|
||||
"numberBlackList": ["10086", "10010"],
|
||||
"inbox": {
|
||||
"maxReceivedMessages": 200,
|
||||
"receivedMessagesFile": "data/received-messages.json",
|
||||
"partitionBySim": true,
|
||||
"allowAllSimMessages": false,
|
||||
"dualSimDefaultScope": "all"
|
||||
},
|
||||
"pushChannels": [
|
||||
{
|
||||
"enabled": true,
|
||||
"type": "dingtalk",
|
||||
"name": "钉钉通知",
|
||||
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||
"secret": "SECxxx"
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 可选项细节
|
||||
|
||||
- `smtp`:不需要邮件时可以删除整个对象,或清空 `server`。启用邮件时需要正确填写 `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` 或 `current`;`current` 表示双卡时默认只看当前 SIM。
|
||||
- `simCards`:只影响管理台显示,不影响短信收发。代码也兼容旧字段名 `simSlots`、`sims`、`sim`。
|
||||
|
||||
## 运行
|
||||
|
||||
### 开发模式
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 生产模式
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### 使用PM2(推荐)
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
## API接口
|
||||
|
||||
### 查询状态
|
||||
```bash
|
||||
GET /api/status
|
||||
```
|
||||
|
||||
### 发送短信
|
||||
```bash
|
||||
POST /api/sms/send
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"phone": "13800138000",
|
||||
"message": "测试短信"
|
||||
}
|
||||
```
|
||||
|
||||
### 查询收到的短信
|
||||
```bash
|
||||
GET /api/sms/received?limit=50
|
||||
```
|
||||
|
||||
双卡双待模式下,默认返回全部 SIM 历史;显式传 `scope=current` 时才按当前卡槽过滤。短信记录会保留 `simSlot`、`simLabel` 与 `simSource`,用于判断收件卡槽。
|
||||
也可以显式按卡槽查询:
|
||||
|
||||
```bash
|
||||
GET /api/sms/received?limit=50&simSlot=0 # SIM1
|
||||
GET /api/sms/received?limit=50&simSlot=1 # SIM2
|
||||
```
|
||||
|
||||
直出模式下,新短信主要通过模组 `+CMT` URC 进入收件箱;如果 URC 未携带卡槽信息,会显示为未知 SIM。
|
||||
|
||||
### 查询信号强度
|
||||
```bash
|
||||
GET /api/modem/signal
|
||||
```
|
||||
|
||||
### 查询模组信息
|
||||
```bash
|
||||
GET /api/modem/info
|
||||
```
|
||||
|
||||
### 查询与切换SIM
|
||||
```bash
|
||||
GET /api/modem/sim
|
||||
POST /api/modem/sim/switch
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slot": 1
|
||||
}
|
||||
```
|
||||
|
||||
当模组返回 `AT+DUALSIM?`、`AT+SWITCHSIM?` 与 `AT+BINDSIM?` 能力时,管理台会显示 SIM1/SIM2。短信接收使用 `CNMI` 直出模式,不为了接收短信切换 `SWITCHSIM`。如果模组上报的 URC 未携带卡槽信息,新短信会记录为未知 SIM。
|
||||
|
||||
管理台切卡会同时设置 `AT+SWITCHSIM` 与 `AT+BINDSIM`,随后恢复短信直出配置。发送短信前会提示当前使用的 SIM。
|
||||
|
||||
如果希望管理台显示手机号,可在 `config.json` 中配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"simCards": [
|
||||
{ "slot": 0, "phoneNumber": "+8613800138000" },
|
||||
{ "slot": 1, "phoneNumber": "+447700900000" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 发送AT命令
|
||||
```bash
|
||||
POST /api/modem/at
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command": "AT+CSQ"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取日志
|
||||
```bash
|
||||
GET /api/logs
|
||||
```
|
||||
|
||||
所有API均需要有效 token。可以通过 `Authorization: Bearer <token>`、`X-Web-Token` 请求头或 `token` 查询参数传入。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── src/
|
||||
│ ├── index.js # 入口文件
|
||||
│ ├── modem.js # 4G模块通信
|
||||
│ ├── sms.js # 短信处理
|
||||
│ ├── push.js # 推送通道
|
||||
│ ├── concat.js # 长短信合并
|
||||
│ ├── api.js # REST API
|
||||
│ └── logger.js # 日志系统
|
||||
├── config.json # 配置文件
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 推送通道类型
|
||||
|
||||
| 类型 | type值 | 配置项 |
|
||||
|------|--------|--------|
|
||||
| 钉钉机器人 | `dingtalk` | url, secret(可选) |
|
||||
| 飞书机器人 | `feishu` | url, secret(可选) |
|
||||
| Telegram Bot | `telegram` | url(bot token), key1(chat_id) |
|
||||
| PushPlus | `pushplus` | key1(token) |
|
||||
| Server酱 | `serverchan` | key1(sendkey) |
|
||||
| Bark | `bark` | url(bark服务器) |
|
||||
| POST JSON | `post_json` | url |
|
||||
| GET请求 | `get` | url |
|
||||
| 自定义模板 | `custom` | url, customBody |
|
||||
|
||||
`pushChannels` 中 `enabled: false` 的通道可以保留空字段;`enabled: true` 时必须配置对应目标:
|
||||
|
||||
- `post_json`、`bark`、`get`、`dingtalk`、`custom`、`feishu`:必须有 `url`。
|
||||
- `telegram`:`url` 填 Bot Token,`key1` 填 Chat ID。
|
||||
- `pushplus`:`key1` 填 PushPlus Token。
|
||||
- `serverchan`:`key1` 填 Server酱 SendKey。
|
||||
- `custom`:`customBody` 如果填写,必须是合法 JSON 字符串,支持 `{sender}`、`{message}`、`{timestamp}` 占位符。
|
||||
|
||||
## 管理员命令
|
||||
|
||||
通过短信发送命令(仅管理员号码):
|
||||
|
||||
### 远程发送短信
|
||||
```
|
||||
SMS:目标号码:短信内容
|
||||
```
|
||||
示例:`SMS:13800138000:测试消息`
|
||||
|
||||
### 重启系统
|
||||
```
|
||||
RESET
|
||||
```
|
||||
|
||||
## 长短信处理
|
||||
|
||||
- 自动检测长短信(分段数 > 1)
|
||||
- 使用参考号和发送者号码标识同一条长短信
|
||||
- 收齐所有分段后自动合并
|
||||
- 30秒超时保护(未收齐也转发)
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 串口打不开
|
||||
```bash
|
||||
# Linux检查串口设备
|
||||
ls -l /dev/ttyUSB*
|
||||
# 添加用户到dialout组
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
|
||||
### 模块不响应
|
||||
- 检查串口路径和波特率
|
||||
- 检查USB连接
|
||||
- 检查SIM卡是否插好
|
||||
|
||||
### 收不到短信
|
||||
- 检查AT+CEREG?网络注册状态
|
||||
- 检查AT+CNMI配置是否正确
|
||||
- 检查SIM卡余额和服务状态
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
+31
-5
@@ -2,20 +2,49 @@
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200,
|
||||
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
|
||||
"autoDetect": true,
|
||||
"probeTimeout": 1200,
|
||||
"_comment": "必填。path是手动串口或自动探测优先项;autoDetect=false时必须写真实串口。Windows使用COM1/COM2/COM3等,也可以直接写数字如3;Linux使用/dev/ttyUSB0。baudRate通常为115200,probeTimeout为AT探测超时毫秒。"
|
||||
},
|
||||
"mobileData": {
|
||||
"cid": 1,
|
||||
"_comment": "可选,默认cid=1。有效范围1-15,cid8为IMS专用,请勿使用。ML307每次启动都会强制断开MIPCALL应用层拨号,并保护性尝试CGACT;管理台开关只影响当前运行期。"
|
||||
},
|
||||
"_simCards_comment": "可选。用于给SIM1/SIM2显示手机号;slot=0是SIM1,slot=1是SIM2。也兼容旧字段名simSlots、sims、sim。",
|
||||
"simCards": [
|
||||
{
|
||||
"slot": 0,
|
||||
"phoneNumber": "+8613800138000"
|
||||
},
|
||||
{
|
||||
"slot": 1,
|
||||
"phoneNumber": "+447700900000"
|
||||
}
|
||||
],
|
||||
"smtp": {
|
||||
"_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"
|
||||
},
|
||||
"_adminPhone_comment": "可选。配置后,只有该号码发来的短信会被当作管理员命令处理,例如SMS:号码:内容、RESET。",
|
||||
"adminPhone": "13800138000",
|
||||
"_numberBlackList_comment": "可选。列表中的号码短信会被忽略;支持带+86或不带+86的匹配。",
|
||||
"numberBlackList": [
|
||||
"10086",
|
||||
"10010"
|
||||
],
|
||||
"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字符串。",
|
||||
"pushChannels": [
|
||||
{
|
||||
"enabled": true,
|
||||
@@ -71,9 +100,6 @@
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token",
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
"_comment": "必填。port是API和管理台监听端口;webToken是唯一鉴权凭据,可通过Authorization: Bearer、X-Web-Token、token查询参数或管理台登录页提交。"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-2
@@ -13,7 +13,6 @@
|
||||
"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",
|
||||
@@ -2045,4 +2044,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -23,7 +23,6 @@
|
||||
"@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",
|
||||
@@ -32,4 +31,4 @@
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
-89
@@ -1,20 +1,30 @@
|
||||
<!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>
|
||||
<title>SMS Gateway · 管理台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="/assets/admin.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
<div class="top-nav__inner">
|
||||
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
|
||||
<span class="brand__mark" aria-hidden="true">✣</span>
|
||||
<span>SMS Gateway</span>
|
||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
<span class="brand__name">SMS Gateway</span>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="管理导航">
|
||||
<a href="#status">状态</a>
|
||||
<a href="#dashboard">状态</a>
|
||||
<a href="#sim">SIM</a>
|
||||
<a href="#inbox">收件箱</a>
|
||||
<a href="#send">短信</a>
|
||||
<a href="#console">AT 控制台</a>
|
||||
@@ -22,91 +32,174 @@
|
||||
</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="badge badge--coral" id="readyBadge">连接检测中</span>
|
||||
<h1>短信网关管理台</h1>
|
||||
<p>集中查看 ML307A 模组状态、发送短信、执行 AT 命令,并检查运行日志。</p>
|
||||
<span class="eyebrow eyebrow--coral">
|
||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
||||
<span id="readyBadge">连接检测中</span>
|
||||
</span>
|
||||
<h1>短信<em>网关</em><br>管理台</h1>
|
||||
<p class="lede">集中查看 ML307A 模组状态、收发短信、执行 AT 命令、检查运行日志。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="text-link" href="#send">发送短信</a>
|
||||
<span class="hero-divider" aria-hidden="true">·</span>
|
||||
<a class="text-link" href="#console">查看 AT 控制台</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-panel" aria-label="运行摘要">
|
||||
<div class="panel-header">
|
||||
<span>runtime</span>
|
||||
<strong id="panelStatus">loading</strong>
|
||||
<aside class="runtime-card" aria-label="运行摘要">
|
||||
<header class="runtime-card__header">
|
||||
<span class="runtime-card__label">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
runtime
|
||||
</span>
|
||||
<span class="runtime-card__status">
|
||||
<span class="status-dot" id="panelStatusDot" aria-hidden="true"></span>
|
||||
<strong id="panelStatus">loading</strong>
|
||||
</span>
|
||||
</header>
|
||||
<div class="runtime-card__hero">
|
||||
<span class="runtime-card__caption">uptime</span>
|
||||
<span class="runtime-card__value" id="uptime">--</span>
|
||||
</div>
|
||||
<dl class="runtime-list">
|
||||
<dl class="runtime-card__meta">
|
||||
<div>
|
||||
<dt>模组</dt>
|
||||
<dd id="modelName">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运营商</dt>
|
||||
<dt>operator</dt>
|
||||
<dd id="operator">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>信号</dt>
|
||||
<dt>signal</dt>
|
||||
<dd id="signalQuality">--</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行时长</dt>
|
||||
<dd id="uptime">--</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band section-band--soft">
|
||||
<!-- ============ STATUS BENTO ============ -->
|
||||
<section class="section-band section-band--soft" id="dashboard">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<h2>当前状态</h2>
|
||||
<p>这些数据来自模组实时 AT 查询,刷新时会串行读取,避免同时发送多条 AT 命令。</p>
|
||||
<div>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
status board
|
||||
</span>
|
||||
<h2>当前<em>状态</em></h2>
|
||||
</div>
|
||||
<button class="button button--primary" id="refreshButton" type="button">刷新状态</button>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">connection</span>
|
||||
|
||||
<div class="bento-grid">
|
||||
<article class="bento-hero">
|
||||
<span class="eyebrow eyebrow--coral">
|
||||
<span class="eyebrow__dot" aria-hidden="true"></span>
|
||||
connection
|
||||
</span>
|
||||
<h3 id="connectionState">--</h3>
|
||||
<p id="connectionHint">等待状态刷新。</p>
|
||||
</article>
|
||||
<article class="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>
|
||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">切到SIM1</button>
|
||||
</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>
|
||||
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">切到SIM2</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-strip">
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">signal</span>
|
||||
<h3 id="signalValue">--</h3>
|
||||
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p>
|
||||
<span class="stat-tile__value" id="signalValue">--</span>
|
||||
<span class="stat-tile__hint" id="signalHint">RSSI 与误码率将在刷新后显示。</span>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">module</span>
|
||||
<h3 id="moduleModel">--</h3>
|
||||
<p id="moduleVersion">--</p>
|
||||
<span class="stat-tile__value" id="moduleModel">--</span>
|
||||
<span class="stat-tile__hint" id="moduleVersion">--</span>
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<span class="eyebrow">sim</span>
|
||||
<h3 id="iccid">--</h3>
|
||||
<p>ICCID 查询来自当前串口模组。</p>
|
||||
<article class="stat-tile">
|
||||
<span class="eyebrow">current sim</span>
|
||||
<span class="stat-tile__value" id="currentSim">--</span>
|
||||
<span class="stat-tile__hint">发送短信会使用当前绑定的 SIM。</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band">
|
||||
<div class="container" id="inbox">
|
||||
<!-- ============ INBOX ============ -->
|
||||
<section class="section-band" id="inbox">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="badge badge--soft">inbox</span>
|
||||
<h2>收到的短信</h2>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
inbox
|
||||
</span>
|
||||
<h2>收到的<em>短信</em></h2>
|
||||
</div>
|
||||
<div class="inbox-actions">
|
||||
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
|
||||
</div>
|
||||
<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>新短信会自动出现在这里</span>
|
||||
<span class="inbox-meta__hint" id="messageScope">新短信会自动出现在这里</span>
|
||||
</div>
|
||||
<div class="message-list" id="messageList" aria-live="polite">
|
||||
<p class="empty-state empty-state--light">暂无收到的短信。</p>
|
||||
@@ -115,21 +208,34 @@
|
||||
</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">
|
||||
<h2>发送短信</h2>
|
||||
<p>使用当前 PDU 模式发送短信,提交后等待模组返回发送结果。</p>
|
||||
<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>
|
||||
<form id="smsForm" class="stack-form">
|
||||
<label>
|
||||
<span>目标号码</span>
|
||||
<label class="field">
|
||||
<span class="field__label">目标号码</span>
|
||||
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>短信内容</span>
|
||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容" required></textarea>
|
||||
<label class="field">
|
||||
<span class="field__label">短信内容</span>
|
||||
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容"
|
||||
required></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="button button--primary" type="submit">发送短信</button>
|
||||
@@ -139,27 +245,44 @@
|
||||
</section>
|
||||
|
||||
<section class="console-card" id="console">
|
||||
<div class="panel-header">
|
||||
<span>AT console</span>
|
||||
<strong id="consoleState">idle</strong>
|
||||
</div>
|
||||
<header class="panel-header">
|
||||
<span class="panel-header__label">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
at console
|
||||
</span>
|
||||
<span class="panel-header__status">
|
||||
<span class="status-dot status-dot--amber" aria-hidden="true"></span>
|
||||
<strong id="consoleState">idle</strong>
|
||||
</span>
|
||||
</header>
|
||||
<form id="atForm" class="at-form">
|
||||
<input class="text-input text-input--dark" name="command" value="AT+CSQ" autocomplete="off" spellcheck="false" required>
|
||||
<button class="button button--secondary-on-dark" type="submit">执行</button>
|
||||
<input class="text-input text-input--mono" name="command" value="AT+CSQ" autocomplete="off"
|
||||
spellcheck="false" required>
|
||||
<button class="button button--primary" type="submit">执行</button>
|
||||
</form>
|
||||
<pre class="console-output" id="atOutput">等待命令...</pre>
|
||||
<pre class="console-output" id="atOutput">› 等待命令...</pre>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-band section-band--dark" id="logs">
|
||||
<!-- ============ LOGS ============ -->
|
||||
<section class="section-band" id="logs">
|
||||
<div class="container">
|
||||
<div class="dark-heading">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="badge badge--dark">logs</span>
|
||||
<h2>运行日志</h2>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
logs
|
||||
</span>
|
||||
<h2>运行<em>日志</em></h2>
|
||||
</div>
|
||||
<button class="button button--secondary-on-dark" id="logsButton" type="button">刷新日志</button>
|
||||
<button class="button button--secondary" id="logsButton" type="button">刷新日志</button>
|
||||
</div>
|
||||
<div class="log-window" id="logWindow" aria-live="polite">
|
||||
<p class="empty-state">等待日志加载。</p>
|
||||
@@ -168,21 +291,32 @@
|
||||
</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">
|
||||
<div class="modal__header">
|
||||
<header class="modal__header">
|
||||
<div>
|
||||
<span class="badge badge--soft">push</span>
|
||||
<h2 id="pushModalTitle">推送渠道设置</h2>
|
||||
<span class="eyebrow eyebrow--spike">
|
||||
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
push channels
|
||||
</span>
|
||||
<h2 id="pushModalTitle">推送渠道<em>设置</em></h2>
|
||||
</div>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button>
|
||||
</div>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="push-settings">
|
||||
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
||||
<div class="panel-header panel-header--light">
|
||||
<span>channels</span>
|
||||
<span class="panel-header__label">channels</span>
|
||||
<div class="channel-add">
|
||||
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
@@ -209,13 +343,13 @@
|
||||
<span>启用通道</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>通道名称</span>
|
||||
<label class="field">
|
||||
<span class="field__label">通道名称</span>
|
||||
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>推送类型</span>
|
||||
<label class="field">
|
||||
<span class="field__label">推送类型</span>
|
||||
<select class="text-input select-input" name="type">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
@@ -229,29 +363,30 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="url">
|
||||
<span id="pushUrlLabel">Webhook URL</span>
|
||||
<label class="field field-row field-row--wide" data-push-field="url">
|
||||
<span class="field__label" id="pushUrlLabel">Webhook URL</span>
|
||||
<input class="text-input" type="text" name="url" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="secret">
|
||||
<span id="pushSecretLabel">加签密钥</span>
|
||||
<label class="field field-row field-row--wide" data-push-field="secret">
|
||||
<span class="field__label" id="pushSecretLabel">加签密钥</span>
|
||||
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label class="field-row" data-push-field="key1">
|
||||
<span id="pushKey1Label">Key1</span>
|
||||
<label class="field field-row" data-push-field="key1">
|
||||
<span class="field__label" id="pushKey1Label">Key1</span>
|
||||
<input class="text-input" type="text" name="key1" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row" data-push-field="key2">
|
||||
<span id="pushKey2Label">Key2</span>
|
||||
<label class="field field-row" data-push-field="key2">
|
||||
<span class="field__label" id="pushKey2Label">Key2</span>
|
||||
<input class="text-input" type="text" name="key2" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-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 class="field field-row field-row--wide" data-push-field="customBody">
|
||||
<span class="field__label">自定义请求体 JSON</span>
|
||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7"
|
||||
spellcheck="false"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -269,11 +404,19 @@
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span>
|
||||
<span>Basic Auth protected management surface</span>
|
||||
<span class="brand brand--footer">
|
||||
<svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" fill="none" />
|
||||
</svg>
|
||||
<span>SMS Gateway</span>
|
||||
</span>
|
||||
<span class="footer__meta">© <span id="footerYear">2026</span> · Token protected · Built on Node.js +
|
||||
ML307A</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/assets/admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+944
-318
File diff suppressed because it is too large
Load Diff
+314
-12
@@ -1,5 +1,10 @@
|
||||
const state = {
|
||||
busy: false,
|
||||
mobileDataBusy: false,
|
||||
trafficBusy: false,
|
||||
simBusy: false,
|
||||
currentMobileData: null,
|
||||
currentSim: null,
|
||||
pushChannels: [],
|
||||
selectedPushChannel: -1,
|
||||
pushLoaded: false
|
||||
@@ -29,18 +34,36 @@ 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'),
|
||||
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'),
|
||||
iccid: document.querySelector('#iccid'),
|
||||
currentSim: document.querySelector('#currentSim'),
|
||||
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'),
|
||||
@@ -103,10 +126,12 @@ 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');
|
||||
@@ -152,12 +177,157 @@ function formatModel(model = {}) {
|
||||
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
|
||||
}
|
||||
|
||||
function renderStatus(status, info) {
|
||||
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 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));
|
||||
setText(stateNode, slot.active ? '当前使用' : '未选中');
|
||||
const phoneText = formatSimSlotPhone(slot);
|
||||
if (phoneNode) {
|
||||
phoneNode.textContent = phoneText;
|
||||
phoneNode.hidden = !phoneText;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSimStatus(sim = {}) {
|
||||
state.currentSim = sim;
|
||||
|
||||
const supported = Boolean(sim.supported && sim.canSwitch);
|
||||
const activeSlot = getActiveSimSlot(sim);
|
||||
const modeLabel = supported ? (sim.modeLabel || '双卡') : '单卡/未检测到双卡';
|
||||
|
||||
setText(elements.simMode, modeLabel);
|
||||
|
||||
renderSimSlot(getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone);
|
||||
renderSimSlot(getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone);
|
||||
|
||||
elements.simSwitchButtons.forEach((button) => {
|
||||
const slot = Number.parseInt(button.dataset.simSwitch, 10);
|
||||
const isActive = activeSlot?.slot === slot;
|
||||
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive;
|
||||
button.textContent = isActive ? '正在使用' : `切到SIM${slot + 1}`;
|
||||
});
|
||||
|
||||
if (state.simBusy || sim.switching) {
|
||||
setStatusMessage(elements.simStatus, '正在切换SIM...');
|
||||
} else if (supported && activeSlot) {
|
||||
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`, 'success');
|
||||
} else if (!supported) {
|
||||
setStatusMessage(elements.simStatus, '双卡不可用');
|
||||
} else {
|
||||
setStatusMessage(elements.simStatus, '');
|
||||
}
|
||||
renderSendSimLabel();
|
||||
}
|
||||
|
||||
function renderStatus(status, info, sim) {
|
||||
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');
|
||||
@@ -165,6 +335,7 @@ function renderStatus(status, info) {
|
||||
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、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
|
||||
@@ -172,7 +343,8 @@ function renderStatus(status, info) {
|
||||
setText(elements.signalHint, signal.ber === undefined ? 'RSSI 与误码率将在刷新后显示。' : `误码率 ${signal.ber},质量 ${signal.quality || '未知'}。`);
|
||||
setText(elements.moduleModel, model.model || '--');
|
||||
setText(elements.moduleVersion, model.version || '--');
|
||||
setText(elements.iccid, infoData.iccid || '--');
|
||||
setText(elements.currentSim, getSimDisplayLabel(activeSim) || infoData.simLabel || '--');
|
||||
renderSimStatus(simData);
|
||||
}
|
||||
|
||||
function parseLogLine(line) {
|
||||
@@ -238,9 +410,13 @@ function formatDateTime(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(messages) {
|
||||
function renderMessages(messages, meta = {}) {
|
||||
elements.messageList.replaceChildren();
|
||||
setText(elements.messageCount, `最近 ${messages.length} 条`);
|
||||
const countLabel = `最近 ${messages.length} 条`;
|
||||
const scopeLabel = '新短信自动显示';
|
||||
|
||||
setText(elements.messageCount, countLabel);
|
||||
setText(elements.messageScope, scopeLabel);
|
||||
|
||||
if (!messages.length) {
|
||||
const empty = document.createElement('p');
|
||||
@@ -590,8 +766,111 @@ async function refreshLogs() {
|
||||
}
|
||||
|
||||
async function refreshMessages() {
|
||||
const messages = await requestJSON('/api/sms/received?limit=50');
|
||||
renderMessages(messages.data || []);
|
||||
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 || {});
|
||||
setStatusMessage(elements.simStatus, result.message || `已切换到SIM${slotNumber + 1}`, 'success');
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
setStatusMessage(elements.simStatus, 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() {
|
||||
@@ -606,7 +885,9 @@ async function refreshAll() {
|
||||
try {
|
||||
const status = await requestJSON('/api/status');
|
||||
const info = await requestJSON('/api/modem/info');
|
||||
renderStatus(status, info);
|
||||
const sim = await requestJSON('/api/modem/sim?refresh=true');
|
||||
renderStatus(status, info, sim);
|
||||
setStatusMessage(elements.mobileDataStatus, '');
|
||||
await refreshMessages();
|
||||
await refreshLogs();
|
||||
} catch (err) {
|
||||
@@ -635,7 +916,8 @@ async function sendSMS(event) {
|
||||
}
|
||||
|
||||
submit.disabled = true;
|
||||
setStatusMessage(elements.smsStatus, '发送中...');
|
||||
renderSendSimLabel();
|
||||
setStatusMessage(elements.smsStatus, `正在使用 ${getActiveSimLabel()} 发送...`);
|
||||
|
||||
try {
|
||||
const result = await requestJSON('/api/sms/send', {
|
||||
@@ -663,18 +945,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;
|
||||
@@ -694,10 +976,30 @@ 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();
|
||||
|
||||
+153
-38
@@ -50,7 +50,7 @@ class APIServer {
|
||||
// JSON解析
|
||||
this.app.use(express.json());
|
||||
|
||||
// Web Token认证,API保留Basic Auth兼容
|
||||
// Web Token认证
|
||||
this.app.use((req, res, next) => {
|
||||
this.authenticateRequest(req, res, next);
|
||||
});
|
||||
@@ -75,22 +75,23 @@ class APIServer {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.isWebRoute(req) && this.getConfiguredWebToken()) {
|
||||
return this.sendTokenGate(res);
|
||||
}
|
||||
if (this.isWebRoute(req)) {
|
||||
const webToken = this.getConfiguredWebToken();
|
||||
if (webToken) {
|
||||
return this.sendTokenGate(res);
|
||||
}
|
||||
|
||||
if (this.hasValidBasicAuth(req)) {
|
||||
return next();
|
||||
return res.status(401).send('未配置 webToken');
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: this.getConfiguredWebToken() ? '需要有效token或Basic Auth' : '需要Basic Auth'
|
||||
error: this.getConfiguredWebToken() ? '需要有效token' : '未配置webToken'
|
||||
});
|
||||
}
|
||||
|
||||
return this.sendBasicAuthChallenge(res);
|
||||
return res.status(401).send(this.getConfiguredWebToken() ? '需要有效token' : '未配置 webToken');
|
||||
}
|
||||
|
||||
getConfiguredWebToken() {
|
||||
@@ -143,24 +144,6 @@ 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));
|
||||
@@ -176,11 +159,6 @@ 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>
|
||||
@@ -364,6 +342,7 @@ 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,
|
||||
@@ -372,6 +351,8 @@ class APIServer {
|
||||
model: this.modem.modelInfo,
|
||||
signal,
|
||||
operator,
|
||||
mobileData,
|
||||
sim: this.modem.getCachedSimStatus?.() || null,
|
||||
uptime: process.uptime()
|
||||
}
|
||||
});
|
||||
@@ -396,11 +377,14 @@ class APIServer {
|
||||
});
|
||||
}
|
||||
|
||||
const success = await this.modem.sendSMS(phone, message);
|
||||
const result = await this.modem.sendSMS(phone, message);
|
||||
const success = Boolean(result?.success);
|
||||
const simLabel = result?.simLabel || '未知SIM';
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? '短信发送成功' : '短信发送失败'
|
||||
message: success ? `短信发送成功,发送SIM: ${simLabel}` : `短信发送失败,发送SIM: ${simLabel}`,
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('发送短信失败:', err);
|
||||
@@ -412,12 +396,20 @@ class APIServer {
|
||||
});
|
||||
|
||||
// 查询收到的短信
|
||||
this.app.get('/api/sms/received', (req, res) => {
|
||||
this.app.get('/api/sms/received', async (req, res) => {
|
||||
try {
|
||||
const messages = this.smsProcessor.getReceivedMessages(req.query.limit);
|
||||
await this.smsProcessor.getCurrentSimIdentity();
|
||||
const currentSimIdentity = this.smsProcessor.getCurrentModemSimIdentity();
|
||||
const result = this.smsProcessor.listReceivedMessages(req.query.limit, {
|
||||
currentSimIdentity,
|
||||
scope: req.query.scope,
|
||||
simSlot: req.query.simSlot ?? req.query.slot
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: messages
|
||||
data: result.messages,
|
||||
meta: result.meta
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询收到短信失败:', err);
|
||||
@@ -448,12 +440,19 @@ class APIServer {
|
||||
// 查询模组信息
|
||||
this.app.get('/api/modem/info', async (req, res) => {
|
||||
try {
|
||||
const iccid = await this.modem.getICCID();
|
||||
const simStatus = this.modem.getCachedSimStatus?.();
|
||||
const currentSlot = simStatus?.slots?.find(slot => slot.active);
|
||||
const simIdentity = this.smsProcessor.setCurrentSimIdentity(
|
||||
this.smsProcessor.getCurrentModemSimIdentity()
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
model: this.modem.modelInfo,
|
||||
iccid,
|
||||
phoneNumber: currentSlot?.phoneNumber || '',
|
||||
simId: simIdentity?.simId || null,
|
||||
simLabel: simIdentity?.simLabel || '未知SIM',
|
||||
ready: this.modem.ready
|
||||
}
|
||||
});
|
||||
@@ -466,6 +465,122 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询SIM状态和双卡能力
|
||||
this.app.get('/api/modem/sim', async (req, res) => {
|
||||
try {
|
||||
const sim = await this.modem.getSimStatus({
|
||||
refreshIdentity: req.query.refresh !== 'false'
|
||||
});
|
||||
this.smsProcessor.setCurrentSimIdentity(this.smsProcessor.getCurrentModemSimIdentity());
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: sim
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询SIM状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 切换当前SIM卡槽
|
||||
this.app.post('/api/modem/sim/switch', async (req, res) => {
|
||||
try {
|
||||
const slot = req.body?.slot ?? req.body?.sim;
|
||||
const result = await this.modem.switchSim(slot);
|
||||
const currentSlot = result.status.slots.find(item => item.active);
|
||||
this.smsProcessor.setCurrentSimIdentity(this.smsProcessor.getCurrentModemSimIdentity());
|
||||
const currentLabel = currentSlot?.phoneLabel || currentSlot?.phoneNumber || currentSlot?.name || '目标SIM';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.changed ? `已绑定并切到${currentLabel}` : `${currentLabel}已绑定并切换`,
|
||||
data: result.status
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('切换SIM失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 查询移动数据连接状态
|
||||
this.app.get('/api/modem/mobile-data', async (req, res) => {
|
||||
try {
|
||||
const mobileData = await this.modem.getMobileDataStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data: mobileData
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询移动数据状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 开启或关闭移动数据连接
|
||||
this.app.post('/api/modem/mobile-data', async (req, res) => {
|
||||
try {
|
||||
if (typeof req.body?.enabled !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必要参数: enabled'
|
||||
});
|
||||
}
|
||||
|
||||
const mobileData = await this.modem.setMobileDataEnabled(req.body.enabled);
|
||||
const message = mobileData.mode === 'mipcall'
|
||||
? (req.body.enabled ? '应用层拨号已开启' : '应用层拨号已断开')
|
||||
: (req.body.enabled ? '移动数据已开启' : '移动数据已关闭');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message,
|
||||
data: mobileData
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('设置移动数据状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 通过模组执行一次MPING,消耗少量流量验证连通性
|
||||
this.app.post('/api/modem/mobile-data/consume', async (req, res) => {
|
||||
try {
|
||||
const ping = await this.modem.consumeMobileDataTraffic({
|
||||
target: req.body?.target || '8.8.8.8'
|
||||
});
|
||||
const mobileData = await this.modem.getMobileDataStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '已消耗少量流量完成连通性测试',
|
||||
data: {
|
||||
ping,
|
||||
mobileData
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('流量消耗测试失败:', err);
|
||||
res.status(err.statusCode || 500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
data: err.data || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 发送AT命令
|
||||
this.app.post('/api/modem/at', async (req, res) => {
|
||||
try {
|
||||
|
||||
+15
-8
@@ -13,10 +13,12 @@ class ConcatManager extends EventEmitter {
|
||||
/**
|
||||
* 查找或创建长短信槽位
|
||||
*/
|
||||
findOrCreateSlot(refNumber, sender, totalParts) {
|
||||
findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
|
||||
const simId = simIdentity?.simId || '';
|
||||
|
||||
// 先查找是否已存在
|
||||
let slot = this.buffer.find(s =>
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender && s.simId === simId
|
||||
);
|
||||
|
||||
if (slot) {
|
||||
@@ -26,7 +28,7 @@ class ConcatManager extends EventEmitter {
|
||||
// 查找空闲槽位
|
||||
slot = this.buffer.find(s => !s.inUse);
|
||||
if (slot) {
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
||||
return slot;
|
||||
}
|
||||
|
||||
@@ -44,17 +46,19 @@ class ConcatManager extends EventEmitter {
|
||||
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
||||
}
|
||||
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化槽位
|
||||
*/
|
||||
initSlot(slot, refNumber, sender, totalParts) {
|
||||
initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
|
||||
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();
|
||||
@@ -65,10 +69,10 @@ class ConcatManager extends EventEmitter {
|
||||
/**
|
||||
* 添加短信分段
|
||||
*/
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) {
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp, options = {}) {
|
||||
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
|
||||
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts);
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts, options.simIdentity);
|
||||
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
|
||||
|
||||
if (partIndex >= 0 && partIndex < this.maxParts) {
|
||||
@@ -110,7 +114,8 @@ class ConcatManager extends EventEmitter {
|
||||
this.emit('complete', {
|
||||
sender: slot.sender,
|
||||
text: fullText,
|
||||
timestamp: slot.timestamp
|
||||
timestamp: slot.timestamp,
|
||||
simIdentity: slot.simIdentity
|
||||
});
|
||||
|
||||
// 清空槽位
|
||||
@@ -124,6 +129,8 @@ class ConcatManager extends EventEmitter {
|
||||
slot.inUse = false;
|
||||
slot.parts = [];
|
||||
slot.receivedParts = 0;
|
||||
slot.simId = '';
|
||||
slot.simIdentity = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+20
-10
@@ -46,7 +46,7 @@ async function main() {
|
||||
logger.info('配置加载完成');
|
||||
|
||||
// 创建模组管理器
|
||||
const modem = new ModemManager(config.serial);
|
||||
const modem = new ModemManager(config.serial, config.mobileData, config);
|
||||
|
||||
// 创建长短信管理器
|
||||
const concatManager = new ConcatManager();
|
||||
@@ -60,7 +60,9 @@ async function main() {
|
||||
// 监听长短信合并完成事件
|
||||
concatManager.on('complete', async (sms) => {
|
||||
logger.info('收到长短信合并完成事件');
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp);
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp, {
|
||||
simIdentity: sms.simIdentity
|
||||
});
|
||||
});
|
||||
|
||||
// 监听模组短信事件
|
||||
@@ -111,19 +113,27 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n收到 SIGINT 信号,正在关闭...');
|
||||
async function shutdown(signal) {
|
||||
logger.info(`\n收到 ${signal} 信号,正在关闭...`);
|
||||
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', async () => {
|
||||
logger.info('\n收到 SIGTERM 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown('SIGTERM').catch((err) => {
|
||||
logger.error('关闭失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
// 捕获未处理的异常
|
||||
|
||||
+1611
-75
File diff suppressed because it is too large
Load Diff
+349
-11
@@ -1,4 +1,5 @@
|
||||
import { parse } from 'node-pdu';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -17,16 +18,135 @@ 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(pduHex) {
|
||||
async processPDU(input) {
|
||||
try {
|
||||
logger.info('开始解析PDU数据...');
|
||||
|
||||
const smsEvent = this.normalizeIncomingPduEvent(input);
|
||||
const pduHex = smsEvent.pdu;
|
||||
|
||||
// 解析PDU
|
||||
const parsed = parse(pduHex);
|
||||
|
||||
@@ -40,10 +160,15 @@ 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}`);
|
||||
|
||||
// 检查是否为长短信
|
||||
@@ -62,11 +187,12 @@ class SMSProcessor {
|
||||
partNumber,
|
||||
totalParts,
|
||||
text,
|
||||
timestamp
|
||||
timestamp,
|
||||
{ simIdentity }
|
||||
);
|
||||
} else {
|
||||
// 普通短信,直接处理
|
||||
await this.processSmsContent(sender, text, timestamp);
|
||||
await this.processSmsContent(sender, text, timestamp, { simIdentity });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('处理PDU失败:', err);
|
||||
@@ -76,14 +202,20 @@ class SMSProcessor {
|
||||
/**
|
||||
* 处理短信内容(管理员命令检查和转发)
|
||||
*/
|
||||
async processSmsContent(sender, text, timestamp) {
|
||||
async processSmsContent(sender, text, timestamp, options = {}) {
|
||||
const simIdentity = options.simIdentity !== undefined
|
||||
? this.normalizeSimIdentity(options.simIdentity)
|
||||
: await this.getCurrentSimIdentity();
|
||||
|
||||
logger.info('=== 处理短信内容 ===');
|
||||
logger.info(`发送者: ${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);
|
||||
const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity);
|
||||
|
||||
// 检查是否在黑名单中
|
||||
if (this.isInBlacklist(sender)) {
|
||||
@@ -127,13 +259,19 @@ class SMSProcessor {
|
||||
/**
|
||||
* 记录收到的短信,供Web管理端展示。
|
||||
*/
|
||||
addReceivedMessage(sender, text, timestamp) {
|
||||
addReceivedMessage(sender, text, timestamp, simIdentity = null) {
|
||||
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
|
||||
const message = {
|
||||
id: `${Date.now()}-${++this.receivedMessageSeq}`,
|
||||
sender,
|
||||
text,
|
||||
timestamp,
|
||||
receivedAt: new Date().toISOString(),
|
||||
simId: normalizedSimIdentity?.simId || '',
|
||||
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
|
||||
simSlot: normalizedSimIdentity?.simSlot ?? null,
|
||||
simSource: normalizedSimIdentity?.simSource || 'unknown',
|
||||
smsCenter: normalizedSimIdentity?.smsCenter || '',
|
||||
status: 'received',
|
||||
statusText: '已接收'
|
||||
};
|
||||
@@ -144,7 +282,7 @@ class SMSProcessor {
|
||||
}
|
||||
|
||||
this.persistReceivedMessages();
|
||||
logger.info(`短信已进入Web收件箱: ${sender}`);
|
||||
logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -163,8 +301,87 @@ class SMSProcessor {
|
||||
* 获取最近收到的短信。
|
||||
*/
|
||||
getReceivedMessages(limit = 50) {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
||||
return this.receivedMessages.slice(0, safeLimit);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,6 +396,20 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析收件箱持久化文件路径。
|
||||
*/
|
||||
@@ -257,17 +488,123 @@ 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
|
||||
});
|
||||
}
|
||||
|
||||
if (item.sim && typeof item.sim === 'object') {
|
||||
return this.normalizeSimIdentity(item.sim);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
|
||||
*/
|
||||
async getCurrentSimIdentity(options = {}) {
|
||||
const refresh = options.refresh === true;
|
||||
const now = Date.now();
|
||||
|
||||
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
if (this.currentSimLookup) {
|
||||
return this.currentSimLookup;
|
||||
}
|
||||
|
||||
if (this.modem?.isSimSwitching?.()) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
const modemSim = this.getCurrentModemSimIdentity();
|
||||
if (modemSim?.simId) {
|
||||
return this.setCurrentSimIdentity(modemSim);
|
||||
}
|
||||
|
||||
this.currentSimIdentity = null;
|
||||
this.currentSimCheckedAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
getCurrentModemSimIdentity() {
|
||||
const simStatus = this.modem?.getCachedSimStatus?.();
|
||||
const activeSlot = simStatus?.activeSlot;
|
||||
if (activeSlot === null || activeSlot === undefined) {
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
const identity = this.createSimIdentityFromSlot(activeSlot, {
|
||||
simSource: 'current_slot'
|
||||
});
|
||||
return identity || this.currentSimIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前SIM身份缓存。
|
||||
*/
|
||||
setCurrentSimIdentity(identity) {
|
||||
this.currentSimIdentity = this.normalizeSimIdentity(identity);
|
||||
this.currentSimCheckedAt = Date.now();
|
||||
return this.currentSimIdentity;
|
||||
}
|
||||
|
||||
normalizeSimIdentity(identity) {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof identity === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof identity !== 'object' || Array.isArray(identity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simSlot = this.normalizeSimSlot(identity.simSlot ?? identity.slot);
|
||||
const simId = String(identity.simId || identity.id || '').trim();
|
||||
if (!simId && simSlot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
simId: simId || crypto.createHash('sha256').update(`slot:${simSlot}`).digest('hex').slice(0, 24),
|
||||
simLabel: String(identity.simLabel || identity.label || (simSlot === null ? '未知SIM' : `SIM${simSlot + 1}`)),
|
||||
simSlot,
|
||||
simSource: String(identity.simSource || identity.source || 'unknown'),
|
||||
smsCenter: ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在黑名单中
|
||||
*/
|
||||
@@ -328,11 +665,12 @@ class SMSProcessor {
|
||||
logger.info(`目标号码: ${targetPhone}`);
|
||||
logger.info(`短信内容: ${smsContent}`);
|
||||
|
||||
const success = await this.modem.sendSMS(targetPhone, smsContent);
|
||||
const sendResult = await this.modem.sendSMS(targetPhone, smsContent);
|
||||
const success = Boolean(sendResult?.success);
|
||||
|
||||
// 发送邮件通知结果
|
||||
const subject = success ? '短信发送成功' : '短信发送失败';
|
||||
const body = `管理员命令执行结果:\n命令: ${command}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`;
|
||||
const body = `管理员命令执行结果:\n命令: ${command}\n发送SIM: ${sendResult?.simLabel || '未知SIM'}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`;
|
||||
await this.pushManager.sendEmail(subject, body);
|
||||
} else {
|
||||
logger.error('SMS命令格式错误');
|
||||
|
||||
Reference in New Issue
Block a user