fix(auth): resetPassword 解除锁定 + 登录错误提示优化

Backend:
- password.service.ts: resetPassword 成功后调用 user.unlock(),
  清除 loginFailCount 和 lockedUntil,避免用户改密后仍无法登录

Frontend:
- api_client.dart: 401 响应提取后端真实错误消息,不再丢弃
- auth_remote_datasource.dart: loginWithPassword 直接 rethrow
  已知异常类型,避免二次包装导致消息格式混乱
- login_page.dart: 登录失败按错误类型分类提示:
  · 账户锁定 → AlertDialog + "找回密码"按钮
  · 还有尝试机会 → SnackBar(橙色) + "找回密码"Action
  · 其他错误 → 普通 SnackBar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-10 19:46:06 -07:00
parent ff82bddbc6
commit 761dcb1115
4 changed files with 61 additions and 4 deletions

View File

@ -63,8 +63,9 @@ export class PasswordService {
throw new NotFoundException('用户不存在');
}
// 修改密码
// 修改密码,同时解除锁定(短信验证身份已通过,清除失败计数)
await user.changePassword(dto.newPassword);
user.unlock();
await this.userRepository.save(user);
}

View File

@ -146,7 +146,15 @@ class ApiClient {
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
if (statusCode == 401) {
return UnauthorizedException();
final data = e.response?.data;
final error = data is Map ? data['error'] : null;
final rawMsg = error is Map ? error['message'] : null;
final msg = rawMsg is String
? rawMsg
: (rawMsg is List && rawMsg.isNotEmpty)
? rawMsg[0].toString()
: null;
return UnauthorizedException(msg ?? '未授权,请重新登录');
}
if (statusCode == 403) {
final data = e.response?.data;

View File

@ -138,6 +138,7 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
);
return AuthResult.fromJson(response.data as Map<String, dynamic>);
} catch (e) {
if (e is UnauthorizedException || e is ForbiddenException || e is NetworkException) rethrow;
throw ServerException(e.toString());
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/routes.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/error/exceptions.dart';
import '../../providers/user_providers.dart';
class LoginPage extends ConsumerStatefulWidget {
@ -45,9 +46,55 @@ class _LoginPageState extends ConsumerState<LoginPage> {
context.go(Routes.contribution);
}
} catch (e) {
if (mounted) {
if (!mounted) return;
final message = e is UnauthorizedException
? e.message
: e is ForbiddenException
? e.message
: e is NetworkException
? e.message
: e.toString().replaceFirst(RegExp(r'^[A-Za-z]+Exception:\s*'), '');
if (message.contains('锁定')) {
// +
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('账户已锁定'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('知道了', style: TextStyle(color: AppColors.textSecondaryOf(context))),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
context.push(Routes.forgotPassword);
},
style: ElevatedButton.styleFrom(backgroundColor: _orange),
child: const Text('找回密码', style: TextStyle(color: Colors.white)),
),
],
),
);
} else if (message.contains('还剩')) {
// SnackBar +
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登录失败: $e')),
SnackBar(
content: Text(message),
backgroundColor: Colors.orange.shade800,
duration: const Duration(seconds: 5),
action: SnackBarAction(
label: '找回密码',
textColor: Colors.white,
onPressed: () => context.push(Routes.forgotPassword),
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}