Merge branch 'dev' into happi/dev/database-update
# Conflicts: # apps/im_app/lib/features/login/presentation/login_view_model.dart
This commit is contained in:
@@ -21,9 +21,9 @@ class AuthInterceptor extends Interceptor {
|
||||
options.extra['customHeaders'] as Map<String, String>?;
|
||||
|
||||
// 保留重试请求的原始 Request-ID(幂等性)
|
||||
// 重试时 options.headers 中已有 APP-Request-ID,
|
||||
// 重试时 options.headers 中已有 app-request-id,
|
||||
// 新生成的 headers 会覆盖它导致服务端无法识别为同一请求。
|
||||
final existingRequestId = options.headers['APP-Request-ID'] as String?;
|
||||
final existingRequestId = options.headers['app-request-id'] as String?;
|
||||
|
||||
// 构建 headers
|
||||
final headers = config.defaultHeaders(
|
||||
@@ -33,7 +33,7 @@ class AuthInterceptor extends Interceptor {
|
||||
|
||||
// 还原原始 Request-ID
|
||||
if (existingRequestId != null) {
|
||||
headers['APP-Request-ID'] = existingRequestId;
|
||||
headers['app-request-id'] = existingRequestId;
|
||||
}
|
||||
|
||||
options.headers.addAll(headers);
|
||||
|
||||
@@ -3,8 +3,44 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// 日志拦截器
|
||||
/// 统一打印请求 + 响应(一个日志块)
|
||||
/// 请求/响应日志拦截器
|
||||
///
|
||||
/// 每次请求打一条日志,请求和响应合并输出,方便对照:
|
||||
///
|
||||
/// ```
|
||||
/// [Network] ── SendOtpRequest (auth_repository_impl.dart:45)
|
||||
/// --> POST https://example.com/app/api/auth/otp/send
|
||||
/// Headers:
|
||||
/// {
|
||||
/// "Platform": "Android",
|
||||
/// ...
|
||||
/// }
|
||||
///
|
||||
/// Body:
|
||||
/// {
|
||||
/// "country_code": "+65",
|
||||
/// "contact": "83465308",
|
||||
/// "type": 1
|
||||
/// }
|
||||
/// <-- 200 https://example.com/app/api/auth/otp/send
|
||||
/// {
|
||||
/// "code": 0,
|
||||
/// "message": "ok"
|
||||
/// }
|
||||
/// ────────────────────────────────────────────────────────────
|
||||
///
|
||||
/// [Network ERR] ── SendOtpRequest (auth_repository_impl.dart:45)
|
||||
/// --> POST https://example.com/app/api/auth/otp/send
|
||||
/// ...
|
||||
/// <-- 404 https://example.com/app/api/auth/otp/send
|
||||
/// Type: badResponse
|
||||
///
|
||||
/// <html>...</html>
|
||||
/// ────────────────────────────────────────────────────────────
|
||||
/// ```
|
||||
///
|
||||
/// Header 行显示 Request 类名 + 调用文件:行号,直接定位到出问题的调用代码。
|
||||
/// Footer 行 `────` 视觉上把相邻请求隔开,成对关系一眼看清。
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
final OnLog? onLog;
|
||||
final bool enabled;
|
||||
@@ -14,7 +50,12 @@ class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (enabled && onLog != null) {
|
||||
_logRequestAndResponse(response);
|
||||
_log(
|
||||
opts: response.requestOptions,
|
||||
statusCode: response.statusCode,
|
||||
data: response.data,
|
||||
tag: 'Network',
|
||||
);
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
@@ -22,59 +63,103 @@ class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (enabled && onLog != null) {
|
||||
_logError(err);
|
||||
_log(
|
||||
opts: err.requestOptions,
|
||||
statusCode: err.response?.statusCode,
|
||||
data: err.response?.data,
|
||||
errorType: err.type.name,
|
||||
errorMessage: err.message,
|
||||
tag: 'Network ERR',
|
||||
);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _logRequestAndResponse(Response response) {
|
||||
try {
|
||||
final logData = {
|
||||
'url': response.requestOptions.uri.toString(),
|
||||
'method': response.requestOptions.method,
|
||||
'request': {
|
||||
if (response.requestOptions.data != null)
|
||||
'body': response.requestOptions.data,
|
||||
},
|
||||
'response': {
|
||||
'status': response.statusCode,
|
||||
if (response.data != null) 'body': response.data,
|
||||
},
|
||||
};
|
||||
// ─── Log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
void _log({
|
||||
required RequestOptions opts,
|
||||
required int? statusCode,
|
||||
required dynamic data,
|
||||
String? errorType,
|
||||
String? errorMessage,
|
||||
required String tag,
|
||||
}) {
|
||||
try {
|
||||
final buf = StringBuffer();
|
||||
const footer =
|
||||
'────────────────────────────────────────────────────────────';
|
||||
|
||||
// Header:Request 类名 + 调用位置
|
||||
final className = opts.extra['_requestClass'] as String? ?? '';
|
||||
final callerFrame = opts.extra['_callerFrame'] as String? ?? '';
|
||||
if (className.isNotEmpty) {
|
||||
buf.write('── $className');
|
||||
if (callerFrame.isNotEmpty) {
|
||||
buf.writeln(' ($callerFrame)');
|
||||
} else {
|
||||
buf.writeln();
|
||||
}
|
||||
}
|
||||
|
||||
// Request:headers + body 合并成一个 JSON 块
|
||||
buf.writeln('--> ${opts.method} ${opts.uri}');
|
||||
final requestMap = <String, dynamic>{
|
||||
'headers': _sanitizeHeaders(opts.headers),
|
||||
if (opts.data != null) 'body': opts.data,
|
||||
};
|
||||
buf.writeln(_formatBody(requestMap));
|
||||
|
||||
// Response
|
||||
final status = statusCode != null ? '$statusCode' : 'ERR';
|
||||
buf.writeln('<-- $status ${opts.uri}');
|
||||
if (errorType != null) {
|
||||
buf.writeln('Type: $errorType');
|
||||
}
|
||||
if (data != null) {
|
||||
buf.writeln();
|
||||
buf.writeln(_formatBody(data));
|
||||
} else if (errorMessage != null && errorType == null) {
|
||||
// 无响应 body(超时、断网等),打印错误消息
|
||||
buf.writeln(errorMessage);
|
||||
}
|
||||
|
||||
// Footer
|
||||
buf.write(footer);
|
||||
|
||||
onLog!(buf.toString(), tag: tag);
|
||||
} catch (_) {
|
||||
onLog!(
|
||||
'API Request + Response:\n${encoder.convert(logData)}',
|
||||
tag: 'Network',
|
||||
);
|
||||
} catch (e) {
|
||||
onLog!(
|
||||
'API: ${response.requestOptions.uri} -> ${response.statusCode}',
|
||||
tag: 'Network',
|
||||
'--> ${opts.method} ${opts.uri} | <-- $statusCode',
|
||||
tag: tag,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logError(DioException error) {
|
||||
try {
|
||||
final logData = {
|
||||
'url': error.requestOptions.uri.toString(),
|
||||
'method': error.requestOptions.method,
|
||||
'type': error.type.toString(),
|
||||
'message': error.message,
|
||||
if (error.response != null) ...{
|
||||
'status': error.response!.statusCode,
|
||||
'data': error.response!.data,
|
||||
},
|
||||
};
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
onLog!(
|
||||
'API Error:\n${encoder.convert(logData)}',
|
||||
tag: 'Network',
|
||||
);
|
||||
} catch (e) {
|
||||
onLog!('API Error: ${error.message}', tag: 'Network');
|
||||
/// Authorization token 只保留前 16 位,防止 token 泄露到日志
|
||||
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
|
||||
return headers.map((key, value) {
|
||||
if (key.toLowerCase() == 'authorization' &&
|
||||
value is String &&
|
||||
value.length > 16) {
|
||||
return MapEntry(key, '${value.substring(0, 16)}...');
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
/// 格式化 body,兼容 String / Map / List
|
||||
String _formatBody(dynamic data) {
|
||||
try {
|
||||
if (data is String) {
|
||||
final parsed = jsonDecode(data);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(data);
|
||||
} catch (_) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,24 @@ import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/token_refresh_manager.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// 重试拦截器
|
||||
///
|
||||
/// 两层重试机制:
|
||||
///
|
||||
/// 1. **Token 刷新重试**(onResponse)
|
||||
/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求
|
||||
/// 1. **业务错误码处理**(onResponse)
|
||||
/// 所有非 0 业务码经 [ApiConfig.onBusinessError] 回调,
|
||||
/// App 层返回 [BusinessErrorAction] 枚举告知 SDK 该怎么做:
|
||||
/// - refreshToken → 刷新 token 后重试
|
||||
/// - forceLogout → 中断请求
|
||||
/// - handled → App 已处理,不在 decodeResponse 中抛错
|
||||
/// - unhandled → 透传给调用方,decodeResponse 会抛 ApiError
|
||||
///
|
||||
/// 2. **瞬态错误重试**(onError)
|
||||
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
|
||||
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
|
||||
///
|
||||
/// 另外在 onResponse 中处理强制登出码和业务错误码。
|
||||
///
|
||||
/// 两层独立运作,可叠加。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final ApiConfig config;
|
||||
@@ -35,7 +39,7 @@ class RetryInterceptor extends Interceptor {
|
||||
proactiveRefreshThreshold: config.proactiveRefreshThreshold,
|
||||
);
|
||||
|
||||
// ── 响应处理(Token 过期 / 强制登出 / 业务错误码)──────────────────────
|
||||
// ── 响应处理(所有非 0 业务码统一走 onBusinessError)──────────────────
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
@@ -46,46 +50,56 @@ class RetryInterceptor extends Interceptor {
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final code = _parseCode(data['code']);
|
||||
if (code == 0) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
final message = data['message'] as String? ?? '';
|
||||
final requestPath = response.requestOptions.path;
|
||||
|
||||
// 检查强制登出
|
||||
if (config.forceLogoutCodes.contains(code)) {
|
||||
config.onLog?.call('Force logout detected (code: $code)', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
message: 'Force logout (code: $code)',
|
||||
),
|
||||
);
|
||||
// 未注册 onBusinessError 时直接放行,由 decodeResponse 抛 ApiError 给调用方
|
||||
if (config.onBusinessError == null) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归)
|
||||
if (config.tokenExpiredCodes.contains(code) &&
|
||||
response.requestOptions.extra['_isTokenRetry'] != true) {
|
||||
config.onLog?.call(
|
||||
'Token expired (code: $code), refreshing...',
|
||||
tag: 'Network',
|
||||
);
|
||||
_handleTokenExpired(response, handler);
|
||||
return;
|
||||
}
|
||||
final action = config.onBusinessError!(code, message, requestPath);
|
||||
switch (action) {
|
||||
case BusinessErrorAction.refreshToken:
|
||||
// 跳过已标记为 token 重试的请求,防止递归
|
||||
if (response.requestOptions.extra['_isTokenRetry'] == true) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
config.onLog?.call(
|
||||
'Token expired (code: $code), refreshing...',
|
||||
tag: 'Network',
|
||||
);
|
||||
_handleTokenExpired(response, handler);
|
||||
|
||||
// 业务错误码拦截:非 0 且不在特殊码集合中
|
||||
if (code != 0 && config.onBusinessError != null) {
|
||||
final handled = config.onBusinessError!(code, message, requestPath);
|
||||
if (handled) {
|
||||
// App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
|
||||
case BusinessErrorAction.forceLogout:
|
||||
config.onLog?.call(
|
||||
'Force logout (code: $code)',
|
||||
tag: 'Network',
|
||||
);
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
message: 'Force logout (code: $code)',
|
||||
),
|
||||
);
|
||||
|
||||
case BusinessErrorAction.handled:
|
||||
// App 层已处理(弹窗 / Toast)→ 标记,让 decodeResponse 跳过二次抛错
|
||||
response.requestOptions.extra['_businessErrorHandled'] = true;
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
case BusinessErrorAction.unhandled:
|
||||
// 未处理,decodeResponse 会抛 ApiError 给调用方
|
||||
handler.next(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 Token 过期:刷新 + 重试
|
||||
@@ -97,7 +111,6 @@ class RetryInterceptor extends Interceptor {
|
||||
|
||||
if (newToken == null) {
|
||||
config.onLog?.call('Token refresh failed', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
|
||||
@@ -58,6 +58,9 @@ class NetworksSdkMethodChannelDataSource {
|
||||
ApiRequestable<T> request, {
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
// 在首个 await 前捕获调用栈,async 间隙后栈信息会丢失
|
||||
final callerFrame = _callerFrame(StackTrace.current);
|
||||
|
||||
await _checkNetwork(request.path);
|
||||
|
||||
try {
|
||||
@@ -76,6 +79,8 @@ class NetworksSdkMethodChannelDataSource {
|
||||
'requestType': request.requestType,
|
||||
'includeToken': request.includeToken,
|
||||
'customHeaders': request.customHeaders,
|
||||
'_requestClass': request.runtimeType.toString(),
|
||||
'_callerFrame': callerFrame,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -275,6 +280,38 @@ class NetworksSdkMethodChannelDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从调用栈提取 App 层第一个帧(文件名:行号),用于日志定位
|
||||
///
|
||||
/// 仅 debug 模式栈帧可读;release 模式返回空字符串(由日志层静默忽略)。
|
||||
static String _callerFrame(StackTrace stack) {
|
||||
try {
|
||||
for (final line in stack.toString().split('\n')) {
|
||||
// 沿用已验证有效的过滤条件,不改动
|
||||
if (line.isEmpty ||
|
||||
line.contains('package:networks_sdk') ||
|
||||
line.contains('package:dio') ||
|
||||
line.contains('(dart:') || // dart: 内部帧格式是 (dart:async/...),不能用 'dart:' 否则会误杀 .dart:LINE
|
||||
line.contains('<asynchronous')) {
|
||||
continue;
|
||||
}
|
||||
// 用简单 regex 匹配 (URI:LINE:COL),已被证明可在 Android 上正常工作
|
||||
final fileMatch = RegExp(r'\((.+):(\d+):\d+\)').firstMatch(line);
|
||||
if (fileMatch == null) { continue; }
|
||||
|
||||
final fileName = fileMatch.group(1)!.split('/').last;
|
||||
final lineNum = fileMatch.group(2)!;
|
||||
|
||||
// 从括号前的文本里取最后一个单词作为 Symbol(可能带 #N 前缀)
|
||||
final before = line.substring(0, fileMatch.start).trim();
|
||||
final symbolMatch = RegExp(r'(\S+)$').firstMatch(before);
|
||||
final symbol = symbolMatch?.group(1) ?? '';
|
||||
|
||||
return symbol.isNotEmpty ? '$symbol · $fileName:$lineNum' : '$fileName:$lineNum';
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 应用响应变换(如果 App 层注入了 onTransformResponse)
|
||||
void _applyResponseTransform(Response response) {
|
||||
final transform = apiClient.config.onTransformResponse;
|
||||
|
||||
@@ -446,12 +446,12 @@ class SocketClient {
|
||||
// ping/pong 是传输层心跳,不经过业务加解密
|
||||
// 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
|
||||
_channel?.sink.add('ping');
|
||||
_log('♥ ping');
|
||||
_log('♥️ ping');
|
||||
|
||||
// 启动 pong 超时计时器
|
||||
_pongTimeoutTimer = Timer(config.pongTimeout, () {
|
||||
if (_waitingForPong) {
|
||||
_log('♥ pong timeout, reconnecting...');
|
||||
_log('♥️ pong timeout, reconnecting...');
|
||||
_waitingForPong = false;
|
||||
_emitError(const SocketError.pingTimeout());
|
||||
_doDisconnect(reason: 'Pong timeout');
|
||||
@@ -461,7 +461,7 @@ class SocketClient {
|
||||
}
|
||||
|
||||
void _onPongReceived() {
|
||||
_log('♥ pong');
|
||||
_log('♥️ pong');
|
||||
_waitingForPong = false;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
|
||||
@@ -94,10 +94,9 @@ abstract class ApiRequestable<T> {
|
||||
final fromJsonFunc = fromJsonRegistry[T];
|
||||
|
||||
if (fromJsonFunc == null) {
|
||||
throw StateError(
|
||||
'fromJson not registered for type $T. '
|
||||
'Add: final _reg = registerResponse<$T>($T.fromJson);',
|
||||
);
|
||||
// void 接口:生成器不注册 fromJson,服务端 data 字段直接忽略
|
||||
// ignore: null_check_on_nullable_type_parameter
|
||||
return null as dynamic;
|
||||
}
|
||||
|
||||
if (fromJsonFunc is T Function(Object?)) {
|
||||
|
||||
@@ -97,7 +97,24 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
|
||||
}
|
||||
|
||||
final className = element.name!;
|
||||
final path = annotation.read('path').stringValue;
|
||||
|
||||
// 尝试保留原始常量引用(如 ApiPaths.authSendOtp),
|
||||
// 这样修改 ApiPaths 里的值不需要重新跑 gen。
|
||||
// 若注解传的是字面量字符串,则回退为带引号字符串。
|
||||
final pathObject = annotation.read('path').objectValue;
|
||||
final pathVariable = pathObject.variable;
|
||||
final String pathExpression;
|
||||
if (pathVariable != null) {
|
||||
final enclosing = pathVariable.enclosingElement;
|
||||
if (enclosing is ClassElement) {
|
||||
pathExpression = '${enclosing.name}.${pathVariable.name!}';
|
||||
} else {
|
||||
pathExpression = pathVariable.name!;
|
||||
}
|
||||
} else {
|
||||
final pathValue = pathObject.toStringValue()!;
|
||||
pathExpression = "'$pathValue'";
|
||||
}
|
||||
|
||||
// 读取 HttpMethod 枚举值
|
||||
final methodName = _readEnumName(
|
||||
@@ -133,17 +150,21 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
|
||||
|
||||
// 有响应类型:parameters getter 中注册 fromJson(使用生成的私有函数)
|
||||
// ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson,同 library 可访问
|
||||
// 无响应类型(void):跳过注册,直接返回 super.parameters
|
||||
final parametersBody = hasResponseType
|
||||
? ''' registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
|
||||
return super.parameters;'''
|
||||
: ' return super.parameters;';
|
||||
// 无响应类型(void):无需注册,不生成 parameters getter(避免 unnecessary_override)
|
||||
final parametersGetter = hasResponseType
|
||||
? '''
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
|
||||
return super.parameters;
|
||||
}'''
|
||||
: '';
|
||||
|
||||
return '''
|
||||
/// Generated by @ApiRequest for [$className]
|
||||
mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
String get path => '$path';
|
||||
String get path => $pathExpression;
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.$methodName;
|
||||
@override
|
||||
@@ -151,11 +172,7 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
bool get includeToken => $includeToken;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => $toJsonBody;
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
$parametersBody
|
||||
}
|
||||
Map<String, dynamic> toJson() => $toJsonBody;$parametersGetter
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
@@ -190,6 +190,9 @@ $params );
|
||||
if (type.isDartCoreDouble) return '$access as double$q';
|
||||
if (type.isDartCoreNum) return '$access as num$q';
|
||||
|
||||
// Map<String, dynamic>:已经是 JSON Map,直接 cast,无需 fromJson
|
||||
if (type.isDartCoreMap) return '$access as Map<String, dynamic>$q';
|
||||
|
||||
// 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数
|
||||
if (type is InterfaceType) {
|
||||
final typeName = type.element.name!;
|
||||
|
||||
@@ -17,9 +17,6 @@ class ApiConfig {
|
||||
/// Token 过期时的刷新回调
|
||||
final OnTokenRefresh? onTokenRefresh;
|
||||
|
||||
/// 需要强制登出时的回调
|
||||
final OnForceLogout? onForceLogout;
|
||||
|
||||
/// Token 更新后的通知回调
|
||||
///
|
||||
/// 在 [updateToken] 被调用且新 token 非空时触发。
|
||||
@@ -61,14 +58,6 @@ class ApiConfig {
|
||||
/// `{ code, data, message }` 结构。返回 null 表示不变换。
|
||||
final OnTransformResponse? onTransformResponse;
|
||||
|
||||
// ── 错误码集合 ──
|
||||
|
||||
/// App 层定义的 Token 过期错误码集合
|
||||
final Set<int> tokenExpiredCodes;
|
||||
|
||||
/// App 层定义的强制登出错误码集合
|
||||
final Set<int> forceLogoutCodes;
|
||||
|
||||
// ── 重试配置 ──
|
||||
|
||||
/// 瞬态错误最大重试次数(5xx / 超时 / 连接失败)
|
||||
@@ -110,7 +99,6 @@ class ApiConfig {
|
||||
this.token,
|
||||
this.platformHeaders = const {},
|
||||
this.onTokenRefresh,
|
||||
this.onForceLogout,
|
||||
this.onTokenUpdated,
|
||||
this.onLog,
|
||||
this.onCheckNetworkAvailable,
|
||||
@@ -118,8 +106,6 @@ class ApiConfig {
|
||||
this.onDecryptResponse,
|
||||
this.onBusinessError,
|
||||
this.onTransformResponse,
|
||||
this.tokenExpiredCodes = const {},
|
||||
this.forceLogoutCodes = const {},
|
||||
this.maxRetries = 0,
|
||||
this.retryBaseDelay = const Duration(seconds: 1),
|
||||
this.tokenRefreshTimeout = const Duration(seconds: 10),
|
||||
@@ -136,12 +122,12 @@ class ApiConfig {
|
||||
Map<String, String>? customHeaders,
|
||||
}) {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Accept': 'application/json',
|
||||
'Keep-Alive': 'timeout=60',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'accept': 'application/json',
|
||||
'keep-alive': 'timeout=60',
|
||||
// Unix 时间戳(秒),整数值,非格式化日期字符串
|
||||
'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
|
||||
'APP-Request-ID': _generateRequestId(),
|
||||
'timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
|
||||
'app-request-id': _generateRequestId(),
|
||||
};
|
||||
|
||||
// 合并平台 headers(App 层注入的 version、platform 等)
|
||||
|
||||
@@ -8,9 +8,6 @@ import 'package:networks_sdk/src/domain/entities/encrypted_request.dart';
|
||||
/// Token 刷新回调,返回新 token;返回 null 表示刷新失败
|
||||
typedef OnTokenRefresh = Future<String?> Function();
|
||||
|
||||
/// 强制登出回调
|
||||
typedef OnForceLogout = void Function();
|
||||
|
||||
// ── Token 生命周期 ──
|
||||
|
||||
/// 获取 token 过期时间
|
||||
@@ -73,11 +70,27 @@ typedef OnDecryptResponse =
|
||||
|
||||
// ── 业务错误 ──
|
||||
|
||||
/// 业务错误拦截回调
|
||||
/// SDK 层收到 App 层对业务错误码的处置指令
|
||||
enum BusinessErrorAction {
|
||||
/// 刷新 token 后重试原请求(原 tokenExpiredCodes 行为)
|
||||
refreshToken,
|
||||
|
||||
/// 强制登出,中断当前请求(原 forceLogoutCodes 行为)
|
||||
forceLogout,
|
||||
|
||||
/// App 层已处理(如全局弹窗/Toast),SDK 正常放行响应,不在 decodeResponse 中抛错
|
||||
handled,
|
||||
|
||||
/// 未处理,SDK 继续正常流程,decodeResponse 会抛 ApiError 给调用方
|
||||
unhandled,
|
||||
}
|
||||
|
||||
/// 业务错误统一回调
|
||||
///
|
||||
/// App 层统一处理特定错误码,返回 true = 已处理(SDK 不再抛错),
|
||||
/// 返回 false = 未处理(SDK 继续正常流程)。
|
||||
typedef OnBusinessError = bool Function(int code, String message, String path);
|
||||
/// 所有非 0 业务码(token 过期、强制登出、踢下线、普通业务错误)全部经此入口,
|
||||
/// App 层通过返回 [BusinessErrorAction] 告诉 SDK 该怎么做。
|
||||
typedef OnBusinessError =
|
||||
BusinessErrorAction Function(int code, String message, String path);
|
||||
|
||||
/// 响应变换回调
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user