From 6f912b12322d75e00b8016fb6a2ae746a229bf1f Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 5 Mar 2026 07:04:20 -0800 Subject: [PATCH] =?UTF-8?q?feat(payment-password):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BF=98=E8=AE=B0=E6=94=AF=E4=BB=98=E5=AF=86=E7=A0=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88=E5=85=A8=E6=A0=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端(identity-service,纯新增): - user-application.service 添加 2 个方法: sendResetPaymentPasswordSmsCode(userId) —— 发送验证码到已绑定手机(Redis key 独立) resetPaymentPassword(userId, smsCode, newPassword) —— 验证码校验 + 格式校验 + bcrypt 更新 - user-account.controller 新增 2 个端点(均为 @ApiBearerAuth): POST /user/send-reset-payment-password-sms POST /user/reset-payment-password 前端(mobile-app,纯新增): - account_service 新增 sendResetPaymentPasswordSmsCode / resetPaymentPassword 两个方法 - 新建 reset_payment_password_page.dart:验证码 + 新6位PIN,重置成功后自动返回 - 路由:RoutePaths / RouteNames / AppRouter 各新增 resetPaymentPassword 条目 - change_payment_password_page:旧密码输入框下方添加「忘记支付密码?」入口链接 Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/user-account.controller.ts | 46 ++ .../services/user-application.service.ts | 90 ++++ .../lib/core/services/account_service.dart | 46 ++ .../pages/change_payment_password_page.dart | 23 +- .../pages/reset_payment_password_page.dart | 495 ++++++++++++++++++ .../mobile-app/lib/routes/app_router.dart | 8 + .../mobile-app/lib/routes/route_names.dart | 3 +- .../mobile-app/lib/routes/route_paths.dart | 3 +- 8 files changed, 711 insertions(+), 3 deletions(-) create mode 100644 frontend/mobile-app/lib/features/security/presentation/pages/reset_payment_password_page.dart 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 432777f9..688576f2 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 @@ -827,6 +827,52 @@ export class UserAccountController { return { valid }; } + // ============ 找回支付密码相关 ============ + + /** + * 发送重置支付密码短信验证码 + * + * 向当前登录用户绑定的手机号发送验证码,用于忘记支付密码时的身份核验。 + * 接口需要 Bearer Token(只有已登录用户才能重置支付密码)。 + */ + @Post('send-reset-payment-password-sms') + @ApiBearerAuth() + @ApiOperation({ + summary: '发送重置支付密码短信验证码', + description: '向当前登录用户绑定的手机号发送验证码,用于忘记支付密码时验证身份', + }) + @ApiResponse({ status: 200, description: '验证码已发送' }) + async sendResetPaymentPasswordSms(@CurrentUser() user: CurrentUserData) { + await this.userService.sendResetPaymentPasswordSmsCode(user.userId); + return { message: '验证码已发送' }; + } + + /** + * 重置支付密码 + * + * 通过短信验证码验证身份后,将支付密码重置为新的 6 位数字密码。 + * 接口需要 Bearer Token(只有已登录用户才能重置支付密码)。 + */ + @Post('reset-payment-password') + @ApiBearerAuth() + @ApiOperation({ + summary: '重置支付密码', + description: '通过短信验证码验证身份后重置支付密码(用于忘记支付密码场景)', + }) + @ApiResponse({ status: 200, description: '支付密码重置成功' }) + @ApiResponse({ status: 400, description: '验证码错误或密码格式不正确' }) + async resetPaymentPassword( + @CurrentUser() user: CurrentUserData, + @Body() body: { smsCode: string; newPassword: string }, + ) { + await this.userService.resetPaymentPassword( + user.userId, + body.smsCode, + body.newPassword, + ); + return { message: '支付密码重置成功' }; + } + @Get('users/resolve-address/:accountSequence') @ApiBearerAuth() @ApiOperation({ 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 5fdefe4b..dd2ee596 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 @@ -2956,6 +2956,96 @@ export class UserApplicationService { return isValid; } + // ============ 找回支付密码相关 ============ + + /** + * 发送重置支付密码短信验证码(已登录用户) + * + * 向当前登录用户绑定的手机号发送验证码,用于忘记支付密码时的身份核验。 + * Redis key: sms:reset_payment_password:${phone},有效期 5 分钟。 + * + * @param userId 当前登录用户 ID(来自 JWT) + */ + async sendResetPaymentPasswordSmsCode(userId: string): Promise { + this.logger.log(`[RESET_PAYMENT_PASSWORD] Sending SMS code for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { phoneNumber: true, isActive: true }, + }); + + if (!user) throw new ApplicationError('用户不存在'); + if (!user.isActive) throw new ApplicationError('账户已冻结或注销'); + if (!user.phoneNumber) throw new ApplicationError('账户未绑定手机号'); + + const code = this.generateSmsCode(); + const cacheKey = `sms:reset_payment_password:${user.phoneNumber}`; + + // 先存储到 Redis,再发送短信(避免竞态条件) + await this.redisService.set(cacheKey, code, 300); // 5分钟有效 + await this.smsService.sendVerificationCode(user.phoneNumber, code); + + this.logger.log( + `[RESET_PAYMENT_PASSWORD] SMS code sent to: ${this.maskPhoneNumber(user.phoneNumber)}`, + ); + } + + /** + * 重置支付密码(已登录用户,通过短信验证码验证身份) + * + * 流程:验证码校验 → 格式校验 → bcrypt 哈希 → 更新数据库 → 删除已用验证码 + * + * @param userId 当前登录用户 ID(来自 JWT) + * @param smsCode 短信验证码 + * @param newPassword 新支付密码(必须为 6 位纯数字) + */ + async resetPaymentPassword( + userId: string, + smsCode: string, + newPassword: string, + ): Promise { + this.logger.log(`[RESET_PAYMENT_PASSWORD] Resetting payment password for user: ${userId}`); + + const user = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(userId) }, + select: { phoneNumber: true, isActive: true }, + }); + + if (!user) throw new ApplicationError('用户不存在'); + if (!user.isActive) throw new ApplicationError('账户已冻结或注销'); + if (!user.phoneNumber) throw new ApplicationError('账户未绑定手机号'); + + // 1. 验证短信验证码 + const cacheKey = `sms:reset_payment_password:${user.phoneNumber}`; + const cachedCode = await this.redisService.get(cacheKey); + + if (!cachedCode || cachedCode !== smsCode) { + this.logger.warn( + `[RESET_PAYMENT_PASSWORD] Invalid SMS code for user: ${userId}`, + ); + throw new ApplicationError('验证码错误或已过期'); + } + + // 2. 验证新密码格式(6位纯数字) + if (!/^\d{6}$/.test(newPassword)) { + throw new ApplicationError('支付密码必须为6位数字'); + } + + // 3. bcrypt 哈希新密码并更新数据库 + const bcrypt = await import('bcrypt'); + const hash = await bcrypt.hash(newPassword, 10); + + await this.prisma.userAccount.update({ + where: { userId: BigInt(userId) }, + data: { paymentPasswordHash: hash }, + }); + + // 4. 删除已使用的验证码,防止重复使用 + await this.redisService.delete(cacheKey); + + this.logger.log(`[RESET_PAYMENT_PASSWORD] Payment password reset successfully for user: ${userId}`); + } + /** * 遮蔽手机号 */ diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 50367a21..fbbd4115 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -2211,6 +2211,52 @@ class AccountService { } } + // ============ 找回支付密码相关 ============ [2026-03-05] + + /// 发送重置支付密码短信验证码 (POST /user/send-reset-payment-password-sms) + /// + /// 向当前登录用户绑定的手机号发送验证码,用于忘记支付密码时身份核验。 + /// 需要 Bearer Token(接口在登录态下调用)。 + Future sendResetPaymentPasswordSmsCode() async { + debugPrint('$_tag sendResetPaymentPasswordSmsCode() - 发送重置支付密码验证码'); + try { + await _apiClient.post('/user/send-reset-payment-password-sms'); + debugPrint('$_tag sendResetPaymentPasswordSmsCode() - 发送成功'); + } on ApiException catch (e) { + debugPrint('$_tag sendResetPaymentPasswordSmsCode() - API 异常: $e'); + rethrow; + } catch (e) { + debugPrint('$_tag sendResetPaymentPasswordSmsCode() - 未知异常: $e'); + throw ApiException('发送验证码失败: $e'); + } + } + + /// 重置支付密码 (POST /user/reset-payment-password) + /// + /// 通过短信验证码验证身份后,将支付密码重置为新的 6 位数字密码。 + /// + /// [smsCode] 短信验证码 + /// [newPassword] 新支付密码(6位纯数字) + Future resetPaymentPassword({ + required String smsCode, + required String newPassword, + }) async { + debugPrint('$_tag resetPaymentPassword() - 开始重置支付密码'); + try { + await _apiClient.post( + '/user/reset-payment-password', + data: {'smsCode': smsCode, 'newPassword': newPassword}, + ); + debugPrint('$_tag resetPaymentPassword() - 重置成功'); + } on ApiException catch (e) { + debugPrint('$_tag resetPaymentPassword() - API 异常: $e'); + rethrow; + } catch (e) { + debugPrint('$_tag resetPaymentPassword() - 未知异常: $e'); + throw ApiException('重置支付密码失败: $e'); + } + } + // ============ 邮箱绑定相关 ============ /// 获取邮箱绑定状态 diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/change_payment_password_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/change_payment_password_page.dart index 5ec834dd..182d72d0 100644 --- a/frontend/mobile-app/lib/features/security/presentation/pages/change_payment_password_page.dart +++ b/frontend/mobile-app/lib/features/security/presentation/pages/change_payment_password_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/di/injection_container.dart'; +import '../../../../routes/route_paths.dart'; /// 支付密码设置/修改页面 /// @@ -177,7 +178,27 @@ class _ChangePaymentPasswordPageState onToggleVisibility: () => setState( () => _showOldPassword = !_showOldPassword), ), - const SizedBox(height: 16), + // 忘记支付密码入口 [2026-03-05] + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () => context.push( + RoutePaths.resetPaymentPassword, + ), + child: const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '忘记支付密码?', + style: TextStyle( + fontSize: 13, + color: Color(0xFFD4AF37), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + const SizedBox(height: 8), ], _buildPasswordInput( controller: _newPasswordController, diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/reset_payment_password_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/reset_payment_password_page.dart new file mode 100644 index 00000000..0633d411 --- /dev/null +++ b/frontend/mobile-app/lib/features/security/presentation/pages/reset_payment_password_page.dart @@ -0,0 +1,495 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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'; + +/// 重置支付密码页面 [2026-03-05] +/// +/// 流程:发送短信验证码(发送到当前账号绑定手机)→ 输入验证码 → 设置新 6 位支付密码 +/// +/// 与 [ForgotPasswordPage](找回登录密码)的区别: +/// - 无需输入手机号(用户已登录,手机号由后端从 JWT 获取) +/// - 密码格式为 6 位纯数字(登录密码无此限制) +/// +/// 后端接口: +/// - POST /user/send-reset-payment-password-sms → 发送验证码 +/// - POST /user/reset-payment-password → { smsCode, newPassword } → 重置 +class ResetPaymentPasswordPage extends ConsumerStatefulWidget { + const ResetPaymentPasswordPage({super.key}); + + @override + ConsumerState createState() => + _ResetPaymentPasswordPageState(); +} + +class _ResetPaymentPasswordPageState + extends ConsumerState { + final TextEditingController _smsCodeController = TextEditingController(); + final TextEditingController _newPasswordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + bool _isSubmitting = false; + bool _isSendingSms = false; + int _countdown = 0; + Timer? _countdownTimer; + String? _errorMessage; + bool _obscureNewPassword = true; + bool _obscureConfirmPassword = true; + + @override + void dispose() { + _countdownTimer?.cancel(); + _smsCodeController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + /// 发送短信验证码(无需传手机号,后端从 JWT 取) + Future _sendSmsCode() async { + setState(() { + _isSendingSms = true; + _errorMessage = null; + }); + + try { + final accountService = ref.read(accountServiceProvider); + await accountService.sendResetPaymentPasswordSmsCode(); + + if (mounted) { + setState(() { + _isSendingSms = false; + _countdown = 60; + }); + _startCountdown(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('验证码已发送到您绑定的手机'), + backgroundColor: Color(0xFF4CAF50), + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _isSendingSms = false; + _errorMessage = '发送失败:$e'; + }); + } + } + } + + void _startCountdown() { + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + if (_countdown > 0) { + _countdown--; + } else { + timer.cancel(); + } + }); + }); + } + + /// 输入校验 + String? _validateInputs() { + final smsCode = _smsCodeController.text.trim(); + final newPassword = _newPasswordController.text; + final confirmPassword = _confirmPasswordController.text; + + if (smsCode.isEmpty) return '请输入短信验证码'; + if (smsCode.length != 6) return '验证码为6位数字'; + if (newPassword.isEmpty) return '请输入新支付密码'; + if (!RegExp(r'^\d{6}$').hasMatch(newPassword)) return '支付密码必须为6位纯数字'; + if (confirmPassword.isEmpty) return '请再次输入新支付密码'; + if (newPassword != confirmPassword) return '两次输入的密码不一致'; + return null; + } + + /// 提交重置 + Future _resetPassword() async { + final validationError = _validateInputs(); + if (validationError != null) { + setState(() => _errorMessage = validationError); + return; + } + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + final accountService = ref.read(accountServiceProvider); + await accountService.resetPaymentPassword( + smsCode: _smsCodeController.text.trim(), + newPassword: _newPasswordController.text, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('支付密码重置成功'), + backgroundColor: Color(0xFF4CAF50), + duration: Duration(seconds: 2), + ), + ); + // 重置成功后返回上一页 + context.pop(); + } + } catch (e) { + if (mounted) { + setState(() { + _isSubmitting = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + // ============================================================ + // Build + // ============================================================ + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFFF7E6), + appBar: _buildAppBar(), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInfoCard(), + SizedBox(height: 24.h), + _buildFormCard(), + if (_errorMessage != null) ...[ + SizedBox(height: 12.h), + _buildErrorMessage(), + ], + SizedBox(height: 32.h), + _buildSubmitButton(), + ], + ), + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: const Color(0xFFFFF7E6), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Color(0xFF5D4037)), + onPressed: () => context.pop(), + ), + title: const Text( + '忘记支付密码', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + centerTitle: true, + ); + } + + /// 说明卡片 + Widget _buildInfoCard() { + return Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFFD700).withValues(alpha: 0.5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, color: Color(0xFFD4AF37), size: 20), + SizedBox(width: 10.w), + const Expanded( + child: Text( + '验证码将发送到您绑定的手机号,验证后可设置新的6位数字支付密码', + style: TextStyle( + fontSize: 13, + color: Color(0xFF5D4037), + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + /// 表单卡片 + Widget _buildFormCard() { + return Container( + padding: EdgeInsets.all(20.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF8B5A2B).withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSmsCodeField(), + SizedBox(height: 16.h), + _buildPasswordField( + controller: _newPasswordController, + label: '新支付密码', + hint: '请输入6位数字', + obscure: _obscureNewPassword, + onToggleObscure: () => + setState(() => _obscureNewPassword = !_obscureNewPassword), + ), + SizedBox(height: 16.h), + _buildPasswordField( + controller: _confirmPasswordController, + label: '确认新支付密码', + hint: '请再次输入6位数字', + obscure: _obscureConfirmPassword, + onToggleObscure: () => setState( + () => _obscureConfirmPassword = !_obscureConfirmPassword), + ), + ], + ), + ); + } + + /// 验证码输入框(含发送按钮) + Widget _buildSmsCodeField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '短信验证码', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 8.h), + Row( + children: [ + Expanded( + child: Container( + height: 48.h, + decoration: BoxDecoration( + color: const Color(0xFFFFF7E6), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0x4D8B5A2B)), + ), + child: TextField( + controller: _smsCodeController, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) { + if (_errorMessage != null) { + setState(() => _errorMessage = null); + } + }, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF5D4037), + ), + decoration: const InputDecoration( + contentPadding: + EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: InputBorder.none, + hintText: '请输入6位验证码', + hintStyle: TextStyle( + fontSize: 14, + color: Color(0x995D4037), + ), + counterText: '', + ), + ), + ), + ), + SizedBox(width: 10.w), + _buildSendSmsButton(), + ], + ), + ], + ); + } + + Widget _buildSendSmsButton() { + final canSend = !_isSendingSms && _countdown == 0; + return GestureDetector( + onTap: canSend ? _sendSmsCode : null, + child: Container( + height: 48.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: canSend ? const Color(0xFFD4AF37) : const Color(0xFFE0D5C5), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: _isSendingSms + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _countdown > 0 ? '${_countdown}s后重发' : '发送验证码', + style: TextStyle( + fontSize: 13, + color: canSend ? Colors.white : const Color(0xFF9E8B7A), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ); + } + + /// 密码输入框(含显示/隐藏切换) + Widget _buildPasswordField({ + required TextEditingController controller, + required String label, + required String hint, + required bool obscure, + required VoidCallback onToggleObscure, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 8.h), + Container( + height: 48.h, + decoration: BoxDecoration( + color: const Color(0xFFFFF7E6), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0x4D8B5A2B)), + ), + child: TextField( + controller: controller, + obscureText: obscure, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) { + if (_errorMessage != null) setState(() => _errorMessage = null); + }, + style: const TextStyle(fontSize: 16, color: Color(0xFF5D4037)), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: InputBorder.none, + hintText: hint, + hintStyle: const TextStyle( + fontSize: 14, + color: Color(0x995D4037), + ), + counterText: '', + suffixIcon: GestureDetector( + onTap: onToggleObscure, + child: Icon( + obscure ? Icons.visibility_off : Icons.visibility, + color: const Color(0xFF8B5A2B), + size: 20, + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildErrorMessage() { + return Row( + children: [ + const Icon(Icons.error_outline, size: 14, color: Color(0xFFFF4D4F)), + SizedBox(width: 4.w), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle(fontSize: 12, color: Color(0xFFFF4D4F)), + ), + ), + ], + ); + } + + Widget _buildSubmitButton() { + final smsCode = _smsCodeController.text.trim(); + final newPassword = _newPasswordController.text; + final confirmPassword = _confirmPasswordController.text; + final canSubmit = !_isSubmitting && + smsCode.length == 6 && + newPassword.length == 6 && + confirmPassword.length == 6; + + return SizedBox( + height: 50.h, + child: ElevatedButton( + onPressed: canSubmit ? _resetPassword : null, + style: ElevatedButton.styleFrom( + backgroundColor: + canSubmit ? const Color(0xFF8B5A2B) : const Color(0xFFD4C4B0), + foregroundColor: Colors.white, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + '重置支付密码', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index 3ad4afa7..4cad5913 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -28,6 +28,7 @@ import '../features/planting/presentation/pages/planting_location_page.dart'; import '../features/security/presentation/pages/google_auth_page.dart'; import '../features/security/presentation/pages/change_password_page.dart'; import '../features/security/presentation/pages/change_payment_password_page.dart'; // [2026-03-05] 支付密码页 +import '../features/security/presentation/pages/reset_payment_password_page.dart'; // [2026-03-05] 忘记支付密码页 import '../features/security/presentation/pages/bind_email_page.dart'; import '../features/authorization/presentation/pages/authorization_apply_page.dart'; import '../features/withdraw/presentation/pages/withdraw_usdt_page.dart'; @@ -349,6 +350,13 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => const ChangePaymentPasswordPage(), ), + // Reset Payment Password Page (忘记支付密码) [2026-03-05] + GoRoute( + path: RoutePaths.resetPaymentPassword, + name: RouteNames.resetPaymentPassword, + builder: (context, state) => const ResetPaymentPasswordPage(), + ), + // Bind Email Page (绑定邮箱) GoRoute( path: RoutePaths.bindEmail, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index a518effe..441932be 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -35,7 +35,8 @@ class RouteNames { static const plantingLocation = 'planting-location'; static const googleAuth = 'google-auth'; static const changePassword = 'change-password'; - static const paymentPassword = 'payment-password'; // [2026-03-05] 支付密码 + static const paymentPassword = 'payment-password'; // [2026-03-05] 支付密码 + static const resetPaymentPassword = 'reset-payment-password'; // [2026-03-05] 忘记支付密码 static const bindEmail = 'bind-email'; static const authorizationApply = 'authorization-apply'; static const transactionHistory = 'transaction-history'; diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index 7d2ae885..24ad7f00 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -35,7 +35,8 @@ class RoutePaths { static const plantingLocation = '/planting/location'; static const googleAuth = '/security/google-auth'; static const changePassword = '/security/password'; - static const paymentPassword = '/security/payment-password'; // [2026-03-05] 支付密码设置/修改 + static const paymentPassword = '/security/payment-password'; // [2026-03-05] 支付密码设置/修改 + static const resetPaymentPassword = '/security/reset-payment-password'; // [2026-03-05] 忘记支付密码 static const bindEmail = '/security/email'; static const authorizationApply = '/authorization/apply'; static const transactionHistory = '/trading/history';