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
214 lines
7.2 KiB
Dart
214 lines
7.2 KiB
Dart
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;
|
||
}
|
||
}
|