Merge remote-tracking branch 'origin/dev' into cody/netwrok_SDK

# Conflicts:
#	apps/im_app/lib/features/chat/presentation/chat_db_test_view_model.dart
#	apps/im_app/lib/features/login/presentation/login_view_model.dart

修复逻辑漏洞,性能优化
This commit is contained in:
Cody
2026-03-08 20:47:28 +08:00
88 changed files with 5695 additions and 593 deletions

View File

@@ -895,7 +895,7 @@ flowchart TD
│ │ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口 │ │ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表 │ │ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表
│ │ │ └── api_response_wrapper.dart # { code, message/msg, data } 信封解析 │ │ │ └── api_response_wrapper.dart # { code, message/msg, data } 响应包装解析
│ │ └── repositories/ │ │ └── repositories/
│ │ ├── networks_sdk_repository_impl.dart │ │ ├── networks_sdk_repository_impl.dart
│ │ └── networks_messaging_repository_impl.dart │ │ └── networks_messaging_repository_impl.dart
@@ -2299,8 +2299,8 @@ class LoginData {
User toEntity() => User(id: userId, email: email); // DTO → Domain Entity User toEntity() => User(id: userId, email: email); // DTO → Domain Entity
} }
// ── Request ── // ── Request(零样板:只需 @ApiRequest无需 @JsonSerializable / fromJson / toJson──
// @ApiRequest 自动生成 path / method / requestType / includeToken / fromJson 注册 // @ApiRequest 自动生成 path / method / requestType / includeToken / toJson / fromJson 注册
@ApiRequest( @ApiRequest(
path: ApiPaths.authLogin, // 路径统一在 core/foundation/api_paths.dart 管理 path: ApiPaths.authLogin, // 路径统一在 core/foundation/api_paths.dart 管理
@@ -2308,15 +2308,12 @@ class LoginData {
responseType: LoginData, responseType: LoginData,
requestType: ApiRequestType.login, requestType: ApiRequestType.login,
) )
@JsonSerializable()
class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi { class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi {
final String email; final String email;
final String password; final String password;
LoginRequest({required this.email, required this.password}); LoginRequest({required this.email, required this.password});
// 完毕toJson 由 mixin 从类字段自动生成fromJson 不需要Request 永远手动构造)
@override
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
} }
</code></pre> </code></pre>
@@ -2353,17 +2350,17 @@ final user = loginData?.toEntity(); // DTO → Domain Entity
<td></td> <td></td>
</tr> </tr>
<tr> <tr>
<td><strong>@ApiRequest + @JsonSerializable</strong></td> <td><strong>@ApiRequest(当前方案)</strong></td>
<td>字段 + 构造函数 + @ApiRequest + @JsonSerializable</td> <td>字段 + 构造函数 + @ApiRequest</td>
<td>path / method / requestType / includeToken / toJson / fromJson / fromJson 注册</td> <td>path / method / requestType / includeToken / toJson / fromJson 注册</td>
<td><strong></strong></td> <td><strong></strong></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p><strong>核心优势</strong></p> <p><strong>核心优势</strong></p>
<ul> <ul>
<li><strong>注解驱动</strong><code>@ApiRequest</code> 自动生成 mixin<code>@JsonSerializable</code> 自动生成 toJson/fromJson</li> <li><strong>注解驱动</strong><code>@ApiRequest</code> 一个注解自动生成 mixin(含 toJson无需 <code>@JsonSerializable</code></li>
<li><strong>自动注册</strong>fromJson 在首次请求时自动注册到全局注册表,无需手动 <code>registerApiResponses()</code></li> <li><strong>自动注册</strong>fromJson 在首次请求时自动注册到全局注册表,无需手动 <code>registerApiResponses()</code></li>
<li><strong>一个端点 = 一个文件</strong>Response DTO + Request 放在同一文件,打开即看全貌</li> <li><strong>一个端点 = 一个文件</strong>Response DTO + Request 放在同一文件,打开即看全貌</li>
<li><strong>傻瓜式使用</strong>:使用者只需关注业务字段和注解配置</li> <li><strong>傻瓜式使用</strong>:使用者只需关注业务字段和注解配置</li>
@@ -2447,36 +2444,27 @@ class ApiRequest {
<p>生成 <strong>mixin</strong>(非 extension因为 mixin 可以 override 基类方法、调用 <code>super</code>,并在 <code>parameters</code> getter 中自动注册 fromJson。</p> <p>生成 <strong>mixin</strong>(非 extension因为 mixin 可以 override 基类方法、调用 <code>super</code>,并在 <code>parameters</code> getter 中自动注册 fromJson。</p>
<p><strong>toJson 生成机制</strong>:生成器读取类的<strong>声明字段</strong>(非继承),直接在 mixin 中生成 Map 字面量。不依赖 <code>@JsonSerializable</code>,避免了继承属性被序列化导致的递归问题。支持 <code>@JsonKey(name: '...')</code> 字段重命名和 <code>@JsonKey(includeToJson: false)</code> 跳过字段。</p>
<pre><code class="language-dart">/// API 请求代码生成器 <pre><code class="language-dart">/// API 请求代码生成器
class ApiRequestGenerator extends GeneratorForAnnotation&lt;ApiRequest&gt; { class ApiRequestGenerator extends GeneratorForAnnotation&lt;ApiRequest&gt; {
@override @override
String generateForAnnotatedElement( String generateForAnnotatedElement(element, annotation, buildStep) {
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
final className = element.name; final className = element.name;
final path = annotation.read('path').stringValue; // ... 读取 path / method / responseType / requestType / includeToken ...
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');
// includeToken默认 login → false其余 → true // 从类的声明字段生成 toJson(),只序列化自身字段,不含继承属性
final includeTokenReader = annotation.peek('includeToken'); final toJsonBody = _buildToJsonBody(element, className);
final includeToken = (includeTokenReader != null &amp;&amp; !includeTokenReader.isNull)
? includeTokenReader.boolValue
: requestTypeName != 'login';
// 生成 mixin使用侧只需 `with _$XxxApi`
return ''' return '''
/// Generated by @ApiRequest for [$className]
mixin _\$${className}Api on ApiRequestable&lt;$responseTypeName&gt; { mixin _\$${className}Api on ApiRequestable&lt;$responseTypeName&gt; {
@override String get path =&gt; '$path'; @override String get path =&gt; '$path';
@override HttpMethod get method =&gt; HttpMethod.$methodName; @override HttpMethod get method =&gt; HttpMethod.$methodName;
@override ApiRequestType get requestType =&gt; ApiRequestType.$requestTypeName; @override ApiRequestType get requestType =&gt; ApiRequestType.$requestTypeName;
@override bool get includeToken =&gt; $includeToken; @override bool get includeToken =&gt; $includeToken;
@override @override
Map&lt;String, dynamic&gt; toJson() =&gt; $toJsonBody;
@override
Map&lt;String, dynamic&gt;? get parameters { Map&lt;String, dynamic&gt;? get parameters {
registerResponse&lt;$responseTypeName&gt;($responseTypeName.fromJson); registerResponse&lt;$responseTypeName&gt;($responseTypeName.fromJson);
return super.parameters; return super.parameters;
@@ -2484,16 +2472,28 @@ mixin _\$${className}Api on ApiRequestable&lt;$responseTypeName&gt; {
} }
'''; ''';
} }
/// 读取类的声明字段,生成 Map 字面量
/// 支持 @JsonKey(name: '...') 重命名
String _buildToJsonBody(ClassElement element, String className) {
final fields = element.fields.where((f) =&gt; !f.isStatic &amp;&amp; !f.isSynthetic);
// =&gt; {'email': (this as LoginRequest).email, 'password': ...}
}
} }
</code></pre> </code></pre>
<p><strong>关键设计</strong><code>parameters</code> getter 在首次请求时自动调用 <code>registerResponse</code>,将 <code>fromJson</code> 注册到全局注册表。无需手动注册,也无需 <code>registerApiResponses()</code> 启动函数。</p> <p><strong>关键设计</strong></p>
<ul>
<li><code>toJson()</code> 只序列化类自身声明的字段,不含 <code>ApiRequestable</code> 的继承属性path / method / parameters 等),避免递归</li>
<li><code>parameters</code> getter 在首次请求时自动调用 <code>registerResponse</code>,将 Response 的 <code>fromJson</code> 注册到全局注册表</li>
<li>Upload 等特殊请求在类中 override <code>toJson()</code>,类的 override 优先于 mixin</li>
</ul>
<h6>4.3 build.yaml 配置</h6> <h6>4.3 build.yaml 配置</h6>
<p><strong>文件:<code>packages/networks_sdk/build.yaml</code></strong></p> <p><strong>文件:<code>packages/networks_sdk/build.yaml</code></strong></p>
<p>使用 <code>SharedPartBuilder</code>,与 <code>@JsonSerializable</code> 共享同一个 <code>.g.dart</code> 文件,无需额外 part 指令。</p> <p>使用 <code>SharedPartBuilder</code>,与 <code>@JsonSerializable</code>Response DTO 用)共享同一个 <code>.g.dart</code> 文件,无需额外 part 指令。</p>
<pre><code class="language-yaml">builders: <pre><code class="language-yaml">builders:
api_request: api_request:
@@ -2522,12 +2522,12 @@ melos run gen
<h6>4.5 更多使用示例</h6> <h6>4.5 更多使用示例</h6>
<p>所有示例遵循同一模式:<code>@ApiRequest</code> + <code>@JsonSerializable</code> + <code>extends ApiRequestable&lt;T&gt; with _$XxxApi</code></p> <p>所有示例遵循同一模式:<code>@ApiRequest</code> + <code>extends ApiRequestable&lt;T&gt; with _$XxxApi</code>。Request 类无需 <code>@JsonSerializable</code></p>
<p><strong>发送消息请求POST</strong></p> <p><strong>发送消息请求POST + @JsonKey 字段重命名</strong></p>
<pre><code class="language-dart">// data/remote/send_message_request.dart <pre><code class="language-dart">// data/remote/send_message_request.dart
// ── Response DTO ── // ── Response DTO(仍用 @JsonSerializable──
@JsonSerializable() @JsonSerializable()
class SendMessageData { class SendMessageData {
@JsonKey(name: 'message_id') @JsonKey(name: 'message_id')
@@ -2539,25 +2539,23 @@ class SendMessageData {
_$SendMessageDataFromJson(json); _$SendMessageDataFromJson(json);
} }
// ── Request ── // ── Request(零样板)──
@ApiRequest(path: ApiPaths.chatSendMessage, responseType: SendMessageData) @ApiRequest(path: ApiPaths.chatSendMessage, responseType: SendMessageData)
@JsonSerializable()
class SendMessageRequest extends ApiRequestable&lt;SendMessageData&gt; class SendMessageRequest extends ApiRequestable&lt;SendMessageData&gt;
with _$SendMessageRequestApi { with _$SendMessageRequestApi {
@JsonKey(name: 'chat_id') @JsonKey(name: 'chat_id') // 生成器会读取JSON 键名为 'chat_id'
final String chatId; final String chatId;
final String content; final String content;
SendMessageRequest({required this.chatId, required this.content}); SendMessageRequest({required this.chatId, required this.content});
@override // toJson 自动生成:{'chat_id': chatId, 'content': content}
Map&lt;String, dynamic&gt; toJson() =&gt; _$SendMessageRequestToJson(this);
} }
</code></pre> </code></pre>
<p><strong>获取用户资料GET靠 token 标识当前用户,无需传参):</strong></p> <p><strong>获取用户资料GET靠 token 标识当前用户,无需传参):</strong></p>
<pre><code class="language-dart">// data/remote/get_profile_request.dart <pre><code class="language-dart">// data/remote/get_profile_request.dart
@JsonSerializable() @JsonSerializable(createToJson: false) // 只需反序列化
class ProfileData { class ProfileData {
@JsonKey(name: 'user_id') @JsonKey(name: 'user_id')
final String userId; final String userId;
@@ -2573,13 +2571,9 @@ class ProfileData {
} }
@ApiRequest(path: ApiPaths.userProfile, method: HttpMethod.get, responseType: ProfileData) @ApiRequest(path: ApiPaths.userProfile, method: HttpMethod.get, responseType: ProfileData)
@JsonSerializable()
class GetProfileRequest extends ApiRequestable&lt;ProfileData&gt; class GetProfileRequest extends ApiRequestable&lt;ProfileData&gt;
with _$GetProfileRequestApi { with _$GetProfileRequestApi {
GetProfileRequest(); // 无参数 — GET /user/profile 靠 token 获取当前用户 GetProfileRequest(); // 无参数 — toJson 自动生成空 map
@override
Map&lt;String, dynamic&gt; toJson() =&gt; _$GetProfileRequestToJson(this);
} }
</code></pre> </code></pre>
@@ -2622,8 +2616,8 @@ class UploadFileRequest extends ApiRequestable&lt;UploadResult&gt;
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;"> <div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
<p><strong>核心价值</strong></p> <p><strong>核心价值</strong></p>
<ul style="margin-bottom: 0;"> <ul style="margin-bottom: 0;">
<li><strong>极简使用</strong>:字段 + 构造函数 + <code>@ApiRequest</code> + <code>@JsonSerializable</code></li> <li><strong>极简使用</strong>:字段 + 构造函数 + <code>@ApiRequest</code>Request 无需 <code>@JsonSerializable</code>、无需 <code>fromJson</code>、无需手写 <code>toJson</code></li>
<li><strong>零维护</strong>path / method / requestType / includeToken / fromJson 注册 全部自动生成</li> <li><strong>零维护</strong>path / method / requestType / includeToken / toJson / fromJson 注册 全部自动生成</li>
<li><strong>类型安全</strong>:泛型 <code>ApiRequestable&lt;T&gt;</code> + <code>responseType</code> 编译期检查</li> <li><strong>类型安全</strong>:泛型 <code>ApiRequestable&lt;T&gt;</code> + <code>responseType</code> 编译期检查</li>
<li><strong>一个端点 = 一个文件</strong>Response DTO + Request 放在同一文件,打开即看全貌</li> <li><strong>一个端点 = 一个文件</strong>Response DTO + Request 放在同一文件,打开即看全貌</li>
</ul> </ul>
@@ -3211,6 +3205,7 @@ flowchart LR
<p><strong>两大核心逻辑</strong></p> <p><strong>两大核心逻辑</strong></p>
<p>1. <strong>MVVM 分层职责</strong>Viewview/只负责渲染和用户交互ViewModelpresentation/持有状态并处理业务逻辑Modelmodel/ + entities/)定义数据结构 —— 三者通过 Riverpod Provider 连接,职责严格分离。</p> <p>1. <strong>MVVM 分层职责</strong>Viewview/只负责渲染和用户交互ViewModelpresentation/持有状态并处理业务逻辑Modelmodel/ + entities/)定义数据结构 —— 三者通过 Riverpod Provider 连接,职责严格分离。</p>
<p>2. <strong>Riverpod 单向数据流</strong>:用户操作 → <code>ref.read(vm.notifier).action()</code> → ViewModel 处理逻辑 → <code>state = newState</code><code>ref.watch(vm)</code> 检测变化 → View 自动 rebuild。数据永远单向流动UI 永远是状态的函数。</p> <p>2. <strong>Riverpod 单向数据流</strong>:用户操作 → <code>ref.read(vm.notifier).action()</code> → ViewModel 处理逻辑 → <code>state = newState</code><code>ref.watch(vm)</code> 检测变化 → View 自动 rebuild。数据永远单向流动UI 永远是状态的函数。</p>
<p>3. <strong>Widget 纯展示原则</strong>ViewWidget层对业务数据严格只读。所有逻辑导航、CRUD、状态变更、条件判断必须在 ViewModel 中完成View 只调用 ViewModel 方法并渲染返回的 State。包括 demo/测试页面也不例外。</p>
</blockquote> </blockquote>
<hr> <hr>
@@ -5698,7 +5693,7 @@ class MessageLocalDataSource {
<ul> <ul>
<li><strong>一个端点 = 一个文件</strong>Response DTO + Request 类放在同一文件中</li> <li><strong>一个端点 = 一个文件</strong>Response DTO + Request 类放在同一文件中</li>
<li><strong>Repository 直接调 NetworksSdkApi</strong>:无需 RemoteDataSource 中间层</li> <li><strong>Repository 直接调 NetworksSdkApi</strong>:无需 RemoteDataSource 中间层</li>
<li><strong>@ApiRequest 注解 + 代码生成</strong>:自动实现 path / method / fromJson 注册</li> <li><strong>@ApiRequest 注解 + 代码生成</strong>:自动实现 path / method / toJson / fromJson 注册Request 无需 @JsonSerializable</li>
</ul> </ul>
<pre><code>// 示例Repository 直接调用 Request <pre><code>// 示例Repository 直接调用 Request
@@ -5907,7 +5902,7 @@ flowchart LR
│ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口executeRequest / executeDownload │ │ └── networks_sdk_method_channel_datasource.dart # 统一执行入口executeRequest / executeDownload
│ ├── dto/ │ ├── dto/
│ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表 + 解码扩展 │ │ ├── api_requestable.dart # 请求基类 + fromJson 注册表 + 解码扩展
│ │ └── api_response_wrapper.dart # { code, message/msg, data } 信封解析 │ │ └── api_response_wrapper.dart # { code, message/msg, data } 响应包装解析
│ └── repositories/ │ └── repositories/
│ ├── networks_sdk_repository_impl.dart │ ├── networks_sdk_repository_impl.dart
│ └── networks_messaging_repository_impl.dart │ └── networks_messaging_repository_impl.dart
@@ -5954,7 +5949,7 @@ flowchart LR
<tr><td>WebSocket 连接</td><td>SocketClient 内部管理(连接/心跳/重连)</td><td>调 connect/disconnect/send</td></tr> <tr><td>WebSocket 连接</td><td>SocketClient 内部管理(连接/心跳/重连)</td><td>调 connect/disconnect/send</td></tr>
<tr><td>WebSocket 心跳</td><td>双层心跳自动管理(底层 ping 5s + 应用层 10s</td><td>无需关心</td></tr> <tr><td>WebSocket 心跳</td><td>双层心跳自动管理(底层 ping 5s + 应用层 10s</td><td>无需关心</td></tr>
<tr><td>WebSocket 重连</td><td>指数退避自动重连1s→2s→4s→8s→16s→30s</td><td>无需关心</td></tr> <tr><td>WebSocket 重连</td><td>指数退避自动重连1s→2s→4s→8s→16s→30s</td><td>无需关心</td></tr>
<tr><td>WebSocket 生命周期</td><td>提供 onEnterForeground/Background</td><td>App 层调用AppLifecycleListener</td></tr> <tr><td>WebSocket 生命周期</td><td>提供 onEnterForeground/Background</td><td>App 层调用AppLifecycleListener。本项目 disconnectInBackground=false所有平台后台保活、心跳不停</td></tr>
<tr><td>WebSocket 消息解析</td><td>JSON.decode → Stream 输出</td><td>App 层按 type 过滤 + DTO 解析</td></tr> <tr><td>WebSocket 消息解析</td><td>JSON.decode → Stream 输出</td><td>App 层按 type 过滤 + DTO 解析</td></tr>
<tr><td>Riverpod</td><td>无依赖</td><td>Provider 包装 NetworksSdkApi / SocketClient</td></tr> <tr><td>Riverpod</td><td>无依赖</td><td>Provider 包装 NetworksSdkApi / SocketClient</td></tr>
</tbody> </tbody>
@@ -6529,7 +6524,7 @@ class UploadFileRequest extends ApiRequestable&lt;UploadResult&gt;
@override @override
Object? get uploadData =&gt; data; // Uint8List 直接作为 body Object? get uploadData =&gt; data; // Uint8List 直接作为 body
/// S3 返回 204 No Content 或 XML不是标准 { code, msg, data } 信封 /// S3 返回 204 No Content 或 XML不是标准 { code, msg, data } 响应格式
/// 必须 override decodeResponse /// 必须 override decodeResponse
@override @override
S3UploadResponse? decodeResponse(Response response) { S3UploadResponse? decodeResponse(Response response) {
@@ -6798,12 +6793,54 @@ final user = await db.selectFirst(appDb.users, (t) =&gt; t.uid.equals(uid));
<p>端对端加密 SDK同时处理 Dart 侧加解密和 Native 侧密钥同步iOS App Group 用于推送通知解密):</p> <p>端对端加密 SDK同时处理 Dart 侧加解密和 Native 侧密钥同步iOS App Group 用于推送通知解密):</p>
<ul> <ul>
<li><code>cipher_guard_sdk_api.dart</code>:公开 API 接口Facade</li> <li><code>cipher_guard_sdk_api.dart</code>:公开 API 接口Facade</li>
<li><code>encryption_flutter_service.dart</code>RSA/AES 双层加解密(纯 Dart 实现,基于 pointycastle + encrypt</li> <li><code>encryption_flutter_service.dart</code>RSA/AES 双层加解密(纯 Dart 实现,基于 pointycastle + encrypt,含性能优化</li>
<li><code>encryption_method_channel.dart</code>Native 密钥同步通道iOS App Group 共享密钥供 Notification Extension 解密)</li> <li><code>encryption_method_channel.dart</code>Native 密钥同步通道iOS App Group 共享密钥供 Notification Extension 解密)</li>
<li>Domain 实体:<code>RsaKeyPair</code> / <code>SessionKey</code> / <code>EncryptedMessage</code> / <code>ChatEncryptionKey</code></li> <li>Domain 实体:<code>RsaKeyPair</code> / <code>SessionKey</code> / <code>EncryptedMessage</code> / <code>ChatEncryptionKey</code></li>
<li><code>android/</code> + <code>ios/</code>Plugin 注册入口,原生侧实现密钥写入 App Group</li> <li><code>android/</code> + <code>ios/</code>Plugin 注册入口,原生侧实现密钥写入 App Group</li>
</ul> </ul>
<h5>加解密性能优化</h5>
<p><code>encryption_flutter_service.dart</code> 针对 IM 高频加解密场景做了四项优化:</p>
<table>
<thead><tr>
<th>优化项</th>
<th>方案</th>
<th>效果</th>
</tr></thead>
<tbody>
<tr>
<td><strong>RSA 密钥生成异步化</strong></td>
<td><code>generateRsaKeyPairAsync</code> 使用 <code>Isolate.run()</code> 在独立线程生成</td>
<td>主线程零阻塞,不卡 UI2048-bit 约 200-500ms</td>
</tr>
<tr>
<td><strong>派生密钥 LRU 缓存</strong></td>
<td><code>_derivedKeyCache</code>Map上限 64 条),缓存键 = <code>sessionKey:round:mode</code>,满时淘汰最早条目</td>
<td>同一 round 的加解密只算一次 KDF后续直接命中缓存</td>
</tr>
<tr>
<td><strong>Random.secure() 复用</strong></td>
<td>静态 <code>_secureRandom</code> 单例,所有 IV / 随机数生成共用</td>
<td>避免每次 <code>Random.secure()</code> 构造开销</td>
</tr>
<tr>
<td><strong>KDF 双模式</strong></td>
<td><code>KdfMode.md5</code>(默认,兼容既有数据)和 <code>KdfMode.pbkdf2</code>PBKDF2-HMAC-SHA256可配迭代次数</td>
<td>默认快速兼容,可选安全增强(防暴力破解)</td>
</tr>
</tbody>
</table>
<p>构造时可配置 KDF 模式和 PBKDF2 迭代次数:</p>
<pre><code>EncryptionFlutterService(
kdfMode: KdfMode.md5, // 默认,兼容既有数据
pbkdf2Iterations: 10000, // PBKDF2 模式下的迭代次数
)</code></pre>
<p><code>clearDerivedKeyCache()</code> 可在 session key 轮换时手动清空缓存。</p>
<h3 id="7-4-l10n">7.4 多语言国际化packages/l10n_sdk/</h3> <h3 id="7-4-l10n">7.4 多语言国际化packages/l10n_sdk/</h3>
<p>已提取为独立 Package被 core/ui 和 Feature 层单向引用foundation 不依赖它)。</p> <p>已提取为独立 Package被 core/ui 和 Feature 层单向引用foundation 不依赖它)。</p>

View File

@@ -0,0 +1,31 @@
{
"code": 0,
"message": "OK",
"data": {
"account_id": "1713925030yFMUBu",
"profile": {
"uid": 2137067,
"uuid": "1713925030yFMUBu",
"last_online": 1772819822,
"profile_pic": "Image/7e/f5/7ef5b60dd83a34a74c164a21fbd1f098/7ef5b60dd83a34a74c164a21fbd1f098.jpg",
"profile_pic_gaussian": "",
"nickname": "Happi哈比",
"contact": "86552205",
"country_code": "+65",
"email": "happi@winwayinfo.com",
"recovery_email": "",
"username": "happi",
"bio": "",
"relationship": 2,
"user_alias": null,
"channel_id": 1,
"channel_group_id": 1,
"hint": "1111"
},
"nonce": "",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjIxMzcwNjcsInVkaWQiOjI2NjI2MzcsInNpZCI6IjE3NzI4NjU0NDc0Z1c2VWc9PSIsImlzcyI6ImFiYy5jb20iLCJhdWQiOlsidXNlciJdLCJleHAiOjE3NzQxNjE0NDcsIm5iZiI6MTc3Mjg2NTQ0NywiaWF0IjoxNzcyODY1NDQ3fQ.gUL6hyKgyPP8Tw9y7kRSq-ndNKfV9uGFhU4YKiQzg0I",
"refresh_token": "ps0FF3XayvnJB_P8Cnfu7w-uD781b1-vfmUjbrONZxI=",
"device_id": "SP1A.210812",
"login_data": ""
}
}

View File

@@ -103,7 +103,7 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
onDecryptResponse: null, // TODO: 接入 cipher_guard_sdk 后注入响应解密回调 onDecryptResponse: null, // TODO: 接入 cipher_guard_sdk 后注入响应解密回调
onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等) onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等)
onTransformResponse: onTransformResponse:
null, // TODO: 如后端信封结构非标准,在此归一化为 { code, data, message } null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message }
onGetTokenExpiry: parseJwtExpiry, onGetTokenExpiry: parseJwtExpiry,
maxRetries: AppConstants.maxRetries, maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay, retryBaseDelay: AppConstants.retryBaseDelay,
@@ -214,6 +214,7 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
final manager = SocketManager( final manager = SocketManager(
client: client, client: client,
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl), wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调 onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
onBeforeReconnect: () async { onBeforeReconnect: () async {
// 重连前检查 token 是否即将过期,是则主动刷新 // 重连前检查 token 是否即将过期,是则主动刷新
@@ -327,9 +328,11 @@ String _buildWsUrl(String httpBaseUrl) {
// //
// WidgetsBindingObserverApp 层 app.dart // WidgetsBindingObserverApp 层 app.dart
// → SocketManager.onEnterBackground() // → SocketManager.onEnterBackground()
// disconnectInBackground=true disconnect默认移动端省电 // disconnectInBackground=false → 完全保活,心跳不停(本项目默认
// disconnectInBackground=false → 完全保活,不断连不暂停心跳(桌面端 // disconnectInBackground=true → disconnect + 暂停心跳(省电模式
// → SocketManager.onEnterForeground() → onBeforeReconnect → reconnect // → SocketManager.onEnterForeground()
// 保活模式 → 检查连接健康,异常则重连
// 断连模式 → onBeforeReconnect → reconnect
// //
// Token 刷新 → WebSocket 同步链路: // Token 刷新 → WebSocket 同步链路:
// //
@@ -524,5 +527,5 @@ String _buildWsUrl(String httpBaseUrl) {
// Upload B: 二进制上传到 S3 presigned URL // Upload B: 二进制上传到 S3 presigned URL
// @override String get path => presignedURL; // 完整 URL不拼 baseURL // @override String get path => presignedURL; // 完整 URL不拼 baseURL
// @override Object? get uploadData => bytes; // Uint8List // @override Object? get uploadData => bytes; // Uint8List
// @override decodeResponse(response) { ... } // S3 不走标准信封 // @override decodeResponse(response) { ... } // S3 不走标准响应格式
// //

View File

@@ -16,7 +16,7 @@ typedef MessageTransformer =
/// ///
/// 在 SocketClientSDK 底层能力)之上封装: /// 在 SocketClientSDK 底层能力)之上封装:
/// - 连接/断连生命周期(登录连接、登出断连) /// - 连接/断连生命周期(登录连接、登出断连)
/// - 前后台生命周期(后台断连省电、前台自动重连 /// - 前后台生命周期(两种模式:后台断连 / 后台保活
/// - 网络状态响应(断网断连、恢复网络立即重连) /// - 网络状态响应(断网断连、恢复网络立即重连)
/// - 操作前置检查(网络可用性 + 后台状态) /// - 操作前置检查(网络可用性 + 后台状态)
/// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等) /// - 消息预处理管道(通过 [onMessageTransform] 回调注入解密等)
@@ -38,13 +38,13 @@ typedef MessageTransformer =
/// ``` /// ```
/// 登录成功 → connect(token) → 前置检查 → 建立连接 /// 登录成功 → connect(token) → 前置检查 → 建立连接
/// ///
/// ── disconnectInBackground = true默认,移动端)── /// ── disconnectInBackground = true后台断连模式)──
/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电) /// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电)
/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连 /// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连
/// ///
/// ── disconnectInBackground = false桌面端)── /// ── disconnectInBackground = false后台保活模式,本项目默认)──
/// App 进后台 → onEnterBackground() → 不操作,完全保活 /// App 进后台 → onEnterBackground() → 不操作,心跳不停、连接不断
/// App 回前台 → onEnterForeground() → 不操作(连接始终在线) /// App 回前台 → onEnterForeground() → 检查连接健康,异常则重连
/// ///
/// 网络丢失 → handleNetworkLost() → 断开连接 /// 网络丢失 → handleNetworkLost() → 断开连接
/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连 /// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连
@@ -86,9 +86,9 @@ class SocketManager {
/// 进后台时是否断开连接 /// 进后台时是否断开连接
/// ///
/// true默认— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。 /// trueSDK 默认)— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。
/// false — 后台保持连接(适用于桌面端或需要后台实时推送的场景) /// false(本项目使用)— 后台保持连接,心跳不停、请求不停,最大程度保活
/// 设为 false 时,后台仅暂停心跳,不主动断连。 /// 回前台时检查连接健康,异常则触发重连。
final bool disconnectInBackground; final bool disconnectInBackground;
/// 日志回调 /// 日志回调
@@ -147,7 +147,7 @@ class SocketManager {
_reconnectOnForeground = false; _reconnectOnForeground = false;
_reconnectOnNetworkRestore = false; _reconnectOnNetworkRestore = false;
// 前置检查:移动端模式下在后台不连接(省电) // 前置检查:后台断连模式下在后台不连接(省电)
if (_isInBackground && disconnectInBackground) { if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true; _reconnectOnForeground = true;
_log('In background, defer connect to foreground'); _log('In background, defer connect to foreground');
@@ -200,18 +200,18 @@ class SocketManager {
// ── 前后台生命周期 ──────────────────────────────────────────────────────── // ── 前后台生命周期 ────────────────────────────────────────────────────────
// //
// 后台 → 断连(省电省流量)或保持连接(桌面端 // 后台 → 保活(心跳不停、连接不断)或断连(省电模式
// 前台 → 自动重连(如果之前有连接) // 前台 → 检查连接健康 / 自动重连
/// App 进后台 /// App 进后台
/// ///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。 /// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
/// ///
/// [disconnectInBackground] 为 true 时(默认,移动端 /// [disconnectInBackground] 为 false 时(后台保活,本项目默认
/// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
///
/// [disconnectInBackground] 为 false 时(桌面端):
/// 不断连、不暂停心跳WebSocket 完全保活。 /// 不断连、不暂停心跳WebSocket 完全保活。
///
/// [disconnectInBackground] 为 true 时(后台断连模式):
/// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
void onEnterBackground() { void onEnterBackground() {
_isInBackground = true; _isInBackground = true;
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连) // 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
@@ -219,12 +219,12 @@ class SocketManager {
_foregroundReconnectTimer = null; _foregroundReconnectTimer = null;
if (!disconnectInBackground) { if (!disconnectInBackground) {
// 桌面端模式:不断连、不暂停心跳,完全保活 // 后台保活模式:不断连、不暂停心跳,不通知 SocketClient
_log('Entering background, keeping connection alive'); _log('Entering background, keeping connection alive');
return; return;
} }
// 移动端模式:通知 SocketClient 进后台(暂停心跳) // 后台断连模式:通知 SocketClient 进后台(暂停心跳)
_client.onEnterBackground(); _client.onEnterBackground();
if (_lastToken == null) return; // 未登录,无需处理 if (_lastToken == null) return; // 未登录,无需处理
@@ -240,50 +240,76 @@ class SocketManager {
} }
} }
/// App 回前台 → 自动重连(如果之前后台断连) /// App 回前台
/// ///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。 /// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.resumed] 时调用。
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。 ///
/// 后台保活模式disconnectInBackground=false
/// 检查连接健康,如果后台期间连接意外断开则自动重连。
///
/// 后台断连模式disconnectInBackground=true
/// 通知 SocketClient 恢复心跳,然后重新建立连接。
void onEnterForeground() { void onEnterForeground() {
_isInBackground = false; _isInBackground = false;
// 只在移动端模式(后台曾断连/暂停心跳)时通知 SocketClient 恢复
if (disconnectInBackground) { if (disconnectInBackground) {
// 后台断连模式:通知 SocketClient 恢复心跳
_client.onEnterForeground(); _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) { if (_reconnectOnForeground && _lastToken != null) {
// 后台断连模式:之前后台断连过,需要重连
_reconnectOnForeground = false; _reconnectOnForeground = false;
_log('Returning to foreground, reconnecting...'); _log('Returning to foreground, reconnecting...');
// 延迟 500ms 等待网络稳定,通过 Timer 跟踪以便进后台时取消 _scheduleReconnect();
_foregroundReconnectTimer?.cancel(); }
_foregroundReconnectTimer = Timer( }
const Duration(milliseconds: 500),
() async { /// 延迟 500ms 后执行重连
_foregroundReconnectTimer = null; ///
// 双重保险:回调执行时再次检查后台状态 /// 等待网络稳定,通过 Timer 跟踪以便进后台时取消。
if (_isInBackground) { void _scheduleReconnect() {
_reconnectOnForeground = true; _foregroundReconnectTimer?.cancel();
_log('Went back to background during delay, skip reconnect'); _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; return;
} }
if (!_client.isConnected && _lastToken != null) { // 重连前钩子:刷新即将过期的 token 等
// 前置检查:网络可用性 await onBeforeReconnect?.call();
if (!await _isNetworkAvailable()) { // token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
_reconnectOnNetworkRestore = true; if (_lastToken != null && !_client.isConnected) {
_log('Network unavailable, defer reconnect to network restore'); _client.connect(_wsUrl, token: _lastToken!);
return;
}
// 重连前钩子:刷新即将过期的 token 等
await onBeforeReconnect?.call();
// token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
if (_lastToken != null && !_client.isConnected) {
_client.connect(_wsUrl, token: _lastToken!);
}
} }
}, }
); },
} );
} }
// ── 网络状态变化 ────────────────────────────────────────────────────────── // ── 网络状态变化 ──────────────────────────────────────────────────────────
@@ -328,7 +354,7 @@ class SocketManager {
if (_reconnectOnNetworkRestore && _lastToken != null) { if (_reconnectOnNetworkRestore && _lastToken != null) {
_reconnectOnNetworkRestore = false; _reconnectOnNetworkRestore = false;
// 移动端模式:在后台不重连,等前台恢复时再连 // 后台断连模式:在后台不重连,等前台恢复时再连
if (_isInBackground && disconnectInBackground) { if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true; _reconnectOnForeground = true;
_log('Network restored but in background, defer to foreground'); _log('Network restored but in background, defer to foreground');
@@ -415,7 +441,10 @@ class SocketManager {
/// 发送前置检查 /// 发送前置检查
/// ///
/// 两重保险:连接状态 + 后台状态。 /// 后台保活模式disconnectInBackground=false只检查连接状态
/// 后台也能正常发送。
///
/// 后台断连模式disconnectInBackground=true额外检查后台状态
/// 后台已断连所以 isConnected 通常就能拦住, /// 后台已断连所以 isConnected 通常就能拦住,
/// 但显式检查 _isInBackground 防止边界情况遗漏。 /// 但显式检查 _isInBackground 防止边界情况遗漏。
bool _canSend() { bool _canSend() {
@@ -424,7 +453,7 @@ class SocketManager {
return false; return false;
} }
if (_isInBackground && disconnectInBackground) { if (_isInBackground && disconnectInBackground) {
_log('In background, skip send'); _log('In background (disconnect mode), skip send');
return false; return false;
} }
return true; return true;

View File

@@ -1,27 +1,82 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/tables/favourites.dart';
import 'package:im_app/data/local/drift/tables/sounds.dart';
import 'package:im_app/data/local/drift/tables/tags.dart';
import 'package:im_app/data/local/drift/tables/pending_friend_request_histories.dart';
import 'package:im_app/data/local/drift/tables/message.dart';
import 'package:im_app/data/local/drift/tables/recent_mini_apps.dart';
import 'package:im_app/data/local/drift/tables/retries.dart';
import 'package:im_app/data/local/drift/tables/groups.dart';
import 'package:im_app/data/local/drift/tables/favorite_mini_apps.dart';
import 'package:im_app/data/local/drift/tables/discover_mini_apps.dart';
import 'package:im_app/data/local/drift/tables/chat_categories.dart';
import 'package:im_app/data/local/drift/tables/chat_bots.dart';
import 'package:im_app/data/local/drift/tables/favourite_details.dart';
import 'package:im_app/data/local/drift/tables/user_request_histories.dart';
import 'package:im_app/data/local/drift/tables/workspaces.dart';
import 'package:im_app/data/local/drift/tables/users.dart'; import 'package:im_app/data/local/drift/tables/users.dart';
import 'package:im_app/data/local/drift/tables/test_tables.dart'; import 'package:im_app/data/local/drift/tables/explore_mini_apps.dart';
import 'package:im_app/data/local/drift/tables/call_logs.dart';
import 'package:im_app/data/local/drift/tables/chats.dart';
part 'app_database.g.dart'; part 'app_database.g.dart';
@DriftDatabase(tables: [Users,TestTables]) //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 { class AppDatabase extends _$AppDatabase {
static Map<Type, TableInfo> getTableRegistry(GeneratedDatabase database) { static Map<Type, TableInfo> getTableRegistry(GeneratedDatabase database) {
if (database is! AppDatabase) { if (database is! AppDatabase) {
return { return {};
};
} }
return { return {
User: database.users, DriftFavourite: database.favourites,
TestTable: database.testTables, DriftSound: database.sounds,
DriftTag: database.tags,
DriftPendingFriendRequestHistory: database.pendingFriendRequestHistories,
DriftMessage: database.messages,
DriftRecentMiniApp: database.recentMiniApps,
DriftRetry: database.retries,
DriftGroup: database.groups,
DriftFavoriteMiniApp: database.favoriteMiniApps,
DriftDiscoverMiniApp: database.discoverMiniApps,
DriftChatCategory: database.chatCategories,
DriftChatBot: database.chatBots,
DriftFavouriteDetail: database.favouriteDetails,
DriftUserRequestHistory: database.userRequestHistories,
DriftWorkspace: database.workspaces,
DriftUser: database.users,
DriftExploreMiniApp: database.exploreMiniApps,
DriftCallLog: database.callLogs,
DriftChat: database.chats,
}; };
} }
AppDatabase(super.e); AppDatabase(super.e);
//升级数据库用此版本号
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -30,9 +85,20 @@ class AppDatabase extends _$AppDatabase {
await m.createAll(); await m.createAll();
}, },
onUpgrade: (m, from, to) async { onUpgrade: (m, from, to) async {
// 自动检测并添加缺失列 // Create any new tables that don't exist yet
for (final table in allTables) { for (final table in allTables) {
//取原来的字段 final existingTables = await m.database
.customSelect(
"SELECT name FROM sqlite_master WHERE type='table' AND name='${table.actualTableName}'",
)
.get();
if (existingTables.isEmpty) {
await m.createTable(table);
continue;
}
// Auto-detect and add missing columns
final existingColumns = await m.database final existingColumns = await m.database
.customSelect('PRAGMA table_info(${table.actualTableName})') .customSelect('PRAGMA table_info(${table.actualTableName})')
.get(); .get();
@@ -42,7 +108,6 @@ class AppDatabase extends _$AppDatabase {
for (final column in table.$columns) { for (final column in table.$columns) {
if (!existingNames.contains(column.name)) { if (!existingNames.contains(column.name)) {
//字段缺失,添加。
await m.addColumn(table, column); await m.addColumn(table, column);
} }
} }
@@ -50,6 +115,4 @@ class AppDatabase extends _$AppDatabase {
}, },
); );
} }
} }

View File

@@ -0,0 +1,24 @@
import 'package:drift/drift.dart';
@DataClassName('DriftCallLog')
class CallLogs extends Table {
TextColumn get id => text()();
IntColumn get callerId => integer().nullable()();
IntColumn get receiverId => integer().nullable()();
IntColumn get chatId => integer().nullable()();
IntColumn get duration => integer().nullable()();
IntColumn get videoCall => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get endedAt => integer().nullable()();
IntColumn get status => integer().nullable()();
IntColumn get isDeleted => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
IntColumn get isRead => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'call_log';
}

View File

@@ -0,0 +1,33 @@
import 'package:drift/drift.dart';
@DataClassName('DriftChatBot')
class ChatBots extends Table {
IntColumn get id => integer()();
TextColumn get name => text().nullable()();
TextColumn get username => text().nullable()();
IntColumn get botUserId => integer().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().nullable()();
TextColumn get description => text().nullable()();
TextColumn get token => text().nullable()();
IntColumn get flag => integer().nullable()();
IntColumn get status => integer().nullable()();
TextColumn get webhook => text().withDefault(const Constant(''))();
TextColumn get commands => text().withDefault(const Constant('[]'))();
TextColumn get banner => text().nullable()();
IntColumn get channelId => integer().nullable()();
IntColumn get channelGroupId => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
TextColumn get internalWebhook => text().nullable()();
IntColumn get mode => integer().nullable()();
TextColumn get redirectUrl => text().nullable()();
IntColumn get isInvitable => integer().nullable()();
IntColumn get isAllowForward => integer().nullable()();
TextColumn get tips => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'chat_bot';
}

View File

@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
@DataClassName('DriftChatCategory')
class ChatCategories extends Table {
IntColumn get id => integer()();
TextColumn get name => text().nullable()();
TextColumn get includedChatIds => text().nullable()();
TextColumn get excludedChatIds => text().nullable()();
IntColumn get seq => integer().nullable()();
IntColumn get isHide => integer().withDefault(const Constant(0))();
IntColumn get createdAt => integer().withDefault(const Constant(0))();
IntColumn get updatedAt => integer().withDefault(const Constant(0))();
IntColumn get deletedAt => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'chat_category';
}

View File

@@ -0,0 +1,59 @@
import 'package:drift/drift.dart';
@DataClassName('DriftChat')
class Chats extends Table {
IntColumn get id => integer()();
IntColumn get typ => integer().nullable()();
IntColumn get lastId => integer().nullable()();
IntColumn get lastTyp => integer().nullable()();
TextColumn get lastMsg => text().nullable()();
IntColumn get lastTime => integer().nullable()();
IntColumn get lastPos => integer().withDefault(const Constant(0))();
IntColumn get firstPos => integer().withDefault(const Constant(-1))();
IntColumn get msgIdx => integer().nullable()();
TextColumn get profile => text().nullable()();
TextColumn get pin => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().withDefault(const Constant(''))();
TextColumn get name => text().nullable()();
IntColumn get userId => integer().nullable()();
IntColumn get chatId => integer().nullable()();
IntColumn get friendId => integer().nullable()();
IntColumn get sort => integer().nullable()();
IntColumn get unreadNum => integer().nullable()();
IntColumn get unreadCount => integer().nullable()();
IntColumn get hideChatMsgIdx => integer().nullable()();
IntColumn get readChatMsgIdx => integer().nullable()();
IntColumn get otherReadIdx => integer().nullable()();
TextColumn get unreadAtMsgIdx => text().nullable()();
IntColumn get deleteTime => integer().nullable()();
IntColumn get addIndex => integer().nullable()();
IntColumn get flag => integer().withDefault(const Constant(0))();
IntColumn get flagMy => integer().nullable()();
IntColumn get autoDeleteInterval => integer().nullable()();
IntColumn get mute => integer().nullable()();
IntColumn get verified => integer().nullable()();
IntColumn get createTime => integer().nullable()();
IntColumn get startIdx => integer().nullable()();
IntColumn get isReadMsg => integer().nullable()();
TextColumn get translateOutgoing => text().withDefault(const Constant(''))();
TextColumn get translateIncoming => text().withDefault(const Constant(''))();
IntColumn get incomingIdx => integer().withDefault(const Constant(0))();
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))();
TextColumn get chatKey => text().withDefault(const Constant(''))();
TextColumn get activeChatKey => text().withDefault(const Constant(''))();
IntColumn get coverIdx => integer().withDefault(const Constant(0))();
IntColumn get round => integer().withDefault(const Constant(0))();
IntColumn get workspaceId => integer().withDefault(const Constant(0))();
IntColumn get localPermission => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'chat';
}

View File

@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
@DataClassName('DriftDiscoverMiniApp')
class DiscoverMiniApps extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
TextColumn get openuid => text().nullable()();
TextColumn get devId => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().nullable()();
TextColumn get downloadUrl => text().nullable()();
TextColumn get description => text().nullable()();
IntColumn get version => integer().nullable()();
IntColumn get typ => integer().nullable()();
IntColumn get flag => integer().nullable()();
IntColumn get reviewStatus => integer().nullable()();
IntColumn get favoriteAt => integer().nullable()();
IntColumn get isActive => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
RealColumn get score => real().nullable()();
TextColumn get channels => text().nullable()();
TextColumn get devName => text().nullable()();
TextColumn get pictureGaussian => text().nullable()();
TextColumn get picture => text().nullable()();
IntColumn get commentNum => integer().nullable()();
TextColumn get lastLoginAt => text().nullable()();
TextColumn get screen => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'discover_mini_app';
}

View File

@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
@DataClassName('DriftExploreMiniApp')
class ExploreMiniApps extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
TextColumn get openuid => text().nullable()();
TextColumn get devId => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().nullable()();
TextColumn get downloadUrl => text().nullable()();
TextColumn get description => text().nullable()();
IntColumn get version => integer().nullable()();
IntColumn get typ => integer().nullable()();
IntColumn get flag => integer().nullable()();
IntColumn get reviewStatus => integer().nullable()();
IntColumn get favoriteAt => integer().nullable()();
IntColumn get isActive => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
RealColumn get score => real().nullable()();
TextColumn get channels => text().nullable()();
TextColumn get devName => text().nullable()();
TextColumn get pictureGaussian => text().nullable()();
TextColumn get picture => text().nullable()();
IntColumn get commentNum => integer().nullable()();
IntColumn get lastLoginAt => integer().nullable()();
TextColumn get screen => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'explore_mini_app';
}

View File

@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
@DataClassName('DriftFavoriteMiniApp')
class FavoriteMiniApps extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
TextColumn get openuid => text().nullable()();
TextColumn get devId => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().nullable()();
TextColumn get downloadUrl => text().nullable()();
TextColumn get description => text().nullable()();
IntColumn get version => integer().nullable()();
IntColumn get typ => integer().nullable()();
IntColumn get flag => integer().nullable()();
IntColumn get reviewStatus => integer().nullable()();
IntColumn get favoriteAt => integer().nullable()();
IntColumn get isActive => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
RealColumn get score => real().nullable()();
TextColumn get channels => text().nullable()();
TextColumn get devName => text().nullable()();
TextColumn get pictureGaussian => text().nullable()();
TextColumn get picture => text().nullable()();
IntColumn get commentNum => integer().nullable()();
IntColumn get lastLoginAt => integer().nullable()();
TextColumn get screen => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'favorite_mini_app';
}

View File

@@ -0,0 +1,16 @@
import 'package:drift/drift.dart';
@DataClassName('DriftFavouriteDetail')
class FavouriteDetails extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get relatedId => text().withDefault(const Constant(''))();
TextColumn get content => text().withDefault(const Constant(''))();
IntColumn get typ => integer().nullable()();
IntColumn get messageId => integer().nullable()();
IntColumn get sendId => integer().nullable()();
IntColumn get chatId => integer().nullable()();
IntColumn get sendTime => integer().nullable()();
@override
String get tableName => 'favourite_detail';
}

View File

@@ -0,0 +1,26 @@
import 'package:drift/drift.dart';
@DataClassName('DriftFavourite')
class Favourites extends Table {
IntColumn get id => integer()();
TextColumn get parentId => text().withDefault(const Constant(''))();
TextColumn get data => text().withDefault(const Constant(''))();
IntColumn get createdAt => integer().withDefault(const Constant(0))();
IntColumn get updatedAt => integer().withDefault(const Constant(0))();
IntColumn get deletedAt => integer().withDefault(const Constant(0))();
IntColumn get source => integer().nullable()();
IntColumn get userId => integer().nullable()();
IntColumn get authorId => integer().nullable()();
TextColumn get typ => text().withDefault(const Constant('[]'))();
TextColumn get tag => text().withDefault(const Constant('[]'))();
IntColumn get isPin => integer().withDefault(const Constant(0))();
IntColumn get chatTyp => integer().withDefault(const Constant(0))();
IntColumn get isUploaded => integer().withDefault(const Constant(1))();
TextColumn get urls => text().withDefault(const Constant('[]'))();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'favourite';
}

View File

@@ -0,0 +1,39 @@
import 'package:drift/drift.dart';
@DataClassName('DriftGroup')
class Groups extends Table {
IntColumn get id => integer()();
IntColumn get userJoinDate => integer().nullable()();
TextColumn get name => text().nullable()();
TextColumn get profile => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().withDefault(const Constant(''))();
IntColumn get permission => integer().nullable()();
IntColumn get admin => integer().nullable()();
TextColumn get members => text().nullable()();
IntColumn get owner => integer().nullable()();
TextColumn get admins => text().nullable()();
IntColumn get visible => integer().nullable()();
IntColumn get speakInterval => integer().nullable()();
IntColumn get groupType => integer().nullable()();
IntColumn get roomType => integer().nullable()();
IntColumn get maxNumber => integer().nullable()();
IntColumn get channelId => integer().nullable()();
IntColumn get channelGroupId => integer().nullable()();
IntColumn get createTime => integer().nullable()();
IntColumn get updateTime => integer().nullable()();
IntColumn get addIndex => integer().nullable()();
IntColumn get maxMember => integer().nullable()();
IntColumn get expireTime => integer().nullable()();
IntColumn get workspaceId => integer().withDefault(const Constant(0))();
IntColumn get mode => integer().withDefault(const Constant(0))();
IntColumn get redpacketPlay => integer().withDefault(const Constant(0))();
TextColumn get topic => text().nullable()();
TextColumn get rp => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'chat_group';
}

View File

@@ -0,0 +1,27 @@
import 'package:drift/drift.dart';
@DataClassName('DriftMessage')
class Messages extends Table {
IntColumn get id => integer()();
IntColumn get messageId => integer().nullable()();
IntColumn get chatId => integer().nullable()();
IntColumn get chatIdx => integer().nullable()();
IntColumn get sendId => integer().nullable()();
TextColumn get content => text().nullable()();
IntColumn get typ => integer().nullable()();
IntColumn get sendTime => integer().nullable()();
IntColumn get expireTime => integer().nullable()();
IntColumn get createTime => integer().nullable()();
TextColumn get atUsers => text().nullable()();
TextColumn get emojis => text().withDefault(const Constant('[]'))();
IntColumn get editTime => integer().withDefault(const Constant(0))();
IntColumn get refTyp => integer().withDefault(const Constant(0))();
IntColumn get flag => integer().withDefault(const Constant(0))();
TextColumn get cmid => text().withDefault(const Constant(''))();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'message';
}

View File

@@ -0,0 +1,17 @@
import 'package:drift/drift.dart';
@DataClassName('DriftPendingFriendRequestHistory')
class PendingFriendRequestHistories extends Table {
IntColumn get id => integer()();
IntColumn get uid => integer()();
IntColumn get requestTime => integer()();
TextColumn get remarks => text().nullable()();
TextColumn get source => text().nullable()();
IntColumn get rs => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'pending_friend_request_histories';
}

View File

@@ -0,0 +1,36 @@
import 'package:drift/drift.dart';
@DataClassName('DriftRecentMiniApp')
class RecentMiniApps extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
TextColumn get openuid => text().nullable()();
TextColumn get devId => text().nullable()();
TextColumn get icon => text().nullable()();
TextColumn get iconGaussian => text().nullable()();
TextColumn get downloadUrl => text().nullable()();
TextColumn get description => text().nullable()();
IntColumn get version => integer().nullable()();
IntColumn get typ => integer().nullable()();
IntColumn get flag => integer().nullable()();
IntColumn get reviewStatus => integer().nullable()();
IntColumn get favoriteAt => integer().nullable()();
IntColumn get isActive => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
RealColumn get score => real().nullable()();
TextColumn get channels => text().nullable()();
TextColumn get devName => text().nullable()();
TextColumn get pictureGaussian => text().nullable()();
TextColumn get picture => text().nullable()();
IntColumn get commentNum => integer().nullable()();
IntColumn get lastLoginAt => integer().nullable()();
TextColumn get screen => text().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'recent_mini_app';
}

View File

@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
@DataClassName('DriftRetry')
class Retries extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get uid => integer().nullable()();
TextColumn get apiType => text().withDefault(const Constant(''))();
TextColumn get endPoint => text().withDefault(const Constant(''))();
TextColumn get requestData => text().withDefault(const Constant(''))();
IntColumn get synced => integer().nullable()();
TextColumn get callbackFun => text().withDefault(const Constant(''))();
IntColumn get expired => integer().nullable()();
IntColumn get replace => integer().nullable()();
IntColumn get expireTime => integer().nullable()();
IntColumn get createTime => integer().nullable()();
IntColumn get addIndex => integer().nullable()();
@override
String get tableName => 'retry';
}

View File

@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
@DataClassName('DriftSound')
class Sounds extends Table {
IntColumn get id => integer()();
TextColumn get filePath => text().withDefault(const Constant(''))();
IntColumn get typ => integer()();
TextColumn get name => text().withDefault(const Constant(''))();
IntColumn get createdAt => integer()();
IntColumn get updatedAt => integer()();
IntColumn get deletedAt => integer().withDefault(const Constant(0))();
IntColumn get channelGroupId => integer()();
IntColumn get isDefault => integer()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'sound';
}

View File

@@ -0,0 +1,15 @@
import 'package:drift/drift.dart';
@DataClassName('DriftTag')
class Tags extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get uid => integer().nullable()();
TextColumn get name => text().withDefault(const Constant(''))();
IntColumn get type => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get addIndex => integer().nullable()();
@override
String get tableName => 'tags';
}

View File

@@ -1,41 +0,0 @@
import 'package:drift/drift.dart';
@DataClassName('TestTable')
class TestTables extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get uid => integer().nullable()();
TextColumn get uuid => text().nullable()();
IntColumn get lastOnline => integer().nullable()();
TextColumn get profilePic => text().nullable()();
TextColumn get profilePicGaussian => text().withDefault(const Constant(''))();
TextColumn get nickname => text().nullable()();
TextColumn get depositName => text().nullable()();
IntColumn get hasSetDepositName => integer().withDefault(const Constant(0))();
TextColumn get contact => text().nullable()();
TextColumn get countryCode => text().nullable()();
TextColumn get username => text().nullable()();
IntColumn get role => integer().nullable()();
IntColumn get relationship => integer().nullable()();
IntColumn get friendStatus => integer().nullable()();
TextColumn get bio => text().nullable()();
TextColumn get userAlias => text().nullable()();
IntColumn get requestAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
TextColumn get email => text().nullable()();
TextColumn get recoveryEmail => text().nullable()();
TextColumn get remark => text().nullable()();
TextColumn get source => text().nullable()();
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))();
TextColumn get groupTags => text().withDefault(const Constant('[]'))();
TextColumn get friendTags => text().withDefault(const Constant('[]'))();
TextColumn get publicKey => text().nullable()();
IntColumn get configBits => integer().withDefault(const Constant(0))();
TextColumn get hint => text().nullable()();
@override
String get tableName => 'test_tables';
}

View File

@@ -0,0 +1,14 @@
import 'package:drift/drift.dart';
@DataClassName('DriftUserRequestHistory')
class UserRequestHistories extends Table {
IntColumn get id => integer()();
IntColumn get status => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'user_request_history';
}

View File

@@ -1,9 +1,9 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
@DataClassName('User') @DataClassName('DriftUser')
class Users extends Table { class Users extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
IntColumn get uid => integer().nullable()(); IntColumn get uid => integer().unique()();
TextColumn get uuid => text().nullable()(); TextColumn get uuid => text().nullable()();
IntColumn get lastOnline => integer().nullable()(); IntColumn get lastOnline => integer().nullable()();
TextColumn get profilePic => text().nullable()(); TextColumn get profilePic => text().nullable()();
@@ -28,14 +28,18 @@ class Users extends Table {
IntColumn get addIndex => integer().nullable()(); IntColumn get addIndex => integer().nullable()();
IntColumn get incomingSoundId => integer().withDefault(const Constant(0))(); IntColumn get incomingSoundId => integer().withDefault(const Constant(0))();
IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))(); IntColumn get outgoingSoundId => integer().withDefault(const Constant(0))();
IntColumn get notificationSoundId => integer().withDefault(const Constant(0))(); IntColumn get notificationSoundId =>
IntColumn get sendMessageSoundId => integer().withDefault(const Constant(0))(); integer().withDefault(const Constant(0))();
IntColumn get groupNotificationSoundId => 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 groupTags => text().withDefault(const Constant('[]'))();
TextColumn get friendTags => text().withDefault(const Constant('[]'))(); TextColumn get friendTags => text().withDefault(const Constant('[]'))();
TextColumn get publicKey => text().nullable()(); TextColumn get publicKey => text().nullable()();
IntColumn get configBits => integer().withDefault(const Constant(0))(); IntColumn get configBits => integer().withDefault(const Constant(0))();
TextColumn get hint => text().nullable()(); TextColumn get hint => text().nullable()();
@override @override
String get tableName => 'user'; String get tableName => 'user';
} }

View File

@@ -0,0 +1,24 @@
import 'package:drift/drift.dart';
@DataClassName('DriftWorkspace')
class Workspaces extends Table {
IntColumn get id => integer()();
TextColumn get name => text().nullable()();
IntColumn get ownerId => integer().nullable()();
TextColumn get description => text().nullable()();
TextColumn get logo => text().nullable()();
IntColumn get grade => integer().nullable()();
IntColumn get cap => integer().nullable()();
TextColumn get currency => text().nullable()();
IntColumn get status => integer().nullable()();
IntColumn get createdAt => integer().nullable()();
IntColumn get updatedAt => integer().nullable()();
IntColumn get deletedAt => integer().nullable()();
IntColumn get channelGroupId => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
@override
String get tableName => 'workspace';
}

View File

@@ -0,0 +1,122 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/call_log.dart';
/// 通话记录 DTOData Transfer Object
///
/// local / remote 共用的数据传输对象。
/// 提供与 Domain Entity [CallLog] 之间的双向转换。
class CallLogDto {
final String id;
final int? callerId;
final int? receiverId;
final int? chatId;
final int? duration;
final int? videoCall;
final int? createdAt;
final int? updatedAt;
final int? endedAt;
final int? status;
final int? isDeleted;
final int? deletedAt;
final int? isRead;
const CallLogDto({
required this.id,
this.callerId,
this.receiverId,
this.chatId,
this.duration,
this.videoCall,
this.createdAt,
this.updatedAt,
this.endedAt,
this.status,
this.isDeleted,
this.deletedAt,
this.isRead,
});
factory CallLogDto.fromJson(Map<String, dynamic> json) => CallLogDto(
id: json['id'] as String,
callerId: json['caller_id'],
receiverId: json['receiver_id'],
chatId: json['chat_id'],
duration: json['duration'],
videoCall: json['video_call'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
endedAt: json['ended_at'],
status: json['status'],
isDeleted: json['is_deleted'],
deletedAt: json['deleted_at'],
isRead: json['is_read'],
);
Map<String, dynamic> toJson() => {
'id': id,
'caller_id': callerId,
'receiver_id': receiverId,
'chat_id': chatId,
'duration': duration,
'video_call': videoCall,
'created_at': createdAt,
'updated_at': updatedAt,
'ended_at': endedAt,
'status': status,
'is_deleted': isDeleted,
'deleted_at': deletedAt,
'is_read': isRead,
};
/// DTO → Domain Entity
CallLog toEntity() => CallLog(
id: id,
callerId: callerId,
receiverId: receiverId,
chatId: chatId,
duration: duration,
videoCall: videoCall,
createdAt: createdAt,
updatedAt: updatedAt,
endedAt: endedAt,
status: status,
isDeleted: isDeleted,
deletedAt: deletedAt,
isRead: isRead,
);
/// Domain Entity → DTO
factory CallLogDto.fromEntity(CallLog callLog) => CallLogDto(
id: callLog.id,
callerId: callLog.callerId,
receiverId: callLog.receiverId,
chatId: callLog.chatId,
duration: callLog.duration,
videoCall: callLog.videoCall,
createdAt: callLog.createdAt,
updatedAt: callLog.updatedAt,
endedAt: callLog.endedAt,
status: callLog.status,
isDeleted: callLog.isDeleted,
deletedAt: callLog.deletedAt,
isRead: callLog.isRead,
);
/// DTO → Drift Companion (for DB insert/update)
CallLogsCompanion toCompanion() => CallLogsCompanion(
id: Value(id),
callerId: Value(callerId),
receiverId: Value(receiverId),
chatId: Value(chatId),
duration: Value(duration),
videoCall: Value(videoCall),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
endedAt: Value(endedAt),
status: Value(status),
isDeleted: Value(isDeleted),
deletedAt: Value(deletedAt),
isRead: Value(isRead),
);
}

View File

@@ -0,0 +1,179 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/chat_bot.dart';
/// 聊天机器人 DTO
class ChatBotDto {
final int id;
final String? name;
final String? username;
final int? botUserId;
final String? icon;
final String? iconGaussian;
final String? description;
final String? token;
final int? flag;
final int? status;
final String? webhook;
final String? commands;
final String? banner;
final int? channelId;
final int? channelGroupId;
final int? deletedAt;
final String? internalWebhook;
final int? mode;
final String? redirectUrl;
final int? isInvitable;
final int? isAllowForward;
final String? tips;
const ChatBotDto({
required this.id,
this.name,
this.username,
this.botUserId,
this.icon,
this.iconGaussian,
this.description,
this.token,
this.flag,
this.status,
this.webhook,
this.commands,
this.banner,
this.channelId,
this.channelGroupId,
this.deletedAt,
this.internalWebhook,
this.mode,
this.redirectUrl,
this.isInvitable,
this.isAllowForward,
this.tips,
});
factory ChatBotDto.fromJson(Map<String, dynamic> json) => ChatBotDto(
id: json['id'] as int,
name: json['name'],
username: json['username'],
botUserId: json['bot_user_id'],
icon: json['icon'],
iconGaussian: json['icon_gaussian'],
description: json['description'],
token: json['token'],
flag: json['flag'],
status: json['status'],
webhook: json['webhook'],
commands: json['commands'],
banner: json['banner'],
channelId: json['channel_id'],
channelGroupId: json['channel_group_id'],
deletedAt: json['deleted_at'],
internalWebhook: json['internal_webhook'],
mode: json['mode'],
redirectUrl: json['redirect_url'],
isInvitable: json['is_invitable'],
isAllowForward: json['is_allow_forward'],
tips: json['tips'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'username': username,
'bot_user_id': botUserId,
'icon': icon,
'icon_gaussian': iconGaussian,
'description': description,
'token': token,
'flag': flag,
'status': status,
'webhook': webhook,
'commands': commands,
'banner': banner,
'channel_id': channelId,
'channel_group_id': channelGroupId,
'deleted_at': deletedAt,
'internal_webhook': internalWebhook,
'mode': mode,
'redirect_url': redirectUrl,
'is_invitable': isInvitable,
'is_allow_forward': isAllowForward,
'tips': tips,
};
ChatBot toEntity() => ChatBot(
id: id,
name: name,
username: username,
botUserId: botUserId,
icon: icon,
iconGaussian: iconGaussian,
description: description,
token: token,
flag: flag,
status: status,
webhook: webhook,
commands: commands,
banner: banner,
channelId: channelId,
channelGroupId: channelGroupId,
deletedAt: deletedAt,
internalWebhook: internalWebhook,
mode: mode,
redirectUrl: redirectUrl,
isInvitable: isInvitable,
isAllowForward: isAllowForward,
tips: tips,
);
factory ChatBotDto.fromEntity(ChatBot chatBot) => ChatBotDto(
id: chatBot.id,
name: chatBot.name,
username: chatBot.username,
botUserId: chatBot.botUserId,
icon: chatBot.icon,
iconGaussian: chatBot.iconGaussian,
description: chatBot.description,
token: chatBot.token,
flag: chatBot.flag,
status: chatBot.status,
webhook: chatBot.webhook,
commands: chatBot.commands,
banner: chatBot.banner,
channelId: chatBot.channelId,
channelGroupId: chatBot.channelGroupId,
deletedAt: chatBot.deletedAt,
internalWebhook: chatBot.internalWebhook,
mode: chatBot.mode,
redirectUrl: chatBot.redirectUrl,
isInvitable: chatBot.isInvitable,
isAllowForward: chatBot.isAllowForward,
tips: chatBot.tips,
);
ChatBotsCompanion toCompanion() => ChatBotsCompanion(
id: Value(id),
name: Value(name),
username: Value(username),
botUserId: Value(botUserId),
icon: Value(icon),
iconGaussian: Value(iconGaussian),
description: Value(description),
token: Value(token),
flag: Value(flag),
status: Value(status),
webhook: Value(webhook ?? ''),
commands: Value(commands ?? '[]'),
banner: Value(banner),
channelId: Value(channelId),
channelGroupId: Value(channelGroupId),
deletedAt: Value(deletedAt),
internalWebhook: Value(internalWebhook),
mode: Value(mode),
redirectUrl: Value(redirectUrl),
isInvitable: Value(isInvitable),
isAllowForward: Value(isAllowForward),
tips: Value(tips),
);
}

View File

@@ -0,0 +1,90 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/chat_category.dart';
/// 聊天分类 DTO
class ChatCategoryDto {
final int id;
final String? name;
final String? includedChatIds;
final String? excludedChatIds;
final int? seq;
final int isHide;
final int createdAt;
final int updatedAt;
final int deletedAt;
const ChatCategoryDto({
required this.id,
this.name,
this.includedChatIds,
this.excludedChatIds,
this.seq,
this.isHide = 0,
this.createdAt = 0,
this.updatedAt = 0,
this.deletedAt = 0,
});
factory ChatCategoryDto.fromJson(Map<String, dynamic> json) =>
ChatCategoryDto(
id: json['id'] as int,
name: json['name'],
includedChatIds: json['included_chat_ids'],
excludedChatIds: json['excluded_chat_ids'],
seq: json['seq'],
isHide: json['is_hide'] ?? 0,
createdAt: json['created_at'] ?? 0,
updatedAt: json['updated_at'] ?? 0,
deletedAt: json['deleted_at'] ?? 0,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'included_chat_ids': includedChatIds,
'excluded_chat_ids': excludedChatIds,
'seq': seq,
'is_hide': isHide,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
};
ChatCategory toEntity() => ChatCategory(
id: id,
name: name,
includedChatIds: includedChatIds,
excludedChatIds: excludedChatIds,
seq: seq,
isHide: isHide,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
);
factory ChatCategoryDto.fromEntity(ChatCategory chatCategory) =>
ChatCategoryDto(
id: chatCategory.id,
name: chatCategory.name,
includedChatIds: chatCategory.includedChatIds,
excludedChatIds: chatCategory.excludedChatIds,
seq: chatCategory.seq,
isHide: chatCategory.isHide,
createdAt: chatCategory.createdAt,
updatedAt: chatCategory.updatedAt,
deletedAt: chatCategory.deletedAt,
);
ChatCategoriesCompanion toCompanion() => ChatCategoriesCompanion(
id: Value(id),
name: Value(name),
includedChatIds: Value(includedChatIds),
excludedChatIds: Value(excludedChatIds),
seq: Value(seq),
isHide: Value(isHide),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
);
}

View File

@@ -0,0 +1,200 @@
/// 聊天 Domain 实体
class Chat {
final int id;
final int? typ;
final int? lastId;
final int? lastTyp;
final String? lastMsg;
final int? lastTime;
final int lastPos;
final int firstPos;
final int? msgIdx;
final String? profile;
final String? pin;
final String? icon;
final String iconGaussian;
final String? name;
final int? userId;
final int? chatId;
final int? friendId;
final int? sort;
final int? unreadNum;
final int? unreadCount;
final int? hideChatMsgIdx;
final int? readChatMsgIdx;
final int? otherReadIdx;
final String? unreadAtMsgIdx;
final int? deleteTime;
final int? addIndex;
final int flag;
final int? flagMy;
final int? autoDeleteInterval;
final int? mute;
final int? verified;
final int? createTime;
final int? startIdx;
final int? isReadMsg;
final String translateOutgoing;
final String translateIncoming;
final int incomingIdx;
final int outgoingIdx;
final int incomingSoundId;
final int outgoingSoundId;
final int notificationSoundId;
final String chatKey;
final String activeChatKey;
final int coverIdx;
final int round;
final int workspaceId;
final int localPermission;
const Chat({
required this.id,
this.typ,
this.lastId,
this.lastTyp,
this.lastMsg,
this.lastTime,
this.lastPos = 0,
this.firstPos = -1,
this.msgIdx,
this.profile,
this.pin,
this.icon,
this.iconGaussian = '',
this.name,
this.userId,
this.chatId,
this.friendId,
this.sort,
this.unreadNum,
this.unreadCount,
this.hideChatMsgIdx,
this.readChatMsgIdx,
this.otherReadIdx,
this.unreadAtMsgIdx,
this.deleteTime,
this.addIndex,
this.flag = 0,
this.flagMy,
this.autoDeleteInterval,
this.mute,
this.verified,
this.createTime,
this.startIdx,
this.isReadMsg,
this.translateOutgoing = '',
this.translateIncoming = '',
this.incomingIdx = 0,
this.outgoingIdx = 0,
this.incomingSoundId = 0,
this.outgoingSoundId = 0,
this.notificationSoundId = 0,
this.chatKey = '',
this.activeChatKey = '',
this.coverIdx = 0,
this.round = 0,
this.workspaceId = 0,
this.localPermission = 0,
});
Chat copyWith({
int? id,
int? typ,
int? lastId,
int? lastTyp,
String? lastMsg,
int? lastTime,
int? lastPos,
int? firstPos,
int? msgIdx,
String? profile,
String? pin,
String? icon,
String? iconGaussian,
String? name,
int? userId,
int? chatId,
int? friendId,
int? sort,
int? unreadNum,
int? unreadCount,
int? hideChatMsgIdx,
int? readChatMsgIdx,
int? otherReadIdx,
String? unreadAtMsgIdx,
int? deleteTime,
int? addIndex,
int? flag,
int? flagMy,
int? autoDeleteInterval,
int? mute,
int? verified,
int? createTime,
int? startIdx,
int? isReadMsg,
String? translateOutgoing,
String? translateIncoming,
int? incomingIdx,
int? outgoingIdx,
int? incomingSoundId,
int? outgoingSoundId,
int? notificationSoundId,
String? chatKey,
String? activeChatKey,
int? coverIdx,
int? round,
int? workspaceId,
int? localPermission,
}) {
return Chat(
id: id ?? this.id,
typ: typ ?? this.typ,
lastId: lastId ?? this.lastId,
lastTyp: lastTyp ?? this.lastTyp,
lastMsg: lastMsg ?? this.lastMsg,
lastTime: lastTime ?? this.lastTime,
lastPos: lastPos ?? this.lastPos,
firstPos: firstPos ?? this.firstPos,
msgIdx: msgIdx ?? this.msgIdx,
profile: profile ?? this.profile,
pin: pin ?? this.pin,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
name: name ?? this.name,
userId: userId ?? this.userId,
chatId: chatId ?? this.chatId,
friendId: friendId ?? this.friendId,
sort: sort ?? this.sort,
unreadNum: unreadNum ?? this.unreadNum,
unreadCount: unreadCount ?? this.unreadCount,
hideChatMsgIdx: hideChatMsgIdx ?? this.hideChatMsgIdx,
readChatMsgIdx: readChatMsgIdx ?? this.readChatMsgIdx,
otherReadIdx: otherReadIdx ?? this.otherReadIdx,
unreadAtMsgIdx: unreadAtMsgIdx ?? this.unreadAtMsgIdx,
deleteTime: deleteTime ?? this.deleteTime,
addIndex: addIndex ?? this.addIndex,
flag: flag ?? this.flag,
flagMy: flagMy ?? this.flagMy,
autoDeleteInterval: autoDeleteInterval ?? this.autoDeleteInterval,
mute: mute ?? this.mute,
verified: verified ?? this.verified,
createTime: createTime ?? this.createTime,
startIdx: startIdx ?? this.startIdx,
isReadMsg: isReadMsg ?? this.isReadMsg,
translateOutgoing: translateOutgoing ?? this.translateOutgoing,
translateIncoming: translateIncoming ?? this.translateIncoming,
incomingIdx: incomingIdx ?? this.incomingIdx,
outgoingIdx: outgoingIdx ?? this.outgoingIdx,
incomingSoundId: incomingSoundId ?? this.incomingSoundId,
outgoingSoundId: outgoingSoundId ?? this.outgoingSoundId,
notificationSoundId: notificationSoundId ?? this.notificationSoundId,
chatKey: chatKey ?? this.chatKey,
activeChatKey: activeChatKey ?? this.activeChatKey,
coverIdx: coverIdx ?? this.coverIdx,
round: round ?? this.round,
workspaceId: workspaceId ?? this.workspaceId,
localPermission: localPermission ?? this.localPermission,
);
}
}

View File

@@ -0,0 +1,112 @@
/// 发现小程序 Domain 实体
class DiscoverMiniApp {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final String? lastLoginAt;
final String? screen;
const DiscoverMiniApp({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
DiscoverMiniApp copyWith({
String? id,
String? name,
String? openuid,
String? devId,
String? icon,
String? iconGaussian,
String? downloadUrl,
String? description,
int? version,
int? typ,
int? flag,
int? reviewStatus,
int? favoriteAt,
int? isActive,
int? createdAt,
int? updatedAt,
int? deletedAt,
double? score,
String? channels,
String? devName,
String? pictureGaussian,
String? picture,
int? commentNum,
String? lastLoginAt,
String? screen,
}) {
return DiscoverMiniApp(
id: id ?? this.id,
name: name ?? this.name,
openuid: openuid ?? this.openuid,
devId: devId ?? this.devId,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
downloadUrl: downloadUrl ?? this.downloadUrl,
description: description ?? this.description,
version: version ?? this.version,
typ: typ ?? this.typ,
flag: flag ?? this.flag,
reviewStatus: reviewStatus ?? this.reviewStatus,
favoriteAt: favoriteAt ?? this.favoriteAt,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
score: score ?? this.score,
channels: channels ?? this.channels,
devName: devName ?? this.devName,
pictureGaussian: pictureGaussian ?? this.pictureGaussian,
picture: picture ?? this.picture,
commentNum: commentNum ?? this.commentNum,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
screen: screen ?? this.screen,
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/explore_mini_app.dart';
/// 探索小程序 DTO
class ExploreMiniAppDto {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final int? lastLoginAt;
final String? screen;
const ExploreMiniAppDto({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
factory ExploreMiniAppDto.fromJson(Map<String, dynamic> json) =>
ExploreMiniAppDto(
id: json['id'] as String,
name: json['name'],
openuid: json['openuid'],
devId: json['dev_id'],
icon: json['icon'],
iconGaussian: json['icon_gaussian'],
downloadUrl: json['download_url'],
description: json['description'],
version: json['version'],
typ: json['typ'],
flag: json['flag'],
reviewStatus: json['review_status'],
favoriteAt: json['favorite_at'],
isActive: json['is_active'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
deletedAt: json['deleted_at'],
score: json['score']?.toDouble(),
channels: json['channels'],
devName: json['dev_name'],
pictureGaussian: json['picture_gaussian'],
picture: json['picture'],
commentNum: json['comment_num'],
lastLoginAt: json['last_login_at'],
screen: json['screen'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'openuid': openuid,
'dev_id': devId,
'icon': icon,
'icon_gaussian': iconGaussian,
'download_url': downloadUrl,
'description': description,
'version': version,
'typ': typ,
'flag': flag,
'review_status': reviewStatus,
'favorite_at': favoriteAt,
'is_active': isActive,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
'score': score,
'channels': channels,
'dev_name': devName,
'picture_gaussian': pictureGaussian,
'picture': picture,
'comment_num': commentNum,
'last_login_at': lastLoginAt,
'screen': screen,
};
ExploreMiniApp toEntity() => ExploreMiniApp(
id: id,
name: name,
openuid: openuid,
devId: devId,
icon: icon,
iconGaussian: iconGaussian,
downloadUrl: downloadUrl,
description: description,
version: version,
typ: typ,
flag: flag,
reviewStatus: reviewStatus,
favoriteAt: favoriteAt,
isActive: isActive,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
score: score,
channels: channels,
devName: devName,
pictureGaussian: pictureGaussian,
picture: picture,
commentNum: commentNum,
lastLoginAt: lastLoginAt,
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,
);
ExploreMiniAppsCompanion toCompanion() => ExploreMiniAppsCompanion(
id: Value(id),
name: Value(name),
openuid: Value(openuid),
devId: Value(devId),
icon: Value(icon),
iconGaussian: Value(iconGaussian),
downloadUrl: Value(downloadUrl),
description: Value(description),
version: Value(version),
typ: Value(typ),
flag: Value(flag),
reviewStatus: Value(reviewStatus),
favoriteAt: Value(favoriteAt),
isActive: Value(isActive),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
score: Value(score),
channels: Value(channels),
devName: Value(devName),
pictureGaussian: Value(pictureGaussian),
picture: Value(picture),
commentNum: Value(commentNum),
lastLoginAt: Value(lastLoginAt),
screen: Value(screen),
);
}

View File

@@ -0,0 +1,202 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/favorite_mini_app.dart';
/// 收藏小程序 DTO
class FavoriteMiniAppDto {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final int? lastLoginAt;
final String? screen;
const FavoriteMiniAppDto({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
factory FavoriteMiniAppDto.fromJson(Map<String, dynamic> json) =>
FavoriteMiniAppDto(
id: json['id'] as String,
name: json['name'],
openuid: json['openuid'],
devId: json['dev_id'],
icon: json['icon'],
iconGaussian: json['icon_gaussian'],
downloadUrl: json['download_url'],
description: json['description'],
version: json['version'],
typ: json['typ'],
flag: json['flag'],
reviewStatus: json['review_status'],
favoriteAt: json['favorite_at'],
isActive: json['is_active'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
deletedAt: json['deleted_at'],
score: json['score']?.toDouble(),
channels: json['channels'],
devName: json['dev_name'],
pictureGaussian: json['picture_gaussian'],
picture: json['picture'],
commentNum: json['comment_num'],
lastLoginAt: json['last_login_at'],
screen: json['screen'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'openuid': openuid,
'dev_id': devId,
'icon': icon,
'icon_gaussian': iconGaussian,
'download_url': downloadUrl,
'description': description,
'version': version,
'typ': typ,
'flag': flag,
'review_status': reviewStatus,
'favorite_at': favoriteAt,
'is_active': isActive,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
'score': score,
'channels': channels,
'dev_name': devName,
'picture_gaussian': pictureGaussian,
'picture': picture,
'comment_num': commentNum,
'last_login_at': lastLoginAt,
'screen': screen,
};
FavoriteMiniApp toEntity() => FavoriteMiniApp(
id: id,
name: name,
openuid: openuid,
devId: devId,
icon: icon,
iconGaussian: iconGaussian,
downloadUrl: downloadUrl,
description: description,
version: version,
typ: typ,
flag: flag,
reviewStatus: reviewStatus,
favoriteAt: favoriteAt,
isActive: isActive,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
score: score,
channels: channels,
devName: devName,
pictureGaussian: pictureGaussian,
picture: picture,
commentNum: commentNum,
lastLoginAt: lastLoginAt,
screen: screen,
);
factory FavoriteMiniAppDto.fromEntity(FavoriteMiniApp app) =>
FavoriteMiniAppDto(
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,
);
FavoriteMiniAppsCompanion toCompanion() => FavoriteMiniAppsCompanion(
id: Value(id),
name: Value(name),
openuid: Value(openuid),
devId: Value(devId),
icon: Value(icon),
iconGaussian: Value(iconGaussian),
downloadUrl: Value(downloadUrl),
description: Value(description),
version: Value(version),
typ: Value(typ),
flag: Value(flag),
reviewStatus: Value(reviewStatus),
favoriteAt: Value(favoriteAt),
isActive: Value(isActive),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
score: Value(score),
channels: Value(channels),
devName: Value(devName),
pictureGaussian: Value(pictureGaussian),
picture: Value(picture),
commentNum: Value(commentNum),
lastLoginAt: Value(lastLoginAt),
screen: Value(screen),
);
}

View File

@@ -0,0 +1,83 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/favourite_detail.dart';
/// 收藏详情 DTO
class FavouriteDetailDto {
final int? id;
final String relatedId;
final String content;
final int? typ;
final int? messageId;
final int? sendId;
final int? chatId;
final int? sendTime;
const FavouriteDetailDto({
this.id,
this.relatedId = '',
this.content = '',
this.typ,
this.messageId,
this.sendId,
this.chatId,
this.sendTime,
});
factory FavouriteDetailDto.fromJson(Map<String, dynamic> json) =>
FavouriteDetailDto(
id: json['id'],
relatedId: json['related_id'] ?? '',
content: json['content'] ?? '',
typ: json['typ'],
messageId: json['messageId'],
sendId: json['sendId'],
chatId: json['chatId'],
sendTime: json['sendTime'],
);
Map<String, dynamic> toJson() => {
'id': id,
'related_id': relatedId,
'content': content,
'typ': typ,
'messageId': messageId,
'sendId': sendId,
'chatId': chatId,
'sendTime': sendTime,
};
FavouriteDetail toEntity() => FavouriteDetail(
id: id,
relatedId: relatedId,
content: content,
typ: typ,
messageId: messageId,
sendId: sendId,
chatId: chatId,
sendTime: sendTime,
);
factory FavouriteDetailDto.fromEntity(FavouriteDetail detail) =>
FavouriteDetailDto(
id: detail.id,
relatedId: detail.relatedId,
content: detail.content,
typ: detail.typ,
messageId: detail.messageId,
sendId: detail.sendId,
chatId: detail.chatId,
sendTime: detail.sendTime,
);
FavouriteDetailsCompanion toCompanion() => FavouriteDetailsCompanion(
id: id != null ? Value(id!) : const Value.absent(),
relatedId: Value(relatedId),
content: Value(content),
typ: Value(typ),
messageId: Value(messageId),
sendId: Value(sendId),
chatId: Value(chatId),
sendTime: Value(sendTime),
);
}

View File

@@ -0,0 +1,130 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/favourite.dart';
/// 收藏 DTO
class FavouriteDto {
final int id;
final String parentId;
final String data;
final int createdAt;
final int updatedAt;
final int deletedAt;
final int? source;
final int? userId;
final int? authorId;
final String typ;
final String tag;
final int isPin;
final int chatTyp;
final int isUploaded;
final String urls;
const FavouriteDto({
required this.id,
this.parentId = '',
this.data = '',
this.createdAt = 0,
this.updatedAt = 0,
this.deletedAt = 0,
this.source,
this.userId,
this.authorId,
this.typ = '[]',
this.tag = '[]',
this.isPin = 0,
this.chatTyp = 0,
this.isUploaded = 1,
this.urls = '[]',
});
factory FavouriteDto.fromJson(Map<String, dynamic> json) => FavouriteDto(
id: json['id'] as int,
parentId: json['parent_id'] ?? '',
data: json['data'] ?? '',
createdAt: json['created_at'] ?? 0,
updatedAt: json['updated_at'] ?? 0,
deletedAt: json['deleted_at'] ?? 0,
source: json['source'],
userId: json['user_id'],
authorId: json['author_id'],
typ: json['typ'] ?? '[]',
tag: json['tag'] ?? '[]',
isPin: json['is_pin'] ?? 0,
chatTyp: json['chat_typ'] ?? 0,
isUploaded: json['is_uploaded'] ?? 1,
urls: json['urls'] ?? '[]',
);
Map<String, dynamic> toJson() => {
'id': id,
'parent_id': parentId,
'data': data,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
'source': source,
'user_id': userId,
'author_id': authorId,
'typ': typ,
'tag': tag,
'is_pin': isPin,
'chat_typ': chatTyp,
'is_uploaded': isUploaded,
'urls': urls,
};
Favourite toEntity() => Favourite(
id: id,
parentId: parentId,
data: data,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
source: source,
userId: userId,
authorId: authorId,
typ: typ,
tag: tag,
isPin: isPin,
chatTyp: chatTyp,
isUploaded: isUploaded,
urls: urls,
);
factory FavouriteDto.fromEntity(Favourite favourite) => FavouriteDto(
id: favourite.id,
parentId: favourite.parentId,
data: favourite.data,
createdAt: favourite.createdAt,
updatedAt: favourite.updatedAt,
deletedAt: favourite.deletedAt,
source: favourite.source,
userId: favourite.userId,
authorId: favourite.authorId,
typ: favourite.typ,
tag: favourite.tag,
isPin: favourite.isPin,
chatTyp: favourite.chatTyp,
isUploaded: favourite.isUploaded,
urls: favourite.urls,
);
FavouritesCompanion toCompanion() => FavouritesCompanion(
id: Value(id),
parentId: Value(parentId),
data: Value(data),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
source: Value(source),
userId: Value(userId),
authorId: Value(authorId),
typ: Value(typ),
tag: Value(tag),
isPin: Value(isPin),
chatTyp: Value(chatTyp),
isUploaded: Value(isUploaded),
urls: Value(urls),
);
}

View File

@@ -0,0 +1,221 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/group.dart';
/// 群组 DTO
class GroupDto {
final int id;
final int? userJoinDate;
final String? name;
final String? profile;
final String? icon;
final String iconGaussian;
final int? permission;
final int? admin;
final String? members;
final int? owner;
final String? admins;
final int? visible;
final int? speakInterval;
final int? groupType;
final int? roomType;
final int? maxNumber;
final int? channelId;
final int? channelGroupId;
final int? createTime;
final int? updateTime;
final int? addIndex;
final int? maxMember;
final int? expireTime;
final int workspaceId;
final int mode;
final int redpacketPlay;
final String? topic;
final String? rp;
const GroupDto({
required this.id,
this.userJoinDate,
this.name,
this.profile,
this.icon,
this.iconGaussian = '',
this.permission,
this.admin,
this.members,
this.owner,
this.admins,
this.visible,
this.speakInterval,
this.groupType,
this.roomType,
this.maxNumber,
this.channelId,
this.channelGroupId,
this.createTime,
this.updateTime,
this.addIndex,
this.maxMember,
this.expireTime,
this.workspaceId = 0,
this.mode = 0,
this.redpacketPlay = 0,
this.topic,
this.rp,
});
factory GroupDto.fromJson(Map<String, dynamic> json) => GroupDto(
id: json['id'] as int,
userJoinDate: json['user_join_date'],
name: json['name'],
profile: json['profile'],
icon: json['icon'],
iconGaussian: json['icon_gaussian'] ?? '',
permission: json['permission'],
admin: json['admin'],
members: json['members'],
owner: json['owner'],
admins: json['admins'],
visible: json['visible'],
speakInterval: json['speak_interval'],
groupType: json['group_type'],
roomType: json['room_type'],
maxNumber: json['max_number'],
channelId: json['channel_id'],
channelGroupId: json['channel_group_id'],
createTime: json['create_time'],
updateTime: json['update_time'],
addIndex: json['__add_index'],
maxMember: json['max_member'],
expireTime: json['expire_time'],
workspaceId: json['workspace_id'] ?? 0,
mode: json['mode'] ?? 0,
redpacketPlay: json['redpacket_play'] ?? 0,
topic: json['topic'],
rp: json['rp'],
);
Map<String, dynamic> toJson() => {
'id': id,
'user_join_date': userJoinDate,
'name': name,
'profile': profile,
'icon': icon,
'icon_gaussian': iconGaussian,
'permission': permission,
'admin': admin,
'members': members,
'owner': owner,
'admins': admins,
'visible': visible,
'speak_interval': speakInterval,
'group_type': groupType,
'room_type': roomType,
'max_number': maxNumber,
'channel_id': channelId,
'channel_group_id': channelGroupId,
'create_time': createTime,
'update_time': updateTime,
'__add_index': addIndex,
'max_member': maxMember,
'expire_time': expireTime,
'workspace_id': workspaceId,
'mode': mode,
'redpacket_play': redpacketPlay,
'topic': topic,
'rp': rp,
};
Group toEntity() => Group(
id: id,
userJoinDate: userJoinDate,
name: name,
profile: profile,
icon: icon,
iconGaussian: iconGaussian,
permission: permission,
admin: admin,
members: members,
owner: owner,
admins: admins,
visible: visible,
speakInterval: speakInterval,
groupType: groupType,
roomType: roomType,
maxNumber: maxNumber,
channelId: channelId,
channelGroupId: channelGroupId,
createTime: createTime,
updateTime: updateTime,
addIndex: addIndex,
maxMember: maxMember,
expireTime: expireTime,
workspaceId: workspaceId,
mode: mode,
redpacketPlay: redpacketPlay,
topic: topic,
rp: rp,
);
factory GroupDto.fromEntity(Group group) => GroupDto(
id: group.id,
userJoinDate: group.userJoinDate,
name: group.name,
profile: group.profile,
icon: group.icon,
iconGaussian: group.iconGaussian,
permission: group.permission,
admin: group.admin,
members: group.members,
owner: group.owner,
admins: group.admins,
visible: group.visible,
speakInterval: group.speakInterval,
groupType: group.groupType,
roomType: group.roomType,
maxNumber: group.maxNumber,
channelId: group.channelId,
channelGroupId: group.channelGroupId,
createTime: group.createTime,
updateTime: group.updateTime,
addIndex: group.addIndex,
maxMember: group.maxMember,
expireTime: group.expireTime,
workspaceId: group.workspaceId,
mode: group.mode,
redpacketPlay: group.redpacketPlay,
topic: group.topic,
rp: group.rp,
);
GroupsCompanion toCompanion() => GroupsCompanion(
id: Value(id),
userJoinDate: Value(userJoinDate),
name: Value(name),
profile: Value(profile),
icon: Value(icon),
iconGaussian: Value(iconGaussian),
permission: Value(permission),
admin: Value(admin),
members: Value(members),
owner: Value(owner),
admins: Value(admins),
visible: Value(visible),
speakInterval: Value(speakInterval),
groupType: Value(groupType),
roomType: Value(roomType),
maxNumber: Value(maxNumber),
channelId: Value(channelId),
channelGroupId: Value(channelGroupId),
createTime: Value(createTime),
updateTime: Value(updateTime),
addIndex: Value(addIndex),
maxMember: Value(maxMember),
expireTime: Value(expireTime),
workspaceId: Value(workspaceId),
mode: Value(mode),
redpacketPlay: Value(redpacketPlay),
topic: Value(topic),
rp: Value(rp),
);
}

View File

@@ -0,0 +1,137 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/message.dart';
/// 消息 DTO
class MessageDto {
final int id;
final int? messageId;
final int? chatId;
final int? chatIdx;
final int? sendId;
final String? content;
final int? typ;
final int? sendTime;
final int? expireTime;
final int? createTime;
final String? atUsers;
final String emojis;
final int editTime;
final int refTyp;
final int flag;
final String cmid;
const MessageDto({
required this.id,
this.messageId,
this.chatId,
this.chatIdx,
this.sendId,
this.content,
this.typ,
this.sendTime,
this.expireTime,
this.createTime,
this.atUsers,
this.emojis = '[]',
this.editTime = 0,
this.refTyp = 0,
this.flag = 0,
this.cmid = '',
});
factory MessageDto.fromJson(Map<String, dynamic> json) => MessageDto(
id: json['id'] as int,
messageId: json['message_id'],
chatId: json['chat_id'],
chatIdx: json['chat_idx'],
sendId: json['send_id'],
content: json['content'],
typ: json['typ'],
sendTime: json['send_time'],
expireTime: json['expire_time'],
createTime: json['create_time'],
atUsers: json['at_users'],
emojis: json['emojis'] ?? '[]',
editTime: json['edit_time'] ?? 0,
refTyp: json['ref_typ'] ?? 0,
flag: json['flag'] ?? 0,
cmid: json['cmid'] ?? '',
);
Map<String, dynamic> toJson() => {
'id': id,
'message_id': messageId,
'chat_id': chatId,
'chat_idx': chatIdx,
'send_id': sendId,
'content': content,
'typ': typ,
'send_time': sendTime,
'expire_time': expireTime,
'create_time': createTime,
'at_users': atUsers,
'emojis': emojis,
'edit_time': editTime,
'ref_typ': refTyp,
'flag': flag,
'cmid': cmid,
};
Message toEntity() => Message(
id: id,
messageId: messageId,
chatId: chatId,
chatIdx: chatIdx,
sendId: sendId,
content: content,
typ: typ,
sendTime: sendTime,
expireTime: expireTime,
createTime: createTime,
atUsers: atUsers,
emojis: emojis,
editTime: editTime,
refTyp: refTyp,
flag: flag,
cmid: cmid,
);
factory MessageDto.fromEntity(Message message) => MessageDto(
id: message.id,
messageId: message.messageId,
chatId: message.chatId,
chatIdx: message.chatIdx,
sendId: message.sendId,
content: message.content,
typ: message.typ,
sendTime: message.sendTime,
expireTime: message.expireTime,
createTime: message.createTime,
atUsers: message.atUsers,
emojis: message.emojis,
editTime: message.editTime,
refTyp: message.refTyp,
flag: message.flag,
cmid: message.cmid,
);
MessagesCompanion toCompanion() => MessagesCompanion(
id: Value(id),
messageId: Value(messageId),
chatId: Value(chatId),
chatIdx: Value(chatIdx),
sendId: Value(sendId),
content: Value(content),
typ: Value(typ),
sendTime: Value(sendTime),
expireTime: Value(expireTime),
createTime: Value(createTime),
atUsers: Value(atUsers),
emojis: Value(emojis),
editTime: Value(editTime),
refTyp: Value(refTyp),
flag: Value(flag),
cmid: Value(cmid),
);
}

View File

@@ -0,0 +1,71 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/pending_friend_request_history.dart';
/// 待处理好友请求历史 DTO
class PendingFriendRequestHistoryDto {
final int id;
final int uid;
final int requestTime;
final String? remarks;
final String? source;
final int? rs;
const PendingFriendRequestHistoryDto({
required this.id,
required this.uid,
required this.requestTime,
this.remarks,
this.source,
this.rs,
});
factory PendingFriendRequestHistoryDto.fromJson(Map<String, dynamic> json) =>
PendingFriendRequestHistoryDto(
id: json['id'] as int,
uid: json['uid'] as int,
requestTime: json['request_time'] as int,
remarks: json['remarks'],
source: json['source'],
rs: json['rs'],
);
Map<String, dynamic> toJson() => {
'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,
);
factory PendingFriendRequestHistoryDto.fromEntity(
PendingFriendRequestHistory history,
) => PendingFriendRequestHistoryDto(
id: history.id,
uid: history.uid,
requestTime: history.requestTime,
remarks: history.remarks,
source: history.source,
rs: history.rs,
);
PendingFriendRequestHistoriesCompanion toCompanion() =>
PendingFriendRequestHistoriesCompanion(
id: Value(id),
uid: Value(uid),
requestTime: Value(requestTime),
remarks: Value(remarks),
source: Value(source),
rs: Value(rs),
);
}

View File

@@ -0,0 +1,201 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/recent_mini_app.dart';
/// 最近小程序 DTO
class RecentMiniAppDto {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final int? lastLoginAt;
final String? screen;
const RecentMiniAppDto({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
factory RecentMiniAppDto.fromJson(Map<String, dynamic> json) =>
RecentMiniAppDto(
id: json['id'] as String,
name: json['name'],
openuid: json['openuid'],
devId: json['dev_id'],
icon: json['icon'],
iconGaussian: json['icon_gaussian'],
downloadUrl: json['download_url'],
description: json['description'],
version: json['version'],
typ: json['typ'],
flag: json['flag'],
reviewStatus: json['review_status'],
favoriteAt: json['favorite_at'],
isActive: json['is_active'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
deletedAt: json['deleted_at'],
score: json['score']?.toDouble(),
channels: json['channels'],
devName: json['dev_name'],
pictureGaussian: json['picture_gaussian'],
picture: json['picture'],
commentNum: json['comment_num'],
lastLoginAt: json['last_login_at'],
screen: json['screen'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'openuid': openuid,
'dev_id': devId,
'icon': icon,
'icon_gaussian': iconGaussian,
'download_url': downloadUrl,
'description': description,
'version': version,
'typ': typ,
'flag': flag,
'review_status': reviewStatus,
'favorite_at': favoriteAt,
'is_active': isActive,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
'score': score,
'channels': channels,
'dev_name': devName,
'picture_gaussian': pictureGaussian,
'picture': picture,
'comment_num': commentNum,
'last_login_at': lastLoginAt,
'screen': screen,
};
RecentMiniApp toEntity() => RecentMiniApp(
id: id,
name: name,
openuid: openuid,
devId: devId,
icon: icon,
iconGaussian: iconGaussian,
downloadUrl: downloadUrl,
description: description,
version: version,
typ: typ,
flag: flag,
reviewStatus: reviewStatus,
favoriteAt: favoriteAt,
isActive: isActive,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
score: score,
channels: channels,
devName: devName,
pictureGaussian: pictureGaussian,
picture: picture,
commentNum: commentNum,
lastLoginAt: lastLoginAt,
screen: screen,
);
factory RecentMiniAppDto.fromEntity(RecentMiniApp app) => RecentMiniAppDto(
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,
);
RecentMiniAppsCompanion toCompanion() => RecentMiniAppsCompanion(
id: Value(id),
name: Value(name),
openuid: Value(openuid),
devId: Value(devId),
icon: Value(icon),
iconGaussian: Value(iconGaussian),
downloadUrl: Value(downloadUrl),
description: Value(description),
version: Value(version),
typ: Value(typ),
flag: Value(flag),
reviewStatus: Value(reviewStatus),
favoriteAt: Value(favoriteAt),
isActive: Value(isActive),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
score: Value(score),
channels: Value(channels),
devName: Value(devName),
pictureGaussian: Value(pictureGaussian),
picture: Value(picture),
commentNum: Value(commentNum),
lastLoginAt: Value(lastLoginAt),
screen: Value(screen),
);
}

View File

@@ -0,0 +1,109 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/retry.dart';
/// 重试 DTO
class RetryDto {
final int? id;
final int? uid;
final String apiType;
final String endPoint;
final String requestData;
final int? synced;
final String callbackFun;
final int? expired;
final int? replace;
final int? expireTime;
final int? createTime;
final int? addIndex;
const RetryDto({
this.id,
this.uid,
this.apiType = '',
this.endPoint = '',
this.requestData = '',
this.synced,
this.callbackFun = '',
this.expired,
this.replace,
this.expireTime,
this.createTime,
this.addIndex,
});
factory RetryDto.fromJson(Map<String, dynamic> json) => RetryDto(
id: json['id'],
uid: json['uid'],
apiType: json['api_type'] ?? '',
endPoint: json['end_point'] ?? '',
requestData: json['request_data'] ?? '',
synced: json['synced'],
callbackFun: json['callback_fun'] ?? '',
expired: json['expired'],
replace: json['replace'],
expireTime: json['expire_time'],
createTime: json['create_time'],
addIndex: json['__add_index'],
);
Map<String, dynamic> toJson() => {
'id': id,
'uid': uid,
'api_type': apiType,
'end_point': endPoint,
'request_data': requestData,
'synced': synced,
'callback_fun': callbackFun,
'expired': expired,
'replace': replace,
'expire_time': expireTime,
'create_time': createTime,
'__add_index': addIndex,
};
Retry toEntity() => Retry(
id: id,
uid: uid,
apiType: apiType,
endPoint: endPoint,
requestData: requestData,
synced: synced,
callbackFun: callbackFun,
expired: expired,
replace: replace,
expireTime: expireTime,
createTime: createTime,
addIndex: addIndex,
);
factory RetryDto.fromEntity(Retry retry) => RetryDto(
id: retry.id,
uid: retry.uid,
apiType: retry.apiType,
endPoint: retry.endPoint,
requestData: retry.requestData,
synced: retry.synced,
callbackFun: retry.callbackFun,
expired: retry.expired,
replace: retry.replace,
expireTime: retry.expireTime,
createTime: retry.createTime,
addIndex: retry.addIndex,
);
RetriesCompanion toCompanion() => RetriesCompanion(
id: id != null ? Value(id!) : const Value.absent(),
uid: Value(uid),
apiType: Value(apiType),
endPoint: Value(endPoint),
requestData: Value(requestData),
synced: Value(synced),
callbackFun: Value(callbackFun),
expired: Value(expired),
replace: Value(replace),
expireTime: Value(expireTime),
createTime: Value(createTime),
addIndex: Value(addIndex),
);
}

View File

@@ -0,0 +1,88 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/sound.dart';
/// 音效 DTO
class SoundDto {
final int id;
final String filePath;
final int typ;
final String name;
final int createdAt;
final int updatedAt;
final int deletedAt;
final int channelGroupId;
final int isDefault;
const SoundDto({
required this.id,
this.filePath = '',
required this.typ,
this.name = '',
required this.createdAt,
required this.updatedAt,
this.deletedAt = 0,
required this.channelGroupId,
required this.isDefault,
});
factory SoundDto.fromJson(Map<String, dynamic> json) => SoundDto(
id: json['id'] as int,
filePath: json['file_path'] ?? '',
typ: json['typ'] as int,
name: json['name'] ?? '',
createdAt: json['created_at'] as int,
updatedAt: json['updated_at'] as int,
deletedAt: json['deleted_at'] ?? 0,
channelGroupId: json['channel_group_id'] as int,
isDefault: json['is_default'] as int,
);
Map<String, dynamic> toJson() => {
'id': id,
'file_path': filePath,
'typ': typ,
'name': name,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
'channel_group_id': channelGroupId,
'is_default': isDefault,
};
Sound toEntity() => Sound(
id: id,
filePath: filePath,
typ: typ,
name: name,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
channelGroupId: channelGroupId,
isDefault: isDefault,
);
factory SoundDto.fromEntity(Sound sound) => SoundDto(
id: sound.id,
filePath: sound.filePath,
typ: sound.typ,
name: sound.name,
createdAt: sound.createdAt,
updatedAt: sound.updatedAt,
deletedAt: sound.deletedAt,
channelGroupId: sound.channelGroupId,
isDefault: sound.isDefault,
);
SoundsCompanion toCompanion() => SoundsCompanion(
id: Value(id),
filePath: Value(filePath),
typ: Value(typ),
name: Value(name),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
channelGroupId: Value(channelGroupId),
isDefault: Value(isDefault),
);
}

View File

@@ -0,0 +1,74 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/tag.dart';
/// 标签 DTO
class TagDto {
final int? id;
final int? uid;
final String name;
final int? type;
final int? createdAt;
final int? updatedAt;
final int? addIndex;
const TagDto({
this.id,
this.uid,
this.name = '',
this.type,
this.createdAt,
this.updatedAt,
this.addIndex,
});
factory TagDto.fromJson(Map<String, dynamic> json) => TagDto(
id: json['id'],
uid: json['uid'],
name: json['name'] ?? '',
type: json['type'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
addIndex: json['__add_index'],
);
Map<String, dynamic> toJson() => {
'id': id,
'uid': uid,
'name': name,
'type': type,
'created_at': createdAt,
'updated_at': updatedAt,
'__add_index': addIndex,
};
Tag toEntity() => Tag(
id: id,
uid: uid,
name: name,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
addIndex: addIndex,
);
factory TagDto.fromEntity(Tag tag) => TagDto(
id: tag.id,
uid: tag.uid,
name: tag.name,
type: tag.type,
createdAt: tag.createdAt,
updatedAt: tag.updatedAt,
addIndex: tag.addIndex,
);
TagsCompanion toCompanion() => TagsCompanion(
id: id != null ? Value(id!) : const Value.absent(),
uid: Value(uid),
name: Value(name),
type: Value(type),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
addIndex: Value(addIndex),
);
}

View File

@@ -1,68 +1,148 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import '../../domain/entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
part 'user_dto.g.dart';
/// 用户 DTOData Transfer Object /// 用户 DTOData Transfer Object
/// ///
/// local / remote 共用的数据传输对象,放在 data/models/。 /// local / remote 共用的数据传输对象,放在 data/models/。
/// 提供与 Domain Entity [User] 之间的双向转换。 /// 提供与 Domain Entity [User] 之间的双向转换。
///
/// ## 数据流位置(本地存储场景)
///
/// ```
/// 写入本地:
/// LoginData.toEntity() → User
/// → UserDto.fromEntity(user) → ★ UserDto ★ ← 你在这里
/// → toJson() → SQLite / SharedPreferences
///
/// 读取本地:
/// SQLite / SharedPreferences → JSON
/// → ★ UserDto.fromJson() ★ ← 你在这里
/// → UserDto.toEntity() → User
/// → ViewModel.state → View
/// ```
///
/// 注意:登录接口的 Response DTO 是 [LoginData](含 token
/// 本类用于纯用户信息的本地持久化,不含 token。
@JsonSerializable()
class UserDto { class UserDto {
@JsonKey(name: 'user_id') final int uid;
final String userId; final String? uuid;
final String email; final int? lastOnline;
final String? profilePic;
final String? profilePicGaussian;
final String? nickname; final String? nickname;
final String? avatar; final String? contact;
final String? countryCode;
final String? email;
final String? recoveryEmail;
final String? username;
final String? bio;
final int? relationship;
final String? userAlias;
final int? channelId;
final int? channelGroupId;
final String? hint;
const UserDto({ const UserDto({
required this.userId, required this.uid,
required this.email, this.uuid,
this.lastOnline,
this.profilePic,
this.profilePicGaussian,
this.nickname, this.nickname,
this.avatar, this.contact,
this.countryCode,
this.email,
this.recoveryEmail,
this.username,
this.bio,
this.relationship,
this.userAlias,
this.channelId,
this.channelGroupId,
this.hint,
}); });
factory UserDto.fromJson(Map<String, dynamic> json) => factory UserDto.fromJson(Map<String, dynamic> json) => UserDto(
_$UserDtoFromJson(json); uid: json['uid'] as int,
uuid: json['uuid'],
lastOnline: json['last_online'],
profilePic: json['profile_pic'],
profilePicGaussian: json['profile_pic_gaussian'] ?? '',
nickname: json['nickname'],
contact: json['contact'],
countryCode: json['country_code'],
email: json['email'],
recoveryEmail: json['recovery_email'] ?? '',
username: json['username'],
bio: json['bio'] ?? '',
relationship: json['relationship'],
userAlias: json['user_alias'],
channelId: json['channel_id'],
channelGroupId: json['channel_group_id'],
hint: json['hint'],
);
Map<String, dynamic> toJson() => _$UserDtoToJson(this); Map<String, dynamic> toJson() => {
'uid': uid,
'uuid': uuid,
'last_online': lastOnline,
'profile_pic': profilePic,
'profile_pic_gaussian': profilePicGaussian,
'nickname': nickname,
'contact': contact,
'country_code': countryCode,
'email': email,
'recovery_email': recoveryEmail,
'username': username,
'bio': bio,
'relationship': relationship,
'user_alias': userAlias,
'channel_id': channelId,
'channel_group_id': channelGroupId,
'hint': hint,
};
/// DTO → Domain Entity /// DTO → Domain Entity
User toEntity() { User toEntity() => User(
return User( uid: uid,
id: userId, uuid: uuid,
email: email, lastOnline: lastOnline,
nickname: nickname, profilePic: profilePic,
avatar: avatar, profilePicGaussian: profilePicGaussian,
); nickname: nickname,
} contact: contact,
countryCode: countryCode,
email: email,
recoveryEmail: recoveryEmail,
username: username,
bio: bio,
relationship: relationship,
userAlias: userAlias,
channelId: channelId,
channelGroupId: channelGroupId,
hint: hint,
);
/// Domain Entity → DTO /// Domain Entity → DTO
factory UserDto.fromEntity(User user) { factory UserDto.fromEntity(User user) => UserDto(
return UserDto( uid: user.uid,
userId: user.id, uuid: user.uuid,
email: user.email, lastOnline: user.lastOnline,
nickname: user.nickname, profilePic: user.profilePic,
avatar: user.avatar, profilePicGaussian: user.profilePicGaussian,
); nickname: user.nickname,
} contact: user.contact,
countryCode: user.countryCode,
email: user.email,
recoveryEmail: user.recoveryEmail,
username: user.username,
bio: user.bio,
relationship: user.relationship,
userAlias: user.userAlias,
channelId: user.channelId,
channelGroupId: user.channelGroupId,
hint: user.hint,
);
/// DTO → Drift Companion (for DB insert/update)
UsersCompanion toCompanion() => UsersCompanion(
uid: Value(uid),
uuid: Value(uuid),
lastOnline: Value(lastOnline),
profilePic: Value(profilePic),
profilePicGaussian: Value(profilePicGaussian ?? ''),
nickname: Value(nickname),
contact: Value(contact),
countryCode: Value(countryCode),
email: Value(email),
recoveryEmail: Value(recoveryEmail),
username: Value(username),
bio: Value(bio),
relationship: Value(relationship),
userAlias: Value(userAlias),
hint: Value(hint),
);
} }

View File

@@ -0,0 +1,41 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/user_request_history.dart';
/// 用户请求历史 DTO
class UserRequestHistoryDto {
final int id;
final int? status;
final int? createdAt;
const UserRequestHistoryDto({required this.id, this.status, this.createdAt});
factory UserRequestHistoryDto.fromJson(Map<String, dynamic> json) =>
UserRequestHistoryDto(
id: json['id'] as int,
status: json['status'],
createdAt: json['created_at'],
);
Map<String, dynamic> toJson() => {
'id': id,
'status': status,
'created_at': createdAt,
};
UserRequestHistory toEntity() =>
UserRequestHistory(id: id, status: status, createdAt: createdAt);
factory UserRequestHistoryDto.fromEntity(UserRequestHistory history) =>
UserRequestHistoryDto(
id: history.id,
status: history.status,
createdAt: history.createdAt,
);
UserRequestHistoriesCompanion toCompanion() => UserRequestHistoriesCompanion(
id: Value(id),
status: Value(status),
createdAt: Value(createdAt),
);
}

View File

@@ -0,0 +1,116 @@
import 'package:drift/drift.dart';
import 'package:im_app/data/local/drift/app_database.dart';
import 'package:im_app/domain/entities/workspace.dart';
/// 工作空间 DTO
class WorkspaceDto {
final int id;
final String? name;
final int? ownerId;
final String? description;
final String? logo;
final int? grade;
final int? cap;
final String? currency;
final int? status;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final int? channelGroupId;
const WorkspaceDto({
required this.id,
this.name,
this.ownerId,
this.description,
this.logo,
this.grade,
this.cap,
this.currency,
this.status,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.channelGroupId,
});
factory WorkspaceDto.fromJson(Map<String, dynamic> json) => WorkspaceDto(
id: json['id'] as int,
name: json['name'],
ownerId: json['owner_id'],
description: json['description'],
logo: json['logo'],
grade: json['grade'],
cap: json['cap'],
currency: json['currency'],
status: json['status'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
deletedAt: json['deleted_at'],
channelGroupId: json['channel_group_id'],
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'owner_id': ownerId,
'description': description,
'logo': logo,
'grade': grade,
'cap': cap,
'currency': currency,
'status': status,
'created_at': createdAt,
'updated_at': updatedAt,
'deleted_at': deletedAt,
'channel_group_id': channelGroupId,
};
Workspace toEntity() => Workspace(
id: id,
name: name,
ownerId: ownerId,
description: description,
logo: logo,
grade: grade,
cap: cap,
currency: currency,
status: status,
createdAt: createdAt,
updatedAt: updatedAt,
deletedAt: deletedAt,
channelGroupId: channelGroupId,
);
factory WorkspaceDto.fromEntity(Workspace workspace) => WorkspaceDto(
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
description: workspace.description,
logo: workspace.logo,
grade: workspace.grade,
cap: workspace.cap,
currency: workspace.currency,
status: workspace.status,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
deletedAt: workspace.deletedAt,
channelGroupId: workspace.channelGroupId,
);
WorkspacesCompanion toCompanion() => WorkspacesCompanion(
id: Value(id),
name: Value(name),
ownerId: Value(ownerId),
description: Value(description),
logo: Value(logo),
grade: Value(grade),
cap: Value(cap),
currency: Value(currency),
status: Value(status),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
deletedAt: Value(deletedAt),
channelGroupId: Value(channelGroupId),
);
}

View File

@@ -28,31 +28,75 @@ part 'get_profile_request.g.dart';
/// 用户资料响应 DTO只需反序列化禁止生成无用的 toJson /// 用户资料响应 DTO只需反序列化禁止生成无用的 toJson
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class ProfileData { class ProfileData {
@JsonKey(name: 'user_id') final int uid;
final String userId; final String uuid;
@JsonKey(name: 'last_online')
final int lastOnline;
@JsonKey(name: 'profile_pic')
final String profilePic;
@JsonKey(name: 'profile_pic_gaussian')
final String profilePicGaussian;
final String nickname;
final String contact;
@JsonKey(name: 'country_code')
final String countryCode;
final String email; final String email;
final String? nickname; @JsonKey(name: 'recovery_email')
final String? avatar; final String recoveryEmail;
final String username;
final String bio;
final int relationship;
@JsonKey(name: 'user_alias')
final String? userAlias;
@JsonKey(name: 'channel_id')
final int channelId;
@JsonKey(name: 'channel_group_id')
final int channelGroupId;
final String hint;
const ProfileData({ const ProfileData({
required this.userId, required this.uid,
required this.uuid,
required this.lastOnline,
required this.profilePic,
required this.profilePicGaussian,
required this.nickname,
required this.contact,
required this.countryCode,
required this.email, required this.email,
this.nickname, required this.recoveryEmail,
this.avatar, required this.username,
required this.bio,
required this.relationship,
this.userAlias,
required this.channelId,
required this.channelGroupId,
required this.hint,
}); });
factory ProfileData.fromJson(Map<String, dynamic> json) => factory ProfileData.fromJson(Map<String, dynamic> json) =>
_$ProfileDataFromJson(json); _$ProfileDataFromJson(json);
/// DTO → Domain Entity /// DTO → Domain Entity
User toEntity() { User toEntity() => User(
return User( uid: uid,
id: userId, uuid: uuid,
email: email, lastOnline: lastOnline,
nickname: nickname, profilePic: profilePic,
avatar: avatar, profilePicGaussian: profilePicGaussian,
); nickname: nickname,
} contact: contact,
countryCode: countryCode,
email: email,
recoveryEmail: recoveryEmail,
username: username,
bio: bio,
relationship: relationship,
userAlias: userAlias,
channelId: channelId,
channelGroupId: channelGroupId,
hint: hint,
);
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@@ -61,7 +105,7 @@ class ProfileData {
/// 获取用户资料请求GET无参数 /// 获取用户资料请求GET无参数
/// ///
/// GET 请求无 bodytoJson() 返回空 map。 /// GET 请求无 bodymixin 自动生成 toJson() 空 map。
/// 如需 query 参数(如分页),添加字段即可, /// 如需 query 参数(如分页),添加字段即可,
/// toJson() 会自动将字段序列化为 URL query string。 /// toJson() 会自动将字段序列化为 URL query string。
@ApiRequest( @ApiRequest(
@@ -69,14 +113,7 @@ class ProfileData {
method: HttpMethod.get, method: HttpMethod.get,
responseType: ProfileData, responseType: ProfileData,
) )
@JsonSerializable()
class GetProfileRequest extends ApiRequestable<ProfileData> class GetProfileRequest extends ApiRequestable<ProfileData>
with _$GetProfileRequestApi { with _$GetProfileRequestApi {
GetProfileRequest(); GetProfileRequest();
factory GetProfileRequest.fromJson(Map<String, dynamic> json) =>
_$GetProfileRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$GetProfileRequestToJson(this);
} }

View File

@@ -8,57 +8,140 @@ part 'login_request.g.dart';
/// # /auth/login — 登录接口 /// # /auth/login — 登录接口
/// ///
/// 一个端点 = 一个文件Response DTO + Request 放在同一文件中。
///
/// ## 数据流位置 /// ## 数据流位置
/// ///
/// ``` /// ```
/// AuthRepositoryImpl.login(email, password) /// AuthRepositoryImpl.login(email, password)
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里 /// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
/// → 服务端 POST /auth/login /// → 服务端 POST /auth/login
/// → 响应 JSON → ★ LoginData ★ ← 也在这里 /// → 响应 JSON → ★ LoginResponse ★ ← 也在这里
/// → LoginData.toEntity() → User /// → LoginResponse.toEntity() → User
/// ``` /// ```
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// Response DTO // Response DTO
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
/// 登录响应 DTO @JsonSerializable(createToJson: false)
/// class LoginProfile {
/// 服务端返回的登录数据,包含 token 和用户信息。 final int uid;
/// 通过 [toEntity] 转换为 Domain Entity [User]。 final String uuid;
@JsonSerializable() @JsonKey(name: 'last_online')
class LoginData { final int lastOnline;
final String token; @JsonKey(name: 'profile_pic')
@JsonKey(name: 'user_id') final String profilePic;
final String userId; @JsonKey(name: 'profile_pic_gaussian')
final String profilePicGaussian;
final String nickname;
final String contact;
@JsonKey(name: 'country_code')
final String countryCode;
final String email; final String email;
final String? nickname; @JsonKey(name: 'recovery_email')
final String? avatar; final String recoveryEmail;
final String username;
final String bio;
final int relationship;
@JsonKey(name: 'user_alias')
final String? userAlias;
@JsonKey(name: 'channel_id')
final int channelId;
@JsonKey(name: 'channel_group_id')
final int channelGroupId;
final String hint;
const LoginProfile({
required this.uid,
required this.uuid,
required this.lastOnline,
required this.profilePic,
required this.profilePicGaussian,
required this.nickname,
required this.contact,
required this.countryCode,
required this.email,
required this.recoveryEmail,
required this.username,
required this.bio,
required this.relationship,
this.userAlias,
required this.channelId,
required this.channelGroupId,
required this.hint,
});
factory LoginProfile.fromJson(Map<String, dynamic> json) =>
_$LoginProfileFromJson(json);
User toEntity() => User(
uid: uid,
uuid: uuid,
lastOnline: lastOnline,
profilePic: profilePic,
profilePicGaussian: profilePicGaussian,
nickname: nickname,
contact: contact,
countryCode: countryCode,
email: email,
recoveryEmail: recoveryEmail,
username: username,
bio: bio,
relationship: relationship,
userAlias: userAlias,
channelId: channelId,
channelGroupId: channelGroupId,
hint: hint,
);
}
@JsonSerializable(createToJson: false, explicitToJson: true)
class LoginData {
@JsonKey(name: 'account_id')
final String accountId;
final LoginProfile profile;
final String nonce;
@JsonKey(name: 'access_token')
final String accessToken;
@JsonKey(name: 'refresh_token')
final String refreshToken;
@JsonKey(name: 'device_id')
final String deviceId;
@JsonKey(name: 'login_data')
final String loginData;
const LoginData({ const LoginData({
required this.token, required this.accountId,
required this.userId, required this.profile,
required this.email, required this.nonce,
this.nickname, required this.accessToken,
this.avatar, required this.refreshToken,
required this.deviceId,
required this.loginData,
}); });
factory LoginData.fromJson(Map<String, dynamic> json) => factory LoginData.fromJson(Map<String, dynamic> json) =>
_$LoginDataFromJson(json); _$LoginDataFromJson(json);
Map<String, dynamic> toJson() => _$LoginDataToJson(this); User toEntity() => profile.toEntity();
}
/// DTO → Domain Entity /// Top-level envelope: { "code": 0, "message": "OK", "data": { ... } }
User toEntity() { @JsonSerializable(createToJson: false, explicitToJson: true)
return User( class LoginResponse {
id: userId, final int code;
email: email, final String message;
nickname: nickname, final LoginData data;
avatar: avatar,
); const LoginResponse({
} required this.code,
required this.message,
required this.data,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) =>
_$LoginResponseFromJson(json);
User toEntity() => data.toEntity();
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@@ -67,24 +150,21 @@ class LoginData {
/// 登录请求 /// 登录请求
/// ///
/// `@ApiRequest` 自动生成 `_$LoginRequestApi` mixin /// `@ApiRequest` 一个注解搞定一切:
/// 提供 path / method / requestType / includeToken / fromJson 自动注册。 /// - mixin 自动生成 path / method / requestType / includeToken / toJson
/// - toJson 只序列化类自身字段email, password不含继承属性
/// - Response 的 fromJson 在 parameters getter 中自动注册
/// - 无需 @JsonSerializable无需手写 fromJson / toJson
@ApiRequest( @ApiRequest(
path: ApiPaths.authLogin, path: ApiPaths.authLogin,
method: HttpMethod.post, method: HttpMethod.post,
responseType: LoginData, responseType: LoginResponse,
requestType: ApiRequestType.login, requestType: ApiRequestType.login,
) )
@JsonSerializable() class LoginRequest extends ApiRequestable<LoginResponse>
class LoginRequest extends ApiRequestable<LoginData> with _$LoginRequestApi { with _$LoginRequestApi {
final String email; final String email;
final String password; final String password;
LoginRequest({required this.email, required this.password}); LoginRequest({required this.email, required this.password});
factory LoginRequest.fromJson(Map<String, dynamic> json) =>
_$LoginRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
} }

View File

@@ -52,7 +52,7 @@ class UploadResult {
/// FormData 上传请求 /// FormData 上传请求
/// ///
/// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 信封 /// 上传到自有后端 `/upload/file`,响应为标准 `{ code, message, data }` 格式
/// 无需 override `decodeResponse`。 /// 无需 override `decodeResponse`。
@ApiRequest( @ApiRequest(
path: ApiPaths.uploadFile, path: ApiPaths.uploadFile,
@@ -97,7 +97,7 @@ class S3UploadResponse {
/// - path 为完整的 presigned URLSDK 检测到 http 开头不拼 baseURL /// - path 为完整的 presigned URLSDK 检测到 http 开头不拼 baseURL
/// - uploadData 为 Uint8List 二进制数据 /// - uploadData 为 Uint8List 二进制数据
/// - 自定义 headersContent-Type: application/octet-stream /// - 自定义 headersContent-Type: application/octet-stream
/// - override decodeResponse — S3 返回 204 No Content 或 XML不是标准信封 /// - override decodeResponse — S3 返回 204 No Content 或 XML不是标准响应格式
class S3UploadRequest extends ApiRequestable<S3UploadResponse> { class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
final Uint8List data; final Uint8List data;
final String presignedURL; final String presignedURL;
@@ -115,8 +115,8 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
@override @override
Map<String, String>? get customHeaders => { Map<String, String>? get customHeaders => {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}; };
@override @override
Map<String, dynamic> toJson() => {}; Map<String, dynamic> toJson() => {};
@@ -125,7 +125,7 @@ class S3UploadRequest extends ApiRequestable<S3UploadResponse> {
@override @override
Object? get uploadData => data; Object? get uploadData => data;
/// S3 响应不走标准 { code, message, data } 信封,需要自定义解码 /// S3 响应不走标准 { code, message, data } 格式,需要自定义解码
/// ///
/// 可能的响应: /// 可能的响应:
/// - 204 No Content空 body→ 成功 /// - 204 No Content空 body→ 成功

View File

@@ -27,21 +27,25 @@ class AuthRepositoryImpl implements AuthRepository {
final NetworksSdkApi _client; final NetworksSdkApi _client;
final void Function(String?) _onTokenUpdate; 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 @override
Future<User> login({required String email, required String password,}) async Future<User> login({required String email, required String password}) async {
{ final LoginResponse? loginResponse = await _client.executeRequest(
final LoginData? loginData = await _client.executeRequest(LoginRequest(email: email, password: password),); LoginRequest(email: email, password: password),
);
if (loginData == null) { if (loginResponse == null) {
throw Exception('Login failed: empty response'); // TODO: 接入国际化 throw Exception('Login failed: empty response');
} }
// 回调写入 Token内存 + 持久化由 Provider 层组合) _onTokenUpdate(loginResponse.data.accessToken);
_onTokenUpdate(loginData.token);
return loginData.toEntity(); // DTO → Domain Entity return loginResponse.toEntity();
} }
@override @override

View File

@@ -0,0 +1,67 @@
/// 通话记录 Domain 实体
///
/// 全局共享实体,被 call / chat 等多个 Feature 共用。
/// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。
class CallLog {
final String id;
final int? callerId;
final int? receiverId;
final int? chatId;
final int? duration;
final int? videoCall;
final int? createdAt;
final int? updatedAt;
final int? endedAt;
final int? status;
final int? isDeleted;
final int? deletedAt;
final int? isRead;
const CallLog({
required this.id,
this.callerId,
this.receiverId,
this.chatId,
this.duration,
this.videoCall,
this.createdAt,
this.updatedAt,
this.endedAt,
this.status,
this.isDeleted,
this.deletedAt,
this.isRead,
});
CallLog copyWith({
String? id,
int? callerId,
int? receiverId,
int? chatId,
int? duration,
int? videoCall,
int? createdAt,
int? updatedAt,
int? endedAt,
int? status,
int? isDeleted,
int? deletedAt,
int? isRead,
}) {
return CallLog(
id: id ?? this.id,
callerId: callerId ?? this.callerId,
receiverId: receiverId ?? this.receiverId,
chatId: chatId ?? this.chatId,
duration: duration ?? this.duration,
videoCall: videoCall ?? this.videoCall,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
endedAt: endedAt ?? this.endedAt,
status: status ?? this.status,
isDeleted: isDeleted ?? this.isDeleted,
deletedAt: deletedAt ?? this.deletedAt,
isRead: isRead ?? this.isRead,
);
}
}

View File

@@ -0,0 +1,200 @@
/// 聊天 Domain 实体
class Chat {
final int id;
final int? typ;
final int? lastId;
final int? lastTyp;
final String? lastMsg;
final int? lastTime;
final int lastPos;
final int firstPos;
final int? msgIdx;
final String? profile;
final String? pin;
final String? icon;
final String iconGaussian;
final String? name;
final int? userId;
final int? chatId;
final int? friendId;
final int? sort;
final int? unreadNum;
final int? unreadCount;
final int? hideChatMsgIdx;
final int? readChatMsgIdx;
final int? otherReadIdx;
final String? unreadAtMsgIdx;
final int? deleteTime;
final int? addIndex;
final int flag;
final int? flagMy;
final int? autoDeleteInterval;
final int? mute;
final int? verified;
final int? createTime;
final int? startIdx;
final int? isReadMsg;
final String translateOutgoing;
final String translateIncoming;
final int incomingIdx;
final int outgoingIdx;
final int incomingSoundId;
final int outgoingSoundId;
final int notificationSoundId;
final String chatKey;
final String activeChatKey;
final int coverIdx;
final int round;
final int workspaceId;
final int localPermission;
const Chat({
required this.id,
this.typ,
this.lastId,
this.lastTyp,
this.lastMsg,
this.lastTime,
this.lastPos = 0,
this.firstPos = -1,
this.msgIdx,
this.profile,
this.pin,
this.icon,
this.iconGaussian = '',
this.name,
this.userId,
this.chatId,
this.friendId,
this.sort,
this.unreadNum,
this.unreadCount,
this.hideChatMsgIdx,
this.readChatMsgIdx,
this.otherReadIdx,
this.unreadAtMsgIdx,
this.deleteTime,
this.addIndex,
this.flag = 0,
this.flagMy,
this.autoDeleteInterval,
this.mute,
this.verified,
this.createTime,
this.startIdx,
this.isReadMsg,
this.translateOutgoing = '',
this.translateIncoming = '',
this.incomingIdx = 0,
this.outgoingIdx = 0,
this.incomingSoundId = 0,
this.outgoingSoundId = 0,
this.notificationSoundId = 0,
this.chatKey = '',
this.activeChatKey = '',
this.coverIdx = 0,
this.round = 0,
this.workspaceId = 0,
this.localPermission = 0,
});
Chat copyWith({
int? id,
int? typ,
int? lastId,
int? lastTyp,
String? lastMsg,
int? lastTime,
int? lastPos,
int? firstPos,
int? msgIdx,
String? profile,
String? pin,
String? icon,
String? iconGaussian,
String? name,
int? userId,
int? chatId,
int? friendId,
int? sort,
int? unreadNum,
int? unreadCount,
int? hideChatMsgIdx,
int? readChatMsgIdx,
int? otherReadIdx,
String? unreadAtMsgIdx,
int? deleteTime,
int? addIndex,
int? flag,
int? flagMy,
int? autoDeleteInterval,
int? mute,
int? verified,
int? createTime,
int? startIdx,
int? isReadMsg,
String? translateOutgoing,
String? translateIncoming,
int? incomingIdx,
int? outgoingIdx,
int? incomingSoundId,
int? outgoingSoundId,
int? notificationSoundId,
String? chatKey,
String? activeChatKey,
int? coverIdx,
int? round,
int? workspaceId,
int? localPermission,
}) {
return Chat(
id: id ?? this.id,
typ: typ ?? this.typ,
lastId: lastId ?? this.lastId,
lastTyp: lastTyp ?? this.lastTyp,
lastMsg: lastMsg ?? this.lastMsg,
lastTime: lastTime ?? this.lastTime,
lastPos: lastPos ?? this.lastPos,
firstPos: firstPos ?? this.firstPos,
msgIdx: msgIdx ?? this.msgIdx,
profile: profile ?? this.profile,
pin: pin ?? this.pin,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
name: name ?? this.name,
userId: userId ?? this.userId,
chatId: chatId ?? this.chatId,
friendId: friendId ?? this.friendId,
sort: sort ?? this.sort,
unreadNum: unreadNum ?? this.unreadNum,
unreadCount: unreadCount ?? this.unreadCount,
hideChatMsgIdx: hideChatMsgIdx ?? this.hideChatMsgIdx,
readChatMsgIdx: readChatMsgIdx ?? this.readChatMsgIdx,
otherReadIdx: otherReadIdx ?? this.otherReadIdx,
unreadAtMsgIdx: unreadAtMsgIdx ?? this.unreadAtMsgIdx,
deleteTime: deleteTime ?? this.deleteTime,
addIndex: addIndex ?? this.addIndex,
flag: flag ?? this.flag,
flagMy: flagMy ?? this.flagMy,
autoDeleteInterval: autoDeleteInterval ?? this.autoDeleteInterval,
mute: mute ?? this.mute,
verified: verified ?? this.verified,
createTime: createTime ?? this.createTime,
startIdx: startIdx ?? this.startIdx,
isReadMsg: isReadMsg ?? this.isReadMsg,
translateOutgoing: translateOutgoing ?? this.translateOutgoing,
translateIncoming: translateIncoming ?? this.translateIncoming,
incomingIdx: incomingIdx ?? this.incomingIdx,
outgoingIdx: outgoingIdx ?? this.outgoingIdx,
incomingSoundId: incomingSoundId ?? this.incomingSoundId,
outgoingSoundId: outgoingSoundId ?? this.outgoingSoundId,
notificationSoundId: notificationSoundId ?? this.notificationSoundId,
chatKey: chatKey ?? this.chatKey,
activeChatKey: activeChatKey ?? this.activeChatKey,
coverIdx: coverIdx ?? this.coverIdx,
round: round ?? this.round,
workspaceId: workspaceId ?? this.workspaceId,
localPermission: localPermission ?? this.localPermission,
);
}
}

View File

@@ -0,0 +1,100 @@
/// 聊天机器人 Domain 实体
class ChatBot {
final int id;
final String? name;
final String? username;
final int? botUserId;
final String? icon;
final String? iconGaussian;
final String? description;
final String? token;
final int? flag;
final int? status;
final String? webhook;
final String? commands;
final String? banner;
final int? channelId;
final int? channelGroupId;
final int? deletedAt;
final String? internalWebhook;
final int? mode;
final String? redirectUrl;
final int? isInvitable;
final int? isAllowForward;
final String? tips;
const ChatBot({
required this.id,
this.name,
this.username,
this.botUserId,
this.icon,
this.iconGaussian,
this.description,
this.token,
this.flag,
this.status,
this.webhook,
this.commands,
this.banner,
this.channelId,
this.channelGroupId,
this.deletedAt,
this.internalWebhook,
this.mode,
this.redirectUrl,
this.isInvitable,
this.isAllowForward,
this.tips,
});
ChatBot copyWith({
int? id,
String? name,
String? username,
int? botUserId,
String? icon,
String? iconGaussian,
String? description,
String? token,
int? flag,
int? status,
String? webhook,
String? commands,
String? banner,
int? channelId,
int? channelGroupId,
int? deletedAt,
String? internalWebhook,
int? mode,
String? redirectUrl,
int? isInvitable,
int? isAllowForward,
String? tips,
}) {
return ChatBot(
id: id ?? this.id,
name: name ?? this.name,
username: username ?? this.username,
botUserId: botUserId ?? this.botUserId,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
description: description ?? this.description,
token: token ?? this.token,
flag: flag ?? this.flag,
status: status ?? this.status,
webhook: webhook ?? this.webhook,
commands: commands ?? this.commands,
banner: banner ?? this.banner,
channelId: channelId ?? this.channelId,
channelGroupId: channelGroupId ?? this.channelGroupId,
deletedAt: deletedAt ?? this.deletedAt,
internalWebhook: internalWebhook ?? this.internalWebhook,
mode: mode ?? this.mode,
redirectUrl: redirectUrl ?? this.redirectUrl,
isInvitable: isInvitable ?? this.isInvitable,
isAllowForward: isAllowForward ?? this.isAllowForward,
tips: tips ?? this.tips,
);
}
}

View File

@@ -0,0 +1,48 @@
/// 聊天分类 Domain 实体
class ChatCategory {
final int id;
final String? name;
final String? includedChatIds;
final String? excludedChatIds;
final int? seq;
final int isHide;
final int createdAt;
final int updatedAt;
final int deletedAt;
const ChatCategory({
required this.id,
this.name,
this.includedChatIds,
this.excludedChatIds,
this.seq,
this.isHide = 0,
this.createdAt = 0,
this.updatedAt = 0,
this.deletedAt = 0,
});
ChatCategory copyWith({
int? id,
String? name,
String? includedChatIds,
String? excludedChatIds,
int? seq,
int? isHide,
int? createdAt,
int? updatedAt,
int? deletedAt,
}) {
return ChatCategory(
id: id ?? this.id,
name: name ?? this.name,
includedChatIds: includedChatIds ?? this.includedChatIds,
excludedChatIds: excludedChatIds ?? this.excludedChatIds,
seq: seq ?? this.seq,
isHide: isHide ?? this.isHide,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}

View File

@@ -0,0 +1,112 @@
/// 发现小程序 Domain 实体
class DiscoverMiniApp {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final String? lastLoginAt;
final String? screen;
const DiscoverMiniApp({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
DiscoverMiniApp copyWith({
String? id,
String? name,
String? openuid,
String? devId,
String? icon,
String? iconGaussian,
String? downloadUrl,
String? description,
int? version,
int? typ,
int? flag,
int? reviewStatus,
int? favoriteAt,
int? isActive,
int? createdAt,
int? updatedAt,
int? deletedAt,
double? score,
String? channels,
String? devName,
String? pictureGaussian,
String? picture,
int? commentNum,
String? lastLoginAt,
String? screen,
}) {
return DiscoverMiniApp(
id: id ?? this.id,
name: name ?? this.name,
openuid: openuid ?? this.openuid,
devId: devId ?? this.devId,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
downloadUrl: downloadUrl ?? this.downloadUrl,
description: description ?? this.description,
version: version ?? this.version,
typ: typ ?? this.typ,
flag: flag ?? this.flag,
reviewStatus: reviewStatus ?? this.reviewStatus,
favoriteAt: favoriteAt ?? this.favoriteAt,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
score: score ?? this.score,
channels: channels ?? this.channels,
devName: devName ?? this.devName,
pictureGaussian: pictureGaussian ?? this.pictureGaussian,
picture: picture ?? this.picture,
commentNum: commentNum ?? this.commentNum,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
screen: screen ?? this.screen,
);
}
}

View File

@@ -0,0 +1,112 @@
/// 探索小程序 Domain 实体
class ExploreMiniApp {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final int? lastLoginAt;
final String? screen;
const ExploreMiniApp({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
ExploreMiniApp copyWith({
String? id,
String? name,
String? openuid,
String? devId,
String? icon,
String? iconGaussian,
String? downloadUrl,
String? description,
int? version,
int? typ,
int? flag,
int? reviewStatus,
int? favoriteAt,
int? isActive,
int? createdAt,
int? updatedAt,
int? deletedAt,
double? score,
String? channels,
String? devName,
String? pictureGaussian,
String? picture,
int? commentNum,
int? lastLoginAt,
String? screen,
}) {
return ExploreMiniApp(
id: id ?? this.id,
name: name ?? this.name,
openuid: openuid ?? this.openuid,
devId: devId ?? this.devId,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
downloadUrl: downloadUrl ?? this.downloadUrl,
description: description ?? this.description,
version: version ?? this.version,
typ: typ ?? this.typ,
flag: flag ?? this.flag,
reviewStatus: reviewStatus ?? this.reviewStatus,
favoriteAt: favoriteAt ?? this.favoriteAt,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
score: score ?? this.score,
channels: channels ?? this.channels,
devName: devName ?? this.devName,
pictureGaussian: pictureGaussian ?? this.pictureGaussian,
picture: picture ?? this.picture,
commentNum: commentNum ?? this.commentNum,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
screen: screen ?? this.screen,
);
}
}

View File

@@ -0,0 +1,112 @@
/// 收藏小程序 Domain 实体
class FavoriteMiniApp {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final int? lastLoginAt;
final String? screen;
const FavoriteMiniApp({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
FavoriteMiniApp copyWith({
String? id,
String? name,
String? openuid,
String? devId,
String? icon,
String? iconGaussian,
String? downloadUrl,
String? description,
int? version,
int? typ,
int? flag,
int? reviewStatus,
int? favoriteAt,
int? isActive,
int? createdAt,
int? updatedAt,
int? deletedAt,
double? score,
String? channels,
String? devName,
String? pictureGaussian,
String? picture,
int? commentNum,
int? lastLoginAt,
String? screen,
}) {
return FavoriteMiniApp(
id: id ?? this.id,
name: name ?? this.name,
openuid: openuid ?? this.openuid,
devId: devId ?? this.devId,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
downloadUrl: downloadUrl ?? this.downloadUrl,
description: description ?? this.description,
version: version ?? this.version,
typ: typ ?? this.typ,
flag: flag ?? this.flag,
reviewStatus: reviewStatus ?? this.reviewStatus,
favoriteAt: favoriteAt ?? this.favoriteAt,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
score: score ?? this.score,
channels: channels ?? this.channels,
devName: devName ?? this.devName,
pictureGaussian: pictureGaussian ?? this.pictureGaussian,
picture: picture ?? this.picture,
commentNum: commentNum ?? this.commentNum,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
screen: screen ?? this.screen,
);
}
}

View File

@@ -0,0 +1,72 @@
/// 收藏 Domain 实体
class Favourite {
final int id;
final String parentId;
final String data;
final int createdAt;
final int updatedAt;
final int deletedAt;
final int? source;
final int? userId;
final int? authorId;
final String typ;
final String tag;
final int isPin;
final int chatTyp;
final int isUploaded;
final String urls;
const Favourite({
required this.id,
this.parentId = '',
this.data = '',
this.createdAt = 0,
this.updatedAt = 0,
this.deletedAt = 0,
this.source,
this.userId,
this.authorId,
this.typ = '[]',
this.tag = '[]',
this.isPin = 0,
this.chatTyp = 0,
this.isUploaded = 1,
this.urls = '[]',
});
Favourite copyWith({
int? id,
String? parentId,
String? data,
int? createdAt,
int? updatedAt,
int? deletedAt,
int? source,
int? userId,
int? authorId,
String? typ,
String? tag,
int? isPin,
int? chatTyp,
int? isUploaded,
String? urls,
}) {
return Favourite(
id: id ?? this.id,
parentId: parentId ?? this.parentId,
data: data ?? this.data,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
source: source ?? this.source,
userId: userId ?? this.userId,
authorId: authorId ?? this.authorId,
typ: typ ?? this.typ,
tag: tag ?? this.tag,
isPin: isPin ?? this.isPin,
chatTyp: chatTyp ?? this.chatTyp,
isUploaded: isUploaded ?? this.isUploaded,
urls: urls ?? this.urls,
);
}
}

View File

@@ -0,0 +1,44 @@
/// 收藏详情 Domain 实体
class FavouriteDetail {
final int? id;
final String relatedId;
final String content;
final int? typ;
final int? messageId;
final int? sendId;
final int? chatId;
final int? sendTime;
const FavouriteDetail({
this.id,
this.relatedId = '',
this.content = '',
this.typ,
this.messageId,
this.sendId,
this.chatId,
this.sendTime,
});
FavouriteDetail copyWith({
int? id,
String? relatedId,
String? content,
int? typ,
int? messageId,
int? sendId,
int? chatId,
int? sendTime,
}) {
return FavouriteDetail(
id: id ?? this.id,
relatedId: relatedId ?? this.relatedId,
content: content ?? this.content,
typ: typ ?? this.typ,
messageId: messageId ?? this.messageId,
sendId: sendId ?? this.sendId,
chatId: chatId ?? this.chatId,
sendTime: sendTime ?? this.sendTime,
);
}
}

View File

@@ -0,0 +1,124 @@
/// 群组 Domain 实体
class Group {
final int id;
final int? userJoinDate;
final String? name;
final String? profile;
final String? icon;
final String iconGaussian;
final int? permission;
final int? admin;
final String? members;
final int? owner;
final String? admins;
final int? visible;
final int? speakInterval;
final int? groupType;
final int? roomType;
final int? maxNumber;
final int? channelId;
final int? channelGroupId;
final int? createTime;
final int? updateTime;
final int? addIndex;
final int? maxMember;
final int? expireTime;
final int workspaceId;
final int mode;
final int redpacketPlay;
final String? topic;
final String? rp;
const Group({
required this.id,
this.userJoinDate,
this.name,
this.profile,
this.icon,
this.iconGaussian = '',
this.permission,
this.admin,
this.members,
this.owner,
this.admins,
this.visible,
this.speakInterval,
this.groupType,
this.roomType,
this.maxNumber,
this.channelId,
this.channelGroupId,
this.createTime,
this.updateTime,
this.addIndex,
this.maxMember,
this.expireTime,
this.workspaceId = 0,
this.mode = 0,
this.redpacketPlay = 0,
this.topic,
this.rp,
});
Group copyWith({
int? id,
int? userJoinDate,
String? name,
String? profile,
String? icon,
String? iconGaussian,
int? permission,
int? admin,
String? members,
int? owner,
String? admins,
int? visible,
int? speakInterval,
int? groupType,
int? roomType,
int? maxNumber,
int? channelId,
int? channelGroupId,
int? createTime,
int? updateTime,
int? addIndex,
int? maxMember,
int? expireTime,
int? workspaceId,
int? mode,
int? redpacketPlay,
String? topic,
String? rp,
}) {
return Group(
id: id ?? this.id,
userJoinDate: userJoinDate ?? this.userJoinDate,
name: name ?? this.name,
profile: profile ?? this.profile,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
permission: permission ?? this.permission,
admin: admin ?? this.admin,
members: members ?? this.members,
owner: owner ?? this.owner,
admins: admins ?? this.admins,
visible: visible ?? this.visible,
speakInterval: speakInterval ?? this.speakInterval,
groupType: groupType ?? this.groupType,
roomType: roomType ?? this.roomType,
maxNumber: maxNumber ?? this.maxNumber,
channelId: channelId ?? this.channelId,
channelGroupId: channelGroupId ?? this.channelGroupId,
createTime: createTime ?? this.createTime,
updateTime: updateTime ?? this.updateTime,
addIndex: addIndex ?? this.addIndex,
maxMember: maxMember ?? this.maxMember,
expireTime: expireTime ?? this.expireTime,
workspaceId: workspaceId ?? this.workspaceId,
mode: mode ?? this.mode,
redpacketPlay: redpacketPlay ?? this.redpacketPlay,
topic: topic ?? this.topic,
rp: rp ?? this.rp,
);
}
}

View File

@@ -0,0 +1,76 @@
/// 消息 Domain 实体
class Message {
final int id;
final int? messageId;
final int? chatId;
final int? chatIdx;
final int? sendId;
final String? content;
final int? typ;
final int? sendTime;
final int? expireTime;
final int? createTime;
final String? atUsers;
final String emojis;
final int editTime;
final int refTyp;
final int flag;
final String cmid;
const Message({
required this.id,
this.messageId,
this.chatId,
this.chatIdx,
this.sendId,
this.content,
this.typ,
this.sendTime,
this.expireTime,
this.createTime,
this.atUsers,
this.emojis = '[]',
this.editTime = 0,
this.refTyp = 0,
this.flag = 0,
this.cmid = '',
});
Message copyWith({
int? id,
int? messageId,
int? chatId,
int? chatIdx,
int? sendId,
String? content,
int? typ,
int? sendTime,
int? expireTime,
int? createTime,
String? atUsers,
String? emojis,
int? editTime,
int? refTyp,
int? flag,
String? cmid,
}) {
return Message(
id: id ?? this.id,
messageId: messageId ?? this.messageId,
chatId: chatId ?? this.chatId,
chatIdx: chatIdx ?? this.chatIdx,
sendId: sendId ?? this.sendId,
content: content ?? this.content,
typ: typ ?? this.typ,
sendTime: sendTime ?? this.sendTime,
expireTime: expireTime ?? this.expireTime,
createTime: createTime ?? this.createTime,
atUsers: atUsers ?? this.atUsers,
emojis: emojis ?? this.emojis,
editTime: editTime ?? this.editTime,
refTyp: refTyp ?? this.refTyp,
flag: flag ?? this.flag,
cmid: cmid ?? this.cmid,
);
}
}

View File

@@ -0,0 +1,36 @@
/// 待处理好友请求历史 Domain 实体
class PendingFriendRequestHistory {
final int id;
final int uid;
final int requestTime;
final String? remarks;
final String? source;
final int? rs;
const PendingFriendRequestHistory({
required this.id,
required this.uid,
required this.requestTime,
this.remarks,
this.source,
this.rs,
});
PendingFriendRequestHistory copyWith({
int? id,
int? uid,
int? requestTime,
String? remarks,
String? source,
int? rs,
}) {
return PendingFriendRequestHistory(
id: id ?? this.id,
uid: uid ?? this.uid,
requestTime: requestTime ?? this.requestTime,
remarks: remarks ?? this.remarks,
source: source ?? this.source,
rs: rs ?? this.rs,
);
}
}

View File

@@ -0,0 +1,112 @@
/// 最近小程序 Domain 实体
class RecentMiniApp {
final String id;
final String? name;
final String? openuid;
final String? devId;
final String? icon;
final String? iconGaussian;
final String? downloadUrl;
final String? description;
final int? version;
final int? typ;
final int? flag;
final int? reviewStatus;
final int? favoriteAt;
final int? isActive;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final double? score;
final String? channels;
final String? devName;
final String? pictureGaussian;
final String? picture;
final int? commentNum;
final int? lastLoginAt;
final String? screen;
const RecentMiniApp({
required this.id,
this.name,
this.openuid,
this.devId,
this.icon,
this.iconGaussian,
this.downloadUrl,
this.description,
this.version,
this.typ,
this.flag,
this.reviewStatus,
this.favoriteAt,
this.isActive,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.score,
this.channels,
this.devName,
this.pictureGaussian,
this.picture,
this.commentNum,
this.lastLoginAt,
this.screen,
});
RecentMiniApp copyWith({
String? id,
String? name,
String? openuid,
String? devId,
String? icon,
String? iconGaussian,
String? downloadUrl,
String? description,
int? version,
int? typ,
int? flag,
int? reviewStatus,
int? favoriteAt,
int? isActive,
int? createdAt,
int? updatedAt,
int? deletedAt,
double? score,
String? channels,
String? devName,
String? pictureGaussian,
String? picture,
int? commentNum,
int? lastLoginAt,
String? screen,
}) {
return RecentMiniApp(
id: id ?? this.id,
name: name ?? this.name,
openuid: openuid ?? this.openuid,
devId: devId ?? this.devId,
icon: icon ?? this.icon,
iconGaussian: iconGaussian ?? this.iconGaussian,
downloadUrl: downloadUrl ?? this.downloadUrl,
description: description ?? this.description,
version: version ?? this.version,
typ: typ ?? this.typ,
flag: flag ?? this.flag,
reviewStatus: reviewStatus ?? this.reviewStatus,
favoriteAt: favoriteAt ?? this.favoriteAt,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
score: score ?? this.score,
channels: channels ?? this.channels,
devName: devName ?? this.devName,
pictureGaussian: pictureGaussian ?? this.pictureGaussian,
picture: picture ?? this.picture,
commentNum: commentNum ?? this.commentNum,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
screen: screen ?? this.screen,
);
}
}

View File

@@ -0,0 +1,60 @@
/// 重试 Domain 实体
class Retry {
final int? id;
final int? uid;
final String apiType;
final String endPoint;
final String requestData;
final int? synced;
final String callbackFun;
final int? expired;
final int? replace;
final int? expireTime;
final int? createTime;
final int? addIndex;
const Retry({
this.id,
this.uid,
this.apiType = '',
this.endPoint = '',
this.requestData = '',
this.synced,
this.callbackFun = '',
this.expired,
this.replace,
this.expireTime,
this.createTime,
this.addIndex,
});
Retry copyWith({
int? id,
int? uid,
String? apiType,
String? endPoint,
String? requestData,
int? synced,
String? callbackFun,
int? expired,
int? replace,
int? expireTime,
int? createTime,
int? addIndex,
}) {
return Retry(
id: id ?? this.id,
uid: uid ?? this.uid,
apiType: apiType ?? this.apiType,
endPoint: endPoint ?? this.endPoint,
requestData: requestData ?? this.requestData,
synced: synced ?? this.synced,
callbackFun: callbackFun ?? this.callbackFun,
expired: expired ?? this.expired,
replace: replace ?? this.replace,
expireTime: expireTime ?? this.expireTime,
createTime: createTime ?? this.createTime,
addIndex: addIndex ?? this.addIndex,
);
}
}

View File

@@ -0,0 +1,48 @@
/// 音效 Domain 实体
class Sound {
final int id;
final String filePath;
final int typ;
final String name;
final int createdAt;
final int updatedAt;
final int deletedAt;
final int channelGroupId;
final int isDefault;
const Sound({
required this.id,
this.filePath = '',
required this.typ,
this.name = '',
required this.createdAt,
required this.updatedAt,
this.deletedAt = 0,
required this.channelGroupId,
required this.isDefault,
});
Sound copyWith({
int? id,
String? filePath,
int? typ,
String? name,
int? createdAt,
int? updatedAt,
int? deletedAt,
int? channelGroupId,
int? isDefault,
}) {
return Sound(
id: id ?? this.id,
filePath: filePath ?? this.filePath,
typ: typ ?? this.typ,
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
channelGroupId: channelGroupId ?? this.channelGroupId,
isDefault: isDefault ?? this.isDefault,
);
}
}

View File

@@ -0,0 +1,40 @@
/// 标签 Domain 实体
class Tag {
final int? id;
final int? uid;
final String name;
final int? type;
final int? createdAt;
final int? updatedAt;
final int? addIndex;
const Tag({
this.id,
this.uid,
this.name = '',
this.type,
this.createdAt,
this.updatedAt,
this.addIndex,
});
Tag copyWith({
int? id,
int? uid,
String? name,
int? type,
int? createdAt,
int? updatedAt,
int? addIndex,
}) {
return Tag(
id: id ?? this.id,
uid: uid ?? this.uid,
name: name ?? this.name,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
addIndex: addIndex ?? this.addIndex,
);
}
}

View File

@@ -14,15 +14,81 @@
/// → View 渲染 /// → View 渲染
/// ``` /// ```
class User { class User {
final String id; final int uid;
final String email; final String? uuid;
final int? lastOnline;
final String? profilePic;
final String? profilePicGaussian;
final String? nickname; final String? nickname;
final String? avatar; final String? contact;
final String? countryCode;
final String? email;
final String? recoveryEmail;
final String? username;
final String? bio;
final int? relationship;
final String? userAlias;
final int? channelId;
final int? channelGroupId;
final String? hint;
const User({ const User({
required this.id, required this.uid,
required this.email, this.uuid,
this.lastOnline,
this.profilePic,
this.profilePicGaussian,
this.nickname, this.nickname,
this.avatar, this.contact,
this.countryCode,
this.email,
this.recoveryEmail,
this.username,
this.bio,
this.relationship,
this.userAlias,
this.channelId,
this.channelGroupId,
this.hint,
}); });
User copyWith({
int? uid,
String? uuid,
int? lastOnline,
String? profilePic,
String? profilePicGaussian,
String? nickname,
String? contact,
String? countryCode,
String? email,
String? recoveryEmail,
String? username,
String? bio,
int? relationship,
String? userAlias,
int? channelId,
int? channelGroupId,
String? hint,
}) {
return User(
uid: uid ?? this.uid,
uuid: uuid ?? this.uuid,
lastOnline: lastOnline ?? this.lastOnline,
profilePic: profilePic ?? this.profilePic,
profilePicGaussian: profilePicGaussian ?? this.profilePicGaussian,
nickname: nickname ?? this.nickname,
contact: contact ?? this.contact,
countryCode: countryCode ?? this.countryCode,
email: email ?? this.email,
recoveryEmail: recoveryEmail ?? this.recoveryEmail,
username: username ?? this.username,
bio: bio ?? this.bio,
relationship: relationship ?? this.relationship,
userAlias: userAlias ?? this.userAlias,
channelId: channelId ?? this.channelId,
channelGroupId: channelGroupId ?? this.channelGroupId,
hint: hint ?? this.hint,
);
}
} }

View File

@@ -0,0 +1,16 @@
/// 用户请求历史 Domain 实体
class UserRequestHistory {
final int id;
final int? status;
final int? createdAt;
const UserRequestHistory({required this.id, this.status, this.createdAt});
UserRequestHistory copyWith({int? id, int? status, int? createdAt}) {
return UserRequestHistory(
id: id ?? this.id,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@@ -0,0 +1,64 @@
/// 工作空间 Domain 实体
class Workspace {
final int id;
final String? name;
final int? ownerId;
final String? description;
final String? logo;
final int? grade;
final int? cap;
final String? currency;
final int? status;
final int? createdAt;
final int? updatedAt;
final int? deletedAt;
final int? channelGroupId;
const Workspace({
required this.id,
this.name,
this.ownerId,
this.description,
this.logo,
this.grade,
this.cap,
this.currency,
this.status,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.channelGroupId,
});
Workspace copyWith({
int? id,
String? name,
int? ownerId,
String? description,
String? logo,
int? grade,
int? cap,
String? currency,
int? status,
int? createdAt,
int? updatedAt,
int? deletedAt,
int? channelGroupId,
}) {
return Workspace(
id: id ?? this.id,
name: name ?? this.name,
ownerId: ownerId ?? this.ownerId,
description: description ?? this.description,
logo: logo ?? this.logo,
grade: grade ?? this.grade,
cap: cap ?? this.cap,
currency: currency ?? this.currency,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
channelGroupId: channelGroupId ?? this.channelGroupId,
);
}
}

View File

@@ -58,17 +58,16 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
return ChatDbTestState(testResults: testResults); return ChatDbTestState(testResults: testResults);
} }
// ── 导航Demo 按钮,正式开发后随 UI 一并替换) ────────────────────────── // ── 操作Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
/// 开始测试 /// 切换测试状态(开始 / 停止)
void startDBTest(BuildContext context) { void toggleDBTest() {
state = state.copyWith(testStarted: true, currentState: '开始测试'); if (state.testStarted) {
_testDBInsert(); state = state.copyWith(testStarted: false, currentState: '结束测试');
} } else {
state = state.copyWith(testStarted: true, currentState: '开始测试');
/// 结束测试 _testDBInsert();
void stopDBTest(BuildContext context) { }
state = state.copyWith(testStarted: false, currentState: '结束测试');
} }
Future<void> _testDBInsert() async { Future<void> _testDBInsert() async {
@@ -84,13 +83,11 @@ class ChatDbTestViewModel extends _$ChatDbTestViewModel {
for (var i = 0; i < count; i += chunkSize) { for (var i = 0; i < count; i += chunkSize) {
final chunk = List.generate( final chunk = List.generate(
chunkSize.clamp(0, count - i), chunkSize.clamp(0, count - i),
(j) => UsersCompanion.insert( (j) =>
uid: Value(i + j), UsersCompanion.insert(uid: i + j, nickname: Value('User ${i + j}')),
nickname: Value('User ${i + j}'),
),
); );
await db.batchInsertOrReplace<User>(chunk); await db.batchInsertOrReplace<DriftUser>(chunk);
completed += chunk.length; completed += chunk.length;
// 让出主线程 // 让出主线程

View File

@@ -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 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
import '../../../core/ui/components/app_button.dart'; import '../../../core/ui/components/app_button.dart';
import '../presentation/chat_view_model.dart';
/// 聊天Demo 按钮 /// 数据库性能测试Demo
/// ///
/// 包含五个演示按钮,覆盖 go_router 的常见导航场景: /// 批量插入 10000 条用户记录,验证 Drift 批量写入性能。
/// - 「切换 Tab」 — go替换历史不可返回 /// 所有操作通过 [ChatDbTestViewModel] 处理View 只负责渲染。
/// - 「有参 pushextra」 — push + extraDart Record可返回 /// 正式开发后此页面将被删除。
/// - 「有参 push路径参数」— push + URL 内嵌 id可返回
/// - 「无参 push」 — push可返回
/// - 「退出登录」 — 守卫自动重定向到 /login
///
/// 所有操作通过 [ChatViewModel] 处理View 不直接调用路由。
/// 正式开发后替换为会话列表,按钮相关代码一并清除。
class ChatDbTestPage extends ConsumerWidget { class ChatDbTestPage extends ConsumerWidget {
const ChatDbTestPage({super.key}); const ChatDbTestPage({super.key});
@@ -25,7 +18,7 @@ class ChatDbTestPage extends ConsumerWidget {
final state = ref.watch(chatDbTestViewModelProvider); final state = ref.watch(chatDbTestViewModelProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('测试数据库'), ), appBar: AppBar(title: const Text('测试数据库')),
body: Column( body: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
spacing: 16, spacing: 16,
@@ -38,17 +31,14 @@ class ChatDbTestPage extends ConsumerWidget {
children: [ children: [
AppButton.inverse( AppButton.inverse(
label: state.testStarted ? '结束' : '开始', label: state.testStarted ? '结束' : '开始',
onPressed: () => state.testStarted ? vm.stopDBTest(context) : vm.startDBTest(context), onPressed: () => vm.toggleDBTest(),
), ),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(state.currentState, textAlign: TextAlign.end),
state.currentState, ),
textAlign: TextAlign.end,
),
)
], ],
) ),
), ),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
@@ -63,7 +53,7 @@ class ChatDbTestPage extends ConsumerWidget {
); );
}, },
), ),
) ),
], ],
), ),
); );

View File

@@ -1,4 +1,8 @@
import 'package:flutter/foundation.dart'; 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:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/db_provider.dart'; import 'package:im_app/app/di/db_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -55,16 +59,25 @@ class LoginViewModel extends _$LoginViewModel {
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。 /// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。 /// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
Future<void> demoLogin() async { Future<void> demoLogin() async {
final storageApi = ref.read(storageSdkProvider); try {
final storageApi = ref.read(storageSdkProvider);
final storageLifeCycle = storageApi as StorageSdkLifecycle;
///TODO: StorageSDKLifeCycle 需要只在主项目暴露 // 读取 mock 数据
final storageLifeCycle = storageApi as StorageSdkLifecycle; final raw = await rootBundle.loadString('assets/loginData.json');
ref.read(authNotifierProvider).login(); final json = jsonDecode(raw) as Map<String, dynamic>;
final loginResponse = LoginResponse.fromJson(json);
final user = loginResponse.data.toEntity();
await storageLifeCycle.openDatabase(1234567); // 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
final rows = await storageApi.rawQuery("PRAGMA table_info('user')"); await storageLifeCycle.openDatabase(user.uid);
for (final row in rows) { final userCompanion = UserDto.fromEntity(user).toCompanion();
debugPrint('Schema: ${row.data}'); await storageApi.insert(userCompanion);
// 全部成功后再更新登录状态,触发路由守卫重定向
ref.read(authNotifierProvider).login();
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
} }
} }

View File

@@ -66,3 +66,5 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/

View File

@@ -9,6 +9,7 @@
library; library;
export 'src/presentation/facade/cipher_guard_sdk_api.dart'; 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/rsa_key_pair.dart';
export 'src/domain/entities/session_key.dart'; export 'src/domain/entities/session_key.dart';
export 'src/domain/entities/encrypted_message.dart'; export 'src/domain/entities/encrypted_message.dart';

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -7,30 +8,96 @@ import 'package:crypto/crypto.dart';
import 'package:encrypt/encrypt.dart' as encrypt_pkg; import 'package:encrypt/encrypt.dart' as encrypt_pkg;
import 'package:pointycastle/api.dart'; import 'package:pointycastle/api.dart';
import 'package:pointycastle/asymmetric/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/api.dart';
import 'package:pointycastle/key_generators/rsa_key_generator.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/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]UU 兼容),可选 [pbkdf2](增强安全性)。
///
/// 解密旧数据时必须使用加密时相同的模式,
/// 通过消息的 version 字段区分。
enum KdfMode {
/// MD5 简单哈希UU 兼容默认模式)
///
/// 适用于 session key 已是 32 字节强随机值的场景。
/// 性能好,每次调用 < 0.1ms。
md5,
/// PBKDF2-HMAC-SHA256可选增强模式
///
/// 适用于从弱密码派生密钥的场景。
/// 性能取决于迭代次数10000 次约 10-50ms。
pbkdf2,
}
/// Flutter 加密服务
///
/// 端对端加密的核心引擎,纯 Dart 实现。
/// 使用 pointycastleRSA+ encryptAES+ cryptoMD5
///
/// ## 性能优化
///
/// - **RSA 密钥生成**:通过 [generateRsaKeyPairAsync] 在 Isolate 中运行,
/// 避免阻塞主线程1024-bit 约 150ms2048-bit 约 300ms
/// - **派生密钥缓存**[_deriveKeyForRound] 结果按 (sessionKey, round) 缓存,
/// 同一 session 的重复加解密直接命中缓存
/// - **Random.secure() 复用**:全局单例,不再每次调用创建新实例
/// - **KDF 双模式**MD5默认UU 兼容)/ PBKDF2可选增强安全性
class EncryptionFlutterService { class EncryptionFlutterService {
// ==================== Constants ==================== // ==================== 配置 ====================
/// 密钥派生模式,默认 MD5UU 兼容)
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 sessionKeySize = 32;
static const int gcmIvLength = 12; static const int gcmIvLength = 12;
static const int _maxDerivedKeyCacheSize = 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 = <String, Uint8List>{};
/// 清空派生密钥缓存session key 轮换时调用)
void clearDerivedKeyCache() => _derivedKeyCache.clear();
// ==================== RSA 密钥管理 ====================
/// 生成 RSA 密钥对(同步,阻塞主线程)
///
/// 建议使用 [generateRsaKeyPairAsync] 代替,避免 UI 卡顿。
RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) { RsaKeyPairResult generateRsaKeyPair({int keySize = 1024}) {
try { try {
// Get secure random
final secureRandom = FortunaRandom(); final secureRandom = FortunaRandom();
secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32))); secureRandom.seed(KeyParameter(_generateSecureRandomBytes(32)));
// Create RSA key generator
final keyGen = RSAKeyGenerator(); final keyGen = RSAKeyGenerator();
keyGen.init( keyGen.init(
ParametersWithRandom( ParametersWithRandom(
@@ -39,12 +106,10 @@ class EncryptionFlutterService {
), ),
); );
// Generate key pair
final keyPair = keyGen.generateKeyPair(); final keyPair = keyGen.generateKeyPair();
final rsaPublicKey = keyPair.publicKey; final rsaPublicKey = keyPair.publicKey;
final rsaPrivateKey = keyPair.privateKey; final rsaPrivateKey = keyPair.privateKey;
// Export to PEM format
final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey); final publicKeyPem = _encodeRSAPublicKey(rsaPublicKey);
final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey); final privateKeyPem = _encodeRSAPrivateKey(rsaPrivateKey);
@@ -57,26 +122,38 @@ class EncryptionFlutterService {
} }
} }
/// Encode RSA public key to PEM format using asn1lib /// 生成 RSA 密钥对(异步,在 Isolate 中运行,不阻塞主线程)
///
/// RSA 密钥生成是 CPU 密集型操作1024-bit 约 150ms2048-bit 约 300ms
/// 放在 Isolate 中避免主线程卡顿。
///
/// **Isolate 隔离说明**
/// Isolate 内会创建一个**默认配置**的 EncryptionFlutterServiceKdfMode.md5
/// 不会继承当前实例的 kdfMode / pbkdf2Iterations。
/// 这对 RSA 密钥生成没有影响RSA 不走 KDF但如果将来需要在
/// Isolate 中执行依赖 KDF 的操作(如消息加解密),需要传递配置参数。
Future<RsaKeyPairResult> generateRsaKeyPairAsync({int keySize = 1024}) async {
return await Isolate.run(
() => EncryptionFlutterService().generateRsaKeyPair(keySize: keySize),
);
}
/// 编码 RSA 公钥为 PEM 格式
String _encodeRSAPublicKey(RSAPublicKey publicKey) { String _encodeRSAPublicKey(RSAPublicKey publicKey) {
// Build RSAPublicKeyInfo structure
final topSeq = ASN1Sequence(); final topSeq = ASN1Sequence();
// AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 + NULL
final algoSeq = ASN1Sequence(); 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()); algoSeq.add(ASN1Null());
topSeq.add(algoSeq); topSeq.add(algoSeq);
// RSAPublicKey: modulus + publicExponent
final keySeq = ASN1Sequence(); final keySeq = ASN1Sequence();
keySeq.add(ASN1Integer(publicKey.n!)); keySeq.add(ASN1Integer(publicKey.n!));
keySeq.add(ASN1Integer(publicKey.exponent!)); keySeq.add(ASN1Integer(publicKey.exponent!));
// BitString wrapping the key (with 0 unused bits prefix)
final keyBytes = keySeq.encodedBytes; final keyBytes = keySeq.encodedBytes;
final keyList = List<int>.from(keyBytes); final keyList = List<int>.from(keyBytes);
keyList.insert(0, 0); // Add unused bits byte keyList.insert(0, 0);
topSeq.add(ASN1BitString(keyList)); topSeq.add(ASN1BitString(keyList));
final derBytes = topSeq.encodedBytes; final derBytes = topSeq.encodedBytes;
@@ -84,51 +161,32 @@ class EncryptionFlutterService {
return '-----BEGIN PUBLIC KEY-----\n$base64\n-----END PUBLIC KEY-----'; 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) { String _encodeRSAPrivateKey(RSAPrivateKey privateKey) {
// Build RSAPrivateKey structure (PKCS#8 format)
final topSeq = ASN1Sequence(); final topSeq = ASN1Sequence();
// Version (0)
topSeq.add(ASN1Integer(BigInt.zero)); topSeq.add(ASN1Integer(BigInt.zero));
// Modulus
topSeq.add(ASN1Integer(privateKey.n!)); topSeq.add(ASN1Integer(privateKey.n!));
// Public Exponent
topSeq.add(ASN1Integer(privateKey.exponent!)); topSeq.add(ASN1Integer(privateKey.exponent!));
// Private Exponent
topSeq.add(ASN1Integer(privateKey.privateExponent!)); topSeq.add(ASN1Integer(privateKey.privateExponent!));
// Prime P
topSeq.add(ASN1Integer(privateKey.p!)); topSeq.add(ASN1Integer(privateKey.p!));
// Prime Q
topSeq.add(ASN1Integer(privateKey.q!)); topSeq.add(ASN1Integer(privateKey.q!));
// (Optional CRT params omitted for simplicity)
final derBytes = topSeq.encodedBytes; final derBytes = topSeq.encodedBytes;
final base64 = base64Encode(derBytes.toList()); final base64 = base64Encode(derBytes.toList());
return '-----BEGIN PRIVATE KEY-----\n$base64\n-----END PRIVATE KEY-----'; 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({ String encryptPrivateKey({
required String privateKey, required String privateKey,
required String password, required String password,
}) { }) {
try { try {
// Generate AES key from MD5(password)
final aesKey = _md5Hash(password); final aesKey = _md5Hash(password);
// Generate random IV (16 bytes)
final iv = _generateSecureRandomBytes(16); final iv = _generateSecureRandomBytes(16);
// AES encrypt using encrypt package
final secretKey = encrypt_pkg.Key(aesKey); final secretKey = encrypt_pkg.Key(aesKey);
final encryptor = encrypt_pkg.Encrypter( final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc), encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
@@ -137,7 +195,6 @@ class EncryptionFlutterService {
final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv)); final encrypted = encryptor.encrypt(privateKey, iv: encrypt_pkg.IV(iv));
final encryptedBytes = encrypted.bytes; final encryptedBytes = encrypted.bytes;
// Combine IV + encrypted data
final combined = Uint8List(iv.length + encryptedBytes.length); final combined = Uint8List(iv.length + encryptedBytes.length);
combined.setAll(0, iv); combined.setAll(0, iv);
combined.setAll(iv.length, encryptedBytes); combined.setAll(iv.length, encryptedBytes);
@@ -148,23 +205,17 @@ class EncryptionFlutterService {
} }
} }
/// Decrypt private key with password (AES-CBC with MD5-derived key) /// 用密码解密私钥AES-CBC密码通过 MD5 派生密钥)
String decryptPrivateKey({ String decryptPrivateKey({
required String encryptedPrivateKey, required String encryptedPrivateKey,
required String password, required String password,
}) { }) {
try { try {
// Generate AES key from MD5(password)
final aesKey = _md5Hash(password); final aesKey = _md5Hash(password);
// Decode Base64
final combined = base64Decode(encryptedPrivateKey); final combined = base64Decode(encryptedPrivateKey);
// Extract IV and encrypted data
final iv = combined.sublist(0, 16); final iv = combined.sublist(0, 16);
final encBytes = combined.sublist(16); final encBytes = combined.sublist(16);
// AES decrypt
final secretKey = encrypt_pkg.Key(aesKey); final secretKey = encrypt_pkg.Key(aesKey);
final encryptor = encrypt_pkg.Encrypter( final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc), encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.cbc),
@@ -181,9 +232,9 @@ class EncryptionFlutterService {
} }
} }
// ==================== Session Key Management ==================== // ==================== 会话密钥管理 ====================
/// Generate session key (32 bytes random) /// 生成会话密钥32 字节随机)
SessionKeyResult generateSessionKey({int initialRound = 1}) { SessionKeyResult generateSessionKey({int initialRound = 1}) {
final keyBytes = _generateSecureRandomBytes(sessionKeySize); final keyBytes = _generateSecureRandomBytes(sessionKeySize);
final key = base64Encode(keyBytes); final key = base64Encode(keyBytes);
@@ -191,16 +242,14 @@ class EncryptionFlutterService {
return SessionKeyResult(key: key, round: initialRound); return SessionKeyResult(key: key, round: initialRound);
} }
/// Encrypt session key with RSA public key /// 用 RSA 公钥加密会话密钥
String encryptSessionKey({ String encryptSessionKey({
required String sessionKey, required String sessionKey,
required String publicKey, required String publicKey,
}) { }) {
try { try {
// Parse RSA public key
final rsaPublicKey = _parsePublicKey(publicKey); final rsaPublicKey = _parsePublicKey(publicKey);
// RSA encrypt using PKCS1 padding (like native implementations)
final cipher = PKCS1Encoding(RSAEngine()); final cipher = PKCS1Encoding(RSAEngine());
cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey)); cipher.init(true, PublicKeyParameter<RSAPublicKey>(rsaPublicKey));
@@ -211,16 +260,14 @@ class EncryptionFlutterService {
} }
} }
/// Decrypt session key with RSA private key /// 用 RSA 私钥解密会话密钥
String decryptSessionKey({ String decryptSessionKey({
required String encryptedSessionKey, required String encryptedSessionKey,
required String privateKey, required String privateKey,
}) { }) {
try { try {
// Parse RSA private key
final rsaPrivateKey = _parsePrivateKey(privateKey); final rsaPrivateKey = _parsePrivateKey(privateKey);
// RSA decrypt using PKCS1 padding (like native implementations)
final cipher = PKCS1Encoding(RSAEngine()); final cipher = PKCS1Encoding(RSAEngine());
cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey)); cipher.init(false, PrivateKeyParameter<RSAPrivateKey>(rsaPrivateKey));
@@ -231,22 +278,18 @@ class EncryptionFlutterService {
} }
} }
// ==================== Message Encryption/Decryption ==================== // ==================== 消息加密/解密 ====================
/// Encrypt message (AES-CTR with round-based key derivation) /// 加密消息(AES-CTR,使用 round 派生密钥)
EncryptedMessageResult encryptMessage({ EncryptedMessageResult encryptMessage({
required String plaintext, required String plaintext,
required String sessionKey, required String sessionKey,
required int round, required int round,
}) { }) {
try { try {
// Derive key for round
final actualKey = _deriveKeyForRound(sessionKey, round); final actualKey = _deriveKeyForRound(sessionKey, round);
// Generate random IV (16 bytes for CTR)
final iv = _generateSecureRandomBytes(16); final iv = _generateSecureRandomBytes(16);
// AES-CTR encrypt
final secretKey = encrypt_pkg.Key(actualKey); final secretKey = encrypt_pkg.Key(actualKey);
final encryptor = encrypt_pkg.Encrypter( final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr), encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
@@ -255,7 +298,6 @@ class EncryptionFlutterService {
final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv)); final encrypted = encryptor.encrypt(plaintext, iv: encrypt_pkg.IV(iv));
final encryptedBytes = encrypted.bytes; final encryptedBytes = encrypted.bytes;
// Combine IV + encrypted data
final combined = Uint8List(iv.length + encryptedBytes.length); final combined = Uint8List(iv.length + encryptedBytes.length);
combined.setAll(0, iv); combined.setAll(0, iv);
combined.setAll(iv.length, encryptedBytes); combined.setAll(iv.length, encryptedBytes);
@@ -268,24 +310,18 @@ class EncryptionFlutterService {
} }
} }
/// Decrypt message (AES-CTR with round-based key derivation) /// 解密消息(AES-CTR,使用 round 派生密钥)
String decryptMessage({ String decryptMessage({
required String encryptedData, required String encryptedData,
required String sessionKey, required String sessionKey,
required int round, required int round,
}) { }) {
try { try {
// Derive key for round
final actualKey = _deriveKeyForRound(sessionKey, round); final actualKey = _deriveKeyForRound(sessionKey, round);
// Decode Base64
final combined = base64Decode(encryptedData); final combined = base64Decode(encryptedData);
// Extract IV and encrypted data
final iv = combined.sublist(0, 16); final iv = combined.sublist(0, 16);
final encBytes = combined.sublist(16); final encBytes = combined.sublist(16);
// AES-CTR decrypt
final secretKey = encrypt_pkg.Key(actualKey); final secretKey = encrypt_pkg.Key(actualKey);
final encryptor = encrypt_pkg.Encrypter( final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr), encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.ctr),
@@ -302,16 +338,16 @@ class EncryptionFlutterService {
} }
} }
// ==================== Push Notification Decryption ==================== // ==================== 推送通知解密 ====================
/// Set AES secret for push notification decryption /// 设置 AES secret(用于推送通知解密)
void setAesSecret(String aesSecret) { void setAesSecret(String aesSecret) {
_aesSecret = aesSecret; _aesSecret = aesSecret;
} }
String? _aesSecret; String? _aesSecret;
/// Decrypt push notification (AES-GCM) /// 解密推送通知(AES-GCM
String decryptPushNotification({required String encryptedData}) { String decryptPushNotification({required String encryptedData}) {
try { try {
final secret = _aesSecret; final secret = _aesSecret;
@@ -319,17 +355,11 @@ class EncryptionFlutterService {
throw Exception('AES_SECRET not set'); throw Exception('AES_SECRET not set');
} }
// Convert hex string to bytes
final secretBytes = _hexStringToBytes(secret); final secretBytes = _hexStringToBytes(secret);
// Decode Base64
final combined = base64Decode(encryptedData); final combined = base64Decode(encryptedData);
// Extract IV and encrypted data
final iv = combined.sublist(0, gcmIvLength); final iv = combined.sublist(0, gcmIvLength);
final encBytes = combined.sublist(gcmIvLength); final encBytes = combined.sublist(gcmIvLength);
// AES-GCM decrypt
final secretKey = encrypt_pkg.Key(secretBytes); final secretKey = encrypt_pkg.Key(secretBytes);
final encryptor = encrypt_pkg.Encrypter( final encryptor = encrypt_pkg.Encrypter(
encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm), encrypt_pkg.AES(secretKey, mode: encrypt_pkg.AESMode.gcm),
@@ -346,37 +376,91 @@ class EncryptionFlutterService {
} }
} }
// ==================== Helper Methods ==================== // ==================== 内部方法 ====================
/// Generate secure random bytes /// 生成安全随机字节(复用全局 Random.secure() 实例)
Uint8List _generateSecureRandomBytes(int length) { Uint8List _generateSecureRandomBytes(int length) {
final random = Random.secure();
final bytes = Uint8List(length); final bytes = Uint8List(length);
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
bytes[i] = random.nextInt(256); bytes[i] = _secureRandom.nextInt(256);
} }
return bytes; return bytes;
} }
/// MD5 hash /// MD5 哈希(用于密码派生密钥)
Uint8List _md5Hash(String input) { Uint8List _md5Hash(String input) {
final bytes = utf8.encode(input); final bytes = utf8.encode(input);
final hash = md5.convert(bytes).bytes as Uint8List; final hash = md5.convert(bytes).bytes;
return hash; 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) { Uint8List _deriveKeyForRound(String sessionKey, int targetRound) {
// Base64 decode session key final modeName = kdfMode == KdfMode.md5 ? 'md5' : 'pbkdf2';
final keyBytes = base64Decode(sessionKey); final cacheKey = '$sessionKey:$targetRound:$modeName';
// Apply MD5 for the round (simplified version) // 缓存命中 — 移至末尾以维护 LRU 顺序
final hash = md5.convert(keyBytes).bytes as Uint8List; final cached = _derivedKeyCache.remove(cacheKey);
if (cached != null) {
_derivedKeyCache[cacheKey] = cached;
return cached;
}
return hash; // 计算派生密钥
final Uint8List result;
switch (kdfMode) {
case KdfMode.md5:
// 将 sessionKey + round 一起参与 hash保证不同 round 产出不同密钥
final keyBytes = base64Decode(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 = base64Decode(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) { RSAPublicKey _parsePublicKey(String pem) {
final base64 = pem final base64 = pem
.replaceAll('-----BEGIN PUBLIC KEY-----', '') .replaceAll('-----BEGIN PUBLIC KEY-----', '')
@@ -385,7 +469,6 @@ class EncryptionFlutterService {
.trim(); .trim();
final bytes = base64Decode(base64); final bytes = base64Decode(base64);
// Parse ASN.1 DER format
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes)); final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
@@ -403,7 +486,7 @@ class EncryptionFlutterService {
); );
} }
/// Parse RSA private key from PEM /// 解析 RSA 私钥 PEM
RSAPrivateKey _parsePrivateKey(String pem) { RSAPrivateKey _parsePrivateKey(String pem) {
final base64 = pem final base64 = pem
.replaceAll('-----BEGIN PRIVATE KEY-----', '') .replaceAll('-----BEGIN PRIVATE KEY-----', '')
@@ -412,7 +495,6 @@ class EncryptionFlutterService {
.trim(); .trim();
final bytes = base64Decode(base64); final bytes = base64Decode(base64);
// Parse ASN.1 DER format
final asn1Parser = ASN1Parser(Uint8List.fromList(bytes)); final asn1Parser = ASN1Parser(Uint8List.fromList(bytes));
final keySeq = asn1Parser.nextObject() as ASN1Sequence; final keySeq = asn1Parser.nextObject() as ASN1Sequence;
@@ -429,7 +511,7 @@ class EncryptionFlutterService {
); );
} }
/// Convert hex string to bytes /// Hex 字符串转字节
Uint8List _hexStringToBytes(String hex) { Uint8List _hexStringToBytes(String hex) {
final len = hex.length; final len = hex.length;
final data = Uint8List(len ~/ 2); final data = Uint8List(len ~/ 2);

View File

@@ -16,7 +16,8 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
@override @override
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024}) async { Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024}) async {
final result = _service.generateRsaKeyPair(keySize: keySize); // 在 Isolate 中运行避免阻塞主线程1024-bit 约 150ms
final result = await _service.generateRsaKeyPairAsync(keySize: keySize);
return RsaKeyPair( return RsaKeyPair(
publicKey: result.publicKey, publicKey: result.publicKey,
privateKey: result.privateKey, privateKey: result.privateKey,
@@ -50,10 +51,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
@override @override
Future<SessionKey> generateSessionKey({int initialRound = 1}) async { Future<SessionKey> generateSessionKey({int initialRound = 1}) async {
final result = _service.generateSessionKey(initialRound: initialRound); final result = _service.generateSessionKey(initialRound: initialRound);
return SessionKey( return SessionKey(key: result.key, round: result.round);
key: result.key,
round: result.round,
);
} }
@override @override
@@ -91,10 +89,7 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
sessionKey: sessionKey, sessionKey: sessionKey,
round: round, round: round,
); );
return EncryptedMessage( return EncryptedMessage(round: result.round, data: result.data);
round: result.round,
data: result.data,
);
} }
@override @override
@@ -110,6 +105,11 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
); );
} }
// ==================== 缓存管理 ====================
@override
void clearDerivedKeyCache() => _service.clearDerivedKeyCache();
// ==================== 原生平台同步 ==================== // ==================== 原生平台同步 ====================
@override @override
@@ -147,4 +147,3 @@ class EncryptionRepositoryImpl implements EncryptionRepository {
return _service.decryptPushNotification(encryptedData: encryptedData); return _service.decryptPushNotification(encryptedData: encryptedData);
} }
} }

View File

@@ -84,6 +84,14 @@ abstract class EncryptionRepository {
required Map<String, Map<String, dynamic>> chatMap, required Map<String, Map<String, dynamic>> chatMap,
}); });
// ==================== 缓存管理 ====================
/// 清空派生密钥缓存
///
/// 在 session key 轮换时调用,确保旧密钥的派生结果不会被复用。
/// 不影响已加密的消息,只影响后续加解密操作的密钥派生。
void clearDerivedKeyCache();
// ==================== 配置相關 ==================== // ==================== 配置相關 ====================
/// 設置 AES_SECRET (用於推送解密) /// 設置 AES_SECRET (用於推送解密)
@@ -91,8 +99,5 @@ abstract class EncryptionRepository {
/// 解密 APNS 推送通知內容 /// 解密 APNS 推送通知內容
/// 使用 release.json 中的 AES_SECRET /// 使用 release.json 中的 AES_SECRET
Future<String?> decryptPushNotification({ Future<String?> decryptPushNotification({required String encryptedData});
required String encryptedData,
});
} }

View File

@@ -7,8 +7,7 @@ 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/domain/entities/encrypted_message.dart';
import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart'; import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_wiring.dart';
abstract class CipherGuardSdkApi abstract class CipherGuardSdkApi {
{
factory CipherGuardSdkApi() => CipherGuardSdkWiring.build(); factory CipherGuardSdkApi() => CipherGuardSdkWiring.build();
// ==================== 平台版本 ==================== // ==================== 平台版本 ====================
@@ -22,10 +21,16 @@ abstract class CipherGuardSdkApi
Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024}); Future<RsaKeyPair> generateRsaKeyPair({int keySize = 1024});
/// 用密碼加密私鑰 /// 用密碼加密私鑰
Future<String> encryptPrivateKey({required String privateKey, required String password,}); Future<String> encryptPrivateKey({
required String privateKey,
required String password,
});
/// 解密私鑰 /// 解密私鑰
Future<String> decryptPrivateKey({required String encryptedPrivateKey, required String password,}); Future<String> decryptPrivateKey({
required String encryptedPrivateKey,
required String password,
});
// ==================== 會話金鑰管理 ==================== // ==================== 會話金鑰管理 ====================
@@ -33,26 +38,56 @@ abstract class CipherGuardSdkApi
Future<SessionKey> generateSessionKey({int initialRound = 1}); Future<SessionKey> generateSessionKey({int initialRound = 1});
/// 用 RSA 公鑰加密會話金鑰 /// 用 RSA 公鑰加密會話金鑰
Future<String> encryptSessionKey({required String sessionKey, required String publicKey,}); Future<String> encryptSessionKey({
required String sessionKey,
required String publicKey,
});
/// 用 RSA 私鑰解密會話金鑰 /// 用 RSA 私鑰解密會話金鑰
Future<String> decryptSessionKey({required String encryptedSessionKey, required String privateKey,}); Future<String> decryptSessionKey({
required String encryptedSessionKey,
required String privateKey,
});
// ==================== 訊息加解密 ==================== // ==================== 訊息加解密 ====================
/// 加密訊息 /// 加密訊息
Future<EncryptedMessage> encryptMessage({required String plaintext, required String sessionKey, required int round,}); Future<EncryptedMessage> encryptMessage({
required String plaintext,
required String sessionKey,
required int round,
});
/// 解密訊息 /// 解密訊息
Future<String> decryptMessage({required String encryptedData, required String sessionKey, required int round,}); Future<String> decryptMessage({
required String encryptedData,
required String sessionKey,
required int round,
});
// ==================== 缓存管理 ====================
/// 清空派生密钥缓存
///
/// session key 轮换后必须调用,否则旧 key 的派生结果可能被复用,
/// 导致加解密使用错误的密钥。
void clearDerivedKeyCache();
// ==================== 原生平台同步 ==================== // ==================== 原生平台同步 ====================
/// 同步加密金鑰到原生平台 (iOS App Group) /// 同步加密金鑰到原生平台 (iOS App Group)
Future<void> syncEncryptionKey({required String chatId, required int activeRound, required int round, required String activeKey, required bool isSingle,}); Future<void> syncEncryptionKey({
required String chatId,
required int activeRound,
required int round,
required String activeKey,
required bool isSingle,
});
/// 批量同步所有加密聊天室的金鑰 /// 批量同步所有加密聊天室的金鑰
Future<void> syncAllEncryptionKeys({required Map<String, Map<String, dynamic>> chatMap,}); Future<void> syncAllEncryptionKeys({
required Map<String, Map<String, dynamic>> chatMap,
});
// ==================== 推送通知解密 ==================== // ==================== 推送通知解密 ====================
@@ -60,6 +95,5 @@ abstract class CipherGuardSdkApi
Future<void> setAesSecret({required String aesSecret}); Future<void> setAesSecret({required String aesSecret});
/// 解密 APNS 推送通知內容 /// 解密 APNS 推送通知內容
Future<String?> decryptPushNotification({required String encryptedData,}); Future<String?> decryptPushNotification({required String encryptedData});
} }

View File

@@ -93,6 +93,9 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
); );
} }
@override
void clearDerivedKeyCache() => _core.encryptionRepo.clearDerivedKeyCache();
@override @override
Future<void> syncEncryptionKey({ Future<void> syncEncryptionKey({
required String chatId, required String chatId,
@@ -123,9 +126,9 @@ class CipherGuardSdkApiImpl implements CipherGuardSdkApi {
} }
@override @override
Future<String?> decryptPushNotification({ Future<String?> decryptPushNotification({required String encryptedData}) {
required String encryptedData, return _core.encryptionRepo.decryptPushNotification(
}) { encryptedData: encryptedData,
return _core.encryptionRepo.decryptPushNotification(encryptedData: encryptedData); );
} }
} }

View File

@@ -9,9 +9,18 @@ import 'package:cipher_guard_sdk/src/presentation/wiring/cipher_guard_sdk_api_im
/// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯 /// 使用 Flutter 本地加密服務,無需原生平台處理加密邏輯
class CipherGuardSdkWiring { class CipherGuardSdkWiring {
/// 構建 SDK 實例 /// 構建 SDK 實例
static CipherGuardSdkApi build() { ///
/// [kdfMode] — 密钥派生模式,默认 [KdfMode.md5](兼容模式)
/// [pbkdf2Iterations] — PBKDF2 迭代次数(仅 pbkdf2 模式生效,默认 10000
static CipherGuardSdkApi build({
KdfMode kdfMode = KdfMode.md5,
int pbkdf2Iterations = 10000,
}) {
// 1. 創建 Flutter 加密服務 // 1. 創建 Flutter 加密服務
final flutterService = EncryptionFlutterService(); final flutterService = EncryptionFlutterService(
kdfMode: kdfMode,
pbkdf2Iterations: pbkdf2Iterations,
);
// 2. 創建 Repository (使用 Flutter 服務) // 2. 創建 Repository (使用 Flutter 服務)
final repository = EncryptionRepositoryImpl(flutterService); final repository = EncryptionRepositoryImpl(flutterService);
@@ -39,4 +48,3 @@ class _CipherGuardPlatformImpl implements CipherGuardPlatform {
return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行 return 'Flutter Native'; // 所有加密邏輯現在都在 Flutter 端執行
} }
} }

View File

@@ -9,7 +9,14 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
/// REST API 客户端 /// REST API 客户端
/// 基于 Dio提供请求执行入口 /// 基于 Dio提供请求执行入口
/// ///
/// 拦截器链顺序Auth → Encryption → 自定义 → Retry → Logging /// 拦截器链顺序onRequestAuth → 自定义 → Retry → Logging → Encryption
///
/// Dio 的 onResponse / onError 按 **逆序** 执行,因此实际响应处理为:
/// `Encryption(解密) → Logging → Retry(业务码判断) → 自定义 → Auth`
///
/// EncryptionInterceptor 放最后,保证:
/// - onRequest 最后加密(其他拦截器操作明文)
/// - onResponse 最先解密(其他拦截器看到明文,业务码判断正常工作)
/// ///
/// 使用方式: /// 使用方式:
/// ```dart /// ```dart
@@ -31,13 +38,15 @@ class ApiClient {
receiveTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60),
); );
// 挂载拦截器顺序Auth → Encryption → 自定义 → Retry → Logging // 挂载拦截器
// onRequest 顺序Auth → 自定义 → Retry → Logging → Encryption
// onResponse 逆序Encryption(解密) → Logging → Retry(业务码) → 自定义 → Auth
_dio.interceptors.addAll([ _dio.interceptors.addAll([
AuthInterceptor(config), AuthInterceptor(config),
EncryptionInterceptor(config),
if (additionalInterceptors != null) ...additionalInterceptors, if (additionalInterceptors != null) ...additionalInterceptors,
RetryInterceptor(config: config, dio: _dio), RetryInterceptor(config: config, dio: _dio),
LoggingInterceptor(onLog: config.onLog), LoggingInterceptor(onLog: config.onLog),
EncryptionInterceptor(config),
]); ]);
} }

View File

@@ -3,8 +3,12 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
/// 加密拦截器(预留给 cipher_guard_sdk /// 加密拦截器(预留给 cipher_guard_sdk
/// ///
/// 在拦截器链中位于 Auth 之后、Retry 之前 /// 在拦截器链中位于最末位
/// `Auth → Encryption → Custom → Retry → Logging` /// onRequest 顺序:`Auth → Custom → Retry → Logging → Encryption`
/// onResponse 逆序:`Encryption(解密) → Logging → Retry(业务码) → Custom → Auth`
///
/// 放最后是因为 Dio onResponse 按逆序执行——加密拦截器最先解密,
/// 后续的 RetryInterceptor 才能正确判断业务错误码、Token 过期等。
/// ///
/// 回调为 null 时自动跳过,不影响正常请求流程。 /// 回调为 null 时自动跳过,不影响正常请求流程。
/// 后续 cipher_guard_sdk 接入后App 层在 ApiConfig 中注入 /// 后续 cipher_guard_sdk 接入后App 层在 ApiConfig 中注入
@@ -20,7 +24,23 @@ import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
/// ///
/// 加密回调接收原始 path、headers、body返回 [EncryptedRequest] /// 加密回调接收原始 path、headers、body返回 [EncryptedRequest]
/// 拦截器根据非 null 字段覆盖请求。 /// 拦截器根据非 null 字段覆盖请求。
///
/// ## Token 重试与重新加密
///
/// 瞬态错误重试5xx / 超时):复用已加密的请求,不重复加密。
/// Token 刷新重试:加密 headers 可能包含过期 token 衍生值
/// (如 X-Token、X-Signature需要恢复加密前状态并用新 token 重新加密。
/// RetryInterceptor 通过 `_needsReEncryption` 标记通知本拦截器重新加密。
class EncryptionInterceptor extends Interceptor { 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; final ApiConfig _config;
EncryptionInterceptor(this._config); EncryptionInterceptor(this._config);
@@ -36,7 +56,28 @@ class EncryptionInterceptor extends Interceptor {
return; 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 { try {
// 保存加密前快照Token 重试时恢复
options.extra[_preEncryptSnapshotKey] = {
'path': options.path,
'data': options.data,
'contentType': options.contentType,
};
// 收集当前 headers转为 Map<String, String> // 收集当前 headers转为 Map<String, String>
final currentHeaders = <String, String>{}; final currentHeaders = <String, String>{};
options.headers.forEach((key, value) { options.headers.forEach((key, value) {
@@ -54,11 +95,17 @@ class EncryptionInterceptor extends Interceptor {
} }
if (result.headers != null) { if (result.headers != null) {
options.headers.addAll(result.headers!); options.headers.addAll(result.headers!);
// 记录加密注入的 header keyToken 重试时移除
options.extra[_encryptionAddedHeadersKey] = result.headers!.keys
.toList();
} }
if (result.contentType != null) { if (result.contentType != null) {
options.contentType = result.contentType; options.contentType = result.contentType;
} }
// 标记已加密,防止瞬态重试时重复加密
options.extra[_encryptedKey] = true;
_config.onLog?.call( _config.onLog?.call(
'Request encrypted: ${options.path}', 'Request encrypted: ${options.path}',
tag: 'Encryption', tag: 'Encryption',
@@ -76,6 +123,41 @@ class EncryptionInterceptor extends Interceptor {
} }
} }
/// 恢复加密前的请求状态
///
/// Token 重试场景旧的加密数据path / body / headers可能包含过期 token
/// 需要恢复原始状态后用新 token 上下文重新加密。
///
/// AuthInterceptor 已在本轮重试中注入了新 token headers
/// 这里只需移除上次加密注入的 headers如 X-Token、X-Signature
/// 保留 Auth 设置的新 token。
void _restorePreEncryptState(RequestOptions options) {
final snapshot =
options.extra[_preEncryptSnapshotKey] as Map<String, dynamic>?;
if (snapshot != null) {
options.path = snapshot['path'] as String;
options.data = snapshot['data'];
options.contentType = snapshot['contentType'] as String?;
}
// 移除上次加密注入的 headers
final addedHeaders = options.extra[_encryptionAddedHeadersKey] as List?;
if (addedHeaders != null) {
for (final key in addedHeaders) {
options.headers.remove(key);
}
}
// 清除加密标记和快照
options.extra.remove(_encryptedKey);
options.extra.remove(_encryptionAddedHeadersKey);
_config.onLog?.call(
'Pre-encrypt state restored for token retry',
tag: 'Encryption',
);
}
@override @override
void onResponse(Response response, ResponseInterceptorHandler handler) async { void onResponse(Response response, ResponseInterceptorHandler handler) async {
final decrypt = _config.onDecryptResponse; final decrypt = _config.onDecryptResponse;
@@ -90,6 +172,14 @@ class EncryptionInterceptor extends Interceptor {
return; return;
} }
// 响应已是 Map → 未加密health check、非加密端点等跳过解密
// 加密模式下响应通常是 Stringbase64或 List<int>bytes
// TODO: 接入加密后,若服务端所有端点都加密,可移除此判断
if (response.data is Map<String, dynamic>) {
handler.next(response);
return;
}
try { try {
final decrypted = await decrypt(response.data as Object); final decrypted = await decrypt(response.data as Object);
response.data = decrypted; response.data = decrypted;

View File

@@ -78,7 +78,8 @@ class RetryInterceptor extends Interceptor {
if (code != 0 && config.onBusinessError != null) { if (code != 0 && config.onBusinessError != null) {
final handled = config.onBusinessError!(code, message, requestPath); final handled = config.onBusinessError!(code, message, requestPath);
if (handled) { if (handled) {
// App 层已处理,正常传递响应 // App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
response.requestOptions.extra['_businessErrorHandled'] = true;
handler.next(response); handler.next(response);
return; return;
} }
@@ -115,6 +116,9 @@ class RetryInterceptor extends Interceptor {
options.headers['token'] = newToken; options.headers['token'] = newToken;
// 标记为 token 重试请求,防止重试后再次进入 _handleTokenExpired 造成递归 // 标记为 token 重试请求,防止重试后再次进入 _handleTokenExpired 造成递归
options.extra['_isTokenRetry'] = true; options.extra['_isTokenRetry'] = true;
// 通知 EncryptionInterceptortoken 变了,需要用新 token 上下文重新加密
// 旧的加密 headers如 X-Token、X-Signature可能包含过期 token 信息
options.extra['_needsReEncryption'] = true;
final retryResponse = await dio.fetch(options); final retryResponse = await dio.fetch(options);
handler.resolve(retryResponse); handler.resolve(retryResponse);

View File

@@ -194,6 +194,9 @@ class NetworksSdkMethodChannelDataSource {
/// ///
/// Dio.download 内部用 FileMode.write从头覆盖无法正确续传。 /// Dio.download 内部用 FileMode.write从头覆盖无法正确续传。
/// 这里手动读流并追加写入文件。 /// 这里手动读流并追加写入文件。
///
/// 如果服务端不支持 Range 请求(返回 200 而非 206
/// 自动回退为覆盖写入,防止文件损坏。
Future<void> _downloadWithResume({ Future<void> _downloadWithResume({
required String url, required String url,
required String savePath, required String savePath,
@@ -215,14 +218,33 @@ class NetworksSdkMethodChannelDataSource {
final stream = response.data?.stream; final stream = response.data?.stream;
if (stream == null) return; if (stream == null) return;
// Content-Length 是本次传输量(不含已下载部分) // 检查服务端是否接受了 Range 请求
// 206 = 支持续传,追加写入
// 200 = 不支持 Range返回完整文件需要覆盖写入
final isPartialContent = response.statusCode == 206;
final effectiveStartBytes = isPartialContent ? startBytes : 0;
if (!isPartialContent) {
apiClient.config.onLog?.call(
'Server does not support Range, falling back to full download',
tag: 'Download',
);
}
// Content-Length 是本次传输量
final contentLength = final contentLength =
int.tryParse(response.headers.value('content-length') ?? '') ?? -1; int.tryParse(response.headers.value('content-length') ?? '') ?? -1;
final totalBytes = contentLength > 0 ? contentLength + startBytes : -1; final totalBytes = contentLength > 0
? contentLength + effectiveStartBytes
: -1;
final file = File(savePath); final file = File(savePath);
final raf = file.openSync(mode: FileMode.writeOnlyAppend); // 不支持续传时用 write 覆盖,支持时用 append 追加
int received = startBytes; final fileMode = isPartialContent
? FileMode.writeOnlyAppend
: FileMode.writeOnly;
final raf = file.openSync(mode: fileMode);
int received = effectiveStartBytes;
try { try {
await for (final chunk in stream) { await for (final chunk in stream) {

View File

@@ -16,7 +16,7 @@ import 'package:web_socket_channel/web_socket_channel.dart';
/// - Stream 输出JSON 消息、原始字符串、二进制、连接状态、错误) /// - Stream 输出JSON 消息、原始字符串、二进制、连接状态、错误)
/// - 生命周期感知(前后台切换) /// - 生命周期感知(前后台切换)
/// - Token 热更新(不断连) /// - Token 热更新(不断连)
/// - 消息加密/解密钩子(预留给 cipher_guard_sdk /// - 消息加密/解密钩子(预留给 cipher_guard_sdkping/pong 走明文不加密
/// ///
/// ## 使用方式 /// ## 使用方式
/// ///
@@ -60,6 +60,15 @@ class SocketClient {
Timer? _reconnectTimer; Timer? _reconnectTimer;
final _random = Random(); final _random = Random();
// ── 消息处理 ──
/// 异步消息处理链,保证解密场景下消息按到达顺序处理
///
/// 无解密回调时不使用(同步处理,天然有序)。
/// 有解密回调时,每条消息的处理链在前一条之后执行,
/// 即使解密耗时不同也不会乱序。
Future<void>? _messageProcessingChain;
// ── Stream Controllers ── // ── Stream Controllers ──
final _messageController = StreamController<Map<String, dynamic>>.broadcast(); final _messageController = StreamController<Map<String, dynamic>>.broadcast();
final _rawMessageController = StreamController<String>.broadcast(); final _rawMessageController = StreamController<String>.broadcast();
@@ -324,45 +333,73 @@ class SocketClient {
// 内部 — 消息处理 // 内部 — 消息处理
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
void _handleMessage(dynamic data) async { void _handleMessage(dynamic data) {
// 二进制消息 // 二进制消息不需要解密,直接分发
if (data is List<int>) { if (data is List<int>) {
_binaryMessageController.add( if (!_binaryMessageController.isClosed) {
data is Uint8List ? data : Uint8List.fromList(data), _binaryMessageController.add(
); data is Uint8List ? data : Uint8List.fromList(data),
);
}
return; return;
} }
if (data is! String) { if (data is! String) {
_rawMessageController.add(data.toString()); if (!_rawMessageController.isClosed) {
_rawMessageController.add(data.toString());
}
return; return;
} }
// 解密(如果配置了解密回调) // pong 是传输层心跳,不经过业务加解密,直接匹配
String text = data; if (data == 'pong') {
if (config.onDecryptMessage != null) {
try {
text = await config.onDecryptMessage!(data);
} catch (e) {
_log('Message decryption failed: $e');
_rawMessageController.add(data);
return;
}
}
// 检查 pong 心跳回复(解密后检查,加密场景下也能正确匹配)
if (text == 'pong') {
_onPongReceived(); _onPongReceived();
return; return;
} }
// 尝试 JSON 解析 if (config.onDecryptMessage != null) {
// 有解密回调 → 链式异步处理,保证消息按到达顺序分发
// 避免解密耗时不同导致后到的消息先完成解密、先分发
final previous = _messageProcessingChain ?? Future.value();
_messageProcessingChain = previous.then((_) => _processTextMessage(data));
} else {
// 无解密回调 → 同步处理,天然有序
_dispatchTextMessage(data);
}
}
/// 异步处理文本消息(解密 → 分发)
Future<void> _processTextMessage(String data) async {
// dispose 期间可能有残留的链式任务,直接跳过
if (_messageController.isClosed) return;
String text;
try {
text = await config.onDecryptMessage!(data);
} catch (e) {
_log('Message decryption failed: $e');
if (!_rawMessageController.isClosed) {
_rawMessageController.add(data);
}
return;
}
_dispatchTextMessage(text);
}
/// 分发文本消息JSON 解析 → 投递到对应 stream
///
/// pong 已在 `_handleMessage` 中提前拦截,不会到这里。
void _dispatchTextMessage(String text) {
try { try {
final json = jsonDecode(text) as Map<String, dynamic>; final json = jsonDecode(text) as Map<String, dynamic>;
_messageController.add(json); if (!_messageController.isClosed) {
_messageController.add(json);
}
} catch (_) { } catch (_) {
// JSON 解析失败,走原始消息流 // JSON 解析失败,走原始消息流
_rawMessageController.add(text); if (!_rawMessageController.isClosed) {
_rawMessageController.add(text);
}
} }
} }
@@ -401,26 +438,20 @@ class SocketClient {
_waitingForPong = false; _waitingForPong = false;
} }
void _sendPing() async { void _sendPing() {
if (_waitingForPong) return; if (_waitingForPong) return;
_waitingForPong = true; _waitingForPong = true;
// 加密场景下 ping 也要加密,与 pong 解密对称 // ping/pong 是传输层心跳,不经过业务加解密
String pingPayload = 'ping'; // 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
if (config.onEncryptMessage != null) { _channel?.sink.add('ping');
try { _log('♥ ping');
pingPayload = await config.onEncryptMessage!('ping');
} catch (e) {
_log('Ping encryption failed: $e');
}
}
_channel?.sink.add(pingPayload);
// 启动 pong 超时计时器 // 启动 pong 超时计时器
_pongTimeoutTimer = Timer(config.pongTimeout, () { _pongTimeoutTimer = Timer(config.pongTimeout, () {
if (_waitingForPong) { if (_waitingForPong) {
_log('Pong timeout, reconnecting...'); _log('♥ pong timeout, reconnecting...');
_waitingForPong = false; _waitingForPong = false;
_emitError(const SocketError.pingTimeout()); _emitError(const SocketError.pingTimeout());
_doDisconnect(reason: 'Pong timeout'); _doDisconnect(reason: 'Pong timeout');
@@ -430,6 +461,7 @@ class SocketClient {
} }
void _onPongReceived() { void _onPongReceived() {
_log('♥ pong');
_waitingForPong = false; _waitingForPong = false;
_pongTimeoutTimer?.cancel(); _pongTimeoutTimer?.cancel();
_pongTimeoutTimer = null; _pongTimeoutTimer = null;
@@ -488,7 +520,9 @@ class SocketClient {
try { try {
await config.onBeforeReconnect!(); await config.onBeforeReconnect!();
} catch (e) { } catch (e) {
_log('onBeforeReconnect failed: $e, skip this reconnect'); _log('onBeforeReconnect failed: $e, schedule next reconnect');
// 重置状态以允许下次 _startReconnect 进入(防止卡死在 reconnecting
_updateConnectionState(SocketConnectionState.disconnected);
_startReconnect(); _startReconnect();
return; return;
} }

View File

@@ -4,35 +4,36 @@ import 'package:networks_sdk/src/domain/entities/api_error.dart';
import 'package:networks_sdk/src/domain/entities/api_request_type.dart'; import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
import 'package:networks_sdk/src/domain/entities/http_method.dart'; import 'package:networks_sdk/src/domain/entities/http_method.dart';
/// API 请求基类 /// API 请求基类
/// ///
/// 使用侧只需:字段 + path + method其余全部有默认实现。 /// 只需 `@ApiRequest` 一个注解,声明字段和构造函数即可:
/// - `toJson()` 由 mixin 自动生成(只序列化类自身字段,不含继承属性)
/// - `path / method / requestType / includeToken` 由 mixin 自动提供
/// - Response 的 fromJson 在 mixin 的 `parameters` getter 中自动注册
/// - **不需要** `@JsonSerializable`**不需要** 手写 `fromJson`
/// ///
/// ```dart /// ```dart
/// @JsonSerializable() /// @ApiRequest(
/// class LoginRequest extends ApiRequestable<LoginData> { /// path: ApiPaths.authLogin,
/// method: HttpMethod.post,
/// responseType: LoginData,
/// requestType: ApiRequestType.login,
/// )
/// class LoginRequest extends ApiRequestable<LoginData>
/// with _$LoginRequestApi {
/// final String email; /// final String email;
/// final String password; /// final String password;
/// ///
/// LoginRequest({required this.email, required this.password}); /// LoginRequest({required this.email, required this.password});
/// /// // 完毕!一行样板代码都不用写
/// factory LoginRequest.fromJson(Map<String, dynamic> json) =>
/// _$LoginRequestFromJson(json);
/// @override
/// Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
///
/// @override
/// String get path => '/auth/login';
/// @override
/// HttpMethod get method => HttpMethod.post;
/// @override
/// bool get includeToken => false;
/// } /// }
///
/// // 文件顶层注册一次(一行)
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
/// ``` /// ```
///
/// 字段名映射:在字段上加 `@JsonKey(name: 'server_name')` 即可,
/// 生成器会读取并使用该名称作为 JSON 键。
///
/// 特殊请求(如 upload在类中 override `toJson()` 即可,
/// 类的 override 优先于 mixin。
abstract class ApiRequestable<T> { abstract class ApiRequestable<T> {
/// API 路径(如 '/auth/login' /// API 路径(如 '/auth/login'
String get path; String get path;
@@ -40,8 +41,8 @@ abstract class ApiRequestable<T> {
/// HTTP 方法 /// HTTP 方法
HttpMethod get method; HttpMethod get method;
/// 序列化为 JSON由 @JsonSerializable 自动生成) /// 序列化为 JSON由 @ApiRequest 生成器在 mixin 中自动生成)
/// 子类 override 返回 `_$XxxToJson(this)` 即可 /// Upload 等特殊请求可在类中 override 返回空 map
Map<String, dynamic> toJson(); Map<String, dynamic> toJson();
/// 请求参数 — 默认调用 toJson()upload 类型返回 null /// 请求参数 — 默认调用 toJson()upload 类型返回 null
@@ -95,7 +96,7 @@ abstract class ApiRequestable<T> {
if (fromJsonFunc == null) { if (fromJsonFunc == null) {
throw StateError( throw StateError(
'fromJson not registered for type $T. ' 'fromJson not registered for type $T. '
'Add: final _reg = registerResponse<$T>($T.fromJson);', 'Add: final _reg = registerResponse<$T>($T.fromJson);',
); );
} }
@@ -106,13 +107,17 @@ abstract class ApiRequestable<T> {
final mapFunc = fromJsonFunc as T Function(Map<String, dynamic>); final mapFunc = fromJsonFunc as T Function(Map<String, dynamic>);
return mapFunc(json); return mapFunc(json);
} }
throw FormatException('Expected Map<String, dynamic>, got ${json.runtimeType}',); throw FormatException(
'Expected Map<String, dynamic>, got ${json.runtimeType}',
);
} }
final wrapper = ApiResponseWrapper<T>.fromJson(data, fromJsonObject); final wrapper = ApiResponseWrapper<T>.fromJson(data, fromJsonObject);
// 业务错误码检查 // 业务错误码检查RetryInterceptor 已处理的跳过,防止双重抛错)
if (wrapper.code != 0) { final handledByInterceptor =
response.requestOptions.extra['_businessErrorHandled'] == true;
if (wrapper.code != 0 && !handledByInterceptor) {
throw ApiError.apiError( throw ApiError.apiError(
code: wrapper.code, code: wrapper.code,
message: wrapper.message ?? 'API error (code: ${wrapper.code})', message: wrapper.message ?? 'API error (code: ${wrapper.code})',
@@ -141,8 +146,9 @@ final fromJsonRegistry = <Type, Function>{};
/// ```dart /// ```dart
/// final _reg = registerResponse<LoginData>(LoginData.fromJson); /// final _reg = registerResponse<LoginData>(LoginData.fromJson);
/// ``` /// ```
T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dynamic>) fromJson,) T Function(Map<String, dynamic>)? registerResponse<T>(
{ T Function(Map<String, dynamic>) fromJson,
) {
fromJsonRegistry[T] = fromJson; fromJsonRegistry[T] = fromJson;
return fromJson; return fromJson;
} }
@@ -152,9 +158,7 @@ T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dyn
/// ```dart /// ```dart
/// final _reg = registerResponseObject<DeviceList>(DeviceList.fromJson); /// final _reg = registerResponseObject<DeviceList>(DeviceList.fromJson);
/// ``` /// ```
T Function(Object?)? registerResponseObject<T>( T Function(Object?)? registerResponseObject<T>(T Function(Object?) fromJson) {
T Function(Object?) fromJson,
) {
fromJsonRegistry[T] = fromJson; fromJsonRegistry[T] = fromJson;
return fromJson; return fromJson;
} }

View File

@@ -1,20 +1,16 @@
/// API 响应信封解析器 /// API 响应包装解析器
/// 统一处理 { code, message/msg, data } 格式的服务器响应 /// 统一处理 { code, message/msg, data } 格式的服务器响应
class ApiResponseWrapper<T> { class ApiResponseWrapper<T> {
final int code; final int code;
final String? message; final String? message;
final T? data; final T? data;
const ApiResponseWrapper({ const ApiResponseWrapper({required this.code, this.message, this.data});
required this.code,
this.message,
this.data,
});
factory ApiResponseWrapper.fromJson( factory ApiResponseWrapper.fromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
T Function(Object?) fromJsonT, T Function(Object?) fromJsonT,
) { ) {
// code 字段:兼容 int 和 String // code 字段:兼容 int 和 String
final int codeValue; final int codeValue;
if (json['code'] is int) { if (json['code'] is int) {
@@ -28,8 +24,7 @@ class ApiResponseWrapper<T> {
} }
// message 字段:兼容 message 和 msg // message 字段:兼容 message 和 msg
final message = final message = json['message'] as String? ?? json['msg'] as String?;
json['message'] as String? ?? json['msg'] as String?;
// 解码 datanull-safelogout / delete 等接口可能无 data // 解码 datanull-safelogout / delete 等接口可能无 data
final rawData = json['data']; final rawData = json['data'];

View File

@@ -4,17 +4,50 @@ import 'package:networks_sdk/src/annotations/api_request.dart';
import 'package:source_gen/source_gen.dart'; import 'package:source_gen/source_gen.dart';
import 'package:build/build.dart'; import 'package:build/build.dart';
/// @JsonKey 检测器(用于读取字段的 JSON 键名映射)
const _jsonKeyChecker = TypeChecker.fromUrl(
'package:json_annotation/src/json_key.dart#JsonKey',
);
/// @ApiRequest 代码生成器 /// @ApiRequest 代码生成器
/// ///
/// 为标注了 `@ApiRequest` 的类自动生成 mixin提供 /// 为标注了 `@ApiRequest` 的类自动生成 mixin提供
/// - `path`, `method`, `requestType`, `includeToken` 协议实现 /// - `path`, `method`, `requestType`, `includeToken` 协议实现
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发 /// - `toJson()` — 从类的声明字段自动生成,只序列化自身字段
/// 保证首次请求前完成注册,无需手动调用 `registerApiResponses()` /// 不含 ApiRequestable 的继承属性,避免递归
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发)
/// ///
/// 生成的 mixin 命名规则:`_$<ClassName>Api` /// 支持 `@JsonKey(name: '...')` 字段重命名。
/// 如有 `@JsonKey(includeToJson: false)` 则跳过该字段。
///
/// ## 使用模式
///
/// Request 类只需 `@ApiRequest` 注解,无需 `@JsonSerializable`
///
/// ```dart
/// @ApiRequest(
/// path: ApiPaths.authLogin,
/// method: HttpMethod.post,
/// responseType: LoginData,
/// requestType: ApiRequestType.login,
/// )
/// class LoginRequest extends ApiRequestable<LoginData>
/// with _$LoginRequestApi {
/// final String email;
/// final String password;
///
/// LoginRequest({required this.email, required this.password});
/// // 完毕toJson / path / method 全部由 mixin 自动生成
/// // Response 的 fromJson 在 parameters getter 中自动注册
/// }
/// ```
///
/// ## mixin 命名规则
///
/// `_$<ClassName>Api`
///
/// ## 生成示例
/// ///
/// 示例输出:
/// ```dart /// ```dart
/// mixin _$LoginRequestApi on ApiRequestable<LoginData> { /// mixin _$LoginRequestApi on ApiRequestable<LoginData> {
/// @override String get path => '/auth/login'; /// @override String get path => '/auth/login';
@@ -22,17 +55,29 @@ import 'package:build/build.dart';
/// @override ApiRequestType get requestType => ApiRequestType.login; /// @override ApiRequestType get requestType => ApiRequestType.login;
/// @override bool get includeToken => false; /// @override bool get includeToken => false;
/// @override /// @override
/// Map<String, dynamic> toJson() => <String, dynamic>{
/// 'email': (this as LoginRequest).email,
/// 'password': (this as LoginRequest).password,
/// };
/// @override
/// Map<String, dynamic>? get parameters { /// Map<String, dynamic>? get parameters {
/// registerResponse<LoginData>(LoginData.fromJson); /// registerResponse<LoginData>(LoginData.fromJson);
/// return super.parameters; /// return super.parameters;
/// } /// }
/// } /// }
/// ``` /// ```
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> ///
{ /// ## Upload 等特殊请求
///
/// 如需自定义 toJson如 upload 返回空 map在类中 override 即可,
/// 类的 override 优先于 mixin。
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
@override @override
String generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep,) String generateForAnnotatedElement(
{ Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
if (element is! ClassElement) { if (element is! ClassElement) {
throw InvalidGenerationSourceError( throw InvalidGenerationSourceError(
'@ApiRequest can only be applied to classes.', '@ApiRequest can only be applied to classes.',
@@ -40,7 +85,7 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
); );
} }
final className = element.name; final className = element.name!;
final path = annotation.read('path').stringValue; final path = annotation.read('path').stringValue;
// 读取 HttpMethod 枚举值 // 读取 HttpMethod 枚举值
@@ -68,6 +113,9 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
includeToken = requestTypeName != 'login'; includeToken = requestTypeName != 'login';
} }
// 从类的声明字段生成 toJson()
final toJsonBody = _buildToJsonBody(element, className);
return ''' return '''
/// Generated by @ApiRequest for [$className] /// Generated by @ApiRequest for [$className]
mixin _\$${className}Api on ApiRequestable<$responseTypeName> { mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
@@ -80,6 +128,8 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
@override @override
bool get includeToken => $includeToken; bool get includeToken => $includeToken;
@override @override
Map<String, dynamic> toJson() => $toJsonBody;
@override
Map<String, dynamic>? get parameters { Map<String, dynamic>? get parameters {
registerResponse<$responseTypeName>($responseTypeName.fromJson); registerResponse<$responseTypeName>($responseTypeName.fromJson);
return super.parameters; return super.parameters;
@@ -88,6 +138,46 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
'''; ''';
} }
/// 从类的声明字段构建 toJson() 方法体
///
/// 只读取类自身声明的实例字段(非 static、非 synthetic
/// 不含继承自 ApiRequestable 的属性,避免递归。
/// 支持 @JsonKey(name: '...') 字段重命名,
/// 以及 @JsonKey(includeToJson: false) 跳过字段。
String _buildToJsonBody(ClassElement element, String className) {
final fields = element.fields
.where((f) => !f.isStatic && !f.isSynthetic)
.toList();
if (fields.isEmpty) {
return '<String, dynamic>{}';
}
final entries = <String>[];
for (final field in fields) {
// 检查 @JsonKey 注解
final jsonKeyAnnotation = _jsonKeyChecker.firstAnnotationOfExact(field);
// @JsonKey(includeToJson: false) → 跳过
final includeToJson = jsonKeyAnnotation
?.getField('includeToJson')
?.toBoolValue();
if (includeToJson == false) continue;
// JSON 键名:@JsonKey(name: '...') 或字段名
final jsonName =
jsonKeyAnnotation?.getField('name')?.toStringValue() ?? field.name;
entries.add("'$jsonName': (this as $className).${field.name}");
}
if (entries.isEmpty) {
return '<String, dynamic>{}';
}
return '<String, dynamic>{${entries.join(', ')}}';
}
/// 从 DartObject 提取枚举常量名称 /// 从 DartObject 提取枚举常量名称
String _readEnumName(dynamic dartObject, String defaultValue) { String _readEnumName(dynamic dartObject, String defaultValue) {
final index = dartObject.getField('index')?.toIntValue(); final index = dartObject.getField('index')?.toIntValue();

View File

@@ -59,7 +59,15 @@ typedef OnEncryptRequest =
/// ///
/// [responseData] 的实际类型取决于服务端响应格式: /// [responseData] 的实际类型取决于服务端响应格式:
/// - 加密模式下通常是 base64 字符串 /// - 加密模式下通常是 base64 字符串
/// - 非加密模式下是 `Map<String, dynamic>` /// - 非加密模式下是 `Map<String, dynamic>`(拦截器会自动跳过,不调用此回调)
///
/// 实现时建议做类型判断兜底,应对非预期的响应格式:
/// ```dart
/// onDecryptResponse: (data) async {
/// if (data is! String) throw FormatException('Expected String, got ${data.runtimeType}');
/// return jsonDecode(aesDecrypt(data));
/// }
/// ```
typedef OnDecryptResponse = typedef OnDecryptResponse =
Future<Map<String, dynamic>> Function(Object responseData); Future<Map<String, dynamic>> Function(Object responseData);

View File

@@ -23,7 +23,7 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
) async { ) async {
final db = _db; final db = _db;
if (db == null) return; if (db == null) return;
await db.into(table).insertOnConflictUpdate(companion); await db.into(table).insert(companion, mode: InsertMode.insertOrReplace);
} }
@override @override
@@ -44,7 +44,8 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
final db = _db; final db = _db;
if (db == null) return; if (db == null) return;
await db.batch( await db.batch(
(batch) => batch.insertAllOnConflictUpdate(table, companions), (batch) =>
batch.insertAll(table, companions, mode: InsertMode.insertOrReplace),
); );
} }
@@ -106,7 +107,10 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
) async { ) async {
final db = _db; final db = _db;
if (db == null) return null; if (db == null) return null;
return (db.select(table)..where(filter)..limit(1)).getSingleOrNull(); return (db.select(table)
..where(filter)
..limit(1))
.getSingleOrNull();
} }
// ── 监听 ───────────────────────────────────────────────────────────────── // ── 监听 ─────────────────────────────────────────────────────────────────
@@ -135,7 +139,10 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
) { ) {
final db = _db; final db = _db;
if (db == null) return const Stream.empty(); if (db == null) return const Stream.empty();
return (db.select(table)..where(filter)..limit(1)).watchSingleOrNull(); return (db.select(table)
..where(filter)
..limit(1))
.watchSingleOrNull();
} }
// ── 原始 SQL ───────────────────────────────────────────────────────────── // ── 原始 SQL ─────────────────────────────────────────────────────────────
@@ -148,18 +155,12 @@ class DatabaseRepositoryImpl implements DatabaseRepository {
final db = _db; final db = _db;
if (db == null) return []; if (db == null) return [];
return db return db
.customSelect( .customSelect(sql, variables: args.map((e) => Variable(e)).toList())
sql,
variables: args.map((e) => Variable(e)).toList(),
)
.get(); .get();
} }
@override @override
Future<void> rawExecute( Future<void> rawExecute(String sql, [List<Object?> args = const []]) async {
String sql, [
List<Object?> args = const [],
]) async {
final db = _db; final db = _db;
if (db == null) return; if (db == null) return;
await db.customStatement(sql, args); await db.customStatement(sql, args);