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

@@ -11,7 +11,7 @@
"test:cov": "jest --coverage",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:migrate:deploy": "prisma migrate deploy && prisma generate",
"db:seed": "ts-node prisma/seed.ts",
"db:studio": "prisma studio"
},

View File

@@ -0,0 +1,2 @@
-- AlterTable: user_preferences 增加头像(内置球员 key
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "avatar_key" VARCHAR(128);

View File

@@ -0,0 +1,4 @@
-- AlterTable: 玩家账号权限与后台可查密码
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "allow_password_change" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "allow_username_change" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "managed_password" VARCHAR(128);

View File

@@ -52,13 +52,17 @@ model UserAuth {
}
model UserPreference {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
locale String @default("en-US") @db.VarChar(10)
phone String? @db.VarChar(32)
email String? @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
locale String @default("en-US") @db.VarChar(10)
phone String? @db.VarChar(32)
email String? @db.VarChar(128)
avatarKey String? @map("avatar_key") @db.VarChar(128)
allowPasswordChange Boolean @default(true) @map("allow_password_change")
allowUsernameChange Boolean @default(false) @map("allow_username_change")
managedPassword String? @map("managed_password") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])

View File

@@ -579,7 +579,7 @@ async function main() {
parentId: agent1.id,
auth: { create: { passwordHash: playerHash } },
wallet: { create: { availableBalance: 1000 } },
preferences: { create: { locale: 'zh-CN' } },
preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
},
update: {},
});

View File

@@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './domains/identity/guards';
import { PrismaModule } from './shared/prisma/prisma.module';
import { SystemConfigModule } from './shared/config/system-config.module';
import { IdentityModule } from './domains/identity/identity.module';
import { AgentsModule } from './domains/agent/agents.module';
import { WalletModule } from './domains/ledger/wallet.module';
@@ -21,6 +22,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
PrismaModule,
SystemConfigModule,
IdentityModule,
AgentsModule,
WalletModule,

View File

@@ -29,6 +29,7 @@ import { AuditService } from '../../domains/operations/audit/audit.service';
import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AdminDashboardService } from './admin-dashboard.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import {
IsString,
IsNumber,
@@ -127,6 +128,25 @@ class UpdatePlayerAdminDto {
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
}
class PlayerAccountSettingsDto {
@IsOptional()
@IsBoolean()
allowPasswordChange?: boolean;
@IsOptional()
@IsBoolean()
allowUsernameChange?: boolean;
}
class CreateAgentAdminDto {
@@ -442,6 +462,7 @@ export class AdminController {
private bets: BetsService,
private prisma: PrismaService,
private readonly dashboardService: AdminDashboardService,
private systemConfig: SystemConfigService,
) {}
@Get('dashboard')
@@ -450,6 +471,28 @@ export class AdminController {
return jsonResponse(overview);
}
@Get('users/settings/account')
async getPlayerAccountSettings() {
const settings = await this.systemConfig.getPlayerAccountSettings();
return jsonResponse(settings);
}
@Put('users/settings/account')
async updatePlayerAccountSettings(
@CurrentUser('id') operatorId: bigint,
@Body() dto: PlayerAccountSettingsDto,
) {
const settings = await this.systemConfig.updatePlayerAccountSettings(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_PLAYER_ACCOUNT_SETTINGS',
module: 'USERS',
afterData: JSON.stringify(settings),
});
return jsonResponse(settings);
}
@Get('users')
async listUsers(
@Query('page') page?: string,

View File

@@ -13,6 +13,7 @@ import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { UsersService } from '../../domains/identity/users.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { OutrightService } from '../../domains/catalog/outright.service';
@@ -72,6 +73,14 @@ class UpdateProfileDto {
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
avatarKey?: string;
@IsOptional()
@IsString()
username?: string;
}
@ApiTags('Player')
@@ -87,12 +96,39 @@ export class PlayerController {
private bets: BetsService,
private content: ContentService,
private cashback: CashbackService,
private systemConfig: SystemConfigService,
) {}
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
const prefs = user.preferences;
const viewablePassword = prefs?.managedPassword ?? null;
const safePrefs = prefs
? (({
managedPassword: _m,
allowPasswordChange: _a,
allowUsernameChange: _b,
...rest
}) => rest)(prefs)
: {};
return {
...user,
id: user.id.toString(),
parentId: user.parentId?.toString() ?? null,
preferences: {
...safePrefs,
viewablePassword,
allowPasswordChange: accountSettings.allowPasswordChange,
allowUsernameChange: accountSettings.allowUsernameChange,
},
};
}
@Get('profile')
async profile(@CurrentUser('id') userId: bigint) {
const user = await this.users.findById(userId);
return jsonResponse(user);
if (!user) return jsonResponse(null);
return jsonResponse(await this.formatPlayerProfile(user));
}
@Post('language')
@@ -104,7 +140,8 @@ export class PlayerController {
@Patch('profile')
async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) {
const user = await this.users.updateProfile(userId, dto);
return jsonResponse(user);
if (!user) return jsonResponse(null);
return jsonResponse(await this.formatPlayerProfile(user));
}
@Get('home')

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

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { SystemConfigService } from './system-config.service';
@Global()
@Module({
providers: [SystemConfigService],
exports: [SystemConfigService],
})
export class SystemConfigModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change';
export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_change';
export type PlayerAccountSettings = {
allowPasswordChange: boolean;
allowUsernameChange: boolean;
};
@Injectable()
export class SystemConfigService {
constructor(private prisma: PrismaService) {}
async getBoolean(key: string, defaultValue: boolean): Promise<boolean> {
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
if (!row) return defaultValue;
return row.configValue === 'true' || row.configValue === '1';
}
async setBoolean(key: string, value: boolean, description?: string) {
await this.prisma.systemConfig.upsert({
where: { configKey: key },
create: {
configKey: key,
configValue: value ? 'true' : 'false',
description,
},
update: { configValue: value ? 'true' : 'false' },
});
}
async getPlayerAccountSettings(): Promise<PlayerAccountSettings> {
const [allowPasswordChange, allowUsernameChange] = await Promise.all([
this.getBoolean(PLAYER_ALLOW_PASSWORD_CHANGE, true),
this.getBoolean(PLAYER_ALLOW_USERNAME_CHANGE, false),
]);
return { allowPasswordChange, allowUsernameChange };
}
async updatePlayerAccountSettings(data: Partial<PlayerAccountSettings>) {
if (data.allowPasswordChange !== undefined) {
await this.setBoolean(
PLAYER_ALLOW_PASSWORD_CHANGE,
data.allowPasswordChange,
'玩家是否可在客户端修改密码',
);
}
if (data.allowUsernameChange !== undefined) {
await this.setBoolean(
PLAYER_ALLOW_USERNAME_CHANGE,
data.allowUsernameChange,
'玩家是否可在客户端修改登录账号名',
);
}
return this.getPlayerAccountSettings();
}
}