sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user