Files

194 lines
6.3 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// auth_api.dart — 控制面认证 HTTP 客户端
//
// 职责:封装 /v1/auth/* 接口调用。
// 关键约束:所有 HTTP 错误统一包装为 AuthApiException
// UI 层通过 e.statusCode / e.messageZh 显示错误文案。
import 'dart:convert';
import 'package:http/http.dart' as http;
/// 认证接口调用失败时抛出。
/// [statusCode] < 0 表示网络层错误。
class AuthApiException implements Exception {
const AuthApiException({
required this.statusCode,
required this.messageZh,
required this.messageEn,
this.code,
});
final int statusCode;
final String messageZh;
final String messageEn;
/// 服务端 apierr 的机器码(如 CURRENCY_MISMATCH),旧调用点可空。
final String? code;
@override
String toString() => 'AuthApiException($statusCode): $messageZh';
}
/// 登录 / 注册成功后返回的 JWT 令牌对。
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.refreshToken,
this.deviceLimit,
});
final String accessToken;
final String refreshToken;
/// 非 null 表示登录成功但账户活跃设备数超套餐上限——登录仍生效(token 已发),
/// 客户端须先让用户移除设备(降到上限内)才进主界面。见 device_limit 挡板。
final DeviceLimit? deviceLimit;
factory AuthTokens.fromJson(Map<String, dynamic> m) => AuthTokens(
accessToken: m['access_token'] as String? ?? '',
refreshToken: m['refresh_token'] as String? ?? '',
deviceLimit: m['device_limit'] is Map
? DeviceLimit.fromJson(Map<String, dynamic>.from(m['device_limit'] as Map))
: null,
);
}
/// 超限信号:当前套餐上限 + 账户活跃设备列表(供「移除设备」页展示)。
class DeviceLimit {
const DeviceLimit({required this.maxDevices, required this.devices});
final int maxDevices;
final List<DeviceBrief> devices;
factory DeviceLimit.fromJson(Map<String, dynamic> m) => DeviceLimit(
maxDevices: (m['max_devices'] as num?)?.toInt() ?? 0,
devices: ((m['devices'] as List?) ?? const [])
.whereType<Map>()
.map((e) => DeviceBrief.fromJson(Map<String, dynamic>.from(e)))
.toList(),
);
}
/// 超限页用的精简设备视图。
class DeviceBrief {
const DeviceBrief({
required this.uuid,
required this.name,
required this.platform,
this.lastSeen,
});
final String uuid;
final String name;
final String platform;
final String? lastSeen; // RFC 3339 UTC
factory DeviceBrief.fromJson(Map<String, dynamic> m) => DeviceBrief(
uuid: m['uuid'] as String? ?? '',
name: m['name'] as String? ?? '',
platform: m['platform'] as String? ?? '',
lastSeen: m['last_seen'] as String?,
);
}
/// [AuthApi] 封装 /v1/auth 接口族。
class AuthApi {
AuthApi({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
final String baseUrl;
final http.Client _client;
// ── 发送验证码:POST /v1/auth/code ─────────────────────────────
/// 向 [email] 发送 6 位验证码。成功无返回,失败抛 [AuthApiException]。
Future<void> sendCode(String email) async {
final resp = await _post('/v1/auth/code', {'email': email});
if (resp.statusCode != 204 && resp.statusCode != 200) {
_throwFromResponse(resp);
}
}
// ── 注册:POST /v1/auth/register ───────────────────────────────
Future<AuthTokens> register({
required String email,
required String code,
required String password,
Map<String, dynamic>? device,
}) async {
final resp = await _post('/v1/auth/register', {
'email': email,
'code': code,
'password': password,
if (device != null) 'device': device,
});
if (resp.statusCode != 200 && resp.statusCode != 201) {
_throwFromResponse(resp);
}
return AuthTokens.fromJson(jsonDecode(resp.body) as Map<String, dynamic>);
}
// ── 登录:POST /v1/auth/login ───────────────────────────────────
Future<AuthTokens> login({
required String email,
required String password,
Map<String, dynamic>? device,
}) async {
final resp = await _post('/v1/auth/login', {
'email': email,
'password': password,
if (device != null) 'device': device,
});
if (resp.statusCode != 200) _throwFromResponse(resp);
return AuthTokens.fromJson(jsonDecode(resp.body) as Map<String, dynamic>);
}
// ── 刷新 tokenPOST /v1/auth/refresh ──────────────────────────
Future<AuthTokens> refresh(String refreshToken) async {
final resp = await _post('/v1/auth/refresh', {'refresh_token': refreshToken});
if (resp.statusCode != 200) _throwFromResponse(resp);
return AuthTokens.fromJson(jsonDecode(resp.body) as Map<String, dynamic>);
}
// ── 内部 ────────────────────────────────────────────────────────
Future<http.Response> _post(String path, Map<String, dynamic> body) async {
final uri = Uri.parse('$baseUrl$path');
try {
final resp = await _client
.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 15));
return resp;
} on Exception catch (e) {
throw AuthApiException(
statusCode: -1,
messageZh: '网络请求失败,请检查连接后重试',
messageEn: 'Network error: $e',
);
}
}
void _throwFromResponse(http.Response resp) {
String messageZh = '操作失败 (HTTP ${resp.statusCode})';
String messageEn = 'Request failed (HTTP ${resp.statusCode})';
try {
final body = jsonDecode(resp.body) as Map<String, dynamic>;
messageZh = body['message_zh'] as String? ?? messageZh;
messageEn = body['message_en'] as String? ?? messageEn;
} catch (_) {}
throw AuthApiException(
statusCode: resp.statusCode,
messageZh: messageZh,
messageEn: messageEn,
);
}
void dispose() => _client.close();
}