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:
parent
19bd804a21
commit
69fa43ebee
|
|
@ -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: '查询我的资料' })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -183,6 +183,14 @@ export class SetPasswordCommand {
|
|||
) {}
|
||||
}
|
||||
|
||||
export class ChangePasswordCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly oldPassword: string,
|
||||
public readonly newPassword: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
// ============ Results ============
|
||||
|
||||
// 钱包状态
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送提取验证短信
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 遮蔽手机号中间部分,用于日志输出
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ', '')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in New Issue