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:
Happi (哈比)
2026-03-09 20:17:03 +08:00
66 changed files with 1749 additions and 556 deletions

View File

@@ -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);

View File

@@ -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 =
'────────────────────────────────────────────────────────────';
// HeaderRequest 类名 + 调用位置
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();
}
}
// Requestheaders + 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();
}
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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?)) {

View File

@@ -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
}
''';
}

View File

@@ -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!;

View File

@@ -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(),
};
// 合并平台 headersApp 层注入的 version、platform 等)

View File

@@ -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 层已处理(如全局弹窗/ToastSDK 正常放行响应,不在 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);
/// 响应变换回调
///