From 6333161b07076801df5177ab353b7021ce5f8e05 Mon Sep 17 00:00:00 2001 From: sokouu87 Date: Fri, 21 Aug 2026 18:14:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=80=82=E9=85=8D=20MoviePilot=20v3=20?= =?UTF-8?q?=E7=9A=84=E7=BB=9F=E4=B8=80=E5=93=8D=E5=BA=94=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 用 ResponseAPIRouter 把几乎所有 JSON 响应统一包装成 {"success": ..., "message": ..., "data": <原始响应>},共有 266 个端点 从 v2 的裸响应变成了 envelope。App 没有对应的解包层,导致: - 仪表盘所有数值显示 0:dashboard/* 在 v2 返回裸 float / 裸 List / 裸对象, v3 变成 envelope,App 直接把 Map 当数值解析,全部回落默认值。 - 媒体详情页仍然 422:版本探测请求 /api/v1/media/source 的判据是 「200 且响应是 List」,而该端点自身也被包成了 envelope,判据永不成立, 探测结果回落 v2,于是继续按 v2 形态请求详情。 改动: - ApiClient 增加唯一一处 v3 envelope 解包拦截器。data 为 null 时保持原 envelope(操作型接口依赖 success 判断成败);data 为 Map 时合并原始字段 并按需补 success / message / data(业务同名字段优先,不被覆盖); data 为 List 或标量时直接解包。仅在探测到 v3 时介入,v2 全程不解包。 - 版本探测请求跳过解包并放宽判据,避免自我递归与误判。 - 修正三处「v3 返回 List 而 App 读 success」的调用点,使其兼容两种形态。 联机验证(真实 v3 服务端):媒体详情、季列表、订阅状态与订阅列表、 仪表盘 CPU / 网络 / 统计、推荐项详情 path 构造共 10 项断言全部通过; 旧形态请求作为对照仍返回 422。 Co-Authored-By: Claude Opus 5 --- .../agent/repositories/agent_repository.dart | 13 +++- .../controllers/dashboard_controller.dart | 17 +++-- .../controllers/site_detail_controller.dart | 27 +++++--- lib/services/api_client.dart | 62 ++++++++++++++++++- lib/services/server_api_version_service.dart | 11 +++- 5 files changed, 110 insertions(+), 20 deletions(-) diff --git a/lib/modules/agent/repositories/agent_repository.dart b/lib/modules/agent/repositories/agent_repository.dart index 6668e77..78e5e1d 100644 --- a/lib/modules/agent/repositories/agent_repository.dart +++ b/lib/modules/agent/repositories/agent_repository.dart @@ -29,13 +29,20 @@ class AgentRepository implements AgentRepositoryContract { int page = 1, int count = 30, }) async { - final response = await _apiClient.get>( + final response = await _apiClient.get( '/api/v1/message/agent/sessions', queryParameters: {'page': page, 'count': count}, ); final data = response.data; - if (data == null || data['success'] != true) { - throw StateError(data?['message']?.toString() ?? '会话列表加载失败'); + if (data is List) { + return data + .whereType() + .map((item) => AgentSession.fromJson(Map.from(item))) + .toList(growable: false); + } + if (data is! Map || data['success'] != true) { + final message = data is Map ? data['message']?.toString() : null; + throw StateError(message ?? '会话列表加载失败'); } final list = data['data']; if (list is! List) return const []; diff --git a/lib/modules/dashboard/controllers/dashboard_controller.dart b/lib/modules/dashboard/controllers/dashboard_controller.dart index 131bf91..6ebeee3 100644 --- a/lib/modules/dashboard/controllers/dashboard_controller.dart +++ b/lib/modules/dashboard/controllers/dashboard_controller.dart @@ -422,14 +422,19 @@ class DashboardController extends GetxController { Future loadMemoryData() async { try { talker.info('开始加载内存数据'); - final response = await apiClient.get>( - '/api/v1/dashboard/memory', - ); + final response = await apiClient.get('/api/v1/dashboard/memory'); if (response.statusCode == 200) { final data = response.data!; - if (data.length >= 2) { - final memoryUsed = data[0] as int; - final memoryUsage = data[1] as int; + int? memoryUsed; + int? memoryUsage; + if (data is Map) { + memoryUsed = (data['used'] as num?)?.toInt(); + memoryUsage = (data['usage'] as num?)?.round(); + } else if (data is List && data.length >= 2) { + memoryUsed = (data[0] as num?)?.toInt(); + memoryUsage = (data[1] as num?)?.round(); + } + if (memoryUsed != null && memoryUsage != null) { memoryData.value = [memoryUsed, memoryUsage]; _appendMemoryChartData(memoryUsage.toDouble()); talker.info('内存数据加载成功: 使用 $memoryUsed 字节, 使用率 $memoryUsage%'); diff --git a/lib/modules/site/controllers/site_detail_controller.dart b/lib/modules/site/controllers/site_detail_controller.dart index 5f573e7..f30ff33 100644 --- a/lib/modules/site/controllers/site_detail_controller.dart +++ b/lib/modules/site/controllers/site_detail_controller.dart @@ -135,17 +135,28 @@ class SiteDetailController extends GetxController { return; } final raw = response.data; - if (raw is! Map) { - historyItems.clear(); - return; - } - final res = SiteUserDataHistoryResponse.fromJson(raw); - if (!res.success) { - errorText.value = res.message ?? '请求失败'; + final List list; + if (raw is List) { + list = raw + .whereType() + .map( + (item) => SiteUserDataHistoryItem.fromJson( + Map.from(item), + ), + ) + .toList(); + } else if (raw is Map) { + final res = SiteUserDataHistoryResponse.fromJson(raw); + if (!res.success) { + errorText.value = res.message ?? '请求失败'; + historyItems.clear(); + return; + } + list = res.data.toList(); + } else { historyItems.clear(); return; } - final list = res.data.toList(); list.sort((a, b) { final da = a.updatedDay; final db = b.updatedDay; diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 44c30b2..94a95dd 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -8,6 +8,7 @@ import 'package:moviepilot_mobile/applog/app_log.dart'; import 'package:talker/talker.dart'; import 'package:talker_dio_logger/talker_dio_logger.dart'; import 'package:moviepilot_mobile/services/app_service.dart'; +import 'package:moviepilot_mobile/services/server_api_version_service.dart'; import 'package:moviepilot_mobile/utils/dio_adapter_config_stub.dart' if (dart.library.io) 'package:moviepilot_mobile/utils/dio_adapter_config_io.dart' if (dart.library.js_interop) 'package:moviepilot_mobile/utils/dio_adapter_config_web.dart'; @@ -41,6 +42,8 @@ class ApiHttpException implements Exception { } class ApiClient extends g.GetxController { + static const _skipV3EnvelopeUnwrapKey = 'skipV3EnvelopeUnwrap'; + final _appService = g.Get.find(); final _iosSharedSessionService = g.Get.find(); final _hiveService = g.Get.find(); @@ -191,6 +194,59 @@ class ApiClient extends g.GetxController { ), ); } + _dio.interceptors.add( + InterceptorsWrapper( + onResponse: (response, handler) async { + if (response.requestOptions.extra[_skipV3EnvelopeUnwrapKey] == true || + response.requestOptions.responseType != ResponseType.json) { + handler.next(response); + return; + } + + final envelope = response.data; + if (envelope is! Map || + envelope['success'] is! bool || + !envelope.containsKey('data')) { + handler.next(response); + return; + } + + if (!g.Get.isRegistered()) { + handler.next(response); + return; + } + + try { + final isV3 = await g.Get.find().isV3(); + if (!isV3) { + handler.next(response); + return; + } + } catch (_) { + handler.next(response); + return; + } + + final data = envelope['data']; + if (data == null) { + handler.next(response); + return; + } + if (data is Map) { + // 业务字段优先:仅在原始数据没有同名键时才补 envelope 的字段, + // 避免把接口本身返回的 success / message / data 覆盖掉。 + final merged = Map.from(data); + merged.putIfAbsent('success', () => envelope['success']); + merged.putIfAbsent('message', () => envelope['message']); + merged.putIfAbsent('data', () => data); + response.data = merged; + } else { + response.data = data; + } + handler.next(response); + }, + ), + ); } Future _ensureReady() => _ready; @@ -531,6 +587,7 @@ class ApiClient extends g.GetxController { int? timeout, Map? headers, bool skipUnauthorizedHandling = false, + bool skipV3EnvelopeUnwrap = false, }) async { await _ensureReady(); final authToken = token ?? this.token; @@ -546,7 +603,10 @@ class ApiClient extends g.GetxController { // 允许所有状态码,让调用者自己处理错误 return true; }, - extra: {if (skipUnauthorizedHandling) 'skipUnauthorizedHandling': true}, + extra: { + if (skipUnauthorizedHandling) 'skipUnauthorizedHandling': true, + if (skipV3EnvelopeUnwrap) _skipV3EnvelopeUnwrapKey: true, + }, ); final response = await _dio.get( path, diff --git a/lib/services/server_api_version_service.dart b/lib/services/server_api_version_service.dart index cce6f22..116e5ce 100644 --- a/lib/services/server_api_version_service.dart +++ b/lib/services/server_api_version_service.dart @@ -23,8 +23,15 @@ class ServerApiVersionService extends GetxService { Future _detect(String baseUrl, int generation) async { try { - final response = await _apiClient.get('/api/v1/media/source'); - final result = response.statusCode == 200 && response.data is List; + final response = await _apiClient.get( + '/api/v1/media/source', + skipV3EnvelopeUnwrap: true, + ); + final data = response.data; + final hasSourceList = + data is List || + (data is Map && data.containsKey('data') && data['data'] is List); + final result = response.statusCode == 200 && hasSourceList; if (generation == _generation) { _cache[baseUrl] = result; }