// account_api.dart — 账户域端点封装(受 JWT 保护,走 ApiClient)。 // // 覆盖:/v1/me、/v1/plans、/v1/me/devices(列表+删除)、/v1/usage、 // /v1/redeem、/v1/ads/unlock。错误统一 AuthApiException(由 ApiClient 抛)。 import '../models/device.dart'; import '../models/device_usage.dart'; import '../models/me.dart'; import '../models/plan.dart'; import '../models/usage_point.dart'; import 'api_client.dart'; /// 兑换结果(POST /v1/redeem)。 class RedeemResult { const RedeemResult({ required this.plan, required this.durationDays, this.idempotent = false, this.expiresAt, }); final String plan; final int durationDays; final bool idempotent; final DateTime? expiresAt; factory RedeemResult.fromJson(Map m) { final exp = m['expires_at'] as String?; return RedeemResult( plan: m['plan'] as String? ?? '', durationDays: (m['duration_days'] as num?)?.toInt() ?? 0, idempotent: m['idempotent'] as bool? ?? false, expiresAt: (exp != null && exp.isNotEmpty) ? DateTime.tryParse(exp) : null, ); } } class AccountApi { AccountApi(this._c); final ApiClient _c; /// GET /v1/me — 账户聚合视图。 Future me() async => Me.fromJson(await _c.getJson('/v1/me')); /// GET /v1/plans — 套餐定义。 Future> plans() async { final body = await _c.getJson('/v1/plans'); final raw = (body['plans'] as List?) ?? const []; return raw.map((e) => Plan.fromJson(e as Map)).toList(); } /// GET /v1/me/devices — 已登录设备列表。 Future> devices() async { final body = await _c.getJson('/v1/me/devices'); final raw = (body['devices'] as List?) ?? const []; return raw.map((e) => Device.fromJson(e as Map)).toList(); } /// DELETE /v1/me/devices/{uuid} — 清除登录信息(移除设备 + 吊销会话/凭证)。 Future removeDevice(String uuid) => _c.delete('/v1/me/devices/$uuid'); /// POST /v1/me/devices/{uuid}/logout — 强制退出(吊销该设备会话,设备留列表)。 Future forceLogout(String uuid) async { await _c.postJson('/v1/me/devices/$uuid/logout'); } /// POST /v1/me/devices/{uuid}/rename — 重命名设备。 Future renameDevice(String uuid, String name) async { await _c.postJson('/v1/me/devices/$uuid/rename', {'name': name}); } /// GET /v1/me/session?device_id= — 本设备会话是否仍有效。 /// false = 已被其他设备强制退出 → 客户端应登出。供 ~15s 轮询实现近实时远程下线。 Future sessionActive(String deviceId) async { final body = await _c.getJson( '/v1/me/session?device_id=${Uri.encodeQueryComponent(deviceId)}'); return body['active'] == true; } /// GET /v1/usage?days=N&tz_offset=M — 最近 N 天用量(默认 7,后端范围 [1,90])。 /// 带本机时区偏移(分钟,如 UTC+8=480),服务端按「本地日」聚合 → 「今天」与本地日历一致 /// (修「29 号却显示 28 号数据」的时区 bug)。 Future> usage({int days = 7, String? device}) async { final tz = DateTime.now().timeZoneOffset.inMinutes; final dev = (device != null && device.isNotEmpty) ? '&device=$device' : ''; final body = await _c.getJson('/v1/usage?days=$days&tz_offset=$tz$dev'); final raw = (body['points'] as List?) ?? const []; return raw.map((e) => UsagePoint.fromJson(e as Map)).toList(); } /// GET /v1/usage/devices?days=N — 最近 N 天按设备用量(默认 7,范围 [1,90])。 Future> deviceUsage({int days = 7}) async { final body = await _c.getJson('/v1/usage/devices?days=$days'); final raw = (body['devices'] as List?) ?? const []; return raw.map((e) => DeviceUsage.fromJson(e as Map)).toList(); } /// POST /v1/redeem — 兑换码。 Future redeem(String code) async => RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code})); /// POST /v1/ads/unlock — 看广告加时(累加式)。返回本次加时分钟与最新剩余分钟。 Future adUnlock({required String deviceId, required String adToken}) async { final body = await _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken}); return AdUnlockResult( grantedMinutes: (body['granted_minutes'] as num?)?.toInt() ?? 0, minutesRemaining: (body['minutes_remaining'] as num?)?.toInt() ?? 0, ); } } /// 看广告加时结果(POST /v1/ads/unlock 响应)。 class AdUnlockResult { const AdUnlockResult({required this.grantedMinutes, required this.minutesRemaining}); /// 本次广告实际加时分钟(已达每日封顶时为 0)。 final int grantedMinutes; /// 加时后账户当日剩余分钟(全账户共享)。 final int minutesRemaining; }