feat(admin,player,api): 玩家账号密码管理与代理上下分

新增玩家头像、可查密码与全局改密/改账号开关;玩家资料页合并账号密码展示;代理直属玩家列表支持自定义上下分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 11:36:53 +08:00
parent f76728dc3e
commit a8e4ead618
81 changed files with 1763 additions and 217 deletions

View File

@@ -626,6 +626,7 @@ export class AgentsService {
locale,
phone: data.phone?.trim() || null,
email: data.email?.trim() || null,
managedPassword: data.password,
},
});

View File

@@ -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 };
}

View File

@@ -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 },
});
}
}
}