feat(api): 新增节点墙检测自动托管与显隐

新增定时墙检测命令与节点托管字段,自动为开启托管的父
节点创建检测任务,并在 blocked 时自动隐藏节点、normal
时仅恢复由墙检测自动隐藏的节点

更新自动上线服务以尊重 blocked 与自动隐藏状态,避免疑
似被墙节点被重新发布;同时补齐管理端墙检测托管开关、
刷新入口、批量设置与相关测试和知识库同步
This commit is contained in:
yinjianm
2026-04-28 00:51:49 +08:00
parent 73b1696b0a
commit ff50030364
27 changed files with 998 additions and 24 deletions
+7
View File
@@ -1,5 +1,12 @@
# CHANGELOG
## [0.6.3] - 2026-04-28
### 新增
- **[node-gfw-check]**: 为节点墙状态检测打通自动检测与自动显隐;`sync:server-gfw-checks` 会自动为开启托管的父节点创建检测任务,`blocked` 时自动隐藏节点并阻止自动上线重新发布,`normal` 时只恢复由墙检测自动隐藏的节点;管理端节点页新增刷新数据、墙检测托管开关和批量设置入口 — by yinjianm
- 方案: [202604280024_node-gfw-auto-check-and-online](archive/2026-04/202604280024_node-gfw-auto-check-and-online/)
- 决策: node-gfw-auto-check-and-online#D001(使用自动隐藏标记隔离管理员手动显隐), node-gfw-auto-check-and-online#D002(自动上线服务必须把 blocked 作为显示否决)
## [0.6.2] - 2026-04-27
### 新增
+2 -2
View File
@@ -3,7 +3,7 @@
```yaml
kb_version: 2
project: Xboard-new
updated_at: 2026-04-27
updated_at: 2026-04-28
active_package:
```
@@ -11,7 +11,7 @@ active_package: 无
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
- 当前重点模块: `admin-frontend``node-gfw-check``order-payment``subscription-protocols`
- 最新归档: `202604272325_node-gfw-check`
- 最新归档: `202604280024_node-gfw-auto-check-and-online`
## 活跃模块
@@ -0,0 +1,10 @@
{
"status": "completed",
"completed": 8,
"failed": 0,
"pending": 0,
"total": 8,
"percent": 100,
"current": "开发实施完成,准备归档方案包",
"updated_at": "2026-04-28 00:48:00"
}
@@ -0,0 +1,229 @@
# 变更提案: node-gfw-auto-check-and-online
## 元信息
```yaml
类型: 新功能
方案类型: implementation
优先级: P1
状态: 已确认
创建: 2026-04-28
```
---
## 1. 需求
### 背景
节点墙状态检测已具备手动触发、节点端执行、结果上报和节点列表展示能力,但当前仍需要管理员手动检测。实际运营需要后台自动检测所有父节点,并在节点疑似被中国防火墙拦截时自动隐藏,避免继续发布到用户订阅配置。
### 目标
- 自动为开启托管的父节点创建墙状态检测任务,子节点不单独检测,继续继承父节点状态。
- 节点可单独关闭自动墙检测托管;关闭后不参与自动检测和自动墙状态显隐动作。
- 仅当检测结果为 `blocked` 时自动隐藏节点;恢复 `normal` 时只恢复由墙检测自动隐藏过的节点。
- 自动上线服务必须尊重墙状态:`blocked` 是显示否决条件,防止现有 `auto_online` 把疑似被墙节点重新发布。
- 节点管理页新增刷新数据按钮,并展示/切换墙检测托管状态。
### 约束条件
```yaml
时间约束: 本轮完成后端、前端、测试和知识库同步
性能约束: 自动检测调度不得重复创建 pending/checking 任务,避免对节点端和三网目标造成无意义压力
兼容性约束: 不改变 mi-node 现有 gfw.check 协议;不改变手动检测接口;现有节点默认开启自动墙检测托管
业务约束: show=0 是订阅发布边界;子节点不创建独立检测任务;partial/failed 不触发自动下线
```
### 验收标准
- [ ] 后端存在定时命令,可自动为开启墙检测托管的父节点创建检测任务,并跳过子节点、关闭托管节点和已有待执行任务的节点。
- [ ] `blocked` 上报后节点自动 `show=0`,并记录自动隐藏标记;`normal` 上报后只恢复曾由墙检测自动隐藏的节点。
- [ ] `sync:server-auto-online` 在节点疑似被墙时不会把节点重新显示。
- [ ] 管理端节点列表可刷新数据、可切换节点自动墙检测托管开关,并能通过搜索/筛选继续识别疑似被墙和正常节点。
- [ ] 前端构建通过;PHP 侧至少完成新增测试用例或说明本机 PHP 不可用导致未执行。
---
## 2. 方案
### 技术方案
采用方案 A:复用现有墙检测链路和自动上线链路,新增节点级墙检测托管字段与自动隐藏标记。
- 数据层在 `v2_server` 新增:
- `gfw_check_enabled`: 是否参与自动墙检测和墙状态自动显隐,默认 `true`
- `gfw_auto_hidden`: 是否由墙检测自动隐藏,默认 `false`,用于避免恢复管理员手动隐藏的节点。
- `gfw_auto_action_at`: 最近一次墙检测自动显隐动作时间。
- `ServerGfwCheckService` 新增自动检测入口:
- 只查询 `parent_id is null``gfw_check_enabled=1` 的节点。
- 若已有 `pending/checking` 检测任务则跳过。
- 创建任务后沿用 `NodeSyncService::push(..., 'gfw.check', ...)` 和节点端 REST 兜底领取。
- `ServerGfwCheckService::reportResult()` 在写入最终状态后执行自动显隐:
- `blocked`: 对父节点及其子节点中仍开启 `gfw_check_enabled` 的节点设置 `show=0``gfw_auto_hidden=1`
- `normal`: 仅对 `gfw_auto_hidden=1` 的节点恢复 `show=1` 并清理标记。
- `partial/failed`: 只记录状态,不改变 `show`
- `ServerAutoOnlineService` 同步时把最新继承墙状态为 `blocked` 作为显示否决条件,避免自动上线覆盖墙检测隐藏。
- 管理端节点页在现有 Apple 风格工作台上增量扩展:
- 工具栏新增刷新数据按钮,调用 `loadNodeBoard()`
- 表格新增“墙检测”托管开关;父节点会自动检测,子节点不独立检测但可单独关闭随父节点自动隐藏/恢复。
- 批量修改弹窗支持统一设置自动墙检测托管。
- 搜索文本补充自动墙检/自动隐藏关键字。
### 影响范围
```yaml
涉及模块:
- Laravel 数据模型: 新增 v2_server 墙检测托管与自动隐藏字段
- node-gfw-check: 新增自动检测调度、自动显隐动作和状态查询辅助
- admin-frontend: 节点列表刷新入口、墙检测托管开关、类型和筛选文案
- 测试: 扩展自动上线与墙检测服务测试
预计变更文件: 12-16
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 自动恢复显示误覆盖管理员手动隐藏 | 高 | 使用 `gfw_auto_hidden` 标记,只恢复由墙检测自动隐藏的节点 |
| 自动上线重新显示 blocked 节点 | 高 | 在 `ServerAutoOnlineService` 中加入墙状态否决 |
| 自动检测任务堆积 | 中 | 自动调度前跳过已有 `pending/checking` 任务的节点 |
| 子节点行为误解 | 中 | 子节点不检测,但继承父节点状态;自动动作只作用于仍开启托管的子节点 |
| 前端列宽变密 | 低 | 延续现有表格密度,新增窄列和 Tooltip,不重做页面结构 |
### 方案取舍
```yaml
唯一方案理由: 复用现有检测协议和自动上线服务,新增最少字段解决自动检测、自动隐藏、自动恢复和手动隐藏冲突问题。
放弃的替代路径:
- 只在上报时直接改 show: 会被 auto_online 覆盖,也无法区分管理员手动隐藏。
- 新建完整策略中心: 审计与扩展更完整,但本轮范围过大,后续接入墙内检测 IP 时再升级更合适。
回滚边界: 可回滚新增迁移、命令、服务逻辑和前端字段;历史 server_gfw_checks 检测记录不需要删除。
```
---
## 3. 技术设计
### 架构设计
```mermaid
flowchart TD
A[Laravel Scheduler] --> B[sync:server-gfw-checks]
B --> C[ServerGfwCheckService::startAutomaticChecks]
C --> D[server_gfw_checks pending]
C --> E[NodeSyncService gfw.check]
F[mi-node WS/REST] --> G[ping 三网目标]
G --> H[POST /server/gfw/report]
H --> I[ServerGfwCheckService::reportResult]
I --> J{status}
J -->|blocked| K[show=0 + gfw_auto_hidden=1]
J -->|normal| L[恢复 gfw_auto_hidden 节点 show=1]
M[sync:server-auto-online] --> N[blocked 状态否决显示]
```
### API 设计
#### POST `server/manage/update`
- **新增请求字段**: `gfw_check_enabled?: boolean`
- **行为**: 父节点可切换自动墙检测托管;子节点保存字段但不创建独立检测任务。
#### POST `server/manage/batchUpdate`
- **新增请求字段**: `gfw_check_enabled?: boolean`
- **行为**: 支持批量开启/关闭自动墙检测托管。
#### GET `server/manage/getNodes`
- **新增响应字段**:
- `gfw_check_enabled`
- `gfw_auto_hidden`
- `gfw_auto_action_at`
- **保留响应字段**: `gfw_check` 继续承载墙状态、继承来源和检测时间。
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `v2_server.gfw_check_enabled` | boolean | 是否参与自动墙检测和墙状态自动显隐 |
| `v2_server.gfw_auto_hidden` | boolean | 是否由墙检测自动隐藏 |
| `v2_server.gfw_auto_action_at` | unsignedInteger nullable | 最近一次墙检测自动显隐动作 Unix 时间 |
---
## 4. 核心场景
### 场景: 自动检测父节点
**模块**: node-gfw-check
**条件**: 父节点 `gfw_check_enabled=1`,且没有 `pending/checking` 检测任务
**行为**: 定时命令创建检测记录并推送 `gfw.check`
**结果**: 节点端通过 WS 或 REST 兜底执行检测并上报结果
### 场景: 疑似被墙自动隐藏
**模块**: node-gfw-check
**条件**: 检测结果判定为 `blocked`
**行为**: 后端将开启墙检测托管的父节点及其子节点 `show=0`,并设置 `gfw_auto_hidden=1`
**结果**: `ServerService::getAvailableServers()` 不再返回这些节点,订阅配置不再发布
### 场景: 恢复正常自动显示
**模块**: node-gfw-check
**条件**: 后续检测结果判定为 `normal`,节点存在 `gfw_auto_hidden=1`
**行为**: 后端恢复 `show=1` 并清理自动隐藏标记
**结果**: 只恢复曾由墙检测自动隐藏的节点,不恢复管理员原本手动隐藏的节点
---
## 5. 技术决策
### node-gfw-auto-check-and-online#D001: 使用自动隐藏标记隔离管理员手动显隐
**日期**: 2026-04-28
**状态**: ✅采纳
**背景**: 自动恢复显示必须避免把管理员手动隐藏的节点误发布。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 新增 `gfw_auto_hidden` 标记 | 可精确恢复自动隐藏节点,和手动显隐互不覆盖 | 需要新增字段和测试 |
| B: 只根据最新状态直接设置 `show` | 实现少 | 会恢复管理员手动隐藏节点 |
**决策**: 选择方案 A
**理由**: 发布控制属于高影响业务行为,必须可追踪自动动作来源。
**影响**: `v2_server``ServerGfwCheckService`、管理端节点列表。
### node-gfw-auto-check-and-online#D002: 自动上线服务必须把 blocked 作为显示否决
**日期**: 2026-04-28
**状态**: ✅采纳
**背景**: 现有 `sync:server-auto-online` 会按在线状态同步 `show`,若不接入墙状态,疑似被墙节点可能被重新显示。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 在自动上线服务中加入墙状态否决 | 和现有定时链路一致,避免状态互相覆盖 | 需要查询最新墙状态 |
| B: 只依赖检测上报时改 `show` | 改动少 | 后续自动上线可能覆盖 |
**决策**: 选择方案 A
**理由**: 自动上线是当前 `show` 的定时真相源之一,必须在同一服务中纳入墙状态。
**影响**: `ServerAutoOnlineService`、相关单元测试。
---
## 6. 验证策略
```yaml
verifyMode: test-first
reviewerFocus:
- ServerGfwCheckService 自动创建任务、自动显隐、子节点继承边界
- ServerAutoOnlineService 与 blocked 状态的冲突处理
- 管理端字段类型和子节点托管提示
testerFocus:
- tests/Unit/ServerAutoOnlineServiceTest.php
- 新增 tests/Unit/ServerGfwCheckServiceTest.php
- admin-frontend npm run build
uiValidation: optional
riskBoundary:
- 不执行真实生产调度
- 不修改 mi-node 检测协议
- 不删除历史检测记录
```
---
## 7. 成果设计
### 设计方向
- **美学基调**: Apple 风格后台工作台的克制密度;以白色工作台、黑色标题区、Apple Blue 交互强调和语义状态色承载新增控制。
- **记忆点**: 节点行内形成“显隐 / 自动上线 / 墙检测”三段式托管开关,运营人员能快速判断哪个自动机制正在接管发布状态。
- **参考**: `apple/DESIGN.md` 与现有 `NodesView.vue`
### 视觉要素
- **配色**: 保持 `#000000` Hero、`#ffffff` 工作台、`#0071e3` 交互蓝;blocked 继续使用危险语义色。
- **字体**: 延续项目现有系统字体栈和 Element Plus 字体,不引入远程字体。
- **布局**: 新增窄列表格列和工具栏刷新按钮,不重构页面;移动端沿用现有表格横向滚动能力。
- **动效**: 使用 Element Plus `loading` 状态和 Switch 反馈,不增加额外动效。
- **氛围**: 维持低装饰成本,无渐变、无背景纹理。
### 技术约束
- **可访问性**: 子节点墙检测开关提供继承行为 Tooltip,刷新按钮带图标和文本。
- **响应式**: 工具栏继续换行,新增按钮不固定宽度,避免窄屏溢出。
@@ -0,0 +1,98 @@
# 任务清单: node-gfw-auto-check-and-online
> **@status:** completed | 2026-04-28 00:41
```yaml
@feature: node-gfw-auto-check-and-online
@created: 2026-04-28
@status: completed
@mode: R2
```
## LIVE_STATUS
```json
{"status":"completed","completed":8,"failed":0,"pending":0,"total":8,"percent":100,"current":"开发实施完成,准备归档方案包","updated_at":"2026-04-28 00:48:00"}
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 8 | 0 | 0 | 8 |
---
## 任务列表
### 1. 后端数据与调度
- [√] 1.1 新增 `database/migrations/*_add_gfw_auto_fields_to_v2_server_table.php`
- 预期变更: 为 `v2_server` 增加 `gfw_check_enabled``gfw_auto_hidden``gfw_auto_action_at` 字段。
- 完成标准: 迁移包含字段存在性保护和 down 回滚逻辑;默认节点自动墙检测开启。
- 验证方式: 代码审查迁移字段和回滚逻辑。
- depends_on: []
- [√] 1.2 修改 `app/Models/Server.php``app/Http/Requests/Admin/ServerSave.php``app/Http/Controllers/V2/Admin/Server/ManageController.php`
- 预期变更: 模型 casts、保存、单节点更新和批量更新支持新增字段。
- 完成标准: `getNodes/save/update/batchUpdate` 可读写新增字段,现有字段不回退。
- 验证方式: 代码审查请求校验和 payload 字段。
- depends_on: [1.1]
- [√] 1.3 修改 `app/Services/ServerGfwCheckService.php`
- 预期变更: 新增自动检测批量创建、跳过 active 任务、blocked/normal 自动显隐、blocked 状态查询辅助。
- 完成标准: 自动检测只作用父节点;子节点继承检测结果但不创建任务;partial/failed 不改变 show。
- 验证方式: 单元测试覆盖自动检测、自动隐藏、自动恢复和子节点联动。
- depends_on: [1.2]
- [√] 1.4 新增 `app/Console/Commands/SyncServerGfwChecks.php` 并修改 `app/Console/Kernel.php`
- 预期变更: 新增 `sync:server-gfw-checks` 命令并加入 Laravel Scheduler。
- 完成标准: 命令输出 started/skipped/active 等统计;调度不与现有命令冲突。
- 验证方式: 代码审查命令签名、调度频率和 withoutOverlapping。
- depends_on: [1.3]
- [√] 1.5 修改 `app/Services/ServerAutoOnlineService.php`
- 预期变更: 自动上线同步时把最新 inherited/source 墙状态 `blocked` 作为显示否决条件。
- 完成标准: `auto_online=1` 且在线的 blocked 节点仍保持隐藏;非 blocked 继续按在线状态同步。
- 验证方式: 单元测试覆盖 blocked 不被自动上线重新显示。
- depends_on: [1.3]
### 2. 管理端前端
- [√] 2.1 修改 `admin-frontend/src/types/api.d.ts``admin-frontend/src/api/admin.ts``admin-frontend/src/utils/nodes.ts`
- 预期变更: 类型、更新 payload、统计和搜索文本支持 `gfw_check_enabled/gfw_auto_hidden/gfw_auto_action_at`
- 完成标准: TypeScript 可识别新增字段;搜索可命中自动墙检/自动隐藏相关关键词。
- 验证方式: `npm run build`
- depends_on: [1.2]
- [√] 2.2 修改 `admin-frontend/src/views/nodes/NodesView.vue``NodeBatchEditDialog.vue`、节点编辑映射工具
- 预期变更: 新增刷新数据按钮、墙检测托管开关、批量设置入口和编辑保存字段映射。
- 完成标准: 父节点可切换自动墙检测;子节点开关提示只控制随父节点自动隐藏/恢复;刷新按钮显示加载反馈。
- 验证方式: `npm run build` + 代码级 UI 审查。
- depends_on: [2.1]
### 3. 测试与知识库
- [√] 3.1 修改/新增 `tests/Unit/ServerAutoOnlineServiceTest.php``tests/Unit/ServerGfwCheckServiceTest.php`
- 预期变更: 覆盖自动墙检测任务创建、blocked 自动隐藏、normal 自动恢复、自动上线 blocked 否决。
- 完成标准: 测试断言自动动作不会误恢复手动隐藏节点。
- 验证方式: 本机可用时运行 PHPUnit;不可用时报告 PHP CLI 缺失。
- depends_on: [1.3, 1.5]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-28 00:24 | 方案设计 | in_progress | 已选择方案 A,准备开发实施 |
| 2026-04-28 00:36 | 后端开发 | completed | 已新增自动检测字段、命令、服务联动和测试 |
| 2026-04-28 00:43 | 前端开发 | completed | 已新增刷新按钮、墙检测托管开关和批量设置入口 |
| 2026-04-28 00:48 | 验证 | completed | `npm run build` 通过;PHP/Composer 不在 PATHPHPUnit 未执行 |
---
## 执行备注
- 用户确认“隐藏”即不发布到订阅配置;当前实现以 `show=0` 为发布边界。
- 子节点不创建自动检测任务,但可继承父节点检测结果;子节点开关只控制是否随父节点自动隐藏/恢复。
- `partial/failed` 不主动恢复由墙检测隐藏的节点;只有 `normal` 结果会恢复 `gfw_auto_hidden=1` 的节点。
+2
View File
@@ -7,6 +7,7 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
| 202604280024 | node-gfw-auto-check-and-online | implementation | node-gfw-check,admin-frontend | node-gfw-auto-check-and-online#D001,#D002 | ✅完成 |
| 202604272338 | admin-frontend-node-auto-online | - | - | - | ✅完成 |
| 202604272325 | node-gfw-check | implementation | node-gfw-check,admin-frontend,mi-node | node-gfw-check#D001,#D002 | ✅完成 |
| 202604272310 | ticket-chat-image-dnd-paste-upload | implementation | admin-frontend | ticket-chat-image-dnd-paste-upload#D001 | ✅完成 |
@@ -39,6 +40,7 @@
## 按月归档
### 2026-04
- [202604280024_node-gfw-auto-check-and-online](./2026-04/202604280024_node-gfw-auto-check-and-online/) - 为节点墙状态检测打通自动检测与自动显隐,支持开启托管的父节点定时检测、疑似被墙自动隐藏、恢复正常自动显示,并让自动上线尊重 blocked 状态
- [202604272325_node-gfw-check](./2026-04/202604272325_node-gfw-check/) - 新增节点墙状态检测闭环,支持父节点检测、子节点继承、管理端展示筛选,以及 mi-node WS/REST 检测上报
- [202604272310_ticket-chat-image-dnd-paste-upload](./2026-04/202604272310_ticket-chat-image-dnd-paste-upload/) - 为工单工作台回复区补齐图片拖拽上传与剪贴板粘贴上传,并将上传逻辑与样式从超大 SFC 中拆分
- [202604250018_admin-frontend-user-activity-status-filter](./2026-04/202604250018_admin-frontend-user-activity-status-filter/) - 为用户管理高级筛选新增“活跃状态”条件,并在后端补齐 `activity_status` 复合过滤规则,支持按活跃 / 非活跃筛选用户
+3 -2
View File
@@ -111,8 +111,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 种协议的新增 / 编辑弹窗和排序对话框
- 节点自动上线由后端 `sync:server-auto-online` 定时命令执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;未开启自动上线的节点继续保持手动显隐控制
- `#/nodes` 当前已升级为真实节点工作台:支持搜索、在线 / 离线筛选、父/子节点筛选、墙状态筛选、分页浏览、显隐切换、自动上线托管开关、墙检测托管开关、刷新数据、复制、单节点置顶、仅对已勾选节点生效的批量修改 / 批量删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框
- 节点自动上线由后端 `sync:server-auto-online` 定时命令执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;未开启自动上线的节点继续保持手动显隐控制;墙状态为 `blocked` 或仍处于 `gfw_auto_hidden` 且未恢复正常时会否决自动显示
- 节点自动墙检测由后端 `sync:server-gfw-checks` 定时命令执行,只为开启 `gfw_check_enabled` 的父节点创建检测任务;子节点不独立检测,但可控制是否随父节点自动隐藏 / 恢复
- Bearer Token 存储于 `sessionStorage/localStorage`
- `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本
+2 -2
View File
@@ -2,7 +2,7 @@
| 模块名 | 说明 | 最近更新 |
|--------|------|----------|
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-27 |
| [node-gfw-check](node-gfw-check.md) | 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 | 2026-04-27 |
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-28 |
| [node-gfw-check](node-gfw-check.md) | 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 | 2026-04-28 |
| [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 |
| [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 |
+2 -1
View File
@@ -42,8 +42,9 @@
- TUIC 表单默认以 V5 / V4 版本选择、`h3 / h2 / http/1.1` ALPN 选项和 `native / quic` UDP Relay Mode 对齐后端协议模板;AnyTLS Padding Scheme 默认值与 `Server` 模型完整模板保持一致
- 节点排序采用本地草稿 + 上移 / 下移模式,保存时向 `server/manage/sort` 提交 `{ id, order }[]` 顺序 payload
- 节点列表现支持本地分页、在线 / 离线筛选、父/子节点筛选,以及跨分页稳定勾选;批量修改 / 批量删除仅作用于已勾选节点,其中批量修改可统一更新 `host / group_ids / rate / auto_online`
- 节点管理页新增“自动上线”托管开关;开启后后台 `sync:server-auto-online` 会按节点在线状态自动同步 `show`,在线 / 待同步时显示、离线时隐藏,未开启的节点仍保持手动显隐控制
- 节点管理页新增“自动上线”托管开关;开启后后台 `sync:server-auto-online` 会按节点在线状态自动同步 `show`,在线 / 待同步时显示、离线时隐藏,未开启的节点仍保持手动显隐控制;疑似被墙或仍处于墙检测自动隐藏状态的节点不会被自动上线重新发布
- 节点管理页现支持墙状态展示、墙状态筛选与关键词搜索;父节点可通过行级或批量操作发起检测,子节点不单独检测并显示“随父节点”的继承状态
- 节点管理页现支持“墙检测托管”开关、批量设置和刷新数据按钮;父节点开启后参与 `sync:server-gfw-checks` 自动检测,子节点不独立检测但可控制是否随父节点自动隐藏 / 恢复
- 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 payload 并提交到 `server/manage/sort`
- 权限组管理页使用真实后端 `server/group/fetch``server/group/save``server/group/drop`,支持关键字搜索、新增/编辑中央弹窗、删除确认,以及从节点数量列跳转到 `#/nodes?group={id}` 的筛选联动
- 路由管理页使用真实后端 `server/route/fetch``server/route/save``server/route/drop`,支持路由列表、关键词搜索、新增/编辑中央弹窗、删除与动作值展示
+7
View File
@@ -13,7 +13,12 @@
- 子节点列表展示继承父节点最新 `gfw_check`,并返回 `inherited=true``source_node_id`
- `server_gfw_checks.status` 使用 `pending / checking / normal / blocked / partial / failed / skipped`
- 管理端 `POST server/manage/checkGfw` 接收 `{ ids: number[] }`,响应中区分 `started``skipped`
- 后端定时命令 `sync:server-gfw-checks` 会自动为 `gfw_check_enabled=1` 的父节点创建检测任务;已有 `pending/checking` 任务时跳过,避免重复检测
- 节点端 `GET server/gfw/task` 只向父节点返回待执行任务;节点端 `POST server/gfw/report` 必须校验 `check_id` 归属当前节点
- `v2_server.gfw_check_enabled` 控制节点是否参与自动墙检测与墙状态自动显隐;父节点开启时会自动创建检测任务,子节点不独立检测但可单独关闭随父节点自动隐藏 / 恢复
- `blocked` 结果会自动隐藏仍开启墙检测托管且当前显示中的父节点及其子节点,并设置 `gfw_auto_hidden=1`
- `normal` 结果只恢复 `gfw_auto_hidden=1` 的节点,避免误恢复管理员手动隐藏的节点;`partial/failed` 只记录状态,不触发自动上线或下线
- `sync:server-auto-online` 会把最新墙状态 `blocked` 和未恢复的 `gfw_auto_hidden` 作为显示否决条件,防止自动上线重新发布疑似被墙节点
- 当前检测方向只做节点服务器主动 ping 国内三网目标;后续墙内探测 IP 可在同一任务模型中扩展
- 参考脚本中的 Telegram 通知、chat_id、bot token 和自动安装依赖逻辑不得进入项目实现
- mi-node 使用 Go 原生 runner 调用系统 `ping`,按三网目标并发检测并结构化上报 `summary / operator_summary / raw_result`
@@ -26,5 +31,7 @@
- 依赖 `app/Http/Controllers/V2/Admin/Server/ManageController.php` 暴露管理端触发接口
- 依赖 `app/Http/Controllers/V2/Server/ServerController.php` 暴露节点端任务领取和上报接口
- 依赖 `app/Services/NodeSyncService.php` 与 Workerman WS 通道向在线节点推送 `gfw.check`
- 依赖 `app/Console/Commands/SyncServerGfwChecks.php` 与 Laravel Scheduler 自动创建检测任务
- 依赖 `app/Services/ServerAutoOnlineService.php` 在自动上线同步时尊重墙状态否决
- 依赖 `E:/code/go/mi-node/internal/gfwcheck` 执行 ping 检测和结果判定
- 依赖 `E:/code/go/mi-node/internal/panel``internal/controlplane``internal/service` 接收任务、轮询兜底并上报结果
+6
View File
@@ -881,6 +881,9 @@ export interface AdminNodeItem {
tags?: string[] | null
show: boolean
auto_online?: boolean
gfw_check_enabled?: boolean
gfw_auto_hidden?: boolean
gfw_auto_action_at?: number | null
enabled?: boolean
parent_id?: number | null
rate?: number | null
@@ -941,6 +944,7 @@ export interface AdminNodeUpdatePayload {
id: number
show?: boolean | number
auto_online?: boolean
gfw_check_enabled?: boolean
enabled?: boolean
machine_id?: number | null
}
@@ -951,6 +955,7 @@ export interface AdminNodeBatchUpdatePayload {
rate?: number
group_ids?: string[]
auto_online?: boolean
gfw_check_enabled?: boolean
}
export interface AdminNodeSavePayload {
@@ -972,6 +977,7 @@ export interface AdminNodeSavePayload {
protocol_settings?: Record<string, unknown>
show?: boolean | number
auto_online?: boolean
gfw_check_enabled?: boolean
}
declare global {
@@ -172,6 +172,7 @@ export function toNodeFormModel(node?: AdminNodeItem | null): NodeFormModel {
form.parentId = node.parent_id ?? null
form.show = toBooleanValue(node.show, true)
form.autoOnline = toBooleanValue(node.auto_online)
form.gfwCheckEnabled = toBooleanValue(node.gfw_check_enabled, true)
form.enabled = toBooleanValue(node.enabled, true)
form.tlsMode = Number(protocolSettings.tls ?? 0)
form.tlsServerName = toStringValue(tlsSettings.server_name || tlsObject.server_name)
@@ -493,5 +494,6 @@ export function toNodeSavePayload(form: NodeFormModel): AdminNodeSavePayload {
protocol_settings: buildProtocolSettings(form),
show: form.show ? 1 : 0,
auto_online: form.autoOnline,
gfw_check_enabled: form.gfwCheckEnabled,
}
}
@@ -32,6 +32,7 @@ export interface NodeFormModel {
parentId: number | null
show: boolean
autoOnline: boolean
gfwCheckEnabled: boolean
enabled: boolean
tlsMode: number
tlsServerName: string
@@ -243,6 +244,7 @@ export function createEmptyNodeForm(): NodeFormModel {
parentId: null,
show: true,
autoOnline: false,
gfwCheckEnabled: true,
enabled: true,
tlsMode: 0,
tlsServerName: '',
+11 -1
View File
@@ -145,11 +145,15 @@ export function getNodeGfwTooltip(node: AdminNodeItem): string {
const checkedAt = node.gfw_check?.checked_at
? new Date(Number(node.gfw_check.checked_at) * 1000).toLocaleString()
: ''
const actionAt = node.gfw_auto_action_at
? new Date(Number(node.gfw_auto_action_at) * 1000).toLocaleString()
: ''
const sourceText = meta.inherited && source ? `,来源父节点 #${source}` : ''
const timeText = checkedAt ? `,检测时间 ${checkedAt}` : ''
const autoText = node.gfw_auto_hidden ? `,已自动隐藏${actionAt ? `${actionAt}` : ''}` : ''
const errorText = node.gfw_check?.error_message ? `,错误:${node.gfw_check.error_message}` : ''
return `${meta.label}${sourceText}${timeText}${errorText}`
return `${meta.label}${sourceText}${timeText}${autoText}${errorText}`
}
function isNodeOnlineStatus(status: NodeStatusClass): boolean {
@@ -200,6 +204,8 @@ function buildNodeSearchText(node: AdminNodeItem): string {
node.server_port,
getNodeTypeLabel(node.type),
node.auto_online ? '自动上线 自动托管 auto online' : '',
node.gfw_check_enabled === false ? '关闭墙检测 关闭自动墙检 gfw disabled' : '自动墙检 墙检测托管 gfw enabled',
node.gfw_auto_hidden ? '自动隐藏 墙检测隐藏 疑似被墙已隐藏 gfw auto hidden' : '',
getNodeGfwMeta(node).searchText,
...getNodeGroupNames(node),
]
@@ -282,3 +288,7 @@ export function countVisibleNodes(nodes: AdminNodeItem[]): number {
export function countAutoOnlineNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => Boolean(node.auto_online)).length
}
export function countAutoGfwCheckNodes(nodes: AdminNodeItem[]): number {
return nodes.filter((node) => node.gfw_check_enabled !== false).length
}
@@ -8,6 +8,7 @@ interface NodeBatchEditPayload {
rate?: number
group_ids?: string[]
auto_online?: boolean
gfw_check_enabled?: boolean
}
const props = defineProps<{
@@ -31,6 +32,8 @@ const form = reactive({
groupIds: [] as number[],
updateAutoOnline: false,
autoOnline: true,
updateGfwCheck: false,
gfwCheckEnabled: true,
})
const hasEnabledField = computed(() => (
@@ -38,6 +41,7 @@ const hasEnabledField = computed(() => (
|| form.updateRate
|| form.updateGroups
|| form.updateAutoOnline
|| form.updateGfwCheck
))
function resetForm() {
@@ -49,6 +53,8 @@ function resetForm() {
form.groupIds = []
form.updateAutoOnline = false
form.autoOnline = true
form.updateGfwCheck = false
form.gfwCheckEnabled = true
}
function closeDialog() {
@@ -76,6 +82,7 @@ function handleSubmit() {
rate: form.updateRate ? Number(form.rate) : undefined,
group_ids: form.updateGroups ? [...new Set(form.groupIds.map((item) => String(item)))] : undefined,
auto_online: form.updateAutoOnline ? form.autoOnline : undefined,
gfw_check_enabled: form.updateGfwCheck ? form.gfwCheckEnabled : undefined,
})
}
@@ -188,6 +195,24 @@ watch(
<ElSwitch v-model="form.autoOnline" :disabled="!form.updateAutoOnline" />
</label>
</section>
<section class="batch-section">
<label class="batch-switch-card">
<div>
<strong>批量设置墙检测托管</strong>
<span>启用后父节点会自动检测子节点不独立检测只跟随父节点自动隐藏或恢复</span>
</div>
<ElSwitch v-model="form.updateGfwCheck" />
</label>
<label class="batch-switch-card batch-switch-card--nested">
<div>
<strong>{{ form.gfwCheckEnabled ? '开启墙检测托管' : '关闭墙检测托管' }}</strong>
<span>关闭后不会参与自动墙检测和墙状态自动显隐</span>
</div>
<ElSwitch v-model="form.gfwCheckEnabled" :disabled="!form.updateGfwCheck" />
</label>
</section>
</div>
<template #footer>
@@ -338,6 +338,13 @@ watch(
</div>
<ElSwitch v-model="form.autoOnline" />
</label>
<label class="switch-card">
<div>
<strong>墙检测托管</strong>
<span>{{ form.parentId ? '子节点不独立检测,只控制是否随父节点自动隐藏或恢复。' : '开启后后台会自动检测并在疑似被墙时隐藏。' }}</span>
</div>
<ElSwitch v-model="form.gfwCheckEnabled" />
</label>
<label class="switch-card">
<div>
<strong>启用节点</strong>
+78 -1
View File
@@ -35,6 +35,7 @@ import NodeEditorDialog from './NodeEditorDialog.vue'
import NodeSortDialog from './NodeSortDialog.vue'
import {
buildNodeTypeOptions,
countAutoGfwCheckNodes,
countAutoOnlineNodes,
countOnlineNodes,
countVisibleNodes,
@@ -77,6 +78,7 @@ const selectedNodeIds = ref<number[]>([])
const syncingSelection = ref(false)
const switchingIds = ref<number[]>([])
const autoSwitchingIds = ref<number[]>([])
const gfwSwitchingIds = ref<number[]>([])
const workingIds = ref<number[]>([])
const editorVisible = ref(false)
const editorMode = ref<NodeDialogMode>('create')
@@ -119,6 +121,7 @@ const summaryCards = computed(() => [
{ label: '在线节点', value: String(countOnlineNodes(nodes.value)) },
{ label: '显示中', value: String(countVisibleNodes(nodes.value)) },
{ label: '自动上线', value: String(countAutoOnlineNodes(nodes.value)) },
{ label: '自动墙检', value: String(countAutoGfwCheckNodes(nodes.value)) },
{ label: '已勾选', value: String(selectedNodes.value.length) },
])
@@ -166,6 +169,10 @@ function isAutoSwitching(id: number): boolean {
return autoSwitchingIds.value.includes(id)
}
function isGfwSwitching(id: number): boolean {
return gfwSwitchingIds.value.includes(id)
}
function isWorking(id: number): boolean {
return workingIds.value.includes(id)
}
@@ -293,6 +300,7 @@ async function handleBatchSubmit(payload: NodeBatchEditPayload) {
rate: payload.rate,
group_ids: payload.group_ids,
auto_online: payload.auto_online,
gfw_check_enabled: payload.gfw_check_enabled,
}
try {
@@ -453,6 +461,29 @@ async function handleToggleAutoOnline(node: AdminNodeItem, nextValue: boolean) {
}
}
async function handleToggleGfwCheck(node: AdminNodeItem, nextValue: boolean) {
const previous = node.gfw_check_enabled !== false
if (previous === nextValue) {
return
}
node.gfw_check_enabled = nextValue
markPending(gfwSwitchingIds, node.id, true)
try {
await updateNode({
id: node.id,
gfw_check_enabled: nextValue,
})
ElMessage.success(nextValue ? '已开启墙检测托管' : '已关闭墙检测托管')
} catch (error) {
node.gfw_check_enabled = previous
ElMessage.error(error instanceof Error ? error.message : '墙检测托管状态更新失败')
} finally {
markPending(gfwSwitchingIds, node.id, false)
}
}
async function handlePinTop(node: AdminNodeItem) {
const orderedNodes = sortNodesByOrder(nodes.value)
if (orderedNodes[0]?.id === node.id) {
@@ -652,6 +683,13 @@ watch(
<ElIcon><Connection /></ElIcon>
检测墙状态
</ElButton>
<ElButton
:loading="loading"
@click="loadNodeBoard"
>
<ElIcon><RefreshRight /></ElIcon>
刷新数据
</ElButton>
<ElButton
type="danger"
plain
@@ -720,13 +758,30 @@ watch(
<ElSwitch
:model-value="Boolean(row.show)"
:loading="isSwitching(row.id)"
:disabled="Boolean(row.auto_online)"
:disabled="Boolean(row.auto_online) || (Boolean(row.gfw_auto_hidden) && row.gfw_check_enabled !== false)"
@change="(value) => handleToggleShow(row, Boolean(value))"
/>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="墙检测" width="118">
<template #default="{ row }">
<ElTooltip
:content="row.parent_id ? '子节点不单独检测;此开关只控制是否随父节点自动隐藏或恢复。' : '关闭后不参与自动墙检测和墙状态自动显隐。'"
placement="top"
>
<div class="switch-shell switch-shell--gfw">
<ElSwitch
:model-value="row.gfw_check_enabled !== false"
:loading="isGfwSwitching(row.id)"
@change="(value) => handleToggleGfwCheck(row, Boolean(value))"
/>
</div>
</ElTooltip>
</template>
</ElTableColumn>
<ElTableColumn label="自动上线" width="118">
<template #default="{ row }">
<div class="switch-shell switch-shell--auto">
@@ -759,6 +814,24 @@ watch(
>
自动上线
</ElTag>
<ElTag
v-if="row.gfw_check_enabled !== false"
round
effect="plain"
type="primary"
class="auto-online-tag"
>
墙检测
</ElTag>
<ElTag
v-if="row.gfw_auto_hidden"
round
effect="plain"
type="danger"
class="auto-online-tag"
>
自动隐藏
</ElTag>
<ElTooltip :content="getNodeGfwTooltip(row)" placement="top">
<ElTag
round
@@ -1048,6 +1121,10 @@ watch(
--el-switch-on-color: #0071e3;
}
.switch-shell--gfw :deep(.el-switch) {
--el-switch-on-color: #34c759;
}
.node-cell,
.stack-cell,
.online-cell {
@@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use App\Services\ServerGfwCheckService;
use Illuminate\Console\Command;
class SyncServerGfwChecks extends Command
{
protected $signature = 'sync:server-gfw-checks {--limit= : Maximum number of nodes to enqueue}';
protected $description = 'Create automated GFW check tasks for managed parent nodes';
public function handle(ServerGfwCheckService $service): int
{
$limit = $this->option('limit');
$result = $service->startAutomaticChecks(
is_numeric($limit) ? (int) $limit : null
);
$this->info(sprintf(
'Server GFW checks synced: total=%d started=%d skipped=%d active=%d',
$result['total'],
count($result['started']),
count($result['skipped']),
$result['active']
));
return self::SUCCESS;
}
}
+1
View File
@@ -45,6 +45,7 @@ class Kernel extends ConsoleKernel
// cleanup stale online_count (GC for Redis TTL expiration)
$schedule->command('cleanup:online-status')->everyFiveMinutes()->onOneServer();
$schedule->command('sync:server-auto-online')->everyFiveMinutes()->onOneServer()->withoutOverlapping(5);
$schedule->command('sync:server-gfw-checks')->everyThirtyMinutes()->onOneServer()->withoutOverlapping(30);
// backup Timing
// if (env('ENABLE_AUTO_BACKUP_AND_UPDATE', false)) {
// $schedule->command('backup:database', ['true'])->daily()->onOneServer();
@@ -59,6 +59,10 @@ class ManageController extends Controller
return $this->fail([400202, '服务器不存在']);
}
try {
if (array_key_exists('show', $params)) {
$params['gfw_auto_hidden'] = false;
$params['gfw_auto_action_at'] = null;
}
$server->update($params);
return $this->success(true);
} catch (\Exception $e) {
@@ -82,6 +86,7 @@ class ManageController extends Controller
'id' => 'required|integer',
'show' => 'nullable|integer',
'auto_online' => 'nullable|boolean',
'gfw_check_enabled' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
'enabled' => 'nullable|boolean',
]);
@@ -93,10 +98,15 @@ class ManageController extends Controller
if (array_key_exists('show', $params)) {
$server->show = (int) $params['show'];
$server->gfw_auto_hidden = false;
$server->gfw_auto_action_at = null;
}
if (array_key_exists('auto_online', $params)) {
$server->auto_online = (bool) $params['auto_online'];
}
if (array_key_exists('gfw_check_enabled', $params)) {
$server->gfw_check_enabled = (bool) $params['gfw_check_enabled'];
}
if (array_key_exists('machine_id', $params)) {
$server->machine_id = $params['machine_id'] ?: null;
}
@@ -231,6 +241,7 @@ class ManageController extends Controller
'ids.*' => 'integer',
'show' => 'nullable|integer|in:0,1',
'auto_online' => 'nullable|boolean',
'gfw_check_enabled' => 'nullable|boolean',
'enabled' => 'nullable|boolean',
'machine_id' => 'nullable|integer',
'host' => 'sometimes|required|string',
@@ -247,10 +258,15 @@ class ManageController extends Controller
$update = [];
if (array_key_exists('show', $params) && $params['show'] !== null) {
$update['show'] = (int) $params['show'];
$update['gfw_auto_hidden'] = false;
$update['gfw_auto_action_at'] = null;
}
if (array_key_exists('auto_online', $params) && $params['auto_online'] !== null) {
$update['auto_online'] = (bool) $params['auto_online'];
}
if (array_key_exists('gfw_check_enabled', $params) && $params['gfw_check_enabled'] !== null) {
$update['gfw_check_enabled'] = (bool) $params['gfw_check_enabled'];
}
if (array_key_exists('enabled', $params) && $params['enabled'] !== null) {
$update['enabled'] = (bool) $params['enabled'];
}
+1
View File
@@ -117,6 +117,7 @@ class ServerSave extends FormRequest
'code' => 'nullable|string',
'show' => '',
'auto_online' => 'nullable|boolean',
'gfw_check_enabled' => 'nullable|boolean',
'name' => 'required|string',
'group_ids' => 'nullable|array',
'route_ids' => 'nullable|array',
+6
View File
@@ -25,6 +25,9 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
* @property array|null $tags 标签
* @property boolean $show 是否显示
* @property boolean $auto_online 是否根据在线状态自动同步显示
* @property boolean $gfw_check_enabled 是否自动检测墙状态并同步显示
* @property boolean $gfw_auto_hidden 是否由墙状态自动隐藏
* @property int|null $gfw_auto_action_at 最近墙状态自动显隐时间
* @property string|null $allow_insecure 是否允许不安全
* @property string|null $network 网络类型
* @property int|null $parent_id 父节点ID
@@ -127,6 +130,9 @@ class Server extends Model
'last_push_at' => 'integer',
'show' => 'boolean',
'auto_online' => 'boolean',
'gfw_check_enabled' => 'boolean',
'gfw_auto_hidden' => 'boolean',
'gfw_auto_action_at' => 'integer',
'enabled' => 'boolean',
'created_at' => 'timestamp',
'updated_at' => 'timestamp',
+24 -3
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Server;
use App\Models\ServerGfwCheck;
class ServerAutoOnlineService
{
@@ -11,6 +12,7 @@ class ServerAutoOnlineService
$servers = Server::query()
->where('auto_online', true)
->get();
$gfwStatuses = app(ServerGfwCheckService::class)->getLatestStatusesForServers($servers);
$result = [
'total' => $servers->count(),
@@ -21,18 +23,37 @@ class ServerAutoOnlineService
];
foreach ($servers as $server) {
$shouldShow = (int) $server->available_status !== Server::STATUS_OFFLINE;
$sourceNodeId = (int) ($server->parent_id ?: $server->id);
$gfwStatus = $gfwStatuses[$sourceNodeId] ?? null;
$isGfwManaged = (bool) ($server->gfw_check_enabled ?? true) && $gfwStatus !== null;
$isGfwBlocked = $isGfwManaged && $gfwStatus === ServerGfwCheck::STATUS_BLOCKED;
$isGfwHeld = $isGfwManaged
&& (bool) $server->gfw_auto_hidden
&& $gfwStatus !== ServerGfwCheck::STATUS_NORMAL;
$shouldShow = !$isGfwBlocked && !$isGfwHeld && (int) $server->available_status !== Server::STATUS_OFFLINE;
$shouldClearGfwAutoHidden = $gfwStatus === ServerGfwCheck::STATUS_NORMAL
&& (bool) $server->gfw_auto_hidden;
$wasShown = (bool) $server->show;
if ((bool) $server->show === $shouldShow) {
if ($wasShown === $shouldShow && !$shouldClearGfwAutoHidden) {
$result['unchanged']++;
continue;
}
$server->show = $shouldShow;
if ($isGfwBlocked) {
$server->gfw_auto_hidden = true;
$server->gfw_auto_action_at = time();
} elseif ($shouldClearGfwAutoHidden) {
$server->gfw_auto_hidden = false;
$server->gfw_auto_action_at = time();
}
$server->save();
$result['updated']++;
$shouldShow ? $result['shown']++ : $result['hidden']++;
if ($wasShown !== $shouldShow) {
$shouldShow ? $result['shown']++ : $result['hidden']++;
}
}
return $result;
+190 -12
View File
@@ -13,7 +13,7 @@ class ServerGfwCheckService
ServerGfwCheck::STATUS_CHECKING,
];
public function startChecks(array $ids, ?int $adminUserId = null): array
public function startChecks(array $ids, ?int $adminUserId = null, bool $respectAutoSwitch = false): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
$servers = Server::whereIn('id', $ids)->get()->keyBy('id');
@@ -37,13 +37,16 @@ class ServerGfwCheckService
continue;
}
$check = ServerGfwCheck::create([
'server_id' => $server->id,
'status' => ServerGfwCheck::STATUS_PENDING,
'triggered_by' => $adminUserId,
]);
if ($respectAutoSwitch && !$this->isGfwCheckEnabled($server)) {
$skipped[] = [
'id' => $id,
'status' => ServerGfwCheck::STATUS_SKIPPED,
'reason' => '节点已关闭自动墙检测',
];
continue;
}
NodeSyncService::push($server->id, 'gfw.check', $this->formatTask($check));
$check = $this->createCheck($server, $adminUserId);
$started[] = [
'id' => $server->id,
'check_id' => $check->id,
@@ -58,6 +61,54 @@ class ServerGfwCheckService
];
}
public function startAutomaticChecks(?int $limit = null): array
{
$query = Server::query()
->whereNull('parent_id')
->where('gfw_check_enabled', true)
->orderBy('sort', 'ASC')
->orderBy('id', 'ASC');
if ($limit !== null && $limit > 0) {
$query->limit($limit);
}
$servers = $query->get();
$activeServerIds = ServerGfwCheck::whereIn('server_id', $servers->pluck('id'))
->whereIn('status', self::TASK_STATUS)
->pluck('server_id')
->map(fn ($id) => (int) $id)
->all();
$activeLookup = array_flip($activeServerIds);
$started = [];
$skipped = [];
foreach ($servers as $server) {
if (isset($activeLookup[(int) $server->id])) {
$skipped[] = [
'id' => (int) $server->id,
'status' => ServerGfwCheck::STATUS_SKIPPED,
'reason' => '已有检测任务等待上报',
];
continue;
}
$check = $this->createCheck($server, null);
$started[] = [
'id' => (int) $server->id,
'check_id' => (int) $check->id,
'status' => $check->status,
];
}
return [
'started' => $started,
'skipped' => $skipped,
'total' => $servers->count(),
'active' => count($activeServerIds),
];
}
public function decorateServers(Collection $servers): Collection
{
$sourceIds = $servers
@@ -65,11 +116,7 @@ class ServerGfwCheckService
->unique()
->values();
$latestChecks = ServerGfwCheck::whereIn('server_id', $sourceIds)
->orderByDesc('id')
->get()
->groupBy('server_id')
->map(fn (Collection $items) => $items->first());
$latestChecks = $this->latestChecksByServerIds($sourceIds);
return $servers->map(function (Server $server) use ($latestChecks) {
$sourceNodeId = (int) ($server->parent_id ?: $server->id);
@@ -79,6 +126,43 @@ class ServerGfwCheckService
});
}
public function getBlockedSourceIdsForServers(Collection $servers): array
{
return collect($this->getLatestStatusesForServers($servers))
->filter(fn (string $status) => $status === ServerGfwCheck::STATUS_BLOCKED)
->keys()
->map(fn ($id) => (int) $id)
->values()
->all();
}
public function getLatestStatusesForServers(Collection $servers): array
{
$sourceIds = $servers
->map(fn (Server $server) => (int) ($server->parent_id ?: $server->id))
->filter()
->unique()
->values();
if ($sourceIds->isEmpty()) {
return [];
}
$enabledSourceIds = Server::whereIn('id', $sourceIds)
->where('gfw_check_enabled', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->values();
if ($enabledSourceIds->isEmpty()) {
return [];
}
return $this->latestChecksByServerIds($enabledSourceIds)
->map(fn (ServerGfwCheck $check) => $check->status)
->all();
}
public function getPendingTaskForNode(Server $node): ?array
{
if ($node->parent_id) {
@@ -134,9 +218,24 @@ class ServerGfwCheckService
'checked_at' => time(),
]);
$this->syncVisibilityFromStatus($node, $status);
return true;
}
private function createCheck(Server $server, ?int $adminUserId): ServerGfwCheck
{
$check = ServerGfwCheck::create([
'server_id' => $server->id,
'status' => ServerGfwCheck::STATUS_PENDING,
'triggered_by' => $adminUserId,
]);
NodeSyncService::push($server->id, 'gfw.check', $this->formatTask($check));
return $check;
}
private function formatTask(ServerGfwCheck $check): array
{
return [
@@ -171,6 +270,85 @@ class ServerGfwCheckService
];
}
private function latestChecksByServerIds($sourceIds): Collection
{
$ids = collect($sourceIds)
->map(fn ($id) => (int) $id)
->filter()
->unique()
->values();
if ($ids->isEmpty()) {
return collect();
}
return ServerGfwCheck::whereIn('server_id', $ids)
->orderByDesc('id')
->get()
->groupBy('server_id')
->map(fn (Collection $items) => $items->first());
}
private function syncVisibilityFromStatus(Server $sourceNode, string $status): array
{
if (!in_array($status, [ServerGfwCheck::STATUS_BLOCKED, ServerGfwCheck::STATUS_NORMAL], true)) {
return ['shown' => 0, 'hidden' => 0, 'unchanged' => 0];
}
if (!$this->isGfwCheckEnabled($sourceNode)) {
return ['shown' => 0, 'hidden' => 0, 'unchanged' => 1];
}
$nodes = Server::query()
->where('id', $sourceNode->id)
->orWhere('parent_id', $sourceNode->id)
->get();
$result = ['shown' => 0, 'hidden' => 0, 'unchanged' => 0];
$now = time();
foreach ($nodes as $node) {
if (!$this->isGfwCheckEnabled($node)) {
$result['unchanged']++;
continue;
}
if ($status === ServerGfwCheck::STATUS_BLOCKED) {
if (!(bool) $node->show) {
$result['unchanged']++;
continue;
}
$node->update([
'show' => false,
'gfw_auto_hidden' => true,
'gfw_auto_action_at' => $now,
]);
$result['hidden']++;
continue;
}
if (!(bool) $node->gfw_auto_hidden) {
$result['unchanged']++;
continue;
}
$node->update([
'show' => true,
'gfw_auto_hidden' => false,
'gfw_auto_action_at' => $now,
]);
$result['shown']++;
}
return $result;
}
private function isGfwCheckEnabled(Server $server): bool
{
return (bool) ($server->gfw_check_enabled ?? true);
}
private function determineStatus(?array $operators, string $reportedStatus, string $errorMessage): string
{
if ($errorMessage !== '') {
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('v2_server', function (Blueprint $table) {
if (!Schema::hasColumn('v2_server', 'gfw_check_enabled')) {
$table->boolean('gfw_check_enabled')
->default(true)
->after('auto_online')
->comment('Automatically run GFW checks and visibility actions');
}
if (!Schema::hasColumn('v2_server', 'gfw_auto_hidden')) {
$table->boolean('gfw_auto_hidden')
->default(false)
->after('gfw_check_enabled')
->comment('Hidden by automated GFW check action');
}
if (!Schema::hasColumn('v2_server', 'gfw_auto_action_at')) {
$table->unsignedInteger('gfw_auto_action_at')
->nullable()
->after('gfw_auto_hidden')
->comment('Last automated GFW visibility action time');
}
});
}
public function down(): void
{
Schema::table('v2_server', function (Blueprint $table) {
if (Schema::hasColumn('v2_server', 'gfw_auto_action_at')) {
$table->dropColumn('gfw_auto_action_at');
}
if (Schema::hasColumn('v2_server', 'gfw_auto_hidden')) {
$table->dropColumn('gfw_auto_hidden');
}
if (Schema::hasColumn('v2_server', 'gfw_check_enabled')) {
$table->dropColumn('gfw_check_enabled');
}
});
}
};
@@ -3,6 +3,7 @@
namespace Tests\Unit;
use App\Models\Server;
use App\Models\ServerGfwCheck;
use App\Services\ServerAutoOnlineService;
use App\Services\ServerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -44,6 +45,56 @@ class ServerAutoOnlineServiceTest extends TestCase
$this->assertTrue($manualOffline->fresh()->show);
}
public function test_sync_keeps_gfw_blocked_auto_online_node_hidden(): void
{
$managedOnline = $this->makeServer([
'name' => 'managed-gfw-blocked',
'show' => true,
'auto_online' => true,
'gfw_check_enabled' => true,
]);
ServerService::touchNode($managedOnline);
ServerGfwCheck::create([
'server_id' => $managedOnline->id,
'status' => ServerGfwCheck::STATUS_BLOCKED,
'checked_at' => time(),
]);
$result = app(ServerAutoOnlineService::class)->sync();
$this->assertSame(1, $result['total']);
$this->assertSame(1, $result['updated']);
$this->assertSame(0, $result['shown']);
$this->assertSame(1, $result['hidden']);
$this->assertFalse($managedOnline->fresh()->show);
$this->assertTrue($managedOnline->fresh()->gfw_auto_hidden);
}
public function test_sync_ignores_blocked_status_when_gfw_check_is_disabled(): void
{
$managedOnline = $this->makeServer([
'name' => 'managed-gfw-disabled',
'show' => false,
'auto_online' => true,
'gfw_check_enabled' => false,
]);
ServerService::touchNode($managedOnline);
ServerGfwCheck::create([
'server_id' => $managedOnline->id,
'status' => ServerGfwCheck::STATUS_BLOCKED,
'checked_at' => time(),
]);
$result = app(ServerAutoOnlineService::class)->sync();
$this->assertSame(1, $result['total']);
$this->assertSame(1, $result['updated']);
$this->assertSame(1, $result['shown']);
$this->assertTrue($managedOnline->fresh()->show);
}
private function makeServer(array $attributes = []): Server
{
return Server::create(array_merge([
@@ -56,6 +107,8 @@ class ServerAutoOnlineServiceTest extends TestCase
'group_ids' => [1],
'show' => false,
'auto_online' => false,
'gfw_check_enabled' => true,
'gfw_auto_hidden' => false,
'enabled' => true,
], $attributes));
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Unit;
use App\Models\Server;
use App\Models\ServerGfwCheck;
use App\Services\ServerGfwCheckService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ServerGfwCheckServiceTest extends TestCase
{
use RefreshDatabase;
public function test_start_automatic_checks_only_enqueues_enabled_parent_nodes_without_active_task(): void
{
$eligible = $this->makeServer(['name' => 'eligible-parent']);
$active = $this->makeServer(['name' => 'active-parent']);
$this->makeServer([
'name' => 'disabled-parent',
'gfw_check_enabled' => false,
]);
$this->makeServer([
'name' => 'child-node',
'parent_id' => $eligible->id,
]);
ServerGfwCheck::create([
'server_id' => $active->id,
'status' => ServerGfwCheck::STATUS_PENDING,
]);
$result = app(ServerGfwCheckService::class)->startAutomaticChecks();
$this->assertSame(2, $result['total']);
$this->assertSame(1, $result['active']);
$this->assertSame([$eligible->id], array_column($result['started'], 'id'));
$this->assertCount(1, $result['skipped']);
$this->assertDatabaseHas('server_gfw_checks', [
'server_id' => $eligible->id,
'status' => ServerGfwCheck::STATUS_PENDING,
]);
}
public function test_report_result_hides_blocked_nodes_and_restores_only_auto_hidden_nodes(): void
{
$parent = $this->makeServer([
'name' => 'parent',
'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(ServerGfwCheckService::class);
$blockedCheck = ServerGfwCheck::create([
'server_id' => $parent->id,
'status' => ServerGfwCheck::STATUS_PENDING,
]);
$this->assertTrue($service->reportResult($parent, [
'check_id' => $blockedCheck->id,
'operator_summary' => $this->blockedOperators(),
]));
$this->assertFalse($parent->fresh()->show);
$this->assertTrue($parent->fresh()->gfw_auto_hidden);
$this->assertFalse($visibleChild->fresh()->show);
$this->assertTrue($visibleChild->fresh()->gfw_auto_hidden);
$this->assertFalse($manualHiddenChild->fresh()->show);
$this->assertFalse($manualHiddenChild->fresh()->gfw_auto_hidden);
$normalCheck = ServerGfwCheck::create([
'server_id' => $parent->id,
'status' => ServerGfwCheck::STATUS_PENDING,
]);
$this->assertTrue($service->reportResult($parent->fresh(), [
'check_id' => $normalCheck->id,
'operator_summary' => $this->normalOperators(),
]));
$this->assertTrue($parent->fresh()->show);
$this->assertFalse($parent->fresh()->gfw_auto_hidden);
$this->assertTrue($visibleChild->fresh()->show);
$this->assertFalse($visibleChild->fresh()->gfw_auto_hidden);
$this->assertFalse($manualHiddenChild->fresh()->show);
$this->assertFalse($manualHiddenChild->fresh()->gfw_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,
'enabled' => true,
], $attributes));
}
private function blockedOperators(): array
{
return [
'ct' => ['total' => 2, 'success' => 0],
'cu' => ['total' => 2, 'success' => 0],
'cm' => ['total' => 2, 'success' => 0],
];
}
private function normalOperators(): array
{
return [
'ct' => ['total' => 2, 'success' => 2],
'cu' => ['total' => 2, 'success' => 2],
'cm' => ['total' => 2, 'success' => 2],
];
}
}