Files
customer-im-client-dev/apps/im_app/lib/features/login/usecases/login_usecase.dart
pp-bot b8f1f82ee5
Some checks failed
CI / Lint (push) Has been cancelled
feat(login): 二级密码登录支持(STATUS_SECONDARY_PASSCODE_ERROR #1)
## 问题
旧版 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
2026-03-31 15:36:54 +09:00

186 lines
5.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
// 连接 WebSockettoken 已由 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
}
}
}