refactor(api): 统一响应解析并修复下载错误文案空白

各 *Api 类此前各自实现 data 结构判断,download_api 在结构异常时抛出空
message,界面会显示空白提示。抽出 ResponseDecoders 提供对象、可空对象、
对象数组三种语义,七个文件改为复用;下载侧异常文案与其他接口对齐。
This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-29 10:44:32 +08:00
1 parent 70be941755
commit 1be8ebf1f9
9 files changed
+188 -67

No files matched your search

+6 -6
View File
@@ -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<List<AccountDevice>> 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<String, dynamic>()),
];
return ResponseDecoders.asMapList(
data,
).map(AccountDevice.fromJson).toList();
}
Future<AccountExport> export() async {
@@ -47,6 +46,7 @@ class AccountApi {
await _client.post('/app/me/deactivate/cancel');
}
/// 个人资料允许缺失:后端未返回对象时按空对象解析,页面用默认值兜底。
Map<String, dynamic> _map(Object? data) =>
data is Map ? data.cast<String, dynamic>() : const {};
ResponseDecoders.tryMap(data) ?? const {};
}
+14 -16
View File
@@ -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> homeFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
return HomeFeed.fromJson(
ResponseDecoders.asMap(await _client.get('/app/home/feed')),
);
}
Future<HomeFeed> 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<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')),
ResponseDecoders.asMap(await _client.get('/app/dramas/$dramaId')),
);
}
List<ContentCategory> _categories(Object? data) {
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
return ResponseDecoders.asMapList(
data,
).map(ContentCategory.fromJson).toList();
}
}
+4 -5
View File
@@ -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<ContentCategory>> 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<String, dynamic>) ContentCategory.fromJson(item),
];
return ResponseDecoders.asMapList(
data,
).map(ContentCategory.fromJson).toList();
}
/// 分类下已上线短剧,pageIndex 从 1 开始。
+7 -13
View File
@@ -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<WalletSnapshot> wallet() async {
final data = await _client.get('/app/wallet');
if (data is Map<String, dynamic>) 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<String, dynamic>>()
.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<String, dynamic>) return AppOrder.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
return AppOrder.fromJson(ResponseDecoders.asMap(data));
}
Future<UnlockResult> unlock(String episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock');
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
return UnlockResult.fromJson(ResponseDecoders.asMap(data));
}
}
+10 -14
View File
@@ -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<DownloadPolicy> 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<DownloadLicense> 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<DownloadLicense>> 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<String, dynamic>) DownloadLicense.fromJson(item),
];
return ResponseDecoders.asMapList(
data,
).map(DownloadLicense.fromJson).toList();
}
/// 续传前刷新短时效下载地址。
Future<DownloadLicense> 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<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '');
}
}
+3 -4
View File
@@ -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<PlayTicket> ticket(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play');
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
return PlayTicket.fromJson(ResponseDecoders.asMap(data));
}
/// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。
Future<EpisodeStats> episodeStats(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/stats');
if (data is Map<String, dynamic>) return EpisodeStats.fromJson(data);
throw const ApiException(-1, '返回数据格式异常');
return EpisodeStats.fromJson(ResponseDecoders.asMap(data));
}
}
@@ -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<String, dynamic> asMap(Object? data) {
final map = tryMap(data);
if (map == null) {
throw const ApiException(malformedCode, malformedMessage);
}
return map;
}
/// 允许缺失:`data` 为空或不是对象时返回 null。
static Map<String, dynamic>? tryMap(Object? data) {
if (data is Map<String, dynamic>) return data;
if (data is Map) return data.cast<String, dynamic>();
return null;
}
/// 对象数组;不是数组时按空列表处理,避免列表页因结构异常白屏。
static List<Map<String, dynamic>> asMapList(Object? data) {
if (data is! List) return const [];
return data
.whereType<Map>()
.map((item) => item.cast<String, dynamic>())
.toList();
}
/// 对象数组;不是数组时抛 [ApiException],用于不能静默为空的数据(如商品)。
static List<Map<String, dynamic>> requiredMapList(Object? data) {
if (data is! List) {
throw const ApiException(malformedCode, malformedMessage);
}
return asMapList(data);
}
}
+8 -9
View File
@@ -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<String, dynamic> ? 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<String, dynamic> ? 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<String, dynamic>.from(data),
WatchProgress.fromJson,
);
final map = ResponseDecoders.tryMap(data);
if (map == null) return const PageResult();
return PageResult.fromJson(map, WatchProgress.fromJson);
}
/// 删除某部剧的观看记录(记录页清单条用)。
+87
View File
@@ -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<ApiException>()
.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<ApiException>()),
);
});
});
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<ApiException>().having(
(error) => error.message,
'message',
ResponseDecoders.malformedMessage,
),
),
);
client.close();
},
);
}