feat(admin,player,api): 玩家账号密码管理与代理上下分
新增玩家头像、可查密码与全局改密/改账号开关;玩家资料页合并账号密码展示;代理直属玩家列表支持自定义上下分。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -626,6 +626,7 @@ export class AgentsService {
|
||||
locale,
|
||||
phone: data.phone?.trim() || null,
|
||||
email: data.email?.trim() || null,
|
||||
managedPassword: data.password,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -20,6 +21,7 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private config: ConfigService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
||||
@@ -107,6 +109,11 @@ export class AuthService {
|
||||
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
|
||||
if (!auth) throw new UnauthorizedException('User not found');
|
||||
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
if (!settings.allowPasswordChange) {
|
||||
throw new ForbiddenException('当前平台未开放玩家自行修改密码');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
|
||||
if (!valid) throw new UnauthorizedException('Invalid old password');
|
||||
|
||||
@@ -115,6 +122,10 @@ export class AuthService {
|
||||
where: { userId },
|
||||
data: { passwordHash: hash },
|
||||
});
|
||||
await this.prisma.userPreference.updateMany({
|
||||
where: { userId },
|
||||
data: { managedPassword: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SUPPORTED_LOCALES } from '@thebet365/shared';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { SUPPORTED_LOCALES, isValidAvatarKey } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
|
||||
export type PlayerListFilters = {
|
||||
@@ -14,6 +16,7 @@ export class UsersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private agents: AgentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private formatPlayerRow(
|
||||
@@ -26,7 +29,11 @@ export class UsersService {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null;
|
||||
preferences?: { phone: string | null; email: string | null } | null;
|
||||
preferences?: {
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
managedPassword?: string | null;
|
||||
} | null;
|
||||
parent?: { username: string } | null;
|
||||
auth?: { lastLoginAt: Date | null } | null;
|
||||
},
|
||||
@@ -41,6 +48,7 @@ export class UsersService {
|
||||
parentUsername: u.parent?.username ?? null,
|
||||
phone: u.preferences?.phone ?? null,
|
||||
email: u.preferences?.email ?? null,
|
||||
managedPassword: u.preferences?.managedPassword ?? null,
|
||||
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
|
||||
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
|
||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||
@@ -81,13 +89,61 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(userId: bigint, data: { phone?: string; email?: string }) {
|
||||
const phone = data.phone?.trim() || null;
|
||||
const email = data.email?.trim() || null;
|
||||
async updateProfile(
|
||||
userId: bigint,
|
||||
data: { phone?: string; email?: string; avatarKey?: string | null; username?: string },
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { preferences: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
if (data.username !== undefined) {
|
||||
const nextUsername = data.username.trim();
|
||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
if (!settings.allowUsernameChange) {
|
||||
throw new ForbiddenException('当前平台未开放玩家自行修改账号名称');
|
||||
}
|
||||
if (nextUsername !== user.username) {
|
||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { username: nextUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const phone = data.phone !== undefined ? data.phone.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email.trim() || null : undefined;
|
||||
let avatarKey: string | null | undefined;
|
||||
if (data.avatarKey !== undefined) {
|
||||
avatarKey = data.avatarKey?.trim() || null;
|
||||
if (avatarKey && !isValidAvatarKey(avatarKey)) {
|
||||
throw new BadRequestException('无效头像');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.prisma.userPreference.findUnique({ where: { userId } });
|
||||
if (!existing && phone === undefined && email === undefined && avatarKey === undefined) {
|
||||
return this.findById(userId);
|
||||
}
|
||||
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, phone, email },
|
||||
update: { phone, email },
|
||||
create: {
|
||||
userId,
|
||||
phone: phone ?? null,
|
||||
email: email ?? null,
|
||||
...(avatarKey !== undefined ? { avatarKey } : {}),
|
||||
},
|
||||
update: {
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
...(avatarKey !== undefined ? { avatarKey } : {}),
|
||||
},
|
||||
});
|
||||
return this.findById(userId);
|
||||
}
|
||||
@@ -195,10 +251,13 @@ export class UsersService {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parentId?: string | null;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { auth: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException('玩家不存在');
|
||||
|
||||
@@ -206,6 +265,35 @@ export class UsersService {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
if (data.username !== undefined) {
|
||||
const nextUsername = data.username.trim();
|
||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||
if (nextUsername !== user.username) {
|
||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { username: nextUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.password !== undefined) {
|
||||
const nextPassword = data.password;
|
||||
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
|
||||
if (!user.auth) throw new BadRequestException('账号认证信息缺失');
|
||||
const hash = await bcrypt.hash(nextPassword, 10);
|
||||
await this.prisma.userAuth.update({
|
||||
where: { userId: playerId },
|
||||
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||||
});
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: { userId: playerId, managedPassword: nextPassword },
|
||||
update: { managedPassword: nextPassword },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
@@ -253,25 +341,43 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
||||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
||||
const prefPatch: {
|
||||
locale?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
} = {};
|
||||
|
||||
if (data.locale) prefPatch.locale = data.locale;
|
||||
if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null;
|
||||
if (data.email !== undefined) prefPatch.email = data.email.trim() || null;
|
||||
|
||||
if (Object.keys(prefPatch).length > 0) {
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: {
|
||||
userId: playerId,
|
||||
locale: data.locale ?? user.locale,
|
||||
phone: phone ?? null,
|
||||
email: email ?? null,
|
||||
},
|
||||
update: {
|
||||
...(data.locale ? { locale: data.locale } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
phone: prefPatch.phone ?? null,
|
||||
email: prefPatch.email ?? null,
|
||||
},
|
||||
update: prefPatch,
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPlayerAdminDetail(playerId);
|
||||
}
|
||||
|
||||
async getPlayerAccountPermissions() {
|
||||
return this.systemConfig.getPlayerAccountSettings();
|
||||
}
|
||||
|
||||
async clearManagedPassword(userId: bigint) {
|
||||
const pref = await this.prisma.userPreference.findUnique({ where: { userId } });
|
||||
if (pref?.managedPassword) {
|
||||
await this.prisma.userPreference.update({
|
||||
where: { userId },
|
||||
data: { managedPassword: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user