Compare commits

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