diff --git a/backend/services/identity-service/src/api/controllers/user-account.controller.ts b/backend/services/identity-service/src/api/controllers/user-account.controller.ts index 0136c59e..f5aa8123 100644 --- a/backend/services/identity-service/src/api/controllers/user-account.controller.ts +++ b/backend/services/identity-service/src/api/controllers/user-account.controller.ts @@ -30,6 +30,7 @@ import { } from '@/shared/guards/jwt-auth.guard'; import { AutoCreateAccountCommand, + RegisterByPhoneCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand, AutoLoginCommand, @@ -50,6 +51,7 @@ import { } from '@/application/commands'; import { AutoCreateAccountDto, + RegisterByPhoneDto, RecoverByMnemonicDto, RecoverByPhoneDto, AutoLoginDto, @@ -102,6 +104,26 @@ export class UserAccountController { ); } + @Public() + @Post('register-by-phone') + @ApiOperation({ + summary: '手机号注册(验证码+密码一步完成)', + description: '验证短信验证码,创建账户,绑定手机号,设置密码,触发钱包生成', + }) + @ApiResponse({ status: 200, type: AutoCreateAccountResponseDto }) + async registerByPhone(@Body() dto: RegisterByPhoneDto) { + return this.userService.registerByPhone( + new RegisterByPhoneCommand( + dto.phoneNumber, + dto.smsCode, + dto.password, + dto.deviceId, + dto.deviceName, + dto.inviterReferralCode, + ), + ); + } + @Public() @Post('recover-by-mnemonic') @ApiOperation({ summary: '用序列号+助记词恢复账户' }) diff --git a/backend/services/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/src/api/dto/request/index.ts index cc51df4e..d7bc33e8 100644 --- a/backend/services/identity-service/src/api/dto/request/index.ts +++ b/backend/services/identity-service/src/api/dto/request/index.ts @@ -1,4 +1,5 @@ export * from './auto-create-account.dto'; +export * from './register-by-phone.dto'; export * from './recover-by-mnemonic.dto'; export * from './recover-by-phone.dto'; export * from './bind-phone.dto'; diff --git a/backend/services/identity-service/src/api/dto/request/register-by-phone.dto.ts b/backend/services/identity-service/src/api/dto/request/register-by-phone.dto.ts new file mode 100644 index 00000000..1b149b3d --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/register-by-phone.dto.ts @@ -0,0 +1,65 @@ +import { + IsString, + IsOptional, + IsNotEmpty, + Matches, + IsObject, + Length, + MinLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DeviceNameDto } from './auto-create-account.dto'; + +export class RegisterByPhoneDto { + @ApiProperty({ + example: '13800138000', + description: '手机号', + }) + @IsString() + @IsNotEmpty() + @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式错误' }) + phoneNumber: string; + + @ApiProperty({ + example: '123456', + description: '短信验证码 (6位数字)', + }) + @IsString() + @IsNotEmpty() + @Length(6, 6, { message: '验证码必须是6位' }) + smsCode: string; + + @ApiProperty({ + example: 'Password123', + description: '登录密码 (6-20位)', + }) + @IsString() + @IsNotEmpty() + @MinLength(6, { message: '密码至少6位' }) + password: string; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: '设备唯一标识', + }) + @IsString() + @IsNotEmpty() + deviceId: string; + + @ApiPropertyOptional({ + description: '设备信息 (JSON 对象)', + example: { model: 'iPhone 15 Pro', platform: 'ios', osVersion: '17.2' }, + }) + @IsOptional() + @IsObject() + deviceName?: DeviceNameDto; + + @ApiPropertyOptional({ + example: 'RWAABC1234', + description: '邀请人推荐码 (6-20位大写字母和数字)', + }) + @IsOptional() + @IsString() + @Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' }) + inviterReferralCode?: string; +} diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index bab47b1c..43805157 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -16,6 +16,17 @@ export class AutoCreateAccountCommand { ) {} } +export class RegisterByPhoneCommand { + constructor( + public readonly phoneNumber: string, + public readonly smsCode: string, + public readonly password: string, + public readonly deviceId: string, + public readonly deviceName?: DeviceNameInput, + public readonly inviterReferralCode?: string, + ) {} +} + export class RecoverByMnemonicCommand { constructor( public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号 diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 4744bbed..2eeb6e13 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -1477,11 +1477,12 @@ class AccountService { } } - /// 手机号注册 + /// 手机号注册 (旧接口,不推荐使用) /// /// [phoneNumber] - 手机号 /// [smsCode] - 6位短信验证码 /// [inviterReferralCode] - 邀请人推荐码(可选) + @Deprecated('使用 registerByPhoneWithPassword 替代') Future registerByPhone({ required String phoneNumber, required String smsCode, @@ -1543,6 +1544,79 @@ class AccountService { } } + /// 手机号注册 (新接口:验证码+密码一步完成) + /// + /// [phoneNumber] - 手机号 + /// [smsCode] - 6位短信验证码 + /// [password] - 登录密码 + /// [inviterReferralCode] - 邀请人推荐码(可选) + Future registerByPhoneWithPassword({ + required String phoneNumber, + required String smsCode, + required String password, + String? inviterReferralCode, + }) async { + debugPrint('$_tag registerByPhoneWithPassword() - 开始手机号注册'); + debugPrint('$_tag registerByPhoneWithPassword() - 手机号: ${_maskPhoneNumber(phoneNumber)}'); + debugPrint('$_tag registerByPhoneWithPassword() - 邀请码: ${inviterReferralCode ?? "无"}'); + + try { + // 获取设备ID + final deviceId = await getDeviceId(); + debugPrint('$_tag registerByPhoneWithPassword() - 获取设备ID成功'); + + // 获取设备硬件信息 + final deviceInfo = await getDeviceHardwareInfo(); + debugPrint('$_tag registerByPhoneWithPassword() - 获取设备硬件信息成功'); + + // 调用 API + debugPrint('$_tag registerByPhoneWithPassword() - 调用 POST /user/register-by-phone'); + final response = await _apiClient.post( + '/user/register-by-phone', + data: { + 'phoneNumber': phoneNumber, + 'smsCode': smsCode, + 'password': password, + 'deviceId': deviceId, + 'deviceName': deviceInfo.toJson(), + if (inviterReferralCode != null) 'inviterReferralCode': inviterReferralCode, + }, + ); + debugPrint('$_tag registerByPhoneWithPassword() - API 响应状态码: ${response.statusCode}'); + + if (response.data == null) { + debugPrint('$_tag registerByPhoneWithPassword() - 错误: API 返回空响应'); + throw const ApiException('注册失败: 空响应'); + } + + debugPrint('$_tag registerByPhoneWithPassword() - 解析响应数据'); + final responseData = response.data as Map; + final data = responseData['data'] as Map; + final result = CreateAccountResponse.fromJson(data); + debugPrint('$_tag registerByPhoneWithPassword() - 解析成功: $result'); + + // 保存账号数据 + debugPrint('$_tag registerByPhoneWithPassword() - 保存账号数据'); + await _saveAccountData(result, deviceId); + + // 保存手机号 + await _secureStorage.write(key: StorageKeys.phoneNumber, value: phoneNumber); + + // 标记密码已设置 + await _secureStorage.write(key: StorageKeys.isPasswordSet, value: 'true'); + + debugPrint('$_tag registerByPhoneWithPassword() - 手机号注册完成'); + return result; + } on ApiException catch (e) { + debugPrint('$_tag registerByPhoneWithPassword() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag registerByPhoneWithPassword() - 未知异常: $e'); + debugPrint('$_tag registerByPhoneWithPassword() - 堆栈: $stackTrace'); + throw ApiException('注册失败: $e'); + } + } + /// 手机号登录 /// /// [phoneNumber] - 手机号 diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart index 931f8b08..11c775ab 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/set_password_page.dart @@ -3,29 +3,38 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/multi_account_service.dart'; import '../../../../routes/route_paths.dart'; /// 设置登录密码页面参数 class SetPasswordParams { - final String userSerialNum; + final String? userSerialNum; // 如果有则表示已创建账号(旧流程),无则表示需要创建账号(新流程) final String? inviterReferralCode; + final String? phoneNumber; // 新流程需要 + final String? smsCode; // 新流程需要 SetPasswordParams({ - required this.userSerialNum, + this.userSerialNum, this.inviterReferralCode, + this.phoneNumber, + this.smsCode, }); } /// 设置登录密码页面 /// 用户完成短信验证后,设置登录密码 class SetPasswordPage extends ConsumerStatefulWidget { - final String userSerialNum; + final String? userSerialNum; // 旧流程:已创建账号 final String? inviterReferralCode; + final String? phoneNumber; // 新流程:手机号 + final String? smsCode; // 新流程:验证码 const SetPasswordPage({ super.key, - required this.userSerialNum, + this.userSerialNum, this.inviterReferralCode, + this.phoneNumber, + this.smsCode, }); @override @@ -45,7 +54,7 @@ class _SetPasswordPageState extends ConsumerState { @override void initState() { super.initState(); - debugPrint('[SetPasswordPage] initState - userSerialNum: ${widget.userSerialNum}'); + debugPrint('[SetPasswordPage] initState - userSerialNum: ${widget.userSerialNum}, phoneNumber: ${widget.phoneNumber != null ? "***" : "null"}'); } @override @@ -99,12 +108,40 @@ class _SetPasswordPageState extends ConsumerState { final accountService = ref.read(accountServiceProvider); final password = _passwordController.text; - debugPrint('[SetPasswordPage] 开始设置密码...'); + // 判断是新流程还是旧流程 + if (widget.phoneNumber != null && widget.smsCode != null) { + // 新流程:使用 register-by-phone API 一步完成注册 + debugPrint('[SetPasswordPage] 使用新流程: register-by-phone'); - // 调用设置密码 API - await accountService.setLoginPassword(password); + final response = await accountService.registerByPhoneWithPassword( + phoneNumber: widget.phoneNumber!, + smsCode: widget.smsCode!, + password: password, + inviterReferralCode: widget.inviterReferralCode, + ); - debugPrint('[SetPasswordPage] 密码设置成功'); + debugPrint('[SetPasswordPage] 注册成功: ${response.userSerialNum}'); + + if (!mounted) return; + + // 将账号添加到多账号列表 + final multiAccountService = ref.read(multiAccountServiceProvider); + await multiAccountService.addAccount( + AccountSummary( + userSerialNum: response.userSerialNum, + username: response.username, + avatarSvg: response.avatarSvg, + createdAt: DateTime.now(), + ), + ); + await multiAccountService.setCurrentAccountId(response.userSerialNum); + debugPrint('[SetPasswordPage] 已添加到多账号列表'); + } else { + // 旧流程:单独设置密码 + debugPrint('[SetPasswordPage] 使用旧流程: set-password'); + await accountService.setLoginPassword(password); + debugPrint('[SetPasswordPage] 密码设置成功'); + } if (!mounted) return; diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart index d0f2f0b4..ea62fed7 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/sms_verify_page.dart @@ -6,7 +6,6 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/services/account_service.dart'; -import '../../../../core/services/multi_account_service.dart'; import '../../../../routes/route_paths.dart'; import 'set_password_page.dart'; @@ -153,49 +152,21 @@ class _SmsVerifyPageState extends ConsumerState { final accountService = ref.read(accountServiceProvider); if (widget.type == SmsCodeType.register) { - // 注册流程:先验证短信验证码,然后调用 auto-create 创建账号 - debugPrint('[SmsVerifyPage] 开始验证短信验证码...'); - - // 验证短信验证码 - await accountService.verifySmsCode( - phoneNumber: widget.phoneNumber, - smsCode: code, - type: SmsCodeType.register, - ); - debugPrint('[SmsVerifyPage] 短信验证码验证成功'); - - // 调用 auto-create 创建账号 - debugPrint('[SmsVerifyPage] 开始调用 auto-create 创建账号...'); - final response = await accountService.createAccount( - inviterReferralCode: widget.inviterReferralCode, - ); - debugPrint('[SmsVerifyPage] 账号创建成功: ${response.userSerialNum}'); + // 注册流程:直接跳转到设置密码页面,由密码页面一起调用 register-by-phone API + debugPrint('[SmsVerifyPage] 验证码输入完成,跳转到设置密码页面'); if (!mounted) return; - // 将账号添加到多账号列表 - final multiAccountService = ref.read(multiAccountServiceProvider); - await multiAccountService.addAccount( - AccountSummary( - userSerialNum: response.userSerialNum, - username: response.username, - avatarSvg: response.avatarSvg, - createdAt: DateTime.now(), - ), - ); - await multiAccountService.setCurrentAccountId(response.userSerialNum); - debugPrint('[SmsVerifyPage] 已添加到多账号列表'); - - if (!mounted) return; - - // 跳转到设置密码页面 + // 跳转到设置密码页面,传递手机号和验证码 context.go( RoutePaths.setPassword, extra: SetPasswordParams( - userSerialNum: response.userSerialNum, + phoneNumber: widget.phoneNumber, + smsCode: code, inviterReferralCode: widget.inviterReferralCode, ), ); + return; // 提前返回,不需要等待 } else if (widget.type == SmsCodeType.login) { // 登录 debugPrint('[SmsVerifyPage] 开始登录...');