diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index 16f574e2..1369018c 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -1060,13 +1060,17 @@ export class UserApplicationService { /** * 验证旧手机验证码(更换手机号第二步) * 返回临时令牌用于后续操作 + * 同时更新 phoneVerified 状态(首次验证时) */ - async verifyOldPhoneCode(userId: string, smsCode: string): Promise<{ changePhoneToken: string }> { + async verifyOldPhoneCode(userId: string, smsCode: string): Promise<{ + changePhoneToken: string; + wasAlreadyVerified: boolean; + }> { this.logger.log(`[CHANGE_PHONE] Verifying old phone code for user: ${userId}`); const account = await this.prisma.userAccount.findUnique({ where: { userId: BigInt(userId) }, - select: { phoneNumber: true }, + select: { phoneNumber: true, phoneVerified: true }, }); if (!account?.phoneNumber) { @@ -1087,13 +1091,28 @@ export class UserApplicationService { // 删除已使用的验证码 await this.redisService.delete(cacheKey); + // 记录之前是否已验证过 + const wasAlreadyVerified = account.phoneVerified; + + // 如果手机号之前未验证,更新为已验证状态 + if (!account.phoneVerified) { + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { + phoneVerified: true, + phoneVerifiedAt: new Date(), + }, + }); + this.logger.log(`[CHANGE_PHONE] Phone marked as verified for user: ${userId}`); + } + // 生成临时令牌(10分钟有效) const changePhoneToken = `CPT_${userId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; await this.redisService.set(`change_phone_token:${userId}`, changePhoneToken, 600); this.logger.log(`[CHANGE_PHONE] Old phone verified for user: ${userId}`); - return { changePhoneToken }; + return { changePhoneToken, wasAlreadyVerified }; } /** diff --git a/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart b/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart index f9e732f9..c7f380eb 100644 --- a/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart +++ b/frontend/mobile-app/lib/features/kyc/data/kyc_service.dart @@ -376,6 +376,24 @@ class PhoneStatusResponse { } } +/// 验证旧手机响应 +class VerifyOldPhoneResponse { + final String changePhoneToken; + final bool wasAlreadyVerified; + + VerifyOldPhoneResponse({ + required this.changePhoneToken, + required this.wasAlreadyVerified, + }); + + factory VerifyOldPhoneResponse.fromJson(Map json) { + return VerifyOldPhoneResponse( + changePhoneToken: json['changePhoneToken'] as String, + wasAlreadyVerified: json['wasAlreadyVerified'] as bool? ?? true, + ); + } +} + /// KYC 服务 - 支持三层认证 /// 层级1: 实名认证 (二要素: 姓名+身份证号) /// 层级2: 实人认证 (人脸活体检测) @@ -657,7 +675,8 @@ class KycService { } /// 验证旧手机验证码 - Future verifyOldPhoneCode(String smsCode) async { + /// 返回包含 changePhoneToken 和 wasAlreadyVerified 的响应 + Future verifyOldPhoneCode(String smsCode) async { debugPrint('$_tag verifyOldPhoneCode() - 验证旧手机验证码'); try { @@ -673,7 +692,7 @@ class KycService { final responseData = response.data as Map; final data = responseData['data'] as Map; - return data['changePhoneToken'] as String; + return VerifyOldPhoneResponse.fromJson(data); } on ApiException { rethrow; } catch (e) { diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart index 98651b98..ae7a5677 100644 --- a/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/change_phone_page.dart @@ -142,13 +142,24 @@ class _ChangePhonePageState extends ConsumerState { try { final kycService = ref.read(kycServiceProvider); - final token = await kycService.verifyOldPhoneCode(code); + final response = await kycService.verifyOldPhoneCode(code); setState(() { - _changePhoneToken = token; - _currentStep = ChangePhoneStep.inputNew; + _changePhoneToken = response.changePhoneToken; _countdown = 0; }); + + // 如果之前手机号未验证过,现在首次验证成功,显示恭喜弹窗 + if (!response.wasAlreadyVerified && mounted) { + await _showCongratulationsDialog(); + } + + // 进入下一步 + if (mounted) { + setState(() { + _currentStep = ChangePhoneStep.inputNew; + }); + } } catch (e) { setState(() { _errorMessage = e.toString().replaceAll('Exception: ', ''); @@ -165,6 +176,114 @@ class _ChangePhonePageState extends ConsumerState { } } + /// 显示恭喜弹窗(首次验证手机号成功) + Future _showCongratulationsDialog() async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.r), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 16.h), + Container( + width: 64.w, + height: 64.w, + decoration: const BoxDecoration( + color: Color(0xFFE8F5E9), + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle, + color: const Color(0xFF2E7D32), + size: 40.sp, + ), + ), + SizedBox(height: 16.h), + Text( + '恭喜您!', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + ), + SizedBox(height: 8.h), + Text( + '您的手机号已验证成功', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF666666), + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 8.h), + Text( + '您可以继续更换新手机号,或点击返回完成验证', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF999999), + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24.h), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); // 关闭弹窗 + this.context.pop(); // 返回上一页 + }, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 12.h), + side: const BorderSide(color: Color(0xFF2E7D32)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + child: Text( + '完成', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF2E7D32), + ), + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); // 关闭弹窗,继续更换手机号流程 + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2E7D32), + padding: EdgeInsets.symmetric(vertical: 12.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + child: Text( + '继续更换', + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + /// 发送新手机验证码 Future _sendNewPhoneCode() async { final newPhone = _newPhoneController.text.trim(); @@ -277,7 +396,7 @@ class _ChangePhonePageState extends ConsumerState { onPressed: () => context.pop(), ), title: Text( - '更换手机号', + '验证/更换手机号', style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, diff --git a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart index 116ac339..16457dfd 100644 --- a/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart +++ b/frontend/mobile-app/lib/features/kyc/presentation/pages/kyc_entry_page.dart @@ -122,8 +122,10 @@ class KycEntryPage extends ConsumerWidget { _buildActionCard( context: context, icon: Icons.phone_android, - title: '更换手机号', - description: status.phoneNumber ?? '更换绑定的手机号', + title: '验证/更换手机号', + description: status.phoneVerified + ? (status.phoneNumber ?? '更换绑定的手机号') + : '手机号未验证,点击验证', onTap: () => context.push(RoutePaths.changePhone), ),