Files
platforms/apps/user_app/lib/data/services/api_client.dart

258 lines
9.1 KiB
Dart
Raw Normal View History

2026-09-02 21:07:47 +08:00
// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。
// 版本1.2.0
import 'dart:convert';
2026-09-02 22:48:47 +08:00
import 'dart:typed_data';
import 'package:http/http.dart' as http;
2026-09-02 21:07:47 +08:00
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);
2026-09-02 21:07:47 +08:00
/// 负责用户端统一 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);
2026-09-02 22:48:47 +08:00
/// 读取需要鉴权的二进制资源;资源不存在时返回空值。
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);
}
2026-09-02 22:48:47 +08:00
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'));
2026-08-02 01:30:07 +08:00
request.headers['accept'] = 'application/json';
var requestToken = '';
if (authenticated) {
requestToken = _tokenProvider();
if (requestToken.isNotEmpty) request.headers['authorization'] = requestToken;
}
if (body != null) {
2026-08-02 01:30:07 +08:00
request.headers['content-type'] = 'application/json; charset=UTF-8';
request.body = jsonEncode(body);
}
2026-09-02 21:07:47 +08:00
final response = await _sendRequest(request);
return _decode(
response,
authenticated: authenticated,
requestToken: requestToken,
);
2026-09-02 21:07:47 +08:00
}
/// 发送请求并统一处理网络连接异常。
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}');
}
2026-09-02 21:07:47 +08:00
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);
}
2026-09-02 21:07:47 +08:00
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);
}