From ff89c31b514f7b52f88e8a154c3f4fcf1478bd85 Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Fri, 12 Jun 2026 13:12:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(player):=20=E7=8E=A9=E5=AE=B6=E7=AB=AF?= =?UTF-8?q?=E7=9F=AD=E4=BF=A1=E6=89=BE=E5=9B=9E=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持手机号验证码重置密码,重置成功后跳转登录页;SMS 增加 reset_password 场景与 purpose 隔离。 Co-authored-by: Cursor --- .../src/domains/identity/auth.controller.ts | 20 +- apps/api/src/domains/identity/auth.dto.ts | 23 ++ apps/api/src/domains/identity/auth.service.ts | 80 +++++ .../domains/identity/sms/sms.controller.ts | 1 + apps/api/src/domains/identity/sms/sms.dto.ts | 8 +- .../src/domains/identity/sms/sms.service.ts | 60 +++- .../api/src/domains/identity/sms/sms.types.ts | 2 + .../api/src/domains/identity/sms/templates.ts | 15 + apps/player/src/composables/useSmsCode.ts | 7 +- apps/player/src/i18n/en-US.ts | 8 + apps/player/src/i18n/ms-MY.ts | 8 + apps/player/src/i18n/zh-CN.ts | 8 + apps/player/src/router/index.ts | 3 +- apps/player/src/views/ForgotPasswordView.vue | 322 ++++++++++++++++++ apps/player/src/views/LoginView.vue | 40 ++- packages/shared/src/api-errors.ts | 5 + 16 files changed, 597 insertions(+), 13 deletions(-) create mode 100644 apps/player/src/views/ForgotPasswordView.vue diff --git a/apps/api/src/domains/identity/auth.controller.ts b/apps/api/src/domains/identity/auth.controller.ts index a2b1750..861bcc4 100644 --- a/apps/api/src/domains/identity/auth.controller.ts +++ b/apps/api/src/domains/identity/auth.controller.ts @@ -1,12 +1,14 @@ -import { Controller, Get, Post, Delete, Body, UseGuards, Query, Param } from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, UseGuards, Query, Param, Req } from '@nestjs/common'; +import type { Request } from 'express'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { InvitesService } from './invites.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; -import { LoginDto, ChangePasswordDto, RegisterDto, GenerateInviteDto } from './auth.dto'; +import { LoginDto, ChangePasswordDto, RegisterDto, GenerateInviteDto, ForgotPasswordDto } from './auth.dto'; import { Public, CurrentUser } from '../../shared/common/decorators'; import { JwtAuthGuard } from './guards'; import { jsonResponse } from '../../shared/common/filters'; +import { getClientIp } from '../../shared/common/client-ip.util'; @ApiTags('Auth') @Controller() @@ -45,6 +47,20 @@ export class AuthController { return jsonResponse(result); } + @Public() + @Post('player/auth/forgot-password') + async playerForgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) { + const result = await this.auth.resetPasswordByPhone({ + phone: dto.phone, + countryCode: dto.countryCode, + smsCode: dto.smsCode, + sessionId: dto.sessionId, + newPassword: dto.newPassword, + ipAddress: getClientIp(req), + }); + return jsonResponse(result); + } + @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Get('manage/invite') diff --git a/apps/api/src/domains/identity/auth.dto.ts b/apps/api/src/domains/identity/auth.dto.ts index 69483ad..661d1a6 100644 --- a/apps/api/src/domains/identity/auth.dto.ts +++ b/apps/api/src/domains/identity/auth.dto.ts @@ -66,6 +66,29 @@ export class ChangePasswordDto { newPassword!: string; } +export class ForgotPasswordDto { + @ApiProperty({ description: '本地手机号,不含国家区号' }) + @IsString() + phone!: string; + + @ApiProperty({ description: '国家区号,不含 +,如 86' }) + @IsString() + countryCode!: string; + + @ApiProperty({ description: '短信验证码' }) + @IsString() + smsCode!: string; + + @ApiProperty({ description: '发送验证码返回的 sessionId' }) + @IsString() + sessionId!: string; + + @ApiProperty() + @IsString() + @MinLength(8) + newPassword!: string; +} + export class GenerateInviteDto { @ApiProperty({ required: false, description: 'Decimal cashback rate, e.g. 0.01 = 1%. Admin only.' }) @IsOptional() diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 3be3b24..5254244 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -9,6 +9,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; import { InvitesService } from './invites.service'; import { SmsService } from './sms/sms.service'; +import { AuditService } from '../operations/audit/audit.service'; import { normalizePhone, resolvePlayerLoginCandidates, stripLocalPhoneDigits } from './sms/phone.util'; const MAX_LOGIN_FAILS = 5; @@ -30,6 +31,7 @@ export class AuthService { private systemConfig: SystemConfigService, private invites: InvitesService, private sms: SmsService, + private audit: AuditService, ) {} /** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */ @@ -227,6 +229,7 @@ export class AuthService { countryCode: data.countryCode, code: data.smsCode.trim(), sessionId: data.sessionId.trim(), + expectedPurpose: 'register', }); const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode); @@ -362,6 +365,83 @@ export class AuthService { return { success: true }; } + async resetPasswordByPhone(data: { + phone: string; + countryCode: string; + smsCode: string; + sessionId: string; + newPassword: string; + ipAddress?: string; + }) { + const settings = await this.systemConfig.getPlayerAccountSettings(); + if (!settings.allowPasswordChange) { + throw appForbidden('PASSWORD_CHANGE_DISABLED'); + } + + if (!data.newPassword || data.newPassword.length < 8) { + throw appBadRequest('PASSWORD_MIN_LENGTH'); + } + if (!data.smsCode?.trim() || !data.sessionId?.trim()) { + throw appBadRequest('SMS_CODE_REQUIRED'); + } + + await this.sms.verifyCode({ + phone: data.phone, + countryCode: data.countryCode, + code: data.smsCode.trim(), + sessionId: data.sessionId.trim(), + expectedPurpose: 'reset_password', + }); + + const player = await this.findPlayerByPhone(data.countryCode, data.phone); + if (!player) { + throw appBadRequest('PHONE_NOT_REGISTERED'); + } + if (player.status === 'DISABLED') { + throw appForbidden('ACCOUNT_DISABLED'); + } + + const hash = await this.hashPassword(data.newPassword); + await this.prisma.userAuth.update({ + where: { userId: player.id }, + data: { + passwordHash: hash, + loginFailCount: 0, + lockedUntil: null, + }, + }); + await this.prisma.userPreference.updateMany({ + where: { userId: player.id }, + data: { managedPassword: null }, + }); + + await this.audit.log({ + operatorId: player.id, + operatorType: 'PLAYER', + action: 'FORGOT_PASSWORD_RESET', + module: 'identity', + targetType: 'user', + targetId: player.id.toString(), + ipAddress: data.ipAddress, + }); + + return { success: true }; + } + + private async findPlayerByPhone(countryCode: string, phone: string) { + const dial = countryCode.replace(/\D/g, ''); + const phoneLocal = stripLocalPhoneDigits(phone); + const pref = await this.prisma.userPreference.findFirst({ + where: { + phoneCountryDial: dial, + phoneLocal, + user: { deletedAt: null, userType: 'PLAYER' }, + }, + select: { user: { select: { id: true, status: true } } }, + }); + return pref?.user ?? null; + } + async hashPassword(password: string): Promise { return bcrypt.hash(password, 10); } diff --git a/apps/api/src/domains/identity/sms/sms.controller.ts b/apps/api/src/domains/identity/sms/sms.controller.ts index 4fae36e..392f50d 100644 --- a/apps/api/src/domains/identity/sms/sms.controller.ts +++ b/apps/api/src/domains/identity/sms/sms.controller.ts @@ -20,6 +20,7 @@ export class SmsController { countryCode: dto.countryCode.trim(), locale: dto.locale, clientIp: getClientIp(req), + purpose: dto.purpose, }); return jsonResponse(result); } diff --git a/apps/api/src/domains/identity/sms/sms.dto.ts b/apps/api/src/domains/identity/sms/sms.dto.ts index a56976e..49f32c2 100644 --- a/apps/api/src/domains/identity/sms/sms.dto.ts +++ b/apps/api/src/domains/identity/sms/sms.dto.ts @@ -1,5 +1,6 @@ -import { IsString, IsOptional } from 'class-validator'; +import { IsString, IsOptional, IsIn } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import type { SmsPurpose } from './sms.types'; export class SendSmsCodeDto { @ApiProperty({ example: '13800138000', description: '本地手机号,不含国家区号' }) @@ -14,6 +15,11 @@ export class SendSmsCodeDto { @IsOptional() @IsString() locale?: string; + + @ApiProperty({ required: false, enum: ['register', 'reset_password'] }) + @IsOptional() + @IsIn(['register', 'reset_password']) + purpose?: SmsPurpose; } export class VerifySmsCodeDto { diff --git a/apps/api/src/domains/identity/sms/sms.service.ts b/apps/api/src/domains/identity/sms/sms.service.ts index ec0ac78..eb49371 100644 --- a/apps/api/src/domains/identity/sms/sms.service.ts +++ b/apps/api/src/domains/identity/sms/sms.service.ts @@ -3,13 +3,15 @@ import { ConfigService } from '@nestjs/config'; import { randomUUID } from 'node:crypto'; import { normalizeLocale } from '@thebet365/shared'; import { RedisService } from '../../../shared/redis/redis.service'; -import { appBadRequest, appTooManyRequests } from '../../../shared/common/app-error'; +import { PrismaService } from '../../../shared/prisma/prisma.service'; +import { SystemConfigService } from '../../../shared/config/system-config.service'; +import { appBadRequest, appForbidden, appTooManyRequests } from '../../../shared/common/app-error'; import { ChuanglanClient } from './chuanglan/client'; import { loadSmsBusinessConfig } from './chuanglan/config'; import { generateSixDigitCode } from './code'; -import { normalizePhone } from './phone.util'; -import { renderVerifySms } from './templates'; -import type { SmsLang } from './sms.types'; +import { normalizePhone, stripLocalPhoneDigits } from './phone.util'; +import { renderResetPasswordSms, renderVerifySms } from './templates'; +import type { SmsLang, SmsPurpose } from './sms.types'; const codeKey = (sessionId: string) => `sms:code:${sessionId}`; const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`; @@ -29,6 +31,8 @@ export class SmsService { constructor( private redis: RedisService, private chuanglan: ChuanglanClient, + private prisma: PrismaService, + private systemConfig: SystemConfigService, config: ConfigService, ) { this.smsCfg = loadSmsBusinessConfig(config); @@ -39,9 +43,15 @@ export class SmsService { countryCode: string; locale?: string; clientIp: string; + purpose?: SmsPurpose; }): Promise<{ sessionId: string }> { + const purpose: SmsPurpose = params.purpose ?? 'register'; const phone = normalizePhone(params.countryCode, params.phone); + if (purpose === 'reset_password') { + await this.assertPlayerCanResetPassword(params.countryCode, params.phone); + } + const [phoneLimited, ipLimited] = await Promise.all([ this.redis.exists(phoneRateKey(phone)), this.redis.exists(ipRateKey(params.clientIp)), @@ -53,7 +63,10 @@ export class SmsService { const code = generateSixDigitCode(); const sessionId = randomUUID(); const lang = mapLocaleToSmsLang(params.locale); - const msg = renderVerifySms(lang, code); + const msg = + purpose === 'reset_password' + ? renderResetPasswordSms(lang, code) + : renderVerifySms(lang, code); const result = await this.chuanglan.sendSms(phone, msg, sessionId); if (!result.success) { @@ -63,7 +76,7 @@ export class SmsService { await Promise.all([ this.redis.set( codeKey(sessionId), - JSON.stringify({ phone, code }), + JSON.stringify({ phone, code, purpose }), this.smsCfg.codeTtlSeconds, ), this.redis.set(phoneRateKey(phone), '1', this.smsCfg.rateLimitSeconds), @@ -78,6 +91,7 @@ export class SmsService { countryCode: string; code: string; sessionId: string; + expectedPurpose?: SmsPurpose; }): Promise { const phone = normalizePhone(params.countryCode, params.phone); const raw = await this.redis.get(codeKey(params.sessionId)); @@ -85,11 +99,43 @@ export class SmsService { throw appBadRequest('SMS_CODE_EXPIRED'); } - const cached = JSON.parse(raw) as { phone: string; code: string }; + const cached = JSON.parse(raw) as { phone: string; code: string; purpose?: SmsPurpose }; + const cachedPurpose: SmsPurpose = cached.purpose ?? 'register'; + const expectedPurpose = params.expectedPurpose ?? 'register'; + + if (cachedPurpose !== expectedPurpose) { + throw appBadRequest('SMS_CODE_INVALID'); + } if (cached.phone !== phone || cached.code !== params.code.trim()) { throw appBadRequest('SMS_CODE_INVALID'); } await this.redis.del(codeKey(params.sessionId)); } + + private async assertPlayerCanResetPassword(countryCode: string, phone: string): Promise { + const dial = countryCode.replace(/\D/g, ''); + const phoneLocal = stripLocalPhoneDigits(phone); + + const pref = await this.prisma.userPreference.findFirst({ + where: { + phoneCountryDial: dial, + phoneLocal, + user: { deletedAt: null, userType: 'PLAYER' }, + }, + include: { user: { select: { status: true } } }, + }); + + if (!pref) { + throw appBadRequest('PHONE_NOT_REGISTERED'); + } + if (pref.user.status === 'DISABLED') { + throw appForbidden('ACCOUNT_DISABLED'); + } + + const settings = await this.systemConfig.getPlayerAccountSettings(); + if (!settings.allowPasswordChange) { + throw appForbidden('PASSWORD_CHANGE_DISABLED'); + } + } } diff --git a/apps/api/src/domains/identity/sms/sms.types.ts b/apps/api/src/domains/identity/sms/sms.types.ts index 7c83697..34527a6 100644 --- a/apps/api/src/domains/identity/sms/sms.types.ts +++ b/apps/api/src/domains/identity/sms/sms.types.ts @@ -1,5 +1,7 @@ export type SmsLang = 'zh' | 'en' | 'vi' | 'ms' | 'kh'; +export type SmsPurpose = 'register' | 'reset_password'; + export interface ChuanglanSendResponse { code: string; message: string; diff --git a/apps/api/src/domains/identity/sms/templates.ts b/apps/api/src/domains/identity/sms/templates.ts index 7e2488f..891842b 100644 --- a/apps/api/src/domains/identity/sms/templates.ts +++ b/apps/api/src/domains/identity/sms/templates.ts @@ -14,3 +14,18 @@ export function renderVerifySms(lang: SmsLang | undefined, code: string): string const tpl = TEMPLATES[key] ?? TEMPLATES.default ?? TEMPLATES.zh; return tpl.replace('{code}', code); } + +const RESET_TEMPLATES: Record = { + default: '您正在重置密码,验证码:{code}。5分钟内有效,请勿泄露。', + zh: '您正在重置密码,验证码:{code}。5分钟内有效,请勿泄露。', + en: 'Password reset code: {code}. Valid for 5 minutes. Do not share.', + vi: 'Mã đặt lại mật khẩu: {code}. Có hiệu lực trong 5 phút.', + ms: 'Kod tetapan semula kata laluan: {code}. Sah selama 5 minit.', + kh: 'កូដកំណត់ពាក្យសម្ងាត់ឡើងវិញ៖ {code} ។ មានសុពលភាព ៥ នាទី។', +}; + +export function renderResetPasswordSms(lang: SmsLang | undefined, code: string): string { + const key = lang?.trim() || 'zh'; + const tpl = RESET_TEMPLATES[key] ?? RESET_TEMPLATES.default ?? RESET_TEMPLATES.zh; + return tpl.replace('{code}', code); +} diff --git a/apps/player/src/composables/useSmsCode.ts b/apps/player/src/composables/useSmsCode.ts index bed65d3..1190d88 100644 --- a/apps/player/src/composables/useSmsCode.ts +++ b/apps/player/src/composables/useSmsCode.ts @@ -30,7 +30,11 @@ export function useSmsCode() { }, 1000); } - async function send(phone: string, countryCode: string) { + async function send( + phone: string, + countryCode: string, + purpose: 'register' | 'reset_password' = 'register', + ) { if (countdown.value > 0 || sending.value) return; const trimmed = phone.trim(); const dial = countryCode.replace(/\D/g, ''); @@ -46,6 +50,7 @@ export function useSmsCode() { phone: trimmed, countryCode: dial, locale, + purpose, }); sessionId.value = data.data.sessionId; startCountdown(); diff --git a/apps/player/src/i18n/en-US.ts b/apps/player/src/i18n/en-US.ts index 974e646..895f347 100644 --- a/apps/player/src/i18n/en-US.ts +++ b/apps/player/src/i18n/en-US.ts @@ -91,6 +91,7 @@ export default { login_username_placeholder: 'Phone number or username', confirm_password: 'Confirm password', password_mismatch: 'Passwords do not match', + password_min_length: 'Password must be at least 8 characters', password_placeholder: 'Enter password', login_btn: 'Log In', login_failed: 'Login failed, please try again', @@ -108,6 +109,13 @@ export default { resend_sms: 'Retry in {sec}s', country_search: 'Search country or code', country_not_found: 'No matching country', + forgot_password: 'Forgot password?', + forgot_password_title: 'Reset password', + reset_password_btn: 'Reset password', + reset_success: 'Password reset. Please sign in with your new password. Contact your agent if you cannot log in.', + reset_failed: 'Reset failed. Please try again.', + phone_not_registered: 'This phone number is not registered', + back_to_login: 'Back to login', }, support: { short: 'Support', diff --git a/apps/player/src/i18n/ms-MY.ts b/apps/player/src/i18n/ms-MY.ts index 6df0db4..343131d 100644 --- a/apps/player/src/i18n/ms-MY.ts +++ b/apps/player/src/i18n/ms-MY.ts @@ -97,6 +97,7 @@ export default { login_username_placeholder: 'Nombor telefon atau akaun', confirm_password: 'Sahkan kata laluan', password_mismatch: 'Kata laluan tidak sepadan', + password_min_length: 'Kata laluan mestilah sekurang-kurangnya 8 aksara', password_placeholder: 'Masukkan kata laluan', login_btn: 'Log Masuk', login_failed: 'Log masuk gagal, sila cuba lagi', @@ -114,6 +115,13 @@ export default { resend_sms: 'Cuba lagi dalam {sec}s', country_search: 'Cari negara atau kod', country_not_found: 'Tiada negara sepadan', + forgot_password: 'Lupa kata laluan?', + forgot_password_title: 'Tetapkan semula kata laluan', + reset_password_btn: 'Tetapkan semula', + reset_success: 'Kata laluan ditetapkan semula. Sila log masuk dengan kata laluan baharu. Hubungi ejen anda jika tidak dapat log masuk.', + reset_failed: 'Gagal menetapkan semula. Sila cuba lagi.', + phone_not_registered: 'Nombor telefon ini belum didaftarkan', + back_to_login: 'Kembali ke log masuk', }, support: { short: 'Sokongan', diff --git a/apps/player/src/i18n/zh-CN.ts b/apps/player/src/i18n/zh-CN.ts index f89aec7..aa9e18b 100644 --- a/apps/player/src/i18n/zh-CN.ts +++ b/apps/player/src/i18n/zh-CN.ts @@ -91,6 +91,7 @@ export default { login_username_placeholder: '手机号或账号', confirm_password: '确认密码', password_mismatch: '两次密码不一致', + password_min_length: '密码至少 8 位', password_placeholder: '请输入密码', login_btn: '登录', login_failed: '登录失败,请重试', @@ -108,6 +109,13 @@ export default { resend_sms: '{sec}s 后重试', country_search: '搜索国家或区号', country_not_found: '未找到匹配国家', + forgot_password: '忘记密码?', + forgot_password_title: '重置密码', + reset_password_btn: '重置密码', + reset_success: '密码已重置,请使用新密码登录。若无法登录请联系代理。', + reset_failed: '重置失败,请重试', + phone_not_registered: '该手机号未注册', + back_to_login: '返回登录', }, support: { short: '客服', diff --git a/apps/player/src/router/index.ts b/apps/player/src/router/index.ts index 0333502..14e4506 100644 --- a/apps/player/src/router/index.ts +++ b/apps/player/src/router/index.ts @@ -6,6 +6,7 @@ const router = createRouter({ routes: [ { path: '/login', component: () => import('../views/LoginView.vue') }, { path: '/register', component: () => import('../views/RegisterView.vue') }, + { path: '/forgot-password', component: () => import('../views/ForgotPasswordView.vue') }, { path: '/', component: () => import('../layouts/MainLayout.vue'), @@ -34,7 +35,7 @@ const router = createRouter({ router.beforeEach((to) => { const auth = useAuthStore(); - if ((to.path === '/login' || to.path === '/register') && auth.token) return '/'; + if ((to.path === '/login' || to.path === '/register' || to.path === '/forgot-password') && auth.token) return '/'; // 需要登录的页面 — 未登录时弹出登录提示,留在当前页 if (to.meta.requiresAuth && !auth.token) { auth.showLoginPrompt(to.fullPath); diff --git a/apps/player/src/views/ForgotPasswordView.vue b/apps/player/src/views/ForgotPasswordView.vue new file mode 100644 index 0000000..4e3445f --- /dev/null +++ b/apps/player/src/views/ForgotPasswordView.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/apps/player/src/views/LoginView.vue b/apps/player/src/views/LoginView.vue index 2c37335..117170a 100644 --- a/apps/player/src/views/LoginView.vue +++ b/apps/player/src/views/LoginView.vue @@ -1,5 +1,5 @@