From 1be8ebf1f94d1905d69d0da0513d86113fa7562c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 29 Oct 2026 10:44:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api):=20=E7=BB=9F=E4=B8=80=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E8=A7=A3=E6=9E=90=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E9=94=99=E8=AF=AF=E6=96=87=E6=A1=88=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各 *Api 类此前各自实现 data 结构判断,download_api 在结构异常时抛出空 message,界面会显示空白提示。抽出 ResponseDecoders 提供对象、可空对象、 对象数组三种语义,七个文件改为复用;下载侧异常文案与其他接口对齐。 --- lib/core/data/remote/account_api.dart | 12 +-- lib/core/data/remote/catalog_api.dart | 30 ++++--- lib/core/data/remote/category_api.dart | 9 +-- lib/core/data/remote/commerce_api.dart | 20 ++--- lib/core/data/remote/download_api.dart | 24 +++--- lib/core/data/remote/playback_api.dart | 7 +- lib/core/data/remote/response_decoders.dart | 49 ++++++++++++ lib/core/data/remote/watch_api.dart | 17 ++-- test/core/response_decoders_test.dart | 87 +++++++++++++++++++++ 9 files changed, 188 insertions(+), 67 deletions(-) create mode 100644 lib/core/data/remote/response_decoders.dart create mode 100644 test/core/response_decoders_test.dart diff --git a/lib/core/data/remote/account_api.dart b/lib/core/data/remote/account_api.dart index 44c78bf..32d92f0 100644 --- a/lib/core/data/remote/account_api.dart +++ b/lib/core/data/remote/account_api.dart @@ -4,6 +4,7 @@ import '../models/account_device.dart'; import '../models/account_export.dart'; import '../models/account_profile.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 个人中心接口:`/app/me*`。 class AccountApi { @@ -23,11 +24,9 @@ class AccountApi { Future> devices() async { final data = await _client.get('/app/me/devices'); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) AccountDevice.fromJson(item.cast()), - ]; + return ResponseDecoders.asMapList( + data, + ).map(AccountDevice.fromJson).toList(); } Future export() async { @@ -47,6 +46,7 @@ class AccountApi { await _client.post('/app/me/deactivate/cancel'); } + /// 个人资料允许缺失:后端未返回对象时按空对象解析,页面用默认值兜底。 Map _map(Object? data) => - data is Map ? data.cast() : const {}; + ResponseDecoders.tryMap(data) ?? const {}; } diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index c57bcc1..f11bfeb 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -6,6 +6,7 @@ import '../models/page_result.dart'; import '../models/playlist_detail.dart'; import '../models/search_result.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 客户端目录接口,对应后端 `/app/**`。 class CatalogApi { @@ -14,11 +15,15 @@ class CatalogApi { final ApiClient _client; Future homeFeed() async { - return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed'))); + return HomeFeed.fromJson( + ResponseDecoders.asMap(await _client.get('/app/home/feed')), + ); } Future discoverFeed() async { - return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); + return HomeFeed.fromJson( + ResponseDecoders.asMap(await _client.get('/app/discover/feed')), + ); } /// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。 @@ -29,7 +34,7 @@ class CatalogApi { int size = 10, }) async { return FeedPage.fromJson( - _asMap( + ResponseDecoders.asMap( await _client.get( '/app/feed', query: { @@ -58,7 +63,7 @@ class CatalogApi { 'pageSize': '$pageSize', }, ); - return SearchResult.fromJson(_asMap(data)); + return SearchResult.fromJson(ResponseDecoders.asMap(data)); } static const int playlistPageSize = 10; @@ -86,7 +91,7 @@ class CatalogApi { int pageSize = playlistDramaPageSize, }) async { return PlaylistDetail.fromJson( - _asMap( + ResponseDecoders.asMap( await _client.get( '/app/playlists/$id', query: { @@ -138,20 +143,13 @@ class CatalogApi { Future dramaDetail(String dramaId) async { return Drama.fromDetailJson( - _asMap(await _client.get('/app/dramas/$dramaId')), + ResponseDecoders.asMap(await _client.get('/app/dramas/$dramaId')), ); } List _categories(Object? data) { - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) ContentCategory.fromJson(item), - ]; - } - - Map _asMap(Object? data) { - if (data is Map) return data; - throw const ApiException(-1, '返回数据格式异常'); + return ResponseDecoders.asMapList( + data, + ).map(ContentCategory.fromJson).toList(); } } diff --git a/lib/core/data/remote/category_api.dart b/lib/core/data/remote/category_api.dart index 45c4f84..0c4c46c 100644 --- a/lib/core/data/remote/category_api.dart +++ b/lib/core/data/remote/category_api.dart @@ -4,6 +4,7 @@ import '../models/content_category.dart'; import '../models/drama.dart'; import '../models/page_result.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 内容分类接口,对应契约 `GET /app/categories`。 class CategoryApi { @@ -16,11 +17,9 @@ class CategoryApi { /// 拉取已启用分类,id 全程按字符串解析。 Future> list() async { final data = await _client.get('/app/categories'); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) ContentCategory.fromJson(item), - ]; + return ResponseDecoders.asMapList( + data, + ).map(ContentCategory.fromJson).toList(); } /// 分类下已上线短剧,pageIndex 从 1 开始。 diff --git a/lib/core/data/remote/commerce_api.dart b/lib/core/data/remote/commerce_api.dart index 5728a69..05a6cc5 100644 --- a/lib/core/data/remote/commerce_api.dart +++ b/lib/core/data/remote/commerce_api.dart @@ -4,6 +4,7 @@ import '../models/order.dart'; import '../models/product.dart'; import '../models/wallet.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 金币与解锁接口,对应契约 §1.8。 class CommerceApi { @@ -13,8 +14,7 @@ class CommerceApi { Future wallet() async { final data = await _client.get('/app/wallet'); - if (data is Map) return WalletSnapshot.fromJson(data); - throw const ApiException(-1, '返回数据格式异常'); + return WalletSnapshot.fromJson(ResponseDecoders.asMap(data)); } /// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。 @@ -23,13 +23,9 @@ class CommerceApi { '/app/products', query: type == null ? null : {'type': type}, ); - if (data is List) { - return data - .whereType>() - .map(AppProduct.fromJson) - .toList(); - } - throw const ApiException(-1, '返回数据格式异常'); + return ResponseDecoders.requiredMapList( + data, + ).map(AppProduct.fromJson).toList(); } /// 创建充值订单,返回 `CREATED` 状态订单。 @@ -38,13 +34,11 @@ class CommerceApi { '/app/orders', body: {'productId': productId}, ); - if (data is Map) return AppOrder.fromJson(data); - throw const ApiException(-1, '返回数据格式异常'); + return AppOrder.fromJson(ResponseDecoders.asMap(data)); } Future unlock(String episodeId) async { final data = await _client.post('/app/episodes/$episodeId/unlock'); - if (data is Map) return UnlockResult.fromJson(data); - throw const ApiException(-1, '返回数据格式异常'); + return UnlockResult.fromJson(ResponseDecoders.asMap(data)); } } diff --git a/lib/core/data/remote/download_api.dart b/lib/core/data/remote/download_api.dart index ae8e744..d63a9ef 100644 --- a/lib/core/data/remote/download_api.dart +++ b/lib/core/data/remote/download_api.dart @@ -3,6 +3,7 @@ import '../models/download_license.dart'; import '../models/download_policy.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。 /// @@ -15,7 +16,7 @@ class DownloadApi { /// 拉取配额和偏好策略。 Future policy() async { return DownloadPolicy.fromJson( - _asMap(await _client.get('/app/downloads/policy')), + ResponseDecoders.asMap(await _client.get('/app/downloads/policy')), ); } @@ -30,7 +31,7 @@ class DownloadApi { /// 申请下载授权;同集重复申请由服务端保证幂等。 Future grant(String episodeId, {String? quality}) async { return DownloadLicense.fromJson( - _asMap( + ResponseDecoders.asMap( await _client.post( '/app/downloads', body: {'episodeId': episodeId, 'quality': ?quality}, @@ -42,17 +43,17 @@ class DownloadApi { /// 拉取当前设备的授权列表。 Future> list() async { final data = await _client.get('/app/downloads'); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) DownloadLicense.fromJson(item), - ]; + return ResponseDecoders.asMapList( + data, + ).map(DownloadLicense.fromJson).toList(); } /// 续传前刷新短时效下载地址。 Future refreshUrl(String episodeId) async { return DownloadLicense.fromJson( - _asMap(await _client.get('/app/downloads/$episodeId/url')), + ResponseDecoders.asMap( + await _client.get('/app/downloads/$episodeId/url'), + ), ); } @@ -67,7 +68,7 @@ class DownloadApi { String? errorMsg, }) async { return DownloadLicense.fromJson( - _asMap( + ResponseDecoders.asMap( await _client.post( '/app/downloads/$episodeId/progress', body: { @@ -93,9 +94,4 @@ class DownloadApi { final data = await _client.delete('/app/downloads'); return data is int ? data : 0; } - - Map _asMap(Object? data) { - if (data is Map) return data; - throw const ApiException(-1, ''); - } } diff --git a/lib/core/data/remote/playback_api.dart b/lib/core/data/remote/playback_api.dart index 6b40fb7..8eafbc1 100644 --- a/lib/core/data/remote/playback_api.dart +++ b/lib/core/data/remote/playback_api.dart @@ -3,6 +3,7 @@ import '../models/episode_stats.dart'; import '../models/play_ticket.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。 /// @@ -15,14 +16,12 @@ class PlaybackApi { Future ticket(String episodeId) async { final data = await _client.get('/app/episodes/$episodeId/play'); - if (data is Map) return PlayTicket.fromJson(data); - throw const ApiException(-1, '返回数据格式异常'); + return PlayTicket.fromJson(ResponseDecoders.asMap(data)); } /// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。 Future episodeStats(String episodeId) async { final data = await _client.get('/app/episodes/$episodeId/stats'); - if (data is Map) return EpisodeStats.fromJson(data); - throw const ApiException(-1, '返回数据格式异常'); + return EpisodeStats.fromJson(ResponseDecoders.asMap(data)); } } diff --git a/lib/core/data/remote/response_decoders.dart b/lib/core/data/remote/response_decoders.dart new file mode 100644 index 0000000..ee42c50 --- /dev/null +++ b/lib/core/data/remote/response_decoders.dart @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +import 'api_exception.dart'; + +/// 接口响应 `data` 字段的统一解析。 +/// +/// 此前每个 `*_api.dart` 各写一份 `is Map` 判断与错误文案,还出现过抛空 +/// message 导致界面显示空白的情况;统一收口到这里,语义只有三种: +/// 必须为对象、可为空对象、对象数组。 +abstract final class ResponseDecoders { + /// 业务失败码:HTTP 成功但 `data` 结构不符合约定。 + static const int malformedCode = -1; + + /// `data` 结构异常时的提示,可直接展示给用户。 + static const String malformedMessage = '返回数据格式异常'; + + /// 要求 `data` 是对象,否则抛 [ApiException]。 + static Map asMap(Object? data) { + final map = tryMap(data); + if (map == null) { + throw const ApiException(malformedCode, malformedMessage); + } + return map; + } + + /// 允许缺失:`data` 为空或不是对象时返回 null。 + static Map? tryMap(Object? data) { + if (data is Map) return data; + if (data is Map) return data.cast(); + return null; + } + + /// 对象数组;不是数组时按空列表处理,避免列表页因结构异常白屏。 + static List> asMapList(Object? data) { + if (data is! List) return const []; + return data + .whereType() + .map((item) => item.cast()) + .toList(); + } + + /// 对象数组;不是数组时抛 [ApiException],用于不能静默为空的数据(如商品)。 + static List> requiredMapList(Object? data) { + if (data is! List) { + throw const ApiException(malformedCode, malformedMessage); + } + return asMapList(data); + } +} diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index 74d39fa..d528c37 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -3,6 +3,7 @@ import '../models/page_result.dart'; import '../models/watch_progress.dart'; import 'api_client.dart'; +import 'response_decoders.dart'; /// 观看进度接口:`/app/watch/*`(契约 §1.5)。支持账号或设备身份。 class WatchApi { @@ -28,7 +29,8 @@ class WatchApi { 'source': source, }, ); - return data is Map ? WatchProgress.fromJson(data) : null; + final map = ResponseDecoders.tryMap(data); + return map == null ? null : WatchProgress.fromJson(map); } /// 某部剧的续看位置;没有记录时后端返回空 data。 @@ -37,7 +39,8 @@ class WatchApi { '/app/watch/progress', query: {'dramaId': dramaId}, ); - return data is Map ? WatchProgress.fromJson(data) : null; + final map = ResponseDecoders.tryMap(data); + return map == null ? null : WatchProgress.fromJson(map); } /// 最近观看分页:`pageIndex`/`pageSize`,解包 `PageResult`。 @@ -49,13 +52,9 @@ class WatchApi { '/app/watch/recent', query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); - if (data is! Map) { - return const PageResult(); - } - return PageResult.fromJson( - Map.from(data), - WatchProgress.fromJson, - ); + final map = ResponseDecoders.tryMap(data); + if (map == null) return const PageResult(); + return PageResult.fromJson(map, WatchProgress.fromJson); } /// 删除某部剧的观看记录(记录页清单条用)。 diff --git a/test/core/response_decoders_test.dart b/test/core/response_decoders_test.dart new file mode 100644 index 0000000..d7d442e --- /dev/null +++ b/test/core/response_decoders_test.dart @@ -0,0 +1,87 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/download_api.dart'; +import 'package:fruitshorts/core/data/remote/response_decoders.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + group('ResponseDecoders', () { + test('should_return_same_map_when_data_is_object', () { + const source = {'id': '1'}; + expect(ResponseDecoders.asMap(source), same(source)); + }); + + test('should_throw_with_readable_message_when_data_is_not_object', () { + expect( + () => ResponseDecoders.asMap(const []), + throwsA( + isA() + .having((e) => e.code, 'code', ResponseDecoders.malformedCode) + .having( + (e) => e.message, + 'message', + ResponseDecoders.malformedMessage, + ), + ), + ); + expect(ResponseDecoders.malformedMessage, isNotEmpty); + }); + + test('should_return_null_when_try_map_receives_non_object', () { + expect(ResponseDecoders.tryMap(null), isNull); + expect(ResponseDecoders.tryMap('text'), isNull); + expect(ResponseDecoders.tryMap({'a': 1})?['a'], 1); + }); + + test('should_skip_non_object_items_when_decoding_object_list', () { + final decoded = ResponseDecoders.asMapList([ + {'id': '1'}, + 'bad', + {'id': '2'}, + ]); + expect(decoded.map((item) => item['id']), ['1', '2']); + }); + + test('should_return_empty_list_when_data_is_not_list', () { + expect(ResponseDecoders.asMapList(const {}), isEmpty); + expect(ResponseDecoders.asMapList(null), isEmpty); + }); + + test('should_throw_when_required_list_receives_non_list', () { + expect( + () => ResponseDecoders.requiredMapList(const {}), + throwsA(isA()), + ); + }); + }); + + test( + 'should_raise_readable_error_when_download_policy_data_is_malformed', + () async { + final adapter = FakeHttpAdapter( + (options) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final client = ApiClient(adapter: adapter); + final api = DownloadApi(client); + + await expectLater( + api.policy(), + throwsA( + isA().having( + (error) => error.message, + 'message', + ResponseDecoders.malformedMessage, + ), + ), + ); + client.close(); + }, + ); +}