feat(account): 接入个人中心接口模型
This commit is contained in:
1 parent
7d68699523
commit
44f6feb249
6 files changed
+501
No files matched your search
@@ -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<String, dynamic> 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() ?? '');
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<AccountCredential> credentials;
|
||||||
|
final int consentCount;
|
||||||
|
final List<AccountDevice> devices;
|
||||||
|
final Map<String, dynamic> raw;
|
||||||
|
|
||||||
|
int get deviceCount => devices.length;
|
||||||
|
String get rawJson => const JsonEncoder.withIndent(' ').convert(raw);
|
||||||
|
|
||||||
|
factory AccountExport.fromJson(Map<String, dynamic> 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<String, dynamic> _map(dynamic value) =>
|
||||||
|
value is Map ? value.cast<String, dynamic>() : const {};
|
||||||
|
|
||||||
|
static List<Map<String, dynamic>> _list(dynamic value) {
|
||||||
|
if (value is! List) return const [];
|
||||||
|
return [
|
||||||
|
for (final item in value)
|
||||||
|
if (item is Map) item.cast<String, dynamic>(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<AccountProfile> profile() async {
|
||||||
|
final data = await _client.get('/app/me');
|
||||||
|
return AccountProfile.fromJson(_map(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AccountProfile> updateProfile(AccountProfileUpdate update) async {
|
||||||
|
final data = await _client.put('/app/me', body: update.toJson());
|
||||||
|
return AccountProfile.fromJson(_map(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
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>()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AccountExport> export() async {
|
||||||
|
final data = await _client.get('/app/me/export');
|
||||||
|
return AccountExport.fromJson(_map(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DateTime?> deactivate({String reason = ''}) async {
|
||||||
|
final data = await _client.post(
|
||||||
|
'/app/me/deactivate',
|
||||||
|
body: {'reason': reason.trim()},
|
||||||
|
);
|
||||||
|
return DateTime.tryParse(data?.toString() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelDeactivation() async {
|
||||||
|
await _client.post('/app/me/deactivate/cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _map(Object? data) =>
|
||||||
|
data is Map ? data.cast<String, dynamic>() : const {};
|
||||||
|
}
|
||||||
@@ -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<AccountProfile> profile();
|
||||||
|
|
||||||
|
Future<AccountProfile> updateProfile(AccountProfileUpdate update);
|
||||||
|
|
||||||
|
Future<List<AccountDevice>> devices();
|
||||||
|
|
||||||
|
Future<AccountExport> export();
|
||||||
|
|
||||||
|
Future<DateTime?> deactivate(String reason);
|
||||||
|
|
||||||
|
Future<void> cancelDeactivation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 实现。
|
||||||
|
class HttpAccountRepository implements AccountRepository {
|
||||||
|
const HttpAccountRepository(this._api);
|
||||||
|
|
||||||
|
final AccountApi _api;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountProfile> profile() => _api.profile();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountProfile> updateProfile(AccountProfileUpdate update) =>
|
||||||
|
_api.updateProfile(update);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AccountDevice>> devices() => _api.devices();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountExport> export() => _api.export();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DateTime?> deactivate(String reason) => _api.deactivate(reason: reason);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelDeactivation() => _api.cancelDeactivation();
|
||||||
|
}
|
||||||
@@ -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<ApiException>()
|
||||||
|
.having((error) => error.code, 'code', 100009)
|
||||||
|
.having((error) => error.message, 'message', '没有待处理的注销申请'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user