feat: 添加推送通道管理

This commit is contained in:
2026-06-24 22:39:12 +08:00
parent deafe6b1c4
commit 15b15b79c9
5 changed files with 964 additions and 9 deletions
+89
View File
@@ -20,8 +20,11 @@
<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>
@@ -165,6 +168,92 @@
</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>
<button class="button button--secondary button--compact" id="pushAddButton" type="button">新增</button>
</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>
+248 -5
View File
@@ -45,7 +45,8 @@ body {
button,
input,
textarea {
textarea,
select {
font: inherit;
letter-spacing: 0;
}
@@ -106,13 +107,19 @@ a {
.nav-links a:focus-visible,
.button:focus-visible,
.text-input: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 .button {
.top-nav__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.button {
@@ -154,6 +161,33 @@ a {
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);
@@ -480,11 +514,22 @@ h3 {
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;
@@ -621,9 +666,178 @@ h3 {
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 {
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 {
.workbench-grid,
.push-settings {
grid-template-columns: 1fr;
}
@@ -656,7 +870,7 @@ h3 {
padding-bottom: 4px;
}
.top-nav .button {
.top-nav__actions {
margin-left: auto;
}
@@ -720,4 +934,33 @@ h3 {
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;
}
}
+387 -1
View File
@@ -1,8 +1,26 @@
const state = {
busy: false
busy: false,
pushChannels: [],
selectedPushChannel: -1,
pushLoaded: false
};
const elements = {
pushSettingsButton: document.querySelector('#pushSettingsButton'),
pushModal: document.querySelector('#pushModal'),
pushModalClose: document.querySelector('#pushModalClose'),
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'),
@@ -29,6 +47,60 @@ const elements = {
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 || '--';
}
@@ -207,6 +279,303 @@ function renderMessages(messages) {
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();
state.pushChannels.push(createDefaultPushChannel());
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 type = controls.type.value;
updatePushFieldVisibility(type);
if (!controls.name.value.trim()) {
controls.name.value = `${getPushTypeLabel(type)}通知`;
}
if (type === 'custom' && !controls.customBody.value.trim()) {
controls.customBody.value = createDefaultPushChannel('custom').customBody;
}
}
async function refreshLogs() {
const logs = await requestJSON('/api/logs');
renderLogs(logs.data || []);
@@ -304,11 +673,28 @@ async function runATCommand(event) {
}
}
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(() => {
+238 -1
View File
@@ -1,3 +1,4 @@
import fs from 'fs';
import express from 'express';
import crypto from 'crypto';
import path from 'path';
@@ -7,13 +8,39 @@ 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 {
constructor(config, modem, smsProcessor) {
constructor(config, modem, smsProcessor, pushManager) {
this.config = config;
this.modem = modem;
this.smsProcessor = smsProcessor;
this.pushManager = pushManager;
this.app = express();
this.publicDir = path.join(__dirname, '../public');
this.configPath = path.join(__dirname, '../config.json');
this.setupMiddleware();
this.setupRoutes();
@@ -194,6 +221,135 @@ class APIServer {
</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() {
this.app.get('/', (req, res) => {
res.redirect('/admin');
@@ -357,6 +513,87 @@ 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) => {
res.json({
+1 -1
View File
@@ -99,7 +99,7 @@ async function main() {
concatManager.startTimeoutChecker();
// 启动API服务器
const apiServer = new APIServer(config, modem, smsProcessor);
const apiServer = new APIServer(config, modem, smsProcessor, pushManager);
apiServer.start();
logger.info('========================================');