diff --git a/lib/core/data/models/account_device.dart b/lib/core/data/models/account_device.dart new file mode 100644 index 0000000..35c64d5 --- /dev/null +++ b/lib/core/data/models/account_device.dart @@ -0,0 +1,40 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 登录设备列表项;只保留页面展示需要的字段。 +class AccountDevice { + const AccountDevice({ + required this.id, + required this.deviceId, + required this.platform, + required this.deviceModel, + required this.status, + this.firstSeenAt, + this.lastSeenAt, + }); + + final String id; + final String deviceId; + final String platform; + final String deviceModel; + final String status; + final DateTime? firstSeenAt; + final DateTime? lastSeenAt; + + /// 兼容 Long 字符串与数字;userId/deleted 不解析、不展示。 + factory AccountDevice.fromJson(Map json) { + return AccountDevice( + id: jsonToId(json['id']), + deviceId: json['deviceId']?.toString() ?? '', + platform: json['platform']?.toString() ?? '', + deviceModel: json['deviceModel']?.toString() ?? '', + status: json['status']?.toString() ?? '', + firstSeenAt: _date(json['firstSeenAt']), + lastSeenAt: _date(json['lastSeenAt']), + ); + } + + static DateTime? _date(dynamic value) => + DateTime.tryParse(value?.toString() ?? ''); +} diff --git a/lib/core/data/models/account_export.dart b/lib/core/data/models/account_export.dart new file mode 100644 index 0000000..8bf0298 --- /dev/null +++ b/lib/core/data/models/account_export.dart @@ -0,0 +1,74 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'account_device.dart'; +import 'account_profile.dart'; + +/// 导出中的登录凭据摘要。 +class AccountCredential { + const AccountCredential({ + required this.type, + required this.masked, + required this.verified, + }); + + final String type; + final String masked; + final bool verified; + + factory AccountCredential.fromJson(Map json) { + return AccountCredential( + type: json['type']?.toString() ?? '', + masked: json['masked']?.toString() ?? '', + verified: json['verified'] == true, + ); + } +} + +/// 账号导出数据;`rawJson` 用于复制后端下发的完整内容。 +class AccountExport { + const AccountExport({ + required this.profile, + required this.credentials, + required this.consentCount, + required this.devices, + required this.raw, + }); + + final AccountProfile profile; + final List credentials; + final int consentCount; + final List devices; + final Map raw; + + int get deviceCount => devices.length; + String get rawJson => const JsonEncoder.withIndent(' ').convert(raw); + + factory AccountExport.fromJson(Map json) { + final profile = _map(json['profile']); + final credentials = _list(json['credentials']); + final consents = _list(json['consents']); + final devices = _list(json['devices']); + return AccountExport( + profile: AccountProfile.fromJson(profile), + credentials: [ + for (final item in credentials) AccountCredential.fromJson(item), + ], + consentCount: consents.length, + devices: [for (final item in devices) AccountDevice.fromJson(item)], + raw: json, + ); + } + + static Map _map(dynamic value) => + value is Map ? value.cast() : const {}; + + static List> _list(dynamic value) { + if (value is! List) return const []; + return [ + for (final item in value) + if (item is Map) item.cast(), + ]; + } +} diff --git a/lib/core/data/models/account_profile.dart b/lib/core/data/models/account_profile.dart new file mode 100644 index 0000000..f874723 --- /dev/null +++ b/lib/core/data/models/account_profile.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'auth_session.dart'; +import 'json_numbers.dart'; + +/// 当前账号资料;字段与 `GET/PUT /app/me` 的实际响应一致。 +class AccountProfile { + const AccountProfile({ + required this.uid, + required this.nickname, + required this.avatarUrl, + required this.gender, + required this.countryCode, + required this.regionCode, + required this.language, + required this.timezone, + required this.currency, + required this.status, + required this.maskedTarget, + required this.vipLevel, + this.createdAt, + this.lastLoginAt, + this.vipExpireAt, + }); + + final String uid; + final String nickname; + final String avatarUrl; + final String gender; + final String countryCode; + final String regionCode; + final String language; + final String timezone; + final String currency; + final String status; + final String maskedTarget; + final int vipLevel; + final DateTime? createdAt; + final DateTime? lastLoginAt; + final DateTime? vipExpireAt; + + /// 解析资料响应;缺失字段按空值处理。 + factory AccountProfile.fromJson(Map json) { + return AccountProfile( + uid: jsonToId(json['uid']), + nickname: json['nickname']?.toString() ?? '', + avatarUrl: json['avatarUrl']?.toString() ?? '', + gender: json['gender']?.toString() ?? '', + countryCode: json['countryCode']?.toString() ?? '', + regionCode: json['regionCode']?.toString() ?? '', + language: json['language']?.toString() ?? '', + timezone: json['timezone']?.toString() ?? '', + currency: json['currency']?.toString() ?? '', + status: json['status']?.toString() ?? '', + maskedTarget: json['maskedTarget']?.toString() ?? '', + vipLevel: jsonToInt(json['vipLevel']), + createdAt: _date(json['createdAt']), + lastLoginAt: _date(json['lastLoginAt']), + vipExpireAt: _date(json['vipExpireAt']), + ); + } + + static DateTime? _date(dynamic value) => + DateTime.tryParse(value?.toString() ?? ''); + + /// 转为本地登录态摘要;只同步登录态实际使用的三项。 + AuthUser toAuthUser() => + AuthUser(uid: uid, nickname: nickname, avatarUrl: avatarUrl); +} + +/// 资料更新请求;null 字段不会进入请求体。 +class AccountProfileUpdate { + const AccountProfileUpdate({ + this.nickname, + this.avatarUrl, + this.gender, + this.birthday, + this.language, + this.timezone, + this.regionCode, + }); + + final String? nickname; + final String? avatarUrl; + final String? gender; + final String? birthday; + final String? language; + final String? timezone; + final String? regionCode; + + /// 只发送本次有值的字段。 + Map toJson() => { + if (nickname != null) 'nickname': nickname, + if (avatarUrl != null) 'avatarUrl': avatarUrl, + if (gender != null) 'gender': gender, + if (birthday != null) 'birthday': birthday, + if (language != null) 'language': language, + if (timezone != null) 'timezone': timezone, + if (regionCode != null) 'regionCode': regionCode, + }; +} diff --git a/lib/core/data/remote/account_api.dart b/lib/core/data/remote/account_api.dart new file mode 100644 index 0000000..44c78bf --- /dev/null +++ b/lib/core/data/remote/account_api.dart @@ -0,0 +1,52 @@ +// Author: fruit.com.cn + +import '../models/account_device.dart'; +import '../models/account_export.dart'; +import '../models/account_profile.dart'; +import 'api_client.dart'; + +/// 个人中心接口:`/app/me*`。 +class AccountApi { + const AccountApi(this._client); + + final ApiClient _client; + + Future profile() async { + final data = await _client.get('/app/me'); + return AccountProfile.fromJson(_map(data)); + } + + Future updateProfile(AccountProfileUpdate update) async { + final data = await _client.put('/app/me', body: update.toJson()); + return AccountProfile.fromJson(_map(data)); + } + + 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()), + ]; + } + + Future export() async { + final data = await _client.get('/app/me/export'); + return AccountExport.fromJson(_map(data)); + } + + Future deactivate({String reason = ''}) async { + final data = await _client.post( + '/app/me/deactivate', + body: {'reason': reason.trim()}, + ); + return DateTime.tryParse(data?.toString() ?? ''); + } + + Future cancelDeactivation() async { + await _client.post('/app/me/deactivate/cancel'); + } + + Map _map(Object? data) => + data is Map ? data.cast() : const {}; +} diff --git a/lib/core/data/repositories/account_repository.dart b/lib/core/data/repositories/account_repository.dart new file mode 100644 index 0000000..7bc9cfe --- /dev/null +++ b/lib/core/data/repositories/account_repository.dart @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +import '../models/account_device.dart'; +import '../models/account_export.dart'; +import '../models/account_profile.dart'; +import '../remote/account_api.dart'; + +/// 账号中心的数据来源。 +abstract interface class AccountRepository { + Future profile(); + + Future updateProfile(AccountProfileUpdate update); + + Future> devices(); + + Future export(); + + Future deactivate(String reason); + + Future cancelDeactivation(); +} + +/// HTTP 实现。 +class HttpAccountRepository implements AccountRepository { + const HttpAccountRepository(this._api); + + final AccountApi _api; + + @override + Future profile() => _api.profile(); + + @override + Future updateProfile(AccountProfileUpdate update) => + _api.updateProfile(update); + + @override + Future> devices() => _api.devices(); + + @override + Future export() => _api.export(); + + @override + Future deactivate(String reason) => _api.deactivate(reason: reason); + + @override + Future cancelDeactivation() => _api.cancelDeactivation(); +} diff --git a/test/account/account_api_test.dart b/test/account/account_api_test.dart new file mode 100644 index 0000000..5bb1e97 --- /dev/null +++ b/test/account/account_api_test.dart @@ -0,0 +1,187 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/account_profile.dart'; +import 'package:fruitshorts/core/data/remote/account_api.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data, {int code = 0, String message = 'ok'}) => + jsonEncode({'code': code, 'message': message, 'data': data}); + + AccountApi apiWith(FakeHttpAdapter adapter) => AccountApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + test('should_get_profile_and_parse_numeric_vip_level', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({ + 'uid': 'U-K19', + 'nickname': 'Nadia', + 'avatarUrl': 'https://cdn/avatar.png', + 'gender': 'female', + 'countryCode': 'ID', + 'regionCode': 'JK', + 'language': 'id', + 'timezone': 'Asia/Jakarta', + 'currency': 'IDR', + 'status': 'normal', + 'maskedTarget': 'n***@example.com', + 'createdAt': '2026-10-19T08:00:00', + 'lastLoginAt': '2026-10-19T09:00:00', + 'vipLevel': 2, + 'vipExpireAt': null, + }), + ), + ); + + final profile = await apiWith(adapter).profile(); + + expect(adapter.requests.single.method, 'GET'); + expect(adapter.requests.single.uri.path, '/app/me'); + expect(profile.uid, 'U-K19'); + expect(profile.nickname, 'Nadia'); + expect(profile.vipLevel, 2); + expect(profile.createdAt, DateTime(2026, 10, 19, 8)); + }); + + test('should_send_only_changed_profile_fields_when_updating', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({'uid': 'U-K19', 'nickname': 'Nadia Baru'}), + ), + ); + + final profile = await apiWith(adapter).updateProfile( + const AccountProfileUpdate( + nickname: 'Nadia Baru', + gender: 'female', + birthday: '1999-01-02', + ), + ); + + expect(adapter.requests.single.method, 'PUT'); + expect(adapter.requests.single.uri.path, '/app/me'); + expect(adapter.requests.single.data, { + 'nickname': 'Nadia Baru', + 'gender': 'female', + 'birthday': '1999-01-02', + }); + expect(profile.nickname, 'Nadia Baru'); + }); + + test('should_parse_device_ids_from_strings_and_numbers', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope([ + { + 'id': '2111012753262120962', + 'userId': '2111012753262120000', + 'deviceId': 'd-device-1234', + 'platform': 'IOS', + 'deviceModel': 'iPhone 15', + 'firstSeenAt': '2026-10-18T08:00:00', + 'lastSeenAt': '2026-10-19T09:00:00', + 'status': 'active', + 'deleted': 0, + }, + { + 'id': 7, + 'deviceId': 'd-android-5678', + 'platform': 'ANDROID', + }, + ]), + ), + ); + + final devices = await apiWith(adapter).devices(); + + expect(adapter.requests.single.uri.path, '/app/me/devices'); + expect(devices.first.id, '2111012753262120962'); + expect(devices.first.lastSeenAt, DateTime(2026, 10, 19, 9)); + expect(devices.last.id, '7'); + expect(devices.last.deviceModel, ''); + }); + + test('should_parse_export_counts_and_keep_complete_json', () async { + final data = { + 'profile': {'uid': 'U-K19', 'nickname': 'Nadia'}, + 'credentials': [ + {'type': 'EMAIL', 'masked': 'n***@example.com', 'verified': true}, + ], + 'consents': [ + {'scene': 'privacy', 'version': '2026-10-01'}, + ], + 'devices': [ + {'id': '9', 'deviceId': 'd-device-1234', 'status': 'active'}, + ], + }; + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(data)), + ); + + final export = await apiWith(adapter).export(); + + expect(adapter.requests.single.uri.path, '/app/me/export'); + expect(export.profile.nickname, 'Nadia'); + expect(export.credentials.single.masked, 'n***@example.com'); + expect(export.credentials.single.verified, isTrue); + expect(export.consentCount, 1); + expect(export.deviceCount, 1); + expect(export.rawJson, contains('"credentials"')); + }); + + test('should_request_deactivation_with_reason_and_parse_schedule', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope('2026-11-03T09:30:00'), + ), + ); + + final scheduledAt = await apiWith( + adapter, + ).deactivate(reason: '不再使用'); + + expect(adapter.requests.single.method, 'POST'); + expect(adapter.requests.single.uri.path, '/app/me/deactivate'); + expect(adapter.requests.single.data, {'reason': '不再使用'}); + expect(scheduledAt, DateTime(2026, 11, 3, 9, 30)); + }); + + test('should_cancel_deactivation_with_post_and_no_body', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse(envelope(null)), + ); + + await apiWith(adapter).cancelDeactivation(); + + expect(adapter.requests.single.method, 'POST'); + expect( + adapter.requests.single.uri.path, + '/app/me/deactivate/cancel', + ); + expect(adapter.requests.single.data, isNull); + }); + + test('should_keep_backend_message_when_cancel_fails', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope(null, code: 100009, message: '没有待处理的注销申请'), + ), + ); + + await expectLater( + apiWith(adapter).cancelDeactivation(), + throwsA( + isA() + .having((error) => error.code, 'code', 100009) + .having((error) => error.message, 'message', '没有待处理的注销申请'), + ), + ); + }); +}