Files
pangolin/client/lib/services/connect_api.dart
T
wangjia 0a70a24cdb
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled
feat: 连接设备上限 backstop — 兜住"已登录的超限设备"(#16)
登录挡板只拦新登录,已登录的超限会话(如 pro 已连 5 台)永远不被提示 → 限制形同虚设。
补服务端连接卡点:超限账户连接直接拒,兜住已登录设备(下次一连即被拦,无需重登)。

服务端:
- Entitlement 加 MaxDevices(EntitlementForUser 查 p.max_devices;free 默认 1)
- NodeStore.CountActiveDevices(近 30d 活跃)+ SQLNodeStore/mock 实现
- ConnectNode:activeCount > MaxDevices → 403 {code:DEVICE_LIMIT_EXCEEDED, max_devices}

客户端:
- ConnectApiException 解析 code/max_devices(结构化错误体)
- _connect 遇 DEVICE_LIMIT_EXCEEDED → 置 deviceLimitProvider → 顶层路由弹「移除设备」页
  (设备列表走 devicesProvider),移除到上限内自动放行

后端 go test(store/nodes/httpapi/devices)全过;前端 analyze 干净 + 66 测试过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:11:13 +08:00

126 lines
4.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,
this.code,
this.maxDevices,
});
final int statusCode;
final String messageZh;
final String messageEn;
/// 服务端错误码(如 DEVICE_LIMIT_EXCEEDED);网络/解析错误时为 null。
final String? code;
/// DEVICE_LIMIT_EXCEEDED 时服务端下发的套餐设备上限。
final int? maxDevices;
@override
String toString() => 'ConnectApiException($statusCode${code != null ? '/$code' : ''}): $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,
bool splitCN = false,
}) async {
// splitCN=true 时带 ?split_cn=1:控制面渲染国内 IP/域名直连(不走隧道,#5)。
var uri = Uri.parse('$baseUrl/v1/nodes/$nodeId/connect');
if (splitCN) {
uri = uri.replace(queryParameters: {'split_cn': '1'});
}
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) {
// 解析结构化错误体(code/message/max_devices),失败则回退通用文案。
String? code;
int? maxDevices;
String? zh, en;
try {
final m = jsonDecode(response.body);
if (m is Map) {
code = m['code'] as String?;
maxDevices = (m['max_devices'] as num?)?.toInt();
zh = m['message_zh'] as String?;
en = m['message_en'] as String?;
}
} catch (_) {/* 非 JSON,用通用文案 */}
throw ConnectApiException(
statusCode: response.statusCode,
code: code,
maxDevices: maxDevices,
messageZh: zh ?? '节点连接失败 (HTTP ${response.statusCode})',
messageEn: en ?? '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();
}