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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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