diff --git a/Doc/IM_App_架构设计.html b/Doc/IM_App_架构设计.html index bfedfb8..698760e 100644 --- a/Doc/IM_App_架构设计.html +++ b/Doc/IM_App_架构设计.html @@ -427,6 +427,14 @@ • 日志与监控系统 Part 7:总结 + + Part 8:UI 设计规范 + • 核心约定 + • 颜色体系 + • 字体体系 + • 组件 — Button + • 业务弹框 — Dialog + • 图标规范 @@ -878,31 +886,36 @@ flowchart TD │ ├── annotations/ │ │ └── api_request.dart # @ApiRequest 注解定义 │ ├── generator/ -│ │ ├── api_request_generator.dart # build_runner 代码生成器实现 -│ │ └── builder.dart # SharedPartBuilder 入口 +│ │ ├── api_request_generator.dart # Request mixin 生成器(toJson / path / method / fromJson 注册) +│ │ ├── api_response_generator.dart # Response fromJson 生成器(从 @ApiRequest(responseType) 自动推导,递归嵌套类型) +│ │ └── builder.dart # SharedPartBuilder 入口(两个生成器合并到同一 .g.dart) │ ├── data/ │ │ ├── datasources/ │ │ │ ├── http/ -│ │ │ │ ├── api_client.dart # Dio REST 客户端 +│ │ │ │ ├── api_client.dart # Dio REST 客户端 +│ │ │ │ ├── token_refresh_manager.dart # Token 刷新管理(竞态安全 + 超时 + 时间窗口复用) │ │ │ │ └── interceptor/ -│ │ │ │ ├── auth_interceptor.dart # Token + 默认 header 注入 -│ │ │ │ ├── retry_interceptor.dart # Token 刷新 + 瞬态错误重试 -│ │ │ │ └── logging_interceptor.dart # 请求/响应日志 -│ │ │ └── socket/ -│ │ │ └── socket_client.dart # WebSocket 长连接(心跳/重连/Stream) +│ │ │ │ ├── auth_interceptor.dart # Token + 默认 header 注入 +│ │ │ │ ├── encryption_interceptor.dart # 加密拦截器(预留给 cipher_guard_sdk) +│ │ │ │ ├── retry_interceptor.dart # Token 刷新 + 瞬态错误重试 + 业务错误钩子 +│ │ │ │ └── logging_interceptor.dart # 请求/响应日志 +│ │ │ ├── socket/ +│ │ │ │ └── socket_client.dart # WebSocket 长连接(心跳/重连/Stream/加密钩子) +│ │ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口 │ │ ├── dto/ │ │ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表 -│ │ │ └── api_response_wrapper.dart # { code, message/msg, data } 信封解析 +│ │ │ └── api_response_wrapper.dart # { code, message/msg, data } 响应包装解析 │ │ └── repositories/ │ │ ├── networks_sdk_repository_impl.dart │ │ └── networks_messaging_repository_impl.dart │ ├── domain/ │ │ ├── entities/ -│ │ │ ├── api_error.dart # @freezed HTTP 错误联合类型 +│ │ │ ├── api_error.dart # @freezed HTTP 错误联合类型(7 变体) +│ │ │ ├── encrypted_request.dart # 加密请求结果数据类 │ │ │ ├── socket_error.dart # @freezed WebSocket 错误联合类型 │ │ │ ├── socket_connection_state.dart # 连接状态 enum │ │ │ ├── http_method.dart # GET/POST/PUT/DELETE/PATCH -│ │ │ └── api_request_type.dart # request/login/upload +│ │ │ └── api_request_type.dart # request/login/upload/stream/download │ │ └── repositories/ │ │ ├── networks_sdk_repository.dart │ │ └── networks_messaging_repository.dart @@ -911,9 +924,9 @@ flowchart TD │ │ ├── networks_sdk_api.dart # HTTP 公开 API 接口 │ │ └── networks_messaging_api.dart # WebSocket 公开 API 接口 │ └── wiring/ -│ ├── api_config.dart # HTTP 配置(baseURL/Token/回调) -│ ├── socket_config.dart # WebSocket 配置(心跳/重连策略) -│ ├── network_callbacks.dart # 回调类型定义 +│ ├── api_config.dart # HTTP 配置(baseURL/Token/回调/加密/重试) +│ ├── socket_config.dart # WebSocket 配置(心跳/重连/加密钩子) +│ ├── network_callbacks.dart # 回调类型定义(认证/加密/业务错误/下载/WS) │ ├── networks_sdk_core.dart │ ├── networks_sdk_api_impl.dart │ ├── networks_messaging_api_impl.dart @@ -1670,7 +1683,7 @@ final viewModel = ref.read(chatViewModelProvider.notifier); // viewModel 类型
// Riverpod DevTools 可以看到完整的依赖图
 ChatViewModel
   ├─ chatRepositoryProvider
-  │   ├─ apiClientProvider
+  │   ├─ networkSdkApiProvider
   │   └─ messageLocalDataSourceProvider
   └─ sendMessageUseCaseProvider
       └─ chatRepositoryProvider
@@ -2187,8 +2200,8 @@ extension APIRequestableDefaults<T> on APIRequestable<T> {
 
 
/// 执行 API 请求 - 唯一的请求入口
 Future<T?> executeRequest<T>(Ref ref, APIRequestable<T> request) async {
-  final dio = ref.read(apiClientProvider);
-  final config = ref.read(aPIConfigurationProvider);
+  final client = ref.read(networkSdkApiProvider);
+  final config = ref.read(apiConfigProvider);
 
   // 1. 检查网络连接
   if (!networkManager.isNetworkAvailable) {
@@ -2274,53 +2287,48 @@ extension APIRequestableExtension<T> on APIRequestable<T> {
 
 

一个端点 = 一个文件(data/remote/login_request.dart),Response DTO + Request 放在同一文件中。

-
import 'package:json_annotation/json_annotation.dart';
-import 'package:networks_sdk/networks_sdk.dart';
+
import 'package:networks_sdk/networks_sdk.dart';
 
 part 'login_request.g.dart';
 
-// ── Response DTO ──
+// ── Response DTO(纯 Dart 类,零注解,零样板)──
+// _$LoginResponseFromJson 由 ApiResponseGenerator 从 @ApiRequest(responseType: T) 自动推导生成
 
-@JsonSerializable()
-class LoginData {
+class LoginResponse {
   final String token;
-  @JsonKey(name: 'user_id')
+  @JsonKey(name: 'user_id')    // @JsonKey 由生成器读取,Response 类不需要 @JsonSerializable
   final String userId;
   final String email;
 
-  const LoginData({required this.token, required this.userId, required this.email});
-  factory LoginData.fromJson(Map<String, dynamic> json) => _$LoginDataFromJson(json);
-  Map<String, dynamic> toJson() => _$LoginDataToJson(this);
+  const LoginResponse({required this.token, required this.userId, required this.email});
+  // 无 factory fromJson — 生成器在 .g.dart 中提供私有 _$LoginResponseFromJson
 
   User toEntity() => User(id: userId, email: email); // DTO → Domain Entity
 }
 
-// ── Request ──
-// @ApiRequest 自动生成 path / method / requestType / includeToken / fromJson 注册
+// ── Request(零样板:只需 @ApiRequest,无需 @JsonSerializable / fromJson / toJson)──
+// @ApiRequest 自动生成 path / method / requestType / includeToken / toJson / fromJson 注册
 
 @ApiRequest(
   path: ApiPaths.authLogin,                // 路径统一在 core/foundation/api_paths.dart 管理
   method: HttpMethod.post,
-  responseType: LoginData,
+  responseType: LoginResponse,
   requestType: ApiRequestType.login,
 )
-@JsonSerializable()
-class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi {
+class LoginRequest extends ApiRequestable<LoginResponse> with _$LoginRequestApi {
   final String email;
   final String password;
 
   LoginRequest({required this.email, required this.password});
-
-  @override
-  Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
+  // 完毕!toJson 由 mixin 从类字段自动生成,fromJson 注册也全部自动处理
 }
 
// 使用 - 超级简单!
-final loginData = await apiClient.executeRequest(
+final loginResponse = await apiClient.executeRequest(
   LoginRequest(email: 'user@example.com', password: '123456'),
 );
-final user = loginData?.toEntity(); // DTO → Domain Entity
+final user = loginResponse?.toEntity(); // DTO → Domain Entity
 
@@ -2349,20 +2357,20 @@ final user = loginData?.toEntity(); // DTO → Domain Entity 中 -@ApiRequest + @JsonSerializable -字段 + 构造函数 + @ApiRequest + @JsonSerializable -path / method / requestType / includeToken / toJson / fromJson / fromJson 注册 - +@ApiRequest(当前方案) +字段 + 构造函数 + @ApiRequest
(Response DTO:字段 + 构造函数,零注解) +Request: path / method / requestType / includeToken / toJson / fromJson 注册
Response: _$XFromJson 私有反序列化函数(按需递归生成嵌套类型) +极低

核心优势

    -
  • 注解驱动@ApiRequest 自动生成 mixin,@JsonSerializable 自动生成 toJson/fromJson
  • +
  • Request 零样板@ApiRequest 一个注解生成 mixin(含 toJson),无需 @JsonSerializable
  • +
  • Response 零注解:Response DTO 不需要 @JsonSerializable,不需要 factory fromJson_$XFromJson 由生成器从 @ApiRequest(responseType: T) 自动推导;嵌套自定义类型递归生成,依赖关系自动处理
  • 自动注册:fromJson 在首次请求时自动注册到全局注册表,无需手动 registerApiResponses()
  • 一个端点 = 一个文件:Response DTO + Request 放在同一文件,打开即看全貌
  • -
  • 傻瓜式使用:使用者只需关注业务字段和注解配置
  • 类型安全ApiRequestable<T> 泛型 + responseType 编译期检查
@@ -2378,12 +2386,12 @@ final user = loginData?.toEntity(); // DTO → Domain Entity Swift -struct LoginRequest: APIRequestable { typealias Response = LoginData ... } +struct LoginRequest: APIRequestable { typealias Response = LoginResponse ... } 协议直接实现,最简洁 Dart -@ApiRequest(...) class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi { ... } +@ApiRequest(...) class LoginRequest extends ApiRequestable<LoginResponse> with _$LoginRequestApi { ... } 注解 + 代码生成,接近 Swift 体验 @@ -2443,36 +2451,27 @@ class ApiRequest {

生成 mixin(非 extension),因为 mixin 可以 override 基类方法、调用 super,并在 parameters getter 中自动注册 fromJson。

+

toJson 生成机制:生成器读取类的声明字段(非继承),直接在 mixin 中生成 Map 字面量。不依赖 @JsonSerializable,避免了继承属性被序列化导致的递归问题。支持 @JsonKey(name: '...') 字段重命名和 @JsonKey(includeToJson: false) 跳过字段。

+
/// API 请求代码生成器
 class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
   @override
-  String generateForAnnotatedElement(
-    Element element,
-    ConstantReader annotation,
-    BuildStep buildStep,
-  ) {
+  String generateForAnnotatedElement(element, annotation, buildStep) {
     final className = element.name;
-    final path = annotation.read('path').stringValue;
-    final methodName = _readEnumName(annotation.read('method').objectValue, 'post');
-    final responseType = annotation.read('responseType').typeValue;
-    final responseTypeName = responseType.getDisplayString();
-    final requestTypeName = _readEnumName(annotation.read('requestType').objectValue, 'request');
+    // ... 读取 path / method / responseType / requestType / includeToken ...
 
-    // includeToken:默认 login → false,其余 → true
-    final includeTokenReader = annotation.peek('includeToken');
-    final includeToken = (includeTokenReader != null && !includeTokenReader.isNull)
-        ? includeTokenReader.boolValue
-        : requestTypeName != 'login';
+    // 从类的声明字段生成 toJson(),只序列化自身字段,不含继承属性
+    final toJsonBody = _buildToJsonBody(element, className);
 
-    // 生成 mixin,使用侧只需 `with _$XxxApi`
     return '''
-/// Generated by @ApiRequest for [$className]
 mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
   @override String get path => '$path';
   @override HttpMethod get method => HttpMethod.$methodName;
   @override ApiRequestType get requestType => ApiRequestType.$requestTypeName;
   @override bool get includeToken => $includeToken;
   @override
+  Map<String, dynamic> toJson() => $toJsonBody;
+  @override
   Map<String, dynamic>? get parameters {
     registerResponse<$responseTypeName>($responseTypeName.fromJson);
     return super.parameters;
@@ -2480,16 +2479,28 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
 }
 ''';
   }
+
+  /// 读取类的声明字段,生成 Map 字面量
+  /// 支持 @JsonKey(name: '...') 重命名
+  String _buildToJsonBody(ClassElement element, String className) {
+    final fields = element.fields.where((f) => !f.isStatic && !f.isSynthetic);
+    // => {'email': (this as LoginRequest).email, 'password': ...}
+  }
 }
 
-

关键设计parameters getter 在首次请求时自动调用 registerResponse,将 fromJson 注册到全局注册表。无需手动注册,也无需 registerApiResponses() 启动函数。

+

关键设计

+
    +
  • toJson() 只序列化类自身声明的字段,不含 ApiRequestable 的继承属性(path / method / parameters 等),避免递归
  • +
  • parameters getter 在首次请求时自动调用 registerResponse,将 Response 的 fromJson 注册到全局注册表
  • +
  • Upload 等特殊请求在类中 override toJson(),类的 override 优先于 mixin
  • +
4.3 build.yaml 配置

文件:packages/networks_sdk/build.yaml

-

使用 SharedPartBuilder,与 @JsonSerializable 共享同一个 .g.dart 文件,无需额外 part 指令。

+

使用 SharedPartBuilder,与 @JsonSerializable(Response DTO 用)共享同一个 .g.dart 文件,无需额外 part 指令。

builders:
   api_request:
@@ -2518,78 +2529,65 @@ melos run gen
 
 
4.5 更多使用示例
-

所有示例遵循同一模式:@ApiRequest + @JsonSerializable + extends ApiRequestable<T> with _$XxxApi

+

所有示例遵循同一模式:@ApiRequest + extends ApiRequestable<T> with _$XxxApi。Request 和 Response 均无需 @JsonSerializable,Response DTO 连 factory fromJson 也不需要。

-

发送消息请求(POST):

+

发送消息请求(POST + @JsonKey 字段重命名):

// data/remote/send_message_request.dart
 
-// ── Response DTO ──
-@JsonSerializable()
-class SendMessageData {
+// ── Response DTO(纯 Dart 类,零注解,_$SendMessageResponseFromJson 由生成器自动提供)──
+class SendMessageResponse {
   @JsonKey(name: 'message_id')
   final String messageId;
   final int timestamp;
 
-  const SendMessageData({required this.messageId, required this.timestamp});
-  factory SendMessageData.fromJson(Map<String, dynamic> json) =>
-      _$SendMessageDataFromJson(json);
+  const SendMessageResponse({required this.messageId, required this.timestamp});
 }
 
-// ── Request ──
-@ApiRequest(path: ApiPaths.chatSendMessage, responseType: SendMessageData)
-@JsonSerializable()
-class SendMessageRequest extends ApiRequestable<SendMessageData>
+// ── Request(零样板)──
+@ApiRequest(path: ApiPaths.chatSendMessage, responseType: SendMessageResponse)
+class SendMessageRequest extends ApiRequestable<SendMessageResponse>
     with _$SendMessageRequestApi {
-  @JsonKey(name: 'chat_id')
+  @JsonKey(name: 'chat_id')  // 生成器会读取,JSON 键名为 'chat_id'
   final String chatId;
   final String content;
 
   SendMessageRequest({required this.chatId, required this.content});
-  @override
-  Map<String, dynamic> toJson() => _$SendMessageRequestToJson(this);
+  // toJson 自动生成:{'chat_id': chatId, 'content': content}
 }
 

获取用户资料(GET,靠 token 标识当前用户,无需传参):

// data/remote/get_profile_request.dart
 
-@JsonSerializable()
-class ProfileData {
+// ── Response DTO(纯 Dart 类,零注解,_$ProfileResponseFromJson 由生成器自动提供)──
+class ProfileResponse {
   @JsonKey(name: 'user_id')
   final String userId;
   final String email;
   final String? nickname;
   final String? avatar;
 
-  const ProfileData({required this.userId, required this.email, this.nickname, this.avatar});
-  factory ProfileData.fromJson(Map<String, dynamic> json) =>
-      _$ProfileDataFromJson(json);
+  const ProfileResponse({required this.userId, required this.email, this.nickname, this.avatar});
 
   User toEntity() => User(id: userId, email: email, nickname: nickname, avatar: avatar);
 }
 
-@ApiRequest(path: ApiPaths.userProfile, method: HttpMethod.get, responseType: ProfileData)
-@JsonSerializable()
-class GetProfileRequest extends ApiRequestable<ProfileData>
+@ApiRequest(path: ApiPaths.userProfile, method: HttpMethod.get, responseType: ProfileResponse)
+class GetProfileRequest extends ApiRequestable<ProfileResponse>
     with _$GetProfileRequestApi {
-  GetProfileRequest();  // 无参数 — GET /user/profile 靠 token 获取当前用户
-
-  @override
-  Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
+  GetProfileRequest();  // 无参数 — toJson 自动生成空 map
 }
 

上传文件请求(FormData multipart):

// data/remote/upload_file_request.dart
 
-@JsonSerializable()
+// ── Response DTO(纯 Dart 类,零注解,_$UploadResultFromJson 由生成器自动提供)──
 class UploadResult {
   final String url;
   @JsonKey(name: 'file_id')
   final String fileId;
   const UploadResult({required this.url, required this.fileId});
-  factory UploadResult.fromJson(Map<String, dynamic> json) =>
-      _$UploadResultFromJson(json);
 }
 
 @ApiRequest(
@@ -2618,8 +2616,9 @@ class UploadFileRequest extends ApiRequestable<UploadResult>
 

核心价值

    -
  • 极简使用:字段 + 构造函数 + @ApiRequest + @JsonSerializable
  • -
  • 零维护:path / method / requestType / includeToken / fromJson 注册 全部自动生成
  • +
  • Request 极简:字段 + 构造函数 + @ApiRequest(无需 @JsonSerializable、无需手写 toJson
  • +
  • Response 零注解:Response DTO 是纯 Dart 类,无需 @JsonSerializable,无需 factory fromJson_$XFromJson 由生成器从 responseType 自动推导
  • +
  • 零维护:path / method / requestType / includeToken / toJson / fromJson 注册 全部自动生成
  • 类型安全:泛型 ApiRequestable<T> + responseType 编译期检查
  • 一个端点 = 一个文件:Response DTO + Request 放在同一文件,打开即看全貌
@@ -2646,18 +2645,20 @@ final apiConfigProvider = Provider<ApiConfig>((ref) { ); }); -/// 2. API 客户端(内部自动挂载 Auth / Retry / Logging 拦截器) -final apiClientProvider = Provider<ApiClient>((ref) { - return ApiClient(config: ref.read(apiConfigProvider)); +/// 2. Networks SDK API Provider(全局单例,Facade 接口) +/// 内部自动挂载 AuthInterceptor / EncryptionInterceptor / RetryInterceptor / LoggingInterceptor +final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) { + final config = ref.read(apiConfigProvider); + return NetworksSdkWiring.build(config: config); }); // ── features/auth/di/auth_providers.dart ── (Auth 模块完整 DI 链路) -/// 3. Repository(注入 domain 接口类型,ViewModel 不感知具体实现) +/// 3. Repository(注入 Facade 接口类型,ViewModel 不感知具体实现) final authRepositoryProvider = Provider<AuthRepository>((ref) { final apiConfig = ref.read(apiConfigProvider); return AuthRepositoryImpl( - client: ref.read(apiClientProvider), // 直接注入 ApiClient + client: ref.read(networkSdkApiProvider), // 注入 Facade 接口 onTokenUpdate: (token) { apiConfig.updateToken(token); // 内存(networks_sdk) // secureStorage.saveToken(token); // 持久化(storage_sdk,待接入) @@ -2707,16 +2708,17 @@ class LoginViewModel extends _$LoginViewModel { → Repository: _client.executeRequest(LoginRequest(...)) → ApiClient.executeRequest() ← networks_sdk 内部 → AuthInterceptor ← 注入 token + headers + → EncryptionInterceptor ← 加密请求体(预留) → Dio.request(baseURL + path, data) ← 实际 HTTP 请求 - → RetryInterceptor ← token 过期自动刷新重试 + → RetryInterceptor ← token 过期自动刷新重试 + 业务错误钩子 → LoggingInterceptor ← 请求/响应日志 ← request.decodeResponse(response) ← 自动解码 ← ApiResponseWrapper.fromJson ← 拆 { code, msg, data } - ← fromJsonRegistry[LoginData] ← 查注册表 - ← LoginData.fromJson(data) ← 反序列化 - ← LoginData(DTO) + ← fromJsonRegistry[LoginResponse] ← 查注册表 + ← _$LoginResponseFromJson(data) ← 反序列化(生成的私有函数) + ← LoginResponse(DTO) → onTokenUpdate(token) ← 回调写入 Token(内存 + 持久化) - ← loginData.toEntity() → User ← DTO → Domain Entity + ← loginResponse.toEntity() → User ← DTO → Domain Entity ← User ← state.copyWith(user: user) ← 更新状态 View: ref.watch → 自动 rebuild ← UI 刷新 @@ -2987,8 +2989,9 @@ flowchart TD │ │ └── auth_guard.dart # 登录守卫(switch AppRouteName,穷举防漏路由) │ │ │ └── di/ # 全局 DI — 手动装配的 Provider -│ ├── network_provider.dart # NetworkMonitor + ApiConfig + ApiClient + SocketConfig + SocketClient + SocketManager -│ └── app_providers.dart # 全局共享状态(themeModeProvider + AuthNotifier) +│ ├── network_provider.dart # NetworkMonitor + ApiConfig + NetworksSdkApi + SocketConfig + SocketClient + SocketManager +│ ├── db_provider.dart # StorageSdkApi(注入 AppDatabase factory) +│ └── app_providers.dart # AppInitializer + ThemeModeNotifier + AuthNotifier │ ├── features/ # 功能模块(垂直切片):Feature 间禁止直接 import │ │ @@ -3022,6 +3025,7 @@ flowchart TD │ ├── di/ │ │ └── settings_providers.dart # settingsRepositoryProvider(待 storage_sdk 接入) │ ├── presentation/ +│ │ ├── settings_view_model.dart # @riverpod ViewModel(设置页导航) │ │ └── theme_view_model.dart # @riverpod ViewModel(生成 theme_view_model.g.dart) │ ├── usecases/ │ │ └── set_theme_usecase.dart # 主题切换用例 @@ -3081,6 +3085,8 @@ flowchart TD │ └── ui/ # Core UI(设计系统 + 可复用组件) ├── base/ # 设计 Token + │ ├── assets.dart # 静态资源路径常量(AppAssets:logo / 占位图) + │ ├── icons.dart # 图标常量(AppIcons:导航 / 操作 / 聊天 / 用户 / 状态) │ ├── app_theme.dart # ThemeData 组装(Light / Dark) │ ├── colors.dart # 颜色体系(品牌色 / 语义色 / 灰阶) │ ├── context_theme_ext.dart # BuildContext 主题扩展(context.theme / context.colors) @@ -3172,7 +3178,7 @@ flowchart LR direction TB Step1["① 用户点击发送按钮"] Step2["② ref.read(chatVM.notifier)
.sendMessage(content)"] - Step3["③ ViewModel 调用 UseCase
→ Repository → ApiClient"] + Step3["③ ViewModel 调用 UseCase
→ Repository → NetworksSdkApi"] Step4["④ state = state.copyWith(
messages: [..., newMsg])"] Step5["⑤ ref.watch(chatVM) 检测变化
→ ConsumerWidget 自动 rebuild"] Step6["⑥ UI 展示最新消息列表"] @@ -3200,6 +3206,13 @@ flowchart LR

两大核心逻辑

1. MVVM 分层职责:View(view/)只负责渲染和用户交互,ViewModel(presentation/)持有状态并处理业务逻辑,Model(model/ + entities/)定义数据结构 —— 三者通过 Riverpod Provider 连接,职责严格分离。

2. Riverpod 单向数据流:用户操作 → ref.read(vm.notifier).action() → ViewModel 处理逻辑 → state = newStateref.watch(vm) 检测变化 → View 自动 rebuild。数据永远单向流动,UI 永远是状态的函数。

+

3. Widget 纯展示原则build() 只做一件事——把 State 属性映射成 Widget 树,不允许出现任何计算或逻辑。

+
    +
  • 派生显示值必须是 State getter:凡是需要从 State 字段推导出另一个值(文本、颜色、样式等),一律写成 State 的 getter,Widget 只读取结果。例如:String get buttonLabel => testStarted ? '结束' : '开始';,Widget 写 state.buttonLabel,不在 build() 里写三元。
  • +
  • 禁止 build() 内定义局部计算变量final isSelected = current == mode 这类从构造参数/state 派生值的局部变量,不属于 build()。组件应接受已算好的 bool isSelected 参数,由父级或 State getter 负责计算。
  • +
  • 导航、CRUD、状态变更全部在 ViewModel 中完成,Widget 只转发事件:onTap: () => ref.read(vm.notifier).doAction()
  • +
  • demo/测试页面同样适用:demo 代码是示范,别人会照着模仿,写法必须与正式页面完全一致。
  • +

@@ -3356,7 +3369,7 @@ abstract class ChatRepository { // Data 层实现接口 class ChatRepositoryImpl implements ChatRepository { - final ApiClient _client; + final NetworksSdkApi _client; final MessageLocalDataSource _localDataSource; @override @@ -5623,7 +5636,7 @@ flowchart TD │ ├── file_storage.dart # 文件存储管理 │ └── image_cache.dart # 图片缓存 │ -├── remote/ # Request 文件(一个端点一个文件,Repository 直接调 ApiClient) +├── remote/ # Request 文件(一个端点一个文件,Repository 直接调 NetworksSdkApi) │ ├── login_request.dart # 登录端点 │ ├── logout_request.dart # 登出端点 │ ├── send_message_request.dart # 发消息端点 @@ -5648,17 +5661,17 @@ flowchart TD Domain[Domain Layer
domain/repositories/
Repository 接口] -.实现.-> Repo[Data Layer
data/repositories/
Repository 实现] Repo -->|读取| LocalDS[Local DataSource
data/local/] - Repo -->|请求| ApiClient[ApiClient
networks_sdk] + Repo -->|请求| SdkApi[NetworksSdkApi
networks_sdk] Repo -->|缓存| Cache[Cache Manager
data/cache/] LocalDS -->|Drift| DB[(Database)] - ApiClient -->|HTTP/WebSocket| API[API Server] + SdkApi -->|HTTP/WebSocket| API[API Server] Cache -->|内存| Memory[Memory Cache] style Domain fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px style Repo fill:#e8f5e9,stroke:#388e3c,stroke-width:2px style LocalDS fill:#c8e6c9,stroke:#388e3c - style ApiClient fill:#c8e6c9,stroke:#388e3c + style SdkApi fill:#c8e6c9,stroke:#388e3c style Cache fill:#c8e6c9,stroke:#388e3c
@@ -5686,16 +5699,16 @@ class MessageLocalDataSource {
  • 一个端点 = 一个文件:Response DTO + Request 类放在同一文件中
  • -
  • Repository 直接调 ApiClient:无需 RemoteDataSource 中间层
  • -
  • @ApiRequest 注解 + 代码生成:自动实现 path / method / fromJson 注册
  • +
  • Repository 直接调 NetworksSdkApi:无需 RemoteDataSource 中间层
  • +
  • @ApiRequest 注解 + 代码生成:自动实现 path / method / toJson / fromJson 注册(Request 无需 @JsonSerializable)
// 示例:Repository 直接调用 Request
 // data/repositories/message_repository_impl.dart
 class MessageRepositoryImpl implements MessageRepository {
-  final ApiClient _client;
+  final NetworksSdkApi _client;
 
-  Future<SendMessageData?> sendMessage({
+  Future<SendMessageResponse?> sendMessage({
     required String chatId,
     required String content,
   }) {
@@ -5758,9 +5771,9 @@ flowchart LR
 
     RepoImpl -->|1. 检查缓存| Cache[Cache]
     RepoImpl -->|2. 读取本地| LocalDS[Local DS]
-    RepoImpl -->|3. 请求远程| ApiClient2[ApiClient]
+    RepoImpl -->|3. 请求远程| SdkApi2[NetworksSdkApi]
 
-    ApiClient2 -->|DTO| RepoImpl
+    SdkApi2 -->|DTO| RepoImpl
     RepoImpl -->|转换| Entity[Entity]
     Entity -->|返回| UC
 
@@ -5884,37 +5897,41 @@ flowchart LR
         ├── data/
         │   ├── datasources/
         │   │   ├── http/
-        │   │   │   ├── api_client.dart          # Dio REST 客户端(executeRequest<T> 唯一入口)
+        │   │   │   ├── api_client.dart              # Dio REST 客户端
+        │   │   │   ├── token_refresh_manager.dart   # Token 刷新管理(竞态安全 + 超时 + 时间窗口复用 + 主动刷新)
         │   │   │   └── interceptor/
-        │   │   │       ├── auth_interceptor.dart    # Token + 默认 header 注入
-        │   │   │       ├── retry_interceptor.dart   # Token 刷新 + 瞬态错误重试
-        │   │   │       └── logging_interceptor.dart # 请求/响应日志
-        │   │   └── socket/
-        │   │       └── socket_client.dart       # WebSocket 长连接(心跳/重连/Stream 输出)
+        │   │   │       ├── auth_interceptor.dart        # Token + 默认 header 注入
+        │   │   │       ├── encryption_interceptor.dart  # 请求加密 / 响应解密(预留给 cipher_guard_sdk)
+        │   │   │       ├── retry_interceptor.dart       # Token 刷新 + 瞬态错误重试 + 业务错误钩子
+        │   │   │       └── logging_interceptor.dart     # 请求/响应日志
+        │   │   ├── socket/
+        │   │   │   └── socket_client.dart           # WebSocket 长连接(心跳/重连/Stream/Token 热更新/加密钩子)
+        │   │   └── networks_sdk_method_channel_datasource.dart  # 统一执行入口(executeRequest / executeDownload)
         │   ├── dto/
         │   │   ├── api_requestable.dart         # 请求基类 + fromJson 注册表 + 解码扩展
-        │   │   └── api_response_wrapper.dart    # { code, message/msg, data } 信封解析
+        │   │   └── api_response_wrapper.dart    # { code, message/msg, data } 响应包装解析
         │   └── repositories/
         │       ├── networks_sdk_repository_impl.dart
         │       └── networks_messaging_repository_impl.dart
         ├── domain/
         │   ├── entities/
-        │   │   ├── api_error.dart               # @freezed HTTP 错误联合类型
+        │   │   ├── api_error.dart               # @freezed HTTP 错误联合类型(7 变体,含 cancelled)
+        │   │   ├── encrypted_request.dart       # 加密请求结果数据类(path / headers / body 覆盖)
         │   │   ├── socket_error.dart             # @freezed WebSocket 错误联合类型
         │   │   ├── socket_connection_state.dart  # 连接状态 enum
         │   │   ├── http_method.dart              # GET / POST / PUT / DELETE / PATCH
-        │   │   └── api_request_type.dart         # request / login / upload
+        │   │   └── api_request_type.dart         # request / login / upload / stream / download
         │   └── repositories/
         │       ├── networks_sdk_repository.dart
         │       └── networks_messaging_repository.dart
         └── presentation/
             ├── facade/
-            │   ├── networks_sdk_api.dart         # HTTP 公开 API 接口
-            │   └── networks_messaging_api.dart   # WebSocket 公开 API 接口
+            │   ├── networks_sdk_api.dart         # HTTP 公开 API 接口(含 executeDownload)
+            │   └── networks_messaging_api.dart   # WebSocket 公开 API 接口(含 updateToken / sendBytes)
             └── wiring/
-                ├── api_config.dart               # HTTP 配置(baseURL / token / 回调)
-                ├── socket_config.dart            # WebSocket 配置(心跳 / 重连策略)
-                ├── network_callbacks.dart        # 回调类型定义(OnTokenRefresh 等)
+                ├── api_config.dart               # HTTP 配置(baseURL / token / 回调 / 加密 / 重试 / 主动刷新)
+                ├── socket_config.dart            # WebSocket 配置(心跳 / 重连 / 加密钩子 / 压缩)
+                ├── network_callbacks.dart        # 回调类型定义(认证 / 加密 / 业务错误 / 下载进度 / WS 加密)
                 ├── networks_sdk_core.dart
                 ├── networks_sdk_api_impl.dart
                 ├── networks_messaging_api_impl.dart
@@ -5928,7 +5945,7 @@ flowchart LR
 职责SDK (networks_sdk)App 层 (im_app)
 
 
-Dio 管理ApiClient 内部创建管理构造 ApiClient 传入 config
+Dio 管理ApiClient 内部创建管理通过 NetworksSdkWiring.build(config:) 创建
 baseURLApiConfig.baseURLAppConfig.apiBaseUrl 提供初始值
 Token 存储ApiConfig.token(内存)安全存储、持久化
 Token 刷新检测过期 → 调 onTokenRefresh提供回调实现
@@ -5939,9 +5956,9 @@ flowchart LR
 WebSocket 连接SocketClient 内部管理(连接/心跳/重连)调 connect/disconnect/send
 WebSocket 心跳双层心跳自动管理(底层 ping 5s + 应用层 10s)无需关心
 WebSocket 重连指数退避自动重连(1s→2s→4s→8s→16s→30s)无需关心
-WebSocket 生命周期提供 onEnterForeground/BackgroundApp 层调用(AppLifecycleListener)
+WebSocket 生命周期提供 onEnterForeground/BackgroundApp 层调用(AppLifecycleListener)。本项目 disconnectInBackground=false,所有平台后台保活、心跳不停
 WebSocket 消息解析JSON.decode → Stream 输出App 层按 type 过滤 + DTO 解析
-Riverpod无依赖Provider 包装 ApiClient / SocketClient
+Riverpod无依赖Provider 包装 NetworksSdkApi / SocketClient
 
 
 
@@ -5954,7 +5971,7 @@ flowchart LR
 层级文件命名类命名示例
 
 
-接口定义{action}_request.dartRequest: {Action}Request
Response DTO: {Action}Datalogin_request.dartLoginRequest + LoginData +接口定义{action}_request.dartRequest: {Action}Request
Response DTO: {Action}Responselogin_request.dartLoginRequest + LoginResponse 持久化 DTOdata/models/{entity}_dto.dart{Entity}Dtouser_dto.dartUserDto Repository 接口domain/repositories/{module}_repository.dart{Module}Repositoryauth_repository.dartAuthRepository Repository 实现data/repositories/{module}_repository_impl.dart{Module}RepositoryImplauth_repository_impl.dartAuthRepositoryImpl @@ -5966,9 +5983,9 @@ flowchart LR

关键规则

  • 一个端点 = 一个 Request 文件:Response DTO + Request 类放在同一文件中
  • -
  • Response DTO 必须有 toEntity():统一 DTO → Domain Entity 的转换入口
  • -
  • 持久化 DTO 和 Response DTO 分开:Response DTO(XxxData)在 request 文件中,持久化 DTO(XxxDto)在 data/models/
  • -
  • 禁止跳层:ViewModel → Repository(→ UseCase 按需)→ ApiClient,每层职责明确
  • +
  • Response DTO 是纯 Dart 类:零注解、零 factory fromJson;只需字段 + 构造函数,toEntity() 按需添加
  • +
  • 持久化 DTO 和 Response DTO 分开:Response DTO(XxxResponse)在 request 文件中,持久化 DTO(XxxDto)在 data/models/
  • +
  • 禁止跳层:ViewModel → Repository(→ UseCase 按需)→ NetworksSdkApi,每层职责明确
傻瓜式教程:从零开始定义并发送一个接口
@@ -6017,8 +6034,10 @@ part 'login_request.g.dart'; // 这行必须写!指向即将自动生成的
// ── Response DTO ──
 
 /// 服务端返回的登录数据
-@JsonSerializable()                       // ← 这个注解让 build_runner 自动生成 fromJson / toJson
-class LoginData {
+/// 纯 Dart 类,零注解,零样板。
+/// _$LoginResponseFromJson 由 ApiResponseGenerator 从 @ApiRequest(responseType: LoginResponse) 自动推导生成,
+/// 无需手动添加任何注解或 factory fromJson。
+class LoginResponse {
   final String token;                      // 服务端返回的字段
   @JsonKey(name: 'user_id')               // 服务端字段名是 user_id,Dart 字段名是 userId
   final String userId;
@@ -6026,18 +6045,15 @@ class LoginData {
   final String? nickname;                  // 可选字段用 String?
   final String? avatar;
 
-  const LoginData({                        // 构造函数,参数和字段一一对应
+  const LoginResponse({                    // 构造函数,参数和字段一一对应
     required this.token,
     required this.userId,
     required this.email,
     this.nickname,
     this.avatar,
   });
-
-  // ↓ 这两行是固定写法,照抄就行,把类名替换掉
-  factory LoginData.fromJson(Map<String, dynamic> json) =>
-      _$LoginDataFromJson(json);           // ← 短暂报红,watch 模式下保存后几秒自动消失
-  Map<String, dynamic> toJson() => _$LoginDataToJson(this);
+  // 不需要 factory fromJson,不需要 toJson,不需要任何注解
+  // 生成器自动在 login_request.g.dart 中生成 _$LoginResponseFromJson 私有函数
 
   /// DTO → Domain Entity(把网络层数据转为业务层数据)
   User toEntity() {
@@ -6079,19 +6095,16 @@ class LoginData {
 @ApiRequest(                               // ← 这个注解让 build_runner 自动生成 path / method 等
   path: ApiPaths.authLogin,                //    路径常量,定义在 core/foundation/api_paths.dart
   method: HttpMethod.post,                 //    HTTP 方法,从接口文档抄
-  responseType: LoginData,                 //    响应类型,就是上面写的 LoginData
+  responseType: LoginResponse,             //    响应类型,就是上面写的 LoginResponse
   requestType: ApiRequestType.login,       //    login 类型不携带 Token(登录前还没有 Token)
 )
-@JsonSerializable()                        // ← 自动生成 toJson(把请求参数序列化为 JSON)
-class LoginRequest extends ApiRequestable<LoginData>  // ← 固定写法:extends ApiRequestable<响应类型>
-    with _$LoginRequestApi {                           // ← 固定写法:with _$类名Api(短暂报红,保存后自动消失)
+class LoginRequest extends ApiRequestable<LoginResponse>  // ← 固定写法:extends ApiRequestable<响应类型>
+    with _$LoginRequestApi {                              // ← 固定写法:with _$类名Api(短暂报红,保存后自动消失)
   final String email;                      // 请求参数:要发给服务端的字段
   final String password;
 
   LoginRequest({required this.email, required this.password});
-
-  @override
-  Map<String, dynamic> toJson() => _$LoginRequestToJson(this);  // ← 固定写法,短暂报红,保存后自动消失
+  // 完毕!toJson 由 _$LoginRequestApi mixin 从类字段自动生成,不需要 @JsonSerializable,不需要手写 toJson
 }
 
@@ -6100,22 +6113,22 @@ class LoginRequest extends ApiRequestable<LoginData> // ← 固定写法

命名规则速查(写之前就能确定引用名)

- + - - - + + +
你写的类名fromJsontoJsonApi mixin
你写的类名fromJson(私有函数)toJsonApi mixin来源
LoginData_$LoginDataFromJson_$LoginDataToJson-
LoginRequest_$LoginRequestFromJson_$LoginRequestToJson_$LoginRequestApi
SendMessageRequest_$SendMessageRequestFromJson_$SendMessageRequestToJson_$SendMessageRequestApi
LoginResponse_$LoginResponseFromJson-(不需要)-ApiResponseGenerator 自动推导
LoginRequest-(不需要)由 mixin 自动生成_$LoginRequestApiApiRequestGenerator 生成 mixin
SendMessageRequest-(不需要)由 mixin 自动生成_$SendMessageRequestApiApiRequestGenerator 生成 mixin
-

规则:_$ + 类名 + FromJson / ToJson / Api。固定前缀,直接拼。

+

规则:Response DTO 类名拼 _$ + 类名 + FromJson(私有,只在 .g.dart 内部使用);Request 类名拼 _$ + 类名 + Api(mixin)。

-
第 2 步:在 Repository 中调用 ApiClient,转为 Domain Entity
+
第 2 步:在 Repository 中调用 NetworksSdkApi,转为 Domain Entity

在哪写lib/data/repositories/auth_repository_impl.dart

-

做什么:直接调 ApiClient.executeRequest → 拿到 DTO → 回调写 Token → 转为 Domain Entity → 返回。

+

做什么:调 NetworksSdkApi.executeRequest → 拿到 DTO → 回调写 Token → 转为 Domain Entity → 返回。

import 'package:networks_sdk/networks_sdk.dart';
 import '../../domain/entities/user.dart';
@@ -6123,11 +6136,11 @@ import '../../domain/repositories/auth_repository.dart';
 import '../remote/login_request.dart';
 
 class AuthRepositoryImpl implements AuthRepository {
-  final ApiClient _client;                        // ← 直接注入 ApiClient
+  final NetworksSdkApi _client;                   // ← 注入 Facade 接口
   final void Function(String?) _onTokenUpdate;    // ← 回调,由 Provider 层组合
 
   AuthRepositoryImpl({
-    required ApiClient client,
+    required NetworksSdkApi client,
     required void Function(String?) onTokenUpdate,
   })  : _client = client,
         _onTokenUpdate = onTokenUpdate;
@@ -6137,20 +6150,20 @@ class AuthRepositoryImpl implements AuthRepository {
     required String email,
     required String password,
   }) async {
-    // 1. 直接调 ApiClient,构造请求 → 发 HTTP → 自动解码 → 返回 DTO
-    final LoginData? loginData = await _client.executeRequest(
+    // 1. 调 NetworksSdkApi,构造请求 → 发 HTTP → 自动解码 → 返回 DTO
+    final LoginResponse? loginResponse = await _client.executeRequest(
       LoginRequest(email: email, password: password),
     );
 
-    if (loginData == null) {
+    if (loginResponse == null) {
       throw Exception('Login failed: empty response');
     }
 
     // 2. 回调写入 Token(内存 + 持久化由 Provider 层组合)
-    _onTokenUpdate(loginData.token);
+    _onTokenUpdate(loginResponse.token);
 
     // 3. DTO → Domain Entity,返回给上层
-    return loginData.toEntity();
+    return loginResponse.toEntity();
   }
 }
 
@@ -6162,15 +6175,15 @@ class AuthRepositoryImpl implements AuthRepository {

3.1 注册 Provider(DI 装配)

在哪写lib/features/{模块}/di/{模块}_providers.dart

-

做什么:在 Feature 目录下创建 Provider 文件,注册该模块的 DI 链路(Repository → UseCase 按需)。app/di/ 只提供 SDK 基础设施(ApiConfig / ApiClient),业务模块的 Provider 内聚在 Feature 目录下。

+

做什么:在 Feature 目录下创建 Provider 文件,注册该模块的 DI 链路(Repository → UseCase 按需)。app/di/ 只提供 SDK 基础设施(ApiConfig / NetworksSdkApi),业务模块的 Provider 内聚在 Feature 目录下。

// ── features/auth/di/auth_providers.dart ──
 
-// Repository(直接注入 ApiClient + 回调组合多个 SDK 能力)
+// Repository(注入 Facade 接口 + 回调组合多个 SDK 能力)
 final authRepositoryProvider = Provider<AuthRepository>((ref) {
   final apiConfig = ref.read(apiConfigProvider);
   return AuthRepositoryImpl(
-    client: ref.read(apiClientProvider),          // 直接注入 ApiClient
+    client: ref.read(networkSdkApiProvider),       // 注入 Facade 接口
     onTokenUpdate: (token) {
       apiConfig.updateToken(token);               // 内存(networks_sdk)
       // secureStorage.saveToken(token);          // 持久化(storage_sdk,待接入)
@@ -6199,7 +6212,7 @@ import '../../../domain/repositories/message_repository.dart';
 // ── Repository ──
 final messageRepositoryProvider = Provider<MessageRepository>((ref) {
   return MessageRepositoryImpl(
-    client: ref.read(apiClientProvider),       // 直接注入 ApiClient
+    client: ref.read(networkSdkApiProvider),    // 注入 Facade 接口
   );
 });
 
@@ -6207,7 +6220,7 @@ final messageRepositoryProvider = Provider<MessageRepository>((ref) {
 // 如需 UseCase(多步编排、跨模块协调),参考 auth_providers.dart 中的 loginUseCaseProvider。
 
-

原则app/di/ 只放 SDK 基础设施(ApiConfig / ApiClient),业务模块的 DI 链路(Repository → UseCase 按需)内聚在 features/{模块}/di/{模块}_providers.dart 中。

+

原则app/di/ 只放 SDK 基础设施(ApiConfig / NetworksSdkApi),业务模块的 DI 链路(Repository → UseCase 按需)内聚在 features/{模块}/di/{模块}_providers.dart 中。

3.2 编写 ViewModel

@@ -6280,7 +6293,7 @@ class LoginViewModel extends _$LoginViewModel { → View: vm.doSomething(...) → ViewModel: ref.read(xxxRepositoryProvider).doSomething(...) → RepositoryImpl.doSomething() // data/repositories/ - → _client.executeRequest(XxxRequest) // 直接调 ApiClient + → _client.executeRequest(XxxRequest) // 调 NetworksSdkApi → 自动注入 header → HTTP 请求 → 自动解码 → DTO → dto.toEntity() → Domain Entity ← state = state.copyWith(...) // 更新状态 @@ -6295,12 +6308,12 @@ class LoginViewModel extends _$LoginViewModel { → LoginUseCase: 格式校验(邮箱 + 密码) // features/auth/usecases/ → LoginUseCase: authRepository.login(...) → AuthRepositoryImpl.login() // data/repositories/ - → _client.executeRequest(LoginRequest) // 直接调 ApiClient - → AuthInterceptor → Dio.request → RetryInterceptor // 自动处理 - ← request.decodeResponse → LoginData.fromJson // 自动解码 - ← LoginData(DTO) + → _client.executeRequest(LoginRequest) // 调 NetworksSdkApi + → Auth → Encryption → Dio.request → Retry → Logging // 拦截器链自动处理 + ← request.decodeResponse → _$LoginResponseFromJson // 自动解码(生成的私有函数) + ← LoginResponse(DTO) → onTokenUpdate(token) // 回调:内存写入 + 持久化 - ← loginData.toEntity() → User(Domain Entity) + ← loginResponse.toEntity() → User(Domain Entity) ← User ← state = state.copyWith(user: user) // 更新状态 ← View: ref.watch → 自动 rebuild → UI 显示用户信息 // 自动刷新 @@ -6321,22 +6334,18 @@ class LoginViewModel extends _$LoginViewModel {

你只需创建一个文件lib/data/remote/send_message_request.dart,然后在 Repository 中调用即可。

-
import 'package:json_annotation/json_annotation.dart';
-import 'package:networks_sdk/networks_sdk.dart';
+
import 'package:networks_sdk/networks_sdk.dart';
 
 part 'send_message_request.g.dart';
 
-// ── Response DTO ──
+// ── Response DTO(纯 Dart 类,零注解,_$SendMessageResponseFromJson 由生成器自动提供)──
 
-@JsonSerializable()
-class SendMessageData {
+class SendMessageResponse {
   @JsonKey(name: 'message_id')
   final String messageId;
   final int timestamp;
 
-  const SendMessageData({required this.messageId, required this.timestamp});
-  factory SendMessageData.fromJson(Map<String, dynamic> json) =>
-      _$SendMessageDataFromJson(json);
+  const SendMessageResponse({required this.messageId, required this.timestamp});
 }
 
 // ── Request ──
@@ -6344,26 +6353,24 @@ class SendMessageData {
 @ApiRequest(
   path: ApiPaths.chatSendMessage,           // 路径常量,定义在 api_paths.dart
   method: HttpMethod.post,                 // HTTP 方法
-  responseType: SendMessageData,           // 响应类型
+  responseType: SendMessageResponse,       // 响应类型
   // requestType 不写,默认 ApiRequestType.request(会携带 Token)
 )
-@JsonSerializable()
-class SendMessageRequest extends ApiRequestable<SendMessageData>
+class SendMessageRequest extends ApiRequestable<SendMessageResponse>
     with _$SendMessageRequestApi {
   @JsonKey(name: 'chat_id')               // JSON 字段名和 Dart 字段名不一样时用 @JsonKey
   final String chatId;
   final String content;
 
   SendMessageRequest({required this.chatId, required this.content});
-  @override
-  Map<String, dynamic> toJson() => _$SendMessageRequestToJson(this);
+  // toJson 自动生成:{'chat_id': chatId, 'content': content}
 }
 
-

保存 → 自动生成 → 然后在 Repository 中直接调 ApiClient 就完了:

+

保存 → 自动生成 → 然后在 Repository 中调 NetworksSdkApi 就完了:

// 在 MessageRepositoryImpl 中添加
-Future<SendMessageData?> sendMessage({
+Future<SendMessageResponse?> sendMessage({
   required String chatId,
   required String content,
 }) {
@@ -6383,22 +6390,19 @@ Future<SendMessageData?> sendMessage({
 
 
// lib/data/remote/get_profile_request.dart
 
-import 'package:json_annotation/json_annotation.dart';
 import 'package:networks_sdk/networks_sdk.dart';
 
 part 'get_profile_request.g.dart';
 
-@JsonSerializable()
-class ProfileData {
+// ── Response DTO(纯 Dart 类,零注解,_$ProfileResponseFromJson 由生成器自动提供)──
+class ProfileResponse {
   @JsonKey(name: 'user_id')
   final String userId;
   final String email;
   final String? nickname;
   final String? avatar;
 
-  const ProfileData({required this.userId, required this.email, this.nickname, this.avatar});
-  factory ProfileData.fromJson(Map<String, dynamic> json) =>
-      _$ProfileDataFromJson(json);
+  const ProfileResponse({required this.userId, required this.email, this.nickname, this.avatar});
 
   User toEntity() => User(id: userId, email: email, nickname: nickname, avatar: avatar);
 }
@@ -6406,15 +6410,12 @@ class ProfileData {
 @ApiRequest(
   path: ApiPaths.userProfile,
   method: HttpMethod.get,                  // ← GET 请求,toJson() 结果作为 query string
-  responseType: ProfileData,
+  responseType: ProfileResponse,
 )
-@JsonSerializable()
-class GetProfileRequest extends ApiRequestable<ProfileData>
+class GetProfileRequest extends ApiRequestable<ProfileResponse>
     with _$GetProfileRequestApi {
   GetProfileRequest();  // 无参数 — token 标识当前用户,无需显式传 user_id
-
-  @override
-  Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
+  // toJson 自动生成空 map {}
 }
 
@@ -6491,7 +6492,7 @@ class UploadFileRequest extends ApiRequestable<UploadResult> }
-

模式 B:二进制上传到 S3 presigned URL(参考 LingoDot-Flutter)

+

模式 B:二进制上传到 S3 presigned URL

先向后端获取 presigned URL,再直接上传到 S3:

@@ -6514,7 +6515,7 @@ class UploadFileRequest extends ApiRequestable<UploadResult> @override Object? get uploadData => data; // Uint8List 直接作为 body - /// S3 返回 204 No Content 或 XML,不是标准 { code, msg, data } 信封 + /// S3 返回 204 No Content 或 XML,不是标准 { code, msg, data } 响应格式 /// 必须 override decodeResponse @override S3UploadResponse? decodeResponse(Response response) { @@ -6568,18 +6569,18 @@ final apiConfigProvider = Provider<ApiConfig>((ref) { ); }); -/// API 客户端 Provider(全局单例) -/// 内部自动挂载 AuthInterceptor / RetryInterceptor / LoggingInterceptor -final apiClientProvider = Provider<ApiClient>((ref) { +/// Networks SDK API Provider(全局单例,Facade 接口) +/// 内部自动挂载 AuthInterceptor / EncryptionInterceptor / RetryInterceptor / LoggingInterceptor +final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) { final config = ref.read(apiConfigProvider); - return ApiClient(config: config); + return NetworksSdkWiring.build(config: config); });
DI 装配总览
app/di/                                ← 手动装配:SDK 基础设施
-└── network_provider.dart              → apiConfigProvider + apiClientProvider
+└── network_provider.dart              → apiConfigProvider + networkSdkApiProvider
 
 features/{模块}/di/                    ← 手动装配:业务模块 DI 链路(Repository → UseCase 按需)
 ├── auth/di/auth_providers.dart        → authRepositoryProvider
@@ -6593,7 +6594,7 @@ features/{模块}/presentation/          ← @riverpod 自动生成:ViewModel
 

di/ 目录的定位:只放需要手动装配的 Provider(构造注入、回调组合等)。ViewModel Provider 由 @riverpod 注解自动生成(写在 presentation/ 下),不在 di/ 中。

-

最小化原则app/di/ 只提供 SDK 能力(ApiConfig / ApiClient),不放业务模块的 Provider。每个业务模块的手动装配 Provider 内聚在 features/{模块}/di/{模块}_providers.dart 中,需要时才创建。

+

最小化原则app/di/ 只提供 SDK 能力(ApiConfig / NetworksSdkApi),不放业务模块的 Provider。每个业务模块的手动装配 Provider 内聚在 features/{模块}/di/{模块}_providers.dart 中,需要时才创建。

SDK 间解耦:回调注入模式
@@ -6605,7 +6606,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) { // final secureStorage = ref.read(secureStorageProvider); // storage_sdk(待接入) return AuthRepositoryImpl( - client: ref.read(apiClientProvider), // 直接注入 ApiClient + client: ref.read(networkSdkApiProvider), // 注入 Facade 接口 onTokenUpdate: (token) { apiConfig.updateToken(token); // 内存(networks_sdk) // secureStorage.saveToken(token); // 持久化(storage_sdk,待接入) @@ -6635,6 +6636,7 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) { networkError: (msg) => showToast('网络错误: $msg'), decodingError: (msg) => showToast('数据解析失败'), apiError: (code, msg) => showToast('服务端错误[$code]: $msg'), + cancelled: () => {}, // 用户主动取消,通常不提示 unknown: (msg) => showToast('未知错误'), ); } @@ -6660,41 +6662,33 @@ melos run gen:watch
// data/remote/login_request.dart
 
-import 'package:json_annotation/json_annotation.dart';
 import 'package:networks_sdk/networks_sdk.dart';
 
 part 'login_request.g.dart';  // ← 必须写,指向即将生成的文件
 
-// ── Response DTO ──
-@JsonSerializable()
-class LoginData {
+// ── Response DTO(纯 Dart 类,零注解,零样板)──
+// _$LoginResponseFromJson 由 ApiResponseGenerator 从 @ApiRequest(responseType: LoginResponse) 自动推导生成
+class LoginResponse {
   final String token;
   final String email;
-  const LoginData({required this.token, required this.email});
-
-  // ↓ 此时 _$LoginDataFromJson 还不存在,IDE 会报红,正常!
-  factory LoginData.fromJson(Map<String, dynamic> json) =>
-      _$LoginDataFromJson(json);
-  Map<String, dynamic> toJson() => _$LoginDataToJson(this);
+  const LoginResponse({required this.token, required this.email});
+  // 不需要 factory fromJson,不需要任何注解
 }
 
 // ── Request ──
 @ApiRequest(
   path: ApiPaths.authLogin,
   method: HttpMethod.post,
-  responseType: LoginData,
+  responseType: LoginResponse,
   requestType: ApiRequestType.login,
 )
-@JsonSerializable()
-class LoginRequest extends ApiRequestable<LoginData>
+class LoginRequest extends ApiRequestable<LoginResponse>
     with _$LoginRequestApi {                // ← 短暂报红,保存后自动消失
   final String email;
   final String password;
 
   LoginRequest({required this.email, required this.password});
-
-  @override
-  Map<String, dynamic> toJson() => _$LoginRequestToJson(this);  // ← 短暂报红,保存后自动消失
+  // toJson 由 mixin 自动生成,保存后红线自动消失
 }
 
@@ -6709,11 +6703,10 @@ class LoginRequest extends ApiRequestable<LoginData>

命名规则(写之前就能确定引用名)

- + - - - + +
注解生成的符号示例
来源生成的符号示例
@JsonSerializable()_$类名FromJson()_$LoginDataFromJson(json)
@JsonSerializable()_$类名ToJson()_$LoginDataToJson(this)
@ApiRequest(...)_$类名Api(mixin)_$LoginRequestApi
@ApiRequest(responseType: T)
(ApiResponseGenerator 推导)
_$类名FromJson(私有函数,.g.dart 内部使用)_$LoginResponseFromJson(json)
@ApiRequest(...) on Request 类
(ApiRequestGenerator 生成 mixin)
_$类名Api(mixin)_$LoginRequestApi
@@ -6782,12 +6775,54 @@ final user = await db.selectFirst(appDb.users, (t) => t.uid.equals(uid));

端对端加密 SDK,同时处理 Dart 侧加解密和 Native 侧密钥同步(iOS App Group 用于推送通知解密):

  • cipher_guard_sdk_api.dart:公开 API 接口(Facade)
  • -
  • encryption_flutter_service.dart:RSA/AES 双层加解密(纯 Dart 实现,基于 pointycastle + encrypt)
  • +
  • encryption_flutter_service.dart:RSA/AES 双层加解密(纯 Dart 实现,基于 pointycastle + encrypt),含性能优化
  • encryption_method_channel.dart:Native 密钥同步通道(iOS App Group 共享密钥供 Notification Extension 解密)
  • Domain 实体:RsaKeyPair / SessionKey / EncryptedMessage / ChatEncryptionKey
  • android/ + ios/:Plugin 注册入口,原生侧实现密钥写入 App Group
+
加解密性能优化
+ +

encryption_flutter_service.dart 针对 IM 高频加解密场景做了四项优化:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
优化项方案效果
RSA 密钥生成异步化generateRsaKeyPairAsync 使用 Isolate.run() 在独立线程生成主线程零阻塞,不卡 UI(2048-bit 约 200-500ms)
派生密钥 LRU 缓存_derivedKeyCache(Map,上限 64 条),缓存键 = sessionKey:round:mode,满时淘汰最早条目同一 round 的加解密只算一次 KDF,后续直接命中缓存
Random.secure() 复用静态 _secureRandom 单例,所有 IV / 随机数生成共用避免每次 Random.secure() 构造开销
KDF 双模式KdfMode.md5(默认,兼容既有数据)和 KdfMode.pbkdf2(PBKDF2-HMAC-SHA256,可配迭代次数)默认快速兼容,可选安全增强(防暴力破解)
+ +

构造时可配置 KDF 模式和 PBKDF2 迭代次数:

+
EncryptionFlutterService(
+  kdfMode: KdfMode.md5,        // 默认,兼容既有数据
+  pbkdf2Iterations: 10000,     // PBKDF2 模式下的迭代次数
+)
+ +

clearDerivedKeyCache() 可在 session key 轮换时手动清空缓存。

+

7.4 多语言国际化(packages/l10n_sdk/)

已提取为独立 Package,被 core/ui 和 Feature 层单向引用(foundation 不依赖它)。

@@ -7137,9 +7172,9 @@ abstract class ProfileRepository {
// data/repositories/profile_repository_impl.dart
 class ProfileRepositoryImpl implements ProfileRepository {
-  final ApiClient _client;
+  final NetworksSdkApi _client;
 
-  ProfileRepositoryImpl({required ApiClient client})
+  ProfileRepositoryImpl({required NetworksSdkApi client})
       : _client = client;
 
   @override
@@ -7166,7 +7201,7 @@ import '../../../app/di/network_provider.dart';
 // ── Repository ──
 final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
   return ProfileRepositoryImpl(
-    client: ref.read(apiClientProvider),       // 直接注入 ApiClient
+    client: ref.read(networkSdkApiProvider),    // 注入 Facade 接口
   );
 });
 
@@ -7174,7 +7209,7 @@ final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
 // ViewModel 通过 @riverpod 注解自动生成 Provider,无需额外注册。
 
-

说明:Profile 属于简单模板,ViewModel 直接调 Repository,无需 UseCase 中间层。app/di/ 只提供 SDK 基础设施(ApiConfig / ApiClient),业务模块的 DI 链路内聚在 Feature 目录下。

+

说明:Profile 属于简单模板,ViewModel 直接调 Repository,无需 UseCase 中间层。app/di/ 只提供 SDK 基础设施(ApiConfig / NetworksSdkApi),业务模块的 DI 链路内聚在 Feature 目录下。

Feature 结构图示

@@ -7714,7 +7749,7 @@ sequenceDiagram participant Repo as domain/repositories/
message_repository.dart participant RepoImpl as data/repositories/
message_repository_impl.dart participant LocalDS as data/local/
message_local_ds.dart - participant SDK as networks_sdk/
ApiClient / SocketClient + participant SDK as networks_sdk/
NetworksSdkApi / SocketClient participant WS as WebSocket Server UI->>VM: 1. 用户点击发送按钮 @@ -7746,7 +7781,7 @@ sequenceDiagram
  • Repository 接口:UseCase 通过 domain/repositories/message_repository.dart 接口调用
  • Repository 实现data/repositories/message_repository_impl.dart 实现具体逻辑
  • 本地优先:先保存到 data/local/message_local_ds.dart
  • -
  • 网络发送:Repository 直接调 SDK(ApiClient / SocketClient)发送
  • +
  • 网络发送:Repository 调 SDK(NetworksSdkApi / SocketClient)发送
  • 服务器确认:WebSocket 服务器确认接收
  • 状态更新:更新本地数据库中的消息状态
  • 数据返回:层层返回,最终更新 UI
  • @@ -7830,7 +7865,7 @@ abstract class MessageRepository {
    // data/repositories/message_repository_impl.dart
     class MessageRepositoryImpl implements MessageRepository {
       final MessageLocalDataSource _localDS;
    -  final ApiClient _client;           // 直接注入 ApiClient / SocketClient
    +  final NetworksSdkApi _client;      // 注入 Facade 接口
     
       MessageRepositoryImpl(this._localDS, this._client);
     
    @@ -7861,7 +7896,7 @@ class MessageRepositoryImpl implements MessageRepository {
     MessageRepository messageRepository(MessageRepositoryRef ref) {
       return MessageRepositoryImpl(
         ref.watch(messageLocalDataSourceProvider),
    -    ref.watch(apiClientProvider),        // 直接注入 ApiClient
    +    ref.watch(networkSdkApiProvider),     // 注入 Facade 接口
       );
     }
     
    @@ -7895,7 +7930,7 @@ sequenceDiagram participant RepoImpl as data/repositories/
    chat_repository_impl.dart participant Cache as data/cache/
    cache_manager.dart participant LocalDS as data/local/
    chat_local_ds.dart - participant SDK as networks_sdk/
    ApiClient + participant SDK as networks_sdk/
    NetworksSdkApi UI->>VM: 1. 页面初始化 VM->>UC: 2. 调用 LoadChatListUseCase @@ -7907,7 +7942,7 @@ sequenceDiagram else 缓存未命中 RepoImpl->>LocalDS: 6b. 读取本地数据库 LocalDS-->>RepoImpl: 7. 返回本地数据 - RepoImpl->>SDK: 8. 直接调 ApiClient 请求远程数据 + RepoImpl->>SDK: 8. 调 NetworksSdkApi 请求远程数据 SDK-->>RepoImpl: 9. 返回最新数据 RepoImpl->>LocalDS: 10. 更新本地数据库 RepoImpl->>Cache: 11. 更新缓存 @@ -8583,9 +8618,9 @@ abstract class ChatRepository { Future<void> sendMessage(Message message); } -// 2. Repository 实现层(直接注入 ApiClient) +// 2. Repository 实现层(注入 Facade 接口) class ChatRepositoryImpl implements ChatRepository { - final ApiClient client; + final NetworksSdkApi client; final LocalDataSource localDataSource; final MessageMapper mapper; @@ -8603,7 +8638,7 @@ class ChatRepositoryImpl implements ChatRepository { return localDTOs.map(mapper.toEntity).toList(); } - // 直接调 ApiClient 从远程获取 + // 调 NetworksSdkApi 从远程获取 final response = await client.executeRequest( GetMessagesRequest(chatId: chatId), ); @@ -9770,6 +9805,146 @@ flowchart TD

    单一职责:每个模块只做一件事,UseCase/ViewModel/Repository 各司其职

    + + +

    第八部分:UI 设计规范

    + +

    本章定义颜色、字体、组件、弹框、图标的命名与使用规则,明确设计与研发的协作约定。Figma 按此命名,代码按此封装,两端名称一一对应。

    + +

    8.0 核心约定

    + +
    +

    全局只有一份

    +

    颜色、字体、基础组件、业务弹框、图片、图标——Figma 里每种元素只有一个定义。没有"备用版本",没有"临时副本",不允许两个"差不多一样"的组件并存。

    +
    + +
      +
    1. Figma 命名是重中之重:点中任何元素都必须看到抽象名称。六类无例外: +
        +
      • 颜色 — 如 primarysurface
      • +
      • 字体 — 如 Body/MediumLabel/Small
      • +
      • 基础组件 — 如 Button/PrimaryInput/Default,全局只有一个版本
      • +
      • 业务弹框 — 如 Dialog/Confirm
      • +
      • 图片 — Figma 统一导出,代码侧 AppAssets 注册,不硬编码路径
      • +
      • 图标 — 如 sendmore_options,代码侧 AppIcons 调用
      • +
      +
    2. +
    3. 基础组件定稿后不随意改动:需改动时必须先告知研发,评估影响范围,双方同步后再执行
    4. +
    5. UI 团队自主维护 UI 基建体系:研发照着 Figma 名字封装,名称必须完全一致
    6. +
    7. 所有元素遵循同一套命名规则:新增任何元素先在 Figma 定名,研发用相同名字注册
    8. +
    + +
    +图片和组件是重灾区:没有统一来源时,不同研发各自导出同一张图,文件名不同、尺寸不同,最终项目里堆满重复文件。Figma 统一命名、代码统一注册,才能从源头堵住。 +
    + +

    8.1 颜色体系

    + +

    所有颜色通过抽象名称引用。抽象名在亮色 / 暗色两套主题下对应不同色值,修改主题只需改映射表,不需逐个找组件。

    + +

    语义色(随主题变化)

    + + + + + + + + + + + + + + +
    抽象名Figma 名亮色暗色用途
    primaryPrimary#2F80ED#5BA3F5主操作、链接、选中态
    backgroundBackground#F8F9FA#202124页面底色
    surfaceSurface#FFFFFF#3C4043卡片、弹框、输入框
    onSurfaceOn Surface#202124#FFFFFFsurface 上的文字
    errorError#EB5757错误状态
    successSuccess#27AE60成功状态
    warningWarning#F2C94C警告状态
    + +

    灰阶(固定值,不随主题变化)

    + + + + + + + + +
    名称色值名称色值名称色值
    white#FFFFFFgray50#F8F9FAgray100#F1F3F4
    gray200#E8EAEDgray400#BDC1C6gray600#80868B
    gray800#3C4043gray900#202124black#000000
    + +

    使用原则:需随主题切换 → 用语义色(primarysurface);亮暗保持不变 → 用灰阶固定值。

    + +

    8.2 字体体系

    + +

    字体按层级分五档:Display、Headline、Title、Body、Label,每档三个尺寸。Figma 中按 层级/尺寸 格式命名(如 Body/Large),开发用同名变量调用。

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Figma 名称字号字重行高字距典型用途
    DISPLAY
    Display/Large5740064-0.25启动页大标题、空状态
    Display/Medium4540052
    Display/Small3640044
    HEADLINE
    Headline/Large3240040页面主标题、导航栏
    Headline/Medium2840036
    Headline/Small2440032
    TITLE
    Title/Large2250028会话列表名称、设置项标题
    Title/Medium16500240.15卡片标题、列表主行
    Title/Small14500200.1
    BODY
    Body/Large16400240.5聊天气泡、表单输入
    Body/Medium14400200.25正文说明、列表副行
    Body/Small12400160.4辅助信息、提示文字
    LABEL
    Label/Large14500200.1按钮文字、Tab 标签、Badge
    Label/Medium12500160.5次要标签、徽标文字
    Label/Small11500160.5最小粒度标签
    语义样式
    Section Label136000.5列表分组标题、设置分区
    Body/Muted1240016说明文字(灰色,低对比度)
    Body/Error1240016表单错误提示(红色)
    Label/Muted1250016时间戳、元数据(低对比度)
    + +

    8.3 组件 — Button

    + +

    按钮共四种变体,每种有明确使用场景和 Figma 组件名。每个页面上主操作只用一个 Primary。

    + + + + + + + + + +
    Figma 组件名用途亮色样式暗色样式状态
    Button/Primary主操作(登录、发送、确认),每屏最多一次背景 #2F80ED,白字背景 #5BA3F5,白字默认 / Loading / 禁用(#BDC1C6)
    Button/Secondary次要操作(注册、稍后再说),描边样式描边 #2F80ED,蓝字描边 #5BA3F5,蓝字默认 / 禁用
    Button/Text辅助链接(忘记密码、查看全部、弹框取消)无背景,蓝字无背景,蓝字默认
    Button/Inverse反色按钮(深色背景高亮),支持左侧图标背景 #202124,白字背景 #FFFFFF,黑字默认
    + +

    8.4 业务弹框 — Dialog

    + +

    当前封装一种通用确认弹框 Dialog/Confirm,后续新增先在 Figma 以 Dialog/ 前缀命名。

    + + + + + + + + +
    项目说明
    结构标题(不超 15 字) + 内容 + 操作区(取消 Text 样式 | 确认 Primary 样式)
    可配置标题文字、内容文字、确认/取消标签、点击背景是否可关闭
    返回值确认 / 取消 / 关闭(点击背景)
    + +

    8.5 图标规范

    + +
      +
    1. UI 先命名,开发跟随:Figma 确定名称,开发用完全相同的名称封装到 AppIcons
    2. +
    3. 名称有实际语义:全小写下划线,如 sendadd_contactmore_options。不用拼音,不缩写
    4. +
    5. 统一用 AppIcons 调用:不允许直接用裸 icon 库变量,替换时改一处全局生效
    6. +
    7. 同义图标只保留一个:同功能图标在整个产品内只存在一种
    8. +
    + +

    新增图标流程:设计师 Figma 确认名称 → 告知开发 → 开发用相同名称在 AppIcons 注册。两端名称必须完全一致。

    + diff --git a/apps/im_app/android/settings.gradle.kts b/apps/im_app/android/settings.gradle.kts index ca7fe06..3ca60d7 100644 --- a/apps/im_app/android/settings.gradle.kts +++ b/apps/im_app/android/settings.gradle.kts @@ -21,6 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false } include(":app") diff --git a/apps/im_app/ios/Podfile b/apps/im_app/ios/Podfile index cf9087f..9100f59 100644 --- a/apps/im_app/ios/Podfile +++ b/apps/im_app/ios/Podfile @@ -38,5 +38,10 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + # 对没有在 podspec 里声明 swift_version 的 pod 设置兜底版本。 + # 已有 swift_version 的 pod(含第三方如 Agora)CocoaPods 优先使用其 podspec 值,不受影响。 + config.build_settings['SWIFT_VERSION'] ||= '6.2' + end end end diff --git a/apps/im_app/ios/Runner.xcodeproj/project.pbxproj b/apps/im_app/ios/Runner.xcodeproj/project.pbxproj index 2bf7cf1..e2932a4 100644 --- a/apps/im_app/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/im_app/ios/Runner.xcodeproj/project.pbxproj @@ -164,7 +164,6 @@ 1C416905D0EA345032C4E612 /* Pods-RunnerTests.release.xcconfig */, 9538107A41BCB5B5D84FBAF3 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -489,7 +488,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; @@ -508,7 +507,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Debug; @@ -524,7 +523,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Release; @@ -540,7 +539,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; @@ -678,7 +677,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; @@ -705,7 +704,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; diff --git a/apps/im_app/ios/Runner/AppDelegate.swift b/apps/im_app/ios/Runner/AppDelegate.swift index fe8e3fa..8d8a0bd 100644 --- a/apps/im_app/ios/Runner/AppDelegate.swift +++ b/apps/im_app/ios/Runner/AppDelegate.swift @@ -1,8 +1,8 @@ -import Flutter +@preconcurrency import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { +@objc class AppDelegate: FlutterAppDelegate { override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return super.application(application, didFinishLaunchingWithOptions: launchOptions) @@ -16,9 +16,11 @@ import UIKit sceneConfig.delegateClass = SceneDelegate.self return sceneConfig } +} - // MARK: - FlutterImplicitEngineDelegate - +// FlutterImplicitEngineDelegate 来自 Flutter ObjC 框架,尚未标注 @MainActor, +// 用 @preconcurrency 抑制 Swift 6 ConformanceIsolation 错误。 +extension AppDelegate: @preconcurrency FlutterImplicitEngineDelegate { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } diff --git a/apps/im_app/lib/app/di/app_providers.dart b/apps/im_app/lib/app/di/app_providers.dart index c5e6f28..c76c412 100644 --- a/apps/im_app/lib/app/di/app_providers.dart +++ b/apps/im_app/lib/app/di/app_providers.dart @@ -23,11 +23,18 @@ class AuthNotifier extends ChangeNotifier { void login() { _isLoggedIn = true; + // TODO: 接入 cipher_guard_sdk 后,在此处完成 RSA 密钥注入: + // 1. 从安全存储(keychain / secure storage)读取公私钥对(只读一次) + // 2. cipherSdk.setActiveKeyPair(publicKey: pubPem, privateKey: privPem) + // 须在 notifyListeners() 之前完成,确保路由跳转后 onEncryptRequest 回调触发时密钥已就绪。 notifyListeners(); } void logout() { _isLoggedIn = false; + // TODO: 接入 cipher_guard_sdk 后,退出登录时清除内存密钥: + // cipherSdk.clearActiveKeyPair() + // cipherSdk.clearDerivedKeyCache() notifyListeners(); } } @@ -37,9 +44,7 @@ class AuthNotifier extends ChangeNotifier { /// 使用 [Provider] 持有 [AuthNotifier] 单例。 /// go_router 通过 [GoRouter.refreshListenable] 直接监听 [AuthNotifier](ChangeNotifier), /// Riverpod 侧不需要响应式更新(导航由 go_router 接管)。 -final authNotifierProvider = Provider( - (ref) => AuthNotifier(), -); +final authNotifierProvider = Provider((ref) => AuthNotifier()); // ── 主题 ────────────────────────────────────────────────────────────────────── diff --git a/apps/im_app/lib/app/di/network_provider.dart b/apps/im_app/lib/app/di/network_provider.dart index e1792c1..be1fd35 100644 --- a/apps/im_app/lib/app/di/network_provider.dart +++ b/apps/im_app/lib/app/di/network_provider.dart @@ -6,6 +6,8 @@ import 'package:networks_sdk/networks_sdk.dart'; import '../../core/foundation/api_paths.dart'; import '../../core/foundation/config.dart'; import '../../core/foundation/constants.dart'; +import '../../core/foundation/errors.dart'; +import '../../core/foundation/utils.dart'; import '../../core/services/network_monitor.dart'; import '../../core/services/socket_manager.dart'; @@ -47,6 +49,21 @@ final networkMonitorProvider = Provider((ref) { return monitor; }); +// ── Token 更新事件流 ───────────────────────────────────────────────────────── + +/// Token 更新事件流 +/// +/// apiConfigProvider.onTokenUpdated → 推送新 token 到此流 +/// socketManagerProvider → 监听此流 → 同步 token 到 WebSocket +/// onBeforeReconnect 中刷新 token 后调用 apiConfig.updateToken → tokenStream.add, +/// 需要同步传播到 socketManager.updateToken → socketClient._currentToken, +/// 确保随后的 _doConnect() 使用新 token。异步模式下 _doConnect 会在 stream +final _tokenUpdateStreamProvider = Provider>((ref) { + final controller = StreamController.broadcast(sync: true); + ref.onDispose(controller.close); + return controller; +}); + // ── HTTP 基础设施 ───────────────────────────────────────────────────────────── /// API 配置 Provider(全局单例) @@ -58,15 +75,18 @@ final networkMonitorProvider = Provider((ref) { /// 请求前先判断网络状态,无网络时直接抛 [ApiError.noNetworkConnection]。 final apiConfigProvider = Provider((ref) { final networkMonitor = ref.read(networkMonitorProvider); + final tokenStream = ref.read(_tokenUpdateStreamProvider); return ApiConfig( baseURL: AppConfig.apiBaseUrl, platformHeaders: { - 'Platform': 'Android', // TODO: 运行时从平台 API 获取 + 'Platform': 'Android', // TODO: 运行时从 platform API 获取 'client-version': '1.0.0', // TODO: 运行时从 package_info 获取 + 'Channel': '', // TODO: 从 AppConfig 读取渠道标识 + 'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取 }, - tokenExpiredCodes: {30002, 30003, 30124}, - forceLogoutCodes: {30125}, + tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes, + forceLogoutCodes: ApiErrorCodes.forceLogoutCodes, onForceLogout: () { // TODO: 清除登录态,跳转登录页 }, @@ -74,7 +94,33 @@ final apiConfigProvider = Provider((ref) { // TODO: App 层刷新 token 逻辑 return null; }, + onTokenUpdated: (newToken) { + // 通过事件流同步到 WebSocket,避免直接引用 socketManagerProvider 造成循环依赖 + tokenStream.add(newToken); + }, onCheckNetworkAvailable: () async => networkMonitor.isConnected, + // TODO: 接入 cipher_guard_sdk 后注入请求加密回调。 + // 前提:AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。 + // 示例: + // onEncryptRequest: (path, headers, body) async { + // final encryptedKey = await cipherSdk.encryptSessionKeyWithActiveKey( + // sessionKey: currentSessionKey, + // ); + // return EncryptedRequest(body: encryptedBody, headers: {'X-Key': encryptedKey}); + // }, + onEncryptRequest: null, + // TODO: 接入 cipher_guard_sdk 后注入响应解密回调。 + // 前提:与 onEncryptRequest 配套,服务端响应同样加密时启用。 + // 示例: + // onDecryptResponse: (data) async { + // final plaintext = await cipherSdk.decryptMessage(encryptedData: data as String, ...); + // return jsonDecode(plaintext) as Map; + // }, + onDecryptResponse: null, + onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等) + onTransformResponse: + null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message } + onGetTokenExpiry: parseJwtExpiry, maxRetries: AppConstants.maxRetries, retryBaseDelay: AppConstants.retryBaseDelay, onLog: (message, {tag}) { @@ -94,16 +140,47 @@ final networkSdkApiProvider = Provider((ref) { // ── WebSocket 基础设施 ──────────────────────────────────────────────────────── -/// SocketConfig Provider(全局单例) +/// SocketConfig Provider(内部使用,不对外暴露) /// /// 与 apiConfigProvider 对称,通过回调注入 App 层能力, /// SDK 内部不调用其他 SDK。 -final socketConfigProvider = Provider((ref) { +final _socketConfigProvider = Provider((ref) { final networkMonitor = ref.read(networkMonitorProvider); return SocketConfig( maxReconnectAttempts: AppConstants.maxRetries, maxReconnectDelay: AppConstants.maxReconnectDelay, + unlimitedReconnect: true, // IM 场景始终保持连接 + onBuildConnectUrl: + null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数) + onEncryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息加密回调 + onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调 + onBeforeReconnect: () async { + // SocketClient 内部重连(心跳超时、stream onDone)前调用。 + // 与 SocketManager.onBeforeReconnect 职责相同:检查 token 并按需刷新。 + // 刷新后通过 sync stream 同步传播到 SocketClient._currentToken, + // 确保随后的 _doConnect() 使用新 token。 + final apiConfig = ref.read(apiConfigProvider); + final currentToken = apiConfig.token; + if (currentToken == null || apiConfig.onGetTokenExpiry == null) return; + + final expiry = apiConfig.onGetTokenExpiry!(currentToken); + if (expiry == null) return; + + final remaining = expiry.difference(DateTime.now()); + if (remaining > apiConfig.proactiveRefreshThreshold) return; + + // ignore: avoid_print + print( + '[Socket] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect', + ); + final newToken = await apiConfig.onTokenRefresh?.call(); + if (newToken != null && newToken.isNotEmpty) { + // updateToken → onTokenUpdated → sync stream → manager.updateToken + // → _client.updateToken → socketClient._currentToken 同步更新 + apiConfig.updateToken(newToken); + } + }, onLog: (message, {tag}) { // ignore: avoid_print print('[${tag ?? 'Socket'}] $message'); @@ -114,12 +191,11 @@ final socketConfigProvider = Provider((ref) { ); }); -/// SocketClient Provider(全局单例) +/// SocketClient Provider(内部使用,不对外暴露) /// -/// 与 apiClientProvider 对称。 -final socketClientProvider = Provider((ref) -{ - final config = ref.read(socketConfigProvider); +/// 与 networkSdkApiProvider 对称。 +final _socketClientProvider = Provider((ref) { + final config = ref.read(_socketConfigProvider); return NetworksMessagingApi()..initialize(config); }); @@ -139,17 +215,44 @@ final socketClientProvider = Provider((ref) /// 网络状态变化由 [networkMonitorProvider](公共服务)驱动, /// 自动触发断连/重连。 /// +/// Token 更新由 [_tokenUpdateStreamProvider] 事件流驱动, +/// HTTP 层刷新 token 后自动同步到 WebSocket。 +/// /// onMessageTransform 参考 HTTP 层 onTokenRefresh 的回调模式: /// 后续接入加解密 SDK 时,在此注入解密回调, /// SDK 内部不调用其他 SDK。 final socketManagerProvider = Provider((ref) { - final client = ref.read(socketClientProvider); + final client = ref.read(_socketClientProvider); final networkMonitor = ref.read(networkMonitorProvider); + final apiConfig = ref.read(apiConfigProvider); + final tokenStream = ref.read(_tokenUpdateStreamProvider); final manager = SocketManager( client: client, wsUrl: _buildWsUrl(AppConfig.apiBaseUrl), + disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断 onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调 + onBeforeReconnect: () async { + // 重连前检查 token 是否即将过期,是则主动刷新 + final currentToken = apiConfig.token; + if (currentToken == null || apiConfig.onGetTokenExpiry == null) return; + + final expiry = apiConfig.onGetTokenExpiry!(currentToken); + if (expiry == null) return; + + final remaining = expiry.difference(DateTime.now()); + if (remaining > apiConfig.proactiveRefreshThreshold) return; + + // ignore: avoid_print + print( + '[SocketManager] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect', + ); + final newToken = await apiConfig.onTokenRefresh?.call(); + if (newToken != null && newToken.isNotEmpty) { + // updateToken 触发 onTokenUpdated → tokenStream → socketManager.updateToken + apiConfig.updateToken(newToken); + } + }, onCheckNetworkAvailable: () async => networkMonitor.isConnected, onLog: (message, {tag}) { // ignore: avoid_print @@ -157,13 +260,19 @@ final socketManagerProvider = Provider((ref) { }, ); + // 监听 token 更新事件 → 同步到 WebSocket + final tokenSub = tokenStream.stream.listen((newToken) { + manager.updateToken(newToken); + }); + // 监听网络状态变化 → 驱动 SocketManager 断连/重连 - final subscription = networkMonitor.onStatusChanged.listen((isAvailable) { + final networkSub = networkMonitor.onStatusChanged.listen((isAvailable) { manager.handleNetworkStatusChanged(isAvailable: isAvailable); }); ref.onDispose(() { - subscription.cancel(); + tokenSub.cancel(); + networkSub.cancel(); unawaited(manager.dispose()); }); @@ -215,23 +324,57 @@ String _buildWsUrl(String httpBaseUrl) { // Provider 链路: // // networkMonitorProvider(公共服务,HTTP + WS 共用) -// ├── apiConfigProvider → apiClientProvider ← HTTP 层 -// └── socketConfigProvider → socketClientProvider ← WS 层 +// ├── apiConfigProvider → networkSdkApiProvider ← HTTP 层 +// └── _socketConfigProvider → _socketClientProvider ← WS 层(内部) // → socketManagerProvider // +// _tokenUpdateStreamProvider(打破循环引用的中间层) +// ← apiConfigProvider.onTokenUpdated 推送 +// → socketManagerProvider 监听 → socketManager.updateToken() +// // 网络事件驱动链路: // // connectivity_plus(平台网络事件) // → NetworkMonitor.onStatusChanged(true / false) // → SocketManager.handleNetworkStatusChanged() // → 断网: disconnect() -// → 恢复: connect(token: lastToken) +// → 恢复: onBeforeReconnect → connect(token: lastToken) // // 前后台事件驱动链路: // // WidgetsBindingObserver(App 层 app.dart) -// → SocketManager.onEnterBackground() → disconnect -// → SocketManager.onEnterForeground() → reconnect +// → SocketManager.onEnterBackground() +// disconnectInBackground=false → 完全保活,心跳不停(本项目默认) +// disconnectInBackground=true → disconnect + 暂停心跳(省电模式) +// → SocketManager.onEnterForeground() +// 保活模式 → 检查连接健康,异常则重连 +// 断连模式 → onBeforeReconnect → reconnect +// +// Token 刷新 → WebSocket 同步链路: +// +// RetryInterceptor 检测 token 过期 +// → TokenRefreshManager.refreshIfNeeded() +// → apiConfig.updateToken(newToken) +// → onTokenUpdated(newToken) +// → _tokenUpdateStream.add(newToken) +// → socketManager.updateToken(newToken) // 不断连,下次重连自动用新 token +// +// 主动 token 刷新(重连前,两个层级): +// +// SocketManager 层(前台恢复 / 网络恢复触发): +// SocketManager.onBeforeReconnect() +// → 解析 JWT exp → 距过期 < 阈值 +// → apiConfig.onTokenRefresh() → 刷新 +// → apiConfig.updateToken(newToken) +// → sync stream → manager.updateToken → _lastToken 更新 +// → _client.connect(token: _lastToken) 使用新 token +// +// SocketClient 层(心跳超时 / stream onDone 触发): +// SocketConfig.onBeforeReconnect() +// → 同上逻辑:检查 JWT exp → 刷新 → apiConfig.updateToken +// → sync stream → manager.updateToken → _client.updateToken +// → socketClient._currentToken 同步更新 +// → _doConnect() 使用新 token // // Repository 直接注入 ApiClient,通过回调注入其他 SDK 能力: // @@ -313,7 +456,7 @@ String _buildWsUrl(String httpBaseUrl) { // final authRepositoryProvider = Provider((ref) { // final apiConfig = ref.read(apiConfigProvider); // return AuthRepositoryImpl( -// client: ref.read(apiClientProvider), // 直接注入 +// client: ref.read(networkSdkApiProvider), // 注入 Facade 接口 // onTokenUpdate: (token) { // apiConfig.updateToken(token); // 内存(network_sdk) // // secureStorage.saveToken(token); // 持久化(crypto_sdk) @@ -400,5 +543,5 @@ String _buildWsUrl(String httpBaseUrl) { // Upload B: 二进制上传到 S3 presigned URL // @override String get path => presignedURL; // 完整 URL,不拼 baseURL // @override Object? get uploadData => bytes; // Uint8List -// @override decodeResponse(response) { ... } // S3 不走标准信封 +// @override decodeResponse(response) { ... } // S3 不走标准响应格式 // diff --git a/apps/im_app/lib/core/foundation/errors.dart b/apps/im_app/lib/core/foundation/errors.dart index e69de29..983c096 100644 --- a/apps/im_app/lib/core/foundation/errors.dart +++ b/apps/im_app/lib/core/foundation/errors.dart @@ -0,0 +1,57 @@ +/// API 错误码常量 +/// +/// 集中管理后端业务错误码,避免散落在各处硬编码。 +/// 按业务域分组,命名风格对齐后端定义。 +/// +/// 使用方式: +/// ```dart +/// ApiConfig( +/// tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes, +/// forceLogoutCodes: ApiErrorCodes.forceLogoutCodes, +/// ) +/// ``` +class ApiErrorCodes { + ApiErrorCodes._(); + + // ── 认证(30001-30009)── + + /// Token 无效 + static const int tokenInvalid = 30002; + + /// JWT 无效 + static const int jwtInvalid = 30003; + + /// 签名方法错误 + static const int signingMethodError = 30008; + + /// 密钥解析失败 + static const int parsingKeyError = 30009; + + /// Session 无效 + static const int sessionInvalid = 30124; + + /// Refresh Token 失效 + static const int refreshTokenFailed = 30125; + + /// 账号在其他设备登录 + static const int loggedInAnotherDevice = 30006; + + // ── 错误码集合 ── + + /// Token 过期错误码集合 — 触发自动刷新 Token + static const Set tokenExpiredCodes = { + tokenInvalid, + jwtInvalid, + sessionInvalid, + }; + + /// 强制登出错误码集合 — 触发退出登录流程 + static const Set forceLogoutCodes = {refreshTokenFailed}; + + /// 踢下线错误码集合 — 触发踢下线 UI 提示 + static const Set kickOffCodes = { + loggedInAnotherDevice, + signingMethodError, + parsingKeyError, + }; +} diff --git a/apps/im_app/lib/core/foundation/utils.dart b/apps/im_app/lib/core/foundation/utils.dart index e69de29..707912c 100644 --- a/apps/im_app/lib/core/foundation/utils.dart +++ b/apps/im_app/lib/core/foundation/utils.dart @@ -0,0 +1,32 @@ +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +/// JWT token 过期时间解析 +/// +/// 使用 dart_jsonwebtoken 解码 JWT payload,提取 `exp` claim 返回过期时间。 +/// 返回 null 表示无法解析(非 JWT 格式或缺少 exp 字段)。 +/// +/// 只读取 payload,不验证签名(验证是服务端的事)。 +/// +/// 用于 [ApiConfig.onGetTokenExpiry] 回调,启用 token 主动刷新: +/// 距过期不足阈值时提前刷新,避免带过期 token 发请求或重连。 +/// +/// ```dart +/// final expiry = parseJwtExpiry('eyJhbGci...'); +/// if (expiry != null) { +/// final remaining = expiry.difference(DateTime.now()); +/// print('Token expires in ${remaining.inMinutes} min'); +/// } +/// ``` +DateTime? parseJwtExpiry(String token) { + try { + final jwt = JWT.decode(token); + final payload = jwt.payload; + if (payload is! Map) return null; + + final exp = payload['exp']; + if (exp is! int) return null; + return DateTime.fromMillisecondsSinceEpoch(exp * 1000); + } catch (_) { + return null; + } +} diff --git a/apps/im_app/lib/core/services/socket_manager.dart b/apps/im_app/lib/core/services/socket_manager.dart index 0049616..efc1d8b 100644 --- a/apps/im_app/lib/core/services/socket_manager.dart +++ b/apps/im_app/lib/core/services/socket_manager.dart @@ -1,6 +1,5 @@ import 'dart:async'; - import 'package:networks_sdk/networks_sdk.dart'; import 'network_backoff_debouncer.dart'; @@ -10,15 +9,14 @@ import 'network_backoff_debouncer.dart'; /// 参考 HTTP 层 onTokenRefresh 的回调注入模式。 /// App 层在 Provider 装配时注入解密/解析逻辑, /// 不在 SDK 内部调用加解密 SDK。 -typedef MessageTransformer = Map Function( - Map raw, -); +typedef MessageTransformer = + Map Function(Map raw); /// WebSocket 连接管理 /// /// 在 SocketClient(SDK 底层能力)之上封装: /// - 连接/断连生命周期(登录连接、登出断连) -/// - 前后台生命周期(后台断连省电、前台自动重连) +/// - 前后台生命周期(两种模式:后台断连 / 后台保活) /// - 网络状态响应(断网断连、恢复网络立即重连) /// - 操作前置检查(网络可用性 + 后台状态) /// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等) @@ -39,19 +37,26 @@ typedef MessageTransformer = Map Function( /// /// ``` /// 登录成功 → connect(token) → 前置检查 → 建立连接 -/// App 进后台 → onEnterBackground() → 断开连接(省电) -/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连 +/// +/// ── disconnectInBackground = true(后台断连模式)── +/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电) +/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连 +/// +/// ── disconnectInBackground = false(后台保活模式,本项目默认)── +/// App 进后台 → onEnterBackground() → 不操作,心跳不停、连接不断 +/// App 回前台 → onEnterForeground() → 检查连接健康,异常则重连 +/// /// 网络丢失 → handleNetworkLost() → 断开连接 -/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动) +/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连 /// 登出 → disconnect() → 断开连接,清除 token /// ``` /// /// ## 前置检查策略 /// /// 所有会发起网络操作的方法都先检查前置条件: -/// - connect → 检查网络可用性 + 是否在后台 -/// - send / sendString → 检查连接状态 + 是否在后台 -/// - onEnterForeground 重连 → 检查网络可用性 +/// - connect → 检查网络可用性 + 是否在后台(仅 disconnectInBackground=true 时拦截) +/// - send / sendString → 检查连接状态 + 是否在后台(仅 disconnectInBackground=true 时拦截) +/// - onEnterForeground / 网络恢复重连 → 检查网络可用性 + onBeforeReconnect class SocketManager { final NetworksMessagingApi _client; final String _wsUrl; @@ -70,6 +75,22 @@ class SocketManager { /// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。 final Future Function()? onCheckNetworkAvailable; + /// 重连前回调 + /// + /// 在 WebSocket 重连前调用(前台恢复、网络恢复),App 层用于: + /// - 检查并刷新即将过期的 token + /// - 更新连接参数 + /// + /// 回调完成后才发起实际重连。 + final Future Function()? onBeforeReconnect; + + /// 进后台时是否断开连接 + /// + /// true(SDK 默认)— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。 + /// false(本项目使用)— 后台保持连接,心跳不停、请求不停,最大程度保活。 + /// 回前台时检查连接健康,异常则触发重连。 + final bool disconnectInBackground; + /// 日志回调 final void Function(String message, {String? tag})? onLog; @@ -104,10 +125,12 @@ class SocketManager { required NetworksMessagingApi client, required String wsUrl, this.onMessageTransform, + this.onBeforeReconnect, + this.disconnectInBackground = true, this.onCheckNetworkAvailable, this.onLog, - }) : _client = client, - _wsUrl = wsUrl; + }) : _client = client, + _wsUrl = wsUrl; // ── 连接 ────────────────────────────────────────────────────────────────── @@ -124,8 +147,8 @@ class SocketManager { _reconnectOnForeground = false; _reconnectOnNetworkRestore = false; - // 前置检查:在后台不连接(省电) - if (_isInBackground) { + // 前置检查:后台断连模式下在后台不连接(省电) + if (_isInBackground && disconnectInBackground) { _reconnectOnForeground = true; _log('In background, defer connect to foreground'); return false; @@ -165,26 +188,47 @@ class SocketManager { /// 当前是否在后台 bool get isInBackground => _isInBackground; + /// Token 热更新 + /// + /// 透传给 SocketClient,仅更新内部 token,不断开连接。 + /// 适用于 HTTP 层 token 刷新后同步到 WebSocket 的场景。 + void updateToken(String token) { + _lastToken = token; + _client.updateToken(token); + _log('Token updated via SocketManager'); + } + // ── 前后台生命周期 ──────────────────────────────────────────────────────── // - // 后台 → 断连(省电省流量) - // 前台 → 自动重连(如果之前有连接) + // 后台 → 保活(心跳不停、连接不断)或断连(省电模式) + // 前台 → 检查连接健康 / 自动重连 - /// App 进后台 → 断开连接,标记前台恢复时重连 + /// App 进后台 /// /// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。 - /// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。 + /// + /// [disconnectInBackground] 为 false 时(后台保活,本项目默认): + /// 不断连、不暂停心跳,WebSocket 完全保活。 + /// + /// [disconnectInBackground] 为 true 时(后台断连模式): + /// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。 void onEnterBackground() { _isInBackground = true; // 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连) _foregroundReconnectTimer?.cancel(); _foregroundReconnectTimer = null; - // 同步 SocketClient 内部状态(与 onEnterForeground 对称) + + if (!disconnectInBackground) { + // 后台保活模式:不断连、不暂停心跳,不通知 SocketClient + _log('Entering background, keeping connection alive'); + return; + } + + // 后台断连模式:通知 SocketClient 进后台(暂停心跳) _client.onEnterBackground(); if (_lastToken == null) return; // 未登录,无需处理 - // 与 _handleNetworkLost 保持一致: // 不仅 connected,connecting / reconnecting 也要断开, // 防止 SocketClient 在后台继续尝试连接浪费电量和流量。 if (_client.isConnected || @@ -196,41 +240,76 @@ class SocketManager { } } - /// App 回前台 → 自动重连(如果之前后台断连) + /// App 回前台 /// /// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。 - /// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。 + /// + /// 后台保活模式(disconnectInBackground=false): + /// 检查连接健康,如果后台期间连接意外断开则自动重连。 + /// + /// 后台断连模式(disconnectInBackground=true): + /// 通知 SocketClient 恢复心跳,然后重新建立连接。 void onEnterForeground() { _isInBackground = false; - _client.onEnterForeground(); + + if (disconnectInBackground) { + // 后台断连模式:通知 SocketClient 恢复心跳 + _client.onEnterForeground(); + } + + if (!disconnectInBackground && _lastToken != null) { + // 后台保活模式:检查连接健康 + // 虽然后台期间心跳不停,但连接仍可能因网络切换、服务端关闭等原因断开。 + // SocketClient 的自动重连在后台也会工作(_isBackground=false), + // 但回前台时兜底检查一次,确保连接可用。 + if (!_client.isConnected) { + _log('Returning to foreground, connection lost, reconnecting...'); + _scheduleReconnect(); + } else { + _log('Returning to foreground, connection healthy'); + } + return; + } if (_reconnectOnForeground && _lastToken != null) { + // 后台断连模式:之前后台断连过,需要重连 _reconnectOnForeground = false; _log('Returning to foreground, reconnecting...'); - // 延迟 500ms 等待网络稳定,通过 Timer 跟踪以便进后台时取消 - _foregroundReconnectTimer?.cancel(); - _foregroundReconnectTimer = Timer( - const Duration(milliseconds: 500), - () async { - _foregroundReconnectTimer = null; - // 双重保险:回调执行时再次检查后台状态 - if (_isInBackground) { - _reconnectOnForeground = true; - _log('Went back to background during delay, skip reconnect'); + _scheduleReconnect(); + } + } + + /// 延迟 500ms 后执行重连 + /// + /// 等待网络稳定,通过 Timer 跟踪以便进后台时取消。 + void _scheduleReconnect() { + _foregroundReconnectTimer?.cancel(); + _foregroundReconnectTimer = Timer( + const Duration(milliseconds: 500), + () async { + _foregroundReconnectTimer = null; + // 双重保险:回调执行时再次检查后台状态 + if (_isInBackground) { + _reconnectOnForeground = true; + _log('Went back to background during delay, skip reconnect'); + return; + } + if (!_client.isConnected && _lastToken != null) { + // 前置检查:网络可用性 + if (!await _isNetworkAvailable()) { + _reconnectOnNetworkRestore = true; + _log('Network unavailable, defer reconnect to network restore'); return; } - if (!_client.isConnected && _lastToken != null) { - // 前置检查:网络可用性 - if (!await _isNetworkAvailable()) { - _reconnectOnNetworkRestore = true; - _log('Network unavailable, defer reconnect to network restore'); - return; - } + // 重连前钩子:刷新即将过期的 token 等 + await onBeforeReconnect?.call(); + // token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步) + if (_lastToken != null && !_client.isConnected) { _client.connect(_wsUrl, token: _lastToken!); } - }, - ); - } + } + }, + ); } // ── 网络状态变化 ────────────────────────────────────────────────────────── @@ -275,18 +354,22 @@ class SocketManager { if (_reconnectOnNetworkRestore && _lastToken != null) { _reconnectOnNetworkRestore = false; - // 在后台不重连,等前台恢复时再连 - if (_isInBackground) { + // 后台断连模式:在后台不重连,等前台恢复时再连 + if (_isInBackground && disconnectInBackground) { _reconnectOnForeground = true; _log('Network restored but in background, defer to foreground'); return; } _log('Network restored, scheduling reconnect with backoff'); - _networkDebouncer.call(() { + _networkDebouncer.call(() async { if (!_client.isConnected && _lastToken != null && !_isInBackground) { - _log('Backoff timer fired, reconnecting'); - _client.connect(_wsUrl, token: _lastToken!); + // 重连前钩子:刷新即将过期的 token 等 + await onBeforeReconnect?.call(); + if (!_client.isConnected && _lastToken != null && !_isInBackground) { + _log('Backoff timer fired, reconnecting'); + _client.connect(_wsUrl, token: _lastToken!); + } } }); } @@ -308,6 +391,9 @@ class SocketManager { /// 原始消息流(不经预处理,调试用) Stream get rawMessageStream => _client.rawMessageStream; + /// 二进制消息流 + Stream get binaryMessageStream => _client.binaryMessageStream; + /// 连接状态变化流 Stream get connectionStateStream => _client.connectionStateStream; @@ -333,6 +419,14 @@ class SocketManager { return _client.sendString(message); } + /// 发送二进制数据 + /// + /// 前置检查:未连接或在后台时不发送。 + Future sendBytes(List bytes) { + if (!_canSend()) return Future.value(false); + return _client.sendBytes(bytes); + } + // ── 释放 ────────────────────────────────────────────────────────────────── /// 释放所有资源 @@ -347,7 +441,10 @@ class SocketManager { /// 发送前置检查 /// - /// 两重保险:连接状态 + 后台状态。 + /// 后台保活模式(disconnectInBackground=false):只检查连接状态, + /// 后台也能正常发送。 + /// + /// 后台断连模式(disconnectInBackground=true):额外检查后台状态, /// 后台已断连所以 isConnected 通常就能拦住, /// 但显式检查 _isInBackground 防止边界情况遗漏。 bool _canSend() { @@ -355,8 +452,8 @@ class SocketManager { _log('Not connected, cannot send'); return false; } - if (_isInBackground) { - _log('In background, skip send'); + if (_isInBackground && disconnectInBackground) { + _log('In background (disconnect mode), skip send'); return false; } return true; diff --git a/apps/im_app/lib/core/ui/base/assets.dart b/apps/im_app/lib/core/ui/base/assets.dart new file mode 100644 index 0000000..5b6122f --- /dev/null +++ b/apps/im_app/lib/core/ui/base/assets.dart @@ -0,0 +1,30 @@ +/// 静态资源路径常量,统一维护,避免路径字符串散落在业务代码中。 +/// +/// 所有路径须与 pubspec.yaml 的 flutter.assets 声明保持一致。 +/// 新增资源:① 文件放入 assets/ ② pubspec.yaml 声明 ③ 此处加常量。 +/// +/// 渲染逻辑(缓存、占位、错误态)由 core/ui/components/ 下的组件负责,不在此处封装。 +/// +/// ## 使用 +/// ```dart +/// Image.asset(AppAssets.logo) +/// Image.asset(AppAssets.logo, width: 80, fit: BoxFit.cover) +/// ``` +abstract final class AppAssets { + AppAssets._(); + + // ── 品牌 ────────────────────────────────────────────────── + static const logo = 'assets/images/logo.png'; + static const logoLight = 'assets/images/logo_light.png'; + + // ── 占位图 ──────────────────────────────────────────────── + static const avatarPlaceholder = 'assets/images/avatar_placeholder.png'; + + // ── 空状态插图(SVG,引入 flutter_svg 后启用) ───────────── + // static const emptyChat = 'assets/svg/empty_chat.svg'; + // static const emptyContact = 'assets/svg/empty_contact.svg'; + // static const emptySearch = 'assets/svg/empty_search.svg'; + + // ── 动画 ────────────────────────────────────────────────── + // static const loading = 'assets/gif/loading.gif'; +} diff --git a/apps/im_app/lib/core/ui/base/icons.dart b/apps/im_app/lib/core/ui/base/icons.dart new file mode 100644 index 0000000..eb1bb6a --- /dev/null +++ b/apps/im_app/lib/core/ui/base/icons.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// 项目图标常量,统一维护,避免 Icons.xxx 散落在业务代码中。 +/// +/// 渲染逻辑(大小、颜色、点击态)由调用方负责,不在此处封装。 +/// +/// ## 使用 +/// ```dart +/// Icon(AppIcons.send) +/// Icon(AppIcons.send, size: 20, color: Colors.white) +/// IconButton(icon: Icon(AppIcons.back), onPressed: ...) +/// ``` +abstract final class AppIcons { + AppIcons._(); + + // ── 底部导航 ────────────────────────────────────────────── + static const chat = Icons.chat_bubble_outline_rounded; + static const contact = Icons.people_outline_rounded; + static const settings = Icons.settings_outlined; + + // ── 通用操作 ────────────────────────────────────────────── + static const back = Icons.arrow_back_ios_new_rounded; + static const close = Icons.close_rounded; + static const more = Icons.more_horiz_rounded; + static const search = Icons.search_rounded; + static const add = Icons.add_rounded; + + // ── 聊天输入区 ──────────────────────────────────────────── + static const send = Icons.send_rounded; + static const attach = Icons.attach_file_rounded; + static const emoji = Icons.emoji_emotions_outlined; + static const camera = Icons.camera_alt_outlined; + static const voice = Icons.mic_outlined; + + // ── 用户 / 联系人 ───────────────────────────────────────── + static const avatar = Icons.account_circle_outlined; + static const addUser = Icons.person_add_outlined; + + // ── 状态反馈 ────────────────────────────────────────────── + static const success = Icons.check_circle_outline_rounded; + static const warning = Icons.warning_amber_rounded; + static const error = Icons.error_outline_rounded; + static const info = Icons.info_outline_rounded; +} diff --git a/apps/im_app/lib/data/local/drift/app_database.dart b/apps/im_app/lib/data/local/drift/app_database.dart index adbcd58..935c7e3 100644 --- a/apps/im_app/lib/data/local/drift/app_database.dart +++ b/apps/im_app/lib/data/local/drift/app_database.dart @@ -21,9 +21,30 @@ import 'package:im_app/data/local/drift/tables/chats.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Favourites,Sounds,Tags,PendingFriendRequestHistories,Messages,RecentMiniApps,Retries,Groups,FavoriteMiniApps,DiscoverMiniApps,ChatCategories,ChatBots,FavouriteDetails,UserRequestHistories,Workspaces,Users,ExploreMiniApps,CallLogs,Chats]) //update mapping here +@DriftDatabase( + tables: [ + Favourites, + Sounds, + Tags, + PendingFriendRequestHistories, + Messages, + RecentMiniApps, + Retries, + Groups, + FavoriteMiniApps, + DiscoverMiniApps, + ChatCategories, + ChatBots, + FavouriteDetails, + UserRequestHistories, + Workspaces, + Users, + ExploreMiniApps, + CallLogs, + Chats, + ], +) //update mapping here class AppDatabase extends _$AppDatabase { - static Map getTableRegistry(GeneratedDatabase database) { if (database is! AppDatabase) { return {}; @@ -67,7 +88,9 @@ class AppDatabase extends _$AppDatabase { // Create any new tables that don't exist yet for (final table in allTables) { final existingTables = await m.database - .customSelect("SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'") + .customSelect( + "SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'", + ) .get(); if (existingTables.isEmpty) { @@ -92,6 +115,4 @@ class AppDatabase extends _$AppDatabase { }, ); } - - } diff --git a/apps/im_app/lib/data/local/drift/tables/call_logs.dart b/apps/im_app/lib/data/local/drift/tables/call_logs.dart index 5aae228..4087d94 100644 --- a/apps/im_app/lib/data/local/drift/tables/call_logs.dart +++ b/apps/im_app/lib/data/local/drift/tables/call_logs.dart @@ -21,4 +21,4 @@ class CallLogs extends Table { @override String get tableName => 'call_log'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/chat_bots.dart b/apps/im_app/lib/data/local/drift/tables/chat_bots.dart index c0837f4..57ef057 100644 --- a/apps/im_app/lib/data/local/drift/tables/chat_bots.dart +++ b/apps/im_app/lib/data/local/drift/tables/chat_bots.dart @@ -30,4 +30,4 @@ class ChatBots extends Table { @override String get tableName => 'chat_bot'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/chat_categories.dart b/apps/im_app/lib/data/local/drift/tables/chat_categories.dart index 42b3f6c..9e2f7c0 100644 --- a/apps/im_app/lib/data/local/drift/tables/chat_categories.dart +++ b/apps/im_app/lib/data/local/drift/tables/chat_categories.dart @@ -17,4 +17,4 @@ class ChatCategories extends Table { @override String get tableName => 'chat_category'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/chats.dart b/apps/im_app/lib/data/local/drift/tables/chats.dart index eaff81a..204bf13 100644 --- a/apps/im_app/lib/data/local/drift/tables/chats.dart +++ b/apps/im_app/lib/data/local/drift/tables/chats.dart @@ -42,7 +42,8 @@ class Chats extends Table { IntColumn get outgoingIdx => integer().withDefault(const Constant(0))(); IntColumn get incomingSoundId => integer().withDefault(const Constant(0))(); IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))(); - IntColumn get notificationSoundId => integer().withDefault(const Constant(0))(); + IntColumn get notificationSoundId => + integer().withDefault(const Constant(0))(); TextColumn get chatKey => text().withDefault(const Constant(''))(); TextColumn get activeChatKey => text().withDefault(const Constant(''))(); IntColumn get coverIdx => integer().withDefault(const Constant(0))(); @@ -55,4 +56,4 @@ class Chats extends Table { @override String get tableName => 'chat'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/discover_mini_apps.dart b/apps/im_app/lib/data/local/drift/tables/discover_mini_apps.dart index 44ba236..6cec9e0 100644 --- a/apps/im_app/lib/data/local/drift/tables/discover_mini_apps.dart +++ b/apps/im_app/lib/data/local/drift/tables/discover_mini_apps.dart @@ -33,4 +33,4 @@ class DiscoverMiniApps extends Table { @override String get tableName => 'discover_mini_app'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/explore_mini_apps.dart b/apps/im_app/lib/data/local/drift/tables/explore_mini_apps.dart index 73d5750..af55a46 100644 --- a/apps/im_app/lib/data/local/drift/tables/explore_mini_apps.dart +++ b/apps/im_app/lib/data/local/drift/tables/explore_mini_apps.dart @@ -33,4 +33,4 @@ class ExploreMiniApps extends Table { @override String get tableName => 'explore_mini_app'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/favorite_mini_apps.dart b/apps/im_app/lib/data/local/drift/tables/favorite_mini_apps.dart index 9e38bb7..8bb732e 100644 --- a/apps/im_app/lib/data/local/drift/tables/favorite_mini_apps.dart +++ b/apps/im_app/lib/data/local/drift/tables/favorite_mini_apps.dart @@ -33,4 +33,4 @@ class FavoriteMiniApps extends Table { @override String get tableName => 'favorite_mini_app'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/favourite_details.dart b/apps/im_app/lib/data/local/drift/tables/favourite_details.dart index 678af16..463bb18 100644 --- a/apps/im_app/lib/data/local/drift/tables/favourite_details.dart +++ b/apps/im_app/lib/data/local/drift/tables/favourite_details.dart @@ -13,4 +13,4 @@ class FavouriteDetails extends Table { @override String get tableName => 'favourite_detail'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/favourites.dart b/apps/im_app/lib/data/local/drift/tables/favourites.dart index 289e1d8..09ae027 100644 --- a/apps/im_app/lib/data/local/drift/tables/favourites.dart +++ b/apps/im_app/lib/data/local/drift/tables/favourites.dart @@ -23,4 +23,4 @@ class Favourites extends Table { @override String get tableName => 'favourite'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/groups.dart b/apps/im_app/lib/data/local/drift/tables/groups.dart index 61da3ba..227a4f3 100644 --- a/apps/im_app/lib/data/local/drift/tables/groups.dart +++ b/apps/im_app/lib/data/local/drift/tables/groups.dart @@ -36,4 +36,4 @@ class Groups extends Table { @override String get tableName => 'chat_group'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/message.dart b/apps/im_app/lib/data/local/drift/tables/message.dart index 30bb7c4..99a2e84 100644 --- a/apps/im_app/lib/data/local/drift/tables/message.dart +++ b/apps/im_app/lib/data/local/drift/tables/message.dart @@ -24,4 +24,4 @@ class Messages extends Table { @override String get tableName => 'message'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/pending_friend_request_histories.dart b/apps/im_app/lib/data/local/drift/tables/pending_friend_request_histories.dart index cddf2e6..6864fd4 100644 --- a/apps/im_app/lib/data/local/drift/tables/pending_friend_request_histories.dart +++ b/apps/im_app/lib/data/local/drift/tables/pending_friend_request_histories.dart @@ -14,4 +14,4 @@ class PendingFriendRequestHistories extends Table { @override String get tableName => 'pending_friend_request_histories'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/recent_mini_apps.dart b/apps/im_app/lib/data/local/drift/tables/recent_mini_apps.dart index 043b176..e3e1cad 100644 --- a/apps/im_app/lib/data/local/drift/tables/recent_mini_apps.dart +++ b/apps/im_app/lib/data/local/drift/tables/recent_mini_apps.dart @@ -33,4 +33,4 @@ class RecentMiniApps extends Table { @override String get tableName => 'recent_mini_app'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/retries.dart b/apps/im_app/lib/data/local/drift/tables/retries.dart index ca39eb6..10b0f13 100644 --- a/apps/im_app/lib/data/local/drift/tables/retries.dart +++ b/apps/im_app/lib/data/local/drift/tables/retries.dart @@ -17,4 +17,4 @@ class Retries extends Table { @override String get tableName => 'retry'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/sounds.dart b/apps/im_app/lib/data/local/drift/tables/sounds.dart index 0e5eec1..45c5a5b 100644 --- a/apps/im_app/lib/data/local/drift/tables/sounds.dart +++ b/apps/im_app/lib/data/local/drift/tables/sounds.dart @@ -17,4 +17,4 @@ class Sounds extends Table { @override String get tableName => 'sound'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/tags.dart b/apps/im_app/lib/data/local/drift/tables/tags.dart index 8da5fdc..10d143b 100644 --- a/apps/im_app/lib/data/local/drift/tables/tags.dart +++ b/apps/im_app/lib/data/local/drift/tables/tags.dart @@ -12,4 +12,4 @@ class Tags extends Table { @override String get tableName => 'tags'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/user_request_histories.dart b/apps/im_app/lib/data/local/drift/tables/user_request_histories.dart index 4ce2660..ffb15f5 100644 --- a/apps/im_app/lib/data/local/drift/tables/user_request_histories.dart +++ b/apps/im_app/lib/data/local/drift/tables/user_request_histories.dart @@ -11,4 +11,4 @@ class UserRequestHistories extends Table { @override String get tableName => 'user_request_history'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/users.dart b/apps/im_app/lib/data/local/drift/tables/users.dart index b6f9cf9..88745df 100644 --- a/apps/im_app/lib/data/local/drift/tables/users.dart +++ b/apps/im_app/lib/data/local/drift/tables/users.dart @@ -28,9 +28,12 @@ class Users extends Table { IntColumn get addIndex => integer().nullable()(); IntColumn get incomingSoundId => integer().withDefault(const Constant(0))(); IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))(); - IntColumn get notificationSoundId => integer().withDefault(const Constant(0))(); - IntColumn get sendMessageSoundId => integer().withDefault(const Constant(0))(); - IntColumn get groupNotificationSoundId => integer().withDefault(const Constant(0))(); + IntColumn get notificationSoundId => + integer().withDefault(const Constant(0))(); + IntColumn get sendMessageSoundId => + integer().withDefault(const Constant(0))(); + IntColumn get groupNotificationSoundId => + integer().withDefault(const Constant(0))(); TextColumn get groupTags => text().withDefault(const Constant('[]'))(); TextColumn get friendTags => text().withDefault(const Constant('[]'))(); TextColumn get publicKey => text().nullable()(); @@ -39,4 +42,4 @@ class Users extends Table { @override String get tableName => 'user'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/local/drift/tables/workspaces.dart b/apps/im_app/lib/data/local/drift/tables/workspaces.dart index ea91f74..8d9fe9a 100644 --- a/apps/im_app/lib/data/local/drift/tables/workspaces.dart +++ b/apps/im_app/lib/data/local/drift/tables/workspaces.dart @@ -21,4 +21,4 @@ class Workspaces extends Table { @override String get tableName => 'workspace'; -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/call_log_dto.dart b/apps/im_app/lib/data/models/call_log_dto.dart index d493247..6c065e1 100644 --- a/apps/im_app/lib/data/models/call_log_dto.dart +++ b/apps/im_app/lib/data/models/call_log_dto.dart @@ -119,4 +119,4 @@ class CallLogDto { deletedAt: Value(deletedAt), isRead: Value(isRead), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/chat_bot_dto.dart b/apps/im_app/lib/data/models/chat_bot_dto.dart index adf1b29..4bf7f9d 100644 --- a/apps/im_app/lib/data/models/chat_bot_dto.dart +++ b/apps/im_app/lib/data/models/chat_bot_dto.dart @@ -176,4 +176,4 @@ class ChatBotDto { isAllowForward: Value(isAllowForward), tips: Value(tips), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/chat_category_dto.dart b/apps/im_app/lib/data/models/chat_category_dto.dart index 5b6bf24..65daf61 100644 --- a/apps/im_app/lib/data/models/chat_category_dto.dart +++ b/apps/im_app/lib/data/models/chat_category_dto.dart @@ -87,4 +87,4 @@ class ChatCategoryDto { updatedAt: Value(updatedAt), deletedAt: Value(deletedAt), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/chat_dto.dart b/apps/im_app/lib/data/models/chat_dto.dart index 63e7f1d..5f15487 100644 --- a/apps/im_app/lib/data/models/chat_dto.dart +++ b/apps/im_app/lib/data/models/chat_dto.dart @@ -197,4 +197,4 @@ class Chat { localPermission: localPermission ?? this.localPermission, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/discover_mini_app_dto.dart b/apps/im_app/lib/data/models/discover_mini_app_dto.dart index 89b81a2..e558a85 100644 --- a/apps/im_app/lib/data/models/discover_mini_app_dto.dart +++ b/apps/im_app/lib/data/models/discover_mini_app_dto.dart @@ -109,4 +109,4 @@ class DiscoverMiniApp { screen: screen ?? this.screen, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/explore_mini_app_dto.dart b/apps/im_app/lib/data/models/explore_mini_app_dto.dart index f8eeb2e..20ae5a0 100644 --- a/apps/im_app/lib/data/models/explore_mini_app_dto.dart +++ b/apps/im_app/lib/data/models/explore_mini_app_dto.dart @@ -143,34 +143,33 @@ class ExploreMiniAppDto { screen: screen, ); - factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) => - ExploreMiniAppDto( - id: app.id, - name: app.name, - openuid: app.openuid, - devId: app.devId, - icon: app.icon, - iconGaussian: app.iconGaussian, - downloadUrl: app.downloadUrl, - description: app.description, - version: app.version, - typ: app.typ, - flag: app.flag, - reviewStatus: app.reviewStatus, - favoriteAt: app.favoriteAt, - isActive: app.isActive, - createdAt: app.createdAt, - updatedAt: app.updatedAt, - deletedAt: app.deletedAt, - score: app.score, - channels: app.channels, - devName: app.devName, - pictureGaussian: app.pictureGaussian, - picture: app.picture, - commentNum: app.commentNum, - lastLoginAt: app.lastLoginAt, - screen: app.screen, - ); + factory ExploreMiniAppDto.fromEntity(ExploreMiniApp app) => ExploreMiniAppDto( + id: app.id, + name: app.name, + openuid: app.openuid, + devId: app.devId, + icon: app.icon, + iconGaussian: app.iconGaussian, + downloadUrl: app.downloadUrl, + description: app.description, + version: app.version, + typ: app.typ, + flag: app.flag, + reviewStatus: app.reviewStatus, + favoriteAt: app.favoriteAt, + isActive: app.isActive, + createdAt: app.createdAt, + updatedAt: app.updatedAt, + deletedAt: app.deletedAt, + score: app.score, + channels: app.channels, + devName: app.devName, + pictureGaussian: app.pictureGaussian, + picture: app.picture, + commentNum: app.commentNum, + lastLoginAt: app.lastLoginAt, + screen: app.screen, + ); ExploreMiniAppsCompanion toCompanion() => ExploreMiniAppsCompanion( id: Value(id), @@ -199,4 +198,4 @@ class ExploreMiniAppDto { lastLoginAt: Value(lastLoginAt), screen: Value(screen), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/favorite_mini_app_dto.dart b/apps/im_app/lib/data/models/favorite_mini_app_dto.dart index d80b64a..f035765 100644 --- a/apps/im_app/lib/data/models/favorite_mini_app_dto.dart +++ b/apps/im_app/lib/data/models/favorite_mini_app_dto.dart @@ -199,4 +199,4 @@ class FavoriteMiniAppDto { lastLoginAt: Value(lastLoginAt), screen: Value(screen), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/favourite_detail_dto.dart b/apps/im_app/lib/data/models/favourite_detail_dto.dart index 0169108..6300e6c 100644 --- a/apps/im_app/lib/data/models/favourite_detail_dto.dart +++ b/apps/im_app/lib/data/models/favourite_detail_dto.dart @@ -80,4 +80,4 @@ class FavouriteDetailDto { chatId: Value(chatId), sendTime: Value(sendTime), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/favourite_dto.dart b/apps/im_app/lib/data/models/favourite_dto.dart index 2c3b2cb..434d424 100644 --- a/apps/im_app/lib/data/models/favourite_dto.dart +++ b/apps/im_app/lib/data/models/favourite_dto.dart @@ -127,4 +127,4 @@ class FavouriteDto { isUploaded: Value(isUploaded), urls: Value(urls), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/group_dto.dart b/apps/im_app/lib/data/models/group_dto.dart index 05e218d..8f14838 100644 --- a/apps/im_app/lib/data/models/group_dto.dart +++ b/apps/im_app/lib/data/models/group_dto.dart @@ -218,4 +218,4 @@ class GroupDto { topic: Value(topic), rp: Value(rp), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/message_dto.dart b/apps/im_app/lib/data/models/message_dto.dart index 2f5908b..762b4eb 100644 --- a/apps/im_app/lib/data/models/message_dto.dart +++ b/apps/im_app/lib/data/models/message_dto.dart @@ -134,4 +134,4 @@ class MessageDto { flag: Value(flag), cmid: Value(cmid), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/pending_friend_request_history_dto.dart b/apps/im_app/lib/data/models/pending_friend_request_history_dto.dart index cdac263..209ed45 100644 --- a/apps/im_app/lib/data/models/pending_friend_request_history_dto.dart +++ b/apps/im_app/lib/data/models/pending_friend_request_history_dto.dart @@ -31,33 +31,33 @@ class PendingFriendRequestHistoryDto { ); Map toJson() => { - 'id': id, - 'uid': uid, - 'request_time': requestTime, - 'remarks': remarks, - 'source': source, - 'rs': rs, - }; + 'id': id, + 'uid': uid, + 'request_time': requestTime, + 'remarks': remarks, + 'source': source, + 'rs': rs, + }; PendingFriendRequestHistory toEntity() => PendingFriendRequestHistory( - id: id, - uid: uid, - requestTime: requestTime, - remarks: remarks, - source: source, - rs: rs, - ); + id: id, + uid: uid, + requestTime: requestTime, + remarks: remarks, + source: source, + rs: rs, + ); factory PendingFriendRequestHistoryDto.fromEntity( - PendingFriendRequestHistory history) => - PendingFriendRequestHistoryDto( - id: history.id, - uid: history.uid, - requestTime: history.requestTime, - remarks: history.remarks, - source: history.source, - rs: history.rs, - ); + PendingFriendRequestHistory history, + ) => PendingFriendRequestHistoryDto( + id: history.id, + uid: history.uid, + requestTime: history.requestTime, + remarks: history.remarks, + source: history.source, + rs: history.rs, + ); PendingFriendRequestHistoriesCompanion toCompanion() => PendingFriendRequestHistoriesCompanion( @@ -68,4 +68,4 @@ class PendingFriendRequestHistoryDto { source: Value(source), rs: Value(rs), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/recent_mini_app_dto.dart b/apps/im_app/lib/data/models/recent_mini_app_dto.dart index 09b0f10..915f2aa 100644 --- a/apps/im_app/lib/data/models/recent_mini_app_dto.dart +++ b/apps/im_app/lib/data/models/recent_mini_app_dto.dart @@ -198,4 +198,4 @@ class RecentMiniAppDto { lastLoginAt: Value(lastLoginAt), screen: Value(screen), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/retry_dto.dart b/apps/im_app/lib/data/models/retry_dto.dart index 72ddf54..feb9bd5 100644 --- a/apps/im_app/lib/data/models/retry_dto.dart +++ b/apps/im_app/lib/data/models/retry_dto.dart @@ -106,4 +106,4 @@ class RetryDto { createTime: Value(createTime), addIndex: Value(addIndex), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/sound_dto.dart b/apps/im_app/lib/data/models/sound_dto.dart index b23d21a..281561f 100644 --- a/apps/im_app/lib/data/models/sound_dto.dart +++ b/apps/im_app/lib/data/models/sound_dto.dart @@ -85,4 +85,4 @@ class SoundDto { channelGroupId: Value(channelGroupId), isDefault: Value(isDefault), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/tag_dto.dart b/apps/im_app/lib/data/models/tag_dto.dart index 1589ca3..32d9910 100644 --- a/apps/im_app/lib/data/models/tag_dto.dart +++ b/apps/im_app/lib/data/models/tag_dto.dart @@ -71,4 +71,4 @@ class TagDto { updatedAt: Value(updatedAt), addIndex: Value(addIndex), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/models/user_request_history_dto.dart b/apps/im_app/lib/data/models/user_request_history_dto.dart index 49b2b35..0749a74 100644 --- a/apps/im_app/lib/data/models/user_request_history_dto.dart +++ b/apps/im_app/lib/data/models/user_request_history_dto.dart @@ -8,11 +8,7 @@ class UserRequestHistoryDto { final int? status; final int? createdAt; - const UserRequestHistoryDto({ - required this.id, - this.status, - this.createdAt, - }); + const UserRequestHistoryDto({required this.id, this.status, this.createdAt}); factory UserRequestHistoryDto.fromJson(Map json) => UserRequestHistoryDto( @@ -27,11 +23,8 @@ class UserRequestHistoryDto { 'created_at': createdAt, }; - UserRequestHistory toEntity() => UserRequestHistory( - id: id, - status: status, - createdAt: createdAt, - ); + UserRequestHistory toEntity() => + UserRequestHistory(id: id, status: status, createdAt: createdAt); factory UserRequestHistoryDto.fromEntity(UserRequestHistory history) => UserRequestHistoryDto( @@ -40,10 +33,9 @@ class UserRequestHistoryDto { createdAt: history.createdAt, ); - UserRequestHistoriesCompanion toCompanion() => - UserRequestHistoriesCompanion( - id: Value(id), - status: Value(status), - createdAt: Value(createdAt), - ); -} \ No newline at end of file + UserRequestHistoriesCompanion toCompanion() => UserRequestHistoriesCompanion( + id: Value(id), + status: Value(status), + createdAt: Value(createdAt), + ); +} diff --git a/apps/im_app/lib/data/models/workspace_dto.dart b/apps/im_app/lib/data/models/workspace_dto.dart index 35c2a7a..a404765 100644 --- a/apps/im_app/lib/data/models/workspace_dto.dart +++ b/apps/im_app/lib/data/models/workspace_dto.dart @@ -113,4 +113,4 @@ class WorkspaceDto { deletedAt: Value(deletedAt), channelGroupId: Value(channelGroupId), ); -} \ No newline at end of file +} diff --git a/apps/im_app/lib/data/remote/get_profile_request.dart b/apps/im_app/lib/data/remote/get_profile_request.dart index 688322b..c146970 100644 --- a/apps/im_app/lib/data/remote/get_profile_request.dart +++ b/apps/im_app/lib/data/remote/get_profile_request.dart @@ -6,28 +6,30 @@ import '../../../domain/entities/user.dart'; part 'get_profile_request.g.dart'; -/// # /user/profile — 获取用户资料(GET 请求示例) +/// # /user/profile — 获取用户资料(GET 请求) /// -/// 演示:GET 请求 + 无 body 参数的模式。 -/// GET 请求的 toJson() 结果会自动作为 URL query parameters 发送。 +/// GET 请求无 body,`toJson()` 结果自动作为 URL query parameters 发送。 +/// 如需 query 参数(如分页),直接在类中添加字段,生成器自动序列化。 /// /// ## 数据流位置 /// /// ``` /// UserRepositoryImpl.getProfile() -/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里 +/// → _client.executeRequest( ★ GetProfileRequest ★ ) ← 你在这里 /// → 服务端 GET /user/profile -/// → 响应 JSON → ★ ProfileData ★ ← 也在这里 -/// → ProfileData.toEntity() → User +/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data } +/// → ★ ProfileResponse ★ = data 字段 ← 也在这里 +/// → ProfileResponse.toEntity() → User /// ``` // ───────────────────────────────────────────── // Response DTO // ───────────────────────────────────────────── -/// 用户资料响应 DTO(只需反序列化,禁止生成无用的 toJson) -@JsonSerializable(createToJson: false) -class ProfileData { +/// 用户资料接口的业务响应数据(对应服务端 `data` 字段)。 +/// +/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理。纯 Dart 类,无需任何注解。 +class ProfileResponse { final int uid; final String uuid; @JsonKey(name: 'last_online') @@ -54,7 +56,7 @@ class ProfileData { final int channelGroupId; final String hint; - const ProfileData({ + const ProfileResponse({ required this.uid, required this.uuid, required this.lastOnline, @@ -74,10 +76,6 @@ class ProfileData { required this.hint, }); - factory ProfileData.fromJson(Map json) => - _$ProfileDataFromJson(json); - - /// DTO → Domain Entity User toEntity() => User( uid: uid, uuid: uuid, @@ -102,23 +100,12 @@ class ProfileData { // ───────────────────────────────────────────── /// 获取用户资料请求(GET,无参数) -/// -/// GET 请求无 body,toJson() 返回空 map。 -/// 如需 query 参数(如分页),添加字段即可, -/// toJson() 会自动将字段序列化为 URL query string。 @ApiRequest( path: ApiPaths.userProfile, method: HttpMethod.get, - responseType: ProfileData, + responseType: ProfileResponse, ) -@JsonSerializable() -class GetProfileRequest extends ApiRequestable +class GetProfileRequest extends ApiRequestable with _$GetProfileRequestApi { GetProfileRequest(); - - factory GetProfileRequest.fromJson(Map json) => - _$GetProfileRequestFromJson(json); - - @override - Map toJson() => _$GetProfileRequestToJson(this); } diff --git a/apps/im_app/lib/data/remote/login_request.dart b/apps/im_app/lib/data/remote/login_request.dart index 910f8b0..eaacb11 100644 --- a/apps/im_app/lib/data/remote/login_request.dart +++ b/apps/im_app/lib/data/remote/login_request.dart @@ -12,17 +12,20 @@ part 'login_request.g.dart'; /// /// ``` /// AuthRepositoryImpl.login(email, password) -/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里 +/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里 /// → 服务端 POST /auth/login -/// → 响应 JSON → ★ LoginResponse ★ ← 也在这里 -/// → LoginResponse.toEntity() → User +/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data } +/// → ★ LoginResponse ★ = data 字段,T in APIResponseWrapper ← 也在这里 +/// → LoginResponse.toEntity() → User /// ``` // ───────────────────────────────────────────── // Response DTO // ───────────────────────────────────────────── -@JsonSerializable(createToJson: false) +/// 登录响应中的用户档案,嵌套在 [LoginResponse.profile] 中。 +/// +/// 纯 Dart 类,无需任何注解。`_$LoginProfileFromJson` 由生成器从 `@ApiRequest` 声明中自动推导生成。 class LoginProfile { final int uid; final String uuid; @@ -70,9 +73,6 @@ class LoginProfile { required this.hint, }); - factory LoginProfile.fromJson(Map json) => - _$LoginProfileFromJson(json); - User toEntity() => User( uid: uid, uuid: uuid, @@ -92,8 +92,11 @@ class LoginProfile { ); } -@JsonSerializable(createToJson: false, explicitToJson: true) -class LoginData { +/// 登录接口的业务响应数据(对应服务端 `data` 字段,即 T in `APIResponseWrapper`)。 +/// +/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理, +/// App 层只接触此类,不感知 envelope 结构。纯 Dart 类,无需任何注解。 +class LoginResponse { @JsonKey(name: 'account_id') final String accountId; final LoginProfile profile; @@ -107,7 +110,7 @@ class LoginData { @JsonKey(name: 'login_data') final String loginData; - const LoginData({ + const LoginResponse({ required this.accountId, required this.profile, required this.nonce, @@ -117,52 +120,28 @@ class LoginData { required this.loginData, }); - factory LoginData.fromJson(Map json) => - _$LoginDataFromJson(json); - User toEntity() => profile.toEntity(); } -/// Top-level envelope: { "code": 0, "message": "OK", "data": { ... } } -@JsonSerializable(createToJson: false, explicitToJson: true) -class LoginResponse { - final int code; - final String message; - final LoginData data; - - const LoginResponse({ - required this.code, - required this.message, - required this.data, - }); - - factory LoginResponse.fromJson(Map json) => - _$LoginResponseFromJson(json); - - User toEntity() => data.toEntity(); -} - // ───────────────────────────────────────────── // Request // ───────────────────────────────────────────── +/// 登录请求 +/// +/// `@ApiRequest` 一个注解搞定一切: +/// - mixin 自动生成 path / method / requestType / includeToken / toJson +/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表 @ApiRequest( path: ApiPaths.authLogin, method: HttpMethod.post, responseType: LoginResponse, requestType: ApiRequestType.login, ) -@JsonSerializable() class LoginRequest extends ApiRequestable with _$LoginRequestApi { final String email; final String password; LoginRequest({required this.email, required this.password}); - - factory LoginRequest.fromJson(Map json) => - _$LoginRequestFromJson(json); - - @override - Map toJson() => _$LoginRequestToJson(this); } diff --git a/apps/im_app/lib/data/remote/logout_request.dart b/apps/im_app/lib/data/remote/logout_request.dart index a4a908d..651f940 100644 --- a/apps/im_app/lib/data/remote/logout_request.dart +++ b/apps/im_app/lib/data/remote/logout_request.dart @@ -2,14 +2,14 @@ import 'package:networks_sdk/networks_sdk.dart'; import '../../../core/foundation/api_paths.dart'; -/// # /auth/logout — 登出接口(无响应数据示例) +part 'logout_request.g.dart'; + +/// # /auth/logout — 登出接口(无响应数据) /// -/// 演示:POST 请求 + 无 Response DTO 的模式。 /// 服务端返回 `{"code": 0, "message": "ok"}` 无 data 字段, /// `executeRequest` 返回 null,调用方直接 await 即可。 /// -/// 此接口不使用 @ApiRequest 注解,直接实现 ApiRequestable, -/// 演示手动实现方式(适用于不需要代码生成器的简单接口)。 +/// `responseType` 省略 → 生成器跳过 `fromJson` 注册,mixin 泛型为 `void`。 /// /// ## 数据流位置 /// @@ -17,16 +17,9 @@ import '../../../core/foundation/api_paths.dart'; /// AuthRepositoryImpl.logout() /// → _client.executeRequest( ★ LogoutRequest ★ ) ← 你在这里 /// → 服务端 POST /auth/logout -/// → 响应 {"code": 0, "message": "ok"} → null +/// → 响应 {"code": 0, "message": "ok"} → null(无 data) /// ``` -class LogoutRequest extends ApiRequestable { - @override - String get path => ApiPaths.authLogout; - - @override - HttpMethod get method => HttpMethod.post; - - /// 登出不需要请求体参数 - @override - Map toJson() => {}; +@ApiRequest(path: ApiPaths.authLogout, method: HttpMethod.post) +class LogoutRequest extends ApiRequestable with _$LogoutRequestApi { + LogoutRequest(); } diff --git a/apps/im_app/lib/data/remote/upload_file_request.dart b/apps/im_app/lib/data/remote/upload_file_request.dart index 479946e..7917ca4 100644 --- a/apps/im_app/lib/data/remote/upload_file_request.dart +++ b/apps/im_app/lib/data/remote/upload_file_request.dart @@ -32,8 +32,7 @@ part 'upload_file_request.g.dart'; // Response DTO // ───────────────────────────────────────────── -/// 文件上传响应 DTO(只需反序列化,禁止生成无用的 toJson) -@JsonSerializable(createToJson: false) +/// 文件上传接口的业务响应数据(对应服务端 `data` 字段)。纯 Dart 类,无需任何注解。 class UploadResult { final String url; @@ -41,9 +40,6 @@ class UploadResult { final String fileId; const UploadResult({required this.url, required this.fileId}); - - factory UploadResult.fromJson(Map json) => - _$UploadResultFromJson(json); } // ═════════════════════════════════════════════ @@ -52,7 +48,7 @@ class UploadResult { /// FormData 上传请求 /// -/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 信封。 +/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 格式。 /// 无需 override `decodeResponse`。 @ApiRequest( path: ApiPaths.uploadFile, @@ -97,7 +93,7 @@ class S3UploadResponse { /// - path 为完整的 presigned URL(SDK 检测到 http 开头不拼 baseURL) /// - uploadData 为 Uint8List 二进制数据 /// - 自定义 headers(Content-Type: application/octet-stream) -/// - override decodeResponse — S3 返回 204 No Content 或 XML,不是标准信封 +/// - override decodeResponse — S3 返回 204 No Content 或 XML,不是标准响应格式 class S3UploadRequest extends ApiRequestable { final Uint8List data; final String presignedURL; @@ -115,8 +111,8 @@ class S3UploadRequest extends ApiRequestable { @override Map? get customHeaders => { - 'Content-Type': 'application/octet-stream', - }; + 'Content-Type': 'application/octet-stream', + }; @override Map toJson() => {}; @@ -125,7 +121,7 @@ class S3UploadRequest extends ApiRequestable { @override Object? get uploadData => data; - /// S3 响应不走标准 { code, message, data } 信封,需要自定义解码 + /// S3 响应不走标准 { code, message, data } 格式,需要自定义解码 /// /// 可能的响应: /// - 204 No Content(空 body)→ 成功 diff --git a/apps/im_app/lib/data/repositories/auth_repository_impl.dart b/apps/im_app/lib/data/repositories/auth_repository_impl.dart index 0dc93e2..afdec66 100644 --- a/apps/im_app/lib/data/repositories/auth_repository_impl.dart +++ b/apps/im_app/lib/data/repositories/auth_repository_impl.dart @@ -8,7 +8,7 @@ import '../remote/logout_request.dart'; /// 认证 Repository 实现 /// /// implements [AuthRepository] 接口(domain/repositories/ 中定义)。 -/// 直接使用 [ApiClient] 发送请求,将 DTO 转为 Domain Entity。 +/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。 /// 后续可加 Local DataSource 实现离线缓存。 /// /// ## 数据流位置 @@ -16,18 +16,22 @@ import '../remote/logout_request.dart'; /// ``` /// LoginUseCase.execute(email, password) /// → ★ AuthRepositoryImpl.login() ★ ← 你在这里 -/// → ApiClient.executeRequest(LoginRequest) +/// → NetworksSdkApi.executeRequest(LoginRequest) /// → 服务端 POST /auth/login -/// ← LoginData(Response DTO) -/// → onTokenUpdate(token) ← 回调写入 Token -/// ← LoginData.toEntity() → User ← DTO → Entity 转换在这里 +/// ← LoginResponse(SDK 已拆包 { code, message, data } envelope) +/// → _onTokenUpdate(accessToken) ← 回调写入 Token +/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里 /// ← User(Domain Entity) /// ``` class AuthRepositoryImpl implements AuthRepository { final NetworksSdkApi _client; final void Function(String?) _onTokenUpdate; - AuthRepositoryImpl({required NetworksSdkApi client, required void Function(String?) onTokenUpdate,}) : _client = client, _onTokenUpdate = onTokenUpdate; + AuthRepositoryImpl({ + required NetworksSdkApi client, + required void Function(String?) onTokenUpdate, + }) : _client = client, + _onTokenUpdate = onTokenUpdate; @override Future login({required String email, required String password}) async { @@ -39,7 +43,7 @@ class AuthRepositoryImpl implements AuthRepository { throw Exception('Login failed: empty response'); } - _onTokenUpdate(loginResponse.data.accessToken); + _onTokenUpdate(loginResponse.accessToken); return loginResponse.toEntity(); } diff --git a/apps/im_app/lib/domain/entities/call_log.dart b/apps/im_app/lib/domain/entities/call_log.dart index e60c3cd..9df7b8f 100644 --- a/apps/im_app/lib/domain/entities/call_log.dart +++ b/apps/im_app/lib/domain/entities/call_log.dart @@ -64,4 +64,4 @@ class CallLog { isRead: isRead ?? this.isRead, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/chat.dart b/apps/im_app/lib/domain/entities/chat.dart index 63e7f1d..5f15487 100644 --- a/apps/im_app/lib/domain/entities/chat.dart +++ b/apps/im_app/lib/domain/entities/chat.dart @@ -197,4 +197,4 @@ class Chat { localPermission: localPermission ?? this.localPermission, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/chat_bot.dart b/apps/im_app/lib/domain/entities/chat_bot.dart index 28c35c3..34ca0da 100644 --- a/apps/im_app/lib/domain/entities/chat_bot.dart +++ b/apps/im_app/lib/domain/entities/chat_bot.dart @@ -97,4 +97,4 @@ class ChatBot { tips: tips ?? this.tips, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/chat_category.dart b/apps/im_app/lib/domain/entities/chat_category.dart index 6dc9d75..2f459f7 100644 --- a/apps/im_app/lib/domain/entities/chat_category.dart +++ b/apps/im_app/lib/domain/entities/chat_category.dart @@ -45,4 +45,4 @@ class ChatCategory { deletedAt: deletedAt ?? this.deletedAt, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/discover_mini_app.dart b/apps/im_app/lib/domain/entities/discover_mini_app.dart index 89b81a2..e558a85 100644 --- a/apps/im_app/lib/domain/entities/discover_mini_app.dart +++ b/apps/im_app/lib/domain/entities/discover_mini_app.dart @@ -109,4 +109,4 @@ class DiscoverMiniApp { screen: screen ?? this.screen, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/explore_mini_app.dart b/apps/im_app/lib/domain/entities/explore_mini_app.dart index 0e6b347..b108649 100644 --- a/apps/im_app/lib/domain/entities/explore_mini_app.dart +++ b/apps/im_app/lib/domain/entities/explore_mini_app.dart @@ -109,4 +109,4 @@ class ExploreMiniApp { screen: screen ?? this.screen, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/favorite_mini_app.dart b/apps/im_app/lib/domain/entities/favorite_mini_app.dart index 3b00e1e..6fb0890 100644 --- a/apps/im_app/lib/domain/entities/favorite_mini_app.dart +++ b/apps/im_app/lib/domain/entities/favorite_mini_app.dart @@ -109,4 +109,4 @@ class FavoriteMiniApp { screen: screen ?? this.screen, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/favourite.dart b/apps/im_app/lib/domain/entities/favourite.dart index 6ce8110..9739eaa 100644 --- a/apps/im_app/lib/domain/entities/favourite.dart +++ b/apps/im_app/lib/domain/entities/favourite.dart @@ -69,4 +69,4 @@ class Favourite { urls: urls ?? this.urls, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/favourite_detail.dart b/apps/im_app/lib/domain/entities/favourite_detail.dart index de4373f..9d59cff 100644 --- a/apps/im_app/lib/domain/entities/favourite_detail.dart +++ b/apps/im_app/lib/domain/entities/favourite_detail.dart @@ -41,4 +41,4 @@ class FavouriteDetail { sendTime: sendTime ?? this.sendTime, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/group.dart b/apps/im_app/lib/domain/entities/group.dart index ac25a52..a507278 100644 --- a/apps/im_app/lib/domain/entities/group.dart +++ b/apps/im_app/lib/domain/entities/group.dart @@ -121,4 +121,4 @@ class Group { rp: rp ?? this.rp, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/message.dart b/apps/im_app/lib/domain/entities/message.dart index 76eff73..ffb7dfb 100644 --- a/apps/im_app/lib/domain/entities/message.dart +++ b/apps/im_app/lib/domain/entities/message.dart @@ -73,4 +73,4 @@ class Message { cmid: cmid ?? this.cmid, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/pending_friend_request_history.dart b/apps/im_app/lib/domain/entities/pending_friend_request_history.dart index ce323f2..84e2517 100644 --- a/apps/im_app/lib/domain/entities/pending_friend_request_history.dart +++ b/apps/im_app/lib/domain/entities/pending_friend_request_history.dart @@ -33,4 +33,4 @@ class PendingFriendRequestHistory { rs: rs ?? this.rs, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/recent_mini_app.dart b/apps/im_app/lib/domain/entities/recent_mini_app.dart index 2a9cd37..367a684 100644 --- a/apps/im_app/lib/domain/entities/recent_mini_app.dart +++ b/apps/im_app/lib/domain/entities/recent_mini_app.dart @@ -109,4 +109,4 @@ class RecentMiniApp { screen: screen ?? this.screen, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/retry.dart b/apps/im_app/lib/domain/entities/retry.dart index 384a1e7..b481708 100644 --- a/apps/im_app/lib/domain/entities/retry.dart +++ b/apps/im_app/lib/domain/entities/retry.dart @@ -57,4 +57,4 @@ class Retry { addIndex: addIndex ?? this.addIndex, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/sound.dart b/apps/im_app/lib/domain/entities/sound.dart index 954127c..f0b7310 100644 --- a/apps/im_app/lib/domain/entities/sound.dart +++ b/apps/im_app/lib/domain/entities/sound.dart @@ -45,4 +45,4 @@ class Sound { isDefault: isDefault ?? this.isDefault, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/tag.dart b/apps/im_app/lib/domain/entities/tag.dart index 9777aef..62034b5 100644 --- a/apps/im_app/lib/domain/entities/tag.dart +++ b/apps/im_app/lib/domain/entities/tag.dart @@ -37,4 +37,4 @@ class Tag { addIndex: addIndex ?? this.addIndex, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/user_request_history.dart b/apps/im_app/lib/domain/entities/user_request_history.dart index 8d197bf..7ec3bae 100644 --- a/apps/im_app/lib/domain/entities/user_request_history.dart +++ b/apps/im_app/lib/domain/entities/user_request_history.dart @@ -4,21 +4,13 @@ class UserRequestHistory { final int? status; final int? createdAt; - const UserRequestHistory({ - required this.id, - this.status, - this.createdAt, - }); + const UserRequestHistory({required this.id, this.status, this.createdAt}); - UserRequestHistory copyWith({ - int? id, - int? status, - int? createdAt, - }) { + UserRequestHistory copyWith({int? id, int? status, int? createdAt}) { return UserRequestHistory( id: id ?? this.id, status: status ?? this.status, createdAt: createdAt ?? this.createdAt, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/domain/entities/workspace.dart b/apps/im_app/lib/domain/entities/workspace.dart index 06d5aeb..933af94 100644 --- a/apps/im_app/lib/domain/entities/workspace.dart +++ b/apps/im_app/lib/domain/entities/workspace.dart @@ -61,4 +61,4 @@ class Workspace { channelGroupId: channelGroupId ?? this.channelGroupId, ); } -} \ No newline at end of file +} diff --git a/apps/im_app/lib/features/chat/presentation/.gitkeep b/apps/im_app/lib/features/chat/presentation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/im_app/lib/features/chat/presentation/chat_db_test_state.dart b/apps/im_app/lib/features/chat/presentation/chat_db_test_state.dart new file mode 100644 index 0000000..c37e5ba --- /dev/null +++ b/apps/im_app/lib/features/chat/presentation/chat_db_test_state.dart @@ -0,0 +1,39 @@ +// 数据库测试页状态(Demo,正式开发后随页面一并删除) + +/// 单条测试结果记录 +class TestResult { + final String title; + final String subtitle; + final String duration; + + TestResult({ + required this.title, + required this.subtitle, + required this.duration, + }); +} + +class ChatDbTestState { + final bool testStarted; + final List testResults; + final String currentState; + + const ChatDbTestState({ + this.testStarted = false, + this.testResults = const [], + this.currentState = '', + }); + + /// 按钮文案(Widget 直接读,不在 View 层做判断) + String get buttonLabel => testStarted ? '结束' : '开始'; + + ChatDbTestState copyWith({ + bool? testStarted, + List? testResults, + String? currentState, + }) => ChatDbTestState( + testStarted: testStarted ?? this.testStarted, + testResults: testResults ?? this.testResults, + currentState: currentState ?? this.currentState, + ); +} diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart index 7f2be5d..23709ec 100644 --- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/ui/base/context_theme_ext.dart'; @@ -12,7 +13,7 @@ import '../../../../core/ui/base/context_theme_ext.dart'; /// /// 将 [conversationId] 传给对应的 Riverpod `.family` provider 加载完整会话数据。 /// 构造参数保持不变,数据来源从 `extra` 换成 provider 即可。 -class ChatDetailPage extends StatelessWidget { +class ChatDetailPage extends ConsumerWidget { const ChatDetailPage({ super.key, required this.conversationId, @@ -23,7 +24,7 @@ class ChatDetailPage extends StatelessWidget { final String title; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final s = context.styles; return Scaffold( diff --git a/apps/im_app/lib/features/chat/view/chat_page.dart b/apps/im_app/lib/features/chat/view/chat_page.dart index ce264a2..590a65b 100644 --- a/apps/im_app/lib/features/chat/view/chat_page.dart +++ b/apps/im_app/lib/features/chat/view/chat_page.dart @@ -20,8 +20,6 @@ class ChatPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(chatViewModelProvider.notifier); - return Scaffold( appBar: AppBar(title: const Text('聊天')), body: Center( @@ -32,36 +30,48 @@ class ChatPage extends ConsumerWidget { // 切换 Tab:用 go,替换整个历史栈,不可返回 AppButton.inverse( label: '切换 Tab(go)', - onPressed: () => vm.goToContact(context), + onPressed: () => + ref.read(chatViewModelProvider.notifier).goToContact(context), ), // 带参数 push:extra 传 Dart Record,适合已有对象的场景 AppButton.inverse( label: '有参 push(extra)', - onPressed: () => vm.pushChatDetailWithExtra(context), + onPressed: () => ref + .read(chatViewModelProvider.notifier) + .pushChatDetailWithExtra(context), ), // 带参数 push:id 内嵌在路径中,适合需要深链接 / 分享的场景 AppButton.inverse( label: '有参 push(路径参数)', - onPressed: () => vm.pushChatDetailById(context), + onPressed: () => ref + .read(chatViewModelProvider.notifier) + .pushChatDetailById(context), ), // 无参 push:压栈,自动显示返回按钮,不切 Tab AppButton.inverse( label: '无参 push', - onPressed: () => vm.pushSettingsTheme(context), + onPressed: () => ref + .read(chatViewModelProvider.notifier) + .pushSettingsTheme(context), ), // 无参 go:替换历史,切换到对应 Tab,TabBar 可见,不可返回 AppButton.inverse( label: '无参 go', - onPressed: () => vm.goToSettings(context), + onPressed: () => ref + .read(chatViewModelProvider.notifier) + .goToSettings(context), ), AppButton.inverse( label: '测试数据库性能', - onPressed: () => vm.goToDatabaseTest(context), + onPressed: () => ref + .read(chatViewModelProvider.notifier) + .goToDatabaseTest(context), ), AppButton.secondary( label: '退出登录', fullWidth: false, - onPressed: () => vm.logout(), + onPressed: () => + ref.read(chatViewModelProvider.notifier).logout(), ), ], ), diff --git a/apps/im_app/lib/features/contact/view/contact_page.dart b/apps/im_app/lib/features/contact/view/contact_page.dart index 7e819a1..d4a4912 100644 --- a/apps/im_app/lib/features/contact/view/contact_page.dart +++ b/apps/im_app/lib/features/contact/view/contact_page.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 联系人页占位 /// /// 待 contact 功能开发后替换为实际内容。 -class ContactPage extends StatelessWidget { +class ContactPage extends ConsumerWidget { const ContactPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return const Scaffold(); } } diff --git a/apps/im_app/lib/features/login/di/auth_providers.dart b/apps/im_app/lib/features/login/di/auth_providers.dart index 10a8068..8802f93 100644 --- a/apps/im_app/lib/features/login/di/auth_providers.dart +++ b/apps/im_app/lib/features/login/di/auth_providers.dart @@ -12,7 +12,7 @@ import '../usecases/login_usecase.dart'; /// ViewModel Provider 由 `@riverpod` 注解自动生成,不在此文件中。 /// /// Auth 模块的 DI 链路:Repository → UseCase(按需)。 -/// app/di/ 只提供 SDK 基础设施(apiConfig / apiClient / socketManager / storageApi), +/// app/di/ 只提供 SDK 基础设施(apiConfig / networkSdkApi / socketManager / storageApi), /// 业务模块的 Provider 内聚在 features/{模块}/di/ 下。 /// /// ``` @@ -21,7 +21,7 @@ import '../usecases/login_usecase.dart'; /// → ref.read(authRepositoryProvider) ← di/ 手动装配 /// → ref.read(socketManagerProvider) ← app/di/ 手动装配 /// → ref.read(apiConfigProvider) ← app/di/ 手动装配 -/// → ref.read(apiClientProvider) ← app/di/ 手动装配 +/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配 /// → ref.read(storageSdkProvider) ← app/di/ 手动装配 /// ``` @@ -41,7 +41,7 @@ final authRepositoryProvider = Provider((ref) { // TODO: final secureStorage = ref.read(secureStorageProvider); return AuthRepositoryImpl( - client: ref.read(networkSdkApiProvider), // 直接注入 ApiClient + client: ref.read(networkSdkApiProvider), // 注入 Facade 接口 onTokenUpdate: (token) { apiConfig.updateToken(token); // 内存(network_sdk) // TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk) diff --git a/apps/im_app/lib/features/login/presentation/login_view_model.dart b/apps/im_app/lib/features/login/presentation/login_view_model.dart index 83b7012..3ae37a0 100644 --- a/apps/im_app/lib/features/login/presentation/login_view_model.dart +++ b/apps/im_app/lib/features/login/presentation/login_view_model.dart @@ -1,10 +1,10 @@ import 'dart:convert'; import 'package:flutter/services.dart'; -import 'package:im_app/app/di/user_provider.dart'; -import 'package:im_app/data/remote/login_request.dart'; -import 'package:networks_sdk/networks_sdk.dart'; import 'package:im_app/app/di/db_provider.dart'; +import 'package:im_app/app/di/user_provider.dart'; +import 'package:im_app/domain/entities/user.dart'; +import 'package:networks_sdk/networks_sdk.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:storage_sdk/storage_sdk.dart'; @@ -33,7 +33,7 @@ part 'login_view_model.g.dart'; /// loginViewModelProvider ← @riverpod 自动生成(本文件) /// → ref.read(loginUseCaseProvider) ← di/ 手动装配 /// → ref.read(authRepositoryProvider) ← di/ 手动装配 -/// → ref.read(apiClientProvider) ← app/di/ 手动装配 +/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配 /// ``` /// /// ## 数据流位置 @@ -44,7 +44,7 @@ part 'login_view_model.g.dart'; /// → LoginUseCase.execute() ← 格式校验 + 调 Repository /// → AuthRepository.login() /// → _client.executeRequest(LoginRequest) -/// ← LoginData → User +/// ← LoginResponse → User /// ← User /// → state = state.copyWith(user: user) ← 更新状态 /// View: ref.watch → 自动 rebuild ← UI 刷新 @@ -59,27 +59,52 @@ class LoginViewModel extends _$LoginViewModel { /// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。 /// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。 Future demoLogin() async { + // 防止连点重入:第一次调用未完成前忽略后续调用 + if (state.isLoading) return; + state = state.copyWith(isLoading: true, error: null); final storageApi = ref.read(storageSdkProvider); final storageLifeCycle = storageApi as StorageSdkLifecycle; final repositoryProvider = ref.read(userRepositoryProvider); final provider = ref.read(authNotifierProvider); - // Read mock response from assets - final String raw = await rootBundle.loadString('assets/loginData.json'); - final Map json = jsonDecode(raw); + try { + // 读取 mock 数据(loginData.json 结构: { code, message, data: {...} }) + // 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为 + final raw = await rootBundle.loadString('assets/loginData.json'); + final json = jsonDecode(raw) as Map; + final data = json['data'] as Map; + final profile = data['profile'] as Map; + // 生成器生成的 _$XFromJson 是 library 私有函数,外部不可调用。 + // Demo 场景直接从 JSON 字段构建 User,不依赖生成的 fromJson。 + final user = User( + uid: profile['uid'] as int, + uuid: profile['uuid'] as String, + lastOnline: profile['last_online'] as int, + profilePic: profile['profile_pic'] as String, + profilePicGaussian: profile['profile_pic_gaussian'] as String, + nickname: profile['nickname'] as String, + contact: profile['contact'] as String, + countryCode: profile['country_code'] as String, + email: profile['email'] as String, + recoveryEmail: profile['recovery_email'] as String, + username: profile['username'] as String, + bio: profile['bio'] as String, + relationship: profile['relationship'] as int, + userAlias: profile['user_alias'] as String?, + hint: profile['hint'] as String, + ); - // Parse → Domain User directly - final loginResponse = LoginResponse.fromJson(json); - final user = loginResponse.data.toEntity(); + // 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录) + await storageLifeCycle.openDatabase(user.uid); + // Save user to DB via repository + await repositoryProvider.saveUser(user); - // Open database for the user - await storageLifeCycle.openDatabase(user.uid); - - // Save user to DB via repository - await repositoryProvider.saveUser(user); - - // Trigger auth state - provider.login(); + // Trigger auth state + provider.login(); + } catch (e) { + // 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state + state = state.copyWith(error: e.toString(), isLoading: false); + } } /// 执行登录 @@ -89,12 +114,10 @@ class LoginViewModel extends _$LoginViewModel { /// 3. 成功:写入 user;失败:写入 error Future login(String email, String password) async { state = state.copyWith(isLoading: true, error: null); + final provider = ref.read(loginUseCaseProvider); try { - final user = await ref - .read(loginUseCaseProvider) - .execute(email: email, password: password); - + final user = await provider.execute(email: email, password: password); state = state.copyWith(user: user, isLoading: false); } on FormatException catch (e) { // 格式校验失败(UseCase 层抛出) diff --git a/apps/im_app/lib/features/login/usecases/login_usecase.dart b/apps/im_app/lib/features/login/usecases/login_usecase.dart index 493ef42..68e76f5 100644 --- a/apps/im_app/lib/features/login/usecases/login_usecase.dart +++ b/apps/im_app/lib/features/login/usecases/login_usecase.dart @@ -28,9 +28,9 @@ import '../../../domain/repositories/auth_repository.dart'; /// → AuthRepository.login() /// → AuthRepositoryImpl.login() /// → _client.executeRequest(LoginRequest) -/// ← LoginData(DTO) -/// → _onTokenUpdate(token) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合) -/// ← LoginData.toEntity() → User +/// ← LoginResponse(SDK 已拆包 envelope) +/// → _onTokenUpdate(accessToken) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合) +/// ← LoginResponse.toEntity() → User /// → SocketManager.connect(token) ← 登录后连接 WebSocket /// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库 /// ← User @@ -41,17 +41,18 @@ class LoginUseCase { final ApiConfig _apiConfig; final StorageSdkApi _storageApi; - StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle; + StorageSdkLifecycle get _storageLifeCycle => + _storageApi as StorageSdkLifecycle; LoginUseCase({ required AuthRepository authRepository, required SocketManager socketManager, required ApiConfig apiConfig, required StorageSdkApi storageApi, - }) : _authRepository = authRepository, - _socketManager = socketManager, - _apiConfig = apiConfig, - _storageApi = storageApi; + }) : _authRepository = authRepository, + _socketManager = socketManager, + _apiConfig = apiConfig, + _storageApi = storageApi; /// 执行登录 /// @@ -72,10 +73,7 @@ class LoginUseCase { _validatePassword(password); // ── 2. 登录 ── - final user = await _authRepository.login( - email: email, - password: password, - ); + final user = await _authRepository.login(email: email, password: password); // ── 3. 连接 WebSocket ── // token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig, diff --git a/apps/im_app/lib/features/login/view/login_page.dart b/apps/im_app/lib/features/login/view/login_page.dart index dcfe9ab..c907581 100644 --- a/apps/im_app/lib/features/login/view/login_page.dart +++ b/apps/im_app/lib/features/login/view/login_page.dart @@ -15,13 +15,12 @@ class LoginPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // ref.watch 保持 loginViewModelProvider 存活(AutoDispose 需要至少一个监听者) + final state = ref.watch(loginViewModelProvider); final s = context.styles; return Scaffold( - appBar: AppBar( - title: const Text('登录'), - automaticallyImplyLeading: false, - ), + appBar: AppBar(title: const Text('登录'), automaticallyImplyLeading: false), body: Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -34,7 +33,9 @@ class LoginPage extends ConsumerWidget { ), const SizedBox(height: 32), FilledButton( - onPressed: () => ref.read(loginViewModelProvider.notifier).demoLogin(), + onPressed: state.isLoading + ? null + : () => ref.read(loginViewModelProvider.notifier).demoLogin(), child: const Text('登录'), ), ], diff --git a/apps/im_app/lib/features/settings/view/theme_view.dart b/apps/im_app/lib/features/settings/view/theme_view.dart index e3e96a9..3151cb9 100644 --- a/apps/im_app/lib/features/settings/view/theme_view.dart +++ b/apps/im_app/lib/features/settings/view/theme_view.dart @@ -16,32 +16,27 @@ class ThemeView extends ConsumerWidget { final current = ref.watch(themeViewModelProvider); return Scaffold( - appBar: AppBar( - title: const Text('主题'), - ), + appBar: AppBar(title: const Text('主题')), body: ListView( children: [ const SettingsSectionHeader(title: '外观'), ThemeOptionTile( label: '跟随系统', - mode: ThemeMode.system, - current: current, + isSelected: current == ThemeMode.system, onTap: () => ref .read(themeViewModelProvider.notifier) .setMode(ThemeMode.system), ), ThemeOptionTile( label: '黑色模式', - mode: ThemeMode.dark, - current: current, + isSelected: current == ThemeMode.dark, onTap: () => ref .read(themeViewModelProvider.notifier) .setMode(ThemeMode.dark), ), ThemeOptionTile( label: '白色模式', - mode: ThemeMode.light, - current: current, + isSelected: current == ThemeMode.light, onTap: () => ref .read(themeViewModelProvider.notifier) .setMode(ThemeMode.light), diff --git a/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart b/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart index d270d9f..92b1df5 100644 --- a/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart +++ b/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart @@ -5,14 +5,13 @@ import '../../../../../core/ui/base/context_theme_ext.dart'; /// 单个主题选项行 /// /// 纯展示 + 事件透传,不感知任何 Provider。 -/// 由父级传入 [current] 判断选中状态,[onTap] 处理切换。 +/// 父级传入 [isSelected] 决定是否显示勾选图标,[onTap] 处理切换。 /// /// 用法: /// ```dart /// ThemeOptionTile( /// label: '黑色模式', -/// mode: ThemeMode.dark, -/// current: current, +/// isSelected: current == ThemeMode.dark, /// onTap: () => ref.read(themeViewModelProvider.notifier).setMode(ThemeMode.dark), /// ) /// ``` @@ -20,20 +19,17 @@ class ThemeOptionTile extends StatelessWidget { const ThemeOptionTile({ super.key, required this.label, - required this.mode, - required this.current, + required this.isSelected, required this.onTap, }); final String label; - final ThemeMode mode; - final ThemeMode current; + final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { final s = context.styles; - final isSelected = current == mode; return ListTile( title: Text(label), diff --git a/apps/im_app/macos/Podfile b/apps/im_app/macos/Podfile index a46f7f2..6fd2b20 100644 --- a/apps/im_app/macos/Podfile +++ b/apps/im_app/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '11.0' +platform :osx, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -38,5 +38,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['SWIFT_VERSION'] ||= '6.2' + end end end diff --git a/apps/im_app/macos/Runner.xcodeproj/project.pbxproj b/apps/im_app/macos/Runner.xcodeproj/project.pbxproj index fecd0db..3208875 100644 --- a/apps/im_app/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/im_app/macos/Runner.xcodeproj/project.pbxproj @@ -481,7 +481,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app"; }; name = Debug; @@ -496,7 +496,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app"; }; name = Release; @@ -511,7 +511,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.cusotmer.im.imApp.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/im_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/im_app"; }; name = Profile; @@ -557,7 +557,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -580,7 +580,7 @@ "@executable_path/../Frameworks", ); PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; }; name = Profile; }; @@ -639,7 +639,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +689,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -713,7 +713,7 @@ ); PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; }; name = Debug; }; @@ -732,7 +732,7 @@ "@executable_path/../Frameworks", ); PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.2; }; name = Release; }; diff --git a/apps/im_app/pubspec.yaml b/apps/im_app/pubspec.yaml index 404e3a3..b0d565e 100644 --- a/apps/im_app/pubspec.yaml +++ b/apps/im_app/pubspec.yaml @@ -37,9 +37,13 @@ dependencies: # 网络状态监听 connectivity_plus: ^6.1.0 + # JWT 解析(token 过期检测、主动刷新) + dart_jsonwebtoken: ^3.3.2 + # 数据库(schema 定义在 im_app,连接/CRUD 封装在 storage_sdk) drift: ^2.22.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/cipher_guard_sdk/android/build.gradle b/packages/cipher_guard_sdk/android/build.gradle deleted file mode 100644 index 60bf876..0000000 --- a/packages/cipher_guard_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.cipher_guard_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.cipher_guard_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/cipher_guard_sdk/android/build.gradle.kts b/packages/cipher_guard_sdk/android/build.gradle.kts new file mode 100644 index 0000000..9360f2b --- /dev/null +++ b/packages/cipher_guard_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.cipher_guard_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.cipher_guard_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift b/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift index 4cfabae..51614bd 100644 --- a/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift +++ b/packages/cipher_guard_sdk/ios/Classes/CipherGuardSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class CipherGuardSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec b/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec index 8f648dd..a44f3dd 100644 --- a/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec +++ b/packages/cipher_guard_sdk/ios/cipher_guard_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/cipher_guard_sdk/lib/cipher_guard_sdk.dart b/packages/cipher_guard_sdk/lib/cipher_guard_sdk.dart index cd7c99d..9078178 100644 --- a/packages/cipher_guard_sdk/lib/cipher_guard_sdk.dart +++ b/packages/cipher_guard_sdk/lib/cipher_guard_sdk.dart @@ -9,6 +9,7 @@ library; export 'src/presentation/facade/cipher_guard_sdk_api.dart'; +export 'src/data/datasources/encryption_flutter_service.dart' show KdfMode; export 'src/domain/entities/rsa_key_pair.dart'; export 'src/domain/entities/session_key.dart'; export 'src/domain/entities/encrypted_message.dart'; diff --git a/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart b/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart index ff455ba..56ee26e 100644 --- a/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart +++ b/packages/cipher_guard_sdk/lib/src/data/datasources/encryption_flutter_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'dart:math'; import 'dart:typed_data'; @@ -7,45 +8,144 @@ import 'package:crypto/crypto.dart'; import 'package:encrypt/encrypt.dart' as encrypt_pkg; import 'package:pointycastle/api.dart'; import 'package:pointycastle/asymmetric/api.dart'; +import 'package:pointycastle/asymmetric/pkcs1.dart'; +import 'package:pointycastle/asymmetric/rsa.dart'; +import 'package:pointycastle/digests/sha256.dart'; +import 'package:pointycastle/key_derivators/api.dart'; +import 'package:pointycastle/key_derivators/pbkdf2.dart'; import 'package:pointycastle/key_generators/api.dart'; import 'package:pointycastle/key_generators/rsa_key_generator.dart'; +import 'package:pointycastle/macs/hmac.dart'; import 'package:pointycastle/random/fortuna_random.dart'; -import 'package:pointycastle/asymmetric/rsa.dart'; -import 'package:pointycastle/asymmetric/pkcs1.dart'; -/// Flutter Encryption Service -/// Implements all encryption logic in Flutter using pointycastle and encrypt packages -/// Replaces native Android/iOS encryption implementations +/// 密钥派生模式 +/// +/// 决定 [EncryptionFlutterService._deriveKeyForRound] 使用哪种算法。 +/// 默认 [md5],可选 [pbkdf2](增强安全性)。 +/// +/// 解密旧数据时必须使用加密时相同的模式, +/// 通过消息的 version 字段区分。 +enum KdfMode { + /// MD5 简单哈希(默认模式) + /// + /// 适用于 session key 已是 32 字节强随机值的场景。 + /// 性能好,每次调用 < 0.1ms。 + md5, + + /// PBKDF2-HMAC-SHA256(可选增强模式) + /// + /// 适用于从弱密码派生密钥的场景。 + /// 性能取决于迭代次数,10000 次约 10-50ms。 + pbkdf2, +} + +/// Flutter 加密服务 +/// +/// 端对端加密的核心引擎,纯 Dart 实现。 +/// 使用 pointycastle(RSA)+ encrypt(AES)+ crypto(MD5)。 +/// +/// ## 性能优化 +/// +/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行, +/// 避免阻塞主线程(1024-bit 约 150ms,2048-bit 约 300ms) +/// - **RSA 解析缓存**:[_parsePublicKey] / [_parsePrivateKey] 缓存 ASN1 解析结果, +/// 同一密钥 PEM 只做一次 BigInt 构造,后续命中缓存(LRU,上限 8 条) +/// - **Session key bytes 缓存**:[_getSessionKeyBytes] 缓存 base64 → Uint8List 结果, +/// 同一 session 的多条消息只解码一次(LRU,上限 64 条) +/// - **派生密钥缓存**:[_deriveKeyForRound] 结果按 (sessionKey, round, mode) 缓存, +/// 同一 session + round 的重复加解密直接命中(LRU,上限 64 条) +/// - **Random.secure() 复用**:全局单例,不再每次调用创建新实例 +/// - **KDF 双模式**:MD5(默认)/ PBKDF2(可选,增强安全性) +/// +/// ## 正确的接入姿势(避免重复读文件) +/// +/// 调用方(App 层)在登录后调一次 [CipherGuardSdkApi.setActiveKeyPair], +/// 把从安全存储读出的公私钥注入 SDK 内存。后续加解密使用 +/// [CipherGuardSdkApi.encryptSessionKeyWithActiveKey] / +/// [CipherGuardSdkApi.decryptSessionKeyWithActiveKey], +/// 不再每次传 key 参数,也不再重复读文件。 class EncryptionFlutterService { - // ==================== Constants ==================== + // ==================== 配置 ==================== + + /// 密钥派生模式,默认 MD5 + final KdfMode kdfMode; + + /// PBKDF2 迭代次数(仅 PBKDF2 模式有效,默认 10000) + final int pbkdf2Iterations; + + EncryptionFlutterService({ + this.kdfMode = KdfMode.md5, + this.pbkdf2Iterations = 10000, + }); + + // ==================== 常量 ==================== + static const int sessionKeySize = 32; static const int gcmIvLength = 12; + static const int _maxDerivedKeyCacheSize = 64; + static const int _maxRsaKeyCacheSize = 8; + static const int _maxSessionKeyBytesCacheSize = 64; - // ==================== RSA Key Management ==================== + // ==================== 性能优化:复用 Random 实例 ==================== - /// Generate RSA key pair in PEM format + /// 全局 Random.secure() 单例,避免每次调用创建新实例 + static final Random _secureRandom = Random.secure(); + + // ==================== 性能优化:派生密钥 LRU 缓存 ==================== + + /// 派生密钥缓存:'sessionKey:round:mode' -> Uint8List + /// + /// 同一 session + round 的加解密只派生一次,后续直接命中缓存。 + /// LinkedHashMap 保持插入顺序,满时淘汰最早条目。 + final _derivedKeyCache = {}; + + /// 清空派生密钥缓存(session key 轮换时调用) + void clearDerivedKeyCache() => _derivedKeyCache.clear(); + + // ==================== 性能优化:RSA 解析缓存 ==================== + + /// RSA 公钥解析缓存:PEM -> RSAPublicKey + /// + /// RSA 密钥生命周期长(通常每设备一对),ASN1 解析 + BigInt 构造代价较高。 + /// 解析结果在内存中复用,省去重复解析开销。上限 8 条,满时淘汰最早。 + final _rsaPublicKeyCache = {}; + + /// RSA 私钥解析缓存:PEM -> RSAPrivateKey + final _rsaPrivateKeyCache = {}; + + // ==================== 性能优化:session key bytes 缓存 ==================== + + /// Session key Base64 → 字节缓存 + /// + /// _deriveKeyForRound 和 _pbkdf2Derive 每次都需要 base64Decode(sessionKey), + /// 对同一会话的多条消息重复解码。缓存后只解码一次,满时淘汰最早。 + final _sessionKeyBytesCache = {}; + + // ==================== RSA 密钥管理 ==================== + + /// 生成 RSA 密钥对(同步,阻塞主线程) + /// + /// 建议使用 [generateRsaKeyPairAsync] 代替,避免 UI 卡顿。 RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) { try { - // Get secure random final secureRandom = FortunaRandom(); secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32))); - - // Create RSA key generator + final keyGen = RSAKeyGenerator(); - keyGen.init(ParametersWithRandom( - RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64), - secureRandom, - )); - - // Generate key pair + keyGen.init( + ParametersWithRandom( + RSAKeyGeneratorParameters(BigInt.parse('65537'), keySize, 64), + secureRandom, + ), + ); + final keyPair = keyGen.generateKeyPair(); - final rsaPublicKey = keyPair.publicKey as RSAPublicKey; - final rsaPrivateKey = keyPair.privateKey as RSAPrivateKey; - - // Export to PEM format + final rsaPublicKey = keyPair.publicKey; + final rsaPrivateKey = keyPair.privateKey; + final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey); final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey); - + return RsaKeyPairResult( publicKey: publicKeyPem, privateKey: privateKeyPem, @@ -55,26 +155,38 @@ class EncryptionFlutterService { } } - /// Encode RSA public key to PEM format using asn1lib + /// 生成 RSA 密钥对(异步,在 Isolate 中运行,不阻塞主线程) + /// + /// RSA 密钥生成是 CPU 密集型操作(1024-bit 约 150ms,2048-bit 约 300ms), + /// 放在 Isolate 中避免主线程卡顿。 + /// + /// **Isolate 隔离说明**: + /// Isolate 内会创建一个**默认配置**的 EncryptionFlutterService(KdfMode.md5), + /// 不会继承当前实例的 kdfMode / pbkdf2Iterations。 + /// 这对 RSA 密钥生成没有影响(RSA 不走 KDF),但如果将来需要在 + /// Isolate 中执行依赖 KDF 的操作(如消息加解密),需要传递配置参数。 + Future generateRsaKeyPairAsync({int keySize = 1024}) async { + return await Isolate.run( + () => EncryptionFlutterService().generateRsaKeyPair(keySize: keySize), + ); + } + + /// 编码 RSA 公钥为 PEM 格式 String _encodeRSAPublicKey(RSAPublicKey publicKey) { - // Build RSAPublicKeyInfo structure final topSeq = ASN1Sequence(); - - // AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 + NULL + final algoSeq = ASN1Sequence(); - algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1])); // RSA + algoSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1])); algoSeq.add(ASN1Null()); topSeq.add(algoSeq); - - // RSAPublicKey: modulus + publicExponent + final keySeq = ASN1Sequence(); keySeq.add(ASN1Integer(publicKey.n!)); keySeq.add(ASN1Integer(publicKey.exponent!)); - - // BitString wrapping the key (with 0 unused bits prefix) + final keyBytes = keySeq.encodedBytes; final keyList = List.from(keyBytes); - keyList.insert(0, 0); // Add unused bits byte + keyList.insert(0, 0); topSeq.add(ASN1BitString(keyList)); final derBytes = topSeq.encodedBytes; @@ -82,129 +194,98 @@ class EncryptionFlutterService { return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----'; } - /// Encode RSA private key to PEM format using asn1lib + /// 编码 RSA 私钥为 PEM 格式 String _encodeRSAPrivateKey(RSAPrivateKey privateKey) { - // Build RSAPrivateKey structure (PKCS#8 format) final topSeq = ASN1Sequence(); - - // Version (0) topSeq.add(ASN1Integer(BigInt.zero)); - - // Modulus topSeq.add(ASN1Integer(privateKey.n!)); - - // Public Exponent topSeq.add(ASN1Integer(privateKey.exponent!)); - - // Private Exponent topSeq.add(ASN1Integer(privateKey.privateExponent!)); - - // Prime P topSeq.add(ASN1Integer(privateKey.p!)); - - // Prime Q topSeq.add(ASN1Integer(privateKey.q!)); - - // (Optional CRT params omitted for simplicity) - + final derBytes = topSeq.encodedBytes; final base64 = base64Encode(derBytes.toList()); return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----'; } - // ==================== Private Key Encryption/Decryption ==================== + // ==================== 私钥加密/解密 ==================== - /// Encrypt private key with password (AES-CBC with MD5-derived key) + /// 用密码加密私钥(AES-CBC,密码通过 MD5 派生密钥) String encryptPrivateKey({ required String privateKey, required String password, }) { try { - // Generate AES key from MD5(password) final aesKey = _md5Hash(password); - - // Generate random IV (16 bytes) final iv = _generateSecureRandomBytes(16); - - // AES encrypt using encrypt package + final secretKey = encrypt_pkg.Key(aesKey); final encryptor = encrypt_pkg.Encrypter( encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc), ); - + final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv)); final encryptedBytes = encrypted.bytes; - - // Combine IV + encrypted data + final combined = Uint8List(iv.length + encryptedBytes.length); combined.setAll(0, iv); combined.setAll(iv.length, encryptedBytes); - + return base64Encode(combined); } catch (e) { throw Exception('Failed to encrypt private key: $e'); } } - /// Decrypt private key with password (AES-CBC with MD5-derived key) + /// 用密码解密私钥(AES-CBC,密码通过 MD5 派生密钥) String decryptPrivateKey({ required String encryptedPrivateKey, required String password, }) { try { - // Generate AES key from MD5(password) final aesKey = _md5Hash(password); - - // Decode Base64 final combined = base64Decode(encryptedPrivateKey); - - // Extract IV and encrypted data final iv = combined.sublist(0, 16); final encBytes = combined.sublist(16); - - // AES decrypt + final secretKey = encrypt_pkg.Key(aesKey); final encryptor = encrypt_pkg.Encrypter( encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc), ); - + final decrypted = encryptor.decrypt( encrypt_pkg.Encrypted(encBytes), iv: encrypt_pkg.IV(iv), ); - + return decrypted; } catch (e) { throw Exception('Failed to decrypt private key: $e'); } } - // ==================== Session Key Management ==================== + // ==================== 会话密钥管理 ==================== - /// Generate session key (32 bytes random) + /// 生成会话密钥(32 字节随机) SessionKeyResult generateSessionKey({int initialRound = 1}) { final keyBytes = _generateSecureRandomBytes(sessionKeySize); final key = base64Encode(keyBytes); - - return SessionKeyResult( - key: key, - round: initialRound, - ); + + return SessionKeyResult(key: key, round: initialRound); } - /// Encrypt session key with RSA public key + /// 用 RSA 公钥加密会话密钥 String encryptSessionKey({ required String sessionKey, required String publicKey, }) { try { - // Parse RSA public key final rsaPublicKey = _parsePublicKey(publicKey); - - // RSA encrypt using PKCS1 padding (like native implementations) + final cipher = PKCS1Encoding(RSAEngine()); cipher.init(true, PublicKeyParameter(rsaPublicKey)); - + final encryptedBytes = cipher.process(utf8.encode(sessionKey)); return base64Encode(encryptedBytes); } catch (e) { @@ -212,19 +293,17 @@ class EncryptionFlutterService { } } - /// Decrypt session key with RSA private key + /// 用 RSA 私钥解密会话密钥 String decryptSessionKey({ required String encryptedSessionKey, required String privateKey, }) { try { - // Parse RSA private key final rsaPrivateKey = _parsePrivateKey(privateKey); - - // RSA decrypt using PKCS1 padding (like native implementations) + final cipher = PKCS1Encoding(RSAEngine()); cipher.init(false, PrivateKeyParameter(rsaPrivateKey)); - + final decryptedBytes = cipher.process(base64Decode(encryptedSessionKey)); return utf8.decode(decryptedBytes); } catch (e) { @@ -232,215 +311,288 @@ class EncryptionFlutterService { } } - // ==================== Message Encryption/Decryption ==================== + // ==================== 消息加密/解密 ==================== - /// Encrypt message (AES-CTR with round-based key derivation) + /// 加密消息(AES-CTR,使用 round 派生密钥) EncryptedMessageResult encryptMessage({ required String plaintext, required String sessionKey, required int round, }) { try { - // Derive key for round final actualKey = _deriveKeyForRound(sessionKey, round); - - // Generate random IV (16 bytes for CTR) final iv = _generateSecureRandomBytes(16); - - // AES-CTR encrypt + final secretKey = encrypt_pkg.Key(actualKey); final encryptor = encrypt_pkg.Encrypter( encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr), ); - + final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv)); final encryptedBytes = encrypted.bytes; - - // Combine IV + encrypted data + final combined = Uint8List(iv.length + encryptedBytes.length); combined.setAll(0, iv); combined.setAll(iv.length, encryptedBytes); - + final data = base64Encode(combined); - - return EncryptedMessageResult( - round: round, - data: data, - ); + + return EncryptedMessageResult(round: round, data: data); } catch (e) { throw Exception('Failed to encrypt message: $e'); } } - /// Decrypt message (AES-CTR with round-based key derivation) + /// 解密消息(AES-CTR,使用 round 派生密钥) String decryptMessage({ required String encryptedData, required String sessionKey, required int round, }) { try { - // Derive key for round final actualKey = _deriveKeyForRound(sessionKey, round); - - // Decode Base64 final combined = base64Decode(encryptedData); - - // Extract IV and encrypted data final iv = combined.sublist(0, 16); final encBytes = combined.sublist(16); - - // AES-CTR decrypt + final secretKey = encrypt_pkg.Key(actualKey); final encryptor = encrypt_pkg.Encrypter( encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr), ); - + final decrypted = encryptor.decrypt( encrypt_pkg.Encrypted(encBytes), iv: encrypt_pkg.IV(iv), ); - + return decrypted; } catch (e) { throw Exception('Failed to decrypt message: $e'); } } - // ==================== Push Notification Decryption ==================== + // ==================== 推送通知解密 ==================== - /// Set AES secret for push notification decryption + /// 设置 AES secret(用于推送通知解密) void setAesSecret(String aesSecret) { _aesSecret = aesSecret; } String? _aesSecret; - /// Decrypt push notification (AES-GCM) - String decryptPushNotification({ - required String encryptedData, - }) { + /// 解密推送通知(AES-GCM) + String decryptPushNotification({required String encryptedData}) { try { final secret = _aesSecret; if (secret == null) { throw Exception('AES_SECRET not set'); } - - // Convert hex string to bytes + final secretBytes = _hexStringToBytes(secret); - - // Decode Base64 final combined = base64Decode(encryptedData); - - // Extract IV and encrypted data final iv = combined.sublist(0, gcmIvLength); final encBytes = combined.sublist(gcmIvLength); - - // AES-GCM decrypt + final secretKey = encrypt_pkg.Key(secretBytes); final encryptor = encrypt_pkg.Encrypter( encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm), ); - + final decrypted = encryptor.decrypt( encrypt_pkg.Encrypted(encBytes), iv: encrypt_pkg.IV(iv), ); - + return decrypted; } catch (e) { throw Exception('Failed to decrypt push notification: $e'); } } - // ==================== Helper Methods ==================== + // ==================== 内部方法 ==================== - /// Generate secure random bytes + /// 生成安全随机字节(复用全局 Random.secure() 实例) Uint8List _generateSecureRandomBytes(int length) { - final random = Random.secure(); final bytes = Uint8List(length); for (var i = 0; i < length; i++) { - bytes[i] = random.nextInt(256); + bytes[i] = _secureRandom.nextInt(256); } return bytes; } - /// MD5 hash + /// MD5 哈希(用于密码派生密钥) Uint8List _md5Hash(String input) { final bytes = utf8.encode(input); - final hash = md5.convert(bytes).bytes as Uint8List; - return hash; + final hash = md5.convert(bytes).bytes; + return Uint8List.fromList(hash); } - /// Derive key for round (MD5 hash of session key) + /// 按 round 派生 AES 密钥(带 LRU 缓存) + /// + /// 支持两种模式: + /// - [KdfMode.md5]:MD5(sessionKey + round),兼容模式,< 0.1ms + /// - [KdfMode.pbkdf2]:PBKDF2-HMAC-SHA256(sessionKey, salt=round),约 10-50ms + /// + /// 两种模式都会将 round 参与派生计算,保证不同 round 产出不同密钥。 + /// 缓存命中时直接返回,跳过计算。 + /// 缓存满时淘汰最久未访问的条目(LRU)。 Uint8List _deriveKeyForRound(String sessionKey, int targetRound) { - // Base64 decode session key - final keyBytes = base64Decode(sessionKey); - - // Apply MD5 for the round (simplified version) - final hash = md5.convert(keyBytes).bytes as Uint8List; - - return hash; + final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2'; + final cacheKey = '$sessionKey:$targetRound:$modeName'; + + // 缓存命中 — 移至末尾以维护 LRU 顺序 + final cached = _derivedKeyCache.remove(cacheKey); + if (cached != null) { + _derivedKeyCache[cacheKey] = cached; + return cached; + } + + // 计算派生密钥 + final Uint8List result; + switch (kdfMode) { + case KdfMode.md5: + // 将 sessionKey + round 一起参与 hash,保证不同 round 产出不同密钥 + final keyBytes = _getSessionKeyBytes(sessionKey); + final roundBytes = utf8.encode(':$targetRound'); + final combined = Uint8List(keyBytes.length + roundBytes.length) + ..setRange(0, keyBytes.length, keyBytes) + ..setRange( + keyBytes.length, + keyBytes.length + roundBytes.length, + roundBytes, + ); + final hash = md5.convert(combined).bytes; + result = Uint8List.fromList(hash); + case KdfMode.pbkdf2: + result = _pbkdf2Derive(sessionKey, targetRound); + } + + // LRU 淘汰:满时移除最久未访问的条目(Map 头部) + if (_derivedKeyCache.length >= _maxDerivedKeyCacheSize) { + _derivedKeyCache.remove(_derivedKeyCache.keys.first); + } + _derivedKeyCache[cacheKey] = result; + + return result; } - /// Parse RSA public key from PEM + /// PBKDF2-HMAC-SHA256 密钥派生 + /// + /// salt 包含 round 信息,不同 round 派生不同密钥。 + /// 迭代次数由 [pbkdf2Iterations] 控制(默认 10000)。 + /// 输出 16 字节(AES-128 密钥)。 + Uint8List _pbkdf2Derive(String sessionKey, int targetRound) { + final keyBytes = _getSessionKeyBytes(sessionKey); + final salt = utf8.encode('round:$targetRound'); + + final derivator = PBKDF2KeyDerivator(HMac(SHA256Digest(), 64)); + derivator.init( + Pbkdf2Parameters(Uint8List.fromList(salt), pbkdf2Iterations, 16), + ); + + return derivator.process(Uint8List.fromList(keyBytes)); + } + + /// 解析 RSA 公钥 PEM(带缓存) RSAPublicKey _parsePublicKey(String pem) { - final base64 = pem + final cached = _rsaPublicKeyCache.remove(pem); + if (cached != null) { + _rsaPublicKeyCache[pem] = cached; + return cached; + } + + final b64 = pem .replaceAll('-----BEGIN PUBLIC KEY-----', '') .replaceAll('-----END PUBLIC KEY-----', '') .replaceAll('\n', '') .trim(); - final bytes = base64Decode(base64); - - // Parse ASN.1 DER format + final bytes = base64Decode(b64); + final asn1Parser = ASN1Parser(Uint8List.fromList(bytes)); final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; - + final subjectPublicKeyInfo = topLevelSeq.elements[1] as ASN1BitString; final keyBytes = subjectPublicKeyInfo.contentBytes(); final keyParser = ASN1Parser(Uint8List.fromList(keyBytes)); final keySeq = keyParser.nextObject() as ASN1Sequence; - + final modulus = keySeq.elements[0] as ASN1Integer; final publicExponent = keySeq.elements[1] as ASN1Integer; - - return RSAPublicKey( + + final key = RSAPublicKey( modulus.valueAsBigInteger, publicExponent.valueAsBigInteger, ); + + if (_rsaPublicKeyCache.length >= _maxRsaKeyCacheSize) { + _rsaPublicKeyCache.remove(_rsaPublicKeyCache.keys.first); + } + _rsaPublicKeyCache[pem] = key; + return key; } - /// Parse RSA private key from PEM + /// 解析 RSA 私钥 PEM(带缓存) RSAPrivateKey _parsePrivateKey(String pem) { - final base64 = pem + final cached = _rsaPrivateKeyCache.remove(pem); + if (cached != null) { + _rsaPrivateKeyCache[pem] = cached; + return cached; + } + + final b64 = pem .replaceAll('-----BEGIN PRIVATE KEY-----', '') .replaceAll('-----END PRIVATE KEY-----', '') .replaceAll('\n', '') .trim(); - final bytes = base64Decode(base64); - - // Parse ASN.1 DER format + final bytes = base64Decode(b64); + final asn1Parser = ASN1Parser(Uint8List.fromList(bytes)); final keySeq = asn1Parser.nextObject() as ASN1Sequence; - + final modulus = keySeq.elements[1] as ASN1Integer; final privateExponent = keySeq.elements[3] as ASN1Integer; final p = keySeq.elements[4] as ASN1Integer; final q = keySeq.elements[5] as ASN1Integer; - return RSAPrivateKey( + final key = RSAPrivateKey( modulus.valueAsBigInteger, privateExponent.valueAsBigInteger, p.valueAsBigInteger, q.valueAsBigInteger, ); + + if (_rsaPrivateKeyCache.length >= _maxRsaKeyCacheSize) { + _rsaPrivateKeyCache.remove(_rsaPrivateKeyCache.keys.first); + } + _rsaPrivateKeyCache[pem] = key; + return key; } - /// Convert hex string to bytes + /// session key Base64 → 字节(带缓存) + /// + /// 同一 session key 在多条消息加解密中反复 decode,缓存后只做一次。 + Uint8List _getSessionKeyBytes(String sessionKey) { + final cached = _sessionKeyBytesCache.remove(sessionKey); + if (cached != null) { + _sessionKeyBytesCache[sessionKey] = cached; + return cached; + } + final bytes = base64Decode(sessionKey); + if (_sessionKeyBytesCache.length >= _maxSessionKeyBytesCacheSize) { + _sessionKeyBytesCache.remove(_sessionKeyBytesCache.keys.first); + } + _sessionKeyBytesCache[sessionKey] = bytes; + return bytes; + } + + /// Hex 字符串转字节 Uint8List _hexStringToBytes(String hex) { final len = hex.length; final data = Uint8List(len ~/ 2); for (var i = 0; i < len; i += 2) { - data[i ~/ 2] = (int.parse(hex[i], radix: 16) << 4) + int.parse(hex[i + 1], radix: 16); + data[i ~/ 2] = + (int.parse(hex[i], radix: 16) << 4) + + int.parse(hex[i + 1], radix: 16); } return data; } @@ -468,4 +620,3 @@ class EncryptedMessageResult { EncryptedMessageResult({required this.round, required this.data}); } - diff --git a/packages/cipher_guard_sdk/lib/src/data/repositories/encryption_repository_impl.dart b/packages/cipher_guard_sdk/lib/src/data/repositories/encryption_repository_impl.dart index 000815e..cd3efd0 100644 --- a/packages/cipher_guard_sdk/lib/src/data/repositories/encryption_repository_impl.dart +++ b/packages/cipher_guard_sdk/lib/src/data/repositories/encryption_repository_impl.dart @@ -16,7 +16,8 @@ class EncryptionRepositoryImpl implements EncryptionRepository { @override Future generateRsaKeyPair({int keySize = 1024}) async { - final result = _service.generateRsaKeyPair(keySize: keySize); + // 在 Isolate 中运行,避免阻塞主线程(1024-bit 约 150ms) + final result = await _service.generateRsaKeyPairAsync(keySize: keySize); return RsaKeyPair( publicKey: result.publicKey, privateKey: result.privateKey, @@ -50,10 +51,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository { @override Future generateSessionKey({int initialRound = 1}) async { final result = _service.generateSessionKey(initialRound: initialRound); - return SessionKey( - key: result.key, - round: result.round, - ); + return SessionKey(key: result.key, round: result.round); } @override @@ -91,10 +89,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository { sessionKey: sessionKey, round: round, ); - return EncryptedMessage( - round: result.round, - data: result.data, - ); + return EncryptedMessage(round: result.round, data: result.data); } @override @@ -110,6 +105,11 @@ class EncryptionRepositoryImpl implements EncryptionRepository { ); } + // ==================== 缓存管理 ==================== + + @override + void clearDerivedKeyCache() => _service.clearDerivedKeyCache(); + // ==================== 原生平台同步 ==================== @override @@ -147,4 +147,3 @@ class EncryptionRepositoryImpl implements EncryptionRepository { return _service.decryptPushNotification(encryptedData: encryptedData); } } - diff --git a/packages/cipher_guard_sdk/lib/src/domain/repositories/encryption_repository.dart b/packages/cipher_guard_sdk/lib/src/domain/repositories/encryption_repository.dart index c272366..b817d3a 100644 --- a/packages/cipher_guard_sdk/lib/src/domain/repositories/encryption_repository.dart +++ b/packages/cipher_guard_sdk/lib/src/domain/repositories/encryption_repository.dart @@ -8,7 +8,7 @@ import '../entities/encrypted_message.dart'; abstract class EncryptionRepository { // ==================== RSA 金鑰管理 ==================== - + /// 生成 RSA 金鑰對 /// [keySize] 金鑰長度 (預設 1024, 可用 2048) Future generateRsaKeyPair({int keySize = 1024}); @@ -84,6 +84,14 @@ abstract class EncryptionRepository { required Map> chatMap, }); + // ==================== 缓存管理 ==================== + + /// 清空派生密钥缓存 + /// + /// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。 + /// 不影响已加密的消息,只影响后续加解密操作的密钥派生。 + void clearDerivedKeyCache(); + // ==================== 配置相關 ==================== /// 設置 AES_SECRET (用於推送解密) @@ -91,8 +99,5 @@ abstract class EncryptionRepository { /// 解密 APNS 推送通知內容 /// 使用 release.json 中的 AES_SECRET - Future decryptPushNotification({ - required String encryptedData, - }); + Future decryptPushNotification({required String encryptedData}); } - diff --git a/packages/cipher_guard_sdk/lib/src/presentation/facade/cipher_guard_sdk_api.dart b/packages/cipher_guard_sdk/lib/src/presentation/facade/cipher_guard_sdk_api.dart index 584c487..45d0f86 100644 --- a/packages/cipher_guard_sdk/lib/src/presentation/facade/cipher_guard_sdk_api.dart +++ b/packages/cipher_guard_sdk/lib/src/presentation/facade/cipher_guard_sdk_api.dart @@ -7,59 +7,124 @@ import 'package:cipher_guard_sdk/src/domain/entities/session_key.dart'; import 'package:cipher_guard_sdk/src/domain/entities/encrypted_message.dart'; import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart'; -abstract class CipherGuardSdkApi -{ +abstract class CipherGuardSdkApi { factory CipherGuardSdkApi() => CipherGuardSdkWiring.build(); // ==================== 平台版本 ==================== - + /// 獲取平台版本 Future platformVersion(); // ==================== RSA 金鑰管理 ==================== - + /// 生成 RSA 金鑰對 Future generateRsaKeyPair({int keySize = 1024}); /// 用密碼加密私鑰 - Future encryptPrivateKey({required String privateKey, required String password,}); + Future encryptPrivateKey({ + required String privateKey, + required String password, + }); /// 解密私鑰 - Future decryptPrivateKey({required String encryptedPrivateKey, required String password,}); + Future decryptPrivateKey({ + required String encryptedPrivateKey, + required String password, + }); // ==================== 會話金鑰管理 ==================== - + /// 生成 AES 會話金鑰 Future generateSessionKey({int initialRound = 1}); /// 用 RSA 公鑰加密會話金鑰 - Future encryptSessionKey({required String sessionKey, required String publicKey,}); + Future encryptSessionKey({ + required String sessionKey, + required String publicKey, + }); /// 用 RSA 私鑰解密會話金鑰 - Future decryptSessionKey({required String encryptedSessionKey, required String privateKey,}); + Future decryptSessionKey({ + required String encryptedSessionKey, + required String privateKey, + }); // ==================== 訊息加解密 ==================== - + /// 加密訊息 - Future encryptMessage({required String plaintext, required String sessionKey, required int round,}); + Future encryptMessage({ + required String plaintext, + required String sessionKey, + required int round, + }); /// 解密訊息 - Future decryptMessage({required String encryptedData, required String sessionKey, required int round,}); + Future decryptMessage({ + required String encryptedData, + required String sessionKey, + required int round, + }); + + // ==================== 活跃密钥注册 ==================== + + /// 注册当前用户的 RSA 密钥对(登录后调用一次,内存持久化) + /// + /// 登录成功后从安全存储 / keychain 取出密钥对,调用此方法注入 SDK。 + /// 后续 [encryptSessionKeyWithActiveKey] / [decryptSessionKeyWithActiveKey] + /// 直接使用内存中的密钥,不再需要调用方每次传参,也不再重复读文件。 + /// + /// 退出登录时调用 [clearActiveKeyPair]。 + void setActiveKeyPair({ + required String publicKey, + required String privateKey, + }); + + /// 清除内存中的活跃密钥对(退出登录时调用) + void clearActiveKeyPair(); + + /// 用内存中的 RSA 公钥加密 session key + /// + /// 等价于 [encryptSessionKey],但无需每次传 publicKey。 + /// 调用前必须先调 [setActiveKeyPair],否则抛 [StateError]。 + Future encryptSessionKeyWithActiveKey({required String sessionKey}); + + /// 用内存中的 RSA 私钥解密 session key + /// + /// 等价于 [decryptSessionKey],但无需每次传 privateKey。 + /// 调用前必须先调 [setActiveKeyPair],否则抛 [StateError]。 + Future decryptSessionKeyWithActiveKey({ + required String encryptedSessionKey, + }); + + // ==================== 缓存管理 ==================== + + /// 清空派生密钥缓存 + /// + /// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用, + /// 导致加解密使用错误的密钥。 + void clearDerivedKeyCache(); // ==================== 原生平台同步 ==================== - + /// 同步加密金鑰到原生平台 (iOS App Group) - Future syncEncryptionKey({required String chatId, required int activeRound, required int round, required String activeKey, required bool isSingle,}); + Future syncEncryptionKey({ + required String chatId, + required int activeRound, + required int round, + required String activeKey, + required bool isSingle, + }); /// 批量同步所有加密聊天室的金鑰 - Future syncAllEncryptionKeys({required Map> chatMap,}); + Future syncAllEncryptionKeys({ + required Map> chatMap, + }); // ==================== 推送通知解密 ==================== - + /// 設置 AES_SECRET (用於推送解密) Future setAesSecret({required String aesSecret}); /// 解密 APNS 推送通知內容 - Future decryptPushNotification({required String encryptedData,}); + Future decryptPushNotification({required String encryptedData}); } - diff --git a/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_api_impl.dart b/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_api_impl.dart index 175e7ec..4ec700c 100644 --- a/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_api_impl.dart +++ b/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_api_impl.dart @@ -10,6 +10,11 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi { CipherGuardSdkApiImpl({required CipherGuardSdkCore core}) : _core = core; + // ── 活跃密钥内存存储(登录后 setActiveKeyPair 写入,退出登录 clearActiveKeyPair 清除)── + + String? _activePublicKey; + String? _activePrivateKey; + @override Future platformVersion() => _core.platform.getPlatformVersion(); @@ -93,6 +98,47 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi { ); } + @override + void setActiveKeyPair({ + required String publicKey, + required String privateKey, + }) { + _activePublicKey = publicKey; + _activePrivateKey = privateKey; + } + + @override + void clearActiveKeyPair() { + _activePublicKey = null; + _activePrivateKey = null; + } + + @override + Future encryptSessionKeyWithActiveKey({required String sessionKey}) { + final key = _activePublicKey; + if (key == null) { + throw StateError('Active key pair not set. Call setActiveKeyPair first.'); + } + return encryptSessionKey(sessionKey: sessionKey, publicKey: key); + } + + @override + Future decryptSessionKeyWithActiveKey({ + required String encryptedSessionKey, + }) { + final key = _activePrivateKey; + if (key == null) { + throw StateError('Active key pair not set. Call setActiveKeyPair first.'); + } + return decryptSessionKey( + encryptedSessionKey: encryptedSessionKey, + privateKey: key, + ); + } + + @override + void clearDerivedKeyCache() => _core.encryptionRepo.clearDerivedKeyCache(); + @override Future syncEncryptionKey({ required String chatId, @@ -123,9 +169,9 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi { } @override - Future decryptPushNotification({ - required String encryptedData, - }) { - return _core.encryptionRepo.decryptPushNotification(encryptedData: encryptedData); + Future decryptPushNotification({required String encryptedData}) { + return _core.encryptionRepo.decryptPushNotification( + encryptedData: encryptedData, + ); } } diff --git a/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_wiring.dart b/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_wiring.dart index 2b36f4a..7f162a4 100644 --- a/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_wiring.dart +++ b/packages/cipher_guard_sdk/lib/src/presentation/wiring/cipher_guard_sdk_wiring.dart @@ -9,9 +9,18 @@ import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_api_im /// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯 class CipherGuardSdkWiring { /// 構建 SDK 實例 - static CipherGuardSdkApi build() { + /// + /// [kdfMode] — 密钥派生模式,默认 [KdfMode.md5](兼容模式) + /// [pbkdf2Iterations] — PBKDF2 迭代次数(仅 pbkdf2 模式生效,默认 10000) + static CipherGuardSdkApi build({ + KdfMode kdfMode = KdfMode.md5, + int pbkdf2Iterations = 10000, + }) { // 1. 創建 Flutter 加密服務 - final flutterService = EncryptionFlutterService(); + final flutterService = EncryptionFlutterService( + kdfMode: kdfMode, + pbkdf2Iterations: pbkdf2Iterations, + ); // 2. 創建 Repository (使用 Flutter 服務) final repository = EncryptionRepositoryImpl(flutterService); @@ -39,4 +48,3 @@ class _CipherGuardPlatformImpl implements CipherGuardPlatform { return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行 } } - diff --git a/packages/im_log_sdk/android/build.gradle.kts b/packages/im_log_sdk/android/build.gradle.kts index 0544edc..7e8555a 100644 --- a/packages/im_log_sdk/android/build.gradle.kts +++ b/packages/im_log_sdk/android/build.gradle.kts @@ -7,7 +7,6 @@ buildscript { google() mavenCentral() } - dependencies { classpath("com.android.tools.build:gradle:8.11.1") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") @@ -23,12 +22,11 @@ allprojects { plugins { id("com.android.library") - id("kotlin-android") + id("org.jetbrains.kotlin.android") } android { namespace = "com.example.im_log_sdk" - compileSdk = 36 compileOptions { @@ -36,17 +34,9 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - sourceSets { - getByName("main") { - java.srcDirs("src/main/kotlin") - } - getByName("test") { - java.srcDirs("src/test/kotlin") - } + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } } defaultConfig { @@ -58,9 +48,7 @@ android { isIncludeAndroidResources = true all { it.useJUnitPlatform() - it.outputs.upToDateWhen { false } - it.testLogging { events("passed", "skipped", "failed", "standardOut", "standardError") showStandardStreams = true @@ -70,6 +58,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.mockito:mockito-core:5.0.0") diff --git a/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift b/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift index 076409e..405c636 100644 --- a/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift +++ b/packages/im_log_sdk/ios/Classes/ImLogSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class ImLogSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/im_log_sdk/ios/im_log_sdk.podspec b/packages/im_log_sdk/ios/im_log_sdk.podspec index f3eb15d..8df33d7 100644 --- a/packages/im_log_sdk/ios/im_log_sdk.podspec +++ b/packages/im_log_sdk/ios/im_log_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift b/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift index 3574fba..b00135b 100644 --- a/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift +++ b/packages/im_log_sdk/macos/Classes/ImLogSdkPlugin.swift @@ -1,5 +1,5 @@ import Cocoa -import FlutterMacOS +@preconcurrency import FlutterMacOS public class ImLogSdkPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { diff --git a/packages/im_log_sdk/macos/im_log_sdk.podspec b/packages/im_log_sdk/macos/im_log_sdk.podspec index e7b420f..d864ccf 100644 --- a/packages/im_log_sdk/macos/im_log_sdk.podspec +++ b/packages/im_log_sdk/macos/im_log_sdk.podspec @@ -24,7 +24,7 @@ A new Flutter plugin project. s.dependency 'FlutterMacOS' - s.platform = :osx, '10.11' + s.platform = :osx, '14.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' + s.swift_version = '6.2' end diff --git a/packages/l10n_sdk/android/build.gradle b/packages/l10n_sdk/android/build.gradle deleted file mode 100644 index 2de5754..0000000 --- a/packages/l10n_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.l10n_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.l10n_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/l10n_sdk/android/build.gradle.kts b/packages/l10n_sdk/android/build.gradle.kts new file mode 100644 index 0000000..e4081dd --- /dev/null +++ b/packages/l10n_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.l10n_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.l10n_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift b/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift index 0c80a7c..eabc282 100644 --- a/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift +++ b/packages/l10n_sdk/ios/Classes/L10nSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class L10nSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/l10n_sdk/ios/l10n_sdk.podspec b/packages/l10n_sdk/ios/l10n_sdk.podspec index a297469..9a1c11a 100644 --- a/packages/l10n_sdk/ios/l10n_sdk.podspec +++ b/packages/l10n_sdk/ios/l10n_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/media_sdk/android/build.gradle b/packages/media_sdk/android/build.gradle deleted file mode 100644 index 0ef8774..0000000 --- a/packages/media_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.media_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.media_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/media_sdk/android/build.gradle.kts b/packages/media_sdk/android/build.gradle.kts new file mode 100644 index 0000000..fe6f889 --- /dev/null +++ b/packages/media_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.media_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.media_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift b/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift index d9b652d..49131a0 100644 --- a/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift +++ b/packages/media_sdk/ios/Classes/MediaSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class MediaSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/media_sdk/ios/media_sdk.podspec b/packages/media_sdk/ios/media_sdk.podspec index 9125e0f..6a1e285 100644 --- a/packages/media_sdk/ios/media_sdk.podspec +++ b/packages/media_sdk/ios/media_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/networks_sdk/android/build.gradle b/packages/networks_sdk/android/build.gradle deleted file mode 100644 index 6746089..0000000 --- a/packages/networks_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.networks_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.networks_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/networks_sdk/android/build.gradle.kts b/packages/networks_sdk/android/build.gradle.kts new file mode 100644 index 0000000..bcfdb84 --- /dev/null +++ b/packages/networks_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.networks_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.networks_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift b/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift index f534410..9c3a439 100644 --- a/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift +++ b/packages/networks_sdk/ios/Classes/NetworksSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class NetworksSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/networks_sdk/ios/networks_sdk.podspec b/packages/networks_sdk/ios/networks_sdk.podspec index a874b54..245e3c4 100644 --- a/packages/networks_sdk/ios/networks_sdk.podspec +++ b/packages/networks_sdk/ios/networks_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/networks_sdk/lib/networks_sdk.dart b/packages/networks_sdk/lib/networks_sdk.dart index 3f26639..b514681 100644 --- a/packages/networks_sdk/lib/networks_sdk.dart +++ b/packages/networks_sdk/lib/networks_sdk.dart @@ -6,8 +6,9 @@ export 'src/presentation/facade/networks_messaging_api.dart'; // Wiring - Implementations export 'src/presentation/wiring/networks_messaging_api_impl.dart'; -// Dio 类型重导出(App 层上传 / override decodeResponse 需要,避免直接依赖 dio) -export 'package:dio/dio.dart' show FormData, MultipartFile, Response; +// Dio 类型重导出(App 层上传 / CancelToken / override decodeResponse 需要,避免直接依赖 dio) +export 'package:dio/dio.dart' + show FormData, MultipartFile, Response, CancelToken; // Config export 'src/presentation/wiring/api_config.dart'; @@ -18,6 +19,7 @@ export 'src/presentation/wiring/network_callbacks.dart'; export 'src/data/dto/api_requestable.dart'; export 'src/data/dto/api_response_wrapper.dart'; export 'src/domain/entities/api_error.dart'; +export 'src/domain/entities/encrypted_request.dart'; export 'src/domain/entities/http_method.dart'; export 'src/domain/entities/api_request_type.dart'; @@ -27,3 +29,4 @@ export 'src/domain/entities/socket_error.dart'; // Annotations(代码生成) export 'src/annotations/api_request.dart'; +export 'src/annotations/api_response.dart'; diff --git a/packages/networks_sdk/lib/src/annotations/api_request.dart b/packages/networks_sdk/lib/src/annotations/api_request.dart index 50a3b23..1fe3204 100644 --- a/packages/networks_sdk/lib/src/annotations/api_request.dart +++ b/packages/networks_sdk/lib/src/annotations/api_request.dart @@ -1,49 +1,56 @@ import 'package:networks_sdk/src/domain/entities/api_request_type.dart'; import 'package:networks_sdk/src/domain/entities/http_method.dart'; - /// API 请求注解 — 标记一个类为 API 请求 /// /// 配合 `build_runner` 代码生成器,自动生成 `ApiRequestable` 协议实现, /// 使用侧只需定义字段 + 注解,path / method / requestType / includeToken /// 全部由生成器自动提供。 /// -/// ## 使用方式 +/// ## 有响应数据(指定 responseType) /// /// ```dart /// @ApiRequest( -/// path: '/auth/login', +/// path: ApiPaths.authLogin, /// method: HttpMethod.post, -/// responseType: LoginData, +/// responseType: LoginResponse, /// requestType: ApiRequestType.login, /// ) -/// @JsonSerializable() -/// class LoginRequest extends ApiRequestable +/// class LoginRequest extends ApiRequestable /// with _$LoginRequestApi { /// final String email; /// final String password; /// /// LoginRequest({required this.email, required this.password}); -/// -/// @override -/// Map toJson() => _$LoginRequestToJson(this); /// } /// ``` /// -/// 生成器自动生成 `_$LoginRequestApi` mixin,提供: -/// - `path` → `'/auth/login'` -/// - `method` → `HttpMethod.post` -/// - `requestType` → `ApiRequestType.login` -/// - `includeToken` → `false`(login 类型自动设为 false) +/// ## 无响应数据(省略 responseType) +/// +/// ```dart +/// @ApiRequest( +/// path: ApiPaths.authLogout, +/// method: HttpMethod.post, +/// ) +/// class LogoutRequest extends ApiRequestable +/// with _$LogoutRequestApi { +/// LogoutRequest(); +/// } +/// ``` +/// +/// 生成器自动生成 `_$XxxRequestApi` mixin,提供: +/// - `path` / `method` / `requestType` / `includeToken` +/// - `toJson()` — 从声明字段自动生成 +/// - `responseType` 存在时:`parameters` getter 自动注册 `fromJson` class ApiRequest { - /// API 路径(如 `'/auth/login'`) + /// API 路径(如 `ApiPaths.authLogin`) final String path; /// HTTP 方法(默认 POST) final HttpMethod method; - /// 响应类型(用于泛型绑定) - final Type responseType; + /// 响应数据类型(省略表示无响应数据,对应 `ApiRequestable`) + final Type? responseType; /// 请求类型(决定 header 处理方式,默认 request) final ApiRequestType requestType; @@ -57,7 +64,7 @@ class ApiRequest { const ApiRequest({ required this.path, this.method = HttpMethod.post, - required this.responseType, + this.responseType, this.requestType = ApiRequestType.request, this.includeToken, this.customHeaders, diff --git a/packages/networks_sdk/lib/src/annotations/api_response.dart b/packages/networks_sdk/lib/src/annotations/api_response.dart new file mode 100644 index 0000000..27f195c --- /dev/null +++ b/packages/networks_sdk/lib/src/annotations/api_response.dart @@ -0,0 +1,31 @@ +/// Response DTO 无需任何注解。 +/// +/// 生成器从 `@ApiRequest(responseType: T)` 声明出发,自动推导并生成 +/// `_$TFromJson` 反序列化函数,以及所有嵌套自定义类型的 `fromJson`。 +/// +/// ## 使用方式(只需标注 Request 类) +/// +/// ```dart +/// // Request:唯一需要注解的地方 +/// @ApiRequest(path: ApiPaths.authLogin, responseType: LoginResponse) +/// class LoginRequest extends ApiRequestable +/// with _$LoginRequestApi { +/// final String email; +/// final String password; +/// LoginRequest({required this.email, required this.password}); +/// } +/// +/// // Response:纯 Dart 类,零注解,零样板 +/// class LoginResponse { +/// @JsonKey(name: 'access_token') final String accessToken; +/// final LoginProfile profile; +/// const LoginResponse({required this.accessToken, required this.profile}); +/// // 生成器自动提供 _$LoginResponseFromJson +/// } +/// ``` +/// +/// 此文件保留作占位符,供未来扩展使用(如自定义序列化策略)。 +/// 当前版本 Response 类无需标注任何注解,所有 fromJson 由生成器自动推导。 +class ApiResponse { + const ApiResponse(); +} diff --git a/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart b/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart index fc90fdd..2da6614 100644 --- a/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart +++ b/packages/networks_sdk/lib/src/data/datasources/http/api_client.dart @@ -1,12 +1,22 @@ import 'package:dio/dio.dart'; import 'package:networks_sdk/src/data/datasources/http/interceptor/auth_interceptor.dart'; +import 'package:networks_sdk/src/data/datasources/http/interceptor/encryption_interceptor.dart'; import 'package:networks_sdk/src/data/datasources/http/interceptor/logging_interceptor.dart'; import 'package:networks_sdk/src/data/datasources/http/interceptor/retry_interceptor.dart'; import 'package:networks_sdk/src/domain/entities/api_error.dart'; import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; /// REST API 客户端 -/// 基于 Dio,提供 `executeRequest` 唯一入口 +/// 基于 Dio,提供请求执行入口 +/// +/// 拦截器链顺序(onRequest):Auth → 自定义 → Retry → Logging → Encryption +/// +/// Dio 的 onResponse / onError 按 **逆序** 执行,因此实际响应处理为: +/// `Encryption(解密) → Logging → Retry(业务码判断) → 自定义 → Auth` +/// +/// EncryptionInterceptor 放最后,保证: +/// - onRequest 最后加密(其他拦截器操作明文) +/// - onResponse 最先解密(其他拦截器看到明文,业务码判断正常工作) /// /// 使用方式: /// ```dart @@ -28,12 +38,15 @@ class ApiClient { receiveTimeout: const Duration(seconds: 60), ); - // 挂载拦截器(顺序:Auth → 自定义 → Retry → Logging) + // 挂载拦截器 + // onRequest 顺序:Auth → 自定义 → Retry → Logging → Encryption + // onResponse 逆序:Encryption(解密) → Logging → Retry(业务码) → 自定义 → Auth _dio.interceptors.addAll([ AuthInterceptor(config), if (additionalInterceptors != null) ...additionalInterceptors, RetryInterceptor(config: config, dio: _dio), LoggingInterceptor(onLog: config.onLog), + EncryptionInterceptor(config), ]); } @@ -49,16 +62,16 @@ class ApiClient { return const ApiError.timeout(); case DioExceptionType.connectionError: return const ApiError.noNetworkConnection(); + case DioExceptionType.cancel: + return const ApiError.cancelled(); default: if (e.response != null) { return ApiError.apiError( code: e.response!.statusCode ?? 0, - message: e.response!.statusMessage ?? - e.message ?? - 'Request failed', + message: e.response!.statusMessage ?? e.message ?? 'Request failed', ); } return ApiError.networkError(e.message ?? 'Network error'); } } -} \ No newline at end of file +} diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/encryption_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/encryption_interceptor.dart new file mode 100644 index 0000000..72cafd3 --- /dev/null +++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/encryption_interceptor.dart @@ -0,0 +1,204 @@ +import 'package:dio/dio.dart'; +import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; + +/// 加密拦截器(预留给 cipher_guard_sdk) +/// +/// 在拦截器链中位于最末位: +/// onRequest 顺序:`Auth → Custom → Retry → Logging → Encryption` +/// onResponse 逆序:`Encryption(解密) → Logging → Retry(业务码) → Custom → Auth` +/// +/// 放最后是因为 Dio onResponse 按逆序执行——加密拦截器最先解密, +/// 后续的 RetryInterceptor 才能正确判断业务错误码、Token 过期等。 +/// +/// 回调为 null 时自动跳过,不影响正常请求流程。 +/// 后续 cipher_guard_sdk 接入后,App 层在 ApiConfig 中注入 +/// `onEncryptRequest` / `onDecryptResponse` 即可启用加密。 +/// +/// ## 加密能力 +/// +/// 与简单的 body 加解密不同,本拦截器支持完整的请求改写: +/// - 路径加密(如 `/api/login` → `/api/hex(encrypt(login))`) +/// - 请求体加密(Map → base64 字符串) +/// - Header 注入(X-Token、X-Signature、secret-key 等) +/// - Content-Type 覆盖(application/json → text/plain) +/// +/// 加密回调接收原始 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); + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final encrypt = _config.onEncryptRequest; + if (encrypt == null) { + handler.next(options); + 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) + final currentHeaders = {}; + options.headers.forEach((key, value) { + if (value != null) currentHeaders[key] = value.toString(); + }); + + final result = await encrypt(options.path, currentHeaders, options.data); + + // 根据非 null 字段覆盖请求 + if (result.path != null) { + options.path = result.path!; + } + if (result.body != null) { + options.data = result.body; + } + 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', + ); + + handler.next(options); + } catch (e) { + _config.onLog?.call('Request encryption failed: $e', tag: 'Encryption'); + handler.reject( + DioException( + requestOptions: options, + message: 'Request encryption failed: $e', + ), + ); + } + } + + /// 恢复加密前的请求状态 + /// + /// 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?; + 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; + if (decrypt == null) { + handler.next(response); + return; + } + + // 跳过 null 响应 + if (response.data == null) { + handler.next(response); + return; + } + + // 响应已是 Map → 未加密(health check、非加密端点等),跳过解密 + // 加密模式下响应通常是 String(base64)或 List(bytes) + // TODO: 接入加密后,若服务端所有端点都加密,可移除此判断 + if (response.data is Map) { + handler.next(response); + return; + } + + try { + final decrypted = await decrypt(response.data as Object); + response.data = decrypted; + + _config.onLog?.call( + 'Response decrypted: ${response.requestOptions.path}', + tag: 'Encryption', + ); + + handler.next(response); + } catch (e) { + _config.onLog?.call('Response decryption failed: $e', tag: 'Encryption'); + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + message: 'Response decryption failed: $e', + ), + ); + } + } +} diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart index 6864329..b02bbec 100644 --- a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart +++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart @@ -1,35 +1,41 @@ -import 'dart:async'; 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'; - /// 重试拦截器 /// /// 两层重试机制: /// /// 1. **Token 刷新重试**(onResponse) -/// 检测 Token 过期响应 → 触发刷新回调 → 用新 Token 重试原请求 +/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求 /// /// 2. **瞬态错误重试**(onError) /// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试 /// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用) /// +/// 另外在 onResponse 中处理强制登出码和业务错误码。 +/// /// 两层独立运作,可叠加。 class RetryInterceptor extends Interceptor { final ApiConfig config; final Dio dio; - - /// Token 刷新锁(防止多个请求同时刷新) - bool _isRefreshing = false; - Completer? _refreshCompleter; + final TokenRefreshManager _tokenManager; final _random = Random(); - RetryInterceptor({required this.config, required this.dio}); + RetryInterceptor({required this.config, required this.dio}) + : _tokenManager = TokenRefreshManager( + onTokenRefresh: config.onTokenRefresh, + onLog: config.onLog, + timeout: config.tokenRefreshTimeout, + reuseWindow: config.tokenReuseWindow, + onGetTokenExpiry: config.onGetTokenExpiry, + proactiveRefreshThreshold: config.proactiveRefreshThreshold, + ); - // ── Token 刷新重试 ──────────────────────────────────────────────────────── + // ── 响应处理(Token 过期 / 强制登出 / 业务错误码)────────────────────── @override void onResponse(Response response, ResponseInterceptorHandler handler) { @@ -40,13 +46,12 @@ class RetryInterceptor extends Interceptor { final data = response.data as Map; final code = _parseCode(data['code']); + 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.onLog?.call('Force logout detected (code: $code)', tag: 'Network'); config.onForceLogout?.call(); handler.reject( DioException( @@ -58,8 +63,9 @@ class RetryInterceptor extends Interceptor { return; } - // 检查 Token 过期 - if (config.tokenExpiredCodes.contains(code)) { + // 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归) + if (config.tokenExpiredCodes.contains(code) && + response.requestOptions.extra['_isTokenRetry'] != true) { config.onLog?.call( 'Token expired (code: $code), refreshing...', tag: 'Network', @@ -68,17 +74,28 @@ class RetryInterceptor extends Interceptor { return; } + // 业务错误码拦截:非 0 且不在特殊码集合中 + if (code != 0 && config.onBusinessError != null) { + final handled = config.onBusinessError!(code, message, requestPath); + if (handled) { + // App 层已处理 → 标记,让 decodeResponse 跳过二次抛错 + response.requestOptions.extra['_businessErrorHandled'] = true; + handler.next(response); + return; + } + } + handler.next(response); } /// 处理 Token 过期:刷新 + 重试 Future _handleTokenExpired( - Response response, - ResponseInterceptorHandler handler, - ) async { - final refreshSuccess = await _refreshToken(); + Response response, + ResponseInterceptorHandler handler, + ) async { + final newToken = await _tokenManager.refreshIfNeeded(); - if (!refreshSuccess) { + if (newToken == null) { config.onLog?.call('Token refresh failed', tag: 'Network'); config.onForceLogout?.call(); handler.reject( @@ -91,12 +108,17 @@ class RetryInterceptor extends Interceptor { return; } - // 刷新成功,用新 token 重试原请求 + // 刷新成功,更新 config 并用新 token 重试原请求 + config.updateToken(newToken); config.onLog?.call('Token refreshed, retrying...', tag: 'Network'); try { final options = response.requestOptions; - // 更新 header 中的 token - options.headers['token'] = config.token; + 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); @@ -105,41 +127,6 @@ class RetryInterceptor extends Interceptor { } } - /// Token 刷新(串行锁) - /// 多个请求同时过期时,只刷新一次,其余等待 - Future _refreshToken() async { - if (_isRefreshing) { - // 等待正在进行的刷新 - return _refreshCompleter?.future ?? Future.value(false); - } - - _isRefreshing = true; - _refreshCompleter = Completer(); - - try { - if (config.onTokenRefresh == null) { - _refreshCompleter!.complete(false); - return false; - } - - final newToken = await config.onTokenRefresh!(); - final success = newToken != null; - - if (success) { - config.updateToken(newToken); - } - - _refreshCompleter!.complete(success); - return success; - } catch (e) { - _refreshCompleter!.complete(false); - return false; - } finally { - _isRefreshing = false; - _refreshCompleter = null; - } - } - // ── 瞬态错误重试(指数退避 + jitter)──────────────────────────────────── @override @@ -162,7 +149,7 @@ class RetryInterceptor extends Interceptor { final delayMs = _backoffDelay(attempt); config.onLog?.call( 'Transient error, retry ${attempt + 1}/${config.maxRetries} ' - 'in ${delayMs}ms: ${options.path}', + 'in ${delayMs}ms: ${options.path}', tag: 'Retry', ); @@ -184,7 +171,7 @@ class RetryInterceptor extends Interceptor { case DioExceptionType.connectionError: return true; case DioExceptionType.badResponse: - // 5xx 服务端错误可重试 + // 5xx 服务端错误可重试 final statusCode = err.response?.statusCode; return statusCode != null && statusCode >= 500; default: @@ -198,7 +185,9 @@ class RetryInterceptor extends Interceptor { int _backoffDelay(int attempt) { final baseMs = config.retryBaseDelay.inMilliseconds; final exponentialMs = min(baseMs * pow(2, attempt).toInt(), 30000); - final jitterMs = _random.nextInt((exponentialMs * 0.25).toInt().clamp(1, 7500)); + final jitterMs = _random.nextInt( + (exponentialMs * 0.25).toInt().clamp(1, 7500), + ); return exponentialMs + jitterMs; } diff --git a/packages/networks_sdk/lib/src/data/datasources/http/token_refresh_manager.dart b/packages/networks_sdk/lib/src/data/datasources/http/token_refresh_manager.dart new file mode 100644 index 0000000..96bee72 --- /dev/null +++ b/packages/networks_sdk/lib/src/data/datasources/http/token_refresh_manager.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; + +/// Token 刷新管理器 +/// +/// 两种刷新模式: +/// +/// 1. **被动刷新**([refreshIfNeeded])— 拦截器检测到 token 过期后调用 +/// 2. **主动刷新**([proactivelyRefreshIfNeeded])— 解析 JWT exp, +/// 距过期不足阈值时提前刷新,避免带过期 token 发请求 +/// +/// 两种模式共享串行锁和时间窗口保护: +/// - **串行锁** — 同一时刻只执行一次刷新,其余请求等待同一 Completer +/// - **时间窗口** — 刷新成功后 [reuseWindow] 内再次调用直接返回缓存 token +/// - **超时保护** — 刷新回调超过 [timeout] 自动失败,防止死锁 +class TokenRefreshManager { + final OnTokenRefresh? onTokenRefresh; + final OnLog? onLog; + + /// 刷新超时时间(防止 onTokenRefresh 卡住导致所有请求阻塞) + final Duration timeout; + + /// 时间窗口:刷新成功后此时间内再次调用直接返回缓存 token + final Duration reuseWindow; + + /// Token 过期时间解析(App 层注入 JWT exp 解析逻辑) + final OnGetTokenExpiry? onGetTokenExpiry; + + /// 主动刷新阈值:距过期不足此时间时提前刷新(默认 1 小时) + final Duration proactiveRefreshThreshold; + + /// 当前正在进行的刷新任务(null = 空闲) + Completer? _completer; + + /// 上次刷新成功的时间戳 + DateTime? _lastRefreshTime; + + /// 上次刷新成功的 token(时间窗口内复用) + String? _lastToken; + + TokenRefreshManager({ + this.onTokenRefresh, + this.onLog, + this.timeout = const Duration(seconds: 10), + this.reuseWindow = const Duration(seconds: 3), + this.onGetTokenExpiry, + this.proactiveRefreshThreshold = const Duration(hours: 1), + }); + + /// 执行 token 刷新(如果需要) + /// + /// 返回新 token(刷新成功或在时间窗口内), + /// 返回 null = 刷新失败或超时。 + Future refreshIfNeeded() async { + // 1. 时间窗口:最近刷新过且未超时 → 直接返回缓存的 token + if (_isWithinReuseWindow()) { + _log('Token refreshed recently, reusing'); + return _lastToken; + } + + // 2. 有正在进行的刷新 → 等待同一 Completer + final existing = _completer; + if (existing != null) { + _log('Waiting for ongoing token refresh'); + return existing.future; + } + + // 3. 发起新的刷新 + if (onTokenRefresh == null) { + _log('No onTokenRefresh callback configured'); + return null; + } + + final completer = Completer(); + _completer = completer; + + try { + final newToken = await onTokenRefresh!().timeout( + timeout, + onTimeout: () { + _log('Token refresh timed out after ${timeout.inSeconds}s'); + return null; + }, + ); + + final success = newToken != null && newToken.isNotEmpty; + + if (success) { + _lastRefreshTime = DateTime.now(); + _lastToken = newToken; + _log('Token refreshed successfully'); + } else { + _log('Token refresh failed (null or empty token)'); + } + + // 先 complete 再清引用,确保等待者能拿到结果 + completer.complete(success ? newToken : null); + return success ? newToken : null; + } catch (e) { + _log('Token refresh error: $e'); + completer.complete(null); + return null; + } finally { + // 清理引用(Completer 已 complete,等待者不受影响) + _completer = null; + } + } + + /// 检查 token 是否即将过期,是则主动刷新 + /// + /// 解析 [currentToken] 的过期时间,距过期不足 [proactiveRefreshThreshold] + /// 时调用 [refreshIfNeeded] 刷新。复用串行锁和超时保护。 + /// + /// 返回新 token(已刷新)或 null(不需要刷新 / 刷新失败 / 无法解析过期时间)。 + Future proactivelyRefreshIfNeeded(String? currentToken) async { + if (currentToken == null || onGetTokenExpiry == null) return null; + + final expiry = onGetTokenExpiry!(currentToken); + if (expiry == null) return null; + + final remaining = expiry.difference(DateTime.now()); + if (remaining > proactiveRefreshThreshold) { + _log( + 'Token valid (expires in ${remaining.inMinutes}min), skip proactive refresh', + ); + return null; + } + + _log( + 'Token expiring soon (${remaining.inMinutes}min left), proactively refreshing', + ); + return refreshIfNeeded(); + } + + /// 重置状态(登出时调用) + void reset() { + _lastRefreshTime = null; + _lastToken = null; + // 不清理 _completer,让正在等待的请求正常结束 + } + + bool _isWithinReuseWindow() { + final lastTime = _lastRefreshTime; + if (lastTime == null) return false; + return DateTime.now().difference(lastTime) < reuseWindow; + } + + void _log(String message) { + onLog?.call(message, tag: 'TokenRefresh'); + } +} diff --git a/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart b/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart index 9e8a418..a8c887a 100644 --- a/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart +++ b/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart @@ -1,14 +1,25 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:networks_sdk/src/data/datasources/http/api_client.dart'; import 'package:networks_sdk/src/data/dto/api_requestable.dart'; import 'package:networks_sdk/src/domain/entities/api_error.dart'; import 'package:networks_sdk/src/domain/entities/api_request_type.dart'; import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; +import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; import '../../../networks_sdk_platform_interface.dart'; import '../../domain/entities/http_method.dart'; -class NetworksSdkMethodChannelDataSource -{ +/// 网络层数据源 +/// +/// 封装 [ApiClient],提供两种请求入口: +/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式) +/// - [executeDownload] — 带进度的文件下载(支持断点续传) +/// +/// 流式(SSE)请求也走 [executeRequest],由业务 Request 类 override +/// `decodeResponse` 处理 SSE 解析。SDK 内部根据 +/// `requestType == ApiRequestType.stream` 自动切换 `ResponseType.plain`。 +class NetworksSdkMethodChannelDataSource { final NetworksSdkPlatform platform; late ApiClient apiClient; @@ -16,44 +27,51 @@ class NetworksSdkMethodChannelDataSource NetworksSdkMethodChannelDataSource(this.platform); Future getPlatformVersion() async { - return await getPlatformVersion(); + return await platform.getPlatformVersion(); } - void initialize(ApiConfig apiConfig){ + void initialize(ApiConfig apiConfig) { apiClient = ApiClient(config: apiConfig); } - /// 执行 API 请求 — 唯一入口 + // ══════════════════════════════════════════════════════════════════════════ + // 统一请求入口 + // ══════════════════════════════════════════════════════════════════════════ + + /// 执行 API 请求 — 统一入口 /// - /// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求 → 解码响应 → 错误映射 - /// 拦截器负责:header 注入、Token 刷新重试、日志 + /// 支持三种请求类型,由 `request.requestType` 控制行为: + /// - `request` / `login` — 标准 JSON 请求 + /// - `upload` — 文件上传(FormData / 二进制) + /// - `stream` — SSE / chunked,内部用 `ResponseType.plain` 获取原始文本, + /// 由业务 Request 类 override `decodeResponse` 处理 SSE 解析 + /// + /// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求 + /// → 响应变换(可选,stream 类型跳过)→ 解码响应 → 错误映射 + /// + /// 拦截器负责:header 注入、加密/解密、Token 刷新重试、业务错误拦截、日志 /// /// Upload 类型支持两种模式: /// - 自有后端上传:path 为相对路径,自动拼接 baseURL /// - S3 presigned URL:path 以 http 开头,直接使用全路径 - Future executeRequest(ApiRequestable request) async { - // 前置检查:网络不可用时直接抛错,避免无效请求 - if (apiClient.config.onCheckNetworkAvailable != null) { - final available = await apiClient.config.onCheckNetworkAvailable!(); - if (!available) { - apiClient.config.onLog?.call( - 'Network unavailable, abort request: ${request.path}', - tag: 'ApiClient', - ); - throw const ApiError.noNetworkConnection(); - } - } + Future executeRequest( + ApiRequestable request, { + CancelToken? cancelToken, + }) async { + await _checkNetwork(request.path); try { - // Upload 且 path 以 http 开头 → 直接用全路径(S3 presigned URL) - // 否则 → 拼接 baseURL final isUpload = request.requestType == ApiRequestType.upload; + final isStream = request.requestType == ApiRequestType.stream; final path = request.path; - final url = (isUpload && path.startsWith('http')) ? path : '${apiClient.config.baseURL}$path'; + final url = (isUpload && path.startsWith('http')) + ? path + : '${apiClient.config.baseURL}$path'; - // 将请求元数据写入 extra,供拦截器读取 final options = Options( method: request.method.value, + // 流式请求用 plain,Dio 返回原始文本,由 decodeResponse 解析 SSE + responseType: isStream ? ResponseType.plain : null, extra: { 'requestType': request.requestType, 'includeToken': request.includeToken, @@ -62,19 +80,22 @@ class NetworksSdkMethodChannelDataSource ); // 访问 parameters 触发代码生成器的 fromJson 注册 - // (@ApiRequest 生成的 mixin 在 parameters getter 中注册响应类型) final params = request.parameters; - // GET → queryParameters;POST/PUT/DELETE/PATCH → JSON body;Upload → uploadData final isGet = request.method == HttpMethod.get; final response = await apiClient.dio.request( url, data: isUpload ? request.uploadData : (isGet ? null : params), queryParameters: isGet ? params : null, options: options, + cancelToken: cancelToken, ); - // 解码响应(Upload 类型通常需要 override decodeResponse) + // 响应变换:stream 类型由 decodeResponse 自行处理,不做变换 + if (!isStream) { + _applyResponseTransform(response); + } + return request.decodeResponse(response); } on DioException catch (e) { throw apiClient.mapDioError(e); @@ -85,4 +106,184 @@ class NetworksSdkMethodChannelDataSource } } + // ══════════════════════════════════════════════════════════════════════════ + // 文件下载 + // ══════════════════════════════════════════════════════════════════════════ + + /// 下载文件到本地路径 + /// + /// 支持进度回调和断点续传(通过 HTTP Range header 实现)。 + /// + /// 非续传模式直接用 Dio.download(高效,内部流式写入)。 + /// 续传模式用 stream + FileMode.append,因为 Dio.download 始终从 + /// 文件头部写入,无法正确追加到已下载部分之后。 + /// + /// [url] — 下载 URL(完整路径或相对路径,相对路径自动拼接 baseURL) + /// [savePath] — 本地保存路径 + /// [onProgress] — 下载进度回调 + /// [cancelToken] — 取消令牌 + /// [resume] — 是否断点续传(文件已存在时从断点继续下载) + /// [headers] — 额外请求头 + Future executeDownload({ + required String url, + required String savePath, + OnDownloadProgress? onProgress, + CancelToken? cancelToken, + bool resume = false, + Map? headers, + }) async { + await _checkNetwork(url); + + try { + final fullUrl = url.startsWith('http') + ? url + : '${apiClient.config.baseURL}$url'; + + final extraHeaders = {}; + if (headers != null) extraHeaders.addAll(headers); + + // 断点续传:读取已下载部分的大小,设置 Range header + int startBytes = 0; + if (resume) { + final file = File(savePath); + if (file.existsSync()) { + startBytes = file.lengthSync(); + extraHeaders['Range'] = 'bytes=$startBytes-'; + } + } + + if (resume && startBytes > 0) { + // 续传模式:stream + append,确保新数据追加到文件末尾 + await _downloadWithResume( + url: fullUrl, + savePath: savePath, + startBytes: startBytes, + headers: extraHeaders, + onProgress: onProgress, + cancelToken: cancelToken, + ); + } else { + // 普通下载:Dio.download(高效,内部流式写入) + await apiClient.dio.download( + fullUrl, + savePath, + cancelToken: cancelToken, + deleteOnError: true, + options: Options( + headers: extraHeaders.isEmpty ? null : extraHeaders, + extra: { + 'requestType': ApiRequestType.download, + 'includeToken': true, + }, + ), + onReceiveProgress: onProgress != null + ? (received, total) => onProgress(received, total) + : null, + ); + } + } on DioException catch (e) { + throw apiClient.mapDioError(e); + } on ApiError { + rethrow; + } catch (e) { + throw ApiError.unknown(e.toString()); + } + } + + /// 断点续传下载:stream 响应 + FileMode.append + /// + /// Dio.download 内部用 FileMode.write(从头覆盖),无法正确续传。 + /// 这里手动读流并追加写入文件。 + /// + /// 如果服务端不支持 Range 请求(返回 200 而非 206), + /// 自动回退为覆盖写入,防止文件损坏。 + Future _downloadWithResume({ + required String url, + required String savePath, + required int startBytes, + required Map headers, + OnDownloadProgress? onProgress, + CancelToken? cancelToken, + }) async { + final response = await apiClient.dio.get( + url, + cancelToken: cancelToken, + options: Options( + responseType: ResponseType.stream, + headers: headers.isEmpty ? null : headers, + extra: {'requestType': ApiRequestType.download, 'includeToken': true}, + ), + ); + + final stream = response.data?.stream; + if (stream == null) return; + + // 检查服务端是否接受了 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 + effectiveStartBytes + : -1; + + final file = File(savePath); + // 不支持续传时用 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) { + raf.writeFromSync(chunk); + received += chunk.length; + onProgress?.call(received, totalBytes); + } + } finally { + raf.closeSync(); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // 内部辅助 + // ══════════════════════════════════════════════════════════════════════════ + + /// 网络前置检查,不可用时直接抛 [ApiError.noNetworkConnection] + Future _checkNetwork(String path) async { + if (apiClient.config.onCheckNetworkAvailable != null) { + final available = await apiClient.config.onCheckNetworkAvailable!(); + if (!available) { + apiClient.config.onLog?.call( + 'Network unavailable, abort request: $path', + tag: 'ApiClient', + ); + throw const ApiError.noNetworkConnection(); + } + } + } + + /// 应用响应变换(如果 App 层注入了 onTransformResponse) + void _applyResponseTransform(Response response) { + final transform = apiClient.config.onTransformResponse; + if (transform == null) return; + if (response.data is! Map) return; + + final transformed = transform(response.data as Map); + if (transformed != null) { + response.data = transformed; + } + } } diff --git a/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart b/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart index 84f47d0..031c033 100644 --- a/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart +++ b/packages/networks_sdk/lib/src/data/datasources/socket/socket_client.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io' as io; import 'dart:math'; +import 'dart:typed_data'; import 'package:networks_sdk/networks_sdk.dart'; import 'package:web_socket_channel/io.dart'; @@ -9,10 +11,12 @@ import 'package:web_socket_channel/web_socket_channel.dart'; /// WebSocket 长连接客户端 /// /// 提供: -/// - 连接 / 断连 / 自动重连(指数退避) +/// - 连接 / 断连 / 自动重连(指数退避,支持无限重连) /// - 双层心跳(底层 ping + 应用层 heartbeat) -/// - Stream 输出(消息、连接状态、错误) +/// - Stream 输出(JSON 消息、原始字符串、二进制、连接状态、错误) /// - 生命周期感知(前后台切换) +/// - Token 热更新(不断连) +/// - 消息加密/解密钩子(预留给 cipher_guard_sdk,ping/pong 走明文不加密) /// /// ## 使用方式 /// @@ -28,6 +32,9 @@ import 'package:web_socket_channel/web_socket_channel.dart'; /// // 发消息 /// await client.send({'type': 'chat', 'data': {...}}); /// +/// // Token 热更新(不断连,下次重连自动使用新 token) +/// client.updateToken('new_token'); +/// /// // 断连 /// await client.disconnect(); /// ``` @@ -53,11 +60,21 @@ class SocketClient { Timer? _reconnectTimer; final _random = Random(); + // ── 消息处理 ── + + /// 异步消息处理链,保证解密场景下消息按到达顺序处理 + /// + /// 无解密回调时不使用(同步处理,天然有序)。 + /// 有解密回调时,每条消息的处理链在前一条之后执行, + /// 即使解密耗时不同也不会乱序。 + Future? _messageProcessingChain; + // ── Stream Controllers ── final _messageController = StreamController>.broadcast(); final _rawMessageController = StreamController.broadcast(); + final _binaryMessageController = StreamController.broadcast(); final _connectionStateController = - StreamController.broadcast(); + StreamController.broadcast(); final _errorController = StreamController.broadcast(); SocketClient({required this.config}); @@ -93,12 +110,20 @@ class SocketClient { } /// 当前是否已连接 - bool get isConnected => - _connectionState == SocketConnectionState.connected; + bool get isConnected => _connectionState == SocketConnectionState.connected; /// 当前连接状态 SocketConnectionState get connectionState => _connectionState; + /// Token 热更新(不断开连接) + /// + /// 仅更新内部持有的 token,下次重连时自动使用新 token。 + /// 适用于 token 刷新后同步到 WebSocket 的场景。 + void updateToken(String token) { + _currentToken = token; + _log('Token updated (no reconnect)'); + } + // ══════════════════════════════════════════════════════════════════════════ // 公开 API — 发送 // ══════════════════════════════════════════════════════════════════════════ @@ -109,6 +134,8 @@ class SocketClient { } /// 发送原始字符串 + /// + /// 如果配置了 [SocketConfig.onEncryptMessage],发送前自动加密。 Future sendString(String message) async { if (!isConnected || _channel == null) { _emitError(SocketError.sendFailed('Not connected')); @@ -116,7 +143,27 @@ class SocketClient { } try { - _channel!.sink.add(message); + String payload = message; + if (config.onEncryptMessage != null) { + payload = await config.onEncryptMessage!(message); + } + _channel!.sink.add(payload); + return true; + } catch (e) { + _emitError(SocketError.sendFailed(e.toString())); + return false; + } + } + + /// 发送二进制数据 + Future sendBytes(List bytes) async { + if (!isConnected || _channel == null) { + _emitError(SocketError.sendFailed('Not connected')); + return false; + } + + try { + _channel!.sink.add(bytes); return true; } catch (e) { _emitError(SocketError.sendFailed(e.toString())); @@ -134,6 +181,9 @@ class SocketClient { /// 原始字符串消息流(JSON 解析失败的也走这里) Stream get rawMessageStream => _rawMessageController.stream; + /// 二进制消息流 + Stream get binaryMessageStream => _binaryMessageController.stream; + /// 连接状态变化流 Stream get connectionStateStream => _connectionStateController.stream; @@ -171,6 +221,7 @@ class SocketClient { await _doDisconnect(reason: 'Dispose'); await _messageController.close(); await _rawMessageController.close(); + await _binaryMessageController.close(); await _connectionStateController.close(); await _errorController.close(); } @@ -197,24 +248,36 @@ class SocketClient { _log('Connecting to $url'); try { - // 构建最终 URL(拼接 token) - final connectUri = _currentToken != null - ? uri.replace( - queryParameters: { - ...uri.queryParameters, - 'token': _currentToken!, - }, - ) - : uri; + // 构建最终连接 URL + // + // 有 onBuildConnectUrl 回调时,App 层完全控制 URL(路径加密、 + // token 加密、添加 cipher 参数等)。 + // 无回调时使用默认行为:URL 后追加 ?token=xxx。 + final String connectUrlString; - // 创建 WebSocket 连接 - _channel = IOWebSocketChannel.connect( - connectUri, - pingInterval: config.pingInterval, - ); + if (config.onBuildConnectUrl != null) { + connectUrlString = config.onBuildConnectUrl!(url, _currentToken); + } else { + final connectUri = _currentToken != null + ? uri.replace( + queryParameters: { + ...uri.queryParameters, + 'token': _currentToken!, + }, + ) + : uri; + connectUrlString = connectUri.toString(); + } - // 等待连接就绪 - await _channel!.ready.timeout(config.connectTimeout); + // 创建 WebSocket 连接(通过 dart:io 支持压缩选项) + final rawSocket = await io.WebSocket.connect( + connectUrlString, + compression: config.enableCompression + ? io.CompressionOptions.compressionDefault + : io.CompressionOptions.compressionOff, + ).timeout(config.connectTimeout); + rawSocket.pingInterval = config.pingInterval; + _channel = IOWebSocketChannel(rawSocket); _log('Connected'); _updateConnectionState(SocketConnectionState.connected); @@ -271,25 +334,72 @@ class SocketClient { // ══════════════════════════════════════════════════════════════════════════ void _handleMessage(dynamic data) { - if (data is! String) { - // 非字符串消息(如二进制),走 rawMessageStream - _rawMessageController.add(data.toString()); + // 二进制消息不需要解密,直接分发 + if (data is List) { + if (!_binaryMessageController.isClosed) { + _binaryMessageController.add( + data is Uint8List ? data : Uint8List.fromList(data), + ); + } return; } - // 检查 pong 心跳回复 + if (data is! String) { + if (!_rawMessageController.isClosed) { + _rawMessageController.add(data.toString()); + } + return; + } + + // 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 _processTextMessage(String data) async { + // dispose 期间可能有残留的链式任务,直接跳过 + if (_messageController.isClosed) return; + + String text; try { - final json = jsonDecode(data) as Map; - _messageController.add(json); + 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; + if (!_messageController.isClosed) { + _messageController.add(json); + } } catch (_) { // JSON 解析失败,走原始消息流 - _rawMessageController.add(data); + if (!_rawMessageController.isClosed) { + _rawMessageController.add(text); + } } } @@ -332,12 +442,16 @@ class SocketClient { if (_waitingForPong) return; _waitingForPong = true; + + // 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'); @@ -347,6 +461,7 @@ class SocketClient { } void _onPongReceived() { + _log('♥ pong'); _waitingForPong = false; _pongTimeoutTimer?.cancel(); _pongTimeoutTimer = null; @@ -360,7 +475,9 @@ class SocketClient { if (_manualDisconnect || !config.autoReconnect || _isBackground) return; if (_connectionState == SocketConnectionState.reconnecting) return; - if (_reconnectAttempts >= config.maxReconnectAttempts) { + // 非无限重连模式下检查重连次数上限 + if (!config.unlimitedReconnect && + _reconnectAttempts >= config.maxReconnectAttempts) { _log('Max reconnect attempts reached ($_reconnectAttempts)'); _reconnectAttempts = 0; return; @@ -375,11 +492,16 @@ class SocketClient { pow(2, _reconnectAttempts).toInt() * 1000, config.maxReconnectDelay.inMilliseconds, ); - final jitterMs = _random.nextInt((baseDelayMs * 0.25).toInt().clamp(1, 7500)); + final jitterMs = _random.nextInt( + (baseDelayMs * 0.25).toInt().clamp(1, 7500), + ); final delay = Duration(milliseconds: baseDelayMs + jitterMs); - _log('Reconnecting in ${delay.inMilliseconds}ms ' - '(attempt $_reconnectAttempts/${config.maxReconnectAttempts})'); + final attemptsInfo = config.unlimitedReconnect + ? 'attempt $_reconnectAttempts/unlimited' + : 'attempt $_reconnectAttempts/${config.maxReconnectAttempts}'; + + _log('Reconnecting in ${delay.inMilliseconds}ms ($attemptsInfo)'); _reconnectTimer = Timer(delay, () async { // 重连前检查网络 @@ -393,6 +515,19 @@ class SocketClient { } } + // 重连前回调(App 层刷新即将过期的 token 等) + if (config.onBeforeReconnect != null) { + try { + await config.onBeforeReconnect!(); + } catch (e) { + _log('onBeforeReconnect failed: $e, schedule next reconnect'); + // 重置状态以允许下次 _startReconnect 进入(防止卡死在 reconnecting) + _updateConnectionState(SocketConnectionState.disconnected); + _startReconnect(); + return; + } + } + _doConnect(); }); } diff --git a/packages/networks_sdk/lib/src/data/dto/api_requestable.dart b/packages/networks_sdk/lib/src/data/dto/api_requestable.dart index 3ca7c62..18d51a8 100644 --- a/packages/networks_sdk/lib/src/data/dto/api_requestable.dart +++ b/packages/networks_sdk/lib/src/data/dto/api_requestable.dart @@ -4,35 +4,36 @@ import 'package:networks_sdk/src/domain/entities/api_error.dart'; import 'package:networks_sdk/src/domain/entities/api_request_type.dart'; import 'package:networks_sdk/src/domain/entities/http_method.dart'; - /// API 请求基类 /// -/// 使用侧只需:字段 + path + method,其余全部有默认实现。 +/// 只需 `@ApiRequest` 一个注解,声明字段和构造函数即可: +/// - `toJson()` 由 mixin 自动生成(只序列化类自身字段,不含继承属性) +/// - `path / method / requestType / includeToken` 由 mixin 自动提供 +/// - Response 的 fromJson 在 mixin 的 `parameters` getter 中自动注册 +/// - **不需要** `@JsonSerializable`,**不需要** 手写 `fromJson` /// /// ```dart -/// @JsonSerializable() -/// class LoginRequest extends ApiRequestable { +/// @ApiRequest( +/// path: ApiPaths.authLogin, +/// method: HttpMethod.post, +/// responseType: LoginData, +/// requestType: ApiRequestType.login, +/// ) +/// class LoginRequest extends ApiRequestable +/// with _$LoginRequestApi { /// final String email; /// final String password; /// /// LoginRequest({required this.email, required this.password}); -/// -/// factory LoginRequest.fromJson(Map json) => -/// _$LoginRequestFromJson(json); -/// @override -/// Map toJson() => _$LoginRequestToJson(this); -/// -/// @override -/// String get path => '/auth/login'; -/// @override -/// HttpMethod get method => HttpMethod.post; -/// @override -/// bool get includeToken => false; +/// // 完毕!一行样板代码都不用写 /// } -/// -/// // 文件顶层注册一次(一行) -/// final _reg = registerResponse(LoginData.fromJson); /// ``` +/// +/// 字段名映射:在字段上加 `@JsonKey(name: 'server_name')` 即可, +/// 生成器会读取并使用该名称作为 JSON 键。 +/// +/// 特殊请求(如 upload):在类中 override `toJson()` 即可, +/// 类的 override 优先于 mixin。 abstract class ApiRequestable { /// API 路径(如 '/auth/login') String get path; @@ -40,8 +41,8 @@ abstract class ApiRequestable { /// HTTP 方法 HttpMethod get method; - /// 序列化为 JSON(由 @JsonSerializable 自动生成) - /// 子类 override 返回 `_$XxxToJson(this)` 即可 + /// 序列化为 JSON(由 @ApiRequest 生成器在 mixin 中自动生成) + /// Upload 等特殊请求可在类中 override 返回空 map Map toJson(); /// 请求参数 — 默认调用 toJson(),upload 类型返回 null @@ -95,7 +96,7 @@ abstract class ApiRequestable { if (fromJsonFunc == null) { throw StateError( 'fromJson not registered for type $T. ' - 'Add: final _reg = registerResponse<$T>($T.fromJson);', + 'Add: final _reg = registerResponse<$T>($T.fromJson);', ); } @@ -106,13 +107,17 @@ abstract class ApiRequestable { final mapFunc = fromJsonFunc as T Function(Map); return mapFunc(json); } - throw FormatException('Expected Map, got ${json.runtimeType}',); + throw FormatException( + 'Expected Map, got ${json.runtimeType}', + ); } final wrapper = ApiResponseWrapper.fromJson(data, fromJsonObject); - // 业务错误码检查 - if (wrapper.code != 0) { + // 业务错误码检查(RetryInterceptor 已处理的跳过,防止双重抛错) + final handledByInterceptor = + response.requestOptions.extra['_businessErrorHandled'] == true; + if (wrapper.code != 0 && !handledByInterceptor) { throw ApiError.apiError( code: wrapper.code, message: wrapper.message ?? 'API error (code: ${wrapper.code})', @@ -141,8 +146,9 @@ final fromJsonRegistry = {}; /// ```dart /// final _reg = registerResponse(LoginData.fromJson); /// ``` -T Function(Map)? registerResponse(T Function(Map) fromJson,) -{ +T Function(Map)? registerResponse( + T Function(Map) fromJson, +) { fromJsonRegistry[T] = fromJson; return fromJson; } @@ -152,9 +158,7 @@ T Function(Map)? registerResponse(T Function(Map(DeviceList.fromJson); /// ``` -T Function(Object?)? registerResponseObject( - T Function(Object?) fromJson, - ) { +T Function(Object?)? registerResponseObject(T Function(Object?) fromJson) { fromJsonRegistry[T] = fromJson; return fromJson; } diff --git a/packages/networks_sdk/lib/src/data/dto/api_response_wrapper.dart b/packages/networks_sdk/lib/src/data/dto/api_response_wrapper.dart index 66dcdf4..ddf7706 100644 --- a/packages/networks_sdk/lib/src/data/dto/api_response_wrapper.dart +++ b/packages/networks_sdk/lib/src/data/dto/api_response_wrapper.dart @@ -1,20 +1,16 @@ -/// API 响应信封解析器 +/// API 响应包装解析器 /// 统一处理 { code, message/msg, data } 格式的服务器响应 class ApiResponseWrapper { final int code; final String? message; final T? data; - const ApiResponseWrapper({ - required this.code, - this.message, - this.data, - }); + const ApiResponseWrapper({required this.code, this.message, this.data}); factory ApiResponseWrapper.fromJson( - Map json, - T Function(Object?) fromJsonT, - ) { + Map json, + T Function(Object?) fromJsonT, + ) { // code 字段:兼容 int 和 String final int codeValue; if (json['code'] is int) { @@ -28,8 +24,7 @@ class ApiResponseWrapper { } // message 字段:兼容 message 和 msg - final message = - json['message'] as String? ?? json['msg'] as String?; + final message = json['message'] as String? ?? json['msg'] as String?; // 解码 data(null-safe:logout / delete 等接口可能无 data) final rawData = json['data']; diff --git a/packages/networks_sdk/lib/src/data/repositories/networks_messaging_repository_impl.dart b/packages/networks_sdk/lib/src/data/repositories/networks_messaging_repository_impl.dart index 9069565..8663c42 100644 --- a/packages/networks_sdk/lib/src/data/repositories/networks_messaging_repository_impl.dart +++ b/packages/networks_sdk/lib/src/data/repositories/networks_messaging_repository_impl.dart @@ -1,10 +1,12 @@ +import 'dart:typed_data'; + import 'package:networks_sdk/src/data/datasources/socket/socket_client.dart'; import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart'; import 'package:networks_sdk/src/domain/entities/socket_error.dart'; import 'package:networks_sdk/src/domain/repositories/networks_messaging_repository.dart'; import 'package:networks_sdk/src/presentation/wiring/socket_config.dart'; -/// Messaging Repository Implementation (Data) +/// [NetworksMessagingRepository] 的实现,透传给 [SocketClient] class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository { SocketClient? _socketClient; bool _isInitialized = false; @@ -47,6 +49,12 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository { return _socketClient!.connectionState; } + @override + void updateToken(String token) { + _checkInitialized(); + _socketClient!.updateToken(token); + } + @override Future send(Map message) { _checkInitialized(); @@ -59,6 +67,12 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository { return _socketClient!.sendString(message); } + @override + Future sendBytes(List bytes) { + _checkInitialized(); + return _socketClient!.sendBytes(bytes); + } + @override Stream> get messageStream { _checkInitialized(); @@ -71,6 +85,12 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository { return _socketClient!.rawMessageStream; } + @override + Stream get binaryMessageStream { + _checkInitialized(); + return _socketClient!.binaryMessageStream; + } + @override Stream get connectionStateStream { _checkInitialized(); @@ -104,4 +124,3 @@ class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository { _isInitialized = false; } } - diff --git a/packages/networks_sdk/lib/src/data/repositories/networks_sdk_repository_impl.dart b/packages/networks_sdk/lib/src/data/repositories/networks_sdk_repository_impl.dart index a9d6318..b255647 100644 --- a/packages/networks_sdk/lib/src/data/repositories/networks_sdk_repository_impl.dart +++ b/packages/networks_sdk/lib/src/data/repositories/networks_sdk_repository_impl.dart @@ -1,13 +1,15 @@ -//Repository Impl +// Repository Impl +import 'package:dio/dio.dart'; import 'package:networks_sdk/src/data/dto/api_requestable.dart'; import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; +import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; import '../../domain/repositories/networks_sdk_repository.dart'; import '../datasources/networks_sdk_method_channel_datasource.dart'; -class NetworksSdkRepositoryImpl implements NetworksSdkRepository -{ +/// [NetworksSdkRepository] 的实现,透传给 [NetworksSdkMethodChannelDataSource] +class NetworksSdkRepositoryImpl implements NetworksSdkRepository { final NetworksSdkMethodChannelDataSource _datasource; const NetworksSdkRepositoryImpl(this._datasource); @@ -18,12 +20,34 @@ class NetworksSdkRepositoryImpl implements NetworksSdkRepository } @override - void initialize(ApiConfig apiConfig){ - _datasource.initialize(apiConfig); + void initialize(ApiConfig apiConfig) { + _datasource.initialize(apiConfig); } @override - Future executeRequest(ApiRequestable request) { - return _datasource.executeRequest(request); + Future executeRequest( + ApiRequestable request, { + CancelToken? cancelToken, + }) { + return _datasource.executeRequest(request, cancelToken: cancelToken); + } + + @override + Future executeDownload({ + required String url, + required String savePath, + OnDownloadProgress? onProgress, + CancelToken? cancelToken, + bool resume = false, + Map? headers, + }) { + return _datasource.executeDownload( + url: url, + savePath: savePath, + onProgress: onProgress, + cancelToken: cancelToken, + resume: resume, + headers: headers, + ); } } diff --git a/packages/networks_sdk/lib/src/domain/entities/api_error.dart b/packages/networks_sdk/lib/src/domain/entities/api_error.dart index 302c39e..f8beca3 100644 --- a/packages/networks_sdk/lib/src/domain/entities/api_error.dart +++ b/packages/networks_sdk/lib/src/domain/entities/api_error.dart @@ -13,6 +13,9 @@ class ApiError with _$ApiError implements Exception { required int code, required String message, }) = _ApiError; + + /// 请求被 CancelToken 取消 + const factory ApiError.cancelled() = _Cancelled; const factory ApiError.unknown(String? message) = _Unknown; } @@ -25,7 +28,8 @@ extension ApiErrorExtension on ApiError { networkError: (message) => 'Network error: $message', decodingError: (message) => 'Decoding error: $message', apiError: (code, message) => message, + cancelled: () => 'Request cancelled', unknown: (message) => message ?? 'Unknown error', ); } -} \ No newline at end of file +} diff --git a/packages/networks_sdk/lib/src/domain/entities/api_request_type.dart b/packages/networks_sdk/lib/src/domain/entities/api_request_type.dart index dd41078..864c07e 100644 --- a/packages/networks_sdk/lib/src/domain/entities/api_request_type.dart +++ b/packages/networks_sdk/lib/src/domain/entities/api_request_type.dart @@ -8,4 +8,13 @@ enum ApiRequestType { /// 文件上传(multipart,不序列化 parameters) upload, + + /// 流式请求(SSE / chunked) + /// + /// SDK 内部自动切换 `ResponseType.plain`,Dio 返回原始文本。 + /// 业务 Request 类 override `decodeResponse` 处理 SSE 解析。 + stream, + + /// 文件下载(带进度回调,支持断点续传) + download, } diff --git a/packages/networks_sdk/lib/src/domain/entities/encrypted_request.dart b/packages/networks_sdk/lib/src/domain/entities/encrypted_request.dart new file mode 100644 index 0000000..595185d --- /dev/null +++ b/packages/networks_sdk/lib/src/domain/entities/encrypted_request.dart @@ -0,0 +1,55 @@ +/// HTTP 请求加密结果 +/// +/// 加密回调返回此对象,[EncryptionInterceptor] 根据非 null 字段覆盖原始请求。 +/// 未设置的字段保持原值不变。 +/// +/// 设计依据:HTTP 加密模式下,加密后需要同时修改: +/// - 路径(原文 path 加密为 hex 编码) +/// - 请求体(JSON body 加密为 base64 字符串,不再是 Map) +/// - Headers(添加 X-Token、X-Signature、secret-key 等加密 header) +/// - Content-Type(JSON → text/plain) +/// +/// ```dart +/// // 加密回调返回示例 +/// EncryptedRequest( +/// path: '/api/${hexEncode(encrypt(originalPath))}', +/// body: base64Encode(encrypt(jsonBody)), +/// headers: { +/// 'X-Token': encryptedToken, +/// 'X-Signature': signature, +/// 'secret-key': clientPublicKey, +/// }, +/// contentType: 'text/plain', +/// ) +/// ``` +class EncryptedRequest { + /// 加密后的路径 + /// + /// null 表示不修改路径。 + /// 如需加密,拦截器会用此值替换 `RequestOptions.path`。 + final String? path; + + /// 加密后的请求体 + /// + /// null 表示不修改 body。 + /// 类型不限于 Map — 加密后通常是 base64 字符串或 bytes。 + final Object? body; + + /// 需要添加或覆盖的 headers + /// + /// null 表示不修改 headers。 + /// 拦截器会将这些 header 合并到请求中(覆盖同名 header)。 + final Map? headers; + + /// 覆盖 Content-Type + /// + /// null 表示不修改。加密后通常是 `text/plain`(body 已是字符串,非 JSON)。 + final String? contentType; + + const EncryptedRequest({ + this.path, + this.body, + this.headers, + this.contentType, + }); +} diff --git a/packages/networks_sdk/lib/src/domain/repositories/networks_messaging_repository.dart b/packages/networks_sdk/lib/src/domain/repositories/networks_messaging_repository.dart index b92ff95..6102725 100644 --- a/packages/networks_sdk/lib/src/domain/repositories/networks_messaging_repository.dart +++ b/packages/networks_sdk/lib/src/domain/repositories/networks_messaging_repository.dart @@ -1,49 +1,45 @@ +import 'dart:typed_data'; + import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart'; import 'package:networks_sdk/src/domain/entities/socket_error.dart'; import 'package:networks_sdk/src/presentation/wiring/socket_config.dart'; -/// Messaging Repository Interface (Domain) +/// Messaging Repository 接口 abstract class NetworksMessagingRepository { - /// Initialize with config void initialize(SocketConfig config); - /// Connect to messaging server Future connect(String url, {String? token}); - /// Disconnect from server Future disconnect(); - /// Check if connected bool get isConnected; - /// Current connection state SocketConnectionState get connectionState; - /// Send a JSON message + /// Token 热更新(不断连) + void updateToken(String token); + Future send(Map message); - /// Send a raw string message Future sendString(String message); - /// Stream of incoming parsed JSON messages + /// 发送二进制数据 + Future sendBytes(List bytes); + Stream> get messageStream; - /// Stream of raw string messages Stream get rawMessageStream; - /// Stream of connection state changes + /// 二进制消息流 + Stream get binaryMessageStream; + Stream get connectionStateStream; - /// Stream of errors Stream get errorStream; - /// Called when app enters foreground void onEnterForeground(); - /// Called when app enters background void onEnterBackground(); - /// Dispose all resources Future dispose(); } - diff --git a/packages/networks_sdk/lib/src/domain/repositories/networks_sdk_repository.dart b/packages/networks_sdk/lib/src/domain/repositories/networks_sdk_repository.dart index 4826893..0aa3e6c 100644 --- a/packages/networks_sdk/lib/src/domain/repositories/networks_sdk_repository.dart +++ b/packages/networks_sdk/lib/src/domain/repositories/networks_sdk_repository.dart @@ -1,12 +1,32 @@ // Repository Interface(Domain) +import 'package:dio/dio.dart'; import 'package:networks_sdk/src/data/dto/api_requestable.dart'; import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; +import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; +/// 网络层 Repository 接口 +/// +/// 定义两种请求入口: +/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式) +/// - [executeDownload] — 带进度的文件下载(支持断点续传) abstract class NetworksSdkRepository { Future platformVersion(); void initialize(ApiConfig apiConfig); - Future executeRequest(ApiRequestable request); -} \ No newline at end of file + Future executeRequest( + ApiRequestable request, { + CancelToken? cancelToken, + }); + + /// 文件下载(支持进度回调和断点续传) + Future executeDownload({ + required String url, + required String savePath, + OnDownloadProgress? onProgress, + CancelToken? cancelToken, + bool resume = false, + Map? headers, + }); +} diff --git a/packages/networks_sdk/lib/src/generator/api_request_generator.dart b/packages/networks_sdk/lib/src/generator/api_request_generator.dart index b68a2ea..d6863ac 100644 --- a/packages/networks_sdk/lib/src/generator/api_request_generator.dart +++ b/packages/networks_sdk/lib/src/generator/api_request_generator.dart @@ -4,35 +4,91 @@ import 'package:networks_sdk/src/annotations/api_request.dart'; import 'package:source_gen/source_gen.dart'; import 'package:build/build.dart'; +/// @JsonKey 检测器(用于读取字段的 JSON 键名映射) +const _jsonKeyChecker = TypeChecker.fromUrl( + 'package:json_annotation/src/json_key.dart#JsonKey', +); /// @ApiRequest 代码生成器 /// /// 为标注了 `@ApiRequest` 的类自动生成 mixin,提供: /// - `path`, `method`, `requestType`, `includeToken` 协议实现 -/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发, -/// 保证首次请求前完成注册,无需手动调用 `registerApiResponses()`) +/// - `toJson()` — 从类的声明字段自动生成,只序列化自身字段, +/// 不含 ApiRequestable 的继承属性,避免递归 +/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发) /// -/// 生成的 mixin 命名规则:`_$Api` +/// 支持 `@JsonKey(name: '...')` 字段重命名。 +/// 如有 `@JsonKey(includeToJson: false)` 则跳过该字段。 +/// +/// ## 使用模式(有响应数据) /// -/// 示例输出: /// ```dart -/// mixin _$LoginRequestApi on ApiRequestable { +/// @ApiRequest( +/// path: ApiPaths.authLogin, +/// method: HttpMethod.post, +/// responseType: LoginResponse, +/// requestType: ApiRequestType.login, +/// ) +/// class LoginRequest extends ApiRequestable +/// with _$LoginRequestApi { +/// final String email; +/// final String password; +/// +/// LoginRequest({required this.email, required this.password}); +/// // 完毕!toJson / path / method / fromJson 注册全部由 mixin 自动生成 +/// } +/// ``` +/// +/// ## 使用模式(无响应数据) +/// +/// ```dart +/// @ApiRequest( +/// path: ApiPaths.authLogout, +/// method: HttpMethod.post, +/// ) +/// class LogoutRequest extends ApiRequestable +/// with _$LogoutRequestApi { +/// LogoutRequest(); +/// // responseType 省略 → mixin 跳过 fromJson 注册 +/// } +/// ``` +/// +/// ## mixin 命名规则 +/// +/// `_$Api` +/// +/// ## 生成示例(有响应数据) +/// +/// ```dart +/// mixin _$LoginRequestApi on ApiRequestable { /// @override String get path => '/auth/login'; /// @override HttpMethod get method => HttpMethod.post; /// @override ApiRequestType get requestType => ApiRequestType.login; /// @override bool get includeToken => false; /// @override +/// Map toJson() => { +/// 'email': (this as LoginRequest).email, +/// 'password': (this as LoginRequest).password, +/// }; +/// @override /// Map? get parameters { -/// registerResponse(LoginData.fromJson); +/// registerResponse(_$LoginResponseFromJson); /// return super.parameters; /// } /// } /// ``` -class ApiRequestGenerator extends GeneratorForAnnotation -{ +/// +/// ## Upload 等特殊请求 +/// +/// 如需自定义 toJson(如 upload 返回空 map),在类中 override 即可, +/// 类的 override 优先于 mixin。 +class ApiRequestGenerator extends GeneratorForAnnotation { @override - String generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep,) - { + String generateForAnnotatedElement( + Element element, + ConstantReader annotation, + BuildStep buildStep, + ) { if (element is! ClassElement) { throw InvalidGenerationSourceError( '@ApiRequest can only be applied to classes.', @@ -40,7 +96,7 @@ class ApiRequestGenerator extends GeneratorForAnnotation ); } - final className = element.name; + final className = element.name!; final path = annotation.read('path').stringValue; // 读取 HttpMethod 枚举值 @@ -49,9 +105,13 @@ class ApiRequestGenerator extends GeneratorForAnnotation 'post', ); - // 读取 responseType(用于泛型绑定 + 自动注册 fromJson) - final responseType = annotation.read('responseType').typeValue; - final responseTypeName = responseType.getDisplayString(); + // 读取 responseType(可选:null 表示 void 响应,无需注册 fromJson) + final responseTypePeek = annotation.peek('responseType'); + final bool hasResponseType = + responseTypePeek != null && !responseTypePeek.isNull; + final String responseTypeName = hasResponseType + ? responseTypePeek.typeValue.getDisplayString() + : 'void'; // 读取 ApiRequestType 枚举值 final requestTypeName = _readEnumName( @@ -68,6 +128,17 @@ class ApiRequestGenerator extends GeneratorForAnnotation includeToken = requestTypeName != 'login'; } + // 从类的声明字段生成 toJson() + final toJsonBody = _buildToJsonBody(element, className); + + // 有响应类型:parameters getter 中注册 fromJson(使用生成的私有函数) + // ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson,同 library 可访问 + // 无响应类型(void):跳过注册,直接返回 super.parameters + final parametersBody = hasResponseType + ? ''' registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson); + return super.parameters;''' + : ' return super.parameters;'; + return ''' /// Generated by @ApiRequest for [$className] mixin _\$${className}Api on ApiRequestable<$responseTypeName> { @@ -80,14 +151,55 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> { @override bool get includeToken => $includeToken; @override + Map toJson() => $toJsonBody; + @override Map? get parameters { - registerResponse<$responseTypeName>($responseTypeName.fromJson); - return super.parameters; +$parametersBody } } '''; } + /// 从类的声明字段构建 toJson() 方法体 + /// + /// 只读取类自身声明的实例字段(非 static、非 synthetic), + /// 不含继承自 ApiRequestable 的属性,避免递归。 + /// 支持 @JsonKey(name: '...') 字段重命名, + /// 以及 @JsonKey(includeToJson: false) 跳过字段。 + String _buildToJsonBody(ClassElement element, String className) { + final fields = element.fields + .where((f) => !f.isStatic && !f.isSynthetic) + .toList(); + + if (fields.isEmpty) { + return '{}'; + } + + final entries = []; + for (final field in fields) { + // 检查 @JsonKey 注解 + final jsonKeyAnnotation = _jsonKeyChecker.firstAnnotationOfExact(field); + + // @JsonKey(includeToJson: false) → 跳过 + final includeToJson = jsonKeyAnnotation + ?.getField('includeToJson') + ?.toBoolValue(); + if (includeToJson == false) continue; + + // JSON 键名:@JsonKey(name: '...') 或字段名 + final jsonName = + jsonKeyAnnotation?.getField('name')?.toStringValue() ?? field.name; + + entries.add("'$jsonName': (this as $className).${field.name}"); + } + + if (entries.isEmpty) { + return '{}'; + } + + return '{${entries.join(', ')}}'; + } + /// 从 DartObject 提取枚举常量名称 String _readEnumName(dynamic dartObject, String defaultValue) { final index = dartObject.getField('index')?.toIntValue(); @@ -105,4 +217,4 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> { return defaultValue; } -} \ No newline at end of file +} diff --git a/packages/networks_sdk/lib/src/generator/api_response_generator.dart b/packages/networks_sdk/lib/src/generator/api_response_generator.dart new file mode 100644 index 0000000..946b920 --- /dev/null +++ b/packages/networks_sdk/lib/src/generator/api_response_generator.dart @@ -0,0 +1,205 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:build/build.dart'; + +/// @JsonKey 检测器(与 ApiRequestGenerator 共用相同策略) +const _jsonKeyChecker = TypeChecker.fromUrl( + 'package:json_annotation/src/json_key.dart#JsonKey', +); + +/// @ApiRequest 检测器(复用,用于找到所有 responseType 入口) +const _apiRequestChecker = TypeChecker.fromUrl( + 'package:networks_sdk/src/annotations/api_request.dart#ApiRequest', +); + +/// Response 反序列化代码生成器 +/// +/// 从同一 library 中的 `@ApiRequest(responseType: T)` 声明出发, +/// 自动生成 `_$TFromJson` 私有函数,无需在 Response 类上添加任何注解。 +/// +/// ## 设计原理 +/// +/// App 层只需标注 Request 类: +/// ```dart +/// @ApiRequest(path: '...', responseType: LoginResponse) +/// class LoginRequest extends ApiRequestable +/// with _$LoginRequestApi { ... } +/// ``` +/// +/// 生成器从 `responseType` 出发,递归找到所有嵌套自定义类型, +/// 在同一 `.g.dart` 中生成所有 `_$XFromJson` 私有函数。 +/// +/// Response 类体本身是纯 Dart 类,**无需任何注解,无需 factory fromJson**: +/// ```dart +/// class LoginResponse { +/// @JsonKey(name: 'access_token') +/// final String accessToken; +/// final LoginProfile profile; +/// // 完毕!fromJson 由生成器自动提供 +/// } +/// ``` +/// +/// ## 生成示例 +/// +/// ```dart +/// LoginProfile _$LoginProfileFromJson(Map json) { +/// return LoginProfile( +/// uid: json['uid'] as int, +/// uuid: json['uuid'] as String, +/// ); +/// } +/// +/// LoginResponse _$LoginResponseFromJson(Map json) { +/// return LoginResponse( +/// accessToken: json['access_token'] as String, +/// profile: _$LoginProfileFromJson(json['profile'] as Map), +/// ); +/// } +/// ``` +/// +/// ## 支持的字段类型 +/// +/// - 基础类型(String / int / bool / double / num) +/// - 可空类型(String? / int? / 自定义类?) +/// - 嵌套对象(自动递归,在同一 `.g.dart` 中生成被依赖类的函数) +/// - `@JsonKey(name: '...')` 字段重命名 +/// - `@JsonKey(includeFromJson: false)` 跳过字段 +class ApiResponseGenerator extends Generator { + @override + String generate(LibraryReader library, BuildStep buildStep) { + final buffer = StringBuffer(); + // 跟踪已生成的类,避免同一 library 中多个 Request 引用同一 Response 类时重复生成 + final generated = {}; + + for (final annotated in library.annotatedWith(_apiRequestChecker)) { + final annotation = annotated.annotation; + final responseTypePeek = annotation.peek('responseType'); + if (responseTypePeek == null || responseTypePeek.isNull) continue; + + final responseType = responseTypePeek.typeValue; + if (responseType is! InterfaceType) continue; + + final responseClass = responseType.element; + if (responseClass is! ClassElement) continue; + + // 只生成属于当前 library 的类(跨包类型跳过) + if (responseClass.library != library.element) continue; + + _generateRecursive(responseClass, library, generated, buffer); + } + + return buffer.toString(); + } + + /// 递归生成:先生成被依赖的嵌套类型,再生成当前类型(保证引用顺序) + void _generateRecursive( + ClassElement element, + LibraryReader library, + Set generated, + StringBuffer buffer, + ) { + final typeName = element.name!; + if (generated.contains(typeName)) return; + generated.add(typeName); + + // 先递归处理嵌套的自定义类型 + // InterfaceType 的 element 在 nullable 和非 nullable 情况下指向同一个 ClassElement + for (final field in element.fields.where( + (f) => !f.isStatic && !f.isSynthetic, + )) { + final fieldType = field.type; + if (fieldType is InterfaceType) { + final fieldClass = fieldType.element; + if (fieldClass is ClassElement && + fieldClass.library == library.element && + !_isPrimitive(fieldClass.name!)) { + _generateRecursive(fieldClass, library, generated, buffer); + } + } + } + + final params = _buildConstructorParams(element); + buffer.write(''' +/// Generated by ApiResponseGenerator for [$typeName] +$typeName _\$${typeName}FromJson(Map json) { + return $typeName( +$params ); +} +'''); + } + + /// 判断是否为不需要递归生成的基础类型 + bool _isPrimitive(String typeName) { + const primitives = { + 'String', + 'int', + 'double', + 'bool', + 'num', + 'dynamic', + 'Object', + 'List', + 'Map', + 'Set', + 'Iterable', + }; + return primitives.contains(typeName); + } + + /// 从类的字段生成构造函数参数列表 + String _buildConstructorParams(ClassElement element) { + final fields = element.fields + .where((f) => !f.isStatic && !f.isSynthetic) + .toList(); + + final lines = []; + for (final field in fields) { + final jsonKeyAnnotation = _jsonKeyChecker.firstAnnotationOfExact(field); + + // @JsonKey(includeFromJson: false) → 跳过 + final includeFromJson = jsonKeyAnnotation + ?.getField('includeFromJson') + ?.toBoolValue(); + if (includeFromJson == false) continue; + + final fieldName = field.name!; + final jsonName = + jsonKeyAnnotation?.getField('name')?.toStringValue() ?? fieldName; + + final type = field.type; + final isNullable = type.nullabilitySuffix == NullabilitySuffix.question; + final expr = _castExpression(type, jsonName, isNullable); + + lines.add(' $fieldName: $expr,'); + } + + return lines.join('\n'); + } + + /// 根据字段类型生成 JSON 取值表达式 + String _castExpression(DartType type, String jsonKey, bool isNullable) { + final q = isNullable ? '?' : ''; + final access = "json['$jsonKey']"; + + // 基础类型:直接 as 转换 + if (type.isDartCoreString) return '$access as String$q'; + if (type.isDartCoreInt) return '$access as int$q'; + if (type.isDartCoreBool) return '$access as bool$q'; + if (type.isDartCoreDouble) return '$access as double$q'; + if (type.isDartCoreNum) return '$access as num$q'; + + // 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数 + if (type is InterfaceType) { + final typeName = type.element.name!; + if (isNullable) { + return '$access == null ? null : _\$${typeName}FromJson($access as Map)'; + } + return '_\$${typeName}FromJson($access as Map)'; + } + + // 兜底 + return '$access as dynamic'; + } +} diff --git a/packages/networks_sdk/lib/src/generator/builder.dart b/packages/networks_sdk/lib/src/generator/builder.dart index 0c91ae3..d3f64b4 100644 --- a/packages/networks_sdk/lib/src/generator/builder.dart +++ b/packages/networks_sdk/lib/src/generator/builder.dart @@ -2,11 +2,16 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'api_request_generator.dart'; +import 'api_response_generator.dart'; -/// @ApiRequest 代码生成器入口 +/// @ApiRequest / @ApiResponse 代码生成器入口 /// /// 在 `build.yaml` 中注册此 builder,配合 `build_runner` 使用。 -/// 生成的代码通过 `SharedPartBuilder` 合并到 `.g.dart` 文件中, -/// 与 `json_serializable` 等生成器共存。 -Builder apiRequestBuilder(BuilderOptions options) => - SharedPartBuilder([ApiRequestGenerator()], 'api_request'); +/// 生成的代码通过 `SharedPartBuilder` 合并到 `.g.dart` 文件中。 +/// +/// - `ApiRequestGenerator`:为 `@ApiRequest` 生成 Request mixin(toJson + path/method 等) +/// - `ApiResponseGenerator`:从 `@ApiRequest(responseType: T)` 推导,生成 Response 的所有 `_$XFromJson` 函数 +Builder apiRequestBuilder(BuilderOptions options) => SharedPartBuilder([ + ApiRequestGenerator(), + ApiResponseGenerator(), +], 'api_request'); diff --git a/packages/networks_sdk/lib/src/presentation/facade/networks_messaging_api.dart b/packages/networks_sdk/lib/src/presentation/facade/networks_messaging_api.dart index 73348d5..d816d0c 100644 --- a/packages/networks_sdk/lib/src/presentation/facade/networks_messaging_api.dart +++ b/packages/networks_sdk/lib/src/presentation/facade/networks_messaging_api.dart @@ -1,92 +1,75 @@ +import 'dart:typed_data'; + import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart'; import 'package:networks_sdk/src/domain/entities/socket_error.dart'; import 'package:networks_sdk/src/presentation/wiring/networks_sdk_wiring.dart'; import 'package:networks_sdk/src/presentation/wiring/socket_config.dart'; -/// Messaging API for real-time communication +/// 实时通信公开接口 /// -/// This abstract class provides a technology-agnostic interface for -/// real-time messaging. The actual implementation may use WebSocket -/// or other transport mechanisms. +/// 底层基于 WebSocket,支持 JSON / 字符串 / 二进制消息、 +/// 自动重连(含无限重连)、Token 热更新、消息加密/解密钩子。 /// -/// ## Usage +/// ## 使用方式 /// /// ```dart /// final messaging = NetworksMessagingApi(); /// await messaging.initialize(SocketConfig(...)); /// -/// // Connect to messaging server /// await messaging.connect('wss://api.example.com/ws', token: 'xxx'); /// -/// // Listen for messages /// messaging.messageStream.listen((msg) => print(msg)); /// -/// // Send messages /// await messaging.send({'type': 'chat', 'data': {...}}); /// -/// // Handle connection state -/// messaging.connectionStateStream.listen((state) => ...); +/// // Token 热更新(不断连) +/// messaging.updateToken('new_token'); /// -/// // Handle errors -/// messaging.errorStream.listen((error) => ...); +/// // 发送二进制 +/// await messaging.sendBytes(Uint8List.fromList([0x01, 0x02])); /// -/// // Lifecycle management -/// messaging.onEnterForeground(); -/// messaging.onEnterBackground(); -/// -/// // Cleanup /// await messaging.disconnect(); /// await messaging.dispose(); /// ``` -abstract class NetworksMessagingApi -{ +abstract class NetworksMessagingApi { factory NetworksMessagingApi() => NetworksSdkWiring.buildMessagingApi(); - /// Initialize the messaging service with configuration void initialize(SocketConfig config); - /// Connect to the messaging server - /// - /// [url] - WebSocket URL (e.g., 'wss://api.example.com/ws') - /// [token] - Optional authentication token Future connect(String url, {String? token}); - /// Disconnect from the messaging server - /// - /// Manual disconnect does not trigger auto-reconnect Future disconnect(); - /// Check if currently connected bool get isConnected; - /// Current connection state SocketConnectionState get connectionState; - /// Send a JSON message + /// Token 热更新(不断开连接) + /// + /// 仅更新内部 token,下次重连自动使用新 token。 + void updateToken(String token); + Future send(Map message); - /// Send a raw string message Future sendString(String message); - /// Stream of incoming parsed JSON messages + /// 发送二进制数据 + Future sendBytes(List bytes); + Stream> get messageStream; - /// Stream of raw string messages (including failed JSON parses) Stream get rawMessageStream; - /// Stream of connection state changes + /// 二进制消息流 + Stream get binaryMessageStream; + Stream get connectionStateStream; - /// Stream of errors Stream get errorStream; - /// Called when app enters foreground void onEnterForeground(); - /// Called when app enters background void onEnterBackground(); - /// Dispose all resources Future dispose(); } - diff --git a/packages/networks_sdk/lib/src/presentation/facade/networks_sdk_api.dart b/packages/networks_sdk/lib/src/presentation/facade/networks_sdk_api.dart index 1a949ff..72c497d 100644 --- a/packages/networks_sdk/lib/src/presentation/facade/networks_sdk_api.dart +++ b/packages/networks_sdk/lib/src/presentation/facade/networks_sdk_api.dart @@ -1,19 +1,69 @@ - - - +import 'package:dio/dio.dart'; import 'package:networks_sdk/src/data/dto/api_requestable.dart'; import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; +import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; import 'package:networks_sdk/src/presentation/wiring/networks_sdk_wiring.dart'; - -/// SDK API -abstract class NetworksSdkApi -{ +/// Networks SDK 公开接口 +/// +/// 提供两种请求入口: +/// - [executeRequest] — 统一请求入口(标准 / Upload / 流式) +/// - [executeDownload] — 带进度的文件下载(支持断点续传) +/// +/// 流式请求(SSE)也走 [executeRequest],由业务 Request 类 override +/// `decodeResponse` 处理 SSE 解析。SDK 根据 `requestType == stream` +/// 自动切换响应类型。 +/// +/// 使用方式: +/// ```dart +/// final sdk = NetworksSdkApi(); +/// sdk.initialize(apiConfig); +/// +/// // 标准请求 +/// final data = await sdk.executeRequest(LoginRequest(...)); +/// +/// // 流式请求(SSE)— 同一入口,Request 类 override decodeResponse +/// final result = await sdk.executeRequest(VoiceTranscribeRequest(...)); +/// +/// // 文件下载 +/// await sdk.executeDownload( +/// url: '/files/report.pdf', +/// savePath: '/tmp/report.pdf', +/// onProgress: (received, total) => print('$received / $total'), +/// ); +/// ``` +abstract class NetworksSdkApi { factory NetworksSdkApi() => NetworksSdkWiring.build(); Future platformVersion(); - void initialize(ApiConfig aApiConfig); + void initialize(ApiConfig apiConfig); - Future executeRequest(ApiRequestable request); + /// 执行 API 请求 — 统一入口 + /// + /// 支持标准请求、登录、上传、流式(SSE),由 `request.requestType` 控制。 + /// 流式请求由业务 Request 类 override `decodeResponse` 处理 SSE 解析。 + /// + /// [cancelToken] — 可选,用于取消正在进行的请求 + Future executeRequest( + ApiRequestable request, { + CancelToken? cancelToken, + }); + + /// 下载文件到本地路径 + /// + /// [url] — 下载 URL(完整路径或相对路径) + /// [savePath] — 本地保存路径 + /// [onProgress] — 下载进度回调 + /// [cancelToken] — 取消令牌 + /// [resume] — 是否断点续传 + /// [headers] — 额外请求头 + Future executeDownload({ + required String url, + required String savePath, + OnDownloadProgress? onProgress, + CancelToken? cancelToken, + bool resume = false, + Map? headers, + }); } diff --git a/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart b/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart index ca73b27..f25a939 100644 --- a/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart +++ b/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart @@ -1,4 +1,3 @@ - import 'network_callbacks.dart'; /// API 配置 @@ -13,12 +12,22 @@ class ApiConfig { /// 平台相关 headers(App 层注入:version、platform、channel 等) Map platformHeaders; + // ── 认证回调 ── + /// Token 过期时的刷新回调 final OnTokenRefresh? onTokenRefresh; /// 需要强制登出时的回调 final OnForceLogout? onForceLogout; + /// Token 更新后的通知回调 + /// + /// 在 [updateToken] 被调用且新 token 非空时触发。 + /// App 层用于同步 token 到 WebSocket 等其他模块。 + final void Function(String newToken)? onTokenUpdated; + + // ── 基础回调 ── + /// 日志输出回调(不设置则不输出日志) final OnLog? onLog; @@ -29,12 +38,39 @@ class ApiConfig { /// 返回 false 则直接抛 [ApiError.noNetworkConnection],不走网络。 final OnCheckNetworkAvailable? onCheckNetworkAvailable; + // ── 加密回调(预留给 cipher_guard_sdk)── + + /// 请求体加密回调,null 时不加密 + final OnEncryptRequest? onEncryptRequest; + + /// 响应体解密回调,null 时不解密 + final OnDecryptResponse? onDecryptResponse; + + // ── 业务错误回调 ── + + /// 业务错误拦截回调 + /// + /// 在 token 过期 / 强制登出判断之后执行。 + /// 返回 true = App 层已处理,SDK 正常传递响应; + /// 返回 false = 未处理,SDK 继续正常流程。 + final OnBusinessError? onBusinessError; + + /// 响应变换回调 + /// + /// 在 `executeRequest` 解码前调用,App 层可以统一解包 + /// `{ code, data, message }` 结构。返回 null 表示不变换。 + final OnTransformResponse? onTransformResponse; + + // ── 错误码集合 ── + /// App 层定义的 Token 过期错误码集合 final Set tokenExpiredCodes; /// App 层定义的强制登出错误码集合 final Set forceLogoutCodes; + // ── 重试配置 ── + /// 瞬态错误最大重试次数(5xx / 超时 / 连接失败) /// /// 0 = 不重试(默认),设为 3 启用重试。 @@ -46,18 +82,50 @@ class ApiConfig { /// 实际延迟 = min(baseDelay * 2^attempt, 30s) + jitter final Duration retryBaseDelay; + // ── Token 刷新配置 ── + + /// Token 刷新超时时间,防止 onTokenRefresh 卡住导致请求永远阻塞 + final Duration tokenRefreshTimeout; + + /// Token 刷新时间窗口:刷新成功后此时间内再次收到过期码直接返回成功, + /// 避免服务端同步延迟导致的误判 + final Duration tokenReuseWindow; + + // ── 主动刷新配置 ── + + /// Token 过期时间解析回调 + /// + /// App 层解析 JWT `exp` claim,用于主动刷新判断。 + /// 未注入时不启用主动刷新。 + final OnGetTokenExpiry? onGetTokenExpiry; + + /// 主动刷新阈值:距过期不足此时间时提前刷新 + /// + /// 默认 1 小时。WebSocket 重连前、App 回前台时 + /// 自动检查并刷新即将过期的 token,避免带过期 token 发起请求。 + final Duration proactiveRefreshThreshold; + ApiConfig({ required this.baseURL, this.token, this.platformHeaders = const {}, this.onTokenRefresh, this.onForceLogout, + this.onTokenUpdated, this.onLog, this.onCheckNetworkAvailable, + this.onEncryptRequest, + 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), + this.tokenReuseWindow = const Duration(seconds: 3), + this.onGetTokenExpiry, + this.proactiveRefreshThreshold = const Duration(hours: 1), }); /// 构建默认 headers @@ -70,6 +138,8 @@ class ApiConfig { final headers = { 'Content-Type': 'application/json; charset=utf-8', 'Accept': 'application/json', + 'Keep-Alive': 'timeout=60', + // Unix 时间戳(秒),整数值,非格式化日期字符串 'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}', 'APP-Request-ID': _generateRequestId(), }; @@ -91,8 +161,13 @@ class ApiConfig { } /// 更新 token + /// + /// 同时触发 [onTokenUpdated] 通知其他模块(如 WebSocket)同步 token。 void updateToken(String? newToken) { token = newToken; + if (newToken != null && newToken.isNotEmpty) { + onTokenUpdated?.call(newToken); + } } /// 更新 base URL @@ -105,4 +180,4 @@ class ApiConfig { final now = DateTime.now().microsecondsSinceEpoch; return '$now${Object().hashCode}'; } -} \ No newline at end of file +} diff --git a/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart b/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart index bca1b2f..df08341 100644 --- a/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart +++ b/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart @@ -1,13 +1,113 @@ /// 网络层回调类型定义,由 App 层注入 SDK,避免 SDK 直接依赖外部实现。 library; +import 'package:networks_sdk/src/domain/entities/encrypted_request.dart'; + +// ── 认证 ── + +/// Token 刷新回调,返回新 token;返回 null 表示刷新失败 typedef OnTokenRefresh = Future Function(); -/// 強制登出回調 +/// 强制登出回调 typedef OnForceLogout = void Function(); -/// 日誌輸出回調 +// ── Token 生命周期 ── + +/// 获取 token 过期时间 +/// +/// App 层解析 JWT 的 `exp` claim 返回过期时间。 +/// 返回 null 表示无法解析(非 JWT 或格式错误)。 +typedef OnGetTokenExpiry = DateTime? Function(String token); + +// ── 基础 ── + +/// 日志输出回调 typedef OnLog = void Function(String message, {String? tag}); -/// 網路可用性查詢(App 層注入,SDK 在請求前調用) -typedef OnCheckNetworkAvailable = Future Function(); \ No newline at end of file +/// 网络可用性查询(App 层注入,SDK 在请求前调用) +typedef OnCheckNetworkAvailable = Future Function(); + +// ── 加密(预留给 cipher_guard_sdk)── + +/// HTTP 请求加密回调 +/// +/// 接收原始路径、headers、请求体,返回 [EncryptedRequest]。 +/// 拦截器根据返回值中的非 null 字段覆盖原始请求。 +/// +/// 参数说明: +/// - [path] — 原始请求路径(如 `/api/v1/auth/login`) +/// - [headers] — 当前请求的全部 headers(含 token、platform headers 等) +/// - [body] — 原始请求体(可能是 Map、String、null 等) +/// +/// App 层实现示例(X25519 + AES-256-CBC 模式): +/// - 加密 path → hex 编码 → 替换路径 +/// - 加密 body → base64 编码 → 替换请求体 +/// - 加密 token → 放入 X-Token header +/// - Ed25519 签名 → 放入 X-Signature header +/// - Content-Type → text/plain +typedef OnEncryptRequest = + Future Function( + String path, + Map headers, + Object? body, + ); + +/// HTTP 响应解密回调 +/// +/// 输入是原始响应数据(加密后可能是 String、`List`、或 Map), +/// 返回解密后的 Map 供业务层使用。 +/// +/// [responseData] 的实际类型取决于服务端响应格式: +/// - 加密模式下通常是 base64 字符串 +/// - 非加密模式下是 `Map`(拦截器会自动跳过,不调用此回调) +/// +/// 实现时建议做类型判断兜底,应对非预期的响应格式: +/// ```dart +/// onDecryptResponse: (data) async { +/// if (data is! String) throw FormatException('Expected String, got ${data.runtimeType}'); +/// return jsonDecode(aesDecrypt(data)); +/// } +/// ``` +typedef OnDecryptResponse = + Future> Function(Object responseData); + +// ── 业务错误 ── + +/// 业务错误拦截回调 +/// +/// App 层统一处理特定错误码,返回 true = 已处理(SDK 不再抛错), +/// 返回 false = 未处理(SDK 继续正常流程)。 +typedef OnBusinessError = bool Function(int code, String message, String path); + +/// 响应变换回调 +/// +/// App 层自定义响应解包逻辑(如统一解包 `{ code, data, message }` 结构)。 +/// 返回 null 表示不变换,使用原始响应。 +typedef OnTransformResponse = + Map? Function(Map raw); + +// ── 下载 ── + +/// 下载进度回调 +typedef OnDownloadProgress = void Function(int received, int total); + +// ── WebSocket 加密(预留给 cipher_guard_sdk)── + +/// WebSocket 连接 URL 构建回调 +/// +/// 建立连接前调用,接收原始 URL 和 token,返回最终的连接 URL 字符串。 +/// WS 握手本质是 HTTP GET 升级请求,只需控制 URL(路径 + 查询参数)。 +/// +/// App 层可在此(通过调用 cipher_guard_sdk): +/// - 加密 URL 路径(如 `/ws` → `/hex(encrypt(ws))`) +/// - 加密 token 参数(明文 token 不出现在 URL 中) +/// - 添加加密模式协商参数(如 `cipher=true&type=mode3`) +/// +/// null 时使用默认行为:在 URL 后追加 `?token=xxx`。 +typedef OnBuildConnectUrl = String Function(String url, String? token); + +/// WebSocket 发送前加密回调 +typedef OnEncryptMessage = Future Function(String plainText); + +/// WebSocket 收到后解密回调 +typedef OnDecryptMessage = Future Function(String cipherText); diff --git a/packages/networks_sdk/lib/src/presentation/wiring/networks_messaging_api_impl.dart b/packages/networks_sdk/lib/src/presentation/wiring/networks_messaging_api_impl.dart index 2e70c56..c3906c4 100644 --- a/packages/networks_sdk/lib/src/presentation/wiring/networks_messaging_api_impl.dart +++ b/packages/networks_sdk/lib/src/presentation/wiring/networks_messaging_api_impl.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:networks_sdk/src/data/repositories/networks_messaging_repository_impl.dart'; import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart'; import 'package:networks_sdk/src/domain/entities/socket_error.dart'; @@ -5,7 +7,7 @@ import 'package:networks_sdk/src/domain/repositories/networks_messaging_reposito import 'package:networks_sdk/src/presentation/facade/networks_messaging_api.dart'; import 'package:networks_sdk/src/presentation/wiring/socket_config.dart'; -/// Implementation of [NetworksMessagingApi] using [NetworksMessagingRepository] +/// [NetworksMessagingApi] 的实现,透传给 [NetworksMessagingRepository] class NetworksMessagingApiImpl implements NetworksMessagingApi { NetworksMessagingRepository? _repository; @@ -47,6 +49,12 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi { return _repository!.connectionState; } + @override + void updateToken(String token) { + _checkInitialized(); + _repository!.updateToken(token); + } + @override Future send(Map message) { _checkInitialized(); @@ -59,6 +67,12 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi { return _repository!.sendString(message); } + @override + Future sendBytes(List bytes) { + _checkInitialized(); + return _repository!.sendBytes(bytes); + } + @override Stream> get messageStream { _checkInitialized(); @@ -71,6 +85,12 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi { return _repository!.rawMessageStream; } + @override + Stream get binaryMessageStream { + _checkInitialized(); + return _repository!.binaryMessageStream; + } + @override Stream get connectionStateStream { _checkInitialized(); @@ -103,4 +123,3 @@ class NetworksMessagingApiImpl implements NetworksMessagingApi { } } } - diff --git a/packages/networks_sdk/lib/src/presentation/wiring/networks_sdk_api_impl.dart b/packages/networks_sdk/lib/src/presentation/wiring/networks_sdk_api_impl.dart index 8dd873c..612357e 100644 --- a/packages/networks_sdk/lib/src/presentation/wiring/networks_sdk_api_impl.dart +++ b/packages/networks_sdk/lib/src/presentation/wiring/networks_sdk_api_impl.dart @@ -1,7 +1,7 @@ import '../../../networks_sdk.dart'; import 'networks_sdk_core.dart'; -/// SDK API Implementation +/// [NetworksSdkApi] 的实现,透传给 Repository class NetworksSdkApiImpl implements NetworksSdkApi { final NetworksSdkCore _core; @@ -14,6 +14,29 @@ class NetworksSdkApiImpl implements NetworksSdkApi { void initialize(ApiConfig apiConfig) => _core.repo.initialize(apiConfig); @override - Future executeRequest(ApiRequestable request) => _core.repo.executeRequest(request); + Future executeRequest( + ApiRequestable request, { + CancelToken? cancelToken, + }) { + return _core.repo.executeRequest(request, cancelToken: cancelToken); + } + @override + Future executeDownload({ + required String url, + required String savePath, + OnDownloadProgress? onProgress, + CancelToken? cancelToken, + bool resume = false, + Map? headers, + }) { + return _core.repo.executeDownload( + url: url, + savePath: savePath, + onProgress: onProgress, + cancelToken: cancelToken, + resume: resume, + headers: headers, + ); + } } diff --git a/packages/networks_sdk/lib/src/presentation/wiring/socket_config.dart b/packages/networks_sdk/lib/src/presentation/wiring/socket_config.dart index 730b2ea..55f379a 100644 --- a/packages/networks_sdk/lib/src/presentation/wiring/socket_config.dart +++ b/packages/networks_sdk/lib/src/presentation/wiring/socket_config.dart @@ -1,9 +1,13 @@ +import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; + /// WebSocket 配置 /// 非单例,由 App 层构造并注入到 SocketClient /// /// 与 [ApiConfig] 设计一致:SDK 不依赖 Flutter, /// 网络检测、生命周期等业务逻辑通过回调注入。 class SocketConfig { + // ── 心跳 ── + /// 应用层心跳间隔(定时发送 "ping" 字符串) final Duration heartbeatInterval; @@ -13,10 +17,19 @@ class SocketConfig { /// Pong 超时(超过此时间未收到 pong 则判定连接断开) final Duration pongTimeout; + // ── 连接 ── + /// 连接超时 final Duration connectTimeout; + /// 是否启用 WebSocket 压缩(permessage-deflate) + final bool enableCompression; + + // ── 重连 ── + /// 最大重连次数(0 = 不重连) + /// + /// 当 [unlimitedReconnect] 为 true 时此字段无效。 final int maxReconnectAttempts; /// 最大重连延迟(指数退避上限) @@ -25,22 +38,65 @@ class SocketConfig { /// 是否自动重连 final bool autoReconnect; + /// 无限重连模式 + /// + /// IM 场景建议开启:连接断开后始终尝试重连,不受 + /// [maxReconnectAttempts] 限制。退避延迟仍受 + /// [maxReconnectDelay] 约束。 + final bool unlimitedReconnect; + + // ── 回调 ── + /// 日志输出回调(与 ApiConfig.onLog 同签名) - final void Function(String message, {String? tag})? onLog; + final OnLog? onLog; /// 网络可用性查询(App 层注入,SDK 在重连前调用) /// 返回 true 表示网络可用,可以尝试重连 - final Future Function()? onCheckNetworkAvailable; + final OnCheckNetworkAvailable? onCheckNetworkAvailable; + + /// 重连前回调 + /// + /// 每次自动重连前调用(心跳超时、连接断开等触发的内部重连)。 + /// App 层用于: + /// - 检查并刷新即将过期的 token(通过 [SocketClient.updateToken]) + /// - 其他重连前准备工作 + /// + /// 回调完成后才发起实际连接。如果回调抛出异常,本次重连跳过, + /// 等下一轮退避定时器触发。 + final Future Function()? onBeforeReconnect; + + // ── 加密回调(预留给 cipher_guard_sdk)── + + /// 连接 URL 构建回调 + /// + /// 建立连接前调用,接收原始 URL 和 token,返回最终连接 URL 字符串。 + /// null 时使用默认行为(URL 后追加 `?token=xxx`)。 + /// + /// App 层注入 cipher_guard_sdk 的加密逻辑:路径/token 加密、 + /// 添加 `cipher=true` 参数等。 + final OnBuildConnectUrl? onBuildConnectUrl; + + /// 发送前加密回调,null 时不加密 + final OnEncryptMessage? onEncryptMessage; + + /// 收到后解密回调,null 时不解密 + final OnDecryptMessage? onDecryptMessage; SocketConfig({ this.heartbeatInterval = const Duration(seconds: 10), this.pingInterval = const Duration(seconds: 5), this.pongTimeout = const Duration(seconds: 10), this.connectTimeout = const Duration(seconds: 15), + this.enableCompression = false, this.maxReconnectAttempts = 5, this.maxReconnectDelay = const Duration(seconds: 30), this.autoReconnect = true, + this.unlimitedReconnect = false, this.onLog, this.onCheckNetworkAvailable, + this.onBeforeReconnect, + this.onBuildConnectUrl, + this.onEncryptMessage, + this.onDecryptMessage, }); } diff --git a/packages/notification_sdk/android/build.gradle b/packages/notification_sdk/android/build.gradle deleted file mode 100644 index 1b803ac..0000000 --- a/packages/notification_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.notification_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.notification_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/notification_sdk/android/build.gradle.kts b/packages/notification_sdk/android/build.gradle.kts new file mode 100644 index 0000000..a85707c --- /dev/null +++ b/packages/notification_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.notification_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.notification_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/notification_sdk/ios/Classes/NotificationSdkPlugin.swift b/packages/notification_sdk/ios/Classes/NotificationSdkPlugin.swift index a642413..af8de48 100644 --- a/packages/notification_sdk/ios/Classes/NotificationSdkPlugin.swift +++ b/packages/notification_sdk/ios/Classes/NotificationSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class NotificationSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/notification_sdk/ios/notification_sdk.podspec b/packages/notification_sdk/ios/notification_sdk.podspec index 9aab754..c171975 100644 --- a/packages/notification_sdk/ios/notification_sdk.podspec +++ b/packages/notification_sdk/ios/notification_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/protocol_sdk/android/build.gradle b/packages/protocol_sdk/android/build.gradle deleted file mode 100644 index cceae73..0000000 --- a/packages/protocol_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.protocol_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.protocol_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/protocol_sdk/android/build.gradle.kts b/packages/protocol_sdk/android/build.gradle.kts new file mode 100644 index 0000000..ffc8989 --- /dev/null +++ b/packages/protocol_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.protocol_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.protocol_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/protocol_sdk/ios/Classes/ProtocolSdkPlugin.swift b/packages/protocol_sdk/ios/Classes/ProtocolSdkPlugin.swift index 93a4115..d40120b 100644 --- a/packages/protocol_sdk/ios/Classes/ProtocolSdkPlugin.swift +++ b/packages/protocol_sdk/ios/Classes/ProtocolSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class ProtocolSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/protocol_sdk/ios/protocol_sdk.podspec b/packages/protocol_sdk/ios/protocol_sdk.podspec index b981a71..2cde62b 100644 --- a/packages/protocol_sdk/ios/protocol_sdk.podspec +++ b/packages/protocol_sdk/ios/protocol_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/rtc_sdk/android/build.gradle b/packages/rtc_sdk/android/build.gradle deleted file mode 100644 index 4fda6fc..0000000 --- a/packages/rtc_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.rtc_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.rtc_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/rtc_sdk/android/build.gradle.kts b/packages/rtc_sdk/android/build.gradle.kts new file mode 100644 index 0000000..19b7f66 --- /dev/null +++ b/packages/rtc_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.rtc_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.rtc_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/rtc_sdk/ios/Classes/RtcSdkPlugin.swift b/packages/rtc_sdk/ios/Classes/RtcSdkPlugin.swift index b24a613..c395710 100644 --- a/packages/rtc_sdk/ios/Classes/RtcSdkPlugin.swift +++ b/packages/rtc_sdk/ios/Classes/RtcSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class RtcSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/rtc_sdk/ios/rtc_sdk.podspec b/packages/rtc_sdk/ios/rtc_sdk.podspec index 83f62f5..ea764cf 100644 --- a/packages/rtc_sdk/ios/rtc_sdk.podspec +++ b/packages/rtc_sdk/ios/rtc_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/storage_sdk/android/build.gradle b/packages/storage_sdk/android/build.gradle deleted file mode 100644 index 908ea2c..0000000 --- a/packages/storage_sdk/android/build.gradle +++ /dev/null @@ -1,66 +0,0 @@ -group = "com.example.storage_sdk" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.2.20" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.11.1") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.example.storage_sdk" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 24 - } - - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/packages/storage_sdk/android/build.gradle.kts b/packages/storage_sdk/android/build.gradle.kts new file mode 100644 index 0000000..1b1fdfe --- /dev/null +++ b/packages/storage_sdk/android/build.gradle.kts @@ -0,0 +1,71 @@ +group = "com.example.storage_sdk" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.storage_sdk" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // Compose BOM 统一管理子库版本,按需在各 SDK 中引入具体组件 + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/packages/storage_sdk/ios/Classes/StorageSdkPlugin.swift b/packages/storage_sdk/ios/Classes/StorageSdkPlugin.swift index 1a9fcf9..aff3fee 100644 --- a/packages/storage_sdk/ios/Classes/StorageSdkPlugin.swift +++ b/packages/storage_sdk/ios/Classes/StorageSdkPlugin.swift @@ -1,4 +1,4 @@ -import Flutter +@preconcurrency import Flutter import UIKit public class StorageSdkPlugin: NSObject, FlutterPlugin { diff --git a/packages/storage_sdk/ios/storage_sdk.podspec b/packages/storage_sdk/ios/storage_sdk.podspec index 95940cf..2239b5d 100644 --- a/packages/storage_sdk/ios/storage_sdk.podspec +++ b/packages/storage_sdk/ios/storage_sdk.podspec @@ -19,7 +19,7 @@ A new Flutter plugin project. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '6.2' # If your plugin requires a privacy manifest, for example if it uses any # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your diff --git a/packages/storage_sdk/lib/src/data/repositories/database_repository_impl.dart b/packages/storage_sdk/lib/src/data/repositories/database_repository_impl.dart index 1bb80f0..41cbfde 100644 --- a/packages/storage_sdk/lib/src/data/repositories/database_repository_impl.dart +++ b/packages/storage_sdk/lib/src/data/repositories/database_repository_impl.dart @@ -18,9 +18,9 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future insertOrReplace( - TableInfo table, - Insertable companion, - ) async { + TableInfo table, + Insertable companion, + ) async { final db = _db; if (db == null) return; await db.into(table).insert(companion, mode: InsertMode.insertOrReplace); @@ -28,9 +28,9 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future insert( - TableInfo table, - Insertable companion, - ) async { + TableInfo table, + Insertable companion, + ) async { final db = _db; if (db == null) return; await db.into(table).insert(companion, mode: InsertMode.insertOrIgnore); @@ -38,17 +38,14 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future batchInsertOrReplace( - TableInfo table, - List> companions, - ) async { + TableInfo table, + List> companions, + ) async { final db = _db; if (db == null) return; await db.batch( - (batch) => batch.insertAll( - table, - companions, - mode: InsertMode.insertOrReplace, - ), + (batch) => + batch.insertAll(table, companions, mode: InsertMode.insertOrReplace), ); } @@ -56,10 +53,10 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future updateWhere( - TableInfo table, - Insertable companion, - Expression Function(T) filter, - ) async { + TableInfo table, + Insertable companion, + Expression Function(T) filter, + ) async { final db = _db; if (db == null) return; await (db.update(table)..where(filter)).write(companion); @@ -69,9 +66,9 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future deleteWhere( - TableInfo table, - Expression Function(T) filter, - ) async { + TableInfo table, + Expression Function(T) filter, + ) async { final db = _db; if (db == null) return; await (db.delete(table)..where(filter)).go(); @@ -95,9 +92,9 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future> selectWhere( - TableInfo table, - Expression Function(T) filter, - ) async { + TableInfo table, + Expression Function(T) filter, + ) async { final db = _db; if (db == null) return []; return (db.select(table)..where(filter)).get(); @@ -105,12 +102,15 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future selectFirst( - TableInfo table, - Expression Function(T) filter, - ) async { + TableInfo table, + Expression Function(T) filter, + ) async { final db = _db; if (db == null) return null; - return (db.select(table)..where(filter)..limit(1)).getSingleOrNull(); + return (db.select(table) + ..where(filter) + ..limit(1)) + .getSingleOrNull(); } // ── 监听 ───────────────────────────────────────────────────────────────── @@ -124,9 +124,9 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Stream> watchWhere( - TableInfo table, - Expression Function(T) filter, - ) { + TableInfo table, + Expression Function(T) filter, + ) { final db = _db; if (db == null) return const Stream.empty(); return (db.select(table)..where(filter)).watch(); @@ -134,36 +134,33 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Stream watchFirst( - TableInfo table, - Expression Function(T) filter, - ) { + TableInfo table, + Expression Function(T) filter, + ) { final db = _db; if (db == null) return const Stream.empty(); - return (db.select(table)..where(filter)..limit(1)).watchSingleOrNull(); + return (db.select(table) + ..where(filter) + ..limit(1)) + .watchSingleOrNull(); } // ── 原始 SQL ───────────────────────────────────────────────────────────── @override Future> rawQuery( - String sql, [ - List args = const [], - ]) async { + String sql, [ + List args = const [], + ]) async { final db = _db; if (db == null) return []; return db - .customSelect( - sql, - variables: args.map((e) => Variable(e)).toList(), - ) + .customSelect(sql, variables: args.map((e) => Variable(e)).toList()) .get(); } @override - Future rawExecute( - String sql, [ - List args = const [], - ]) async { + Future rawExecute(String sql, [List args = const []]) async { final db = _db; if (db == null) return; await db.customStatement(sql, args); @@ -173,9 +170,9 @@ class DatabaseRepositoryImpl implements DatabaseRepository { @override Future count( - TableInfo table, { - Expression Function(T)? filter, - }) async { + TableInfo table, { + Expression Function(T)? filter, + }) async { final db = _db; if (db == null) return 0; final countExpr = table.$columns.first.count(); @@ -184,4 +181,4 @@ class DatabaseRepositoryImpl implements DatabaseRepository { final row = await query.getSingle(); return row.read(countExpr) ?? 0; } -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index 7b87f8a..ddf3fa5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "92.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" analyzer: dependency: transitive description: @@ -241,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: c6ecb3bb991c459b91c5adf9e871113dcb32bbe8fe7ca2c92723f88ffc1e0b7a + url: "https://pub.dev" + source: hosted + version: "3.3.2" dart_style: dependency: transitive description: @@ -297,6 +313,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" encrypt: dependency: transitive description: @@ -471,14 +495,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: transitive description: @@ -720,13 +736,13 @@ packages: source: hosted version: "2.1.8" pointycastle: - dependency: transitive + dependency: "direct overridden" description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "4.0.0" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4d6e1b5..c5c5851 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,12 @@ workspace: - packages/storage_sdk - packages/im_log_sdk +dependency_overrides: + # encrypt 5.0.3 限制 pointycastle ^3.6.2,但 dart_jsonwebtoken 需要 ^4.0.0。 + # pointycastle 4.0.0 无破坏性 API 变更(主要是新增算法和泛型改进), + # encrypt 在 4.0.0 下运行无问题,强制升级解决冲突。 + pointycastle: ^4.0.0 + dev_dependencies: melos: ^7.0.0 diff --git a/scripts/new_sdk.sh b/scripts/new_sdk.sh index 67e6d5e..c69c3ce 100755 --- a/scripts/new_sdk.sh +++ b/scripts/new_sdk.sh @@ -217,6 +217,117 @@ with open(path, 'w') as f: f.write(content) PY +# ---- Step 3.5: 修正 podspec swift_version ---- +# flutter create 默认生成 swift_version = '5.0',统一改为项目标准 6.2 +for podspec in \ + "$PKG_DIR/ios/${PKG_NAME}.podspec" \ + "$PKG_DIR/macos/${PKG_NAME}.podspec"; do + if [[ -f "$podspec" ]]; then + sed -i '' "s/s.swift_version = '5.0'/s.swift_version = '6.2'/" "$podspec" + fi +done + +# Flutter SDK 尚未完整标注 Swift 6 并发属性,用 @preconcurrency import 将 +# FlutterMethodNotImplemented 等全局变量的并发警告降级,避免编译失败。 +# 注意:不在类上加 @MainActor,否则与 FlutterPlugin 协议的 nonisolated 要求冲突, +# 导致 ConformanceIsolation 编译错误。 +for swift_file in \ + "$PKG_DIR/ios/Classes/${PASCAL_NAME}SdkPlugin.swift" \ + "$PKG_DIR/macos/Classes/${PASCAL_NAME}SdkPlugin.swift"; do + if [[ -f "$swift_file" ]]; then + sed -i '' \ + 's/^import Flutter$/@preconcurrency import Flutter/' \ + "$swift_file" + fi +done + +# ---- Step 3.6: 替换 Android build.gradle → build.gradle.kts(Kotlin DSL + Compose) ---- +# flutter create 默认生成 Groovy build.gradle,统一替换为 Kotlin DSL +ANDROID_DIR="$PKG_DIR/android" +[[ -f "$ANDROID_DIR/build.gradle" ]] && rm "$ANDROID_DIR/build.gradle" + +cat > "$ANDROID_DIR/build.gradle.kts" << GRADLE_EOF +group = "com.example.${PKG_NAME}" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:\$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + // 若该 SDK 需要 Compose,取消注释下面两行,并在 dependencies 中加 compose runtime + // id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.example.${PKG_NAME}" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { java.srcDirs("src/main/kotlin") } + getByName("test") { java.srcDirs("src/test/kotlin") } + } + + defaultConfig { + minSdk = 24 + } + + // 若该 SDK 需要 Compose,取消注释 + // buildFeatures { compose = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + it.outputs.upToDateWhen { false } + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + // 若该 SDK 需要 Compose,取消注释以下两行并按需引入具体组件: + // val composeBom = platform("androidx.compose:compose-bom:2025.05.01") + // implementation(composeBom) + // implementation("androidx.compose.runtime:runtime") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} +GRADLE_EOF + # ---- Step 4: IDE 配置 ---- echo "[4/5] Updating IDE config..."