From 922e86070d051653b0a65a02fefacbd74448bb69 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Wed, 29 Apr 2026 00:46:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=9C=88=E6=B5=81=E9=87=8F=E9=99=90=E9=A2=9D=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E4=B8=8B=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增节点级月流量限额配置、重置调度和运行状态持久化 下发 traffic_limit 给 mi-node,并在超额后停止内核、到期后恢复 管理端支持编辑限额参数并展示额度进度、状态和下次重置 手动与定时重置会同步清理限额状态并通知节点刷新配置 --- .helloagents/CHANGELOG.md | 7 + .helloagents/INDEX.md | 5 +- .../.status.json | 10 + .../proposal.md | 252 ++++++++++++++++++ .../tasks.md | 119 +++++++++ .helloagents/archive/_index.md | 2 + .helloagents/context.md | 2 + .helloagents/modules/_index.md | 1 + .helloagents/modules/admin-frontend.md | 4 +- .helloagents/modules/node-traffic-limit.md | 27 ++ admin-frontend/src/types/api.d.ts | 23 ++ admin-frontend/src/utils/nodeEditorMapper.ts | 22 ++ admin-frontend/src/utils/nodeEditorOptions.ts | 21 ++ admin-frontend/src/utils/nodes.ts | 56 ++++ .../src/views/nodes/NodeEditorDialog.vue | 62 +++++ admin-frontend/src/views/nodes/NodesView.vue | 44 +++ .../Commands/SyncServerTrafficLimits.php | 36 +++ app/Console/Kernel.php | 1 + .../V2/Admin/Server/ManageController.php | 17 +- app/Http/Requests/Admin/ServerSave.php | 9 + app/Models/Server.php | 15 ++ app/Observers/ServerObserver.php | 7 + app/Services/ServerService.php | 7 + app/Services/ServerTrafficLimitService.php | 245 +++++++++++++++++ ...raffic_limit_fields_to_v2_server_table.php | 90 +++++++ tests/Unit/ServerTrafficLimitServiceTest.php | 54 ++++ 26 files changed, 1127 insertions(+), 11 deletions(-) create mode 100644 .helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/.status.json create mode 100644 .helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/proposal.md create mode 100644 .helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/tasks.md create mode 100644 .helloagents/modules/node-traffic-limit.md create mode 100644 app/Console/Commands/SyncServerTrafficLimits.php create mode 100644 app/Services/ServerTrafficLimitService.php create mode 100644 database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php create mode 100644 tests/Unit/ServerTrafficLimitServiceTest.php diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 2328595..7109055 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## [0.6.18] - 2026-04-28 + +### 新增 +- **[node-traffic-limit]**: 新增节点月流量限额强制下线能力;Xboard 可为单节点配置月额度、重置日、重置时间和时区,下发 `traffic_limit` 给 mi-node,并在手动/定时重置和 metrics 回传时同步限额状态;管理端节点编辑与流量浮层同步展示限额配置、用量、状态和下次重置 — by yinjianm + - 方案: [202604281921_node-traffic-limit-enforcement](archive/2026-04/202604281921_node-traffic-limit-enforcement/) + - 决策: node-traffic-limit-enforcement#D001(由 mi-node 本地强制节点下线), node-traffic-limit-enforcement#D002(复用 `transfer_enable` 作为节点月额度) + ## [0.6.17] - 2026-04-28 ### 快速修改 diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index e5a096b..0b06c72 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -10,8 +10,8 @@ active_package: 无 ## 项目概览 - 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端 -- 当前重点模块: `admin-frontend`、`deploy`、`node-gfw-check`、`order-payment`、`queue-mail`、`subscription-protocols` -- 最新归档: `202604281303_xboard-reusable-server-deploy` +- 当前重点模块: `admin-frontend`、`deploy`、`node-gfw-check`、`node-traffic-limit`、`order-payment`、`queue-mail`、`subscription-protocols` +- 最新归档: `202604281921_node-traffic-limit-enforcement` ## 活跃模块 @@ -19,6 +19,7 @@ active_package: 无 - [ci-workflows](modules/ci-workflows.md): GitHub Actions 后端与管理端前端镜像发布工作流、路径触发边界和 GHCR 发布规则 - [deploy](modules/deploy.md): 可复制到服务器的 Xboard Compose 部署模板、环境变量模板和运维脚本 - [node-gfw-check](modules/node-gfw-check.md): 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 +- [node-traffic-limit](modules/node-traffic-limit.md): 节点月流量限额配置、重置调度、metrics 状态回写与 mi-node 强制下线协作 - [order-payment](modules/order-payment.md): 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 - [queue-mail](modules/queue-mail.md): 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 - [subscription-protocols](modules/subscription-protocols.md): 客户端订阅导出入口、协议适配器与版本兼容过滤 diff --git a/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/.status.json b/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/.status.json new file mode 100644 index 0000000..d216d12 --- /dev/null +++ b/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/.status.json @@ -0,0 +1,10 @@ +{ + "status": "completed", + "completed": 11, + "failed": 0, + "pending": 0, + "total": 11, + "percent": 100, + "current": "验收完成,准备归档", + "updated_at": "2026-04-28 20:37:00" +} diff --git a/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/proposal.md b/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/proposal.md new file mode 100644 index 0000000..5d5d291 --- /dev/null +++ b/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/proposal.md @@ -0,0 +1,252 @@ +# 变更提案: node-traffic-limit-enforcement + +## 元信息 +```yaml +类型: 新功能 +方案类型: implementation +优先级: P1 +状态: 已确认 +创建: 2026-04-28 +``` + +--- + +## 1. 需求 + +### 背景 +部分节点供应商有固定月流量额度,且每个节点的额度重置时间不同。当前 Xboard 只在面板侧通过 `show`、订阅过滤或节点累计 `u/d` 判断可见性,不能让 `mi-node` 实际停止节点内核,因此无法满足超额后真实下线的要求。 + +### 目标 +- 管理员可以为单个节点设置每月流量额度,上行和下行合并计算。 +- 管理员可以设置每月重置日期和重置时间。 +- `mi-node` 在当前周期内统计节点总流量,达到额度后调用内核 `Stop()`,使节点真实下线。 +- 重置时间到达后,`mi-node` 清理本周期状态并重新启动内核,使节点恢复上线。 +- Xboard 负责保存、下发、展示和手动重置节点流量限额状态,不把该状态混入 `show` 或 `auto_online`。 + +### 约束条件 +```yaml +时间约束: 本轮在现有 Xboard 与 mi-node 项目内增量实现,不重写节点同步架构。 +性能约束: mi-node 额度检查复用现有 tracker tick,不能增加按连接扫描的重负载路径。 +兼容性约束: 未启用节点流量限额的节点行为保持不变;旧 mi-node 忽略新增配置字段。 +业务约束: 节点下线必须是 mi-node 内核停止,不只是订阅隐藏或 show=false。 +``` + +### 验收标准 +- [ ] Xboard 节点编辑接口和管理端可保存节点月流量额度、重置日、重置时间。 +- [ ] Xboard 下发的节点配置包含流量限额、重置规则和时区信息。 +- [ ] mi-node 可解析新增配置,并在达到额度后停止内核,同时阻止 `ensureRunning()` 自动拉起。 +- [ ] mi-node 在重置时间到达后清理限额状态并恢复内核运行。 +- [ ] mi-node 重启后能从本地持久化状态恢复周期用量和限额下线状态。 +- [ ] Xboard 管理端能展示限额额度、当前已用、下次重置、限额状态。 +- [ ] 手动/自动重置节点流量后会通知 mi-node,使本地限额状态及时恢复。 +- [ ] `go test ./...`、`admin-frontend npm run build` 完成;PHP 侧在本机工具可用时执行语法/测试验证。 + +--- + +## 2. 方案 + +### 技术方案 +采用“节点本地强执行 + 面板配置编排”。 + +Xboard 在 `v2_server` 增加限额配置字段,复用已有 `transfer_enable` 作为节点月流量额度,新增启用状态、重置日、重置时间、时区和限额状态字段。`ServerService::buildNodeConfig()` 将限额配置下发给 `mi-node`,`ServerObserver` 在配置字段变化时推送 `sync.config`。管理端节点编辑弹窗增加限额配置区,节点列表流量浮层展示限额状态。 + +`mi-node` 在 `NodeConfig` / `NodeSpec` 中新增 `traffic_limit` 结构,新增节点流量限额管理组件,复用 tracker 每个 tick 计算出的增量合计周期用量。达到额度后设置 suspended 状态并调用 `kernel.Stop()`;在重置时间到达后清空周期状态,并在已有 `lastConfig` / `lastUsers` 可用时重启内核。状态写入本地 JSON,避免进程重启后丢失周期用量。 + +### 影响范围 +```yaml +涉及模块: + - Xboard 数据模型: v2_server 限额字段、casts、保存校验。 + - Xboard 节点同步: buildNodeConfig、ServerObserver、NodeSyncService 推送触发。 + - Xboard 节点状态: report metrics 缓存、手动重置流量后通知节点恢复。 + - Xboard 管理端: 节点编辑弹窗、类型定义、保存映射、节点列表限额展示。 + - mi-node 面板协议: panel.NodeConfig、model.NodeSpec、WS/REST 转换。 + - mi-node 服务层: tracker 增量接入、内核 stop/start gate、metrics 上报。 + - mi-node 本地状态: 新增持久化文件保存周期用量和 suspended 状态。 +预计变更文件: 20-30 个,跨 PHP、Vue/TypeScript、Go 三层。 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| mi-node 本地周期用量与 Xboard `u/d` 展示短暂不一致 | 中 | 以 mi-node 为强执行源,Xboard 展示同时保留面板统计和 node metrics;手动重置触发 config/full sync。 | +| `ensureRunning()` 或配置 reload 在 suspended 状态下误重启内核 | 高 | 在 `startKernel()` / `ensureRunning()` / `applyChanges()` 入口统一检查限额 gate,并补单元测试。 | +| 重置日遇到短月,如 31 号 | 中 | 下次重置时间计算时钳制到当月最后一天。 | +| 旧 mi-node 不支持新增字段 | 低 | 新字段为可选结构,旧节点忽略;Xboard 仍能保存和展示配置。 | +| 同一节点多实例运行导致额度各自计算 | 中 | 本轮按一节点一运行实例处理;方案文档注明该边界,多实例集中裁决不在本轮范围。 | + +### 方案取舍 +```yaml +唯一方案理由: 本方案把真实下线放在 mi-node 本地执行,能避免面板报告延迟导致超额后仍继续服务,同时保留 Xboard 的配置、审计和人工操作入口。 +放弃的替代路径: + - 面板集中裁决 + WS 停启事件: 状态源集中但依赖 report/WS 延迟,不能保证及时停止节点。 + - 只扩展 show/enabled 或用户移除逻辑: 成本低但不满足“节点实际下线”。 +回滚边界: 可关闭单节点限额开关恢复旧行为;数据库新增字段可独立回滚;mi-node 新增限额组件不影响未启用限额节点。 +``` + +--- + +## 3. 技术设计 + +### 架构设计 +```mermaid +flowchart TD + A[Xboard 管理端节点编辑] --> B[v2_server 限额字段] + B --> C[ServerService::buildNodeConfig] + C --> D[REST/WS sync.config] + D --> E[mi-node NodeSpec.TrafficLimit] + E --> F[TrafficLimitManager 本地状态] + G[tracker.Process delta] --> F + F -->|超额| H[kernel.Stop] + F -->|到达重置时间| I[startKernel] + F --> J[metrics.traffic_limit] + J --> K[Xboard 管理端状态展示] +``` + +### API 设计 +#### Admin 节点保存 payload +- **请求新增字段**: + - `traffic_limit_enabled`: boolean + - `traffic_limit_reset_day`: integer|null,1-31 + - `traffic_limit_reset_time`: string|null,`HH:mm` + - `traffic_limit_timezone`: string|null,默认面板时区 + - `transfer_enable`: integer|null,字节,0 表示不限额 +- **响应**: 复用现有节点保存响应。 + +#### 节点配置下发 +- **新增结构**: +```json +{ + "traffic_limit": { + "enabled": true, + "limit": 1099511627776, + "reset_day": 1, + "reset_time": "04:00", + "timezone": "Asia/Shanghai", + "current_used": 123456, + "next_reset_at": 1774977600 + } +} +``` + +#### mi-node metrics +- **新增结构**: +```json +{ + "traffic_limit": { + "enabled": true, + "limit": 1099511627776, + "used": 123456, + "suspended": false, + "next_reset_at": 1774977600, + "last_reset_at": 1772299200 + } +} +``` + +### 数据模型 +| 字段 | 类型 | 说明 | +|------|------|------| +| `traffic_limit_enabled` | boolean | 是否启用节点月流量强制下线。 | +| `traffic_limit_reset_day` | tinyint nullable | 每月重置日,1-31,短月钳制到最后一天。 | +| `traffic_limit_reset_time` | string nullable | 每月重置时间,`HH:mm`。 | +| `traffic_limit_timezone` | string nullable | 重置时间使用的时区,默认面板时区。 | +| `traffic_limit_status` | string nullable | 面板缓存的限额状态,如 `normal` / `suspended`。 | +| `traffic_limit_last_reset_at` | integer nullable | 最近一次重置时间戳。 | +| `traffic_limit_next_reset_at` | integer nullable | 下一次重置时间戳。 | +| `traffic_limit_suspended_at` | integer nullable | 最近一次超额下线时间戳。 | + +--- + +## 4. 核心场景 + +### 场景: 节点超额后真实下线 +**模块**: mi-node 服务层 +**条件**: 节点启用流量限额,周期用量累计达到 `limit`。 +**行为**: `TrafficLimitManager` 标记 suspended,持久化状态,调用 `kernel.Stop()`。 +**结果**: `kernel_status=false`,节点不再提供代理服务,`ensureRunning()` 不会自动重启。 + +### 场景: 重置时间到达后恢复上线 +**模块**: mi-node 服务层 +**条件**: 节点处于限额 suspended,当前时间达到 `next_reset_at`。 +**行为**: 管理组件清空周期用量,更新 reset 时间,持久化状态,调用 `startKernel(lastConfig,lastUsers)`。 +**结果**: 节点内核重新运行,metrics 显示 `suspended=false`。 + +### 场景: 管理员调整限额配置 +**模块**: Xboard 节点同步 +**条件**: 管理员保存节点限额字段。 +**行为**: Xboard 保存字段,`ServerObserver` 触发 `sync.config`。 +**结果**: mi-node 收到新配置并重新计算限额状态;未启用限额时不影响旧行为。 + +--- + +## 5. 技术决策 + +### node-traffic-limit-enforcement#D001: 由 mi-node 本地强制节点下线 +**日期**: 2026-04-28 +**状态**: ✅采纳 +**背景**: 用户明确要求“不是只是不显示,要实际上的下线”。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: mi-node 本地强执行 | 停机及时,网络异常时仍能执行,符合真实下线 | 需要本地状态持久化和更多测试 | +| B: Xboard 集中裁决后下发停启 | 状态集中,管理端一致性强 | 依赖 report/WS 延迟,节点可能继续服务 | +| C: 修改 show/enabled/用户列表 | 实现简单 | 不是真实节点下线 | +**决策**: 选择方案 A。 +**理由**: 真实下线必须发生在运行代理内核的进程内,本地强执行可以最小化超额后的继续服务窗口。 +**影响**: `mi-node` 服务层成为限额执行源,Xboard 负责配置和展示。 + +### node-traffic-limit-enforcement#D002: 复用 `transfer_enable` 作为节点月额度 +**日期**: 2026-04-28 +**状态**: ✅采纳 +**背景**: Xboard 已有 `v2_server.transfer_enable/u/d` 字段和管理端流量统计展示。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 复用 `transfer_enable` | 避免重复额度字段,兼容已有过滤与展示 | 字段语义需要在 UI 中明确为节点月额度 | +| B: 新增 `traffic_limit_bytes` | 语义独立 | 与现有 `transfer_enable` 容易重复和不同步 | +**决策**: 选择方案 A。 +**理由**: 现有字段已经是节点流量上限,新增启用和重置规则即可表达“每月额度”。 +**影响**: 保存 payload、模型 casts、下发配置和 UI 显示都以 `transfer_enable` 为额度来源。 + +--- + +## 6. 验证策略 + +```yaml +verifyMode: test-first +reviewerFocus: + - mi-node suspended 状态下所有内核启动路径是否被 gate 住。 + - Xboard 限额字段变化是否一定触发 config sync。 + - 手动重置是否同时清面板统计并通知节点恢复。 +testerFocus: + - go test ./... + - cd admin-frontend && npm run build + - PHP 可用时执行 php artisan test 或针对新增测试执行 vendor/bin/phpunit + - 节点配置接口返回 traffic_limit 结构 + - mi-node 超额 stop、重置 start、重启恢复状态 +uiValidation: optional +riskBoundary: + - 不执行生产数据库迁移。 + - 不推送远端、不部署生产环境。 + - 不修改与节点限额无关的 show/auto_online/gfw 检测策略。 +``` + +--- + +## 7. 成果设计 + +### 设计方向 +- **美学基调**: 工具型运维界面,保持节点工作台既有密度和控件体系,新增内容以紧凑表单行、状态标签和进度信息呈现。 +- **记忆点**: 节点流量浮层中出现一条清晰的“额度进度 + 下次重置 + 限额状态”信息带。 +- **参考**: 延续当前 `NodesView.vue` 和 `NodeEditorDialog.vue` 的 Element Plus 管理端风格。 + +### 视觉要素 +- **配色**: 使用现有状态色,正常为绿色,接近限额为橙色,限额下线为红色,避免引入新的主视觉体系。 +- **字体**: 沿用项目当前字体栈,避免在管理端局部引入不一致字体。 +- **布局**: 节点编辑弹窗在基础配置区增加“流量限额”配置组;节点列表流量 popover 在今日/本月/累计后增加额度状态。 +- **动效**: 仅使用 Element Plus 表单显隐和 loading 状态,不新增装饰动效。 +- **氛围**: 保持运维工具克制、可扫描,避免营销式说明文字。 + +### 技术约束 +- **可访问性**: 开关、输入框、时间选择器保留明确 label;状态不只靠颜色表达。 +- **响应式**: 编辑弹窗使用现有 grid/form 布局,在窄屏下自动换行。 diff --git a/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/tasks.md b/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/tasks.md new file mode 100644 index 0000000..cf5a3c5 --- /dev/null +++ b/.helloagents/archive/2026-04/202604281921_node-traffic-limit-enforcement/tasks.md @@ -0,0 +1,119 @@ +# 任务清单: node-traffic-limit-enforcement + +> **@status:** completed | 2026-04-29 00:21 + +> **LIVE_STATUS:** completed | completed=11 failed=0 pending=0 total=11 percent=100 current=验收完成,准备归档 + +```yaml +@feature: node-traffic-limit-enforcement +@created: 2026-04-28 +@status: completed +@mode: R2 +@package: 202604281921_node-traffic-limit-enforcement +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 11 | 0 | 0 | 11 | + +--- + +## 任务列表 + +### 1. Xboard 数据与接口 + +- [√] 1.1 修改 `database/migrations/*_add_*_to_v2_server_table.php`、`app/Models/Server.php` + - 预期变更: 新增节点限额启用、重置日、重置时间、时区、状态时间戳字段;补齐 casts 和属性语义。 + - 完成标准: `v2_server` 可保存限额配置和运行状态;未设置时默认不启用。 + - 验证方式: 代码审查 migration up/down 与 casts;PHP 可用时执行语法检查或迁移测试。 + - depends_on: [] + +- [√] 1.2 修改 `app/Http/Requests/Admin/ServerSave.php`、`app/Http/Controllers/V2/Admin/Server/ManageController.php` + - 预期变更: 管理端保存和批量/手动重置入口支持限额字段,手动重置清理面板限额状态并通知节点。 + - 完成标准: 节点保存 payload 可包含限额字段;重置节点流量后 `u/d` 与限额状态一起清理。 + - 验证方式: 审查 validation、save/update/resetTraffic/batchResetTraffic 路径;PHP 可用时执行相关测试。 + - depends_on: [1.1] + +- [√] 1.3 修改 `app/Services/ServerService.php`、`app/Observers/ServerObserver.php`、`app/Services/NodeSyncService.php` + - 预期变更: `buildNodeConfig()` 下发 `traffic_limit`;限额字段变化触发 `sync.config`;metrics 缓存限额状态。 + - 完成标准: 配置接口返回完整 `traffic_limit` 结构;节点上报 metrics 后管理端能读取限额状态。 + - 验证方式: 静态审查 config 结构和 observer 触发字段;PHP 可用时补接口/单元测试。 + - depends_on: [1.1, 1.2] + +- [√] 1.4 新增或扩展 Xboard 定时重置逻辑,作用范围 `app/Console`、`app/Services` + - 预期变更: 到达节点重置时间时清理面板 `u/d` 与限额状态,并推送节点配置同步。 + - 完成标准: 自动重置不会影响未启用限额节点;短月重置日按当月最后一天处理。 + - 验证方式: 代码审查时间计算;PHP 可用时补服务测试。 + - depends_on: [1.1, 1.3] + +### 2. Xboard 管理端 + +- [√] 2.1 修改 `admin-frontend/src/types/api.d.ts`、`admin-frontend/src/utils/nodeEditorOptions.ts`、`admin-frontend/src/utils/nodeEditorMapper.ts` + - 预期变更: 前端类型、表单模型、节点保存映射支持月额度、限额开关、重置日、重置时间、时区。 + - 完成标准: 新建/编辑节点时限额字段能正确回填和提交;禁用限额时提交安全默认值。 + - 验证方式: `cd admin-frontend && npm run build`。 + - depends_on: [1.2] + +- [√] 2.2 修改 `admin-frontend/src/views/nodes/NodeEditorDialog.vue`、`admin-frontend/src/views/nodes/NodesView.vue`、`admin-frontend/src/utils/nodes.ts` + - 预期变更: 编辑弹窗新增流量限额配置区;节点列表/流量浮层展示额度、使用量、状态和下次重置。 + - 完成标准: UI 文案简洁;状态不只依赖颜色;移动宽度下不挤压原有控件。 + - 验证方式: `cd admin-frontend && npm run build`,必要时人工检查节点页。 + - depends_on: [2.1, 1.3] + +### 3. mi-node 协议与执行 + +- [√] 3.1 修改 `internal/panel/types.go`、`internal/model/types.go`、`internal/model/panel.go`、`internal/controlplane/mailbox.go` + - 预期变更: 新增 `TrafficLimit` 配置结构,并完成 REST/WS/machine mailbox 转换和 clone。 + - 完成标准: 新字段可从 Xboard 配置进入 `NodeSpec`,machine mode 不丢字段。 + - 验证方式: `go test ./internal/model ./internal/controlplane` 或 `go test ./...`。 + - depends_on: [1.3] + +- [√] 3.2 新增 `internal/trafficlimit` 或同等职责包 + - 预期变更: 实现周期窗口、短月重置日、双向增量累加、suspended 判定、本地 JSON 持久化和 metrics snapshot。 + - 完成标准: 单元测试覆盖未启用、超额、重置、短月、重启恢复。 + - 验证方式: `go test ./internal/trafficlimit`。 + - depends_on: [3.1] + +- [√] 3.3 修改 `internal/tracker/tracker.go`、`internal/service/service.go` + - 预期变更: tracker 暴露本 tick 节点总增量;Service 在 track tick 后检查限额,超额 `kernel.Stop()`,重置后恢复 `startKernel()`,并阻止 suspended 状态下自动启动。 + - 完成标准: `ensureRunning()`、`applyChanges()`、用户更新路径不会绕过 suspended gate;metrics 包含 `traffic_limit`。 + - 验证方式: `go test ./internal/service ./internal/tracker` 或 `go test ./...`。 + - depends_on: [3.2] + +### 4. 验证与知识库 + +- [√] 4.1 补充 Xboard 与 mi-node 相关测试 + - 预期变更: 为 Xboard 配置下发/重置服务、mi-node 限额状态机和 service gate 补核心测试。 + - 完成标准: 测试覆盖超额下线、到点恢复、手动重置通知、短月重置。 + - 验证方式: `go test ./...`;PHP 测试在本机运行时可用时执行。 + - depends_on: [1.4, 3.3] + +- [√] 4.2 运行验收并同步知识库 + - 预期变更: 执行可用验证命令;更新 `.helloagents/context.md`、模块文档、`CHANGELOG.md`,并记录验证结果。 + - 完成标准: 验收报告列出通过项、受阻项和残余风险;方案包状态更新为 completed 或标明失败原因。 + - 验证方式: `go test ./...`、`cd admin-frontend && npm run build`、知识库 diff 审查。 + - depends_on: [4.1] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-28 19:21 | 方案包初始化 | completed | 用户选择方案 1:节点本地强执行 + 面板配置编排;子代理构思按主代理直接执行记录 | +| 2026-04-28 20:32 | Xboard 限额配置与管理端 | completed | 新增限额字段、配置下发、重置服务、节点编辑表单和流量浮层展示 | +| 2026-04-28 20:34 | mi-node 限额执行 | completed | 新增本地持久化限额管理器、tracker 双向增量、内核停启 gate 和 metrics | +| 2026-04-28 20:36 | 测试与验证 | completed | go test ./...、admin-frontend npm run build、PHP 语法检查通过;Laravel PHPUnit 因缺 vendor/autoload.php 未运行 | +| 2026-04-28 20:37 | 知识库同步 | completed | 更新 Xboard 与 mi-node 模块文档、上下文和 CHANGELOG | + +--- + +## 执行备注 + +- 本轮不执行生产部署、不运行生产数据库迁移、不推送远端。 +- 子代理调度因当前主代理工具约束降级为主代理直接执行,后续任务按同一职责清单串行落地。 +- `transfer_enable` 复用为节点月流量额度;新增字段只负责启用、重置规则和限额运行状态。 +- 已补充 Xboard `ServerTrafficLimitServiceTest` 与 mi-node `trafficlimit/service/tracker` 相关测试;本机缺少 `vendor/autoload.php`,Laravel PHPUnit 未执行,仅完成 PHP 语法检查。 +- 可用验收结果:`go test ./...` 通过;`admin-frontend npm run build` 通过;本轮 PHP 文件 `php -l` 通过。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 332c3f2..ff0421d 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -7,6 +7,7 @@ | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | |--------|------|------|---------|------|------| +| 202604281921 | node-traffic-limit-enforcement | implementation | node-traffic-limit,admin-frontend,mi-node | node-traffic-limit-enforcement#D001,#D002 | ✅完成 | | 202604281625 | admin-frontend-node-traffic-hover | - | - | - | ✅完成 | | 202604281632 | admin-frontend-node-auto-online-immediate-sync | - | - | - | ✅完成 | | 202604281441 | fix-admin-node-gfw-null-enabled | implementation | node-gfw-check,admin-frontend | fix-admin-node-gfw-null-enabled#D001 | ✅完成 | @@ -46,6 +47,7 @@ ## 按月归档 ### 2026-04 +- [202604281921_node-traffic-limit-enforcement](./2026-04/202604281921_node-traffic-limit-enforcement/) - 新增节点月流量限额强制下线能力,Xboard 负责配置、重置调度和状态展示,mi-node 负责本地额度累计、内核停止与重置恢复 - [202604281441_fix-admin-node-gfw-null-enabled](./2026-04/202604281441_fix-admin-node-gfw-null-enabled/) - 修复 `parent_id=0` 父节点不会被自动墙检入队导致长期显示“未检测”的问题,并让自动墙检查询对齐项目父节点与启用语义 - [202604281303_xboard-reusable-server-deploy](./2026-04/202604281303_xboard-reusable-server-deploy/) - 新增可复制到服务器的 Xboard Compose 部署模板,补齐独立 `scheduler` 服务,并提供 `.env.example`、初始化/部署/更新/状态检查脚本和部署说明 - [202604281258_fix-send-email-job-timeout](./2026-04/202604281258_fix-send-email-job-timeout/) - 修复 `SendEmailJob` 10 秒超时导致 `send_email` 队列批量失败的问题,补齐邮件 job 超时/backoff、SMTP transport timeout、运行时 mailer 刷新和 MailLog 配置脱敏 diff --git a/.helloagents/context.md b/.helloagents/context.md index 9c3e662..6654dd4 100644 --- a/.helloagents/context.md +++ b/.helloagents/context.md @@ -54,6 +54,7 @@ - `server/manage/checkGfw` - `server/manage/resetTraffic` - `server/manage/batchResetTraffic` +- 节点月流量限额由 Xboard 保存和编排:`v2_server.transfer_enable` 作为月额度,`traffic_limit_*` 字段记录启用、重置日/时间/时区和节点端运行状态;`ServerTrafficLimitService` 负责下发 `traffic_limit`、手动/定时重置、metrics 状态回写和通知 mi-node - 管理端套餐管理现已接入: - `plan/fetch` - `plan/save` @@ -117,6 +118,7 @@ - `#/nodes` 当前已升级为真实节点工作台:支持搜索、在线 / 离线筛选、显隐筛选、父/子节点筛选、墙状态筛选、分页浏览、显隐切换、自动上线托管开关、墙检测托管开关、刷新数据、复制、单节点置顶、仅对已勾选节点生效的批量修改 / 批量删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框 - 节点自动上线由后端 `ServerAutoOnlineService` 统一执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;管理端保存 / 开启自动上线、REST 心跳和 WebSocket 状态上报会触发当前节点即时同步,`sync:server-auto-online` 每 5 分钟继续兜底;未开启自动上线的节点继续保持手动显隐控制;墙状态为 `blocked` 或仍处于 `gfw_auto_hidden` 且未恢复正常时会否决自动显示 - 节点自动墙检测由后端 `sync:server-gfw-checks` 定时命令执行,只为开启 `gfw_check_enabled` 的父节点创建检测任务;父节点兼容 `parent_id IS NULL` 与历史 `parent_id=0` 两种表示,`gfw_check_enabled` 仅明确为 `false` 时关闭;子节点不独立检测,但可控制是否随父节点自动隐藏 / 恢复 +- 节点新增 / 编辑弹窗支持配置月流量限额、重置日期、重置时间和时区;节点列表流量详情卡会展示月额度、当前已用、限额状态和下次重置。限额超额后的真实下线由 mi-node 本地执行,Xboard 不通过 `show` 或 `auto_online` 伪装下线 - Compose 部署必须确保 Laravel Scheduler 持续运行;`deploy/xboard-server/compose.yaml` 通过独立 `scheduler` 服务执行 `php artisan schedule:work`,否则自动墙检测只会在手动触发时创建任务 - Bearer Token 存储于 `sessionStorage/localStorage` - `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本 diff --git a/.helloagents/modules/_index.md b/.helloagents/modules/_index.md index 6cf5da2..b657d6e 100644 --- a/.helloagents/modules/_index.md +++ b/.helloagents/modules/_index.md @@ -6,6 +6,7 @@ | [ci-workflows](ci-workflows.md) | GitHub Actions 镜像发布工作流、路径触发规则与前后端镜像发布边界 | 2026-04-28 | | [deploy](deploy.md) | 可复制到服务器的 Xboard Compose 部署模板、环境变量模板和运维脚本 | 2026-04-28 | | [node-gfw-check](node-gfw-check.md) | 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 | 2026-04-28 | +| [node-traffic-limit](node-traffic-limit.md) | 节点月流量限额配置、重置调度、metrics 状态回写与 mi-node 强制下线协作 | 2026-04-28 | | [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 | | [queue-mail](queue-mail.md) | 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 | 2026-04-28 | | [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 | diff --git a/.helloagents/modules/admin-frontend.md b/.helloagents/modules/admin-frontend.md index eb5d54b..0346389 100644 --- a/.helloagents/modules/admin-frontend.md +++ b/.helloagents/modules/admin-frontend.md @@ -47,6 +47,8 @@ - 节点管理页现支持“墙检测托管”开关、批量设置和刷新数据按钮;父节点开启后参与 `sync:server-gfw-checks` 自动检测,自动墙检统计只计算父节点;子节点不独立检测但可控制是否随父节点自动隐藏 / 恢复 - 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 payload 并提交到 `server/manage/sort` - 节点列表中鼠标悬停节点名称会显示节点流量详情卡;`server/manage/getNodes` 会返回 `traffic_stats.today/month/total`,三组数据均来自 `v2_stat_server` 按节点聚合,前端统一按 B/KB/MB/GB/TB 自适应格式化展示上行、下行和合计 +- 节点新增 / 编辑弹窗现支持月流量限额配置,字段包含启用开关、月流量额度 GB、重置日期、重置时间和时区;保存时会把 GB 转为字节写入 `transfer_enable`,并提交 `traffic_limit_*` 字段 +- 节点流量详情卡会在启用限额时追加“月额度”进度、限额状态和下次重置时间,节点标签区同步显示正常 / 接近额度 / 已限额状态;搜索关键字可匹配“流量限额 / 月流量 / 超额下线” - 权限组管理页使用真实后端 `server/group/fetch`、`server/group/save` 与 `server/group/drop`,支持关键字搜索、新增/编辑中央弹窗、删除确认,以及从节点数量列跳转到 `#/nodes?group={id}` 的筛选联动 - 路由管理页使用真实后端 `server/route/fetch`、`server/route/save` 与 `server/route/drop`,支持路由列表、关键词搜索、新增/编辑中央弹窗、删除与动作值展示 - 路由管理页的节点引用摘要由 `server/manage/getNodes` 返回的 `route_ids` 推导,不在前端伪造额外接口 @@ -100,7 +102,7 @@ - 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化 - 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化 - 依赖 `src/utils/routes.ts` 负责路由动作映射、匹配规则序列化、节点引用摘要与搜索过滤 -- 依赖 `src/utils/nodes.ts` 负责节点在线状态、自动上线统计、父/子节点、墙状态 meta、搜索文本和筛选逻辑 +- 依赖 `src/utils/nodes.ts` 负责节点在线状态、自动上线统计、父/子节点、墙状态 meta、节点限额展示、搜索文本和筛选逻辑 - 依赖 `src/views/tickets/useTicketReplyImages.ts` 收敛工单回复区图片点击上传、拖拽上传、粘贴上传、文件校验和 Markdown 插入 - 依赖 Laravel 后端 `TicketService::reply()` 提供工单“再次回复自动重开”的统一业务语义 - 依赖 Laravel 注入的 `window.settings` diff --git a/.helloagents/modules/node-traffic-limit.md b/.helloagents/modules/node-traffic-limit.md new file mode 100644 index 0000000..16c0a3a --- /dev/null +++ b/.helloagents/modules/node-traffic-limit.md @@ -0,0 +1,27 @@ +# node-traffic-limit + +## 职责 + +- 保存节点级月流量限额配置、重置规则和运行状态 +- 将限额配置下发给 `mi-node`,由节点端执行真实内核停启 +- 在手动重置、定时重置和节点 metrics 回传时同步面板侧状态 + +## 行为规范 + +- `v2_server.transfer_enable` 是节点月流量额度,单位为字节;新增字段只负责启用状态、重置日、重置时间、时区和运行状态 +- `traffic_limit_enabled=false` 或 `transfer_enable<=0` 时不启用节点限额,`ServerTrafficLimitService::buildNodeConfig()` 仍会下发 disabled 配置,旧行为保持不变 +- 重置日支持 `1-31`,短月按当月最后一天计算;重置时间使用 `HH:mm`,时区优先使用节点字段,空值或非法值回退 `config('app.timezone')` +- 管理端保存节点后调用 `ServerTrafficLimitService::refreshSchedule()` 计算 `traffic_limit_next_reset_at`,并通过 `NodeSyncService::notifyConfigUpdated()` 通知节点更新配置 +- 手动重置和定时重置统一走 `ServerTrafficLimitService::resetServer()`:清空节点 `u/d`,恢复 `traffic_limit_status=normal`,记录 `traffic_limit_last_reset_at`,计算下一次重置时间,并触发 `notifyFullSync()` +- `sync:server-traffic-limits` 每分钟扫描到期且启用限额的节点,只处理 `traffic_limit_next_reset_at <= now()` 的记录,不影响未启用限额的节点 +- `ServerService::updateMetrics()` 会缓存 `metrics.traffic_limit` 并把节点端 `suspended / last_reset_at / next_reset_at / suspended_at` 写回 `v2_server` +- 限额下线不修改 `show`、`auto_online` 或墙检测字段;真实下线由 `mi-node` 调用内核 `Stop()` 完成 + +## 依赖关系 + +- 依赖 `app/Services/ServerTrafficLimitService.php` 统一处理配置下发、时间计算、状态回写和重置 +- 依赖 `app/Services/ServerService.php` 在节点配置中追加 `traffic_limit` 并消费节点 metrics +- 依赖 `app/Observers/ServerObserver.php` 在限额配置变化时推送 `sync.config` +- 依赖 `app/Console/Commands/SyncServerTrafficLimits.php` 与 Laravel Scheduler 执行到期重置 +- 依赖管理端 `admin-frontend/src/utils/nodeEditor*`、`admin-frontend/src/utils/nodes.ts` 和 `admin-frontend/src/views/nodes/*` 提供配置与展示入口 +- 依赖 `E:/code/go/mi-node/internal/trafficlimit` 和 `internal/service` 执行本地强制下线、重置恢复与状态持久化 diff --git a/admin-frontend/src/types/api.d.ts b/admin-frontend/src/types/api.d.ts index 00fa841..5147fc4 100644 --- a/admin-frontend/src/types/api.d.ts +++ b/admin-frontend/src/types/api.d.ts @@ -859,6 +859,16 @@ export interface AdminNodeMetrics { active_connections?: number active_users?: number kernel_status?: boolean + traffic_limit?: { + enabled?: boolean + limit?: number + used?: number + suspended?: boolean + last_reset_at?: number + next_reset_at?: number + suspended_at?: number + status?: string + } | null updated_at?: number } @@ -890,6 +900,14 @@ export interface AdminNodeItem { gfw_check_enabled?: boolean gfw_auto_hidden?: boolean gfw_auto_action_at?: number | null + traffic_limit_enabled?: boolean + traffic_limit_reset_day?: number | null + traffic_limit_reset_time?: string | null + traffic_limit_timezone?: string | null + traffic_limit_status?: string | null + traffic_limit_last_reset_at?: number | null + traffic_limit_next_reset_at?: number | null + traffic_limit_suspended_at?: number | null enabled?: boolean parent_id?: number | null rate?: number | null @@ -988,6 +1006,11 @@ export interface AdminNodeSavePayload { show?: boolean | number auto_online?: boolean gfw_check_enabled?: boolean + transfer_enable?: number | null + traffic_limit_enabled?: boolean + traffic_limit_reset_day?: number | null + traffic_limit_reset_time?: string | null + traffic_limit_timezone?: string | null } declare global { diff --git a/admin-frontend/src/utils/nodeEditorMapper.ts b/admin-frontend/src/utils/nodeEditorMapper.ts index 50b38ec..564e36c 100644 --- a/admin-frontend/src/utils/nodeEditorMapper.ts +++ b/admin-frontend/src/utils/nodeEditorMapper.ts @@ -28,6 +28,18 @@ function toNullableNumber(value: unknown): number | null { return Number.isFinite(normalized) ? normalized : null } +function bytesToGigabytes(value: unknown): number | null { + const normalized = Number(value) + if (!Number.isFinite(normalized) || normalized <= 0) return null + return Number((normalized / 1073741824).toFixed(2)) +} + +function gigabytesToBytes(value: unknown): number { + const normalized = Number(value) + if (!Number.isFinite(normalized) || normalized <= 0) return 0 + return Math.round(normalized * 1073741824) +} + function toBooleanValue(value: unknown, fallback = false): boolean { if (typeof value === 'boolean') return value if (typeof value === 'number') return value !== 0 @@ -151,6 +163,11 @@ export function toNodeFormModel(node?: AdminNodeItem | null): NodeFormModel { form.name = toStringValue(node.name) form.code = toStringValue(node.code) form.rate = toNumberValue(node.rate, 1) + form.trafficLimitEnabled = toBooleanValue(node.traffic_limit_enabled) + form.trafficLimitGb = bytesToGigabytes(node.transfer_enable) + form.trafficLimitResetDay = toNullableNumber(node.traffic_limit_reset_day) ?? 1 + form.trafficLimitResetTime = toStringValue(node.traffic_limit_reset_time || '00:00') + form.trafficLimitTimezone = toStringValue(node.traffic_limit_timezone || 'Asia/Shanghai') form.rateTimeEnable = toBooleanValue(node.rate_time_enable) form.rateTimeRanges = Array.isArray(node.rate_time_ranges) && node.rate_time_ranges.length > 0 ? node.rate_time_ranges.map((item, index) => ({ @@ -495,5 +512,10 @@ export function toNodeSavePayload(form: NodeFormModel): AdminNodeSavePayload { show: form.show ? 1 : 0, auto_online: form.autoOnline, gfw_check_enabled: form.gfwCheckEnabled, + transfer_enable: form.trafficLimitEnabled ? gigabytesToBytes(form.trafficLimitGb) : 0, + traffic_limit_enabled: form.trafficLimitEnabled, + traffic_limit_reset_day: form.trafficLimitEnabled ? form.trafficLimitResetDay : null, + traffic_limit_reset_time: form.trafficLimitEnabled ? form.trafficLimitResetTime : null, + traffic_limit_timezone: form.trafficLimitEnabled ? form.trafficLimitTimezone.trim() || undefined : undefined, } } diff --git a/admin-frontend/src/utils/nodeEditorOptions.ts b/admin-frontend/src/utils/nodeEditorOptions.ts index a760578..e2c3d56 100644 --- a/admin-frontend/src/utils/nodeEditorOptions.ts +++ b/admin-frontend/src/utils/nodeEditorOptions.ts @@ -21,6 +21,11 @@ export interface NodeFormModel { name: string code: string rate: number + trafficLimitEnabled: boolean + trafficLimitGb: number | null + trafficLimitResetDay: number | null + trafficLimitResetTime: string + trafficLimitTimezone: string rateTimeEnable: boolean rateTimeRanges: NodeRateRangeForm[] tags: string[] @@ -233,6 +238,11 @@ export function createEmptyNodeForm(): NodeFormModel { name: '', code: '', rate: 1, + trafficLimitEnabled: false, + trafficLimitGb: null, + trafficLimitResetDay: 1, + trafficLimitResetTime: '00:00', + trafficLimitTimezone: 'Asia/Shanghai', rateTimeEnable: false, rateTimeRanges: [createRateRange()], tags: [], @@ -399,6 +409,17 @@ export function validateNodeForm(form: NodeFormModel): string | null { return '请至少填写一条有效的动态倍率规则' } } + if (form.trafficLimitEnabled) { + if (!Number.isFinite(Number(form.trafficLimitGb)) || Number(form.trafficLimitGb) <= 0) { + return '请输入大于 0 的月流量额度' + } + if (!Number.isInteger(Number(form.trafficLimitResetDay)) || Number(form.trafficLimitResetDay) < 1 || Number(form.trafficLimitResetDay) > 31) { + return '重置日期需为 1-31' + } + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(form.trafficLimitResetTime)) { + return '重置时间格式需为 HH:mm' + } + } if (form.type === 'shadowsocks' && !form.shadowsocksCipher.trim()) { return '请选择 Shadowsocks 加密方式' } diff --git a/admin-frontend/src/utils/nodes.ts b/admin-frontend/src/utils/nodes.ts index 1c6a82d..cf9a5ed 100644 --- a/admin-frontend/src/utils/nodes.ts +++ b/admin-frontend/src/utils/nodes.ts @@ -29,6 +29,16 @@ export interface NodeTrafficDetail { total: string } +export interface NodeTrafficLimitDetail { + enabled: boolean + used: string + limit: string + percent: number + statusLabel: string + tagType: 'success' | 'warning' | 'danger' | 'info' + nextReset: string +} + type TrafficAmountLike = { upload?: number | string | null download?: number | string | null @@ -261,6 +271,50 @@ export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[] }) } +export function getNodeTrafficLimitDetail(node: AdminNodeItem): NodeTrafficLimitDetail { + const limit = normalizeTrafficValue(node.transfer_enable) + const metrics = node.metrics?.traffic_limit + const used = normalizeTrafficValue(metrics?.used) || normalizeTrafficValue(node.u) + normalizeTrafficValue(node.d) + const suspended = Boolean(metrics?.suspended) || node.traffic_limit_status === 'suspended' + const nextResetAt = normalizeTrafficValue(metrics?.next_reset_at) || normalizeTrafficValue(node.traffic_limit_next_reset_at) + const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0 + + let statusLabel = '未启用' + let tagType: NodeTrafficLimitDetail['tagType'] = 'info' + if (Boolean(node.traffic_limit_enabled) && limit > 0) { + if (suspended) { + statusLabel = '已限额' + tagType = 'danger' + } else if (percent >= 90) { + statusLabel = '接近额度' + tagType = 'warning' + } else { + statusLabel = '正常' + tagType = 'success' + } + } + + return { + enabled: Boolean(node.traffic_limit_enabled) && limit > 0, + used: formatTrafficBytes(used), + limit: formatTrafficBytes(limit), + percent, + statusLabel, + tagType, + nextReset: formatTimestamp(nextResetAt), + } +} + +function formatTimestamp(value: number): string { + if (!value) return '未设置' + return new Date(value * 1000).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + export function getNodeGroupNames(node: AdminNodeItem): string[] { return (node.groups ?? []) .map((group) => group.name) @@ -285,6 +339,8 @@ function buildNodeSearchText(node: AdminNodeItem): string { node.server_port, getNodeTypeLabel(node.type), node.auto_online ? '自动上线 自动托管 auto online' : '', + node.traffic_limit_enabled ? '流量限额 月流量 超额下线 traffic limit quota' : '', + node.traffic_limit_status === 'suspended' ? '限额下线 已限额 suspended quota exceeded' : '', node.gfw_check_enabled === false ? '关闭墙检测 关闭自动墙检 gfw disabled' : '自动墙检 墙检测托管 gfw enabled', node.gfw_auto_hidden ? '自动隐藏 墙检测隐藏 疑似被墙已隐藏 gfw auto hidden' : '', getNodeGfwMeta(node).searchText, diff --git a/admin-frontend/src/views/nodes/NodeEditorDialog.vue b/admin-frontend/src/views/nodes/NodeEditorDialog.vue index d849518..8e10041 100644 --- a/admin-frontend/src/views/nodes/NodeEditorDialog.vue +++ b/admin-frontend/src/views/nodes/NodeEditorDialog.vue @@ -357,6 +357,68 @@ watch( +
+
+
+

流量限额

+

按节点月流量控制 mi-node 内核下线与恢复。

+
+
+ +
+ +
+
+ 月流量限额 + 达到额度后节点内核停止,重置后恢复。 +
+ +
+
+ + + + + + + + + + + + + +
+
+
diff --git a/admin-frontend/src/views/nodes/NodesView.vue b/admin-frontend/src/views/nodes/NodesView.vue index 5aee5eb..40028e4 100644 --- a/admin-frontend/src/views/nodes/NodesView.vue +++ b/admin-frontend/src/views/nodes/NodesView.vue @@ -47,6 +47,7 @@ import { getNodeGroupNames, getNodeIdLabel, getNodeStatusMeta, + getNodeTrafficLimitDetail, getNodeTrafficDetails, getNodeTypeLabel, type NodeRelationFilter, @@ -921,12 +922,36 @@ watch( 下行 {{ traffic.download }}
+
+
+ 月额度 + {{ getNodeTrafficLimitDetail(row).used }} / {{ getNodeTrafficLimitDetail(row).limit }} +
+
+ +
+
+ {{ getNodeTrafficLimitDetail(row).statusLabel }} + {{ getNodeTrafficLimitDetail(row).nextReset }} +
+
{{ getNodeStatusMeta(row).label }} + + {{ getNodeTrafficLimitDetail(row).statusLabel }} + resetDueServers(); + $this->info("处理 {$result['processed']} 个节点,重置 {$result['reset']} 个节点"); + + if (!empty($result['errors'])) { + $this->warn('部分节点重置失败,详情请查看日志'); + } + + return self::SUCCESS; + } catch (\Throwable $e) { + Log::error('节点流量限额同步失败', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $this->error("节点流量限额同步失败: {$e->getMessage()}"); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 200f082..3e3f64c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -46,6 +46,7 @@ class Kernel extends ConsoleKernel $schedule->command('cleanup:online-status')->everyFiveMinutes()->onOneServer(); $schedule->command('sync:server-auto-online')->everyFiveMinutes()->onOneServer()->withoutOverlapping(5); $schedule->command('sync:server-gfw-checks')->everyThirtyMinutes()->onOneServer()->withoutOverlapping(30); + $schedule->command('sync:server-traffic-limits')->everyMinute()->onOneServer()->withoutOverlapping(10); // backup Timing // if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) { // $schedule->command('backup:database', ['true'])->daily()->onOneServer(); diff --git a/app/Http/Controllers/V2/Admin/Server/ManageController.php b/app/Http/Controllers/V2/Admin/Server/ManageController.php index a4cb39b..b4068f2 100644 --- a/app/Http/Controllers/V2/Admin/Server/ManageController.php +++ b/app/Http/Controllers/V2/Admin/Server/ManageController.php @@ -11,6 +11,7 @@ use App\Models\StatServer; use App\Services\ServerAutoOnlineService; use App\Services\ServerGfwCheckService; use App\Services\ServerService; +use App\Services\ServerTrafficLimitService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -134,6 +135,7 @@ class ManageController extends Controller $params['gfw_auto_action_at'] = null; } $server->update($params); + app(ServerTrafficLimitService::class)->refreshSchedule($server->refresh()); $this->syncAutoOnlineIfEnabled($server); return $this->success(true); } catch (\Exception $e) { @@ -144,6 +146,7 @@ class ManageController extends Controller try { $server = Server::create($params); + app(ServerTrafficLimitService::class)->refreshSchedule($server->refresh()); $this->syncAutoOnlineIfEnabled($server); return $this->success(true); } catch (\Exception $e) { @@ -262,10 +265,8 @@ class ManageController extends Controller } try { - $server->u = 0; - $server->d = 0; - $server->save(); - + app(ServerTrafficLimitService::class)->resetServer($server); + Log::info("Server {$server->id} ({$server->name}) traffic reset by admin"); return $this->success(true); } catch (\Exception $e) { @@ -292,10 +293,10 @@ class ManageController extends Controller } try { - Server::whereIn('id', $ids)->update([ - 'u' => 0, - 'd' => 0, - ]); + $service = app(ServerTrafficLimitService::class); + Server::whereIn('id', $ids) + ->get() + ->each(fn (Server $server) => $service->resetServer($server)); Log::info("Servers " . implode(',', $ids) . " traffic reset by admin"); return $this->success(true); diff --git a/app/Http/Requests/Admin/ServerSave.php b/app/Http/Requests/Admin/ServerSave.php index 8fc806d..067ddad 100644 --- a/app/Http/Requests/Admin/ServerSave.php +++ b/app/Http/Requests/Admin/ServerSave.php @@ -141,6 +141,10 @@ class ServerSave extends FormRequest 'rate_time_ranges.*.rate' => 'required_with:rate_time_ranges|numeric|min:0', 'protocol_settings' => 'array', 'transfer_enable' => 'nullable|integer|min:0', + 'traffic_limit_enabled' => 'nullable|boolean', + 'traffic_limit_reset_day' => 'nullable|integer|min:1|max:31', + 'traffic_limit_reset_time' => 'nullable|string|date_format:H:i', + 'traffic_limit_timezone' => 'nullable|string|max:64', ]; } @@ -304,6 +308,11 @@ class ServerSave extends FormRequest 'protocol_settings.*.in' => ':attribute 的值不合法', 'transfer_enable.integer' => '流量上限必须是整数', 'transfer_enable.min' => '流量上限不能小于0', + 'traffic_limit_reset_day.integer' => '重置日期必须是整数', + 'traffic_limit_reset_day.min' => '重置日期不能小于1', + 'traffic_limit_reset_day.max' => '重置日期不能大于31', + 'traffic_limit_reset_time.date_format' => '重置时间格式必须为HH:mm', + 'traffic_limit_timezone.max' => '重置时区长度不能超过64个字符', ]; } } diff --git a/app/Models/Server.php b/app/Models/Server.php index c54d120..7d8139c 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -28,6 +28,14 @@ use Illuminate\Database\Eloquent\Casts\Attribute; * @property boolean $gfw_check_enabled 是否自动检测墙状态并同步显示 * @property boolean $gfw_auto_hidden 是否由墙状态自动隐藏 * @property int|null $gfw_auto_action_at 最近墙状态自动显隐时间 + * @property boolean $traffic_limit_enabled 是否启用节点流量限额强制下线 + * @property int|null $traffic_limit_reset_day 节点流量每月重置日 + * @property string|null $traffic_limit_reset_time 节点流量重置时间 + * @property string|null $traffic_limit_timezone 节点流量重置时区 + * @property string|null $traffic_limit_status 节点流量限额运行状态 + * @property int|null $traffic_limit_last_reset_at 节点流量最近重置时间 + * @property int|null $traffic_limit_next_reset_at 节点流量下次重置时间 + * @property int|null $traffic_limit_suspended_at 节点流量限额下线时间 * @property string|null $allow_insecure 是否允许不安全 * @property string|null $network 网络类型 * @property int|null $parent_id 父节点ID @@ -78,6 +86,8 @@ class Server extends Model public const STATUS_OFFLINE = 0; public const STATUS_ONLINE_NO_PUSH = 1; public const STATUS_ONLINE = 2; + public const TRAFFIC_LIMIT_STATUS_NORMAL = 'normal'; + public const TRAFFIC_LIMIT_STATUS_SUSPENDED = 'suspended'; public const CHECK_INTERVAL = 300; // 5 minutes in seconds @@ -133,6 +143,11 @@ class Server extends Model 'gfw_check_enabled' => 'boolean', 'gfw_auto_hidden' => 'boolean', 'gfw_auto_action_at' => 'integer', + 'traffic_limit_enabled' => 'boolean', + 'traffic_limit_reset_day' => 'integer', + 'traffic_limit_last_reset_at' => 'integer', + 'traffic_limit_next_reset_at' => 'integer', + 'traffic_limit_suspended_at' => 'integer', 'enabled' => 'boolean', 'created_at' => 'timestamp', 'updated_at' => 'timestamp', diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php index f134ba5..14803da 100644 --- a/app/Observers/ServerObserver.php +++ b/app/Observers/ServerObserver.php @@ -26,6 +26,13 @@ class ServerObserver 'custom_outbounds', 'custom_routes', 'cert_config', + 'transfer_enable', + 'traffic_limit_enabled', + 'traffic_limit_reset_day', + 'traffic_limit_reset_time', + 'traffic_limit_timezone', + 'traffic_limit_last_reset_at', + 'traffic_limit_next_reset_at', ])) { NodeSyncService::notifyConfigUpdated($server->id); } diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index e388875..e5680d7 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -241,10 +241,15 @@ class ServerService 'api' => $metrics['api'] ?? [], 'ws' => $metrics['ws'] ?? [], 'limits' => $metrics['limits'] ?? [], + 'traffic_limit' => $metrics['traffic_limit'] ?? null, 'updated_at' => now()->timestamp, 'kernel_status' => (bool) ($metrics['kernel_status'] ?? false), ]; + if (isset($metrics['traffic_limit']) && is_array($metrics['traffic_limit'])) { + app(ServerTrafficLimitService::class)->applyRuntimeMetrics($node, $metrics['traffic_limit']); + } + Cache::put( CacheKey::get('SERVER_' . $nodeType . '_METRICS', $nodeId), $metricsData, @@ -397,6 +402,8 @@ class ServerService } } + $response['traffic_limit'] = app(ServerTrafficLimitService::class)->buildNodeConfig($node); + return $response; } diff --git a/app/Services/ServerTrafficLimitService.php b/app/Services/ServerTrafficLimitService.php new file mode 100644 index 0000000..7ef8cb5 --- /dev/null +++ b/app/Services/ServerTrafficLimitService.php @@ -0,0 +1,245 @@ +isEnabled($server); + $nextResetAt = $enabled + ? ($server->traffic_limit_next_reset_at ?: $this->calculateNextResetAt($server)?->timestamp) + : null; + + return [ + 'enabled' => $enabled, + 'limit' => $enabled ? (int) $server->transfer_enable : 0, + 'reset_day' => $enabled ? $this->normalizeResetDay($server->traffic_limit_reset_day) : 0, + 'reset_time' => $enabled ? $this->normalizeResetTime($server->traffic_limit_reset_time) : null, + 'timezone' => $enabled ? $this->normalizeTimezone($server->traffic_limit_timezone) : null, + 'current_used' => max(0, (int) $server->u + (int) $server->d), + 'last_reset_at' => (int) ($server->traffic_limit_last_reset_at ?? 0), + 'next_reset_at' => (int) ($nextResetAt ?? 0), + 'suspended_at' => (int) ($server->traffic_limit_suspended_at ?? 0), + 'status' => $server->traffic_limit_status ?: Server::TRAFFIC_LIMIT_STATUS_NORMAL, + ]; + } + + /** + * Refresh persisted schedule fields after admin edits node limit settings. + */ + public function refreshSchedule(Server $server, bool $notifyNode = true): void + { + $values = $this->scheduleValues($server); + $server->forceFill($values)->saveQuietly(); + + if ($notifyNode) { + NodeSyncService::notifyConfigUpdated((int) $server->id); + } + } + + /** + * Reset panel-side node traffic and notify mi-node to clear local limiter state. + */ + public function resetServer(Server $server, bool $notifyNode = true): void + { + $now = time(); + $server->forceFill([ + 'u' => 0, + 'd' => 0, + 'traffic_limit_status' => Server::TRAFFIC_LIMIT_STATUS_NORMAL, + 'traffic_limit_last_reset_at' => $now, + 'traffic_limit_next_reset_at' => $this->isEnabled($server) + ? $this->calculateNextResetAt($server, Carbon::createFromTimestamp($now + 1, $this->normalizeTimezone($server->traffic_limit_timezone)))?->timestamp + : null, + 'traffic_limit_suspended_at' => null, + ])->saveQuietly(); + + if ($notifyNode) { + NodeSyncService::notifyFullSync((int) $server->id); + } + } + + /** + * Reset all nodes whose configured reset time has arrived. + */ + public function resetDueServers(): array + { + $now = time(); + $processed = 0; + $reset = 0; + $errors = []; + + Server::query() + ->where('traffic_limit_enabled', true) + ->where('transfer_enable', '>', 0) + ->whereNotNull('traffic_limit_next_reset_at') + ->where('traffic_limit_next_reset_at', '<=', $now) + ->orderBy('id') + ->chunkById(100, function ($servers) use (&$processed, &$reset, &$errors) { + foreach ($servers as $server) { + $processed++; + try { + $this->resetServer($server); + $reset++; + } catch (\Throwable $e) { + $errors[] = [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]; + Log::error('节点流量限额重置失败', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + } + } + }); + + return [ + 'processed' => $processed, + 'reset' => $reset, + 'errors' => $errors, + ]; + } + + /** + * Calculate the next monthly reset time from a reference time. + */ + public function calculateNextResetAt(Server $server, ?Carbon $from = null): ?Carbon + { + if (!$this->isEnabled($server)) { + return null; + } + + $timezone = $this->normalizeTimezone($server->traffic_limit_timezone); + $from = ($from ?: Carbon::now($timezone))->copy()->timezone($timezone); + [$hour, $minute] = $this->parseResetTime($server->traffic_limit_reset_time); + + $target = $this->targetForMonth( + $from->year, + $from->month, + $this->normalizeResetDay($server->traffic_limit_reset_day), + $hour, + $minute, + $timezone + ); + + if ($target->timestamp > $from->timestamp) { + return $target; + } + + $nextMonth = $from->copy()->startOfMonth()->addMonthNoOverflow(); + return $this->targetForMonth( + $nextMonth->year, + $nextMonth->month, + $this->normalizeResetDay($server->traffic_limit_reset_day), + $hour, + $minute, + $timezone + ); + } + + /** + * Apply limiter metrics from mi-node to panel-side runtime fields. + */ + public function applyRuntimeMetrics(Server $server, array $trafficLimit): void + { + if (empty($trafficLimit)) { + return; + } + + $suspended = (bool) ($trafficLimit['suspended'] ?? false); + $server->forceFill([ + 'traffic_limit_status' => $suspended + ? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED + : Server::TRAFFIC_LIMIT_STATUS_NORMAL, + 'traffic_limit_last_reset_at' => $this->nullableTimestamp($trafficLimit['last_reset_at'] ?? null), + 'traffic_limit_next_reset_at' => $this->nullableTimestamp($trafficLimit['next_reset_at'] ?? null), + 'traffic_limit_suspended_at' => $suspended + ? $this->nullableTimestamp($trafficLimit['suspended_at'] ?? null) + : null, + ]); + + if ($server->isDirty()) { + $server->saveQuietly(); + } + } + + private function scheduleValues(Server $server): array + { + if (!$this->isEnabled($server)) { + return [ + 'traffic_limit_enabled' => false, + 'traffic_limit_status' => null, + 'traffic_limit_next_reset_at' => null, + 'traffic_limit_suspended_at' => null, + ]; + } + + return [ + 'traffic_limit_enabled' => true, + 'traffic_limit_reset_day' => $this->normalizeResetDay($server->traffic_limit_reset_day), + 'traffic_limit_reset_time' => $this->normalizeResetTime($server->traffic_limit_reset_time), + 'traffic_limit_timezone' => $this->normalizeTimezone($server->traffic_limit_timezone), + 'traffic_limit_status' => $server->traffic_limit_status ?: Server::TRAFFIC_LIMIT_STATUS_NORMAL, + 'traffic_limit_next_reset_at' => $this->calculateNextResetAt($server)?->timestamp, + ]; + } + + private function isEnabled(Server $server): bool + { + return (bool) $server->traffic_limit_enabled && (int) $server->transfer_enable > 0; + } + + private function normalizeResetDay($day): int + { + $normalized = (int) ($day ?: 1); + return max(1, min(31, $normalized)); + } + + private function normalizeResetTime(?string $time): string + { + return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) $time) + ? (string) $time + : '00:00'; + } + + private function normalizeTimezone(?string $timezone): string + { + $timezone = trim((string) $timezone); + if ($timezone === '') { + return config('app.timezone', 'UTC'); + } + + return in_array($timezone, timezone_identifiers_list(), true) + ? $timezone + : config('app.timezone', 'UTC'); + } + + private function parseResetTime(?string $time): array + { + [$hour, $minute] = explode(':', $this->normalizeResetTime($time)); + return [(int) $hour, (int) $minute]; + } + + private function targetForMonth(int $year, int $month, int $day, int $hour, int $minute, string $timezone): Carbon + { + $firstDay = Carbon::create($year, $month, 1, $hour, $minute, 0, $timezone); + $targetDay = min($day, $firstDay->copy()->endOfMonth()->day); + + return Carbon::create($year, $month, $targetDay, $hour, $minute, 0, $timezone); + } + + private function nullableTimestamp($value): ?int + { + $timestamp = (int) ($value ?? 0); + return $timestamp > 0 ? $timestamp : null; + } +} diff --git a/database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php b/database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php new file mode 100644 index 0000000..e0edb1a --- /dev/null +++ b/database/migrations/2026_04_28_192200_add_traffic_limit_fields_to_v2_server_table.php @@ -0,0 +1,90 @@ +boolean('traffic_limit_enabled') + ->default(false) + ->after('transfer_enable') + ->comment('Enable node traffic limit enforcement'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_reset_day')) { + $table->unsignedTinyInteger('traffic_limit_reset_day') + ->nullable() + ->after('traffic_limit_enabled') + ->comment('Monthly reset day, 1-31'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_reset_time')) { + $table->string('traffic_limit_reset_time', 5) + ->nullable() + ->after('traffic_limit_reset_day') + ->comment('Monthly reset time in HH:mm'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_timezone')) { + $table->string('traffic_limit_timezone', 64) + ->nullable() + ->after('traffic_limit_reset_time') + ->comment('Timezone used for node traffic reset'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_status')) { + $table->string('traffic_limit_status', 32) + ->nullable() + ->after('traffic_limit_timezone') + ->comment('Runtime status reported by node traffic limiter'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_last_reset_at')) { + $table->unsignedBigInteger('traffic_limit_last_reset_at') + ->nullable() + ->after('traffic_limit_status') + ->comment('Last node traffic reset timestamp'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_next_reset_at')) { + $table->unsignedBigInteger('traffic_limit_next_reset_at') + ->nullable() + ->after('traffic_limit_last_reset_at') + ->comment('Next node traffic reset timestamp'); + } + if (!Schema::hasColumn('v2_server', 'traffic_limit_suspended_at')) { + $table->unsignedBigInteger('traffic_limit_suspended_at') + ->nullable() + ->after('traffic_limit_next_reset_at') + ->comment('Timestamp when node was suspended by traffic limit'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('v2_server', function (Blueprint $table) { + $columns = [ + 'traffic_limit_enabled', + 'traffic_limit_reset_day', + 'traffic_limit_reset_time', + 'traffic_limit_timezone', + 'traffic_limit_status', + 'traffic_limit_last_reset_at', + 'traffic_limit_next_reset_at', + 'traffic_limit_suspended_at', + ]; + + foreach ($columns as $column) { + if (Schema::hasColumn('v2_server', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/tests/Unit/ServerTrafficLimitServiceTest.php b/tests/Unit/ServerTrafficLimitServiceTest.php new file mode 100644 index 0000000..fe342a8 --- /dev/null +++ b/tests/Unit/ServerTrafficLimitServiceTest.php @@ -0,0 +1,54 @@ + true, + 'transfer_enable' => 100, + 'traffic_limit_reset_day' => 31, + 'traffic_limit_reset_time' => '03:30', + 'traffic_limit_timezone' => 'UTC', + ]); + + $nextReset = app(ServerTrafficLimitService::class)->calculateNextResetAt( + $server, + Carbon::create(2026, 2, 1, 0, 0, 0, 'UTC') + ); + + $this->assertSame('2026-02-28 03:30:00', $nextReset?->format('Y-m-d H:i:s')); + } + + public function test_build_node_config_uses_transfer_enable_and_panel_usage(): void + { + $server = new Server([ + 'traffic_limit_enabled' => true, + 'transfer_enable' => 1024, + 'traffic_limit_reset_day' => 1, + 'traffic_limit_reset_time' => '04:00', + 'traffic_limit_timezone' => 'Asia/Shanghai', + 'traffic_limit_next_reset_at' => 1774977600, + 'u' => 400, + 'd' => 600, + ]); + + $config = app(ServerTrafficLimitService::class)->buildNodeConfig($server); + + $this->assertTrue($config['enabled']); + $this->assertSame(1024, $config['limit']); + $this->assertSame(1000, $config['current_used']); + $this->assertSame(1, $config['reset_day']); + $this->assertSame('04:00', $config['reset_time']); + $this->assertSame('Asia/Shanghai', $config['timezone']); + $this->assertSame(1774977600, $config['next_reset_at']); + $this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $config['status']); + } +}