feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../shared/common/decorators';
|
||||
import { assertPlayerUsername } from '@thebet365/shared';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
@@ -774,6 +775,7 @@ export class AgentsService {
|
||||
phone: p.user.preferences?.phone ?? null,
|
||||
email: p.user.preferences?.email ?? null,
|
||||
locale: p.user.locale,
|
||||
inviteCode: p.user.inviteCode ?? null,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
};
|
||||
@@ -849,6 +851,7 @@ export class AgentsService {
|
||||
email: profile.user.preferences?.email ?? null,
|
||||
managedPassword: profile.user.preferences?.managedPassword ?? null,
|
||||
locale: profile.user.locale,
|
||||
inviteCode: profile.user.inviteCode ?? null,
|
||||
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
|
||||
loginFailCount: profile.user.auth?.loginFailCount ?? 0,
|
||||
createdAt: profile.createdAt,
|
||||
@@ -1232,6 +1235,7 @@ export class AgentsService {
|
||||
parentId: null,
|
||||
},
|
||||
});
|
||||
await assignInviteCodeWithHistory(tx, userId);
|
||||
|
||||
if (user.preferences) {
|
||||
await tx.userPreference.update({
|
||||
@@ -1295,10 +1299,17 @@ export class AgentsService {
|
||||
) {
|
||||
await this.validateAgentLevel(data.level, data.parentAgentId);
|
||||
|
||||
let resolvedCashbackRate = data.cashbackRate ?? 0;
|
||||
if (data.parentAgentId) {
|
||||
const parentProfile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: data.parentAgentId },
|
||||
select: { cashbackRate: true },
|
||||
});
|
||||
resolvedCashbackRate =
|
||||
data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
|
||||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
cashbackRate: resolvedCashbackRate,
|
||||
maxSingleDeposit: data.maxSingleDeposit,
|
||||
maxDailyDeposit: data.maxDailyDeposit,
|
||||
});
|
||||
@@ -1320,6 +1331,7 @@ export class AgentsService {
|
||||
locale,
|
||||
},
|
||||
});
|
||||
await assignInviteCodeWithHistory(tx, user.id);
|
||||
|
||||
await tx.userAuth.create({
|
||||
data: { userId: user.id, passwordHash: hash },
|
||||
@@ -1340,7 +1352,7 @@ export class AgentsService {
|
||||
level: data.level,
|
||||
parentAgentId: data.parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
cashbackRate: resolvedCashbackRate,
|
||||
maxSingleDeposit,
|
||||
maxDailyDeposit,
|
||||
},
|
||||
@@ -1522,11 +1534,27 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getDirectPlayers(agentId: bigint) {
|
||||
return this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||
include: {
|
||||
wallet: true,
|
||||
usedInvite: { select: { code: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((u) => ({
|
||||
id: u.id.toString(),
|
||||
username: u.username,
|
||||
status: u.status,
|
||||
createdAt: u.createdAt,
|
||||
inviteCode: u.usedInvite?.code ?? null,
|
||||
wallet: u.wallet
|
||||
? {
|
||||
availableBalance: u.wallet.availableBalance.toString(),
|
||||
frozenBalance: u.wallet.frozenBalance.toString(),
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async getChildAgents(agentId: bigint) {
|
||||
|
||||
10
apps/api/src/domains/deposit/deposit.module.ts
Normal file
10
apps/api/src/domains/deposit/deposit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
providers: [DepositService],
|
||||
exports: [DepositService],
|
||||
})
|
||||
export class DepositModule {}
|
||||
440
apps/api/src/domains/deposit/deposit.service.ts
Normal file
440
apps/api/src/domains/deposit/deposit.service.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
|
||||
function generateOrderNo(): string {
|
||||
const ts = Date.now().toString(36).toUpperCase();
|
||||
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `DEP${ts}${rand}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DepositService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
// ============ Payment Methods (Admin CRUD) ============
|
||||
|
||||
async createPaymentMethod(data: {
|
||||
methodType: string;
|
||||
bankName?: string;
|
||||
accountHolder?: string;
|
||||
accountNumber?: string;
|
||||
usdtAddress?: string;
|
||||
qrCodeUrl?: string;
|
||||
displayName?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
showOnPlayer?: boolean;
|
||||
createdBy?: bigint;
|
||||
translations?: {
|
||||
displayName?: Record<string, string>;
|
||||
bankName?: Record<string, string>;
|
||||
};
|
||||
}) {
|
||||
const method = await this.prisma.paymentMethod.create({
|
||||
data: {
|
||||
methodType: data.methodType,
|
||||
bankName: data.bankName,
|
||||
accountHolder: data.accountHolder,
|
||||
accountNumber: data.accountNumber,
|
||||
usdtAddress: data.usdtAddress,
|
||||
qrCodeUrl: data.qrCodeUrl,
|
||||
displayName: data.displayName,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
isActive: data.isActive ?? true,
|
||||
showOnPlayer: data.showOnPlayer ?? true,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
});
|
||||
if (data.translations) {
|
||||
await this.upsertPaymentMethodTranslations(method.id, data.translations);
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
async updatePaymentMethod(
|
||||
id: bigint,
|
||||
data: {
|
||||
bankName?: string;
|
||||
accountHolder?: string;
|
||||
accountNumber?: string;
|
||||
usdtAddress?: string;
|
||||
qrCodeUrl?: string;
|
||||
displayName?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
showOnPlayer?: boolean;
|
||||
translations?: {
|
||||
displayName?: Record<string, string>;
|
||||
bankName?: Record<string, string>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const { translations, ...rest } = data;
|
||||
const method = await this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: rest,
|
||||
});
|
||||
if (translations) {
|
||||
await this.upsertPaymentMethodTranslations(id, translations);
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
async deletePaymentMethod(id: bigint) {
|
||||
return this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: { isActive: false, showOnPlayer: false },
|
||||
});
|
||||
}
|
||||
|
||||
async listPaymentMethods(filters?: { methodType?: string }) {
|
||||
const where: Prisma.PaymentMethodWhereInput = {};
|
||||
if (filters?.methodType) {
|
||||
where.methodType = filters.methodType;
|
||||
}
|
||||
const items = await this.prisma.paymentMethod.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
// Attach translations for admin editing
|
||||
const ids = items.map((m) => m.id);
|
||||
const translations = ids.length
|
||||
? await this.prisma.entityTranslation.findMany({
|
||||
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
|
||||
})
|
||||
: [];
|
||||
const tMap = new Map<string, Record<string, Record<string, string>>>();
|
||||
for (const t of translations) {
|
||||
const key = t.entityId.toString();
|
||||
if (!tMap.has(key)) tMap.set(key, {});
|
||||
const entityMap = tMap.get(key)!;
|
||||
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
|
||||
entityMap[t.fieldName][t.locale] = t.value;
|
||||
}
|
||||
return items.map((m) => ({
|
||||
...m,
|
||||
translations: tMap.get(m.id.toString()) ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
async listPlayerPaymentMethods(methodType?: string, locale?: string) {
|
||||
const where: Prisma.PaymentMethodWhereInput = {
|
||||
isActive: true,
|
||||
showOnPlayer: true,
|
||||
};
|
||||
if (methodType) {
|
||||
where.methodType = methodType;
|
||||
}
|
||||
const items = await this.prisma.paymentMethod.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
methodType: true,
|
||||
bankName: true,
|
||||
accountHolder: true,
|
||||
accountNumber: true,
|
||||
usdtAddress: true,
|
||||
qrCodeUrl: true,
|
||||
displayName: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
// Resolve translations for player locale
|
||||
if (locale && items.length) {
|
||||
const ids = items.map((m) => m.id);
|
||||
const translations = await this.prisma.entityTranslation.findMany({
|
||||
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
|
||||
});
|
||||
const tMap = new Map<string, Record<string, Record<string, string>>>();
|
||||
for (const t of translations) {
|
||||
const key = t.entityId.toString();
|
||||
if (!tMap.has(key)) tMap.set(key, {});
|
||||
const entityMap = tMap.get(key)!;
|
||||
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
|
||||
entityMap[t.fieldName][t.locale] = t.value;
|
||||
}
|
||||
return items.map((m) => {
|
||||
const t = tMap.get(m.id.toString());
|
||||
const resolvedDisplayName = t?.displayName
|
||||
? resolveTranslationFallback(t.displayName, locale) || m.displayName
|
||||
: m.displayName;
|
||||
const resolvedBankName = t?.bankName
|
||||
? resolveTranslationFallback(t.bankName, locale) || m.bankName
|
||||
: m.bankName;
|
||||
return { ...m, displayName: resolvedDisplayName, bankName: resolvedBankName };
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ============ Translation helpers ============
|
||||
|
||||
private async upsertPaymentMethodTranslations(
|
||||
entityId: bigint,
|
||||
translations: {
|
||||
displayName?: Record<string, string>;
|
||||
bankName?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
for (const fieldName of ['displayName', 'bankName'] as const) {
|
||||
const fieldTranslations = translations[fieldName];
|
||||
if (!fieldTranslations) continue;
|
||||
for (const [locale, value] of Object.entries(fieldTranslations)) {
|
||||
await this.prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'PAYMENT_METHOD',
|
||||
entityId,
|
||||
locale,
|
||||
fieldName,
|
||||
},
|
||||
},
|
||||
create: { entityType: 'PAYMENT_METHOD', entityId, locale, fieldName, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Deposit Orders ============
|
||||
|
||||
async createDepositOrder(
|
||||
playerId: bigint,
|
||||
paymentMethodId: bigint,
|
||||
amount: number,
|
||||
screenshotUrl: string,
|
||||
) {
|
||||
const method = await this.prisma.paymentMethod.findUnique({
|
||||
where: { id: paymentMethodId },
|
||||
});
|
||||
if (!method || !method.isActive) {
|
||||
throw appBadRequest('PAYMENT_METHOD_NOT_FOUND');
|
||||
}
|
||||
|
||||
const order = await this.prisma.depositOrder.create({
|
||||
data: {
|
||||
orderNo: generateOrderNo(),
|
||||
playerId,
|
||||
paymentMethodId,
|
||||
methodType: method.methodType,
|
||||
amount: new Decimal(amount),
|
||||
screenshotUrl,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
async getPlayerDepositOrders(playerId: bigint, page = 1, pageSize = 20) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = { playerId };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.depositOrder.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
paymentMethod: {
|
||||
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.depositOrder.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
methodType: o.methodType,
|
||||
amount: o.amount.toString(),
|
||||
screenshotUrl: o.screenshotUrl,
|
||||
status: o.status,
|
||||
approvedAmount: o.approvedAmount?.toString() ?? null,
|
||||
rejectReason: o.rejectReason,
|
||||
remark: o.remark,
|
||||
createdAt: o.createdAt,
|
||||
reviewedAt: o.reviewedAt,
|
||||
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async listDepositOrders(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
methodType?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}) {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.DepositOrderWhereInput = {};
|
||||
|
||||
if (params.status) {
|
||||
where.status = params.status;
|
||||
}
|
||||
|
||||
if (params.methodType) {
|
||||
where.methodType = params.methodType;
|
||||
}
|
||||
|
||||
if (params.dateFrom || params.dateTo) {
|
||||
where.createdAt = {};
|
||||
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
|
||||
if (params.dateTo) where.createdAt.lte = params.dateTo;
|
||||
}
|
||||
|
||||
if (params.keyword?.trim()) {
|
||||
const players = await this.prisma.user.findMany({
|
||||
where: {
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
username: { contains: params.keyword.trim(), mode: 'insensitive' },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
if (!players.length) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
where.playerId = { in: players.map((p) => p.id) };
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.depositOrder.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
paymentMethod: {
|
||||
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.depositOrder.count({ where }),
|
||||
]);
|
||||
|
||||
// Enrich with player usernames and reviewer info
|
||||
const playerIds = [...new Set(rows.map((r) => r.playerId))];
|
||||
const reviewerIds = [...new Set(rows.map((r) => r.reviewerId).filter((id): id is bigint => id != null))];
|
||||
|
||||
const [players, reviewers] = await Promise.all([
|
||||
playerIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: playerIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [],
|
||||
reviewerIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: reviewerIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const playerMap = new Map(players.map((p) => [p.id.toString(), p.username]));
|
||||
const reviewerMap = new Map(reviewers.map((r) => [r.id.toString(), r.username]));
|
||||
|
||||
return {
|
||||
items: rows.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
playerId: o.playerId.toString(),
|
||||
playerUsername: playerMap.get(o.playerId.toString()) ?? null,
|
||||
methodType: o.methodType,
|
||||
amount: o.amount.toString(),
|
||||
screenshotUrl: o.screenshotUrl,
|
||||
status: o.status,
|
||||
approvedAmount: o.approvedAmount?.toString() ?? null,
|
||||
reviewerId: o.reviewerId?.toString() ?? null,
|
||||
reviewerUsername: o.reviewerId ? (reviewerMap.get(o.reviewerId.toString()) ?? null) : null,
|
||||
rejectReason: o.rejectReason,
|
||||
remark: o.remark,
|
||||
createdAt: o.createdAt,
|
||||
reviewedAt: o.reviewedAt,
|
||||
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async approveDepositOrder(
|
||||
orderId: bigint,
|
||||
operatorId: bigint,
|
||||
approvedAmount?: number,
|
||||
remark?: string,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
|
||||
|
||||
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedAmount: creditAmount,
|
||||
reviewerId: operatorId,
|
||||
reviewedAt: new Date(),
|
||||
remark: remark ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Credit player wallet
|
||||
await this.wallet.deposit(
|
||||
order.playerId,
|
||||
creditAmount,
|
||||
operatorId,
|
||||
remark ?? `Deposit order ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT',
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
async rejectDepositOrder(orderId: bigint, operatorId: bigint, reason: string) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
|
||||
|
||||
await this.prisma.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
reviewerId: operatorId,
|
||||
reviewedAt: new Date(),
|
||||
rejectReason: reason,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, UseGuards, Query, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { InvitesService } from './invites.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { LoginDto, ChangePasswordDto } from './auth.dto';
|
||||
import { LoginDto, ChangePasswordDto, RegisterDto, GenerateInviteDto } from './auth.dto';
|
||||
import { Public, CurrentUser } from '../../shared/common/decorators';
|
||||
import { JwtAuthGuard } from './guards';
|
||||
import { jsonResponse } from '../../shared/common/filters';
|
||||
@@ -12,6 +13,7 @@ import { jsonResponse } from '../../shared/common/filters';
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private auth: AuthService,
|
||||
private invites: InvitesService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
@@ -22,6 +24,98 @@ export class AuthController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('player/auth/register')
|
||||
async playerRegister(@Body() dto: RegisterDto) {
|
||||
const result = await this.auth.registerPlayer({
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
inviteCode: dto.inviteCode,
|
||||
locale: dto.locale,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('manage/invite')
|
||||
async manageInvite(@CurrentUser('id') userId: bigint) {
|
||||
const info = await this.auth.getInviteInfo(userId);
|
||||
return jsonResponse(info);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('manage/invite/generate')
|
||||
async manageInviteGenerate(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('userType') userType: string,
|
||||
@Body() dto: GenerateInviteDto,
|
||||
) {
|
||||
const info = await this.auth.generateInviteCode(userId, userType, dto.cashbackRate);
|
||||
return jsonResponse(info);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('manage/invites')
|
||||
async manageInvites(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('userType') userType: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('sponsorId') sponsorId?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
const result = await this.invites.listInvites(
|
||||
{ id: userId, userType },
|
||||
{
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
|
||||
status,
|
||||
sponsorId,
|
||||
keyword,
|
||||
},
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('manage/invites/sponsors')
|
||||
async manageInviteSponsors(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('userType') userType: string,
|
||||
) {
|
||||
const items = await this.invites.listSponsorOptions({ id: userId, userType });
|
||||
return jsonResponse({ items });
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('manage/invites/:id/revoke')
|
||||
async manageInviteRevoke(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('userType') userType: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const item = await this.invites.revokeInvite({ id: userId, userType }, BigInt(id));
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Delete('manage/invites/:id')
|
||||
async manageInviteDelete(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@CurrentUser('userType') userType: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const result = await this.invites.deleteInvite({ id: userId, userType }, BigInt(id));
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/auth/login')
|
||||
async adminLogin(@Body() dto: LoginDto) {
|
||||
@@ -64,6 +158,11 @@ export class AuthController {
|
||||
maxAgentLevel === 0 || level < maxAgentLevel;
|
||||
}
|
||||
|
||||
let inviteCode: string | null = null;
|
||||
if (userType === 'ADMIN' || userType === 'AGENT') {
|
||||
inviteCode = (await this.auth.getInviteInfo(userId)).inviteCode;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
id: userId.toString(),
|
||||
username,
|
||||
@@ -73,6 +172,7 @@ export class AuthController {
|
||||
agentLevel: level,
|
||||
maxAgentLevel,
|
||||
canManageSubAgents,
|
||||
inviteCode,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import { IsString, MinLength, IsOptional, IsNumber } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@@ -12,6 +12,27 @@ export class LoginDto {
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inviteCode?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -22,3 +43,10 @@ export class ChangePasswordDto {
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
export class GenerateInviteDto {
|
||||
@ApiProperty({ required: false, description: 'Decimal cashback rate, e.g. 0.01 = 1%. Admin only.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
cashbackRate?: number;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { InvitesService } from './invites.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
@@ -20,8 +21,8 @@ import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, InvitesService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, JwtModule],
|
||||
exports: [AuthService, InvitesService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { appForbidden, appUnauthorized } from '../../shared/common/app-error';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { appForbidden, appUnauthorized, appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { assertPlayerUsername } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { InvitesService } from './invites.service';
|
||||
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -23,6 +26,7 @@ export class AuthService {
|
||||
private jwt: JwtService,
|
||||
private config: ConfigService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private invites: InvitesService,
|
||||
) {}
|
||||
|
||||
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
||||
@@ -129,6 +133,125 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async resolveInviteSponsor(inviteCodeRaw?: string | null) {
|
||||
const resolved = await this.invites.resolveActiveInvite(inviteCodeRaw);
|
||||
return {
|
||||
sponsorId: resolved.sponsorId,
|
||||
parentId: resolved.parentId,
|
||||
inviteId: resolved.inviteId,
|
||||
};
|
||||
}
|
||||
|
||||
async registerPlayer(data: {
|
||||
username: string;
|
||||
password: string;
|
||||
inviteCode?: string;
|
||||
locale?: string;
|
||||
}) {
|
||||
const username = data.username.trim();
|
||||
if (!username) {
|
||||
throw appBadRequest('USERNAME_REQUIRED');
|
||||
}
|
||||
try {
|
||||
assertPlayerUsername(username);
|
||||
} catch {
|
||||
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
||||
}
|
||||
if (!data.password || data.password.length < 8) {
|
||||
throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||||
}
|
||||
|
||||
const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode);
|
||||
const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null;
|
||||
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw appBadRequest('USERNAME_TAKEN');
|
||||
}
|
||||
|
||||
const hash = await this.hashPassword(data.password);
|
||||
const locale = data.locale?.trim() || 'zh-CN';
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
userType: 'PLAYER',
|
||||
parentId,
|
||||
inviteSponsorId,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userAuth.create({
|
||||
data: { userId: created.id, passwordHash: hash },
|
||||
});
|
||||
|
||||
await tx.wallet.create({
|
||||
data: { userId: created.id },
|
||||
});
|
||||
|
||||
await tx.userPreference.create({
|
||||
data: { userId: created.id, locale },
|
||||
});
|
||||
|
||||
if (inviteId) {
|
||||
await this.invites.recordRegistration(inviteId, created.id, tx);
|
||||
const invite = await tx.userInvite.findUnique({
|
||||
where: { id: inviteId },
|
||||
select: { cashbackRate: true },
|
||||
});
|
||||
if (invite?.cashbackRate != null && new Decimal(invite.cashbackRate).gt(0)) {
|
||||
await tx.cashbackRule.updateMany({
|
||||
where: { targetType: 'USER', targetId: created.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
await tx.cashbackRule.create({
|
||||
data: {
|
||||
name: `Player ${created.id.toString()}`,
|
||||
targetType: 'USER',
|
||||
targetId: created.id,
|
||||
rate: invite.cashbackRate,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return this.login(username, data.password, 'player');
|
||||
}
|
||||
|
||||
async getInviteInfo(userId: bigint) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { inviteCode: true, userType: true, deletedAt: true },
|
||||
});
|
||||
if (!user || user.deletedAt) {
|
||||
throw appNotFound('USER_NOT_FOUND');
|
||||
}
|
||||
if (user.userType !== 'ADMIN' && user.userType !== 'AGENT') {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
return { inviteCode: user.inviteCode ?? null };
|
||||
}
|
||||
|
||||
async generateInviteCode(
|
||||
userId: bigint,
|
||||
userType: string,
|
||||
cashbackRate?: number,
|
||||
) {
|
||||
return this.invites.generateInviteCode(userId, {
|
||||
userType,
|
||||
cashbackRate: cashbackRate ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(userId: bigint, oldPassword: string, newPassword: string) {
|
||||
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
|
||||
if (!auth) throw appUnauthorized('USER_NOT_FOUND');
|
||||
|
||||
401
apps/api/src/domains/identity/invites.service.ts
Normal file
401
apps/api/src/domains/identity/invites.service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import {
|
||||
INVITE_STATUS_ACTIVE,
|
||||
INVITE_STATUS_REVOKED,
|
||||
normalizeInviteCode,
|
||||
rotateUserInviteCode,
|
||||
} from '../../shared/common/invite-code.util';
|
||||
|
||||
export interface InviteListQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
sponsorId?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
function formatInviteRow(row: {
|
||||
id: bigint;
|
||||
code: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
cashbackRate: Prisma.Decimal | null;
|
||||
sponsor: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
userType: string;
|
||||
agentLevel: number | null;
|
||||
};
|
||||
registrations?: { id: bigint; username: string }[];
|
||||
}) {
|
||||
const registrant = row.registrations?.[0] ?? null;
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
code: row.code,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
revokedAt: row.revokedAt?.toISOString() ?? null,
|
||||
cashbackRate: row.cashbackRate?.toString() ?? null,
|
||||
sponsorId: row.sponsor.id.toString(),
|
||||
sponsorUsername: row.sponsor.username,
|
||||
sponsorUserType: row.sponsor.userType,
|
||||
sponsorAgentLevel: row.sponsor.agentLevel,
|
||||
registeredPlayerId: registrant ? registrant.id.toString() : null,
|
||||
registeredPlayerUsername: registrant?.username ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const inviteListInclude = {
|
||||
sponsor: {
|
||||
select: { id: true, username: true, userType: true, agentLevel: true },
|
||||
},
|
||||
registrations: {
|
||||
where: { userType: 'PLAYER', deletedAt: null },
|
||||
select: { id: true, username: true },
|
||||
take: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class InvitesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getSelfAndDescendantIds(agentId: bigint): Promise<bigint[]> {
|
||||
const rows = await this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
select: { descendantId: true },
|
||||
});
|
||||
return rows.map((r) => r.descendantId);
|
||||
}
|
||||
|
||||
private async assertSponsorInScope(
|
||||
operator: { id: bigint; userType: string },
|
||||
sponsorId: bigint,
|
||||
) {
|
||||
if (operator.userType === 'ADMIN') return;
|
||||
if (operator.userType !== 'AGENT') {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
const allowed = await this.getSelfAndDescendantIds(operator.id);
|
||||
if (!allowed.some((id) => id === sponsorId)) {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
}
|
||||
|
||||
async resolveActiveInvite(inviteCodeRaw?: string | null) {
|
||||
const inviteCode = normalizeInviteCode(inviteCodeRaw ?? '');
|
||||
if (!inviteCode) {
|
||||
return { inviteId: null as bigint | null, sponsorId: null as bigint | null, parentId: null as bigint | null };
|
||||
}
|
||||
|
||||
const invite = await this.prisma.userInvite.findUnique({
|
||||
where: { code: inviteCode },
|
||||
include: {
|
||||
sponsor: {
|
||||
select: {
|
||||
id: true,
|
||||
userType: true,
|
||||
status: true,
|
||||
deletedAt: true,
|
||||
agentProfile: { select: { blockDirectPlayerLogin: true } },
|
||||
},
|
||||
},
|
||||
registrations: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite || invite.status !== INVITE_STATUS_ACTIVE) {
|
||||
throw appBadRequest('INVITE_CODE_INVALID');
|
||||
}
|
||||
if (invite.registrations.length > 0) {
|
||||
throw appBadRequest('INVITE_CODE_ALREADY_USED');
|
||||
}
|
||||
|
||||
const sponsor = invite.sponsor;
|
||||
if (!sponsor || sponsor.deletedAt) {
|
||||
throw appBadRequest('INVITE_CODE_INVALID');
|
||||
}
|
||||
if (sponsor.userType !== 'ADMIN' && sponsor.userType !== 'AGENT') {
|
||||
throw appBadRequest('INVITE_CODE_INVALID');
|
||||
}
|
||||
if (sponsor.status === 'DISABLED') {
|
||||
throw appBadRequest('INVITE_CODE_NOT_AVAILABLE');
|
||||
}
|
||||
if (sponsor.userType === 'AGENT') {
|
||||
if (sponsor.status === 'SUSPENDED') {
|
||||
throw appBadRequest('INVITE_CODE_NOT_AVAILABLE');
|
||||
}
|
||||
if (sponsor.agentProfile?.blockDirectPlayerLogin) {
|
||||
throw appBadRequest('INVITE_CODE_NOT_AVAILABLE');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inviteId: invite.id,
|
||||
sponsorId: sponsor.id,
|
||||
parentId: sponsor.userType === 'AGENT' ? sponsor.id : null,
|
||||
};
|
||||
}
|
||||
|
||||
async recordRegistration(inviteId: bigint, playerId: bigint, tx: Prisma.TransactionClient) {
|
||||
const invite = await tx.userInvite.findUnique({
|
||||
where: { id: inviteId },
|
||||
include: {
|
||||
sponsor: { select: { inviteCode: true } },
|
||||
registrations: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!invite) {
|
||||
throw appBadRequest('INVITE_CODE_INVALID');
|
||||
}
|
||||
if (invite.registrations.length > 0) {
|
||||
throw appBadRequest('INVITE_CODE_ALREADY_USED');
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: playerId },
|
||||
data: { usedInviteId: inviteId },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await tx.userInvite.update({
|
||||
where: { id: inviteId },
|
||||
data: {
|
||||
registerCount: 1,
|
||||
status: INVITE_STATUS_REVOKED,
|
||||
revokedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (invite.sponsor.inviteCode === invite.code) {
|
||||
await tx.user.update({
|
||||
where: { id: invite.sponsorId },
|
||||
data: { inviteCode: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async generateInviteCode(
|
||||
userId: bigint,
|
||||
options?: { userType?: string; cashbackRate?: number | null },
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { userType: true, deletedAt: true },
|
||||
});
|
||||
if (!user || user.deletedAt) {
|
||||
throw appNotFound('USER_NOT_FOUND');
|
||||
}
|
||||
if (user.userType !== 'ADMIN' && user.userType !== 'AGENT') {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
|
||||
let cashbackRate: Prisma.Decimal | null = null;
|
||||
if (options?.cashbackRate != null) {
|
||||
if (user.userType !== 'ADMIN') {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
if (!Number.isFinite(options.cashbackRate) || options.cashbackRate < 0) {
|
||||
throw appBadRequest('INVITE_CASHBACK_RATE_INVALID');
|
||||
}
|
||||
cashbackRate = new Prisma.Decimal(options.cashbackRate);
|
||||
}
|
||||
|
||||
const inviteCode = await rotateUserInviteCode(this.prisma, userId, cashbackRate);
|
||||
return {
|
||||
inviteCode,
|
||||
cashbackRate: cashbackRate?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async listInvites(
|
||||
operator: { id: bigint; userType: string },
|
||||
query: InviteListQuery,
|
||||
) {
|
||||
const page = Math.max(1, query.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 20));
|
||||
const where: Prisma.UserInviteWhereInput = {};
|
||||
|
||||
if (query.status === INVITE_STATUS_ACTIVE || query.status === INVITE_STATUS_REVOKED) {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
const keyword = query.keyword?.trim();
|
||||
if (keyword) {
|
||||
where.code = { contains: normalizeInviteCode(keyword) };
|
||||
}
|
||||
|
||||
if (operator.userType === 'ADMIN') {
|
||||
if (query.sponsorId?.trim()) {
|
||||
where.sponsorId = BigInt(query.sponsorId.trim());
|
||||
}
|
||||
} else if (operator.userType === 'AGENT') {
|
||||
const allowedIds = await this.getSelfAndDescendantIds(operator.id);
|
||||
if (query.sponsorId?.trim()) {
|
||||
const sid = BigInt(query.sponsorId.trim());
|
||||
if (!allowedIds.some((id) => id === sid)) {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
where.sponsorId = sid;
|
||||
} else {
|
||||
where.sponsorId = { in: allowedIds };
|
||||
}
|
||||
} else {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
this.prisma.userInvite.count({ where }),
|
||||
this.prisma.userInvite.findMany({
|
||||
where,
|
||||
include: inviteListInclude,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map(formatInviteRow),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async listSponsorOptions(operator: { id: bigint; userType: string }) {
|
||||
if (operator.userType === 'ADMIN') {
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: {
|
||||
userType: { in: ['ADMIN', 'AGENT'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true, username: true, userType: true, agentLevel: true },
|
||||
orderBy: [{ userType: 'asc' }, { username: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
username: r.username,
|
||||
userType: r.userType,
|
||||
agentLevel: r.agentLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
if (operator.userType !== 'AGENT') {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
|
||||
const ids = await this.getSelfAndDescendantIds(operator.id);
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: { id: { in: ids }, deletedAt: null },
|
||||
select: { id: true, username: true, userType: true, agentLevel: true },
|
||||
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
username: r.username,
|
||||
userType: r.userType,
|
||||
agentLevel: r.agentLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
async revokeInvite(
|
||||
operator: { id: bigint; userType: string },
|
||||
inviteId: bigint,
|
||||
) {
|
||||
const invite = await this.prisma.userInvite.findUnique({
|
||||
where: { id: inviteId },
|
||||
include: { sponsor: { select: { id: true, inviteCode: true } } },
|
||||
});
|
||||
if (!invite) {
|
||||
throw appNotFound('INVITE_NOT_FOUND');
|
||||
}
|
||||
if (invite.status === INVITE_STATUS_REVOKED) {
|
||||
const full = await this.prisma.userInvite.findUnique({
|
||||
where: { id: inviteId },
|
||||
include: inviteListInclude,
|
||||
});
|
||||
if (!full) throw appNotFound('INVITE_NOT_FOUND');
|
||||
return formatInviteRow(full);
|
||||
}
|
||||
|
||||
await this.assertSponsorInScope(operator, invite.sponsorId);
|
||||
|
||||
if (operator.userType === 'AGENT' && invite.sponsorId !== operator.id) {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const row = await tx.userInvite.update({
|
||||
where: { id: inviteId },
|
||||
data: { status: INVITE_STATUS_REVOKED, revokedAt: new Date() },
|
||||
include: {
|
||||
sponsor: {
|
||||
select: { id: true, username: true, userType: true, agentLevel: true, inviteCode: true },
|
||||
},
|
||||
registrations: {
|
||||
where: { userType: 'PLAYER', deletedAt: null },
|
||||
select: { id: true, username: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (row.sponsor.inviteCode === row.code) {
|
||||
await tx.user.update({
|
||||
where: { id: row.sponsorId },
|
||||
data: { inviteCode: null },
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return formatInviteRow(updated);
|
||||
}
|
||||
|
||||
async deleteInvite(
|
||||
operator: { id: bigint; userType: string },
|
||||
inviteId: bigint,
|
||||
) {
|
||||
const invite = await this.prisma.userInvite.findUnique({
|
||||
where: { id: inviteId },
|
||||
select: { id: true, status: true, sponsorId: true },
|
||||
});
|
||||
if (!invite) {
|
||||
throw appNotFound('INVITE_NOT_FOUND');
|
||||
}
|
||||
if (invite.status !== INVITE_STATUS_REVOKED) {
|
||||
throw appBadRequest('INVITE_MUST_REVOKE_FIRST');
|
||||
}
|
||||
|
||||
const usedBy = await this.prisma.user.findFirst({
|
||||
where: { usedInviteId: inviteId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (usedBy) {
|
||||
throw appBadRequest('INVITE_CANNOT_DELETE_USED');
|
||||
}
|
||||
|
||||
await this.assertSponsorInScope(operator, invite.sponsorId);
|
||||
|
||||
if (operator.userType === 'AGENT' && invite.sponsorId !== operator.id) {
|
||||
throw appForbidden('ACCESS_DENIED_PORTAL');
|
||||
}
|
||||
|
||||
await this.prisma.userInvite.delete({ where: { id: inviteId } });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
import { CashbackModule } from '../operations/cashback/cashback.module';
|
||||
|
||||
@Module({
|
||||
imports: [AgentsModule],
|
||||
imports: [AgentsModule, CashbackModule],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,9 @@ import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@theb
|
||||
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 { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
export type PlayerListFilters = {
|
||||
keyword?: string;
|
||||
@@ -19,6 +21,7 @@ export class UsersService {
|
||||
private prisma: PrismaService,
|
||||
private agents: AgentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private cashback: CashbackService,
|
||||
) {}
|
||||
|
||||
private buildAffiliationAgents(
|
||||
@@ -103,6 +106,7 @@ export class UsersService {
|
||||
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[],
|
||||
@@ -116,6 +120,7 @@ export class UsersService {
|
||||
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,
|
||||
@@ -281,6 +286,7 @@ export class UsersService {
|
||||
},
|
||||
},
|
||||
auth: { select: { lastLoginAt: true } },
|
||||
usedInvite: { select: { code: true } },
|
||||
},
|
||||
skip,
|
||||
take: pageSize,
|
||||
@@ -320,17 +326,23 @@ export class UsersService {
|
||||
},
|
||||
},
|
||||
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] = await Promise.all([
|
||||
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 {
|
||||
@@ -345,6 +357,8 @@ export class UsersService {
|
||||
betCount,
|
||||
totalStake: betStake._sum.stake?.toString() ?? '0',
|
||||
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
|
||||
customCashbackRate: customCashbackRate?.toString() ?? null,
|
||||
defaultCashbackRate: defaultCashbackRate.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,6 +371,7 @@ export class UsersService {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
cashbackRate?: number | null;
|
||||
},
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
@@ -440,6 +455,17 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ export class WalletService {
|
||||
|
||||
let typeWhere: Record<string, unknown> = {};
|
||||
if (typeFilter === 'deposit') {
|
||||
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
|
||||
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
|
||||
} else if (typeFilter === 'withdraw') {
|
||||
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
|
||||
} else if (typeFilter === 'bet') {
|
||||
@@ -312,7 +312,7 @@ export class WalletService {
|
||||
private walletTypeCategoryWhere(category?: string): Prisma.WalletTransactionWhereInput {
|
||||
const cat = category?.trim();
|
||||
if (cat === 'deposit') {
|
||||
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
|
||||
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
|
||||
}
|
||||
if (cat === 'withdraw') {
|
||||
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
|
||||
@@ -341,6 +341,99 @@ export class WalletService {
|
||||
return {};
|
||||
}
|
||||
|
||||
private static readonly DEPOSIT_RECHARGE_TYPES = new Set([
|
||||
'MANUAL_DEPOSIT',
|
||||
'DEPOSIT',
|
||||
'PLAYER_DEPOSIT',
|
||||
]);
|
||||
|
||||
private paymentMethodDisplayName(pm: {
|
||||
displayName: string | null;
|
||||
bankName: string | null;
|
||||
usdtAddress: string | null;
|
||||
methodType: string;
|
||||
} | null | undefined): string | null {
|
||||
if (!pm) return null;
|
||||
return pm.displayName ?? pm.bankName ?? pm.usdtAddress ?? pm.methodType ?? null;
|
||||
}
|
||||
|
||||
private async resolveDepositMethodsForRows(
|
||||
rows: Array<{ id: bigint; transactionType: string; referenceId: string | null; operatorId: bigint | null }>,
|
||||
) {
|
||||
const rechargeRows = rows.filter((r) => WalletService.DEPOSIT_RECHARGE_TYPES.has(r.transactionType));
|
||||
const result = new Map<
|
||||
string,
|
||||
{ depositMethodKey: string | null; depositMethodName: string | null }
|
||||
>();
|
||||
|
||||
if (!rechargeRows.length) return result;
|
||||
|
||||
const orderNos = [
|
||||
...new Set(
|
||||
rechargeRows
|
||||
.filter((r) => r.transactionType === 'PLAYER_DEPOSIT' && r.referenceId)
|
||||
.map((r) => r.referenceId!),
|
||||
),
|
||||
];
|
||||
const manualOperatorIds = [
|
||||
...new Set(
|
||||
rechargeRows
|
||||
.filter((r) => r.transactionType !== 'PLAYER_DEPOSIT' && r.operatorId)
|
||||
.map((r) => r.operatorId!),
|
||||
),
|
||||
];
|
||||
|
||||
const [orders, operators] = await Promise.all([
|
||||
orderNos.length
|
||||
? this.prisma.depositOrder.findMany({
|
||||
where: { orderNo: { in: orderNos } },
|
||||
include: {
|
||||
paymentMethod: {
|
||||
select: {
|
||||
displayName: true,
|
||||
bankName: true,
|
||||
usdtAddress: true,
|
||||
methodType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [],
|
||||
manualOperatorIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: manualOperatorIds } },
|
||||
select: { id: true, userType: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const orderByNo = new Map(orders.map((o) => [o.orderNo, o]));
|
||||
const operatorTypeById = new Map(operators.map((o) => [o.id.toString(), o.userType]));
|
||||
|
||||
for (const row of rechargeRows) {
|
||||
const rowKey = row.id.toString();
|
||||
if (row.transactionType === 'PLAYER_DEPOSIT' && row.referenceId) {
|
||||
const order = orderByNo.get(row.referenceId);
|
||||
result.set(rowKey, {
|
||||
depositMethodKey: null,
|
||||
depositMethodName: this.paymentMethodDisplayName(order?.paymentMethod),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const operatorType = row.operatorId
|
||||
? operatorTypeById.get(row.operatorId.toString())
|
||||
: null;
|
||||
let depositMethodKey = 'MANUAL';
|
||||
if (operatorType === 'ADMIN') depositMethodKey = 'MANUAL_ADMIN';
|
||||
else if (operatorType === 'AGENT') depositMethodKey = 'MANUAL_AGENT';
|
||||
|
||||
result.set(rowKey, { depositMethodKey, depositMethodName: null });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async listWalletTransactionsAdmin(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -495,11 +588,13 @@ export class WalletService {
|
||||
const playerById = new Map(players.map((p) => [p.id.toString(), p]));
|
||||
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
|
||||
const parentById = new Map(parentAgents.map((a) => [a.id.toString(), a.username]));
|
||||
const depositMethodByRowId = await this.resolveDepositMethodsForRows(rows);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => {
|
||||
const player = playerById.get(row.userId.toString());
|
||||
const parentId = player?.parentId;
|
||||
const depositMethod = depositMethodByRowId.get(row.id.toString());
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
transactionId: row.transactionId,
|
||||
@@ -516,6 +611,8 @@ export class WalletService {
|
||||
referenceType: row.referenceType,
|
||||
referenceId: row.referenceId,
|
||||
betNo: row.referenceType === 'BET' ? row.referenceId : null,
|
||||
depositMethodKey: depositMethod?.depositMethodKey ?? null,
|
||||
depositMethodName: depositMethod?.depositMethodName ?? null,
|
||||
operatorId: row.operatorId?.toString() ?? null,
|
||||
operatorUsername: row.operatorId
|
||||
? (operatorById.get(row.operatorId.toString()) ?? null)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { SystemConfigService } from '../../../shared/config/system-config.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../../shared/common/decorators';
|
||||
import { appBadRequest } from '../../../shared/common/app-error';
|
||||
@@ -33,6 +34,7 @@ export class CashbackService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
/** 已被待发放/已发放返水批次占用的注单 */
|
||||
@@ -55,14 +57,15 @@ export class CashbackService {
|
||||
eligibleBetCount: number;
|
||||
skippedClaimedCount: number;
|
||||
}> {
|
||||
const [settledBets, rules, agentProfiles, claimedBetIds] = await Promise.all([
|
||||
const [settledBets, rules, agentProfiles, claimedBetIds, platformDirectRateRaw] =
|
||||
await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST'] },
|
||||
settledAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, parentId: true } },
|
||||
user: { select: { id: true, parentId: true, inviteSponsorId: true } },
|
||||
selections: { select: { marketType: true } },
|
||||
},
|
||||
}),
|
||||
@@ -71,11 +74,46 @@ export class CashbackService {
|
||||
select: { userId: true, cashbackRate: true },
|
||||
}),
|
||||
this.loadClaimedBetIds(),
|
||||
this.systemConfig.getPlatformDirectCashbackSettings(),
|
||||
]);
|
||||
|
||||
const platformDirectDefaultRate = new Decimal(platformDirectRateRaw.platformDirectRate);
|
||||
const adminInviteDefaultRate = new Decimal(platformDirectRateRaw.adminInviteRate);
|
||||
|
||||
const agentRateById = new Map(
|
||||
agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]),
|
||||
);
|
||||
const sponsorTypeById = new Map<string, string>();
|
||||
const sponsorIds = [
|
||||
...new Set(
|
||||
settledBets
|
||||
.map((b) => b.user.inviteSponsorId)
|
||||
.filter((id): id is bigint => id != null),
|
||||
),
|
||||
];
|
||||
if (sponsorIds.length > 0) {
|
||||
const sponsors = await this.prisma.user.findMany({
|
||||
where: { id: { in: sponsorIds } },
|
||||
select: { id: true, userType: true },
|
||||
});
|
||||
for (const sponsor of sponsors) {
|
||||
sponsorTypeById.set(sponsor.id.toString(), sponsor.userType);
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefaultRate = (user: {
|
||||
parentId: bigint | null;
|
||||
inviteSponsorId: bigint | null;
|
||||
}) => {
|
||||
if (user.parentId) {
|
||||
return agentRateById.get(user.parentId.toString()) ?? new Decimal(0);
|
||||
}
|
||||
if (user.inviteSponsorId) {
|
||||
const sponsorType = sponsorTypeById.get(user.inviteSponsorId.toString());
|
||||
if (sponsorType === 'ADMIN') return adminInviteDefaultRate;
|
||||
}
|
||||
return platformDirectDefaultRate;
|
||||
};
|
||||
const ruleRows: CashbackRuleRow[] = rules.map((r) => ({
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
@@ -93,9 +131,7 @@ export class CashbackService {
|
||||
|
||||
for (const bet of settledBets) {
|
||||
const agentId = bet.user.parentId;
|
||||
const agentDefaultRate = agentId
|
||||
? agentRateById.get(agentId.toString()) ?? new Decimal(0)
|
||||
: new Decimal(0);
|
||||
const agentDefaultRate = resolveDefaultRate(bet.user);
|
||||
const marketTypes = bet.selections.map((s) => s.marketType);
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: bet.userId,
|
||||
@@ -538,4 +574,64 @@ export class CashbackService {
|
||||
createdAt: item.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPlayerCustomCashbackRate(userId: bigint): Promise<Decimal | null> {
|
||||
const rule = await this.prisma.cashbackRule.findFirst({
|
||||
where: {
|
||||
targetType: 'USER',
|
||||
targetId: userId,
|
||||
isActive: true,
|
||||
marketType: null,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (!rule) return null;
|
||||
const rate = new Decimal(rule.rate);
|
||||
return rate.gt(0) ? rate : null;
|
||||
}
|
||||
|
||||
async setPlayerCustomCashbackRate(userId: bigint, rate: Decimal | null) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.cashbackRule.updateMany({
|
||||
where: { targetType: 'USER', targetId: userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
if (rate && rate.gt(0)) {
|
||||
await tx.cashbackRule.create({
|
||||
data: {
|
||||
name: `Player ${userId.toString()}`,
|
||||
targetType: 'USER',
|
||||
targetId: userId,
|
||||
rate,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async resolvePlayerDefaultCashbackRate(params: {
|
||||
parentId: bigint | null;
|
||||
inviteSponsorId?: bigint | null;
|
||||
}): Promise<Decimal> {
|
||||
if (params.parentId) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: params.parentId },
|
||||
select: { cashbackRate: true },
|
||||
});
|
||||
return profile ? new Decimal(profile.cashbackRate) : new Decimal(0);
|
||||
}
|
||||
|
||||
const settings = await this.systemConfig.getPlatformDirectCashbackSettings();
|
||||
if (params.inviteSponsorId) {
|
||||
const sponsor = await this.prisma.user.findUnique({
|
||||
where: { id: params.inviteSponsorId },
|
||||
select: { userType: true },
|
||||
});
|
||||
if (sponsor?.userType === 'ADMIN') {
|
||||
return new Decimal(settings.adminInviteRate);
|
||||
}
|
||||
}
|
||||
return new Decimal(settings.platformDirectRate);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user