feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
|
||||
export type PlayerListFilters = {
|
||||
keyword?: string;
|
||||
parentId?: bigint;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private agents: AgentsService,
|
||||
) {}
|
||||
|
||||
private formatPlayerRow(
|
||||
u: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
status: string;
|
||||
locale: string;
|
||||
parentId: bigint | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null;
|
||||
preferences?: { phone: string | null; email: string | null } | null;
|
||||
parent?: { username: string } | null;
|
||||
auth?: { lastLoginAt: Date | null } | null;
|
||||
},
|
||||
bet?: { count: number; totalStake: string; totalReturn: string },
|
||||
) {
|
||||
return {
|
||||
id: u.id.toString(),
|
||||
username: u.username,
|
||||
status: u.status,
|
||||
locale: u.locale,
|
||||
parentId: u.parentId?.toString() ?? null,
|
||||
parentUsername: u.parent?.username ?? null,
|
||||
phone: u.preferences?.phone ?? null,
|
||||
email: u.preferences?.email ?? null,
|
||||
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
|
||||
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
|
||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||
betCount: bet?.count ?? 0,
|
||||
totalStake: bet?.totalStake ?? '0',
|
||||
totalReturn: bet?.totalReturn ?? '0',
|
||||
createdAt: u.createdAt,
|
||||
updatedAt: u.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadBetStatsMap(userIds: bigint[]) {
|
||||
if (userIds.length === 0) return new Map<string, { count: number; totalStake: string; totalReturn: string }>();
|
||||
|
||||
const groups = await this.prisma.bet.groupBy({
|
||||
by: ['userId'],
|
||||
where: { userId: { in: userIds } },
|
||||
_count: { _all: true },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
});
|
||||
|
||||
return new Map(
|
||||
groups.map((g) => [
|
||||
g.userId.toString(),
|
||||
{
|
||||
count: g._count._all,
|
||||
totalStake: g._sum.stake?.toString() ?? '0',
|
||||
totalReturn: g._sum.actualReturn?.toString() ?? '0',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async findById(id: bigint) {
|
||||
return this.prisma.user.findUnique({
|
||||
@@ -36,19 +104,170 @@ export class UsersService {
|
||||
return { locale };
|
||||
}
|
||||
|
||||
async listPlayers(page = 1, pageSize = 20, parentId?: bigint) {
|
||||
const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) };
|
||||
async listPlayers(
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
filters: PlayerListFilters = {},
|
||||
) {
|
||||
const where: {
|
||||
userType: string;
|
||||
deletedAt: null;
|
||||
parentId?: bigint;
|
||||
status?: string;
|
||||
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
|
||||
} = {
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
};
|
||||
if (filters.parentId) where.parentId = filters.parentId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.keyword?.trim()) {
|
||||
const kw = filters.keyword.trim();
|
||||
where.OR = [{ username: { contains: kw, mode: 'insensitive' } }];
|
||||
}
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
include: { wallet: true },
|
||||
include: {
|
||||
wallet: true,
|
||||
preferences: true,
|
||||
parent: { select: { id: true, username: true } },
|
||||
auth: { select: { lastLoginAt: true } },
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
|
||||
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
||||
return {
|
||||
items: rows.map((u) => this.formatPlayerRow(u, betMap.get(u.id.toString()))),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getPlayerAdminDetail(playerId: bigint) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: {
|
||||
wallet: true,
|
||||
preferences: true,
|
||||
parent: { select: { id: true, username: true, agentLevel: true } },
|
||||
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('玩家不存在');
|
||||
|
||||
const [betCount, betStake] = await Promise.all([
|
||||
this.prisma.bet.count({ where: { userId: playerId } }),
|
||||
this.prisma.bet.aggregate({
|
||||
where: { userId: playerId },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...this.formatPlayerRow(user),
|
||||
lastLoginAt: user.auth?.lastLoginAt ?? null,
|
||||
loginFailCount: user.auth?.loginFailCount ?? 0,
|
||||
lockedUntil: user.auth?.lockedUntil ?? null,
|
||||
betCount,
|
||||
totalStake: betStake._sum.stake?.toString() ?? '0',
|
||||
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
|
||||
};
|
||||
}
|
||||
|
||||
async updatePlayerAdmin(
|
||||
playerId: bigint,
|
||||
data: {
|
||||
status?: string;
|
||||
locale?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parentId?: string | null;
|
||||
},
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
});
|
||||
if (!user) throw new NotFoundException('玩家不存在');
|
||||
|
||||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { status: data.status },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.parentId !== undefined) {
|
||||
const newParentId =
|
||||
data.parentId === null || data.parentId === ''
|
||||
? null
|
||||
: BigInt(data.parentId);
|
||||
|
||||
if (newParentId !== null) {
|
||||
const parent = await this.prisma.user.findUnique({
|
||||
where: { id: newParentId },
|
||||
});
|
||||
if (!parent || parent.userType !== 'AGENT') {
|
||||
throw new BadRequestException('上级必须为代理账号');
|
||||
}
|
||||
}
|
||||
|
||||
const oldParentId = user.parentId;
|
||||
const changed =
|
||||
(oldParentId?.toString() ?? null) !== (newParentId?.toString() ?? null);
|
||||
|
||||
if (changed) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
if (oldParentId) {
|
||||
await this.agents.recalculateUsedCredit(oldParentId);
|
||||
}
|
||||
if (newParentId) {
|
||||
await this.agents.recalculateUsedCredit(newParentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.locale) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { locale: data.locale },
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPlayerAdminDetail(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user