import { Controller, Get, Post, Patch, Delete, Body, Param, Query, Headers, UseGuards, UseInterceptors, UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { randomUUID } from 'crypto'; import { mkdir, writeFile } from 'fs/promises'; import { extname, join } from 'path'; import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards'; import { CurrentUser, Public } from '../../shared/common/decorators'; import { jsonResponse } from '../../shared/common/filters'; import { appBadRequest } from '../../shared/common/app-error'; import { getUploadRoot } from '../../shared/uploads/upload-paths'; import { UsersService } from '../../domains/identity/users.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; import { WalletService } from '../../domains/ledger/wallet.service'; import { MatchesService } from '../../domains/catalog/matches.service'; import { OutrightService } from '../../domains/catalog/outright.service'; import { BetsService } from '../../domains/betting/bets.service'; import { ContentService } from '../../domains/operations/content/content.service'; import { CashbackService } from '../../domains/operations/cashback/cashback.service'; import { DepositService } from '../../domains/deposit/deposit.service'; import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service'; import { PresenceService } from '../../domains/presence/presence.service'; import { isInLocalTodayMatchWindow } from '@thebet365/shared'; import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator'; import { Type } from 'class-transformer'; class SingleBetDto { @IsString() selectionId!: string; @IsString() oddsVersion!: string; @IsNumber() @Min(0.01) stake!: number; @IsString() requestId!: string; } class ParlayLegDto { @IsString() selectionId!: string; @IsString() oddsVersion!: string; } class ParlayBetDto { @IsArray() @ValidateNested({ each: true }) @Type(() => ParlayLegDto) legs!: ParlayLegDto[]; @IsNumber() @Min(0.01) stake!: number; @IsString() requestId!: string; } class LocaleDto { @IsString() locale!: string; } class UpdateProfileDto { @IsOptional() @IsString() phone?: string; @IsOptional() @IsString() email?: string; @IsOptional() @IsString() avatarKey?: string; @IsOptional() @IsString() username?: string; } function safeTimeZone(input?: string): string { const value = input?.trim(); if (!value) return 'UTC'; try { new Intl.DateTimeFormat('en-US', { timeZone: value }).format(new Date()); return value; } catch { return 'UTC'; } } @ApiTags('Player') @Controller('player') @UseGuards(JwtAuthGuard, PlayerGuard) @ApiBearerAuth() export class PlayerController { constructor( private users: UsersService, private wallet: WalletService, private matches: MatchesService, private outright: OutrightService, private bets: BetsService, private content: ContentService, private cashback: CashbackService, private systemConfig: SystemConfigService, private deposit: DepositService, private playerMessages: PlayerMessagesService, private presence: PresenceService, ) {} @Post('presence/ping') async presencePing(@CurrentUser('id') userId: bigint) { await this.presence.touch(userId); return jsonResponse({ ok: true }); } private async formatPlayerProfile(user: NonNullable>>) { const accountSettings = await this.systemConfig.getPlayerAccountSettings(); const prefs = user.preferences; const viewablePassword = prefs?.managedPassword ?? null; const safePrefs = prefs ? (({ managedPassword: _m, allowPasswordChange: _a, allowUsernameChange: _b, ...rest }) => rest)(prefs) : {}; return { ...user, id: user.id.toString(), parentId: user.parentId?.toString() ?? null, preferences: { ...safePrefs, viewablePassword, allowPasswordChange: accountSettings.allowPasswordChange, allowUsernameChange: accountSettings.allowUsernameChange, }, }; } @Get('profile') async profile(@CurrentUser('id') userId: bigint) { const user = await this.users.findById(userId); if (!user) return jsonResponse(null); return jsonResponse(await this.formatPlayerProfile(user)); } @Post('language') async setLanguage(@CurrentUser('id') userId: bigint, @Body() dto: LocaleDto) { const result = await this.users.updateLocale(userId, dto.locale); return jsonResponse(result); } @Patch('profile') async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) { const user = await this.users.updateProfile(userId, dto); if (!user) return jsonResponse(null); return jsonResponse(await this.formatPlayerProfile(user)); } @Public() @Get('home') async home( @CurrentUser('locale') userLocale: string | undefined, @Headers('x-locale') headerLocale?: string, @Headers('x-time-zone') headerTimeZone?: string, ) { const locale = userLocale || headerLocale || 'zh-CN'; const [banners, announcements, allMatches, upcomingMatches, inboxEnabled] = await Promise.all([ this.content.listActive('BANNER', locale), this.content.listActiveAnnouncements(locale), this.matches.listPublished(locale, undefined, { includeMarkets: false }), this.matches.listUpcomingPublished(locale), this.systemConfig.getInboxFeatureEnabled(), ]); const timeZone = safeTimeZone(headerTimeZone); const now = new Date(); const hotMatches = (allMatches as Array<{ isHot?: boolean; status?: string }>).filter( (m) => m.isHot && m.status !== 'SETTLED', ); const todayMatches = (allMatches as Array<{ startTime: string; status?: string }>).filter((m) => { if (m.status === 'SETTLED') return false; const kickoff = new Date(m.startTime); return !Number.isNaN(kickoff.getTime()) && isInLocalTodayMatchWindow(kickoff, now, timeZone); }); return jsonResponse({ banners, announcements, /** @deprecated 使用 announcements */ ticker: announcements, /** @deprecated 使用 announcements */ notices: announcements, hotMatches, todayMatches, upcomingMatches, inboxEnabled, }); } @Public() @Get('matches') async listMatches( @CurrentUser('locale') userLocale: string | undefined, @Headers('x-locale') headerLocale: string | undefined, @Query('leagueId') leagueId?: string, @Query('scope') scope?: string, ) { const locale = userLocale || headerLocale || 'zh-CN'; const lid = leagueId ? BigInt(leagueId) : undefined; if (scope === 'parlay') { const items = await this.matches.listPublished(locale, lid, { includeMarkets: true, parlayMarketsOnly: true, }); return jsonResponse(items); } const items = await this.matches.listPublished(locale, lid, { includeMarkets: false }); return jsonResponse(items); } @Public() @Get('outrights') async listOutrights( @CurrentUser('locale') userLocale: string | undefined, @Headers('x-locale') headerLocale: string | undefined, ) { const locale = userLocale || headerLocale || 'zh-CN'; const items = await this.outright.listForPlayer(locale); return jsonResponse(items); } @Public() @Get('matches/:id') async matchDetail( @Param('id') id: string, @CurrentUser('locale') userLocale: string | undefined, @Headers('x-locale') headerLocale: string | undefined, ) { const locale = userLocale || headerLocale || 'zh-CN'; const match = await this.matches.getMatchDetail(BigInt(id), locale); return jsonResponse(match); } @Public() @Get('selections/odds') async selectionOdds(@Query('ids') ids?: string) { if (!ids?.trim()) throw appBadRequest('SELECTION_NOT_FOUND'); const parsed = ids .split(',') .map((s) => s.trim()) .filter(Boolean) .map((s) => BigInt(s)); if (!parsed.length || parsed.length > 20) throw appBadRequest('PARLAY_LEG_COUNT_INVALID'); const items = await this.matches.getSelectionsOdds(parsed); return jsonResponse({ items }); } @Post('bets/single') async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) { const bet = await this.bets.placeSingleBet( userId, parentId, BigInt(dto.selectionId), BigInt(dto.oddsVersion), dto.stake, dto.requestId, ); return jsonResponse(bet); } @Post('bets/parlay') async parlayBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: ParlayBetDto) { const bet = await this.bets.placeParlayBet( userId, parentId, dto.legs.map((l) => ({ selectionId: BigInt(l.selectionId), oddsVersion: BigInt(l.oddsVersion) })), dto.stake, dto.requestId, ); return jsonResponse(bet); } @Get('bets') async myBets( @CurrentUser('id') userId: bigint, @CurrentUser('locale') locale: string, @Query('status') status?: string, @Query('page') page?: string, @Query('matchId') matchId?: string, ) { const result = await this.bets.getUserBets( userId, status, page ? parseInt(page, 10) : 1, 20, matchId ? BigInt(matchId) : undefined, ); const items = await this.matches.enrichBetsForHistory(result.items, locale); return jsonResponse({ ...result, items }); } @Get('bets/stats') async betStats(@CurrentUser('id') userId: bigint) { const stats = await this.bets.getUserBetStats(userId); return jsonResponse(stats); } @Get('bets/:betNo') async betDetail( @CurrentUser('id') userId: bigint, @CurrentUser('locale') locale: string, @Param('betNo') betNo: string, ) { const bet = await this.bets.getBetByNo(betNo, userId); if (!bet) return jsonResponse(null); const [enriched] = await this.matches.enrichBetsForHistory([bet], locale); return jsonResponse(enriched); } @Get('wallet/transactions/stats') async transactionStats(@CurrentUser('id') userId: bigint) { const stats = await this.wallet.getTransactionStats(userId); return jsonResponse(stats); } @Get('wallet/transactions') async transactions( @CurrentUser('id') userId: bigint, @Query('page') page?: string, @Query('type') type?: string, ) { const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1, 20, type); return jsonResponse(result); } @Get('wallet/transactions/:transactionId') async transactionDetail( @CurrentUser('id') userId: bigint, @Param('transactionId') transactionId: string, ) { const detail = await this.wallet.getTransactionDetail(userId, transactionId); return jsonResponse(detail); } @Get('cashbacks') async cashbacks(@CurrentUser('id') userId: bigint) { const items = await this.cashback.getUserCashbacks(userId); return jsonResponse(items); } // ============ Deposit / Recharge ============ @Get('payment-methods') async paymentMethods( @Query('methodType') methodType?: string, @CurrentUser('locale') userLocale?: string, @Headers('x-locale') headerLocale?: string, ) { const locale = userLocale || headerLocale || 'zh-CN'; const items = await this.deposit.listPlayerPaymentMethods(methodType || undefined, locale); return jsonResponse(items); } @Post('deposit-orders') @UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 5 * 1024 * 1024 } })) async createDepositOrder( @CurrentUser('id') userId: bigint, @UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer; size: number } | undefined, @Body() body: { paymentMethodId: string; amount: string }, ) { if (!file) throw appBadRequest('SCREENSHOT_REQUIRED'); if (!file.mimetype.startsWith('image/')) throw appBadRequest('FILE_MUST_BE_IMAGE'); const amount = parseFloat(body.amount); if (!amount || amount <= 0) throw appBadRequest('INVALID_AMOUNT'); if (!body.paymentMethodId) throw appBadRequest('PAYMENT_METHOD_REQUIRED'); // Save screenshot const ext = extname(file.originalname || '.jpg').toLowerCase() || '.jpg'; const filename = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`; const root = getUploadRoot(); const targetDir = join(root, 'deposits'); await mkdir(targetDir, { recursive: true }); await writeFile(join(targetDir, filename), file.buffer); const screenshotUrl = `/uploads/deposits/${filename}`; const order = await this.deposit.createDepositOrder( userId, BigInt(body.paymentMethodId), amount, screenshotUrl, ); return jsonResponse({ id: order.id.toString(), orderNo: order.orderNo, amount: order.amount.toString(), status: order.status, createdAt: order.createdAt, }); } @Get('deposit-orders') async myDepositOrders( @CurrentUser('id') userId: bigint, @Query('page') page?: string, ) { const result = await this.deposit.getPlayerDepositOrders( userId, page ? parseInt(page, 10) : 1, ); return jsonResponse(result); } @Get('deposit-orders/:id/audit-logs') async myDepositOrderAuditLogs( @CurrentUser('id') userId: bigint, @Param('id') id: string, ) { const items = await this.deposit.getPlayerDepositOrderAuditLogs(BigInt(id), userId); return jsonResponse({ items }); } @Post('deposit-orders/:id/reapply') @UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 5 * 1024 * 1024 } })) async reapplyDepositOrder( @CurrentUser('id') userId: bigint, @Param('id') id: string, @UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer; size: number } | undefined, @Body() body: { paymentMethodId?: string; amount?: string }, ) { if (!file) throw appBadRequest('SCREENSHOT_REQUIRED'); if (!file.mimetype.startsWith('image/')) throw appBadRequest('FILE_MUST_BE_IMAGE'); const amount = body.amount != null && body.amount !== '' ? parseFloat(body.amount) : undefined; if (amount != null && (!amount || amount <= 0)) throw appBadRequest('INVALID_AMOUNT'); const ext = extname(file.originalname || '.jpg').toLowerCase() || '.jpg'; const filename = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`; const root = getUploadRoot(); const targetDir = join(root, 'deposits'); await mkdir(targetDir, { recursive: true }); await writeFile(join(targetDir, filename), file.buffer); const screenshotUrl = `/uploads/deposits/${filename}`; const order = await this.deposit.reapplyDepositOrder( userId, BigInt(id), screenshotUrl, amount, body.paymentMethodId ? BigInt(body.paymentMethodId) : undefined, ); return jsonResponse({ id: order!.id.toString(), orderNo: order!.orderNo, amount: order!.amount.toString(), status: order!.status, createdAt: order!.createdAt, }); } // ============ Messages / Inbox ============ @Get('messages') async listMessages( @CurrentUser('id') userId: bigint, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { const result = await this.playerMessages.listForPlayer( userId, page ? parseInt(page, 10) : 1, pageSize ? parseInt(pageSize, 10) : 20, ); return jsonResponse(result); } @Get('messages/unread-count') async messageUnreadCount(@CurrentUser('id') userId: bigint) { const result = await this.playerMessages.getUnreadCount(userId); return jsonResponse(result); } @Get('messages/:id') async messageDetail(@CurrentUser('id') userId: bigint, @Param('id') id: string) { const message = await this.playerMessages.getForPlayer(userId, BigInt(id)); return jsonResponse(message); } @Patch('messages/read-all') async markAllMessagesRead(@CurrentUser('id') userId: bigint) { const result = await this.playerMessages.markAllRead(userId); return jsonResponse(result); } @Patch('messages/:id/read') async markMessageRead(@CurrentUser('id') userId: bigint, @Param('id') id: string) { const message = await this.playerMessages.markRead(userId, BigInt(id)); return jsonResponse(message); } @Delete('messages') async deleteAllMessages(@CurrentUser('id') userId: bigint) { const result = await this.playerMessages.deleteAllForPlayer(userId); return jsonResponse(result); } @Delete('messages/:id') async deleteMessage(@CurrentUser('id') userId: bigint, @Param('id') id: string) { const result = await this.playerMessages.deleteForPlayer(userId, BigInt(id)); return jsonResponse(result); } }