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}`);
|
this.logger.log(`[CHANGE_PHONE] Verifying old phone code for user: ${userId}`);
|
||||||
|
|
||||||
const account = await this.prisma.userAccount.findUnique({
|
const account = await this.prisma.userAccount.findUnique({
|
||||||
where: { userId: BigInt(userId) },
|
where: { userId: BigInt(userId) },
|
||||||
select: { phoneNumber: true },
|
select: { phoneNumber: true, phoneVerified: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!account?.phoneNumber) {
|
if (!account?.phoneNumber) {
|
||||||
|
|
@ -1087,13 +1091,28 @@ export class UserApplicationService {
|
||||||
// 删除已使用的验证码
|
// 删除已使用的验证码
|
||||||
await this.redisService.delete(cacheKey);
|
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分钟有效)
|
// 生成临时令牌(10分钟有效)
|
||||||
const changePhoneToken = `CPT_${userId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
const changePhoneToken = `CPT_${userId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||||
await this.redisService.set(`change_phone_token:${userId}`, changePhoneToken, 600);
|
await this.redisService.set(`change_phone_token:${userId}`, changePhoneToken, 600);
|
||||||
|
|
||||||
this.logger.log(`[CHANGE_PHONE] Old phone verified for user: ${userId}`);
|
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 服务 - 支持三层认证
|
/// KYC 服务 - 支持三层认证
|
||||||
/// 层级1: 实名认证 (二要素: 姓名+身份证号)
|
/// 层级1: 实名认证 (二要素: 姓名+身份证号)
|
||||||
/// 层级2: 实人认证 (人脸活体检测)
|
/// 层级2: 实人认证 (人脸活体检测)
|
||||||
|
|
@ -657,7 +675,8 @@ class KycService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 验证旧手机验证码
|
/// 验证旧手机验证码
|
||||||
Future<String> verifyOldPhoneCode(String smsCode) async {
|
/// 返回包含 changePhoneToken 和 wasAlreadyVerified 的响应
|
||||||
|
Future<VerifyOldPhoneResponse> verifyOldPhoneCode(String smsCode) async {
|
||||||
debugPrint('$_tag verifyOldPhoneCode() - 验证旧手机验证码');
|
debugPrint('$_tag verifyOldPhoneCode() - 验证旧手机验证码');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -673,7 +692,7 @@ class KycService {
|
||||||
|
|
||||||
final responseData = response.data as Map<String, dynamic>;
|
final responseData = response.data as Map<String, dynamic>;
|
||||||
final data = responseData['data'] as Map<String, dynamic>;
|
final data = responseData['data'] as Map<String, dynamic>;
|
||||||
return data['changePhoneToken'] as String;
|
return VerifyOldPhoneResponse.fromJson(data);
|
||||||
} on ApiException {
|
} on ApiException {
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,24 @@ class _ChangePhonePageState extends ConsumerState<ChangePhonePage> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final kycService = ref.read(kycServiceProvider);
|
final kycService = ref.read(kycServiceProvider);
|
||||||
final token = await kycService.verifyOldPhoneCode(code);
|
final response = await kycService.verifyOldPhoneCode(code);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_changePhoneToken = token;
|
_changePhoneToken = response.changePhoneToken;
|
||||||
_currentStep = ChangePhoneStep.inputNew;
|
|
||||||
_countdown = 0;
|
_countdown = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 如果之前手机号未验证过,现在首次验证成功,显示恭喜弹窗
|
||||||
|
if (!response.wasAlreadyVerified && mounted) {
|
||||||
|
await _showCongratulationsDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入下一步
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentStep = ChangePhoneStep.inputNew;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_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 {
|
Future<void> _sendNewPhoneCode() async {
|
||||||
final newPhone = _newPhoneController.text.trim();
|
final newPhone = _newPhoneController.text.trim();
|
||||||
|
|
@ -277,7 +396,7 @@ class _ChangePhonePageState extends ConsumerState<ChangePhonePage> {
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'更换手机号',
|
'验证/更换手机号',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18.sp,
|
fontSize: 18.sp,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
|
||||||
|
|
@ -122,8 +122,10 @@ class KycEntryPage extends ConsumerWidget {
|
||||||
_buildActionCard(
|
_buildActionCard(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.phone_android,
|
icon: Icons.phone_android,
|
||||||
title: '更换手机号',
|
title: '验证/更换手机号',
|
||||||
description: status.phoneNumber ?? '更换绑定的手机号',
|
description: status.phoneVerified
|
||||||
|
? (status.phoneNumber ?? '更换绑定的手机号')
|
||||||
|
: '手机号未验证,点击验证',
|
||||||
onTap: () => context.push(RoutePaths.changePhone),
|
onTap: () => context.push(RoutePaths.changePhone),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue