refactor(client): 网络层由官方 http 换成 dio(对外行为不变)
为什么:权威文档(AGENTS.md / CODING_STANDARDS.md)强制网络层用 dio,差异清单 A 组第 1 题
用户裁定选 A;原来用官方 http 是在无文档状态下按口头要求选的。
- ApiClient 改用 dio + BaseOptions(validateStatus 恒真、8s 超时、ResponseType.bytes 自行 UTF-8 解码)
- 新增 AuthHeaderInterceptor(Authorization / X-Device-Id)与 EnvelopeInterceptor
(解包 {code,message,data} + 错误码映射 + 401 判定)
- 401 → 刷新 → 重发一次在 ApiClient 里编排,刻意不放在拦截器内重发:dio 的排队拦截器
在回调里再发请求会自锁
- 接口层(auth_api/catalog_api)签名、全部 controller/logic/page 未改动
- 测试:新增假 dio adapter 替代 MockClient,断言口径一条未改;用例名改为 should_<期望>_when_<条件>;
新增 api_client_test(解包/错误映射/请求头/查询参数/跳过重试)与 live_auth_test(真实后端 POST 链路)
- pubspec:加 dio、移除已无引用的 http
- 实测:dart analyze lib test 零告警;flutter test 155 全过;真实联调(8081)GET/POST 链路均通
This commit is contained in:
1 parent
ae49b10407
commit
1853e1845c
10 files changed
+508
-143
No files matched your search
@@ -1,35 +1,52 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptors.dart';
|
||||
|
||||
/// 接口异常:code 为后端业务码,message 可直接展示。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
||||
export 'api_exception.dart';
|
||||
|
||||
/// 后端统一响应壳 `{code, message, data}` 的解析入口。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 用 dio 发请求(`AuthHeaderInterceptor` 注入令牌与设备号,`EnvelopeInterceptor` 解包与映射错误码);
|
||||
/// - 编排「401 → 刷新令牌 → 重发一次」——**刻意不放在拦截器里重发**,因为 dio 的排队拦截器
|
||||
/// 在回调里再发起请求会把自己排到队尾,造成自锁。
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
String? baseUrl,
|
||||
http.Client? client,
|
||||
HttpClientAdapter? adapter,
|
||||
this.accessTokenReader,
|
||||
this.tokenRefresher,
|
||||
this.onUnauthorized,
|
||||
this.deviceIdReader,
|
||||
})
|
||||
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||||
_client = client ?? http.Client();
|
||||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: this.baseUrl,
|
||||
connectTimeout: AppConfig.requestTimeout,
|
||||
receiveTimeout: AppConfig.requestTimeout,
|
||||
// 拿原始字节自己解码:后端消息是 UTF-8,且"不是 JSON 的响应体"要能被识别出来
|
||||
responseType: ResponseType.bytes,
|
||||
// 非 2xx 不抛异常:业务结果由响应体里的 code 表达(与旧实现一致)
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
_dio.interceptors.addAll([
|
||||
AuthHeaderInterceptor(
|
||||
accessTokenReader: accessTokenReader,
|
||||
deviceIdReader: deviceIdReader,
|
||||
),
|
||||
const EnvelopeInterceptor(),
|
||||
]);
|
||||
if (adapter != null) {
|
||||
_dio.httpClientAdapter = adapter;
|
||||
}
|
||||
}
|
||||
|
||||
final String baseUrl;
|
||||
final http.Client _client;
|
||||
late final Dio _dio;
|
||||
|
||||
/// 取当前 access token(未登录返回 null)
|
||||
final String? Function()? accessTokenReader;
|
||||
@@ -44,7 +61,7 @@ class ApiClient {
|
||||
final Future<String> Function()? deviceIdReader;
|
||||
|
||||
Future<Object?> get(String path, {Map<String, String>? query}) {
|
||||
return _send('GET', path, query: query);
|
||||
return _request('GET', path, query: query);
|
||||
}
|
||||
|
||||
Future<Object?> post(
|
||||
@@ -53,10 +70,16 @@ class ApiClient {
|
||||
Map<String, String>? query,
|
||||
bool skipAuthRetry = false,
|
||||
}) {
|
||||
return _send('POST', path, body: body, query: query, skipAuthRetry: skipAuthRetry);
|
||||
return _request(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
skipAuthRetry: skipAuthRetry,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Object?> _send(
|
||||
Future<Object?> _request(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
@@ -64,67 +87,45 @@ class ApiClient {
|
||||
bool retried = false,
|
||||
bool skipAuthRetry = false,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl$path',
|
||||
).replace(queryParameters: query?.isEmpty ?? true ? null : query);
|
||||
final headers = await _headers(withBody: body != null);
|
||||
final response = await (method == 'GET'
|
||||
? _client.get(uri, headers: headers)
|
||||
: _client.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: body == null ? null : jsonEncode(body),
|
||||
))
|
||||
.timeout(AppConfig.requestTimeout);
|
||||
|
||||
Map<String, dynamic> shell = const {};
|
||||
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
|
||||
try {
|
||||
shell = (jsonDecode(utf8.decode(response.bodyBytes)) as Map)
|
||||
.cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
shell = const {};
|
||||
}
|
||||
final code = shell['code'] as int? ?? -1;
|
||||
|
||||
// 令牌过期:刷新一次再重试原请求;刷新失败就清登录态并抛给调用方
|
||||
if (code == 401 && !retried && !skipAuthRetry && tokenRefresher != null) {
|
||||
final refreshed = await tokenRefresher!.call();
|
||||
if (refreshed) {
|
||||
return _send(
|
||||
method,
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
retried: true,
|
||||
skipAuthRetry: skipAuthRetry,
|
||||
);
|
||||
final response = await _dio.request<Object?>(
|
||||
path,
|
||||
data: body,
|
||||
queryParameters: (query?.isEmpty ?? true) ? null : query,
|
||||
options: Options(
|
||||
method: method,
|
||||
contentType: body == null ? null : Headers.jsonContentType,
|
||||
extra: {
|
||||
ApiRequestFlags.disableAuthRetry: disableAuthRetry,
|
||||
ApiRequestFlags.retried: retried,
|
||||
},
|
||||
),
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (error) {
|
||||
final cause = error.error;
|
||||
if (cause is TokenExpiredException) {
|
||||
final refreshed = await tokenRefresher?.call() ?? false;
|
||||
if (refreshed) {
|
||||
return _request(
|
||||
method,
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
retried: true,
|
||||
skipAuthRetry: skipAuthRetry,
|
||||
);
|
||||
}
|
||||
onUnauthorized?.call();
|
||||
throw ApiException(401, cause.message ?? '登录已过期');
|
||||
}
|
||||
onUnauthorized?.call();
|
||||
throw ApiException(401, shell['message'] as String? ?? '登录已过期');
|
||||
if (cause is ApiException) {
|
||||
throw cause;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (code == -1) {
|
||||
throw ApiException(response.statusCode, 'HTTP ${response.statusCode}');
|
||||
}
|
||||
if (code != 0) {
|
||||
throw ApiException(code, shell['message'] as String? ?? '请求失败');
|
||||
}
|
||||
return shell['data'];
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _headers({required bool withBody}) async {
|
||||
final headers = <String, String>{'Accept': 'application/json'};
|
||||
if (withBody) headers['Content-Type'] = 'application/json';
|
||||
final token = accessTokenReader?.call();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final deviceId = await deviceIdReader?.call();
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
headers['X-Device-Id'] = deviceId;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
void close() => _client.close();
|
||||
void close() => _dio.close(force: true);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 接口异常:code 为后端业务码,message 可直接展示。
|
||||
///
|
||||
/// 单独成文件是为了让 [ApiClient] 与拦截器都能引用它而不互相 import
|
||||
/// (`api_client.dart` 会 `export` 本文件,历史调用方继续 `import 'api_client.dart'` 即可拿到它)。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
||||
class ApiRequestFlags {
|
||||
const ApiRequestFlags._();
|
||||
|
||||
/// 本次请求不参与「401 → 刷新 → 重试」:刷新令牌自身、登出,以及没有刷新回调时。
|
||||
static const String disableAuthRetry = 'app.auth.disableRetry';
|
||||
|
||||
/// 本次请求已经是刷新后的重试,见到它就不再刷新,避免无限循环。
|
||||
static const String retried = 'app.auth.retried';
|
||||
}
|
||||
|
||||
/// 内部信号:响应体里 `code == 401`,应当由 `ApiClient` 刷新令牌后重发一次。
|
||||
///
|
||||
/// 只在本层流转,不会漏给调用方 —— 调用方拿到的始终是 `ApiException`。
|
||||
class TokenExpiredException implements Exception {
|
||||
const TokenExpiredException(this.message);
|
||||
|
||||
/// 后端返回的提示语(可能为空),用于刷新失败时拼 `ApiException` 的消息。
|
||||
final String? message;
|
||||
}
|
||||
|
||||
/// 注入鉴权与设备标识请求头。
|
||||
///
|
||||
/// 每个请求都重新取一次令牌,所以刷新成功后重发的那次会自动带上新令牌。
|
||||
class AuthHeaderInterceptor extends Interceptor {
|
||||
AuthHeaderInterceptor({this.accessTokenReader, this.deviceIdReader});
|
||||
|
||||
/// 当前 access token;未登录返回 null 或空串。
|
||||
final String? Function()? accessTokenReader;
|
||||
|
||||
/// 设备号(`X-Device-Id`):注册风控、下载绑定都用它。
|
||||
final Future<String> Function()? deviceIdReader;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
options.headers['Accept'] = 'application/json';
|
||||
final token = accessTokenReader?.call();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final deviceId = await deviceIdReader?.call();
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
options.headers['X-Device-Id'] = deviceId;
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解包后端统一响应壳 `{code,message,data}`,并把业务码映射成异常。
|
||||
///
|
||||
/// 判定口径与旧的 `http` 实现逐条对齐:
|
||||
/// - body 不是合法 JSON → 视为 `code = -1`,抛 `ApiException(状态码, 'HTTP <状态码>')`
|
||||
/// - `code != 0` → 抛 `ApiException(code, message ?? '请求失败')`
|
||||
/// - `code == 401` 且允许刷新 → 抛 [TokenExpiredException] 作为信号(由 `ApiClient` 刷新后重发)
|
||||
class EnvelopeInterceptor extends Interceptor {
|
||||
const EnvelopeInterceptor();
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
final options = response.requestOptions;
|
||||
final shell = _decodeShell(response.data);
|
||||
final code = shell['code'] as int? ?? -1;
|
||||
final message = shell['message'] as String?;
|
||||
final retried = options.extra[ApiRequestFlags.retried] == true;
|
||||
final disableRetry =
|
||||
options.extra[ApiRequestFlags.disableAuthRetry] == true;
|
||||
|
||||
if (code == 401 && !retried && !disableRetry) {
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: TokenExpiredException(message),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (code == -1) {
|
||||
final status = response.statusCode ?? -1;
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: ApiException(status, 'HTTP $status'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (code != 0) {
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: ApiException(code, message ?? '请求失败'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
response.data = shell['data'];
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
/// 统一按 UTF-8 解码:后端的中文提示语不能因为缺 charset 而乱码。
|
||||
Map<String, dynamic> _decodeShell(Object? body) {
|
||||
final text = switch (body) {
|
||||
List<int> bytes => utf8.decode(bytes, allowMalformed: true),
|
||||
String text => text,
|
||||
_ => '',
|
||||
};
|
||||
try {
|
||||
return (jsonDecode(text) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user