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 = newState → ref.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:) 创建
baseURL ApiConfig.baseURL AppConfig.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/Background App 层调用(AppLifecycleListener)
+WebSocket 生命周期 提供 onEnterForeground/Background App 层调用(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}Data login_request.dart → LoginRequest + LoginData
+接口定义 {action}_request.dartRequest: {Action}Request
Response DTO: {Action}Response login_request.dart → LoginRequest + LoginResponse
持久化 DTO data/models/{entity}_dto.dart{Entity}Dtouser_dto.dart → UserDto
Repository 接口 domain/repositories/{module}_repository.dart{Module}Repositoryauth_repository.dart → AuthRepository
Repository 实现 data/repositories/{module}_repository_impl.dart{Module}RepositoryImplauth_repository_impl.dart → AuthRepositoryImpl
@@ -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> // ← 固定写法
命名规则速查(写之前就能确定引用名)
-你写的类名 fromJson toJson Api mixin
+你写的类名 fromJson(私有函数) toJson Api 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 里每种元素只有一个定义。没有"备用版本",没有"临时副本",不允许两个"差不多一样"的组件并存。
+
+
+
+- Figma 命名是重中之重:点中任何元素都必须看到抽象名称。六类无例外:
+
+ 颜色 — 如 primary、surface
+ 字体 — 如 Body/Medium、Label/Small
+ 基础组件 — 如 Button/Primary、Input/Default,全局只有一个版本
+ 业务弹框 — 如 Dialog/Confirm
+ 图片 — Figma 统一导出,代码侧 AppAssets 注册,不硬编码路径
+ 图标 — 如 send、more_options,代码侧 AppIcons 调用
+
+
+- 基础组件定稿后不随意改动:需改动时必须先告知研发,评估影响范围,双方同步后再执行
+- UI 团队自主维护 UI 基建体系:研发照着 Figma 名字封装,名称必须完全一致
+- 所有元素遵循同一套命名规则:新增任何元素先在 Figma 定名,研发用相同名字注册
+
+
+
+图片和组件是重灾区:没有统一来源时,不同研发各自导出同一张图,文件名不同、尺寸不同,最终项目里堆满重复文件。Figma 统一命名、代码统一注册,才能从源头堵住。
+
+
+8.1 颜色体系
+
+所有颜色通过抽象名称引用。抽象名在亮色 / 暗色两套主题下对应不同色值,修改主题只需改映射表,不需逐个找组件。
+
+语义色(随主题变化)
+
+
+
+抽象名 Figma 名 亮色 暗色 用途
+
+
+primaryPrimary #2F80ED #5BA3F5 主操作、链接、选中态
+backgroundBackground #F8F9FA #202124 页面底色
+surfaceSurface #FFFFFF #3C4043 卡片、弹框、输入框
+onSurfaceOn Surface #202124 #FFFFFF surface 上的文字
+errorError #EB5757 错误状态
+successSuccess #27AE60 成功状态
+warningWarning #F2C94C 警告状态
+
+
+
+灰阶(固定值,不随主题变化)
+
+
+名称 色值 名称 色值 名称 色值
+
+white #FFFFFF gray50 #F8F9FA gray100 #F1F3F4
+gray200 #E8EAED gray400 #BDC1C6 gray600 #80868B
+gray800 #3C4043 gray900 #202124 black #000000
+
+
+
+使用原则:需随主题切换 → 用语义色(primary、surface);亮暗保持不变 → 用灰阶固定值。
+
+8.2 字体体系
+
+字体按层级分五档:Display、Headline、Title、Body、Label,每档三个尺寸。Figma 中按 层级/尺寸 格式命名(如 Body/Large),开发用同名变量调用。
+
+
+Figma 名称 字号 字重 行高 字距 典型用途
+
+DISPLAY
+Display/Large 57 400 64 -0.25 启动页大标题、空状态
+Display/Medium 45 400 52 — —
+Display/Small 36 400 44 — —
+HEADLINE
+Headline/Large 32 400 40 — 页面主标题、导航栏
+Headline/Medium 28 400 36 — —
+Headline/Small 24 400 32 — —
+TITLE
+Title/Large 22 500 28 — 会话列表名称、设置项标题
+Title/Medium 16 500 24 0.15 卡片标题、列表主行
+Title/Small 14 500 20 0.1 —
+BODY
+Body/Large 16 400 24 0.5 聊天气泡、表单输入
+Body/Medium 14 400 20 0.25 正文说明、列表副行
+Body/Small 12 400 16 0.4 辅助信息、提示文字
+LABEL
+Label/Large 14 500 20 0.1 按钮文字、Tab 标签、Badge
+Label/Medium 12 500 16 0.5 次要标签、徽标文字
+Label/Small 11 500 16 0.5 最小粒度标签
+语义样式
+Section Label 13 600 — 0.5 列表分组标题、设置分区
+Body/Muted 12 400 16 — 说明文字(灰色,低对比度)
+Body/Error 12 400 16 — 表单错误提示(红色)
+Label/Muted 12 500 16 — 时间戳、元数据(低对比度)
+
+
+
+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 图标规范
+
+
+- UI 先命名,开发跟随:Figma 确定名称,开发用完全相同的名称封装到
AppIcons
+- 名称有实际语义:全小写下划线,如
send、add_contact、more_options。不用拼音,不缩写
+- 统一用 AppIcons 调用:不允许直接用裸 icon 库变量,替换时改一处全局生效
+- 同义图标只保留一个:同功能图标在整个产品内只存在一种
+
+
+新增图标流程:设计师 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_dto.dart b/apps/im_app/lib/data/models/user_dto.dart
index beb55c0..b47ae3c 100644
--- a/apps/im_app/lib/data/models/user_dto.dart
+++ b/apps/im_app/lib/data/models/user_dto.dart
@@ -145,4 +145,4 @@ class UserDto {
userAlias: Value(userAlias),
hint: Value(hint),
);
-}
\ 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 5398026..5030fec 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,
@@ -104,23 +102,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 4145d71..b8b18a7 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,
@@ -94,8 +94,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;
@@ -109,7 +112,7 @@ class LoginData {
@JsonKey(name: 'login_data')
final String loginData;
- const LoginData({
+ const LoginResponse({
required this.accountId,
required this.profile,
required this.nonce,
@@ -119,52 +122,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);
-}
\ No newline at end of file
+}
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.dart b/apps/im_app/lib/domain/entities/user.dart
index 0e15597..a801c1c 100644
--- a/apps/im_app/lib/domain/entities/user.dart
+++ b/apps/im_app/lib/domain/entities/user.dart
@@ -91,4 +91,4 @@ class User {
hint: hint ?? this.hint,
);
}
-}
\ 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/presentation/chat_db_test_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
index 00df1da..4386f91 100644
--- a/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
+++ b/apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
@@ -6,51 +6,20 @@ import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'chat_db_test_state.dart';
+
+export 'chat_db_test_state.dart';
+
part 'chat_db_test_view_model.g.dart';
-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 = '',
- });
-
- ChatDbTestState copyWith({
- bool? testStarted,
- List? testResults,
- String? currentState,
- }) => ChatDbTestState(
- testStarted: testStarted ?? this.testStarted,
- testResults: testResults ?? this.testResults,
- currentState: currentState ?? this.currentState,
- );
-}
-
@riverpod
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
-
@override
ChatDbTestState build() {
// 这里就是 onInit
final List testResults = List.generate(
1000,
- (i) => TestResult(
+ (i) => TestResult(
title: '用户 ${Random().nextInt(9999)}',
subtitle: 'uid: ${Random().nextInt(999999)}',
duration: '${Random().nextInt(500)}ms',
@@ -59,17 +28,16 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
return ChatDbTestState(testResults: testResults);
}
- // ── 导航(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
+ // ── 操作(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
- /// 开始测试
- void startDBTest(BuildContext context) {
- state = state.copyWith(testStarted: true, currentState: '开始测试');
- _testDBInsert();
- }
-
- /// 结束测试
- void stopDBTest(BuildContext context) {
- state = state.copyWith(testStarted: false, currentState: '结束测试');
+ /// 切换测试状态(开始 / 停止)
+ void toggleDBTest() {
+ if (state.testStarted) {
+ state = state.copyWith(testStarted: false, currentState: '结束测试');
+ } else {
+ state = state.copyWith(testStarted: true, currentState: '开始测试');
+ _testDBInsert();
+ }
}
Future _testDBInsert() async {
@@ -85,10 +53,8 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
for (var i = 0; i < count; i += chunkSize) {
final chunk = List.generate(
chunkSize.clamp(0, count - i),
- (j) => UsersCompanion.insert(
- uid: i + j,
- nickname: Value('User ${i + j}'),
- ),
+ (j) =>
+ UsersCompanion.insert(uid: i + j, nickname: Value('User ${i + j}')),
);
await db.batchInsertOrReplace(chunk);
@@ -97,13 +63,13 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
// 让出主线程
await Future.delayed(Duration.zero);
- debugPrint('已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)');
+ debugPrint(
+ '已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)',
+ );
// 更新 UI 状态
if (ref.mounted) {
- state = state.copyWith(
- currentState: '已插入 $completed / $count 条',
- );
+ state = state.copyWith(currentState: '已插入 $completed / $count 条');
}
}
@@ -115,4 +81,4 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
);
}
}
-}
\ No newline at end of file
+}
diff --git a/apps/im_app/lib/features/chat/view/chat_db_test_page.dart b/apps/im_app/lib/features/chat/view/chat_db_test_page.dart
index 06ec586..3c8c025 100644
--- a/apps/im_app/lib/features/chat/view/chat_db_test_page.dart
+++ b/apps/im_app/lib/features/chat/view/chat_db_test_page.dart
@@ -3,19 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
import '../../../core/ui/components/app_button.dart';
-import '../presentation/chat_view_model.dart';
-/// 聊天页(Demo 按钮)
+/// 数据库性能测试页(Demo)
///
-/// 包含五个演示按钮,覆盖 go_router 的常见导航场景:
-/// - 「切换 Tab」 — go,替换历史,不可返回
-/// - 「有参 push(extra)」 — push + extra(Dart Record),可返回
-/// - 「有参 push(路径参数)」— push + URL 内嵌 id,可返回
-/// - 「无参 push」 — push,可返回
-/// - 「退出登录」 — 守卫自动重定向到 /login
-///
-/// 所有操作通过 [ChatViewModel] 处理,View 不直接调用路由。
-/// 正式开发后替换为会话列表,按钮相关代码一并清除。
+/// 批量插入 10000 条用户记录,验证 Drift 批量写入性能。
+/// 所有操作通过 [ChatDbTestViewModel] 处理,View 只负责渲染。
+/// 正式开发后此页面将被删除。
class ChatDbTestPage extends ConsumerWidget {
const ChatDbTestPage({super.key});
@@ -25,7 +18,7 @@ class ChatDbTestPage extends ConsumerWidget {
final state = ref.watch(chatDbTestViewModelProvider);
return Scaffold(
- appBar: AppBar(title: const Text('测试数据库'), ),
+ appBar: AppBar(title: const Text('测试数据库')),
body: Column(
mainAxisSize: MainAxisSize.max,
spacing: 16,
@@ -37,18 +30,15 @@ class ChatDbTestPage extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AppButton.inverse(
- label: state.testStarted ? '结束' : '开始',
- onPressed: () => state.testStarted ? vm.stopDBTest(context) : vm.startDBTest(context),
+ label: state.buttonLabel,
+ onPressed: () => vm.toggleDBTest(),
),
SizedBox(width: 8),
Expanded(
- child: Text(
- state.currentState,
- textAlign: TextAlign.end,
- ),
- )
+ child: Text(state.currentState, textAlign: TextAlign.end),
+ ),
],
- )
+ ),
),
Expanded(
child: ListView.builder(
@@ -63,7 +53,7 @@ class ChatDbTestPage extends ConsumerWidget {
);
},
),
- )
+ ),
],
),
);
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 e81cd20..2cf2b45 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/data/models/user_dto.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/data/models/user_dto.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,24 +59,56 @@ class LoginViewModel extends _$LoginViewModel {
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
Future demoLogin() async {
- final storageApi = ref.read(storageSdkProvider);
- final storageLifeCycle = storageApi as StorageSdkLifecycle;
- final provider = ref.read(authNotifierProvider);
+ // 防止连点重入:第一次调用未完成前忽略后续调用
+ if (state.isLoading) return;
+ state = state.copyWith(isLoading: true, error: null);
- // Read mock response from assets
- final String raw = await rootBundle.loadString('assets/loginData.json');
- final Map json = jsonDecode(raw);
+ try {
+ final storageApi = ref.read(storageSdkProvider);
+ final storageLifeCycle = storageApi as StorageSdkLifecycle;
- // Parse into LoginData (nested under 'data' key)
- final loginResponse = LoginResponse.fromJson(json);
- final user = loginResponse.data.toEntity();
+ // 读取 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?,
+ channelId: profile['channel_id'] as int,
+ channelGroupId: profile['channel_group_id'] as int,
+ hint: profile['hint'] as String,
+ );
- provider.login();
- // Open database for the user
- await storageLifeCycle.openDatabase(user.uid);
- ///TODO: User 和 DTO和数据库之间转换
- final userCompanion = UserDto.fromEntity(user).toCompanion();
- storageApi.insert(userCompanion);
+ // 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
+ await storageLifeCycle.openDatabase(user.uid);
+ final userCompanion = UserDto.fromEntity(user).toCompanion();
+ await storageApi.insertOrReplace(userCompanion);
+
+ // 全部成功后再更新登录状态,触发路由守卫重定向
+ // 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state
+ if (!ref.mounted) return;
+ ref.read(authNotifierProvider).login();
+ } catch (e) {
+ // 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
+ if (!ref.mounted) return;
+ state = state.copyWith(error: e.toString(), isLoading: false);
+ }
}
/// 执行登录
@@ -88,20 +120,23 @@ class LoginViewModel extends _$LoginViewModel {
state = state.copyWith(isLoading: true, error: null);
try {
- final user = await ref.read(loginUseCaseProvider).execute(
- email: email,
- password: password,
- );
+ final user = await ref
+ .read(loginUseCaseProvider)
+ .execute(email: email, password: password);
+ if (!ref.mounted) return;
state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) {
// 格式校验失败(UseCase 层抛出)
+ if (!ref.mounted) return;
state = state.copyWith(error: e.message, isLoading: false);
} on ApiError catch (e) {
// 网络 / 服务端错误(Repository → SDK 透传)
+ if (!ref.mounted) return;
state = state.copyWith(error: e.displayMessage, isLoading: false);
} catch (e) {
// 兜底:防止未预期的异常导致 isLoading 死锁
+ if (!ref.mounted) return;
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
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