feat(admin): 管理端列表分页、控制台图表与赛事导入

- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 13:49:31 +08:00
parent 2c356b2048
commit 80adc0e928
45 changed files with 6564 additions and 499 deletions

View File

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