API: - 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板 - 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数 - User 表增加 visible_menus 字段;新增 player_messages 表及迁移 - 充值审核通过/拒绝时按系统配置自动写入玩家站内消息 - 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count - 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置 - 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管) - SystemConfig 增加 inbox 功能开关及各类通知开关 Admin: - 员工管理:按角色默认菜单 + 可勾选可见菜单项 - ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询 - Contents:富文本编辑器、图片字段组件重构 - DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列 - 多页面 i18n 与权限细节调整 Player: - 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除 - 公告列表与详情页;走马灯可跳转详情 - 客服 Modal 改为 Panel,与邮箱 Hub 整合 - 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认 - HomeView 今日赛事板块;FootballView 等体验优化 Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码 Docs: 玩家端缺失功能分析文档 Chore: 移除 .agents/skills 设计类 skill 文件 Co-authored-by: Cursor <cursoragent@cursor.com>
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@thebet365/shared';
|
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
|
import { AgentsService } from '../agent/agents.service';
|
|
import { CashbackService } from '../operations/cashback/cashback.service';
|
|
import { PresenceService } from '../presence/presence.service';
|
|
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
|
import { Decimal } from '@prisma/client/runtime/library';
|
|
|
|
export type PlayerListFilters = {
|
|
keyword?: string;
|
|
parentId?: bigint;
|
|
platformDirect?: boolean;
|
|
status?: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class UsersService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private agents: AgentsService,
|
|
private systemConfig: SystemConfigService,
|
|
private cashback: CashbackService,
|
|
private presence: PresenceService,
|
|
) {}
|
|
|
|
private buildAffiliationAgents(
|
|
parent?: {
|
|
username: string;
|
|
agentLevel: number | null;
|
|
parent?: { username: string; agentLevel: number | null; parent?: unknown } | null;
|
|
} | null,
|
|
chain?: string[],
|
|
): string[] {
|
|
if (chain?.length) return chain;
|
|
if (!parent) return [];
|
|
const ancestors: string[] = [];
|
|
let cur: typeof parent | null | undefined = parent;
|
|
while (cur) {
|
|
ancestors.unshift(cur.username);
|
|
cur = cur.parent as typeof parent | null | undefined;
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
private async buildAffiliationChainMap(parentIds: (bigint | null | undefined)[]) {
|
|
const map = new Map<string, string[]>();
|
|
const pending = new Set<bigint>();
|
|
const cache = new Map<string, { username: string; parentId: bigint | null }>();
|
|
|
|
for (const pid of parentIds) {
|
|
if (pid) pending.add(pid);
|
|
}
|
|
|
|
while (pending.size > 0) {
|
|
const batch = [...pending];
|
|
pending.clear();
|
|
const agents = await this.prisma.user.findMany({
|
|
where: { id: { in: batch }, userType: 'AGENT', deletedAt: null },
|
|
select: { id: true, username: true, parentId: true },
|
|
});
|
|
for (const agent of agents) {
|
|
cache.set(agent.id.toString(), { username: agent.username, parentId: agent.parentId });
|
|
if (agent.parentId && !cache.has(agent.parentId.toString())) {
|
|
pending.add(agent.parentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const build = (startId: bigint | null | undefined): string[] => {
|
|
const chain: string[] = [];
|
|
let cur = startId ?? null;
|
|
while (cur) {
|
|
const hit = cache.get(cur.toString());
|
|
if (!hit) break;
|
|
chain.unshift(hit.username);
|
|
cur = hit.parentId;
|
|
}
|
|
return chain;
|
|
};
|
|
|
|
for (const pid of parentIds) {
|
|
if (pid) map.set(pid.toString(), build(pid));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
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;
|
|
managedPassword?: string | null;
|
|
} | null;
|
|
parent?: {
|
|
username: string;
|
|
agentLevel: number | null;
|
|
parent?: { username: string; agentLevel: number | null } | null;
|
|
} | null;
|
|
auth?: { lastLoginAt: Date | null } | null;
|
|
usedInvite?: { code: string } | null;
|
|
},
|
|
bet?: { count: number; totalStake: string; totalReturn: string },
|
|
affiliationChain?: string[],
|
|
) {
|
|
const affiliationAgents = this.buildAffiliationAgents(u.parent, affiliationChain);
|
|
return {
|
|
id: u.id.toString(),
|
|
username: u.username,
|
|
status: u.status,
|
|
locale: u.locale,
|
|
parentId: u.parentId?.toString() ?? null,
|
|
parentUsername: u.parent?.username ?? null,
|
|
affiliationAgents,
|
|
inviteCode: u.usedInvite?.code ?? 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,
|
|
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({
|
|
where: { id },
|
|
include: { wallet: true, agentProfile: true, preferences: true },
|
|
});
|
|
}
|
|
|
|
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 appNotFound('USER_NOT_FOUND');
|
|
|
|
if (data.username !== undefined) {
|
|
const nextUsername = data.username.trim();
|
|
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
|
|
try {
|
|
assertPlayerUsername(nextUsername);
|
|
} catch {
|
|
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
|
}
|
|
const settings = await this.systemConfig.getPlayerAccountSettings();
|
|
if (!settings.allowUsernameChange) {
|
|
throw appForbidden('USERNAME_CHANGE_DISABLED');
|
|
}
|
|
if (nextUsername !== user.username) {
|
|
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
|
if (taken) throw appBadRequest('USERNAME_TAKEN');
|
|
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 appBadRequest('INVALID_AVATAR');
|
|
}
|
|
}
|
|
|
|
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: phone ?? null,
|
|
email: email ?? null,
|
|
...(avatarKey !== undefined ? { avatarKey } : {}),
|
|
},
|
|
update: {
|
|
...(phone !== undefined ? { phone } : {}),
|
|
...(email !== undefined ? { email } : {}),
|
|
...(avatarKey !== undefined ? { avatarKey } : {}),
|
|
},
|
|
});
|
|
return this.findById(userId);
|
|
}
|
|
|
|
async updateLocale(userId: bigint, locale: string) {
|
|
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
|
throw appBadRequest('UNSUPPORTED_LOCALE');
|
|
}
|
|
await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: { locale },
|
|
});
|
|
await this.prisma.userPreference.upsert({
|
|
where: { userId },
|
|
create: { userId, locale },
|
|
update: { locale },
|
|
});
|
|
return { locale };
|
|
}
|
|
|
|
async listPlayers(
|
|
page = 1,
|
|
pageSize = 10,
|
|
filters: PlayerListFilters = {},
|
|
) {
|
|
const where: {
|
|
userType: string;
|
|
deletedAt: null;
|
|
parentId?: bigint | null;
|
|
status?: string;
|
|
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
|
|
} = {
|
|
userType: 'PLAYER',
|
|
deletedAt: null,
|
|
};
|
|
if (filters.platformDirect) {
|
|
where.parentId = null;
|
|
} else 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 [rows, total] = await Promise.all([
|
|
this.prisma.user.findMany({
|
|
where,
|
|
include: {
|
|
wallet: true,
|
|
preferences: true,
|
|
parent: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
agentLevel: true,
|
|
parent: { select: { username: true, agentLevel: true } },
|
|
},
|
|
},
|
|
auth: { select: { lastLoginAt: true } },
|
|
usedInvite: { select: { code: true } },
|
|
},
|
|
skip,
|
|
take: pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
this.prisma.user.count({ where }),
|
|
]);
|
|
|
|
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
|
|
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
|
|
const onlineSet = await this.presence.filterOnlineIds(rows.map((r) => r.id));
|
|
return {
|
|
items: rows.map((u) => {
|
|
const row = this.formatPlayerRow(
|
|
u,
|
|
betMap.get(u.id.toString()),
|
|
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
|
|
);
|
|
return {
|
|
...row,
|
|
isOnline: onlineSet.has(row.id),
|
|
};
|
|
}),
|
|
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,
|
|
parent: { select: { username: true, agentLevel: true } },
|
|
},
|
|
},
|
|
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
|
|
usedInvite: { select: { code: true } },
|
|
},
|
|
});
|
|
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
|
|
|
|
const affiliationMap = await this.buildAffiliationChainMap([user.parentId]);
|
|
const [betCount, betStake, customCashbackRate, defaultCashbackRate] = await Promise.all([
|
|
this.prisma.bet.count({ where: { userId: playerId } }),
|
|
this.prisma.bet.aggregate({
|
|
where: { userId: playerId },
|
|
_sum: { stake: true, actualReturn: true },
|
|
}),
|
|
this.cashback.getPlayerCustomCashbackRate(playerId),
|
|
this.cashback.resolvePlayerDefaultCashbackRate({
|
|
parentId: user.parentId,
|
|
inviteSponsorId: user.inviteSponsorId,
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
...this.formatPlayerRow(
|
|
user,
|
|
undefined,
|
|
user.parentId ? affiliationMap.get(user.parentId.toString()) : undefined,
|
|
),
|
|
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',
|
|
customCashbackRate: customCashbackRate?.toString() ?? null,
|
|
defaultCashbackRate: defaultCashbackRate.toString(),
|
|
};
|
|
}
|
|
|
|
async updatePlayerAdmin(
|
|
playerId: bigint,
|
|
data: {
|
|
status?: string;
|
|
locale?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
username?: string;
|
|
password?: string;
|
|
cashbackRate?: number | null;
|
|
},
|
|
) {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
|
include: { auth: true },
|
|
});
|
|
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
|
|
|
|
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
|
throw appBadRequest('INVALID_STATUS');
|
|
}
|
|
|
|
if (data.username !== undefined) {
|
|
const nextUsername = data.username.trim();
|
|
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
|
|
try {
|
|
assertPlayerUsername(nextUsername);
|
|
} catch {
|
|
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
|
}
|
|
if (nextUsername !== user.username) {
|
|
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
|
if (taken) throw appBadRequest('USERNAME_TAKEN');
|
|
await this.prisma.user.update({
|
|
where: { id: playerId },
|
|
data: { username: nextUsername },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (data.password !== undefined) {
|
|
const nextPassword = data.password;
|
|
if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
|
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
|
|
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 },
|
|
data: { status: data.status },
|
|
});
|
|
}
|
|
|
|
if (data.locale) {
|
|
await this.prisma.user.update({
|
|
where: { id: playerId },
|
|
data: { locale: data.locale },
|
|
});
|
|
}
|
|
|
|
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: prefPatch.phone ?? null,
|
|
email: prefPatch.email ?? null,
|
|
},
|
|
update: prefPatch,
|
|
});
|
|
}
|
|
|
|
if (data.cashbackRate !== undefined) {
|
|
if (data.cashbackRate != null && (!Number.isFinite(data.cashbackRate) || data.cashbackRate < 0)) {
|
|
throw appBadRequest('CASHBACK_RATE_NEGATIVE');
|
|
}
|
|
const rate =
|
|
data.cashbackRate != null && data.cashbackRate > 0
|
|
? new Decimal(data.cashbackRate)
|
|
: null;
|
|
await this.cashback.setPlayerCustomCashbackRate(playerId, rate);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
}
|
|
|
|
async softDeletePlayer(playerId: bigint) {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: playerId, deletedAt: null },
|
|
});
|
|
if (!user) throw appNotFound('USER_NOT_FOUND');
|
|
if (user.userType !== 'PLAYER') {
|
|
throw appBadRequest('NOT_PLAYER');
|
|
}
|
|
// Block deletion when the player has any unresolved bets
|
|
const betCount = await this.prisma.bet.count({
|
|
where: {
|
|
userId: playerId,
|
|
status: 'PENDING',
|
|
},
|
|
});
|
|
if (betCount > 0) {
|
|
throw appBadRequest('PLAYER_HAS_PENDING_BETS');
|
|
}
|
|
// Block deletion when wallet still has balance
|
|
const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
|
|
if (wallet) {
|
|
const available = new Decimal(wallet.availableBalance);
|
|
const frozen = new Decimal(wallet.frozenBalance);
|
|
if (available.gt(0) || frozen.gt(0)) {
|
|
throw appBadRequest('PLAYER_HAS_BALANCE');
|
|
}
|
|
}
|
|
return this.prisma.user.update({
|
|
where: { id: playerId },
|
|
data: { deletedAt: new Date(), status: 'SUSPENDED' },
|
|
});
|
|
}
|
|
}
|