Compare commits

..
2 Commits
Author SHA1 Message Date
wuxu a37344253b feat: 添加Windows COM口支持和跨平台说明
- 支持Windows COM1/COM2/COM3等串口格式
- 支持数字串口号自动转换(如: 3 -> COM3)
- 新增PLATFORM.md跨平台使用说明文档
- 更新配置示例为Windows友好的COM3
- 包含Windows和Linux的串口查看、权限配置等详细说明
2026-06-23 17:53:38 +08:00
wuxu 3ab7a19874 feat: 初始化Node.js 4G短信网关项目
- 实现AT指令通信和4G模组管理
- 支持PDU模式短信接收和发送
- 长短信自动合并功能
- 多通道推送支持(钉钉/飞书/Telegram等)
- 管理员短信远程控制
- RESTful API接口
- 完整的日志系统
2026-06-23 17:46:56 +08:00
18 changed files with 873 additions and 5065 deletions
-18
View File
@@ -1,18 +0,0 @@
.git
.env
.npm
.npm-cache
.vscode
.idea
node_modules
logs
data
config.json
*.log
*.tgz
*.swp
*.swo
*~
AGENTS.md
DESIGN.md
ML307A_*.md
-3
View File
@@ -1,6 +1,5 @@
node_modules/ node_modules/
logs/ logs/
data/
*.log *.log
config.json config.json
.DS_Store .DS_Store
@@ -11,6 +10,4 @@ config.json
*.swo *.swo
*~ *~
.npm .npm
.npm-cache/
*.tgz
.eslintcache .eslintcache
+310
View File
@@ -0,0 +1,310 @@
# 4G模块AT指令交互文档
## 串口配置
- **波特率**: 115200
- **数据位**: 8
- **停止位**: 1
- **校验**: None
- **流控**: None
## 初始化流程
### 1. 基础握手
```
AT
响应: OK
作用: 测试模组是否响应
```
### 2. 查询模组信息
```
ATI
响应:
厂商名称
型号
固件版本
OK
作用: 获取模组厂商、型号、固件版本
```
### 3. 禁用数据连接(防止流量消耗)
```
AT+CGACT=0,1
响应: OK
作用: 关闭4G数据连接,防止自动联网消耗流量
注意: ML307Y型号此命令有bug,需跳过
```
### 4. 配置短信URC上报
```
AT+CNMI=2,2,0,0,0
响应: OK
作用: 配置短信到达时主动上报(URC模式)
参数说明:
- 第1参数=2: 缓冲URC到TE
- 第2参数=2: 短信直接上报+CMT URC(PDU模式)
- 其余参数=0: 禁用状态报告、广播消息等
```
### 5. 设置PDU模式
```
AT+CMGF=0
响应: OK
作用: 设置短信为PDU模式(支持中文、长短信)
```
### 6. 等待网络注册
```
AT+CEREG?
响应: +CEREG: <n>,<stat>
OK
作用: 查询LTE网络注册状态
<stat>值:
- 0: 未注册,未搜索
- 1: 已注册,本地网络
- 2: 未注册,正在搜索
- 3: 注册被拒绝
- 4: 未知状态
- 5: 已注册,漫游网络
成功条件: <stat>=1 或 5
```
## 短信接收(URC自动上报)
当收到短信时,模组会主动发送:
```
+CMT: ,<length>
<PDU_HEX_STRING>
```
**示例**:
```
+CMT: ,26
0891683108200005F0040D91683158148764F30000222151114270802C4F60597D
```
### PDU解析
PDU是十六进制字符串,需要使用PDU解析库(如 `node-pdu`)解析:
- **发送者号码**
- **时间戳**(YYMMDDHHMMSS格式,带时区)
- **短信内容**(支持中文UCS2编码)
- **长短信信息**(参考号、当前段号、总段数)
### 长短信处理
长短信PDU包含额外头部(UDH):
- **参考号** (Reference Number): 用于标识同一条长短信的不同分段
- **总段数** (Total Parts): 该长短信分为几段
- **当前段号** (Part Number): 当前是第几段(从1开始)
**合并逻辑**:
1. 检测到长短信(总段数 > 1)时,创建缓存槽位
2. 使用 `(参考号 + 发送者号码)` 作为唯一标识
3. 收到每一段后,存入对应位置
4. 收齐所有分段后,按段号顺序拼接内容
5. 30秒超时保护:未收齐也强制转发已收到的部分
## 短信发送(PDU模式)
### 1. 编码PDU
使用PDU编码库将目标号码和短信内容编码为PDU十六进制字符串。
### 2. 发送AT+CMGS命令
```
AT+CMGS=<length>
响应: > (提示符)
作用: 准备发送短信,<length>为PDU数据长度(字节数,不含SMSC)
```
### 3. 发送PDU数据
```
<PDU_HEX_STRING><Ctrl+Z>
响应: +CMGS: <mr>
OK
作用: 发送PDU数据,以Ctrl+Z(0x1A)结束
<mr>: 消息参考号
```
**完整流程示例**:
```
-> AT+CMGS=23
<- >
-> 0011000D91683158148764F30000AA05E4BDA0E5A5BD1A
<- +CMGS: 123
OK
```
## 其他常用命令
### 查询信号强度
```
AT+CSQ
响应: +CSQ: <rssi>,<ber>
OK
<rssi>: 信号强度 (0-31, 99=未知)
- 0-9: 弱
- 10-14: 一般
- 15-19: 好
- 20-31: 很好
- 99: 未知或不可检测
```
### 查询SIM卡状态
```
AT+CPIN?
响应: +CPIN: READY (或其他状态)
OK
状态:
- READY: SIM卡已就绪
- SIM PIN: 需要PIN码
- SIM PUK: 需要PUK码
```
### 查询ICCID
```
AT+CCID
响应: +CCID: <iccid>
OK
作用: 获取SIM卡ICCID(集成电路卡识别码)
```
### 查询IMEI
```
AT+GSN
响应: <imei>
OK
作用: 获取模组IMEI号
```
### 查询运营商
```
AT+COPS?
响应: +COPS: <mode>,<format>,<oper>
OK
示例: +COPS: 0,0,"CHINA MOBILE"
```
### 临时激活数据连接(用于Ping)
```
AT+CGACT=1,1
响应: OK
作用: 激活数据连接(用于网络测试后需关闭)
```
### Ping测试
```
AT+CPING="www.baidu.com",1,4,64,1000,10000,0
响应: +CPING: 1,<ip>,<time>,<ttl>
+CPING: 2,0,0,0,0
OK
参数: 域名,回显次数,数据包大小,超时,间隔,最大等待,保留
注意: 需要先激活数据连接(AT+CGACT=1,1)
```
### 删除所有短信(清理存储空间)
```
AT+CMGD=1,4
响应: OK
作用: 删除所有短信(参数4=删除所有)
```
## URC(主动上报)消息
### 短信到达
```
+CMT: ,<length>
<PDU_HEX_STRING>
```
### 网络注册状态变化
```
+CEREG: <stat>
```
## 错误处理
### 常见ERROR原因
- **命令格式错误**: 检查AT命令语法
- **参数错误**: 检查参数范围和类型
- **模组未就绪**: 确保模组已完成初始化
- **网络未注册**: 等待CEREG状态变为1或5
- **SIM卡未就绪**: 检查SIM卡是否插好
### 超时处理
- **AT命令超时**: 一般1-2秒,网络相关命令5-10秒
- **发送短信超时**: 30秒
- **网络注册超时**: 30次重试(约30-60秒)
### 重试策略
- 初始化命令失败:立即重试
- 网络注册失败:等待后重试
- 发送短信失败:记录日志,不重试
## Node.js实现要点
### 串口读取
- 使用 `serialport` 库
- 设置行解析器 (`@serialport/parser-readline`)
- 监听数据事件,逐行处理
### AT命令发送
```javascript
async function sendATCommand(cmd, timeout = 2000) {
return new Promise((resolve, reject) => {
let buffer = '';
port.write(cmd + '\r\n');
const timer = setTimeout(() => {
reject(new Error('Timeout'));
}, timeout);
const handler = (data) => {
buffer += data;
if (buffer.includes('OK') || buffer.includes('ERROR')) {
clearTimeout(timer);
port.removeListener('data', handler);
resolve(buffer);
}
};
port.on('data', handler);
});
}
```
### PDU解析
使用 `node-pdu` 库:
```javascript
const PDU = require('node-pdu');
const parsed = PDU.parse(pduHexString);
// parsed.sender: 发送者号码
// parsed.text: 短信内容
// parsed.time: 时间戳
```
### URC监听
```javascript
parser.on('data', (line) => {
if (line.startsWith('+CMT:')) {
// 下一行是PDU数据
isWaitingPDU = true;
} else if (isWaitingPDU && /^[0-9A-Fa-f]+$/.test(line)) {
// 收到PDU数据
handleSMS(line);
isWaitingPDU = false;
}
});
```
## 状态机设计
```
IDLE状态
└─ 收到 +CMT: → WAIT_PDU状态
└─ 收到十六进制行 → 解析PDU → 处理短信 → IDLE状态
└─ 收到非十六进制行 → IDLE状态
```
-43
View File
@@ -1,43 +0,0 @@
FROM node:20-bookworm-slim AS deps
ENV NODE_ENV=production \
DEBIAN_FRONTEND=noninteractive \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false \
NPM_CONFIG_UPDATE_NOTIFIER=false
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm cache clean --force
FROM node:20-bookworm-slim AS runtime
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 udev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY src ./src
COPY public ./public
COPY config.example.json ./
RUN mkdir -p logs data
EXPOSE 3000
CMD ["node", "src/index.js"]
+222
View File
@@ -0,0 +1,222 @@
# 跨平台支持说明
本项目支持在 **Windows** 和 **Linux** 上运行。
## 串口配置
### Windows
Windows 使用 `COMx` 格式:
```json
{
"serial": {
"path": "COM3",
"baudRate": 115200
}
}
```
也可以直接写数字,程序会自动添加 `COM` 前缀:
```json
{
"serial": {
"path": "3",
"baudRate": 115200
}
}
```
**查看可用串口**:
```powershell
# 方法1: 设备管理器
devmgmt.msc
# 方法2: PowerShell
Get-WmiObject Win32_SerialPort | Select-Object Name, DeviceID
# 方法3: 使用 mode 命令
mode
```
### Linux
Linux 使用 `/dev/ttyUSBx` 或 `/dev/ttyACMx` 格式:
```json
{
"serial": {
"path": "/dev/ttyUSB0",
"baudRate": 115200
}
}
```
**查看可用串口**:
```bash
# 查看所有USB串口设备
ls -l /dev/ttyUSB*
ls -l /dev/ttyACM*
# 查看详细信息
dmesg | grep tty
# 使用 lsusb 查看USB设备
lsusb
```
**权限配置**(Linux必需):
```bash
# 将当前用户添加到 dialout 组
sudo usermod -a -G dialout $USER
# 注销并重新登录后生效,或临时授权
sudo chmod 666 /dev/ttyUSB0
```
## 平台差异
### 文件路径
- Windows: 使用反斜杠 `\` 或正斜杠 `/` (Node.js会自动处理)
- Linux: 使用正斜杠 `/`
本项目使用 Node.js 的 `path` 模块,会自动处理平台差异。
### PM2 守护进程
Windows 和 Linux 都支持 PM2,但启动脚本略有不同:
**Windows**:
```powershell
pm2 start ecosystem.config.js
pm2 save
pm2 startup
# 按提示执行生成的命令(需要管理员权限)
```
**Linux**:
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup
# 按提示执行生成的命令(使用 sudo)
```
### 日志目录
两个平台都会在项目根目录下创建 `logs/` 目录:
- Windows: `.\logs\`
- Linux: `./logs/`
## 测试串口连接
可以使用以下工具测试串口是否正常:
### Windows
- **PuTTY**: https://www.putty.org/
- **Tera Term**: https://ttssh2.osdn.jp/
- **串口调试助手**
### Linux
- **minicom**: `sudo apt install minicom && sudo minicom -D /dev/ttyUSB0 -b 115200`
- **screen**: `sudo screen /dev/ttyUSB0 115200`
- **picocom**: `sudo apt install picocom && sudo picocom /dev/ttyUSB0 -b 115200`
测试方法:
1. 打开串口工具,连接到对应COM口
2. 发送 `AT` 命令
3. 应该收到 `OK` 响应
## 常见问题
### Windows
**Q: 提示串口被占用**
```
Error: Port is already open
```
A: 检查是否有其他程序(如Arduino IDE、串口调试助手)占用了该端口
**Q: 找不到COM口**
A:
1. 检查设备管理器中4G模块是否正确识别
2. 确认驱动是否已安装
3. 尝试拔插USB重新识别
**Q: 权限不足**
A: Windows通常不需要特殊权限,如果遇到问题,尝试以管理员身份运行
### Linux
**Q: 权限不足**
```
Error: Opening COM1: Permission denied
```
A:
```bash
sudo usermod -a -G dialout $USER
# 注销重新登录
```
**Q: 串口不存在**
```
Error: No such file or directory, cannot open /dev/ttyUSB0
```
A:
```bash
# 检查设备是否识别
dmesg | tail -20
lsusb
# 可能是 ttyACM0 而不是 ttyUSB0
ls /dev/tty*
```
**Q: ModemManager占用串口**
A: 某些Linux发行版的ModemManager会自动占用4G模块
```bash
# 临时停用
sudo systemctl stop ModemManager
# 永久禁用(不推荐,可能影响系统网络功能)
sudo systemctl disable ModemManager
```
## 开发环境
### Node.js 版本
- 推荐: Node.js 18.x LTS 或更高
- 最低: Node.js 16.x
### 依赖库
所有依赖都是跨平台的:
- `serialport` - 底层使用 C++ 绑定,支持 Windows/Linux/macOS
- `express` - Web框架
- `nodemailer` - 邮件发送
- `axios` - HTTP客户端
- `winston` - 日志库
## 性能建议
### Windows
- 关闭Windows Defender实时扫描对 `node_modules/` 的监控
- 使用SSD存储项目文件
### Linux
- 使用 `noatime` 挂载选项减少磁盘IO
- 调整串口缓冲区大小(如有需要):
```bash
setserial /dev/ttyUSB0 low_latency
```
## 部署建议
### Windows Server
- 使用 PM2 作为守护进程
- 配置 Windows 防火墙允许 API 端口(默认3000)
- 设置 PM2 开机自启动
### Linux Server
- 使用 PM2 + systemd
- 配置 UFW/iptables 防火墙
- 考虑使用 Nginx 反向代理
+212
View File
@@ -0,0 +1,212 @@
# 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": "COM3",
"baudRate": 115200,
"_comment": "Windows使用COM1/COM2/COM3等; Linux使用/dev/ttyUSB0"
},
"smtp": {
"server": "smtp.qq.com",
"port": 465,
"user": "your@qq.com",
"pass": "your_auth_code",
"sendTo": "recipient@example.com"
},
"adminPhone": "13800138000",
"numberBlackList": [],
"pushChannels": [
{
"enabled": true,
"type": "dingtalk",
"name": "钉钉通知",
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
"secret": "SECxxx"
}
],
"api": {
"port": 3000,
"auth": {
"username": "admin",
"password": "admin123"
}
}
}
```
## 运行
### 开发模式
```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/modem/signal
```
### 查询模组信息
```bash
GET /api/modem/info
```
### 发送AT命令
```bash
POST /api/modem/at
Content-Type: application/json
{
"command": "AT+CSQ"
}
```
### 获取日志
```bash
GET /api/logs
```
所有API均需要HTTP Basic认证。
## 项目结构
```
.
├── 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 |
## 管理员命令
通过短信发送命令(仅管理员号码):
### 远程发送短信
```
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
-1
View File
@@ -70,7 +70,6 @@
], ],
"api": { "api": {
"port": 3000, "port": 3000,
"webToken": "change-this-token",
"auth": { "auth": {
"username": "admin", "username": "admin",
"password": "admin123" "password": "admin123"
-26
View File
@@ -1,26 +0,0 @@
# Before starting:
# cp config.example.json config.json
# Keep serial.autoDetect enabled in config.json so the app can probe visible serial ports.
services:
sms-gateway:
build:
context: .
dockerfile: Dockerfile
image: sms-forwarding:latest
container_name: sms-forwarding
restart: unless-stopped
init: true
environment:
NODE_ENV: production
TZ: ${TZ:-Asia/Shanghai}
ports:
- "${SMS_GATEWAY_PORT:-3000}:3000"
volumes:
- ./config.json:/app/config.json
- ./logs:/app/logs
- ./data:/app/data
- /dev:/dev
- /sys:/sys:ro
device_cgroup_rules:
- "c 188:* rwm"
- "c 166:* rwm"
-2048
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@
"dependencies": { "dependencies": {
"serialport": "^12.0.0", "serialport": "^12.0.0",
"@serialport/parser-readline": "^12.0.0", "@serialport/parser-readline": "^12.0.0",
"node-pdu": "^2.1.2", "node-pdu": "^0.2.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",
-279
View File
@@ -1,279 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMS Gateway 管理台</title>
<link rel="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>
</a>
<nav class="nav-links" aria-label="管理导航">
<a href="#status">状态</a>
<a href="#inbox">收件箱</a>
<a href="#send">短信</a>
<a href="#console">AT 控制台</a>
<a href="#logs">日志</a>
</nav>
<div class="top-nav__actions">
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
</div>
</div>
</header>
<main>
<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>
</div>
<div class="product-panel" aria-label="运行摘要">
<div class="panel-header">
<span>runtime</span>
<strong id="panelStatus">loading</strong>
</div>
<dl class="runtime-list">
<div>
<dt>模组</dt>
<dd id="modelName">--</dd>
</div>
<div>
<dt>运营商</dt>
<dd id="operator">--</dd>
</div>
<div>
<dt>信号</dt>
<dd id="signalQuality">--</dd>
</div>
<div>
<dt>运行时长</dt>
<dd id="uptime">--</dd>
</div>
</dl>
</div>
</div>
</section>
<section class="section-band section-band--soft">
<div class="container">
<div class="section-heading">
<h2>当前状态</h2>
<p>这些数据来自模组实时 AT 查询,刷新时会串行读取,避免同时发送多条 AT 命令。</p>
</div>
<div class="status-grid">
<article class="feature-card">
<span class="eyebrow">connection</span>
<h3 id="connectionState">--</h3>
<p id="connectionHint">等待状态刷新。</p>
</article>
<article class="feature-card">
<span class="eyebrow">signal</span>
<h3 id="signalValue">--</h3>
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p>
</article>
<article class="feature-card">
<span class="eyebrow">module</span>
<h3 id="moduleModel">--</h3>
<p id="moduleVersion">--</p>
</article>
<article class="feature-card">
<span class="eyebrow">sim</span>
<h3 id="iccid">--</h3>
<p>ICCID 查询来自当前串口模组。</p>
</article>
</div>
</div>
</section>
<section class="section-band">
<div class="container" id="inbox">
<div class="section-heading">
<div>
<span class="badge badge--soft">inbox</span>
<h2>收到的短信</h2>
</div>
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
</div>
<div class="inbox-panel">
<div class="inbox-meta">
<span id="messageCount">最近 0 条</span>
<span>新短信会自动出现在这里</span>
</div>
<div class="message-list" id="messageList" aria-live="polite">
<p class="empty-state empty-state--light">暂无收到的短信。</p>
</div>
</div>
</div>
</section>
<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>
</div>
<form id="smsForm" class="stack-form">
<label>
<span>目标号码</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>
<div class="form-actions">
<button class="button button--primary" type="submit">发送短信</button>
<p class="form-status" id="smsStatus" role="status"></p>
</div>
</form>
</section>
<section class="console-card" id="console">
<div class="panel-header">
<span>AT console</span>
<strong id="consoleState">idle</strong>
</div>
<form id="atForm" class="at-form">
<input class="text-input text-input--dark" name="command" value="AT+CSQ" autocomplete="off" spellcheck="false" required>
<button class="button button--secondary-on-dark" type="submit">执行</button>
</form>
<pre class="console-output" id="atOutput">等待命令...</pre>
</section>
</div>
</section>
<section class="section-band section-band--dark" id="logs">
<div class="container">
<div class="dark-heading">
<div>
<span class="badge badge--dark">logs</span>
<h2>运行日志</h2>
</div>
<button class="button button--secondary-on-dark" id="logsButton" type="button">刷新日志</button>
</div>
<div class="log-window" id="logWindow" aria-live="polite">
<p class="empty-state">等待日志加载。</p>
</div>
</div>
</section>
</main>
<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">
<div>
<span class="badge badge--soft">push</span>
<h2 id="pushModalTitle">推送渠道设置</h2>
</div>
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button>
</div>
<div class="push-settings">
<aside class="channel-list-panel" aria-label="推送渠道列表">
<div class="panel-header panel-header--light">
<span>channels</span>
<div class="channel-add">
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
<option value="dingtalk">钉钉机器人</option>
<option value="feishu">飞书机器人</option>
<option value="telegram">Telegram Bot</option>
<option value="pushplus">PushPlus</option>
<option value="serverchan">Server酱</option>
<option value="post_json">POST JSON</option>
<option value="bark">Bark</option>
<option value="get">GET</option>
<option value="custom">自定义 POST</option>
</select>
<button class="button button--secondary button--compact" id="pushAddButton" type="button">新增</button>
</div>
</div>
<div class="channel-list" id="pushChannelList"></div>
</aside>
<form class="channel-form" id="pushChannelForm">
<fieldset class="form-fieldset" id="pushFormFields" disabled>
<div class="channel-form__grid">
<label class="switch-row">
<input type="checkbox" name="enabled">
<span>启用通道</span>
</label>
<label>
<span>通道名称</span>
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
</label>
<label>
<span>推送类型</span>
<select class="text-input select-input" name="type">
<option value="dingtalk">钉钉机器人</option>
<option value="feishu">飞书机器人</option>
<option value="telegram">Telegram Bot</option>
<option value="pushplus">PushPlus</option>
<option value="serverchan">Server酱</option>
<option value="post_json">POST JSON</option>
<option value="bark">Bark</option>
<option value="get">GET</option>
<option value="custom">自定义 POST</option>
</select>
</label>
<label class="field-row field-row--wide" data-push-field="url">
<span id="pushUrlLabel">Webhook URL</span>
<input class="text-input" type="text" name="url" autocomplete="off">
</label>
<label class="field-row field-row--wide" data-push-field="secret">
<span id="pushSecretLabel">加签密钥</span>
<input class="text-input" type="password" name="secret" autocomplete="new-password">
</label>
<label class="field-row" data-push-field="key1">
<span 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>
<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>
</div>
</fieldset>
<div class="form-actions channel-form__actions">
<button class="button button--primary" id="pushSaveButton" type="submit">保存设置</button>
<button class="button button--secondary" id="pushTestButton" type="button">发送测试</button>
<button class="button button--danger" id="pushDeleteButton" type="button">删除通道</button>
<p class="form-status" id="pushStatus" role="status"></p>
</div>
</form>
</div>
</section>
</div>
<footer class="footer">
<div class="container footer__inner">
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span>
<span>Basic Auth protected management surface</span>
</div>
</footer>
<script src="/assets/admin.js" type="module"></script>
</body>
</html>
-987
View File
@@ -1,987 +0,0 @@
:root {
--primary: #cc785c;
--primary-active: #a9583e;
--primary-disabled: #e6dfd8;
--ink: #141413;
--body: #3d3d3a;
--body-strong: #252523;
--muted: #6c6a64;
--muted-soft: #8e8b82;
--hairline: #e6dfd8;
--hairline-soft: #ebe6df;
--canvas: #faf9f5;
--surface-soft: #f5f0e8;
--surface-card: #efe9de;
--surface-dark: #181715;
--surface-dark-elevated: #252320;
--surface-dark-soft: #1f1e1b;
--on-primary: #ffffff;
--on-dark: #faf9f5;
--on-dark-soft: #a09d96;
--accent-teal: #5db8a6;
--accent-amber: #e8a55a;
--success: #5db872;
--warning: #d4a017;
--error: #c64545;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--canvas);
color: var(--body);
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 16px;
line-height: 1.55;
letter-spacing: 0;
}
button,
input,
textarea,
select {
font: inherit;
letter-spacing: 0;
}
a {
color: inherit;
text-decoration: none;
}
.container {
width: min(1200px, calc(100% - 48px));
margin: 0 auto;
}
.top-nav {
position: sticky;
top: 0;
z-index: 10;
height: 64px;
background: rgba(250, 249, 245, 0.94);
border-bottom: 1px solid var(--hairline-soft);
}
.top-nav__inner {
width: min(1200px, calc(100% - 48px));
height: 64px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 24px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--ink);
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.brand__mark {
color: var(--ink);
font-size: 18px;
line-height: 1;
}
.nav-links {
display: flex;
align-items: center;
gap: 18px;
margin-left: 16px;
color: var(--muted);
font-size: 14px;
font-weight: 500;
}
.nav-links a:focus-visible,
.button:focus-visible,
.icon-button:focus-visible,
.channel-list__item:focus-visible,
.text-input:focus-visible,
.select-input:focus-visible {
outline: 3px solid rgba(204, 120, 92, 0.18);
outline-offset: 2px;
}
.top-nav__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
border: 0;
border-radius: 8px;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
line-height: 1;
cursor: pointer;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.button--primary {
background: var(--primary);
color: var(--on-primary);
}
.button--primary:active {
background: var(--primary-active);
}
.button--secondary {
background: var(--canvas);
color: var(--ink);
border: 1px solid var(--hairline);
}
.button--secondary-on-dark {
background: var(--surface-dark-elevated);
color: var(--on-dark);
}
.button--danger {
border: 1px solid rgba(198, 69, 69, 0.28);
background: rgba(198, 69, 69, 0.1);
color: var(--error);
}
.button--compact {
min-height: 32px;
padding: 8px 12px;
font-size: 13px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--hairline);
border-radius: 9999px;
background: var(--canvas);
color: var(--ink);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.hero-band {
padding: 72px 0 96px;
background: var(--canvas);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 520px);
align-items: stretch;
gap: 48px;
}
.hero-copy {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 340px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1,
h2 {
color: var(--ink);
font-family: "Cormorant Garamond", "EB Garamond", Georgia, "Times New Roman", serif;
font-weight: 500;
letter-spacing: 0;
}
h1 {
max-width: 700px;
margin-top: 18px;
font-size: 64px;
line-height: 1.05;
}
h2 {
font-size: 36px;
line-height: 1.15;
}
h3 {
color: var(--ink);
font-size: 22px;
font-weight: 500;
line-height: 1.3;
}
.hero-copy p {
max-width: 620px;
margin-top: 22px;
color: var(--body-strong);
font-size: 18px;
line-height: 1.55;
}
.badge {
display: inline-flex;
align-items: center;
width: max-content;
border-radius: 9999px;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0;
}
.badge--coral {
background: var(--primary);
color: var(--on-primary);
}
.badge--dark {
background: var(--surface-dark-elevated);
color: var(--on-dark);
}
.badge--soft {
background: var(--surface-card);
color: var(--ink);
}
.product-panel,
.console-card {
border-radius: 12px;
background: var(--surface-dark);
color: var(--on-dark);
padding: 32px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--on-dark-soft);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
}
.panel-header strong {
color: var(--accent-teal);
font-weight: 500;
}
.runtime-list {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
margin: 32px 0 0;
}
.runtime-list div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 16px;
padding: 16px 0;
border-top: 1px solid rgba(250, 249, 245, 0.1);
}
.runtime-list dt {
color: var(--on-dark-soft);
font-size: 13px;
}
.runtime-list dd {
margin: 0;
overflow-wrap: anywhere;
color: var(--on-dark);
font-size: 15px;
}
.section-band {
padding: 96px 0;
background: var(--canvas);
}
.section-band--soft {
background: var(--surface-soft);
}
.section-band--dark {
background: var(--surface-dark);
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 32px;
margin-bottom: 32px;
}
.section-heading p {
max-width: 560px;
color: var(--muted);
}
.section-heading--compact {
display: block;
margin-bottom: 28px;
}
.section-heading--compact p {
margin-top: 10px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.feature-card,
.form-card {
border-radius: 12px;
background: var(--surface-card);
padding: 32px;
}
.feature-card {
min-height: 190px;
}
.feature-card p {
margin-top: 14px;
color: var(--muted);
font-size: 14px;
}
.eyebrow {
display: block;
margin-bottom: 18px;
color: var(--muted);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.4;
}
.workbench-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(420px, 1.1fr);
gap: 24px;
}
.inbox-panel {
border-radius: 12px;
background: var(--surface-card);
padding: 24px;
}
.inbox-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--hairline);
padding-bottom: 16px;
color: var(--muted);
font-size: 14px;
}
.message-list {
display: grid;
gap: 12px;
max-height: 520px;
overflow: auto;
padding-top: 16px;
}
.message-item {
border: 1px solid var(--hairline);
border-radius: 12px;
background: var(--canvas);
padding: 18px;
}
.message-item__header {
display: grid;
grid-template-columns: minmax(140px, 1fr) auto auto;
align-items: center;
gap: 12px;
}
.message-item__header strong {
overflow-wrap: anywhere;
color: var(--ink);
font-size: 16px;
font-weight: 500;
}
.message-item__header time {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.message-status {
border-radius: 9999px;
background: var(--surface-card);
color: var(--muted);
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.message-item.is-forwarded .message-status {
background: rgba(93, 184, 114, 0.16);
color: #2f7c44;
}
.message-item.is-admin .message-status {
background: rgba(232, 165, 90, 0.2);
color: #8a5b16;
}
.message-item.is-ignored .message-status {
background: rgba(198, 69, 69, 0.12);
color: var(--error);
}
.message-text {
margin-top: 12px;
color: var(--body);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.stack-form {
display: grid;
gap: 18px;
}
.stack-form label {
display: grid;
gap: 8px;
color: var(--body-strong);
font-size: 14px;
font-weight: 500;
}
.text-input {
width: 100%;
min-height: 40px;
border: 1px solid var(--hairline);
border-radius: 8px;
background: var(--canvas);
color: var(--ink);
padding: 10px 14px;
font-size: 16px;
line-height: 1.4;
}
.text-input:focus {
border-color: var(--primary);
outline: 3px solid rgba(204, 120, 92, 0.15);
}
.select-input {
appearance: auto;
}
.text-area {
min-height: 144px;
resize: vertical;
}
.text-area--code {
min-height: 160px;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
line-height: 1.6;
}
.form-actions {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.form-status {
color: var(--muted);
font-size: 14px;
}
.form-status.is-error {
color: var(--error);
}
.form-status.is-success {
color: var(--success);
}
.console-card {
display: grid;
grid-template-rows: auto auto minmax(220px, 1fr);
gap: 20px;
}
.at-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.text-input--dark {
border-color: rgba(250, 249, 245, 0.12);
background: var(--surface-dark-soft);
color: var(--on-dark);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
}
.console-output,
.log-window {
overflow: auto;
border-radius: 12px;
background: var(--surface-dark-soft);
color: var(--on-dark);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 14px;
line-height: 1.6;
}
.console-output {
min-height: 220px;
margin: 0;
padding: 20px;
white-space: pre-wrap;
}
.dark-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
margin-bottom: 32px;
}
.dark-heading h2 {
margin-top: 14px;
color: var(--on-dark);
}
.log-window {
height: 420px;
padding: 18px 20px;
}
.log-line {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 16px;
border-bottom: 1px solid rgba(250, 249, 245, 0.08);
padding: 8px 0;
}
.log-line:last-child {
border-bottom: 0;
}
.log-level {
color: var(--on-dark-soft);
}
.log-line.is-error .log-level {
color: var(--error);
}
.log-line.is-warn .log-level {
color: var(--warning);
}
.log-line.is-info .log-level {
color: var(--accent-teal);
}
.log-message {
overflow-wrap: anywhere;
color: var(--on-dark);
}
.empty-state {
color: var(--on-dark-soft);
}
.empty-state--light {
color: var(--muted);
}
.footer {
background: var(--surface-dark);
color: var(--on-dark-soft);
border-top: 1px solid rgba(250, 249, 245, 0.08);
padding: 32px 0;
font-size: 14px;
}
.footer__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.brand--footer {
color: var(--on-dark);
}
body.modal-open {
overflow: hidden;
}
.modal {
position: fixed;
inset: 0;
z-index: 50;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal.is-open {
display: flex;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(20, 20, 19, 0.48);
}
.modal__dialog {
position: relative;
width: min(1040px, 100%);
max-height: min(86vh, 860px);
overflow: auto;
border-radius: 12px;
background: var(--canvas);
box-shadow: 0 24px 80px rgba(20, 20, 19, 0.24);
padding: 32px;
}
.modal__header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--hairline);
padding-bottom: 24px;
}
.modal__header h2 {
margin-top: 14px;
}
.push-settings {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
margin-top: 24px;
}
.channel-list-panel {
border-radius: 12px;
background: var(--surface-card);
padding: 20px;
}
.panel-header--light {
color: var(--muted);
}
.channel-list-panel .panel-header {
display: grid;
grid-template-columns: 1fr;
align-items: start;
gap: 10px;
}
.channel-add {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
width: 100%;
}
.channel-add__select {
min-height: 32px;
min-width: 0;
padding: 6px 8px;
font-size: 13px;
}
.channel-list {
display: grid;
gap: 10px;
max-height: 520px;
overflow: auto;
margin-top: 18px;
}
.channel-list__item {
width: 100%;
border: 1px solid var(--hairline);
border-radius: 8px;
background: var(--canvas);
color: var(--body);
padding: 14px;
text-align: left;
cursor: pointer;
}
.channel-list__item.is-active {
border-color: var(--surface-dark);
background: var(--surface-dark);
color: var(--on-dark);
}
.channel-list__name {
display: block;
overflow-wrap: anywhere;
color: inherit;
font-size: 15px;
font-weight: 500;
}
.channel-list__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
.channel-list__item.is-active .channel-list__meta {
color: var(--on-dark-soft);
}
.channel-form {
border-radius: 12px;
background: var(--surface-card);
padding: 24px;
}
.form-fieldset {
margin: 0;
border: 0;
padding: 0;
}
.form-fieldset:disabled {
opacity: 0.62;
}
.channel-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.channel-form label {
display: grid;
gap: 8px;
color: var(--body-strong);
font-size: 14px;
font-weight: 500;
}
.switch-row {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
width: max-content;
}
.switch-row input {
width: 18px;
min-height: 18px;
accent-color: var(--primary);
}
.field-row--wide {
grid-column: 1 / -1;
}
.channel-form__actions {
margin-top: 20px;
}
.is-hidden {
display: none !important;
}
@media (max-width: 1024px) {
.hero-grid,
.workbench-grid,
.push-settings {
grid-template-columns: 1fr;
}
.status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 767px) {
.container,
.top-nav__inner {
width: min(100% - 32px, 1200px);
}
.top-nav {
height: auto;
}
.top-nav__inner {
min-height: 64px;
flex-wrap: wrap;
padding: 12px 0;
}
.nav-links {
order: 3;
width: 100%;
margin-left: 0;
overflow-x: auto;
padding-bottom: 4px;
}
.top-nav__actions {
margin-left: auto;
}
.hero-band {
padding: 48px 0 64px;
}
h1 {
font-size: 38px;
}
h2 {
font-size: 30px;
}
.hero-copy {
min-height: 0;
}
.section-band {
padding: 64px 0;
}
.section-heading,
.dark-heading,
.footer__inner {
display: grid;
align-items: start;
}
.status-grid {
grid-template-columns: 1fr;
}
.product-panel,
.console-card,
.feature-card,
.form-card {
padding: 24px;
}
.at-form {
grid-template-columns: 1fr;
}
.message-item__header {
grid-template-columns: 1fr;
align-items: start;
}
.message-item__header time,
.message-status {
width: max-content;
}
.log-window {
height: 360px;
}
.log-line {
grid-template-columns: 1fr;
gap: 4px;
}
.modal {
align-items: stretch;
padding: 12px;
}
.modal__dialog {
max-height: calc(100vh - 24px);
padding: 20px;
}
.modal__header {
gap: 16px;
padding-bottom: 18px;
}
.channel-form,
.channel-list-panel {
padding: 20px;
}
.channel-form__grid {
grid-template-columns: 1fr;
}
.switch-row,
.field-row--wide {
grid-column: auto;
}
}
-710
View File
@@ -1,710 +0,0 @@
const state = {
busy: false,
pushChannels: [],
selectedPushChannel: -1,
pushLoaded: false
};
const elements = {
pushSettingsButton: document.querySelector('#pushSettingsButton'),
pushModal: document.querySelector('#pushModal'),
pushModalClose: document.querySelector('#pushModalClose'),
pushAddTypeSelect: document.querySelector('#pushAddTypeSelect'),
pushAddButton: document.querySelector('#pushAddButton'),
pushDeleteButton: document.querySelector('#pushDeleteButton'),
pushSaveButton: document.querySelector('#pushSaveButton'),
pushTestButton: document.querySelector('#pushTestButton'),
pushChannelList: document.querySelector('#pushChannelList'),
pushChannelForm: document.querySelector('#pushChannelForm'),
pushFormFields: document.querySelector('#pushFormFields'),
pushStatus: document.querySelector('#pushStatus'),
pushUrlLabel: document.querySelector('#pushUrlLabel'),
pushSecretLabel: document.querySelector('#pushSecretLabel'),
pushKey1Label: document.querySelector('#pushKey1Label'),
pushKey2Label: document.querySelector('#pushKey2Label'),
refreshButton: document.querySelector('#refreshButton'),
logsButton: document.querySelector('#logsButton'),
readyBadge: document.querySelector('#readyBadge'),
panelStatus: document.querySelector('#panelStatus'),
modelName: document.querySelector('#modelName'),
operator: document.querySelector('#operator'),
signalQuality: document.querySelector('#signalQuality'),
uptime: document.querySelector('#uptime'),
connectionState: document.querySelector('#connectionState'),
connectionHint: document.querySelector('#connectionHint'),
signalValue: document.querySelector('#signalValue'),
signalHint: document.querySelector('#signalHint'),
moduleModel: document.querySelector('#moduleModel'),
moduleVersion: document.querySelector('#moduleVersion'),
iccid: document.querySelector('#iccid'),
smsForm: document.querySelector('#smsForm'),
smsStatus: document.querySelector('#smsStatus'),
messagesButton: document.querySelector('#messagesButton'),
messageCount: document.querySelector('#messageCount'),
messageList: document.querySelector('#messageList'),
atForm: document.querySelector('#atForm'),
atOutput: document.querySelector('#atOutput'),
consoleState: document.querySelector('#consoleState'),
logWindow: document.querySelector('#logWindow')
};
const PUSH_TYPE_OPTIONS = {
dingtalk: '钉钉机器人',
feishu: '飞书机器人',
telegram: 'Telegram Bot',
pushplus: 'PushPlus',
serverchan: 'Server酱',
post_json: 'POST JSON',
bark: 'Bark',
get: 'GET',
custom: '自定义 POST'
};
const PUSH_TYPE_META = {
dingtalk: {
urlLabel: '钉钉 Webhook URL',
secretLabel: '加签密钥',
show: ['url', 'secret']
},
feishu: {
urlLabel: '飞书 Webhook URL',
secretLabel: '加签密钥',
show: ['url', 'secret']
},
telegram: {
urlLabel: 'Bot Token',
key1Label: 'Chat ID',
show: ['url', 'key1']
},
pushplus: {
key1Label: 'PushPlus Token',
show: ['key1']
},
serverchan: {
key1Label: 'Server酱 SendKey',
show: ['key1']
},
post_json: {
urlLabel: 'POST URL',
show: ['url']
},
bark: {
urlLabel: 'Bark 推送 URL',
show: ['url']
},
get: {
urlLabel: 'GET URL',
show: ['url']
},
custom: {
urlLabel: 'POST URL',
show: ['url', 'customBody']
}
};
function setText(node, value) {
node.textContent = value || '--';
}
function setStatusMessage(node, message, type = '') {
node.textContent = message;
node.classList.toggle('is-success', type === 'success');
node.classList.toggle('is-error', type === 'error');
}
async function requestJSON(url, options = {}) {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data;
}
function formatUptime(seconds) {
const total = Math.max(0, Math.floor(seconds || 0));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (days > 0) {
return `${days}天 ${hours}小时 ${minutes}分`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}分`;
}
if (minutes > 0) {
return `${minutes}分 ${secs}秒`;
}
return `${secs}秒`;
}
function formatModel(model = {}) {
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
}
function renderStatus(status, info) {
const data = status.data || {};
const infoData = info.data || {};
const model = data.model || infoData.model || {};
const signal = data.signal || {};
const ready = Boolean(data.ready);
setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪');
setText(elements.panelStatus, ready ? 'ready' : 'not ready');
setText(elements.modelName, formatModel(model));
setText(elements.operator, data.operator || '未知');
setText(elements.signalQuality, signal.quality || '未知');
setText(elements.uptime, formatUptime(data.uptime));
setText(elements.connectionState, ready ? '运行中' : '未就绪');
setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
setText(elements.signalValue, signal.rssi === undefined ? '--' : `RSSI ${signal.rssi}`);
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 || '--');
}
function parseLogLine(line) {
const levelMatch = line.match(/\[(INFO|WARN|ERROR)\]/);
const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
return {
time: timeMatch ? timeMatch[1].slice(11) : '',
level: levelMatch ? levelMatch[1] : '',
message: line.replace(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s*/, '')
};
}
function renderLogs(logs) {
elements.logWindow.replaceChildren();
if (!logs.length) {
const empty = document.createElement('p');
empty.className = 'empty-state';
empty.textContent = '暂无日志。';
elements.logWindow.append(empty);
return;
}
const fragment = document.createDocumentFragment();
logs.forEach((line) => {
const item = parseLogLine(line);
const row = document.createElement('div');
row.className = `log-line is-${item.level.toLowerCase()}`;
const level = document.createElement('span');
level.className = 'log-level';
level.textContent = item.level ? `${item.time} ${item.level}` : item.time;
const message = document.createElement('span');
message.className = 'log-message';
message.textContent = item.message;
row.append(level, message);
fragment.append(row);
});
elements.logWindow.append(fragment);
elements.logWindow.scrollTop = elements.logWindow.scrollHeight;
}
function formatDateTime(value) {
if (!value) {
return '--';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function renderMessages(messages) {
elements.messageList.replaceChildren();
setText(elements.messageCount, `最近 ${messages.length} 条`);
if (!messages.length) {
const empty = document.createElement('p');
empty.className = 'empty-state empty-state--light';
empty.textContent = '暂无收到的短信。';
elements.messageList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
messages.forEach((message) => {
const item = document.createElement('article');
item.className = `message-item is-${message.status || 'received'}`;
const header = document.createElement('div');
header.className = 'message-item__header';
const sender = document.createElement('strong');
sender.textContent = message.sender || '未知号码';
const status = document.createElement('span');
status.className = 'message-status';
status.textContent = message.statusText || '已接收';
const time = document.createElement('time');
time.textContent = formatDateTime(message.timestamp || message.receivedAt);
const text = document.createElement('p');
text.className = 'message-text';
text.textContent = message.text || '';
header.append(sender, status, time);
item.append(header, text);
fragment.append(item);
});
elements.messageList.append(fragment);
}
function getPushTypeLabel(type) {
return PUSH_TYPE_OPTIONS[type] || type || '推送通道';
}
function createDefaultPushChannel(type = 'dingtalk') {
return {
enabled: true,
type,
name: `${getPushTypeLabel(type)}通知`,
url: '',
secret: '',
key1: '',
key2: '',
customBody: type === 'custom' ? '{"from":"{sender}","message":"{message}","timestamp":"{timestamp}"}' : ''
};
}
function normalizePushChannel(channel = {}) {
const fallback = createDefaultPushChannel(channel.type || 'dingtalk');
return {
...fallback,
enabled: Boolean(channel.enabled),
type: String(channel.type || fallback.type),
name: String(channel.name || fallback.name),
url: String(channel.url || ''),
secret: String(channel.secret || ''),
key1: String(channel.key1 || ''),
key2: String(channel.key2 || ''),
customBody: String(channel.customBody || '')
};
}
function setPushStatus(message, type = '') {
setStatusMessage(elements.pushStatus, message, type);
}
function getPushControls() {
return elements.pushChannelForm.elements;
}
function readPushChannelForm() {
const controls = getPushControls();
return {
enabled: controls.enabled.checked,
type: String(controls.type.value || 'dingtalk'),
name: String(controls.name.value || '').trim(),
url: String(controls.url.value || '').trim(),
secret: String(controls.secret.value || '').trim(),
key1: String(controls.key1.value || '').trim(),
key2: String(controls.key2.value || '').trim(),
customBody: String(controls.customBody.value || '').trim()
};
}
function writePushChannelForm(channel) {
const controls = getPushControls();
controls.enabled.checked = Boolean(channel.enabled);
controls.type.value = channel.type;
controls.name.value = channel.name;
controls.url.value = channel.url;
controls.secret.value = channel.secret;
controls.key1.value = channel.key1;
controls.key2.value = channel.key2;
controls.customBody.value = channel.customBody;
updatePushFieldVisibility(channel.type);
}
function clearPushChannelForm() {
writePushChannelForm(createDefaultPushChannel());
}
function setPushFieldVisible(name, visible) {
const row = elements.pushChannelForm.querySelector(`[data-push-field="${name}"]`);
if (row) {
row.classList.toggle('is-hidden', !visible);
}
}
function updatePushFieldVisibility(type) {
const meta = PUSH_TYPE_META[type] || PUSH_TYPE_META.dingtalk;
const visibleFields = new Set(meta.show || []);
elements.pushUrlLabel.textContent = meta.urlLabel || 'Webhook URL';
elements.pushSecretLabel.textContent = meta.secretLabel || '密钥';
elements.pushKey1Label.textContent = meta.key1Label || 'Key1';
elements.pushKey2Label.textContent = meta.key2Label || 'Key2';
['url', 'secret', 'key1', 'key2', 'customBody'].forEach((name) => {
setPushFieldVisible(name, visibleFields.has(name));
});
}
function syncCurrentPushChannel() {
const index = state.selectedPushChannel;
if (index < 0 || !state.pushChannels[index] || elements.pushFormFields.disabled) {
return;
}
state.pushChannels[index] = normalizePushChannel(readPushChannelForm());
}
function renderPushChannelList() {
elements.pushChannelList.replaceChildren();
if (!state.pushChannels.length) {
const empty = document.createElement('p');
empty.className = 'empty-state empty-state--light';
empty.textContent = '暂无推送通道。';
elements.pushChannelList.append(empty);
return;
}
const fragment = document.createDocumentFragment();
state.pushChannels.forEach((channel, index) => {
const item = document.createElement('button');
item.type = 'button';
item.className = `channel-list__item${index === state.selectedPushChannel ? ' is-active' : ''}`;
const name = document.createElement('span');
name.className = 'channel-list__name';
name.textContent = channel.name || getPushTypeLabel(channel.type);
const meta = document.createElement('span');
meta.className = 'channel-list__meta';
const type = document.createElement('span');
type.textContent = getPushTypeLabel(channel.type);
const status = document.createElement('span');
status.textContent = channel.enabled ? '启用' : '停用';
meta.append(type, status);
item.append(name, meta);
item.addEventListener('click', () => {
selectPushChannel(index);
});
fragment.append(item);
});
elements.pushChannelList.append(fragment);
}
function renderPushChannelForm() {
const channel = state.pushChannels[state.selectedPushChannel];
const hasChannel = Boolean(channel);
elements.pushFormFields.disabled = !hasChannel;
elements.pushTestButton.disabled = !hasChannel;
elements.pushDeleteButton.disabled = !hasChannel;
elements.pushSaveButton.disabled = !state.pushLoaded;
if (!hasChannel) {
clearPushChannelForm();
return;
}
writePushChannelForm(channel);
}
function renderPushSettings() {
renderPushChannelList();
renderPushChannelForm();
}
function selectPushChannel(index) {
syncCurrentPushChannel();
state.selectedPushChannel = index;
renderPushSettings();
setPushStatus('');
}
function addPushChannel() {
syncCurrentPushChannel();
const type = elements.pushAddTypeSelect?.value || 'dingtalk';
state.pushChannels.push(createDefaultPushChannel(type));
state.selectedPushChannel = state.pushChannels.length - 1;
renderPushSettings();
setPushStatus('');
}
function deletePushChannel() {
if (state.selectedPushChannel < 0) {
return;
}
state.pushChannels.splice(state.selectedPushChannel, 1);
state.selectedPushChannel = Math.min(state.selectedPushChannel, state.pushChannels.length - 1);
renderPushSettings();
setPushStatus('');
}
async function loadPushChannels() {
elements.pushAddButton.disabled = true;
elements.pushSaveButton.disabled = true;
elements.pushTestButton.disabled = true;
elements.pushDeleteButton.disabled = true;
setPushStatus('加载中...');
try {
const result = await requestJSON('/api/push/channels');
state.pushChannels = (result.data || []).map(normalizePushChannel);
state.selectedPushChannel = state.pushChannels.length ? 0 : -1;
state.pushLoaded = true;
renderPushSettings();
setPushStatus('');
} catch (err) {
setPushStatus(err.message, 'error');
} finally {
elements.pushAddButton.disabled = false;
elements.pushSaveButton.disabled = !state.pushLoaded;
}
}
function openPushModal() {
elements.pushModal.classList.add('is-open');
elements.pushModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
if (!state.pushLoaded) {
loadPushChannels();
} else {
renderPushSettings();
setPushStatus('');
}
}
function closePushModal() {
syncCurrentPushChannel();
elements.pushModal.classList.remove('is-open');
elements.pushModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
}
async function savePushChannels(event) {
event.preventDefault();
syncCurrentPushChannel();
elements.pushSaveButton.disabled = true;
setPushStatus('保存中...');
try {
const result = await requestJSON('/api/push/channels', {
method: 'PUT',
body: JSON.stringify({ channels: state.pushChannels })
});
state.pushChannels = (result.data || []).map(normalizePushChannel);
if (state.selectedPushChannel >= state.pushChannels.length) {
state.selectedPushChannel = state.pushChannels.length - 1;
}
renderPushSettings();
setPushStatus(result.message || '推送通道配置已保存。', 'success');
await refreshLogs();
} catch (err) {
setPushStatus(err.message, 'error');
} finally {
elements.pushSaveButton.disabled = false;
}
}
async function testPushChannel() {
syncCurrentPushChannel();
const channel = state.pushChannels[state.selectedPushChannel];
if (!channel) {
setPushStatus('请先新增一个推送通道。', 'error');
return;
}
elements.pushTestButton.disabled = true;
setPushStatus('测试发送中...');
try {
const result = await requestJSON('/api/push/test', {
method: 'POST',
body: JSON.stringify({ channel })
});
setPushStatus(result.message || '测试推送已发送。', 'success');
await refreshLogs();
} catch (err) {
setPushStatus(err.message, 'error');
} finally {
elements.pushTestButton.disabled = false;
}
}
function handlePushTypeChange() {
const controls = getPushControls();
const channel = state.pushChannels[state.selectedPushChannel];
const previousType = channel?.type || 'dingtalk';
const previousDefaultName = createDefaultPushChannel(previousType).name;
const type = controls.type.value;
const nextDefaultName = createDefaultPushChannel(type).name;
const currentName = controls.name.value.trim();
updatePushFieldVisibility(type);
if (!currentName || currentName === previousDefaultName) {
controls.name.value = nextDefaultName;
}
if (type === 'custom' && !controls.customBody.value.trim()) {
controls.customBody.value = createDefaultPushChannel('custom').customBody;
}
syncCurrentPushChannel();
}
async function refreshLogs() {
const logs = await requestJSON('/api/logs');
renderLogs(logs.data || []);
}
async function refreshMessages() {
const messages = await requestJSON('/api/sms/received?limit=50');
renderMessages(messages.data || []);
}
async function refreshAll() {
if (state.busy) {
return;
}
state.busy = true;
elements.refreshButton.disabled = true;
elements.refreshButton.textContent = '刷新中';
try {
const status = await requestJSON('/api/status');
const info = await requestJSON('/api/modem/info');
renderStatus(status, info);
await refreshMessages();
await refreshLogs();
} catch (err) {
setText(elements.readyBadge, '刷新失败');
setText(elements.panelStatus, 'error');
setText(elements.connectionState, '查询失败');
setText(elements.connectionHint, err.message);
} finally {
elements.refreshButton.disabled = false;
elements.refreshButton.textContent = '刷新状态';
state.busy = false;
}
}
async function sendSMS(event) {
event.preventDefault();
const submit = elements.smsForm.querySelector('button[type="submit"]');
const formData = new FormData(elements.smsForm);
const phone = String(formData.get('phone') || '').trim();
const message = String(formData.get('message') || '').trim();
if (!phone || !message) {
setStatusMessage(elements.smsStatus, '号码和内容都不能为空。', 'error');
return;
}
submit.disabled = true;
setStatusMessage(elements.smsStatus, '发送中...');
try {
const result = await requestJSON('/api/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, message })
});
setStatusMessage(elements.smsStatus, result.message || '短信发送成功。', result.success ? 'success' : 'error');
await refreshLogs();
} catch (err) {
setStatusMessage(elements.smsStatus, err.message, 'error');
} finally {
submit.disabled = false;
}
}
async function runATCommand(event) {
event.preventDefault();
const submit = elements.atForm.querySelector('button[type="submit"]');
const command = String(new FormData(elements.atForm).get('command') || '').trim();
if (!command) {
return;
}
submit.disabled = true;
setText(elements.consoleState, 'running');
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();
setText(elements.consoleState, 'done');
await refreshLogs();
} catch (err) {
elements.atOutput.textContent = `> ${command}\nERROR: ${err.message}`;
setText(elements.consoleState, 'error');
} finally {
submit.disabled = false;
}
}
elements.pushSettingsButton.addEventListener('click', openPushModal);
elements.pushModalClose.addEventListener('click', closePushModal);
elements.pushModal.addEventListener('click', (event) => {
if (event.target.matches('[data-close-push-modal]')) {
closePushModal();
}
});
elements.pushAddButton.addEventListener('click', addPushChannel);
elements.pushDeleteButton.addEventListener('click', deletePushChannel);
elements.pushChannelForm.addEventListener('submit', savePushChannels);
elements.pushTestButton.addEventListener('click', testPushChannel);
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
elements.refreshButton.addEventListener('click', refreshAll);
elements.logsButton.addEventListener('click', refreshLogs);
elements.messagesButton.addEventListener('click', refreshMessages);
elements.smsForm.addEventListener('submit', sendSMS);
elements.atForm.addEventListener('submit', runATCommand);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
closePushModal();
}
});
refreshAll();
setInterval(() => {
refreshMessages().catch(() => {});
}, 5000);
+11 -433
View File
@@ -1,46 +1,13 @@
import fs from 'fs';
import express from 'express'; import express from 'express';
import crypto from 'crypto'; import basicAuth from 'express-basic-auth';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from './logger.js'; import logger from './logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PUSH_CHANNEL_TYPES = new Set([
'post_json',
'bark',
'get',
'dingtalk',
'pushplus',
'serverchan',
'custom',
'feishu',
'telegram'
]);
const PUSH_CHANNEL_TYPE_LABELS = {
post_json: 'POST JSON',
bark: 'Bark',
get: 'GET',
dingtalk: '钉钉',
pushplus: 'PushPlus',
serverchan: 'Server酱',
custom: '自定义',
feishu: '飞书',
telegram: 'Telegram'
};
class APIServer { class APIServer {
constructor(config, modem, smsProcessor, pushManager) { constructor(config, modem, smsProcessor) {
this.config = config; this.config = config;
this.modem = modem; this.modem = modem;
this.smsProcessor = smsProcessor; this.smsProcessor = smsProcessor;
this.pushManager = pushManager;
this.app = express(); this.app = express();
this.publicDir = path.join(__dirname, '../public');
this.configPath = path.join(__dirname, '../config.json');
this.setupMiddleware(); this.setupMiddleware();
this.setupRoutes(); this.setupRoutes();
@@ -50,315 +17,24 @@ class APIServer {
// JSON解析 // JSON解析
this.app.use(express.json()); this.app.use(express.json());
// Web Token认证,API保留Basic Auth兼容 // HTTP Basic认证
this.app.use((req, res, next) => { const users = {};
this.authenticateRequest(req, res, next); users[this.config.api.auth.username] = this.config.api.auth.password;
});
this.app.use(basicAuth({
users,
challenge: true,
realm: 'SMS Gateway'
}));
// 请求日志 // 请求日志
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`); logger.info(`${req.method} ${req.path}`);
next(); next();
}); });
this.app.use('/assets', express.static(path.join(this.publicDir, 'assets')));
}
authenticateRequest(req, res, next) {
if (this.hasValidWebToken(req)) {
this.persistWebToken(req, res);
if (this.shouldCleanTokenFromUrl(req)) {
return res.redirect(302, this.getCleanUrl(req));
}
return next();
}
if (this.isWebRoute(req) && this.getConfiguredWebToken()) {
return this.sendTokenGate(res);
}
if (this.hasValidBasicAuth(req)) {
return next();
}
if (req.path.startsWith('/api/')) {
return res.status(401).json({
success: false,
error: this.getConfiguredWebToken() ? '需要有效token或Basic Auth' : '需要Basic Auth'
});
}
return this.sendBasicAuthChallenge(res);
}
getConfiguredWebToken() {
return String(this.config.api.webToken || '').trim();
}
hasValidWebToken(req) {
const expected = this.getConfiguredWebToken();
if (!expected) {
return false;
}
const token = this.getRequestToken(req);
return this.safeEqual(token, expected);
}
getRequestToken(req) {
const bearer = req.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1];
return req.query.token || req.get('x-web-token') || bearer || this.getCookie(req, 'sms_gateway_token') || '';
}
getCookie(req, name) {
const cookieHeader = req.get('cookie') || '';
const cookies = cookieHeader.split(';').map(item => item.trim());
const prefix = `${name}=`;
const cookie = cookies.find(item => item.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : '';
}
persistWebToken(req, res) {
if (!req.query.token) {
return;
}
res.cookie('sms_gateway_token', req.query.token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/'
});
}
shouldCleanTokenFromUrl(req) {
return this.isWebRoute(req) && Boolean(req.query.token);
}
getCleanUrl(req) {
const url = new URL(req.originalUrl, 'http://localhost');
url.searchParams.delete('token');
return `${url.pathname}${url.search}`;
}
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));
if (actualBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(actualBuffer, expectedBuffer);
}
isWebRoute(req) {
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>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMS Gateway Token</title>
<style>
:root { color-scheme: light; --canvas:#faf9f5; --ink:#141413; --body:#3d3d3a; --muted:#6c6a64; --card:#efe9de; --primary:#cc785c; --primary-active:#a9583e; --hairline:#e6dfd8; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--canvas); color: var(--body); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.55; }
main { width: min(480px, calc(100% - 32px)); border-radius: 12px; background: var(--card); padding: 32px; }
.mark { color: var(--ink); font-size: 20px; }
h1 { margin: 18px 0 10px; color: var(--ink); font-family: "Cormorant Garamond", "EB Garamond", Georgia, serif; font-size: 40px; font-weight: 500; line-height: 1.1; letter-spacing: 0; }
p { margin: 0 0 24px; color: var(--muted); }
label { display: grid; gap: 8px; color: var(--ink); font-size: 14px; font-weight: 500; }
input { width: 100%; min-height: 40px; border: 1px solid var(--hairline); border-radius: 8px; background: var(--canvas); color: var(--ink); padding: 10px 14px; font: inherit; }
input:focus { border-color: var(--primary); outline: 3px solid rgba(204, 120, 92, .15); }
button { margin-top: 18px; width: 100%; min-height: 40px; border: 0; border-radius: 8px; background: var(--primary); color: #fff; padding: 12px 20px; font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; }
button:active { background: var(--primary-active); }
</style>
</head>
<body>
<main>
<span class="mark">✣</span>
<h1>输入访问 token</h1>
<p>管理台需要有效 token 才能继续访问。</p>
<form method="get" action="/admin">
<label>
Token
<input name="token" type="password" autocomplete="current-password" autofocus required>
</label>
<button type="submit">进入管理台</button>
</form>
</main>
</body>
</html>`);
}
async readConfigFile() {
const configContent = await fs.promises.readFile(this.configPath, 'utf8');
return JSON.parse(configContent);
}
async savePushChannels(channels) {
const diskConfig = await this.readConfigFile();
diskConfig.pushChannels = channels;
await fs.promises.writeFile(
this.configPath,
`${JSON.stringify(diskConfig, null, 2)}\n`,
'utf8'
);
this.config.pushChannels = channels;
if (this.pushManager?.config) {
this.pushManager.config.pushChannels = channels;
}
}
getPushChannelsFromConfig(config = this.config) {
return Array.isArray(config.pushChannels) ? config.pushChannels : [];
}
normalizePushChannels(channels) {
if (!Array.isArray(channels)) {
throw new Error('pushChannels 必须是数组');
}
if (channels.length > 30) {
throw new Error('推送通道数量不能超过 30 个');
}
return channels.map((channel, index) => this.normalizePushChannel(channel, index));
}
normalizePushChannel(channel, index = 0) {
if (!channel || typeof channel !== 'object' || Array.isArray(channel)) {
throw new Error(`第 ${index + 1} 个推送通道格式不正确`);
}
const type = this.normalizeString(channel.type, '推送类型', 32);
if (!PUSH_CHANNEL_TYPES.has(type)) {
throw new Error(`第 ${index + 1} 个推送通道类型不支持`);
}
const typeLabel = PUSH_CHANNEL_TYPE_LABELS[type] || type;
const fallbackName = `${typeLabel}通知`;
const name = this.normalizeString(channel.name || fallbackName, '通道名称', 80);
return {
enabled: Boolean(channel.enabled),
type,
name: name || fallbackName,
url: this.normalizeString(channel.url, 'URL', 2048),
secret: this.normalizeString(channel.secret, '密钥', 2048),
key1: this.normalizeString(channel.key1, 'Key1', 2048),
key2: this.normalizeString(channel.key2, 'Key2', 2048),
customBody: this.normalizeString(channel.customBody, '自定义请求体', 10000)
};
}
normalizeString(value, fieldName, maxLength) {
const text = String(value ?? '').trim();
if (text.length > maxLength) {
throw new Error(`${fieldName}长度不能超过 ${maxLength} 个字符`);
}
return text;
}
validatePushChannels(channels) {
channels.forEach((channel, index) => {
this.validatePushChannel(channel, { index });
});
}
validatePushChannel(channel, options = {}) {
const index = options.index ?? 0;
const requireDestination = Boolean(options.requireDestination || channel.enabled);
const prefix = `第 ${index + 1} 个推送通道`;
const urlTypes = new Set(['post_json', 'bark', 'get', 'dingtalk', 'custom', 'feishu']);
if (urlTypes.has(channel.type)) {
if (requireDestination && !channel.url) {
throw new Error(`${prefix}缺少 URL`);
}
if (channel.url && !this.isHttpUrl(channel.url)) {
throw new Error(`${prefix}的 URL 格式不正确`);
}
}
if (channel.type === 'telegram') {
if (requireDestination && !channel.url) {
throw new Error(`${prefix}缺少 Bot Token`);
}
if (requireDestination && !channel.key1) {
throw new Error(`${prefix}缺少 Chat ID`);
}
}
if (channel.type === 'pushplus' && requireDestination && !channel.key1) {
throw new Error(`${prefix}缺少 PushPlus Token`);
}
if (channel.type === 'serverchan' && requireDestination && !channel.key1) {
throw new Error(`${prefix}缺少 Server酱 SendKey`);
}
if (channel.type === 'custom' && channel.customBody) {
try {
JSON.parse(channel.customBody);
} catch (err) {
throw new Error(`${prefix}的自定义请求体不是有效 JSON`);
}
}
}
isHttpUrl(value) {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (err) {
return false;
}
} }
setupRoutes() { setupRoutes() {
this.app.get('/', (req, res) => {
res.redirect('/admin');
});
this.app.get('/admin', (req, res) => {
res.sendFile(path.join(this.publicDir, 'admin.html'));
});
// 状态查询 // 状态查询
this.app.get('/api/status', async (req, res) => { this.app.get('/api/status', async (req, res) => {
try { try {
@@ -411,23 +87,6 @@ class APIServer {
} }
}); });
// 查询收到的短信
this.app.get('/api/sms/received', (req, res) => {
try {
const messages = this.smsProcessor.getReceivedMessages(req.query.limit);
res.json({
success: true,
data: messages
});
} catch (err) {
logger.error('查询收到短信失败:', err);
res.status(500).json({
success: false,
error: err.message
});
}
});
// 查询信号强度 // 查询信号强度
this.app.get('/api/modem/signal', async (req, res) => { this.app.get('/api/modem/signal', async (req, res) => {
try { try {
@@ -513,87 +172,6 @@ class APIServer {
} }
}); });
// 查询推送通道配置
this.app.get('/api/push/channels', async (req, res) => {
try {
const diskConfig = await this.readConfigFile();
const channels = this.normalizePushChannels(this.getPushChannelsFromConfig(diskConfig));
res.json({
success: true,
data: channels
});
} catch (err) {
logger.error('读取推送通道配置失败:', err);
res.status(500).json({
success: false,
error: err.message
});
}
});
// 保存推送通道配置
this.app.put('/api/push/channels', async (req, res) => {
try {
const rawChannels = Array.isArray(req.body) ? req.body : req.body?.channels;
const channels = this.normalizePushChannels(rawChannels);
this.validatePushChannels(channels);
await this.savePushChannels(channels);
res.json({
success: true,
message: '推送通道配置已保存',
data: channels
});
} catch (err) {
logger.error('保存推送通道配置失败:', err);
res.status(400).json({
success: false,
error: err.message
});
}
});
// 测试推送通道
this.app.post('/api/push/test', async (req, res) => {
try {
if (!this.pushManager) {
return res.status(500).json({
success: false,
error: '推送管理器未初始化'
});
}
const channel = this.normalizePushChannel(req.body?.channel || req.body);
this.validatePushChannel(channel, { requireDestination: true });
const success = await this.pushManager.pushToChannel(
{ ...channel, enabled: true },
'测试号码',
'这是一条短信网关推送测试消息',
new Date().toISOString()
);
if (!success) {
return res.status(502).json({
success: false,
error: '测试推送发送失败,请查看运行日志'
});
}
res.json({
success: true,
message: '测试推送已发送'
});
} catch (err) {
logger.error('测试推送失败:', err);
res.status(400).json({
success: false,
error: err.message
});
}
});
// 健康检查 // 健康检查
this.app.get('/health', (req, res) => { this.app.get('/health', (req, res) => {
res.json({ res.json({
+1 -1
View File
@@ -99,7 +99,7 @@ async function main() {
concatManager.startTimeoutChecker(); concatManager.startTimeoutChecker();
// 启动API服务器 // 启动API服务器
const apiServer = new APIServer(config, modem, smsProcessor, pushManager); const apiServer = new APIServer(config, modem, smsProcessor);
apiServer.start(); apiServer.start();
logger.info('========================================'); logger.info('========================================');
+3 -43
View File
@@ -72,54 +72,14 @@ class LogBuffer {
// 创建全局日志缓冲区 // 创建全局日志缓冲区
const logBuffer = new LogBuffer(120); const logBuffer = new LogBuffer(120);
function formatLogPart(part) { // 拦截日志输出,同时写入缓冲区
if (part instanceof Error) {
return part.stack || part.message;
}
if (typeof part === 'object' && part !== null) {
try {
return JSON.stringify(part);
} catch (err) {
return String(part);
}
}
return String(part);
}
function formatLocalTimestamp(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join('-') + ' ' + [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join(':');
}
function addToBuffer(level, message, args = []) {
const timestamp = formatLocalTimestamp();
const details = [message, ...args].map(formatLogPart).join(' ');
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${details}`);
}
// 拦截日志输出,同时写入缓冲区。winston的info/warn/error便捷方法不会走自定义log方法。
const originalLog = logger.log.bind(logger); const originalLog = logger.log.bind(logger);
logger.log = function(level, message, ...args) { logger.log = function(level, message, ...args) {
addToBuffer(level, message, args); const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
logBuffer.add(`${timestamp} [${level.toUpperCase()}] ${message}`);
return originalLog(level, message, ...args); return originalLog(level, message, ...args);
}; };
['error', 'warn', 'info', 'debug'].forEach((level) => {
const original = logger[level].bind(logger);
logger[level] = function(message, ...args) {
addToBuffer(level, message, args);
return original(message, ...args);
};
});
// 导出日志缓冲区 // 导出日志缓冲区
logger.getBuffer = () => logBuffer; logger.getBuffer = () => logBuffer;
+80 -263
View File
@@ -1,6 +1,5 @@
import { SerialPort } from 'serialport'; import { SerialPort } from 'serialport';
import { ReadlineParser } from '@serialport/parser-readline'; import { ReadlineParser } from '@serialport/parser-readline';
import { Submit } from 'node-pdu';
import EventEmitter from 'events'; import EventEmitter from 'events';
import logger from './logger.js'; import logger from './logger.js';
@@ -155,53 +154,6 @@ class ModemManager extends EventEmitter {
} }
} }
/**
* 发送AT命令,失败时打印模组原始响应,便于排查型号差异
*/
async sendATWithRetry(cmd, options = {}) {
const {
timeout = 2000,
retries = 3,
retryDelay = 1000,
label = cmd,
required = true
} = options;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const resp = await this.sendATCommand(cmd, timeout);
if (resp.includes('OK')) {
return resp;
}
logger.warn(`${label}失败(${attempt}/${retries}): ${this.formatATResponse(resp)}`);
} catch (err) {
logger.warn(`${label}失败(${attempt}/${retries}): ${err.message}`);
}
if (attempt < retries && retryDelay > 0) {
await this.sleep(retryDelay);
}
}
if (required) {
throw new Error(`${label}失败`);
}
return null;
}
/**
* 压缩AT响应,避免日志跨太多行
*/
formatATResponse(resp) {
return resp
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join(' | ');
}
/** /**
* 初始化模组 * 初始化模组
*/ */
@@ -234,11 +186,45 @@ class ModemManager extends EventEmitter {
logger.warn('查询模组信息失败'); logger.warn('查询模组信息失败');
} }
// 3. 按ML307A文档先确认SIM卡和协议栈状态 // 3. 禁用数据连接(ML307Y跳过)
await this.waitSIMReady(); if (this.modelInfo.model !== 'ML307Y') {
await this.ensureFullFunctionality(); retries = 0;
while (!(await this.sendATandWaitOK('AT+CGACT=0,1', 5000)) && retries < 3) {
logger.warn('设置CGACT失败,重试...');
retries++;
}
if (retries < 3) {
logger.info('✓ 已禁用数据连接(AT+CGACT=0,1),防止流量消耗');
} else {
logger.warn('设置CGACT失败,可能会消耗流量');
}
} else {
logger.info('ML307Y型号跳过CGACT配置');
}
// 4. 等待网络注册 // 4. 配置短信URC上报
retries = 0;
while (!(await this.sendATandWaitOK('AT+CNMI=2,2,0,0,0', 1000)) && retries < 3) {
logger.warn('设置CNMI失败,重试...');
retries++;
}
if (retries >= 3) {
throw new Error('CNMI配置失败');
}
logger.info('✓ CNMI参数设置完成');
// 5. 设置PDU模式
retries = 0;
while (!(await this.sendATandWaitOK('AT+CMGF=0', 1000)) && retries < 3) {
logger.warn('设置PDU模式失败,重试...');
retries++;
}
if (retries >= 3) {
throw new Error('PDU模式设置失败');
}
logger.info('✓ PDU模式设置完成');
// 6. 等待网络注册
retries = 0; retries = 0;
while (!(await this.waitCEREG()) && retries < 30) { while (!(await this.waitCEREG()) && retries < 30) {
logger.info('等待网络注册...'); logger.info('等待网络注册...');
@@ -249,146 +235,14 @@ class ModemManager extends EventEmitter {
logger.info('✓ 网络已注册'); logger.info('✓ 网络已注册');
this.ready = true; this.ready = true;
} else { } else {
logger.error('网络注册超时(无SIM卡或信号差)'); logger.error('⚠️ 网络注册超时(无SIM卡或信号差)');
this.ready = false; this.ready = false;
} }
// 5. 数据连接处理。ML307文档不建议用CGACT做PDP激活/去激活。
await this.disableDataConnection();
// 6. 按文档配置短信功能,并启用本项目需要的PDU模式
await this.configureSMS();
logger.info('模组初始化完成'); logger.info('模组初始化完成');
this.emit('ready'); this.emit('ready');
} }
/**
* 等待SIM卡完成初始化
*/
async waitSIMReady() {
for (let attempt = 1; attempt <= 10; attempt++) {
try {
const resp = await this.sendATCommand('AT+CPIN?', 2000);
if (resp.includes('+CPIN: READY')) {
logger.info('✓ SIM卡已就绪');
return;
}
logger.warn(`SIM卡未就绪(${attempt}/10): ${this.formatATResponse(resp)}`);
} catch (err) {
logger.warn(`查询SIM卡状态失败(${attempt}/10): ${err.message}`);
}
await this.sleep(1000);
}
throw new Error('SIM卡未就绪');
}
/**
* 确保驻网前协议栈功能模式为CFUN=1
*/
async ensureFullFunctionality() {
const resp = await this.sendATCommand('AT+CFUN?', 2000);
const match = resp.match(/\+CFUN:\s*(\d+)/);
if (match && match[1] === '1') {
logger.info('✓ 协议栈功能模式正常(CFUN=1)');
return;
}
logger.warn(`当前CFUN状态不是1: ${this.formatATResponse(resp)}`);
await this.sendATWithRetry('AT+CFUN=1', {
timeout: 5000,
retries: 3,
label: '设置CFUN=1'
});
// 文档要求CFUN切换后等待模组完成协议栈恢复。
await this.sleep(2000);
logger.info('✓ 已设置协议栈功能模式(CFUN=1)');
}
/**
* 按模组型号处理数据连接,避免ML307系列使用文档不推荐的CGACT。
*/
async disableDataConnection() {
if (this.isML307Family()) {
logger.info('ML307系列跳过AT+CGACT,按文档尝试断开应用层拨号(AT+MIPCALL=0,1)');
const resp = await this.sendATWithRetry('AT+MIPCALL=0,1', {
timeout: 5000,
retries: 1,
label: '断开应用层拨号',
required: false
});
if (resp) {
logger.info('✓ 已断开应用层拨号连接');
} else {
logger.warn('应用层拨号未断开或当前未激活,继续启动');
}
return;
}
const resp = await this.sendATWithRetry('AT+CGACT=0,1', {
timeout: 5000,
retries: 3,
label: '禁用数据连接',
required: false
});
if (resp) {
logger.info('✓ 已禁用数据连接(AT+CGACT=0,1),防止流量消耗');
} else {
logger.warn('设置CGACT失败,可能会消耗流量');
}
}
/**
* 按ML307A文档配置短信功能;项目接收逻辑依赖PDU模式下的+CMT上报。
*/
async configureSMS() {
if (this.modelInfo.version.includes('ML307A-DL')) {
throw new Error('当前ML307A-DL型号不支持短信功能');
}
await this.sendATWithRetry('AT+CMGF=0', {
timeout: 2000,
retries: 3,
label: '设置PDU模式'
});
logger.info('✓ PDU模式设置完成');
await this.sendATWithRetry('AT+CNMI=2,2,0,2,0', {
timeout: 2000,
retries: 3,
label: '设置CNMI短信上报'
});
logger.info('✓ CNMI参数设置完成');
await this.sendATWithRetry('AT+CSCS="IRA"', {
timeout: 2000,
retries: 3,
label: '设置短信字符集'
});
logger.info('✓ 短信字符集设置完成');
await this.sendATWithRetry('AT+CSMP=33,167,0,0', {
timeout: 2000,
retries: 3,
label: '设置短信发送参数'
});
logger.info('✓ 短信发送参数设置完成');
}
/**
* 判断是否为ML307系列模组
*/
isML307Family() {
return this.modelInfo.model.startsWith('ML307') || this.modelInfo.version.startsWith('ML307');
}
/** /**
* 检测网络注册状态 * 检测网络注册状态
*/ */
@@ -415,105 +269,56 @@ class ModemManager extends EventEmitter {
logger.info(`短信内容: ${message}`); logger.info(`短信内容: ${message}`);
try { try {
// 注意: node-pdu的API与C++版本不同,这里需要根据实际库调整
const PDU = (await import('node-pdu')).default;
// 编码PDU // 编码PDU
const submit = new Submit(phoneNumber, message); const submit = PDU.Submit(phoneNumber, message);
const parts = submit.getPartStrings(); const parts = submit.getParts();
if (parts.length === 0) { if (parts.length === 0) {
throw new Error('PDU编码失败'); throw new Error('PDU编码失败');
} }
for (let i = 0; i < parts.length; i++) { // 发送第一段(简化处理,实际可能需要处理多段)
const pduData = parts[i]; const pduData = parts[0];
const pduLength = this.getSubmitPduLength(pduData); const pduLength = Math.floor((pduData.length - parts[0].smscLength * 2) / 2);
logger.debug(`PDU分段: ${i + 1}/${parts.length}`);
logger.debug(`PDU数据: ${pduData}`); logger.debug(`PDU数据: ${pduData}`);
logger.debug(`PDU长度: ${pduLength}`); logger.debug(`PDU长度: ${pduLength}`);
const success = await this.sendPduPart(pduData, pduLength); // 发送AT+CMGS命令
if (!success) {
logger.error(`✗ 短信分段发送失败: ${i + 1}/${parts.length}`);
return false;
}
}
logger.info('✓ 短信发送成功');
return true;
} catch (err) {
logger.error('发送短信异常:', err);
return false;
}
}
/**
* 计算AT+CMGS需要的TPDU长度,不包含SMSC长度字段和SMSC内容。
*/
getSubmitPduLength(pduData) {
const smscLength = parseInt(pduData.slice(0, 2), 16);
const totalLength = pduData.length / 2;
const submitLength = totalLength - smscLength - 1;
if (!Number.isFinite(submitLength) || submitLength <= 0) {
throw new Error(`PDU长度无效: ${pduData}`);
}
return submitLength;
}
/**
* 发送单个PDU分段。
*/
async sendPduPart(pduData, pduLength) {
const cmgsCmd = `AT+CMGS=${pduLength}`; const cmgsCmd = `AT+CMGS=${pduLength}`;
this.port.write(cmgsCmd + '\r\n'); this.port.write(cmgsCmd + '\r\n');
const gotPrompt = await this.waitForPrompt(); // 等待 > 提示符
const gotPrompt = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), 5000);
const handler = (line) => {
if (line.includes('>')) {
clearTimeout(timer);
this.parser.removeListener('data', handler);
resolve(true);
}
};
this.parser.on('data', handler);
});
if (!gotPrompt) { if (!gotPrompt) {
throw new Error('未收到>提示符'); throw new Error('未收到>提示符');
} }
logger.debug('收到>提示符,发送PDU数据...'); logger.debug('收到>提示符,发送PDU数据...');
// 发送PDU数据 + Ctrl+Z
this.port.write(pduData + String.fromCharCode(0x1A)); this.port.write(pduData + String.fromCharCode(0x1A));
return await this.waitForCMGSResult(); // 等待响应
} const success = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), 30000);
/**
* 等待CMGS输入提示符。提示符通常没有行结束符,所以直接监听原始串口数据。
*/
waitForPrompt(timeout = 5000) {
return new Promise((resolve) => {
let buffer = ''; let buffer = '';
const timer = setTimeout(() => {
this.port.removeListener('data', handler);
resolve(false);
}, timeout);
const handler = (chunk) => {
buffer += chunk.toString('utf8');
if (buffer.includes('>')) {
clearTimeout(timer);
this.port.removeListener('data', handler);
resolve(true);
}
};
this.port.on('data', handler);
});
}
/**
* 等待短信发送结果。
*/
waitForCMGSResult(timeout = 30000) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.parser.removeListener('data', handler);
resolve(false);
}, timeout);
const handler = (line) => { const handler = (line) => {
buffer += line;
if (line.includes('OK')) { if (line.includes('OK')) {
clearTimeout(timer); clearTimeout(timer);
this.parser.removeListener('data', handler); this.parser.removeListener('data', handler);
@@ -524,9 +329,21 @@ class ModemManager extends EventEmitter {
resolve(false); resolve(false);
} }
}; };
this.parser.on('data', handler); this.parser.on('data', handler);
}); });
if (success) {
logger.info('✓ 短信发送成功');
return true;
} else {
logger.error('✗ 短信发送失败');
return false;
}
} catch (err) {
logger.error('发送短信异常:', err);
return false;
}
} }
/** /**
+13 -189
View File
@@ -1,23 +1,12 @@
import { parse } from 'node-pdu'; import PDU from 'node-pdu';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from './logger.js'; import logger from './logger.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class SMSProcessor { class SMSProcessor {
constructor(config, modem, concatManager, pushManager) { constructor(config, modem, concatManager, pushManager) {
this.config = config; this.config = config;
this.modem = modem; this.modem = modem;
this.concatManager = concatManager; this.concatManager = concatManager;
this.pushManager = pushManager; this.pushManager = pushManager;
this.receivedMessages = [];
this.receivedMessageSeq = 0;
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
this.loadReceivedMessages();
} }
/** /**
@@ -28,45 +17,39 @@ class SMSProcessor {
logger.info('开始解析PDU数据...'); logger.info('开始解析PDU数据...');
// 解析PDU // 解析PDU
const parsed = parse(pduHex); const parsed = PDU.parse(pduHex);
if (!parsed) { if (!parsed) {
logger.error('PDU解析失败'); logger.error('PDU解析失败');
return; return;
} }
const sender = parsed.address?.phone || '未知号码';
const timestamp = parsed.serviceCenterTimeStamp?.getIsoString?.() || new Date().toISOString();
const text = parsed.data?.getText?.() || '';
const part = parsed.data?.parts?.[0];
const header = part?.header;
logger.info('✓ PDU解析成功'); logger.info('✓ PDU解析成功');
logger.info(`发送者: ${sender}`); logger.info(`发送者: ${parsed.sender}`);
logger.info(`时间戳: ${timestamp}`); logger.info(`时间戳: ${parsed.time}`);
logger.info(`内容: ${text}`); logger.info(`内容: ${parsed.text}`);
// 检查是否为长短信 // 检查是否为长短信
if (header && header.getType() !== undefined) { if (parsed.udh && parsed.udh.iei === 0x00) {
// 长短信头部 // 长短信头部
const refNumber = header.getPointer(); const refNumber = parsed.udh.data.refNumber;
const totalParts = header.getSegments(); const totalParts = parsed.udh.data.parts;
const partNumber = header.getCurrent(); const partNumber = parsed.udh.data.current;
logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`); logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`);
// 添加到长短信缓存 // 添加到长短信缓存
this.concatManager.addPart( this.concatManager.addPart(
refNumber, refNumber,
sender, parsed.sender,
partNumber, partNumber,
totalParts, totalParts,
text, parsed.text,
timestamp parsed.time
); );
} else { } else {
// 普通短信,直接处理 // 普通短信,直接处理
await this.processSmsContent(sender, text, timestamp); await this.processSmsContent(parsed.sender, parsed.text, parsed.time);
} }
} catch (err) { } catch (err) {
logger.error('处理PDU失败:', err); logger.error('处理PDU失败:', err);
@@ -83,14 +66,8 @@ class SMSProcessor {
logger.info(`内容: ${text}`); logger.info(`内容: ${text}`);
logger.info('===================='); logger.info('====================');
const messageRecord = this.addReceivedMessage(sender, text, timestamp);
// 检查是否在黑名单中 // 检查是否在黑名单中
if (this.isInBlacklist(sender)) { if (this.isInBlacklist(sender)) {
this.updateReceivedMessage(messageRecord.id, {
status: 'ignored',
statusText: '黑名单忽略'
});
logger.info('发送者在黑名单中,忽略该短信'); logger.info('发送者在黑名单中,忽略该短信');
return; return;
} }
@@ -101,10 +78,6 @@ class SMSProcessor {
const trimmedText = text.trim(); const trimmedText = text.trim();
if (trimmedText.startsWith('SMS:') || trimmedText === 'RESET') { if (trimmedText.startsWith('SMS:') || trimmedText === 'RESET') {
this.updateReceivedMessage(messageRecord.id, {
status: 'admin',
statusText: '管理员命令'
});
await this.processAdminCommand(sender, trimmedText); await this.processAdminCommand(sender, trimmedText);
return; // 命令已处理,不再发送普通通知 return; // 命令已处理,不再发送普通通知
} }
@@ -117,155 +90,6 @@ class SMSProcessor {
const subject = `短信${sender},${text.substring(0, 20)}`; const subject = `短信${sender},${text.substring(0, 20)}`;
const body = `来自:${sender},时间:${timestamp},内容:${text}`; const body = `来自:${sender},时间:${timestamp},内容:${text}`;
await this.pushManager.sendEmail(subject, body); await this.pushManager.sendEmail(subject, body);
this.updateReceivedMessage(messageRecord.id, {
status: 'forwarded',
statusText: '已转发'
});
}
/**
* 记录收到的短信,供Web管理端展示。
*/
addReceivedMessage(sender, text, timestamp) {
const message = {
id: `${Date.now()}-${++this.receivedMessageSeq}`,
sender,
text,
timestamp,
receivedAt: new Date().toISOString(),
status: 'received',
statusText: '已接收'
};
this.receivedMessages.unshift(message);
if (this.receivedMessages.length > this.maxReceivedMessages) {
this.receivedMessages.length = this.maxReceivedMessages;
}
this.persistReceivedMessages();
logger.info(`短信已进入Web收件箱: ${sender}`);
return message;
}
/**
* 更新收件箱短信状态。
*/
updateReceivedMessage(id, patch) {
const message = this.receivedMessages.find(item => item.id === id);
if (message) {
Object.assign(message, patch);
this.persistReceivedMessages();
}
}
/**
* 获取最近收到的短信。
*/
getReceivedMessages(limit = 50) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
return this.receivedMessages.slice(0, safeLimit);
}
/**
* 解析收件箱保留条数。
*/
resolveMaxReceivedMessages() {
const configured = Number(this.config.inbox?.maxReceivedMessages);
if (!Number.isFinite(configured) || configured <= 0) {
return 200;
}
return Math.min(Math.floor(configured), 5000);
}
/**
* 解析收件箱持久化文件路径。
*/
resolveReceivedMessagesFile() {
const configuredPath = this.config.inbox?.receivedMessagesFile || 'data/received-messages.json';
if (path.isAbsolute(configuredPath)) {
return configuredPath;
}
return path.join(__dirname, '..', configuredPath);
}
/**
* 启动时加载持久化收件箱。
*/
loadReceivedMessages() {
try {
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
if (!fs.existsSync(this.receivedMessagesFile)) {
logger.info(`短信收件箱持久化文件不存在,将在收到短信后创建: ${this.receivedMessagesFile}`);
return;
}
const content = fs.readFileSync(this.receivedMessagesFile, 'utf8').trim();
if (!content) {
return;
}
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
logger.warn('短信收件箱持久化文件格式不是数组,已忽略');
return;
}
this.receivedMessages = parsed
.map(item => this.normalizeStoredMessage(item))
.filter(Boolean)
.slice(0, this.maxReceivedMessages);
logger.info(`已加载持久化短信收件箱: ${this.receivedMessages.length} 条`);
} catch (err) {
logger.error('加载短信收件箱持久化文件失败:', err);
this.receivedMessages = [];
}
}
/**
* 保存当前收件箱到磁盘。
*/
persistReceivedMessages() {
try {
fs.mkdirSync(path.dirname(this.receivedMessagesFile), { recursive: true });
const tmpFile = `${this.receivedMessagesFile}.${process.pid}.tmp`;
fs.writeFileSync(
tmpFile,
`${JSON.stringify(this.receivedMessages, null, 2)}\n`,
'utf8'
);
fs.renameSync(tmpFile, this.receivedMessagesFile);
} catch (err) {
logger.error('保存短信收件箱持久化文件失败:', err);
}
}
/**
* 规范化历史短信记录,避免坏数据影响Web展示。
*/
normalizeStoredMessage(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null;
}
const id = String(item.id || '').trim();
if (!id) {
return null;
}
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()),
status: String(item.status || 'received'),
statusText: String(item.statusText || '已接收')
};
} }
/** /**