Files
customer-im-client-dev/apps/im_app/lib/data/remote/login_request.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

214 lines
7.2 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 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/foundation/api_paths.dart';
import 'package:im_app/domain/entities/user.dart';
part 'login_request.g.dart';
/// # /app/api/auth/login-user — 使用 vcode_token 完成登录
///
/// 流程:发送验证码([SendOtpRequest])→ 校验验证码([VerifyOtpRequest]
/// → ★ 用 vcode_token 登录(本请求)★ → 获得 access_token
///
/// ## 数据流位置
///
/// ```
/// AuthRepositoryImpl.login(countryCode, contact, vcodeToken)
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
/// → 服务端 POST /app/api/auth/login-user
/// → SDK 拆包 {code, message, data} envelope
/// → ★ LoginResponse ★ ← 也在这里
/// → LoginResponse.toEntity() → User
/// ```
// ─────────────────────────────────────────────
// Response DTO
// ─────────────────────────────────────────────
/// 登录响应中的用户档案,嵌套在 [LoginResponse.profile] 中。
///
/// 纯 Dart 类,无需任何注解。`_$LoginProfileFromJson` 由生成器从 `@ApiRequest` 声明中自动推导生成。
class LoginProfile {
final int uid;
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;
@JsonKey(name: 'recovery_email')
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) => LoginProfile(
uid: (json['uid'] as num?)?.toInt() ?? 0,
uuid: json['uuid'] as String? ?? '',
lastOnline: (json['last_online'] as num?)?.toInt() ?? 0,
profilePic: json['profile_pic'] as String? ?? '',
profilePicGaussian: json['profile_pic_gaussian'] as String? ?? '',
nickname: json['nickname'] as String? ?? '',
contact: json['contact'] as String? ?? '',
countryCode: json['country_code'] as String? ?? '',
email: json['email'] as String? ?? '',
recoveryEmail: json['recovery_email'] as String? ?? '',
username: json['username'] as String? ?? '',
bio: json['bio'] as String? ?? '',
relationship: (json['relationship'] as num?)?.toInt() ?? 0,
userAlias: json['user_alias'] as String?,
channelId: (json['channel_id'] as num?)?.toInt() ?? 0,
channelGroupId: (json['channel_group_id'] as num?)?.toInt() ?? 0,
hint: json['hint'] as String? ?? '',
);
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,
hint: hint,
);
}
/// 登录接口的业务响应数据(对应服务端 `data` 字段,即 T in `APIResponseWrapper<T>`)。
///
/// `{ code, message }` 由 SDK 内部的 `ApiResponseWrapper` 统一处理,
/// App 层只接触此类,不感知 envelope 结构。纯 Dart 类,无需任何注解。
class LoginResponse {
@JsonKey(name: 'account_id')
final String accountId;
final LoginProfile profile;
@JsonKey(name: 'access_token')
final String accessToken;
@JsonKey(name: 'refresh_token')
final String refreshToken;
@JsonKey(name: 'device_id')
final String deviceId;
final String nonce;
@JsonKey(name: 'login_data')
final String loginData;
@JsonKey(name: 'is_verified')
final bool? isVerified;
const LoginResponse({
required this.accountId,
required this.profile,
required this.accessToken,
required this.refreshToken,
required this.deviceId,
this.nonce = '',
this.loginData = '',
this.isVerified,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) => LoginResponse(
accountId: json['account_id'] as String? ?? '',
profile: LoginProfile.fromJson(json['profile'] as Map<String, dynamic>),
accessToken: json['access_token'] as String? ?? '',
refreshToken: json['refresh_token'] as String? ?? '',
deviceId: json['device_id'] as String? ?? '',
nonce: json['nonce'] as String? ?? '',
loginData: json['login_data'] as String? ?? '',
isVerified: json['is_verified'] as bool?,
);
User toEntity() => profile.toEntity();
}
// ─────────────────────────────────────────────
// Request
// ─────────────────────────────────────────────
/// 使用 vcode_token 完成登录的请求
///
/// 上游:[VerifyOtpRequest] 返回的 `token` 即 vcodeToken。
/// 成功后 [LoginResponse.accessToken] 写入 ApiConfig后续请求自动携带。
@ApiRequest(
path: ApiPaths.authLogin,
method: HttpMethod.post,
responseType: LoginResponse,
requestType: ApiRequestType.login,
)
class LoginRequest extends ApiRequestable<LoginResponse>
with _$LoginRequestApi {
@JsonKey(name: 'country_code')
final String countryCode;
final String contact;
@JsonKey(name: 'vcode_token')
final String vcodeToken;
/// 二级密码MD5 哈希后的值)。
/// 账号未设置二级密码时传 null字段不序列化到请求体。
/// 对齐旧版:`accountLogin({String? password})` → 有值才加入 dataBody
@JsonKey(name: 'password', includeToJson: false) // 由下方 toJson() 手动控制
final String? password;
LoginRequest({
required this.countryCode,
required this.contact,
required this.vcodeToken,
this.password,
});
/// 手动 override toJson() 以支持 password 条件序列化。
/// 类的 override 优先于 mixin_$LoginRequestApi.toJson
/// 即使 build_runner 重新生成 .g.dart 也不影响此行为。
@override
Map<String, dynamic> toJson() {
final map = <String, dynamic>{
'country_code': countryCode,
'contact': contact,
'vcode_token': vcodeToken,
};
if (password != null) {
map['password'] = password;
}
return map;
}
}