197 lines
6.9 KiB
Dart
197 lines
6.9 KiB
Dart
// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。
|
||
// 版本:1.1.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;
|
||
}
|
||
|
||
/// 负责用户端统一 HTTP 请求、鉴权头和响应解析。
|
||
class ApiClient {
|
||
ApiClient(
|
||
this._tokenProvider, {
|
||
http.Client? client,
|
||
String? baseUrl,
|
||
}) : _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;
|
||
|
||
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 == 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';
|
||
if (authenticated) {
|
||
final token = _tokenProvider();
|
||
if (token.isNotEmpty) request.headers['authorization'] = token;
|
||
}
|
||
if (body != null) {
|
||
request.headers['content-type'] = 'application/json; charset=UTF-8';
|
||
request.body = jsonEncode(body);
|
||
}
|
||
final response = await _sendRequest(request);
|
||
return _decode(response);
|
||
}
|
||
|
||
/// 发送请求并统一处理网络连接异常。
|
||
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) {
|
||
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) {
|
||
throw ApiException(
|
||
code,
|
||
localizeApiErrorMessage(code, decoded['message'] as String?),
|
||
);
|
||
}
|
||
return decoded['details'];
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|