Files
thebet365/apps/api/src/applications/player/player.controller.ts
Mars f9343b00af feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
API:
- 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板
- 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数
- User 表增加 visible_menus 字段;新增 player_messages 表及迁移
- 充值审核通过/拒绝时按系统配置自动写入玩家站内消息
- 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count
- 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置
- 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管)
- SystemConfig 增加 inbox 功能开关及各类通知开关

Admin:
- 员工管理:按角色默认菜单 + 可勾选可见菜单项
- ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询
- Contents:富文本编辑器、图片字段组件重构
- DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列
- 多页面 i18n 与权限细节调整

Player:
- 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除
- 公告列表与详情页;走马灯可跳转详情
- 客服 Modal 改为 Panel,与邮箱 Hub 整合
- 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认
- HomeView 今日赛事板块;FootballView 等体验优化

Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码
Docs: 玩家端缺失功能分析文档
Chore: 移除 .agents/skills 设计类 skill 文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:51:10 +08:00

534 lines
17 KiB
TypeScript

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<Awaited<ReturnType<UsersService['findById']>>>) {
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 }>).filter((m) => {
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);
}
}