+
+
+
@@ -178,27 +175,16 @@ function isLeagueExpanded(id: string) {
-
- {{ t('match.expand_outright_hint') }}
+
+ {{ t('match.open_outright_hint') }}
-
-
-
-
-
@@ -240,9 +226,22 @@ function isLeagueExpanded(id: string) {
+
diff --git a/apps/admin/src/views/agent/GlobalSettingsView.vue b/apps/admin/src/views/agent/GlobalSettingsView.vue
new file mode 100644
index 0000000..a9ec386
--- /dev/null
+++ b/apps/admin/src/views/agent/GlobalSettingsView.vue
@@ -0,0 +1,368 @@
+
+
+
+
+
+
+
+
+
{{ t('user.global_settings') }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('agent.hierarchy.settings_title') }}
+
{{ t('agent.hierarchy.settings_hint') }}
+
+
+
+
+
+
+ {{ t('common.save') }}
+
+
+
+
+
+
+
{{ t('cashback.settings_title') }}
+
+
+
+
+ {{ t('cashback.platform_direct_default_hint') }}
+
+
+
+ {{ t('cashback.admin_invite_default_hint') }}
+
+
+ {{ t('common.save') }}
+
+
+
+
+
+
+
{{ t('user.betting_limits') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.save') }}
+
+
+
+
+
+
+
{{ t('user.reset_database') }}
+
{{ t('user.reset_database_hint') }}
+
+
+
+
+
+
+
+ {{ t('user.reset_database_btn') }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/matches/LeagueMatchesPage.vue b/apps/admin/src/views/matches/LeagueMatchesPage.vue
new file mode 100644
index 0000000..cfaf449
--- /dev/null
+++ b/apps/admin/src/views/matches/LeagueMatchesPage.vue
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+ {{ t('match.create_fixture_btn') }}
+
+
+
+
+
+
+
+
+
+ {{ leagueTitle }}
+
+
+
+ {{ t('match.timezone.platform_hint') }}
+
+
+
+
{{ t('match.field.home_team') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('match.field.away_team') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('match.hint.create_draft') }}
+
+
+ {{ t('common.cancel') }}
+
+ {{ t('user.btn.create') }}
+
+
+
+
+
+
+
diff --git a/apps/admin/src/views/matches/LeagueMatchesPanel.vue b/apps/admin/src/views/matches/LeagueMatchesPanel.vue
index cf35eaa..60bcaf0 100644
--- a/apps/admin/src/views/matches/LeagueMatchesPanel.vue
+++ b/apps/admin/src/views/matches/LeagueMatchesPanel.vue
@@ -1,21 +1,33 @@
+
+
+
+
+
+
diff --git a/apps/admin/src/views/matches/MatchEventEditor.vue b/apps/admin/src/views/matches/MatchEventEditor.vue
index cdbb7ec..9952726 100644
--- a/apps/admin/src/views/matches/MatchEventEditor.vue
+++ b/apps/admin/src/views/matches/MatchEventEditor.vue
@@ -16,11 +16,23 @@ import {
} from '../match-form';
import AdminSubNav from '../../components/AdminSubNav.vue';
+const props = withDefaults(
+ defineProps<{
+ matchIdProp?: string;
+ embedded?: boolean;
+ }>(),
+ { embedded: false },
+);
+
+const emit = defineEmits<{
+ saved: [];
+}>();
+
const route = useRoute();
const router = useRouter();
const { t } = useAdminLocale();
-const matchId = computed(() => String(route.params.matchId ?? ''));
+const matchId = computed(() => props.matchIdProp ?? String(route.params.matchId ?? ''));
const loading = ref(false);
const savingMeta = ref(false);
const status = ref('DRAFT');
@@ -52,7 +64,7 @@ async function load() {
const detail = data.data as AdminMatchDetail;
if (detail.isOutright) {
ElMessage.warning(t('msg.outright_no_edit'));
- router.replace('/matches');
+ if (!props.embedded) router.replace('/matches');
return;
}
status.value = detail.status;
@@ -81,7 +93,8 @@ async function saveMeta() {
try {
await api.put(`/admin/matches/${matchId.value}`, payload);
ElMessage.success(t('msg.saved'));
- await load();
+ emit('saved');
+ if (!props.embedded) await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -92,8 +105,13 @@ async function saveMeta() {
-
+
@@ -257,6 +275,12 @@ async function saveMeta() {
color: var(--text);
}
+.match-editor-page--embedded {
+ padding-bottom: 0;
+ max-height: min(78vh, 880px);
+ overflow-y: auto;
+}
+
.panel {
background: #ffffff;
border: 1px solid var(--border);
diff --git a/apps/admin/src/views/outrights/OutrightEditRedirect.vue b/apps/admin/src/views/outrights/OutrightEditRedirect.vue
index 752b3a1..5ebe10c 100644
--- a/apps/admin/src/views/outrights/OutrightEditRedirect.vue
+++ b/apps/admin/src/views/outrights/OutrightEditRedirect.vue
@@ -15,9 +15,13 @@ onMounted(async () => {
try {
const { data } = await api.get(`/admin/outrights/${matchId}`);
const leagueId = data.data?.leagueId as string | undefined;
+ if (leagueId) {
+ await router.replace(`/matches/outrights/leagues/${leagueId}`);
+ return;
+ }
await router.replace({
path: '/matches/outrights',
- query: leagueId ? { leagueId } : { matchId },
+ query: { matchId },
});
} catch {
await router.replace('/matches/outrights');
diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts
index 75aa5e1..47da3ce 100644
--- a/apps/api/src/applications/admin/admin.controller.ts
+++ b/apps/api/src/applications/admin/admin.controller.ts
@@ -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';
@@ -1109,6 +1111,17 @@ class InboxNotifySettingsDto {
deposit?: boolean;
}
+class UpdateDepositCleanupConfigDto {
+ @IsOptional()
+ @IsBoolean()
+ enabled?: boolean;
+
+ @IsOptional()
+ @IsNumber()
+ @Min(1)
+ keepDays?: number;
+}
+
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -1236,6 +1249,7 @@ export class AdminController {
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
+ private depositCleanup: DepositScreenshotCleanupService,
) {}
@Get('presence/online-count')
@@ -1997,6 +2011,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 +2022,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 +2861,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(
@@ -3158,6 +3199,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() {
diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts
index 4deb5c4..aa7f418 100644
--- a/apps/api/src/domains/catalog/matches.service.ts
+++ b/apps/api/src/domains/catalog/matches.service.ts
@@ -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' } } },
diff --git a/apps/api/src/domains/deposit/deposit-screenshot-cleanup.service.ts b/apps/api/src/domains/deposit/deposit-screenshot-cleanup.service.ts
new file mode 100644
index 0000000..29c1bfa
--- /dev/null
+++ b/apps/api/src/domains/deposit/deposit-screenshot-cleanup.service.ts
@@ -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 };
+ }
+}
diff --git a/apps/api/src/domains/deposit/deposit.module.ts b/apps/api/src/domains/deposit/deposit.module.ts
index ac5c4ec..b23c673 100644
--- a/apps/api/src/domains/deposit/deposit.module.ts
+++ b/apps/api/src/domains/deposit/deposit.module.ts
@@ -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 {}
diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts
index 1b18a30..571da2a 100644
--- a/apps/api/src/domains/settlement/settlement.service.ts
+++ b/apps/api/src/domains/settlement/settlement.service.ts
@@ -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;
diff --git a/apps/api/src/shared/config/system-config.service.ts b/apps/api/src/shared/config/system-config.service.ts
index b06a778..1e2f251 100644
--- a/apps/api/src/shared/config/system-config.service.ts
+++ b/apps/api/src/shared/config/system-config.service.ts
@@ -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();
+ }
}
diff --git a/apps/player/src/views/RechargeView.vue b/apps/player/src/views/RechargeView.vue
index 20966fe..850b9c4 100644
--- a/apps/player/src/views/RechargeView.vue
+++ b/apps/player/src/views/RechargeView.vue
@@ -95,30 +95,44 @@ function selectMethod(m: PaymentMethod) {
}
const MAX_ORIGINAL_BYTES = 10 * 1024 * 1024;
-const MAX_SCREENSHOT_BYTES = 1024 * 1024;
+const MAX_SCREENSHOT_BYTES = 300 * 1024;
async function compressScreenshot(file: File): Promise {
const baseOptions = {
- maxSizeMB: 1,
- maxWidthOrHeight: 1920,
+ maxSizeMB: 0.3,
+ maxWidthOrHeight: 1200,
useWebWorker: true,
- maxIteration: 15,
+ maxIteration: 10,
+ fileType: 'image/webp',
} as const;
const attempts = [
- { ...baseOptions, initialQuality: 0.85 },
- { ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 },
- { ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 },
+ { ...baseOptions, initialQuality: 0.8 },
+ { ...baseOptions, initialQuality: 0.6, maxWidthOrHeight: 1000 },
];
+ let compressed: File | null = null;
for (const options of attempts) {
- const compressed = (await imageCompression(file, options)) as File;
- if (compressed.size <= MAX_SCREENSHOT_BYTES) {
- return compressed;
+ try {
+ compressed = (await imageCompression(file, options)) as File;
+ if (compressed.size <= MAX_SCREENSHOT_BYTES) {
+ break;
+ }
+ } catch (err) {
+ console.error('Compression attempt failed:', err);
}
}
- throw new Error('COMPRESS_TOO_LARGE');
+ if (!compressed) {
+ throw new Error('COMPRESS_FAILED');
+ }
+
+ // 重命名文件后缀为 .webp
+ const name = file.name.replace(/\.[^/.]+$/, "") + '.webp';
+ return new File([compressed], name, {
+ type: 'image/webp',
+ lastModified: Date.now(),
+ });
}
async function handleFileChange(event: Event) {
diff --git a/packages/shared/src/api-errors.js b/packages/shared/src/api-errors.js
index 0ce425b..d5ba5f7 100644
--- a/packages/shared/src/api-errors.js
+++ b/packages/shared/src/api-errors.js
@@ -1007,6 +1007,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'This country or region is not supported',
'ms-MY': 'Negara atau wilayah ini tidak disokong',
},
+ BEFORE_DATE_REQUIRED: {
+ 'zh-CN': '截止日期不能为空',
+ 'en-US': 'Before date is required',
+ 'ms-MY': 'Tarikh akhir diperlukan',
+ },
+ INVALID_BEFORE_DATE: {
+ 'zh-CN': '截止日期无效',
+ 'en-US': 'Invalid before date',
+ 'ms-MY': 'Tarikh akhir tidak sah',
+ },
+ BEFORE_DATE_CANNOT_BE_FUTURE: {
+ 'zh-CN': '截止日期不能是未来日期',
+ 'en-US': 'Before date cannot be in the future',
+ 'ms-MY': 'Tarikh akhir tidak boleh pada masa hadapan',
+ },
};
export function normalizeLocale(input) {
const raw = String(input ?? '').trim();
diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts
index 20db5ca..6864d51 100644
--- a/packages/shared/src/api-errors.ts
+++ b/packages/shared/src/api-errors.ts
@@ -1009,6 +1009,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'This country or region is not supported',
'ms-MY': 'Negara atau wilayah ini tidak disokong',
},
+ BEFORE_DATE_REQUIRED: {
+ 'zh-CN': '截止日期不能为空',
+ 'en-US': 'Before date is required',
+ 'ms-MY': 'Tarikh akhir diperlukan',
+ },
+ INVALID_BEFORE_DATE: {
+ 'zh-CN': '截止日期无效',
+ 'en-US': 'Invalid before date',
+ 'ms-MY': 'Tarikh akhir tidak sah',
+ },
+ BEFORE_DATE_CANNOT_BE_FUTURE: {
+ 'zh-CN': '截止日期不能是未来日期',
+ 'en-US': 'Before date cannot be in the future',
+ 'ms-MY': 'Tarikh akhir tidak boleh pada masa hadapan',
+ },
} as const satisfies Record>;
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;