diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 7109055..954ee25 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -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 ### 新增 diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 0b06c72..7dcc527 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -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): 客户端订阅导出入口、协议适配器与版本兼容过滤 diff --git a/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/.status.json b/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/.status.json new file mode 100644 index 0000000..f11d136 --- /dev/null +++ b/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/.status.json @@ -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" +} diff --git a/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/proposal.md b/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/proposal.md new file mode 100644 index 0000000..08668ee --- /dev/null +++ b/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/proposal.md @@ -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 宽度维持现状,新增一行不改变表格布局。 diff --git a/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/tasks.md b/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/tasks.md new file mode 100644 index 0000000..10f7810 --- /dev/null +++ b/.helloagents/archive/2026-04/202604290123_node-traffic-yesterday-stats/tasks.md @@ -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` 对应下行;本次不反转历史语义。 +- 用户截图中的“今日下行多、本月上行多”本身可能是正常数据分布,因为本月包含今天及之前日期;新增昨日后便于判断差异来自哪一天。 diff --git a/.helloagents/archive/2026-04/202604290132_shared-node-traffic-limit/proposal.md b/.helloagents/archive/2026-04/202604290132_shared-node-traffic-limit/proposal.md new file mode 100644 index 0000000..619d2bc --- /dev/null +++ b/.helloagents/archive/2026-04/202604290132_shared-node-traffic-limit/proposal.md @@ -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。本次不调整节点管理页视觉结构,只修正“月额度”展示数据源。 diff --git a/.helloagents/archive/2026-04/202604290132_shared-node-traffic-limit/tasks.md b/.helloagents/archive/2026-04/202604290132_shared-node-traffic-limit/tasks.md new file mode 100644 index 0000000..3f2b97c --- /dev/null +++ b/.helloagents/archive/2026-04/202604290132_shared-node-traffic-limit/tasks.md @@ -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。 +- 不新增数据库字段,不执行生产数据操作。 diff --git a/.helloagents/archive/2026-04/202604290153_parent-node-auto-visibility/proposal.md b/.helloagents/archive/2026-04/202604290153_parent-node-auto-visibility/proposal.md new file mode 100644 index 0000000..502241a --- /dev/null +++ b/.helloagents/archive/2026-04/202604290153_parent-node-auto-visibility/proposal.md @@ -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。此次为后端状态联动与数据标记修复,不涉及视觉产出。 diff --git a/.helloagents/archive/2026-04/202604290153_parent-node-auto-visibility/tasks.md b/.helloagents/archive/2026-04/202604290153_parent-node-auto-visibility/tasks.md new file mode 100644 index 0000000..ce2672a --- /dev/null +++ b/.helloagents/archive/2026-04/202604290153_parent-node-auto-visibility/tasks.md @@ -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 通过。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index ff0421d..38038ea 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -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`、初始化/部署/更新/状态检查脚本和部署说明 diff --git a/.helloagents/context.md b/.helloagents/context.md index 6654dd4..01016ff 100644 --- a/.helloagents/context.md +++ b/.helloagents/context.md @@ -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 风格为基线,优先纯色分区、系统字体栈和低装饰成本 diff --git a/.helloagents/modules/_index.md b/.helloagents/modules/_index.md index b657d6e..9b60b3c 100644 --- a/.helloagents/modules/_index.md +++ b/.helloagents/modules/_index.md @@ -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 | diff --git a/.helloagents/modules/admin-frontend.md b/.helloagents/modules/admin-frontend.md index 0346389..1f4a940 100644 --- a/.helloagents/modules/admin-frontend.md +++ b/.helloagents/modules/admin-frontend.md @@ -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` 推导,不在前端伪造额外接口 diff --git a/.helloagents/modules/node-traffic-limit.md b/.helloagents/modules/node-traffic-limit.md index 16c0a3a..3271d03 100644 --- a/.helloagents/modules/node-traffic-limit.md +++ b/.helloagents/modules/node-traffic-limit.md @@ -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/*` 提供配置与展示入口 diff --git a/admin-frontend/src/types/api.d.ts b/admin-frontend/src/types/api.d.ts index 5147fc4..73e8d4c 100644 --- a/admin-frontend/src/types/api.d.ts +++ b/admin-frontend/src/types/api.d.ts @@ -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 diff --git a/admin-frontend/src/utils/nodes.ts b/admin-frontend/src/utils/nodes.ts index cf9a5ed..3a73c1c 100644 --- a/admin-frontend/src/utils/nodes.ts +++ b/admin-frontend/src/utils/nodes.ts @@ -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, diff --git a/app/Http/Controllers/V2/Admin/Server/ManageController.php b/app/Http/Controllers/V2/Admin/Server/ManageController.php index b4068f2..a6c3562 100644 --- a/app/Http/Controllers/V2/Admin/Server/ManageController.php +++ b/app/Http/Controllers/V2/Admin/Server/ManageController.php @@ -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 + */ + 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; diff --git a/app/Models/Server.php b/app/Models/Server.php index 7d8139c..5e6c732 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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', diff --git a/app/Services/ServerAutoOnlineService.php b/app/Services/ServerAutoOnlineService.php index bd541f8..7351d1e 100644 --- a/app/Services/ServerAutoOnlineService.php +++ b/app/Services/ServerAutoOnlineService.php @@ -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 diff --git a/app/Services/ServerParentVisibilityService.php b/app/Services/ServerParentVisibilityService.php new file mode 100644 index 0000000..cc9ad76 --- /dev/null +++ b/app/Services/ServerParentVisibilityService.php @@ -0,0 +1,113 @@ +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, + ]; + } +} diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index e5680d7..09cf561 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -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( diff --git a/app/Services/ServerTrafficLimitService.php b/app/Services/ServerTrafficLimitService.php index 7ef8cb5..d5679f8 100644 --- a/app/Services/ServerTrafficLimitService.php +++ b/app/Services/ServerTrafficLimitService.php @@ -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); diff --git a/database/migrations/2026_04_29_015400_add_parent_auto_visibility_fields_to_v2_server_table.php b/database/migrations/2026_04_29_015400_add_parent_auto_visibility_fields_to_v2_server_table.php new file mode 100644 index 0000000..2948442 --- /dev/null +++ b/database/migrations/2026_04_29_015400_add_parent_auto_visibility_fields_to_v2_server_table.php @@ -0,0 +1,44 @@ +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); + } + } + }); + } +}; diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..539c7dd --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,9 @@ +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 + */ + private function resolveWindows(int $referenceTimestamp): array + { + $controller = new ManageController(); + $method = new ReflectionMethod(ManageController::class, 'resolveNodeTrafficWindows'); + $method->setAccessible(true); + + /** @var array $result */ + $result = $method->invoke($controller, $referenceTimestamp); + + return $result; + } +} diff --git a/tests/Unit/ServerAutoOnlineServiceTest.php b/tests/Unit/ServerAutoOnlineServiceTest.php index 59666f7..69d84f1 100644 --- a/tests/Unit/ServerAutoOnlineServiceTest.php +++ b/tests/Unit/ServerAutoOnlineServiceTest.php @@ -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)); } diff --git a/tests/Unit/ServerTrafficLimitServiceTest.php b/tests/Unit/ServerTrafficLimitServiceTest.php index fe342a8..6055e8b 100644 --- a/tests/Unit/ServerTrafficLimitServiceTest.php +++ b/tests/Unit/ServerTrafficLimitServiceTest.php @@ -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(), + ]); + } }