sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "player_message_broadcasts" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"title" VARCHAR(256) NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"target_type" VARCHAR(16) NOT NULL,
|
||||
"target_user_id" BIGINT,
|
||||
"target_username" VARCHAR(64),
|
||||
"recipient_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_by_id" BIGINT,
|
||||
"created_by_username" VARCHAR(64),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "player_message_broadcasts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "player_messages" ADD COLUMN "broadcast_id" BIGINT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "player_message_broadcasts_created_at_idx" ON "player_message_broadcasts"("created_at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "player_messages_broadcast_id_idx" ON "player_messages"("broadcast_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_broadcast_id_fkey" FOREIGN KEY ("broadcast_id") REFERENCES "player_message_broadcasts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "player_message_broadcasts" ADD COLUMN "translations" JSONB;
|
||||
|
||||
-- Backfill existing rows as English content
|
||||
UPDATE "player_message_broadcasts"
|
||||
SET "translations" = jsonb_build_object(
|
||||
'en-US', jsonb_build_object('title', "title", 'body', "body")
|
||||
)
|
||||
WHERE "translations" IS NULL;
|
||||
@@ -816,22 +816,44 @@ model DepositOrderAuditLog {
|
||||
}
|
||||
|
||||
model PlayerMessage {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @map("user_id")
|
||||
type String @db.VarChar(32)
|
||||
title String @db.VarChar(256)
|
||||
body String @db.Text
|
||||
payload Json?
|
||||
readAt DateTime? @map("read_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @map("user_id")
|
||||
type String @db.VarChar(32)
|
||||
title String @db.VarChar(256)
|
||||
body String @db.Text
|
||||
payload Json?
|
||||
broadcastId BigInt? @map("broadcast_id")
|
||||
readAt DateTime? @map("read_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
broadcast PlayerMessageBroadcast? @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([userId, readAt])
|
||||
@@index([broadcastId])
|
||||
@@map("player_messages")
|
||||
}
|
||||
|
||||
model PlayerMessageBroadcast {
|
||||
id BigInt @id @default(autoincrement())
|
||||
title String @db.VarChar(256)
|
||||
body String @db.Text
|
||||
translations Json? @map("translations")
|
||||
targetType String @map("target_type") @db.VarChar(16)
|
||||
targetUserId BigInt? @map("target_user_id")
|
||||
targetUsername String? @map("target_username") @db.VarChar(64)
|
||||
recipientCount Int @default(0) @map("recipient_count")
|
||||
createdById BigInt? @map("created_by_id")
|
||||
createdByUsername String? @map("created_by_username") @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
messages PlayerMessage[]
|
||||
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("player_message_broadcasts")
|
||||
}
|
||||
|
||||
// ============ System Config & Audit ============
|
||||
|
||||
model SystemConfig {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -558,6 +558,8 @@ export class MatchesService {
|
||||
pageSize?: number;
|
||||
hasBets?: string;
|
||||
orderBy?: string;
|
||||
startFrom?: Date;
|
||||
startTo?: Date;
|
||||
},
|
||||
) {
|
||||
const where: Prisma.MatchWhereInput = {
|
||||
@@ -566,6 +568,11 @@ export class MatchesService {
|
||||
isOutright: false,
|
||||
};
|
||||
if (opts.status) where.status = opts.status;
|
||||
if (opts.startFrom || opts.startTo) {
|
||||
where.startTime = {};
|
||||
if (opts.startFrom) where.startTime.gte = opts.startFrom;
|
||||
if (opts.startTo) where.startTime.lte = opts.startTo;
|
||||
}
|
||||
const kw = opts.keyword?.trim();
|
||||
if (kw) {
|
||||
where.OR = [
|
||||
@@ -626,6 +633,10 @@ export class MatchesService {
|
||||
const stakeB = parseFloat(b.totalStake);
|
||||
return stakeB - stakeA;
|
||||
});
|
||||
} else if (opts.orderBy === 'kickoffAsc') {
|
||||
filteredItems.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
} else if (opts.orderBy === 'kickoffDesc') {
|
||||
filteredItems.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||
} else {
|
||||
filteredItems.sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) {
|
||||
@@ -640,12 +651,19 @@ export class MatchesService {
|
||||
return { items: paginatedItems, total, page, pageSize };
|
||||
}
|
||||
|
||||
const orderBy =
|
||||
opts.orderBy === 'kickoffAsc'
|
||||
? [{ startTime: 'asc' as const }, { displayOrder: 'asc' as const }]
|
||||
: opts.orderBy === 'kickoffDesc'
|
||||
? [{ startTime: 'desc' as const }, { displayOrder: 'asc' as const }]
|
||||
: [{ displayOrder: 'asc' as const }, { startTime: 'desc' as const }];
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
this.prisma.match.count({ where }),
|
||||
this.prisma.match.findMany({
|
||||
where,
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
@@ -865,9 +883,32 @@ export class MatchesService {
|
||||
|
||||
async getAdminMatchDetail(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
const scoreRow = await this.prisma.matchScore.findUnique({
|
||||
let scoreRow = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId },
|
||||
});
|
||||
if (!scoreRow) {
|
||||
const previewBatch = await this.prisma.settlementBatch.findFirst({
|
||||
where: { matchId, status: 'PREVIEW' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (previewBatch) {
|
||||
scoreRow = {
|
||||
htHomeScore: previewBatch.htHomeScore,
|
||||
htAwayScore: previewBatch.htAwayScore,
|
||||
ftHomeScore: previewBatch.ftHomeScore,
|
||||
ftAwayScore: previewBatch.ftAwayScore,
|
||||
homeCorners: previewBatch.homeCorners,
|
||||
awayCorners: previewBatch.awayCorners,
|
||||
homeYellowCards: previewBatch.homeYellowCards,
|
||||
awayYellowCards: previewBatch.awayYellowCards,
|
||||
homeRedCards: previewBatch.homeRedCards,
|
||||
awayRedCards: previewBatch.awayRedCards,
|
||||
homeCards: previewBatch.homeCards,
|
||||
awayCards: previewBatch.awayCards,
|
||||
winnerTeamId: null,
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
const markets = await this.prisma.market.findMany({
|
||||
where: { matchId },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { getUploadRoot } from '../../shared/uploads/upload-paths';
|
||||
import { join, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
|
||||
|
||||
@Injectable()
|
||||
export class DepositScreenshotCleanupService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DepositScreenshotCleanupService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private systemConfigService: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureExpiredPlaceholderExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已过期/已清理的截图占位图存在
|
||||
*/
|
||||
async ensureExpiredPlaceholderExists() {
|
||||
// 1x1 像素透明 PNG
|
||||
const defaultExpiredPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
const root = getUploadRoot();
|
||||
const expiredPath = join(root, 'defaults', 'expired.png');
|
||||
try {
|
||||
await mkdir(dirname(expiredPath), { recursive: true });
|
||||
// 强制写入以确保生成最新的透明 1x1 占位图
|
||||
await writeFile(expiredPath, Buffer.from(defaultExpiredPngBase64, 'base64'));
|
||||
this.logger.log('Ensured expired screenshot default placeholder (transparent 1x1).');
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to create default expired screenshot placeholder', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日凌晨3点定时执行清理
|
||||
*/
|
||||
@Cron('0 0 3 * * *')
|
||||
async handleScheduledCleanup() {
|
||||
this.logger.log('Scheduled deposit screenshot cleanup job started');
|
||||
try {
|
||||
const config = await this.systemConfigService.getDepositScreenshotCleanupConfig();
|
||||
if (!config.enabled) {
|
||||
this.logger.log('Scheduled cleanup is disabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeDate = new Date();
|
||||
beforeDate.setDate(beforeDate.getDate() - config.keepDays);
|
||||
this.logger.log(`Cleaning deposit screenshots older than ${config.keepDays} days (before ${beforeDate.toISOString()})`);
|
||||
|
||||
const result = await this.cleanOldDepositScreenshots(beforeDate);
|
||||
this.logger.log(`Scheduled cleanup completed. Cleaned: ${result.cleaned} screenshots, Freed: ${(result.freedBytes / 1024 / 1024).toFixed(2)} MB`);
|
||||
} catch (err) {
|
||||
this.logger.error('Scheduled cleanup failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定日期之前的已处理充值订单截图
|
||||
*/
|
||||
async cleanOldDepositScreenshots(before: Date): Promise<{ cleaned: number; freedBytes: number }> {
|
||||
const orders = await this.prisma.depositOrder.findMany({
|
||||
where: {
|
||||
createdAt: { lt: before },
|
||||
status: { in: ['APPROVED', 'REJECTED'] },
|
||||
screenshotUrl: {
|
||||
startsWith: '/uploads/',
|
||||
not: { startsWith: '/uploads/defaults/' },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
screenshotUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
let cleaned = 0;
|
||||
let freedBytes = 0;
|
||||
const batchSize = 100;
|
||||
const root = getUploadRoot();
|
||||
|
||||
for (let i = 0; i < orders.length; i += batchSize) {
|
||||
const chunk = orders.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
chunk.map(async (order) => {
|
||||
const url = order.screenshotUrl;
|
||||
if (!url.startsWith('/uploads/')) return;
|
||||
const relative = url.slice('/uploads/'.length);
|
||||
// 安全路径校验,防止目录穿越
|
||||
if (!relative || relative.includes('..') || relative.includes('\\')) return;
|
||||
const filePath = join(root, relative);
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
size = fileStats.size;
|
||||
await unlink(filePath);
|
||||
freedBytes += size;
|
||||
} catch {
|
||||
// 文件不存在或已被删除,静默跳过,但依然更新数据库
|
||||
}
|
||||
|
||||
await this.prisma.depositOrder.update({
|
||||
where: { id: order.id },
|
||||
data: { screenshotUrl: '/uploads/defaults/expired.png' },
|
||||
});
|
||||
cleaned++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { cleaned, freedBytes };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { DepositScreenshotCleanupService } from './deposit-screenshot-cleanup.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
import { PlayerMessagesModule } from '../player-messages/player-messages.module';
|
||||
@@ -7,7 +8,7 @@ import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AgentsModule, PlayerMessagesModule, SystemConfigModule],
|
||||
providers: [DepositService],
|
||||
exports: [DepositService],
|
||||
providers: [DepositService, DepositScreenshotCleanupService],
|
||||
exports: [DepositService, DepositScreenshotCleanupService],
|
||||
})
|
||||
export class DepositModule {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PlayerMessagesService } from './player-messages.service';
|
||||
|
||||
describe('PlayerMessagesService', () => {
|
||||
const prisma = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prisma: any = {
|
||||
playerMessage: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
@@ -13,10 +14,19 @@ describe('PlayerMessagesService', () => {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
playerMessageBroadcast: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma)),
|
||||
};
|
||||
|
||||
let service: PlayerMessagesService;
|
||||
@@ -162,4 +172,58 @@ describe('PlayerMessagesService', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates custom broadcast to all active players', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: 1n, username: 'p1', locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
|
||||
{ id: 2n, username: 'p2', locale: 'en-US', preferences: null },
|
||||
]);
|
||||
prisma.playerMessageBroadcast.create.mockResolvedValue({
|
||||
id: 9n,
|
||||
title: 'Hello',
|
||||
body: '<p>World</p>',
|
||||
translations: {
|
||||
'en-US': { title: 'Hello', body: '<p>World</p>' },
|
||||
'zh-CN': { title: '你好', body: '<p>内容</p>' },
|
||||
},
|
||||
targetType: 'ALL',
|
||||
targetUserId: null,
|
||||
targetUsername: null,
|
||||
recipientCount: 2,
|
||||
createdById: 99n,
|
||||
createdByUsername: 'admin',
|
||||
createdAt: new Date('2026-06-22T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await service.createCustomBroadcast({
|
||||
translations: [
|
||||
{ locale: 'en-US', title: 'Hello', body: '<p>World</p>' },
|
||||
{ locale: 'zh-CN', title: '你好', body: '<p>内容</p>' },
|
||||
],
|
||||
targetType: 'ALL',
|
||||
createdById: 99n,
|
||||
createdByUsername: 'admin',
|
||||
});
|
||||
|
||||
expect(result.recipientCount).toBe(2);
|
||||
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({ userId: 1n, type: 'ADMIN_CUSTOM', title: '你好', broadcastId: 9n }),
|
||||
expect.objectContaining({ userId: 2n, type: 'ADMIN_CUSTOM', title: 'Hello', broadcastId: 9n }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes broadcast and cascades player messages', async () => {
|
||||
prisma.playerMessageBroadcast.findUnique.mockResolvedValue({
|
||||
id: 5n,
|
||||
recipientCount: 3,
|
||||
});
|
||||
prisma.playerMessageBroadcast.delete.mockResolvedValue({ id: 5n });
|
||||
|
||||
const result = await service.deleteBroadcast(5n);
|
||||
|
||||
expect(result).toEqual({ deleted: true, recipientCount: 3 });
|
||||
expect(prisma.playerMessageBroadcast.delete).toHaveBeenCalledWith({ where: { id: 5n } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appNotFound } from '../../shared/common/app-error';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export type PlayerMessageType =
|
||||
| 'DEPOSIT_APPROVED'
|
||||
| 'DEPOSIT_REJECTED'
|
||||
| 'BANNER_PROMO'
|
||||
| 'ANNOUNCEMENT_PROMO';
|
||||
| 'ANNOUNCEMENT_PROMO'
|
||||
| 'ADMIN_CUSTOM';
|
||||
|
||||
export type DepositMessagePayload = {
|
||||
depositOrderId: string;
|
||||
@@ -47,7 +48,7 @@ type MessageTemplate = {
|
||||
};
|
||||
|
||||
const MESSAGE_TEMPLATES: Record<
|
||||
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
||||
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
|
||||
Record<string, MessageTemplate>
|
||||
> = {
|
||||
DEPOSIT_APPROVED: {
|
||||
@@ -200,12 +201,122 @@ function mapMessageRow(row: {
|
||||
};
|
||||
}
|
||||
|
||||
export type BroadcastTranslation = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type BroadcastTranslations = Record<string, BroadcastTranslation>;
|
||||
|
||||
export type BroadcastTranslationInput = {
|
||||
locale: string;
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
};
|
||||
|
||||
function normalizeBroadcastTranslations(
|
||||
inputs: BroadcastTranslationInput[],
|
||||
): BroadcastTranslations {
|
||||
const map: BroadcastTranslations = {};
|
||||
for (const tr of inputs) {
|
||||
const locale = resolveLocale(tr.locale);
|
||||
map[locale] = {
|
||||
title: tr.title?.trim() ?? '',
|
||||
body: tr.body?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveBroadcastContent(
|
||||
translations: BroadcastTranslations,
|
||||
locale: string | null | undefined,
|
||||
): BroadcastTranslation | null {
|
||||
const chain = [resolveLocale(locale), 'en-US', 'zh-CN', 'ms-MY'];
|
||||
const seen = new Set<string>();
|
||||
for (const loc of chain) {
|
||||
if (seen.has(loc)) continue;
|
||||
seen.add(loc);
|
||||
const tr = translations[loc];
|
||||
if (!tr) continue;
|
||||
const title = tr.title?.trim();
|
||||
const body = tr.body?.trim();
|
||||
if (title || body) {
|
||||
return {
|
||||
title: title || stripHtml(body).slice(0, 256) || 'Notification',
|
||||
body: body || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const tr of Object.values(translations)) {
|
||||
const title = tr.title?.trim();
|
||||
const body = tr.body?.trim();
|
||||
if (title || body) {
|
||||
return {
|
||||
title: title || stripHtml(body).slice(0, 256) || 'Notification',
|
||||
body: body || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBroadcastTranslations(value: Prisma.JsonValue | null | undefined): BroadcastTranslations {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const map: BroadcastTranslations = {};
|
||||
for (const [locale, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
||||
const row = raw as Record<string, unknown>;
|
||||
map[locale] = {
|
||||
title: typeof row.title === 'string' ? row.title : '',
|
||||
body: typeof row.body === 'string' ? row.body : '',
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mapBroadcastRow(row: {
|
||||
id: bigint;
|
||||
title: string;
|
||||
body: string;
|
||||
translations: Prisma.JsonValue | null;
|
||||
targetType: string;
|
||||
targetUserId: bigint | null;
|
||||
targetUsername: string | null;
|
||||
recipientCount: number;
|
||||
createdById: bigint | null;
|
||||
createdByUsername: string | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
const translations = parseBroadcastTranslations(row.translations);
|
||||
const hasTranslations = Object.keys(translations).length > 0;
|
||||
const preview =
|
||||
resolveBroadcastContent(hasTranslations ? translations : { 'en-US': { title: row.title, body: row.body } }, 'en-US') ??
|
||||
{ title: row.title, body: row.body };
|
||||
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
title: preview.title,
|
||||
body: preview.body,
|
||||
translations: hasTranslations
|
||||
? translations
|
||||
: { 'en-US': { title: row.title, body: row.body } },
|
||||
targetType: row.targetType,
|
||||
targetUserId: row.targetUserId?.toString() ?? null,
|
||||
targetUsername: row.targetUsername,
|
||||
recipientCount: row.recipientCount,
|
||||
createdById: row.createdById?.toString() ?? null,
|
||||
createdByUsername: row.createdByUsername,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlayerMessagesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private buildDepositMessage(
|
||||
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
|
||||
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
|
||||
locale: string | null | undefined,
|
||||
payload: DepositMessagePayload,
|
||||
) {
|
||||
@@ -434,4 +545,135 @@ export class PlayerMessagesService {
|
||||
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
|
||||
return { deleted: result.count };
|
||||
}
|
||||
|
||||
async listBroadcasts(page = 1, pageSize = 20) {
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(50, Math.max(1, pageSize));
|
||||
const skip = (safePage - 1) * safePageSize;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.playerMessageBroadcast.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: safePageSize,
|
||||
}),
|
||||
this.prisma.playerMessageBroadcast.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map(mapBroadcastRow),
|
||||
total,
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async createCustomBroadcast(data: {
|
||||
translations: BroadcastTranslationInput[];
|
||||
targetType: 'ALL' | 'USER';
|
||||
targetUsername?: string | null;
|
||||
createdById?: bigint | null;
|
||||
createdByUsername?: string | null;
|
||||
}) {
|
||||
const translations = normalizeBroadcastTranslations(data.translations);
|
||||
const preview = resolveBroadcastContent(translations, 'en-US');
|
||||
if (!preview?.title && !preview?.body) {
|
||||
throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
|
||||
}
|
||||
const title = preview.title.slice(0, 256);
|
||||
const body = preview.body;
|
||||
if (!title && !body) throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
|
||||
|
||||
let targetUsers: Array<{
|
||||
id: bigint;
|
||||
username: string;
|
||||
locale: string | null;
|
||||
preferences: { locale: string | null } | null;
|
||||
}> = [];
|
||||
let targetUserId: bigint | null = null;
|
||||
let targetUsername: string | null = null;
|
||||
|
||||
if (data.targetType === 'ALL') {
|
||||
targetUsers = await this.prisma.user.findMany({
|
||||
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
locale: true,
|
||||
preferences: { select: { locale: true } },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const username = data.targetUsername?.trim();
|
||||
if (!username) throw appBadRequest('BROADCAST_TARGET_USER_REQUIRED');
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
userType: 'PLAYER',
|
||||
deletedAt: null,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
locale: true,
|
||||
preferences: { select: { locale: true } },
|
||||
},
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
targetUsers = [player];
|
||||
targetUserId = player.id;
|
||||
targetUsername = player.username;
|
||||
}
|
||||
|
||||
if (!targetUsers.length) throw appBadRequest('BROADCAST_NO_RECIPIENTS');
|
||||
|
||||
const broadcast = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.playerMessageBroadcast.create({
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
translations: translations as Prisma.InputJsonValue,
|
||||
targetType: data.targetType,
|
||||
targetUserId,
|
||||
targetUsername,
|
||||
recipientCount: targetUsers.length,
|
||||
createdById: data.createdById ?? null,
|
||||
createdByUsername: data.createdByUsername ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const rows = targetUsers.map((player) => {
|
||||
const playerLocale = player.preferences?.locale ?? player.locale;
|
||||
const content =
|
||||
resolveBroadcastContent(translations, playerLocale) ?? preview;
|
||||
return {
|
||||
userId: player.id,
|
||||
type: 'ADMIN_CUSTOM',
|
||||
title: content.title.slice(0, 256),
|
||||
body: content.body,
|
||||
broadcastId: created.id,
|
||||
payload: { broadcastId: created.id.toString() } as Prisma.InputJsonValue,
|
||||
};
|
||||
});
|
||||
|
||||
const batchSize = 200;
|
||||
for (let i = 0; i < rows.length; i += batchSize) {
|
||||
await tx.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return mapBroadcastRow(broadcast);
|
||||
}
|
||||
|
||||
async deleteBroadcast(broadcastId: bigint) {
|
||||
const row = await this.prisma.playerMessageBroadcast.findUnique({
|
||||
where: { id: broadcastId },
|
||||
});
|
||||
if (!row) throw appNotFound('BROADCAST_NOT_FOUND');
|
||||
await this.prisma.playerMessageBroadcast.delete({ where: { id: broadcastId } });
|
||||
return { deleted: true, recipientCount: row.recipientCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,83 @@ export class SettlementService {
|
||||
};
|
||||
}
|
||||
|
||||
async getActivePreview(
|
||||
matchId: bigint,
|
||||
opts?: { page?: number; pageSize?: number },
|
||||
) {
|
||||
const batch = await this.prisma.settlementBatch.findFirst({
|
||||
where: { matchId, status: 'PREVIEW' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!batch) return null;
|
||||
|
||||
const existingScore = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId },
|
||||
});
|
||||
const computation = await this.computePreviewComputation(matchId, {
|
||||
htHome: batch.htHomeScore ?? 0,
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
homeCorners: batch.homeCorners ?? null,
|
||||
awayCorners: batch.awayCorners ?? null,
|
||||
homeYellowCards: batch.homeYellowCards ?? null,
|
||||
awayYellowCards: batch.awayYellowCards ?? null,
|
||||
homeRedCards: batch.homeRedCards ?? null,
|
||||
awayRedCards: batch.awayRedCards ?? null,
|
||||
homeCards: batch.homeCards ?? null,
|
||||
awayCards: batch.awayCards ?? null,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
});
|
||||
|
||||
return this.buildPreviewResponse(computation, batch, opts);
|
||||
}
|
||||
|
||||
async getMatchSettlementHistory(matchId: bigint) {
|
||||
const batches = await this.prisma.settlementBatch.findMany({
|
||||
where: { matchId, status: 'CONFIRMED' },
|
||||
orderBy: { confirmedAt: 'desc' },
|
||||
});
|
||||
|
||||
const operatorIds = batches
|
||||
.map((b) => b.operatorId)
|
||||
.filter((id): id is bigint => id !== null);
|
||||
|
||||
const operators =
|
||||
operatorIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const operatorMap = new Map(operators.map((o) => [o.id.toString(), o.username]));
|
||||
|
||||
return batches.map((b) => ({
|
||||
id: b.id.toString(),
|
||||
batchNo: b.batchNo,
|
||||
htHomeScore: b.htHomeScore,
|
||||
htAwayScore: b.htAwayScore,
|
||||
ftHomeScore: b.ftHomeScore,
|
||||
ftAwayScore: b.ftAwayScore,
|
||||
homeCorners: b.homeCorners,
|
||||
awayCorners: b.awayCorners,
|
||||
homeYellowCards: b.homeYellowCards,
|
||||
awayYellowCards: b.awayYellowCards,
|
||||
homeRedCards: b.homeRedCards,
|
||||
awayRedCards: b.awayRedCards,
|
||||
homeCards: b.homeCards,
|
||||
awayCards: b.awayCards,
|
||||
totalBets: b.totalBets,
|
||||
totalPayout: b.totalPayout.toString(),
|
||||
totalRefund: b.totalRefund.toString(),
|
||||
confirmedAt: b.confirmedAt?.toISOString() ?? null,
|
||||
isResettle: b.isResettle,
|
||||
reason: b.reason,
|
||||
operatorUsername: b.operatorId ? operatorMap.get(b.operatorId.toString()) ?? '—' : '—',
|
||||
}));
|
||||
}
|
||||
|
||||
private buildPreviewResponse(
|
||||
computation: {
|
||||
scoreInput: ScoreInput;
|
||||
|
||||
@@ -263,4 +263,31 @@ export class SystemConfigService {
|
||||
}
|
||||
return this.getInboxNotifySettings();
|
||||
}
|
||||
|
||||
async getDepositScreenshotCleanupConfig(): Promise<{ enabled: boolean; keepDays: number }> {
|
||||
const enabled = await this.getBoolean('deposit.cleanup.enabled', false);
|
||||
const keepDays = await this.getInt('deposit.cleanup.keep_days', 180);
|
||||
return { enabled, keepDays };
|
||||
}
|
||||
|
||||
async updateDepositScreenshotCleanupConfig(data: { enabled?: boolean; keepDays?: number }) {
|
||||
if (data.enabled !== undefined) {
|
||||
await this.setBoolean(
|
||||
'deposit.cleanup.enabled',
|
||||
data.enabled,
|
||||
'是否开启定时清理充值截图',
|
||||
);
|
||||
}
|
||||
if (data.keepDays !== undefined) {
|
||||
if (!Number.isInteger(data.keepDays) || data.keepDays <= 0) {
|
||||
throw new Error('keepDays must be a positive integer');
|
||||
}
|
||||
await this.setInt(
|
||||
'deposit.cleanup.keep_days',
|
||||
data.keepDays,
|
||||
'充值截图保留天数',
|
||||
);
|
||||
}
|
||||
return this.getDepositScreenshotCleanupConfig();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user