Files
pangolin/client/lib/services/connect_api.dart
T
wangjia bd8f974a25 feat(M6): 控制面联调 — mock connect server + 客户端 ConnectApi 透传
tsk_nuoKSM4Vt-zK

## 服务端(server/cmd/mockserver)
- `main.go`(79 行):独立 mock HTTP server,实现 POST /v1/nodes/:id/connect
  - 路径/请求体/响应体字段名与 §3.1 契约逐字一致
  - Bearer token 鉴权(-token 参数,空值跳过,不入库)
  - 缺少 device_id → 400;未授权 → 401;非 POST → 405
  - 返回完整 sing-box config JSON(tun + REALITY/Hy2 outbound + urltest + route/dns)
  - 注:mock 联调;待 #5/#6 真实 connect 接口就绪后替换
- `main_test.go`:6 项单测,覆盖 §3.1 结构校验、auth、method、path

## 客户端(client/)
- `lib/services/connect_api.dart`:ConnectApi 类
  - fetchConfig(nodeId, deviceId) → 原始响应体字符串(不做任何修改)
  - 错误路径:HTTP 非 200 / 超时 / 非法 JSON → ConnectApiException(含双语 message)
- `lib/services/vpn_bridge.dart`:VpnBridge stub(M6 联调,libbox 绑定待 11C)
  - start(configJson) 原样存储,不修改;stop() 清空
- `lib/widgets/home_shell.dart`:_toggle/_pick 替换为真实 API 流程
  - ConnectApi.fetchConfig → VpnBridge.start(透传,无中间变换)
  - 错误路径:ConnectApiException → SnackBar,status 回 off,无残留半开隧道
  - API URL/token/deviceId 由 --dart-define 注入(不入库)
- `lib/widgets/server_tile.dart`:ServerInfo 新增 nodeId 字段
- `pubspec.yaml`:新增 http: ^1.2.1 依赖
- `test/connect_passthrough_test.dart`:4 项透传断言单测
  - 核心:fetchConfig 返回字符串 === VpnBridge.start 接收字符串(逐字节相等)
  - 空格/格式原样保留(不经 jsonEncode 重序列化)
  - HTTP 错误、非法 JSON 错误路径覆盖

## 运行方式(M6 联调)
```
# 启动 mock server(无 auth)
go run ./server/cmd/mockserver -addr :8081

# 启动客户端(指向 mock server)
flutter run \
  --dart-define=PANGOLIN_API_URL=http://localhost:8081 \
  --dart-define=PANGOLIN_API_TOKEN=dev-mock-token \
  --dart-define=PANGOLIN_DEVICE_ID=demo-device-001
```

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:16:29 +08:00

98 lines
3.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// connect_api.dart — 控制面 connect 接口客户端
//
// 职责:POST /v1/nodes/:id/connect → 获取完整 sing-box config JSON。
// 关键约束(§3.1):返回的 JSON 字符串必须原样透传给 VpnBridge.start
// 调用方禁止对字符串做任何增删改;本类同样不修改响应体。
//
// M6 联调:mock 服务器地址由 --dart-define=PANGOLIN_API_URL 注入(默认 :8081)。
// tsk_nuoKSM4Vt-zK
import 'dart:convert';
import 'package:http/http.dart' as http;
/// 控制面接口调用失败时抛出。
/// [statusCode] < 0 表示网络层错误,-2 表示响应体 JSON 解析失败。
class ConnectApiException implements Exception {
const ConnectApiException({
required this.statusCode,
required this.messageZh,
required this.messageEn,
});
final int statusCode;
final String messageZh;
final String messageEn;
@override
String toString() => 'ConnectApiException($statusCode): $messageZh';
}
/// [ConnectApi] 封装 POST /v1/nodes/:id/connect 调用。
///
/// 可通过 [client] 注入自定义 [http.Client](方便单测使用 MockClient)。
class ConnectApi {
ConnectApi({
required this.baseUrl,
required this.authToken,
http.Client? client,
}) : _client = client ?? http.Client();
final String baseUrl;
final String authToken;
final http.Client _client;
/// 向控制面请求 [nodeId] 节点的完整 sing-box config JSON。
///
/// 返回**原始响应体字符串**,调用方必须不做任何修改直接传入 `VpnBridge.start`。
/// 异常:[ConnectApiException]HTTP 非 200、超时、非法 JSON)。
Future<String> fetchConfig({
required String nodeId,
required String deviceId,
}) async {
final uri = Uri.parse('$baseUrl/v1/nodes/$nodeId/connect');
final http.Response response;
try {
response = await _client
.post(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
},
body: jsonEncode({'device_id': deviceId}),
)
.timeout(const Duration(seconds: 15));
} on Exception catch (e) {
throw ConnectApiException(
statusCode: -1,
messageZh: '网络请求失败,请检查连接后重试',
messageEn: 'Network error: $e',
);
}
if (response.statusCode != 200) {
throw ConnectApiException(
statusCode: response.statusCode,
messageZh: '节点连接失败 (HTTP ${response.statusCode})',
messageEn: 'Connect failed (HTTP ${response.statusCode})',
);
}
// 仅做合法性校验,不修改响应体。
try {
jsonDecode(response.body);
} catch (_) {
throw const ConnectApiException(
statusCode: -2,
messageZh: '收到的配置格式无效,请稍后重试',
messageEn: 'Invalid config JSON received from server',
);
}
// 原样返回——调用方透传给 VpnBridge.start,不得在途中修改。
return response.body;
}
void dispose() => _client.close();
}