Merge pull request 'User表结构和数据同步完全按照架构进行调整 + 新增改、删案例' (#8) from happi/dev/database-update into dev
Reviewed-on: https://gitea.winwayinfo.com/CUS-IM/customer-im-client/pulls/8
This commit is contained in:
37
apps/im_app/lib/app/di/user_provider.dart
Normal file
37
apps/im_app/lib/app/di/user_provider.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:im_app/domain/usecases/insert_users_use_case.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:im_app/app/di/db_provider.dart';
|
||||||
|
import 'package:im_app/data/repositories/user_repository_impl.dart';
|
||||||
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
|
import 'package:im_app/domain/repositories/user_repository.dart';
|
||||||
|
|
||||||
|
part 'user_provider.g.dart';
|
||||||
|
|
||||||
|
// ── Repository ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
UserRepository userRepository(Ref ref) {
|
||||||
|
return UserRepositoryImpl(ref.watch(storageSdkProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Use Cases ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 批量插入用户用例 Provider
|
||||||
|
///
|
||||||
|
/// 封装去重 + 分批插入逻辑,ViewModel 只需传入用户列表
|
||||||
|
final insertUsersUseCaseProvider = Provider<InsertUsersUseCase>((ref) {
|
||||||
|
return InsertUsersUseCase(userRepository: ref.read(userRepositoryProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Streams ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Stream<List<User>> users(Ref ref, Set<int> uids) {
|
||||||
|
return ref.watch(userRepositoryProvider).watchUsers(uids.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Stream<List<User>> allUsers(Ref ref) {
|
||||||
|
return ref.watch(userRepositoryProvider).watchAllUsers();
|
||||||
|
}
|
||||||
69
apps/im_app/lib/app/notifiers/user_notifier.dart
Normal file
69
apps/im_app/lib/app/notifiers/user_notifier.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:im_app/app/di/user_provider.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:im_app/data/local/drift/app_database.dart';
|
||||||
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
|
import 'package:im_app/domain/repositories/user_repository.dart';
|
||||||
|
|
||||||
|
part 'user_notifier.g.dart';
|
||||||
|
|
||||||
|
/// 单个用户状态管理 (family — 每个 uid 独立 notifier)
|
||||||
|
///
|
||||||
|
/// ## 用法
|
||||||
|
/// ```dart
|
||||||
|
/// // 监听 — 自动重建
|
||||||
|
/// final userAsync = ref.watch(userNotifierProvider(123));
|
||||||
|
///
|
||||||
|
/// // 即时读取,无需 await
|
||||||
|
/// final user = ref.read(userNotifierProvider(123).notifier).current;
|
||||||
|
///
|
||||||
|
/// // 部分更新
|
||||||
|
/// ref.read(userNotifierProvider(123).notifier).updateFields(
|
||||||
|
/// UsersCompanion(nickname: Value('New Name')),
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
@riverpod
|
||||||
|
class UserNotifier extends _$UserNotifier {
|
||||||
|
User? _cached;
|
||||||
|
|
||||||
|
UserRepository get _repo => ref.watch(userRepositoryProvider);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User?> build(int uid) async {
|
||||||
|
ref.onDispose(() => _cached = null);
|
||||||
|
|
||||||
|
// Stream starts automatically — uid is the family arg
|
||||||
|
_repo.watchUser(uid).listen((user) {
|
||||||
|
_cached = user;
|
||||||
|
state = AsyncData(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return initial DB value
|
||||||
|
return _repo.getUser(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 即时访问,无需 await ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
User? get current => _cached;
|
||||||
|
|
||||||
|
// ── 写入 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> saveUser(User user) async {
|
||||||
|
await _repo.saveUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFields(UsersCompanion companion) async {
|
||||||
|
await _repo.updateFields(uid, companion); // uid from build arg
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateUser(User user) async {
|
||||||
|
await _repo.saveUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 删除 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> deleteUser() async {
|
||||||
|
await _repo.deleteUser(uid);
|
||||||
|
_cached = null;
|
||||||
|
state = const AsyncData(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:im_app/data/local/drift/app_database.dart';
|
|
||||||
import 'package:im_app/domain/entities/user.dart';
|
|
||||||
|
|
||||||
/// 用户 DTO(Data Transfer Object)
|
|
||||||
///
|
|
||||||
/// local / remote 共用的数据传输对象,放在 data/models/。
|
|
||||||
/// 提供与 Domain Entity [User] 之间的双向转换。
|
|
||||||
class UserDto {
|
|
||||||
final int uid;
|
|
||||||
final String? uuid;
|
|
||||||
final int? lastOnline;
|
|
||||||
final String? profilePic;
|
|
||||||
final String? profilePicGaussian;
|
|
||||||
final String? nickname;
|
|
||||||
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({
|
|
||||||
required this.uid,
|
|
||||||
this.uuid,
|
|
||||||
this.lastOnline,
|
|
||||||
this.profilePic,
|
|
||||||
this.profilePicGaussian,
|
|
||||||
this.nickname,
|
|
||||||
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) => UserDto(
|
|
||||||
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() => {
|
|
||||||
'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
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Domain Entity → DTO
|
|
||||||
factory UserDto.fromEntity(User user) => UserDto(
|
|
||||||
uid: user.uid,
|
|
||||||
uuid: user.uuid,
|
|
||||||
lastOnline: user.lastOnline,
|
|
||||||
profilePic: user.profilePic,
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -91,8 +91,6 @@ class ProfileResponse {
|
|||||||
bio: bio,
|
bio: bio,
|
||||||
relationship: relationship,
|
relationship: relationship,
|
||||||
userAlias: userAlias,
|
userAlias: userAlias,
|
||||||
channelId: channelId,
|
|
||||||
channelGroupId: channelGroupId,
|
|
||||||
hint: hint,
|
hint: hint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ class LoginProfile {
|
|||||||
bio: bio,
|
bio: bio,
|
||||||
relationship: relationship,
|
relationship: relationship,
|
||||||
userAlias: userAlias,
|
userAlias: userAlias,
|
||||||
channelId: channelId,
|
|
||||||
channelGroupId: channelGroupId,
|
|
||||||
hint: hint,
|
hint: hint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
238
apps/im_app/lib/data/repositories/user_repository_impl.dart
Normal file
238
apps/im_app/lib/data/repositories/user_repository_impl.dart
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:im_app/data/local/drift/app_database.dart';
|
||||||
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
|
import 'package:im_app/domain/repositories/user_repository.dart';
|
||||||
|
import 'package:storage_sdk/storage_sdk.dart';
|
||||||
|
|
||||||
|
/// 用户仓储实现
|
||||||
|
///
|
||||||
|
/// ## 职责
|
||||||
|
/// - 所有 DB 操作通过 [StorageSdkApi],不直接接触 AppDatabase
|
||||||
|
/// - DriftUser ↔ Domain User 映射
|
||||||
|
/// - 持久化决策由调用方决定
|
||||||
|
///
|
||||||
|
/// ## 数据流
|
||||||
|
/// ```
|
||||||
|
/// 网络:User.fromJson(json) → (可选) saveUser(user) → StorageSdkApi → DB
|
||||||
|
/// 监听:StorageSdkApi.watchWhere/watchAll → DriftUser → _toEntity() → UI
|
||||||
|
/// 部分更新:updateFields(uid, UsersCompanion) → StorageSdkApi.updateWhere
|
||||||
|
/// ```
|
||||||
|
class UserRepositoryImpl implements UserRepository {
|
||||||
|
final StorageSdkApi _storage;
|
||||||
|
|
||||||
|
UserRepositoryImpl(this._storage);
|
||||||
|
|
||||||
|
// ── DB row → Domain ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
User _toEntity(DriftUser row) => User(
|
||||||
|
uid: row.uid,
|
||||||
|
uuid: row.uuid,
|
||||||
|
lastOnline: row.lastOnline,
|
||||||
|
profilePic: row.profilePic,
|
||||||
|
profilePicGaussian: row.profilePicGaussian,
|
||||||
|
nickname: row.nickname,
|
||||||
|
depositName: row.depositName,
|
||||||
|
hasSetDepositName: row.hasSetDepositName,
|
||||||
|
contact: row.contact,
|
||||||
|
countryCode: row.countryCode,
|
||||||
|
username: row.username,
|
||||||
|
role: row.role,
|
||||||
|
relationship: row.relationship,
|
||||||
|
friendStatus: row.friendStatus,
|
||||||
|
bio: row.bio,
|
||||||
|
userAlias: row.userAlias,
|
||||||
|
requestAt: row.requestAt,
|
||||||
|
deletedAt: row.deletedAt,
|
||||||
|
email: row.email,
|
||||||
|
recoveryEmail: row.recoveryEmail,
|
||||||
|
remark: row.remark,
|
||||||
|
source: row.source,
|
||||||
|
addIndex: row.addIndex,
|
||||||
|
incomingSoundId: row.incomingSoundId,
|
||||||
|
outgoingSoundId: row.outgoingSoundId,
|
||||||
|
notificationSoundId: row.notificationSoundId,
|
||||||
|
sendMessageSoundId: row.sendMessageSoundId,
|
||||||
|
groupNotificationSoundId: row.groupNotificationSoundId,
|
||||||
|
groupTags: row.groupTags,
|
||||||
|
friendTags: row.friendTags,
|
||||||
|
publicKey: row.publicKey,
|
||||||
|
configBits: row.configBits,
|
||||||
|
hint: row.hint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Domain → DB companion ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
UsersCompanion _toCompanion(User user) => UsersCompanion(
|
||||||
|
uid: Value(user.uid),
|
||||||
|
uuid: Value(user.uuid),
|
||||||
|
lastOnline: Value(user.lastOnline),
|
||||||
|
profilePic: Value(user.profilePic),
|
||||||
|
profilePicGaussian: Value(user.profilePicGaussian ?? ''),
|
||||||
|
nickname: Value(user.nickname),
|
||||||
|
depositName: Value(user.depositName),
|
||||||
|
hasSetDepositName: Value(user.hasSetDepositName ?? 0),
|
||||||
|
contact: Value(user.contact),
|
||||||
|
countryCode: Value(user.countryCode),
|
||||||
|
username: Value(user.username),
|
||||||
|
role: Value(user.role),
|
||||||
|
relationship: Value(user.relationship),
|
||||||
|
friendStatus: Value(user.friendStatus),
|
||||||
|
bio: Value(user.bio),
|
||||||
|
userAlias: Value(user.userAlias),
|
||||||
|
requestAt: Value(user.requestAt),
|
||||||
|
deletedAt: Value(user.deletedAt),
|
||||||
|
email: Value(user.email),
|
||||||
|
recoveryEmail: Value(user.recoveryEmail),
|
||||||
|
remark: Value(user.remark),
|
||||||
|
source: Value(user.source),
|
||||||
|
addIndex: Value(user.addIndex),
|
||||||
|
incomingSoundId: Value(user.incomingSoundId ?? 0),
|
||||||
|
outgoingSoundId: Value(user.outgoingSoundId ?? 0),
|
||||||
|
notificationSoundId: Value(user.notificationSoundId ?? 0),
|
||||||
|
sendMessageSoundId: Value(user.sendMessageSoundId ?? 0),
|
||||||
|
groupNotificationSoundId: Value(user.groupNotificationSoundId ?? 0),
|
||||||
|
groupTags: Value(user.groupTags ?? '[]'),
|
||||||
|
friendTags: Value(user.friendTags ?? '[]'),
|
||||||
|
publicKey: Value(user.publicKey),
|
||||||
|
configBits: Value(user.configBits ?? 0),
|
||||||
|
hint: Value(user.hint),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 监听 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 监听单个用户
|
||||||
|
@override
|
||||||
|
Stream<User?> watchUser(int uid) {
|
||||||
|
return _storage
|
||||||
|
.watchFirst<DriftUser, $UsersTable>((t) => t.uid.equals(uid))
|
||||||
|
.map((row) => row != null ? _toEntity(row) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监听指定 uid 列表
|
||||||
|
@override
|
||||||
|
Stream<List<User>> watchUsers(List<int> uids) {
|
||||||
|
return _storage
|
||||||
|
.watchWhere<DriftUser, $UsersTable>((t) => t.uid.isIn(uids))
|
||||||
|
.map((rows) => rows.map(_toEntity).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 监听所有用户
|
||||||
|
@override
|
||||||
|
Stream<List<User>> watchAllUsers() {
|
||||||
|
return _storage.watchAll<DriftUser>().map(
|
||||||
|
(rows) => rows.map(_toEntity).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 读取 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User?> getUser(int uid) async {
|
||||||
|
final row = await _storage.selectFirst<DriftUser, $UsersTable>(
|
||||||
|
(t) => t.uid.equals(uid),
|
||||||
|
);
|
||||||
|
return row != null ? _toEntity(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<User>> getAllUsers() async {
|
||||||
|
final rows = await _storage.selectAll<DriftUser>();
|
||||||
|
return rows.map(_toEntity).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<User>> getUsers({required int offset, required int limit}) async {
|
||||||
|
final rows = await _storage.rawQuery(
|
||||||
|
'SELECT * FROM user ORDER BY id LIMIT ? OFFSET ?',
|
||||||
|
[limit, offset],
|
||||||
|
);
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(row) => User(
|
||||||
|
uid: row.read<int>('uid'),
|
||||||
|
uuid: row.readNullable<String>('uuid'),
|
||||||
|
lastOnline: row.readNullable<int>('last_online'),
|
||||||
|
profilePic: row.readNullable<String>('profile_pic'),
|
||||||
|
profilePicGaussian: row.readNullable<String>(
|
||||||
|
'profile_pic_gaussian',
|
||||||
|
),
|
||||||
|
nickname: row.readNullable<String>('nickname'),
|
||||||
|
depositName: row.readNullable<String>('deposit_name'),
|
||||||
|
hasSetDepositName: row.readNullable<int>('has_set_deposit_name'),
|
||||||
|
contact: row.readNullable<String>('contact'),
|
||||||
|
countryCode: row.readNullable<String>('country_code'),
|
||||||
|
username: row.readNullable<String>('username'),
|
||||||
|
role: row.readNullable<int>('role'),
|
||||||
|
relationship: row.readNullable<int>('relationship'),
|
||||||
|
friendStatus: row.readNullable<int>('friend_status'),
|
||||||
|
bio: row.readNullable<String>('bio'),
|
||||||
|
userAlias: row.readNullable<String>('user_alias'),
|
||||||
|
requestAt: row.readNullable<int>('request_at'),
|
||||||
|
deletedAt: row.readNullable<int>('deleted_at'),
|
||||||
|
email: row.readNullable<String>('email'),
|
||||||
|
recoveryEmail: row.readNullable<String>('recovery_email'),
|
||||||
|
remark: row.readNullable<String>('remark'),
|
||||||
|
source: row.readNullable<String>('source'),
|
||||||
|
addIndex: row.readNullable<int>('add_index'),
|
||||||
|
incomingSoundId: row.readNullable<int>('incoming_sound_id'),
|
||||||
|
outgoingSoundId: row.readNullable<int>('outgoing_sound_id'),
|
||||||
|
notificationSoundId: row.readNullable<int>('notification_sound_id'),
|
||||||
|
sendMessageSoundId: row.readNullable<int>('send_message_sound_id'),
|
||||||
|
groupNotificationSoundId: row.readNullable<int>(
|
||||||
|
'group_notification_sound_id',
|
||||||
|
),
|
||||||
|
groupTags: row.readNullable<String>('group_tags'),
|
||||||
|
friendTags: row.readNullable<String>('friend_tags'),
|
||||||
|
publicKey: row.readNullable<String>('public_key'),
|
||||||
|
configBits: row.readNullable<int>('config_bits'),
|
||||||
|
hint: row.readNullable<String>('hint'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> countUsers() async {
|
||||||
|
return _storage.count<DriftUser, $UsersTable>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 写入 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveUser(User user) async {
|
||||||
|
await _storage.insertOrReplace<DriftUser>(_toCompanion(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveUsers(List<User> users) async {
|
||||||
|
await _storage.batchInsertOrReplace<DriftUser>(
|
||||||
|
users.map(_toCompanion).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅更新指定列,其他列不变
|
||||||
|
///
|
||||||
|
/// 示例:
|
||||||
|
/// ```dart
|
||||||
|
/// await userRepo.updateFields(uid, UsersCompanion(
|
||||||
|
/// nickname: Value('New Name'),
|
||||||
|
/// lastOnline: Value(DateTime.now().millisecondsSinceEpoch),
|
||||||
|
/// ));
|
||||||
|
/// ```
|
||||||
|
@override
|
||||||
|
Future<void> updateFields(int uid, UsersCompanion companion) async {
|
||||||
|
await _storage.updateWhere<DriftUser, $UsersTable>(
|
||||||
|
companion,
|
||||||
|
(t) => t.uid.equals(uid),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 删除 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteUser(int uid) async {
|
||||||
|
await _storage.deleteWhere<DriftUser, $UsersTable>(
|
||||||
|
(t) => t.uid.equals(uid),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,12 @@
|
|||||||
/// 全局共享实体,被 auth / chat / contact 等多个 Feature 共用。
|
/// 全局共享实体,被 auth / chat / contact 等多个 Feature 共用。
|
||||||
/// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。
|
/// 纯 Dart 类,零 Flutter / 零网络 / 零 DB 依赖。
|
||||||
///
|
///
|
||||||
/// ## 数据流位置
|
/// ## 数据流
|
||||||
///
|
|
||||||
/// ```
|
/// ```
|
||||||
/// 服务端 JSON
|
/// 服务端 JSON
|
||||||
/// → LoginData(Response DTO,data/remote/login_request.dart)
|
/// → User.fromJson() ← 直接从网络创建
|
||||||
/// → LoginData.toEntity()
|
|
||||||
/// → ★ User ★ ← 你在这里
|
/// → ★ User ★ ← 你在这里
|
||||||
|
/// → userRepo.saveUser(user) ← 可选持久化
|
||||||
/// → ViewModel.state
|
/// → ViewModel.state
|
||||||
/// → View 渲染
|
/// → View 渲染
|
||||||
/// ```
|
/// ```
|
||||||
@@ -20,16 +19,32 @@ class User {
|
|||||||
final String? profilePic;
|
final String? profilePic;
|
||||||
final String? profilePicGaussian;
|
final String? profilePicGaussian;
|
||||||
final String? nickname;
|
final String? nickname;
|
||||||
|
final String? depositName;
|
||||||
|
final int? hasSetDepositName;
|
||||||
final String? contact;
|
final String? contact;
|
||||||
final String? countryCode;
|
final String? countryCode;
|
||||||
|
final String? username;
|
||||||
|
final int? role;
|
||||||
|
final int? relationship;
|
||||||
|
final int? friendStatus;
|
||||||
|
final String? bio;
|
||||||
|
final String? userAlias;
|
||||||
|
final int? requestAt;
|
||||||
|
final int? deletedAt;
|
||||||
final String? email;
|
final String? email;
|
||||||
final String? recoveryEmail;
|
final String? recoveryEmail;
|
||||||
final String? username;
|
final String? remark;
|
||||||
final String? bio;
|
final String? source;
|
||||||
final int? relationship;
|
final int? addIndex;
|
||||||
final String? userAlias;
|
final int? incomingSoundId;
|
||||||
final int? channelId;
|
final int? outgoingSoundId;
|
||||||
final int? channelGroupId;
|
final int? notificationSoundId;
|
||||||
|
final int? sendMessageSoundId;
|
||||||
|
final int? groupNotificationSoundId;
|
||||||
|
final String? groupTags;
|
||||||
|
final String? friendTags;
|
||||||
|
final String? publicKey;
|
||||||
|
final int? configBits;
|
||||||
final String? hint;
|
final String? hint;
|
||||||
|
|
||||||
const User({
|
const User({
|
||||||
@@ -39,19 +54,73 @@ class User {
|
|||||||
this.profilePic,
|
this.profilePic,
|
||||||
this.profilePicGaussian,
|
this.profilePicGaussian,
|
||||||
this.nickname,
|
this.nickname,
|
||||||
|
this.depositName,
|
||||||
|
this.hasSetDepositName,
|
||||||
this.contact,
|
this.contact,
|
||||||
this.countryCode,
|
this.countryCode,
|
||||||
|
this.username,
|
||||||
|
this.role,
|
||||||
|
this.relationship,
|
||||||
|
this.friendStatus,
|
||||||
|
this.bio,
|
||||||
|
this.userAlias,
|
||||||
|
this.requestAt,
|
||||||
|
this.deletedAt,
|
||||||
this.email,
|
this.email,
|
||||||
this.recoveryEmail,
|
this.recoveryEmail,
|
||||||
this.username,
|
this.remark,
|
||||||
this.bio,
|
this.source,
|
||||||
this.relationship,
|
this.addIndex,
|
||||||
this.userAlias,
|
this.incomingSoundId,
|
||||||
this.channelId,
|
this.outgoingSoundId,
|
||||||
this.channelGroupId,
|
this.notificationSoundId,
|
||||||
|
this.sendMessageSoundId,
|
||||||
|
this.groupNotificationSoundId,
|
||||||
|
this.groupTags,
|
||||||
|
this.friendTags,
|
||||||
|
this.publicKey,
|
||||||
|
this.configBits,
|
||||||
this.hint,
|
this.hint,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 直接从网络 JSON 创建 Domain 实体
|
||||||
|
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||||
|
uid: json['uid'] as int,
|
||||||
|
uuid: json['uuid'],
|
||||||
|
lastOnline: json['last_online'],
|
||||||
|
profilePic: json['profile_pic'],
|
||||||
|
profilePicGaussian: json['profile_pic_gaussian'],
|
||||||
|
nickname: json['nickname'],
|
||||||
|
depositName: json['deposit_name'],
|
||||||
|
hasSetDepositName: json['has_set_deposit_name'],
|
||||||
|
contact: json['contact'],
|
||||||
|
countryCode: json['country_code'],
|
||||||
|
username: json['username'],
|
||||||
|
role: json['role'],
|
||||||
|
relationship: json['relationship'],
|
||||||
|
friendStatus: json['friend_status'],
|
||||||
|
bio: json['bio'],
|
||||||
|
userAlias: json['user_alias'],
|
||||||
|
requestAt: json['request_at'],
|
||||||
|
deletedAt: json['deleted_at'],
|
||||||
|
email: json['email'],
|
||||||
|
recoveryEmail: json['recovery_email'],
|
||||||
|
remark: json['remark'],
|
||||||
|
source: json['source'],
|
||||||
|
addIndex: json['__add_index'],
|
||||||
|
incomingSoundId: json['incoming_sound_id'],
|
||||||
|
outgoingSoundId: json['outgoing_sound_id'],
|
||||||
|
notificationSoundId: json['notification_sound_id'],
|
||||||
|
sendMessageSoundId: json['send_message_sound_id'],
|
||||||
|
groupNotificationSoundId: json['group_notification_sound_id'],
|
||||||
|
groupTags: json['group_tags'],
|
||||||
|
friendTags: json['friend_tags'],
|
||||||
|
publicKey: json['public_key'],
|
||||||
|
configBits: json['config_bits'],
|
||||||
|
hint: json['hint'],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 仅更新部分字段
|
||||||
User copyWith({
|
User copyWith({
|
||||||
int? uid,
|
int? uid,
|
||||||
String? uuid,
|
String? uuid,
|
||||||
@@ -59,16 +128,32 @@ class User {
|
|||||||
String? profilePic,
|
String? profilePic,
|
||||||
String? profilePicGaussian,
|
String? profilePicGaussian,
|
||||||
String? nickname,
|
String? nickname,
|
||||||
|
String? depositName,
|
||||||
|
int? hasSetDepositName,
|
||||||
String? contact,
|
String? contact,
|
||||||
String? countryCode,
|
String? countryCode,
|
||||||
|
String? username,
|
||||||
|
int? role,
|
||||||
|
int? relationship,
|
||||||
|
int? friendStatus,
|
||||||
|
String? bio,
|
||||||
|
String? userAlias,
|
||||||
|
int? requestAt,
|
||||||
|
int? deletedAt,
|
||||||
String? email,
|
String? email,
|
||||||
String? recoveryEmail,
|
String? recoveryEmail,
|
||||||
String? username,
|
String? remark,
|
||||||
String? bio,
|
String? source,
|
||||||
int? relationship,
|
int? addIndex,
|
||||||
String? userAlias,
|
int? incomingSoundId,
|
||||||
int? channelId,
|
int? outgoingSoundId,
|
||||||
int? channelGroupId,
|
int? notificationSoundId,
|
||||||
|
int? sendMessageSoundId,
|
||||||
|
int? groupNotificationSoundId,
|
||||||
|
String? groupTags,
|
||||||
|
String? friendTags,
|
||||||
|
String? publicKey,
|
||||||
|
int? configBits,
|
||||||
String? hint,
|
String? hint,
|
||||||
}) {
|
}) {
|
||||||
return User(
|
return User(
|
||||||
@@ -78,16 +163,33 @@ class User {
|
|||||||
profilePic: profilePic ?? this.profilePic,
|
profilePic: profilePic ?? this.profilePic,
|
||||||
profilePicGaussian: profilePicGaussian ?? this.profilePicGaussian,
|
profilePicGaussian: profilePicGaussian ?? this.profilePicGaussian,
|
||||||
nickname: nickname ?? this.nickname,
|
nickname: nickname ?? this.nickname,
|
||||||
|
depositName: depositName ?? this.depositName,
|
||||||
|
hasSetDepositName: hasSetDepositName ?? this.hasSetDepositName,
|
||||||
contact: contact ?? this.contact,
|
contact: contact ?? this.contact,
|
||||||
countryCode: countryCode ?? this.countryCode,
|
countryCode: countryCode ?? this.countryCode,
|
||||||
|
username: username ?? this.username,
|
||||||
|
role: role ?? this.role,
|
||||||
|
relationship: relationship ?? this.relationship,
|
||||||
|
friendStatus: friendStatus ?? this.friendStatus,
|
||||||
|
bio: bio ?? this.bio,
|
||||||
|
userAlias: userAlias ?? this.userAlias,
|
||||||
|
requestAt: requestAt ?? this.requestAt,
|
||||||
|
deletedAt: deletedAt ?? this.deletedAt,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
recoveryEmail: recoveryEmail ?? this.recoveryEmail,
|
recoveryEmail: recoveryEmail ?? this.recoveryEmail,
|
||||||
username: username ?? this.username,
|
remark: remark ?? this.remark,
|
||||||
bio: bio ?? this.bio,
|
source: source ?? this.source,
|
||||||
relationship: relationship ?? this.relationship,
|
addIndex: addIndex ?? this.addIndex,
|
||||||
userAlias: userAlias ?? this.userAlias,
|
incomingSoundId: incomingSoundId ?? this.incomingSoundId,
|
||||||
channelId: channelId ?? this.channelId,
|
outgoingSoundId: outgoingSoundId ?? this.outgoingSoundId,
|
||||||
channelGroupId: channelGroupId ?? this.channelGroupId,
|
notificationSoundId: notificationSoundId ?? this.notificationSoundId,
|
||||||
|
sendMessageSoundId: sendMessageSoundId ?? this.sendMessageSoundId,
|
||||||
|
groupNotificationSoundId:
|
||||||
|
groupNotificationSoundId ?? this.groupNotificationSoundId,
|
||||||
|
groupTags: groupTags ?? this.groupTags,
|
||||||
|
friendTags: friendTags ?? this.friendTags,
|
||||||
|
publicKey: publicKey ?? this.publicKey,
|
||||||
|
configBits: configBits ?? this.configBits,
|
||||||
hint: hint ?? this.hint,
|
hint: hint ?? this.hint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
apps/im_app/lib/domain/repositories/user_repository.dart
Normal file
78
apps/im_app/lib/domain/repositories/user_repository.dart
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:im_app/data/local/drift/app_database.dart';
|
||||||
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
|
|
||||||
|
/// 用户仓储接口
|
||||||
|
///
|
||||||
|
/// ## 职责
|
||||||
|
/// - StorageSdkApi ↔ Domain User 映射
|
||||||
|
/// - CRUD 操作(通过 StorageSdkApi,不直接接触 DB)
|
||||||
|
/// - 实时监听(单个 / 多个 / 全部)
|
||||||
|
///
|
||||||
|
/// ## 数据流
|
||||||
|
/// ```
|
||||||
|
/// 写入:Domain User → _toCompanion() → StorageSdkApi → DB
|
||||||
|
/// 读取:DB row (DriftUser) → _toEntity() → Domain User
|
||||||
|
/// 监听:DB 变化 → stream → Domain User → UI
|
||||||
|
/// ```
|
||||||
|
abstract class UserRepository {
|
||||||
|
// ── 监听 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 监听单个用户,DB 变化自动反映
|
||||||
|
Stream<User?> watchUser(int uid);
|
||||||
|
|
||||||
|
/// 监听指定 uid 列表,任一变化自动反映
|
||||||
|
Stream<List<User>> watchUsers(List<int> uids);
|
||||||
|
|
||||||
|
/// 监听所有用户,任一变化自动反映
|
||||||
|
Stream<List<User>> watchAllUsers();
|
||||||
|
|
||||||
|
// ── 读取 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 从 DB 读取单个用户,不存在返回 null
|
||||||
|
Future<User?> getUser(int uid);
|
||||||
|
|
||||||
|
/// 从 DB 读取所有用户
|
||||||
|
Future<List<User>> getAllUsers();
|
||||||
|
|
||||||
|
/// 分页读取用户
|
||||||
|
///
|
||||||
|
/// [offset] 起始偏移量
|
||||||
|
/// [limit] 每页数量
|
||||||
|
///
|
||||||
|
/// 示例:
|
||||||
|
/// ```dart
|
||||||
|
/// // 第一页
|
||||||
|
/// final page1 = await repo.getUsers(offset: 0, limit: 50);
|
||||||
|
/// // 第二页
|
||||||
|
/// final page2 = await repo.getUsers(offset: 50, limit: 50);
|
||||||
|
/// ```
|
||||||
|
Future<List<User>> getUsers({required int offset, required int limit});
|
||||||
|
|
||||||
|
/// 统计 DB 中用户总数
|
||||||
|
Future<int> countUsers();
|
||||||
|
|
||||||
|
// ── 写入 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 保存完整用户(insert or replace)
|
||||||
|
/// 调用方决定是否持久化
|
||||||
|
Future<void> saveUser(User user);
|
||||||
|
|
||||||
|
/// 批量保存用户(insert or replace)
|
||||||
|
Future<void> saveUsers(List<User> users);
|
||||||
|
|
||||||
|
/// 仅更新指定字段,不影响其他列
|
||||||
|
///
|
||||||
|
/// 示例:
|
||||||
|
/// ```dart
|
||||||
|
/// await repo.updateFields(uid, UsersCompanion(
|
||||||
|
/// nickname: Value('New Name'),
|
||||||
|
/// lastOnline: Value(DateTime.now().millisecondsSinceEpoch),
|
||||||
|
/// ));
|
||||||
|
/// ```
|
||||||
|
Future<void> updateFields(int uid, UsersCompanion companion);
|
||||||
|
|
||||||
|
// ── 删除 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 删除指定用户
|
||||||
|
Future<void> deleteUser(int uid);
|
||||||
|
}
|
||||||
55
apps/im_app/lib/domain/usecases/insert_users_use_case.dart
Normal file
55
apps/im_app/lib/domain/usecases/insert_users_use_case.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
|
import 'package:im_app/domain/repositories/user_repository.dart';
|
||||||
|
|
||||||
|
/// 批量插入用户用例
|
||||||
|
///
|
||||||
|
/// ## 职责
|
||||||
|
/// - 封装用户插入的业务规则
|
||||||
|
/// - 去重(uid 相同时保留最后一个)
|
||||||
|
/// - 分批插入,避免单次写入过大
|
||||||
|
///
|
||||||
|
/// ## 数据流
|
||||||
|
/// ```
|
||||||
|
/// ViewModel
|
||||||
|
/// → InsertUsersUseCase.execute(users)
|
||||||
|
/// → 去重
|
||||||
|
/// → UserRepository.saveUsers(chunk) ← 分批写入
|
||||||
|
/// → onProgress(completed, total) ← 可选进度回调
|
||||||
|
/// ← 实际插入数量
|
||||||
|
/// ```
|
||||||
|
class InsertUsersUseCase {
|
||||||
|
final UserRepository _repo;
|
||||||
|
static const _chunkSize = 200;
|
||||||
|
|
||||||
|
InsertUsersUseCase({required UserRepository userRepository})
|
||||||
|
: _repo = userRepository;
|
||||||
|
|
||||||
|
/// 批量插入用户
|
||||||
|
///
|
||||||
|
/// [users] 要插入的用户列表
|
||||||
|
/// [onProgress] 可选回调,每批完成后触发
|
||||||
|
///
|
||||||
|
/// 返回实际插入数量
|
||||||
|
Future<int> execute(
|
||||||
|
List<User> users, {
|
||||||
|
void Function(int completed, int total, List<User> chunk)? onProgress,
|
||||||
|
}) async {
|
||||||
|
if (users.isEmpty) return 0;
|
||||||
|
|
||||||
|
final deduped = {for (final u in users) u.uid: u}.values.toList();
|
||||||
|
final total = deduped.length;
|
||||||
|
int completed = 0;
|
||||||
|
|
||||||
|
while (completed < total) {
|
||||||
|
final end = (completed + _chunkSize).clamp(0, total);
|
||||||
|
final chunk = deduped.sublist(completed, end);
|
||||||
|
|
||||||
|
await _repo.saveUsers(chunk);
|
||||||
|
completed += chunk.length;
|
||||||
|
|
||||||
|
onProgress?.call(completed, total, chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,83 +1,149 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:im_app/app/di/db_provider.dart';
|
import 'package:im_app/app/di/user_provider.dart';
|
||||||
import 'package:im_app/data/local/drift/app_database.dart';
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import 'chat_db_test_state.dart';
|
|
||||||
|
|
||||||
export 'chat_db_test_state.dart';
|
|
||||||
|
|
||||||
part 'chat_db_test_view_model.g.dart';
|
part 'chat_db_test_view_model.g.dart';
|
||||||
|
|
||||||
|
class ChatDbTestState {
|
||||||
|
final bool testStarted;
|
||||||
|
final List<User> users;
|
||||||
|
final String currentState;
|
||||||
|
final bool hasMore;
|
||||||
|
final int currentPage;
|
||||||
|
final int totalCount;
|
||||||
|
|
||||||
|
const ChatDbTestState({
|
||||||
|
this.testStarted = false,
|
||||||
|
this.users = const [],
|
||||||
|
this.currentState = '',
|
||||||
|
this.hasMore = true,
|
||||||
|
this.currentPage = 0,
|
||||||
|
this.totalCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatDbTestState copyWith({
|
||||||
|
bool? testStarted,
|
||||||
|
List<User>? users,
|
||||||
|
String? currentState,
|
||||||
|
bool? hasMore,
|
||||||
|
int? currentPage,
|
||||||
|
int? totalCount,
|
||||||
|
}) => ChatDbTestState(
|
||||||
|
testStarted: testStarted ?? this.testStarted,
|
||||||
|
users: users ?? this.users,
|
||||||
|
currentState: currentState ?? this.currentState,
|
||||||
|
hasMore: hasMore ?? this.hasMore,
|
||||||
|
currentPage: currentPage ?? this.currentPage,
|
||||||
|
totalCount: totalCount ?? this.totalCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
class ChatDbTestViewModel extends _$ChatDbTestViewModel {
|
||||||
|
final _random = Random();
|
||||||
|
bool _isTesting = false;
|
||||||
|
static const _pageSize = 50;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChatDbTestState build() {
|
ChatDbTestState build() {
|
||||||
// 这里就是 onInit
|
Future.microtask(() => _loadNextPage(reset: true));
|
||||||
final List<TestResult> testResults = List.generate(
|
return const ChatDbTestState(currentState: '加载中...');
|
||||||
1000,
|
|
||||||
(i) => TestResult(
|
|
||||||
title: '用户 ${Random().nextInt(9999)}',
|
|
||||||
subtitle: 'uid: ${Random().nextInt(999999)}',
|
|
||||||
duration: '${Random().nextInt(500)}ms',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return ChatDbTestState(testResults: testResults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 操作(Demo 按钮,正式开发后随 UI 一并替换) ──────────────────────────
|
// ── 分页 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 切换测试状态(开始 / 停止)
|
Future<void> _loadNextPage({bool reset = false}) async {
|
||||||
void toggleDBTest() {
|
if (!state.hasMore && !reset) return;
|
||||||
if (state.testStarted) {
|
|
||||||
state = state.copyWith(testStarted: false, currentState: '结束测试');
|
final repo = ref.read(userRepositoryProvider);
|
||||||
} else {
|
final page = reset ? 0 : state.currentPage;
|
||||||
|
final offset = page * _pageSize;
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
repo.getUsers(offset: offset, limit: _pageSize),
|
||||||
|
repo.countUsers(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
|
final newUsers = results[0] as List<User>;
|
||||||
|
final total = results[1] as int;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
users: reset ? newUsers : [...state.users, ...newUsers],
|
||||||
|
currentPage: page + 1,
|
||||||
|
hasMore: newUsers.length >= _pageSize,
|
||||||
|
totalCount: total,
|
||||||
|
currentState: reset && newUsers.isEmpty ? '暂无数据' : '空闲 (共 $total 条)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by ListView when reaching the end
|
||||||
|
void loadMore() {
|
||||||
|
if (!state.hasMore || state.testStarted) return;
|
||||||
|
_loadNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 测试 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void startDBTest(BuildContext context) {
|
||||||
|
_isTesting = true;
|
||||||
state = state.copyWith(testStarted: true, currentState: '开始测试');
|
state = state.copyWith(testStarted: true, currentState: '开始测试');
|
||||||
_testDBInsert();
|
_testDBInsert();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void stopDBTest(BuildContext context) {
|
||||||
|
_isTesting = false;
|
||||||
|
state = state.copyWith(testStarted: false, currentState: '结束测试');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _testDBInsert() async {
|
Future<void> _testDBInsert() async {
|
||||||
final db = ref.read(storageSdkProvider);
|
final useCase = ref.read(insertUsersUseCaseProvider);
|
||||||
|
final repo = ref.read(userRepositoryProvider);
|
||||||
const count = 10000;
|
const count = 10000;
|
||||||
const chunkSize = 50;
|
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
|
final baseUid = DateTime.now().microsecondsSinceEpoch;
|
||||||
|
|
||||||
debugPrint('开始测试: $count 条,每批 $chunkSize 条');
|
debugPrint('开始测试: $count 条');
|
||||||
|
|
||||||
int completed = 0;
|
final workingList = List<User>.from(state.users);
|
||||||
|
|
||||||
for (var i = 0; i < count; i += chunkSize) {
|
final allUsers = List.generate(
|
||||||
final chunk = List.generate(
|
count,
|
||||||
chunkSize.clamp(0, count - i),
|
(i) => User(uid: baseUid + i, nickname: 'User ${_random.nextInt(9999)}'),
|
||||||
(j) =>
|
|
||||||
UsersCompanion.insert(uid: i + j, nickname: Value('User ${i + j}')),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await db.batchInsertOrReplace<DriftUser>(chunk);
|
await useCase.execute(
|
||||||
completed += chunk.length;
|
allUsers,
|
||||||
|
onProgress: (completed, total, chunk) {
|
||||||
// 让出主线程
|
workingList.addAll(chunk);
|
||||||
await Future.delayed(Duration.zero);
|
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'已完成: $completed / $count (${stopwatch.elapsedMilliseconds}ms)',
|
'已完成: $completed / $total (${stopwatch.elapsedMilliseconds}ms)',
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新 UI 状态
|
if (ref.mounted && _isTesting) {
|
||||||
if (ref.mounted) {
|
state = state.copyWith(
|
||||||
state = state.copyWith(currentState: '已插入 $completed / $count 条');
|
users: List.unmodifiable(workingList),
|
||||||
}
|
currentState: '已插入 $completed / $total 条',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_isTesting = false;
|
||||||
|
final elapsed = stopwatch.elapsedMilliseconds;
|
||||||
|
debugPrint('全部完成: ${elapsed}ms');
|
||||||
|
|
||||||
debugPrint('全部完成: ${stopwatch.elapsedMilliseconds}ms');
|
|
||||||
if (ref.mounted) {
|
if (ref.mounted) {
|
||||||
|
final total = await repo.countUsers();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
testStarted: false,
|
testStarted: false,
|
||||||
currentState: '完成!共 $count 条,耗时 ${stopwatch.elapsedMilliseconds}ms',
|
totalCount: total,
|
||||||
|
currentState: '完成!共 $total 条,耗时 ${elapsed}ms',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
|
import 'package:im_app/domain/repositories/user_repository.dart';
|
||||||
|
|
||||||
|
/// 批量插入用户用例
|
||||||
|
///
|
||||||
|
/// ## 职责
|
||||||
|
/// - 封装用户插入的业务规则
|
||||||
|
/// - 去重(uid 相同时保留最后一个)
|
||||||
|
/// - 分批插入,避免单次写入过大
|
||||||
|
///
|
||||||
|
/// ## 数据流
|
||||||
|
/// ```
|
||||||
|
/// ViewModel
|
||||||
|
/// → InsertUsersUseCase.execute(users)
|
||||||
|
/// → 去重
|
||||||
|
/// → UserRepository.saveUsers(chunk) ← 分批写入
|
||||||
|
/// → onProgress(completed, total) ← 可选进度回调
|
||||||
|
/// ← 实际插入数量
|
||||||
|
/// ```
|
||||||
|
class InsertUsersUseCase {
|
||||||
|
final UserRepository _repo;
|
||||||
|
static const _chunkSize = 200;
|
||||||
|
|
||||||
|
InsertUsersUseCase({required UserRepository userRepository})
|
||||||
|
: _repo = userRepository;
|
||||||
|
|
||||||
|
/// 批量插入用户
|
||||||
|
///
|
||||||
|
/// [users] 要插入的用户列表
|
||||||
|
/// [onProgress] 可选回调,每批完成后触发
|
||||||
|
///
|
||||||
|
/// 返回实际插入数量
|
||||||
|
Future<int> execute(
|
||||||
|
List<User> users, {
|
||||||
|
void Function(int completed, int total, List<User> chunk)? onProgress,
|
||||||
|
}) async {
|
||||||
|
if (users.isEmpty) return 0;
|
||||||
|
|
||||||
|
final deduped = {for (final u in users) u.uid: u}.values.toList();
|
||||||
|
final total = deduped.length;
|
||||||
|
int completed = 0;
|
||||||
|
|
||||||
|
while (completed < total) {
|
||||||
|
final end = (completed + _chunkSize).clamp(0, total);
|
||||||
|
final chunk = deduped.sublist(completed, end);
|
||||||
|
|
||||||
|
await _repo.saveUsers(chunk);
|
||||||
|
completed += chunk.length;
|
||||||
|
|
||||||
|
onProgress?.call(completed, total, chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,53 +4,89 @@ 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';
|
||||||
|
|
||||||
/// 数据库性能测试页(Demo)
|
class ChatDbTestPage extends ConsumerStatefulWidget {
|
||||||
///
|
|
||||||
/// 批量插入 10000 条用户记录,验证 Drift 批量写入性能。
|
|
||||||
/// 所有操作通过 [ChatDbTestViewModel] 处理,View 只负责渲染。
|
|
||||||
/// 正式开发后此页面将被删除。
|
|
||||||
class ChatDbTestPage extends ConsumerWidget {
|
|
||||||
const ChatDbTestPage({super.key});
|
const ChatDbTestPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ChatDbTestPage> createState() => _ChatDbTestPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatDbTestPageState extends ConsumerState<ChatDbTestPage> {
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 300) {
|
||||||
|
ref.read(chatDbTestViewModelProvider.notifier).loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final vm = ref.read(chatDbTestViewModelProvider.notifier);
|
final vm = ref.read(chatDbTestViewModelProvider.notifier);
|
||||||
final state = ref.watch(chatDbTestViewModelProvider);
|
final testStarted = ref.watch(
|
||||||
|
chatDbTestViewModelProvider.select((s) => s.testStarted),
|
||||||
|
);
|
||||||
|
final currentState = ref.watch(
|
||||||
|
chatDbTestViewModelProvider.select((s) => s.currentState),
|
||||||
|
);
|
||||||
|
final users = ref.watch(chatDbTestViewModelProvider.select((s) => s.users));
|
||||||
|
final hasMore = ref.watch(
|
||||||
|
chatDbTestViewModelProvider.select((s) => s.hasMore),
|
||||||
|
);
|
||||||
|
final totalCount = ref.watch(
|
||||||
|
chatDbTestViewModelProvider.select((s) => s.totalCount),
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('测试数据库')),
|
appBar: AppBar(title: Text('测试数据库 ($totalCount)')),
|
||||||
body: Column(
|
body: Column(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsetsGeometry.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
AppButton.inverse(
|
AppButton.inverse(
|
||||||
label: state.buttonLabel,
|
label: testStarted ? '结束' : '开始',
|
||||||
onPressed: () => vm.toggleDBTest(),
|
onPressed: () => testStarted
|
||||||
),
|
? vm.stopDBTest(context)
|
||||||
SizedBox(width: 8),
|
: vm.startDBTest(context),
|
||||||
Expanded(
|
|
||||||
child: Text(state.currentState, textAlign: TextAlign.end),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(currentState, textAlign: TextAlign.end)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: state.testResults.length,
|
controller: _scrollController,
|
||||||
|
itemCount: users.length + (hasMore ? 1 : 0),
|
||||||
|
cacheExtent: 500,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final result = state.testResults[index];
|
if (index == users.length) {
|
||||||
return ListTile(
|
return const Padding(
|
||||||
titleAlignment: ListTileTitleAlignment.center,
|
padding: EdgeInsets.all(16),
|
||||||
title: Text(result.title),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
subtitle: Text(result.subtitle),
|
|
||||||
// trailing: Text(result.duration),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
final user = users[index];
|
||||||
|
return _UserTile(uid: user.uid, nickname: user.nickname);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -59,3 +95,19 @@ class ChatDbTestPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _UserTile extends StatelessWidget {
|
||||||
|
final int uid;
|
||||||
|
final String? nickname;
|
||||||
|
|
||||||
|
const _UserTile({required this.uid, required this.nickname});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
|
title: Text(nickname ?? '-'),
|
||||||
|
subtitle: Text('uid: $uid'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:im_app/app/di/db_provider.dart';
|
import 'package:im_app/app/di/db_provider.dart';
|
||||||
import 'package:im_app/data/models/user_dto.dart';
|
import 'package:im_app/app/di/user_provider.dart';
|
||||||
import 'package:im_app/domain/entities/user.dart';
|
import 'package:im_app/domain/entities/user.dart';
|
||||||
import 'package:networks_sdk/networks_sdk.dart';
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -62,11 +62,12 @@ class LoginViewModel extends _$LoginViewModel {
|
|||||||
// 防止连点重入:第一次调用未完成前忽略后续调用
|
// 防止连点重入:第一次调用未完成前忽略后续调用
|
||||||
if (state.isLoading) return;
|
if (state.isLoading) return;
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
try {
|
|
||||||
final storageApi = ref.read(storageSdkProvider);
|
final storageApi = ref.read(storageSdkProvider);
|
||||||
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
final storageLifeCycle = storageApi as StorageSdkLifecycle;
|
||||||
|
final repositoryProvider = ref.read(userRepositoryProvider);
|
||||||
|
final provider = ref.read(authNotifierProvider);
|
||||||
|
|
||||||
|
try {
|
||||||
// 读取 mock 数据(loginData.json 结构: { code, message, data: {...} })
|
// 读取 mock 数据(loginData.json 结构: { code, message, data: {...} })
|
||||||
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
|
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
|
||||||
final raw = await rootBundle.loadString('assets/loginData.json');
|
final raw = await rootBundle.loadString('assets/loginData.json');
|
||||||
@@ -90,23 +91,18 @@ class LoginViewModel extends _$LoginViewModel {
|
|||||||
bio: profile['bio'] as String,
|
bio: profile['bio'] as String,
|
||||||
relationship: profile['relationship'] as int,
|
relationship: profile['relationship'] as int,
|
||||||
userAlias: profile['user_alias'] as String?,
|
userAlias: profile['user_alias'] as String?,
|
||||||
channelId: profile['channel_id'] as int,
|
|
||||||
channelGroupId: profile['channel_group_id'] as int,
|
|
||||||
hint: profile['hint'] as String,
|
hint: profile['hint'] as String,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
|
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
|
||||||
await storageLifeCycle.openDatabase(user.uid);
|
await storageLifeCycle.openDatabase(user.uid);
|
||||||
final userCompanion = UserDto.fromEntity(user).toCompanion();
|
// Save user to DB via repository
|
||||||
await storageApi.insertOrReplace(userCompanion);
|
await repositoryProvider.saveUser(user);
|
||||||
|
|
||||||
// 全部成功后再更新登录状态,触发路由守卫重定向
|
// Trigger auth state
|
||||||
// 注意:login() 触发导航后 provider 随即被 dispose,之后不能再写 state
|
provider.login();
|
||||||
if (!ref.mounted) return;
|
|
||||||
ref.read(authNotifierProvider).login();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
|
// 导航已发生时 provider 已被 dispose,静默丢弃,不再写 state
|
||||||
if (!ref.mounted) return;
|
|
||||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,25 +114,19 @@ class LoginViewModel extends _$LoginViewModel {
|
|||||||
/// 3. 成功:写入 user;失败:写入 error
|
/// 3. 成功:写入 user;失败:写入 error
|
||||||
Future<void> login(String email, String password) async {
|
Future<void> login(String email, String password) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
final provider = ref.read(loginUseCaseProvider);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final user = await ref
|
final user = await provider.execute(email: email, password: password);
|
||||||
.read(loginUseCaseProvider)
|
|
||||||
.execute(email: email, password: password);
|
|
||||||
|
|
||||||
if (!ref.mounted) return;
|
|
||||||
state = state.copyWith(user: user, isLoading: false);
|
state = state.copyWith(user: user, isLoading: false);
|
||||||
} on FormatException catch (e) {
|
} on FormatException catch (e) {
|
||||||
// 格式校验失败(UseCase 层抛出)
|
// 格式校验失败(UseCase 层抛出)
|
||||||
if (!ref.mounted) return;
|
|
||||||
state = state.copyWith(error: e.message, isLoading: false);
|
state = state.copyWith(error: e.message, isLoading: false);
|
||||||
} on ApiError catch (e) {
|
} on ApiError catch (e) {
|
||||||
// 网络 / 服务端错误(Repository → SDK 透传)
|
// 网络 / 服务端错误(Repository → SDK 透传)
|
||||||
if (!ref.mounted) return;
|
|
||||||
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
state = state.copyWith(error: e.displayMessage, isLoading: false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 兜底:防止未预期的异常导致 isLoading 死锁
|
// 兜底:防止未预期的异常导致 isLoading 死锁
|
||||||
if (!ref.mounted) return;
|
|
||||||
state = state.copyWith(error: e.toString(), isLoading: false);
|
state = state.copyWith(error: e.toString(), isLoading: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user