feat(kyc): 优化手机号验证/更换流程

- 将入口重命名为"验证/更换手机号"
- 验证旧手机成功后更新 phoneVerified 状态
- 首次验证成功时显示恭喜弹窗,用户可选择完成或继续更换
- 已验证过的用户验证通过后直接进入下一步

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-24 07:50:54 -08:00
parent 0a4ec49c8a
commit 934323e4c6
4 changed files with 170 additions and 11 deletions

View File

@ -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 };
}
/**

View File

@ -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<String, dynamic> json) {
return VerifyOldPhoneResponse(
changePhoneToken: json['changePhoneToken'] as String,
wasAlreadyVerified: json['wasAlreadyVerified'] as bool? ?? true,
);
}
}
/// KYC -
/// 1: (: +)
/// 2: ()
@ -657,7 +675,8 @@ class KycService {
}
///
Future<String> verifyOldPhoneCode(String smsCode) async {
/// changePhoneToken wasAlreadyVerified
Future<VerifyOldPhoneResponse> verifyOldPhoneCode(String smsCode) async {
debugPrint('$_tag verifyOldPhoneCode() - 验证旧手机验证码');
try {
@ -673,7 +692,7 @@ class KycService {
final responseData = response.data as Map<String, dynamic>;
final data = responseData['data'] as Map<String, dynamic>;
return data['changePhoneToken'] as String;
return VerifyOldPhoneResponse.fromJson(data);
} on ApiException {
rethrow;
} catch (e) {

View File

@ -142,13 +142,24 @@ class _ChangePhonePageState extends ConsumerState<ChangePhonePage> {
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<ChangePhonePage> {
}
}
///
Future<void> _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<void> _sendNewPhoneCode() async {
final newPhone = _newPhoneController.text.trim();
@ -277,7 +396,7 @@ class _ChangePhonePageState extends ConsumerState<ChangePhonePage> {
onPressed: () => context.pop(),
),
title: Text(
'更换手机号',
'验证/更换手机号',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,

View File

@ -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),
),