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:
parent
0a4ec49c8a
commit
934323e4c6
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue