feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
-- This is an empty migration.
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Per-invite cashback rate (admin can set when generating)
|
||||
ALTER TABLE "user_invites"
|
||||
ADD COLUMN "cashback_rate" DECIMAL(8, 4);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
114
apps/api/src/shared/common/invite-code.util.ts
Normal file
114
apps/api/src/shared/common/invite-code.util.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user