// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。 // 版本:1.2.0 import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; const Map _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 get(String path, {bool authenticated = true}) => _send('GET', path, authenticated: authenticated); /// 读取需要鉴权的二进制资源;资源不存在时返回空值。 Future 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 post( String path, { Map? body, bool authenticated = true, }) => _send('POST', path, body: body, authenticated: authenticated); Future put(String path, {Map? body}) => _send('PUT', path, body: body); Future delete(String path, {Map? body}) => _send('DELETE', path, body: body); Future _send( String method, String path, { Map? 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 _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) { 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 jsonMap(Object? value) { if (value is Map) return value; if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item)); throw const ApiException(500, '服务端数据格式错误'); } List> jsonList(Object? value) { if (value is! List) return const []; return value.map>(jsonMap).toList(growable: false); }