diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c5fef05 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.env +.npm +.npm-cache +.vscode +.idea +node_modules +logs +data +config.json +*.log +*.tgz +*.swp +*.swo +*~ +AGENTS.md +DESIGN.md +ML307A_*.md diff --git a/.gitignore b/.gitignore index 37bf180..d3d7c90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ logs/ +data/ *.log config.json .DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66d2560 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b743775 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# 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" diff --git a/src/sms.js b/src/sms.js index d0868e5..69506f3 100644 --- a/src/sms.js +++ b/src/sms.js @@ -1,6 +1,12 @@ import { parse } from 'node-pdu'; +import fs from 'fs'; +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 SMSProcessor { constructor(config, modem, concatManager, pushManager) { this.config = config; @@ -9,7 +15,9 @@ class SMSProcessor { this.pushManager = pushManager; this.receivedMessages = []; this.receivedMessageSeq = 0; - this.maxReceivedMessages = 200; + this.maxReceivedMessages = this.resolveMaxReceivedMessages(); + this.receivedMessagesFile = this.resolveReceivedMessagesFile(); + this.loadReceivedMessages(); } /** @@ -110,6 +118,7 @@ class SMSProcessor { this.receivedMessages.length = this.maxReceivedMessages; } + this.persistReceivedMessages(); logger.info(`短信已进入Web收件箱: ${sender}`); return message; } @@ -121,6 +130,7 @@ class SMSProcessor { const message = this.receivedMessages.find(item => item.id === id); if (message) { Object.assign(message, patch); + this.persistReceivedMessages(); } } @@ -132,6 +142,107 @@ class SMSProcessor { 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 || '已接收') + }; + } + } export default SMSProcessor;