feat(auth): 实现修改密码API和Token过期自动跳转登录

后端:
- 新增 ChangePasswordCommand 和 ChangePasswordDto
- 新增 POST /user/change-password 接口
- 实现 changePassword() 方法,验证旧密码后更新新密码

前端:
- 新增 AuthEventService 认证事件服务,处理 token 过期事件
- api_client 在 token 刷新失败时发送过期事件
- App 监听认证事件,token 过期时清除账号状态并跳转登录页
- splash_page 优化路由逻辑:退出登录后跳转手机登录页而非向导页
- change_password_page 调用真实 API 修改密码
- account_service 新增 changePassword() 方法
- multi_account_service 退出登录时清除 phoneNumber 和 isPasswordSet

🤖 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-23 20:25:56 -08:00
parent 19bd804a21
commit 69fa43ebee
13 changed files with 309 additions and 31 deletions

View File

@ -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: '查询我的资料' })

View File

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

View File

@ -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';

View File

@ -183,6 +183,14 @@ export class SetPasswordCommand {
) {}
}
export class ChangePasswordCommand {
constructor(
public readonly userId: string,
public readonly oldPassword: string,
public readonly newPassword: string,
) {}
}
// ============ Results ============
// 钱包状态

View File

@ -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<void> {
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}`);
}
/**
*
*/

View File

@ -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<App> createState() => _AppState();
}
class _AppState extends ConsumerState<App> {
StreamSubscription<AuthEvent>? _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<void> _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(

View File

@ -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<bool> _tryRefreshToken() async {
final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken);

View File

@ -1941,6 +1941,38 @@ class AccountService {
debugPrint('$_tag isPasswordSet() - 结果: $result');
return result;
}
///
///
/// [oldPassword] -
/// [newPassword] - 6-20
Future<void> 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');
}
}
}
///

View File

@ -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<AuthEvent>.broadcast();
///
Stream<AuthEvent> get events => _eventController.stream;
/// Navigator Key AppRouter
GlobalKey<NavigatorState>? 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<AuthEventService>((ref) {
return AuthEventService();
});

View File

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

View File

@ -132,20 +132,20 @@ class _SplashPageState extends ConsumerState<SplashPage> {
//
//
// 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);
}
}

View File

@ -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<ChangePasswordPage> {
});
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<ChangePasswordPage> {
});
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<ChangePasswordPage> {
_isSubmitting = false;
});
_showErrorSnackBar('操作失败: ${e.toString()}');
_showErrorSnackBar('操作失败: ${e.toString().replaceAll('Exception: ', '')}');
}
}
}

View File

@ -36,7 +36,8 @@ import '../features/account/presentation/pages/account_switch_page.dart';
import 'route_paths.dart';
import 'route_names.dart';
final _rootNavigatorKey = GlobalKey<NavigatorState>();
/// Key Widget
final rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// ( - )
@ -99,7 +100,7 @@ class SharePageParams {
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: _rootNavigatorKey,
navigatorKey: rootNavigatorKey,
initialLocation: RoutePaths.splash,
debugLogDiagnostics: true,
routes: [