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 c6db8f0c..d18e01ea 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 @@ -50,6 +50,7 @@ import { MarkMnemonicBackedUpCommand, VerifySmsCodeCommand, SetPasswordCommand, + ChangePasswordCommand, } from '@/application/commands'; import { AutoCreateAccountDto, @@ -80,6 +81,7 @@ import { WalletStatusGeneratingResponseDto, VerifySmsCodeDto, SetPasswordDto, + ChangePasswordDto, LoginWithPasswordDto, ResetPasswordDto, } from '@/api/dto'; @@ -295,6 +297,24 @@ export class UserAccountController { return { message: '密码设置成功' }; } + @Post('change-password') + @ApiBearerAuth() + @ApiOperation({ + summary: '修改登录密码', + description: '验证旧密码后修改为新密码', + }) + @ApiResponse({ status: 200, description: '密码修改成功' }) + @ApiResponse({ status: 400, description: '旧密码错误' }) + async changePassword( + @CurrentUser() user: CurrentUserData, + @Body() dto: ChangePasswordDto, + ) { + await this.userService.changePassword( + new ChangePasswordCommand(user.userId, dto.oldPassword, dto.newPassword), + ); + return { message: '密码修改成功' }; + } + @Get('my-profile') @ApiBearerAuth() @ApiOperation({ summary: '查询我的资料' }) diff --git a/backend/services/identity-service/src/api/dto/request/change-password.dto.ts b/backend/services/identity-service/src/api/dto/request/change-password.dto.ts new file mode 100644 index 00000000..b9c03638 --- /dev/null +++ b/backend/services/identity-service/src/api/dto/request/change-password.dto.ts @@ -0,0 +1,23 @@ +import { IsString, MinLength, MaxLength, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChangePasswordDto { + @ApiProperty({ + example: 'OldPass123', + description: '当前密码', + }) + @IsString() + oldPassword: string; + + @ApiProperty({ + example: 'NewPass456', + description: '新密码,6-20位,包含字母和数字', + }) + @IsString() + @MinLength(6, { message: '密码长度至少6位' }) + @MaxLength(20, { message: '密码长度不能超过20位' }) + @Matches(/^(?=.*[A-Za-z])(?=.*\d).+$/, { + message: '密码需包含字母和数字', + }) + newPassword: string; +} diff --git a/backend/services/identity-service/src/api/dto/request/index.ts b/backend/services/identity-service/src/api/dto/request/index.ts index d7bc33e8..758a55d1 100644 --- a/backend/services/identity-service/src/api/dto/request/index.ts +++ b/backend/services/identity-service/src/api/dto/request/index.ts @@ -12,4 +12,5 @@ export * from './generate-backup-codes.dto'; export * from './recover-by-backup-code.dto'; export * from './verify-sms-code.dto'; export * from './set-password.dto'; +export * from './change-password.dto'; export * from './login-with-password.dto'; diff --git a/backend/services/identity-service/src/application/commands/index.ts b/backend/services/identity-service/src/application/commands/index.ts index b312f303..3e176b36 100644 --- a/backend/services/identity-service/src/application/commands/index.ts +++ b/backend/services/identity-service/src/application/commands/index.ts @@ -183,6 +183,14 @@ export class SetPasswordCommand { ) {} } +export class ChangePasswordCommand { + constructor( + public readonly userId: string, + public readonly oldPassword: string, + public readonly newPassword: string, + ) {} +} + // ============ Results ============ // 钱包状态 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 fdc0a617..3d7fbffd 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 @@ -1984,6 +1984,60 @@ export class UserApplicationService { this.logger.log(`Password set successfully for user: ${command.userId}`); } + /** + * 修改登录密码 + */ + async changePassword(command: { + userId: string; + oldPassword: string; + newPassword: string; + }): Promise { + this.logger.log(`Changing password for user: ${command.userId}`); + + const userId = UserId.create(command.userId); + const user = await this.userRepository.findById(userId); + + if (!user) { + throw new ApplicationError('用户不存在'); + } + + // 获取当前密码哈希 + const account = await this.prisma.userAccount.findUnique({ + where: { userId: BigInt(command.userId) }, + select: { passwordHash: true }, + }); + + if (!account?.passwordHash) { + throw new ApplicationError('尚未设置密码'); + } + + // 验证旧密码 + const bcrypt = await import('bcrypt'); + const isOldPasswordValid = await bcrypt.compare( + command.oldPassword, + account.passwordHash, + ); + + if (!isOldPasswordValid) { + this.logger.warn( + `Invalid old password for user: ${command.userId}`, + ); + throw new ApplicationError('当前密码错误'); + } + + // 使用 bcrypt 哈希新密码 + const saltRounds = 10; + const newPasswordHash = await bcrypt.hash(command.newPassword, saltRounds); + + // 更新数据库 + await this.prisma.userAccount.update({ + where: { userId: BigInt(command.userId) }, + data: { passwordHash: newPasswordHash }, + }); + + this.logger.log(`Password changed successfully for user: ${command.userId}`); + } + /** * 发送提取验证短信 */ diff --git a/frontend/mobile-app/lib/app.dart b/frontend/mobile-app/lib/app.dart index 55a6510e..7543ec62 100644 --- a/frontend/mobile-app/lib/app.dart +++ b/frontend/mobile-app/lib/app.dart @@ -1,14 +1,76 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; import 'core/theme/app_theme.dart'; +import 'core/services/auth_event_service.dart'; +import 'core/services/multi_account_service.dart'; +import 'core/storage/secure_storage.dart'; import 'routes/app_router.dart'; +import 'routes/route_paths.dart'; -class App extends ConsumerWidget { +class App extends ConsumerStatefulWidget { const App({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _AppState(); +} + +class _AppState extends ConsumerState { + StreamSubscription? _authEventSubscription; + + @override + void initState() { + super.initState(); + _listenToAuthEvents(); + } + + @override + void dispose() { + _authEventSubscription?.cancel(); + super.dispose(); + } + + /// 监听认证事件 + void _listenToAuthEvents() { + final authEventService = ref.read(authEventServiceProvider); + _authEventSubscription = authEventService.events.listen((event) { + if (event.type == AuthEventType.tokenExpired) { + _handleTokenExpired(event.message); + } + }); + } + + /// 处理 token 过期 + Future _handleTokenExpired(String? message) async { + debugPrint('[App] Token expired, navigating to login page'); + + // 清除当前账号状态(但保留账号列表和向导页标识) + final secureStorage = ref.read(secureStorageProvider); + final multiAccountService = MultiAccountService(secureStorage); + await multiAccountService.logoutCurrentAccount(); + + // 使用全局 Navigator Key 跳转到登录页面 + final navigatorState = rootNavigatorKey.currentState; + if (navigatorState != null) { + // 显示提示消息 + if (message != null) { + ScaffoldMessenger.of(navigatorState.context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.orange, + ), + ); + } + // 跳转到登录页面 + navigatorState.context.go(RoutePaths.phoneLogin); + } + } + + @override + Widget build(BuildContext context) { final router = ref.watch(appRouterProvider); return ScreenUtilInit( diff --git a/frontend/mobile-app/lib/core/network/api_client.dart b/frontend/mobile-app/lib/core/network/api_client.dart index f39bb6d5..5eaef2e8 100644 --- a/frontend/mobile-app/lib/core/network/api_client.dart +++ b/frontend/mobile-app/lib/core/network/api_client.dart @@ -4,6 +4,7 @@ import '../storage/secure_storage.dart'; import '../storage/storage_keys.dart'; import '../errors/exceptions.dart'; import '../config/app_config.dart'; +import '../services/auth_event_service.dart'; /// API 客户端 /// @@ -106,19 +107,28 @@ class ApiClient { final response = await _retryRequest(error.requestOptions); return handler.resolve(response); } else { - // refresh token 不存在,不清除数据,让用户重新登录 + // refresh token 不存在,需要重新登录 debugPrint('Token refresh failed: no refresh token available'); + _notifyTokenExpired(); } } catch (e) { debugPrint('Token refresh exception: $e'); - // 只有当 refresh token 明确过期(401)时才清除数据 - // 其他错误(如网络错误)不清除,避免误清除 + // refresh token 刷新失败(可能过期),需要重新登录 + if (e is DioException && e.response?.statusCode == 401) { + debugPrint('Refresh token expired, notifying token expired event'); + _notifyTokenExpired(); + } } } handler.next(error); } + /// 通知 token 过期事件 + void _notifyTokenExpired() { + AuthEventService().emitTokenExpired(message: '登录已过期,请重新登录'); + } + /// 尝试刷新 Token Future _tryRefreshToken() async { final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken); diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 2ea4243f..42c83f06 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -1941,6 +1941,38 @@ class AccountService { debugPrint('$_tag isPasswordSet() - 结果: $result'); return result; } + + /// 修改登录密码 + /// + /// [oldPassword] - 当前密码 + /// [newPassword] - 新密码(6-20位,包含字母和数字) + Future changePassword({ + required String oldPassword, + required String newPassword, + }) async { + debugPrint('$_tag changePassword() - 开始修改登录密码'); + + try { + debugPrint('$_tag changePassword() - 调用 POST /user/change-password'); + final response = await _apiClient.post( + '/user/change-password', + data: { + 'oldPassword': oldPassword, + 'newPassword': newPassword, + }, + ); + debugPrint('$_tag changePassword() - API 响应状态码: ${response.statusCode}'); + + debugPrint('$_tag changePassword() - 登录密码修改完成'); + } on ApiException catch (e) { + debugPrint('$_tag changePassword() - API 异常: $e'); + rethrow; + } catch (e, stackTrace) { + debugPrint('$_tag changePassword() - 未知异常: $e'); + debugPrint('$_tag changePassword() - 堆栈: $stackTrace'); + throw ApiException('修改密码失败: $e'); + } + } } /// 遮蔽手机号中间部分,用于日志输出 diff --git a/frontend/mobile-app/lib/core/services/auth_event_service.dart b/frontend/mobile-app/lib/core/services/auth_event_service.dart new file mode 100644 index 00000000..24ac8771 --- /dev/null +++ b/frontend/mobile-app/lib/core/services/auth_event_service.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 认证事件类型 +enum AuthEventType { + /// Token 过期,需要重新登录 + tokenExpired, +} + +/// 认证事件 +class AuthEvent { + final AuthEventType type; + final String? message; + + AuthEvent({ + required this.type, + this.message, + }); +} + +/// 认证事件服务 +/// +/// 用于在 token 过期等情况下通知 UI 层进行页面跳转 +class AuthEventService { + /// 单例实例 + static final AuthEventService _instance = AuthEventService._internal(); + + factory AuthEventService() => _instance; + + AuthEventService._internal(); + + /// 事件流控制器 + final _eventController = StreamController.broadcast(); + + /// 获取事件流 + Stream get events => _eventController.stream; + + /// 全局 Navigator Key(由 AppRouter 设置) + GlobalKey? navigatorKey; + + /// 发送 token 过期事件 + void emitTokenExpired({String? message}) { + debugPrint('[AuthEventService] Emitting token expired event'); + _eventController.add(AuthEvent( + type: AuthEventType.tokenExpired, + message: message ?? '登录已过期,请重新登录', + )); + } + + /// 释放资源 + void dispose() { + _eventController.close(); + } +} + +/// 认证事件服务 Provider +final authEventServiceProvider = Provider((ref) { + return AuthEventService(); +}); diff --git a/frontend/mobile-app/lib/core/services/multi_account_service.dart b/frontend/mobile-app/lib/core/services/multi_account_service.dart index ed168469..9425d27a 100644 --- a/frontend/mobile-app/lib/core/services/multi_account_service.dart +++ b/frontend/mobile-app/lib/core/services/multi_account_service.dart @@ -264,6 +264,8 @@ class MultiAccountService { StorageKeys.referralCode, StorageKeys.inviterSequence, StorageKeys.isAccountCreated, + StorageKeys.phoneNumber, + StorageKeys.isPasswordSet, // 钱包信息 StorageKeys.walletAddressBsc, StorageKeys.walletAddressKava, @@ -308,6 +310,8 @@ class MultiAccountService { StorageKeys.referralCode, StorageKeys.inviterSequence, StorageKeys.isAccountCreated, + StorageKeys.phoneNumber, + StorageKeys.isPasswordSet, StorageKeys.walletAddressBsc, StorageKeys.walletAddressKava, StorageKeys.walletAddressDst, diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart index 0b415827..c699513a 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart @@ -132,20 +132,20 @@ class _SplashPageState extends ConsumerState { // 根据认证状态决定跳转目标 // 优先级: // 1. 账号已创建 → 主页面(龙虎榜) - // 2. 首次打开或未看过向导 → 向导页 - // 3. 其他情况 → 主页面(龙虎榜) + // 2. 首次打开 → 向导页 + // 3. 已看过向导但账号未创建(退出登录后) → 登录页面 if (authState.isAccountCreated) { // 账号已创建,进入主页面(龙虎榜) debugPrint('[SplashPage] 账号已创建 → 跳转到龙虎榜'); context.go(RoutePaths.ranking); - } else if (authState.isFirstLaunch || !authState.hasSeenGuide) { - // 首次打开或未看过向导,进入向导页 - debugPrint('[SplashPage] 首次打开或未看过向导 → 跳转到向导页'); + } else if (authState.isFirstLaunch) { + // 首次打开,进入向导页 + debugPrint('[SplashPage] 首次打开 → 跳转到向导页'); context.go(RoutePaths.guide); } else { - // 其他情况,直接进入主页面(龙虎榜) - debugPrint('[SplashPage] 其他情况 → 跳转到龙虎榜'); - context.go(RoutePaths.ranking); + // 已看过向导但账号未创建(退出登录后),跳转到登录页面 + debugPrint('[SplashPage] 已看过向导,账号未创建 → 跳转到登录页面'); + context.go(RoutePaths.phoneLogin); } } diff --git a/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart b/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart index b0ae0e27..01c15f65 100644 --- a/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart +++ b/frontend/mobile-app/lib/features/security/presentation/pages/change_password_page.dart @@ -1,6 +1,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'; /// 修改密码页面 /// 支持设置或修改登录密码 @@ -60,22 +61,20 @@ class _ChangePasswordPageState extends ConsumerState { }); try { - // TODO: 调用API获取密码状态 - // final accountService = ref.read(accountServiceProvider); - // final hasPassword = await accountService.hasPassword(); - - // 模拟数据 - await Future.delayed(const Duration(milliseconds: 500)); + final accountService = ref.read(accountServiceProvider); + final hasPassword = await accountService.isPasswordSet(); if (mounted) { setState(() { - _hasPassword = false; // 模拟未设置密码 + _hasPassword = hasPassword; _isLoading = false; }); } } catch (e) { if (mounted) { setState(() { + // 出错时默认已设置密码(注册时必须设置) + _hasPassword = true; _isLoading = false; }); } @@ -144,15 +143,18 @@ class _ChangePasswordPageState extends ConsumerState { }); try { - // TODO: 调用API修改密码 - // final accountService = ref.read(accountServiceProvider); - // await accountService.changePassword( - // oldPassword: _hasPassword ? oldPassword : null, - // newPassword: newPassword, - // ); + final accountService = ref.read(accountServiceProvider); - // 模拟请求 - await Future.delayed(const Duration(seconds: 1)); + if (_hasPassword) { + // 已有密码,调用修改密码 API + await accountService.changePassword( + oldPassword: oldPassword, + newPassword: newPassword, + ); + } else { + // 未设置过密码,调用设置密码 API + await accountService.setLoginPassword(newPassword); + } if (mounted) { setState(() { @@ -174,7 +176,7 @@ class _ChangePasswordPageState extends ConsumerState { _isSubmitting = false; }); - _showErrorSnackBar('操作失败: ${e.toString()}'); + _showErrorSnackBar('操作失败: ${e.toString().replaceAll('Exception: ', '')}'); } } } diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index 15673094..945743b6 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -36,7 +36,8 @@ import '../features/account/presentation/pages/account_switch_page.dart'; import 'route_paths.dart'; import 'route_names.dart'; -final _rootNavigatorKey = GlobalKey(); +/// 全局根导航 Key,用于在非 Widget 上下文中进行导航 +final rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); /// 备份助记词页面参数 (新版 - 只需要用户序列号) @@ -99,7 +100,7 @@ class SharePageParams { final appRouterProvider = Provider((ref) { return GoRouter( - navigatorKey: _rootNavigatorKey, + navigatorKey: rootNavigatorKey, initialLocation: RoutePaths.splash, debugLogDiagnostics: true, routes: [