From 7a68668aa99e2f0f37d4dffcc955a8f3402f4a56 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 11 Jan 2026 18:33:23 -0800 Subject: [PATCH] =?UTF-8?q?feat(auth-service):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E5=92=8C?= =?UTF-8?q?=E6=8C=87=E6=95=B0=E9=80=80=E9=81=BF=E9=94=81=E5=AE=9A=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 区分用户不存在和密码错误的提示信息 - 登录失败最多允许6次尝试 - 每次密码错误显示剩余尝试次数 - 超过次数后实现指数退避锁定(1,2,4,8...分钟,最长24小时) - 锁定时显示剩余等待时间 - 优化mining-app底部导航栏图标间距 Co-Authored-By: Claude Opus 4.5 --- .../src/application/services/auth.service.ts | 16 ++++-- .../src/domain/aggregates/user.aggregate.ts | 55 +++++++++++++++++-- .../lib/presentation/widgets/main_shell.dart | 30 ++++++---- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/backend/services/auth-service/src/application/services/auth.service.ts b/backend/services/auth-service/src/application/services/auth.service.ts index 0ab586ed..4e3d0290 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -123,7 +123,7 @@ export class AuthService { // 尝试从同步的 V1 用户表查找 const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phone); if (!legacyUser) { - throw new UnauthorizedException('手机号或密码错误'); + throw new UnauthorizedException('该手机号未注册'); } if (legacyUser.migratedToV2) { @@ -193,16 +193,22 @@ export class AuthService { ): Promise { if (!user.canLogin) { if (user.isLocked) { - throw new UnauthorizedException('账户已被锁定,请稍后再试'); + const remainingSeconds = user.getLockRemainingSeconds(); + const minutes = Math.ceil(remainingSeconds / 60); + throw new UnauthorizedException(`账户已被锁定,请${minutes}分钟后再试`); } throw new UnauthorizedException('账户已被禁用'); } const isValid = await user.verifyPassword(password); if (!isValid) { - user.recordLoginFailure(); + const result = user.recordLoginFailure(); await this.userRepository.save(user); - throw new UnauthorizedException('手机号或密码错误'); + + if (result.remainingAttempts === 0) { + throw new UnauthorizedException(`密码错误次数过多,账户已锁定${result.lockMinutes}分钟`); + } + throw new UnauthorizedException(`密码错误,还剩${result.remainingAttempts}次尝试机会`); } user.recordLoginSuccess(ipAddress); @@ -224,7 +230,7 @@ export class AuthService { const bcrypt = await import('bcrypt'); const isValid = await bcrypt.compare(password, legacyUser.passwordHash); if (!isValid) { - throw new UnauthorizedException('手机号或密码错误'); + throw new UnauthorizedException('密码错误'); } // 创建 V2 用户(保留原 accountSequence) diff --git a/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts b/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts index eb492965..21d456a4 100644 --- a/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts +++ b/backend/services/auth-service/src/domain/aggregates/user.aggregate.ts @@ -248,14 +248,59 @@ export class UserAggregate { } /** - * 记录登录失败 + * 计算指数退避锁定时间(分钟) + * 第6次失败: 1分钟 + * 第7次失败: 2分钟 + * 第8次失败: 4分钟 + * 第9次失败: 8分钟 + * 第10次失败: 16分钟 + * ...以此类推,最长24小时 */ - recordLoginFailure(maxAttempts: number = 5, lockMinutes: number = 30): void { + private calculateLockMinutes(failCount: number, maxAttempts: number): number { + const excessAttempts = failCount - maxAttempts; + // 2^(excessAttempts) 分钟,最长 1440 分钟(24小时) + const lockMinutes = Math.pow(2, excessAttempts); + return Math.min(lockMinutes, 1440); + } + + /** + * 记录登录失败 + * @param maxAttempts 最大尝试次数,默认6次 + * @returns 返回剩余尝试次数和锁定信息 + */ + recordLoginFailure(maxAttempts: number = 6): { remainingAttempts: number; lockedUntil?: Date; lockMinutes?: number } { this._loginFailCount += 1; - if (this._loginFailCount >= maxAttempts) { - this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000); - } this._updatedAt = new Date(); + + if (this._loginFailCount >= maxAttempts) { + const lockMinutes = this.calculateLockMinutes(this._loginFailCount, maxAttempts); + this._lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000); + return { + remainingAttempts: 0, + lockedUntil: this._lockedUntil, + lockMinutes, + }; + } + + return { + remainingAttempts: maxAttempts - this._loginFailCount, + }; + } + + /** + * 获取剩余尝试次数 + */ + getRemainingAttempts(maxAttempts: number = 6): number { + return Math.max(0, maxAttempts - this._loginFailCount); + } + + /** + * 获取锁定剩余时间(秒) + */ + getLockRemainingSeconds(): number { + if (!this._lockedUntil) return 0; + const remaining = this._lockedUntil.getTime() - Date.now(); + return Math.max(0, Math.ceil(remaining / 1000)); } /** diff --git a/frontend/mining-app/lib/presentation/widgets/main_shell.dart b/frontend/mining-app/lib/presentation/widgets/main_shell.dart index 079cfd0b..03f99191 100644 --- a/frontend/mining-app/lib/presentation/widgets/main_shell.dart +++ b/frontend/mining-app/lib/presentation/widgets/main_shell.dart @@ -16,17 +16,23 @@ class MainShell extends StatelessWidget { return Scaffold( body: child, bottomNavigationBar: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: Colors.white, - border: Border( - top: BorderSide(color: Color(0xFFF3F4F6), width: 1), - ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], ), child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + top: false, + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildNavItem( context, @@ -77,10 +83,10 @@ class MainShell extends StatelessWidget { return GestureDetector( onTap: () => _onItemTapped(index, context), behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: SizedBox( + width: 64, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( isSelected ? activeIcon : icon, @@ -91,8 +97,8 @@ class MainShell extends StatelessWidget { Text( label, style: TextStyle( - fontSize: 10, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? _orange : _grayText, ), ),