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>
534 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|