Some checks failed
CI / Lint (push) Has been cancelled
## 问题 旧版 Flutter 项目在 /vcode/check 返回 30164 时展示二级密码输入界面; 新版完全缺失此路径,有二级密码的账号无法登录。 ## 改动 ### networks_sdk - `networks_sdk_method_channel_datasource.dart`:executeRequest 的 generic catch 改为 rethrow,允许 decodeResponse override 抛出 自定义业务异常(原为 ApiError.unknown 包裹导致数据丢失) ### 数据层 - `errors.dart`:新增 `secondaryPasscodeRequired = 30164` - `exceptions.dart`(新增):`SecondaryPasscodeRequiredException` 携带 vcodeToken / recoveryEmail / hint / resetStatus - `verify_otp_request.dart`:override decodeResponse,拦截 30164, 从响应 data 提取字段,throw SecondaryPasscodeRequiredException - `login_request.dart`:新增可选 password 字段 + toJson override (条件序列化,null 时不带 password 字段) - `auth_repository.dart`:新增 loginWithPasscode() 接口 - `auth_repository_impl.dart`:实现 loginWithPasscode() ### 业务层 - `login_usecase.dart`:新增 loginWithSecondaryPasscode() (MD5 哈希 passcode → 调 AuthRepository.loginWithPasscode) - `pubspec.yaml`:新增 crypto: ^3.0.6(用于 MD5) ### UI 层 - `login_state.dart`:新增 LoginStep.secondaryPasscode + vcodeToken / passcodeHint / recoveryEmail 字段 - `login_view_model.dart`:verifyAndLogin 捕获 SecondaryPasscodeRequiredException 跳转步骤 3;新增 verifyPasscode() - `login_secondary_passcode_step.dart`(新增):密码输入 UI(hint 显示、 obscured 输入框、错误提示、忘记密码占位) - `login_page.dart`:switch 路由接入 LoginStep.secondaryPasscode
186 lines
5.9 KiB
Dart
186 lines
5.9 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:networks_sdk/networks_sdk.dart';
|
||
import 'package:storage_sdk/storage_sdk.dart';
|
||
|
||
import 'package:im_app/core/services/socket_manager.dart';
|
||
import 'package:im_app/domain/entities/user.dart';
|
||
import 'package:im_app/domain/repositories/auth_repository.dart';
|
||
import 'package:im_app/domain/repositories/user_repository.dart';
|
||
|
||
/// 登录用例
|
||
///
|
||
/// 封装登录的完整业务流程:
|
||
/// - sendOtp:格式校验 → 发短信
|
||
/// - verifyAndLogin:格式校验 → 校验验证码 → 登录 → 初始化 WebSocket → 打开本地数据库
|
||
///
|
||
/// ## 为什么需要 UseCase?
|
||
///
|
||
/// 登录有明确的多步业务规则,UseCase 把这些规则集中封装,
|
||
/// ViewModel 只需一行调用。
|
||
///
|
||
/// ## 数据流位置
|
||
///
|
||
/// ```
|
||
/// LoginViewModel.sendOtp(countryCode, contact)
|
||
/// → ★ LoginUseCase.sendOtp() ★ ← 你在这里(步骤 1)
|
||
/// → 格式校验(手机号)
|
||
/// → AuthRepository.sendOtp()
|
||
///
|
||
/// LoginViewModel.verifyAndLogin(code)
|
||
/// → ★ LoginUseCase.verifyAndLogin() ★ ← 你在这里(步骤 2+3)
|
||
/// → 格式校验(验证码)
|
||
/// → AuthRepository.verifyOtp() → vcode_token
|
||
/// → AuthRepository.login() → User + token
|
||
/// → SocketManager.connect(token)
|
||
/// → StorageSdkApi.openDatabase(uid)
|
||
/// → UserRepository.insertOrReplaceUser(user)
|
||
/// ← User
|
||
/// ```
|
||
class LoginUseCase {
|
||
final AuthRepository _authRepository;
|
||
final SocketManager _socketManager;
|
||
final ApiConfig _apiConfig;
|
||
final StorageSdkApi _storageApi;
|
||
final UserRepository _userRepository;
|
||
|
||
StorageSdkLifecycle get _storageLifeCycle =>
|
||
_storageApi as StorageSdkLifecycle;
|
||
|
||
LoginUseCase({
|
||
required AuthRepository authRepository,
|
||
required SocketManager socketManager,
|
||
required ApiConfig apiConfig,
|
||
required StorageSdkApi storageApi,
|
||
required UserRepository userRepository,
|
||
}) : _authRepository = authRepository,
|
||
_socketManager = socketManager,
|
||
_apiConfig = apiConfig,
|
||
_storageApi = storageApi,
|
||
_userRepository = userRepository;
|
||
|
||
/// 步骤 1:发送手机验证码
|
||
///
|
||
/// 抛出:
|
||
/// - [FormatException] — 手机号格式不合法
|
||
/// - [ApiError] — 网络/服务端错误
|
||
Future<void> sendOtp({
|
||
required String countryCode,
|
||
required String contact,
|
||
}) async {
|
||
_validatePhone(contact);
|
||
await _authRepository.sendOtp(countryCode: countryCode, contact: contact);
|
||
}
|
||
|
||
/// 步骤 2+3:校验验证码并完成登录,返回 [User]
|
||
///
|
||
/// 内部串行:verifyOtp → login → connectWebSocket → openDatabase → saveUser
|
||
///
|
||
/// 抛出:
|
||
/// - [FormatException] — 验证码格式不合法
|
||
/// - [ApiError] — 网络/服务端错误
|
||
Future<User> verifyAndLogin({
|
||
required String countryCode,
|
||
required String contact,
|
||
required String code,
|
||
}) async {
|
||
_validateCode(code);
|
||
|
||
// 校验验证码,换取 vcode_token
|
||
final vcodeToken = await _authRepository.verifyOtp(
|
||
countryCode: countryCode,
|
||
contact: contact,
|
||
code: code,
|
||
);
|
||
|
||
// 用 vcode_token 登录(token 写入由 Repository._onTokenUpdate 回调处理)
|
||
final user = await _authRepository.login(
|
||
countryCode: countryCode,
|
||
contact: contact,
|
||
vcodeToken: vcodeToken,
|
||
);
|
||
|
||
// 连接 WebSocket(token 已由 Repository 写入 ApiConfig,直接读取)
|
||
final token = _apiConfig.token;
|
||
if (token != null && token.isNotEmpty) {
|
||
await _socketManager.connect(token: token);
|
||
}
|
||
|
||
// 按用户 uid 打开本地数据库
|
||
await _storageLifeCycle.openDatabase(user.uid);
|
||
|
||
// 持久化登录用户信息
|
||
await _userRepository.insertOrReplaceUser(user);
|
||
|
||
// TODO: 扩展点 — 同步联系人列表、注册推送 token
|
||
|
||
return user;
|
||
}
|
||
|
||
/// 步骤 2b:用户输入二级密码后完成登录
|
||
///
|
||
/// 在 [verifyAndLogin] 抛出 [SecondaryPasscodeRequiredException] 后调用:
|
||
/// - MD5 哈希用户输入的密码
|
||
/// - 调 [AuthRepository.loginWithPasscode]
|
||
/// - 初始化 WebSocket / 数据库(与 [verifyAndLogin] 后半段一致)
|
||
///
|
||
/// 抛出:
|
||
/// - [FormatException] — 密码为空
|
||
/// - [ApiError] — 网络/服务端错误(密码错误 → 服务端返回错误码)
|
||
Future<User> loginWithSecondaryPasscode({
|
||
required String countryCode,
|
||
required String contact,
|
||
required String vcodeToken,
|
||
required String passcode,
|
||
}) async {
|
||
if (passcode.trim().isEmpty) {
|
||
throw const FormatException('密码不能为空');
|
||
}
|
||
|
||
// MD5 哈希(对齐旧版 makeMD5 逻辑)
|
||
final bytes = utf8.encode(passcode);
|
||
final passwordMd5 = md5.convert(bytes).toString();
|
||
|
||
final user = await _authRepository.loginWithPasscode(
|
||
countryCode: countryCode,
|
||
contact: contact,
|
||
vcodeToken: vcodeToken,
|
||
passwordMd5: passwordMd5,
|
||
);
|
||
|
||
final token = _apiConfig.token;
|
||
if (token != null && token.isNotEmpty) {
|
||
await _socketManager.connect(token: token);
|
||
}
|
||
|
||
await _storageLifeCycle.openDatabase(user.uid);
|
||
await _userRepository.insertOrReplaceUser(user);
|
||
|
||
return user;
|
||
}
|
||
|
||
void _validatePhone(String contact) {
|
||
final trimmed = contact.trim();
|
||
if (trimmed.isEmpty) {
|
||
throw const FormatException('手机号不能为空'); // TODO: 接入国际化
|
||
}
|
||
if (trimmed.length < 7 || trimmed.length > 15) {
|
||
throw const FormatException('手机号长度不正确'); // TODO: 接入国际化
|
||
}
|
||
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
|
||
throw const FormatException('手机号只能包含数字'); // TODO: 接入国际化
|
||
}
|
||
}
|
||
|
||
void _validateCode(String code) {
|
||
final trimmed = code.trim();
|
||
if (trimmed.isEmpty) {
|
||
throw const FormatException('验证码不能为空'); // TODO: 接入国际化
|
||
}
|
||
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
|
||
throw const FormatException('验证码只能包含数字'); // TODO: l10n
|
||
}
|
||
}
|
||
}
|