Files
platforms/apps/user_app/lib/data/services/api_client.dart
2026-09-04 21:54:35 +08:00

258 lines
9.1 KiB
Dart
Raw 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.
// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。
// 版本1.2.0
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
const Map<int, String> _apiErrorMessages = {
404: '记录不存在',
1001: '请求标识缺失',
1002: '登录信息缺失,请重新登录',
1003: '接口密钥缺失',
1004: '请求头参数缺失',
1101: '暂无数据',
1102: '请求解析失败',
1103: '请填写必填参数',
1104: '无权执行此操作',
1105: '服务端数据处理失败',
1106: '服务端数据处理失败',
1107: '服务内部错误,请稍后重试',
1108: '密码错误',
1110: '账号已禁用',
1111: '当前记录已停用',
1112: '记录不存在',
1301: '登录状态已失效,请重新登录',
1302: '登录状态已失效,请重新登录',
1303: '登录状态已失效,请重新登录',
1304: '登录状态已失效,请重新登录',
1305: '登录状态已失效,请重新登录',
1306: '登录状态已失效,请重新登录',
1307: '登录状态已变更,请重新登录',
1308: '登录已过期,请重新登录',
1309: '登录状态已失效,请重新登录',
1310: '登录状态已失效,请重新登录',
1311: '登录状态已失效,请重新登录',
1312: '登录状态已失效,请重新登录',
1313: '登录失败,请稍后重试',
1314: '登录状态已失效,请重新登录',
1501: '服务暂时不可用,请稍后重试',
1502: '服务暂时不可用,请稍后重试',
1503: '服务暂时不可用,请稍后重试',
1504: '服务暂时不可用,请稍后重试',
1505: '服务暂时不可用,请稍后重试',
1506: '服务暂时不可用,请稍后重试',
1507: '服务暂时不可用,请稍后重试',
1702: '操作已取消',
1703: '操作失败,请稍后重试',
1704: '请求参数错误',
1705: '请求超时,请稍后重试',
1706: '记录已存在',
1707: '无权执行此操作',
1708: '系统繁忙,请稍后重试',
1709: '当前状态不支持此操作',
1710: '操作已中止',
1711: '输入内容超出允许范围',
1712: '当前功能暂不支持',
1713: '服务暂时不可用,请稍后重试',
1714: '服务数据异常,请稍后重试',
1715: '请先登录',
};
final RegExp _chineseCharacterPattern = RegExp(r'[\u3400-\u9fff]');
/// 将服务端错误转换为中文用户提示。
///
/// [code] 是稳定错误码,[message] 是服务端原始消息;返回值不会透传未知英文技术信息。
String localizeApiErrorMessage(int code, String? message) {
final mappedMessage = _apiErrorMessages[code];
if (mappedMessage != null) return mappedMessage;
final originalMessage = message?.trim() ?? '';
if (_chineseCharacterPattern.hasMatch(originalMessage)) return originalMessage;
return '操作失败,请稍后重试';
}
/// 表示客户端可识别并可安全展示的接口异常。
class ApiException implements Exception {
const ApiException(this.code, this.message);
final int code;
final String message;
@override
String toString() => message;
}
/// 表示鉴权会话已经失效;页面层应等待路由跳转,不再展示普通网络错误。
class SessionExpiredException extends ApiException {
const SessionExpiredException() : super(401, '登录状态已失效,请重新登录');
}
/// 接收被服务端拒绝的请求令牌,用于安全地失效对应会话。
typedef UnauthorizedCallback = void Function(String rejectedToken);
/// 负责用户端统一 HTTP 请求、鉴权头和响应解析。
class ApiClient {
ApiClient(
this._tokenProvider, {
http.Client? client,
String? baseUrl,
this.onUnauthorized,
}) : _client = client ?? http.Client(),
baseUrl =
baseUrl ??
const String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:12426',
);
final String baseUrl;
final String Function() _tokenProvider;
final http.Client _client;
final UnauthorizedCallback? onUnauthorized;
Future<Object?> get(String path, {bool authenticated = true}) =>
_send('GET', path, authenticated: authenticated);
/// 读取需要鉴权的二进制资源;资源不存在时返回空值。
Future<Uint8List?> getBytes(String path) async {
final request = http.Request('GET', Uri.parse('$baseUrl$path'));
request.headers['accept'] = 'image/jpeg, image/png';
final token = _tokenProvider();
if (token.isNotEmpty) request.headers['authorization'] = token;
final response = await _sendRequest(request);
if (response.statusCode == 401 && token.isNotEmpty) {
_rejectSession(token);
}
final responseCode = _jsonResponseCode(response);
if (token.isNotEmpty && responseCode != null && _isAuthenticationFailure(responseCode)) {
_rejectSession(token);
}
if (response.statusCode == 404) return null;
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, '头像加载失败');
}
return response.bodyBytes.isEmpty ? null : response.bodyBytes;
}
Future<Object?> post(
String path, {
Map<String, Object?>? body,
bool authenticated = true,
}) => _send('POST', path, body: body, authenticated: authenticated);
Future<Object?> put(String path, {Map<String, Object?>? body}) => _send('PUT', path, body: body);
Future<Object?> delete(String path, {Map<String, Object?>? body}) =>
_send('DELETE', path, body: body);
Future<Object?> _send(
String method,
String path, {
Map<String, Object?>? body,
bool authenticated = true,
}) async {
final request = http.Request(method, Uri.parse('$baseUrl$path'));
request.headers['accept'] = 'application/json';
var requestToken = '';
if (authenticated) {
requestToken = _tokenProvider();
if (requestToken.isNotEmpty) request.headers['authorization'] = requestToken;
}
if (body != null) {
request.headers['content-type'] = 'application/json; charset=UTF-8';
request.body = jsonEncode(body);
}
final response = await _sendRequest(request);
return _decode(
response,
authenticated: authenticated,
requestToken: requestToken,
);
}
/// 发送请求并统一处理网络连接异常。
Future<http.Response> _sendRequest(http.BaseRequest request) async {
try {
return await http.Response.fromStream(await _client.send(request));
} catch (_) {
throw const ApiException(500, '网络连接失败,请稍后重试');
}
}
/// 解析统一响应结构,并按错误码生成中文提示。
Object? _decode(
http.Response response, {
required bool authenticated,
required String requestToken,
}) {
if (authenticated && response.statusCode == 401 && requestToken.isNotEmpty) {
_rejectSession(requestToken);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, '网络请求失败(${response.statusCode}');
}
Object? decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException {
throw const ApiException(500, '服务端响应格式错误');
}
if (decoded is! Map<String, Object?>) {
throw const ApiException(500, '服务端响应格式错误');
}
final code = (decoded['code'] as num?)?.toInt() ?? 500;
if (code != 0) {
if (authenticated && requestToken.isNotEmpty && _isAuthenticationFailure(code)) {
_rejectSession(requestToken);
}
throw ApiException(
code,
localizeApiErrorMessage(code, decoded['message'] as String?),
);
}
return decoded['details'];
}
/// 通知会话层并抛出专用异常,避免页面把鉴权失败误报为网络问题。
Never _rejectSession(String rejectedToken) {
onUnauthorized?.call(rejectedToken);
throw const SessionExpiredException();
}
/// 尝试从二进制接口返回的 JSON 错误体中提取业务码。
int? _jsonResponseCode(http.Response response) {
final contentType = response.headers['content-type'] ?? '';
int? firstContentByte;
for (final byte in response.bodyBytes) {
if (byte == 0x20 || byte == 0x09 || byte == 0x0a || byte == 0x0d) continue;
firstContentByte = byte;
break;
}
if (!contentType.contains('json') && firstContentByte != 0x7b) {
return null;
}
try {
final decoded = jsonDecode(response.body);
return decoded is Map ? (decoded['code'] as num?)?.toInt() : null;
} on FormatException {
return null;
}
}
}
/// 判断服务端稳定错误码是否表示登录会话无效。
bool _isAuthenticationFailure(int code) => (code >= 1301 && code <= 1314) || code == 1715;
Map<String, Object?> jsonMap(Object? value) {
if (value is Map<String, Object?>) return value;
if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item));
throw const ApiException(500, '服务端数据格式错误');
}
List<Map<String, Object?>> jsonList(Object? value) {
if (value is! List) return const [];
return value.map<Map<String, Object?>>(jsonMap).toList(growable: false);
}