sync(theme-4): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-4 样式。
This commit is contained in:
2026-06-22 14:12:40 +08:00
parent 5fbb1fd1f6
commit cffad00652
50 changed files with 4654 additions and 1186 deletions

View File

@@ -15,10 +15,12 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile, unlink } from 'fs/promises';
import { mkdir, writeFile, unlink, readdir, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { DepositScreenshotCleanupService } from '../../domains/deposit/deposit-screenshot-cleanup.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
@@ -62,6 +64,7 @@ import {
IsIn,
Min,
Max,
MaxLength,
Equals,
ValidateIf,
} from 'class-validator';
@@ -1109,6 +1112,44 @@ class InboxNotifySettingsDto {
deposit?: boolean;
}
class BroadcastTranslationDto {
@IsString()
locale!: string;
@IsOptional()
@IsString()
@MaxLength(256)
title?: string;
@IsOptional()
@IsString()
body?: string;
}
class CreatePlayerMessageBroadcastDto {
@IsArray()
translations!: BroadcastTranslationDto[];
@IsIn(['ALL', 'USER'])
targetType!: 'ALL' | 'USER';
@ValidateIf((dto: CreatePlayerMessageBroadcastDto) => dto.targetType === 'USER')
@IsString()
@MinLength(1)
targetUsername?: string;
}
class UpdateDepositCleanupConfigDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsNumber()
@Min(1)
keepDays?: number;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -1236,6 +1277,7 @@ export class AdminController {
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
private depositCleanup: DepositScreenshotCleanupService,
) {}
@Get('presence/online-count')
@@ -1997,6 +2039,8 @@ export class AdminController {
@Query('pageSize') pageSize?: string,
@Query('hasBets') hasBets?: string,
@Query('orderBy') orderBy?: string,
@Query('startFrom') startFrom?: string,
@Query('startTo') startTo?: string,
) {
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
@@ -2006,6 +2050,8 @@ export class AdminController {
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
hasBets: hasBets || undefined,
orderBy: orderBy || undefined,
startFrom: startFrom ? new Date(startFrom) : undefined,
startTo: startTo ? new Date(startTo) : undefined,
});
return jsonResponse(result);
}
@@ -2843,6 +2889,29 @@ export class AdminController {
return jsonResponse(preview);
}
@Get('matches/:id/settlement/preview')
@RequirePermissions(P.settlement, P.reports)
async getActiveSettlementPreview(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const matchId = BigInt(id);
const preview = await this.settlement.getActivePreview(matchId, {
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
});
return jsonResponse(preview);
}
@Get('matches/:id/settlement/history')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementHistory(@Param('id') id: string) {
const matchId = BigInt(id);
const history = await this.settlement.getMatchSettlementHistory(matchId);
return jsonResponse(history);
}
@Get('settlement/:batchId/preview-items')
@RequirePermissions(P.settlement)
async getSettlementPreviewItems(
@@ -3061,8 +3130,24 @@ export class AdminController {
@Query('category') category?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('imagesOnly') imagesOnly?: string,
) {
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
const where: {
category?: string | { in: string[] };
mimeType?: { startsWith: string };
} = {};
if (category && UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
where.category = category;
} else if (imagesOnly === '1' || imagesOnly === 'true') {
// 媒体库选择器:默认可选 banners/teams/contents/payments不含 deposits
where.category = { in: ['banners', 'teams', 'contents', 'payments'] };
}
if (imagesOnly === '1' || imagesOnly === 'true') {
where.mimeType = { startsWith: 'image/' };
}
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
@@ -3158,6 +3243,103 @@ export class AdminController {
return urls;
}
@Get('files/storage-stats')
@RequirePermissions(P.content)
async getStorageStats() {
const statsGroup = await this.prisma.uploadedFile.groupBy({
by: ['category'],
_count: { _all: true },
_sum: { size: true }
});
const categories = statsGroup.map((g) => ({
category: g.category,
count: g._count._all,
sizeBytes: g._sum.size ?? 0,
}));
// Calculate deposits on disk
let depositCount = 0;
let depositSizeBytes = 0;
const depositsDir = join(getUploadRoot(), 'deposits');
try {
const files = await readdir(depositsDir);
for (const file of files) {
const filePath = join(depositsDir, file);
try {
const fileStats = await stat(filePath);
if (fileStats.isFile()) {
depositCount++;
depositSizeBytes += fileStats.size;
}
} catch {}
}
} catch {}
categories.push({
category: 'deposits',
count: depositCount,
sizeBytes: depositSizeBytes,
});
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
const totalSizeBytes = categories.reduce((sum, c) => sum + c.sizeBytes, 0);
return jsonResponse({
categories,
total: {
count: totalCount,
sizeBytes: totalSizeBytes,
},
});
}
@Get('deposits/screenshot-cleanup-config')
@RequirePermissions(P.content)
async getScreenshotCleanupConfig() {
const config = await this.systemConfig.getDepositScreenshotCleanupConfig();
return jsonResponse(config);
}
@Put('deposits/screenshot-cleanup-config')
@RequirePermissions(P.content)
async updateScreenshotCleanupConfig(@Body() body: UpdateDepositCleanupConfigDto) {
const config = await this.systemConfig.updateDepositScreenshotCleanupConfig(body);
return jsonResponse(config);
}
@Delete('deposits/screenshots')
@RequirePermissions(P.content)
async cleanOldScreenshots(
@CurrentUser('id') operatorId: bigint,
@Query('before') beforeStr?: string,
) {
if (!beforeStr) throw appBadRequest('BEFORE_DATE_REQUIRED');
const beforeDate = new Date(beforeStr);
if (Number.isNaN(beforeDate.getTime())) {
throw appBadRequest('INVALID_BEFORE_DATE');
}
if (beforeDate.getTime() > Date.now()) {
throw appBadRequest('BEFORE_DATE_CANNOT_BE_FUTURE');
}
const result = await this.depositCleanup.cleanOldDepositScreenshots(beforeDate);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'PURGE_DEPOSIT_SCREENSHOTS',
module: 'MEDIA',
afterData: JSON.stringify({
before: beforeDate.toISOString(),
cleanedCount: result.cleaned,
freedBytes: result.freedBytes,
}),
});
return jsonResponse(result);
}
@Get('contents/inbox-notify-settings')
@RequirePermissions(P.content, P.reports)
async getInboxNotifySettings() {
@@ -3172,6 +3354,68 @@ export class AdminController {
return jsonResponse(settings);
}
@Get('player-message-broadcasts')
@RequirePermissions(P.content, P.reports)
async listPlayerMessageBroadcasts(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 50);
const result = await this.playerMessages.listBroadcasts(p, size);
return jsonResponse(result);
}
@Post('player-message-broadcasts')
@RequirePermissions(P.content)
async createPlayerMessageBroadcast(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlayerMessageBroadcastDto,
) {
const operator = await this.prisma.user.findUnique({
where: { id: operatorId },
select: { username: true },
});
const item = await this.playerMessages.createCustomBroadcast({
translations: dto.translations,
targetType: dto.targetType,
targetUsername: dto.targetUsername,
createdById: operatorId,
createdByUsername: operator?.username ?? null,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'SEND_PLAYER_MESSAGE_BROADCAST',
module: 'CONTENT',
afterData: JSON.stringify({
id: item.id,
title: item.title,
targetType: item.targetType,
recipientCount: item.recipientCount,
}),
});
return jsonResponse(item);
}
@Delete('player-message-broadcasts/:id')
@RequirePermissions(P.content)
async deletePlayerMessageBroadcast(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.playerMessages.deleteBroadcast(BigInt(id));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_PLAYER_MESSAGE_BROADCAST',
module: 'CONTENT',
targetId: id,
afterData: JSON.stringify(result),
});
return jsonResponse(result);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(