feat(player): 玩家端短信找回密码
支持手机号验证码重置密码,重置成功后跳转登录页;SMS 增加 reset_password 场景与 purpose 隔离。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user