正式上线"自定义线路"功能(仅限付费用户组)

This commit is contained in:
2026-06-04 11:10:44 +08:00
parent 42db3f9683
commit 33509de4e3
2 changed files with 578 additions and 22 deletions
+8 -1
View File
@@ -140,6 +140,8 @@ jobs:
- name: Build Android release APK
shell: bash
env:
IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }}
run: |
set -euo pipefail
@@ -151,8 +153,13 @@ jobs:
rm -rf "$pkg_dir"
fi
build_args=(--release)
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
fi
echo "Building Flutter Android release APK..."
flutter build apk -v --release
flutter build apk "${build_args[@]}"
mkdir -p "$pkg_dir"
apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)"
@@ -4,8 +4,12 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../services/api_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/desktop_constrained.dart';
import '../../widgets/toast_helper.dart';
@@ -21,21 +25,29 @@ class NetworkTestPage extends StatefulWidget {
class _NetworkTestPageState extends State<NetworkTestPage> {
static final _panUri = Uri.parse('https://pan.gongyun.org');
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
static final _telegramUri = Uri.parse('https://t.me/GYNetwork');
static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/');
static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/');
static const _ipNodeApiBaseUrlOverride = String.fromEnvironment(
'IP_NODE_API_BASE_URL',
);
static const _probeHeaders = {'Cache-Control': 'no-cache'};
static const _probeGetFallbackHeaders = {
'Cache-Control': 'no-cache',
'Range': 'bytes=0-0',
};
static const _customProbeSampleCount = 3;
_NetworkTestTab _tab = _NetworkTestTab.network;
bool _networkLoading = false;
bool _serviceLoading = false;
bool _customLoading = false;
_NetworkDiagnostics? _networkDiagnostics;
_ServiceDiagnostics? _serviceDiagnostics;
_CustomLineDiagnostics? _customDiagnostics;
String? _networkError;
String? _serviceError;
String? _customError;
@override
void initState() {
@@ -82,24 +94,8 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
),
],
selected: {_tab},
onSelectionChanged: (selection) {
final selected = selection.first;
if (selected == _NetworkTestTab.custom) {
_showCustomUnavailableDialog();
return;
}
setState(() => _tab = selected);
if (selected == _NetworkTestTab.network &&
_networkDiagnostics == null &&
!_networkLoading) {
_runNetworkTest();
}
if (selected == _NetworkTestTab.service &&
_serviceDiagnostics == null &&
!_serviceLoading) {
_runServiceTest();
}
},
onSelectionChanged: (selection) =>
_handleTabSelection(selection.first),
),
),
),
@@ -121,7 +117,14 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
diagnostics: _serviceDiagnostics,
onRefresh: _runServiceTest,
),
_NetworkTestTab.custom => const SizedBox.shrink(),
_NetworkTestTab.custom => _CustomLinePanel(
key: const ValueKey('custom'),
loading: _customLoading,
error: _customError,
diagnostics: _customDiagnostics,
onRefresh: _runCustomLineTest,
onOpenTelegram: () => _openExternalUrl(_telegramUri),
),
},
),
),
@@ -131,12 +134,54 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
);
}
Future<void> _showCustomUnavailableDialog() async {
void _handleTabSelection(_NetworkTestTab selected) {
if (selected == _NetworkTestTab.custom) {
unawaited(_openCustomLineTab());
return;
}
setState(() => _tab = selected);
if (selected == _NetworkTestTab.network &&
_networkDiagnostics == null &&
!_networkLoading) {
_runNetworkTest();
}
if (selected == _NetworkTestTab.service &&
_serviceDiagnostics == null &&
!_serviceLoading) {
_runServiceTest();
}
}
Future<void> _openCustomLineTab() async {
final auth = context.read<AuthProvider>();
final groupName = auth.user?.group?.name.trim();
if (!auth.isAuthenticated || auth.user == null) {
await _showCustomBlockedDialog('自定义线路需要登录后使用,请先登录后再试。');
return;
}
if (groupName == null || groupName.isEmpty || _isUserGroup(groupName)) {
await _showCustomBlockedDialog(
'自定义线路为会员功能,您所在用户组为普通用户组请成为非普通用户方可使用"自定义线路"功能。',
);
return;
}
setState(() => _tab = _NetworkTestTab.custom);
if (_customDiagnostics == null && !_customLoading) {
await _runCustomLineTest();
}
}
bool _isUserGroup(String groupName) =>
groupName.trim().toLowerCase() == 'user';
Future<void> _showCustomBlockedDialog(String message) async {
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('自定义线路'),
content: const Text('自定义线路为会员功能,当前版本暂未开放。'),
content: Text(message),
actions: [
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(),
@@ -158,6 +203,191 @@ class _NetworkTestPageState extends State<NetworkTestPage> {
}
}
Future<void> _runCustomLineTest() async {
if (_customLoading) return;
final auth = context.read<AuthProvider>();
final groupName = auth.user?.group?.name.trim();
if (!auth.isAuthenticated ||
auth.user == null ||
groupName == null ||
groupName.isEmpty ||
_isUserGroup(groupName)) {
setState(() {
_customError = '当前用户组无法使用自定义线路功能';
});
return;
}
setState(() {
_customLoading = true;
_customError = null;
});
try {
final clientId = await StorageService.instance.getOrCreateClientId();
final nodes = await _fetchCustomNodes(
clientId: clientId,
groupName: groupName,
);
final probes = await Future.wait(nodes.map(_testCustomNode));
final recommended = _bestCustomNode(probes);
if (!mounted) return;
setState(() {
_customDiagnostics = _CustomLineDiagnostics(
groupName: groupName,
nodes: probes,
recommended: recommended,
);
});
} catch (e) {
if (mounted) setState(() => _customError = e.toString());
} finally {
if (mounted) setState(() => _customLoading = false);
}
}
Future<List<_CustomNode>> _fetchCustomNodes({
required String clientId,
required String groupName,
}) async {
final handshakeResponse = await http
.post(
_ipNodeApiUri('/api/v1/handshake'),
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({'client_uuid': clientId}),
)
.timeout(const Duration(seconds: 10));
if (handshakeResponse.statusCode < 200 ||
handshakeResponse.statusCode >= 300) {
throw Exception('IP节点服务握手失败: HTTP ${handshakeResponse.statusCode}');
}
final handshakePayload = _payloadObject(
_decodeObject(handshakeResponse.body),
);
final sessionToken = handshakePayload['session_token']?.toString();
if (sessionToken == null || sessionToken.isEmpty) {
throw Exception('IP节点服务未返回有效会话密钥');
}
final nodesResponse = await http
.get(
_ipNodeApiUri('/api/v1/nodes', queryParameters: {'group': groupName}),
headers: {
'Accept': 'application/json',
'X-Client-UUID': clientId,
'X-Session-Token': sessionToken,
},
)
.timeout(const Duration(seconds: 12));
if (nodesResponse.statusCode < 200 || nodesResponse.statusCode >= 300) {
throw Exception('IP节点池获取失败: HTTP ${nodesResponse.statusCode}');
}
final nodesPayload = _payloadObject(_decodeObject(nodesResponse.body));
final rawNodes = nodesPayload['nodes'];
if (rawNodes is! List) return const [];
return rawNodes
.whereType<Map>()
.map((node) => _CustomNode.fromJson(Map<String, dynamic>.from(node)))
.toList();
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) return decoded;
if (decoded is Map) return Map<String, dynamic>.from(decoded);
throw Exception('IP节点服务返回格式错误');
}
Map<String, dynamic> _payloadObject(Map<String, dynamic> response) {
final data = response['data'];
if (data is Map<String, dynamic>) return data;
if (data is Map) return Map<String, dynamic>.from(data);
return response;
}
Uri _ipNodeApiUri(String path, {Map<String, String>? queryParameters}) {
final base = _ipNodeApiBaseUri;
return Uri(
scheme: base.scheme,
userInfo: base.userInfo,
host: base.host,
port: base.hasPort ? base.port : null,
path: _joinUriPath(base.path, path),
queryParameters: queryParameters,
);
}
Uri get _ipNodeApiBaseUri {
final override = _ipNodeApiBaseUrlOverride.trim();
if (override.isNotEmpty) return Uri.parse(override);
final appApiBase = Uri.tryParse(ApiService.instance.dio.options.baseUrl);
if (appApiBase != null &&
appApiBase.hasScheme &&
appApiBase.host.isNotEmpty) {
return Uri(scheme: appApiBase.scheme, host: appApiBase.host, port: 7998);
}
return Uri.parse('http://127.0.0.1:7998');
}
String _joinUriPath(String basePath, String path) {
final child = path.startsWith('/') ? path.substring(1) : path;
if (basePath.isEmpty || basePath == '/') return '/$child';
final normalizedBase = basePath.endsWith('/')
? basePath.substring(0, basePath.length - 1)
: basePath;
return '$normalizedBase/$child';
}
Future<_CustomNodeProbe> _testCustomNode(_CustomNode node) async {
final probeUri = node.probeUri ?? node.endpointUri;
if (probeUri == null) {
return _CustomNodeProbe(
node: node,
sampleCount: _customProbeSampleCount,
successCount: 0,
);
}
final latencies = <int>[];
var successCount = 0;
for (var i = 0; i < _customProbeSampleCount; i++) {
final probe = await _probeUri(probeUri);
if (probe.reachable) {
successCount += 1;
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
}
if (i < _customProbeSampleCount - 1) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
final averageLatencyMs = latencies.isEmpty
? null
: (latencies.reduce((a, b) => a + b) / latencies.length).round();
return _CustomNodeProbe(
node: node,
sampleCount: _customProbeSampleCount,
successCount: successCount,
averageLatencyMs: averageLatencyMs,
);
}
_CustomNodeProbe? _bestCustomNode(List<_CustomNodeProbe> probes) {
final reachable = probes.where((probe) => probe.successCount > 0).toList();
if (reachable.isEmpty) return null;
reachable.sort((a, b) => a.score.compareTo(b.score));
return reachable.first;
}
Future<void> _runNetworkTest() async {
if (_networkLoading) return;
@@ -600,6 +830,154 @@ class _ServicePanel extends StatelessWidget {
}
}
class _CustomLinePanel extends StatelessWidget {
final bool loading;
final String? error;
final _CustomLineDiagnostics? diagnostics;
final VoidCallback onRefresh;
final VoidCallback onOpenTelegram;
const _CustomLinePanel({
super.key,
required this.loading,
required this.error,
required this.diagnostics,
required this.onRefresh,
required this.onOpenTelegram,
});
@override
Widget build(BuildContext context) {
final data = diagnostics;
final recommended = data?.recommended;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
children: [
_SectionCard(
title: '推荐节点',
children: [
if (loading) const LinearProgressIndicator(),
if (error != null) _ErrorText(error!),
if (data != null) ...[
_InfoRow(label: '当前用户组', value: data.groupName),
_InfoRow(label: '可用节点数量', value: '${data.nodes.length}'),
],
if (recommended != null) ...[
_InfoRow(
label: '推荐节点',
value: recommended.node.displayName,
valueColor: Colors.green,
),
_InfoRow(label: '节点地址', value: recommended.node.endpointLabel),
_InfoRow(label: '地区', value: recommended.node.locationLabel),
_InfoRow(label: '运营商', value: recommended.node.carrierLabel),
_InfoRow(label: '平均延迟', value: recommended.latencyLabel),
_InfoRow(label: '丢包率', value: recommended.lossLabel),
] else if (!loading && data != null) ...[
const _DescriptionLine('暂无可推荐节点,请刷新后重试或联系官方支持。'),
] else if (!loading && error == null) ...[
const _DescriptionLine('进入页面后将自动获取节点并进行推荐。'),
],
],
),
_SectionCard(title: '可选节点', children: _availableNodeChildren(data)),
_SectionCard(
title: '自定义节点',
children: const [_DescriptionLine('预留功能,后续将支持手动填写和保存自定义节点。')],
),
_SectionCard(
title: '专线节点申请',
children: [
const _DescriptionLine(
'如需申请专线节点,可以联系公云存储官方邮箱 operate@gongyun.org 或 Telegramhttps://t.me/GYNetwork。',
),
const _DescriptionLine('申请时请提供所在国家/地区、对应省份和运营商,便于官方评估线路可用性。'),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: onOpenTelegram,
icon: const Icon(Icons.open_in_new),
label: const Text('打开 Telegram'),
),
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: loading ? null : onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('重新检测'),
),
],
);
}
List<Widget> _availableNodeChildren(_CustomLineDiagnostics? data) {
if (data == null) {
return [_InfoRow(label: '节点', value: loading ? '加载中' : '等待获取')];
}
if (data.nodes.isEmpty) {
return const [_DescriptionLine('当前用户组暂无可选节点。')];
}
return [
for (var i = 0; i < data.nodes.length; i++) ...[
if (i > 0) const Divider(height: 18),
_CustomNodeTile(probe: data.nodes[i]),
],
];
}
}
class _CustomNodeTile extends StatelessWidget {
final _CustomNodeProbe probe;
const _CustomNodeTile({required this.probe});
@override
Widget build(BuildContext context) {
final available = probe.successCount > 0;
final statusColor = _statusColor(context, available);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
probe.node.displayName,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Text(
available ? '可用' : '不可用',
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
Text(probe.node.endpointLabel),
const SizedBox(height: 4),
Text('${probe.node.locationLabel} / ${probe.node.carrierLabel}'),
const SizedBox(height: 4),
Text(
'平均延迟 ${probe.latencyLabel} / 丢包率 ${probe.lossLabel} / 样本 ${probe.sampleLabel}',
),
],
),
);
}
}
class _SectionCard extends StatelessWidget {
final String title;
final List<Widget> children;
@@ -737,6 +1115,177 @@ class _IpInfo {
});
}
class _CustomLineDiagnostics {
final String groupName;
final List<_CustomNodeProbe> nodes;
final _CustomNodeProbe? recommended;
const _CustomLineDiagnostics({
required this.groupName,
required this.nodes,
required this.recommended,
});
}
class _CustomNode {
final String id;
final String name;
final String host;
final String ip;
final int? port;
final String protocol;
final String region;
final String country;
final String province;
final String carrier;
final List<String> groups;
final bool enabled;
final String probeUrl;
const _CustomNode({
required this.id,
required this.name,
required this.host,
required this.ip,
required this.port,
required this.protocol,
required this.region,
required this.country,
required this.province,
required this.carrier,
required this.groups,
required this.enabled,
required this.probeUrl,
});
factory _CustomNode.fromJson(Map<String, dynamic> json) {
return _CustomNode(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
host: json['host']?.toString() ?? '',
ip: json['ip']?.toString() ?? '',
port: _asNullableInt(json['port']),
protocol: json['protocol']?.toString() ?? 'https',
region: json['region']?.toString() ?? '',
country: json['country']?.toString() ?? '',
province: json['province']?.toString() ?? '',
carrier: json['carrier']?.toString() ?? '',
groups: _asStringList(json['groups']),
enabled: json['enabled'] != false,
probeUrl: json['probe_url']?.toString() ?? '',
);
}
String get displayName {
final trimmedName = name.trim();
if (trimmedName.isNotEmpty) return trimmedName;
final endpoint = endpointHost;
return endpoint.isEmpty ? '未命名节点' : endpoint;
}
String get endpointHost {
final trimmedHost = host.trim();
if (trimmedHost.isNotEmpty) return trimmedHost;
return ip.trim();
}
String get endpointLabel {
final endpoint = endpointHost;
if (endpoint.isEmpty) return '未配置地址';
final scheme = protocol.trim().isEmpty ? 'https' : protocol.trim();
final portSuffix = port != null && port! > 0 ? ':$port' : '';
return '$scheme://$endpoint$portSuffix';
}
String get locationLabel {
final parts = <String>[];
for (final value in [country, province, region]) {
final trimmed = value.trim();
if (trimmed.isNotEmpty && !parts.contains(trimmed)) {
parts.add(trimmed);
}
}
return parts.isEmpty ? '未配置地区' : parts.join(' / ');
}
String get carrierLabel {
final trimmedCarrier = carrier.trim();
return trimmedCarrier.isEmpty ? '未配置运营商' : trimmedCarrier;
}
Uri? get probeUri {
final value = probeUrl.trim();
if (value.isEmpty) return null;
final uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null;
return uri;
}
Uri? get endpointUri {
final endpoint = endpointHost;
final scheme = protocol.trim().toLowerCase();
if (endpoint.isEmpty || (scheme != 'http' && scheme != 'https')) {
return null;
}
return Uri(scheme: scheme, host: endpoint, port: port);
}
static int? _asNullableInt(Object? value) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(value?.toString() ?? '');
}
static List<String> _asStringList(Object? value) {
if (value is List) {
return value
.map((item) => item.toString().trim())
.where((item) => item.isNotEmpty)
.toList();
}
if (value is String) {
return value
.split(',')
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList();
}
return const [];
}
}
class _CustomNodeProbe {
final _CustomNode node;
final int sampleCount;
final int successCount;
final int? averageLatencyMs;
const _CustomNodeProbe({
required this.node,
required this.sampleCount,
required this.successCount,
this.averageLatencyMs,
});
double get packetLossPercent {
if (sampleCount <= 0) return 100;
return ((sampleCount - successCount) / sampleCount) * 100;
}
double get score {
return (averageLatencyMs ?? 999999).toDouble() + packetLossPercent * 20;
}
String get latencyLabel {
if (averageLatencyMs == null) return '-- ms';
return '$averageLatencyMs ms';
}
String get lossLabel => '${packetLossPercent.toStringAsFixed(0)}%';
String get sampleLabel => '$successCount/$sampleCount';
}
class _ServiceDiagnostics {
final DateTime fetchedAt;
final List<_ServiceItem> business;