// Author: fruit.com.cn import 'dart:io'; import 'package:flutter/foundation.dart'; /// 客户端与后端约定的运行时配置。 /// /// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。 /// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。 class AppConfig { const AppConfig._(); /// 开发调试默认服务地址,当前指向本机局域网 IP。 /// /// 换网络时只改这一行,地址不要带结尾斜杠。 static const String devHostBaseUrl = 'http://192.168.31.187:8081'; /// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 static const String lanBaseUrl = 'http://127.0.0.1:8081'; /// 公网地址(远程/移动网络访问时使用)。 static const String publicBaseUrl = 'http://39.162.50.99:8081'; static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL'); static String _runtimeBaseUrl = ''; static Future? _ready; /// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。 static Future get ready => _ready ?? Future.value(); static set ready(Future value) => _ready = value; static const Duration _probeTimeout = Duration(seconds: 2); static const List _probeCandidates = [ lanBaseUrl, devHostBaseUrl, if (devHostBaseUrl != publicBaseUrl) publicBaseUrl, ]; /// Build-time server URL supplied through `--dart-define=API_BASE_URL`. static String get compiledBaseUrl => _overrideBaseUrl; /// Existing platform-aware fallback used when no runtime or build override exists. static String get defaultBaseUrl => _defaultBaseUrl; /// Effective server URL, including the debug-only runtime override. static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty ? _runtimeBaseUrl : (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl); static final String _defaultBaseUrl = _runsOnDevMachine ? lanBaseUrl : devHostBaseUrl; /// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。 /// Android 没有等价的探测方式,统一按真机处理(模拟器用 /// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。 static final bool _runsOnDevMachine = kIsWeb || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux || (defaultTargetPlatform == TargetPlatform.iOS && Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')); /// Applies an already normalized runtime URL. Passing null clears it. static void applyRuntimeBaseUrl(String? value) { _runtimeBaseUrl = value ?? ''; } /// Clears the in-memory runtime override, mainly for tests and restarts. static void clearRuntimeBaseUrl() { _runtimeBaseUrl = ''; } static const Duration requestTimeout = Duration(seconds: 8); /// 按环回、开发机、公网顺序探测,写入首个返回 200 的地址。 /// /// 全部不可达时使用平台默认地址,真机保留开发机地址,避免退回公网。 static Future probeAndApplyBaseUrl({ Future Function(String baseUrl)? probe, String? fallbackBaseUrl, /// 编译期地址测试注入;未传时读取 `API_BASE_URL`。 String? compiledOverride, }) async { final compiled = compiledOverride ?? compiledBaseUrl; if (compiled.isNotEmpty) { if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled); return; } final candidates = [ if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl, for (final candidate in _probeCandidates) if (candidate != _runtimeBaseUrl) candidate, ]; final results = await Future.wait([ for (final candidate in candidates) probe == null ? _probeBaseUrl(candidate) : _runInjectedProbe(candidate, probe), ]); for (var index = 0; index < candidates.length; index++) { final candidate = candidates[index]; final result = results[index]; if (kDebugMode) { final status = result.ok ? 'ok' : 'fail(${result.reason ?? 'request failed'})'; debugPrint('[probe] $candidate -> $status'); } if (result.ok) { applyRuntimeBaseUrl(candidate); return; } } applyRuntimeBaseUrl(fallbackBaseUrl ?? defaultBaseUrl); } static Future<_ProbeResult> _runInjectedProbe( String baseUrl, Future Function(String baseUrl) probe, ) async { try { return _ProbeResult(ok: await probe(baseUrl)); } catch (error) { return _ProbeResult(ok: false, reason: error.toString()); } } static Future<_ProbeResult> _probeBaseUrl(String baseUrl) async { final client = HttpClient()..connectionTimeout = _probeTimeout; try { return await _requestCategories( client, Uri.parse('$baseUrl/app/categories'), ).timeout(_probeTimeout); } catch (error) { return _ProbeResult(ok: false, reason: error.toString()); } finally { client.close(force: true); } } static Future<_ProbeResult> _requestCategories( HttpClient client, Uri uri, ) async { final request = await client.getUrl(uri); final response = await request.close(); await response.drain(); final ok = response.statusCode == 200; return _ProbeResult( ok: ok, reason: ok ? null : 'HTTP ${response.statusCode}', ); } /// 后端返回的是相对路径(/files/...),这里补上服务地址。 static String mediaUrl(String path) { if (path.isEmpty) return ''; if (path.startsWith('http://') || path.startsWith('https://')) return path; return '$apiBaseUrl$path'; } } class _ProbeResult { const _ProbeResult({required this.ok, this.reason}); final bool ok; final String? reason; }