refactor(api): 统一响应解析并修复下载错误文案空白
各 *Api 类此前各自实现 data 结构判断,download_api 在结构异常时抛出空 message,界面会显示空白提示。抽出 ResponseDecoders 提供对象、可空对象、 对象数组三种语义,七个文件改为复用;下载侧异常文案与其他接口对齐。
This commit is contained in:
1 parent
70be941755
commit
1be8ebf1f9
9 files changed
+188
-67
No files matched your search
@@ -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 {};
|
||||
}
|
||||
@@ -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,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 开始。
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
|
||||
Reference in new issue
Block a user