Merge remote-tracking branch 'origin/dev' into cody/netwrok_SDK
# Conflicts: # apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart # apps/im_app/lib/features/login/presentation/login_view_model.dart 修复逻辑漏洞,性能优化
This commit is contained in:
@@ -9,7 +9,14 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
/// REST API 客户端
|
||||
/// 基于 Dio,提供请求执行入口
|
||||
///
|
||||
/// 拦截器链顺序:Auth → Encryption → 自定义 → Retry → Logging
|
||||
/// 拦截器链顺序(onRequest):Auth → 自定义 → Retry → Logging → Encryption
|
||||
///
|
||||
/// Dio 的 onResponse / onError 按 **逆序** 执行,因此实际响应处理为:
|
||||
/// `Encryption(解密) → Logging → Retry(业务码判断) → 自定义 → Auth`
|
||||
///
|
||||
/// EncryptionInterceptor 放最后,保证:
|
||||
/// - onRequest 最后加密(其他拦截器操作明文)
|
||||
/// - onResponse 最先解密(其他拦截器看到明文,业务码判断正常工作)
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
@@ -31,13 +38,15 @@ class ApiClient {
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
);
|
||||
|
||||
// 挂载拦截器(顺序:Auth → Encryption → 自定义 → Retry → Logging)
|
||||
// 挂载拦截器
|
||||
// onRequest 顺序:Auth → 自定义 → Retry → Logging → Encryption
|
||||
// onResponse 逆序:Encryption(解密) → Logging → Retry(业务码) → 自定义 → Auth
|
||||
_dio.interceptors.addAll([
|
||||
AuthInterceptor(config),
|
||||
EncryptionInterceptor(config),
|
||||
if (additionalInterceptors != null) ...additionalInterceptors,
|
||||
RetryInterceptor(config: config, dio: _dio),
|
||||
LoggingInterceptor(onLog: config.onLog),
|
||||
EncryptionInterceptor(config),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
/// 加密拦截器(预留给 cipher_guard_sdk)
|
||||
///
|
||||
/// 在拦截器链中位于 Auth 之后、Retry 之前:
|
||||
/// `Auth → Encryption → Custom → Retry → Logging`
|
||||
/// 在拦截器链中位于最末位:
|
||||
/// onRequest 顺序:`Auth → Custom → Retry → Logging → Encryption`
|
||||
/// onResponse 逆序:`Encryption(解密) → Logging → Retry(业务码) → Custom → Auth`
|
||||
///
|
||||
/// 放最后是因为 Dio onResponse 按逆序执行——加密拦截器最先解密,
|
||||
/// 后续的 RetryInterceptor 才能正确判断业务错误码、Token 过期等。
|
||||
///
|
||||
/// 回调为 null 时自动跳过,不影响正常请求流程。
|
||||
/// 后续 cipher_guard_sdk 接入后,App 层在 ApiConfig 中注入
|
||||
@@ -20,7 +24,23 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
///
|
||||
/// 加密回调接收原始 path、headers、body,返回 [EncryptedRequest],
|
||||
/// 拦截器根据非 null 字段覆盖请求。
|
||||
///
|
||||
/// ## Token 重试与重新加密
|
||||
///
|
||||
/// 瞬态错误重试(5xx / 超时):复用已加密的请求,不重复加密。
|
||||
/// Token 刷新重试:加密 headers 可能包含过期 token 衍生值
|
||||
/// (如 X-Token、X-Signature),需要恢复加密前状态并用新 token 重新加密。
|
||||
/// RetryInterceptor 通过 `_needsReEncryption` 标记通知本拦截器重新加密。
|
||||
class EncryptionInterceptor extends Interceptor {
|
||||
/// extra 标记键:请求已加密,瞬态重试时跳过
|
||||
static const _encryptedKey = '__encrypted__';
|
||||
|
||||
/// extra 标记键:加密前的请求快照(path / body / contentType)
|
||||
static const _preEncryptSnapshotKey = '__preEncryptSnapshot__';
|
||||
|
||||
/// extra 标记键:加密回调注入的 header key 列表
|
||||
static const _encryptionAddedHeadersKey = '__encryptionAddedHeaders__';
|
||||
|
||||
final ApiConfig _config;
|
||||
|
||||
EncryptionInterceptor(this._config);
|
||||
@@ -36,7 +56,28 @@ class EncryptionInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Token 重试 + 已加密 → 恢复加密前状态,用新 token 上下文重新加密
|
||||
// 旧的加密 headers(如 X-Token、X-Signature)可能包含过期 token 信息
|
||||
if (options.extra[_encryptedKey] == true &&
|
||||
options.extra['_needsReEncryption'] == true) {
|
||||
_restorePreEncryptState(options);
|
||||
options.extra.remove('_needsReEncryption');
|
||||
}
|
||||
|
||||
// 已加密(瞬态错误重试)→ 复用加密请求,不重复加密
|
||||
if (options.extra[_encryptedKey] == true) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 保存加密前快照,Token 重试时恢复
|
||||
options.extra[_preEncryptSnapshotKey] = {
|
||||
'path': options.path,
|
||||
'data': options.data,
|
||||
'contentType': options.contentType,
|
||||
};
|
||||
|
||||
// 收集当前 headers(转为 Map<String, String>)
|
||||
final currentHeaders = <String, String>{};
|
||||
options.headers.forEach((key, value) {
|
||||
@@ -54,11 +95,17 @@ class EncryptionInterceptor extends Interceptor {
|
||||
}
|
||||
if (result.headers != null) {
|
||||
options.headers.addAll(result.headers!);
|
||||
// 记录加密注入的 header key,Token 重试时移除
|
||||
options.extra[_encryptionAddedHeadersKey] = result.headers!.keys
|
||||
.toList();
|
||||
}
|
||||
if (result.contentType != null) {
|
||||
options.contentType = result.contentType;
|
||||
}
|
||||
|
||||
// 标记已加密,防止瞬态重试时重复加密
|
||||
options.extra[_encryptedKey] = true;
|
||||
|
||||
_config.onLog?.call(
|
||||
'Request encrypted: ${options.path}',
|
||||
tag: 'Encryption',
|
||||
@@ -76,6 +123,41 @@ class EncryptionInterceptor extends Interceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复加密前的请求状态
|
||||
///
|
||||
/// Token 重试场景:旧的加密数据(path / body / headers)可能包含过期 token,
|
||||
/// 需要恢复原始状态后用新 token 上下文重新加密。
|
||||
///
|
||||
/// AuthInterceptor 已在本轮重试中注入了新 token headers,
|
||||
/// 这里只需移除上次加密注入的 headers(如 X-Token、X-Signature),
|
||||
/// 保留 Auth 设置的新 token。
|
||||
void _restorePreEncryptState(RequestOptions options) {
|
||||
final snapshot =
|
||||
options.extra[_preEncryptSnapshotKey] as Map<String, dynamic>?;
|
||||
if (snapshot != null) {
|
||||
options.path = snapshot['path'] as String;
|
||||
options.data = snapshot['data'];
|
||||
options.contentType = snapshot['contentType'] as String?;
|
||||
}
|
||||
|
||||
// 移除上次加密注入的 headers
|
||||
final addedHeaders = options.extra[_encryptionAddedHeadersKey] as List?;
|
||||
if (addedHeaders != null) {
|
||||
for (final key in addedHeaders) {
|
||||
options.headers.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除加密标记和快照
|
||||
options.extra.remove(_encryptedKey);
|
||||
options.extra.remove(_encryptionAddedHeadersKey);
|
||||
|
||||
_config.onLog?.call(
|
||||
'Pre-encrypt state restored for token retry',
|
||||
tag: 'Encryption',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) async {
|
||||
final decrypt = _config.onDecryptResponse;
|
||||
@@ -90,6 +172,14 @@ class EncryptionInterceptor extends Interceptor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 响应已是 Map → 未加密(health check、非加密端点等),跳过解密
|
||||
// 加密模式下响应通常是 String(base64)或 List<int>(bytes)
|
||||
// TODO: 接入加密后,若服务端所有端点都加密,可移除此判断
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decrypted = await decrypt(response.data as Object);
|
||||
response.data = decrypted;
|
||||
|
||||
@@ -78,7 +78,8 @@ class RetryInterceptor extends Interceptor {
|
||||
if (code != 0 && config.onBusinessError != null) {
|
||||
final handled = config.onBusinessError!(code, message, requestPath);
|
||||
if (handled) {
|
||||
// App 层已处理,正常传递响应
|
||||
// App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
|
||||
response.requestOptions.extra['_businessErrorHandled'] = true;
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
@@ -115,6 +116,9 @@ class RetryInterceptor extends Interceptor {
|
||||
options.headers['token'] = newToken;
|
||||
// 标记为 token 重试请求,防止重试后再次进入 _handleTokenExpired 造成递归
|
||||
options.extra['_isTokenRetry'] = true;
|
||||
// 通知 EncryptionInterceptor:token 变了,需要用新 token 上下文重新加密
|
||||
// 旧的加密 headers(如 X-Token、X-Signature)可能包含过期 token 信息
|
||||
options.extra['_needsReEncryption'] = true;
|
||||
|
||||
final retryResponse = await dio.fetch(options);
|
||||
handler.resolve(retryResponse);
|
||||
|
||||
@@ -194,6 +194,9 @@ class NetworksSdkMethodChannelDataSource {
|
||||
///
|
||||
/// Dio.download 内部用 FileMode.write(从头覆盖),无法正确续传。
|
||||
/// 这里手动读流并追加写入文件。
|
||||
///
|
||||
/// 如果服务端不支持 Range 请求(返回 200 而非 206),
|
||||
/// 自动回退为覆盖写入,防止文件损坏。
|
||||
Future<void> _downloadWithResume({
|
||||
required String url,
|
||||
required String savePath,
|
||||
@@ -215,14 +218,33 @@ class NetworksSdkMethodChannelDataSource {
|
||||
final stream = response.data?.stream;
|
||||
if (stream == null) return;
|
||||
|
||||
// Content-Length 是本次传输量(不含已下载部分)
|
||||
// 检查服务端是否接受了 Range 请求
|
||||
// 206 = 支持续传,追加写入
|
||||
// 200 = 不支持 Range,返回完整文件,需要覆盖写入
|
||||
final isPartialContent = response.statusCode == 206;
|
||||
final effectiveStartBytes = isPartialContent ? startBytes : 0;
|
||||
|
||||
if (!isPartialContent) {
|
||||
apiClient.config.onLog?.call(
|
||||
'Server does not support Range, falling back to full download',
|
||||
tag: 'Download',
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Length 是本次传输量
|
||||
final contentLength =
|
||||
int.tryParse(response.headers.value('content-length') ?? '') ?? -1;
|
||||
final totalBytes = contentLength > 0 ? contentLength + startBytes : -1;
|
||||
final totalBytes = contentLength > 0
|
||||
? contentLength + effectiveStartBytes
|
||||
: -1;
|
||||
|
||||
final file = File(savePath);
|
||||
final raf = file.openSync(mode: FileMode.writeOnlyAppend);
|
||||
int received = startBytes;
|
||||
// 不支持续传时用 write 覆盖,支持时用 append 追加
|
||||
final fileMode = isPartialContent
|
||||
? FileMode.writeOnlyAppend
|
||||
: FileMode.writeOnly;
|
||||
final raf = file.openSync(mode: fileMode);
|
||||
int received = effectiveStartBytes;
|
||||
|
||||
try {
|
||||
await for (final chunk in stream) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
/// - Stream 输出(JSON 消息、原始字符串、二进制、连接状态、错误)
|
||||
/// - 生命周期感知(前后台切换)
|
||||
/// - Token 热更新(不断连)
|
||||
/// - 消息加密/解密钩子(预留给 cipher_guard_sdk)
|
||||
/// - 消息加密/解密钩子(预留给 cipher_guard_sdk,ping/pong 走明文不加密)
|
||||
///
|
||||
/// ## 使用方式
|
||||
///
|
||||
@@ -60,6 +60,15 @@ class SocketClient {
|
||||
Timer? _reconnectTimer;
|
||||
final _random = Random();
|
||||
|
||||
// ── 消息处理 ──
|
||||
|
||||
/// 异步消息处理链,保证解密场景下消息按到达顺序处理
|
||||
///
|
||||
/// 无解密回调时不使用(同步处理,天然有序)。
|
||||
/// 有解密回调时,每条消息的处理链在前一条之后执行,
|
||||
/// 即使解密耗时不同也不会乱序。
|
||||
Future<void>? _messageProcessingChain;
|
||||
|
||||
// ── Stream Controllers ──
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _rawMessageController = StreamController<String>.broadcast();
|
||||
@@ -324,45 +333,73 @@ class SocketClient {
|
||||
// 内部 — 消息处理
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _handleMessage(dynamic data) async {
|
||||
// 二进制消息
|
||||
void _handleMessage(dynamic data) {
|
||||
// 二进制消息不需要解密,直接分发
|
||||
if (data is List<int>) {
|
||||
_binaryMessageController.add(
|
||||
data is Uint8List ? data : Uint8List.fromList(data),
|
||||
);
|
||||
if (!_binaryMessageController.isClosed) {
|
||||
_binaryMessageController.add(
|
||||
data is Uint8List ? data : Uint8List.fromList(data),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data is! String) {
|
||||
_rawMessageController.add(data.toString());
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(data.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 解密(如果配置了解密回调)
|
||||
String text = data;
|
||||
if (config.onDecryptMessage != null) {
|
||||
try {
|
||||
text = await config.onDecryptMessage!(data);
|
||||
} catch (e) {
|
||||
_log('Message decryption failed: $e');
|
||||
_rawMessageController.add(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 pong 心跳回复(解密后检查,加密场景下也能正确匹配)
|
||||
if (text == 'pong') {
|
||||
// pong 是传输层心跳,不经过业务加解密,直接匹配
|
||||
if (data == 'pong') {
|
||||
_onPongReceived();
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试 JSON 解析
|
||||
if (config.onDecryptMessage != null) {
|
||||
// 有解密回调 → 链式异步处理,保证消息按到达顺序分发
|
||||
// 避免解密耗时不同导致后到的消息先完成解密、先分发
|
||||
final previous = _messageProcessingChain ?? Future.value();
|
||||
_messageProcessingChain = previous.then((_) => _processTextMessage(data));
|
||||
} else {
|
||||
// 无解密回调 → 同步处理,天然有序
|
||||
_dispatchTextMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步处理文本消息(解密 → 分发)
|
||||
Future<void> _processTextMessage(String data) async {
|
||||
// dispose 期间可能有残留的链式任务,直接跳过
|
||||
if (_messageController.isClosed) return;
|
||||
|
||||
String text;
|
||||
try {
|
||||
text = await config.onDecryptMessage!(data);
|
||||
} catch (e) {
|
||||
_log('Message decryption failed: $e');
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_dispatchTextMessage(text);
|
||||
}
|
||||
|
||||
/// 分发文本消息(JSON 解析 → 投递到对应 stream)
|
||||
///
|
||||
/// pong 已在 `_handleMessage` 中提前拦截,不会到这里。
|
||||
void _dispatchTextMessage(String text) {
|
||||
try {
|
||||
final json = jsonDecode(text) as Map<String, dynamic>;
|
||||
_messageController.add(json);
|
||||
if (!_messageController.isClosed) {
|
||||
_messageController.add(json);
|
||||
}
|
||||
} catch (_) {
|
||||
// JSON 解析失败,走原始消息流
|
||||
_rawMessageController.add(text);
|
||||
if (!_rawMessageController.isClosed) {
|
||||
_rawMessageController.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,26 +438,20 @@ class SocketClient {
|
||||
_waitingForPong = false;
|
||||
}
|
||||
|
||||
void _sendPing() async {
|
||||
void _sendPing() {
|
||||
if (_waitingForPong) return;
|
||||
|
||||
_waitingForPong = true;
|
||||
|
||||
// 加密场景下 ping 也要加密,与 pong 解密对称
|
||||
String pingPayload = 'ping';
|
||||
if (config.onEncryptMessage != null) {
|
||||
try {
|
||||
pingPayload = await config.onEncryptMessage!('ping');
|
||||
} catch (e) {
|
||||
_log('Ping encryption failed: $e');
|
||||
}
|
||||
}
|
||||
_channel?.sink.add(pingPayload);
|
||||
// ping/pong 是传输层心跳,不经过业务加解密
|
||||
// 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
|
||||
_channel?.sink.add('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');
|
||||
@@ -430,6 +461,7 @@ class SocketClient {
|
||||
}
|
||||
|
||||
void _onPongReceived() {
|
||||
_log('♥ pong');
|
||||
_waitingForPong = false;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
@@ -488,7 +520,9 @@ class SocketClient {
|
||||
try {
|
||||
await config.onBeforeReconnect!();
|
||||
} catch (e) {
|
||||
_log('onBeforeReconnect failed: $e, skip this reconnect');
|
||||
_log('onBeforeReconnect failed: $e, schedule next reconnect');
|
||||
// 重置状态以允许下次 _startReconnect 进入(防止卡死在 reconnecting)
|
||||
_updateConnectionState(SocketConnectionState.disconnected);
|
||||
_startReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user