diff --git a/lib/core/data/models/auth_session.dart b/lib/core/data/models/auth_session.dart index d3efa84..998fa49 100644 --- a/lib/core/data/models/auth_session.dart +++ b/lib/core/data/models/auth_session.dart @@ -1,3 +1,5 @@ +import 'json_numbers.dart'; + /// 登录态:服务端下发的令牌与用户摘要。 /// /// 只保留客户端真正要用的字段(uid / 昵称 / 头像),不做整份 AppUserRespVO 的映射 —— @@ -38,7 +40,8 @@ class AuthSession { factory AuthSession.fromJson(Map json) => AuthSession( accessToken: json['accessToken'] as String? ?? '', refreshToken: json['refreshToken'] as String? ?? '', - expiresIn: (json['expiresIn'] as num?)?.toInt() ?? 0, + // expiresIn 后端是 long,按字符串下发(见 json_numbers.dart) + expiresIn: jsonToInt(json['expiresIn']), user: AuthUser.fromJson((json['user'] as Map?)?.cast() ?? const {}), ); diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index a137f78..623e70d 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -1,3 +1,5 @@ +import 'json_numbers.dart'; + /// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。 /// /// 封面与播放地址都是后端返回的相对路径(`/files/...`), @@ -98,7 +100,8 @@ class Drama { genre: json['genre'] as String? ?? '', totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, - viewCount: (json['viewCount'] as num?)?.toInt() ?? 0, + // viewCount 后端是 Long,按字符串下发(见 json_numbers.dart) + viewCount: jsonToInt(json['viewCount']), ); } @@ -114,7 +117,7 @@ class Drama { totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0, freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, - viewCount: (json['viewCount'] as num?)?.toInt() ?? 0, + viewCount: jsonToInt(json['viewCount']), detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() diff --git a/lib/core/data/models/json_numbers.dart b/lib/core/data/models/json_numbers.dart new file mode 100644 index 0000000..6fd1084 --- /dev/null +++ b/lib/core/data/models/json_numbers.dart @@ -0,0 +1,13 @@ +// Author: fruit.com.cn + +/// 数值字段的兼容解析。 +/// +/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段**一律以字符串下发** +/// (雪花 id 与计数都算),`Integer`/`int` 仍是数字。所以这里既接受数字也接受数字字符串, +/// 否则一个 `"12345"` 就能让 `as num?` 直接抛异常、整页解析失败。 +int jsonToInt(dynamic value, {int fallback = 0}) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value.trim()) ?? fallback; + return fallback; +} diff --git a/test/core/json_id_parsing_test.dart b/test/core/json_id_parsing_test.dart new file mode 100644 index 0000000..2fbaa11 --- /dev/null +++ b/test/core/json_id_parsing_test.dart @@ -0,0 +1,103 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/json_numbers.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; + +/// 后端契约(FruitServer/docs/api-phase1.md §0):`Long`/`long` 字段以字符串下发。 +/// 这里钉住客户端在"收到字符串形式的 id / 计数"时照样能解析,不抛异常。 +void main() { + group('jsonToInt', () { + test('should_parse_int_when_value_is_number', () { + expect(jsonToInt(12345), 12345); + expect(jsonToInt(12.9), 12); + }); + + test('should_parse_int_when_value_is_numeric_string', () { + expect(jsonToInt('2111012753262120962'), 2111012753262120962); + expect(jsonToInt(' 42 '), 42); + }); + + test('should_fall_back_when_value_is_missing_or_invalid', () { + expect(jsonToInt(null), 0); + expect(jsonToInt(''), 0); + expect(jsonToInt('abc'), 0); + expect(jsonToInt(null, fallback: 7), 7); + }); + }); + + group('drama model', () { + test('should_parse_string_ids_when_backend_sends_ids_as_string', () { + final drama = Drama.fromCardJson({ + 'id': '2111012753262120962', + 'title': '闪婚后,傅先生每天都在撒糖', + 'totalEpisodes': 72, + 'viewCount': '3100000', + 'isHot': true, + }); + + expect(drama.id, '2111012753262120962'); + expect(drama.title, '闪婚后,傅先生每天都在撒糖'); + expect(drama.totalEpisodes, 72); + // viewCount 后端是 Long → 字符串 + expect(drama.viewCount, 3100000); + }); + + test('should_parse_view_count_when_backend_sends_number', () { + final drama = Drama.fromCardJson({'id': 5, 'title': 'T', 'viewCount': 1200}); + + expect(drama.id, '5'); + expect(drama.viewCount, 1200); + }); + + test('should_parse_detail_payload_when_ids_are_strings', () { + final drama = Drama.fromDetailJson({ + 'id': '2111012753262120962', + 'title': 'T', + 'totalEpisodes': 12, + 'freeEpisodes': 3, + 'viewCount': '99', + 'episodes': [ + {'id': '2111012753262120963', 'number': 1, 'title': 'E1', 'isFree': true}, + ], + }); + + expect(drama.id, '2111012753262120962'); + expect(drama.freeEpisodes, 3); + expect(drama.viewCount, 99); + expect(drama.episodes.first.id, '2111012753262120963'); + expect(drama.episodes.first.number, 1); + }); + }); + + group('auth session', () { + test('should_parse_expires_in_when_backend_sends_string', () { + final session = AuthSession.fromJson({ + 'accessToken': 'a', + 'refreshToken': 'r', + 'expiresIn': '7200', + 'user': {'uid': 'u-1', 'nickname': 'n'}, + }); + + expect(session.expiresIn, 7200); + expect(session.user.uid, 'u-1'); + }); + }); + + group('play ticket', () { + test('should_parse_episode_id_when_backend_sends_string', () { + final ticket = PlayTicket.fromJson({ + 'episodeId': '2111012753262120963', + 'accessible': false, + 'accessType': PlayTicket.coinRequired, + 'unlockCoin': 30, + }); + + expect(ticket.episodeId, '2111012753262120963'); + expect(ticket.unlockCoin, 30); + expect(ticket.needsUnlock, isTrue); + }); + }); +}