feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View File

@@ -0,0 +1,61 @@
-- CreateTable
CREATE TABLE "payment_methods" (
"id" BIGSERIAL NOT NULL,
"method_type" VARCHAR(20) NOT NULL,
"bank_name" VARCHAR(128),
"account_holder" VARCHAR(128),
"account_number" VARCHAR(128),
"usdt_address" VARCHAR(256),
"qr_code_url" VARCHAR(500),
"display_name" VARCHAR(128),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"show_on_player" BOOLEAN NOT NULL DEFAULT true,
"created_by" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deposit_orders" (
"id" BIGSERIAL NOT NULL,
"order_no" VARCHAR(64) NOT NULL,
"player_id" BIGINT NOT NULL,
"payment_method_id" BIGINT NOT NULL,
"method_type" VARCHAR(20) NOT NULL,
"amount" DECIMAL(18,4) NOT NULL,
"screenshot_url" VARCHAR(500) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
"approved_amount" DECIMAL(18,4),
"reviewer_id" BIGINT,
"reviewed_at" TIMESTAMP(3),
"reject_reason" VARCHAR(500),
"remark" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "deposit_orders_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "payment_methods_method_type_is_active_idx" ON "payment_methods"("method_type", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "deposit_orders_order_no_key" ON "deposit_orders"("order_no");
-- CreateIndex
CREATE INDEX "deposit_orders_player_id_idx" ON "deposit_orders"("player_id");
-- CreateIndex
CREATE INDEX "deposit_orders_status_idx" ON "deposit_orders"("status");
-- CreateIndex
CREATE INDEX "deposit_orders_created_at_idx" ON "deposit_orders"("created_at");
-- AddForeignKey
ALTER TABLE "deposit_orders" ADD CONSTRAINT "deposit_orders_player_id_fkey" FOREIGN KEY ("player_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deposit_orders" ADD CONSTRAINT "deposit_orders_payment_method_id_fkey" FOREIGN KEY ("payment_method_id") REFERENCES "payment_methods"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1 @@
-- This is an empty migration.

View File

@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "invite_code" VARCHAR(16);
-- Backfill existing admins and agents with deterministic unique codes
UPDATE "users"
SET "invite_code" = UPPER(SUBSTR(MD5("id"::text || ':invite'), 1, 8))
WHERE "user_type" IN ('ADMIN', 'AGENT')
AND "invite_code" IS NULL;
-- CreateIndex
CREATE UNIQUE INDEX "users_invite_code_key" ON "users"("invite_code");

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "invite_sponsor_id" BIGINT;
-- CreateIndex
CREATE INDEX "users_invite_sponsor_id_idx" ON "users"("invite_sponsor_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_invite_sponsor_id_fkey" FOREIGN KEY ("invite_sponsor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,56 @@
-- CreateTable
CREATE TABLE "user_invites" (
"id" BIGSERIAL NOT NULL,
"code" VARCHAR(16) NOT NULL,
"sponsor_id" BIGINT NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"register_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revoked_at" TIMESTAMP(3),
CONSTRAINT "user_invites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_invites_code_key" ON "user_invites"("code");
CREATE INDEX "user_invites_sponsor_id_idx" ON "user_invites"("sponsor_id");
CREATE INDEX "user_invites_status_idx" ON "user_invites"("status");
CREATE INDEX "user_invites_created_at_idx" ON "user_invites"("created_at");
-- AddForeignKey
ALTER TABLE "user_invites" ADD CONSTRAINT "user_invites_sponsor_id_fkey" FOREIGN KEY ("sponsor_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "used_invite_id" BIGINT;
CREATE INDEX "users_used_invite_id_idx" ON "users"("used_invite_id");
ALTER TABLE "users" ADD CONSTRAINT "users_used_invite_id_fkey" FOREIGN KEY ("used_invite_id") REFERENCES "user_invites"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Backfill existing invite codes into history
INSERT INTO "user_invites" ("code", "sponsor_id", "status", "register_count", "created_at")
SELECT u."invite_code", u."id", 'ACTIVE', 0, COALESCE(u."updated_at", u."created_at")
FROM "users" u
WHERE u."invite_code" IS NOT NULL
AND u."user_type" IN ('ADMIN', 'AGENT')
ON CONFLICT ("code") DO NOTHING;
-- Link players to invite records when possible
UPDATE "users" p
SET "used_invite_id" = ui."id"
FROM "user_invites" ui
WHERE p."invite_sponsor_id" = ui."sponsor_id"
AND p."user_type" = 'PLAYER'
AND p."used_invite_id" IS NULL
AND ui."status" = 'ACTIVE'
AND ui."code" = (
SELECT s."invite_code" FROM "users" s WHERE s."id" = p."invite_sponsor_id"
);
UPDATE "user_invites" ui
SET "register_count" = sub.cnt
FROM (
SELECT "used_invite_id" AS id, COUNT(*)::int AS cnt
FROM "users"
WHERE "used_invite_id" IS NOT NULL
GROUP BY "used_invite_id"
) sub
WHERE ui."id" = sub.id;

View File

@@ -0,0 +1,3 @@
-- Per-invite cashback rate (admin can set when generating)
ALTER TABLE "user_invites"
ADD COLUMN "cashback_rate" DECIMAL(8, 4);

View File

@@ -17,9 +17,12 @@ model User {
parentId BigInt? @map("parent_id")
agentLevel Int? @map("agent_level")
locale String @default("en-US") @db.VarChar(10)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
inviteSponsorId BigInt? @map("invite_sponsor_id")
usedInviteId BigInt? @map("used_invite_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
auth UserAuth?
wallet Wallet?
@@ -27,15 +30,41 @@ model User {
adminRole AdminUserRole?
bets Bet[]
preferences UserPreference?
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
invitedPlayers User[] @relation("InviteSponsor")
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
invites UserInvite[] @relation("UserInvites")
@@index([userType])
@@index([parentId])
@@index([inviteSponsorId])
@@index([usedInviteId])
@@map("users")
}
model UserInvite {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(16)
sponsorId BigInt @map("sponsor_id")
status String @default("ACTIVE") @db.VarChar(20)
registerCount Int @default(0) @map("register_count")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
cashbackRate Decimal? @map("cashback_rate") @db.Decimal(8, 4)
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
registrations User[] @relation("UsedInvite")
@@index([sponsorId])
@@index([status])
@@index([createdAt])
@@map("user_invites")
}
model UserAuth {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
@@ -613,6 +642,56 @@ model UploadedFile {
@@map("uploaded_files")
}
// ============ Manual Deposit / Recharge ============
model PaymentMethod {
id BigInt @id @default(autoincrement())
methodType String @map("method_type") @db.VarChar(20)
bankName String? @map("bank_name") @db.VarChar(128)
accountHolder String? @map("account_holder") @db.VarChar(128)
accountNumber String? @map("account_number") @db.VarChar(128)
usdtAddress String? @map("usdt_address") @db.VarChar(256)
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
displayName String? @map("display_name") @db.VarChar(128)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
showOnPlayer Boolean @default(true) @map("show_on_player")
createdBy BigInt? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
depositOrders DepositOrder[]
@@index([methodType, isActive])
@@map("payment_methods")
}
model DepositOrder {
id BigInt @id @default(autoincrement())
orderNo String @unique @map("order_no") @db.VarChar(64)
playerId BigInt @map("player_id")
paymentMethodId BigInt @map("payment_method_id")
methodType String @map("method_type") @db.VarChar(20)
amount Decimal @db.Decimal(18, 4)
screenshotUrl String @map("screenshot_url") @db.VarChar(500)
status String @default("PENDING") @db.VarChar(20)
approvedAmount Decimal? @map("approved_amount") @db.Decimal(18, 4)
reviewerId BigInt? @map("reviewer_id")
reviewedAt DateTime? @map("reviewed_at")
rejectReason String? @map("reject_reason") @db.VarChar(500)
remark String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
player User @relation("PlayerDepositOrders", fields: [playerId], references: [id])
paymentMethod PaymentMethod @relation(fields: [paymentMethodId], references: [id])
@@index([playerId])
@@index([status])
@@index([createdAt])
@@map("deposit_orders")
}
// ============ System Config & Audit ============
model SystemConfig {

View File

@@ -17,4 +17,6 @@ export const P = {
content: 'content.manage',
audit: 'audit.view',
resetDatabase: 'settings.reset_database',
depositManage: 'deposit.manage',
depositReview: 'deposit.review',
} as const;

View File

@@ -41,6 +41,7 @@ import { SystemConfigService } from '../../shared/config/system-config.service';
import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import {
IsString,
IsNumber,
@@ -56,7 +57,7 @@ import {
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const;
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents', 'payments', 'deposits'] as const;
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];
const IMAGE_MIME_EXT: Record<string, string> = {
@@ -236,6 +237,25 @@ class UpdatePlayerAdminDto {
@IsString()
@MinLength(8)
password?: string;
/** 玩家专属返水比例小数null 或 0 表示清除单独设置、使用默认 */
@IsOptional()
@ValidateIf((_, v) => v != null)
@IsNumber()
@Min(0)
cashbackRate?: number | null;
}
class PlatformDirectCashbackSettingsDto {
@IsOptional()
@IsNumber()
@Min(0)
platformDirectRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
adminInviteRate?: number;
}
class PlayerAccountSettingsDto {
@@ -912,6 +932,7 @@ export class AdminController {
private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
) {}
@Get('dashboard')
@@ -1080,7 +1101,15 @@ export class AdminController {
@Param('id') id: string,
@Body() dto: UpdatePlayerAdminDto,
) {
const detail = await this.users.updatePlayerAdmin(BigInt(id), dto);
const detail = await this.users.updatePlayerAdmin(BigInt(id), {
status: dto.status,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
username: dto.username,
password: dto.password,
cashbackRate: dto.cashbackRate,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
@@ -2003,6 +2032,20 @@ export class AdminController {
return jsonResponse(detail);
}
@Get('settings/cashback/platform-direct')
@RequirePermissions(P.cashback, P.reports)
async getPlatformDirectCashbackSettings() {
const settings = await this.systemConfig.getPlatformDirectCashbackSettings();
return jsonResponse(settings);
}
@Put('settings/cashback/platform-direct')
@RequirePermissions(P.cashback)
async updatePlatformDirectCashbackSettings(@Body() dto: PlatformDirectCashbackSettingsDto) {
const settings = await this.systemConfig.updatePlatformDirectCashbackSettings(dto);
return jsonResponse(settings);
}
@Post('cashbacks/preview')
@RequirePermissions(P.cashback, P.reports)
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
@@ -2307,4 +2350,134 @@ export class AdminController {
);
return jsonResponse(result);
}
// ============ Payment Methods ============
@Post('payment-methods')
@RequirePermissions(P.depositManage)
async createPaymentMethod(
@CurrentUser('id') operatorId: bigint,
@Body() body: {
methodType: string;
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>;
};
},
) {
if (!body.methodType || !['BANK', 'USDT'].includes(body.methodType)) {
throw appBadRequest('INVALID_METHOD_TYPE');
}
const method = await this.depositService.createPaymentMethod({
...body,
createdBy: operatorId,
});
return jsonResponse(method);
}
@Get('payment-methods')
@RequirePermissions(P.depositManage)
async listPaymentMethods(@Query('methodType') methodType?: string) {
const items = await this.depositService.listPaymentMethods({
methodType: methodType || undefined,
});
return jsonResponse(items);
}
@Put('payment-methods/:id')
@RequirePermissions(P.depositManage)
async updatePaymentMethod(
@Param('id') id: string,
@Body() body: {
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 method = await this.depositService.updatePaymentMethod(BigInt(id), body);
return jsonResponse(method);
}
@Delete('payment-methods/:id')
@RequirePermissions(P.depositManage)
async deletePaymentMethod(@Param('id') id: string) {
await this.depositService.deletePaymentMethod(BigInt(id));
return jsonResponse({ success: true });
}
// ============ Deposit Orders (Admin Review) ============
@Get('deposit-orders')
@RequirePermissions(P.depositReview)
async listDepositOrders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('methodType') methodType?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.depositService.listDepositOrders({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
status: status || undefined,
keyword: keyword || undefined,
methodType: methodType || undefined,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
@Post('deposit-orders/:id/approve')
@RequirePermissions(P.depositReview)
async approveDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() body: { approvedAmount?: number; remark?: string },
) {
const result = await this.depositService.approveDepositOrder(
BigInt(id),
operatorId,
body.approvedAmount,
body.remark,
);
return jsonResponse(result);
}
@Post('deposit-orders/:id/reject')
@RequirePermissions(P.depositReview)
async rejectDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() body: { reason: string },
) {
if (!body.reason?.trim()) throw appBadRequest('REASON_REQUIRED');
const result = await this.depositService.rejectDepositOrder(
BigInt(id),
operatorId,
body.reason.trim(),
);
return jsonResponse(result);
}
}

View File

@@ -14,6 +14,7 @@ import { I18nModule } from '../../domains/operations/i18n/i18n.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test
BetsModule,
DatabaseModule,
SmokeTestModule,
DepositModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -8,11 +8,19 @@ import {
Query,
Headers,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { extname, join } from 'path';
import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
import { CurrentUser, Public } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest } from '../../shared/common/app-error';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { WalletService } from '../../domains/ledger/wallet.service';
@@ -21,6 +29,7 @@ import { OutrightService } from '../../domains/catalog/outright.service';
import { BetsService } from '../../domains/betting/bets.service';
import { ContentService } from '../../domains/operations/content/content.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
@@ -98,6 +107,7 @@ export class PlayerController {
private content: ContentService,
private cashback: CashbackService,
private systemConfig: SystemConfigService,
private deposit: DepositService,
) {}
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
@@ -290,4 +300,68 @@ export class PlayerController {
const items = await this.cashback.getUserCashbacks(userId);
return jsonResponse(items);
}
// ============ Deposit / Recharge ============
@Get('payment-methods')
async paymentMethods(
@Query('methodType') methodType?: string,
@CurrentUser('locale') userLocale?: string,
@Headers('x-locale') headerLocale?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const items = await this.deposit.listPlayerPaymentMethods(methodType || undefined, locale);
return jsonResponse(items);
}
@Post('deposit-orders')
@UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 5 * 1024 * 1024 } }))
async createDepositOrder(
@CurrentUser('id') userId: bigint,
@UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer; size: number } | undefined,
@Body() body: { paymentMethodId: string; amount: string },
) {
if (!file) throw appBadRequest('SCREENSHOT_REQUIRED');
if (!file.mimetype.startsWith('image/')) throw appBadRequest('FILE_MUST_BE_IMAGE');
const amount = parseFloat(body.amount);
if (!amount || amount <= 0) throw appBadRequest('INVALID_AMOUNT');
if (!body.paymentMethodId) throw appBadRequest('PAYMENT_METHOD_REQUIRED');
// Save screenshot
const ext = extname(file.originalname || '.jpg').toLowerCase() || '.jpg';
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
const root = getUploadRoot();
const targetDir = join(root, 'deposits');
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, filename), file.buffer);
const screenshotUrl = `/uploads/deposits/${filename}`;
const order = await this.deposit.createDepositOrder(
userId,
BigInt(body.paymentMethodId),
amount,
screenshotUrl,
);
return jsonResponse({
id: order.id.toString(),
orderNo: order.orderNo,
amount: order.amount.toString(),
status: order.status,
createdAt: order.createdAt,
});
}
@Get('deposit-orders')
async myDepositOrders(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
) {
const result = await this.deposit.getPlayerDepositOrders(
userId,
page ? parseInt(page, 10) : 1,
);
return jsonResponse(result);
}
}

View File

@@ -6,9 +6,10 @@ import { MatchesModule } from '../../domains/catalog/matches.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { ContentModule } from '../../domains/operations/content/content.module';
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
@Module({
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule],
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule],
controllers: [PlayerController],
})
export class PlayerModule {}

View File

@@ -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) {

View 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 {}

View 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 };
}
}

View File

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

View File

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

View File

@@ -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 {}

View File

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

View 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 };
}
}

View File

@@ -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],
})

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync';
import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
export const DEMO_ACCOUNTS = [
'admin / Admin@123',
@@ -746,5 +747,13 @@ export async function runSeed(client: PrismaClient) {
},
}).catch(() => {});
const staffWithoutInvite = await prisma.user.findMany({
where: { userType: { in: ['ADMIN', 'AGENT'] }, inviteCode: null },
select: { id: true },
});
for (const row of staffWithoutInvite) {
await ensureUserInviteCode(prisma, row.id);
}
console.log(`Seed completed! ${DEMO_ACCOUNTS.join(', ')}`);
}

View File

@@ -0,0 +1,114 @@
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
const INVITE_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const INVITE_CODE_LENGTH = 8;
export const INVITE_STATUS_ACTIVE = 'ACTIVE';
export const INVITE_STATUS_REVOKED = 'REVOKED';
export function normalizeInviteCode(input: string): string {
return input.trim().toUpperCase();
}
export function generateInviteCodeCandidate(): string {
let code = '';
for (let i = 0; i < INVITE_CODE_LENGTH; i++) {
code += INVITE_CODE_CHARS[Math.floor(Math.random() * INVITE_CODE_CHARS.length)];
}
return code;
}
type InviteCodeDb = Pick<PrismaService, 'user' | 'userInvite'>;
function isInviteCodeUniqueViolation(err: unknown): boolean {
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002';
}
async function isCodeTaken(db: InviteCodeDb, code: string): Promise<boolean> {
const [userHit, inviteHit] = await Promise.all([
db.user.findUnique({ where: { inviteCode: code }, select: { id: true } }),
db.userInvite.findUnique({ where: { code }, select: { id: true } }),
]);
return Boolean(userHit || inviteHit);
}
export async function generateUniqueInviteCode(
db: InviteCodeDb,
maxAttempts = 12,
): Promise<string> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const code = generateInviteCodeCandidate();
if (!(await isCodeTaken(db, code))) return code;
}
throw new Error('Failed to generate unique invite code');
}
/** Create history record and set as the user's current invite code. */
export async function assignInviteCodeWithHistory(
db: InviteCodeDb,
sponsorId: bigint,
cashbackRate?: Prisma.Decimal | null,
): Promise<string> {
for (let attempt = 0; attempt < 12; attempt++) {
const candidate = generateInviteCodeCandidate();
if (await isCodeTaken(db, candidate)) continue;
try {
await db.userInvite.create({
data: {
code: candidate,
sponsorId,
status: INVITE_STATUS_ACTIVE,
...(cashbackRate != null ? { cashbackRate } : {}),
},
});
await db.user.update({
where: { id: sponsorId },
data: { inviteCode: candidate },
});
return candidate;
} catch (err) {
if (!isInviteCodeUniqueViolation(err)) throw err;
}
}
throw new Error('Failed to assign invite code');
}
/** Revoke active codes and assign a new one with history. */
export async function rotateUserInviteCode(
db: InviteCodeDb,
userId: bigint,
cashbackRate?: Prisma.Decimal | null,
): Promise<string> {
await db.userInvite.updateMany({
where: { sponsorId: userId, status: INVITE_STATUS_ACTIVE },
data: { status: INVITE_STATUS_REVOKED, revokedAt: new Date() },
});
return assignInviteCodeWithHistory(db, userId, cashbackRate);
}
/** Assign a stable invite code once per staff user (admin/agent). */
export async function ensureUserInviteCode(db: InviteCodeDb, userId: bigint): Promise<string> {
const existing = await db.user.findUnique({
where: { id: userId },
select: { inviteCode: true },
});
if (existing?.inviteCode) {
const history = await db.userInvite.findUnique({
where: { code: existing.inviteCode },
select: { id: true },
});
if (!history) {
await db.userInvite.create({
data: {
code: existing.inviteCode,
sponsorId: userId,
status: INVITE_STATUS_ACTIVE,
},
});
}
return existing.inviteCode;
}
return assignInviteCodeWithHistory(db, userId);
}

View File

@@ -7,6 +7,15 @@ export const AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
export const AGENT_MAX_LEVEL = 'agent.max_level';
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
export type PlatformDirectCashbackSettings = {
/** 平台直属玩家默认返水比例小数0.01 = 1% */
platformDirectRate: number;
/** 管理员邀请注册玩家返水比例;未配置时与 platformDirectRate 相同 */
adminInviteRate: number;
};
export type AgentHierarchySettings = {
/** 最大代理层级0 = 不限制 */
@@ -155,4 +164,62 @@ export class SystemConfigService {
}
return this.getAgentHierarchySettings();
}
async getDecimalRate(key: string, defaultValue = 0): Promise<number> {
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
if (!row) return defaultValue;
const parsed = parseFloat(row.configValue);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
}
async setDecimalRate(key: string, value: number, description?: string) {
const safe = Math.max(0, value);
await this.prisma.systemConfig.upsert({
where: { configKey: key },
create: {
configKey: key,
configValue: String(safe),
description,
},
update: { configValue: String(safe) },
});
}
async getPlatformDirectCashbackSettings(): Promise<PlatformDirectCashbackSettings> {
const platformDirectRate = await this.getDecimalRate(CASHBACK_PLATFORM_DIRECT_RATE, 0);
const adminInviteConfigured = await this.prisma.systemConfig.findUnique({
where: { configKey: CASHBACK_ADMIN_INVITE_RATE },
});
const adminInviteRate = adminInviteConfigured
? await this.getDecimalRate(CASHBACK_ADMIN_INVITE_RATE, platformDirectRate)
: platformDirectRate;
return { platformDirectRate, adminInviteRate };
}
async updatePlatformDirectCashbackSettings(data: {
platformDirectRate?: number;
adminInviteRate?: number;
}) {
if (data.platformDirectRate !== undefined) {
if (!Number.isFinite(data.platformDirectRate) || data.platformDirectRate < 0) {
throw new Error('platformDirectRate must be a non-negative number');
}
await this.setDecimalRate(
CASHBACK_PLATFORM_DIRECT_RATE,
data.platformDirectRate,
'平台直属玩家默认返水比例(小数,如 0.01 = 1%',
);
}
if (data.adminInviteRate !== undefined) {
if (!Number.isFinite(data.adminInviteRate) || data.adminInviteRate < 0) {
throw new Error('adminInviteRate must be a non-negative number');
}
await this.setDecimalRate(
CASHBACK_ADMIN_INVITE_RATE,
data.adminInviteRate,
'管理员邀请注册玩家返水比例(小数,如 0.01 = 1%',
);
}
return this.getPlatformDirectCashbackSettings();
}
}