Files
thebet365/apps/api/src/domains/identity/users.service.ts
Mars f9343b00af feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
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>
2026-06-17 17:51:10 +08:00

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