feat(api): 新增节点墙状态检测闭环
新增父节点墙状态检测任务、结果上报与节点列表状态装饰, 支持子节点继承父节点检测结果并通过 WS/REST 双链路执行 管理端补充墙状态筛选、搜索、单行与批量检测入口, 同时更新知识库归档并新增后续自动上线方案包
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# CHANGELOG
|
||||
|
||||
## [0.6.0] - 2026-04-27
|
||||
|
||||
### 新增
|
||||
- **[node-gfw-check]**: 新增节点墙状态检测闭环,管理端可对父节点发起检测并在节点列表展示、搜索和筛选正常 / 疑似被墙 / 部分异常 / 检测失败 / 未检测状态;子节点不单独检测并继承父节点状态,mi-node 支持 `gfw.check` WS 触发、REST 兜底领取和三网 ping 结果上报 — by yinjianm
|
||||
- 方案: [202604272325_node-gfw-check](archive/2026-04/202604272325_node-gfw-check/)
|
||||
- 决策: node-gfw-check#D001(使用 WS 触发 + REST 兜底), node-gfw-check#D002(子节点继承父节点墙状态)
|
||||
|
||||
## [0.5.19] - 2026-04-27
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -10,12 +10,13 @@ active_package: 无
|
||||
## 项目概览
|
||||
|
||||
- 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端
|
||||
- 当前重点模块: `admin-frontend`、`order-payment`、`subscription-protocols`
|
||||
- 最新归档: `202604272310_ticket-chat-image-dnd-paste-upload`
|
||||
- 当前重点模块: `admin-frontend`、`node-gfw-check`、`order-payment`、`subscription-protocols`
|
||||
- 最新归档: `202604272325_node-gfw-check`
|
||||
|
||||
## 活跃模块
|
||||
|
||||
- [admin-frontend](modules/admin-frontend.md): 管理端登录、主布局、仪表盘、用户/节点/订阅/系统管理与管理 API 前端封装
|
||||
- [node-gfw-check](modules/node-gfw-check.md): 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路
|
||||
- [order-payment](modules/order-payment.md): 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示
|
||||
- [subscription-protocols](modules/subscription-protocols.md): 客户端订阅导出入口、协议适配器与版本兼容过滤
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# 变更提案: node-gfw-check
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 新功能
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已选方案
|
||||
创建: 2026-04-27
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
节点管理页目前无法判断节点公网 IP 是否疑似被中国防火墙拦截。用户确认墙是双向影响,但当前暂时没有墙内探测 IP,因此第一阶段只让节点服务器主动 ping 国内三网目标,参考 `E:\code\shell\shell-script\network-tools\test_delay.sh` 的检测思路,不复用其中 Telegram 通知与自动安装依赖逻辑。
|
||||
|
||||
### 目标
|
||||
- 在 Xboard 管理端支持对父节点发起墙状态检测,并在节点列表中显示检测结果。
|
||||
- 子节点默认不单独检测,展示与筛选继承父节点最新墙状态,并标记为随父节点。
|
||||
- 节点端 `E:\code\go\mi-node` 支持接收检测任务、执行国内三网 ping、结构化上报结果。
|
||||
- 搜索与筛选支持区分被墙、正常、异常、未检测、随父节点。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮落地基础闭环,不等待后续墙内检测 IP。
|
||||
性能约束: 检测任务低频触发,节点端并发 ping 要设置超时与并发上限,避免阻塞主服务循环。
|
||||
兼容性约束: 现有节点同步、用户同步、设备同步、REST fallback 不可回归;旧节点端未支持 gfw.check 时管理端应保留 pending/failed 可见状态。
|
||||
业务约束: 只对父节点创建实际检测任务;子节点继承父节点结果,不作为独立检测目标。
|
||||
安全约束: 不引入 Telegram token/chat_id;不在生产节点自动安装系统依赖;不执行破坏性命令。
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] 管理端节点列表可以发起单个/批量墙状态检测,子节点不会被独立下发检测。
|
||||
- [ ] `getNodes` 返回 `gfw_check` 字段;子节点返回父节点结果并带 `inherited=true` 与 `source_node_id`。
|
||||
- [ ] mi-node 收到 `gfw.check` WS 事件后执行检测并上报;WS 不可用时可通过 REST 任务接口兜底。
|
||||
- [ ] 前端节点旁显示墙状态标签,筛选和搜索可过滤被墙/正常/异常/未检测/随父节点。
|
||||
- [ ] PHP 语法检查、前端 build、mi-node Go 测试通过或明确记录无法执行原因。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
采用已确认的方案 A:`WS 触发 + REST 兜底 + 子节点继承父节点状态`。
|
||||
|
||||
- 后端新增 `server_gfw_checks` 表与 `ServerGfwCheck` 模型,记录每次父节点检测任务、状态、摘要、原始结果和错误。
|
||||
- 后端新增 `ServerGfwCheckService`,负责发起检测、过滤子节点、推送 `gfw.check`、提供 REST 兜底任务读取、接收节点端报告、计算最终状态。
|
||||
- 管理端新增 `POST /server/manage/checkGfw`,支持单个/批量节点 ID。输入中子节点不下发任务,按父节点继承规则返回 skipped/inherited。
|
||||
- 节点端新增 `GET /server/gfw/task` 与 `POST /server/gfw/report`,由 `ServerV2` 鉴权保护。
|
||||
- mi-node 新增 `internal/gfwcheck` 包,内置三网目标,使用系统 `ping` 并发检测。Docker runtime 安装 `iputils`。
|
||||
- 前端新增墙状态类型、筛选器、标签和动作入口。关键词搜索覆盖中文状态词。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- Xboard 后端: 新表、新模型、新服务、管理端接口、节点端接口、节点列表返回字段、WS 推送事件。
|
||||
- admin-frontend: 节点页筛选、搜索、状态标签、单行/批量检测动作、API 类型。
|
||||
- mi-node: WS 事件解析、控制面事件转发、服务层检测执行、REST 兜底轮询与上报、Docker 运行依赖。
|
||||
预计变更文件: 20 个左右,覆盖 PHP、Vue/TypeScript、Go、Dockerfile 与方案包。
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 部分节点环境没有 `ping` 或缺 ICMP 权限 | 中 | 节点端检测失败时上报 `failed` 与错误;Docker 镜像补 `iputils`,不自动安装依赖 |
|
||||
| 国内目标临时不可达导致误判 | 中 | 按运营商分组统计,区分 `blocked` 与 `partial`,保存 raw_result 供人工核对 |
|
||||
| 旧 mi-node 不支持新事件 | 中 | 后端保留 REST task 与 checking/pending 状态,前端清楚显示未完成/失败 |
|
||||
| 子节点继承状态造成误解 | 低 | `gfw_check.inherited=true`,前端显示“随父节点”并在 tooltip 中说明来源 |
|
||||
| 检测任务阻塞主服务循环 | 中 | mi-node 在 goroutine 中执行,服务层避免同一 check 并发重复执行 |
|
||||
|
||||
### 方案取舍
|
||||
```yaml
|
||||
唯一方案理由: WS 触发能让在线节点立即执行检测,REST 兜底能覆盖 WS 不可用或旧链路;只检测父节点符合当前子节点中转落地到父节点的业务模型。
|
||||
放弃的替代路径:
|
||||
- 只用管理端远程 ping 节点: 无法判断节点主动访问国内目标是否被墙,且与用户确认的双向墙逻辑不匹配。
|
||||
- 立即引入墙内探测节点: 当前没有墙内检测 IP,无法落地;后续可在同一数据模型中扩展为双向检测。
|
||||
- 子节点逐个检测: 子节点一般是中转入口,实际落地到父节点;逐个检测会制造噪音并增加不必要任务。
|
||||
回滚边界: 可回滚新增接口、服务、前端入口和 mi-node 事件处理;数据库新增表独立,不改动 v2_server 结构。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Admin NodesView] -->|POST checkGfw| B[ManageController]
|
||||
B --> C[ServerGfwCheckService]
|
||||
C --> D[(server_gfw_checks)]
|
||||
C -->|gfw.check| E[NodeSyncService / WS]
|
||||
C -->|pending task| F[GET /server/gfw/task]
|
||||
E --> G[mi-node service]
|
||||
F --> G
|
||||
G --> H[internal/gfwcheck ping runner]
|
||||
H -->|structured result| I[POST /server/gfw/report]
|
||||
I --> C
|
||||
C --> D
|
||||
A -->|fetchNodes| J[getNodes + latest gfw_check]
|
||||
```
|
||||
|
||||
### API 设计
|
||||
#### POST `/api/v2/{secure_path}/server/manage/checkGfw`
|
||||
- **请求**: `{ "ids": [1, 2, 3] }`
|
||||
- **响应**: `{ "started": [...], "skipped": [...], "total": 3 }`
|
||||
|
||||
#### GET `/api/v2/server/gfw/task`
|
||||
- **请求**: `token/node_id` 鉴权参数
|
||||
- **响应**: `{ "data": { "check_id": 123, "targets": {...}, "ping_count": 2, "timeout_seconds": 2, "parallel": 12 } }` 或 `null`
|
||||
|
||||
#### POST `/api/v2/server/gfw/report`
|
||||
- **请求**: `{ "check_id": 123, "status": "normal|blocked|partial|failed", "summary": {...}, "raw_result": {...}, "error_message": null }`
|
||||
- **响应**: `{ "data": true }`
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | big integer | 检测记录 ID |
|
||||
| server_id | big integer | 父节点 ID |
|
||||
| status | string | pending/checking/normal/blocked/partial/failed/skipped |
|
||||
| triggered_by | unsigned big integer nullable | 发起检测的管理员 ID |
|
||||
| summary | json nullable | 后端/前端摘要 |
|
||||
| operator_summary | json nullable | 三网聚合结果 |
|
||||
| raw_result | json nullable | 节点端原始结构化结果 |
|
||||
| error_message | text nullable | 失败原因 |
|
||||
| checked_at | unsigned integer nullable | 完成检测时间戳 |
|
||||
| timestamps | timestamps | 创建/更新时间 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 管理员检测父节点
|
||||
**模块**: Xboard 后端 + admin-frontend
|
||||
**条件**: 管理员在节点列表选择父节点,点击检测墙状态。
|
||||
**行为**: 后端创建 checking 记录并推送 `gfw.check`,前端刷新显示“检测中”。
|
||||
**结果**: mi-node 上报后节点旁显示正常/疑似被墙/部分异常/检测失败。
|
||||
|
||||
### 场景: 管理员选择子节点
|
||||
**模块**: Xboard 后端 + admin-frontend
|
||||
**条件**: 管理员选择子节点发起检测。
|
||||
**行为**: 后端不对该子节点创建独立任务,返回 skipped/inherited。
|
||||
**结果**: 前端提示子节点随父节点,列表显示父节点检测结果来源。
|
||||
|
||||
### 场景: WS 不可用
|
||||
**模块**: Xboard 后端 + mi-node
|
||||
**条件**: 节点端没有收到 `gfw.check` WS 事件。
|
||||
**行为**: mi-node 定期查询 `/server/gfw/task` 获取未完成任务。
|
||||
**结果**: 任务仍可被执行并上报。
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### node-gfw-check#D001: 使用 WS 触发 + REST 兜底
|
||||
**日期**: 2026-04-27
|
||||
**状态**: ✅采纳
|
||||
**背景**: 管理端需要尽快触发在线节点检测,但不能依赖 WS 一定可用。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: WS 触发 + REST 兜底 | 在线响应快,离线/断连可兜底,符合现有节点同步架构 | 需要同时改两条链路 |
|
||||
| B: 仅 REST 轮询 | 实现较少,行为稳定 | 检测响应慢,用户点击后等待不确定 |
|
||||
| C: 仅 WS | 响应最快 | WS 不可用时任务丢失 |
|
||||
**决策**: 选择方案 A。
|
||||
**理由**: 与现有 `NodeSyncService`、`NodeWorker`、mi-node 控制面抽象兼容,兼顾及时性与可靠性。
|
||||
**影响**: 后端新增任务接口与推送事件;mi-node 新增 WS 事件和 REST 轮询。
|
||||
|
||||
### node-gfw-check#D002: 子节点继承父节点墙状态
|
||||
**日期**: 2026-04-27
|
||||
**状态**: ✅采纳
|
||||
**背景**: 用户明确说明子节点通常作为中转,实际落地到父节点。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 子节点继承父节点 | 符合业务模型,减少噪音 | 需要 UI 明确说明来源 |
|
||||
| B: 子节点也独立检测 | 看似更完整 | 结果不代表真实落地路径,任务成本高 |
|
||||
**决策**: 选择方案 A。
|
||||
**理由**: 检测目标应代表落地 IP,父节点更接近真实出口。
|
||||
**影响**: 后端 `getNodes` 拼接继承状态;前端筛选按有效状态处理。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证策略
|
||||
|
||||
```yaml
|
||||
verifyMode: review-first
|
||||
reviewerFocus:
|
||||
- app/Services/ServerGfwCheckService.php 的状态判定和子节点过滤
|
||||
- app/Http/Controllers/V2/Server/ServerController.php 的节点端报告鉴权与 check_id 归属校验
|
||||
- E:/code/go/mi-node/internal/gfwcheck 的 ping 超时、并发和错误处理
|
||||
testerFocus:
|
||||
- php -l 新增/修改 PHP 文件
|
||||
- cd admin-frontend && npm run build
|
||||
- cd E:/code/go/mi-node && go test ./...
|
||||
uiValidation: required
|
||||
riskBoundary:
|
||||
- 不执行数据库迁移到生产库
|
||||
- 不复用参考脚本中的 Telegram token/chat_id
|
||||
- 不自动安装节点系统依赖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 成果设计
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: Apple 式后台精致极简,延续当前节点页黑色 hero 与白色工作台,不新增抢眼装饰色。
|
||||
- **记忆点**: 节点名旁新增一个轻量“连通性信号”状态胶囊,正常、检测中、疑似被墙、随父节点可以一眼区分但不压过节点名。
|
||||
- **参考**: `apple/DESIGN.md` 与现有 `NodesView.vue`。
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 继续使用页面黑白主节奏;交互强调使用 Apple Blue `#0071e3`;风险态沿用 Element Plus 语义色,避免新增复杂色盘。
|
||||
- **字体**: 继承项目现有字体栈与 Element Plus 表格字号,节点状态使用较小胶囊标签保持信息密度。
|
||||
- **布局**: 筛选栏增加“墙状态”下拉;节点单元格内在在线状态下方并列墙状态标签,不新增大面积卡片。
|
||||
- **动效**: 检测中使用按钮 loading 与标签文案反馈,不加额外动画。
|
||||
- **氛围**: 维持白色工作台与细分隔的管理后台质感,不使用渐变、纹理或装饰背景。
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: 状态不只靠颜色区分,标签文本必须明确;操作按钮 loading/disabled 状态可见。
|
||||
- **响应式**: 复用现有 toolbar wrap;新增筛选宽度与现有 select 一致,移动端自然换行。
|
||||
@@ -0,0 +1,135 @@
|
||||
# 任务清单: node-gfw-check
|
||||
|
||||
> **@status:** completed | 2026-04-27 23:40
|
||||
|
||||
```yaml
|
||||
@feature: node-gfw-check
|
||||
@created: 2026-04-27
|
||||
@status: completed
|
||||
@mode: R2
|
||||
```
|
||||
|
||||
## LIVE_STATUS
|
||||
|
||||
```json
|
||||
{"status":"completed","completed":15,"failed":0,"pending":0,"total":15,"percent":100,"current":"实现与验证完成,准备归档方案包","updated_at":"2026-04-27 23:58:00"}
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 15 | 0 | 0 | 15 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. Xboard 后端数据与服务
|
||||
|
||||
- [√] 1.1 新增 `database/migrations/*_create_server_gfw_checks_table.php`
|
||||
- 预期变更: 创建 `server_gfw_checks` 表,保存父节点检测任务、状态、摘要、原始结果、错误和完成时间。
|
||||
- 完成标准: migration 结构完整,down 可删除表,索引覆盖 `server_id/status/created_at` 查询。
|
||||
- 验证方式: `php -l database/migrations/*_create_server_gfw_checks_table.php`(当前环境无 PHP CLI,已做代码审查)
|
||||
- depends_on: []
|
||||
- [√] 1.2 新增 `app/Models/ServerGfwCheck.php` 并扩展 `app/Models/Server.php`
|
||||
- 预期变更: 增加检测记录模型、JSON casts、状态常量、`Server::gfwChecks()` 关系。
|
||||
- 完成标准: 模型可被服务层创建、查询、更新,PHP 语法通过。
|
||||
- 验证方式: `php -l app/Models/ServerGfwCheck.php app/Models/Server.php`(当前环境无 PHP CLI,已做代码审查)
|
||||
- depends_on: [1.1]
|
||||
- [√] 1.3 新增 `app/Services/ServerGfwCheckService.php`
|
||||
- 预期变更: 实现 `startChecks`、`getPendingTaskForNode`、`reportResult`、`decorateServers`、状态判定与子节点继承。
|
||||
- 完成标准: 父节点下发任务;子节点返回 skipped/inherited;报告 check_id 必须归属当前节点。
|
||||
- 验证方式: `php -l app/Services/ServerGfwCheckService.php`(当前环境无 PHP CLI,已做代码审查)
|
||||
- depends_on: [1.2]
|
||||
|
||||
### 2. Xboard 后端接口与 WS
|
||||
|
||||
- [√] 2.1 修改 `app/Http/Controllers/V2/Admin/Server/ManageController.php` 与 `app/Http/Routes/V2/AdminRoute.php`
|
||||
- 预期变更: `getNodes` 附加 `gfw_check`;新增 `checkGfw` 管理接口。
|
||||
- 完成标准: 管理端可以按 ids 发起检测,响应包含 started/skipped/total。
|
||||
- 验证方式: `php -l app/Http/Controllers/V2/Admin/Server/ManageController.php app/Http/Routes/V2/AdminRoute.php`(当前环境无 PHP CLI,已做代码审查)
|
||||
- depends_on: [1.3]
|
||||
- [√] 2.2 修改 `app/Http/Controllers/V2/Server/ServerController.php` 与 `app/Http/Routes/V2/ServerRoute.php`
|
||||
- 预期变更: 新增 `gfwTask` 和 `gfwReport` 节点端接口。
|
||||
- 完成标准: 通过 `server.v2` 鉴权读取节点;节点只能领取/上报自己的父节点检测任务。
|
||||
- 验证方式: `php -l app/Http/Controllers/V2/Server/ServerController.php app/Http/Routes/V2/ServerRoute.php`(当前环境无 PHP CLI,已做代码审查)
|
||||
- depends_on: [1.3]
|
||||
- [√] 2.3 修改 `app/WebSocket/NodeWorker.php` 或相关事件处理
|
||||
- 预期变更: 确保 `gfw.check` 作为 panel->node 推送事件能经现有 Redis/NodeRegistry 发送到节点端。
|
||||
- 完成标准: 不影响现有 `sync.*` 和 `report.devices` 事件。
|
||||
- 验证方式: 代码审查确认现有 Redis/NodeRegistry 已支持任意 panel->node event,无需修改 `NodeWorker.php`
|
||||
- depends_on: [2.1]
|
||||
|
||||
### 3. admin-frontend 节点管理 UI
|
||||
|
||||
- [√] 3.1 修改 `admin-frontend/src/types/api.d.ts` 与 `admin-frontend/src/api/admin.ts`
|
||||
- 预期变更: 增加 `AdminNodeGfwCheck`、`AdminNodeGfwStatus`、`checkNodeGfw` API。
|
||||
- 完成标准: TypeScript 类型覆盖节点列表字段和检测接口响应。
|
||||
- 验证方式: `cd admin-frontend && npm run build`
|
||||
- depends_on: [2.1]
|
||||
- [√] 3.2 修改 `admin-frontend/src/utils/nodes.ts`
|
||||
- 预期变更: 增加墙状态 meta、tooltip、状态筛选类型,搜索文本包含被墙/正常/异常/未检测/随父节点。
|
||||
- 完成标准: `filterNodes` 可按墙状态过滤,关键词可命中墙状态中文词。
|
||||
- 验证方式: `cd admin-frontend && npm run build`
|
||||
- depends_on: [3.1]
|
||||
- [√] 3.3 修改 `admin-frontend/src/views/nodes/NodesView.vue`
|
||||
- 预期变更: 增加墙状态筛选、节点旁状态标签、单行检测、批量检测与 loading 状态。
|
||||
- 完成标准: 父节点可检测;子节点操作提示随父节点;UI 保持现有节点页风格。
|
||||
- 验证方式: `cd admin-frontend && npm run build`
|
||||
- depends_on: [3.2]
|
||||
|
||||
### 4. mi-node 检测执行与上报
|
||||
|
||||
- [√] 4.1 新增 `E:/code/go/mi-node/internal/gfwcheck`
|
||||
- 预期变更: 实现三网目标定义、并发 ping runner、结果汇总与状态判定。
|
||||
- 完成标准: 不依赖 shell 脚本,不包含 Telegram/自动安装逻辑;超时和并发可控。
|
||||
- 验证方式: `cd E:/code/go/mi-node && go test ./internal/gfwcheck`
|
||||
- depends_on: []
|
||||
- [√] 4.2 修改 `E:/code/go/mi-node/internal/panel` 与 `E:/code/go/mi-node/internal/controlplane`
|
||||
- 预期变更: 支持 `gfw.check` WS 事件、REST 获取任务、上报检测结果。
|
||||
- 完成标准: 事件可转为 service 层事件;REST payload 自动携带 token/node_id。
|
||||
- 验证方式: `cd E:/code/go/mi-node && go test ./internal/panel ./internal/controlplane`
|
||||
- depends_on: [4.1]
|
||||
- [√] 4.3 修改 `E:/code/go/mi-node/internal/service/service.go` 与 `E:/code/go/mi-node/internal/config/config.go`
|
||||
- 预期变更: 服务层处理 WS 检测事件,增加低频 REST 兜底轮询,避免重复执行同一 check。
|
||||
- 完成标准: 检测在后台执行,不阻塞主 select 循环;支持配置默认轮询间隔。
|
||||
- 验证方式: `cd E:/code/go/mi-node && go test ./internal/service ./internal/config`
|
||||
- depends_on: [4.2]
|
||||
- [√] 4.4 修改 `E:/code/go/mi-node/Dockerfile`
|
||||
- 预期变更: runtime 镜像安装 `iputils` 以提供 `ping`。
|
||||
- 完成标准: 不新增无关依赖,不改变入口。
|
||||
- 验证方式: 文件审查
|
||||
- depends_on: [4.1]
|
||||
|
||||
### 5. 验证与知识库同步
|
||||
|
||||
- [√] 5.1 执行端到端验证命令
|
||||
- 预期变更: 运行 PHP 语法检查、前端 build、mi-node Go 测试,记录结果。
|
||||
- 完成标准: 可执行验证通过;不可执行项记录原因与残余风险。
|
||||
- 验证方式: `php -l ...`、`cd admin-frontend && npm run build`、`cd E:/code/go/mi-node && go test ./...`
|
||||
- depends_on: [1.1,1.2,1.3,2.1,2.2,2.3,3.1,3.2,3.3,4.1,4.2,4.3,4.4]
|
||||
- [√] 5.2 更新 `.helloagents/CHANGELOG.md` 与方案包状态
|
||||
- 预期变更: 记录本次新增墙状态检测闭环,更新任务状态、LIVE_STATUS 和执行日志。
|
||||
- 完成标准: CHANGELOG 与 tasks.md 反映实际完成内容和验证结果。
|
||||
- 验证方式: 文件审查
|
||||
- depends_on: [5.1]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-27 23:25 | DESIGN | in_progress | 创建方案包并固化方案 A |
|
||||
| 2026-04-27 23:42 | backend/frontend/mi-node | completed | 完成后端接口、前端节点页、mi-node 检测与上报链路 |
|
||||
| 2026-04-27 23:50 | verification | completed | `npm run build` 通过,`go test ./...` 通过;PHP CLI 不在 PATH |
|
||||
| 2026-04-27 23:55 | knowledge | completed | 更新 CHANGELOG 与模块知识库 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 当前没有墙内检测 IP,本轮只做节点主动 ping 国内三网目标。
|
||||
- 子节点默认不独立检测,显示和筛选继承父节点墙状态。
|
||||
- 参考脚本中的 Telegram 通知和自动安装依赖不进入项目实现。
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|
||||
|--------|------|------|---------|------|------|
|
||||
| 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 | ✅完成 |
|
||||
| 202604250018 | admin-frontend-user-activity-status-filter | implementation | admin-frontend,backend | admin-frontend-user-activity-status-filter#D001,#D002,#D003 | ✅完成 |
|
||||
| 202604250002 | order-payment-snapshot | implementation | admin-frontend,order-payment | order-payment-snapshot#D001,#D002 | ✅完成 |
|
||||
@@ -37,6 +38,7 @@
|
||||
## 按月归档
|
||||
|
||||
### 2026-04
|
||||
- [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` 复合过滤规则,支持按活跃 / 非活跃筛选用户
|
||||
- [202604250002_order-payment-snapshot](./2026-04/202604250002_order-payment-snapshot/) - 补齐订单支付成功快照保存链路,并在后台订单详情中集中展示支付渠道、支付方法、平台订单号、商户订单号、实付金额与支付 IP
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
| 模块名 | 说明 | 最近更新 |
|
||||
|--------|------|----------|
|
||||
| [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-25 |
|
||||
| [node-gfw-check](node-gfw-check.md) | 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 | 2026-04-27 |
|
||||
| [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 |
|
||||
| [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 |
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
- 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`
|
||||
- 节点管理页现支持墙状态展示、墙状态筛选与关键词搜索;父节点可通过行级或批量操作发起检测,子节点不单独检测并显示“随父节点”的继承状态
|
||||
- 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 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`,支持路由列表、关键词搜索、新增/编辑中央弹窗、删除与动作值展示
|
||||
@@ -95,6 +96,7 @@
|
||||
- 依赖 `src/utils/notices.ts` 负责公告表单转换、内容摘要、排序与显示字段归一化
|
||||
- 依赖 `src/utils/systemConfig.ts` 负责系统配置字段元信息、默认值、回填与保存序列化
|
||||
- 依赖 `src/utils/routes.ts` 负责路由动作映射、匹配规则序列化、节点引用摘要与搜索过滤
|
||||
- 依赖 `src/utils/nodes.ts` 负责节点在线状态、父/子节点、墙状态 meta、搜索文本和筛选逻辑
|
||||
- 依赖 `src/views/tickets/useTicketReplyImages.ts` 收敛工单回复区图片点击上传、拖拽上传、粘贴上传、文件校验和 Markdown 插入
|
||||
- 依赖 Laravel 后端 `TicketService::reply()` 提供工单“再次回复自动重开”的统一业务语义
|
||||
- 依赖 Laravel 注入的 `window.settings`
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# node-gfw-check
|
||||
|
||||
## 职责
|
||||
|
||||
- 在 Xboard 后端维护节点墙状态检测任务和检测结果
|
||||
- 通过管理端接口触发父节点墙状态检测,并通过节点端接口提供 REST 兜底领取与结果上报
|
||||
- 通过 `NodeSyncService` 发送 `gfw.check` WS 事件,让在线 mi-node 可即时执行检测
|
||||
- 在节点列表返回 `gfw_check` 字段,并对子节点应用父节点检测结果继承规则
|
||||
|
||||
## 行为规范
|
||||
|
||||
- 检测任务只对父节点创建;带 `parent_id` 的子节点不单独下发任务
|
||||
- 子节点列表展示继承父节点最新 `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`
|
||||
- 节点端 `GET server/gfw/task` 只向父节点返回待执行任务;节点端 `POST server/gfw/report` 必须校验 `check_id` 归属当前节点
|
||||
- 当前检测方向只做节点服务器主动 ping 国内三网目标;后续墙内探测 IP 可在同一任务模型中扩展
|
||||
- 参考脚本中的 Telegram 通知、chat_id、bot token 和自动安装依赖逻辑不得进入项目实现
|
||||
- mi-node 使用 Go 原生 runner 调用系统 `ping`,按三网目标并发检测并结构化上报 `summary / operator_summary / raw_result`
|
||||
- Docker runtime 镜像需要提供 `ping`,当前通过 Alpine `iputils` 满足
|
||||
|
||||
## 依赖关系
|
||||
|
||||
- 依赖 `app/Services/ServerGfwCheckService.php` 统一处理任务创建、状态判定、结果装饰和子节点继承
|
||||
- 依赖 `app/Models/ServerGfwCheck.php` 与 `server_gfw_checks` 表保存检测记录
|
||||
- 依赖 `app/Http/Controllers/V2/Admin/Server/ManageController.php` 暴露管理端触发接口
|
||||
- 依赖 `app/Http/Controllers/V2/Server/ServerController.php` 暴露节点端任务领取和上报接口
|
||||
- 依赖 `app/Services/NodeSyncService.php` 与 Workerman WS 通道向在线节点推送 `gfw.check`
|
||||
- 依赖 `E:/code/go/mi-node/internal/gfwcheck` 执行 ping 检测和结果判定
|
||||
- 依赖 `E:/code/go/mi-node/internal/panel`、`internal/controlplane` 与 `internal/service` 接收任务、轮询兜底并上报结果
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "in_progress",
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"pending": 8,
|
||||
"total": 8,
|
||||
"done": 0,
|
||||
"percent": 0,
|
||||
"current": "方案包已创建,准备进入开发实施",
|
||||
"updated_at": "2026-04-27 23:38:00"
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
# 变更提案: admin-frontend-node-auto-online
|
||||
|
||||
## 元信息
|
||||
```yaml
|
||||
类型: 新功能
|
||||
方案类型: implementation
|
||||
优先级: P1
|
||||
状态: 已选定方案
|
||||
创建: 2026-04-27
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求
|
||||
|
||||
### 背景
|
||||
节点管理页当前已经展示在线、离线、待同步和显隐状态,但 `show` 仍完全依赖管理员手动切换。用户希望增加“自动上线”能力:后台定时检测节点状态,对启用该能力的节点自动同步前台展示状态,节点离线时自动隐藏,节点在线时自动显示。
|
||||
|
||||
### 目标
|
||||
- 在节点管理页新增“自动上线”开关。
|
||||
- 只有开启“自动上线”的节点由后台自动改写 `show`;未开启的节点继续保持手动显隐控制。
|
||||
- 后台定时根据现有 `available_status` / `is_online` 判定自动同步 `show`。
|
||||
- 节点编辑、新建、列表展示和 API 类型定义同步支持该字段。
|
||||
|
||||
### 约束条件
|
||||
```yaml
|
||||
时间约束: 本轮完成实现并执行可用验证
|
||||
性能约束: 定时任务只处理开启 auto_online 的节点,避免全量无意义写库
|
||||
兼容性约束: 默认 auto_online=false,避免升级后自动覆盖现有节点显隐状态
|
||||
业务约束: 未开启自动上线的节点不得被后台任务改写 show
|
||||
```
|
||||
|
||||
### 验收标准
|
||||
- [ ] `v2_server` 新增 `auto_online` 字段,默认关闭,并在 `Server` 模型中正确 cast。
|
||||
- [ ] 管理端 `save` / `update` / `batchUpdate` 接口能保存 `auto_online`。
|
||||
- [ ] 后台调度命令只同步 `auto_online=1` 的节点:在线或待同步时 `show=1`,离线时 `show=0`。
|
||||
- [ ] 节点管理表格和编辑弹窗展示/编辑“自动上线”开关,且不会破坏现有显隐开关、墙状态检测和筛选能力。
|
||||
- [ ] 前端类型检查与构建通过;后端相关文件通过 PHP 语法检查。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 技术方案
|
||||
采用“独立字段 + 独立同步服务”方案:
|
||||
|
||||
- 数据层新增 `v2_server.auto_online` 布尔字段,默认 `false`。
|
||||
- `Server` 模型增加 `auto_online` cast 和文档注释。
|
||||
- 后端新增 `ServerAutoOnlineService`,集中处理 `auto_online` 节点的显示状态同步,避免把业务逻辑散落在 Controller 或 Console Command 中。
|
||||
- 新增 Artisan 命令 `sync:server-auto-online`,由 `app/Console/Kernel.php` 每 5 分钟调度,并使用 `onOneServer()` / `withoutOverlapping()` 防止多实例重复写入。
|
||||
- 管理端 API 扩展 `save` / `update` / `batchUpdate` 参数,允许保存或批量切换 `auto_online`。
|
||||
- 管理端节点页在表格中展示自动托管状态,编辑弹窗基础信息区增加开关;批量修改弹窗增加可选批量设置。
|
||||
|
||||
### 影响范围
|
||||
```yaml
|
||||
涉及模块:
|
||||
- 数据库迁移: 增加 v2_server.auto_online 字段
|
||||
- 节点模型: 增加字段 cast 与文档注释
|
||||
- 节点管理 API: 保存、单点更新、批量更新支持 auto_online
|
||||
- 后台调度: 新增自动同步服务、命令和定时任务
|
||||
- 管理端前端: 类型、API payload、节点页、编辑弹窗、批量修改弹窗
|
||||
- 知识库: 更新项目上下文和节点管理模块说明
|
||||
预计变更文件: 12-16
|
||||
```
|
||||
|
||||
### 风险评估
|
||||
| 风险 | 等级 | 应对 |
|
||||
|------|------|------|
|
||||
| 开启自动上线后后台任务覆盖管理员手动显隐 | 中 | 仅 `auto_online=1` 的节点自动同步;UI 文案明确“自动托管前台显示” |
|
||||
| 在线状态依赖缓存,缓存过期导致节点被隐藏 | 中 | 使用现有 `available_status` 语义,保持与节点列表在线状态一致;不引入新的检测标准 |
|
||||
| 批量更新误操作影响多个节点 | 中 | 批量修改弹窗保持“字段启用后才更新”的模式,默认不更新 `auto_online` |
|
||||
| 已有墙状态检测改动被覆盖 | 低 | 增量修改当前脏文件,不移除 `gfw_check` 相关类型、接口和 UI |
|
||||
|
||||
### 方案取舍
|
||||
```yaml
|
||||
唯一方案理由: 独立字段语义清晰,定时同步服务可测试、可扩展,并且默认关闭能保护现有手动显隐行为。
|
||||
放弃的替代路径:
|
||||
- 复用 check:server 命令: 会把离线通知和展示同步耦合,且 1800 秒通知阈值与 300 秒在线状态阈值不一致。
|
||||
- 写入 protocol_settings: 自动上线不是协议配置,会污染协议数据并增加查询和批量更新复杂度。
|
||||
回滚边界: 可撤销前端开关、接口参数、同步命令和迁移;已开启 auto_online 后由任务改写过的 show 需要管理员按业务需要手动恢复。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术设计
|
||||
|
||||
### 架构设计
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[节点心跳/上报缓存] --> B[Server.available_status]
|
||||
B --> C[ServerAutoOnlineService]
|
||||
C --> D{auto_online=1}
|
||||
D -->|在线/待同步| E[show=1]
|
||||
D -->|离线| F[show=0]
|
||||
G[节点管理页] --> H[save/update/batchUpdate]
|
||||
H --> I[v2_server.auto_online]
|
||||
I --> C
|
||||
```
|
||||
|
||||
### API设计
|
||||
#### POST server/manage/save
|
||||
- **请求**: 原节点保存 payload 增加 `auto_online?: boolean`
|
||||
- **响应**: 沿用 `ApiResponse<boolean>`
|
||||
|
||||
#### POST server/manage/update
|
||||
- **请求**: `{ id: number, show?: 0|1, enabled?: boolean, machine_id?: number|null, auto_online?: boolean }`
|
||||
- **响应**: 沿用 `ApiResponse<boolean>`
|
||||
|
||||
#### POST server/manage/batchUpdate
|
||||
- **请求**: 原批量更新 payload 增加 `auto_online?: boolean`
|
||||
- **响应**: 沿用 `ApiResponse<boolean>`
|
||||
|
||||
### 数据模型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `auto_online` | boolean | 是否允许后台根据节点在线状态自动同步 `show` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心场景
|
||||
|
||||
### 场景: 自动上线节点在线
|
||||
**模块**: 节点管理
|
||||
**条件**: 节点 `auto_online=1`,`available_status` 为在线或待同步
|
||||
**行为**: 调度命令执行自动同步
|
||||
**结果**: 节点 `show=1`,用户侧可见
|
||||
|
||||
### 场景: 自动上线节点离线
|
||||
**模块**: 节点管理
|
||||
**条件**: 节点 `auto_online=1`,`available_status` 为离线
|
||||
**行为**: 调度命令执行自动同步
|
||||
**结果**: 节点 `show=0`,用户侧隐藏
|
||||
|
||||
### 场景: 手动节点不受影响
|
||||
**模块**: 节点管理
|
||||
**条件**: 节点 `auto_online=0`
|
||||
**行为**: 调度命令执行自动同步
|
||||
**结果**: 后台不改写该节点 `show`
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术决策
|
||||
|
||||
### admin-frontend-node-auto-online#D001: 自动上线使用独立字段与独立同步服务
|
||||
**日期**: 2026-04-27
|
||||
**状态**: 采纳
|
||||
**背景**: 需要让后台自动同步节点展示状态,同时保留未开启节点的手动显隐控制。
|
||||
**选项分析**:
|
||||
| 选项 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A: 独立字段 + 独立服务 | 语义清晰、可测试、默认关闭安全、后续可扩展 | 需要新增迁移和命令 |
|
||||
| B: 复用 `check:server` | 改动较少 | 阈值语义不一致,离线通知和显示同步耦合 |
|
||||
| C: 写入 `protocol_settings` | 避免迁移 | 污染协议配置,查询和批量更新复杂 |
|
||||
**决策**: 选择方案 A
|
||||
**理由**: 自动上线是节点运营策略,不是协议配置或离线通知;独立字段和服务能把边界表达清楚。
|
||||
**影响**: 数据库、节点模型、节点管理 API、调度命令和管理端节点 UI。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证策略
|
||||
|
||||
```yaml
|
||||
verifyMode: review-first
|
||||
reviewerFocus:
|
||||
- app/Services/ServerAutoOnlineService.php 的 show 同步边界
|
||||
- app/Http/Controllers/V2/Admin/Server/ManageController.php 的 update/batchUpdate 参数兼容性
|
||||
- admin-frontend/src/views/nodes/* 的现有墙状态检测与批量选择逻辑不回退
|
||||
testerFocus:
|
||||
- php -l 后端新增/修改 PHP 文件
|
||||
- npm run build 管理端类型检查与构建
|
||||
- 人工检查 auto_online 默认关闭,批量修改默认不覆盖
|
||||
uiValidation: required
|
||||
riskBoundary:
|
||||
- 不执行数据库迁移到真实环境
|
||||
- 不写生产数据
|
||||
- 不移除现有未提交的墙状态检测相关改动
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 成果设计
|
||||
|
||||
### 设计方向
|
||||
- **美学基调**: Apple 风格的运维控制台,黑白主节奏、低装饰、强信息密度;自动上线作为状态治理能力,不做营销化强调。
|
||||
- **记忆点**: 每行节点同时能看见“当前显示状态”和“是否自动托管”,管理员可以一眼区分手动控制与自动控制。
|
||||
- **参考**: `apple/DESIGN.md` 与现有 `NodesView.vue`。
|
||||
|
||||
### 视觉要素
|
||||
- **配色**: 延续黑色 hero、白色表格面板和 Apple Blue `#0071e3` 作为自动托管强调色;在线/离线仍使用现有绿色/红色状态点。
|
||||
- **字体**: 沿用项目现有系统字体栈,不引入新字体,保证管理端一致性。
|
||||
- **布局**: 表格显隐列附近增加自动上线开关或标识,编辑弹窗基础信息的“节点状态”区域加入第三张开关卡片。
|
||||
- **动效**: 复用 Element Plus Switch loading 与当前 `switch-shell` 动态反馈,避免新增噪音动效。
|
||||
- **氛围**: 克制透明浅底提示块,突出“自动托管”状态但不抢占节点名称和在线状态。
|
||||
|
||||
### 技术约束
|
||||
- **可访问性**: 开关必须有明确文本标签和辅助描述;不能只用颜色表达自动状态。
|
||||
- **响应式**: 表格保持现有横向滚动和断点行为;编辑弹窗开关卡片在窄屏下自然堆叠。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 任务清单: admin-frontend-node-auto-online
|
||||
|
||||
```yaml
|
||||
@feature: admin-frontend-node-auto-online
|
||||
@created: 2026-04-27
|
||||
@status: in_progress
|
||||
@mode: R2
|
||||
@workflow: INTERACTIVE
|
||||
@complexity: complex
|
||||
```
|
||||
|
||||
## LIVE_STATUS
|
||||
|
||||
```json
|
||||
{"status":"in_progress","completed":0,"failed":0,"pending":8,"total":8,"percent":0,"current":"方案包已创建,准备进入开发实施","updated_at":"2026-04-27 23:38:00"}
|
||||
```
|
||||
|
||||
## 进度概览
|
||||
|
||||
| 完成 | 失败 | 跳过 | 总数 |
|
||||
|------|------|------|------|
|
||||
| 0 | 0 | 0 | 8 |
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
### 1. 后端数据与同步机制
|
||||
|
||||
- [ ] 1.1 新增 `database/migrations/*_add_auto_online_to_v2_server_table.php`
|
||||
- 预期变更: 为 `v2_server` 增加 `auto_online` 布尔字段,默认 `false`,down 可回滚字段。
|
||||
- 完成标准: 迁移文件存在,字段名、默认值和回滚逻辑明确。
|
||||
- 验证方式: `php -l database/migrations/*_add_auto_online_to_v2_server_table.php`
|
||||
- depends_on: []
|
||||
|
||||
- [ ] 1.2 修改 `app/Models/Server.php`
|
||||
- 预期变更: 增加 `auto_online` 属性注释和 boolean cast,保持现有在线状态访问器与墙状态关系不变。
|
||||
- 完成标准: `Server` JSON/API 输出包含 `auto_online`,现有 `gfwChecks()` 不被移除。
|
||||
- 验证方式: `php -l app/Models/Server.php`
|
||||
- depends_on: [1.1]
|
||||
|
||||
- [ ] 1.3 新增 `app/Services/ServerAutoOnlineService.php`
|
||||
- 预期变更: 封装自动上线同步逻辑,只处理 `auto_online=true` 的节点,在线/待同步写 `show=true`,离线写 `show=false`。
|
||||
- 完成标准: 服务返回同步统计,跳过未托管节点,不引入生产外部副作用。
|
||||
- 验证方式: `php -l app/Services/ServerAutoOnlineService.php`
|
||||
- depends_on: [1.2]
|
||||
|
||||
- [ ] 1.4 新增命令并接入调度 `app/Console/Commands/SyncServerAutoOnline.php`, `app/Console/Kernel.php`
|
||||
- 预期变更: 新增 `sync:server-auto-online` 命令,每 5 分钟调度,使用 `onOneServer()` 与 `withoutOverlapping()`。
|
||||
- 完成标准: 命令可调用服务并输出统计,调度不影响现有任务。
|
||||
- 验证方式: `php -l app/Console/Commands/SyncServerAutoOnline.php`; `php -l app/Console/Kernel.php`
|
||||
- depends_on: [1.3]
|
||||
|
||||
### 2. 后端 API 契约
|
||||
|
||||
- [ ] 2.1 修改 `app/Http/Requests/Admin/ServerSave.php` 与 `app/Http/Controllers/V2/Admin/Server/ManageController.php`
|
||||
- 预期变更: `save`、`update`、`batchUpdate` 支持 `auto_online`,批量更新保持字段显式传入才更新。
|
||||
- 完成标准: 手动显隐字段 `show` 和自动上线字段 `auto_online` 可独立保存。
|
||||
- 验证方式: `php -l app/Http/Requests/Admin/ServerSave.php`; `php -l app/Http/Controllers/V2/Admin/Server/ManageController.php`
|
||||
- depends_on: [1.2]
|
||||
|
||||
### 3. 管理端前端
|
||||
|
||||
- [ ] 3.1 修改 `admin-frontend/src/types/api.d.ts`, `admin-frontend/src/utils/nodeEditorOptions.ts`, `admin-frontend/src/utils/nodeEditorMapper.ts`
|
||||
- 预期变更: 类型、表单模型、默认值、编辑回填和保存 payload 支持 `auto_online`。
|
||||
- 完成标准: 新建默认关闭,编辑能正确回填,保存能提交布尔值。
|
||||
- 验证方式: `npm run build`
|
||||
- depends_on: [2.1]
|
||||
|
||||
- [ ] 3.2 修改 `admin-frontend/src/views/nodes/NodeEditorDialog.vue`, `NodeBatchEditDialog.vue`, `NodesView.vue`, `admin-frontend/src/utils/nodes.ts`
|
||||
- 预期变更: 编辑弹窗和批量修改弹窗增加自动上线开关;节点表格展示自动托管状态;现有墙状态检测 UI 保持可用。
|
||||
- 完成标准: 管理员可单节点和批量设置 `auto_online`;未开启批量字段时不覆盖。
|
||||
- 验证方式: `npm run build`
|
||||
- depends_on: [3.1]
|
||||
|
||||
### 4. 知识库与验收
|
||||
|
||||
- [ ] 4.1 更新 `.helloagents/context.md`, `.helloagents/modules/*`, `.helloagents/CHANGELOG.md`
|
||||
- 预期变更: 同步记录节点自动上线能力、后端命令和管理端节点页行为。
|
||||
- 完成标准: 知识库反映代码事实,CHANGELOG 包含方案链接和决策 ID。
|
||||
- 验证方式: 人工检查文档条目与本次改动一致。
|
||||
- depends_on: [1.4, 2.1, 3.2]
|
||||
|
||||
---
|
||||
|
||||
## 执行日志
|
||||
|
||||
| 时间 | 任务 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 2026-04-27 23:38 | DESIGN | in_progress | 用户选择方案 A,方案包创建并进入开发实施 |
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 当前工作树已有节点墙状态检测相关未提交改动,本任务必须保留并兼容这些改动。
|
||||
- 按上级工具约束,本轮不调度子代理,复杂任务由主代理直接实施并在验收中说明。
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
AdminNoticeSavePayload,
|
||||
AdminNodeItem,
|
||||
AdminNodeBatchUpdatePayload,
|
||||
AdminNodeGfwCheckResult,
|
||||
AdminNodeSavePayload,
|
||||
AdminNodeRouteItem,
|
||||
AdminNodeRouteSavePayload,
|
||||
@@ -518,6 +519,10 @@ export function batchDeleteNodes(ids: number[]): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/server/manage/batchDelete', { ids })
|
||||
}
|
||||
|
||||
export function checkNodeGfw(ids: number[]): Promise<ApiResponse<AdminNodeGfwCheckResult>> {
|
||||
return unwrapPost<AdminNodeGfwCheckResult>('/server/manage/checkGfw', { ids })
|
||||
}
|
||||
|
||||
export function saveNode(payload: AdminNodeSavePayload): Promise<ApiResponse<boolean>> {
|
||||
return unwrapPost<boolean>('/server/manage/save', payload as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
Vendored
+38
@@ -896,6 +896,44 @@ export interface AdminNodeItem {
|
||||
metrics?: AdminNodeMetrics | null
|
||||
groups?: AdminServerGroupItem[]
|
||||
parent?: AdminNodeParentRef | null
|
||||
gfw_check?: AdminNodeGfwCheck | null
|
||||
}
|
||||
|
||||
export type AdminNodeGfwStatus =
|
||||
| 'unchecked'
|
||||
| 'pending'
|
||||
| 'checking'
|
||||
| 'normal'
|
||||
| 'blocked'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
export interface AdminNodeGfwCheck {
|
||||
id?: number
|
||||
status: AdminNodeGfwStatus | string
|
||||
inherited?: boolean
|
||||
source_node_id?: number | null
|
||||
summary?: Record<string, unknown> | null
|
||||
operator_summary?: Record<string, unknown> | null
|
||||
error_message?: string | null
|
||||
checked_at?: number | null
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
export interface AdminNodeGfwCheckResult {
|
||||
started: Array<{
|
||||
id: number
|
||||
check_id: number
|
||||
status: AdminNodeGfwStatus | string
|
||||
}>
|
||||
skipped: Array<{
|
||||
id: number
|
||||
status: AdminNodeGfwStatus | string
|
||||
reason?: string
|
||||
source_node_id?: number
|
||||
}>
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AdminNodeUpdatePayload {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AdminNodeItem } from '@/types/api'
|
||||
|
||||
export type NodeRelationFilter = 'all' | 'parent' | 'child'
|
||||
export type NodeGfwFilter = 'all' | 'normal' | 'blocked' | 'partial' | 'failed' | 'unchecked' | 'checking' | 'inherited'
|
||||
export type NodeStatusFilter = 'all' | 'online' | 'offline'
|
||||
|
||||
export interface NodeStatusMeta {
|
||||
@@ -11,6 +12,14 @@ export interface NodeStatusMeta {
|
||||
|
||||
type NodeStatusClass = NodeStatusMeta['dotClass']
|
||||
|
||||
export interface NodeGfwMeta {
|
||||
label: string
|
||||
searchText: string
|
||||
tagType: 'success' | 'warning' | 'danger' | 'info' | 'primary'
|
||||
tone: 'normal' | 'blocked' | 'partial' | 'failed' | 'unchecked' | 'checking'
|
||||
inherited: boolean
|
||||
}
|
||||
|
||||
const NODE_TYPE_LABELS: Record<string, string> = {
|
||||
shadowsocks: 'Shadowsocks',
|
||||
trojan: 'Trojan',
|
||||
@@ -66,6 +75,83 @@ export function getNodeStatusMeta(node: AdminNodeItem): NodeStatusMeta {
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeGfwMeta(node: AdminNodeItem): NodeGfwMeta {
|
||||
const status = normalizeText(node.gfw_check?.status || 'unchecked')
|
||||
const inherited = Boolean(node.gfw_check?.inherited)
|
||||
const inheritedPrefix = inherited ? '随父节点 · ' : ''
|
||||
|
||||
if (status === 'normal') {
|
||||
return {
|
||||
label: `${inheritedPrefix}正常`,
|
||||
searchText: `${inherited ? '随父节点 继承 ' : ''}正常 未被墙 墙正常 gfw normal`,
|
||||
tagType: 'success',
|
||||
tone: 'normal',
|
||||
inherited,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'blocked') {
|
||||
return {
|
||||
label: `${inheritedPrefix}疑似被墙`,
|
||||
searchText: `${inherited ? '随父节点 继承 ' : ''}被墙 疑似被墙 gfw blocked`,
|
||||
tagType: 'danger',
|
||||
tone: 'blocked',
|
||||
inherited,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'partial') {
|
||||
return {
|
||||
label: `${inheritedPrefix}部分异常`,
|
||||
searchText: `${inherited ? '随父节点 继承 ' : ''}异常 部分异常 gfw partial`,
|
||||
tagType: 'warning',
|
||||
tone: 'partial',
|
||||
inherited,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return {
|
||||
label: `${inheritedPrefix}检测失败`,
|
||||
searchText: `${inherited ? '随父节点 继承 ' : ''}失败 检测失败 异常 gfw failed`,
|
||||
tagType: 'danger',
|
||||
tone: 'failed',
|
||||
inherited,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'pending' || status === 'checking') {
|
||||
return {
|
||||
label: `${inheritedPrefix}检测中`,
|
||||
searchText: `${inherited ? '随父节点 继承 ' : ''}检测中 等待检测 gfw checking pending`,
|
||||
tagType: 'primary',
|
||||
tone: 'checking',
|
||||
inherited,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: inherited ? '随父节点 · 未检测' : '未检测',
|
||||
searchText: `${inherited ? '随父节点 继承 ' : ''}未检测 unchecked`,
|
||||
tagType: 'info',
|
||||
tone: 'unchecked',
|
||||
inherited,
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeGfwTooltip(node: AdminNodeItem): string {
|
||||
const meta = getNodeGfwMeta(node)
|
||||
const source = node.gfw_check?.source_node_id
|
||||
const checkedAt = node.gfw_check?.checked_at
|
||||
? new Date(Number(node.gfw_check.checked_at) * 1000).toLocaleString()
|
||||
: ''
|
||||
const sourceText = meta.inherited && source ? `,来源父节点 #${source}` : ''
|
||||
const timeText = checkedAt ? `,检测时间 ${checkedAt}` : ''
|
||||
const errorText = node.gfw_check?.error_message ? `,错误:${node.gfw_check.error_message}` : ''
|
||||
|
||||
return `${meta.label}${sourceText}${timeText}${errorText}`
|
||||
}
|
||||
|
||||
function isNodeOnlineStatus(status: NodeStatusClass): boolean {
|
||||
return status === 'online' || status === 'pending'
|
||||
}
|
||||
@@ -113,6 +199,7 @@ function buildNodeSearchText(node: AdminNodeItem): string {
|
||||
node.port,
|
||||
node.server_port,
|
||||
getNodeTypeLabel(node.type),
|
||||
getNodeGfwMeta(node).searchText,
|
||||
...getNodeGroupNames(node),
|
||||
]
|
||||
.map((item) => String(item ?? '').trim())
|
||||
@@ -128,12 +215,14 @@ export function filterNodes(
|
||||
groupFilter: string,
|
||||
statusFilter: NodeStatusFilter = 'all',
|
||||
relationFilter: NodeRelationFilter = 'all',
|
||||
gfwFilter: NodeGfwFilter = 'all',
|
||||
): AdminNodeItem[] {
|
||||
const normalizedKeyword = normalizeText(keyword)
|
||||
const normalizedType = normalizeText(typeFilter)
|
||||
const normalizedGroup = normalizeText(groupFilter)
|
||||
const normalizedStatus = normalizeText(statusFilter)
|
||||
const normalizedRelation = normalizeText(relationFilter)
|
||||
const normalizedGfw = normalizeText(gfwFilter)
|
||||
|
||||
return nodes.filter((node) => {
|
||||
if (normalizedKeyword && !buildNodeSearchText(node).includes(normalizedKeyword)) {
|
||||
@@ -168,6 +257,15 @@ export function filterNodes(
|
||||
return false
|
||||
}
|
||||
|
||||
const gfwMeta = getNodeGfwMeta(node)
|
||||
if (normalizedGfw === 'inherited' && !gfwMeta.inherited) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedGfw !== '' && normalizedGfw !== 'all' && normalizedGfw !== 'inherited' && gfwMeta.tone !== normalizedGfw) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
batchDeleteNodes,
|
||||
batchUpdateNodes,
|
||||
checkNodeGfw,
|
||||
copyNode,
|
||||
deleteNode,
|
||||
fetchNodes,
|
||||
@@ -38,17 +39,20 @@ import {
|
||||
countVisibleNodes,
|
||||
filterNodes,
|
||||
formatNodeRate,
|
||||
getNodeGfwMeta,
|
||||
getNodeGfwTooltip,
|
||||
getNodeAddress,
|
||||
getNodeGroupNames,
|
||||
getNodeIdLabel,
|
||||
getNodeStatusMeta,
|
||||
getNodeTypeLabel,
|
||||
type NodeRelationFilter,
|
||||
type NodeGfwFilter,
|
||||
type NodeStatusFilter,
|
||||
} from '@/utils/nodes'
|
||||
import { sortNodesByOrder } from '@/utils/nodeEditor'
|
||||
|
||||
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete'
|
||||
type NodeAction = 'edit' | 'copy' | 'pin-top' | 'delete' | 'check-gfw'
|
||||
type NodeDialogMode = 'create' | 'edit'
|
||||
type NodeBatchEditPayload = Omit<AdminNodeBatchUpdatePayload, 'ids'>
|
||||
|
||||
@@ -65,6 +69,7 @@ const typeFilter = ref('all')
|
||||
const groupFilter = ref('all')
|
||||
const statusFilter = ref<NodeStatusFilter>('all')
|
||||
const relationFilter = ref<NodeRelationFilter>('all')
|
||||
const gfwFilter = ref<NodeGfwFilter>('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedNodeIds = ref<number[]>([])
|
||||
@@ -78,6 +83,7 @@ const sortDialogVisible = ref(false)
|
||||
const batchEditVisible = ref(false)
|
||||
const batchSubmitting = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
const batchGfwChecking = ref(false)
|
||||
|
||||
const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
|
||||
nodes.value,
|
||||
@@ -86,6 +92,7 @@ const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
|
||||
groupFilter.value,
|
||||
statusFilter.value,
|
||||
relationFilter.value,
|
||||
gfwFilter.value,
|
||||
)))
|
||||
|
||||
const paginatedNodes = computed(() => {
|
||||
@@ -102,6 +109,7 @@ const hasActiveFilters = computed(() => (
|
||||
|| groupFilter.value !== 'all'
|
||||
|| statusFilter.value !== 'all'
|
||||
|| relationFilter.value !== 'all'
|
||||
|| gfwFilter.value !== 'all'
|
||||
))
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
@@ -238,6 +246,7 @@ function handleReset() {
|
||||
groupFilter.value = 'all'
|
||||
statusFilter.value = 'all'
|
||||
relationFilter.value = 'all'
|
||||
gfwFilter.value = 'all'
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
@@ -337,6 +346,59 @@ async function handleBatchDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckGfw(ids: number[], label: string) {
|
||||
if (ids.length === 0) {
|
||||
ElMessage.warning('请先选择需要检测的节点')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await checkNodeGfw(ids)
|
||||
const started = response.data?.started?.length ?? 0
|
||||
const skipped = response.data?.skipped?.length ?? 0
|
||||
|
||||
if (started > 0) {
|
||||
ElMessage.success(`${label}已发起墙状态检测,${started} 个父节点等待上报`)
|
||||
} else if (skipped > 0) {
|
||||
ElMessage.info('所选节点均为子节点,墙状态随父节点显示')
|
||||
} else {
|
||||
ElMessage.info('没有可检测的节点')
|
||||
}
|
||||
|
||||
await loadNodeBoard()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '墙状态检测发起失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchCheckGfw() {
|
||||
if (!hasSelectedNodes.value) {
|
||||
ElMessage.warning('请先勾选需要检测的节点')
|
||||
return
|
||||
}
|
||||
|
||||
batchGfwChecking.value = true
|
||||
try {
|
||||
await handleCheckGfw([...selectedNodeIds.value], '批量')
|
||||
} finally {
|
||||
batchGfwChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNodeCheckGfw(node: AdminNodeItem) {
|
||||
if (node.parent_id) {
|
||||
ElMessage.info('子节点不单独检测,墙状态随父节点显示')
|
||||
return
|
||||
}
|
||||
|
||||
markPending(workingIds, node.id, true)
|
||||
try {
|
||||
await handleCheckGfw([node.id], '')
|
||||
} finally {
|
||||
markPending(workingIds, node.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleShow(node: AdminNodeItem, nextValue: boolean) {
|
||||
const previous = Boolean(node.show)
|
||||
if (previous === nextValue) {
|
||||
@@ -396,6 +458,11 @@ async function handleAction(action: NodeAction, node: AdminNodeItem) {
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'check-gfw') {
|
||||
await handleNodeCheckGfw(node)
|
||||
return
|
||||
}
|
||||
|
||||
markPending(workingIds, node.id, true)
|
||||
|
||||
try {
|
||||
@@ -437,7 +504,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter], () => {
|
||||
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter, gfwFilter], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
@@ -530,11 +597,30 @@ watch(
|
||||
<ElOption label="父节点" value="parent" />
|
||||
<ElOption label="子节点" value="child" />
|
||||
</ElSelect>
|
||||
|
||||
<ElSelect v-model="gfwFilter" class="toolbar-select" placeholder="墙状态">
|
||||
<ElOption label="全部墙状态" value="all" />
|
||||
<ElOption label="正常" value="normal" />
|
||||
<ElOption label="疑似被墙" value="blocked" />
|
||||
<ElOption label="部分异常" value="partial" />
|
||||
<ElOption label="检测失败" value="failed" />
|
||||
<ElOption label="检测中" value="checking" />
|
||||
<ElOption label="未检测" value="unchecked" />
|
||||
<ElOption label="随父节点" value="inherited" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<span class="scope-hint">{{ batchTargetLabel }}</span>
|
||||
<ElButton :disabled="!hasSelectedNodes || batchDeleting" @click="openBatchEditor">批量修改</ElButton>
|
||||
<ElButton
|
||||
:disabled="!hasSelectedNodes || batchGfwChecking"
|
||||
:loading="batchGfwChecking"
|
||||
@click="handleBatchCheckGfw"
|
||||
>
|
||||
<ElIcon><Connection /></ElIcon>
|
||||
检测墙状态
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="danger"
|
||||
plain
|
||||
@@ -620,6 +706,17 @@ watch(
|
||||
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
|
||||
{{ getNodeStatusMeta(row).label }}
|
||||
</ElTag>
|
||||
<ElTooltip :content="getNodeGfwTooltip(row)" placement="top">
|
||||
<ElTag
|
||||
round
|
||||
effect="plain"
|
||||
:type="getNodeGfwMeta(row).tagType"
|
||||
class="gfw-tag"
|
||||
:class="`gfw-tag--${getNodeGfwMeta(row).tone}`"
|
||||
>
|
||||
{{ getNodeGfwMeta(row).label }}
|
||||
</ElTag>
|
||||
</ElTooltip>
|
||||
<span>{{ getNodeTypeLabel(row.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -687,6 +784,9 @@ watch(
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="edit">编辑节点</ElDropdownItem>
|
||||
<ElDropdownItem command="check-gfw" :disabled="Boolean(row.parent_id)">
|
||||
检测墙状态
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="pin-top">置顶节点</ElDropdownItem>
|
||||
<ElDropdownItem command="copy">复制节点</ElDropdownItem>
|
||||
<ElDropdownItem command="delete" divided>删除节点</ElDropdownItem>
|
||||
@@ -907,6 +1007,10 @@ watch(
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-cell__sub {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.node-cell__main strong,
|
||||
.stack-cell strong {
|
||||
color: var(--xboard-text-strong);
|
||||
@@ -950,10 +1054,15 @@ watch(
|
||||
}
|
||||
|
||||
.rate-tag,
|
||||
.id-tag {
|
||||
.id-tag,
|
||||
.gfw-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gfw-tag {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.action-trigger {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\ServerSave;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerGroup;
|
||||
use App\Services\ServerGfwCheckService;
|
||||
use App\Services\ServerService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -16,7 +17,7 @@ class ManageController extends Controller
|
||||
{
|
||||
public function getNodes(Request $request)
|
||||
{
|
||||
$servers = ServerService::getAllServers()->map(function ($item) {
|
||||
$servers = app(ServerGfwCheckService::class)->decorateServers(ServerService::getAllServers())->map(function ($item) {
|
||||
$item['groups'] = ServerGroup::whereIn('id', $item['group_ids'] ?? [])->get(['name', 'id']);
|
||||
$item['parent'] = $item->parent;
|
||||
return $item;
|
||||
@@ -277,6 +278,25 @@ class ManageController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function checkGfw(Request $request)
|
||||
{
|
||||
$params = $request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
if (empty($params['ids'])) {
|
||||
return $this->fail([400, '请选择需要检测的节点']);
|
||||
}
|
||||
|
||||
$result = app(ServerGfwCheckService::class)->startChecks(
|
||||
$params['ids'],
|
||||
$request->user()?->id
|
||||
);
|
||||
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制节点
|
||||
* @param \Illuminate\Http\Request $request
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\V2\Server;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ServerGfwCheckService;
|
||||
use App\Services\ServerService;
|
||||
use App\WebSocket\NodeWorker;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -75,4 +76,33 @@ class ServerController extends Controller
|
||||
|
||||
return response()->json(['data' => true]);
|
||||
}
|
||||
|
||||
public function gfwTask(Request $request, ServerGfwCheckService $service): JsonResponse
|
||||
{
|
||||
$node = $request->attributes->get('node_info');
|
||||
if (!$node) {
|
||||
return response()->json(['data' => null]);
|
||||
}
|
||||
|
||||
return response()->json(['data' => $service->getPendingTaskForNode($node)]);
|
||||
}
|
||||
|
||||
public function gfwReport(Request $request, ServerGfwCheckService $service): JsonResponse
|
||||
{
|
||||
$node = $request->attributes->get('node_info');
|
||||
if (!$node) {
|
||||
return response()->json(['data' => false], 404);
|
||||
}
|
||||
|
||||
$params = $request->validate([
|
||||
'check_id' => 'required|integer',
|
||||
'status' => 'nullable|string',
|
||||
'summary' => 'nullable|array',
|
||||
'operator_summary' => 'nullable|array',
|
||||
'raw_result' => 'nullable|array',
|
||||
'error_message' => 'nullable|string',
|
||||
]);
|
||||
|
||||
return response()->json(['data' => $service->reportResult($node, $params)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ class AdminRoute
|
||||
$router->post('/sort', [ManageController::class, 'sort']);
|
||||
$router->post('/batchDelete', [ManageController::class, 'batchDelete']);
|
||||
$router->post('/batchUpdate', [ManageController::class, 'batchUpdate']);
|
||||
$router->post('/checkGfw', [ManageController::class, 'checkGfw']);
|
||||
$router->post('/resetTraffic', [ManageController::class, 'resetTraffic']);
|
||||
$router->post('/batchResetTraffic', [ManageController::class, 'batchResetTraffic']);
|
||||
$router->get('/generateEchKey', [ManageController::class, 'generateEchKey']);
|
||||
|
||||
@@ -18,6 +18,8 @@ class ServerRoute
|
||||
], function ($route) {
|
||||
$route->match(['GET', 'POST'], 'handshake', [ServerController::class, 'handshake']);
|
||||
$route->post('report', [ServerController::class, 'report']);
|
||||
$route->get('gfw/task', [ServerController::class, 'gfwTask']);
|
||||
$route->post('gfw/report', [ServerController::class, 'gfwReport']);
|
||||
$route->get('config', [UniProxyController::class, 'config']);
|
||||
$route->get('user', [UniProxyController::class, 'user']);
|
||||
$route->post('push', [UniProxyController::class, 'push']);
|
||||
|
||||
@@ -36,7 +36,8 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
* @property int $updated_at
|
||||
*
|
||||
* @property-read Server|null $parent 父节点
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, StatServer> $stats 节点统计
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, StatServer> $stats 节点统计
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, ServerGfwCheck> $gfwChecks 墙状态检测记录
|
||||
*
|
||||
* @property-read int|null $last_check_at 最后检查时间(Unix时间戳)
|
||||
* @property-read int|null $last_push_at 最后推送时间(Unix时间戳)
|
||||
@@ -462,6 +463,11 @@ class Server extends Model
|
||||
return $this->hasMany(StatServer::class, 'server_id', 'id');
|
||||
}
|
||||
|
||||
public function gfwChecks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerGfwCheck::class, 'server_id', 'id');
|
||||
}
|
||||
|
||||
public function machine(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerMachine::class, 'machine_id');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServerGfwCheck extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CHECKING = 'checking';
|
||||
public const STATUS_NORMAL = 'normal';
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const FINAL_STATUSES = [
|
||||
self::STATUS_NORMAL,
|
||||
self::STATUS_BLOCKED,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_SKIPPED,
|
||||
];
|
||||
|
||||
protected $table = 'server_gfw_checks';
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'summary' => 'array',
|
||||
'operator_summary' => 'array',
|
||||
'raw_result' => 'array',
|
||||
'checked_at' => 'integer',
|
||||
];
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class, 'server_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerGfwCheck;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ServerGfwCheckService
|
||||
{
|
||||
private const TASK_STATUS = [
|
||||
ServerGfwCheck::STATUS_PENDING,
|
||||
ServerGfwCheck::STATUS_CHECKING,
|
||||
];
|
||||
|
||||
public function startChecks(array $ids, ?int $adminUserId = null): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
$servers = Server::whereIn('id', $ids)->get()->keyBy('id');
|
||||
$started = [];
|
||||
$skipped = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$server = $servers->get($id);
|
||||
if (!$server) {
|
||||
$skipped[] = ['id' => $id, 'status' => ServerGfwCheck::STATUS_SKIPPED, 'reason' => '节点不存在'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($server->parent_id) {
|
||||
$skipped[] = [
|
||||
'id' => $id,
|
||||
'status' => ServerGfwCheck::STATUS_SKIPPED,
|
||||
'reason' => '子节点随父节点检测',
|
||||
'source_node_id' => (int) $server->parent_id,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$check = ServerGfwCheck::create([
|
||||
'server_id' => $server->id,
|
||||
'status' => ServerGfwCheck::STATUS_PENDING,
|
||||
'triggered_by' => $adminUserId,
|
||||
]);
|
||||
|
||||
NodeSyncService::push($server->id, 'gfw.check', $this->formatTask($check));
|
||||
$started[] = [
|
||||
'id' => $server->id,
|
||||
'check_id' => $check->id,
|
||||
'status' => $check->status,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'started' => $started,
|
||||
'skipped' => $skipped,
|
||||
'total' => count($ids),
|
||||
];
|
||||
}
|
||||
|
||||
public function decorateServers(Collection $servers): Collection
|
||||
{
|
||||
$sourceIds = $servers
|
||||
->map(fn (Server $server) => (int) ($server->parent_id ?: $server->id))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$latestChecks = ServerGfwCheck::whereIn('server_id', $sourceIds)
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->groupBy('server_id')
|
||||
->map(fn (Collection $items) => $items->first());
|
||||
|
||||
return $servers->map(function (Server $server) use ($latestChecks) {
|
||||
$sourceNodeId = (int) ($server->parent_id ?: $server->id);
|
||||
$check = $latestChecks->get($sourceNodeId);
|
||||
$server->setAttribute('gfw_check', $this->formatCheck($check, (bool) $server->parent_id, $sourceNodeId));
|
||||
return $server;
|
||||
});
|
||||
}
|
||||
|
||||
public function getPendingTaskForNode(Server $node): ?array
|
||||
{
|
||||
if ($node->parent_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$check = ServerGfwCheck::where('server_id', $node->id)
|
||||
->whereIn('status', self::TASK_STATUS)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (!$check) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($check->status === ServerGfwCheck::STATUS_PENDING) {
|
||||
$check->update(['status' => ServerGfwCheck::STATUS_CHECKING]);
|
||||
}
|
||||
|
||||
return $this->formatTask($check->refresh());
|
||||
}
|
||||
|
||||
public function reportResult(Server $node, array $payload): bool
|
||||
{
|
||||
$checkId = (int) ($payload['check_id'] ?? 0);
|
||||
if ($node->parent_id || $checkId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$check = ServerGfwCheck::where('id', $checkId)
|
||||
->where('server_id', $node->id)
|
||||
->first();
|
||||
|
||||
if (!$check) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rawResult = $this->arrayOrNull($payload['raw_result'] ?? null);
|
||||
$operatorSummary = $this->arrayOrNull($payload['operator_summary'] ?? null)
|
||||
?: $this->arrayOrNull(data_get($rawResult, 'operators'));
|
||||
$summary = $this->arrayOrNull($payload['summary'] ?? null) ?: [];
|
||||
$errorMessage = trim((string) ($payload['error_message'] ?? ''));
|
||||
$status = $this->determineStatus($operatorSummary, (string) ($payload['status'] ?? ''), $errorMessage);
|
||||
|
||||
$summary = array_merge($summary, $this->buildSummary($operatorSummary, $status));
|
||||
|
||||
$check->update([
|
||||
'status' => $status,
|
||||
'summary' => $summary,
|
||||
'operator_summary' => $operatorSummary,
|
||||
'raw_result' => $rawResult,
|
||||
'error_message' => $errorMessage !== '' ? $errorMessage : null,
|
||||
'checked_at' => time(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function formatTask(ServerGfwCheck $check): array
|
||||
{
|
||||
return [
|
||||
'check_id' => (int) $check->id,
|
||||
'targets' => $this->defaultTargets(),
|
||||
'ping_count' => 2,
|
||||
'timeout_seconds' => 2,
|
||||
'parallel' => 12,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatCheck(?ServerGfwCheck $check, bool $inherited, int $sourceNodeId): array
|
||||
{
|
||||
if (!$check) {
|
||||
return [
|
||||
'status' => 'unchecked',
|
||||
'inherited' => $inherited,
|
||||
'source_node_id' => $sourceNodeId,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $check->id,
|
||||
'status' => $check->status,
|
||||
'inherited' => $inherited,
|
||||
'source_node_id' => $sourceNodeId,
|
||||
'summary' => $check->summary,
|
||||
'operator_summary' => $check->operator_summary,
|
||||
'error_message' => $check->error_message,
|
||||
'checked_at' => $check->checked_at,
|
||||
'updated_at' => optional($check->updated_at)->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
private function determineStatus(?array $operators, string $reportedStatus, string $errorMessage): string
|
||||
{
|
||||
if ($errorMessage !== '') {
|
||||
return ServerGfwCheck::STATUS_FAILED;
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
ServerGfwCheck::STATUS_NORMAL,
|
||||
ServerGfwCheck::STATUS_BLOCKED,
|
||||
ServerGfwCheck::STATUS_PARTIAL,
|
||||
ServerGfwCheck::STATUS_FAILED,
|
||||
];
|
||||
|
||||
if (!$operators) {
|
||||
return in_array($reportedStatus, $allowed, true) ? $reportedStatus : ServerGfwCheck::STATUS_FAILED;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$success = 0;
|
||||
$reachableOperators = 0;
|
||||
foreach ($operators as $operator) {
|
||||
$operatorTotal = (int) ($operator['total'] ?? 0);
|
||||
$operatorSuccess = (int) ($operator['success'] ?? 0);
|
||||
$total += $operatorTotal;
|
||||
$success += $operatorSuccess;
|
||||
if ($operatorSuccess > 0) {
|
||||
$reachableOperators++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($total <= 0) {
|
||||
return ServerGfwCheck::STATUS_FAILED;
|
||||
}
|
||||
|
||||
$timeoutRatio = ($total - $success) / $total;
|
||||
if ($success === 0 || $timeoutRatio >= 0.95) {
|
||||
return ServerGfwCheck::STATUS_BLOCKED;
|
||||
}
|
||||
if ($reachableOperators >= 3 && $timeoutRatio <= 0.8) {
|
||||
return ServerGfwCheck::STATUS_NORMAL;
|
||||
}
|
||||
return ServerGfwCheck::STATUS_PARTIAL;
|
||||
}
|
||||
|
||||
private function buildSummary(?array $operators, string $status): array
|
||||
{
|
||||
if (!$operators) {
|
||||
return ['status' => $status];
|
||||
}
|
||||
|
||||
$total = array_sum(array_map(fn ($item) => (int) ($item['total'] ?? 0), $operators));
|
||||
$success = array_sum(array_map(fn ($item) => (int) ($item['success'] ?? 0), $operators));
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'total' => $total,
|
||||
'success' => $success,
|
||||
'timeout' => max(0, $total - $success),
|
||||
'timeout_ratio' => $total > 0 ? round(($total - $success) / $total, 4) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function arrayOrNull($value): ?array
|
||||
{
|
||||
return is_array($value) ? $value : null;
|
||||
}
|
||||
|
||||
private function defaultTargets(): array
|
||||
{
|
||||
return [
|
||||
'ct' => [
|
||||
['name' => '北京电信', 'host' => 'v4-bj-ct.oojj.de'],
|
||||
['name' => '上海电信', 'host' => '61.170.82.99'],
|
||||
['name' => '江苏电信', 'host' => 'v4-js-ct.oojj.de'],
|
||||
['name' => '广东电信', 'host' => 'gd-ct-v4.ip.zstaticcdn.com'],
|
||||
['name' => '四川电信', 'host' => 'sc-ct-v4.ip.zstaticcdn.com'],
|
||||
['name' => '重庆电信', 'host' => 'cq-ct-v4.ip.zstaticcdn.com'],
|
||||
],
|
||||
'cu' => [
|
||||
['name' => '北京联通', 'host' => 'v4-bj-cu.oojj.de'],
|
||||
['name' => '上海联通', 'host' => 'sh-cu-v4.ip.zstaticcdn.com'],
|
||||
['name' => '江苏联通', 'host' => 'js-cu-v4.ip.zstaticcdn.com'],
|
||||
['name' => '广东联通', 'host' => 'gd-cu-v4.ip.zstaticcdn.com'],
|
||||
['name' => '云南联通', 'host' => '14.205.93.189'],
|
||||
['name' => '重庆联通', 'host' => 'cq-cu-v4.ip.zstaticcdn.com'],
|
||||
],
|
||||
'cm' => [
|
||||
['name' => '北京移动', 'host' => 'bj-cm-v4.ip.zstaticcdn.com'],
|
||||
['name' => '上海移动', 'host' => 'sh-cm-v4.ip.zstaticcdn.com'],
|
||||
['name' => '山东移动', 'host' => '218.201.96.130'],
|
||||
['name' => '广东移动', 'host' => '211.136.192.6'],
|
||||
['name' => '四川移动', 'host' => '183.221.253.100'],
|
||||
['name' => '重庆移动', 'host' => 'cq-cm-v4.ip.zstaticcdn.com'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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::create('server_gfw_checks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('server_id');
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->unsignedBigInteger('triggered_by')->nullable();
|
||||
$table->json('summary')->nullable();
|
||||
$table->json('operator_summary')->nullable();
|
||||
$table->json('raw_result')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->unsignedInteger('checked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('server_id')->references('id')->on('v2_server')->cascadeOnDelete();
|
||||
$table->index(['server_id', 'created_at']);
|
||||
$table->index(['server_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('server_gfw_checks');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user