feat: 初始化短信网关核心
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
logs/
|
||||
*.log
|
||||
config.json
|
||||
.DS_Store
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.npm
|
||||
.npm-cache/
|
||||
*.tgz
|
||||
.eslintcache
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"serial": {
|
||||
"path": "COM3",
|
||||
"baudRate": 115200,
|
||||
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0"
|
||||
},
|
||||
"smtp": {
|
||||
"server": "smtp.qq.com",
|
||||
"port": 465,
|
||||
"user": "your@qq.com",
|
||||
"pass": "your_auth_code",
|
||||
"sendTo": "recipient@example.com"
|
||||
},
|
||||
"pushChannels": [
|
||||
{
|
||||
"enabled": true,
|
||||
"type": "dingtalk",
|
||||
"name": "钉钉通知",
|
||||
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
||||
"secret": "SECxxx",
|
||||
"key1": "",
|
||||
"key2": "",
|
||||
"customBody": ""
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"type": "feishu",
|
||||
"name": "飞书通知",
|
||||
"url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx",
|
||||
"secret": "",
|
||||
"key1": "",
|
||||
"key2": "",
|
||||
"customBody": ""
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"type": "telegram",
|
||||
"name": "Telegram通知",
|
||||
"url": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"secret": "",
|
||||
"key1": "987654321",
|
||||
"key2": "",
|
||||
"customBody": ""
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"type": "pushplus",
|
||||
"name": "PushPlus通知",
|
||||
"url": "",
|
||||
"secret": "",
|
||||
"key1": "your_pushplus_token",
|
||||
"key2": "",
|
||||
"customBody": ""
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"type": "custom",
|
||||
"name": "自定义推送",
|
||||
"url": "https://your-webhook.com/api/notify",
|
||||
"secret": "",
|
||||
"key1": "",
|
||||
"key2": "",
|
||||
"customBody": "{\"from\":\"{sender}\",\"msg\":\"{message}\",\"ts\":\"{timestamp}\"}"
|
||||
}
|
||||
],
|
||||
"api": {
|
||||
"port": 3000,
|
||||
"webToken": "change-this-token",
|
||||
"auth": {
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'sms-gateway',
|
||||
script: './src/index.js',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '200M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
LOG_LEVEL: 'info'
|
||||
},
|
||||
error_file: './logs/pm2-error.log',
|
||||
out_file: './logs/pm2-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
merge_logs: true,
|
||||
min_uptime: '10s',
|
||||
max_restarts: 10,
|
||||
restart_delay: 5000
|
||||
}]
|
||||
};
|
||||
Generated
+2048
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "nodejs-sms-gateway",
|
||||
"version": "1.0.0",
|
||||
"description": "4G SMS Gateway with AT command communication",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"sms",
|
||||
"4g",
|
||||
"at-commands",
|
||||
"gateway",
|
||||
"modem"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"serialport": "^12.0.0",
|
||||
"@serialport/parser-readline": "^12.0.0",
|
||||
"node-pdu": "^2.1.2",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"axios": "^1.6.2",
|
||||
"winston": "^3.11.0",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import logger from './logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class APIServer {
|
||||
constructor(config, modem, smsProcessor) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.smsProcessor = smsProcessor;
|
||||
this.app = express();
|
||||
this.publicDir = path.join(__dirname, '../public');
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
setupMiddleware() {
|
||||
// JSON解析
|
||||
this.app.use(express.json());
|
||||
|
||||
// Web Token认证,API保留Basic Auth兼容
|
||||
this.app.use((req, res, next) => {
|
||||
this.authenticateRequest(req, res, next);
|
||||
});
|
||||
|
||||
// 请求日志
|
||||
this.app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`);
|
||||
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>`);
|
||||
}
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const signal = await this.modem.getSignalQuality();
|
||||
const operator = await this.modem.getOperator();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
ready: this.modem.ready,
|
||||
model: this.modem.modelInfo,
|
||||
signal,
|
||||
operator,
|
||||
uptime: process.uptime()
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询状态失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 发送短信
|
||||
this.app.post('/api/sms/send', async (req, res) => {
|
||||
try {
|
||||
const { phone, message } = req.body;
|
||||
|
||||
if (!phone || !message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必要参数: phone, message'
|
||||
});
|
||||
}
|
||||
|
||||
const success = await this.modem.sendSMS(phone, message);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? '短信发送成功' : '短信发送失败'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('发送短信失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 查询收到的短信
|
||||
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) => {
|
||||
try {
|
||||
const signal = await this.modem.getSignalQuality();
|
||||
res.json({
|
||||
success: true,
|
||||
data: signal
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询信号失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 查询模组信息
|
||||
this.app.get('/api/modem/info', async (req, res) => {
|
||||
try {
|
||||
const iccid = await this.modem.getICCID();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
model: this.modem.modelInfo,
|
||||
iccid,
|
||||
ready: this.modem.ready
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('查询模组信息失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 发送AT命令
|
||||
this.app.post('/api/modem/at', async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必要参数: command'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await this.modem.sendATCommand(command, 5000);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
command,
|
||||
response
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('发送AT命令失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取日志
|
||||
this.app.get('/api/logs', (req, res) => {
|
||||
try {
|
||||
const logs = logger.getBuffer().getAll();
|
||||
res.json({
|
||||
success: true,
|
||||
data: logs
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('获取日志失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
// 404处理
|
||||
this.app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Not Found'
|
||||
});
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
this.app.use((err, req, res, next) => {
|
||||
logger.error('API错误:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
const port = this.config.api.port;
|
||||
this.app.listen(port, () => {
|
||||
logger.info(`API服务器已启动,监听端口: ${port}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default APIServer;
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import EventEmitter from 'events';
|
||||
import logger from './logger.js';
|
||||
|
||||
class ConcatManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = [];
|
||||
this.maxSlots = 5;
|
||||
this.maxParts = 10;
|
||||
this.timeoutMs = 30000; // 30秒超时
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找或创建长短信槽位
|
||||
*/
|
||||
findOrCreateSlot(refNumber, sender, totalParts) {
|
||||
// 先查找是否已存在
|
||||
let slot = this.buffer.find(s =>
|
||||
s.inUse && s.refNumber === refNumber && s.sender === sender
|
||||
);
|
||||
|
||||
if (slot) {
|
||||
return slot;
|
||||
}
|
||||
|
||||
// 查找空闲槽位
|
||||
slot = this.buffer.find(s => !s.inUse);
|
||||
if (slot) {
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
return slot;
|
||||
}
|
||||
|
||||
// 没有空闲槽位,查找最老的槽位覆盖
|
||||
if (this.buffer.length < this.maxSlots) {
|
||||
slot = {
|
||||
inUse: false,
|
||||
parts: []
|
||||
};
|
||||
this.buffer.push(slot);
|
||||
} else {
|
||||
slot = this.buffer.reduce((oldest, current) =>
|
||||
current.firstPartTime < oldest.firstPartTime ? current : oldest
|
||||
);
|
||||
logger.warn('长短信缓存已满,覆盖最老的槽位');
|
||||
}
|
||||
|
||||
this.initSlot(slot, refNumber, sender, totalParts);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化槽位
|
||||
*/
|
||||
initSlot(slot, refNumber, sender, totalParts) {
|
||||
slot.inUse = true;
|
||||
slot.refNumber = refNumber;
|
||||
slot.sender = sender;
|
||||
slot.totalParts = totalParts;
|
||||
slot.receivedParts = 0;
|
||||
slot.firstPartTime = Date.now();
|
||||
slot.timestamp = null;
|
||||
slot.parts = new Array(totalParts).fill(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加短信分段
|
||||
*/
|
||||
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) {
|
||||
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
|
||||
|
||||
const slot = this.findOrCreateSlot(refNumber, sender, totalParts);
|
||||
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
|
||||
|
||||
if (partIndex >= 0 && partIndex < this.maxParts) {
|
||||
if (!slot.parts[partIndex]) {
|
||||
slot.parts[partIndex] = text;
|
||||
slot.receivedParts++;
|
||||
|
||||
// 保存第一个收到的分段的时间戳
|
||||
if (slot.receivedParts === 1) {
|
||||
slot.timestamp = timestamp;
|
||||
}
|
||||
|
||||
logger.info(`已缓存分段 ${partNumber},当前已收到 ${slot.receivedParts}/${totalParts}`);
|
||||
|
||||
// 检查是否已收齐
|
||||
if (slot.receivedParts >= totalParts) {
|
||||
logger.info('✅ 长短信已收齐,开始合并转发');
|
||||
this.assembleAndEmit(slot);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`⚠️ 分段 ${partNumber} 已存在,跳过`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并并发射完整短信
|
||||
*/
|
||||
assembleAndEmit(slot) {
|
||||
let fullText = '';
|
||||
for (let i = 0; i < slot.totalParts; i++) {
|
||||
if (slot.parts[i]) {
|
||||
fullText += slot.parts[i];
|
||||
} else {
|
||||
fullText += `[缺失分段${i + 1}]`;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('complete', {
|
||||
sender: slot.sender,
|
||||
text: fullText,
|
||||
timestamp: slot.timestamp
|
||||
});
|
||||
|
||||
// 清空槽位
|
||||
this.clearSlot(slot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空槽位
|
||||
*/
|
||||
clearSlot(slot) {
|
||||
slot.inUse = false;
|
||||
slot.parts = [];
|
||||
slot.receivedParts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查超时
|
||||
*/
|
||||
checkTimeout() {
|
||||
const now = Date.now();
|
||||
this.buffer.forEach(slot => {
|
||||
if (slot.inUse && (now - slot.firstPartTime) >= this.timeoutMs) {
|
||||
logger.warn(`⏰ 长短信超时,强制转发不完整消息`);
|
||||
logger.warn(` 参考号: ${slot.refNumber}, 已收到: ${slot.receivedParts}/${slot.totalParts}`);
|
||||
this.assembleAndEmit(slot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动超时检查定时器
|
||||
*/
|
||||
startTimeoutChecker() {
|
||||
this.timeoutChecker = setInterval(() => {
|
||||
this.checkTimeout();
|
||||
}, 5000); // 每5秒检查一次
|
||||
logger.info('长短信超时检查器已启动');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止超时检查定时器
|
||||
*/
|
||||
stopTimeoutChecker() {
|
||||
if (this.timeoutChecker) {
|
||||
clearInterval(this.timeoutChecker);
|
||||
this.timeoutChecker = null;
|
||||
logger.info('长短信超时检查器已停止');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ConcatManager;
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import logger from './logger.js';
|
||||
import ModemManager from './modem.js';
|
||||
import ConcatManager from './concat.js';
|
||||
import PushManager from './push.js';
|
||||
import SMSProcessor from './sms.js';
|
||||
import APIServer from './api.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 加载配置
|
||||
function loadConfig() {
|
||||
const configPath = path.join(__dirname, '../config.json');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.error('配置文件不存在: config.json');
|
||||
logger.error('请复制 config.example.json 为 config.json 并修改配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(configContent);
|
||||
}
|
||||
|
||||
// 创建日志目录
|
||||
function ensureLogDirectory() {
|
||||
const logDir = path.join(__dirname, '../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
// 确保日志目录存在
|
||||
ensureLogDirectory();
|
||||
|
||||
logger.info('========================================');
|
||||
logger.info(' 4G SMS Gateway 启动中... ');
|
||||
logger.info('========================================');
|
||||
|
||||
// 加载配置
|
||||
const config = loadConfig();
|
||||
logger.info('配置加载完成');
|
||||
|
||||
// 创建模组管理器
|
||||
const modem = new ModemManager(config.serial);
|
||||
|
||||
// 创建长短信管理器
|
||||
const concatManager = new ConcatManager();
|
||||
|
||||
// 创建推送管理器
|
||||
const pushManager = new PushManager(config);
|
||||
|
||||
// 创建短信处理器
|
||||
const smsProcessor = new SMSProcessor(config, modem, concatManager, pushManager);
|
||||
|
||||
// 监听长短信合并完成事件
|
||||
concatManager.on('complete', async (sms) => {
|
||||
logger.info('收到长短信合并完成事件');
|
||||
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp);
|
||||
});
|
||||
|
||||
// 监听模组短信事件
|
||||
modem.on('sms', async (pduHex) => {
|
||||
await smsProcessor.processPDU(pduHex);
|
||||
});
|
||||
|
||||
// 监听模组错误事件
|
||||
modem.on('error', (err) => {
|
||||
logger.error('模组错误:', err);
|
||||
});
|
||||
|
||||
// 监听模组关闭事件
|
||||
modem.on('close', () => {
|
||||
logger.warn('模组连接已关闭');
|
||||
process.exit(1); // PM2会自动重启
|
||||
});
|
||||
|
||||
// 监听模组就绪事件
|
||||
modem.on('ready', async () => {
|
||||
logger.info('✓ 模组已就绪');
|
||||
|
||||
// 发送启动通知邮件
|
||||
if (config.smtp && config.smtp.server) {
|
||||
const subject = '短信网关已启动';
|
||||
const body = `4G SMS Gateway 已成功启动\n\n模组信息:\n- 厂商: ${modem.modelInfo.manufacturer}\n- 型号: ${modem.modelInfo.model}\n- 版本: ${modem.modelInfo.version}\n\nAPI地址: http://your-server:${config.api.port}`;
|
||||
await pushManager.sendEmail(subject, body);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 打开串口并初始化模组
|
||||
await modem.open();
|
||||
|
||||
// 启动长短信超时检查器
|
||||
concatManager.startTimeoutChecker();
|
||||
|
||||
// 启动API服务器
|
||||
const apiServer = new APIServer(config, modem, smsProcessor);
|
||||
apiServer.start();
|
||||
|
||||
logger.info('========================================');
|
||||
logger.info(' 4G SMS Gateway 运行中 ');
|
||||
logger.info('========================================');
|
||||
|
||||
} catch (err) {
|
||||
logger.error('启动失败:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n收到 SIGINT 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('\n收到 SIGTERM 信号,正在关闭...');
|
||||
concatManager.stopTimeoutChecker();
|
||||
await modem.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 捕获未处理的异常
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error('未捕获的异常:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('未处理的Promise拒绝:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
main().catch(err => {
|
||||
logger.error('应用启动失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||
if (stack) {
|
||||
return `${timestamp} [${level.toUpperCase()}] ${message}\n${stack}`;
|
||||
}
|
||||
return `${timestamp} [${level.toUpperCase()}] ${message}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ level, message, timestamp }) => {
|
||||
return `${timestamp} ${level}: ${message}`;
|
||||
})
|
||||
)
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: 'logs/combined.log',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// 创建循环日志缓冲区(用于API查询)
|
||||
class LogBuffer {
|
||||
constructor(maxLines = 120) {
|
||||
this.buffer = [];
|
||||
this.maxLines = maxLines;
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
add(line) {
|
||||
if (this.buffer.length < this.maxLines) {
|
||||
this.buffer.push(line);
|
||||
} else {
|
||||
this.buffer[this.index] = line;
|
||||
this.index = (this.index + 1) % this.maxLines;
|
||||
}
|
||||
}
|
||||
|
||||
getAll() {
|
||||
if (this.buffer.length < this.maxLines) {
|
||||
return this.buffer.slice();
|
||||
}
|
||||
// 从index位置开始重新排序,保持时间顺序
|
||||
return [
|
||||
...this.buffer.slice(this.index),
|
||||
...this.buffer.slice(0, this.index)
|
||||
];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.buffer = [];
|
||||
this.index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局日志缓冲区
|
||||
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);
|
||||
logger.log = function(level, message, ...args) {
|
||||
addToBuffer(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;
|
||||
|
||||
export default logger;
|
||||
+617
@@ -0,0 +1,617 @@
|
||||
import { SerialPort } from 'serialport';
|
||||
import { ReadlineParser } from '@serialport/parser-readline';
|
||||
import { Submit } from 'node-pdu';
|
||||
import EventEmitter from 'events';
|
||||
import logger from './logger.js';
|
||||
|
||||
class ModemManager extends EventEmitter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.port = null;
|
||||
this.parser = null;
|
||||
this.ready = false;
|
||||
this.modelInfo = {
|
||||
manufacturer: '未知',
|
||||
model: '未知',
|
||||
version: '未知'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开串口并初始化模组
|
||||
*/
|
||||
async open() {
|
||||
try {
|
||||
// 处理串口路径:Windows支持COMx格式
|
||||
let portPath = this.config.path;
|
||||
|
||||
// 如果是Windows且指定了COM端口号(数字),自动添加COM前缀
|
||||
if (process.platform === 'win32' && /^\d+$/.test(portPath)) {
|
||||
portPath = `COM${portPath}`;
|
||||
}
|
||||
|
||||
logger.info(`准备打开串口: ${portPath} (${this.config.baudRate})`);
|
||||
|
||||
// 打开串口
|
||||
this.port = new SerialPort({
|
||||
path: portPath,
|
||||
baudRate: this.config.baudRate,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none'
|
||||
});
|
||||
|
||||
// 设置行解析器
|
||||
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r\n' }));
|
||||
|
||||
// 监听串口事件
|
||||
this.port.on('error', (err) => {
|
||||
logger.error('串口错误:', err);
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
this.port.on('close', () => {
|
||||
logger.warn('串口已关闭');
|
||||
this.ready = false;
|
||||
this.emit('close');
|
||||
});
|
||||
|
||||
// 监听URC(主动上报)消息
|
||||
this.setupURCListener();
|
||||
|
||||
// 等待串口打开
|
||||
await new Promise((resolve) => this.port.once('open', resolve));
|
||||
logger.info(`串口已打开: ${this.config.path}`);
|
||||
|
||||
// 初始化模组
|
||||
await this.init();
|
||||
|
||||
} catch (err) {
|
||||
logger.error('打开串口失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置URC监听器
|
||||
*/
|
||||
setupURCListener() {
|
||||
let waitingPDU = false;
|
||||
|
||||
this.parser.on('data', (line) => {
|
||||
line = line.trim();
|
||||
|
||||
// 调试输出
|
||||
if (line.length > 0 && !line.startsWith('AT')) {
|
||||
logger.debug(`<< ${line}`);
|
||||
}
|
||||
|
||||
// 检测短信URC
|
||||
if (line.startsWith('+CMT:')) {
|
||||
logger.info('检测到短信URC,等待PDU数据...');
|
||||
waitingPDU = true;
|
||||
} else if (waitingPDU && this.isHexString(line)) {
|
||||
logger.info('收到PDU数据');
|
||||
waitingPDU = false;
|
||||
this.emit('sms', line); // 发射短信事件
|
||||
} else if (waitingPDU && line.length === 0) {
|
||||
// 跳过空行
|
||||
} else if (waitingPDU) {
|
||||
// 收到非PDU数据,返回等待状态
|
||||
waitingPDU = false;
|
||||
}
|
||||
|
||||
// 检测网络注册状态变化
|
||||
if (line.startsWith('+CEREG:')) {
|
||||
this.emit('cereg', line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为十六进制字符串
|
||||
*/
|
||||
isHexString(str) {
|
||||
return /^[0-9A-Fa-f]+$/.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送AT命令并等待响应
|
||||
*/
|
||||
async sendATCommand(cmd, timeout = 2000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = '';
|
||||
const timer = setTimeout(() => {
|
||||
this.parser.removeListener('data', handler);
|
||||
reject(new Error(`AT命令超时: ${cmd}`));
|
||||
}, timeout);
|
||||
|
||||
const handler = (line) => {
|
||||
buffer += line + '\n';
|
||||
if (line.includes('OK') || line.includes('ERROR')) {
|
||||
clearTimeout(timer);
|
||||
this.parser.removeListener('data', handler);
|
||||
resolve(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
this.parser.on('data', handler);
|
||||
|
||||
logger.debug(`>> ${cmd}`);
|
||||
this.port.write(cmd + '\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送AT命令并等待OK
|
||||
*/
|
||||
async sendATandWaitOK(cmd, timeout = 2000) {
|
||||
try {
|
||||
const resp = await this.sendATCommand(cmd, timeout);
|
||||
return resp.includes('OK');
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送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(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模组
|
||||
*/
|
||||
async init() {
|
||||
logger.info('开始初始化4G模组...');
|
||||
|
||||
// 1. AT握手
|
||||
let retries = 0;
|
||||
while (!(await this.sendATandWaitOK('AT', 1000)) && retries < 10) {
|
||||
logger.warn('AT未响应,重试...');
|
||||
retries++;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
if (retries >= 10) {
|
||||
throw new Error('模组AT握手失败');
|
||||
}
|
||||
logger.info('✓ 模组AT响应正常');
|
||||
|
||||
// 2. 查询模组信息
|
||||
try {
|
||||
const resp = await this.sendATCommand('ATI', 2000);
|
||||
const lines = resp.split('\n').map(l => l.trim()).filter(l => l && l !== 'ATI' && l !== 'OK');
|
||||
if (lines.length >= 3) {
|
||||
this.modelInfo.manufacturer = lines[0];
|
||||
this.modelInfo.model = lines[1];
|
||||
this.modelInfo.version = lines[2];
|
||||
logger.info(`模组信息: ${this.modelInfo.manufacturer} ${this.modelInfo.model} ${this.modelInfo.version}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('查询模组信息失败');
|
||||
}
|
||||
|
||||
// 3. 按ML307A文档先确认SIM卡和协议栈状态
|
||||
await this.waitSIMReady();
|
||||
await this.ensureFullFunctionality();
|
||||
|
||||
// 4. 等待网络注册
|
||||
retries = 0;
|
||||
while (!(await this.waitCEREG()) && retries < 30) {
|
||||
logger.info('等待网络注册...');
|
||||
retries++;
|
||||
await this.sleep(2000);
|
||||
}
|
||||
if (retries < 30) {
|
||||
logger.info('✓ 网络已注册');
|
||||
this.ready = true;
|
||||
} else {
|
||||
logger.error('网络注册超时(无SIM卡或信号差)');
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
// 5. 数据连接处理。ML307文档不建议用CGACT做PDP激活/去激活。
|
||||
await this.disableDataConnection();
|
||||
|
||||
// 6. 按文档配置短信功能,并启用本项目需要的PDU模式
|
||||
await this.configureSMS();
|
||||
|
||||
logger.info('模组初始化完成');
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测网络注册状态
|
||||
*/
|
||||
async waitCEREG() {
|
||||
try {
|
||||
const resp = await this.sendATCommand('AT+CEREG?', 2000);
|
||||
// +CEREG: 0,1 或 +CEREG: 0,5 表示已注册
|
||||
if (resp.includes('+CEREG:')) {
|
||||
if (resp.includes(',1') || resp.includes(',5')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信(PDU模式)
|
||||
*/
|
||||
async sendSMS(phoneNumber, message) {
|
||||
logger.info(`准备发送短信到: ${phoneNumber}`);
|
||||
logger.info(`短信内容: ${message}`);
|
||||
|
||||
try {
|
||||
// 编码PDU
|
||||
const submit = new Submit(phoneNumber, message);
|
||||
const parts = submit.getPartStrings();
|
||||
|
||||
if (parts.length === 0) {
|
||||
throw new Error('PDU编码失败');
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const pduData = parts[i];
|
||||
const pduLength = this.getSubmitPduLength(pduData);
|
||||
|
||||
logger.debug(`PDU分段: ${i + 1}/${parts.length}`);
|
||||
logger.debug(`PDU数据: ${pduData}`);
|
||||
logger.debug(`PDU长度: ${pduLength}`);
|
||||
|
||||
const success = await this.sendPduPart(pduData, pduLength);
|
||||
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}`;
|
||||
this.port.write(cmgsCmd + '\r\n');
|
||||
|
||||
const gotPrompt = await this.waitForPrompt();
|
||||
if (!gotPrompt) {
|
||||
throw new Error('未收到>提示符');
|
||||
}
|
||||
|
||||
logger.debug('收到>提示符,发送PDU数据...');
|
||||
this.port.write(pduData + String.fromCharCode(0x1A));
|
||||
|
||||
return await this.waitForCMGSResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待CMGS输入提示符。提示符通常没有行结束符,所以直接监听原始串口数据。
|
||||
*/
|
||||
waitForPrompt(timeout = 5000) {
|
||||
return new Promise((resolve) => {
|
||||
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) => {
|
||||
if (line.includes('OK')) {
|
||||
clearTimeout(timer);
|
||||
this.parser.removeListener('data', handler);
|
||||
resolve(true);
|
||||
} else if (line.includes('ERROR')) {
|
||||
clearTimeout(timer);
|
||||
this.parser.removeListener('data', handler);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.parser.on('data', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询信号强度
|
||||
*/
|
||||
async getSignalQuality() {
|
||||
try {
|
||||
const resp = await this.sendATCommand('AT+CSQ', 2000);
|
||||
const match = resp.match(/\+CSQ:\s*(\d+),(\d+)/);
|
||||
if (match) {
|
||||
const rssi = parseInt(match[1]);
|
||||
const ber = parseInt(match[2]);
|
||||
let quality = '未知';
|
||||
if (rssi === 99) {
|
||||
quality = '未知或不可检测';
|
||||
} else if (rssi >= 20) {
|
||||
quality = '很好';
|
||||
} else if (rssi >= 15) {
|
||||
quality = '好';
|
||||
} else if (rssi >= 10) {
|
||||
quality = '一般';
|
||||
} else {
|
||||
quality = '弱';
|
||||
}
|
||||
return { rssi, ber, quality };
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.error('查询信号强度失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询运营商
|
||||
*/
|
||||
async getOperator() {
|
||||
try {
|
||||
const resp = await this.sendATCommand('AT+COPS?', 2000);
|
||||
const match = resp.match(/\+COPS:\s*\d+,\d+,"([^"]+)"/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return '未知';
|
||||
} catch (err) {
|
||||
logger.error('查询运营商失败:', err);
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询ICCID
|
||||
*/
|
||||
async getICCID() {
|
||||
try {
|
||||
const resp = await this.sendATCommand('AT+CCID', 2000);
|
||||
const match = resp.match(/\+CCID:\s*(\d+)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.error('查询ICCID失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭串口
|
||||
*/
|
||||
async close() {
|
||||
if (this.port && this.port.isOpen) {
|
||||
await new Promise((resolve) => {
|
||||
this.port.close(resolve);
|
||||
});
|
||||
logger.info('串口已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:延时
|
||||
*/
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export default ModemManager;
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import logger from './logger.js';
|
||||
|
||||
class PushManager {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.smtpTransporter = null;
|
||||
|
||||
// 初始化SMTP
|
||||
if (config.smtp && config.smtp.server) {
|
||||
this.smtpTransporter = nodemailer.createTransport({
|
||||
host: config.smtp.server,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.port === 465,
|
||||
auth: {
|
||||
user: config.smtp.user,
|
||||
pass: config.smtp.pass
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件通知
|
||||
*/
|
||||
async sendEmail(subject, body) {
|
||||
if (!this.smtpTransporter) {
|
||||
logger.warn('邮件配置不完整,跳过发送');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.smtpTransporter.sendMail({
|
||||
from: `"SMS Notify" <${this.config.smtp.user}>`,
|
||||
to: this.config.smtp.sendTo,
|
||||
subject: subject,
|
||||
text: body
|
||||
});
|
||||
logger.info('✓ 邮件发送成功');
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('邮件发送失败:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送到所有启用的推送通道
|
||||
*/
|
||||
async pushToAll(sender, message, timestamp) {
|
||||
const channels = this.config.pushChannels || [];
|
||||
const promises = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
if (channel.enabled) {
|
||||
promises.push(this.pushToChannel(channel, sender, message, timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送到单个推送通道
|
||||
*/
|
||||
async pushToChannel(channel, sender, message, timestamp) {
|
||||
logger.info(`推送到通道: ${channel.name} (${channel.type})`);
|
||||
|
||||
try {
|
||||
switch (channel.type) {
|
||||
case 'post_json':
|
||||
return await this.pushPostJSON(channel, sender, message, timestamp);
|
||||
case 'bark':
|
||||
return await this.pushBark(channel, sender, message);
|
||||
case 'get':
|
||||
return await this.pushGET(channel, sender, message, timestamp);
|
||||
case 'dingtalk':
|
||||
return await this.pushDingTalk(channel, sender, message);
|
||||
case 'pushplus':
|
||||
return await this.pushPushPlus(channel, sender, message);
|
||||
case 'serverchan':
|
||||
return await this.pushServerChan(channel, sender, message);
|
||||
case 'custom':
|
||||
return await this.pushCustom(channel, sender, message, timestamp);
|
||||
case 'feishu':
|
||||
return await this.pushFeishu(channel, sender, message);
|
||||
case 'telegram':
|
||||
return await this.pushTelegram(channel, sender, message);
|
||||
default:
|
||||
logger.warn(`未知的推送类型: ${channel.type}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`推送到 ${channel.name} 失败:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON 推送
|
||||
*/
|
||||
async pushPostJSON(channel, sender, message, timestamp) {
|
||||
const response = await axios.post(channel.url, {
|
||||
sender,
|
||||
message,
|
||||
timestamp
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ POST JSON推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bark 推送
|
||||
*/
|
||||
async pushBark(channel, sender, message) {
|
||||
const response = await axios.post(channel.url, {
|
||||
title: sender,
|
||||
body: message
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ Bark推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 推送
|
||||
*/
|
||||
async pushGET(channel, sender, message, timestamp) {
|
||||
const url = new URL(channel.url);
|
||||
url.searchParams.set('sender', sender);
|
||||
url.searchParams.set('message', message);
|
||||
url.searchParams.set('timestamp', timestamp);
|
||||
|
||||
const response = await axios.get(url.toString(), {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ GET推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉钉机器人推送
|
||||
*/
|
||||
async pushDingTalk(channel, sender, message) {
|
||||
let url = channel.url;
|
||||
|
||||
// 如果配置了secret,进行签名
|
||||
if (channel.secret) {
|
||||
const timestamp = Date.now();
|
||||
const sign = this.dingtalkSign(channel.secret, timestamp);
|
||||
url += `×tamp=${timestamp}&sign=${sign}`;
|
||||
}
|
||||
|
||||
const response = await axios.post(url, {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: `来自: ${sender}\n\n${message}`
|
||||
}
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ 钉钉推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉钉签名
|
||||
*/
|
||||
dingtalkSign(secret, timestamp) {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', secret);
|
||||
hmac.update(stringToSign);
|
||||
const sign = hmac.digest('base64');
|
||||
return encodeURIComponent(sign);
|
||||
}
|
||||
|
||||
/**
|
||||
* PushPlus 推送
|
||||
*/
|
||||
async pushPushPlus(channel, sender, message) {
|
||||
const response = await axios.post('http://www.pushplus.plus/send', {
|
||||
token: channel.key1,
|
||||
title: `来自 ${sender}`,
|
||||
content: message,
|
||||
template: 'html'
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ PushPlus推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server酱 推送
|
||||
*/
|
||||
async pushServerChan(channel, sender, message) {
|
||||
const url = `https://sctapi.ftqq.com/${channel.key1}.send`;
|
||||
const response = await axios.post(url, {
|
||||
title: `来自 ${sender}`,
|
||||
desp: message
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ Server酱推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义模板推送
|
||||
*/
|
||||
async pushCustom(channel, sender, message, timestamp) {
|
||||
let body = channel.customBody || '{}';
|
||||
body = body.replace('{sender}', sender);
|
||||
body = body.replace('{message}', message);
|
||||
body = body.replace('{timestamp}', timestamp);
|
||||
|
||||
const response = await axios.post(channel.url, JSON.parse(body), {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ 自定义推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 飞书机器人推送
|
||||
*/
|
||||
async pushFeishu(channel, sender, message) {
|
||||
let url = channel.url;
|
||||
|
||||
// 如果配置了secret,进行签名
|
||||
if (channel.secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sign = this.feishuSign(channel.secret, timestamp);
|
||||
url += `×tamp=${timestamp}&sign=${sign}`;
|
||||
}
|
||||
|
||||
const response = await axios.post(url, {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: `来自: ${sender}\n\n${message}`
|
||||
}
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ 飞书推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 飞书签名
|
||||
*/
|
||||
feishuSign(secret, timestamp) {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', secret);
|
||||
hmac.update(stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram Bot 推送
|
||||
*/
|
||||
async pushTelegram(channel, sender, message) {
|
||||
const botToken = channel.url; // url字段存储bot token
|
||||
const chatId = channel.key1;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
const response = await axios.post(url, {
|
||||
chat_id: chatId,
|
||||
text: `来自: ${sender}\n\n${message}`
|
||||
}, {
|
||||
timeout: 10000
|
||||
});
|
||||
logger.info(`✓ Telegram推送成功: ${response.status}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default PushManager;
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { parse } from 'node-pdu';
|
||||
import logger from './logger.js';
|
||||
|
||||
class SMSProcessor {
|
||||
constructor(config, modem, concatManager, pushManager) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.concatManager = concatManager;
|
||||
this.pushManager = pushManager;
|
||||
this.receivedMessages = [];
|
||||
this.receivedMessageSeq = 0;
|
||||
this.maxReceivedMessages = 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的PDU短信
|
||||
*/
|
||||
async processPDU(pduHex) {
|
||||
try {
|
||||
logger.info('开始解析PDU数据...');
|
||||
|
||||
// 解析PDU
|
||||
const parsed = parse(pduHex);
|
||||
|
||||
if (!parsed) {
|
||||
logger.error('PDU解析失败');
|
||||
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(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
|
||||
// 检查是否为长短信
|
||||
if (header && header.getType() !== undefined) {
|
||||
// 长短信头部
|
||||
const refNumber = header.getPointer();
|
||||
const totalParts = header.getSegments();
|
||||
const partNumber = header.getCurrent();
|
||||
|
||||
logger.info(`长短信信息: 参考号=${refNumber}, 当前=${partNumber}, 总计=${totalParts}`);
|
||||
|
||||
// 添加到长短信缓存
|
||||
this.concatManager.addPart(
|
||||
refNumber,
|
||||
sender,
|
||||
partNumber,
|
||||
totalParts,
|
||||
text,
|
||||
timestamp
|
||||
);
|
||||
} else {
|
||||
// 普通短信,直接处理
|
||||
await this.processSmsContent(sender, text, timestamp);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('处理PDU失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理短信内容并转发
|
||||
*/
|
||||
async processSmsContent(sender, text, timestamp) {
|
||||
logger.info('=== 处理短信内容 ===');
|
||||
logger.info(`发送者: ${sender}`);
|
||||
logger.info(`时间戳: ${timestamp}`);
|
||||
logger.info(`内容: ${text}`);
|
||||
logger.info('====================');
|
||||
|
||||
const messageRecord = this.addReceivedMessage(sender, text, timestamp);
|
||||
|
||||
// 推送到所有通道
|
||||
await this.pushManager.pushToAll(sender, text, timestamp);
|
||||
|
||||
// 发送邮件通知
|
||||
const subject = `短信${sender},${text.substring(0, 20)}`;
|
||||
const body = `来自:${sender},时间:${timestamp},内容:${text}`;
|
||||
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;
|
||||
}
|
||||
|
||||
logger.info(`短信已进入Web收件箱: ${sender}`);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收件箱短信状态。
|
||||
*/
|
||||
updateReceivedMessage(id, patch) {
|
||||
const message = this.receivedMessages.find(item => item.id === id);
|
||||
if (message) {
|
||||
Object.assign(message, patch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近收到的短信。
|
||||
*/
|
||||
getReceivedMessages(limit = 50) {
|
||||
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
|
||||
return this.receivedMessages.slice(0, safeLimit);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SMSProcessor;
|
||||
Reference in New Issue
Block a user