fix(api): 修复节点流量限额共享统计与父子显隐联动
统一节点流量统计与限额展示口径,节点详情新增昨日流量, 并让今日、昨日和本月使用清晰的半开时间窗口聚合 同 machine_id 或同 host 的节点现在共享当前账期已用流量, 管理端优先使用后端 traffic_limit_snapshot 展示月额度状态, mi-node 下发的 current_used 也改为共享账期统计 新增 parent_auto_hidden 标记与父节点显隐联动服务,父节点 因自动上线或流量限额变为不可展示时会隐藏当前显示的子节点, 恢复时只恢复这批自动隐藏的子节点,避免覆盖手动操作
This commit is contained in:
@@ -1,5 +1,33 @@
|
||||
# CHANGELOG
|
||||
|
||||
## [0.6.22] - 2026-04-29
|
||||
|
||||
### 修复
|
||||
- **[node-traffic-limit]**: 修复父节点自动下线后子节点仍可能保持上线的问题;新增 `parent_auto_hidden` 标记和父节点显隐联动服务,自动上线离线、流量限额 suspended 会隐藏当时仍显示的直接子节点,自动恢复或限额重置后只恢复这批由联动逻辑隐藏的子节点,手动隐藏的子节点不被误上线 — by yinjianm
|
||||
- 方案: [202604290153_parent-node-auto-visibility](archive/2026-04/202604290153_parent-node-auto-visibility/)
|
||||
- 决策: parent-node-auto-visibility#D001(使用独立父级自动隐藏标记)
|
||||
|
||||
## [0.6.21] - 2026-04-29
|
||||
|
||||
### 修复
|
||||
- **[node-traffic-limit]**: 修正节点管理月额度使用量口径;同 `machine_id` 或同 host 节点现在共享当前账期用量,`server/manage/getNodes` 返回 `traffic_limit_snapshot`,mi-node 下发的 `traffic_limit.current_used` 也改为共享账期统计,管理端优先显示快照并保留旧 metrics / `u+d` 回退 — by yinjianm
|
||||
- 方案: [202604290132_shared-node-traffic-limit](archive/2026-04/202604290132_shared-node-traffic-limit/)
|
||||
- 决策: shared-node-traffic-limit#D001(共享范围优先 machine_id,兜底 host)
|
||||
|
||||
## [0.6.20] - 2026-04-29
|
||||
|
||||
### 新增
|
||||
- **[admin-frontend]**: 节点流量详情卡新增“昨日”统计;`server/manage/getNodes` 现在返回 `traffic_stats.today/yesterday/month/total`,今日、昨日和本月均使用半开时间窗口聚合,便于对比“今日下行多、本月上行多”的流量分布来源 — by yinjianm
|
||||
- 方案: [202604290123_node-traffic-yesterday-stats](archive/2026-04/202604290123_node-traffic-yesterday-stats/)
|
||||
- 决策: node-traffic-yesterday-stats#D001(保持 u/d 语义并新增后端 yesterday 字段)
|
||||
|
||||
## [0.6.19] - 2026-04-29
|
||||
|
||||
### 快速修改
|
||||
- **[node-traffic-limit]**: 修复节点提高月流量额度后管理端仍显示“已限额”的问题;保存配置、缓存 metrics 回写和节点下发配置现在都会按当前已用流量与新额度重新计算 suspended 状态,旧额度产生的 stale metrics 不会再把节点重新标记为限额下线 — by yinjianm
|
||||
- 类型: 快速修改(无方案包)
|
||||
- 文件: app/Services/ServerTrafficLimitService.php:16-320, app/Services/ServerService.php:247-252, tests/Unit/ServerTrafficLimitServiceTest.php:60-148, E:/code/go/mi-node/internal/trafficlimit/manager_test.go:120-157
|
||||
|
||||
## [0.6.18] - 2026-04-28
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
```yaml
|
||||
kb_version: 2
|
||||
project: Xboard-new
|
||||
updated_at: 2026-04-28
|
||||
updated_at: 2026-04-29
|
||||
active_package: 无
|
||||
```
|
||||
|
||||
@@ -11,7 +11,7 @@ active_package: 无
|
||||
|
||||
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
||||
- 当前重点模块: `admin-frontend`、`deploy`、`node-gfw-check`、`node-traffic-limit`、`order-payment`、`queue-mail`、`subscription-protocols`
|
||||
- 最新归档: `202604281921_node-traffic-limit-enforcement`
|
||||
- 最新归档: `202604290153_parent-node-auto-visibility`
|
||||
|
||||
## 活跃模块
|
||||
|
||||
@@ -19,7 +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 强制下线协作
|
||||
- [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): 客户端订阅导出入口、协议适配器与版本兼容过滤
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"status": "completed",
|
||||
"completed": 5,
|
||||
"failed": 0,
|
||||
"pending": 0,
|
||||
"total": 5,
|
||||
"percent": 100,
|
||||
"current": "节点昨日流量统计已实现并完成验证",
|
||||
"updated_at": "2026-04-29 01:50:00"
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# 变更提案: node-traffic-yesterday-stats
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 修复 + 新功能
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已规划
|
||||
创建: 2026-04-29
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
管理后台 `#/nodes` 的节点流量详情卡当前展示“今日 / 本月 / 累计”。用户反馈某节点“今日下行很多,但本月上行最多”看起来不匹配,并要求统计加入“昨日”。
|
||||
|
||||
### 目标
|
||||
- 核对节点统计的上行/下行字段映射,确认是否存在前后端反转或聚合口径错误。
|
||||
- 在节点流量详情卡中新增“昨日”统计,口径与“今日”一致。
|
||||
- 收紧统计窗口边界,保证“今日 / 昨日 / 本月 / 累计”各自窗口清晰。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 无
|
||||
性能约束: 节点列表接口仍按当前批量聚合方式查询,避免逐节点查询
|
||||
兼容性约束: 保持现有 traffic_stats.today/month/total 字段兼容,新增 yesterday 字段
|
||||
业务约束: 不改变 StatServer.u/d 的含义,不迁移历史数据
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] `server/manage/getNodes` 响应中的 `traffic_stats` 包含 `yesterday`。
|
||||
- [ ] `today` 只统计当天 `[today, tomorrow)`,`yesterday` 只统计 `[yesterday, today)`,`month` 只统计 `[monthStart, nextMonthStart)`。
|
||||
- [ ] 前端节点流量详情卡按“今日 / 昨日 / 本月 / 累计”展示。
|
||||
- [ ] 后端测试覆盖新窗口边界,前端构建通过。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `ManageController` 内扩展节点流量窗口构建:
|
||||
- `emptyNodeTrafficStats()` 增加 `yesterday` 默认值。
|
||||
- `buildNodeTrafficStats()` 使用 `strtotime('today')`、`strtotime('tomorrow')`、`strtotime('yesterday')` 和下月月初计算窗口。
|
||||
- `fillTrafficWindow()` 支持可选结束时间,查询使用半开区间:`record_at >= startAt` 且 `record_at < endAt`。
|
||||
|
||||
前端同步:
|
||||
- `AdminNodeTrafficStats` 增加 `yesterday: TrafficAmount`。
|
||||
- `NodeTrafficDetail.key` 增加 `yesterday`。
|
||||
- `getNodeTrafficDetails()` 在“今日”和“本月”之间插入“昨日”。
|
||||
|
||||
测试:
|
||||
- 新增/扩展 `ManageController` 单元测试,通过反射调用私有构建方法,构造跨天、跨月和未来记录,验证各窗口不会互相污染。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- admin-frontend: 节点流量详情卡类型与展示行
|
||||
- backend-admin-api: 节点列表接口 traffic_stats 聚合窗口
|
||||
- tests: 节点统计窗口单元测试
|
||||
预计变更文件: 4-6
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 历史数据中 u/d 语义本身来自节点端上报,若节点端上报方向定义与面板相反,面板无法单独纠正 | 中 | 本次只核对面板字段链路;不改变历史语义,避免误修 |
|
||||
| 新增窗口可能增加查询次数 | 低 | 仍为按 server_id 批量聚合,仅从 3 个窗口增至 4 个窗口 |
|
||||
| 月统计加入上界后不再包含未来 record_at | 低 | 这是更严格的窗口口径,符合预期 |
|
||||
|
||||
### 方案取舍
|
||||
```yaml
|
||||
唯一方案理由: 后端统一输出 yesterday 字段,前端只按接口字段展示,可以保持统计口径单一且兼容现有调用方。
|
||||
放弃的替代路径:
|
||||
- 仅前端用 today/month/total 推导昨日: 无法准确还原昨日上行/下行。
|
||||
- 修改 StatServerJob 的 u/d 写入方向: 当前面板链路内 u=上行、d=下行一致,贸然反转会破坏历史数据和用户统计。
|
||||
- 新增独立节点详情接口: 本次只影响列表详情卡,新增接口会扩大维护面。
|
||||
回滚边界: 可独立回退 ManageController 的 yesterday/window 变更、前端类型/展示变更和测试文件,不涉及数据库迁移。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### API 设计
|
||||
#### GET server/manage/getNodes
|
||||
- **响应新增字段**: `traffic_stats.yesterday`
|
||||
- **结构**:
|
||||
```json
|
||||
{
|
||||
"traffic_stats": {
|
||||
"today": {"upload": 0, "download": 0, "total": 0},
|
||||
"yesterday": {"upload": 0, "download": 0, "total": 0},
|
||||
"month": {"upload": 0, "download": 0, "total": 0},
|
||||
"total": {"upload": 0, "download": 0, "total": 0}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 数据模型
|
||||
不新增数据表或字段,继续读取 `v2_stat_server` 的日统计记录:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `u` | bigint | 上行流量 |
|
||||
| `d` | bigint | 下行流量 |
|
||||
| `record_at` | int | 日统计归属日的 00:00:00 Unix 时间戳 |
|
||||
| `record_type` | char | 本节点页只读取 `d` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 节点流量详情卡查看昨日统计
|
||||
**模块**: admin-frontend
|
||||
**条件**: 管理员打开 `#/nodes` 并悬停节点名称
|
||||
**行为**: 前端读取 `traffic_stats.yesterday` 并渲染“昨日”行
|
||||
**结果**: 管理员可以直接对比今日、昨日、本月和累计的上行/下行分布
|
||||
|
||||
### 场景: 节点列表接口按清晰窗口聚合
|
||||
**模块**: backend-admin-api
|
||||
**条件**: `v2_stat_server` 存在昨天、今天、本月其他日期和未来日期记录
|
||||
**行为**: `server/manage/getNodes` 构建半开时间窗口
|
||||
**结果**: 今日、昨日、本月统计互不串窗,累计仍覆盖全部历史记录
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### node-traffic-yesterday-stats#D001: 保持 u/d 语义并新增后端 yesterday 字段
|
||||
**日期**: 2026-04-29
|
||||
**状态**: ✅采纳
|
||||
**背景**: 用户反馈上行/下行看起来不匹配,同时要求加入昨日统计。代码链路显示前端、接口和入库任务均使用 `u=upload`、`d=download`。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 后端新增 `traffic_stats.yesterday` 并收紧窗口 | 口径统一、可测试、兼容当前字段 | 增加一个聚合查询 |
|
||||
| B: 前端推导昨日 | 不改后端 | 无法准确得到昨日上行/下行 |
|
||||
| C: 反转 u/d 字段 | 可能符合某些节点端方向理解 | 会破坏现有面板语义和历史统计 |
|
||||
**决策**: 选择方案 A
|
||||
**理由**: 问题核心是缺少可对比的昨日窗口和窗口边界不够明确,不是面板链路内字段反转。
|
||||
**影响**: `server/manage/getNodes` 响应字段增加,节点页详情卡增加一行展示。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证策略
|
||||
|
||||
```yaml
|
||||
verifyMode: test-first
|
||||
reviewerFocus:
|
||||
- app/Http/Controllers/V2/Admin/Server/ManageController.php 的窗口边界和兼容字段
|
||||
- admin-frontend/src/utils/nodes.ts 的展示顺序与空值兜底
|
||||
testerFocus:
|
||||
- php artisan test --filter NodeTrafficStatsTest
|
||||
- npm run build(admin-frontend)
|
||||
uiValidation: optional
|
||||
riskBoundary:
|
||||
- 不执行数据库删除、重置或生产环境操作
|
||||
- 不修改历史 StatServer 数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 成果设计
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: 延续现有 Apple 风格节点详情卡,新增“昨日”作为同等层级数据行,不引入额外视觉系统。
|
||||
- **记忆点**: 今日与昨日紧邻展示,便于直接比较日流量方向变化。
|
||||
- **参考**: 现有节点流量 popover。
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 沿用现有白底、浅灰行底和蓝色总量强调。
|
||||
- **字体**: 沿用现有管理端字体栈,不新增字体依赖。
|
||||
- **布局**: 维持纵向统计行结构,顺序为今日、昨日、本月、累计。
|
||||
- **动效**: 沿用 Element Plus Popover 行为,不新增动效。
|
||||
- **氛围**: 与当前节点页一致。
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: 不改变现有 hover/focus 触发方式。
|
||||
- **响应式**: Popover 宽度维持现状,新增一行不改变表格布局。
|
||||
@@ -0,0 +1,84 @@
|
||||
# 任务清单: node-traffic-yesterday-stats
|
||||
|
||||
> **@status:** completed | 2026-04-29 01:37
|
||||
|
||||
```yaml
|
||||
@feature: node-traffic-yesterday-stats
|
||||
@created: 2026-04-29
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## LIVE_STATUS
|
||||
|
||||
```json
|
||||
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"节点昨日流量统计已实现并完成验证","updated_at":"2026-04-29 01:50:00"}
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 5 | 0 | 0 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 后端统计窗口
|
||||
|
||||
- [√] 1.1 修改 `app/Http/Controllers/V2/Admin/Server/ManageController.php`
|
||||
- 预期变更: `traffic_stats` 增加 `yesterday`,今日/昨日/本月使用半开时间窗口聚合。
|
||||
- 完成标准: 接口保留 `today/month/total`,新增 `yesterday`,空数据返回 0。
|
||||
- 验证方式: `php -l app/Http/Controllers/V2/Admin/Server/ManageController.php`; `vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Unit/Admin/NodeTrafficStatsWindowTest.php`
|
||||
- depends_on: []
|
||||
- 完成备注: 已新增 `resolveNodeTrafficWindows()` 并让 `fillTrafficWindow()` 使用 `record_at >= start` 与 `record_at < end` 的半开窗口。
|
||||
|
||||
### 2. 前端展示
|
||||
|
||||
- [√] 2.1 修改 `admin-frontend/src/types/api.d.ts`
|
||||
- 预期变更: `AdminNodeTrafficStats` 类型增加 `yesterday: TrafficAmount`。
|
||||
- 完成标准: TypeScript 类型与后端响应字段一致。
|
||||
- 验证方式: `npm run build`
|
||||
- depends_on: [1.1]
|
||||
- 完成备注: `AdminNodeTrafficStats` 已包含 `yesterday`。
|
||||
- [√] 2.2 修改 `admin-frontend/src/utils/nodes.ts`
|
||||
- 预期变更: `getNodeTrafficDetails()` 在今日后展示昨日。
|
||||
- 完成标准: 节点详情卡顺序为今日、昨日、本月、累计,缺失字段时显示 0。
|
||||
- 验证方式: `npm run build`
|
||||
- depends_on: [2.1]
|
||||
- 完成备注: 节点流量详情顺序已调整为今日、昨日、本月、累计。
|
||||
|
||||
### 3. 验证与知识库
|
||||
|
||||
- [√] 3.1 新增或更新后端单元测试
|
||||
- 预期变更: 覆盖今日、昨日、本月和累计窗口边界。
|
||||
- 完成标准: 测试能证明未来记录不进入今日/月统计,昨日记录独立统计。
|
||||
- 验证方式: `vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Unit/Admin/NodeTrafficStatsWindowTest.php`
|
||||
- depends_on: [1.1]
|
||||
- 完成备注: 已新增窗口边界单元测试,覆盖普通日期和月初日期。
|
||||
- [√] 3.2 执行构建/测试并同步知识库
|
||||
- 预期变更: 运行可用验证命令,更新 `.helloagents` 模块文档和变更日志。
|
||||
- 完成标准: 验证结果记录在执行日志,知识库反映 `traffic_stats.yesterday`。
|
||||
- 验证方式: 文件检查 + 命令输出
|
||||
- depends_on: [2.2, 3.1]
|
||||
- 完成备注: 已通过 PHP 语法检查、PHPUnit 单元测试和管理端前端构建,知识库与 CHANGELOG 已同步。
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-29 01:23:00 | DESIGN | in_progress | 已完成上下文收集和方案包创建 |
|
||||
| 2026-04-29 01:38:00 | DEVELOP 1.1 | completed | 后端新增 yesterday 窗口并收紧 today/month 上界 |
|
||||
| 2026-04-29 01:40:00 | DEVELOP 2.1-2.2 | completed | 前端类型和节点详情卡展示已加入“昨日” |
|
||||
| 2026-04-29 01:44:00 | DEVELOP 3.1 | completed | 新增节点流量窗口边界单元测试 |
|
||||
| 2026-04-29 01:50:00 | DEVELOP 3.2 | completed | 验证命令通过,知识库同步完成 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 面板链路中 `StatServer.u` 对应上行、`StatServer.d` 对应下行;本次不反转历史语义。
|
||||
- 用户截图中的“今日下行多、本月上行多”本身可能是正常数据分布,因为本月包含今天及之前日期;新增昨日后便于判断差异来自哪一天。
|
||||
@@ -0,0 +1,179 @@
|
||||
# 变更提案: shared-node-traffic-limit
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 修复
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 草稿
|
||||
创建: 2026-04-29
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
节点管理页的“流量统计”弹层中,“月额度”当前优先使用单个节点的 mi-node metrics 或 `v2_server.u + v2_server.d`。当同一台机器上配置多个节点时,机器月流量额度是共享的,单节点口径会低估或拆散真实使用量。现有“本月”统计也按自然月聚合,不等同于机器供应商从指定重置日开始的账期。
|
||||
|
||||
### 目标
|
||||
- 同一台机器 / 同一 IP 下的多个节点共享月额度使用量。
|
||||
- 月额度使用量按当前限额配置的上一个重置边界到现在统计,而不是按自然月或单节点累计。
|
||||
- 节点管理页的“月额度 used / limit”、进度条和限额状态使用后端统一快照。
|
||||
- 保留“今日 / 昨日 / 本月 / 累计”节点流量统计的现有展示口径,不把自然月统计改成账期统计。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 无
|
||||
性能约束: 节点列表接口需要避免全表扫描式重复统计,按当前节点集合和共享范围聚合
|
||||
兼容性约束: 不新增必填数据库字段;未配置 machine_id 的节点仍可按 host/IP 共享
|
||||
业务约束: 不执行生产数据修复;不改变节点显隐和用户订阅筛选规则
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 同一 `machine_id` 的两个限额节点,节点管理页显示相同的月额度已用量。
|
||||
- [ ] 未配置 `machine_id` 但 `host` 相同的两个限额节点,节点管理页显示相同的月额度已用量。
|
||||
- [ ] 月额度已用量从当前时间之前最近一次重置边界开始统计,例如重置日 18 日则按最近一个 18 日 00:00 起算。
|
||||
- [ ] 不同 `machine_id` 或不同 `host` 的节点不互相累加。
|
||||
- [ ] 前端在后端缺少共享快照时仍能回退到原有 metrics / `u + d` 展示。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
在 `ServerTrafficLimitService` 中集中新增共享限额快照能力:
|
||||
- 共享范围优先使用 `machine_id`;未绑定机器时使用规范化后的 `host`。空 host 回退为单节点范围。
|
||||
- 新增当前账期起点计算:根据节点的 `traffic_limit_reset_day / traffic_limit_reset_time / traffic_limit_timezone` 计算“当前时间之前最近一次重置时间”。
|
||||
- 新增共享账期用量统计:从 `v2_stat_server` 中按共享范围内的 `server_id` 聚合 `record_type='d'` 的 `u/d`,统计窗口为账期起点到当前日。若节点未持久化或没有统计来源,再回退到共享范围内的 `u + d`。
|
||||
- `buildNodeConfig()` 继续返回 mi-node 既有 `traffic_limit` 结构,但 `current_used` 改用共享账期用量。
|
||||
- 管理端 `server/manage/getNodes` 为每个节点追加 `traffic_limit_snapshot`,前端月额度优先使用该快照展示。
|
||||
- 管理端 TypeScript 类型和 `getNodeTrafficLimitDetail()` 同步增加快照优先级,保持缺省兼容。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- node-traffic-limit: 限额账期、共享范围和当前用量计算
|
||||
- admin-frontend: 节点管理页月额度展示数据源
|
||||
预计变更文件: 5-7 个
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| `v2_stat_server.record_at` 是日粒度,非 00:00 重置时间无法做到小时级切分 | 中 | 按重置日所在自然日作为统计起点,保留 `traffic_limit_last_reset_at` 和 mi-node metrics 作为运行态辅助;在测试中覆盖 00:00 主路径 |
|
||||
| 同 host 但实际不是同一机器的节点会被共享统计 | 中 | 优先使用 `machine_id`;未绑定机器时按用户明确要求的同 IP/host 兜底,并在知识库记录规则 |
|
||||
| 前端旧数据结构缺少新快照 | 低 | 前端保留原有 metrics / `u + d` 回退 |
|
||||
| 改动影响 mi-node 下发的 `current_used` | 中 | 仅改变限额模块中的用量口径,不改变配置字段名;用单元测试覆盖共享和非共享场景 |
|
||||
|
||||
### 方案取舍
|
||||
```yaml
|
||||
唯一方案理由: 共享用量属于限额领域逻辑,集中在 ServerTrafficLimitService 能同时服务节点配置下发和管理端展示,避免前端自行猜测同机节点。
|
||||
放弃的替代路径:
|
||||
- 仅前端按 host 汇总: 只能修展示,无法修正 mi-node 下发的 current_used,且会复制业务规则
|
||||
- 新增 machine_quota 表: 能表达机器级额度,但超出本次问题范围,需要新增配置入口和迁移
|
||||
- 每次 DNS 解析 host 后按真实 IP 汇总: 接口性能和网络副作用不可控,且域名解析会受环境影响
|
||||
回滚边界: 可独立回退 ServerTrafficLimitService 的共享快照、ManageController 的 traffic_limit_snapshot 返回和前端快照优先展示;不涉及数据库结构回滚
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[ManageController getNodes] --> B[ServerTrafficLimitService buildSnapshotsForServers]
|
||||
C[ServerService buildNodeConfig] --> D[ServerTrafficLimitService buildNodeConfig]
|
||||
B --> E[shared scope: machine_id or host]
|
||||
D --> E
|
||||
E --> F[v2_stat_server cycle usage]
|
||||
B --> G[traffic_limit_snapshot]
|
||||
G --> H[admin-frontend getNodeTrafficLimitDetail]
|
||||
```
|
||||
|
||||
### API 设计
|
||||
#### GET `server/manage/getNodes`
|
||||
- **请求**: 保持不变。
|
||||
- **响应**: 每个节点新增可选字段:
|
||||
```json
|
||||
{
|
||||
"traffic_limit_snapshot": {
|
||||
"enabled": true,
|
||||
"limit": 1073741824000,
|
||||
"used": 616327110656,
|
||||
"percent": 57,
|
||||
"suspended": false,
|
||||
"status": "normal",
|
||||
"cycle_start_at": 1776441600,
|
||||
"last_reset_at": 1776441600,
|
||||
"next_reset_at": 1779033600,
|
||||
"scope_key": "host:82.40.33.225",
|
||||
"scope_node_ids": [327, 272]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 数据模型
|
||||
不新增数据库字段。共享范围由现有 `v2_server.machine_id` 和 `v2_server.host` 推导。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 同 IP 节点共享月额度
|
||||
**模块**: node-traffic-limit
|
||||
**条件**: 两个节点 `host` 相同,均启用月流量限额,重置日一致。
|
||||
**行为**: 管理端打开节点列表并悬停任一节点名称。
|
||||
**结果**: 两个节点的“月额度”已用量均为该 host 下节点账期流量合计。
|
||||
|
||||
### 场景: 绑定机器优先共享
|
||||
**模块**: node-traffic-limit
|
||||
**条件**: 两个节点绑定相同 `machine_id`,host 可以不同。
|
||||
**行为**: 后端生成节点列表或 mi-node 配置。
|
||||
**结果**: 共享范围按 `machine_id` 聚合,不再按 host 分裂。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### shared-node-traffic-limit#D001: 共享范围优先 machine_id,兜底 host
|
||||
**日期**: 2026-04-29
|
||||
**状态**: ✅采纳
|
||||
**背景**: 项目已有 `v2_server_machine` 和 `machine_id`,但用户当前问题来自同 IP 多节点共享机器额度,不能要求所有旧节点先补机器绑定。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 只按 machine_id | 语义最准确 | 旧节点或未绑定机器的同 IP 节点无法修复 |
|
||||
| B: 只按 host/IP | 满足截图场景 | 已绑定机器但 host 不同的同机节点会被拆散 |
|
||||
| C: machine_id 优先,host 兜底 | 覆盖新旧两类场景,改动较小 | host 相同但非同机的节点会共享统计 |
|
||||
**决策**: 选择方案 C。
|
||||
**理由**: 在不新增配置入口的前提下,C 能覆盖已有机器模型和用户明确的同 IP 场景。
|
||||
**影响**: `ServerTrafficLimitService` 成为共享范围规则的唯一实现位置,管理端和节点配置下发共用该规则。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证策略
|
||||
|
||||
```yaml
|
||||
verifyMode: test-first
|
||||
reviewerFocus:
|
||||
- app/Services/ServerTrafficLimitService.php 的账期起点、共享范围和回退逻辑
|
||||
- app/Http/Controllers/V2/Admin/Server/ManageController.php 的响应兼容性
|
||||
- admin-frontend/src/utils/nodes.ts 的快照优先级和旧数据回退
|
||||
testerFocus:
|
||||
- vendor/bin/phpunit tests/Unit/ServerTrafficLimitServiceTest.php
|
||||
- vendor/bin/phpunit tests/Unit/Admin/NodeTrafficStatsWindowTest.php
|
||||
- php -l app/Services/ServerTrafficLimitService.php
|
||||
- php -l app/Http/Controllers/V2/Admin/Server/ManageController.php
|
||||
uiValidation: optional
|
||||
riskBoundary:
|
||||
- 不执行数据库迁移或生产数据更新
|
||||
- 不修改删除节点、重置节点流量等破坏性接口语义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 成果设计
|
||||
|
||||
N/A。本次不调整节点管理页视觉结构,只修正“月额度”展示数据源。
|
||||
@@ -0,0 +1,81 @@
|
||||
# 任务清单: shared-node-traffic-limit
|
||||
|
||||
> **@status:** completed | 2026-04-29 01:56
|
||||
|
||||
```yaml
|
||||
@feature: shared-node-traffic-limit
|
||||
@created: 2026-04-29
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## LIVE_STATUS
|
||||
|
||||
```json
|
||||
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"开发实施、验证和知识库同步完成","updated_at":"2026-04-29 02:08:00"}
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 5 | 0 | 0 | 5 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 后端共享限额口径
|
||||
|
||||
- [√] 1.1 修改 `app/Services/ServerTrafficLimitService.php`
|
||||
- 预期变更: 新增共享范围解析、当前账期起点计算、共享账期用量聚合和批量快照输出;`buildNodeConfig()` 的 `current_used` 使用共享账期口径。
|
||||
- 完成标准: 同 `machine_id` 或同 `host` 节点可得到一致 used;不同范围节点互不累加;未启用限额仍返回 disabled。
|
||||
- 验证方式: `vendor/bin/phpunit tests/Unit/ServerTrafficLimitServiceTest.php`,并执行 `php -l app/Services/ServerTrafficLimitService.php`。
|
||||
- depends_on: []
|
||||
|
||||
- [√] 1.2 修改 `app/Http/Controllers/V2/Admin/Server/ManageController.php`
|
||||
- 预期变更: `getNodes` 批量生成并返回 `traffic_limit_snapshot`;保留 `traffic_stats` 现有自然日/自然月统计。
|
||||
- 完成标准: 响应中每个节点包含可选快照字段;没有快照时不影响原节点列表返回。
|
||||
- 验证方式: `php -l app/Http/Controllers/V2/Admin/Server/ManageController.php`,并用相关单元测试覆盖窗口不回归。
|
||||
- depends_on: [1.1]
|
||||
|
||||
### 2. 管理端展示兼容
|
||||
|
||||
- [√] 2.1 修改 `admin-frontend/src/types/api.d.ts`
|
||||
- 预期变更: 为节点接口补充 `AdminNodeTrafficLimitSnapshot` 和 `traffic_limit_snapshot` 类型。
|
||||
- 完成标准: TypeScript 能识别新字段,旧字段类型不被破坏。
|
||||
- 验证方式: 运行可用的前端类型检查或构建命令;不可用时至少静态检查引用。
|
||||
- depends_on: [1.2]
|
||||
|
||||
- [√] 2.2 修改 `admin-frontend/src/utils/nodes.ts`
|
||||
- 预期变更: `getNodeTrafficLimitDetail()` 优先使用 `traffic_limit_snapshot` 的 limit、used、status 和 reset 时间,缺失时回退到 metrics / `u + d`。
|
||||
- 完成标准: 有快照时展示共享账期用量;无快照时展示行为与旧版一致。
|
||||
- 验证方式: 前端类型检查或构建;人工核对逻辑分支。
|
||||
- depends_on: [2.1]
|
||||
|
||||
### 3. 测试与知识库
|
||||
|
||||
- [√] 3.1 修改 `tests/Unit/ServerTrafficLimitServiceTest.php`、`.helloagents/modules/node-traffic-limit.md`、`.helloagents/modules/admin-frontend.md`
|
||||
- 预期变更: 增加共享 host / machine 账期用量测试;更新知识库记录共享限额口径和管理端快照字段。
|
||||
- 完成标准: 测试覆盖同范围聚合、不同范围隔离、账期起点;知识库与代码行为一致。
|
||||
- 验证方式: 运行后端测试和语法检查;检查知识库描述不再声称限额只按单节点 `u/d`。
|
||||
- depends_on: [1.1, 1.2, 2.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-29 02:08 | 3.1 | completed | 已补充共享 host / machine / 账期起点 / runtime suspended 测试,并完成知识库同步 |
|
||||
| 2026-04-29 02:06 | 验证 | completed | PHPUnit 10 tests / 44 assertions 通过;admin-frontend 构建通过 |
|
||||
| 2026-04-29 02:03 | 2.1-2.2 | completed | 管理端类型与月额度展示已优先消费 `traffic_limit_snapshot` |
|
||||
| 2026-04-29 01:58 | 1.1-1.2 | completed | 后端已生成共享账期快照并接入 `server/manage/getNodes` |
|
||||
| 2026-04-29 01:32 | DESIGN | in_progress | 已完成方案设计与任务拆分 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 当前执行模式: INTERACTIVE。
|
||||
- 不新增数据库字段,不执行生产数据操作。
|
||||
@@ -0,0 +1,171 @@
|
||||
# 变更提案: parent-node-auto-visibility
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 修复
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已设计
|
||||
创建: 2026-04-29
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
当前父节点自动状态链路存在不一致:
|
||||
- `ServerAutoOnlineService` 只同步开启 `auto_online` 的节点自身;父节点因离线被自动隐藏时,不会保证其子节点同步隐藏。
|
||||
- `ServerTrafficLimitService` 将流量限额超额状态写入 `traffic_limit_status`,既有知识库明确记录“不修改 `show`”,因此父节点超额后子节点仍可能保持展示。
|
||||
- 墙检测已有 `gfw_auto_hidden` 标记,只恢复上次由墙检测自动隐藏的节点;本次需求需要为“父节点自动下线”建立同等可追溯标记,避免误恢复原本手动隐藏的子节点。
|
||||
|
||||
### 目标
|
||||
- 父节点因系统自动逻辑变为不可展示时,自动隐藏当时仍展示的子节点。
|
||||
- 父节点由系统自动逻辑恢复可展示时,只恢复上一次由该父节点联动逻辑自动隐藏的子节点。
|
||||
- 原本 `show=0`、管理员手动隐藏、后续被手动调整的子节点不能被误上线。
|
||||
- 覆盖自动上线同步、流量限额超额/恢复等当前可定位的自动状态入口,并保留墙检测自身的独立标记逻辑。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮完成后端实现、迁移、测试和知识库同步
|
||||
性能约束: 子节点联动只按单个父节点查询/更新,不引入全表循环外的额外扫描
|
||||
兼容性约束: 不改变现有管理端 API 请求结构,不改变 mi-node 下发协议
|
||||
业务约束: 只修改系统自动联动产生的 show 状态;不修改 enabled、auto_online、gfw_check_enabled
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 父节点自动下线时,当前 `show=1` 的子节点被隐藏并打上父级自动隐藏标记。
|
||||
- [ ] 父节点自动恢复时,仅 `parent_auto_hidden=1` 的子节点恢复展示,原本隐藏的子节点保持隐藏。
|
||||
- [ ] 管理员手动修改子节点 `show` 时会清除父级自动隐藏标记,后续父节点恢复不会覆盖人工决定。
|
||||
- [ ] 流量限额从 normal 变为 suspended 时触发子节点隐藏,从 suspended/超额状态恢复为 normal 时触发标记子节点恢复。
|
||||
- [ ] 相关单元测试通过,至少覆盖自动上线和流量限额两条入口。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
新增一组父节点联动标记字段到 `v2_server`:
|
||||
- `parent_auto_hidden`: 子节点是否由父节点自动状态联动隐藏。
|
||||
- `parent_auto_action_at`: 最近一次父节点联动操作时间。
|
||||
|
||||
新增 `ServerParentVisibilityService` 作为集中服务:
|
||||
- `hideChildrenForParent(Server $parent)`: 只隐藏当前 `show=1` 的直接子节点,并设置 `parent_auto_hidden=1`。
|
||||
- `restoreChildrenForParent(Server $parent)`: 只恢复 `parent_auto_hidden=1` 且未被其他自动隐藏原因阻断的直接子节点,并清除标记。
|
||||
- `clearParentAutoHidden(Server $server)`: 管理员手动调整节点展示状态时清除标记,防止后续自动恢复覆盖人工操作。
|
||||
|
||||
接入点:
|
||||
- `ServerAutoOnlineService`: 父节点自动同步后,根据父节点最终 `show` 决定隐藏或恢复子节点;即使父节点自身状态未变化,也根据当前最终状态补齐子节点联动。
|
||||
- `ServerTrafficLimitService`: `refreshSchedule()`、`resetServer()`、`applyRuntimeMetrics()` 写入限额运行状态后,对父节点执行子节点隐藏/恢复。超额或节点端上报 suspended 时隐藏;恢复 normal 或重置后恢复标记子节点。
|
||||
- `ManageController`: 在单节点保存、快速更新、批量更新中,手动传入 `show` 时同步清除 `parent_auto_hidden`。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- node-traffic-limit: 限额 suspended/normal 状态影响子节点展示联动
|
||||
- node-auto-online: 自动上线同步影响父节点子节点展示联动
|
||||
- admin-server-manage: 手动 show 修改时清理自动联动标记
|
||||
预计变更文件: 8
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 恢复子节点时覆盖其他自动隐藏原因 | 中 | 恢复时跳过 `gfw_auto_hidden=1` 的节点,并只处理 `parent_auto_hidden=1` 的子节点 |
|
||||
| 流量限额状态频繁上报导致重复更新 | 低 | 服务方法先按当前状态筛选,只更新需要变化的子节点 |
|
||||
| 新字段未迁移导致运行时异常 | 中 | 添加幂等迁移、模型 casts 和测试覆盖 |
|
||||
|
||||
### 方案取舍
|
||||
```yaml
|
||||
唯一方案理由: 独立 `parent_auto_hidden` 标记能精确表达“上次由父节点联动自动下线”的来源,满足只恢复自动下线子节点的要求,且不会污染墙检测专用字段。
|
||||
放弃的替代路径:
|
||||
- 复用 `gfw_auto_hidden`: 会把墙检测和父节点自动联动混在一起,恢复时无法区分原因。
|
||||
- 不加字段、只按当前 show 推断: 无法判断子节点原本是否手动隐藏,会误上线。
|
||||
回滚边界: 可回退新增服务接入、模型字段和迁移;数据库字段保留时不会影响旧逻辑,删除字段需单独迁移。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[ServerAutoOnlineService] --> C[ServerParentVisibilityService]
|
||||
B[ServerTrafficLimitService] --> C
|
||||
D[ManageController manual show] --> C
|
||||
C --> E[v2_server parent_auto_hidden]
|
||||
C --> F[v2_server show]
|
||||
```
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| parent_auto_hidden | boolean default false | 子节点是否由父节点自动状态联动隐藏 |
|
||||
| parent_auto_action_at | unsignedBigInteger nullable | 最近一次父节点联动隐藏或恢复时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 父节点自动下线联动子节点
|
||||
**模块**: node-auto-online / node-traffic-limit
|
||||
**条件**: 父节点因自动上线检测离线、流量限额超额或其他系统自动状态变为不可展示;子节点 A 当前 `show=1`,子节点 B 当前 `show=0`。
|
||||
**行为**: 服务隐藏子节点 A 并设置 `parent_auto_hidden=1`,子节点 B 保持隐藏且不设置标记。
|
||||
**结果**: 父节点恢复时只恢复子节点 A。
|
||||
|
||||
### 场景: 父节点自动恢复只恢复上次自动下线子节点
|
||||
**模块**: node-auto-online / node-traffic-limit
|
||||
**条件**: 父节点恢复可展示;子节点 A `parent_auto_hidden=1`,子节点 B 是手动隐藏。
|
||||
**行为**: 服务恢复子节点 A 并清除标记,子节点 B 不变。
|
||||
**结果**: 不误上线原本未展示的子节点。
|
||||
|
||||
### 场景: 管理员手动修改子节点展示状态
|
||||
**模块**: admin-server-manage
|
||||
**条件**: 子节点此前由父节点联动隐藏,管理员手动设置 `show`。
|
||||
**行为**: 控制器清除 `parent_auto_hidden` 和 `parent_auto_action_at`。
|
||||
**结果**: 后续父节点自动恢复不会覆盖管理员最新选择。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### parent-node-auto-visibility#D001: 使用独立父级自动隐藏标记
|
||||
**日期**: 2026-04-29
|
||||
**状态**: ✅采纳
|
||||
**背景**: 需求要求恢复“上次自动下线”的子节点,不能恢复原本未启用或手动隐藏的子节点。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 独立 `parent_auto_hidden` 标记 | 语义清晰,可与墙检测、手动隐藏并存 | 需要新增迁移和模型字段 |
|
||||
| B: 复用 `gfw_auto_hidden` | 改动少 | 原因混淆,容易误恢复墙检测隐藏节点 |
|
||||
| C: 不持久化标记 | 无数据库变更 | 不能跨进程、跨重启准确恢复 |
|
||||
**决策**: 选择方案 A
|
||||
**理由**: 只有持久化来源标记能准确表达“上次被父节点自动联动下线”的子节点集合。
|
||||
**影响**: `v2_server` 表、节点自动上线服务、流量限额服务、管理端节点状态接口、相关测试。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证策略
|
||||
|
||||
```yaml
|
||||
verifyMode: test-first
|
||||
reviewerFocus:
|
||||
- app/Services/ServerParentVisibilityService.php 的恢复条件是否避免误上线
|
||||
- ServerAutoOnlineService 与 ServerTrafficLimitService 的触发时机是否覆盖状态变化
|
||||
- ManageController 手动 show 修改是否清除自动标记
|
||||
testerFocus:
|
||||
- vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php tests/Unit/ServerTrafficLimitServiceTest.php
|
||||
- php -l 新增/修改的 PHP 文件
|
||||
uiValidation: none
|
||||
riskBoundary:
|
||||
- 不执行生产数据库迁移
|
||||
- 不调用生产 API
|
||||
- 不修改 mi-node 协议
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 成果设计
|
||||
|
||||
N/A。此次为后端状态联动与数据标记修复,不涉及视觉产出。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 任务清单: parent-node-auto-visibility
|
||||
|
||||
> **@status:** completed | 2026-04-29 02:07
|
||||
|
||||
```yaml
|
||||
@feature: parent-node-auto-visibility
|
||||
@created: 2026-04-29
|
||||
@status: completed
|
||||
@mode: R2
|
||||
@type: implementation
|
||||
```
|
||||
|
||||
## LIVE_STATUS
|
||||
|
||||
```json
|
||||
{"status":"completed","completed":6,"failed":0,"pending":0,"total":6,"percent":100,"current":"父节点自动下线联动子节点隐藏与恢复已完成并通过验证","updated_at":"2026-04-29 02:14:00"}
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 6 | 0 | 0 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 数据模型与联动服务
|
||||
|
||||
- [√] 1.1 新增 `v2_server` 父级自动隐藏标记字段
|
||||
- 文件路径或作用范围: `database/migrations/*_add_parent_auto_visibility_fields_to_v2_server_table.php`, `app/Models/Server.php`
|
||||
- 预期变更: 增加 `parent_auto_hidden` 与 `parent_auto_action_at` 字段,补充模型 docblock 和 casts。
|
||||
- 完成标准: 新字段迁移幂等,模型可布尔/整数转换字段。
|
||||
- 验证方式: `php -l` 检查新增迁移和模型语法。
|
||||
- depends_on: []
|
||||
|
||||
- [√] 1.2 新增父节点子节点展示联动服务
|
||||
- 文件路径或作用范围: `app/Services/ServerParentVisibilityService.php`
|
||||
- 预期变更: 实现隐藏当前展示子节点、恢复被标记子节点、清除手动标记的集中方法。
|
||||
- 完成标准: 只标记本次自动隐藏的子节点;恢复时不恢复未标记或仍被墙检测隐藏的节点。
|
||||
- 验证方式: 单元测试覆盖自动隐藏和恢复行为。
|
||||
- depends_on: [1.1]
|
||||
|
||||
### 2. 自动状态入口接入
|
||||
|
||||
- [√] 2.1 接入自动上线同步
|
||||
- 文件路径或作用范围: `app/Services/ServerAutoOnlineService.php`
|
||||
- 预期变更: 父节点自动同步后根据最终展示状态隐藏或恢复直接子节点;结果统计包含子节点联动更新。
|
||||
- 完成标准: 父节点离线自动隐藏时同步隐藏展示中的子节点;父节点恢复在线时只恢复 `parent_auto_hidden=1` 的子节点。
|
||||
- 验证方式: `tests/Unit/ServerAutoOnlineServiceTest.php` 新增断言。
|
||||
- depends_on: [1.2]
|
||||
|
||||
- [√] 2.2 接入流量限额超额和恢复
|
||||
- 文件路径或作用范围: `app/Services/ServerTrafficLimitService.php`
|
||||
- 预期变更: `refreshSchedule()`、`resetServer()`、`applyRuntimeMetrics()` 状态落库后触发父节点子节点隐藏/恢复。
|
||||
- 完成标准: suspended 隐藏子节点,normal/reset 恢复被标记子节点,未启用限额不触发误恢复。
|
||||
- 验证方式: `tests/Unit/ServerTrafficLimitServiceTest.php` 新增断言。
|
||||
- depends_on: [1.2]
|
||||
|
||||
- [√] 2.3 清理手动 show 修改的自动联动标记
|
||||
- 文件路径或作用范围: `app/Http/Controllers/V2/Admin/Server/ManageController.php`
|
||||
- 预期变更: 单节点保存、快速更新、批量更新接收 `show` 时清除 `parent_auto_hidden` 和 `parent_auto_action_at`。
|
||||
- 完成标准: 手动显示/隐藏子节点后,后续父节点恢复不会覆盖人工决定。
|
||||
- 验证方式: 代码检查和相关服务测试覆盖标记清除方法。
|
||||
- depends_on: [1.1, 1.2]
|
||||
|
||||
### 3. 验证与知识库
|
||||
|
||||
- [√] 3.1 补充测试、知识库和变更记录
|
||||
- 文件路径或作用范围: `tests/Unit/ServerAutoOnlineServiceTest.php`, `tests/Unit/ServerTrafficLimitServiceTest.php`, `.helloagents/modules/node-traffic-limit.md`, `.helloagents/context.md`, `.helloagents/CHANGELOG.md`
|
||||
- 预期变更: 增加自动上线与流量限额联动测试,更新知识库说明和变更记录。
|
||||
- 完成标准: 目标测试通过或明确记录环境阻塞;知识库反映代码事实。
|
||||
- 验证方式: `vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php tests/Unit/ServerTrafficLimitServiceTest.php`
|
||||
- depends_on: [2.1, 2.2, 2.3]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-29 01:53 | 方案设计 | in_progress | 已完成上下文收集与任务拆分 |
|
||||
| 2026-04-29 02:03 | 1.1-2.3 | completed | 已完成迁移、模型、联动服务和入口接入 |
|
||||
| 2026-04-29 02:08 | 3.1 | completed | 已补充自动上线和流量限额测试 |
|
||||
| 2026-04-29 02:14 | 验证 | completed | PHP 语法、PHPStan 和目标 PHPUnit 测试通过 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 当前已有遗留方案包 `202604250006_ticket-closed-reply-reopen` 标记 `in_progress`,本任务作为新方案包独立执行。
|
||||
- 不执行生产数据库迁移,仅提交迁移文件和测试。
|
||||
- 验证命令:
|
||||
- `php -l` 检查新增/修改 PHP 文件。
|
||||
- `vendor\bin\phpstan analyse app\Services\ServerParentVisibilityService.php app\Services\ServerAutoOnlineService.php app\Services\ServerTrafficLimitService.php app\Http\Controllers\V2\Admin\Server\ManageController.php app\Models\Server.php --memory-limit=1G`
|
||||
- 使用一次性 SQLite 文件执行 `vendor\bin\phpunit tests\Unit\ServerAutoOnlineServiceTest.php tests\Unit\ServerTrafficLimitServiceTest.php`,结果 17 tests / 83 assertions 通过。
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 202604290153 | parent-node-auto-visibility | - | - | - | ✅完成 |
|
||||
| 202604290132 | shared-node-traffic-limit | implementation | node-traffic-limit,admin-frontend | shared-node-traffic-limit#D001 | ✅完成 |
|
||||
| 202604290123 | node-traffic-yesterday-stats | implementation | admin-frontend,backend-admin-api | node-traffic-yesterday-stats#D001 | ✅完成 |
|
||||
| 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 | - | - | - | ✅完成 |
|
||||
@@ -47,6 +50,8 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-04
|
||||
- [202604290132_shared-node-traffic-limit](./2026-04/202604290132_shared-node-traffic-limit/) - 修正节点管理月额度使用量口径,同 `machine_id` 或同 host 节点共享当前账期用量,并由后端快照统一服务管理端展示和 mi-node 下发
|
||||
- [202604290123_node-traffic-yesterday-stats](./2026-04/202604290123_node-traffic-yesterday-stats/) - 节点流量详情卡新增“昨日”统计,并让今日、昨日和本月统计按半开窗口聚合,便于核对上行/下行流量分布
|
||||
- [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`、初始化/部署/更新/状态检查脚本和部署说明
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
- `server/manage/checkGfw`
|
||||
- `server/manage/resetTraffic`
|
||||
- `server/manage/batchResetTraffic`
|
||||
- 节点月流量限额由 Xboard 保存和编排:`v2_server.transfer_enable` 作为月额度,`traffic_limit_*` 字段记录启用、重置日/时间/时区和节点端运行状态;`ServerTrafficLimitService` 负责下发 `traffic_limit`、手动/定时重置、metrics 状态回写和通知 mi-node
|
||||
- 节点月流量限额由 Xboard 保存和编排:`v2_server.transfer_enable` 作为月额度,`traffic_limit_*` 字段记录启用、重置日/时间/时区和节点端运行状态;`ServerTrafficLimitService` 负责下发 `traffic_limit`、手动/定时重置、metrics 状态回写、父节点限额下线时的子节点联动显隐和通知 mi-node
|
||||
- 管理端套餐管理现已接入:
|
||||
- `plan/fetch`
|
||||
- `plan/save`
|
||||
@@ -116,9 +116,9 @@
|
||||
- 管理端路由使用 Hash 模式
|
||||
- 管理端当前业务路由包含 `/dashboard`、`/users`、`/tickets`、`/nodes`、`/node-groups`、`/node-routes`、`/subscriptions/plans`、`/subscriptions/orders`、`/subscriptions/coupons`、`/subscriptions/gift-cards`、`/system/config`、`/system/notices`、`/system/payments`、`/system/plugins`、`/system/themes` 与 `/system/knowledge`
|
||||
- `#/nodes` 当前已升级为真实节点工作台:支持搜索、在线 / 离线筛选、显隐筛选、父/子节点筛选、墙状态筛选、分页浏览、显隐切换、自动上线托管开关、墙检测托管开关、刷新数据、复制、单节点置顶、仅对已勾选节点生效的批量修改 / 批量删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框
|
||||
- 节点自动上线由后端 `ServerAutoOnlineService` 统一执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;管理端保存 / 开启自动上线、REST 心跳和 WebSocket 状态上报会触发当前节点即时同步,`sync:server-auto-online` 每 5 分钟继续兜底;未开启自动上线的节点继续保持手动显隐控制;墙状态为 `blocked` 或仍处于 `gfw_auto_hidden` 且未恢复正常时会否决自动显示
|
||||
- 节点自动上线由后端 `ServerAutoOnlineService` 统一执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;父节点自动隐藏时会通过 `parent_auto_hidden` 标记隐藏当时仍显示的直接子节点,父节点自动恢复时只恢复这批子节点;管理端保存 / 开启自动上线、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` 伪装下线
|
||||
- 节点新增 / 编辑弹窗支持配置月流量限额、重置日期、重置时间和时区;节点列表流量详情卡会展示月额度、当前已用、限额状态和下次重置。限额超额后的父节点真实下线由 mi-node 本地执行,Xboard 不通过父节点自身 `show` 或 `auto_online` 伪装下线;若超额节点是父节点,Xboard 会同步隐藏当时仍显示的直接子节点并在恢复时只恢复 `parent_auto_hidden=1` 的子节点
|
||||
- Compose 部署必须确保 Laravel Scheduler 持续运行;`deploy/xboard-server/compose.yaml` 通过独立 `scheduler` 服务执行 `php artisan schedule:work`,否则自动墙检测只会在手动触发时创建任务
|
||||
- Bearer Token 存储于 `sessionStorage/localStorage`
|
||||
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
| 模块名 | 说明 | 最近更新 |
|
||||
|--------|------|----------|
|
||||
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-28 |
|
||||
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-29 |
|
||||
| [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 |
|
||||
| [node-traffic-limit](node-traffic-limit.md) | 节点月流量限额配置、共享账期用量、重置调度、metrics 状态回写与 mi-node 强制下线协作 | 2026-04-29 |
|
||||
| [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 |
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
- 节点管理页现支持墙状态展示、墙状态筛选与关键词搜索;父节点可通过行级或批量操作发起检测,子节点不单独检测并显示“随父节点”的继承状态
|
||||
- 节点管理页现支持“墙检测托管”开关、批量设置和刷新数据按钮;父节点开启后参与 `sync:server-gfw-checks` 自动检测,自动墙检统计只计算父节点;子节点不独立检测但可控制是否随父节点自动隐藏 / 恢复
|
||||
- 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 payload 并提交到 `server/manage/sort`
|
||||
- 节点列表中鼠标悬停节点名称会显示节点流量详情卡;`server/manage/getNodes` 会返回 `traffic_stats.today/month/total`,三组数据均来自 `v2_stat_server` 按节点聚合,前端统一按 B/KB/MB/GB/TB 自适应格式化展示上行、下行和合计
|
||||
- 节点列表中鼠标悬停节点名称会显示节点流量详情卡;`server/manage/getNodes` 会返回 `traffic_stats.today/yesterday/month/total`,其中今日、昨日和本月按半开时间窗口从 `v2_stat_server` 聚合,累计不加时间窗口,前端统一按 B/KB/MB/GB/TB 自适应格式化展示上行、下行和合计
|
||||
- 节点新增 / 编辑弹窗现支持月流量限额配置,字段包含启用开关、月流量额度 GB、重置日期、重置时间和时区;保存时会把 GB 转为字节写入 `transfer_enable`,并提交 `traffic_limit_*` 字段
|
||||
- 节点流量详情卡会在启用限额时追加“月额度”进度、限额状态和下次重置时间,节点标签区同步显示正常 / 接近额度 / 已限额状态;搜索关键字可匹配“流量限额 / 月流量 / 超额下线”
|
||||
- 节点流量详情卡会在启用限额时追加“月额度”进度、限额状态和下次重置时间,优先使用 `server/manage/getNodes` 返回的 `traffic_limit_snapshot` 展示同 `machine_id` 或同 host 的共享当前账期用量;后端缺少快照时回退 mi-node metrics / `u+d`,节点标签区同步显示正常 / 接近额度 / 已限额状态;搜索关键字可匹配“流量限额 / 月流量 / 超额下线”
|
||||
- 权限组管理页使用真实后端 `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` 推导,不在前端伪造额外接口
|
||||
|
||||
@@ -5,22 +5,32 @@
|
||||
- 保存节点级月流量限额配置、重置规则和运行状态
|
||||
- 将限额配置下发给 `mi-node`,由节点端执行真实内核停启
|
||||
- 在手动重置、定时重置和节点 metrics 回传时同步面板侧状态
|
||||
- 为管理端和 mi-node 下发提供共享月额度快照,统一计算同机器 / 同 host 节点的当前账期已用流量
|
||||
|
||||
## 行为规范
|
||||
|
||||
- `v2_server.transfer_enable` 是节点月流量额度,单位为字节;新增字段只负责启用状态、重置日、重置时间、时区和运行状态
|
||||
- `v2_server.transfer_enable` 是节点月流量额度配置,单位为字节;新增字段只负责启用状态、重置日、重置时间、时区和运行状态
|
||||
- `traffic_limit_enabled=false` 或 `transfer_enable<=0` 时不启用节点限额,`ServerTrafficLimitService::buildNodeConfig()` 仍会下发 disabled 配置,旧行为保持不变
|
||||
- 重置日支持 `1-31`,短月按当月最后一天计算;重置时间使用 `HH:mm`,时区优先使用节点字段,空值或非法值回退 `config('app.timezone')`
|
||||
- 月额度使用量按共享范围计算:优先按 `machine_id` 聚合,未绑定机器时按规范化后的 `host` 聚合,空 host 回退单节点范围;同一范围内的节点在管理端快照中返回相同 `used / scope_key / scope_node_ids`
|
||||
- 共享月额度按当前账期统计:`ServerTrafficLimitService::calculateCurrentCycleStartAt()` 会取当前时间之前最近一次重置边界,`current_used` 优先聚合 `v2_stat_server.record_type='d'` 在 `[cycle_start_at, now]` 日粒度窗口内的 `u+d`
|
||||
- 当前账期没有统计行时回退共享范围内 `v2_server.u + v2_server.d`;同范围任一节点有当前有效的 mi-node runtime metrics 时,快照会取 metrics `used` 的最大值并保留同限额下的 `suspended` 运行态
|
||||
- `ServerTrafficLimitService::buildNodeConfig()` 下发给 mi-node 的 `traffic_limit.current_used` 使用共享账期口径,不再只使用当前单节点的 `u+d`
|
||||
- `ServerTrafficLimitService::buildSnapshotsForServers()` 为管理端节点列表批量生成 `traffic_limit_snapshot`,避免前端自行按 IP 猜测共享规则
|
||||
- 管理端保存节点后调用 `ServerTrafficLimitService::refreshSchedule()` 计算 `traffic_limit_next_reset_at`,并通过 `NodeSyncService::notifyConfigUpdated()` 通知节点更新配置
|
||||
- 手动重置和定时重置统一走 `ServerTrafficLimitService::resetServer()`:清空节点 `u/d`,恢复 `traffic_limit_status=normal`,记录 `traffic_limit_last_reset_at`,计算下一次重置时间,并触发 `notifyFullSync()`
|
||||
- 手动重置和定时重置统一走 `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()` 完成
|
||||
- 限额下线不修改父节点自身的 `show`、`auto_online` 或墙检测字段;真实下线由 `mi-node` 调用内核 `Stop()` 完成
|
||||
- 父节点限额状态变为 `suspended` 时会通过 `ServerParentVisibilityService` 自动隐藏当时仍显示的直接子节点,并写入 `parent_auto_hidden=1`;限额重置或恢复 `normal` 后只恢复这些由父节点联动自动隐藏的子节点,原本手动隐藏的子节点保持隐藏
|
||||
|
||||
## 依赖关系
|
||||
|
||||
- 依赖 `app/Services/ServerTrafficLimitService.php` 统一处理配置下发、时间计算、状态回写和重置
|
||||
- 依赖 `app/Services/ServerParentVisibilityService.php` 在父节点限额下线 / 恢复时同步直接子节点显隐
|
||||
- 依赖 `app/Services/ServerService.php` 在节点配置中追加 `traffic_limit` 并消费节点 metrics
|
||||
- 依赖 `app/Http/Controllers/V2/Admin/Server/ManageController.php` 在 `server/manage/getNodes` 响应中返回 `traffic_limit_snapshot`
|
||||
- 依赖 `v2_stat_server` 的日统计记录作为当前账期共享已用流量的主要来源
|
||||
- 依赖 `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/*` 提供配置与展示入口
|
||||
|
||||
Vendored
+18
@@ -874,10 +874,26 @@ export interface AdminNodeMetrics {
|
||||
|
||||
export interface AdminNodeTrafficStats {
|
||||
today: TrafficAmount
|
||||
yesterday: TrafficAmount
|
||||
month: TrafficAmount
|
||||
total: TrafficAmount
|
||||
}
|
||||
|
||||
export interface AdminNodeTrafficLimitSnapshot {
|
||||
enabled: boolean
|
||||
limit: number
|
||||
used: number
|
||||
percent: number
|
||||
suspended: boolean
|
||||
last_reset_at?: number
|
||||
cycle_start_at?: number
|
||||
next_reset_at?: number
|
||||
suspended_at?: number
|
||||
status?: string
|
||||
scope_key?: string
|
||||
scope_node_ids?: number[]
|
||||
}
|
||||
|
||||
export interface AdminNodeRateTimeRange {
|
||||
start: string
|
||||
end: string
|
||||
@@ -909,6 +925,7 @@ export interface AdminNodeItem {
|
||||
traffic_limit_next_reset_at?: number | null
|
||||
traffic_limit_suspended_at?: number | null
|
||||
enabled?: boolean
|
||||
machine_id?: number | null
|
||||
parent_id?: number | null
|
||||
rate?: number | null
|
||||
transfer_enable?: number | null
|
||||
@@ -926,6 +943,7 @@ export interface AdminNodeItem {
|
||||
last_push_at?: number | null
|
||||
metrics?: AdminNodeMetrics | null
|
||||
traffic_stats?: AdminNodeTrafficStats | null
|
||||
traffic_limit_snapshot?: AdminNodeTrafficLimitSnapshot | null
|
||||
groups?: AdminServerGroupItem[]
|
||||
parent?: AdminNodeParentRef | null
|
||||
gfw_check?: AdminNodeGfwCheck | null
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface NodeGfwMeta {
|
||||
}
|
||||
|
||||
export interface NodeTrafficDetail {
|
||||
key: 'today' | 'month' | 'total'
|
||||
key: 'today' | 'yesterday' | 'month' | 'total'
|
||||
label: string
|
||||
upload: string
|
||||
download: string
|
||||
@@ -70,6 +70,15 @@ function normalizeTrafficValue(value: unknown): number {
|
||||
return Number.isFinite(normalized) && normalized > 0 ? normalized : 0
|
||||
}
|
||||
|
||||
function normalizeOptionalTrafficValue(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = Number(value)
|
||||
return Number.isFinite(normalized) && normalized >= 0 ? normalized : null
|
||||
}
|
||||
|
||||
function normalizeTrafficAmount(amount?: TrafficAmountLike | null): TrafficAmount {
|
||||
const upload = normalizeTrafficValue(amount?.upload)
|
||||
const download = normalizeTrafficValue(amount?.download)
|
||||
@@ -255,6 +264,7 @@ export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[]
|
||||
|
||||
const rows: Array<{ key: NodeTrafficDetail['key']; label: string; source?: TrafficAmountLike | null }> = [
|
||||
{ key: 'today', label: '今日', source: stats?.today },
|
||||
{ key: 'yesterday', label: '昨日', source: stats?.yesterday },
|
||||
{ key: 'month', label: '本月', source: stats?.month },
|
||||
{ key: 'total', label: '累计', source: stats?.total ?? totalFallback },
|
||||
]
|
||||
@@ -272,16 +282,25 @@ export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[]
|
||||
}
|
||||
|
||||
export function getNodeTrafficLimitDetail(node: AdminNodeItem): NodeTrafficLimitDetail {
|
||||
const limit = normalizeTrafficValue(node.transfer_enable)
|
||||
const snapshot = node.traffic_limit_snapshot
|
||||
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 limit = normalizeOptionalTrafficValue(snapshot?.limit)
|
||||
?? normalizeOptionalTrafficValue(metrics?.limit)
|
||||
?? normalizeTrafficValue(node.transfer_enable)
|
||||
const used = normalizeOptionalTrafficValue(snapshot?.used)
|
||||
?? normalizeOptionalTrafficValue(metrics?.used)
|
||||
?? normalizeTrafficValue(node.u) + normalizeTrafficValue(node.d)
|
||||
const status = normalizeText(snapshot?.status || metrics?.status || node.traffic_limit_status)
|
||||
const suspended = Boolean(snapshot?.suspended) || Boolean(metrics?.suspended) || status === 'suspended'
|
||||
const nextResetAt = normalizeOptionalTrafficValue(snapshot?.next_reset_at)
|
||||
?? normalizeOptionalTrafficValue(metrics?.next_reset_at)
|
||||
?? normalizeTrafficValue(node.traffic_limit_next_reset_at)
|
||||
const percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0
|
||||
const enabled = (snapshot ? Boolean(snapshot.enabled) : Boolean(node.traffic_limit_enabled)) && limit > 0
|
||||
|
||||
let statusLabel = '未启用'
|
||||
let tagType: NodeTrafficLimitDetail['tagType'] = 'info'
|
||||
if (Boolean(node.traffic_limit_enabled) && limit > 0) {
|
||||
if (enabled) {
|
||||
if (suspended) {
|
||||
statusLabel = '已限额'
|
||||
tagType = 'danger'
|
||||
@@ -295,7 +314,7 @@ export function getNodeTrafficLimitDetail(node: AdminNodeItem): NodeTrafficLimit
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: Boolean(node.traffic_limit_enabled) && limit > 0,
|
||||
enabled,
|
||||
used: formatTrafficBytes(used),
|
||||
limit: formatTrafficBytes(limit),
|
||||
percent,
|
||||
|
||||
@@ -10,9 +10,11 @@ use App\Models\ServerGroup;
|
||||
use App\Models\StatServer;
|
||||
use App\Services\ServerAutoOnlineService;
|
||||
use App\Services\ServerGfwCheckService;
|
||||
use App\Services\ServerParentVisibilityService;
|
||||
use App\Services\ServerService;
|
||||
use App\Services\ServerTrafficLimitService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -22,11 +24,13 @@ class ManageController extends Controller
|
||||
{
|
||||
$servers = ServerService::getAllServers();
|
||||
$trafficStats = $this->buildNodeTrafficStats($servers);
|
||||
$trafficLimitSnapshots = app(ServerTrafficLimitService::class)->buildSnapshotsForServers($servers);
|
||||
|
||||
$servers = app(ServerGfwCheckService::class)->decorateServers($servers)->map(function ($item) use ($trafficStats) {
|
||||
$servers = app(ServerGfwCheckService::class)->decorateServers($servers)->map(function ($item) use ($trafficStats, $trafficLimitSnapshots) {
|
||||
$item['groups'] = ServerGroup::whereIn('id', $item['group_ids'] ?? [])->get(['name', 'id']);
|
||||
$item['parent'] = $item->parent;
|
||||
$item['traffic_stats'] = $trafficStats[(int) $item['id']] ?? $this->emptyNodeTrafficStats();
|
||||
$item['traffic_limit_snapshot'] = $trafficLimitSnapshots[(int) $item['id']] ?? null;
|
||||
return $item;
|
||||
});
|
||||
return $this->success($servers);
|
||||
@@ -44,14 +48,45 @@ class ManageController extends Controller
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->fillTrafficWindow($stats, 'today', strtotime('today'));
|
||||
$this->fillTrafficWindow($stats, 'month', strtotime(date('Y-m-01')));
|
||||
foreach ($this->resolveNodeTrafficWindows() as $key => $window) {
|
||||
$this->fillTrafficWindow($stats, $key, $window['start'], $window['end']);
|
||||
}
|
||||
$this->fillTrafficWindow($stats, 'total');
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function fillTrafficWindow(array &$stats, string $key, ?int $startAt = null): void
|
||||
/**
|
||||
* Resolve half-open node traffic windows for daily and monthly stats.
|
||||
*
|
||||
* @param int|null $referenceTimestamp Unix timestamp used as the current time.
|
||||
* @return array<string, array{start: int, end: int}>
|
||||
*/
|
||||
protected function resolveNodeTrafficWindows(?int $referenceTimestamp = null): array
|
||||
{
|
||||
$reference = Carbon::createFromTimestamp(
|
||||
$referenceTimestamp ?? time(),
|
||||
config('app.timezone', date_default_timezone_get())
|
||||
);
|
||||
$todayStart = $reference->copy()->startOfDay()->timestamp;
|
||||
|
||||
return [
|
||||
'today' => [
|
||||
'start' => $todayStart,
|
||||
'end' => $reference->copy()->addDay()->startOfDay()->timestamp,
|
||||
],
|
||||
'yesterday' => [
|
||||
'start' => $reference->copy()->subDay()->startOfDay()->timestamp,
|
||||
'end' => $todayStart,
|
||||
],
|
||||
'month' => [
|
||||
'start' => $reference->copy()->startOfMonth()->timestamp,
|
||||
'end' => $reference->copy()->addMonthNoOverflow()->startOfMonth()->timestamp,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function fillTrafficWindow(array &$stats, string $key, ?int $startAt = null, ?int $endAt = null): void
|
||||
{
|
||||
$rows = StatServer::query()
|
||||
->selectRaw('server_id, COALESCE(SUM(u), 0) as upload, COALESCE(SUM(d), 0) as download')
|
||||
@@ -60,11 +95,17 @@ class ManageController extends Controller
|
||||
->when($startAt !== null, function ($query) use ($startAt) {
|
||||
$query->where('record_at', '>=', $startAt);
|
||||
})
|
||||
->when($endAt !== null, function ($query) use ($endAt) {
|
||||
$query->where('record_at', '<', $endAt);
|
||||
})
|
||||
->groupBy('server_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$stats[(int) $row->server_id][$key] = $this->buildTrafficAmount($row->upload, $row->download);
|
||||
$stats[(int) $row->server_id][$key] = $this->buildTrafficAmount(
|
||||
$row->getAttribute('upload'),
|
||||
$row->getAttribute('download')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +113,7 @@ class ManageController extends Controller
|
||||
{
|
||||
return [
|
||||
'today' => $this->buildTrafficAmount(0, 0),
|
||||
'yesterday' => $this->buildTrafficAmount(0, 0),
|
||||
'month' => $this->buildTrafficAmount(0, 0),
|
||||
'total' => $this->buildTrafficAmount(0, 0),
|
||||
];
|
||||
@@ -133,6 +175,8 @@ class ManageController extends Controller
|
||||
if (array_key_exists('show', $params)) {
|
||||
$params['gfw_auto_hidden'] = false;
|
||||
$params['gfw_auto_action_at'] = null;
|
||||
$params['parent_auto_hidden'] = false;
|
||||
$params['parent_auto_action_at'] = null;
|
||||
}
|
||||
$server->update($params);
|
||||
app(ServerTrafficLimitService::class)->refreshSchedule($server->refresh());
|
||||
@@ -175,6 +219,7 @@ class ManageController extends Controller
|
||||
$server->show = (int) $params['show'];
|
||||
$server->gfw_auto_hidden = false;
|
||||
$server->gfw_auto_action_at = null;
|
||||
app(ServerParentVisibilityService::class)->clearParentAutoHidden($server);
|
||||
}
|
||||
if (array_key_exists('auto_online', $params)) {
|
||||
$server->auto_online = (bool) $params['auto_online'];
|
||||
@@ -335,6 +380,8 @@ class ManageController extends Controller
|
||||
$update['show'] = (int) $params['show'];
|
||||
$update['gfw_auto_hidden'] = false;
|
||||
$update['gfw_auto_action_at'] = null;
|
||||
$update['parent_auto_hidden'] = false;
|
||||
$update['parent_auto_action_at'] = null;
|
||||
}
|
||||
if (array_key_exists('auto_online', $params) && $params['auto_online'] !== null) {
|
||||
$update['auto_online'] = (bool) $params['auto_online'];
|
||||
@@ -415,7 +462,7 @@ class ManageController extends Controller
|
||||
}
|
||||
|
||||
$copiedServer = $server->replicate();
|
||||
$copiedServer->show = 0;
|
||||
$copiedServer->show = false;
|
||||
$copiedServer->code = null;
|
||||
$copiedServer->u = 0;
|
||||
$copiedServer->d = 0;
|
||||
|
||||
@@ -28,6 +28,8 @@ 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 $parent_auto_hidden 是否由父节点自动状态联动隐藏
|
||||
* @property int|null $parent_auto_action_at 最近父节点自动联动显隐时间
|
||||
* @property boolean $traffic_limit_enabled 是否启用节点流量限额强制下线
|
||||
* @property int|null $traffic_limit_reset_day 节点流量每月重置日
|
||||
* @property string|null $traffic_limit_reset_time 节点流量重置时间
|
||||
@@ -143,6 +145,8 @@ class Server extends Model
|
||||
'gfw_check_enabled' => 'boolean',
|
||||
'gfw_auto_hidden' => 'boolean',
|
||||
'gfw_auto_action_at' => 'integer',
|
||||
'parent_auto_hidden' => 'boolean',
|
||||
'parent_auto_action_at' => 'integer',
|
||||
'traffic_limit_enabled' => 'boolean',
|
||||
'traffic_limit_reset_day' => 'integer',
|
||||
'traffic_limit_last_reset_at' => 'integer',
|
||||
|
||||
@@ -53,6 +53,7 @@ class ServerAutoOnlineService
|
||||
$wasShown = (bool) $server->show;
|
||||
|
||||
if ($wasShown === $shouldShow && !$shouldClearGfwAutoHidden) {
|
||||
$this->syncChildrenForFinalState($server, $shouldShow, $result);
|
||||
$result['unchanged']++;
|
||||
return;
|
||||
}
|
||||
@@ -71,6 +72,24 @@ class ServerAutoOnlineService
|
||||
if ($wasShown !== $shouldShow) {
|
||||
$shouldShow ? $result['shown']++ : $result['hidden']++;
|
||||
}
|
||||
$this->syncChildrenForFinalState($server, $shouldShow, $result);
|
||||
}
|
||||
|
||||
private function syncChildrenForFinalState(Server $server, bool $shouldShow, array &$result): void
|
||||
{
|
||||
$childResult = app(ServerParentVisibilityService::class)
|
||||
->syncChildrenForParent($server, $shouldShow);
|
||||
$hidden = (int) ($childResult['hidden'] ?? 0);
|
||||
$restored = (int) ($childResult['restored'] ?? 0);
|
||||
$childUpdates = $hidden + $restored;
|
||||
|
||||
if ($childUpdates <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result['updated'] += $childUpdates;
|
||||
$result['hidden'] += $hidden;
|
||||
$result['shown'] += $restored;
|
||||
}
|
||||
|
||||
private function emptyResult(int $total = 0): array
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ServerParentVisibilityService
|
||||
{
|
||||
public function syncChildrenForParent(Server $parent, bool $parentVisible): array
|
||||
{
|
||||
return $parentVisible
|
||||
? $this->restoreChildrenForParent($parent)
|
||||
: $this->hideChildrenForParent($parent);
|
||||
}
|
||||
|
||||
public function hideChildrenForParent(Server $parent): array
|
||||
{
|
||||
if (!$this->isParentNode($parent)) {
|
||||
return $this->emptyResult();
|
||||
}
|
||||
|
||||
$result = $this->emptyResult();
|
||||
$now = time();
|
||||
|
||||
$children = $this->childrenQuery($parent)
|
||||
->where('show', true)
|
||||
->get();
|
||||
|
||||
foreach ($children as $child) {
|
||||
if (!$child instanceof Server) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$child->forceFill([
|
||||
'show' => false,
|
||||
'parent_auto_hidden' => true,
|
||||
'parent_auto_action_at' => $now,
|
||||
])->save();
|
||||
$result['hidden']++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function restoreChildrenForParent(Server $parent): array
|
||||
{
|
||||
if (!$this->isParentNode($parent)) {
|
||||
return $this->emptyResult();
|
||||
}
|
||||
|
||||
$result = $this->emptyResult();
|
||||
$now = time();
|
||||
|
||||
$children = $this->childrenQuery($parent)
|
||||
->where('parent_auto_hidden', true)
|
||||
->get();
|
||||
|
||||
foreach ($children as $child) {
|
||||
if (!$child instanceof Server) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->hasBlockingAutoHide($child)) {
|
||||
$result['unchanged']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$child->forceFill([
|
||||
'show' => true,
|
||||
'parent_auto_hidden' => false,
|
||||
'parent_auto_action_at' => $now,
|
||||
])->save();
|
||||
$result['restored']++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function clearParentAutoHidden(Server $server): void
|
||||
{
|
||||
if (!(bool) $server->parent_auto_hidden && $server->parent_auto_action_at === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server->parent_auto_hidden = false;
|
||||
$server->parent_auto_action_at = null;
|
||||
}
|
||||
|
||||
private function isParentNode(Server $server): bool
|
||||
{
|
||||
return (int) ($server->parent_id ?? 0) <= 0 && (int) ($server->id ?? 0) > 0;
|
||||
}
|
||||
|
||||
private function childrenQuery(Server $parent): Builder
|
||||
{
|
||||
return Server::query()->where('parent_id', (int) $parent->id);
|
||||
}
|
||||
|
||||
private function hasBlockingAutoHide(Server $server): bool
|
||||
{
|
||||
return (bool) $server->gfw_auto_hidden;
|
||||
}
|
||||
|
||||
private function emptyResult(): array
|
||||
{
|
||||
return [
|
||||
'hidden' => 0,
|
||||
'restored' => 0,
|
||||
'unchanged' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,8 @@ class ServerService
|
||||
];
|
||||
|
||||
if (isset($metrics['traffic_limit']) && is_array($metrics['traffic_limit'])) {
|
||||
app(ServerTrafficLimitService::class)->applyRuntimeMetrics($node, $metrics['traffic_limit']);
|
||||
$metricsData['traffic_limit'] = app(ServerTrafficLimitService::class)
|
||||
->applyRuntimeMetrics($node, $metrics['traffic_limit']);
|
||||
}
|
||||
|
||||
Cache::put(
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\StatServer;
|
||||
use App\Utils\CacheKey;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerTrafficLimitService
|
||||
@@ -13,22 +17,95 @@ class ServerTrafficLimitService
|
||||
*/
|
||||
public function buildNodeConfig(Server $server): array
|
||||
{
|
||||
$enabled = $this->isEnabled($server);
|
||||
$nextResetAt = $enabled
|
||||
? ($server->traffic_limit_next_reset_at ?: $this->calculateNextResetAt($server)?->timestamp)
|
||||
: null;
|
||||
$snapshot = $this->buildTrafficLimitSnapshot($server);
|
||||
$enabled = (bool) $snapshot['enabled'];
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'limit' => $enabled ? (int) $server->transfer_enable : 0,
|
||||
'limit' => (int) $snapshot['limit'],
|
||||
'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),
|
||||
'current_used' => (int) $snapshot['used'],
|
||||
'last_reset_at' => (int) $snapshot['last_reset_at'],
|
||||
'next_reset_at' => (int) $snapshot['next_reset_at'],
|
||||
'suspended_at' => (int) $snapshot['suspended_at'],
|
||||
'status' => (string) $snapshot['status'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build traffic limit snapshots for an already loaded server list.
|
||||
*/
|
||||
public function buildSnapshotsForServers(Collection $servers, ?int $referenceTimestamp = null): array
|
||||
{
|
||||
$serversByScope = $servers->groupBy(fn (Server $server) => $this->trafficLimitScopeKey($server));
|
||||
$snapshots = [];
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$scopeServers = $serversByScope->get($this->trafficLimitScopeKey($server), collect([$server]));
|
||||
$snapshots[(int) $server->id] = $this->buildTrafficLimitSnapshot(
|
||||
$server,
|
||||
$scopeServers,
|
||||
$referenceTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a panel-side snapshot using the shared traffic limit scope.
|
||||
*/
|
||||
public function buildTrafficLimitSnapshot(
|
||||
Server $server,
|
||||
?Collection $scopeServers = null,
|
||||
?int $referenceTimestamp = null
|
||||
): array {
|
||||
$enabled = $this->isEnabled($server);
|
||||
$scopeServers = $this->resolveTrafficLimitScopeServers($server, $scopeServers);
|
||||
$limit = $enabled ? (int) $server->transfer_enable : 0;
|
||||
$trafficLimit = $server->getKey() ? $this->cachedTrafficLimitMetrics($server) : null;
|
||||
$used = $enabled ? $this->currentUsed($server, $trafficLimit, $scopeServers, $referenceTimestamp) : 0;
|
||||
$reportedSuspension = $this->scopeReportedSuspension($server, $trafficLimit, $scopeServers, $limit);
|
||||
$suspended = $enabled && $limit > 0 && (
|
||||
$used >= $limit
|
||||
|| $reportedSuspension['suspended']
|
||||
);
|
||||
$reference = $this->referenceTime($server, $referenceTimestamp);
|
||||
$cycleStartAt = $enabled ? $this->calculateCurrentCycleStartAt($server, $reference) : null;
|
||||
$cycleStartTimestamp = $cycleStartAt ? $cycleStartAt->timestamp : 0;
|
||||
$nextResetAt = $enabled
|
||||
? ($server->traffic_limit_next_reset_at ?: $this->calculateNextResetAt($server, $reference)?->timestamp)
|
||||
: 0;
|
||||
$lastResetAt = max(
|
||||
(int) ($server->traffic_limit_last_reset_at ?? 0),
|
||||
$cycleStartTimestamp
|
||||
);
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'limit' => $limit,
|
||||
'used' => $used,
|
||||
'percent' => $limit > 0 ? min(100, (int) round(($used / $limit) * 100)) : 0,
|
||||
'suspended' => $suspended,
|
||||
'last_reset_at' => $enabled ? $lastResetAt : 0,
|
||||
'cycle_start_at' => $enabled ? $cycleStartTimestamp : 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,
|
||||
'suspended_at' => $suspended
|
||||
? (int) ($reportedSuspension['suspended_at'] ?: ($server->traffic_limit_suspended_at ?? time()))
|
||||
: 0,
|
||||
'status' => $suspended
|
||||
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
|
||||
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
'scope_key' => $this->trafficLimitScopeKey($server),
|
||||
'scope_node_ids' => $scopeServers
|
||||
->pluck('id')
|
||||
->filter(fn ($id) => (int) $id > 0)
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -39,6 +116,9 @@ class ServerTrafficLimitService
|
||||
{
|
||||
$values = $this->scheduleValues($server);
|
||||
$server->forceFill($values)->saveQuietly();
|
||||
$freshServer = $server->refresh();
|
||||
$this->syncCachedRuntimeMetrics($freshServer);
|
||||
$this->syncParentChildrenFromTrafficLimit($freshServer);
|
||||
|
||||
if ($notifyNode) {
|
||||
NodeSyncService::notifyConfigUpdated((int) $server->id);
|
||||
@@ -61,6 +141,9 @@ class ServerTrafficLimitService
|
||||
: null,
|
||||
'traffic_limit_suspended_at' => null,
|
||||
])->saveQuietly();
|
||||
$freshServer = $server->refresh();
|
||||
$this->syncCachedRuntimeMetrics($freshServer);
|
||||
$this->syncParentChildrenFromTrafficLimit($freshServer);
|
||||
|
||||
if ($notifyNode) {
|
||||
NodeSyncService::notifyFullSync((int) $server->id);
|
||||
@@ -147,29 +230,69 @@ class ServerTrafficLimitService
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply limiter metrics from mi-node to panel-side runtime fields.
|
||||
* Calculate the reset boundary that starts the current billing cycle.
|
||||
*/
|
||||
public function applyRuntimeMetrics(Server $server, array $trafficLimit): void
|
||||
public function calculateCurrentCycleStartAt(Server $server, ?Carbon $from = null): ?Carbon
|
||||
{
|
||||
if (empty($trafficLimit)) {
|
||||
return;
|
||||
if (!$this->isEnabled($server)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$suspended = (bool) ($trafficLimit['suspended'] ?? false);
|
||||
$timezone = $this->normalizeTimezone($server->traffic_limit_timezone);
|
||||
$from = ($from ?: Carbon::now($timezone))->copy()->timezone($timezone);
|
||||
[$hour, $minute] = $this->parseResetTime($server->traffic_limit_reset_time);
|
||||
|
||||
$currentMonthTarget = $this->targetForMonth(
|
||||
$from->year,
|
||||
$from->month,
|
||||
$this->normalizeResetDay($server->traffic_limit_reset_day),
|
||||
$hour,
|
||||
$minute,
|
||||
$timezone
|
||||
);
|
||||
|
||||
if ($currentMonthTarget->timestamp <= $from->timestamp) {
|
||||
return $currentMonthTarget;
|
||||
}
|
||||
|
||||
$previousMonth = $from->copy()->startOfMonth()->subMonthNoOverflow();
|
||||
return $this->targetForMonth(
|
||||
$previousMonth->year,
|
||||
$previousMonth->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): array
|
||||
{
|
||||
if (empty($trafficLimit)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$snapshot = $this->runtimeSnapshot($server, $trafficLimit);
|
||||
$suspended = (bool) $snapshot['suspended'];
|
||||
$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,
|
||||
'traffic_limit_last_reset_at' => $this->nullableTimestamp($snapshot['last_reset_at']),
|
||||
'traffic_limit_next_reset_at' => $this->nullableTimestamp($snapshot['next_reset_at']),
|
||||
'traffic_limit_suspended_at' => $this->nullableTimestamp($snapshot['suspended_at']),
|
||||
]);
|
||||
|
||||
if ($server->isDirty()) {
|
||||
$server->saveQuietly();
|
||||
}
|
||||
|
||||
$this->syncParentChildrenFromTrafficLimit($server->refresh());
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private function scheduleValues(Server $server): array
|
||||
@@ -183,13 +306,21 @@ class ServerTrafficLimitService
|
||||
];
|
||||
}
|
||||
|
||||
$currentUsed = $this->currentUsed($server);
|
||||
$suspended = $this->isSuspendedByUsage($server, $currentUsed);
|
||||
|
||||
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_status' => $suspended
|
||||
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
|
||||
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
'traffic_limit_next_reset_at' => $this->calculateNextResetAt($server)?->timestamp,
|
||||
'traffic_limit_suspended_at' => $suspended
|
||||
? ($server->traffic_limit_suspended_at ?: time())
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -198,6 +329,285 @@ class ServerTrafficLimitService
|
||||
return (bool) $server->traffic_limit_enabled && (int) $server->transfer_enable > 0;
|
||||
}
|
||||
|
||||
private function isSuspendedByUsage(Server $server, ?int $used = null): bool
|
||||
{
|
||||
return $this->isEnabled($server)
|
||||
&& ($used ?? $this->currentUsed($server)) >= (int) $server->transfer_enable;
|
||||
}
|
||||
|
||||
private function syncParentChildrenFromTrafficLimit(Server $server): void
|
||||
{
|
||||
if ((int) ($server->parent_id ?? 0) > 0 || (int) ($server->id ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$suspended = $server->traffic_limit_status === Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
|
||||
|| $this->isSuspendedByUsage($server);
|
||||
|
||||
app(ServerParentVisibilityService::class)->syncChildrenForParent(
|
||||
$server,
|
||||
!$suspended && (bool) $server->show
|
||||
);
|
||||
}
|
||||
|
||||
private function currentUsed(
|
||||
Server $server,
|
||||
?array $trafficLimit = null,
|
||||
?Collection $scopeServers = null,
|
||||
?int $referenceTimestamp = null
|
||||
): int
|
||||
{
|
||||
$scopeServers = $this->resolveTrafficLimitScopeServers($server, $scopeServers);
|
||||
$cycleUsed = $this->currentCycleUsed($server, $scopeServers, $referenceTimestamp);
|
||||
$panelUsed = $this->panelUsed($scopeServers);
|
||||
$reportedUsed = $this->scopeReportedUsed($server, $trafficLimit, $scopeServers);
|
||||
|
||||
return max($cycleUsed ?? $panelUsed, $reportedUsed);
|
||||
}
|
||||
|
||||
private function runtimeSnapshot(Server $server, array $trafficLimit): array
|
||||
{
|
||||
$enabled = $this->isEnabled($server);
|
||||
$limit = $enabled ? (int) $server->transfer_enable : 0;
|
||||
$used = $this->currentUsed($server, $trafficLimit);
|
||||
$reportedLimit = (int) ($trafficLimit['limit'] ?? 0);
|
||||
$snapshotCurrent = $this->isRuntimeSnapshotCurrent($server, $trafficLimit);
|
||||
$reportedSuspended = $snapshotCurrent && (bool) ($trafficLimit['suspended'] ?? false);
|
||||
$sameLimitSnapshot = $reportedLimit === 0 || $reportedLimit === $limit;
|
||||
$metricNextResetAt = (int) ($trafficLimit['next_reset_at'] ?? 0);
|
||||
$serverNextResetAt = (int) ($server->traffic_limit_next_reset_at ?? 0);
|
||||
$suspended = $enabled && (
|
||||
$used >= $limit
|
||||
|| ($reportedSuspended && $sameLimitSnapshot)
|
||||
);
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'limit' => $limit,
|
||||
'used' => $used,
|
||||
'suspended' => $suspended,
|
||||
'last_reset_at' => max(
|
||||
(int) ($server->traffic_limit_last_reset_at ?? 0),
|
||||
(int) ($trafficLimit['last_reset_at'] ?? 0)
|
||||
),
|
||||
'next_reset_at' => $snapshotCurrent && $sameLimitSnapshot && $metricNextResetAt > 0
|
||||
? $metricNextResetAt
|
||||
: $serverNextResetAt,
|
||||
'suspended_at' => $suspended
|
||||
? (int) ($trafficLimit['suspended_at'] ?? $server->traffic_limit_suspended_at ?? time())
|
||||
: 0,
|
||||
'status' => $suspended
|
||||
? Server::TRAFFIC_LIMIT_STATUS_SUSPENDED
|
||||
: Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
];
|
||||
}
|
||||
|
||||
private function syncCachedRuntimeMetrics(Server $server): void
|
||||
{
|
||||
$metrics = Cache::get($this->metricsCacheKey($server));
|
||||
if (!is_array($metrics)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$trafficLimit = $this->runtimeSnapshot(
|
||||
$server,
|
||||
is_array($metrics['traffic_limit'] ?? null) ? $metrics['traffic_limit'] : []
|
||||
);
|
||||
|
||||
$metrics['traffic_limit'] = $trafficLimit;
|
||||
Cache::put(
|
||||
$this->metricsCacheKey($server),
|
||||
$metrics,
|
||||
max(300, (int) admin_setting('server_push_interval', 60) * 3)
|
||||
);
|
||||
}
|
||||
|
||||
private function cachedTrafficLimitMetrics(Server $server): ?array
|
||||
{
|
||||
$metrics = Cache::get($this->metricsCacheKey($server));
|
||||
return is_array($metrics) && is_array($metrics['traffic_limit'] ?? null)
|
||||
? $metrics['traffic_limit']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function scopeReportedUsed(Server $server, ?array $trafficLimit, Collection $scopeServers): int
|
||||
{
|
||||
$used = 0;
|
||||
|
||||
foreach ($scopeServers as $scopeServer) {
|
||||
$scopeTrafficLimit = $this->scopeTrafficLimitMetrics($server, $trafficLimit, $scopeServer);
|
||||
if (!is_array($scopeTrafficLimit) || !$this->isRuntimeSnapshotCurrent($scopeServer, $scopeTrafficLimit)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$used = max($used, max(0, (int) ($scopeTrafficLimit['used'] ?? 0)));
|
||||
}
|
||||
|
||||
return $used;
|
||||
}
|
||||
|
||||
private function scopeReportedSuspension(
|
||||
Server $server,
|
||||
?array $trafficLimit,
|
||||
Collection $scopeServers,
|
||||
int $limit
|
||||
): array {
|
||||
foreach ($scopeServers as $scopeServer) {
|
||||
$scopeTrafficLimit = $this->scopeTrafficLimitMetrics($server, $trafficLimit, $scopeServer);
|
||||
if (!is_array($scopeTrafficLimit) || !$this->isRuntimeSnapshotCurrent($scopeServer, $scopeTrafficLimit)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reportedLimit = (int) ($scopeTrafficLimit['limit'] ?? 0);
|
||||
$sameLimitSnapshot = $reportedLimit === 0 || $reportedLimit === $limit;
|
||||
if ($sameLimitSnapshot && (bool) ($scopeTrafficLimit['suspended'] ?? false)) {
|
||||
return [
|
||||
'suspended' => true,
|
||||
'suspended_at' => (int) ($scopeTrafficLimit['suspended_at'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'suspended' => false,
|
||||
'suspended_at' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function scopeTrafficLimitMetrics(
|
||||
Server $server,
|
||||
?array $trafficLimit,
|
||||
Server $scopeServer
|
||||
): ?array {
|
||||
if (!$scopeServer->getKey()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $scopeServer->getKey() === (int) $server->getKey()) {
|
||||
return $trafficLimit ?? $this->cachedTrafficLimitMetrics($scopeServer);
|
||||
}
|
||||
|
||||
return $this->cachedTrafficLimitMetrics($scopeServer);
|
||||
}
|
||||
|
||||
private function isRuntimeSnapshotCurrent(Server $server, array $trafficLimit): bool
|
||||
{
|
||||
$serverLastResetAt = (int) ($server->traffic_limit_last_reset_at ?? 0);
|
||||
$snapshotLastResetAt = (int) ($trafficLimit['last_reset_at'] ?? 0);
|
||||
|
||||
return $serverLastResetAt <= 0
|
||||
|| ($snapshotLastResetAt > 0 && $snapshotLastResetAt >= $serverLastResetAt);
|
||||
}
|
||||
|
||||
private function metricsCacheKey(Server $server): string
|
||||
{
|
||||
$serverId = $server->parent_id ?: $server->id;
|
||||
return CacheKey::get('SERVER_' . strtoupper((string) $server->type) . '_METRICS', $serverId);
|
||||
}
|
||||
|
||||
private function resolveTrafficLimitScopeServers(Server $server, ?Collection $scopeServers = null): Collection
|
||||
{
|
||||
if ($scopeServers instanceof Collection && $scopeServers->isNotEmpty()) {
|
||||
return $scopeServers->values();
|
||||
}
|
||||
|
||||
if (!$server->exists || !$server->getKey()) {
|
||||
return collect([$server]);
|
||||
}
|
||||
|
||||
$machineId = (int) ($server->machine_id ?? 0);
|
||||
if ($machineId > 0) {
|
||||
return Server::query()
|
||||
->where('machine_id', $machineId)
|
||||
->get();
|
||||
}
|
||||
|
||||
$host = $this->normalizeHost($server->host);
|
||||
if ($host === '') {
|
||||
return collect([$server]);
|
||||
}
|
||||
|
||||
return Server::query()
|
||||
->whereRaw('LOWER(TRIM(host)) = ?', [$host])
|
||||
->get();
|
||||
}
|
||||
|
||||
private function currentCycleUsed(
|
||||
Server $server,
|
||||
Collection $scopeServers,
|
||||
?int $referenceTimestamp = null
|
||||
): ?int {
|
||||
$serverIds = $scopeServers
|
||||
->pluck('id')
|
||||
->filter(fn ($id) => (int) $id > 0)
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($serverIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reference = $this->referenceTime($server, $referenceTimestamp);
|
||||
$cycleStartAt = $this->calculateCurrentCycleStartAt($server, $reference);
|
||||
if (!$cycleStartAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// v2_stat_server is stored at day granularity, so include the reset day.
|
||||
$startAt = $cycleStartAt->copy()->startOfDay()->timestamp;
|
||||
$endAt = $reference->copy()->addDay()->startOfDay()->timestamp;
|
||||
$row = StatServer::query()
|
||||
->selectRaw('COUNT(*) as records, COALESCE(SUM(u), 0) as upload, COALESCE(SUM(d), 0) as download')
|
||||
->whereIn('server_id', $serverIds->all())
|
||||
->where('record_type', 'd')
|
||||
->where('record_at', '>=', $startAt)
|
||||
->where('record_at', '<', $endAt)
|
||||
->first();
|
||||
|
||||
if ((int) ($row->records ?? 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max(0, (int) ($row->upload ?? 0) + (int) ($row->download ?? 0));
|
||||
}
|
||||
|
||||
private function panelUsed(Collection $scopeServers): int
|
||||
{
|
||||
return max(0, (int) $scopeServers->sum(
|
||||
fn (Server $server) => max(0, (int) $server->u + (int) $server->d)
|
||||
));
|
||||
}
|
||||
|
||||
private function trafficLimitScopeKey(Server $server): string
|
||||
{
|
||||
$machineId = (int) ($server->machine_id ?? 0);
|
||||
if ($machineId > 0) {
|
||||
return 'machine:' . $machineId;
|
||||
}
|
||||
|
||||
$host = $this->normalizeHost($server->host);
|
||||
if ($host !== '') {
|
||||
return 'host:' . $host;
|
||||
}
|
||||
|
||||
return 'server:' . (int) ($server->id ?? 0);
|
||||
}
|
||||
|
||||
private function normalizeHost(?string $host): string
|
||||
{
|
||||
return strtolower(trim((string) $host));
|
||||
}
|
||||
|
||||
private function referenceTime(Server $server, ?int $referenceTimestamp = null): Carbon
|
||||
{
|
||||
$timezone = $this->normalizeTimezone($server->traffic_limit_timezone);
|
||||
|
||||
return $referenceTimestamp !== null
|
||||
? Carbon::createFromTimestamp($referenceTimestamp, $timezone)
|
||||
: Carbon::now($timezone);
|
||||
}
|
||||
|
||||
private function normalizeResetDay($day): int
|
||||
{
|
||||
$normalized = (int) ($day ?: 1);
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('v2_server', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('v2_server', 'parent_auto_hidden')) {
|
||||
$table->boolean('parent_auto_hidden')
|
||||
->default(false)
|
||||
->after('gfw_auto_action_at')
|
||||
->comment('Hidden automatically because parent node is unavailable');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('v2_server', 'parent_auto_action_at')) {
|
||||
$table->unsignedBigInteger('parent_auto_action_at')
|
||||
->nullable()
|
||||
->after('parent_auto_hidden')
|
||||
->comment('Last parent visibility sync action timestamp');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('v2_server', function (Blueprint $table) {
|
||||
foreach (['parent_auto_hidden', 'parent_auto_action_at'] as $column) {
|
||||
if (Schema::hasColumn('v2_server', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Admin;
|
||||
|
||||
use App\Http\Controllers\V2\Admin\Server\ManageController;
|
||||
use Carbon\Carbon;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NodeTrafficStatsWindowTest extends TestCase
|
||||
{
|
||||
public function test_node_traffic_windows_include_today_yesterday_and_current_month(): void
|
||||
{
|
||||
$timezone = config('app.timezone');
|
||||
$reference = Carbon::create(2026, 4, 29, 13, 45, 0, $timezone)->timestamp;
|
||||
|
||||
$windows = $this->resolveWindows($reference);
|
||||
|
||||
$this->assertSame(Carbon::create(2026, 4, 29, 0, 0, 0, $timezone)->timestamp, $windows['today']['start']);
|
||||
$this->assertSame(Carbon::create(2026, 4, 30, 0, 0, 0, $timezone)->timestamp, $windows['today']['end']);
|
||||
$this->assertSame(Carbon::create(2026, 4, 28, 0, 0, 0, $timezone)->timestamp, $windows['yesterday']['start']);
|
||||
$this->assertSame(Carbon::create(2026, 4, 29, 0, 0, 0, $timezone)->timestamp, $windows['yesterday']['end']);
|
||||
$this->assertSame(Carbon::create(2026, 4, 1, 0, 0, 0, $timezone)->timestamp, $windows['month']['start']);
|
||||
$this->assertSame(Carbon::create(2026, 5, 1, 0, 0, 0, $timezone)->timestamp, $windows['month']['end']);
|
||||
}
|
||||
|
||||
public function test_node_traffic_windows_handle_first_day_of_month(): void
|
||||
{
|
||||
$timezone = config('app.timezone');
|
||||
$reference = Carbon::create(2026, 5, 1, 8, 10, 0, $timezone)->timestamp;
|
||||
|
||||
$windows = $this->resolveWindows($reference);
|
||||
|
||||
$this->assertSame(Carbon::create(2026, 4, 30, 0, 0, 0, $timezone)->timestamp, $windows['yesterday']['start']);
|
||||
$this->assertSame(Carbon::create(2026, 5, 1, 0, 0, 0, $timezone)->timestamp, $windows['yesterday']['end']);
|
||||
$this->assertSame(Carbon::create(2026, 5, 1, 0, 0, 0, $timezone)->timestamp, $windows['month']['start']);
|
||||
$this->assertSame(Carbon::create(2026, 6, 1, 0, 0, 0, $timezone)->timestamp, $windows['month']['end']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{start: int, end: int}>
|
||||
*/
|
||||
private function resolveWindows(int $referenceTimestamp): array
|
||||
{
|
||||
$controller = new ManageController();
|
||||
$method = new ReflectionMethod(ManageController::class, 'resolveNodeTrafficWindows');
|
||||
$method->setAccessible(true);
|
||||
|
||||
/** @var array<string, array{start: int, end: int}> $result */
|
||||
$result = $method->invoke($controller, $referenceTimestamp);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerGfwCheck;
|
||||
use App\Services\ServerAutoOnlineService;
|
||||
use App\Services\ServerParentVisibilityService;
|
||||
use App\Services\ServerService;
|
||||
use App\Utils\CacheKey;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -15,6 +16,13 @@ class ServerAutoOnlineServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_sync_updates_only_nodes_with_auto_online_enabled(): void
|
||||
{
|
||||
$managedOffline = $this->makeServer([
|
||||
@@ -115,6 +123,42 @@ class ServerAutoOnlineServiceTest extends TestCase
|
||||
$this->assertTrue($managedOnline->fresh()->show);
|
||||
}
|
||||
|
||||
public function test_parent_auto_online_sync_hides_and_restores_only_auto_hidden_children(): void
|
||||
{
|
||||
$parent = $this->makeServer([
|
||||
'name' => 'parent-auto-managed',
|
||||
'show' => true,
|
||||
'auto_online' => true,
|
||||
]);
|
||||
$visibleChild = $this->makeServer([
|
||||
'name' => 'visible-child',
|
||||
'parent_id' => $parent->id,
|
||||
'show' => true,
|
||||
]);
|
||||
$manualHiddenChild = $this->makeServer([
|
||||
'name' => 'manual-hidden-child',
|
||||
'parent_id' => $parent->id,
|
||||
'show' => false,
|
||||
]);
|
||||
|
||||
app(ServerAutoOnlineService::class)->sync();
|
||||
|
||||
$this->assertFalse($parent->fresh()->show);
|
||||
$this->assertFalse($visibleChild->fresh()->show);
|
||||
$this->assertTrue($visibleChild->fresh()->parent_auto_hidden);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->show);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->parent_auto_hidden);
|
||||
|
||||
$this->markNodeOnline($parent);
|
||||
app(ServerAutoOnlineService::class)->sync();
|
||||
|
||||
$this->assertTrue($parent->fresh()->show);
|
||||
$this->assertTrue($visibleChild->fresh()->show);
|
||||
$this->assertFalse($visibleChild->fresh()->parent_auto_hidden);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->show);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->parent_auto_hidden);
|
||||
}
|
||||
|
||||
public function test_touch_node_syncs_auto_online_node_immediately(): void
|
||||
{
|
||||
$managedOnline = $this->makeServer([
|
||||
@@ -128,6 +172,22 @@ class ServerAutoOnlineServiceTest extends TestCase
|
||||
$this->assertTrue($managedOnline->fresh()->show);
|
||||
}
|
||||
|
||||
public function test_clear_parent_auto_hidden_prevents_later_parent_restore_from_overriding_manual_choice(): void
|
||||
{
|
||||
$child = $this->makeServer([
|
||||
'name' => 'manually-adjusted-child',
|
||||
'show' => false,
|
||||
'parent_auto_hidden' => true,
|
||||
'parent_auto_action_at' => 123456,
|
||||
]);
|
||||
|
||||
app(ServerParentVisibilityService::class)->clearParentAutoHidden($child);
|
||||
$child->save();
|
||||
|
||||
$this->assertFalse($child->fresh()->parent_auto_hidden);
|
||||
$this->assertNull($child->fresh()->parent_auto_action_at);
|
||||
}
|
||||
|
||||
private function makeServer(array $attributes = []): Server
|
||||
{
|
||||
return Server::create(array_merge([
|
||||
@@ -142,6 +202,7 @@ class ServerAutoOnlineServiceTest extends TestCase
|
||||
'auto_online' => false,
|
||||
'gfw_check_enabled' => true,
|
||||
'gfw_auto_hidden' => false,
|
||||
'parent_auto_hidden' => false,
|
||||
'enabled' => true,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerMachine;
|
||||
use App\Models\StatServer;
|
||||
use App\Services\ServerTrafficLimitService;
|
||||
use App\Utils\CacheKey;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServerTrafficLimitServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_calculate_next_reset_at_clamps_day_to_short_month(): void
|
||||
{
|
||||
$server = new Server([
|
||||
@@ -51,4 +65,376 @@ class ServerTrafficLimitServiceTest extends TestCase
|
||||
$this->assertSame(1774977600, $config['next_reset_at']);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $config['status']);
|
||||
}
|
||||
|
||||
public function test_current_cycle_start_uses_previous_reset_boundary(): void
|
||||
{
|
||||
$server = new Server([
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => 1024,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$service = app(ServerTrafficLimitService::class);
|
||||
|
||||
$beforeCurrentMonthReset = $service->calculateCurrentCycleStartAt(
|
||||
$server,
|
||||
Carbon::create(2026, 4, 17, 12, 0, 0, 'UTC')
|
||||
);
|
||||
$afterCurrentMonthReset = $service->calculateCurrentCycleStartAt(
|
||||
$server,
|
||||
Carbon::create(2026, 4, 29, 12, 0, 0, 'UTC')
|
||||
);
|
||||
|
||||
$this->assertSame('2026-03-18 00:00:00', $beforeCurrentMonthReset?->format('Y-m-d H:i:s'));
|
||||
$this->assertSame('2026-04-18 00:00:00', $afterCurrentMonthReset?->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function test_traffic_limit_snapshot_shares_cycle_usage_by_host(): void
|
||||
{
|
||||
$reference = Carbon::create(2026, 4, 17, 12, 0, 0, 'UTC')->timestamp;
|
||||
$limit = 1000 * 1024 * 1024;
|
||||
$first = $this->makeServer([
|
||||
'name' => 'same-host-a',
|
||||
'host' => '82.40.33.225',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $limit,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
$second = $this->makeServer([
|
||||
'name' => 'same-host-b',
|
||||
'host' => '82.40.33.225',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $limit,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
$other = $this->makeServer([
|
||||
'name' => 'other-host',
|
||||
'host' => '203.0.113.10',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $limit,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$this->makeServerStat($first, '2026-03-17', 900, 0);
|
||||
$this->makeServerStat($first, '2026-03-18', 100, 40);
|
||||
$this->makeServerStat($second, '2026-04-01', 200, 60);
|
||||
$this->makeServerStat($other, '2026-04-01', 500, 0);
|
||||
|
||||
$snapshots = app(ServerTrafficLimitService::class)->buildSnapshotsForServers(
|
||||
collect([$first, $second, $other]),
|
||||
$reference
|
||||
);
|
||||
|
||||
$this->assertSame(400, $snapshots[$first->id]['used']);
|
||||
$this->assertSame(400, $snapshots[$second->id]['used']);
|
||||
$this->assertSame(500, $snapshots[$other->id]['used']);
|
||||
$this->assertSame([$first->id, $second->id], $snapshots[$first->id]['scope_node_ids']);
|
||||
$this->assertSame('host:82.40.33.225', $snapshots[$first->id]['scope_key']);
|
||||
}
|
||||
|
||||
public function test_traffic_limit_snapshot_prefers_machine_scope_over_host(): void
|
||||
{
|
||||
$reference = Carbon::create(2026, 4, 17, 12, 0, 0, 'UTC')->timestamp;
|
||||
$limit = 1000 * 1024 * 1024;
|
||||
$machine = ServerMachine::create([
|
||||
'name' => 'shared-machine',
|
||||
'token' => ServerMachine::generateToken(),
|
||||
]);
|
||||
$first = $this->makeServer([
|
||||
'name' => 'machine-a',
|
||||
'host' => '198.51.100.1',
|
||||
'machine_id' => $machine->id,
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $limit,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
$second = $this->makeServer([
|
||||
'name' => 'machine-b',
|
||||
'host' => '198.51.100.2',
|
||||
'machine_id' => $machine->id,
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $limit,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
$sameHostWithoutMachine = $this->makeServer([
|
||||
'name' => 'same-host-without-machine',
|
||||
'host' => '198.51.100.1',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $limit,
|
||||
'traffic_limit_reset_day' => 18,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$this->makeServerStat($first, '2026-03-18', 100, 0);
|
||||
$this->makeServerStat($second, '2026-03-18', 200, 0);
|
||||
$this->makeServerStat($sameHostWithoutMachine, '2026-03-18', 500, 0);
|
||||
|
||||
$snapshots = app(ServerTrafficLimitService::class)->buildSnapshotsForServers(
|
||||
collect([$first, $second, $sameHostWithoutMachine]),
|
||||
$reference
|
||||
);
|
||||
|
||||
$this->assertSame(300, $snapshots[$first->id]['used']);
|
||||
$this->assertSame(300, $snapshots[$second->id]['used']);
|
||||
$this->assertSame(500, $snapshots[$sameHostWithoutMachine->id]['used']);
|
||||
$this->assertSame('machine:' . $machine->id, $snapshots[$first->id]['scope_key']);
|
||||
}
|
||||
|
||||
public function test_build_node_config_preserves_current_runtime_suspended_state(): void
|
||||
{
|
||||
$server = $this->makeServer([
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => 1024,
|
||||
'traffic_limit_reset_day' => 1,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
'traffic_limit_status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
'traffic_limit_suspended_at' => 123456,
|
||||
'u' => 100,
|
||||
'd' => 100,
|
||||
]);
|
||||
|
||||
Cache::put($this->metricsCacheKey($server), [
|
||||
'traffic_limit' => [
|
||||
'enabled' => true,
|
||||
'limit' => 1024,
|
||||
'used' => 400,
|
||||
'suspended' => true,
|
||||
'last_reset_at' => 0,
|
||||
'next_reset_at' => Carbon::now('UTC')->addDay()->timestamp,
|
||||
'suspended_at' => 123456,
|
||||
'status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
],
|
||||
], 300);
|
||||
|
||||
$config = app(ServerTrafficLimitService::class)->buildNodeConfig($server);
|
||||
|
||||
$this->assertSame(400, $config['current_used']);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_SUSPENDED, $config['status']);
|
||||
$this->assertSame(123456, $config['suspended_at']);
|
||||
}
|
||||
|
||||
public function test_traffic_limit_snapshot_shares_runtime_metrics_by_scope(): void
|
||||
{
|
||||
$first = $this->makeServer([
|
||||
'name' => 'runtime-a',
|
||||
'host' => '82.40.33.225',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => 1024,
|
||||
'traffic_limit_reset_day' => 1,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
$second = $this->makeServer([
|
||||
'name' => 'runtime-b',
|
||||
'host' => '82.40.33.225',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => 1024,
|
||||
'traffic_limit_reset_day' => 1,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
Cache::put($this->metricsCacheKey($first), [
|
||||
'traffic_limit' => [
|
||||
'enabled' => true,
|
||||
'limit' => 1024,
|
||||
'used' => 700,
|
||||
'suspended' => true,
|
||||
'last_reset_at' => 0,
|
||||
'next_reset_at' => Carbon::now('UTC')->addDay()->timestamp,
|
||||
'suspended_at' => 123456,
|
||||
'status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
],
|
||||
], 300);
|
||||
|
||||
$snapshots = app(ServerTrafficLimitService::class)->buildSnapshotsForServers(collect([$first, $second]));
|
||||
|
||||
$this->assertSame(700, $snapshots[$first->id]['used']);
|
||||
$this->assertSame(700, $snapshots[$second->id]['used']);
|
||||
$this->assertTrue($snapshots[$first->id]['suspended']);
|
||||
$this->assertTrue($snapshots[$second->id]['suspended']);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_SUSPENDED, $snapshots[$second->id]['status']);
|
||||
}
|
||||
|
||||
public function test_refresh_schedule_resumes_suspended_node_when_limit_increases_above_usage(): void
|
||||
{
|
||||
$oneGiB = 1024 * 1024 * 1024;
|
||||
$fiveHundredGiB = 500 * $oneGiB;
|
||||
$nextResetAt = Carbon::now('UTC')->addDay()->timestamp;
|
||||
$server = $this->makeServer([
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => $fiveHundredGiB,
|
||||
'traffic_limit_reset_day' => 1,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
'traffic_limit_status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
'traffic_limit_next_reset_at' => $nextResetAt,
|
||||
'traffic_limit_suspended_at' => 123456,
|
||||
'u' => $oneGiB,
|
||||
'd' => 0,
|
||||
]);
|
||||
|
||||
Cache::put($this->metricsCacheKey($server), [
|
||||
'traffic_limit' => [
|
||||
'enabled' => true,
|
||||
'limit' => $oneGiB,
|
||||
'used' => $oneGiB,
|
||||
'suspended' => true,
|
||||
'last_reset_at' => 0,
|
||||
'next_reset_at' => $nextResetAt,
|
||||
'suspended_at' => 123456,
|
||||
'status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
],
|
||||
], 300);
|
||||
|
||||
$service = app(ServerTrafficLimitService::class);
|
||||
$service->refreshSchedule($server, false);
|
||||
|
||||
$fresh = $server->fresh();
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $fresh->traffic_limit_status);
|
||||
$this->assertNull($fresh->traffic_limit_suspended_at);
|
||||
|
||||
$config = $service->buildNodeConfig($fresh);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $config['status']);
|
||||
$this->assertSame(0, $config['suspended_at']);
|
||||
|
||||
$metrics = Cache::get($this->metricsCacheKey($fresh));
|
||||
$this->assertFalse($metrics['traffic_limit']['suspended']);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $metrics['traffic_limit']['status']);
|
||||
$this->assertSame($fiveHundredGiB, $metrics['traffic_limit']['limit']);
|
||||
}
|
||||
|
||||
public function test_stale_metrics_from_old_limit_do_not_re_suspend_after_limit_increase(): void
|
||||
{
|
||||
$oneGiB = 1024 * 1024 * 1024;
|
||||
$server = $this->makeServer([
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => 500 * $oneGiB,
|
||||
'traffic_limit_reset_day' => 1,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
'traffic_limit_status' => Server::TRAFFIC_LIMIT_STATUS_NORMAL,
|
||||
'u' => $oneGiB,
|
||||
'd' => 0,
|
||||
]);
|
||||
|
||||
$snapshot = app(ServerTrafficLimitService::class)->applyRuntimeMetrics($server, [
|
||||
'enabled' => true,
|
||||
'limit' => $oneGiB,
|
||||
'used' => $oneGiB,
|
||||
'suspended' => true,
|
||||
'last_reset_at' => 0,
|
||||
'next_reset_at' => Carbon::now('UTC')->addDay()->timestamp,
|
||||
'suspended_at' => 123456,
|
||||
'status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
]);
|
||||
|
||||
$this->assertFalse($snapshot['suspended']);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $snapshot['status']);
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $server->fresh()->traffic_limit_status);
|
||||
$this->assertNull($server->fresh()->traffic_limit_suspended_at);
|
||||
}
|
||||
|
||||
public function test_traffic_limit_suspension_hides_and_reset_restores_only_auto_hidden_children(): void
|
||||
{
|
||||
$parent = $this->makeServer([
|
||||
'name' => 'limited-parent',
|
||||
'traffic_limit_enabled' => true,
|
||||
'transfer_enable' => 1000,
|
||||
'traffic_limit_reset_day' => 1,
|
||||
'traffic_limit_reset_time' => '00:00',
|
||||
'traffic_limit_timezone' => 'UTC',
|
||||
'show' => true,
|
||||
]);
|
||||
$visibleChild = $this->makeServer([
|
||||
'name' => 'visible-child',
|
||||
'parent_id' => $parent->id,
|
||||
'show' => true,
|
||||
]);
|
||||
$manualHiddenChild = $this->makeServer([
|
||||
'name' => 'manual-hidden-child',
|
||||
'parent_id' => $parent->id,
|
||||
'show' => false,
|
||||
]);
|
||||
|
||||
$service = app(ServerTrafficLimitService::class);
|
||||
$service->applyRuntimeMetrics($parent, [
|
||||
'enabled' => true,
|
||||
'limit' => 1000,
|
||||
'used' => 1000,
|
||||
'suspended' => true,
|
||||
'last_reset_at' => 0,
|
||||
'next_reset_at' => Carbon::now('UTC')->addDay()->timestamp,
|
||||
'suspended_at' => 123456,
|
||||
'status' => Server::TRAFFIC_LIMIT_STATUS_SUSPENDED,
|
||||
]);
|
||||
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_SUSPENDED, $parent->fresh()->traffic_limit_status);
|
||||
$this->assertFalse($visibleChild->fresh()->show);
|
||||
$this->assertTrue($visibleChild->fresh()->parent_auto_hidden);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->show);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->parent_auto_hidden);
|
||||
|
||||
$service->resetServer($parent->fresh(), false);
|
||||
|
||||
$this->assertSame(Server::TRAFFIC_LIMIT_STATUS_NORMAL, $parent->fresh()->traffic_limit_status);
|
||||
$this->assertTrue($visibleChild->fresh()->show);
|
||||
$this->assertFalse($visibleChild->fresh()->parent_auto_hidden);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->show);
|
||||
$this->assertFalse($manualHiddenChild->fresh()->parent_auto_hidden);
|
||||
}
|
||||
|
||||
private function makeServer(array $attributes = []): Server
|
||||
{
|
||||
return Server::create(array_merge([
|
||||
'name' => 'test-node',
|
||||
'type' => Server::TYPE_VMESS,
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 443,
|
||||
'server_port' => 443,
|
||||
'rate' => '1',
|
||||
'group_ids' => [1],
|
||||
'show' => true,
|
||||
'auto_online' => false,
|
||||
'gfw_check_enabled' => true,
|
||||
'gfw_auto_hidden' => false,
|
||||
'parent_auto_hidden' => false,
|
||||
'enabled' => true,
|
||||
'u' => 0,
|
||||
'd' => 0,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function metricsCacheKey(Server $server): string
|
||||
{
|
||||
return CacheKey::get('SERVER_' . strtoupper($server->type) . '_METRICS', $server->id);
|
||||
}
|
||||
|
||||
private function makeServerStat(Server $server, string $date, int $upload, int $download): StatServer
|
||||
{
|
||||
return StatServer::create([
|
||||
'server_id' => $server->id,
|
||||
'server_type' => $server->type,
|
||||
'record_type' => 'd',
|
||||
'record_at' => Carbon::parse($date, 'UTC')->startOfDay()->timestamp,
|
||||
'u' => $upload,
|
||||
'd' => $download,
|
||||
'created_at' => time(),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user