diff --git a/.gitignore b/.gitignore index 590e551..b97b576 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ accounts.json password.json sessions.json antigravity-accounts-*.json - +docs/MODULE_NAMING.md # ==================== # 用户数据 & 数据库 # ==================== diff --git a/README.md b/README.md index aa99d51..561ff2e 100644 --- a/README.md +++ b/README.md @@ -109,8 +109,8 @@ api-monitor/ │ ├── services/ # 业务服务 │ └── utils/ # 工具函数 ├── modules/ # 可插拔业务模块 -│ ├── server-management/ # 服务器/SSH/Docker -│ ├── cloudflare-dns/ # Cloudflare DNS +│ ├── server-api/ # 服务器/SSH/Docker +│ ├── cloudflare-api/ # Cloudflare DNS │ ├── antigravity-api/ # Antigravity Agent │ ├── music-api/ # 网易云音乐代理 │ └── ... # 更多模块 diff --git a/agent-go/README.md b/agent-go/README.md index e122f44..0cf660d 100644 --- a/agent-go/README.md +++ b/agent-go/README.md @@ -4,11 +4,11 @@ ## 特性 -- 🚀 **高性能**: Go 语言编写,单二进制部署,资源占用低 -- 📊 **实时监控**: CPU、内存、磁盘、网络流量实时采集 -- 🔗 **Socket.IO**: 与 Dashboard 实时通信 -- 🔄 **自动重连**: 断线自动重连,稳定可靠 -- 🐧 **跨平台**: 支持 Linux、Windows、macOS +- **高性能**: Go 语言编写,单二进制部署,资源占用低 +- **实时监控**: CPU、内存、磁盘、网络流量实时采集 +- **Socket.IO**: 与 Dashboard 实时通信 +- **自动重连**: 断线自动重连,稳定可靠 +- **跨平台**: 支持 Linux、Windows、macOS ## 构建 @@ -46,7 +46,7 @@ GOOS=windows GOARCH=amd64 go build -o agent-windows-amd64.exe | 参数 | 说明 | 默认值 | |------|------|--------| -| `-s, --server` | Dashboard 地址 | http://localhost:3000 | +| `-s, --server` | Dashboard 地址 | | | `--id` | 主机 ID (必需) | - | | `-k` | Agent 密钥 (必需) | - | | `-i` | 上报间隔 (毫秒) | 1500 | @@ -99,6 +99,6 @@ GOOS=windows GOARCH=amd64 go build -o agent-windows-amd64.exe - [gorilla/websocket](https://github.com/gorilla/websocket) - WebSocket 客户端 - [shirou/gopsutil](https://github.com/shirou/gopsutil) - 系统信息采集 -## License +## 许可证 MIT diff --git a/agent-go/main.go b/agent-go/main.go index 76ad1f1..829515e 100644 --- a/agent-go/main.go +++ b/agent-go/main.go @@ -517,6 +517,7 @@ func (a *AgentClient) heartbeat() { // Socket.IO 中只有服务端发送 ping (2),客户端只需响应 pong (3) // 我们在 handleMessage 中已经处理了 ping 响应 // 这个 goroutine 只是为了监听 stopChan + // 这下应该不会错了(应该 <-a.stopChan } diff --git a/diagnose_db.js b/diagnose_db.js deleted file mode 100644 index eabe392..0000000 --- a/diagnose_db.js +++ /dev/null @@ -1,43 +0,0 @@ -const db = require('./src/db/database'); -const fs = require('fs'); - -try { - console.log('正在分析数据库中的消息大小...'); - const database = db.getDatabase(); - - // 获取最近的 20 条消息 - const messages = database.prepare(` - SELECT id, role, length(content) as len, substr(content, 1, 100) as preview - FROM chat_messages - ORDER BY id DESC - LIMIT 20 - `).all(); - - console.log('ID\t| Role\t| Size(KB)\t| Preview'); - console.log('----------------------------------------------------'); - - let base64Count = 0; - - messages.forEach(msg => { - const sizeKB = (msg.len / 1024).toFixed(2); - const isBase64 = msg.preview.includes('data:image') || msg.preview.includes('base64'); - if (isBase64 && msg.len > 1000) base64Count++; - - // 简单的颜色标记 - const mark = (msg.len > 10240) ? '(!)' : ' '; - - console.log(`${msg.id}\t| ${msg.role}\t| ${sizeKB} KB${mark}\t| ${msg.preview.replace(/\n/g, ' ')}...`); - }); - - console.log('----------------------------------------------------'); - console.log(`分析完成。在大约最后 20 条消息中,发现 ${base64Count} 条疑似 Base64 图片记录。`); - - if (base64Count > 0) { - console.log('建议:这证实了数据库中确实存储了 Base64 数据。'); - } else { - console.log('提示:最近的消息似乎没有巨大的 Base64 数据。可能之前的记录导致了数据库膨胀。'); - } - -} catch (e) { - console.error('分析失败:', e); -} diff --git a/diagnose_result.txt b/diagnose_result.txt deleted file mode 100644 index 5d08689..0000000 Binary files a/diagnose_result.txt and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index d40fb37..6687083 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: api-monitor: image: iwvw/api-monitor:latest + # image: iwvw/api-monitor:dev # 开发版本可选 container_name: api-monitor restart: unless-stopped ports: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a08c315..383271a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,13 +58,13 @@ api-monitor/ │ ├── services/ # 业务服务 │ └── utils/ # 工具函数 ├── modules/ # 可插拔业务模块 (13个) -│ ├── server-management/ # 服务器/SSH/Docker -│ ├── cloudflare-dns/ # Cloudflare DNS +│ ├── server-api/ # 服务器/SSH/Docker +│ ├── cloudflare-api/ # Cloudflare DNS │ ├── antigravity-api/ # Antigravity Agent │ ├── gemini-cli-api/ # Gemini CLI │ ├── zeabur-api/ # Zeabur PaaS │ ├── koyeb-api/ # Koyeb PaaS -│ ├── fly-api/ # Fly.io +│ ├── flyio-api/ # Fly.io │ ├── music-api/ # 网易云音乐代理 │ ├── totp-api/ # 2FA 管理 │ └── ... diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ea90bf4..af73c7c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -56,6 +56,7 @@ npm run format ``` **类型 (type)**: + - `feat`: 新功能 - `fix`: 修复 bug - `docs`: 文档更新 @@ -66,6 +67,7 @@ npm run format - `chore`: 构建/工具变更 **示例**: + ``` feat(music): 添加歌词同步功能 @@ -107,7 +109,7 @@ api-monitor/ │ └── utils/ # 工具函数 ├── modules/ # 功能模块 │ ├── music-api/ # 音乐 API -│ ├── cloudflare-dns/# DNS 管理 +│ ├── cloudflare-api/# DNS 管理 │ └── ... ├── agent-go/ # Go Agent 源码 └── test/ # 测试文件 diff --git a/eslint.config.js b/eslint.config.js index b238739..a36fbaa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -160,7 +160,7 @@ module.exports = [ 'plugin/**', // 浏览器扩展使用特殊 API 'src/js/modules/template.js', // 模板文件 'src/*_snippet*.js', // 代码片段 - 'modules/cloudflare-dns/router_utf8.js', // 备份文件 + ], }, ]; diff --git a/modules/_template/README.md b/modules/_template/README.md index 5d8eb39..6672e0a 100644 --- a/modules/_template/README.md +++ b/modules/_template/README.md @@ -1,52 +1,166 @@ -# 模块开发模板使用指南 - -本目录提供了一个标准化的模块开发模板,用于快速扩展项目功能。 - -## 快速开始 - -### 1. 后端部分 (Backend) - -1. **复制模板**: 复制 `modules/_template` 文件夹并重命名为你的模块名 (例如 `my-service-api`)。 -2. **配置数据库**: - * 修改 `schema.sql`,定义你的表结构。 - * 在 `src/db/database.js` 中添加新表的初始化逻辑(或者手动在 SQLite 中运行 SQL)。 -3. **定义模型**: - * 修改 `models.js`,将 `{{module_name}}` 替换为实际表名。 - * 在 `src/db/models/index.js` 中导出你的新模型。 -4. **实现存储层**: 修改 `storage.js` 中的逻辑。 -5. **实现 API 服务**: 在 `service.js` 中实现与外部第三方 API 的交互。 -6. **注册路由**: - * 修改 `router.js` 中的路由定义。 - * 在 `src/routes/index.js` 的 `moduleRouteMap` 中添加你的模块路由映射。 - -### 2. 前端部分 (Frontend) - -1. **样式**: - * 复制 `src/css/template.css` 为 `src/css/your-module.css`。 - * 在 `src/index.html` 中引入该 CSS 文件。 -2. **模板**: - * 复制 `src/templates/template.html` 为 `src/templates/your-module.html`。 - * 在 `src/index.html` 的 templates 区域引入该文件(或通过 `template-loader.js` 加载)。 -3. **逻辑**: - * 复制 `src/js/modules/template.js` 为 `src/js/modules/your-module.js`。 - * 在 `src/js/main.js` 中导入并混入 (mixin) 你的模块方法。 -4. **导航**: - * 在 `src/index.html` 的侧边栏导航中添加指向你模块的链接。 - -## 关键变量替换 - -在模板文件中,请全局替换以下占位符: - -* `{{module_name}}`: 模块名称 (小写,下划线分隔),如 `my_service`。 -* `{{ModuleName}}`: 模块名称 (大驼峰),如 `MyService`。 -* `{{module_prefix}}`: 数据库 ID 前缀,如 `ms`。 -* `{{MODULE_ENV_VAR}}`: 环境变量名称,如 `MY_SERVICE_TOKEN`。 -* `{{module_title}}`: UI 显示的中文标题。 - -## 集成检查清单 - -- [ ] `schema.sql` 已执行 (数据库表已创建) -- [ ] `src/db/models/index.js` 已导出新模型 -- [ ] `src/routes/index.js` 已注册路由映射 -- [ ] `src/js/main.js` 已混入前端方法 -- [ ] `src/index.html` 已添加导航项、CSS 和 HTML 模板 +# Antigravity 模块开发与集成规范指南 + +本文档规定了新模块接入系统的标准化流程与代码规范,确保全站 UI/UX 一致性及系统架构的整洁。 + +--- + +## 🚀 1. 挂载与注册 (Mounting & Registration) + +### 1.1 后端路由挂载 + +在 `src/routes/index.js` 中,将模块路由添加到 `moduleRouteMap`: + +```javascript +const moduleRouteMap = { + 'notification-api': '/api/notification', // 键名为前端模块ID, 值为后端路径前缀 + // ... +}; +``` + +### 1.2 前端基础配置 + +在 `src/js/stores/app.js` 中配置图标与基本信息: + +```javascript +export const MODULE_CONFIG = { + notification: { + name: '通知管理', + icon: 'fas fa-bell', + desc: '统一告警与通知中心' + }, + // ... +}; +``` + +### 1.3 模块可见性 (白名单) + +**核心步骤!** 在 `src/js/modules/settings.js` 的 `validModules` 数组中手动添加模块 ID,否则模块在“用户设置”中会被过滤,导致前端不显示。 + +```javascript +const validModules = [ + 'dashboard', 'uptime', 'notification', // 必须包含模块名 + // ... +]; +``` + +--- + +## 💻 2. 前端显示与方法 (Frontend Methods) + +### 2.1 Vue 方法集成 (Vue Mixin) + +在 `src/js/main.js` 中导入并混入数据和方法: + +```javascript +import { notificationData, notificationMethods } from './modules/notification.js'; + +const app = Vue.createApp({ + data() { + return { + ...notificationData, // 混入数据 + // ... + }; + }, + methods: { + ...notificationMethods, // 混入方法 + // ... + } +}); +``` + +### 2.2 HTML 模板加载 + +1. **容器定义**: 在 `src/index.html` 的主内容区域添加: + + ```html +
+ ``` + +2. **路由映射**: 在 `src/js/template-loader.js` 的 `templateMap` 中指定文件: + + ```javascript + const templateMap = { + 'notification.html': '#template-notification', + }; + ``` + +--- + +## 🎨 3. UI 标准化定义 (Standard UI) + +### 3.1 主题配色 (Theming) + +在 `src/css/styles.css` 中定义主题变量,所有子组件将自动继承配色: + +```css +.theme-notification { + --current-primary: #f59e0b; /* 主色调 */ + --current-dark: #d97706; /* 悬停/深色态 */ + --current-rgb: 245, 158, 11; /* 阴影/半透明色 */ +} +/* 定义子标签页激活态渐变 (必须) */ +.theme-notification .tab-btn.active { + background: linear-gradient(135deg, var(--current-primary), var(--current-dark)) !important; + box-shadow: 0 2px 8px rgba(var(--current-rgb), 0.3); +} +``` + +### 3.2 子标签栏 (SecTabs) + +统一使用 `sec-tabs` 类名。 + +```html +
+ +
+``` + +### 3.3 模态框标准化 (Modals) + +1. **外部放置**: 模态框 HTML 必须放在 `.tab-content` 主容器之外。 +2. **主题声明**: 在 `modal-overlay` 上添加模块主题类名。 +3. **结构规范**: + +```html + + +``` + +### 3.4 开关切换器 (AG-Switch) + +不再使用原生复选框,统一使用以下结构: + +```html + +``` + +--- + +## ✅ 集成检查清单 (Checklist) + +- [ ] 后端 `router.js` 定义完毕并注册到 `src/routes/index.js`。 +- [ ] 前端 `validModules` 已加入白名单。 +- [ ] `main.js` 已导入并混入 `{module}Data` 和 `{module}Methods`。 +- [ ] `index.html` 占位符 ID 与 `template-loader.js` 映射一致。 +- [ ] 所有的模态框均在容器外层且绑定了 `theme-xxx` 类。 +- [ ] `styles.css` 中已定义模块专属渐变激活态样式。 diff --git a/modules/aliyun-api/aliyun-api.js b/modules/aliyun-api/aliyun-api.js new file mode 100644 index 0000000..a737a2a --- /dev/null +++ b/modules/aliyun-api/aliyun-api.js @@ -0,0 +1,695 @@ +/** + * Aliyun API 封装 + * 使用 @alicloud/pop-core SDK + */ + +const RPCClient = require('@alicloud/pop-core'); +const { createLogger } = require('../../src/utils/logger'); +const logger = createLogger('AliyunAPI'); + +// API 版本常量 +const API_VERSIONS = { + DNS: '2015-01-09', + ECS: '2014-05-26', + CMS: '2019-01-01', + SWAS: '2018-08-08' +}; + +const REGION_MAP = { + 'cn-hangzhou': '华东1 (杭州)', + 'cn-shanghai': '华东2 (上海)', + 'cn-qingdao': '华北1 (青岛)', + 'cn-beijing': '华北2 (北京)', + 'cn-zhangjiakou': '华北3 (张家口)', + 'cn-huhehaote': '华北5 (呼和浩特)', + 'cn-wulanchabu': '华北6 (乌兰察布)', + 'cn-shenzhen': '华南1 (深圳)', + 'cn-heyuan': '华南2 (河源)', + 'cn-guangzhou': '华南3 (广州)', + 'cn-chengdu': '西南1 (成都)', + 'cn-hongkong': '中国香港', + 'ap-southeast-1': '新加坡', + 'ap-southeast-2': '澳大利亚 (悉尼)', + 'ap-southeast-3': '马来西亚 (吉隆坡)', + 'ap-southeast-5': '印度尼西亚 (雅加达)', + 'ap-northeast-1': '日本 (东京)', + 'ap-south-1': '印度 (孟买)', + 'us-east-1': '美国 (弗吉尼亚)', + 'us-west-1': '美国 (硅谷)', + 'eu-central-1': '德国 (法兰克福)', + 'eu-west-1': '英国 (伦敦)', + 'me-east-1': '阿联酋 (迪拜)', + 'cn-wuhan-lrb': '华中1 (武汉-轻量)', +}; + +/** + * 友好化显示规格 + */ +function formatFlavor(flavor) { + if (!flavor) return '-'; + // 匹配类似 ecs.c5.large 或 s.c2m2s40b1 + if (flavor.includes('c1m1')) return '1核 1GB'; + if (flavor.includes('c2m2')) return '2核 2GB'; + if (flavor.includes('c2m4')) return '2核 4GB'; + if (flavor.includes('c4m4')) return '4核 4GB'; + if (flavor.includes('c4m8')) return '4核 8GB'; + if (flavor.includes('c8m16')) return '8核 16GB'; + + // 常见的 ECS 命名 + const match = flavor.match(/(\d+)c(\d+)m/); + if (match) return `${match[1]}核 ${match[2]}GB`; + + return flavor; +} + +/** + * 创建 CMS (云监控) Client + */ +function createCmsClient(auth) { + return new RPCClient({ + accessKeyId: auth.accessKeyId, + accessKeySecret: auth.accessKeySecret, + endpoint: 'https://metrics.aliyuncs.com', + apiVersion: API_VERSIONS.CMS, + opts: { timeout: 15000 } + }); +} + +/** + * 创建通用 Client + */ +function createClient(auth, apiVersion) { + if (!auth.accessKeyId || !auth.accessKeySecret) { + throw new Error('Missing AccessKeyId or AccessKeySecret'); + } + + return new RPCClient({ + accessKeyId: auth.accessKeyId, + accessKeySecret: auth.accessKeySecret, + endpoint: 'https://ecs.aliyuncs.com', // 默认 ECS + apiVersion: apiVersion, + opts: { timeout: 15000 } + }); +} + +/** + * 创建 DNS Client (特殊端点) + */ +function createDnsClient(auth) { + if (!auth.accessKeyId || !auth.accessKeySecret) { + throw new Error('Missing AccessKeyId or AccessKeySecret'); + } + + return new RPCClient({ + accessKeyId: auth.accessKeyId, + accessKeySecret: auth.accessKeySecret, + endpoint: 'https://alidns.aliyuncs.com', + apiVersion: API_VERSIONS.DNS, + opts: { timeout: 15000 } + }); +} + +/** + * 创建不同区域的 ECS Client + */ +function createEcsClient(auth, regionId) { + if (!auth.accessKeyId || !auth.accessKeySecret) { + throw new Error('Missing AccessKeyId or AccessKeySecret'); + } + + return new RPCClient({ + accessKeyId: auth.accessKeyId, + accessKeySecret: auth.accessKeySecret, + endpoint: `https://ecs.${regionId}.aliyuncs.com`, + apiVersion: API_VERSIONS.ECS, + opts: { timeout: 15000 } + }); +} + +// ==================== DNS 相关 ==================== + +/** + * 获取域名列表 + */ +async function listDomains(auth, options = {}) { + const client = createDnsClient(auth); + try { + const result = await client.request('DescribeDomains', { + PageSize: options.pageSize || 100, + PageNumber: options.pageNumber || 1 + }); + return { + domains: result.Domains?.Domain || [], + total: result.TotalCount + }; + } catch (e) { + throw new Error(`DescribeDomains Failed: ${e.message}`); + } +} + +/** + * 添加域名 + */ +async function addDomain(auth, domainName) { + const client = createDnsClient(auth); + try { + // 1. 先尝试获取域名信息,如果已经存在于账号中,直接返回 NS + try { + const info = await client.request('DescribeDomainInfo', { + DomainName: domainName + }); + return { + DomainName: domainName, + DnsServers: info.DnsServers?.DnsServer || [], + AlreadyExists: true + }; + } catch (e) { + // 如果报错不是域名不存在,则继续尝试添加 + if (e.code !== 'InvalidDomainName.NoExist') { + logger.warn(`Check domain existence failed for ${domainName}:`, e.message); + } + } + + // 2. 尝试添加域名 + let addResult; + try { + addResult = await client.request('AddDomain', { + DomainName: domainName + }); + } catch (e) { + // 如果添加失败,但错误提示是域名已存在,则再次尝试获取信息 + if (e.code === 'DomainAlreadyExist' || e.message.includes('exists')) { + const info = await client.request('DescribeDomainInfo', { + DomainName: domainName + }); + return { + DomainName: domainName, + DnsServers: info.DnsServers?.DnsServer || [], + AlreadyExists: true + }; + } + throw e; + } + + // 3. 获取新添加域名的 NS 记录 + const info = await client.request('DescribeDomainInfo', { + DomainName: domainName + }); + + return { + ...addResult, + DnsServers: info.DnsServers?.DnsServer || [] + }; + } catch (e) { + // 针对所有权验证失败的特殊处理 + if (e.message.includes('TXT record') || e.code === 'VerificationFailed') { + throw new Error(`域名所有权验证未通过。请确保已按阿里云要求添加 TXT 记录,或者尝试添加主域名而非子域名。详细错误: ${e.message}`); + } + throw new Error(`AddDomain Failed: ${e.message}`); + } +} + +/** + * 删除域名 + */ +async function deleteDomain(auth, domainName) { + const client = createDnsClient(auth); + try { + const result = await client.request('DeleteDomain', { + DomainName: domainName + }); + return result; + } catch (e) { + throw new Error(`DeleteDomain Failed: ${e.message}`); + } +} + +/** + * 获取监控数据 + */ +async function getMetricData(auth, params) { + const client = createCmsClient(auth); + try { + const result = await client.request('DescribeMetricList', { + Namespace: params.namespace || 'acs_ecs_dashboard', + MetricName: params.metricName, + Dimensions: JSON.stringify(params.dimensions), + StartTime: params.startTime, + EndTime: params.endTime, + Period: params.period || '60' + }); + return result; + } catch (e) { + throw new Error(`GetMetricData Failed: ${e.message}`); + } +} + +/** + * 获取轻量服务器防火墙规则 + */ +async function listFirewallRules(auth, regionId, instanceId) { + const client = createSwasClient(auth, regionId); + try { + const result = await client.request('ListFirewallRules', { + InstanceId: instanceId, + RegionId: regionId + }); + return result.FirewallRules || []; + } catch (e) { + throw new Error(`ListFirewallRules Failed: ${e.message}`); + } +} + +/** + * 添加防火墙规则 + */ +async function createFirewallRule(auth, regionId, instanceId, rule) { + const client = createSwasClient(auth, regionId); + try { + return await client.request('CreateFirewallRule', { + InstanceId: instanceId, + RegionId: regionId, + RuleProtocol: rule.protocol, + Port: rule.port, + Remark: rule.remark + }); + } catch (e) { + throw new Error(`CreateFirewallRule Failed: ${e.message}`); + } +} + +/** + * 删除防火墙规则 + */ +async function deleteFirewallRule(auth, regionId, instanceId, ruleId) { + const client = createSwasClient(auth, regionId); + try { + return await client.request('DeleteFirewallRule', { + InstanceId: instanceId, + RegionId: regionId, + RuleId: ruleId + }); + } catch (e) { + throw new Error(`DeleteFirewallRule Failed: ${e.message}`); + } +} + +/** + * 获取域名解析记录 + */ +async function listDomainRecords(auth, domainName, options = {}) { + const client = createDnsClient(auth); + try { + const result = await client.request('DescribeDomainRecords', { + DomainName: domainName, + PageSize: options.pageSize || 100, + PageNumber: options.pageNumber || 1 + }); + return { + records: result.DomainRecords?.Record || [], + total: result.TotalCount + }; + } catch (e) { + throw new Error(`DescribeDomainRecords Failed: ${e.message}`); + } +} + +/** + * 添加解析记录 + */ +async function addDomainRecord(auth, domainName, record) { + const client = createDnsClient(auth); + try { + const result = await client.request('AddDomainRecord', { + DomainName: domainName, + RR: record.RR, + Type: record.Type, + Value: record.Value, + TTL: record.TTL || 600, + Priority: record.Priority + }); + return result; + } catch (e) { + throw new Error(`AddDomainRecord Failed: ${e.message}`); + } +} + +/** + * 修改解析记录 + */ +async function updateDomainRecord(auth, recordId, record) { + const client = createDnsClient(auth); + try { + const result = await client.request('UpdateDomainRecord', { + RecordId: recordId, + RR: record.RR, + Type: record.Type, + Value: record.Value, + TTL: record.TTL || 600, + Priority: record.Priority + }); + return result; + } catch (e) { + throw new Error(`UpdateDomainRecord Failed: ${e.message}`); + } +} + +/** + * 删除解析记录 + */ +async function deleteDomainRecord(auth, recordId) { + const client = createDnsClient(auth); + try { + const result = await client.request('DeleteDomainRecord', { + RecordId: recordId + }); + return result; + } catch (e) { + throw new Error(`DeleteDomainRecord Failed: ${e.message}`); + } +} + +/** + * 设置记录状态 + */ +async function setDomainRecordStatus(auth, recordId, status) { + const client = createDnsClient(auth); + try { + const result = await client.request('SetDomainRecordStatus', { + RecordId: recordId, + Status: status === 'Enable' ? 'Enable' : 'Disable' + }); + return result; + } catch (e) { + throw new Error(`SetDomainRecordStatus Failed: ${e.message}`); + } +} + +// ==================== ECS 相关 ==================== + +/** + * 获取可用区域 + */ +async function listRegions(auth) { + const client = createClient(auth, API_VERSIONS.ECS); + try { + const result = await client.request('DescribeRegions', {}); + return result.Regions.Region || []; + } catch (e) { + console.warn('Failed to fetch regions, fallback to defaults', e.message); + return [ + { RegionId: 'cn-hangzhou', LocalName: '华东1 (杭州)' }, + { RegionId: 'cn-shanghai', LocalName: '华东2 (上海)' }, + { RegionId: 'cn-beijing', LocalName: '华北2 (北京)' }, + { RegionId: 'cn-shenzhen', LocalName: '华南1 (深圳)' }, + { RegionId: 'cn-hongkong', LocalName: '中国香港' } + ]; + } +} + +/** + * 获取单个区域的实例 + */ +async function listInstancesInRegion(auth, regionId, options = {}) { + try { + const client = createEcsClient(auth, regionId); + const result = await client.request('DescribeInstances', { + RegionId: regionId, + PageSize: options.PageSize || 100, + PageNumber: options.PageNumber || 1 + }); + return result.Instances.Instance || []; + } catch (e) { + // 某些区域可能未开通或无权访问,静默处理 + return []; + } +} + +/** + * 获取所有区域的所有实例 (ECS) + */ +async function listAllInstances(auth, options = {}) { + // 1. 获取所有区域 + const regions = await listRegions(auth); + + // 优先加载常用的中国大陆及香港区域,分散并发压力 + const priorityRegions = ['cn-hangzhou', 'cn-shanghai', 'cn-beijing', 'cn-shenzhen', 'cn-hongkong']; + const otherRegions = regions.filter(r => !priorityRegions.includes(r.RegionId)).map(r => r.RegionId); + + const allInstances = []; + + // 分批查询,防止瞬间几百个请求导致 SSL 错误或限流 + // 先查优先级区域 + const priorityResults = await Promise.all( + priorityRegions.map(rid => listInstancesInRegion(auth, rid, options)) + ); + priorityResults.forEach(list => allInstances.push(...list)); + + // 再查其他区域 (分块进行,每块 5 个) + const chunkSize = 5; + for (let i = 0; i < otherRegions.length; i += chunkSize) { + const chunk = otherRegions.slice(i, i + chunkSize); + const chunkResults = await Promise.all( + chunk.map(rid => listInstancesInRegion(auth, rid, options)) + ); + chunkResults.forEach(list => allInstances.push(...list)); + } + + return { + instances: allInstances, + total: allInstances.length + }; +} + +/** + * 获取实例列表 + */ +async function listInstances(auth, options = {}) { + if (options.allRegions) { + return await listAllInstances(auth, options); + } + + const client = createEcsClient(auth, auth.region_id || 'cn-hangzhou'); + try { + const result = await client.request('DescribeInstances', { + RegionId: auth.region_id || 'cn-hangzhou', + PageSize: options.pageSize || 100, + PageNumber: options.pageNumber || 1 + }); + return { + instances: result.Instances.Instance || [], + total: result.TotalCount + }; + } catch (e) { + throw new Error(`DescribeInstances Failed: ${e.message}`); + } +} + +/** + * 启动实例 + */ +async function startInstance(auth, regionId, instanceId) { + const client = createEcsClient(auth, regionId || auth.regionId || 'cn-hangzhou'); + try { + const result = await client.request('StartInstance', { + InstanceId: instanceId + }); + return result; + } catch (e) { + throw new Error(`StartInstance Failed: ${e.message}`); + } +} + +/** + * 停止实例 + */ +async function stopInstance(auth, regionId, instanceId, force = false) { + const client = createEcsClient(auth, regionId || auth.regionId || 'cn-hangzhou'); + try { + const result = await client.request('StopInstance', { + InstanceId: instanceId, + ForceStop: force ? 'true' : 'false' + }); + return result; + } catch (e) { + throw new Error(`StopInstance Failed: ${e.message}`); + } +} + +/** + * 重启实例 + */ +async function rebootInstance(auth, regionId, instanceId, force = false) { + const client = createEcsClient(auth, regionId || auth.regionId || 'cn-hangzhou'); + try { + const result = await client.request('RebootInstance', { + InstanceId: instanceId, + ForceStop: force ? 'true' : 'false' + }); + return result; + } catch (e) { + throw new Error(`RebootInstance Failed: ${e.message}`); + } +} + +// ==================== 轻量应用服务器 (SWAS) ==================== + +const SWAS_API_VERSION = '2020-06-01'; + +/** + * 创建 SWAS Client + */ +function createSwasClient(auth, regionId) { + if (!auth.accessKeyId || !auth.accessKeySecret) { + throw new Error('Missing AccessKeyId or AccessKeySecret'); + } + + // 轻量服务器优先使用区域端点 + const endpoint = `swas.${regionId}.aliyuncs.com`; + + return new RPCClient({ + accessKeyId: auth.accessKeyId, + accessKeySecret: auth.accessKeySecret, + endpoint: `https://${endpoint}`, + apiVersion: SWAS_API_VERSION, + opts: { timeout: 15000 } + }); +} + +/** + * 获取支持轻量服务器的区域列表 + */ +async function listSwasRegions(auth) { + // 使用默认管理端点获取区域 + const client = createSwasClient(auth, 'cn-hangzhou'); + try { + const result = await client.request('ListRegions', {}); + return result.Regions || []; + } catch (e) { + // 如果 ListRegions 失败,回退到常见可用区域列表 + return [ + { RegionId: 'cn-hangzhou' }, { RegionId: 'cn-shanghai' }, + { RegionId: 'cn-beijing' }, { RegionId: 'cn-shenzhen' }, + { RegionId: 'cn-hongkong' }, { RegionId: 'ap-southeast-1' }, + { RegionId: 'cn-wuhan-lrb' }, { RegionId: 'cn-chengdu' }, + { RegionId: 'cn-guangzhou' }, { RegionId: 'cn-qingdao' } + ]; + } +} + +/** + * 获取单个区域的轻量服务器列表 + */ +async function listSwasInRegion(auth, regionId, options = {}) { + try { + const client = createSwasClient(auth, regionId); + const params = { + RegionId: regionId, + PageSize: options.pageSize || 100, + PageNumber: options.pageNumber || 1 + }; + const result = await client.request('ListInstances', params); + // SWAS 目前返回结构通常是 { Instances: [ ... ] } + return result.Instances || []; + } catch (e) { + return []; + } +} + +/** + * 获取所有区域的轻量应用服务器列表 + */ +async function listSwasInstances(auth, options = {}) { + // 1. 获取所有支持的区域 + const regions = await listSwasRegions(auth); + + // 2. 分批并行查询(每批 3-5 个,防止 SSL 压力过大) + const allInstances = []; + const batchSize = 5; + + for (let i = 0; i < regions.length; i += batchSize) { + const batch = regions.slice(i, i + batchSize); + const results = await Promise.all( + batch.map(region => listSwasInRegion(auth, region.RegionId, options)) + ); + results.forEach(instances => { + if (Array.isArray(instances)) { + allInstances.push(...instances); + } + }); + } + + return { + instances: allInstances, + total: allInstances.length + }; +} + +/** + * 启动轻量服务器 + */ +async function startSwasInstance(auth, regionId, instanceId) { + const client = createSwasClient(auth, regionId); + try { + return await client.request('StartInstance', { + InstanceId: instanceId + }); + } catch (e) { + throw new Error(`StartSwas Failed: ${e.message}`); + } +} + +/** + * 停止轻量服务器 + */ +async function stopSwasInstance(auth, regionId, instanceId, force = false) { + const client = createSwasClient(auth, regionId); + try { + return await client.request('StopInstance', { + InstanceId: instanceId, + ForceStop: force + }); + } catch (e) { + throw new Error(`StopSwas Failed: ${e.message}`); + } +} + +/** + * 重启轻量服务器 + */ +async function rebootSwasInstance(auth, regionId, instanceId, force = false) { + const client = createSwasClient(auth, regionId); + try { + return await client.request('RebootInstance', { + InstanceId: instanceId, + ForceStop: force + }); + } catch (e) { + throw new Error(`RebootSwas Failed: ${e.message}`); + } +} + +module.exports = { + listDomains, + addDomain, + deleteDomain, + listDomainRecords, + addDomainRecord, + updateDomainRecord, + deleteDomainRecord, + setDomainRecordStatus, + listInstances, + startInstance, + stopInstance, + rebootInstance, + listSwasInstances, + startSwasInstance, + stopSwasInstance, + rebootSwasInstance, + getMetricData, + listFirewallRules, + createFirewallRule, + deleteFirewallRule, + REGION_MAP, + formatFlavor +}; diff --git a/modules/aliyun-api/router.js b/modules/aliyun-api/router.js new file mode 100644 index 0000000..5c66c8f --- /dev/null +++ b/modules/aliyun-api/router.js @@ -0,0 +1,417 @@ +const express = require('express'); +const router = express.Router(); +const aliyunApi = require('./aliyun-api'); +const { createLogger } = require('../../src/utils/logger'); +const db = require('../../src/db/database'); + +const logger = createLogger('AliyunAPI'); + +// 调试日志中间件 +router.use((req, res, next) => { + logger.info(`[Router Request] ${req.method} ${req.path}`); + next(); +}); + +// 中间件:获取并验证阿里云账号 +async function getAccount(req, res, next) { + const accountId = req.params.accountId || req.query.accountId; + logger.info(`[getAccount] accountId: ${accountId}`); + if (!accountId) { + return res.status(400).json({ error: 'Missing accountId' }); + } + + try { + const database = db.getDatabase(); + const account = database.prepare('SELECT * FROM aliyun_accounts WHERE id = ?').get(accountId); + + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + req.aliyunAuth = { + accessKeyId: account.access_key_id, + accessKeySecret: account.access_key_secret, + regionId: account.region_id + }; + next(); + } catch (error) { + logger.error('获取账号失败:', error); + res.status(500).json({ error: 'Database error' }); + } +} + +// ==================== 账号管理 ==================== + +// 获取监控数据 +router.post('/accounts/:accountId/metrics', getAccount, async (req, res) => { + try { + const result = await aliyunApi.getMetricData(req.aliyunAuth, req.body); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 获取防火墙规则 +router.get('/accounts/:accountId/swas/:instanceId/firewall', getAccount, async (req, res) => { + const { regionId } = req.query; + try { + const result = await aliyunApi.listFirewallRules(req.aliyunAuth, regionId, req.params.instanceId); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 添加防火墙规则 +router.post('/accounts/:accountId/swas/:instanceId/firewall', getAccount, async (req, res) => { + const { rule } = req.body; + const { regionId } = req.body; + try { + const result = await aliyunApi.createFirewallRule(req.aliyunAuth, regionId, req.params.instanceId, rule); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除防火墙规则 +router.delete('/accounts/:accountId/swas/:instanceId/firewall/:ruleId', getAccount, async (req, res) => { + const { regionId } = req.query; + try { + const result = await aliyunApi.deleteFirewallRule(req.aliyunAuth, regionId, req.params.instanceId, req.params.ruleId); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 获取所有账号 (返回脱敏的 AccessKey ID) +router.get('/accounts', (req, res) => { + try { + const database = db.getDatabase(); + const accounts = database.prepare('SELECT id, name, access_key_id, region_id, description, is_default, created_at FROM aliyun_accounts ORDER BY created_at DESC').all(); + + // 脱敏处理并确保字段名兼容 + const normalizedAccounts = accounts.map(acc => { + const maskedId = acc.access_key_id ? + acc.access_key_id.slice(0, 8) + '****' + acc.access_key_id.slice(-4) : '-'; + + return { + ...acc, + // 同时提供下划线和驼峰命名,确保模板和逻辑都能访问到 + accessKeyId: maskedId, + access_key_id: maskedId, + regionId: acc.region_id, + region_id: acc.region_id + }; + }); + + res.json(normalizedAccounts); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 添加账号 +router.post('/accounts', (req, res) => { + const { name, accessKeyId, accessKeySecret, regionId, description } = req.body; + if (!name || !accessKeyId || !accessKeySecret) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + try { + const database = db.getDatabase(); + const result = database.prepare(` + INSERT INTO aliyun_accounts (name, access_key_id, access_key_secret, region_id, description) + VALUES (?, ?, ?, ?, ?) + `).run(name, accessKeyId, accessKeySecret, regionId || 'cn-hangzhou', description || ''); + + res.json({ success: true, id: result.lastInsertRowid }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 更新账号 +router.put('/accounts/:id', (req, res) => { + const { name, accessKeyId, accessKeySecret, regionId, description } = req.body; + + try { + const database = db.getDatabase(); + + // 如果提供了新的 AccessKey,则更新;否则只更新其他字段 + if (accessKeyId && accessKeySecret) { + database.prepare(` + UPDATE aliyun_accounts + SET name = ?, access_key_id = ?, access_key_secret = ?, region_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(name, accessKeyId, accessKeySecret, regionId || 'cn-hangzhou', description || '', req.params.id); + } else { + database.prepare(` + UPDATE aliyun_accounts + SET name = ?, region_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(name, regionId || 'cn-hangzhou', description || '', req.params.id); + } + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除账号 +router.delete('/accounts/:id', (req, res) => { + try { + const database = db.getDatabase(); + database.prepare('DELETE FROM aliyun_accounts WHERE id = ?').run(req.params.id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== DNS 管理 ==================== + +// 获取域名列表 +router.get('/accounts/:accountId/domains', getAccount, async (req, res) => { + try { + const result = await aliyunApi.listDomains(req.aliyunAuth, { + PageSize: req.query.pageSize, + PageNumber: req.query.pageNumber, + KeyWord: req.query.keyword + }); + res.json(result); + } catch (error) { + logger.error('获取域名列表失败:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// 添加域名 +router.post('/accounts/:accountId/domains', getAccount, async (req, res) => { + const { domainName } = req.body; + if (!domainName) return res.status(400).json({ error: 'Missing domainName' }); + + try { + const result = await aliyunApi.addDomain(req.aliyunAuth, domainName); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除域名 +router.delete('/accounts/:accountId/domains/:domainName', getAccount, async (req, res) => { + try { + const result = await aliyunApi.deleteDomain(req.aliyunAuth, req.params.domainName); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 获取解析记录 +router.get('/accounts/:accountId/domains/:domainName/records', getAccount, async (req, res) => { + try { + const result = await aliyunApi.listDomainRecords(req.aliyunAuth, req.params.domainName, { + PageSize: req.query.pageSize, + PageNumber: req.query.pageNumber, + RRKeyWord: req.query.rrKeyword, + TypeKeyWord: req.query.typeKeyword, + ValueKeyWord: req.query.valueKeyword + }); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 添加解析记录 +router.post('/accounts/:accountId/domains/:domainName/records', getAccount, async (req, res) => { + try { + const params = { + DomainName: req.params.domainName, + RR: req.body.rr, + Type: req.body.type, + Value: req.body.value, + TTL: req.body.ttl, + Priority: req.body.priority, + Line: req.body.line + }; + const result = await aliyunApi.addDomainRecord(req.aliyunAuth, params); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 修改解析记录 +router.put('/accounts/:accountId/records/:recordId', getAccount, async (req, res) => { + try { + const params = { + RecordId: req.params.recordId, + RR: req.body.rr, + Type: req.body.type, + Value: req.body.value, + TTL: req.body.ttl, + Priority: req.body.priority, + Line: req.body.line + }; + const result = await aliyunApi.updateDomainRecord(req.aliyunAuth, params); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除解析记录 +router.delete('/accounts/:accountId/records/:recordId', getAccount, async (req, res) => { + try { + const result = await aliyunApi.deleteDomainRecord(req.aliyunAuth, req.params.recordId); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 设置解析记录状态 +router.put('/accounts/:accountId/records/:recordId/status', getAccount, async (req, res) => { + try { + const result = await aliyunApi.setDomainRecordStatus(req.aliyunAuth, req.params.recordId, req.body.status); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== ECS 管理 ==================== + +// 获取实例列表 (自动查询所有区域) +router.get('/accounts/:accountId/instances', getAccount, async (req, res) => { + try { + const result = await aliyunApi.listInstances(req.aliyunAuth, { + PageSize: req.query.pageSize, + PageNumber: req.query.pageNumber, + allRegions: true // 自动查询所有区域 + }); + + // 增强数据 + if (result.instances) { + result.instances = result.instances.map(inst => ({ + ...inst, + RegionName: aliyunApi.REGION_MAP[inst.RegionId] || inst.RegionId, + InstanceTypeFriendly: aliyunApi.formatFlavor(inst.InstanceType) + })); + } + + res.json(result); + } catch (error) { + logger.error('获取ECS实例失败:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// 启动实例 +router.post('/accounts/:accountId/instances/:instanceId/start', getAccount, async (req, res) => { + const { regionId } = req.body; + try { + const result = await aliyunApi.startInstance(req.aliyunAuth, regionId, req.params.instanceId); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 停止实例 +router.post('/accounts/:accountId/instances/:instanceId/stop', getAccount, async (req, res) => { + const { regionId, force } = req.body; + try { + const result = await aliyunApi.stopInstance(req.aliyunAuth, regionId, req.params.instanceId, force); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 重启实例 +router.post('/accounts/:accountId/instances/:instanceId/reboot', getAccount, async (req, res) => { + const { regionId, force } = req.body; + try { + const result = await aliyunApi.rebootInstance(req.aliyunAuth, regionId, req.params.instanceId, force); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== 轻量应用服务器 (SWAS) 管理 ==================== + +// 获取轻量服务器列表 (自动查询所有区域) +router.get('/accounts/:accountId/swas', getAccount, async (req, res) => { + try { + const result = await aliyunApi.listSwasInstances(req.aliyunAuth, { + pageSize: req.query.pageSize, + pageNumber: req.query.pageNumber + }); + + // 增强数据 + if (result.instances) { + result.instances = result.instances.map(inst => ({ + ...inst, + RegionName: aliyunApi.REGION_MAP[inst.RegionId] || inst.RegionId, + InstanceTypeFriendly: aliyunApi.formatFlavor(inst.PlanId) // 轻量用 PlanId 作为规格 + })); + } + + res.json(result); + } catch (error) { + logger.error('获取SWAS实例失败:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// 启动轻量服务器 +router.post('/accounts/:accountId/swas/:instanceId/start', getAccount, async (req, res) => { + const { regionId } = req.body; + if (!regionId) return res.status(400).json({ error: 'Missing regionId' }); + try { + const result = await aliyunApi.startSwasInstance(req.aliyunAuth, regionId, req.params.instanceId); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 停止轻量服务器 +router.post('/accounts/:accountId/swas/:instanceId/stop', getAccount, async (req, res) => { + const { regionId, force } = req.body; + if (!regionId) return res.status(400).json({ error: 'Missing regionId' }); + try { + const result = await aliyunApi.stopSwasInstance(req.aliyunAuth, regionId, req.params.instanceId, force); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 重启轻量服务器 +router.post('/accounts/:accountId/swas/:instanceId/reboot', getAccount, async (req, res) => { + const { regionId, force } = req.body; + if (!regionId) return res.status(400).json({ error: 'Missing regionId' }); + try { + const result = await aliyunApi.rebootSwasInstance(req.aliyunAuth, regionId, req.params.instanceId, force); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 兜底 404 +router.use((req, res) => { + logger.warn(`[Router 404 Fallback] ${req.method} ${req.originalUrl} -> No match in Aliyun Router`); + res.status(404).json({ error: `Path not found in Aliyun router: ${req.path}` }); +}); + +module.exports = router; diff --git a/modules/aliyun-api/schema.sql b/modules/aliyun-api/schema.sql new file mode 100644 index 0000000..8194474 --- /dev/null +++ b/modules/aliyun-api/schema.sql @@ -0,0 +1,30 @@ +/** + * Aliyun 数据库 Schema + * + * 包含: + * 1. aliyun_accounts - 存储 AK/SK (简单加密存储或明文,视项目安全策略) + */ + +-- 阿里云账号表 +CREATE TABLE IF NOT EXISTS aliyun_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + access_key_id TEXT NOT NULL, + access_key_secret TEXT NOT NULL, + region_id TEXT DEFAULT 'cn-hangzhou', -- 默认区域 + description TEXT, + is_default INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 阿里云 DNS 域名列表缓存 (简单缓存,主要靠实时查询) +CREATE TABLE IF NOT EXISTS aliyun_domains ( + instance_id TEXT PRIMARY KEY, -- 域名实例ID + domain_name TEXT NOT NULL, -- 域名 + account_id INTEGER NOT NULL, + version_name TEXT, -- 版本名称 (免费版/个人版等) + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES aliyun_accounts(id) ON DELETE CASCADE +); diff --git a/modules/antigravity-api/router.js b/modules/antigravity-api/router.js index 54c1c17..5e71586 100644 --- a/modules/antigravity-api/router.js +++ b/modules/antigravity-api/router.js @@ -7,7 +7,7 @@ const axios = require('axios'); const crypto = require('crypto'); const fs = require('fs'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('AntiG-Service'); +const logger = createLogger('AntiG'); const { requireAuth } = require('../../src/middleware/auth'); const { getSession, getSessionById } = require('../../src/services/session'); diff --git a/modules/cloudflare-dns/cloudflare-api.js b/modules/cloudflare-api/cloudflare-api.js similarity index 100% rename from modules/cloudflare-dns/cloudflare-api.js rename to modules/cloudflare-api/cloudflare-api.js diff --git a/modules/cloudflare-dns/models.js b/modules/cloudflare-api/models.js similarity index 100% rename from modules/cloudflare-dns/models.js rename to modules/cloudflare-api/models.js diff --git a/modules/cloudflare-dns/router.js b/modules/cloudflare-api/router.js similarity index 99% rename from modules/cloudflare-dns/router.js rename to modules/cloudflare-api/router.js index f6ecefb..ad565cb 100644 --- a/modules/cloudflare-dns/router.js +++ b/modules/cloudflare-api/router.js @@ -8,7 +8,7 @@ const storage = require('./storage'); const cfApi = require('./cloudflare-api'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('CF-DNS'); +const logger = createLogger('Cloudflare'); // ==================== 璐﹀彿绠$悊 ==================== diff --git a/modules/cloudflare-dns/schema.sql b/modules/cloudflare-api/schema.sql similarity index 100% rename from modules/cloudflare-dns/schema.sql rename to modules/cloudflare-api/schema.sql diff --git a/modules/cloudflare-dns/storage.js b/modules/cloudflare-api/storage.js similarity index 100% rename from modules/cloudflare-dns/storage.js rename to modules/cloudflare-api/storage.js diff --git a/modules/cloudflare-dns/router_utf8.js b/modules/cloudflare-dns/router_utf8.js deleted file mode 100644 index 105ef51..0000000 --- a/modules/cloudflare-dns/router_utf8.js +++ /dev/null @@ -1,1595 +0,0 @@ -/** - * Cloudflare DNS 管理 - API 路由 - */ - -const express = require('express'); -const router = express.Router(); -const storage = require('./storage'); -const cfApi = require('./cloudflare-api'); -const { createLogger } = require('../../src/utils/logger'); - -const logger = createLogger('CF-DNS'); - -// ==================== 账号管理 ==================== - -/** - * 获取所有账号(隐藏 API Token�? - */ -router.get('/accounts', (req, res) => { - try { - const accounts = storage.getAccounts(); - // 隐藏敏感信息 - const safeAccounts = accounts.map(a => ({ - id: a.id, - name: a.name, - email: a.email, - createdAt: a.createdAt, - lastUsed: a.lastUsed, - hasToken: !!a.apiToken - })); - res.json(safeAccounts); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 导出所有账号(包含 API Token,用于备份) - */ -router.get('/accounts/export', (req, res) => { - try { - const accounts = storage.getAccounts(); - // 返回完整信息用于导出 - const exportAccounts = accounts.map(a => ({ - name: a.name, - email: a.email, - apiToken: a.apiToken - })); - res.json({ - success: true, - accounts: exportAccounts - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 添加账号 - */ -router.post('/accounts', async (req, res) => { - try { - const { name, apiToken, email, skipVerify } = req.body; - - if (!name || !apiToken) { - return res.status(400).json({ error: '名称�?API Token 必填' }); - } - - // 验证 Token(除非明确跳过验证,用于数据导入�? - if (!skipVerify) { - // 根据是否�?email 选择验证方式 - const auth = email - ? { email, key: apiToken } // Global API Key - : apiToken; // API Token - - const verification = await cfApi.verifyToken(auth); - if (!verification.valid) { - return res.status(400).json({ error: `Token 无效: ${verification.error}` }); - } - } - - const account = storage.addAccount({ name, apiToken, email }); - res.json({ - success: true, - account: { - id: account.id, - name: account.name, - email: account.email, - createdAt: account.createdAt - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 更新账号 - */ -router.put('/accounts/:id', async (req, res) => { - try { - const { id } = req.params; - const { name, apiToken, email } = req.body; - - // 如果更新 Token,先验证 - if (apiToken) { - // 根据是否�?email 选择验证方式 - const auth = email - ? { email, key: apiToken } // Global API Key - : apiToken; // API Token - - const verification = await cfApi.verifyToken(auth); - if (!verification.valid) { - return res.status(400).json({ error: `Token 无效: ${verification.error}` }); - } - } - - const updated = storage.updateAccount(id, { name, apiToken, email }); - if (!updated) { - return res.status(404).json({ error: '账号不存�? }); - } - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除账号 - */ -router.delete('/accounts/:id', (req, res) => { - try { - const { id } = req.params; - const deleted = storage.deleteAccount(id); - if (!deleted) { - return res.status(404).json({ error: '账号不存�? }); - } - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 验证账号 Token - */ -router.post('/accounts/:id/verify', async (req, res) => { - try { - const { id } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - // 根据账号配置选择认证方式 - const auth = account.email - ? { email: account.email, key: account.apiToken } // Global API Key - : account.apiToken; // API Token - - const verification = await cfApi.verifyToken(auth); - res.json(verification); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取账号�?API Token(用于显示) - */ -router.get('/accounts/:id/token', (req, res) => { - try { - const { id } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - res.json({ - success: true, - apiToken: account.apiToken - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -// ==================== Zone 管理 ==================== - -/** - * 获取账号下的所有域�? - */ -router.get('/accounts/:id/zones', async (req, res) => { - try { - const { id } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - - // 根据账号配置选择认证方式 - const auth = account.email - ? { email: account.email, key: account.apiToken } - : account.apiToken; - - const { zones, resultInfo } = await cfApi.listZones(auth); - - res.json({ - zones: zones.map(z => ({ - id: z.id, - name: z.name, - status: z.status, - paused: z.paused, - type: z.type, - nameServers: z.name_servers, - createdOn: z.created_on, - modifiedOn: z.modified_on - })), - pagination: resultInfo - }); - } catch (e) { - logger.error(`获取域名列表失败 (ID: ${req.params.id}):`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 创建域名 (添加�?Zone) - */ -router.post('/accounts/:id/zones', async (req, res) => { - try { - const { id } = req.params; - const { name, jumpStart } = req.body; - - if (!name) { - return res.status(400).json({ error: '域名不能为空' }); - } - - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - - // 获取 Cloudflare Account ID - const cfAccountId = await cfApi.getAccountId(account.apiToken); - - // 创建新域�? - const zone = await cfApi.createZone(account.apiToken, name, { - account: { id: cfAccountId }, - jump_start: jumpStart !== undefined ? jumpStart : false - }); - - logger.info(`域名创建成功: ${name} (Zone ID: ${zone.id})`); - - res.json({ - success: true, - zone: { - id: zone.id, - name: zone.name, - status: zone.status, - nameServers: zone.name_servers, - createdOn: zone.created_on - } - }); - } catch (e) { - logger.error(`创建域名失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除域名 (删除 Zone) - */ -router.delete('/accounts/:accountId/zones/:zoneId', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const result = await cfApi.deleteZone(account.apiToken, zoneId); - - logger.info(`域名删除成功: Zone ID ${zoneId}`); - - res.json({ - success: true, - result - }); - } catch (e) { - logger.error(`删除域名失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -// ==================== DNS 记录管理 ==================== - -/** - * 获取域名�?DNS 记录 - */ -router.get('/accounts/:accountId/zones/:zoneId/records', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { type, name, page } = req.query; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - - // 根据账号配置选择认证方式 - const auth = account.email - ? { email: account.email, key: account.apiToken } - : account.apiToken; - - const { records, resultInfo } = await cfApi.listDnsRecords( - auth, - zoneId, - { type, name, page } - ); - - res.json({ - records: records.map(r => ({ - id: r.id, - type: r.type, - name: r.name, - content: r.content, - proxied: r.proxied, - ttl: r.ttl, - priority: r.priority, - createdOn: r.created_on, - modifiedOn: r.modified_on - })), - pagination: resultInfo - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 创建 DNS 记录 - */ -router.post('/accounts/:accountId/zones/:zoneId/records', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { type, name, content, ttl, proxied, priority } = req.body; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - // 验证记录 - const validation = cfApi.validateDnsRecord({ type, name, content, priority }); - if (!validation.valid) { - return res.status(400).json({ error: validation.errors.join(', ') }); - } - - storage.touchAccount(accountId); - const record = await cfApi.createDnsRecord( - account.apiToken, - zoneId, - { type, name, content, ttl, proxied, priority } - ); - - res.json({ - success: true, - record: { - id: record.id, - type: record.type, - name: record.name, - content: record.content, - proxied: record.proxied, - ttl: record.ttl - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 更新 DNS 记录 - */ -router.put('/accounts/:accountId/zones/:zoneId/records/:recordId', async (req, res) => { - try { - const { accountId, zoneId, recordId } = req.params; - const { type, name, content, ttl, proxied, priority } = req.body; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const record = await cfApi.updateDnsRecord( - account.apiToken, - zoneId, - recordId, - { type, name, content, ttl, proxied, priority } - ); - - res.json({ - success: true, - record: { - id: record.id, - type: record.type, - name: record.name, - content: record.content, - proxied: record.proxied, - ttl: record.ttl - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 DNS 记录 - */ -router.delete('/accounts/:accountId/zones/:zoneId/records/:recordId', async (req, res) => { - try { - const { accountId, zoneId, recordId } = req.params; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - await cfApi.deleteDnsRecord(account.apiToken, zoneId, recordId); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 快速切�?DNS 记录内容 - */ -router.post('/accounts/:accountId/zones/:zoneId/switch', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { type, name, newContent } = req.body; - - if (!type || !name || !newContent) { - return res.status(400).json({ error: 'type, name, newContent 必填' }); - } - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const updated = await cfApi.switchDnsContent( - account.apiToken, - zoneId, - type, - name, - newContent - ); - - res.json({ - success: true, - updated: updated.length, - records: updated.map(r => ({ - id: r.id, - name: r.name, - content: r.content - })) - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 批量创建 DNS 记录 - */ -router.post('/accounts/:accountId/zones/:zoneId/batch', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { records } = req.body; - - if (!records || !Array.isArray(records)) { - return res.status(400).json({ error: '需要提�?records 数组' }); - } - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const { results, errors } = await cfApi.batchCreateDnsRecords( - account.apiToken, - zoneId, - records - ); - - res.json({ - success: errors.length === 0, - created: results.length, - failed: errors.length, - results, - errors - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -// ==================== 缓存管理 ==================== - -/** - * 清除域名的所有缓�? - */ -router.post('/accounts/:accountId/zones/:zoneId/purge', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { purge_everything } = req.body; - - logger.info(`收到清除缓存请求 - Account: ${accountId}, Zone: ${zoneId}, Body:`, req.body); - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - - // 根据账号配置选择认证方式 - const auth = account.email - ? { email: account.email, key: account.apiToken } // Global API Key - : account.apiToken; // API Token - - logger.info(`使用认证方式: ${account.email ? 'Global API Key' : 'API Token'}`); - - // 调用 Cloudflare API 清除缓存 - logger.info(`调用 Cloudflare API 清除缓存...`); - const result = await cfApi.purgeCache(auth, zoneId, { purge_everything }); - - logger.info(`缓存已清除成�?(Zone: ${zoneId})`); - - res.json({ - success: true, - message: '缓存已清�?, - result - }); - } catch (e) { - logger.error(`清除缓存失败:`, e.message, e.stack); - res.status(500).json({ error: e.message, details: e.stack }); - } -}); - -// ==================== SSL/TLS 管理 ==================== - -/** - * 获取域名的SSL/TLS信息 - */ -router.get('/accounts/:accountId/zones/:zoneId/ssl', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - - // 认证方式选择 - const auth = account.email - ? { email: account.email, key: account.apiToken } - : account.apiToken; - - // 并行获取多个SSL相关信息 - const [settings, certificates, verification] = await Promise.all([ - cfApi.getSslSettings(auth, zoneId), - cfApi.getSslCertificates(auth, zoneId), - cfApi.getSslVerification(auth, zoneId) - ]); - - logger.info(`获取SSL信息成功 (Zone: ${zoneId})`); - - res.json({ - success: true, - ssl: { - mode: settings.value, - modifiedOn: settings.modified_on, - editable: settings.editable, - certificates: certificates.map(cert => ({ - id: cert.id, - type: cert.type, - hosts: cert.hosts, - status: cert.status, - validityDays: cert.validity_days, - certificateAuthority: cert.certificate_authority, - primary: cert.primary - })), - verification: verification - } - }); - } catch (e) { - logger.error(`获取SSL信息失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 修改域名的SSL模式 - */ -router.patch('/accounts/:accountId/zones/:zoneId/ssl', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { mode } = req.body; - - if (!mode || !['off', 'flexible', 'full', 'strict'].includes(mode)) { - return res.status(400).json({ error: '无效的SSL模式' }); - } - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - - const auth = account.email - ? { email: account.email, key: account.apiToken } - : account.apiToken; - - const result = await cfApi.updateSslMode(auth, zoneId, mode); - - logger.info(`SSL模式已更�?(Zone: ${zoneId}, Mode: ${mode})`); - - res.json({ - success: true, - ssl: { - mode: result.value, - modifiedOn: result.modified_on - } - }); - } catch (e) { - logger.error(`更新SSL模式失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -// ==================== Analytics 分析 ==================== - -/** - * 获取域名的Analytics数据 - */ -router.get('/accounts/:accountId/zones/:zoneId/analytics', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { timeRange = '24h' } = req.query; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - - const auth = account.email - ? { email: account.email, key: account.apiToken } - : account.apiToken; - - const analytics = await cfApi.getSimpleAnalytics(auth, zoneId, timeRange); - - logger.info(`获取Analytics成功 (Zone: ${zoneId}, Range: ${timeRange})`); - - res.json({ - success: true, - analytics, - timeRange - }); - } catch (e) { - logger.error(`获取Analytics失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -// ==================== DNS 模板管理 ==================== - -/** - * 获取所有模�? - */ -router.get('/templates', (req, res) => { - try { - const templates = storage.getTemplates(); - res.json(templates); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 添加模板 - */ -router.post('/templates', (req, res) => { - try { - const { name, type, content, proxied, ttl, priority, description } = req.body; - - if (!name || !type || !content) { - return res.status(400).json({ error: '名称、类型、内容必�? }); - } - - const template = storage.addTemplate({ - name, type, content, proxied, ttl, priority, description - }); - - res.json({ success: true, template }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 更新模板 - */ -router.put('/templates/:id', (req, res) => { - try { - const { id } = req.params; - const updated = storage.updateTemplate(id, req.body); - - if (!updated) { - return res.status(404).json({ error: '模板不存�? }); - } - - res.json({ success: true, template: updated }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除模板 - */ -router.delete('/templates/:id', (req, res) => { - try { - const { id } = req.params; - const deleted = storage.deleteTemplate(id); - - if (!deleted) { - return res.status(404).json({ error: '模板不存�? }); - } - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 应用模板到域�? - */ -router.post('/templates/:templateId/apply', async (req, res) => { - try { - const { templateId } = req.params; - const { accountId, zoneId, recordName } = req.body; - - if (!accountId || !zoneId || !recordName) { - return res.status(400).json({ error: 'accountId, zoneId, recordName 必填' }); - } - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const templates = storage.getTemplates(); - const template = templates.find(t => t.id === templateId); - if (!template) { - return res.status(404).json({ error: '模板不存�? }); - } - - storage.touchAccount(accountId); - const record = await cfApi.createDnsRecord( - account.apiToken, - zoneId, - { - type: template.type, - name: recordName, - content: template.content, - ttl: template.ttl, - proxied: template.proxied, - priority: template.priority - } - ); - - res.json({ - success: true, - record: { - id: record.id, - type: record.type, - name: record.name, - content: record.content - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -// ==================== 实用功能 ==================== - -/** - * 获取支持的记录类�? - */ -router.get('/record-types', (req, res) => { - res.json(cfApi.getSupportedRecordTypes()); -}); - -/** - * 导出账号(包含完整数据,用于备份�? - */ -router.get('/export/accounts', (req, res) => { - try { - const accounts = storage.getAccounts(); - res.json(accounts); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 批量导入账号(直接覆盖数据库�? - */ -router.post('/import/accounts', (req, res) => { - try { - const { accounts, overwrite } = req.body; - - if (!accounts || !Array.isArray(accounts)) { - return res.status(400).json({ error: '需要提�?accounts 数组' }); - } - - if (overwrite) { - // 直接覆盖所有账�? - storage.saveAccounts(accounts); - } else { - // 追加账号 - accounts.forEach(account => { - storage.addAccount(account); - }); - } - - res.json({ success: true, count: accounts.length }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 批量导入模板(直接覆盖数据库�? - */ -router.post('/import/templates', (req, res) => { - try { - const { templates, overwrite } = req.body; - - if (!templates || !Array.isArray(templates)) { - return res.status(400).json({ error: '需要提�?templates 数组' }); - } - - if (overwrite) { - // 直接覆盖所有模�? - storage.saveTemplates(templates); - } else { - // 追加模板 - templates.forEach(template => { - storage.addTemplate(template); - }); - } - - res.json({ success: true, count: templates.length }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -// ==================== Workers 管理 ==================== - -/** - * 获取账号�?Cloudflare Account ID - */ -router.get('/accounts/:id/cf-account-id', async (req, res) => { - try { - const { id } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - res.json({ success: true, cfAccountId }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取账号下的所�?Workers - */ -router.get('/accounts/:id/workers', async (req, res) => { - try { - const { id } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - - // 先获�?CF Account ID - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const workers = await cfApi.listWorkers(account.apiToken, cfAccountId); - logger.info(`获取�?${workers.length} �?Workers (Account: ${cfAccountId})`); - - // 获取子域名信�? - const subdomain = await cfApi.getWorkersSubdomain(account.apiToken, cfAccountId); - - res.json({ - workers: workers.map(w => ({ - id: w.id, - name: w.id, // Worker �?id 就是名称 - createdOn: w.created_on, - modifiedOn: w.modified_on, - etag: w.etag - })), - subdomain: subdomain?.subdomain || null, - cfAccountId - }); - } catch (e) { - logger.error(`获取 Workers 列表失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取 Worker 脚本内容 - */ -router.get('/accounts/:id/workers/:scriptName', async (req, res) => { - try { - const { id, scriptName } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const worker = await cfApi.getWorkerScript(account.apiToken, cfAccountId, scriptName); - - res.json({ - success: true, - worker: { - name: worker.name, - script: worker.script, - meta: worker.meta - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 创建或更�?Worker 脚本 - */ -router.put('/accounts/:id/workers/:scriptName', async (req, res) => { - try { - const { id, scriptName } = req.params; - const { script, bindings, compatibility_date } = req.body; - - logger.info(`保存 Worker: ${scriptName}, 脚本长度: ${script?.length || 0}`); - - if (!script) { - return res.status(400).json({ error: '脚本内容不能为空' }); - } - - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - const cfAccountId = await cfApi.getAccountId(account.apiToken); - logger.info(`CF Account ID: ${cfAccountId}`); - - const result = await cfApi.putWorkerScript( - account.apiToken, - cfAccountId, - scriptName, - script, - { bindings, compatibility_date } - ); - - res.json({ - success: true, - worker: result - }); - } catch (e) { - logger.error(`保存 Worker 失败:`, e.message, e.stack); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 Worker 脚本 - */ -router.delete('/accounts/:id/workers/:scriptName', async (req, res) => { - try { - const { id, scriptName } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - const cfAccountId = await cfApi.getAccountId(account.apiToken); - await cfApi.deleteWorkerScript(account.apiToken, cfAccountId, scriptName); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 启用/禁用 Worker (子域访问) - */ -router.post('/accounts/:id/workers/:scriptName/toggle', async (req, res) => { - try { - const { id, scriptName } = req.params; - const { enabled } = req.body; - - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const result = await cfApi.setWorkerEnabled(account.apiToken, cfAccountId, scriptName, enabled); - - res.json({ success: true, result }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取域名�?Worker 路由 - */ -router.get('/accounts/:accountId/zones/:zoneId/workers/routes', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const routes = await cfApi.listWorkerRoutes(account.apiToken, zoneId); - - res.json({ - routes: routes.map(r => ({ - id: r.id, - pattern: r.pattern, - script: r.script - })) - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 创建 Worker 路由 - */ -router.post('/accounts/:accountId/zones/:zoneId/workers/routes', async (req, res) => { - try { - const { accountId, zoneId } = req.params; - const { pattern, script } = req.body; - - if (!pattern || !script) { - return res.status(400).json({ error: 'pattern �?script 必填' }); - } - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const route = await cfApi.createWorkerRoute(account.apiToken, zoneId, pattern, script); - - res.json({ - success: true, - route: { - id: route.id, - pattern: route.pattern, - script: route.script - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 更新 Worker 路由 - */ -router.put('/accounts/:accountId/zones/:zoneId/workers/routes/:routeId', async (req, res) => { - try { - const { accountId, zoneId, routeId } = req.params; - const { pattern, script } = req.body; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - const route = await cfApi.updateWorkerRoute(account.apiToken, zoneId, routeId, pattern, script); - - res.json({ - success: true, - route: { - id: route.id, - pattern: route.pattern, - script: route.script - } - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 Worker 路由 - */ -router.delete('/accounts/:accountId/zones/:zoneId/workers/routes/:routeId', async (req, res) => { - try { - const { accountId, zoneId, routeId } = req.params; - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(accountId); - await cfApi.deleteWorkerRoute(account.apiToken, zoneId, routeId); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取 Worker 统计信息 - */ -router.get('/accounts/:id/workers/:scriptName/analytics', async (req, res) => { - try { - const { id, scriptName } = req.params; - const { since } = req.query; - - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const analytics = await cfApi.getWorkerAnalytics(account.apiToken, cfAccountId, scriptName, since); - - res.json({ success: true, analytics }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -// ==================== Workers 自定义域名管�?==================== - -/** - * 获取 Worker 的自定义域名列表 - */ -router.get('/accounts/:id/workers/:scriptName/domains', async (req, res) => { - try { - const { id, scriptName } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const domains = await cfApi.listWorkerDomains(account.apiToken, cfAccountId, scriptName); - - res.json({ - success: true, - domains: domains.map(d => ({ - id: d.id, - hostname: d.hostname, - service: d.service, - environment: d.environment, - zoneId: d.zone_id, - zoneName: d.zone_name - })) - }); - } catch (e) { - logger.error(`获取 Worker 域名失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 添加 Worker 自定义域�? - */ -router.post('/accounts/:id/workers/:scriptName/domains', async (req, res) => { - try { - const { id, scriptName } = req.params; - const { hostname, environment } = req.body; - - if (!hostname) { - return res.status(400).json({ error: '请输入域�? }); - } - - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const result = await cfApi.addWorkerDomain(account.apiToken, cfAccountId, scriptName, hostname, environment || 'production'); - - res.json({ success: true, domain: result }); - } catch (e) { - logger.error(`添加 Worker 域名失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 Worker 自定义域�? - */ -router.delete('/accounts/:id/workers/:scriptName/domains/:domainId', async (req, res) => { - try { - const { id, scriptName, domainId } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - const cfAccountId = await cfApi.getAccountId(account.apiToken); - await cfApi.deleteWorkerDomain(account.apiToken, cfAccountId, domainId); - - res.json({ success: true }); - } catch (e) { - logger.error(`删除 Worker 域名失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -// ==================== Pages 管理路由 ==================== - - -/** - * 获取 Pages 项目列表 - */ -router.get('/accounts/:id/pages', async (req, res) => { - try { - const { id } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - storage.touchAccount(id); - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const projects = await cfApi.listPagesProjects(account.apiToken, cfAccountId); - - logger.info(`获取�?${projects.length} �?Pages 项目 (Account: ${cfAccountId})`); - - res.json({ - projects: projects.map(p => ({ - name: p.name, - subdomain: p.subdomain, - domains: p.domains || [], - createdOn: p.created_on, - productionBranch: p.production_branch, - latestDeployment: p.latest_deployment ? { - id: p.latest_deployment.id, - url: p.latest_deployment.url, - status: p.latest_deployment.latest_stage?.status || 'unknown', - createdOn: p.latest_deployment.created_on - } : null - })), - cfAccountId - }); - } catch (e) { - logger.error(`获取 Pages 项目失败:`, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取 Pages 项目的部署列�? - */ -router.get('/accounts/:id/pages/:projectName/deployments', async (req, res) => { - try { - const { id, projectName } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const deployments = await cfApi.listPagesDeployments(account.apiToken, cfAccountId, projectName); - - res.json({ - success: true, - deployments: (deployments || []).map(d => { - // 防御性处理:防止 d 为空或字段缺�? - if (!d) return null; - return { - id: d.id, - url: d.url, - environment: d.environment, - status: (d.latest_stage && d.latest_stage.status) ? d.latest_stage.status : 'unknown', - createdOn: d.created_on, - source: d.source, - buildConfig: d.build_config - }; - }).filter(d => d !== null) // 过滤掉无效项 - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 Pages 部署 - */ -router.delete('/accounts/:id/pages/:projectName/deployments/:deploymentId', async (req, res) => { - try { - const { id, projectName, deploymentId } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - await cfApi.deletePagesDeployment(account.apiToken, cfAccountId, projectName, deploymentId); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 获取 Pages 项目的自定义域名 - */ -router.get('/accounts/:id/pages/:projectName/domains', async (req, res) => { - try { - const { id, projectName } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const domains = await cfApi.listPagesDomains(account.apiToken, cfAccountId, projectName); - - res.json({ - success: true, - domains: domains.map(d => ({ - id: d.id, - name: d.name, - status: d.status, - validationStatus: d.validation_data?.status || null, - createdOn: d.created_on - })) - }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 添加 Pages 自定义域�? - */ -router.post('/accounts/:id/pages/:projectName/domains', async (req, res) => { - try { - const { id, projectName } = req.params; - const { domain } = req.body; - - if (!domain) { - return res.status(400).json({ error: '请输入域�? }); - } - - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - const result = await cfApi.addPagesDomain(account.apiToken, cfAccountId, projectName, domain); - - res.json({ success: true, domain: result.result }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 Pages 自定义域�? - */ -router.delete('/accounts/:id/pages/:projectName/domains/:domain', async (req, res) => { - try { - const { id, projectName, domain } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - await cfApi.deletePagesDomain(account.apiToken, cfAccountId, projectName, domain); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/** - * 删除 Pages 项目 - */ -router.delete('/accounts/:id/pages/:projectName', async (req, res) => { - try { - const { id, projectName } = req.params; - const account = storage.getAccountById(id); - if (!account) { - return res.status(404).json({ error: '账号不存�? }); - } - - const cfAccountId = await cfApi.getAccountId(account.apiToken); - await cfApi.deletePagesProject(account.apiToken, cfAccountId, projectName); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -// ==================== R2 �洢���� ==================== - -/** - * ��ȡ R2 �洢Ͱ�б� - */ -router.get('/accounts/:accountId/r2/buckets', async (req, res) => { - try { - const { accountId } = req.params; - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '�˺Ų�����' }); - } - - const auth = account.email ? { email: account.email, key: account.apiToken } : account.apiToken; - const cfAccountId = await cfApi.getAccountId(account.apiToken); - - const buckets = await cfApi.listR2Buckets(auth, cfAccountId); - res.json({ success: true, buckets }); - } catch (e) { - logger.error(��ȡ R2 �洢Ͱʧ��:, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * ���� R2 �洢Ͱ - */ -router.post('/accounts/:accountId/r2/buckets', async (req, res) => { - try { - const { accountId } = req.params; - const { name, location } = req.body; - - if (!name) { - return res.status(400).json({ error: 'Ͱ���Ʊ���' }); - } - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '�˺Ų�����' }); - } - - const auth = account.email ? { email: account.email, key: account.apiToken } : account.apiToken; - const cfAccountId = await cfApi.getAccountId(account.apiToken); - - const bucket = await cfApi.createR2Bucket(auth, cfAccountId, name, location); - res.json({ success: true, bucket }); - } catch (e) { - logger.error(���� R2 �洢Ͱʧ��:, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * ɾ�� R2 �洢Ͱ - */ -router.delete('/accounts/:accountId/r2/buckets/:bucketName', async (req, res) => { - try { - const { accountId, bucketName } = req.params; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '�˺Ų�����' }); - } - - const auth = account.email ? { email: account.email, key: account.apiToken } : account.apiToken; - const cfAccountId = await cfApi.getAccountId(account.apiToken); - - await cfApi.deleteR2Bucket(auth, cfAccountId, bucketName); - res.json({ success: true }); - } catch (e) { - logger.error(ɾ�� R2 �洢Ͱʧ��:, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * �г� R2 ���� - */ -router.get('/accounts/:accountId/r2/buckets/:bucketName/objects', async (req, res) => { - try { - const { accountId, bucketName } = req.params; - const { prefix, cursor, limit, delimiter } = req.query; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '�˺Ų�����' }); - } - - const auth = account.email ? { email: account.email, key: account.apiToken } : account.apiToken; - const cfAccountId = await cfApi.getAccountId(account.apiToken); - - const result = await cfApi.listR2Objects(auth, cfAccountId, bucketName, { - prefix, cursor, limit, delimiter - }); - - res.json({ success: true, ...result }); - } catch (e) { - logger.error(�г� R2 ����ʧ��:, e.message); - res.status(500).json({ error: e.message }); - } -}); - -/** - * ɾ�� R2 ���� - */ -router.delete('/accounts/:accountId/r2/buckets/:bucketName/objects/:objectKey', async (req, res) => { - try { - const { accountId, bucketName, objectKey } = req.params; - - const account = storage.getAccountById(accountId); - if (!account) { - return res.status(404).json({ error: '�˺Ų�����' }); - } - - const auth = account.email ? { email: account.email, key: account.apiToken } : account.apiToken; - const cfAccountId = await cfApi.getAccountId(account.apiToken); - - await cfApi.deleteR2Object(auth, cfAccountId, bucketName, objectKey); - res.json({ success: true }); - } catch (e) { - logger.error(ɾ�� R2 ����ʧ��:, e.message); - res.status(500).json({ error: e.message }); - } -}); - - -module.exports = router; diff --git a/modules/cron-api/service.js b/modules/cron-api/service.js index be84fd6..11001ca 100644 --- a/modules/cron-api/service.js +++ b/modules/cron-api/service.js @@ -3,7 +3,7 @@ const { exec } = require('child_process'); const { CronTask, CronLog } = require('./models'); const axios = require('axios'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('CronService'); +const logger = createLogger('Cron'); class CronService { constructor() { diff --git a/modules/fly-api/models.js b/modules/flyio-api/models.js similarity index 100% rename from modules/fly-api/models.js rename to modules/flyio-api/models.js diff --git a/modules/fly-api/router.js b/modules/flyio-api/router.js similarity index 99% rename from modules/fly-api/router.js rename to modules/flyio-api/router.js index fa8decc..4c16eaf 100644 --- a/modules/fly-api/router.js +++ b/modules/flyio-api/router.js @@ -7,7 +7,7 @@ const router = express.Router(); const storage = require('./storage'); const axios = require('axios'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('Fly.io'); +const logger = createLogger('Fly'); logger.info('Module Loaded'); diff --git a/modules/fly-api/schema.sql b/modules/flyio-api/schema.sql similarity index 100% rename from modules/fly-api/schema.sql rename to modules/flyio-api/schema.sql diff --git a/modules/fly-api/storage.js b/modules/flyio-api/storage.js similarity index 100% rename from modules/fly-api/storage.js rename to modules/flyio-api/storage.js diff --git a/modules/gemini-cli-api/gemini-client.js b/modules/gemini-cli-api/gemini-client.js index 302a9b9..d716c0b 100644 --- a/modules/gemini-cli-api/gemini-client.js +++ b/modules/gemini-cli-api/gemini-client.js @@ -1,7 +1,7 @@ const axios = require('axios'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('Gemini-Client'); +const logger = createLogger('GCLI-Client'); const AntigravityRequester = require('../antigravity-api/antigravity-requester'); const path = require('path'); let storage; diff --git a/modules/gemini-cli-api/router.js b/modules/gemini-cli-api/router.js index 56052a4..53c5023 100644 --- a/modules/gemini-cli-api/router.js +++ b/modules/gemini-cli-api/router.js @@ -6,7 +6,7 @@ const client = require('./gemini-client'); const StreamProcessor = require('./utils/stream-processor'); const { requireAuth } = require('../../src/middleware/auth'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('GCLI-Service'); +const logger = createLogger('GCLI'); const streamProcessor = new StreamProcessor(client); diff --git a/modules/gemini-cli-api/utils/stream-processor.js b/modules/gemini-cli-api/utils/stream-processor.js index 20134dc..e66a6e0 100644 --- a/modules/gemini-cli-api/utils/stream-processor.js +++ b/modules/gemini-cli-api/utils/stream-processor.js @@ -1,6 +1,6 @@ const { Readable } = require('stream'); const { createLogger } = require('../../../src/utils/logger'); -const logger = createLogger('Gemini-Stream'); +const logger = createLogger('GCLI-Stream'); class StreamProcessor { constructor(client) { diff --git a/modules/notification-api/channels/email.js b/modules/notification-api/channels/email.js new file mode 100644 index 0000000..32fbd6e --- /dev/null +++ b/modules/notification-api/channels/email.js @@ -0,0 +1,180 @@ +/** + * Email 通知渠道 + */ + +const nodemailer = require('nodemailer'); +const { createLogger } = require('../../../src/utils/logger'); +const { decrypt } = require('../../../src/utils/encryption'); + +const logger = createLogger('NotificationChannel:Email'); + +class EmailChannel { + constructor() { + this.transporters = new Map(); // host -> transporter + } + + /** + * 发送邮件 + * @param {Object} config - 邮件配置 (已解密) + * @param {string} title - 邮件标题 + * @param {string} message - 邮件内容 + * @param {Object} options - 额外选项 + * @returns {Promise} + */ + async send(config, title, message, options = {}) { + try { + const transporter = this.getTransporter(config); + + const mailOptions = { + from: config.auth.user, + to: config.to || config.auth.user, + subject: title, + text: message, + html: this.formatHTML(message), + ...options, + }; + + const info = await transporter.sendMail(mailOptions); + logger.info(`Email 发送成功: ${info.messageId}`); + return true; + } catch (error) { + logger.error(`Email 发送失败: ${error.message}`); + throw error; + } + } + + /** + * 获取或创建 Transporter + */ + getTransporter(config) { + const key = `${config.host}:${config.port}:${config.auth.user}`; + + if (this.transporters.has(key)) { + return this.transporters.get(key); + } + + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, // true for 465, false for other ports + auth: { + user: config.auth.user, + pass: config.auth.pass, + }, + }); + + this.transporters.set(key, transporter); + return transporter; + } + + /** + * 格式化 HTML + */ + formatHTML(message) { + return ` + + + + + + + +
+

🔔 系统通知

+
+
+ ${this.formatMessage(message)} +
+ 发送时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })} +
+
+ + + + `; + } + + /** + * 格式化消息内容 + */ + formatMessage(message) { + // 如果是 JSON,格式化显示 + try { + const data = JSON.parse(message); + return `
${JSON.stringify(data, null, 2)}
`; + } catch (e) { + // 普通文本,转换为段落 + return message.split('\n').map(line => `

${line}

`).join(''); + } + } + + /** + * 测试连接 + */ + async test(config) { + try { + const transporter = this.getTransporter(config); + await transporter.verify(); + logger.info('Email 连接测试成功'); + return true; + } catch (error) { + logger.error(`Email 连接测试失败: ${error.message}`); + throw error; + } + } + + /** + * 关闭所有 Transporter + */ + close() { + for (const [key, transporter] of this.transporters) { + transporter.close(); + } + this.transporters.clear(); + } +} + +module.exports = new EmailChannel(); diff --git a/modules/notification-api/channels/telegram.js b/modules/notification-api/channels/telegram.js new file mode 100644 index 0000000..91190d0 --- /dev/null +++ b/modules/notification-api/channels/telegram.js @@ -0,0 +1,177 @@ +/** + * Telegram 通知渠道 + */ + +const axios = require('axios'); +const { createLogger } = require('../../../src/utils/logger'); + +const logger = createLogger('NotificationChannel:Telegram'); + +class TelegramChannel { + constructor() { + this.apiBase = 'https://api.telegram.org/bot'; + } + + /** + * 发送消息 + * @param {Object} config - Telegram 配置 (已解密) + * @param {string} title - 消息标题 + * @param {string} message - 消息内容 + * @param {Object} options - 额外选项 + * @returns {Promise} + */ + async send(config, title, message, options = {}) { + try { + const url = `${this.apiBase}${config.bot_token}/sendMessage`; + + const text = this.formatMessage(title, message); + + const response = await axios.post(url, { + chat_id: config.chat_id, + text: text, + parse_mode: 'HTML', + disable_web_page_preview: true, + ...options, + }, { + timeout: 10000, // 10秒超时 + }); + + if (response.data.ok) { + logger.info(`Telegram 发送成功: chat_id=${config.chat_id}`); + return true; + } else { + throw new Error(response.data.description || 'Unknown error'); + } + } catch (error) { + if (error.response) { + logger.error(`Telegram 发送失败: ${error.response.status} - ${JSON.stringify(error.response.data)}`); + } else { + logger.error(`Telegram 发送失败: ${error.message}`); + } + throw error; + } + } + + /** + * 格式化消息 + */ + formatMessage(title, message) { + // 根据严重程度添加不同的图标 + const severityIcons = { + critical: '🚨', + warning: '⚠️', + info: 'ℹ️', + }; + + // 提取 severity (从 title 中) + let icon = '🔔'; + if (title.includes('[CRITICAL]')) icon = severityIcons.critical; + else if (title.includes('[WARNING]')) icon = severityIcons.warning; + else if (title.includes('[INFO]')) icon = severityIcons.info; + + let text = `${icon} ${this.escapeHTML(title)}\n\n`; + + // 格式化消息内容 + text += this.formatContent(message); + + return text; + } + + /** + * 格式化内容 + */ + formatContent(message) { + // 如果是 JSON,格式化显示 + try { + const data = JSON.parse(message); + const jsonStr = JSON.stringify(data, null, 2); + return `
${this.escapeHTML(jsonStr)}
`; + } catch (e) { + // 普通文本,转义并保留换行 + return this.escapeHTML(message).replace(/\n/g, '\n'); + } + } + + /** + * HTML 转义 + */ + escapeHTML(str) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return str.replace(/[&<>"']/g, m => map[m]); + } + + /** + * 测试连接 + */ + async test(config) { + try { + const url = `${this.apiBase}${config.bot_token}/getMe`; + + const response = await axios.get(url, { + timeout: 10000, + }); + + if (response.data.ok) { + const bot = response.data.result; + logger.info(`Telegram 连接测试成功: ${bot.first_name} (@${bot.username})`); + return true; + } else { + throw new Error(response.data.description || 'Unknown error'); + } + } catch (error) { + if (error.response) { + logger.error(`Telegram 连接测试失败: ${error.response.status} - ${JSON.stringify(error.response.data)}`); + } else { + logger.error(`Telegram 连接测试失败: ${error.message}`); + } + throw error; + } + } + + /** + * 获取 Bot 信息 + */ + async getBotInfo(botToken) { + try { + const url = `${this.apiBase}${botToken}/getMe`; + const response = await axios.get(url, { timeout: 10000 }); + + if (response.data.ok) { + return response.data.result; + } + throw new Error(response.data.description || 'Unknown error'); + } catch (error) { + logger.error(`获取 Bot 信息失败: ${error.message}`); + throw error; + } + } + + /** + * 获取更新 (用于获取 chat_id) + */ + async getUpdates(botToken, offset = 0, limit = 10) { + try { + const url = `${this.apiBase}${botToken}/getUpdates`; + const response = await axios.get(url, { + params: { offset, limit }, + timeout: 10000, + }); + + if (response.data.ok) { + return response.data.result; + } + throw new Error(response.data.description || 'Unknown error'); + } catch (error) { + logger.error(`获取更新失败: ${error.message}`); + throw error; + } + } +} + +module.exports = new TelegramChannel(); diff --git a/modules/notification-api/models.js b/modules/notification-api/models.js new file mode 100644 index 0000000..54208ea --- /dev/null +++ b/modules/notification-api/models.js @@ -0,0 +1,417 @@ +/** + * 通知系统数据模型 + */ + +const BaseModel = require('../../src/db/models/BaseModel'); +const crypto = require('crypto'); + +/** + * 生成唯一ID + */ +function generateId() { + return `notif_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`; +} + +/** + * 通知渠道模型 + */ +class NotificationChannelModel extends BaseModel { + constructor() { + super('notification_channels'); + } + + /** + * 创建渠道 + */ + createChannel(channelData) { + const data = { + id: channelData.id || generateId(), + name: channelData.name, + type: channelData.type, + enabled: channelData.enabled !== undefined ? channelData.enabled : 1, + config: channelData.config, + }; + this.insert(data); + return data; + } + + /** + * 获取启用的渠道 + */ + getEnabledChannels() { + return this.findWhere({ enabled: 1 }); + } + + /** + * 根据类型获取渠道 + */ + getChannelsByType(type) { + return this.findWhere({ type, enabled: 1 }); + } + + /** + * 更新渠道 + */ + updateChannel(id, channelData) { + const data = {}; + + if (channelData.name !== undefined) data.name = channelData.name; + if (channelData.enabled !== undefined) data.enabled = channelData.enabled; + if (channelData.config !== undefined) data.config = channelData.config; + + return this.update(id, data); + } +} + +/** + * 告警规则模型 + */ +class AlertRuleModel extends BaseModel { + constructor() { + super('alert_rules'); + } + + /** + * 创建规则 + */ + createRule(ruleData) { + const data = { + id: ruleData.id || generateId(), + name: ruleData.name, + source_module: ruleData.source_module, + event_type: ruleData.event_type, + severity: ruleData.severity || 'warning', + enabled: ruleData.enabled !== undefined ? ruleData.enabled : 1, + channels: JSON.stringify(ruleData.channels || []), + conditions: JSON.stringify(ruleData.conditions || {}), + suppression: JSON.stringify(ruleData.suppression || {}), + time_window: JSON.stringify(ruleData.time_window || { enabled: false }), + description: ruleData.description || '', + }; + this.insert(data); + return data; + } + + /** + * 根据来源模块获取启用的规则 + */ + getEnabledRulesBySource(sourceModule) { + const rules = this.findWhere({ source_module: sourceModule, enabled: 1 }); + return rules.map(rule => this.parseRuleFields(rule)); + } + + /** + * 根据来源和事件类型获取规则 + */ + getRulesBySourceAndEvent(sourceModule, eventType) { + const db = this.getDb(); + const stmt = db.prepare(` + SELECT * FROM ${this.tableName} + WHERE source_module = ? AND event_type = ? AND enabled = 1 + `); + const rules = stmt.all(sourceModule, eventType); + return rules.map(rule => this.parseRuleFields(rule)); + } + + /** + * 解析规则字段 + */ + parseRuleFields(rule) { + return { + ...rule, + channels: JSON.parse(rule.channels || '[]'), + conditions: JSON.parse(rule.conditions || '{}'), + suppression: JSON.parse(rule.suppression || '{}'), + time_window: JSON.parse(rule.time_window || '{"enabled":false}'), + }; + } + + /** + * 更新规则 + */ + updateRule(id, ruleData) { + const data = {}; + + if (ruleData.name !== undefined) data.name = ruleData.name; + if (ruleData.severity !== undefined) data.severity = ruleData.severity; + if (ruleData.enabled !== undefined) data.enabled = ruleData.enabled; + if (ruleData.channels !== undefined) data.channels = JSON.stringify(ruleData.channels); + if (ruleData.conditions !== undefined) data.conditions = JSON.stringify(ruleData.conditions); + if (ruleData.suppression !== undefined) data.suppression = JSON.stringify(ruleData.suppression); + if (ruleData.time_window !== undefined) data.time_window = JSON.stringify(ruleData.time_window); + if (ruleData.description !== undefined) data.description = ruleData.description; + + return this.update(id, data); + } +} + +/** + * 通知历史模型 + */ +class NotificationHistoryModel extends BaseModel { + constructor() { + super('notification_history'); + } + + /** + * 创建历史记录 + */ + createLog(logData) { + const data = { + rule_id: logData.rule_id, + channel_id: logData.channel_id, + status: logData.status || 'pending', + title: logData.title, + message: logData.message, + data: JSON.stringify(logData.data || {}), + error_message: logData.error_message || null, + sent_at: logData.sent_at || null, + retry_count: logData.retry_count || 0, + }; + const result = this.insert(data); + return { ...data, id: result.lastInsertRowid }; + } + + /** + * 获取最近的历史记录 + */ + getRecentHistory(limit = 100) { + const db = this.getDb(); + const stmt = db.prepare(` + SELECT * FROM ${this.tableName} + ORDER BY created_at DESC + LIMIT ? + `); + return stmt.all(limit); + } + + /** + * 获取失败待重试的记录 + */ + getFailedLogs(maxRetry = 3) { + const db = this.getDb(); + const stmt = db.prepare(` + SELECT * FROM ${this.tableName} + WHERE status IN ('failed', 'retrying') + AND (retry_count IS NULL OR retry_count < ?) + ORDER BY created_at ASC + `); + return stmt.all(maxRetry); + } + + /** + * 根据状态获取历史记录 + */ + getHistoryByStatus(status, limit = 100) { + const db = this.getDb(); + const stmt = db.prepare(` + SELECT * FROM ${this.tableName} + WHERE status = ? + ORDER BY created_at DESC + LIMIT ? + `); + return stmt.all(status, limit); + } + + /** + * 更新历史记录状态 + */ + updateStatus(id, status, sentAt = null, errorMessage = null) { + const data = { + status, + sent_at: sentAt, + error_message: errorMessage, + }; + + if (status === 'retrying') { + const log = this.findById(id); + data.retry_count = (log.retry_count || 0) + 1; + } + + return this.update(id, data); + } + + /** + * 清空旧历史记录 + */ + cleanOldHistory(retentionDays = 30) { + const db = this.getDb(); + const stmt = db.prepare(` + DELETE FROM ${this.tableName} + WHERE created_at < datetime('now', '-' || ? || ' days') + `); + return stmt.run(retentionDays); + } +} + +/** + * 告警状态追踪模型 + */ +class AlertStateTrackingModel extends BaseModel { + constructor() { + super('alert_state_tracking'); + } + + /** + * 更新或插入状态 + */ + upsertState(ruleId, fingerprint, updates = {}) { + const db = this.getDb(); + + // 查找现有记录 + const existing = db.prepare(` + SELECT * FROM ${this.tableName} + WHERE rule_id = ? AND fingerprint = ? + `).get(ruleId, fingerprint); + + if (existing) { + // 更新现有记录 + const data = { + last_triggered_at: Date.now(), + consecutive_failures: (existing.consecutive_failures || 0) + 1, + ...updates, + }; + this.update(existing.id, data); + return { ...existing, ...data }; + } else { + // 插入新记录 + const data = { + rule_id: ruleId, + fingerprint: fingerprint, + last_triggered_at: Date.now(), + consecutive_failures: 1, + ...updates, + }; + const result = this.insert(data); + return { ...data, id: result.lastInsertRowid }; + } + } + + /** + * 重置状态(恢复时调用) + */ + resetState(ruleId, fingerprint) { + const db = this.getDb(); + const existing = db.prepare(` + SELECT * FROM ${this.tableName} + WHERE rule_id = ? AND fingerprint = ? + `).get(ruleId, fingerprint); + + if (existing) { + this.update(existing.id, { + consecutive_failures: 0, + last_notified_at: null, + }); + return existing; + } + return null; + } + + /** + * 更新最后通知时间 + */ + updateLastNotified(ruleId, fingerprint) { + const db = this.getDb(); + const existing = db.prepare(` + SELECT * FROM ${this.tableName} + WHERE rule_id = ? AND fingerprint = ? + `).get(ruleId, fingerprint); + + if (existing) { + this.update(existing.id, { + last_notified_at: Date.now(), + }); + return existing; + } + return null; + } + + /** + * 获取状态 + */ + getState(ruleId, fingerprint) { + return this.findWhere({ rule_id: ruleId, fingerprint })[0] || null; + } + + /** + * 清理旧状态记录 + */ + cleanOldStates(beforeTimestamp) { + const db = this.getDb(); + const stmt = db.prepare(` + DELETE FROM ${this.tableName} + WHERE last_triggered_at < ? + `); + return stmt.run(beforeTimestamp); + } +} + +/** + * 全局配置模型 + */ +class NotificationGlobalConfigModel extends BaseModel { + constructor() { + super('notification_global_config'); + } + + /** + * 获取配置(单例) + */ + getConfig() { + const config = this.findById(1); + if (!config) { + // 返回默认配置 + return { + id: 1, + max_retry_times: 3, + retry_interval_seconds: 60, + history_retention_days: 30, + enable_batch: 1, + batch_interval_seconds: 30, + default_channels: '[]', + }; + } + return config; + } + + /** + * 更新配置 + */ + updateConfig(configData) { + const data = {}; + + if (configData.max_retry_times !== undefined) data.max_retry_times = configData.max_retry_times; + if (configData.retry_interval_seconds !== undefined) data.retry_interval_seconds = configData.retry_interval_seconds; + if (configData.history_retention_days !== undefined) data.history_retention_days = configData.history_retention_days; + if (configData.enable_batch !== undefined) data.enable_batch = configData.enable_batch; + if (configData.batch_interval_seconds !== undefined) data.batch_interval_seconds = configData.batch_interval_seconds; + if (configData.default_channels !== undefined) data.default_channels = JSON.stringify(configData.default_channels); + + return this.update(1, data); + } + + /** + * 获取默认配置 + */ + getDefaultConfig() { + const config = this.getConfig(); + return { + max_retry_times: config.max_retry_times || 3, + retry_interval_seconds: config.retry_interval_seconds || 60, + history_retention_days: config.history_retention_days || 30, + enable_batch: config.enable_batch === 1, + batch_interval_seconds: config.batch_interval_seconds || 30, + default_channels: JSON.parse(config.default_channels || '[]'), + }; + } +} + +// 导出单例实例 +module.exports = { + NotificationChannel: new NotificationChannelModel(), + AlertRule: new AlertRuleModel(), + NotificationHistory: new NotificationHistoryModel(), + AlertStateTracking: new AlertStateTrackingModel(), + NotificationGlobalConfig: new NotificationGlobalConfigModel(), + generateId, +}; diff --git a/modules/notification-api/router.js b/modules/notification-api/router.js new file mode 100644 index 0000000..2802eb4 --- /dev/null +++ b/modules/notification-api/router.js @@ -0,0 +1,439 @@ +/** + * 通知系统 API 路由 + */ + +const express = require('express'); +const router = express.Router(); +const { createLogger } = require('../../src/utils/logger'); +const { encrypt, decrypt } = require('../../src/utils/encryption'); +const storage = require('./storage'); +const notificationService = require('./service'); + +const emailChannel = require('./channels/email'); +const telegramChannel = require('./channels/telegram'); + +const logger = createLogger('NotificationAPI'); + +// ==================== 渠道管理 ==================== + +/** + * 获取所有渠道 + */ +router.get('/channels', (req, res) => { + try { + const channels = storage.channel.getAll(); + // 不返回敏感配置 + const safeChannels = channels.map(ch => { + let config = ch.config; + try { + // 如果是加密字符串,尝试解密 + if (config && config.startsWith('u2f')) { // 简单的加密特征判断 + config = JSON.parse(decrypt(config)); + } else { + config = JSON.parse(config); + } + } catch (e) { + // 如果解析失败,可能是已加密但未匹配特征,或者本身就是存的明文但格式不对 + try { + config = JSON.parse(decrypt(ch.config)); + } catch (e2) { + config = {}; + } + } + return { ...ch, config }; + }); + res.json({ success: true, data: safeChannels }); + } catch (error) { + logger.error(`获取渠道列表失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 获取单个渠道详情 + */ +router.get('/channels/:id', (req, res) => { + try { + const channel = storage.channel.getById(req.params.id); + if (!channel) { + return res.status(404).json({ success: false, error: '渠道不存在' }); + } + let config = channel.config; + try { + // 尝试解密配置 + config = JSON.parse(decrypt(config)); + } catch (e) { + // 如果解密失败,尝试直接解析(可能是未加密的旧数据或明文) + try { + config = JSON.parse(channel.config); + } catch (e2) { + // 如果都失败,则返回空对象 + config = {}; + } + } + res.json({ + success: true, + data: { + ...channel, + config, + }, + }); + } catch (error) { + logger.error(`获取渠道详情失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 创建渠道 + */ +router.post('/channels', (req, res) => { + try { + const { name, type, config, enabled = true } = req.body; + + if (!name || !type || !config) { + return res.status(400).json({ success: false, error: '缺少必要参数' }); + } + + if (!['email', 'telegram'].includes(type)) { + return res.status(400).json({ success: false, error: '不支持的渠道类型' }); + } + + // 加密配置 + const encryptedConfig = encrypt(JSON.stringify(config)); + + const channel = storage.channel.create({ + name, + type, + config: encryptedConfig, + enabled: enabled ? 1 : 0, + }); + + logger.info(`创建渠道成功: ${name} (${type})`); + res.json({ success: true, data: channel }); + } catch (error) { + logger.error(`创建渠道失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 更新渠道 + */ +router.put('/channels/:id', (req, res) => { + try { + const { name, config, enabled } = req.body; + + const updateData = {}; + if (name !== undefined) updateData.name = name; + if (enabled !== undefined) updateData.enabled = enabled ? 1 : 0; + if (config !== undefined) { + updateData.config = encrypt(JSON.stringify(config)); + } + + storage.channel.update(req.params.id, updateData); + + logger.info(`更新渠道成功: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + logger.error(`更新渠道失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 删除渠道 + */ +router.delete('/channels/:id', (req, res) => { + try { + storage.channel.delete(req.params.id); + logger.info(`删除渠道成功: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + logger.error(`删除渠道失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 测试渠道 - 发送实际测试消息 + */ +router.post('/channels/:id/test', async (req, res) => { + try { + const channel = storage.channel.getById(req.params.id); + if (!channel) { + return res.status(404).json({ success: false, error: '渠道不存在' }); + } + + // 解密配置 + const config = JSON.parse(decrypt(channel.config)); + + const testTitle = '🔔 [测试] API Monitor 通知测试'; + const testMessage = `这是一条来自 API Monitor 的测试通知。 + +📋 渠道名称: ${channel.name} +📧 渠道类型: ${channel.type === 'email' ? 'Email 邮箱' : 'Telegram'} +⏰ 发送时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })} + +如果您收到此消息,说明通知渠道配置正确!`; + + let success = false; + if (channel.type === 'email') { + success = await emailChannel.send(config, testTitle, testMessage); + } else if (channel.type === 'telegram') { + success = await telegramChannel.send(config, testTitle, testMessage); + } + + if (success) { + logger.info(`渠道测试成功: ${channel.name} (${channel.type})`); + res.json({ success: true, message: '测试消息已发送,请检查接收' }); + } else { + res.status(500).json({ success: false, error: '测试消息发送失败' }); + } + } catch (error) { + logger.error(`测试渠道失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 规则管理 ==================== + +/** + * 获取所有规则 + */ +router.get('/rules', (req, res) => { + try { + const rules = storage.rule.getAll(); + res.json({ success: true, data: rules }); + } catch (error) { + logger.error(`获取规则列表失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 获取单个规则详情 + */ +router.get('/rules/:id', (req, res) => { + try { + const rule = storage.rule.getById(req.params.id); + if (!rule) { + return res.status(404).json({ success: false, error: '规则不存在' }); + } + res.json({ success: true, data: rule }); + } catch (error) { + logger.error(`获取规则详情失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 创建规则 + */ +router.post('/rules', (req, res) => { + try { + const { + name, + source_module, + event_type, + severity = 'warning', + channels, + conditions = {}, + suppression = {}, + time_window = { enabled: false }, + description = '', + enabled = true, + } = req.body; + + if (!name || !source_module || !event_type || !channels) { + return res.status(400).json({ success: false, error: '缺少必要参数' }); + } + + const rule = storage.rule.create({ + name, + source_module, + event_type, + severity, + channels, + conditions, + suppression, + time_window, + description, + enabled: enabled ? 1 : 0, + }); + + logger.info(`创建规则成功: ${name} (${source_module}/${event_type})`); + res.json({ success: true, data: rule }); + } catch (error) { + logger.error(`创建规则失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 更新规则 + */ +router.put('/rules/:id', (req, res) => { + try { + const { + name, + severity, + channels, + conditions, + suppression, + time_window, + description, + enabled, + } = req.body; + + const updateData = {}; + if (name !== undefined) updateData.name = name; + if (severity !== undefined) updateData.severity = severity; + if (channels !== undefined) updateData.channels = channels; + if (conditions !== undefined) updateData.conditions = conditions; + if (suppression !== undefined) updateData.suppression = suppression; + if (time_window !== undefined) updateData.time_window = time_window; + if (description !== undefined) updateData.description = description; + if (enabled !== undefined) updateData.enabled = enabled ? 1 : 0; + + storage.rule.update(req.params.id, updateData); + + logger.info(`更新规则成功: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + logger.error(`更新规则失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 删除规则 + */ +router.delete('/rules/:id', (req, res) => { + try { + storage.rule.delete(req.params.id); + logger.info(`删除规则成功: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + logger.error(`删除规则失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 启用规则 + */ +router.post('/rules/:id/enable', (req, res) => { + try { + storage.rule.enable(req.params.id); + logger.info(`启用规则成功: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + logger.error(`启用规则失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 禁用规则 + */ +router.post('/rules/:id/disable', (req, res) => { + try { + storage.rule.disable(req.params.id); + logger.info(`禁用规则成功: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + logger.error(`禁用规则失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 历史记录 ==================== + +/** + * 获取通知历史 + */ +router.get('/history', (req, res) => { + try { + const { status, limit = 100 } = req.query; + + let history; + if (status) { + history = storage.history.getByStatus(status, parseInt(limit)); + } else { + history = storage.history.getRecent(parseInt(limit)); + } + + res.json({ success: true, data: history }); + } catch (error) { + logger.error(`获取通知历史失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 清空历史记录 + */ +router.delete('/history', (req, res) => { + try { + storage.history.clear(); + logger.info('清空历史记录成功'); + res.json({ success: true }); + } catch (error) { + logger.error(`清空历史记录失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 全局配置 ==================== + +/** + * 获取全局配置 + */ +router.get('/config', (req, res) => { + try { + const config = storage.globalConfig.getDefault(); + res.json({ success: true, data: config }); + } catch (error) { + logger.error(`获取全局配置失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 更新全局配置 + */ +router.put('/config', (req, res) => { + try { + storage.globalConfig.update(req.body); + logger.info('更新全局配置成功'); + res.json({ success: true }); + } catch (error) { + logger.error(`更新全局配置失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// ==================== 触发器 ==================== + +/** + * 手动触发告警 + */ +router.post('/trigger', async (req, res) => { + try { + const { source_module, event_type, data } = req.body; + + if (!source_module || !event_type) { + return res.status(400).json({ success: false, error: '缺少必要参数' }); + } + + await notificationService.trigger(source_module, event_type, data || {}); + + res.json({ success: true, message: '告警已触发' }); + } catch (error) { + logger.error(`触发告警失败: ${error.message}`); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/modules/notification-api/schema.sql b/modules/notification-api/schema.sql new file mode 100644 index 0000000..518b38b --- /dev/null +++ b/modules/notification-api/schema.sql @@ -0,0 +1,89 @@ +-- ==================== 通知系统模块 ==================== + +-- 19. 通知渠道配置表 +CREATE TABLE IF NOT EXISTS notification_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('email', 'telegram')), + enabled INTEGER DEFAULT 1, + config TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 20. 告警规则表 +CREATE TABLE IF NOT EXISTS alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_module TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT DEFAULT 'warning' CHECK(severity IN ('critical', 'warning', 'info')), + enabled INTEGER DEFAULT 1, + channels TEXT NOT NULL, + conditions TEXT, + suppression TEXT, + time_window TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 21. 通知历史表 +CREATE TABLE IF NOT EXISTS notification_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'failed', 'retrying')), + title TEXT NOT NULL, + message TEXT NOT NULL, + data TEXT, + error_message TEXT, + sent_at DATETIME, + retry_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE +); + +-- 22. 告警状态追踪表 +CREATE TABLE IF NOT EXISTS alert_state_tracking ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id TEXT NOT NULL, + fingerprint TEXT NOT NULL, + last_triggered_at DATETIME NOT NULL, + consecutive_failures INTEGER DEFAULT 1, + last_notified_at DATETIME, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(rule_id, fingerprint), + FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE CASCADE +); + +-- 23. 全局通知配置表 +CREATE TABLE IF NOT EXISTS notification_global_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + max_retry_times INTEGER DEFAULT 3, + retry_interval_seconds INTEGER DEFAULT 60, + history_retention_days INTEGER DEFAULT 30, + enable_batch INTEGER DEFAULT 1, + batch_interval_seconds INTEGER DEFAULT 30, + default_channels TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 索引优化 +CREATE INDEX IF NOT EXISTS idx_notification_channels_type ON notification_channels(type, enabled); +CREATE INDEX IF NOT EXISTS idx_alert_rules_source ON alert_rules(source_module, enabled); +CREATE INDEX IF NOT EXISTS idx_notification_history_rule ON notification_history(rule_id, created_at); +CREATE INDEX IF NOT EXISTS idx_notification_history_status ON notification_history(status, created_at); +CREATE INDEX IF NOT EXISTS idx_alert_state_tracking_rule ON alert_state_tracking(rule_id, fingerprint); +CREATE INDEX IF NOT EXISTS idx_alert_state_tracking_triggered ON alert_state_tracking(last_triggered_at); + +-- 插入默认全局配置 +INSERT OR IGNORE INTO notification_global_config ( + id, max_retry_times, retry_interval_seconds, + history_retention_days, enable_batch, batch_interval_seconds +) VALUES ( + 1, 3, 60, 30, 1, 30 +); diff --git a/modules/notification-api/service.js b/modules/notification-api/service.js new file mode 100644 index 0000000..6251da7 --- /dev/null +++ b/modules/notification-api/service.js @@ -0,0 +1,459 @@ +/** + * 通知服务核心引擎 + */ + +const EventEmitter = require('events'); +const { createLogger } = require('../../src/utils/logger'); +const { encrypt, decrypt } = require('../../src/utils/encryption'); +const storage = require('./storage'); + +const emailChannel = require('./channels/email'); +const telegramChannel = require('./channels/telegram'); + +const logger = createLogger('NotificationService'); + +class NotificationService extends EventEmitter { + constructor() { + super(); + this.initialized = false; + this.queue = []; + this.processing = false; + this.retryTimer = null; + } + + /** + * 初始化服务 + */ + init(server) { + if (this.initialized) { + logger.warn('通知服务已经初始化'); + return; + } + + logger.info('正在初始化通知服务...'); + + // 加载所有启用的渠道 + this.loadChannels(); + + // 启动队列处理器 + this.startQueueProcessor(); + + // 启动失败重试处理器 + this.startRetryProcessor(); + + // 启动定时清理任务 + this.startCleanupTasks(); + + this.initialized = true; + logger.info('✅ 通知服务已初始化'); + } + + /** + * 触发告警 (供其他模块调用) + * @param {string} sourceModule - 来源模块 (uptime/server/zeabur/openai) + * @param {string} eventType - 事件类型 (down/up/offline/cpu_high/balance_low) + * @param {object} data - 事件数据 + */ + async trigger(sourceModule, eventType, data) { + try { + logger.debug(`触发告警: ${sourceModule}/${eventType}`); + + // 自动处理恢复:如果是恢复事件,重置对应的故障状态追踪 + // 这样下次故障时 repeat_count 可以重新计数 + if (eventType === 'up' || eventType === 'online') { + const oppositeType = eventType === 'up' ? 'down' : 'offline'; + const downRules = storage.rule.getBySourceAndEvent(sourceModule, oppositeType); + if (downRules.length > 0) { + logger.debug(`检测到恢复事件,正在重置 ${downRules.length} 条故障规则的状态记录`); + for (const rule of downRules) { + const fingerprint = this.generateFingerprint(rule, data); + storage.stateTracking.reset(rule.id, fingerprint); + } + } + } + + // 查找匹配当前事件的规则 + const rules = storage.rule.getBySourceAndEvent(sourceModule, eventType); + + if (rules.length === 0) { + logger.debug(`无匹配规则: ${sourceModule}/${eventType}`); + return; + } + + logger.info(`找到 ${rules.length} 条匹配规则`); + + // 对每条规则执行策略引擎 + for (const rule of rules) { + await this.processRule(rule, data); + } + } catch (error) { + logger.error(`触发告警失败: ${error.message}`); + } + } + + /** + * 处理单条规则 + */ + async processRule(rule, eventData) { + const { suppression, time_window, channels: channelIds } = rule; + + // 生成指纹 (唯一标识同一问题) + const fingerprint = this.generateFingerprint(rule, eventData); + + // 1. 检查时间窗口 + if (time_window.enabled && !this.checkTimeWindow(time_window)) { + logger.debug(`不在时间窗口内,跳过: ${rule.name}`); + return; + } + + // 2. 更新状态追踪 + const state = storage.stateTracking.upsert(rule.id, fingerprint, { + metadata: JSON.stringify(eventData), + }); + + // 3. 检查重复抑制 + const repeatCount = suppression.repeat_count || 1; + if (state.consecutive_failures < repeatCount) { + logger.debug(`未达到重复阈值 (${state.consecutive_failures}/${repeatCount}): ${rule.name}`); + return; + } + + // 4. 检查静默期 + if (state.last_notified_at) { + const silenceMs = (suppression.silence_minutes || 0) * 60 * 1000; + if (Date.now() - state.last_notified_at < silenceMs) { + logger.debug(`在静默期内,跳过: ${rule.name}`); + return; + } + } + + // 5. 发送通知 + for (const channelId of channelIds) { + const channel = storage.channel.getById(channelId); + if (!channel || !channel.enabled) { + logger.warn(`渠道不存在或已禁用: ${channelId}`); + continue; + } + + const notification = { + rule_id: rule.id, + channel_id: channelId, + title: this.formatTitle(rule, eventData), + message: this.formatMessage(rule, eventData), + data: eventData, + }; + + this.enqueue(notification); + } + + // 6. 更新最后通知时间 + storage.stateTracking.updateLastNotified(rule.id, fingerprint); + } + + /** + * 发送通知 (核心逻辑) + */ + async send(notification) { + const { channel_id, title, message } = notification; + const channel = storage.channel.getById(channel_id); + + if (!channel) { + logger.error(`渠道不存在: ${channel_id}`); + return false; + } + + try { + // 解密配置 + const config = JSON.parse(decrypt(channel.config)); + + let success = false; + + if (channel.type === 'email') { + success = await emailChannel.send(config, title, message); + } else if (channel.type === 'telegram') { + success = await telegramChannel.send(config, title, message); + } else { + logger.error(`未知渠道类型: ${channel.type}`); + return false; + } + + // 更新历史记录 + if (success) { + storage.history.updateStatus( + notification.log_id, + 'sent', + new Date().toISOString() + ); + logger.info(`通知发送成功: ${title}`); + } else { + storage.history.updateStatus( + notification.log_id, + 'failed', + null, + '发送失败' + ); + } + + return success; + } catch (error) { + logger.error(`发送通知失败: ${error.message}`); + + // 更新历史记录为失败 + storage.history.updateStatus( + notification.log_id, + 'failed', + null, + error.message + ); + + return false; + } + } + + /** + * 队列管理 + */ + enqueue(notification) { + // 创建历史记录 + const log = storage.history.create(notification); + notification.log_id = log.id; + + // 加入队列 + this.queue.push(notification); + + logger.debug(`通知已加入队列: ${notification.title} (队列长度: ${this.queue.length})`); + + // 确保队列处理器运行 + if (!this.processing) { + this.startQueueProcessor(); + } + } + + /** + * 启动队列处理器 + */ + async startQueueProcessor() { + if (this.processing) return; + + this.processing = true; + + while (this.queue.length > 0) { + const notification = this.queue.shift(); + await this.send(notification); + } + + this.processing = false; + } + + /** + * 启动失败重试处理器 + */ + startRetryProcessor() { + const config = storage.globalConfig.getDefault(); + const intervalMs = (config.retry_interval_seconds || 60) * 1000; + + this.retryTimer = setInterval(async () => { + try { + const maxRetry = config.max_retry_times || 3; + const failedLogs = storage.history.getFailed(maxRetry); + + if (failedLogs.length === 0) return; + + logger.info(`发现 ${failedLogs.length} 条失败记录,准备重试`); + + for (const log of failedLogs) { + const retryCount = log.retry_count || 0; + if (retryCount >= maxRetry) { + logger.warn(`达到最大重试次数,放弃: ${log.title}`); + continue; + } + + // 重新加入队列 + const notification = { + rule_id: log.rule_id, + channel_id: log.channel_id, + title: log.title, + message: log.message, + data: JSON.parse(log.data || '{}'), + log_id: log.id, + }; + + this.enqueue(notification); + } + + // 启动队列处理 + if (!this.processing) { + this.startQueueProcessor(); + } + } catch (error) { + logger.error(`重试处理器错误: ${error.message}`); + } + }, intervalMs); + + logger.info(`失败重试处理器已启动 (间隔: ${intervalMs}ms)`); + } + + /** + * 启动定时清理任务 + */ + startCleanupTasks() { + // 每天凌晨 3 点清理旧记录 + const schedule = () => { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(3, 0, 0, 0); + + const delay = tomorrow - now; + + setTimeout(() => { + this.cleanup(); + // 递归调用,安排下一次清理 + schedule(); + }, delay); + + logger.info(`下次清理时间: ${tomorrow.toLocaleString('zh-CN')}`); + }; + + schedule(); + } + + /** + * 清理旧记录 + */ + cleanup() { + try { + const config = storage.globalConfig.getDefault(); + const retentionDays = config.history_retention_days || 30; + + const historyResult = storage.history.cleanOld(retentionDays); + logger.info(`清理历史记录: ${historyResult.changes} 条`); + + // 清理 30 天前的状态记录 + const beforeTimestamp = Date.now() - (30 * 24 * 60 * 60 * 1000); + const stateResult = storage.stateTracking.cleanOld(beforeTimestamp); + logger.info(`清理状态记录: ${stateResult.changes} 条`); + } catch (error) { + logger.error(`清理任务失败: ${error.message}`); + } + } + + /** + * 加载渠道 + */ + loadChannels() { + const channels = storage.channel.getEnabled(); + logger.info(`已加载 ${channels.length} 个启用的通知渠道`); + } + + /** + * 生成指纹 + */ + generateFingerprint(rule, eventData) { + // 根据规则和事件数据生成唯一指纹 + const keyParts = [ + rule.source_module, + rule.event_type, + ]; + + // 添加特定资源的ID + if (eventData.monitorId) keyParts.push(`monitor:${eventData.monitorId}`); + else if (eventData.serverId) keyParts.push(`server:${eventData.serverId}`); + else if (eventData.accountId) keyParts.push(`account:${eventData.accountId}`); + else keyParts.push('global'); + + return keyParts.join(':'); + } + + /** + * 检查时间窗口 + */ + checkTimeWindow(timeWindow) { + if (!timeWindow.enabled) return true; + + try { + const now = new Date(); + const [startHour, startMin] = timeWindow.start.split(':').map(Number); + const [endHour, endMin] = timeWindow.end.split(':').map(Number); + + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const startMinutes = startHour * 60 + startMin; + const endMinutes = endHour * 60 + endMin; + + // 如果结束时间小于开始时间,表示跨天 + if (endMinutes < startMinutes) { + return currentMinutes >= startMinutes || currentMinutes <= endMinutes; + } + + return currentMinutes >= startMinutes && currentMinutes <= endMinutes; + } catch (error) { + logger.error(`检查时间窗口失败: ${error.message}`); + return true; // 出错时默认发送 + } + } + + /** + * 格式化标题 + */ + formatTitle(rule, eventData) { + const severityIcon = { + critical: '🚨', + warning: '⚠️', + info: 'ℹ️', + }; + + const icon = severityIcon[rule.severity] || '🔔'; + return `${icon} [${rule.severity.toUpperCase()}] ${rule.name}`; + } + + /** + * 格式化消息 + */ + formatMessage(rule, eventData) { + // 根据事件类型格式化消息 + const lines = []; + + // 添加基本信息 + if (eventData.monitorName) lines.push(`📊 监控项: ${eventData.monitorName}`); + if (eventData.serverName) lines.push(`🖥️ 主机: ${eventData.serverName}`); + if (eventData.accountName) lines.push(`💳 账户: ${eventData.accountName}`); + + lines.push(''); // 空行 + + // 添加详细信息 + if (eventData.url) lines.push(`🔗 URL: ${eventData.url}`); + if (eventData.host) lines.push(`🌐 主机: ${eventData.host}`); + if (eventData.error) lines.push(`❌ 错误: ${eventData.error}`); + if (eventData.ping !== undefined) lines.push(`⏱️ 响应时间: ${eventData.ping}ms`); + if (eventData.cpu_usage !== undefined) lines.push(`📊 CPU 使用率: ${eventData.cpu_usage}%`); + if (eventData.mem_percent !== undefined) lines.push(`💾 内存使用率: ${eventData.mem_percent}%`); + if (eventData.balance !== undefined) lines.push(`💰 余额: $${eventData.balance}`); + if (eventData.threshold !== undefined) lines.push(`🎯 阈值: ${eventData.threshold}`); + + // 如果没有特定信息,显示完整数据 + if (lines.length <= 1) { + return JSON.stringify(eventData, null, 2); + } + + lines.push(''); + lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`); + + return lines.join('\n'); + } + + /** + * 停止服务 + */ + stop() { + if (this.retryTimer) { + clearInterval(this.retryTimer); + this.retryTimer = null; + } + + emailChannel.close(); + logger.info('通知服务已停止'); + } +} + +// 导出单例 +module.exports = new NotificationService(); diff --git a/modules/notification-api/storage.js b/modules/notification-api/storage.js new file mode 100644 index 0000000..be3d2e8 --- /dev/null +++ b/modules/notification-api/storage.js @@ -0,0 +1,298 @@ +/** + * 通知系统数据访问层 + */ + +const { + NotificationChannel, + AlertRule, + NotificationHistory, + AlertStateTracking, + NotificationGlobalConfig, +} = require('./models'); + +/** + * 渠道存储操作 + */ +const channelStorage = { + /** + * 获取所有渠道 + */ + getAll() { + return NotificationChannel.findAll(); + }, + + /** + * 获取启用的渠道 + */ + getEnabled() { + return NotificationChannel.getEnabledChannels(); + }, + + /** + * 根据ID获取渠道 + */ + getById(id) { + return NotificationChannel.findById(id); + }, + + /** + * 根据类型获取渠道 + */ + getByType(type) { + return NotificationChannel.getChannelsByType(type); + }, + + /** + * 创建渠道 + */ + create(channelData) { + return NotificationChannel.createChannel(channelData); + }, + + /** + * 更新渠道 + */ + update(id, channelData) { + return NotificationChannel.updateChannel(id, channelData); + }, + + /** + * 删除渠道 + */ + delete(id) { + return NotificationChannel.delete(id); + }, + + /** + * 启用/禁用渠道 + */ + setEnabled(id, enabled) { + return NotificationChannel.update(id, { enabled: enabled ? 1 : 0 }); + }, +}; + +/** + * 规则存储操作 + */ +const ruleStorage = { + /** + * 获取所有规则 + */ + getAll() { + const rules = AlertRule.findAll(); + return rules.map(rule => AlertRule.parseRuleFields(rule)); + }, + + /** + * 根据来源模块获取启用的规则 + */ + getEnabledBySource(sourceModule) { + return AlertRule.getEnabledRulesBySource(sourceModule); + }, + + /** + * 根据来源和事件类型获取规则 + */ + getBySourceAndEvent(sourceModule, eventType) { + return AlertRule.getRulesBySourceAndEvent(sourceModule, eventType); + }, + + /** + * 根据ID获取规则 + */ + getById(id) { + const rule = AlertRule.findById(id); + return rule ? AlertRule.parseRuleFields(rule) : null; + }, + + /** + * 创建规则 + */ + create(ruleData) { + return AlertRule.createRule(ruleData); + }, + + /** + * 更新规则 + */ + update(id, ruleData) { + return AlertRule.updateRule(id, ruleData); + }, + + /** + * 删除规则 + */ + delete(id) { + return AlertRule.delete(id); + }, + + /** + * 启用规则 + */ + enable(id) { + return AlertRule.update(id, { enabled: 1 }); + }, + + /** + * 禁用规则 + */ + disable(id) { + return AlertRule.update(id, { enabled: 0 }); + }, +}; + +/** + * 历史记录存储操作 + */ +const historyStorage = { + /** + * 获取最近的历史记录 + */ + getRecent(limit = 100) { + return NotificationHistory.getRecentHistory(limit); + }, + + /** + * 根据状态获取历史记录 + */ + getByStatus(status, limit = 100) { + return NotificationHistory.getHistoryByStatus(status, limit); + }, + + /** + * 根据规则ID获取历史记录 + */ + getByRuleId(ruleId, limit = 50) { + const db = NotificationHistory.getDb(); + const stmt = db.prepare(` + SELECT * FROM ${NotificationHistory.tableName} + WHERE rule_id = ? + ORDER BY created_at DESC + LIMIT ? + `); + return stmt.all(ruleId, limit); + }, + + /** + * 根据渠道ID获取历史记录 + */ + getByChannelId(channelId, limit = 50) { + const db = NotificationHistory.getDb(); + const stmt = db.prepare(` + SELECT * FROM ${NotificationHistory.tableName} + WHERE channel_id = ? + ORDER BY created_at DESC + LIMIT ? + `); + return stmt.all(channelId, limit); + }, + + /** + * 创建历史记录 + */ + create(logData) { + return NotificationHistory.createLog(logData); + }, + + /** + * 更新状态 + */ + updateStatus(id, status, sentAt = null, errorMessage = null) { + return NotificationHistory.updateStatus(id, status, sentAt, errorMessage); + }, + + /** + * 获取失败待重试的记录 + */ + getFailed(maxRetry = 3) { + return NotificationHistory.getFailedLogs(maxRetry); + }, + + /** + * 清空历史记录 + */ + clear() { + return NotificationHistory.truncate(); + }, + + /** + * 清理旧历史记录 + */ + cleanOld(retentionDays = 30) { + return NotificationHistory.cleanOldHistory(retentionDays); + }, +}; + +/** + * 状态追踪存储操作 + */ +const stateTrackingStorage = { + /** + * 更新或插入状态 + */ + upsert(ruleId, fingerprint, updates = {}) { + return AlertStateTracking.upsertState(ruleId, fingerprint, updates); + }, + + /** + * 重置状态 + */ + reset(ruleId, fingerprint) { + return AlertStateTracking.resetState(ruleId, fingerprint); + }, + + /** + * 获取状态 + */ + get(ruleId, fingerprint) { + return AlertStateTracking.getState(ruleId, fingerprint); + }, + + /** + * 更新最后通知时间 + */ + updateLastNotified(ruleId, fingerprint) { + return AlertStateTracking.updateLastNotified(ruleId, fingerprint); + }, + + /** + * 清理旧状态记录 + */ + cleanOld(beforeTimestamp) { + return AlertStateTracking.cleanOldStates(beforeTimestamp); + }, +}; + +/** + * 全局配置存储操作 + */ +const globalConfigStorage = { + /** + * 获取配置 + */ + get() { + return NotificationGlobalConfig.getConfig(); + }, + + /** + * 获取默认配置 + */ + getDefault() { + return NotificationGlobalConfig.getDefaultConfig(); + }, + + /** + * 更新配置 + */ + update(configData) { + return NotificationGlobalConfig.updateConfig(configData); + }, +}; + +module.exports = { + channel: channelStorage, + rule: ruleStorage, + history: historyStorage, + stateTracking: stateTrackingStorage, + globalConfig: globalConfigStorage, +}; diff --git a/modules/openai-api/router.js b/modules/openai-api/router.js index 422a2b8..2277072 100644 --- a/modules/openai-api/router.js +++ b/modules/openai-api/router.js @@ -9,7 +9,7 @@ const openaiApi = require('./openai-api'); const { proxyLimiter } = require('../../src/middleware/rateLimit'); const { validate, chatCompletionSchema } = require('../../src/middleware/validation'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('OpenAI-Service'); +const logger = createLogger('OpenAI'); // ==================== 端点管理 ==================== diff --git a/modules/server-management/agent-service.js b/modules/server-api/agent-service.js similarity index 92% rename from modules/server-management/agent-service.js rename to modules/server-api/agent-service.js index 879b911..c28c6b8 100644 --- a/modules/server-management/agent-service.js +++ b/modules/server-api/agent-service.js @@ -415,6 +415,9 @@ class AgentService extends EventEmitter { resolved_id: serverId, // 告知 Agent 实际使用的 ID }); + // 触发上线通知 + this.triggerOnlineAlert(serverId); + // 广播上线状态给前端 this.broadcastServerStatus(serverId, 'online'); @@ -535,6 +538,7 @@ class AgentService extends EventEmitter { this.stopHeartbeat(serverId); this.updateServerStatus(serverId, 'offline'); this.broadcastServerStatus(serverId, 'offline'); + this.triggerOfflineAlert(serverId); // Ensure offline alert is triggered // 更新兼容缓存 const status = this.legacyStatus.get(serverId); @@ -633,6 +637,58 @@ class AgentService extends EventEmitter { this.connections.delete(serverId); this.updateServerStatus(serverId, 'offline'); this.broadcastServerStatus(serverId, 'offline'); + + // 触发离线告警 + this.triggerOfflineAlert(serverId); + } + + /** + * 触发主机离线告警 + */ + triggerOfflineAlert(serverId) { + try { + const server = serverStorage.getById(serverId); + if (!server) return; + + const notificationService = require('../notification-api/service'); + const hostInfo = this.hostInfoCache.get(serverId); + + notificationService.trigger('server', 'offline', { + serverId: serverId, + serverName: server.name, + host: server.host, + lastSeen: hostInfo?.received_at || Date.now(), + hostname: hostInfo?.hostname + }); + + logger.warn(`[主机告警] ${server.name} (${server.host}) 离线`); + } catch (error) { + logger.error(`触发离线告警失败: ${error.message}`); + } + } + + /** + * 触发主机上线通知 + */ + triggerOnlineAlert(serverId) { + try { + const server = serverStorage.getById(serverId); + if (!server) return; + + const notificationService = require('../notification-api/service'); + const hostInfo = this.hostInfoCache.get(serverId); + + notificationService.trigger('server', 'online', { + serverId: serverId, + serverName: server.name, + host: server.host, + hostname: hostInfo?.hostname + }); + + logger.info(`[主机通知] ${server.name} (${server.host}) 已上线`); + } catch (error) { + logger.error(`触发上线通知失败: ${error.message}`); + } } // ==================== 广播方法 ==================== @@ -1011,6 +1067,9 @@ class AgentService extends EventEmitter { version: metrics.agent_version || 'http-legacy', }); + // 检查资源告警 + this.checkResourceAlerts(serverId, processedMetrics); + // 广播给前端 this.broadcastMetrics(serverId, processedMetrics); @@ -1019,6 +1078,74 @@ class AgentService extends EventEmitter { return processedMetrics; } + /** + * 检查资源告警 + */ + checkResourceAlerts(serverId, metrics) { + try { + const server = serverStorage.getById(serverId); + if (!server) return; + + const notificationService = require('../notification-api/service'); + + // CPU 告警阈值 (80%) + if (metrics.cpu > 80) { + notificationService.trigger('server', 'cpu_high', { + serverId: serverId, + serverName: server.name, + host: server.host, + cpu_usage: metrics.cpu, + threshold: 80 + }); + logger.warn(`[资源告警] ${server.name} CPU 使用率: ${metrics.cpu}%`); + } + + // 内存告警阈值 (85%) + if (metrics.mem) { + const memMatch = metrics.mem.match(/(\d+)\/(\d+)/); + if (memMatch) { + const memUsed = parseInt(memMatch[1]); + const memTotal = parseInt(memMatch[2]); + const memPercent = (memUsed / memTotal) * 100; + + if (memPercent > 85) { + notificationService.trigger('server', 'memory_high', { + serverId: serverId, + serverName: server.name, + host: server.host, + mem_percent: memPercent.toFixed(2), + mem_used: memUsed, + mem_total: memTotal, + threshold: 85 + }); + logger.warn(`[资源告警] ${server.name} 内存使用率: ${memPercent.toFixed(2)}%`); + } + } + } + + // 磁盘告警阈值 (90%) + if (metrics.disk) { + const diskMatch = metrics.disk.match(/([.\d]+)%/); + if (diskMatch) { + const diskPercent = parseFloat(diskMatch[1]); + if (diskPercent > 90) { + notificationService.trigger('server', 'disk_high', { + serverId: serverId, + serverName: server.name, + host: server.host, + disk_usage: metrics.disk, + disk_percent: diskPercent, + threshold: 90 + }); + logger.warn(`[资源告警] ${server.name} 磁盘使用率: ${diskPercent}%`); + } + } + } + } catch (error) { + logger.error(`检查资源告警失败: ${error.message}`); + } + } + // ==================== 安装脚本生成 ==================== /** diff --git a/modules/server-management/credentials-router.js b/modules/server-api/credentials-router.js similarity index 100% rename from modules/server-management/credentials-router.js rename to modules/server-api/credentials-router.js diff --git a/modules/server-management/models.js b/modules/server-api/models.js similarity index 100% rename from modules/server-management/models.js rename to modules/server-api/models.js diff --git a/modules/server-management/monitor-service.js b/modules/server-api/monitor-service.js similarity index 98% rename from modules/server-management/monitor-service.js rename to modules/server-api/monitor-service.js index 285d08a..b98e1e8 100644 --- a/modules/server-management/monitor-service.js +++ b/modules/server-api/monitor-service.js @@ -7,7 +7,7 @@ const cron = require('node-cron'); const { ServerAccount, ServerMonitorLog, ServerMonitorConfig } = require('./models'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('ServerMonitor'); +const logger = createLogger('Monitor'); class MonitorService { constructor() { @@ -88,6 +88,9 @@ class MonitorService { const agentStatus = agentService.getStatus(server.id); const agentMetrics = agentService.getMetrics(server.id); + // 状态记录逻辑 + const oldStatus = server.status; + try { // 1. 先用 TCP ping 测量网络延迟 const responseTime = await this.tcpPing(server.host, server.port || 22); diff --git a/modules/server-management/protocol.js b/modules/server-api/protocol.js similarity index 100% rename from modules/server-management/protocol.js rename to modules/server-api/protocol.js diff --git a/modules/server-management/router.js b/modules/server-api/router.js similarity index 78% rename from modules/server-management/router.js rename to modules/server-api/router.js index 7ae9c12..406961d 100644 --- a/modules/server-management/router.js +++ b/modules/server-api/router.js @@ -354,6 +354,7 @@ router.post('/ping-all', async (req, res) => { /** * 获取服务器详细信息 (极速缓存优化) + * 优先使用 Agent,如无 Agent 则通过 SSH 获取基础状态 */ router.post('/info', async (req, res) => { try { @@ -363,18 +364,104 @@ router.post('/info', async (req, res) => { const server = serverStorage.getById(serverId); if (!server) return res.status(404).json({ success: false, error: '服务器不存在' }); - // 纯 Agent 模式:直接返回内存中的最新指标作为服务器详情 + // 尝试获取 Agent 指标 const metrics = agentService.getMetrics(serverId); - if (!metrics) { - return res - .status(404) - .json({ success: false, error: 'Agent 指标尚未就绪,请确保 Agent 已启动并在线' }); + if (metrics) { + return res.json({ + success: true, + ...metrics, + is_agent: true, + }); } + // 无 Agent 数据,尝试通过 SSH 获取基础状态 + const sshService = require('./ssh-service'); + + // 增强版命令,包含 1秒网速采样 + const infoCommand = ` + IFACE=$(ip route get 8.8.8.8 2>/dev/null | grep dev | awk '{print $5}' || echo "eth0") + read r1 t1 < <(cat /proc/net/dev | grep "$IFACE" | awk '{print $2, $10}' || echo "0 0") + sleep 1 + read r2 t2 < <(cat /proc/net/dev | grep "$IFACE" | awk '{print $2, $10}' || echo "0 0") + + echo "===SYSTEM===" + uname -s 2>/dev/null || echo "Unknown" + echo "===UPTIME===" + cat /proc/uptime 2>/dev/null | cut -d' ' -f1 || echo "0" + echo "===CPU===" + grep -c ^processor /proc/cpuinfo 2>/dev/null || echo "1" + echo "===LOAD===" + cat /proc/loadavg 2>/dev/null | cut -d' ' -f1-3 || echo "0 0 0" + echo "===CPU_USAGE===" + top -bn1 2>/dev/null | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1 | head -1 || echo "0" + echo "===MEMORY===" + free -b 2>/dev/null | grep Mem | awk '{printf "%.0f %.0f", $3, $2}' || echo "0 0" + echo "===DISK===" + df -B1 / 2>/dev/null | tail -1 | awk '{printf "%.0f %.0f %s", $3, $2, $5}' || echo "0 0 0%" + echo "===NET===" + echo "$(( r2-r1 )) $(( t2-t1 ))" + `.trim(); + + const result = await sshService.executeCommand(serverId, server, infoCommand, 15000); + + if (!result.success) { + return res.status(500).json({ + success: false, + error: 'SSH 连接失败: ' + (result.error || result.stderr || '未知错误'), + is_agent: false, + }); + } + + const output = result.stdout || ''; + const parseSection = (name) => { + const regex = new RegExp(`===\\s*${name}\\s*===\\s*([\\s\\S]*?)(?====|$)`, 'i'); + const match = output.match(regex); + return match ? match[1].trim() : ''; + }; + + const platform = parseSection('SYSTEM') || 'Linux'; + const uptimeSeconds = parseFloat(parseSection('UPTIME')) || 0; + const cores = parseInt(parseSection('CPU')) || 1; + const load = parseSection('LOAD') || '0 0 0'; + const cpuRate = parseFloat(parseSection('CPU_USAGE')) || 0; + + const memStr = parseSection('MEMORY').split(/\s+/); + const mUsed = parseInt(memStr[0]) || 0; + const mTotal = parseInt(memStr[1]) || 1; + + const diskStr = parseSection('DISK').split(/\s+/); + const dUsed = parseInt(diskStr[0]) || 0; + const dTotal = parseInt(diskStr[1]) || 1; + const dPerc = diskStr[2] || '0%'; + + const netStr = parseSection('NET').split(/\s+/); + const rb = parseInt(netStr[0]) || 0; + const tb = parseInt(netStr[1]) || 0; + + const fmt = (b) => { + if (b <= 0) return '0 B'; + const k = 1024; + const ss = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(b) / Math.log(k)); + return parseFloat((b / Math.pow(k, i)).toFixed(1)) + ' ' + ss[i]; + }; + res.json({ success: true, - ...metrics, - is_agent: true, + platform, + uptime: Math.floor(uptimeSeconds), + cores, + load, + cpu_usage: cpuRate.toFixed(1) + '%', + mem: `${fmt(mUsed)} / ${fmt(mTotal)}`, + mem_percent: Math.round((mUsed / mTotal) * 100), + disk: `${fmt(dUsed)} / ${fmt(dTotal)} (${dPerc})`, + network: { + down: fmt(rb) + '/s', + up: fmt(tb) + '/s' + }, + is_agent: false, + source: 'ssh', }); } catch (error) { res.status(500).json({ success: false, error: error.message }); @@ -1391,4 +1478,249 @@ router.post('/task/refresh/:serverId', (req, res) => { } }); +// ==================== SFTP 文件管理 ==================== + +const sftpService = require('./sftp-service'); +// 使用项目已有的 express-fileupload,不需要额外配置 + +/** + * 列出目录内容 + * POST /sftp/list + * { serverId, path } + */ +router.post('/sftp/list', async (req, res) => { + try { + const { serverId, path = '/' } = req.body; + if (!serverId) return res.status(400).json({ success: false, error: '缺少服务器 ID' }); + + const files = await sftpService.listDirectory(serverId, path); + res.json({ success: true, data: files, path }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 获取文件/目录信息 + * POST /sftp/stat + * { serverId, path } + */ +router.post('/sftp/stat', async (req, res) => { + try { + const { serverId, path } = req.body; + if (!serverId || !path) return res.status(400).json({ success: false, error: '缺少参数' }); + + const stats = await sftpService.stat(serverId, path); + res.json({ success: true, data: stats }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 读取文件内容 + * POST /sftp/read + * { serverId, path, maxSize? } + */ +router.post('/sftp/read', async (req, res) => { + try { + const { serverId, path, maxSize } = req.body; + if (!serverId || !path) return res.status(400).json({ success: false, error: '缺少参数' }); + + const content = await sftpService.readFile(serverId, path, maxSize); + res.json({ success: true, data: content }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 写入文件内容 + * POST /sftp/write + * { serverId, path, content } + */ +router.post('/sftp/write', async (req, res) => { + try { + const { serverId, path, content } = req.body; + if (!serverId || !path) return res.status(400).json({ success: false, error: '缺少参数' }); + + await sftpService.writeFile(serverId, path, content || ''); + res.json({ success: true, message: '文件保存成功' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 创建目录 + * POST /sftp/mkdir + * { serverId, path } + */ +router.post('/sftp/mkdir', async (req, res) => { + try { + const { serverId, path } = req.body; + if (!serverId || !path) return res.status(400).json({ success: false, error: '缺少参数' }); + + await sftpService.mkdir(serverId, path); + res.json({ success: true, message: '目录创建成功' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 删除文件 + * POST /sftp/delete + * { serverId, path } + */ +router.post('/sftp/delete', async (req, res) => { + try { + const { serverId, path } = req.body; + if (!serverId || !path) return res.status(400).json({ success: false, error: '缺少参数' }); + + await sftpService.deleteFile(serverId, path); + res.json({ success: true, message: '文件删除成功' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 删除目录 + * POST /sftp/rmdir + * { serverId, path, recursive? } + */ +router.post('/sftp/rmdir', async (req, res) => { + try { + const { serverId, path, recursive } = req.body; + if (!serverId || !path) return res.status(400).json({ success: false, error: '缺少参数' }); + + if (recursive) { + await sftpService.rmdirRecursive(serverId, path); + } else { + await sftpService.rmdir(serverId, path); + } + res.json({ success: true, message: '目录删除成功' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 重命名/移动 + * POST /sftp/rename + * { serverId, oldPath, newPath } + */ +router.post('/sftp/rename', async (req, res) => { + try { + const { serverId, oldPath, newPath } = req.body; + if (!serverId || !oldPath || !newPath) return res.status(400).json({ success: false, error: '缺少参数' }); + + await sftpService.rename(serverId, oldPath, newPath); + res.json({ success: true, message: '重命名成功' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 修改权限 + * POST /sftp/chmod + * { serverId, path, mode } + */ +router.post('/sftp/chmod', async (req, res) => { + try { + const { serverId, path, mode } = req.body; + if (!serverId || !path || mode === undefined) return res.status(400).json({ success: false, error: '缺少参数' }); + + await sftpService.chmod(serverId, path, parseInt(mode, 8)); + res.json({ success: true, message: '权限修改成功' }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 下载文件 + * GET /sftp/download/:serverId?path=xxx + */ +router.get('/sftp/download/:serverId', async (req, res) => { + try { + const { serverId } = req.params; + const { path: remotePath } = req.query; + + if (!serverId || !remotePath) { + return res.status(400).json({ success: false, error: '缺少参数' }); + } + + const { stream, size, filename, conn } = await sftpService.downloadStream(serverId, remotePath); + + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); + res.setHeader('Content-Length', size); + res.setHeader('Content-Type', 'application/octet-stream'); + + stream.pipe(res); + + stream.on('error', err => { + console.error('Download stream error:', err); + conn.end(); + if (!res.headersSent) { + res.status(500).json({ success: false, error: err.message }); + } + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +/** + * 上传文件 + * POST /sftp/upload + * FormData: serverId, path, file + * 使用 express-fileupload(在 server.js 中全局配置) + */ +router.post('/sftp/upload', async (req, res) => { + try { + const { serverId, path: remotePath } = req.body; + + // express-fileupload 将文件放在 req.files 中 + if (!req.files || !req.files.file) { + return res.status(400).json({ success: false, error: '未找到上传的文件' }); + } + + const file = req.files.file; + // relativePath 是文件在原文件夹中的相对路径(用于文件夹上传) + const { relativePath } = req.body; + + if (!serverId || !remotePath) { + return res.status(400).json({ success: false, error: '缺少 serverId 或 path 参数' }); + } + + // 构建完整的远程文件路径 + let fullPath; + if (relativePath) { + // 文件夹上传:使用相对路径 + fullPath = remotePath.endsWith('/') + ? remotePath + relativePath + : remotePath + '/' + relativePath; + + // 确保父目录存在 + const parentDir = require('path').posix.dirname(fullPath); + if (parentDir !== remotePath && parentDir !== '/') { + await sftpService.mkdirRecursive(serverId, parentDir); + } + } else { + // 普通文件上传 + fullPath = remotePath.endsWith('/') + ? remotePath + file.name + : remotePath + '/' + file.name; + } + + await sftpService.uploadFile(serverId, fullPath, file.data); + res.json({ success: true, message: '上传成功', path: fullPath }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + module.exports = router; diff --git a/modules/server-management/schema.sql b/modules/server-api/schema.sql similarity index 100% rename from modules/server-management/schema.sql rename to modules/server-api/schema.sql diff --git a/modules/server-api/sftp-service.js b/modules/server-api/sftp-service.js new file mode 100644 index 0000000..373330a --- /dev/null +++ b/modules/server-api/sftp-service.js @@ -0,0 +1,504 @@ +/** + * SFTP 服务 - 提供远程文件管理功能 + * 包括文件浏览、上传、下载、编辑、删除等操作 + */ + +const { Client } = require('ssh2'); +const { serverStorage } = require('./storage'); +const { createLogger } = require('../../src/utils/logger'); +const path = require('path'); +const { Readable } = require('stream'); + +const logger = createLogger('SFTPService'); + +class SFTPService { + constructor() { + // 连接缓存池(可选,用于会话复用) + this.connectionPool = new Map(); + } + + /** + * 获取 SFTP 连接 + * @param {string} serverId - 服务器 ID + * @returns {Promise<{ sftp: Object, conn: Client }>} + */ + async getConnection(serverId) { + const serverConfig = serverStorage.getById(serverId); + if (!serverConfig) { + throw new Error('服务器配置不存在'); + } + + return new Promise((resolve, reject) => { + const conn = new Client(); + const timeout = setTimeout(() => { + conn.end(); + reject(new Error('SFTP 连接超时')); + }, 20000); + + conn.on('ready', () => { + clearTimeout(timeout); + conn.sftp((err, sftp) => { + if (err) { + conn.end(); + return reject(err); + } + resolve({ sftp, conn }); + }); + }); + + conn.on('error', err => { + clearTimeout(timeout); + reject(err); + }); + + const connSettings = { + host: serverConfig.host, + port: serverConfig.port || 22, + username: serverConfig.username, + readyTimeout: 20000, + }; + + if (serverConfig.auth_type === 'key') { + connSettings.privateKey = serverConfig.private_key; + if (serverConfig.passphrase) connSettings.passphrase = serverConfig.passphrase; + } else { + connSettings.password = serverConfig.password; + } + + conn.connect(connSettings); + }); + } + + /** + * 列出目录内容 + * @param {string} serverId - 服务器 ID + * @param {string} remotePath - 远程路径 + * @returns {Promise} + */ + async listDirectory(serverId, remotePath = '/') { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.readdir(remotePath, (err, list) => { + if (err) return reject(err); + + // 格式化文件列表 + const files = list.map(item => ({ + name: item.filename, + path: path.posix.join(remotePath, item.filename), + isDirectory: item.attrs.isDirectory(), + isFile: item.attrs.isFile(), + isSymlink: item.attrs.isSymbolicLink(), + size: item.attrs.size, + mode: item.attrs.mode, + mtime: item.attrs.mtime * 1000, // 转换为毫秒 + atime: item.attrs.atime * 1000, + uid: item.attrs.uid, + gid: item.attrs.gid, + permissions: this._formatPermissions(item.attrs.mode), + })); + + // 按类型和名称排序:目录优先,然后按名称字母排序 + files.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }); + + resolve(files); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 获取文件状态信息 + * @param {string} serverId + * @param {string} remotePath + */ + async stat(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, stats) => { + if (err) return reject(err); + resolve({ + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + isSymlink: stats.isSymbolicLink(), + size: stats.size, + mode: stats.mode, + mtime: stats.mtime * 1000, + atime: stats.atime * 1000, + uid: stats.uid, + gid: stats.gid, + permissions: this._formatPermissions(stats.mode), + }); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 读取文件内容 + * @param {string} serverId + * @param {string} remotePath + * @param {number} maxSize - 最大读取大小(字节),默认 1MB + */ + async readFile(serverId, remotePath, maxSize = 1024 * 1024) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + // 先检查文件大小 + const stats = await new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, stats) => { + if (err) return reject(err); + resolve(stats); + }); + }); + + if (stats.size > maxSize) { + throw new Error(`文件过大 (${this._formatSize(stats.size)}),最大支持 ${this._formatSize(maxSize)}`); + } + + // 读取文件 + return await new Promise((resolve, reject) => { + const chunks = []; + const stream = sftp.createReadStream(remotePath); + + stream.on('data', chunk => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }); + } finally { + conn.end(); + } + } + + /** + * 写入文件内容 + * @param {string} serverId + * @param {string} remotePath + * @param {string|Buffer} content + */ + async writeFile(serverId, remotePath, content) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + const stream = sftp.createWriteStream(remotePath); + + stream.on('close', () => resolve({ success: true })); + stream.on('error', reject); + + // 写入内容 + if (Buffer.isBuffer(content)) { + stream.end(content); + } else { + stream.end(content, 'utf8'); + } + }); + } finally { + conn.end(); + } + } + + /** + * 创建目录 + * @param {string} serverId + * @param {string} remotePath + */ + async mkdir(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.mkdir(remotePath, err => { + if (err) return reject(err); + resolve({ success: true }); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 递归创建目录(如果父目录不存在则自动创建) + * @param {string} serverId + * @param {string} remotePath + */ + async mkdirRecursive(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + await this._mkdirRecursiveInternal(sftp, remotePath); + return { success: true }; + } finally { + conn.end(); + } + } + + /** + * 内部递归创建目录方法 + */ + async _mkdirRecursiveInternal(sftp, remotePath) { + const parts = remotePath.split('/').filter(Boolean); + let currentPath = ''; + + for (const part of parts) { + currentPath += '/' + part; + + // 检查目录是否存在 + const exists = await new Promise(resolve => { + sftp.stat(currentPath, (err, stats) => { + if (err) return resolve(false); + resolve(stats.isDirectory()); + }); + }); + + if (!exists) { + await new Promise((resolve, reject) => { + sftp.mkdir(currentPath, err => { + if (err && err.code !== 4) return reject(err); // code 4 = 已存在 + resolve(); + }); + }); + } + } + } + + /** + * 删除文件 + * @param {string} serverId + * @param {string} remotePath + */ + async deleteFile(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.unlink(remotePath, err => { + if (err) return reject(err); + resolve({ success: true }); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 删除目录(必须为空) + * @param {string} serverId + * @param {string} remotePath + */ + async rmdir(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.rmdir(remotePath, err => { + if (err) return reject(err); + resolve({ success: true }); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 递归删除目录(包括非空目录) + * @param {string} serverId + * @param {string} remotePath + */ + async rmdirRecursive(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + await this._rmdirRecursiveInternal(sftp, remotePath); + return { success: true }; + } finally { + conn.end(); + } + } + + /** + * 内部递归删除方法(使用已有的 sftp 连接) + */ + async _rmdirRecursiveInternal(sftp, remotePath) { + // 列出目录内容 + const list = await new Promise((resolve, reject) => { + sftp.readdir(remotePath, (err, list) => { + if (err) return reject(err); + resolve(list); + }); + }); + + // 递归删除每个子项 + for (const item of list) { + const itemPath = path.posix.join(remotePath, item.filename); + + if (item.attrs.isDirectory()) { + // 递归删除子目录 + await this._rmdirRecursiveInternal(sftp, itemPath); + } else { + // 删除文件 + await new Promise((resolve, reject) => { + sftp.unlink(itemPath, err => { + if (err) return reject(err); + resolve(); + }); + }); + } + } + + // 最后删除空目录 + await new Promise((resolve, reject) => { + sftp.rmdir(remotePath, err => { + if (err) return reject(err); + resolve(); + }); + }); + } + + /** + * 重命名/移动文件或目录 + * @param {string} serverId + * @param {string} oldPath + * @param {string} newPath + */ + async rename(serverId, oldPath, newPath) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.rename(oldPath, newPath, err => { + if (err) return reject(err); + resolve({ success: true }); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 修改文件权限 + * @param {string} serverId + * @param {string} remotePath + * @param {number} mode - 八进制权限值,如 0o755 + */ + async chmod(serverId, remotePath, mode) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + sftp.chmod(remotePath, mode, err => { + if (err) return reject(err); + resolve({ success: true }); + }); + }); + } finally { + conn.end(); + } + } + + /** + * 获取文件下载流 + * @param {string} serverId + * @param {string} remotePath + * @returns {Promise<{ stream: Readable, size: number, filename: string }>} + */ + async downloadStream(serverId, remotePath) { + const { sftp, conn } = await this.getConnection(serverId); + + // 获取文件信息 + const stats = await new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, stats) => { + if (err) return reject(err); + resolve(stats); + }); + }); + + const stream = sftp.createReadStream(remotePath); + + // 保存连接引用以便后续关闭 + stream.on('close', () => conn.end()); + stream.on('error', () => conn.end()); + + return { + stream, + size: stats.size, + filename: path.posix.basename(remotePath), + conn, // 返回连接以便手动管理 + }; + } + + /** + * 上传文件 + * @param {string} serverId + * @param {string} remotePath + * @param {Buffer|Readable} data + */ + async uploadFile(serverId, remotePath, data) { + const { sftp, conn } = await this.getConnection(serverId); + + try { + return await new Promise((resolve, reject) => { + const writeStream = sftp.createWriteStream(remotePath); + + writeStream.on('close', () => resolve({ success: true })); + writeStream.on('error', reject); + + if (Buffer.isBuffer(data)) { + writeStream.end(data); + } else if (data instanceof Readable) { + data.pipe(writeStream); + } else { + writeStream.end(data); + } + }); + } finally { + conn.end(); + } + } + + // ==================== 工具方法 ==================== + + /** + * 格式化权限位为 rwx 形式 + */ + _formatPermissions(mode) { + const perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']; + const owner = perms[(mode >> 6) & 7]; + const group = perms[(mode >> 3) & 7]; + const other = perms[mode & 7]; + + let type = '-'; + if ((mode & 0o170000) === 0o040000) type = 'd'; // 目录 + if ((mode & 0o170000) === 0o120000) type = 'l'; // 符号链接 + + return type + owner + group + other; + } + + /** + * 格式化文件大小 + */ + _formatSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + +module.exports = new SFTPService(); diff --git a/modules/server-management/ssh-service.js b/modules/server-api/ssh-service.js similarity index 100% rename from modules/server-management/ssh-service.js rename to modules/server-api/ssh-service.js diff --git a/modules/server-management/storage.js b/modules/server-api/storage.js similarity index 100% rename from modules/server-management/storage.js rename to modules/server-api/storage.js diff --git a/modules/tencent-api/router.js b/modules/tencent-api/router.js new file mode 100644 index 0000000..832e8fd --- /dev/null +++ b/modules/tencent-api/router.js @@ -0,0 +1,243 @@ +const express = require('express'); +const router = express.Router(); +const tencentApi = require('./tencent-api'); +const { createLogger } = require('../../src/utils/logger'); +const db = require('../../src/db/database'); + +const logger = createLogger('TencentAPI'); + +// 中间件:获取并验证腾讯云账号 +async function getAccount(req, res, next) { + const accountId = req.params.accountId || req.query.accountId; + if (!accountId) { + return res.status(400).json({ error: 'Missing accountId' }); + } + + try { + const database = db.getDatabase(); + const account = database.prepare('SELECT * FROM tencent_accounts WHERE id = ?').get(accountId); + + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + req.tencentAuth = { + secretId: account.secret_id, + secretKey: account.secret_key, + regionId: account.region_id + }; + next(); + } catch (error) { + logger.error('获取账号失败:', error); + res.status(500).json({ error: 'Database error' }); + } +} + +// ==================== 账号管理 ==================== + +// 获取所有账号 +router.get('/accounts', (req, res) => { + try { + const database = db.getDatabase(); + const accounts = database.prepare('SELECT id, name, secret_id, region_id, description, is_default, created_at FROM tencent_accounts ORDER BY created_at DESC').all(); + + // 脱敏处理 + const normalizedAccounts = accounts.map(acc => ({ + ...acc, + secret_id: acc.secret_id ? acc.secret_id.slice(0, 8) + '****' + acc.secret_id.slice(-4) : '-' + })); + + res.json(normalizedAccounts); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 添加账号 +router.post('/accounts', (req, res) => { + const { name, secretId, secretKey, regionId, description } = req.body; + if (!name || !secretId || !secretKey) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + try { + const database = db.getDatabase(); + const result = database.prepare(` + INSERT INTO tencent_accounts (name, secret_id, secret_key, region_id, description) + VALUES (?, ?, ?, ?, ?) + `).run(name, secretId, secretKey, regionId || 'ap-guangzhou', description || ''); + + res.json({ id: result.lastInsertRowid, success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 更新账号 +router.put('/accounts/:id', (req, res) => { + const { name, secretId, secretKey, regionId, description } = req.body; + try { + const database = db.getDatabase(); + if (secretKey) { + database.prepare(` + UPDATE tencent_accounts + SET name = ?, secret_id = ?, secret_key = ?, region_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(name, secretId, secretKey, regionId, description || '', req.params.id); + } else { + database.prepare(` + UPDATE tencent_accounts + SET name = ?, secret_id = ?, region_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(name, secretId, regionId, description || '', req.params.id); + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除账号 +router.delete('/accounts/:id', (req, res) => { + try { + const database = db.getDatabase(); + database.prepare('DELETE FROM tencent_accounts WHERE id = ?').run(req.params.id); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== DNS 管理 ==================== + +// 获取域名列表 +router.get('/accounts/:accountId/domains', getAccount, async (req, res) => { + try { + logger.info(`Fetching domains for account ${req.params.accountId}`); + const result = await tencentApi.listDomains(req.tencentAuth); + res.json(result); + } catch (error) { + logger.error('listDomains failed:', error.message, error.stack); + res.status(500).json({ error: error.message }); + } +}); + +// 添加域名 +router.post('/accounts/:accountId/domains', getAccount, async (req, res) => { + const { domain } = req.body; + try { + const result = await tencentApi.addDomain(req.tencentAuth, domain); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除域名 +router.delete('/accounts/:accountId/domains/:domain', getAccount, async (req, res) => { + try { + const result = await tencentApi.deleteDomain(req.tencentAuth, req.params.domain); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 获取记录列表 +router.get('/accounts/:accountId/domains/:domain/records', getAccount, async (req, res) => { + try { + const result = await tencentApi.listDomainRecords(req.tencentAuth, req.params.domain); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 添加记录 +router.post('/accounts/:accountId/domains/:domain/records', getAccount, async (req, res) => { + try { + const result = await tencentApi.addDomainRecord(req.tencentAuth, req.params.domain, req.body); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 更新记录 +router.put('/accounts/:accountId/domains/:domain/records/:recordId', getAccount, async (req, res) => { + try { + const result = await tencentApi.updateDomainRecord(req.tencentAuth, req.params.domain, req.params.recordId, req.body); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除记录 +router.delete('/accounts/:accountId/domains/:domain/records/:recordId', getAccount, async (req, res) => { + try { + const result = await tencentApi.deleteDomainRecord(req.tencentAuth, req.params.domain, req.params.recordId); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 修改记录状态 +router.patch('/accounts/:accountId/domains/:domain/records/:recordId/status', getAccount, async (req, res) => { + const { status } = req.body; + try { + const result = await tencentApi.setDomainRecordStatus(req.tencentAuth, req.params.domain, req.params.recordId, status); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== CVM 管理 ==================== + +// 获取所有 CVM +router.get('/accounts/:accountId/cvm', getAccount, async (req, res) => { + try { + const result = await tencentApi.listAllCvmInstances(req.tencentAuth); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 控制 CVM +router.post('/accounts/:accountId/cvm/:instanceId/control', getAccount, async (req, res) => { + const { action, region } = req.body; + try { + const result = await tencentApi.controlCvmInstance(req.tencentAuth, region, req.params.instanceId, action); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ==================== Lighthouse 管理 ==================== + +// 获取所有轻量服务器 +router.get('/accounts/:accountId/lighthouse', getAccount, async (req, res) => { + try { + const result = await tencentApi.listAllLighthouseInstances(req.tencentAuth); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 控制轻量服务器 +router.post('/accounts/:accountId/lighthouse/:instanceId/control', getAccount, async (req, res) => { + const { action, region } = req.body; + try { + const result = await tencentApi.controlLighthouseInstance(req.tencentAuth, region, req.params.instanceId, action); + res.json({ success: true, result }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/modules/tencent-api/schema.sql b/modules/tencent-api/schema.sql new file mode 100644 index 0000000..dc90808 --- /dev/null +++ b/modules/tencent-api/schema.sql @@ -0,0 +1,27 @@ +/** + * Tencent 数据库 Schema + */ + +-- 腾讯云账号表 +CREATE TABLE IF NOT EXISTS tencent_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + secret_id TEXT NOT NULL, + secret_key TEXT NOT NULL, + region_id TEXT DEFAULT 'ap-guangzhou', -- 默认区域 + description TEXT, + is_default INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 腾讯云 DNS 域名列表缓存 +CREATE TABLE IF NOT EXISTS tencent_domains ( + domain_id TEXT PRIMARY KEY, -- 域名ID + domain_name TEXT NOT NULL, -- 域名 + account_id INTEGER NOT NULL, + status TEXT, -- 状态 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES tencent_accounts(id) ON DELETE CASCADE +); diff --git a/modules/tencent-api/tencent-api.js b/modules/tencent-api/tencent-api.js new file mode 100644 index 0000000..c79b50b --- /dev/null +++ b/modules/tencent-api/tencent-api.js @@ -0,0 +1,320 @@ +/** + * Tencent Cloud API 封装 + */ + +const tencentcloud = require('tencentcloud-sdk-nodejs'); +const { createLogger } = require('../../src/utils/logger'); +const logger = createLogger('TencentAPI'); + +// API 版本及客户端定义 +const DnspodClient = tencentcloud.dnspod.v20210323.Client; +const CvmClient = tencentcloud.cvm.v20170312.Client; +const LighthouseClient = tencentcloud.lighthouse.v20200324.Client; +const MonitorClient = tencentcloud.monitor.v20180724.Client; + +const REGION_MAP = { + 'ap-guangzhou': '华南地区 (广州)', + 'ap-shanghai': '华东地区 (上海)', + 'ap-nanjing': '华东地区 (南京)', + 'ap-beijing': '华北地区 (北京)', + 'ap-chengdu': '西南地区 (成都)', + 'ap-chongqing': '西南地区 (重庆)', + 'ap-hongkong': '中国香港', + 'ap-singapore': '新加坡', + 'ap-tokyo': '日本 (东京)', + 'ap-seoul': '韩国 (首尔)', + 'ap-bangkok': '泰国 (曼谷)', + 'ap-mumbai': '印度 (孟买)', + 'na-siliconvalley': '美西 (硅谷)', + 'na-ashburn': '美东 (弗吉尼亚)', + 'eu-frankfurt': '欧洲地区 (法兰克福)', + 'eu-moscow': '欧洲地区 (莫斯科)' +}; + +/** + * 格式化机型配置 + */ +function formatFlavor(instance) { + if (!instance) return '-'; + if (instance.CPU && instance.Memory) { + return `${instance.CPU}核 ${instance.Memory}GB`; + } + return instance.InstanceType || '-'; +} + +/** + * 创建客户端工具函数 + */ +function createClient(ClientClass, auth, region = 'ap-guangzhou') { + const clientConfig = { + credential: { + secretId: auth.secretId, + secretKey: auth.secretKey, + }, + region: region, + profile: { + httpProfile: { + endpoint: "", + }, + }, + }; + return new ClientClass(clientConfig); +} + +// ==================== DNS 相关 (DNSPod) ==================== + +/** + * 获取域名列表 + */ +async function listDomains(auth) { + const client = createClient(DnspodClient, auth); + try { + const result = await client.DescribeDomainList({}); + return { + domains: result.DomainList || [], + total: result.DomainCount || 0 + }; + } catch (e) { + throw new Error(`DescribeDomainList Failed: ${e.message}`); + } +} + +/** + * 添加域名 + */ +async function addDomain(auth, domain) { + const client = createClient(DnspodClient, auth); + try { + const result = await client.CreateDomain({ Domain: domain }); + return result; + } catch (e) { + throw new Error(`CreateDomain Failed: ${e.message}`); + } +} + +/** + * 删除域名 + */ +async function deleteDomain(auth, domain) { + const client = createClient(DnspodClient, auth); + try { + const result = await client.DeleteDomain({ Domain: domain }); + return result; + } catch (e) { + throw new Error(`DeleteDomain Failed: ${e.message}`); + } +} + +/** + * 获取解析记录 + */ +async function listDomainRecords(auth, domain) { + const client = createClient(DnspodClient, auth); + try { + const result = await client.DescribeRecordList({ Domain: domain }); + return { + records: result.RecordList || [], + total: result.RecordCount || 0 + }; + } catch (e) { + throw new Error(`DescribeRecordList Failed: ${e.message}`); + } +} + +/** + * 添加解析记录 + */ +async function addDomainRecord(auth, domain, record) { + const client = createClient(DnspodClient, auth); + try { + return await client.CreateRecord({ + Domain: domain, + SubDomain: record.subDomain, + RecordType: record.recordType, + RecordLine: record.recordLine || '默认', + Value: record.value, + TTL: record.ttl || 600, + MX: record.mx, + Status: "ENABLE" + }); + } catch (e) { + throw new Error(`CreateRecord Failed: ${e.message}`); + } +} + +/** + * 修改解析记录 + */ +async function updateDomainRecord(auth, domain, recordId, record) { + const client = createClient(DnspodClient, auth); + try { + return await client.ModifyRecord({ + Domain: domain, + RecordId: parseInt(recordId), + SubDomain: record.subDomain, + RecordType: record.recordType, + RecordLine: record.recordLine || '默认', + Value: record.value, + TTL: record.ttl || 600, + MX: record.mx + }); + } catch (e) { + throw new Error(`ModifyRecord Failed: ${e.message}`); + } +} + +/** + * 删除解析记录 + */ +async function deleteDomainRecord(auth, domain, recordId) { + const client = createClient(DnspodClient, auth); + try { + return await client.DeleteRecord({ + Domain: domain, + RecordId: parseInt(recordId) + }); + } catch (e) { + throw new Error(`DeleteRecord Failed: ${e.message}`); + } +} + +/** + * 设置记录状态 + */ +async function setDomainRecordStatus(auth, domain, recordId, status) { + const client = createClient(DnspodClient, auth); + try { + return await client.ModifyRecordStatus({ + Domain: domain, + RecordId: parseInt(recordId), + Status: status === 'ENABLE' ? 'ENABLE' : 'DISABLE' + }); + } catch (e) { + throw new Error(`ModifyRecordStatus Failed: ${e.message}`); + } +} + +// ==================== CVM 相关 ==================== + +/** + * 获取所有区域的 CVM 实例 + */ +async function listCvmInstances(auth, region) { + const client = createClient(CvmClient, auth, region); + try { + const result = await client.DescribeInstances({}); + return result.InstanceSet || []; + } catch (e) { + // 区域无权限或报错静默返回 + return []; + } +} + +/** + * 获取常用区域的所有 CVM + */ +async function listAllCvmInstances(auth) { + const regions = ['ap-guangzhou', 'ap-shanghai', 'ap-beijing', 'ap-hongkong', 'ap-singapore']; + const results = await Promise.all(regions.map(r => listCvmInstances(auth, r))); + const all = [].concat(...results); + return { + instances: all, + total: all.length + }; +} + +/** + * CVM 实例控制 + */ +async function controlCvmInstance(auth, region, instanceId, action) { + const client = createClient(CvmClient, auth, region); + const params = { InstanceIds: [instanceId] }; + try { + switch (action) { + case 'start': return await client.StartInstances(params); + case 'stop': return await client.StopInstances(params); + case 'reboot': return await client.RebootInstances(params); + default: throw new Error('Invalid action'); + } + } catch (e) { + throw new Error(`CVM ${action} Failed: ${e.message}`); + } +} + +// ==================== Lighthouse 相关 ==================== + +/** + * 获取单个区域的轻量实例 + */ +async function listLighthouseInRegion(auth, region) { + const client = createClient(LighthouseClient, auth, region); + try { + const result = await client.DescribeInstances({}); + return result.InstanceSet || []; + } catch (e) { + return []; + } +} + +/** + * 获取所有区域的轻量服务器 + */ +async function listAllLighthouseInstances(auth) { + const regions = ['ap-guangzhou', 'ap-shanghai', 'ap-beijing', 'ap-hongkong', 'ap-singapore', 'ap-nanjing', 'ap-chengdu']; + const results = await Promise.all(regions.map(r => listLighthouseInRegion(auth, r))); + const all = [].concat(...results); + return { + instances: all, + total: all.length + }; +} + +/** + * 轻量服务器实例控制 + */ +async function controlLighthouseInstance(auth, region, instanceId, action) { + const client = createClient(LighthouseClient, auth, region); + const params = { InstanceIds: [instanceId] }; + try { + switch (action) { + case 'start': return await client.StartInstances(params); + case 'stop': return await client.StopInstances(params); + case 'reboot': return await client.RebootInstances(params); + default: throw new Error('Invalid action'); + } + } catch (e) { + throw new Error(`Lighthouse ${action} Failed: ${e.message}`); + } +} + +// ==================== 监控相关 ==================== + +/** + * 获取监控指标 + */ +async function getMetricData(auth, region, params) { + const client = createClient(MonitorClient, auth, region); + try { + return await client.GetMonitorData(params); + } catch (e) { + throw new Error(`GetMonitorData Failed: ${e.message}`); + } +} + +module.exports = { + listDomains, + addDomain, + deleteDomain, + listDomainRecords, + addDomainRecord, + updateDomainRecord, + deleteDomainRecord, + setDomainRecordStatus, + listAllCvmInstances, + controlCvmInstance, + listAllLighthouseInstances, + controlLighthouseInstance, + getMetricData, + REGION_MAP, + formatFlavor +}; diff --git a/modules/uptime-api/monitor-service.js b/modules/uptime-api/monitor-service.js index 51233a0..1ba1191 100644 --- a/modules/uptime-api/monitor-service.js +++ b/modules/uptime-api/monitor-service.js @@ -9,7 +9,7 @@ const https = require('https'); const storage = require('./storage'); const { createLogger } = require('../../src/utils/logger'); -const logger = createLogger('UptimeService'); +const logger = createLogger('Uptime'); // 全局定时器映射: monitorId -> IntervalID const intervals = {}; @@ -81,6 +81,9 @@ class UptimeService { let msg = ''; let ping = 0; + // 获取上一次的心跳状态 + const oldBeat = storage.getLastHeartbeat(monitor.id); + try { if (monitor.type === 'http') { await this.checkHttp(monitor); @@ -129,12 +132,57 @@ class UptimeService { // 保存 storage.saveHeartbeat(monitor.id, beat); + // 触发通知逻辑 + // 1. 如果当前处于宕机状态 (status === 0),即使之前也是宕机,也需要触发(通知模块会处理抑制逻辑) + // 2. 如果状态发生了变化 (从 0 变 1 或 从 1 变 0) + // 3. 初始状态 (之前无记录) 且当前为宕机 + const statusChanged = oldBeat && oldBeat.status !== beat.status; + const isCurrentlyDown = beat.status === 0; + const isFirstDown = !oldBeat && beat.status === 0; + + if (isCurrentlyDown || statusChanged || isFirstDown) { + this.triggerNotification(monitor, beat, oldBeat); + } + // 通过 Socket.IO 推送 if (io) { io.emit('uptime:heartbeat', { monitorId: monitor.id, beat }); } } + /** + * 触发通知 + */ + triggerNotification(monitor, newBeat, oldBeat) { + try { + const notificationService = require('../notification-api/service'); + + if (newBeat.status === 0) { + // 宕机 + notificationService.trigger('uptime', 'down', { + monitorId: monitor.id, + monitorName: monitor.name, + url: monitor.url || `${monitor.hostname}:${monitor.port}`, + error: newBeat.msg, + type: monitor.type + }); + logger.warn(`[监控告警] ${monitor.name} 宕机 - ${newBeat.msg}`); + } else if (oldBeat && oldBeat.status === 0 && newBeat.status === 1) { + // 恢复 + notificationService.trigger('uptime', 'up', { + monitorId: monitor.id, + monitorName: monitor.name, + url: monitor.url || `${monitor.hostname}:${monitor.port}`, + ping: newBeat.ping, + type: monitor.type + }); + logger.info(`[监控恢复] ${monitor.name} 已恢复 - 响应时间: ${newBeat.ping}ms`); + } + } catch (error) { + logger.error(`触发通知失败: ${error.message}`); + } + } + // --- 检查逻辑 --- async checkHttp(monitor) { diff --git a/modules/uptime-api/storage.js b/modules/uptime-api/storage.js index 39c41ba..b6b393d 100644 --- a/modules/uptime-api/storage.js +++ b/modules/uptime-api/storage.js @@ -151,6 +151,14 @@ class UptimeStorage { return history; } + /** + * 获取最后一次心跳 + */ + getLastHeartbeat(monitorId) { + const history = this.getHistory(monitorId, 1); + return history.length > 0 ? history[0] : null; + } + /** * 获取历史数据 */ diff --git a/package-lock.json b/package-lock.json index 377bf19..7473128 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "api-monitor", "version": "0.1.2", "dependencies": { + "@alicloud/pop-core": "^1.8.0", "@neteasecloudmusicapienhanced/api": "^4.29.20", "@unblockneteasemusic/server": "^0.28.0", "axios": "^1.13.2", @@ -22,15 +23,19 @@ "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "https-proxy-agent": "^7.0.6", + "jsqr": "^1.4.0", "katex": "^0.16.21", "lru-cache": "^11.2.4", + "multer": "^2.0.2", "node-cron": "^4.2.1", + "nodemailer": "^7.0.12", "otplib": "^12.0.1", "pinia": "^3.0.4", "simple-icons-font": "^16.4.0", "socket.io": "^4.7.2", "socket.io-client": "^4.8.3", "ssh2": "^1.17.0", + "tencentcloud-sdk-nodejs": "^4.1.165", "uuid": "^13.0.0", "ws": "^8.18.3", "zod": "^4.2.1" @@ -55,6 +60,7 @@ "artplayer": "^5.3.0", "bezier-easing": "^2.1.0", "concurrently": "^9.2.1", + "cross-env": "^10.1.0", "deep-freeze": "^0.0.1", "dompurify": "^3.3.1", "eslint": "^9.39.2", @@ -74,6 +80,50 @@ "vue": "^3.5.26" } }, + "node_modules/@alicloud/pop-core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@alicloud/pop-core/-/pop-core-1.8.0.tgz", + "integrity": "sha512-ef6vIVigtr9n8Lw6Ld2GZ9jVUD0+ReHviaQaMqZDPI2HwdpVvrq1Rvn2tBnFToe0tdTpovz9N7XFSf/C274OtA==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "httpx": "^2.1.2", + "json-bigint": "^1.0.0", + "kitx": "^1.2.1", + "xml2js": "^0.5.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@alicloud/pop-core/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@alicloud/pop-core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@alicloud/pop-core/node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/@applemusic-like-lyrics/core": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@applemusic-like-lyrics/core/-/core-0.2.0.tgz", @@ -171,6 +221,13 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -2955,6 +3012,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3216,6 +3279,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3341,7 +3413,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/buildcheck": { @@ -3628,6 +3699,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -3776,6 +3862,24 @@ "node": ">=10.0.0" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5212,6 +5316,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -5514,6 +5630,54 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/httpx": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz", + "integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==", + "license": "MIT", + "dependencies": { + "@types/node": "^20", + "debug": "^4.1.1" + } + }, + "node_modules/httpx/node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/httpx/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/httpx/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/httpx/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5703,6 +5867,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -5868,6 +6044,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5902,6 +6087,12 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/katex": { "version": "0.16.27", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", @@ -5938,6 +6129,12 @@ "node": ">=0.10.0" } }, + "node_modules/kitx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/kitx/-/kitx-1.3.0.tgz", + "integrity": "sha512-fhBqFlXd0GkKTB+8ayLfpzPUw+LHxZlPAukPNBD1Om7JMeInT+/PxCAf1yLagvD+VKoyWhXtJR68xQkX/a0wOQ==", + "license": "MIT" + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -6200,6 +6397,18 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -6221,6 +6430,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/music-metadata": { "version": "11.10.3", "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz", @@ -6371,6 +6598,26 @@ "node": ">=6.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", @@ -6401,6 +6648,15 @@ "yargs": "^17.5.1" } }, + "node_modules/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -8273,6 +8529,118 @@ "node": ">=6" } }, + "node_modules/tencentcloud-sdk-nodejs": { + "version": "4.1.165", + "resolved": "https://registry.npmjs.org/tencentcloud-sdk-nodejs/-/tencentcloud-sdk-nodejs-4.1.165.tgz", + "integrity": "sha512-uf0PiPpi/XOOykOw3bxBtg/upoO/L0nS4/Xg75RSRGUJjXoGT8e5nOCNvEQbnUOSM86jnX9nj+W8bU45C+re4Q==", + "license": "Apache-2.0", + "dependencies": { + "form-data": "^3.0.4", + "get-stream": "^6.0.0", + "https-proxy-agent": "^5.0.0", + "ini": "^5.0.0", + "is-stream": "^2.0.0", + "json-bigint": "^1.0.0", + "node-fetch": "^2.2.0", + "tslib": "1.13.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/tslib": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", + "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==", + "license": "0BSD" + }, + "node_modules/tencentcloud-sdk-nodejs/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/terser": { "version": "5.44.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", @@ -8443,6 +8811,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -8512,6 +8886,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -8887,6 +9267,12 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webworkify-webpack": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz", @@ -8894,6 +9280,16 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9023,6 +9419,15 @@ "node": ">=0.4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index b0b8115..119632f 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "description": "Multi-api monitoring dashboard", "main": "server.js", "scripts": { - "start": "node server.js", - "dev:server": "node server.js", - "dev:client": "vite", - "dev": "concurrently --names \"SERVER,VITE\" --prefix-colors \"cyan,yellow\" \"npm run dev:server\" \"npm run dev:client\"", + "start": "cross-env DOTENV_CONFIG_QUIET=1 node server.js", + "dev:server": "cross-env DOTENV_CONFIG_QUIET=1 node server.js", + "dev:client": "cross-env DOTENV_CONFIG_QUIET=1 vite", + "dev": "cross-env DOTENV_CONFIG_QUIET=1 concurrently --names \"SERVER,VITE\" --prefix-colors \"cyan,yellow\" \"npm run dev:server\" \"npm run dev:client\"", "build": "vite build", "preview": "vite preview", "lint": "eslint .", @@ -19,6 +19,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@alicloud/pop-core": "^1.8.0", "@neteasecloudmusicapienhanced/api": "^4.29.20", "@unblockneteasemusic/server": "^0.28.0", "axios": "^1.13.2", @@ -33,15 +34,19 @@ "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "https-proxy-agent": "^7.0.6", + "jsqr": "^1.4.0", "katex": "^0.16.21", "lru-cache": "^11.2.4", + "multer": "^2.0.2", "node-cron": "^4.2.1", + "nodemailer": "^7.0.12", "otplib": "^12.0.1", "pinia": "^3.0.4", "simple-icons-font": "^16.4.0", "socket.io": "^4.7.2", "socket.io-client": "^4.8.3", "ssh2": "^1.17.0", + "tencentcloud-sdk-nodejs": "^4.1.165", "uuid": "^13.0.0", "ws": "^8.18.3", "zod": "^4.2.1" @@ -66,6 +71,7 @@ "artplayer": "^5.3.0", "bezier-easing": "^2.1.0", "concurrently": "^9.2.1", + "cross-env": "^10.1.0", "deep-freeze": "^0.0.1", "dompurify": "^3.3.1", "eslint": "^9.39.2", diff --git a/server.js b/server.js index 7b1d448..dcf9102 100644 --- a/server.js +++ b/server.js @@ -8,8 +8,20 @@ const http = require('http'); const { createLogger, logger: globalLogger } = require('./src/utils/logger'); const logger = createLogger('Server'); -// 打印简易启动标识 (Logo 移至 logger 记录) -logger.info('>>> Gravity Engineering System v0.1.2 <<<'); +// 打印 Logo +console.log(`\x1b[36m + ______ _______ ______ ______ ______ __ + / \\ / \\ / | / \\ / \\ / | +/$$$$$$ |$$$$$$$ |$$$$$$/ /$$$$$$ |/$$$$$$ | $$ | +$$ |__$$ |$$ |__$$ | $$ | $$ | _$$/ $$ | $$ | $$ | +$$ $$ |$$ $$/ $$ | $$ |/ |$$ | $$ | $$ | +$$$$$$$$ |$$$$$$$/ $$ | $$ |$$$$ |$$ | $$ | $$/ +$$ | $$ |$$ | _$$ |_ $$ \\__$$ |$$ \\__$$ | __ +$$ | $$ |$$ | / $$ | $$ $$/ $$ $$/ / | +$$/ $$/ $$/ $$$$$$/ $$$$$$/ $$$$$$/ $$/ +\x1b[0m\x1b[33m +>>> Gravity Engineering System v0.1.2 <<<\x1b[0m +`); // 导入中间件 const { configureHelmet, apiSecurityHeaders, corsConfig } = require('./src/middleware/security'); @@ -46,11 +58,11 @@ const PORT = process.env.PORT || 3000; // 初始化 WebSocket 服务 const logWss = logService.init(server); const metricsWss = metricsService.init(server); -const sshService = require('./modules/server-management/ssh-service'); +const sshService = require('./modules/server-api/ssh-service'); const sshWss = sshService.init(server); // 初始化 Agent Socket.IO 服务 -const agentService = require('./modules/server-management/agent-service'); +const agentService = require('./modules/server-api/agent-service'); agentService.initSocketIO(server); // 统一处理 WebSocket 升级请求 @@ -315,6 +327,10 @@ loadSessions(); server.listen(PORT, '0.0.0.0', () => { logger.success(`主机启动成功 - http://0.0.0.0:${PORT}`); + // 初始化通知服务 + const notificationService = require('./modules/notification-api/service'); + notificationService.init(server); + // 检查密码配置 if (process.env.ADMIN_PASSWORD) { logger.info('管理员密码: 环境变量'); @@ -386,7 +402,7 @@ server.listen(PORT, '0.0.0.0', () => { // 启动主机监控服务 try { - const monitorService = require('./modules/server-management/monitor-service'); + const monitorService = require('./modules/server-api/monitor-service'); monitorService.start(); } catch (error) { logger.warn('主机监控服务启动失败:', error.message); @@ -396,7 +412,7 @@ server.listen(PORT, '0.0.0.0', () => { try { const uptimeService = require('./modules/uptime-api/monitor-service'); // 注入 Socket.IO (复用 AgentService 的 IO 实例) - const agentService = require('./modules/server-management/agent-service'); + const agentService = require('./modules/server-api/agent-service'); if (agentService.io) { uptimeService.setIO(agentService.io); } diff --git a/src/css/antigravity.css b/src/css/antigravity.css index b826f10..2eb6eea 100644 --- a/src/css/antigravity.css +++ b/src/css/antigravity.css @@ -770,7 +770,7 @@ input:checked+.slider:before { display: flex; gap: 8px; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; } .select-sm-adaptive { diff --git a/src/css/dns.css b/src/css/cloudflare.css similarity index 99% rename from src/css/dns.css rename to src/css/cloudflare.css index d5483c2..c8db9f8 100644 --- a/src/css/dns.css +++ b/src/css/cloudflare.css @@ -26,7 +26,7 @@ /* Zones */ .zones-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(324px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-bottom: 16px; } diff --git a/src/css/modals.css b/src/css/modals.css index 2e9db1c..c25ca4b 100644 --- a/src/css/modals.css +++ b/src/css/modals.css @@ -6,8 +6,8 @@ left: 0; width: 100vw; height: 100vh; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px) saturate(180%); display: flex; align-items: center; justify-content: center; @@ -48,6 +48,12 @@ opacity 0.3s ease; } +/* 手动深色模式支持 */ +[data-theme='dark'] .modal { + background-color: var(--card-bg); + border-color: var(--border-color); +} + /* 大尺寸模态框 */ .modal-lg { max-width: 900px; @@ -138,12 +144,12 @@ /* 模态框底部 */ .modal-footer { - padding: 12px 16px; + padding: 16px 20px; border-top: 1px solid var(--border-color); - background: var(--section-bg); + background: var(--bg-secondary); display: flex; justify-content: flex-end; - gap: 10px; + gap: 12px; } .modal-header { @@ -517,7 +523,7 @@ } .modal-body { - padding: 20px; + padding: 12px; } .modal-footer { diff --git a/src/css/notification.css b/src/css/notification.css new file mode 100644 index 0000000..72edbb7 --- /dev/null +++ b/src/css/notification.css @@ -0,0 +1,388 @@ +/* ==================== 通知管理模块样式 ==================== */ +/* 该文件仅包含通知模块特有的样式,通用组件请使用 styles.css 中的全局样式 */ + +/* 容器与布局 */ +.notification-tab-content { + transform: none !important; + position: static !important; +} + +.notification-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* 工具栏 */ +.notification-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +/* 筛选栏 */ +.notification-filter-bar { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +/* 空状态 */ +.notification-empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.notification-empty-state i { + font-size: 48px; + opacity: 0.5; + margin-bottom: 16px; +} + +.notification-empty-state h3 { + font-size: 18px; + font-weight: 600; + margin: 12px 0 6px; + color: var(--text-primary); +} + +.notification-empty-state p { + font-size: 14px; + margin-bottom: 24px; +} + +/* ==================== 渠道列表 ==================== */ +.notification-channel-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 16px; +} + +.notification-channel-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px; + transition: all 0.2s ease; +} + +.notification-channel-card:hover { + border-color: var(--primary-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.channel-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.channel-icon { + width: 48px; + height: 48px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.channel-icon.email { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.channel-icon.telegram { + background: linear-gradient(135deg, #0088cc 0%, #005f8c 100%); + color: white; +} + +.channel-info { + flex: 1; + min-width: 0; +} + +.channel-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.channel-type { + font-size: 13px; + color: var(--text-secondary); +} + +.channel-actions { + display: flex; + gap: 6px; +} + +.channel-status { + display: flex; + justify-content: flex-end; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.status-badge.enabled { + background: rgba(16, 185, 129, 0.15); + color: #10b981; +} + +/* 品牌化主题覆盖 */ +.theme-notification .notification-channel-card:hover, +.theme-notification .notification-rule-card:hover { + border-color: var(--notification-primary); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.15); +} + +.theme-notification .btn-primary { + background: var(--notification-primary); + border-color: var(--notification-dark); + color: white; +} + +.theme-notification .btn-primary:hover { + background: var(--notification-dark); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2); +} + +.theme-notification .form-select-modern { + min-width: 130px; +} + +/* ==================== 规则列表 ==================== */ +.notification-rule-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.notification-rule-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 16px; + transition: all 0.2s ease; +} + +.notification-rule-card:hover { + border-color: var(--primary-color); +} + +.rule-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.rule-severity { + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.rule-severity.critical { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.rule-severity.warning { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.rule-severity.info { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.rule-info { + flex: 1; + min-width: 0; +} + +.rule-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.rule-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.rule-meta .meta-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.rule-actions { + display: flex; + gap: 6px; +} + +.rule-config { + display: flex; + gap: 16px; + flex-wrap: wrap; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.rule-config .config-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); +} + +.rule-config .config-item i { + font-size: 12px; + opacity: 0.7; +} + +/* ==================== 历史列表 ==================== */ +.notification-history-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.notification-history-item { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 14px; + display: flex; + gap: 12px; + transition: all 0.2s ease; +} + +.notification-history-item:hover { + border-color: var(--primary-color); +} + +.history-status { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; +} + +.history-status.sent { + background: rgba(16, 185, 129, 0.15); + color: #10b981; +} + +.history-status.failed { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.history-status.pending { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.history-content { + flex: 1; + min-width: 0; +} + +.history-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.history-message { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; + line-height: 1.5; +} + +.history-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; +} + +.history-meta .meta-time { + color: var(--text-tertiary); + margin-top: 6px; + display: block; + font-family: var(--font-mono); + font-size: 11px; +} + +.history-meta .meta-error { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + padding: 2px 6px; + border-radius: 4px; +} + +.history-meta .meta-retry { + color: #f59e0b; +} + +/* 继承全局 .modal-overlay 定义,仅在需要时进行微调 */ +.notification-modal-overlay {} + +/* ==================== 响应式 ==================== */ +@media (max-width: 768px) { + .notification-container { + padding: 12px; + } + + .notification-channel-list { + grid-template-columns: 1fr; + } + + .notification-toolbar { + flex-wrap: wrap; + } + + .notification-toolbar h3 { + width: 100%; + margin-bottom: 12px; + } +} \ No newline at end of file diff --git a/src/css/openai.css b/src/css/openai.css index 5e7614f..21a6ee2 100644 --- a/src/css/openai.css +++ b/src/css/openai.css @@ -2838,6 +2838,7 @@ background: rgba(16, 163, 127, 0.08) !important; } +/* .fa-cat { color: #ff9ff3; } @@ -2897,7 +2898,7 @@ .fa-book { color: #eccc68; -} +} */ .persona-modal-overlay { z-index: 2100000; diff --git a/src/css/refined-mobile.css b/src/css/refined-mobile.css index 443fd00..d1f68dc 100644 --- a/src/css/refined-mobile.css +++ b/src/css/refined-mobile.css @@ -1275,6 +1275,7 @@ html.single-page-mode [class*='-sec-tabs'] { } .panel-header { + padding-bottom: 3px; flex-direction: column !important; align-items: stretch !important; gap: 12px !important; diff --git a/src/css/self-h.css b/src/css/self-h.css index eba0e48..9fcc634 100644 --- a/src/css/self-h.css +++ b/src/css/self-h.css @@ -1150,7 +1150,7 @@ } .cron-modal .modal-body { - padding: 20px; + padding: 12px; } .cron-modal .form-group { diff --git a/src/css/ssh-ide.css b/src/css/ssh-ide.css index 96ceeac..e1827de 100644 --- a/src/css/ssh-ide.css +++ b/src/css/ssh-ide.css @@ -1,28 +1,21 @@ -/* ==================== SSH 缁堢 - VS Code 椋庢牸鍒嗗睆璁捐 ==================== */ - -/* 缁堢瀛愰〉闈㈠鍣?*/ -.ssh-terminal-sub-page { +.ssh-terminal-sub-page { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; height: calc(100vh - 180px); - /* 固定高度 */ min-height: 300px; - /* 最小高度保证可见 */ padding: 0; background: transparent; overflow: hidden; } -/* 涓诲鍣?*/ .ssh-ide-layout { display: flex; flex-direction: column; width: 100%; max-width: 1400px; height: 100%; - /* 填满父容器高度 */ background: var(--bg-primary); border-radius: 12px; overflow: hidden; @@ -42,7 +35,6 @@ border: none; } -/* ==================== 椤堕儴宸ュ叿鏍?==================== */ .ssh-top-bar { display: flex; justify-content: space-between; @@ -57,7 +49,6 @@ z-index: 200; } -/* 浼氳瘽鏍囩瀹瑰櫒 */ .ssh-session-tabs { display: flex; align-items: center; @@ -72,7 +63,6 @@ display: none; } -/* 浼氳瘽鏍囩 - 涓庢搷浣滄寜閽鏍肩粺涓€ */ .ssh-session-tab { display: flex; align-items: center; @@ -102,7 +92,6 @@ border-color: var(--border-color); } -/* 宸插垎灞忔爣璇?*/ .ssh-session-tab.is-split::after { content: ''; width: 6px; @@ -113,7 +102,6 @@ flex-shrink: 0; } -/* 鑱氬悎缁勬爣绛?*/ .ssh-session-tab.is-group { background: rgba(var(--primary-rgb), 0.08); border: 1px dashed rgba(var(--primary-rgb), 0.4); @@ -132,7 +120,6 @@ white-space: nowrap; } -/* 鎿嶄綔鎸夐挳鍖?*/ .ssh-header-actions { display: flex; align-items: center; @@ -141,7 +128,6 @@ z-index: 300; } -/* 蹇€熻繛鎺ヤ笅鎷夎彍鍗曞畾浣?*/ .ssh-header-actions .ssh-quick-connect-wrapper { position: relative; } @@ -156,7 +142,6 @@ z-index: 99999; } -/* 鎿嶄綔鎸夐挳 */ .ssh-icon-btn { display: flex; align-items: center; @@ -187,7 +172,6 @@ color: #ef4444; } -/* ==================== 缁堢涓诲鍣?==================== */ .ssh-main-container { display: flex; flex: 1; @@ -198,7 +182,6 @@ background: var(--bg-primary); } -/* 缁堢鍖呰鍣?- 鏍稿績甯冨眬 */ .ssh-terminal-wrapper { flex: 1; height: 100%; @@ -209,21 +192,18 @@ flex-direction: column; } -/* ==================== 1px 宸ヤ笟妗嗘灦甯冨眬 ==================== */ -/* 鍗曞睆妯″紡 */ .ssh-terminal-wrapper.layout-single { display: block !important; } -.ssh-terminal-wrapper.layout-single > .ssh-terminal-instance { +.ssh-terminal-wrapper.layout-single>.ssh-terminal-instance { position: absolute; inset: 0; border: none; margin: 0; } -/* 姘村钩鍒嗗睆 (宸﹀彸) */ .ssh-terminal-wrapper.layout-split-h { display: grid !important; grid-template-columns: 1fr 1fr; @@ -231,7 +211,6 @@ gap: 0; } -/* 鍨傜洿鍒嗗睆 (涓婁笅) */ .ssh-terminal-wrapper.layout-split-v { display: grid !important; grid-template-columns: 1fr; @@ -239,7 +218,6 @@ gap: 0; } -/* 缃戞牸甯冨眬 (3-9 灞? */ .ssh-terminal-wrapper.layout-grid { display: grid !important; grid-template-columns: 1fr 1fr; @@ -247,7 +225,6 @@ gap: 0; } -/* 绾靛悜涓夊垎灞?*/ .ssh-terminal-wrapper.layout-grid-v { display: grid !important; grid-template-columns: 1fr; @@ -255,19 +232,16 @@ gap: 0; } -/* 鏍规嵁绐楁牸鏁伴噺璋冩暣缃戞牸 */ .ssh-terminal-wrapper.layout-grid[data-pane-count='3'] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; } -.ssh-terminal-wrapper.layout-grid[data-pane-count='3'][data-split-side='right'] - > .ssh-terminal-instance:first-child { +.ssh-terminal-wrapper.layout-grid[data-pane-count='3'][data-split-side='right']>.ssh-terminal-instance:first-child { grid-row: span 2; } -.ssh-terminal-wrapper.layout-grid[data-pane-count='3'][data-split-side='left'] - > .ssh-terminal-instance:last-child { +.ssh-terminal-wrapper.layout-grid[data-pane-count='3'][data-split-side='left']>.ssh-terminal-instance:last-child { grid-row: span 2; } @@ -284,22 +258,16 @@ grid-template-rows: repeat(3, 1fr); } -/* ==================== 缁堢瀹炰緥 - 1px 鐙珛妗嗘灦 ==================== */ .ssh-terminal-instance { display: flex; flex-direction: column; flex: 1; - /* 填充可用空间 */ background: var(--bg-primary); position: relative; min-height: 150px; - /* 确保最小可见高度 */ min-width: 200px; overflow: hidden; - /* 防止内容溢出 */ - /* 1px 宸ヤ笟妗嗘灦 */ border: 1px solid var(--border-color); - /* 杈规閲嶅彔娑堥櫎 */ margin-right: -1px; margin-bottom: -1px; } @@ -314,7 +282,6 @@ border-color: var(--primary-color); } -/* ==================== 绐楁牸鏍囬鏍?- 鎷栨嫿鎶婃墜 ==================== */ .ssh-instance-header { height: 28px; min-height: 28px; @@ -381,12 +348,10 @@ color: #ef4444; } -/* ==================== 缁堢鍐呭鍖?- 鍐呭祵鑷€傚簲 ==================== */ .ssh-slot { flex: 1; width: 100%; height: 100%; - /* 填充父容器 */ overflow: hidden; display: flex; flex-direction: column; @@ -401,7 +366,6 @@ div[id^='ssh-terminal-'] { flex-direction: column !important; } -/* xterm 内嵌适配 */ .terminal.xterm { height: 100% !important; padding: 4px 0 0 6px !important; @@ -410,14 +374,12 @@ div[id^='ssh-terminal-'] { .xterm-viewport { background-color: var(--bg-primary) !important; overflow-y: auto !important; - /* 确保可滚动 */ } .xterm-screen { padding: 0 !important; } -/* ==================== 鎷栨嫿鎰熷簲绯荤粺 ==================== */ .instance-drop-overlay { position: absolute; inset: 0; @@ -431,12 +393,10 @@ div[id^='ssh-terminal-'] { pointer-events: auto; } -/* 鎷栨嫿鍖哄煙 */ .drop-zone { position: absolute; pointer-events: auto; z-index: 101; - /* debug: background: rgba(255,0,0,0.1); */ } .drop-zone.pos-top { @@ -474,7 +434,6 @@ div[id^='ssh-terminal-'] { right: 30%; } -/* 鎷栨嫿棰勮鎸囩ず鍣?*/ .instance-drop-indicator { position: absolute; background: rgba(var(--primary-rgb), 0.15); @@ -524,7 +483,6 @@ div[id^='ssh-terminal-'] { background: rgba(var(--primary-rgb), 0.1); } -/* 鍏ㄥ眬鎷栨嫿鎻愮ず */ .global-drop-hint { position: absolute; z-index: 1000; @@ -539,7 +497,6 @@ div[id^='ssh-terminal-'] { display: block; } -/* ==================== 浠g爜鐗囨渚ц竟鏍?==================== */ .ssh-snippets-sidebar { width: 220px; background: var(--bg-secondary); @@ -603,7 +560,6 @@ div[id^='ssh-terminal-'] { display: flex; } -/* ==================== 閫氱敤鎸夐挳 ==================== */ .mini-btn-primary { background: transparent; color: var(--primary-color); @@ -621,7 +577,6 @@ div[id^='ssh-terminal-'] { border-color: var(--primary-color); } -/* ==================== 鍝嶅簲寮?==================== */ @media (max-width: 768px) { .ssh-top-bar { padding: 0 8px; @@ -646,7 +601,6 @@ div[id^='ssh-terminal-'] { } } -/* ==================== 涓绘満璇︽儏鏍峰紡 ==================== */ .server-details-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); @@ -710,7 +664,6 @@ div[id^='ssh-terminal-'] { background: var(--danger-color); } -/* Docker 鍒楄〃 */ .docker-containers-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -726,3 +679,396 @@ div[id^='ssh-terminal-'] { display: flex; gap: 6px; } + +/* ==================== SFTP 文件管理器样式 ==================== */ + +.ssh-sftp-sidebar { + width: 280px; + background: var(--bg-secondary); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.sftp-header { + height: 29px; + padding: 0 12px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + font-size: 12px; + color: var(--text-secondary); +} + +.sftp-header-title { + display: flex; + align-items: center; + gap: 6px; +} + +.sftp-header-actions { + display: flex; + gap: 4px; +} + +.sftp-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + background: var(--bg-tertiary); +} + +.sftp-breadcrumb { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + border-bottom: 1px solid var(--border-color); + font-size: 11px; + overflow-x: auto; + scrollbar-width: none; + flex-wrap: nowrap; + white-space: nowrap; +} + +.sftp-breadcrumb::-webkit-scrollbar { + display: none; +} + +.sftp-crumb { + padding: 2px 6px; + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.sftp-crumb:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +.sftp-crumb-separator { + color: var(--text-tertiary); + opacity: 0.5; + flex-shrink: 0; +} + +.sftp-file-list { + flex: 1; + overflow-y: auto; + padding: 4px; +} + +.sftp-file-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + border: 1px solid transparent; +} + +.sftp-file-item:hover { + background: var(--bg-tertiary); + border-color: var(--border-color); +} + +.sftp-file-item.selected { + background: rgba(var(--primary-rgb), 0.08); + border-color: rgba(var(--primary-rgb), 0.3); +} + +.sftp-file-icon { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +.sftp-file-info { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.sftp-file-name { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sftp-file-meta { + font-size: 10px; + color: var(--text-tertiary); + display: flex; + gap: 8px; + margin-top: 1px; +} + +.sftp-file-actions { + display: none; + gap: 2px; +} + +.sftp-file-item:hover .sftp-file-actions { + display: flex; +} + +.sftp-action-btn { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-tertiary); + cursor: pointer; + font-size: 10px; + transition: all 0.15s; +} + +.sftp-action-btn:hover { + background: var(--bg-secondary); + color: var(--primary-color); +} + +.sftp-action-btn.danger:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +.sftp-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} + +.sftp-empty i { + font-size: 32px; + margin-bottom: 12px; + opacity: 0.3; +} + +.sftp-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--text-tertiary); +} + +/* 隐藏的文件上传输入框 */ +.sftp-upload-input { + display: none; +} + +/* 文件编辑器弹窗 */ +.sftp-editor-content { + width: 100%; + min-height: 400px; + max-height: 60vh; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + resize: vertical; + color: var(--text-primary); +} + +.sftp-editor-content:focus { + outline: none; + border-color: var(--primary-color); +} + +/* ==================== 服务器状态侧栏 ==================== */ +.ssh-status-sidebar { + width: 240px; + height: 100%; + background: var(--bg-secondary); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + flex-shrink: 0; + z-index: 50; + overflow: hidden; +} + +.ssh-terminal-wrapper { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 侧边栏过渡动画 */ +.slide-right-enter-active, +.slide-right-leave-active, +.slide-left-enter-active, +.slide-left-leave-active { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.slide-right-enter-from, +.slide-right-leave-to { + transform: translateX(100%); + opacity: 0; +} + +.slide-left-enter-from, +.slide-left-leave-to { + transform: translateX(-100%); + opacity: 0; +} + +.status-content { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.status-sections { + display: flex; + flex-direction: column; + gap: 12px; +} + +.status-section { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 10px; +} + +.status-section-title { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.status-section-title i { + font-size: 12px; + color: var(--primary-color); +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.status-label { + font-size: 11px; + color: var(--text-tertiary); +} + +.status-value { + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + font-family: var(--font-mono); +} + +/* 进度条 */ +.status-progress { + height: 4px; + background: var(--bg-primary); + border-radius: 2px; + overflow: hidden; + margin: 4px 0; +} + +.status-progress-bar { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; +} + +/* Docker 容器列表 */ +.docker-container-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.docker-container-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + background: var(--bg-primary); + border-radius: 4px; +} + +.docker-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.docker-container-name { + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); +} + +/* 数据来源标识 */ +.status-source-badge { + font-size: 9px; + padding: 2px 6px; + border-radius: 8px; + font-weight: 500; + margin-left: 6px; +} + +.badge-agent { + background: linear-gradient(135deg, #22c55e, #16a34a); + color: white; +} + +.badge-ssh { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; +} + +/* 响应式 */ +@media (max-width: 768px) { + + .ssh-sftp-sidebar, + .ssh-status-sidebar { + width: 200px; + } + + .sftp-file-meta { + display: none; + } +} \ No newline at end of file diff --git a/src/css/styles.css b/src/css/styles.css index 4f1c4c6..c690776 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -99,6 +99,9 @@ pre, --totp-primary: #8b5cf6; --totp-dark: #7c3aed; + --notification-primary: #f59e0b; + --notification-dark: #d97706; + /* 常用功能色映射 (核心驱动) */ --primary-color: var(--paas-primary); --primary-dark: var(--paas-dark); @@ -108,6 +111,135 @@ pre, --danger-color: #ef4444; --info-color: #3b82f6; + /* --- 现代化表单系统 (Modern Form System) --- */ + .form-group-modern { + margin-bottom: 16px; + } + + .form-label-modern { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; + letter-spacing: 0.5px; + } + + .form-input-modern, + .form-select-modern, + .form-textarea-modern { + width: 100%; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-family: inherit; + outline: none; + min-height: 40px; + } + + .form-input-modern:focus, + .form-select-modern:focus, + .form-textarea-modern:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.1); + background-color: var(--bg-secondary); + } + + .form-input-modern::placeholder { + color: var(--text-tertiary); + opacity: 0.5; + } + + /* 现代化选择框自定义箭头 */ + .form-select-modern { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 16px; + padding-right: 40px; + cursor: pointer; + line-height: normal; + } + + .form-select-modern:focus, + .form-select-modern:active { + background-position: right 14px center !important; + } + + /* --- 开关组件 (Modern Switch) --- */ + .ag-switch-container { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + } + + .ag-switch { + position: relative; + display: inline-block; + width: 42px; + height: 22px; + flex-shrink: 0; + } + + .ag-switch input { + opacity: 0; + width: 0; + height: 0; + } + + .ag-switch-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + transition: .3s; + border-radius: 34px; + border: 1px solid var(--border-color); + } + + .ag-switch-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 2px; + background-color: white; + transition: .3s; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + } + + .ag-switch input:checked+.ag-switch-slider { + background-color: var(--success-color); + border-color: var(--success-dark); + } + + .ag-switch input:checked+.ag-switch-slider:before { + transform: translateX(18px); + } + + .ag-switch input:focus+.ag-switch-slider { + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); + } + + .ag-switch-label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + } + /* --- 通用组件样式 --- */ /* 二级标签栏基础布局 */ @@ -130,6 +262,11 @@ pre, -webkit-overflow-scrolling: touch; } + .aliyun-sec-tabs { + position: relative; + /* border-bottom: 2px solid rgba(255, 106, 0, 0.1); */ + } + /* 单页模式下移除分割线和标题栏 */ body.single-page-mode .sec-tabs, body.single-page-mode [class*='-sec-tabs'] { @@ -239,7 +376,8 @@ pre, .theme-paas, .theme-totp, .theme-music, - .theme-uptime { + .theme-uptime, + .theme-notification { --current-primary: var(--primary-color); --current-dark: var(--primary-dark); } @@ -304,6 +442,65 @@ pre, --current-rgb: 16, 185, 129; } + .theme-notification { + --current-primary: var(--notification-primary); + --current-dark: var(--notification-dark); + --current-rgb: 245, 158, 11; + } + + .theme-aliyun { + --current-primary: #ff6a00; + --current-dark: #e55c00; + --current-rgb: 255, 106, 0; + } + + .theme-tencent { + --current-primary: #0052d9; + --current-dark: #003db3; + --current-rgb: 0, 82, 217; + } + + /* 通用输入框图标样式 */ + .input-with-icon { + position: relative; + display: flex; + align-items: center; + width: 100%; + } + + .input-with-icon i { + position: absolute; + left: 12px; + color: var(--text-tertiary); + font-size: 14px; + pointer-events: none; + z-index: 1; + } + + .input-with-icon .form-input { + padding-left: 36px !important; + width: 100%; + } + + .theme-aliyun .tab-btn:hover:not(.active) { + border-color: #ff6a00 !important; + } + + .theme-aliyun .tab-btn.active { + background: linear-gradient(135deg, #ff6a00, #e55c00) !important; + box-shadow: 0 2px 8px rgba(255, 106, 0, 0.3); + } + + .theme-aliyun .zone-card.active { + border-color: #ff6a00 !important; + background: rgba(255, 106, 0, 0.05); + box-shadow: 0 2px 8px rgba(255, 106, 0, 0.1); + } + + .theme-aliyun .zone-card.active i { + color: #ff6a00 !important; + } + /* 应用动态颜色到通用组件 */ [class*='theme-'] .btn-primary { background: var(--current-primary) !important; @@ -384,6 +581,11 @@ pre, box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); } + .theme-notification .tab-btn.active { + background: linear-gradient(135deg, var(--notification-primary), var(--notification-dark)) !important; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); + } + /* ======================================== Antigravity Loading System (Elegant Fluid) ======================================== */ @@ -542,6 +744,36 @@ pre, } } +/* 手动深色模式支持 (与媒体查询保持同步) */ +[data-theme='dark'] { + --bg-primary: #111827; + --bg-secondary: #1f2937; + --bg-secondary-rgb: 31, 41, 55; + --bg-tertiary: #374151; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-tertiary: #9ca3af; + --border-color: #374151; + --card-bg: #1f2937; + --card-shadow: 2px 2px 2px 0px rgba(0, 0, 0, 0.3), 2px 2px 2px 0px rgba(0, 0, 0, 0.2); + --card-hover-shadow: 5px 5px 6px 0px rgba(0, 0, 0, 0.3), 4px 4px 5px 0px rgba(0, 0, 0, 0.2); + --modal-overlay: rgba(0, 0, 0, 0.7); + --input-bg: transparent; + --input-border: #4b5563; + --input-focus-border: #6366f1; + --button-primary-bg: #6366f1; + --button-primary-hover: #4f46e5; + --button-secondary-bg: #374151; + --button-secondary-hover: #004346; + --status-running: #10b981; + --status-stopped: #ef4444; + --status-suspended: #f59e0b; + --status-deploying: #8b5cf6; + --status-paused: #6b7280; + --sidebar-bg: #1f2937; + --sidebar-border: #374151; +} + body.modal-open { overflow: hidden; } @@ -921,10 +1153,6 @@ body.modal-open { color: white; } -.btn-primary:hover:not(:disabled) { - /* 仅颜色亮度变化 */ -} - .btn-secondary { background: var(--bg-tertiary); border-color: var(--border-color); @@ -988,10 +1216,6 @@ body.modal-open { border-radius: 14px; } -.btn-md { - /* 默认尺寸,无需在此定义 */ -} - .btn-sm { height: 32px; padding: 0 12px; @@ -1321,10 +1545,6 @@ body.modal-open { width: 100%; } -.account-header:active { - /* transform: scale(0.995); */ -} - /* account-header 横向滚动条样式 */ .account-header::-webkit-scrollbar { height: 4px; @@ -2859,10 +3079,6 @@ body.modal-open { grid-template-columns: repeat(2, 1fr); } - .account-header { - /* padding-right: 100px; */ - } - .account-header>div:last-child { right: 8px; top: 8px; diff --git a/src/css/totp.css b/src/css/totp.css index b689250..a0c7bc1 100644 --- a/src/css/totp.css +++ b/src/css/totp.css @@ -90,7 +90,7 @@ .totp-card { background: var(--card-bg); - border: 1px solid var(--border-color); + border: 1px solid var(--card-accent, var(--totp-primary)); border-radius: 10px; padding: 12px 12px 6px 12px; cursor: pointer; @@ -501,7 +501,7 @@ } .totp-modal .modal-body { - padding: 15px; + padding: 12px; /* 增大全局内边距,提升呼吸感 */ } @@ -540,7 +540,7 @@ } /* 主题色输入框样式 */ -.totp-modal .totp-advanced-content > .form-group { +.totp-modal .totp-advanced-content>.form-group { margin-top: 12px; } @@ -617,11 +617,11 @@ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } -.toggle-checkbox:checked + .toggle-switch { +.toggle-checkbox:checked+.toggle-switch { background: var(--totp-primary); } -.toggle-checkbox:checked + .toggle-switch::after { +.toggle-checkbox:checked+.toggle-switch::after { left: 20px; } @@ -718,6 +718,7 @@ /* ==================== 响应式 - 手机端列表布局 ==================== */ @media (max-width: 768px) { + /* 列表布局 */ .totp-cards-grid { display: flex; @@ -822,7 +823,7 @@ gap: 8px; } - .totp-tab-content .panel-header > div:last-child { + .totp-tab-content .panel-header>div:last-child { flex-wrap: wrap; width: 100%; } @@ -991,6 +992,6 @@ } /* 分隔线 */ -.totp-context-menu-item + .totp-context-menu-item.danger { +.totp-context-menu-item+.totp-context-menu-item.danger { border-top: 1px solid var(--border-color); -} +} \ No newline at end of file diff --git a/src/db/models/index.js b/src/db/models/index.js index eb26853..16d0e3e 100644 --- a/src/db/models/index.js +++ b/src/db/models/index.js @@ -8,7 +8,7 @@ const { CloudflareDnsTemplate, CloudflareZone, CloudflareDnsRecord, -} = require('../../../modules/cloudflare-dns/models'); +} = require('../../../modules/cloudflare-api/models'); const { OpenAIEndpoint, OpenAIHealthHistory } = require('../../../modules/openai-api/models'); const { SystemConfig, Session, UserSettings, OperationLog } = require('./System'); const { @@ -17,7 +17,7 @@ const { ServerMonitorConfig, ServerCredential, ServerSnippet, -} = require('../../../modules/server-management/models'); +} = require('../../../modules/server-api/models'); module.exports = { // Zeabur 模块 diff --git a/src/index.html b/src/index.html index 3532e5d..e324d34 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,6 @@ -