Skip to content

Commit 882fcad

Browse files
committed
FIX: 按系统 Region 统一服务端点
1 parent 73a660f commit 882fcad

13 files changed

Lines changed: 299 additions & 957 deletions

‎TASKS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
55
## 本次完成
66

7+
- [x] 修复并复核地区端点路由(2026-10-09):所有构建模式共用端点配置;启动日志确认中国 API 在首次请求前选中。定向测试 18 项通过;社区音频响应含全球 CDN URL,本次未见实际下载。**完成时间**: 2026-10-09 22:58
8+
- [x] 简化地区判定并统一服务路由(2026-10-09):中国区只按系统 Region 的 `CN` 判定;冷启动同步选区,resume 不切换;API/CDN 不跨区回退。生命周期与路由定向测试通过。**完成时间**: 2026-10-09 22:30
9+
- [x] 分析 Android 首装启动日志(2026-10-09):启动与首屏正常;发现中国区 Paddle 首次请求先返回 JP/USD,随后刷新为 CN/CNY。**完成时间**: 2026-10-09 21:17
710
- [x] 删除低价值 WAV 指标公式单测(2026-10-09):移除只用固定 250ms 样本验证 PCM 字节数和时长换算的断言;保留验证录音诊断能进入持久化应用日志的行为测试。未改生产代码。**完成时间**: 2026-10-09
811
- [x] 补充 Android 跟读录音取证日志(2026-10-09):记录 AudioRecord 初始化/启动状态、正帧数、0/负返回次数及最后非正返回值、停止时录音协程等待是否超时,并记录写入字节/时长、采集耗时和 WAV 裁剪前后文件大小/时长;通过现有持久化应用日志与日志支持包收集。只增加观测,不改变录音、裁剪、停止或识别行为,也不记录音频/识别文本;复核时增加诊断类型容错并在每轮会话清空旧统计。RecordingService 日志测试 3 项通过,4 个 Dart 文件定向分析无问题,Android `WavRecorderTrimTest` 通过,`git diff --check` 通过。未运行 `scripts/check.sh`:本次限于 Android 录音诊断日志与指标计算,定向验证覆盖改动范围。未在 vivo X100s Pro 上复现,根因仍待用户日志确认。**完成时间**: 2026-10-09 11:21
912
- [x] 修复 iOS Apple AI 合成音频无法播放(2026-10-08):平台 TTS 默认输出统一为 WAV,iOS 继续调用 `flutter_tts.synthesizeToFile`,macOS 保持现有原生合成路径;缓存命名空间升级至 v3,避免命中旧 CAF 缓存。新增默认 WAV 和旧缓存 key 失效回归测试。TTS 定向测试 28 项通过,4 个相关 Dart 文件定向分析通过,`git diff --check` 通过。未运行 `scripts/check.sh`:仅改动 TTS 格式和缓存兼容性,定向测试覆盖范围足够;未在 iPhone 上安装运行,真实设备播放需手动验证。**完成时间**: 2026-10-08 23:57

‎lib/features/remote_config/remote_config_service.dart‎

Lines changed: 5 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ class RemoteConfigService {
2121
required Dio dio,
2222
required RemoteConfigStore store,
2323
DateTime Function()? now,
24-
RuntimeEndpointRouter? endpointRouter,
2524
}) : _dio = dio,
2625
_store = store,
27-
_now = now ?? DateTime.now,
28-
_endpointRouter = endpointRouter;
26+
_now = now ?? DateTime.now;
2927

3028
RemoteConfigService.create({
3129
required SharedPreferences prefs,
@@ -40,13 +38,11 @@ class RemoteConfigService {
4038
apiLogTag: 'REMOTE-CONFIG',
4139
),
4240
store: RemoteConfigStore(prefs),
43-
endpointRouter: endpointRouter,
4441
);
4542

4643
final Dio _dio;
4744
final RemoteConfigStore _store;
4845
final DateTime Function() _now;
49-
final RuntimeEndpointRouter? _endpointRouter;
5046

5147
/// 最近一次成功获取远程配置的时间,用于统一刷新节流。
5248
DateTime? get lastFetchedAt => _store.readFetchedAt();
@@ -116,7 +112,10 @@ class RemoteConfigService {
116112
/// 运行期刷新使用本方法,避免 [load] 的缓存优先逻辑让过期检查后的刷新
117113
/// 再次命中本地缓存。调用方负责决定失败时是否保留旧内存配置。
118114
Future<RemoteConfig> fetchRemote() async {
119-
final response = await _fetchRemoteConfigResponse();
115+
final response = await _dio.get<Object?>(
116+
'/api/v1/client/config',
117+
queryParameters: {'platform': clientPlatformName()},
118+
);
120119
final config = RemoteConfig.fromRemoteJson(response.data);
121120
await _store.write(config, now: _now());
122121
AppLogger.log(
@@ -127,114 +126,4 @@ class RemoteConfigService {
127126
);
128127
return config;
129128
}
130-
131-
/// 请求当前首选区域的 Client Config;网络错误或 5xx 时最多尝试备用区域一次。
132-
///
133-
/// 4xx、取消请求或没有备用端点时保留原错误,成功响应会更新当前 API 区域。
134-
Future<Response<Object?>> _fetchRemoteConfigResponse() async {
135-
const path = '/api/v1/client/config';
136-
final router = _endpointRouter;
137-
if (router == null) return _getConfig(path);
138-
139-
final preferredRegion = router.preferredApiRegion;
140-
final preferredUri = router.apiUriForRegion(preferredRegion, path);
141-
if (preferredUri == null) {
142-
AppLogger.log(
143-
_logTag,
144-
'preferred config API is not configured; using Dio base URL',
145-
);
146-
return _getConfig(path);
147-
}
148-
149-
AppLogger.log(
150-
_logTag,
151-
'request client config preferred=${preferredRegion.name} '
152-
'endpoint=${_endpointLabel(preferredUri)}',
153-
);
154-
155-
try {
156-
final response = await _getConfig(preferredUri.toString());
157-
router.markApiRegionAvailable(preferredRegion);
158-
return response;
159-
} on DioException catch (preferredError) {
160-
if (!_shouldTryAlternate(preferredError)) {
161-
AppLogger.log(
162-
_logTag,
163-
'preferred config API fallback skipped '
164-
'type=${preferredError.type.name} '
165-
'status=${preferredError.response?.statusCode ?? "none"}',
166-
);
167-
rethrow;
168-
}
169-
170-
final alternateRegion = router.alternateApiRegion;
171-
if (alternateRegion == null) {
172-
AppLogger.log(
173-
_logTag,
174-
'config API fallback unavailable: no alternate endpoint configured',
175-
);
176-
rethrow;
177-
}
178-
final alternateUri = router.apiUriForRegion(alternateRegion, path);
179-
if (alternateUri == null) {
180-
AppLogger.log(
181-
_logTag,
182-
'config API fallback unavailable: '
183-
'alternate=${alternateRegion.name} endpoint is not configured',
184-
);
185-
rethrow;
186-
}
187-
188-
AppLogger.log(
189-
_logTag,
190-
'preferred config endpoint failed; trying alternate '
191-
'preferred=${preferredRegion.name}('
192-
'${_endpointLabel(preferredUri)}) '
193-
'alternate=${alternateRegion.name}('
194-
'${_endpointLabel(alternateUri)}) '
195-
'type=${preferredError.type.name} '
196-
'status=${preferredError.response?.statusCode ?? "none"}',
197-
);
198-
try {
199-
final response = await _getConfig(alternateUri.toString());
200-
router.markApiRegionAvailable(alternateRegion);
201-
return response;
202-
} catch (alternateError, stackTrace) {
203-
final errorType = alternateError is DioException
204-
? alternateError.type.name
205-
: alternateError.runtimeType.toString();
206-
final statusCode = alternateError is DioException
207-
? alternateError.response?.statusCode
208-
: null;
209-
AppLogger.log(
210-
_logTag,
211-
'alternate config endpoint failed '
212-
'region=${alternateRegion.name} '
213-
'endpoint=${_endpointLabel(alternateUri)} '
214-
'type=$errorType status=${statusCode ?? "none"}',
215-
);
216-
Error.throwWithStackTrace(alternateError, stackTrace);
217-
}
218-
}
219-
}
220-
221-
Future<Response<Object?>> _getConfig(String pathOrUri) => _dio.get<Object?>(
222-
pathOrUri,
223-
queryParameters: {'platform': clientPlatformName()},
224-
);
225-
226-
bool _shouldTryAlternate(DioException error) {
227-
if (error.type == DioExceptionType.cancel) return false;
228-
final statusCode = error.response?.statusCode;
229-
return statusCode == null || statusCode >= 500;
230-
}
231-
232-
String _endpointLabel(Uri uri) {
233-
return Uri(
234-
scheme: uri.scheme,
235-
host: uri.host,
236-
port: uri.hasPort ? uri.port : null,
237-
path: uri.path,
238-
).toString();
239-
}
240129
}
Lines changed: 4 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,7 @@
1-
/// 当前用户的地区判定状态。
2-
///
3-
/// 此状态只缓存于当前 App 进程:冷启动和回到前台时会重新确认,避免把商店区、
4-
/// 系统地区或服务端推断出的旧结果长期写入本地偏好。
1+
/// 基于系统 Region 判断用户是否使用中国区服务。
52
library;
63

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';
4+
/// 系统 Region 国家码为 CN 时使用中国区服务,其余情况使用全球服务。
5+
bool isChinaSystemRegion(String? countryCode) {
6+
return countryCode?.trim().toUpperCase() == 'CN';
1067
}

0 commit comments

Comments
 (0)