水印优化+ORC优化

This commit is contained in:
2026-09-28 11:39:19 +08:00
parent c5b3d81d07
commit 6176c49964
19 changed files with 1701 additions and 171 deletions
+123 -8
View File
@@ -14,8 +14,21 @@ import '../core/network/api_exception.dart';
/// 硬编码在客户端)。风险只有一个:AK 被刷爆后逆地理会失效,水印仍能正常合成。
/// 百度地图开放平台 AK(服务端类型,需勾选逆地理编码权限)
///
/// ⚠️ 有每日配额,用完当天就不可用 —— 所以下面还有 [nominatimUrl] 兜底。
const String _baiduAk = 'I3IYR0glCNfmfYzMoBDr7lLTKdZ8Y0Xs';
const String _baiduReverseUrl = 'https://api.map.baidu.com/reverse_geocoding/v3/';
/// OpenStreetMap Nominatim:**完全免费、不需要 key、没有配额**。
///
/// 用它兜底是因为百度的免费配额会耗尽(用户实际撞上了)。
/// 代价两点,都写在明面上:
/// 1. 它是海外服务,国内访问偶尔会慢(实测约 1 秒)
/// 2. 使用政策要求「每秒不超过 1 次请求」+ 必须带 User-Agent。
/// 我们只在百度失败时才走它,量很小,符合政策。
const String _nominatimUrl = 'https://nominatim.openstreetmap.org/reverse';
const String _nominatimUserAgent = 'QiangbaoAssistant/1.0 (personal use)';
const String _openMeteoUrl = 'https://api.open-meteo.com/v1/forecast';
final geoApiProvider = Provider<GeoApi>((ref) => GeoApi());
@@ -29,6 +42,7 @@ class GeoSnapshot {
this.weather = '',
this.temperature = '',
this.error,
this.addressError,
});
final double? latitude;
@@ -40,6 +54,12 @@ class GeoSnapshot {
/// 定位失败时的原因(地址/天气会留空,页面据此提示用户)
final String? error;
/// 定位成功了、但地址没解析出来时的原因。
///
/// 和 [error] 分开是为了不混淆:定位成功但地址失败时,坐标还在,
/// 用户该看到「有位置、但地址没解析出来 + 为什么」,而不是一句笼统的失败。
final String? addressError;
bool get hasPosition => latitude != null && longitude != null;
}
@@ -47,8 +67,10 @@ class GeoApi {
GeoApi({Dio? dio})
: _dio = dio ??
Dio(BaseOptions(
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 10),
// 比默认宽松一点:移动网络下 TLS 握手 + DNS 偶尔要好几秒,
// 国内直连百度和海外 Nominatim 的耗时也不一样
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
final Dio _dio;
@@ -86,10 +108,31 @@ class GeoApi {
}
/// 经纬度 → 文字地址。
///
/// 走「**百度优先、失败降级 Nominatim**」:百度国内数据最准(有街道门牌),
/// 但它有每日配额;配额用完时自动换 OSM,至少还能拿到街道级地址。
/// 用户实际撞上过配额耗尽 —— 那时候不降级就只剩一串坐标了。
Future<String> reverseAddress({
required double latitude,
required double longitude,
}) async {
String? baiduError;
try {
return await _baiduReverseAddress(latitude, longitude);
} on ApiException catch (e) {
baiduError = e.message;
}
try {
return await _nominatimReverseAddress(latitude, longitude);
} catch (_) {
// 两个都不行。把百度的原因带出来 —— 那多半是「配额用完」,用户看得懂
throw ApiException(-1, baiduError);
}
}
/// 百度逆地理(主用)
Future<String> _baiduReverseAddress(double latitude, double longitude) async {
final Response<dynamic> res;
try {
res = await _dio.get<dynamic>(
@@ -105,21 +148,22 @@ class GeoApi {
},
);
} on DioException catch (e) {
throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}');
throw ApiException(-1, '百度地址解析失败:${e.message ?? '网络异常'}');
}
final data = res.data;
if (data is! Map) {
throw ApiException(-1, '地址解析失败:返回格式异常');
throw ApiException(-1, '百度地址解析失败:返回格式异常');
}
final status = data['status'];
if (status != 0) {
throw ApiException(-1, '地址解析失败:${data['msg'] ?? '百度返回 $status'}');
final msg = data['msg']?.toString() ?? '';
throw ApiException(-1, '百度地址解析失败:${msg.isEmpty ? '返回 $status' : msg}');
}
final result = data['result'];
if (result is! Map) {
throw ApiException(-1, '地址解析失败:没有结果');
throw ApiException(-1, '百度地址解析失败:没有结果');
}
final formatted = result['formatted_address']?.toString() ?? '';
@@ -132,6 +176,71 @@ class GeoApi {
'${ac['street'] ?? ''}${ac['street_number'] ?? ''}';
if (joined.isNotEmpty) return joined;
}
throw ApiException(-1, '百度地址解析失败:结果为空');
}
/// OSM Nominatim 逆地理(兜底,免费且没有配额)
Future<String> _nominatimReverseAddress(double latitude, double longitude) async {
final Response<dynamic> res;
try {
res = await _dio.get<dynamic>(
_nominatimUrl,
queryParameters: {
'format': 'json',
'lat': latitude,
'lon': longitude,
'accept-language': 'zh-CN',
'zoom': 18, // 建筑/门牌级,尽量贴近百度的粒度
},
// Nominatim 的使用政策要求带 User-Agent,不带会被拒
options: Options(headers: {'User-Agent': _nominatimUserAgent}),
);
} on DioException catch (e) {
throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}');
}
final data = res.data;
if (data is! Map) {
throw ApiException(-1, '地址解析失败:返回格式异常');
}
// Nominatim 的 address 各字段是「小 → 大」,拼成中文习惯的「大 → 小」:
// 云南省 + 昆明市 + 五华区 + 护国街道 + 庆云街
final address = data['address'];
if (address is Map) {
final parts = <String>[];
for (final key in const [
'state',
'city',
'district',
'county',
'suburb',
'city_district',
'road',
'pedestrian',
'house_number',
]) {
final value = address[key]?.toString().trim() ?? '';
// 去重:有些地方 city 和 district 会填同一个值
if (value.isEmpty || parts.contains(value)) continue;
parts.add(value);
}
if (parts.isNotEmpty) return parts.join();
}
// 再退一步:display_name 是逗号分隔的「小 → 大」,反过来拼,顺手滤掉邮编和国家
final display = data['display_name']?.toString() ?? '';
if (display.isNotEmpty) {
final segments = display
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty && s != '中国')
.where((s) => int.tryParse(s) == null)
.toList()
.reversed;
if (segments.isNotEmpty) return segments.join();
}
throw ApiException(-1, '地址解析失败:结果为空');
}
@@ -181,16 +290,21 @@ class GeoApi {
return GeoSnapshot(error: e.message);
}
// 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做)
// 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做)。
// 同时把**原因**记下来给页面显示 —— 只给一串坐标、不说为什么,
// 上次就是这样让人以为是功能坏了。
var address = '${position.latitude.toStringAsFixed(4)}, '
'${position.longitude.toStringAsFixed(4)}';
String? addressError;
try {
address = await reverseAddress(
latitude: position.latitude,
longitude: position.longitude,
);
} on ApiException catch (e) {
addressError = e.message;
} catch (_) {
// 保持坐标兜底
addressError = '地址解析失败';
}
final weather = await this.weather(
@@ -205,6 +319,7 @@ class GeoApi {
address: address,
weather: weather.weather,
temperature: weather.temperature,
addressError: addressError,
);
}