feat: 按 SIM ICCID 隔离短信

This commit is contained in:
2026-06-27 09:27:16 +08:00
parent d8ef61adec
commit c395c3fc4e
8 changed files with 325 additions and 28 deletions
+1 -1
View File
@@ -166,7 +166,7 @@
<div class="inbox-panel">
<div class="inbox-meta">
<span id="messageCount">最近 0 条</span>
<span class="inbox-meta__hint">新短信会自动出现在这里</span>
<span class="inbox-meta__hint" id="messageScope">新短信会自动出现在这里</span>
</div>
<div class="message-list" id="messageList" aria-live="polite">
<p class="empty-state empty-state--light">暂无收到的短信。</p>
+20
View File
@@ -759,6 +759,26 @@ h3 {
letter-spacing: -0.2px;
}
.message-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.message-sim {
border: 1px solid var(--hairline);
border-radius: var(--r-pill);
background: var(--surface-soft);
color: var(--muted);
padding: 3px 9px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.message-item__header time {
color: var(--muted);
font-family: var(--font-sans);
+20 -4
View File
@@ -51,6 +51,7 @@ const elements = {
smsStatus: document.querySelector('#smsStatus'),
messagesButton: document.querySelector('#messagesButton'),
messageCount: document.querySelector('#messageCount'),
messageScope: document.querySelector('#messageScope'),
messageList: document.querySelector('#messageList'),
atForm: document.querySelector('#atForm'),
atOutput: document.querySelector('#atOutput'),
@@ -311,9 +312,16 @@ function formatDateTime(value) {
});
}
function renderMessages(messages) {
function renderMessages(messages, meta = {}) {
elements.messageList.replaceChildren();
setText(elements.messageCount, `最近 ${messages.length}`);
const currentSimLabel = meta.currentSim?.simLabel || '当前SIM未知';
const countLabel = meta.partitionBySim ? `${currentSimLabel} 最近 ${messages.length}` : `全部SIM 最近 ${messages.length}`;
const scopeLabel = meta.partitionBySim
? (meta.currentSimKnown ? '仅显示当前SIM收到的短信' : '未识别当前SIM,收件箱已隐藏')
: 'SIM隔离未启用';
setText(elements.messageCount, countLabel);
setText(elements.messageScope, scopeLabel);
if (!messages.length) {
const empty = document.createElement('p');
@@ -345,8 +353,16 @@ function renderMessages(messages) {
text.className = 'message-text';
text.textContent = message.text || '';
const metaRow = document.createElement('div');
metaRow.className = 'message-meta';
const sim = document.createElement('span');
sim.className = 'message-sim';
sim.textContent = message.simLabel || '未知SIM';
metaRow.append(sim);
header.append(sender, status, time);
item.append(header, text);
item.append(header, metaRow, text);
fragment.append(item);
});
@@ -664,7 +680,7 @@ async function refreshLogs() {
async function refreshMessages() {
const messages = await requestJSON('/api/sms/received?limit=50');
renderMessages(messages.data || []);
renderMessages(messages.data || [], messages.meta || {});
}
async function toggleMobileData() {
+14 -4
View File
@@ -414,12 +414,18 @@ class APIServer {
});
// 查询收到的短信
this.app.get('/api/sms/received', (req, res) => {
this.app.get('/api/sms/received', async (req, res) => {
try {
const messages = this.smsProcessor.getReceivedMessages(req.query.limit);
const currentSimIdentity = await this.smsProcessor.getCurrentSimIdentity();
const result = this.smsProcessor.listReceivedMessages(req.query.limit, {
currentSimIdentity,
scope: req.query.scope
});
res.json({
success: true,
data: messages
data: result.messages,
meta: result.meta
});
} catch (err) {
logger.error('查询收到短信失败:', err);
@@ -450,12 +456,16 @@ class APIServer {
// 查询模组信息
this.app.get('/api/modem/info', async (req, res) => {
try {
const iccid = await this.modem.getICCID();
const iccid = await this.modem.getICCID({ refresh: true });
const simIdentity = this.smsProcessor.setCurrentSimFromICCID(iccid);
res.json({
success: true,
data: {
model: this.modem.modelInfo,
iccid,
simId: simIdentity?.simId || null,
simLabel: simIdentity?.simLabel || '未知SIM',
ready: this.modem.ready
}
});
+15 -8
View File
@@ -13,10 +13,12 @@ class ConcatManager extends EventEmitter {
/**
* 查找或创建长短信槽位
*/
findOrCreateSlot(refNumber, sender, totalParts) {
findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
const simId = simIdentity?.simId || '';
// 先查找是否已存在
let slot = this.buffer.find(s =>
s.inUse && s.refNumber === refNumber && s.sender === sender
s.inUse && s.refNumber === refNumber && s.sender === sender && s.simId === simId
);
if (slot) {
@@ -26,7 +28,7 @@ class ConcatManager extends EventEmitter {
// 查找空闲槽位
slot = this.buffer.find(s => !s.inUse);
if (slot) {
this.initSlot(slot, refNumber, sender, totalParts);
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
return slot;
}
@@ -44,17 +46,19 @@ class ConcatManager extends EventEmitter {
logger.warn('长短信缓存已满,覆盖最老的槽位');
}
this.initSlot(slot, refNumber, sender, totalParts);
this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
return slot;
}
/**
* 初始化槽位
*/
initSlot(slot, refNumber, sender, totalParts) {
initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
slot.inUse = true;
slot.refNumber = refNumber;
slot.sender = sender;
slot.simId = simIdentity?.simId || '';
slot.simIdentity = simIdentity;
slot.totalParts = totalParts;
slot.receivedParts = 0;
slot.firstPartTime = Date.now();
@@ -65,10 +69,10 @@ class ConcatManager extends EventEmitter {
/**
* 添加短信分段
*/
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) {
addPart(refNumber, sender, partNumber, totalParts, text, timestamp, options = {}) {
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
const slot = this.findOrCreateSlot(refNumber, sender, totalParts);
const slot = this.findOrCreateSlot(refNumber, sender, totalParts, options.simIdentity);
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
if (partIndex >= 0 && partIndex < this.maxParts) {
@@ -110,7 +114,8 @@ class ConcatManager extends EventEmitter {
this.emit('complete', {
sender: slot.sender,
text: fullText,
timestamp: slot.timestamp
timestamp: slot.timestamp,
simIdentity: slot.simIdentity
});
// 清空槽位
@@ -124,6 +129,8 @@ class ConcatManager extends EventEmitter {
slot.inUse = false;
slot.parts = [];
slot.receivedParts = 0;
slot.simId = '';
slot.simIdentity = null;
}
/**
+3 -1
View File
@@ -60,7 +60,9 @@ async function main() {
// 监听长短信合并完成事件
concatManager.on('complete', async (sms) => {
logger.info('收到长短信合并完成事件');
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp);
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp, {
simIdentity: sms.simIdentity
});
});
// 监听模组短信事件
+31 -2
View File
@@ -21,6 +21,8 @@ class ModemManager extends EventEmitter {
model: '未知',
version: '未知'
};
this.iccid = null;
this.iccidCheckedAt = null;
this.mobileData = {
cid: this.mobileDataConfig.cid,
desiredEnabled: false,
@@ -996,6 +998,7 @@ class ModemManager extends EventEmitter {
// 3. 按ML307A文档先确认SIM卡和协议栈状态
await this.waitSIMReady();
await this.getICCID({ refresh: true });
await this.ensureFullFunctionality();
// 4. 启动保护:先断开应用层拨号,再等待短信所需的网络注册。
@@ -1308,20 +1311,46 @@ class ModemManager extends EventEmitter {
/**
* 查询ICCID
*/
async getICCID() {
async getICCID(options = {}) {
if (!options.refresh && this.iccid) {
return this.iccid;
}
try {
const resp = await this.sendATCommand('AT+CCID', 2000);
const match = resp.match(/\+CCID:\s*(\d+)/);
if (match) {
return match[1];
const previousICCID = this.iccid;
this.iccid = match[1];
this.iccidCheckedAt = new Date().toISOString();
if (previousICCID !== this.iccid) {
logger.info(`✓ 当前SIM ICCID: ${this.formatICCID(this.iccid)}`);
}
return this.iccid;
}
this.iccid = null;
this.iccidCheckedAt = new Date().toISOString();
return null;
} catch (err) {
if (options.refresh) {
this.iccid = null;
this.iccidCheckedAt = new Date().toISOString();
}
logger.error('查询ICCID失败:', err);
return null;
}
}
formatICCID(iccid) {
const value = String(iccid || '');
if (!value) {
return '未知';
}
return `...${value.slice(-6)}`;
}
/**
* 关闭串口
*/
+221 -8
View File
@@ -1,4 +1,5 @@
import { parse } from 'node-pdu';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -17,6 +18,12 @@ class SMSProcessor {
this.receivedMessageSeq = 0;
this.maxReceivedMessages = this.resolveMaxReceivedMessages();
this.receivedMessagesFile = this.resolveReceivedMessagesFile();
this.partitionMessagesBySim = this.resolvePartitionMessagesBySim();
this.allowAllSimMessages = this.resolveAllowAllSimMessages();
this.currentSimIdentity = null;
this.currentSimCheckedAt = 0;
this.currentSimLookup = null;
this.simIdentityTtlMs = 5 * 1000;
this.loadReceivedMessages();
}
@@ -40,10 +47,12 @@ class SMSProcessor {
const text = parsed.data?.getText?.() || '';
const part = parsed.data?.parts?.[0];
const header = part?.header;
const simIdentity = await this.getCurrentSimIdentity();
logger.info('✓ PDU解析成功');
logger.info(`发送者: ${sender}`);
logger.info(`时间戳: ${timestamp}`);
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
logger.info(`内容: ${text}`);
// 检查是否为长短信
@@ -62,11 +71,12 @@ class SMSProcessor {
partNumber,
totalParts,
text,
timestamp
timestamp,
{ simIdentity }
);
} else {
// 普通短信,直接处理
await this.processSmsContent(sender, text, timestamp);
await this.processSmsContent(sender, text, timestamp, { simIdentity });
}
} catch (err) {
logger.error('处理PDU失败:', err);
@@ -76,14 +86,19 @@ class SMSProcessor {
/**
* 处理短信内容并转发
*/
async processSmsContent(sender, text, timestamp) {
async processSmsContent(sender, text, timestamp, options = {}) {
const simIdentity = options.simIdentity !== undefined
? this.normalizeSimIdentity(options.simIdentity)
: await this.getCurrentSimIdentity();
logger.info('=== 处理短信内容 ===');
logger.info(`发送者: ${sender}`);
logger.info(`时间戳: ${timestamp}`);
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
logger.info(`内容: ${text}`);
logger.info('====================');
const messageRecord = this.addReceivedMessage(sender, text, timestamp);
const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity);
// 推送到所有通道
await this.pushManager.pushToAll(sender, text, timestamp);
@@ -102,13 +117,16 @@ class SMSProcessor {
/**
* 记录收到的短信,供Web管理端展示。
*/
addReceivedMessage(sender, text, timestamp) {
addReceivedMessage(sender, text, timestamp, simIdentity = null) {
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
const message = {
id: `${Date.now()}-${++this.receivedMessageSeq}`,
sender,
text,
timestamp,
receivedAt: new Date().toISOString(),
simId: normalizedSimIdentity?.simId || '',
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
status: 'received',
statusText: '已接收'
};
@@ -119,7 +137,7 @@ class SMSProcessor {
}
this.persistReceivedMessages();
logger.info(`短信已进入Web收件箱: ${sender}`);
logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
return message;
}
@@ -138,8 +156,53 @@ class SMSProcessor {
* 获取最近收到的短信。
*/
getReceivedMessages(limit = 50) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
return this.receivedMessages.slice(0, safeLimit);
return this.receivedMessages.slice(0, this.resolveReceivedMessagesLimit(limit));
}
/**
* 获取按SIM作用域过滤后的短信。
*/
listReceivedMessages(limit = 50, options = {}) {
const safeLimit = this.resolveReceivedMessagesLimit(limit);
const requestedScope = String(options.scope || 'current').trim().toLowerCase();
const currentSim = this.normalizeSimIdentity(options.currentSimIdentity);
const includeAllSims = requestedScope === 'all' && this.allowAllSimMessages;
let effectiveScope = includeAllSims ? 'all' : 'current';
let messages = this.receivedMessages;
if (this.partitionMessagesBySim && !includeAllSims) {
if (currentSim?.simId) {
messages = this.receivedMessages.filter(message => message.simId === currentSim.simId);
} else {
messages = [];
effectiveScope = 'unavailable';
}
} else if (!this.partitionMessagesBySim) {
effectiveScope = 'all';
}
return {
messages: messages.slice(0, safeLimit),
meta: {
limit: safeLimit,
total: messages.length,
scope: effectiveScope,
requestedScope,
partitionBySim: this.partitionMessagesBySim,
allSimMessagesAllowed: this.allowAllSimMessages,
allSimMessagesDenied: this.partitionMessagesBySim && requestedScope === 'all' && !this.allowAllSimMessages,
currentSim,
currentSimKnown: Boolean(currentSim?.simId)
}
};
}
/**
* 限制收件箱查询条数。
*/
resolveReceivedMessagesLimit(limit = 50) {
return Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
}
/**
@@ -154,6 +217,20 @@ class SMSProcessor {
return Math.min(Math.floor(configured), 5000);
}
/**
* 是否按当前SIM隔离Web收件箱。默认开启,避免换卡后读取其他SIM历史。
*/
resolvePartitionMessagesBySim() {
return this.config.inbox?.partitionBySim !== false;
}
/**
* 是否允许显式查询全部SIM历史。默认关闭。
*/
resolveAllowAllSimMessages() {
return this.config.inbox?.allowAllSimMessages === true;
}
/**
* 解析收件箱持久化文件路径。
*/
@@ -232,17 +309,153 @@ class SMSProcessor {
return null;
}
const simIdentity = this.normalizeStoredSimIdentity(item);
return {
id,
sender: String(item.sender || '未知号码'),
text: String(item.text || ''),
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
receivedAt: String(item.receivedAt || new Date().toISOString()),
simId: simIdentity?.simId || '',
simLabel: simIdentity?.simLabel || '未知SIM',
status: String(item.status || 'received'),
statusText: String(item.statusText || '已接收')
};
}
/**
* 从历史记录中恢复SIM身份。旧记录没有SIM字段,会归到未知SIM。
*/
normalizeStoredSimIdentity(item) {
if (item.simId || item.simLabel) {
return this.normalizeSimIdentity({
simId: item.simId,
simLabel: item.simLabel
});
}
if (item.sim && typeof item.sim === 'object') {
return this.normalizeSimIdentity(item.sim);
}
if (item.simIccid || item.iccid) {
return this.createSimIdentityFromICCID(item.simIccid || item.iccid);
}
return null;
}
/**
* 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
*/
async getCurrentSimIdentity(options = {}) {
const refresh = options.refresh === true;
const now = Date.now();
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
return this.currentSimIdentity;
}
if (this.currentSimLookup) {
return this.currentSimLookup;
}
if (!this.modem?.getICCID) {
this.currentSimIdentity = null;
this.currentSimCheckedAt = now;
return null;
}
this.currentSimLookup = (async () => {
try {
const iccid = await this.modem.getICCID({ refresh: true });
return this.setCurrentSimFromICCID(iccid);
} catch (err) {
logger.warn(`查询当前SIM标识失败: ${err.message}`);
this.currentSimIdentity = null;
this.currentSimCheckedAt = Date.now();
return null;
} finally {
this.currentSimLookup = null;
}
})();
return this.currentSimLookup;
}
/**
* 使用ICCID更新当前SIM身份缓存。
*/
setCurrentSimFromICCID(iccid) {
return this.setCurrentSimIdentity(this.createSimIdentityFromICCID(iccid));
}
/**
* 更新当前SIM身份缓存。
*/
setCurrentSimIdentity(identity) {
this.currentSimIdentity = this.normalizeSimIdentity(identity);
this.currentSimCheckedAt = Date.now();
return this.currentSimIdentity;
}
/**
* 根据ICCID生成稳定但不暴露完整卡号的SIM标识。
*/
createSimIdentityFromICCID(iccid) {
const normalized = this.normalizeICCID(iccid);
if (!normalized) {
return null;
}
return {
simId: crypto.createHash('sha256').update(`iccid:${normalized}`).digest('hex').slice(0, 24),
simLabel: this.formatSimLabel(normalized)
};
}
normalizeICCID(iccid) {
return String(iccid || '').replace(/\D/g, '');
}
formatSimLabel(iccid) {
const value = String(iccid || '');
if (!value) {
return '未知SIM';
}
return `ICCID ...${value.slice(-6)}`;
}
normalizeSimIdentity(identity) {
if (!identity) {
return null;
}
if (typeof identity === 'string') {
return this.createSimIdentityFromICCID(identity);
}
if (typeof identity !== 'object' || Array.isArray(identity)) {
return null;
}
if (identity.iccid || identity.simIccid) {
return this.createSimIdentityFromICCID(identity.iccid || identity.simIccid);
}
const simId = String(identity.simId || identity.id || '').trim();
if (!simId) {
return null;
}
return {
simId,
simLabel: String(identity.simLabel || identity.label || '未知SIM')
};
}
}
export default SMSProcessor;