feat(player): 玩家端短信找回密码

支持手机号验证码重置密码,重置成功后跳转登录页;SMS 增加 reset_password 场景与 purpose 隔离。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 13:12:00 +08:00
parent e140861a2b
commit ff89c31b51
16 changed files with 597 additions and 13 deletions

View File

@@ -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<string> {
return bcrypt.hash(password, 10);
}