Skip to content

Commit eb2a413

Browse files
committed
FIX: 调整设置页关于入口顺序
1 parent c946f69 commit eb2a413

47 files changed

Lines changed: 2913 additions & 357 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

TASKS.md

Lines changed: 160 additions & 1 deletion
Large diffs are not rendered by default.

assets/icon/help.svg

Lines changed: 2 additions & 0 deletions
Loading

lib/analytics/analytics_providers.dart

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,13 @@ import 'package:flutter/foundation.dart';
1212
import 'package:flutter_riverpod/flutter_riverpod.dart';
1313
import 'package:shared_preferences/shared_preferences.dart';
1414

15-
import '../config/api_config.dart';
16-
import '../services/backend_dio.dart';
1715
import 'analytics_channel.dart';
1816
import 'analytics_service.dart';
1917
import 'channels/firebase_channel.dart';
2018
import 'channels/log_only_channel.dart';
2119
import 'channels/posthog_channel.dart';
2220
import 'channels/umeng_channel.dart';
2321
import 'consent_manager.dart';
24-
import 'geo_interceptor.dart';
2522

2623
/// 分析服务单例(在 main() 中通过 [initAnalyticsService] 初始化)
2724
late AnalyticsService _analyticsService;
@@ -66,35 +63,6 @@ Future<AnalyticsService> initAnalyticsService(
6663
return AnalyticsService(channel: channel, consent: consent);
6764
}
6865

69-
/// 获取地区:缓存优先 → geo API → locale fallback
70-
///
71-
/// 当前仅供 GeoInterceptor 更新缓存使用,不再用于通道选择。
72-
/// API 成功的结果会持久化;locale fallback 不持久化。
73-
Future<bool> resolveIsMainlandChina(SharedPreferences prefs) async {
74-
// 1. 有缓存直接用
75-
final cached = prefs.getString(geoCountryKey);
76-
if (cached != null) return cached == 'CN';
77-
78-
// 2. 无缓存:调 geo API
79-
try {
80-
final response = await createBackendDio(
81-
connectTimeout: const Duration(seconds: 2),
82-
receiveTimeout: const Duration(seconds: 2),
83-
).get('$apiBaseUrl/api/v1/user/geo');
84-
final data = response.data;
85-
final country = data is Map ? data['country'] as String? : null;
86-
if (country != null && country.isNotEmpty) {
87-
await prefs.setString(geoCountryKey, country);
88-
return country == 'CN';
89-
}
90-
} catch (_) {
91-
// API 不可用,继续 fallback
92-
}
93-
94-
// 3. API 失败:locale fallback(不持久化)
95-
return Platform.localeName.contains('CN');
96-
}
97-
9866
/// 根据配置选择分析通道
9967
///
10068
/// 当前策略:PostHog 全平台统一上报。

lib/features/audio_import/audio_import_service.dart

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../../models/audio_item.dart';
99
import '../../providers/audio_library_provider.dart';
1010
import '../../providers/collection_provider.dart';
1111
import '../../services/app_logger.dart';
12+
import '../../services/reliable_http_downloader.dart';
1213
import '../../utils/app_data_dir.dart';
1314
import '../../utils/audio_duration.dart';
1415
import 'audio_finalization_service.dart';
@@ -44,9 +45,15 @@ class AudioImportService {
4445
transcodeService: transcodeService ?? AudioTranscodeService(),
4546
computeSha256: computeSha256,
4647
uuid: uuid,
47-
);
48+
) {
49+
_downloader = DioReliableHttpDownloader(dio: _dio);
50+
}
4851

4952
final Dio _dio;
53+
54+
/// `ReliableHttpDownloader` 接口本身不提供释放能力,本类也不做 `dispose`
55+
/// (复用调用方注入的 [_dio] 或全局默认 Dio,生命周期不归本类管)。
56+
late final ReliableHttpDownloader _downloader;
5057
final Uuid _uuid;
5158
final Future<Directory> Function() _resolveDataDir;
5259
final Future<int> Function(String relativePath) _readDurationSeconds;
@@ -278,30 +285,37 @@ class AudioImportService {
278285
final tmpDir = Directory(p.join(dataDir.path, 'tmp', 'audio_import'));
279286
await tmpDir.create(recursive: true);
280287

281-
final tmpFile = File(p.join(tmpDir.path, '$audioId.part'));
282288
final downloadedFile = File(
283289
p.join(tmpDir.path, '$audioId.${resolved.extension}'),
284290
);
285291
try {
286-
await _dio.download(
287-
resolved.uri.toString(),
288-
tmpFile.path,
292+
// allowResume: false——导入取消/失败不需要跨调用续传,失败即清理
293+
// `.part`,与迁移前手写 finally 删临时文件的既有语义一致。
294+
await _downloader.download(
295+
uri: resolved.uri,
296+
savePath: downloadedFile.path,
297+
allowResume: false,
289298
cancelToken: cancelToken,
290-
options: Options(followRedirects: true),
291-
onReceiveProgress: (received, total) {
292-
onProgress?.call(received, total <= 0 ? null : total);
293-
},
299+
onProgress: (received, total) => onProgress?.call(received, total),
294300
);
295-
await tmpFile.rename(downloadedFile.path);
296301
return p.join('tmp', 'audio_import', p.basename(downloadedFile.path));
297-
} on DioException catch (e) {
298-
if (CancelToken.isCancel(e)) {
302+
} on ReliableDownloadException catch (e) {
303+
if (e.kind == ReliableDownloadFailure.cancelled) {
299304
throw const AudioImportException(
300305
AudioImportFailureCode.canceled,
301306
'Audio import canceled',
302307
);
303308
}
304309
_logDownloadFailure(resolved.uri, e);
310+
// 磁盘写入失败已被下载器内部归类为 storage(如空间不足),单独区分,
311+
// 不与「网络请求失败」混为一谈。
312+
if (e.kind == ReliableDownloadFailure.storage) {
313+
throw AudioImportException(
314+
AudioImportFailureCode.storage,
315+
'Failed to save audio',
316+
e,
317+
);
318+
}
305319
throw AudioImportException(
306320
AudioImportFailureCode.network,
307321
'Failed to download audio',
@@ -313,12 +327,6 @@ class AudioImportService {
313327
'Failed to save audio',
314328
e,
315329
);
316-
} finally {
317-
if (await tmpFile.exists()) {
318-
try {
319-
await tmpFile.delete();
320-
} catch (_) {}
321-
}
322330
}
323331
}
324332

@@ -365,14 +373,14 @@ class AudioImportService {
365373
return uri.replace(scheme: 'https', pathSegments: nextSegments);
366374
}
367375

368-
void _logDownloadFailure(Uri uri, DioException error) {
376+
void _logDownloadFailure(Uri uri, ReliableDownloadException error) {
369377
AppLogger.log(
370378
_logTag,
371379
'download failed url=$uri '
372-
'type=${error.type} '
373-
'status=${error.response?.statusCode ?? "(null)"} '
374-
'message=${error.message ?? "(null)"} '
375-
'cause=${error.error ?? "(null)"}',
380+
'kind=${error.kind} '
381+
'status=${error.statusCode ?? "(null)"} '
382+
'message=${error.message} '
383+
'cause=${error.cause ?? "(null)"}',
376384
);
377385
}
378386

lib/features/baidu_netdisk/data/baidu_netdisk_api.dart

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,9 @@ class DefaultBaiduNetdiskApi implements BaiduNetdiskApi {
182182
} on DioException catch (error) {
183183
throw _mapDioException(error, fallbackMessage: 'Baidu download failed.');
184184
} on ReliableDownloadException catch (error) {
185-
throw BaiduNetdiskFileException(
186-
kind: BaiduNetdiskFileErrorKind.network,
187-
message: _displayMessageForReliableDownloadException(
188-
error,
189-
fallbackMessage: 'Baidu download failed.',
190-
),
191-
cause: error,
185+
throw _mapReliableDownloadException(
186+
error,
187+
fallbackMessage: 'Baidu download failed.',
192188
);
193189
}
194190
}
@@ -259,6 +255,40 @@ class DefaultBaiduNetdiskApi implements BaiduNetdiskApi {
259255
);
260256
}
261257

258+
/// 把 [ReliableHttpDownloader] 的结构化异常映射回既有错误分类。
259+
///
260+
/// httpStatus 按 statusCode 复用与 [_mapDioException] 相同的
261+
/// unauthorized/notFound/rateLimited 判定;cancelled 映射为 canceled;
262+
/// 其余 kind(network/timeout/redirect/storage/integrity/conflict/unknown)
263+
/// 统一归为 network,与迁移前「非 DioException 一律 network」的行为一致。
264+
BaiduNetdiskFileException _mapReliableDownloadException(
265+
ReliableDownloadException error, {
266+
required String fallbackMessage,
267+
}) {
268+
if (error.kind == ReliableDownloadFailure.cancelled) {
269+
return const BaiduNetdiskFileException(
270+
kind: BaiduNetdiskFileErrorKind.canceled,
271+
message: 'Baidu request canceled.',
272+
);
273+
}
274+
final kind = switch (error.kind == ReliableDownloadFailure.httpStatus
275+
? error.statusCode
276+
: null) {
277+
401 || 403 => BaiduNetdiskFileErrorKind.unauthorized,
278+
404 => BaiduNetdiskFileErrorKind.notFound,
279+
429 => BaiduNetdiskFileErrorKind.rateLimited,
280+
_ => BaiduNetdiskFileErrorKind.network,
281+
};
282+
return BaiduNetdiskFileException(
283+
kind: kind,
284+
message: _displayMessageForReliableDownloadException(
285+
error,
286+
fallbackMessage: fallbackMessage,
287+
),
288+
cause: error,
289+
);
290+
}
291+
262292
/// 保留 Dio 底层异常原因,供批量导入结果页展示可排查的失败信息。
263293
String _displayMessageForDioException(
264294
DioException error, {

lib/features/official_collections/download/official_download_notifier.dart

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import '../../../providers/audio_library_provider.dart';
1515
import '../../../providers/learning_progress_provider.dart';
1616
import '../../../providers/listening_practice/listening_practice_provider.dart';
1717
import '../../../services/app_logger.dart';
18+
import '../../../services/reliable_http_downloader.dart';
1819
import '../../../utils/app_data_dir.dart';
1920
import '../../../utils/transcript_stats.dart';
2021
import '../data/official_collection_api.dart';
@@ -237,33 +238,36 @@ class OfficialDownload extends _$OfficialDownload {
237238
String remoteAudioId,
238239
) async {
239240
final api = ref.read(officialCollectionApiProvider);
240-
final dio = Dio();
241+
final downloader = DioReliableHttpDownloader(dio: Dio());
241242
final docDir = await getAppDataDirectory();
242243
final tmpDir = Directory(p.join(docDir.path, 'tmp', 'official_audio'));
243244
await tmpDir.create(recursive: true);
244-
final tmpAudioFile = File(p.join(tmpDir.path, '${audioItem.id}.m4a.part'));
245+
final tmpAudioFile = File(p.join(tmpDir.path, '${audioItem.id}.m4a'));
245246

246247
try {
247248
// 1) 拉 /content(SRT + wordTimestamps + audioUrl)
248249
final content = await api.getAudioContent(remoteAudioId);
249250
if (sid != _sessionId) return false; // 过期
250251

251-
// 2) 下载音频到 tmp
252-
await dio.download(
253-
content.audioUrl,
254-
tmpAudioFile.path,
252+
// 2) 下载音频到 tmp(allowResume: false——失败/取消不留 `.part` 残留,
253+
// 与下方 finally 清理 tmp 文件的既有语义一致;取消判定不依赖异常类型,
254+
// 由 [cancel] 提前递增 sessionId、下面的 `sid != _sessionId` 检查负责丢弃)。
255+
await downloader.download(
256+
uri: Uri.parse(content.audioUrl),
257+
savePath: tmpAudioFile.path,
258+
allowResume: false,
255259
cancelToken: _cancelToken,
256-
onReceiveProgress: (received, total) {
260+
onProgress: (received, total) {
257261
if (sid != _sessionId) return;
258262
if (state is! DownloadInProgress) return;
259263
final prev = state as DownloadInProgress;
260-
final ratio = total > 0 ? received / total : -1.0;
264+
final ratio = (total != null && total > 0) ? received / total : -1.0;
261265
state = DownloadInProgress(
262266
audioItemId: prev.audioItemId,
263267
displayName: prev.displayName,
264268
progress: ratio,
265269
receivedBytes: received,
266-
totalBytes: total <= 0 ? null : total,
270+
totalBytes: total,
267271
);
268272
},
269273
);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/// 当前用户的地区判定状态。
2+
///
3+
/// 此状态只缓存于当前 App 进程:冷启动和回到前台时会重新确认,避免把商店区、
4+
/// 系统地区或服务端推断出的旧结果长期写入本地偏好。
5+
library;
6+
7+
/// 参与中国用户判定的证据来源。
8+
enum UserRegionEvidence { storefront, deviceRegion, clientConfig }
9+
10+
/// 单个地区策略的执行状态。
11+
enum UserRegionSourceStatus { pending, available, skipped, failed }
12+
13+
/// 单个地区策略的最新结果。
14+
class UserRegionSourceResult {
15+
const UserRegionSourceResult({required this.status, this.countryCode});
16+
17+
const UserRegionSourceResult.pending()
18+
: status = UserRegionSourceStatus.pending,
19+
countryCode = null;
20+
21+
const UserRegionSourceResult.skipped()
22+
: status = UserRegionSourceStatus.skipped,
23+
countryCode = null;
24+
25+
const UserRegionSourceResult.failed()
26+
: status = UserRegionSourceStatus.failed,
27+
countryCode = null;
28+
29+
final UserRegionSourceStatus status;
30+
final String? countryCode;
31+
32+
/// 此来源是否明确表明用户属于中国。
33+
bool get isChina => _isChinaCountryCode(countryCode);
34+
35+
@override
36+
String toString() => '${status.name}:${countryCode ?? "unknown"}';
37+
}
38+
39+
/// 地区判定的进程内单一真相源。
40+
class UserRegionState {
41+
const UserRegionState({
42+
required this.isChinaUser,
43+
required this.storefront,
44+
required this.deviceRegion,
45+
required this.clientConfig,
46+
required this.matchedSources,
47+
required this.isRefreshing,
48+
this.lastRefreshedAt,
49+
});
50+
51+
/// 以所有已知证据生成一致的最终结论。
52+
factory UserRegionState.resolve({
53+
required UserRegionSourceResult storefront,
54+
required UserRegionSourceResult deviceRegion,
55+
required UserRegionSourceResult clientConfig,
56+
required bool isRefreshing,
57+
DateTime? lastRefreshedAt,
58+
}) {
59+
final sources = <UserRegionEvidence>[
60+
if (storefront.isChina) UserRegionEvidence.storefront,
61+
if (deviceRegion.isChina) UserRegionEvidence.deviceRegion,
62+
if (clientConfig.isChina) UserRegionEvidence.clientConfig,
63+
];
64+
return UserRegionState(
65+
isChinaUser: sources.isNotEmpty,
66+
storefront: storefront,
67+
deviceRegion: deviceRegion,
68+
clientConfig: clientConfig,
69+
matchedSources: List.unmodifiable(sources),
70+
isRefreshing: isRefreshing,
71+
lastRefreshedAt: lastRefreshedAt,
72+
);
73+
}
74+
75+
final bool isChinaUser;
76+
final UserRegionSourceResult storefront;
77+
final UserRegionSourceResult deviceRegion;
78+
final UserRegionSourceResult clientConfig;
79+
final List<UserRegionEvidence> matchedSources;
80+
final bool isRefreshing;
81+
final DateTime? lastRefreshedAt;
82+
83+
UserRegionState copyWith({
84+
UserRegionSourceResult? storefront,
85+
UserRegionSourceResult? deviceRegion,
86+
UserRegionSourceResult? clientConfig,
87+
bool? isRefreshing,
88+
DateTime? lastRefreshedAt,
89+
}) {
90+
return UserRegionState.resolve(
91+
storefront: storefront ?? this.storefront,
92+
deviceRegion: deviceRegion ?? this.deviceRegion,
93+
clientConfig: clientConfig ?? this.clientConfig,
94+
isRefreshing: isRefreshing ?? this.isRefreshing,
95+
lastRefreshedAt: lastRefreshedAt ?? this.lastRefreshedAt,
96+
);
97+
}
98+
}
99+
100+
/// 由应用生命周期传入的刷新原因,供诊断日志区分启动和回前台。
101+
enum UserRegionRefreshTrigger { startup, resume }
102+
103+
bool _isChinaCountryCode(String? countryCode) {
104+
final normalized = countryCode?.trim().toUpperCase();
105+
return normalized == 'CN' || normalized == 'CHN';
106+
}

0 commit comments

Comments
 (0)