重构 API 为 8 领域 + 应用层架构

将后端模块拆分为 domains、applications、shared 三层,结算计算器移入 domain 纯函数目录,API 路径与测试保持不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:48:41 +08:00
parent 14e49374ac
commit 4c92157299
47 changed files with 169 additions and 138 deletions

View File

@@ -0,0 +1,400 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AdminGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { MarketsService } from '../../domains/odds/markets.service';
import { SettlementService } from '../../domains/settlement/settlement.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { I18nService } from '../../domains/operations/i18n/i18n.service';
import { AuditService } from '../../domains/operations/audit/audit.service';
import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator';
class CreateUserDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsNumber()
creditLimit?: number;
}
class DepositDto {
@IsNumber()
amount!: number;
@IsString()
requestId!: string;
@IsOptional()
@IsString()
remark?: string;
}
class CreateMatchDto {
@IsString()
leagueId!: string;
@IsString()
homeTeamId!: string;
@IsString()
awayTeamId!: string;
@IsString()
startTime!: string;
@IsOptional()
@IsBoolean()
isHot?: boolean;
}
class ScoreDto {
@IsNumber()
htHome!: number;
@IsNumber()
htAway!: number;
@IsNumber()
ftHome!: number;
@IsNumber()
ftAway!: number;
}
class MarketTemplatesDto {
@IsArray()
marketTypes!: string[];
}
class UpdateOddsDto {
@IsNumber()
odds!: number;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@IsString()
periodEnd!: string;
}
@ApiTags('Admin')
@Controller('admin')
@UseGuards(JwtAuthGuard, AdminGuard)
@ApiBearerAuth()
export class AdminController {
constructor(
private users: UsersService,
private agents: AgentsService,
private wallet: WalletService,
private matches: MatchesService,
private markets: MarketsService,
private settlement: SettlementService,
private cashback: CashbackService,
private content: ContentService,
private i18n: I18nService,
private audit: AuditService,
private bets: BetsService,
private prisma: PrismaService,
) {}
@Get('dashboard')
async dashboard() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [todayBets, pendingMatches, totalPlayers] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.match.count({ where: { status: 'PENDING_SETTLEMENT' } }),
this.prisma.user.count({ where: { userType: 'PLAYER' } }),
]);
return jsonResponse({
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayPayout: todayBets._sum.actualReturn,
pendingSettlement: pendingMatches,
totalPlayers,
});
}
@Get('users')
async listUsers(@Query('page') page?: string) {
const result = await this.users.listPlayers(page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Post('users')
async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
const user = await this.agents.createPlayer(operatorId, {
username: dto.username,
password: dto.password,
parentId: dto.parentId ? BigInt(dto.parentId) : operatorId,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_PLAYER',
module: 'USERS',
targetId: user.id.toString(),
});
return jsonResponse(user);
}
@Get('agents')
async listAgents() {
const agents = await this.prisma.agentProfile.findMany({
include: { user: true },
});
return jsonResponse(agents);
}
@Post('agents')
async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
const user = await this.agents.createAgent(operatorId, {
username: dto.username,
password: dto.password,
level: 1,
creditLimit: dto.creditLimit,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_AGENT',
module: 'AGENTS',
targetId: user.id.toString(),
});
return jsonResponse(user);
}
@Post('agents/:id/credit')
async adjustCredit(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: DepositDto,
) {
const result = await this.agents.adjustCredit(
BigInt(id),
dto.amount,
operatorId,
dto.requestId,
dto.remark,
);
return jsonResponse(result);
}
@Post('wallet/deposit')
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
const result = await this.wallet.deposit(
BigInt(dto.userId),
dto.amount,
operatorId,
dto.remark,
dto.requestId,
);
return jsonResponse(result);
}
@Post('wallet/withdraw')
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
const result = await this.wallet.withdraw(
BigInt(dto.userId),
dto.amount,
operatorId,
dto.remark,
dto.requestId,
);
return jsonResponse(result);
}
@Get('wallet/transactions')
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Post('leagues')
async createLeague(@Body() dto: { code: string; translations: Record<string, string> }) {
const league = await this.matches.createLeague(dto.code, dto.translations);
return jsonResponse(league);
}
@Post('teams')
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
const team = await this.matches.createTeam(dto.code, dto.translations);
return jsonResponse(team);
}
@Get('matches')
async listMatches() {
const matches = await this.prisma.match.findMany({
include: { markets: { include: { selections: true } } },
orderBy: { startTime: 'desc' },
});
return jsonResponse(matches);
}
@Post('matches')
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateMatchDto) {
const match = await this.matches.createMatch({
leagueId: BigInt(dto.leagueId),
homeTeamId: BigInt(dto.homeTeamId),
awayTeamId: BigInt(dto.awayTeamId),
startTime: new Date(dto.startTime),
isHot: dto.isHot,
createdBy: operatorId,
});
return jsonResponse(match);
}
@Post('matches/:id/publish')
async publishMatch(@Param('id') id: string) {
const match = await this.matches.publishMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/close')
async closeMatch(@Param('id') id: string) {
const match = await this.matches.closeMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/cancel')
async cancelMatch(@Param('id') id: string) {
await this.matches.cancelMatch(BigInt(id));
const voided = await this.settlement.voidMatchBets(BigInt(id));
return jsonResponse(voided);
}
@Post('matches/:id/markets/templates')
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
return jsonResponse(markets);
}
@Put('selections/:id/odds')
async updateOdds(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateOddsDto,
) {
const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId);
return jsonResponse(selection);
}
@Post('matches/:id/settlement/score')
async recordScore(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ScoreDto,
) {
const result = await this.settlement.recordScore(
BigInt(id),
dto.htHome,
dto.htAway,
dto.ftHome,
dto.ftAway,
operatorId,
);
return jsonResponse(result);
}
@Post('matches/:id/settlement/preview')
async settlementPreview(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId);
return jsonResponse(preview);
}
@Post('settlement/:batchId/confirm')
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
return jsonResponse(result);
}
@Get('bets')
async listBets(@Query('status') status?: string, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
const where = status ? { status } : {};
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
}),
this.prisma.bet.count({ where }),
]);
return jsonResponse({ items, total });
}
@Post('cashbacks/preview')
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
const preview = await this.cashback.previewBatch(
new Date(dto.periodStart),
new Date(dto.periodEnd),
);
return jsonResponse(preview);
}
@Post('cashbacks/:batchId/confirm')
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
return jsonResponse(result);
}
@Get('contents')
async listContents(@Query('type') type?: string) {
const items = await this.content.listAll(type);
return jsonResponse(items);
}
@Post('contents')
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
const item = await this.content.create(dto);
return jsonResponse(item);
}
@Get('i18n/messages')
async getMessages(@Query('locale') locale = 'en-US') {
const messages = await this.i18n.getMessages(locale);
return jsonResponse(messages);
}
@Get('audit-logs')
async auditLogs(@Query('page') page?: string, @Query('module') module?: string) {
const result = await this.audit.list(page ? parseInt(page) : 1, 50, module);
return jsonResponse(result);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { UsersModule } from '../../domains/identity/users.module';
import { AgentsModule } from '../../domains/agent/agents.module';
import { WalletModule } from '../../domains/ledger/wallet.module';
import { MatchesModule } from '../../domains/catalog/matches.module';
import { MarketsModule } from '../../domains/odds/markets.module';
import { SettlementModule } from '../../domains/settlement/settlement.module';
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
import { ContentModule } from '../../domains/operations/content/content.module';
import { I18nModule } from '../../domains/operations/i18n/i18n.module';
import { BetsModule } from '../../domains/betting/bets.module';
@Module({
imports: [
UsersModule,
AgentsModule,
WalletModule,
MatchesModule,
MarketsModule,
SettlementModule,
CashbackModule,
ContentModule,
I18nModule,
BetsModule,
],
controllers: [AdminController],
})
export class AdminModule {}

View File

@@ -0,0 +1,189 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AgentGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { IsString, IsNumber, MinLength, IsOptional } from 'class-validator';
class CreatePlayerDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
}
class CreateSubAgentDto extends CreatePlayerDto {
@IsOptional()
@IsNumber()
creditLimit?: number;
}
class TransferDto {
@IsNumber()
amount!: number;
@IsString()
requestId!: string;
}
class CreditDto extends TransferDto {
@IsOptional()
@IsString()
remark?: string;
}
@ApiTags('Agent Portal')
@Controller('agent')
@UseGuards(JwtAuthGuard, AgentGuard)
@ApiBearerAuth()
export class AgentPortalController {
constructor(
private agents: AgentsService,
private wallet: WalletService,
private bets: BetsService,
private prisma: PrismaService,
) {}
@Get('profile')
async profile(@CurrentUser('id') agentId: bigint) {
const profile = await this.agents.getProfile(agentId);
return jsonResponse(profile);
}
@Get('players')
async listPlayers(@CurrentUser('id') agentId: bigint) {
const players = await this.agents.getDirectPlayers(agentId);
return jsonResponse(players);
}
@Post('players')
async createPlayer(@CurrentUser('id') agentId: bigint, @Body() dto: CreatePlayerDto) {
const user = await this.agents.createPlayer(agentId, {
username: dto.username,
password: dto.password,
parentId: agentId,
});
return jsonResponse(user);
}
@Get('agents')
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
if (level !== 1) {
return jsonResponse([]);
}
const agents = await this.agents.getChildAgents(agentId);
return jsonResponse(agents);
}
@Post('agents')
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can create sub-agents');
}
const user = await this.agents.createAgent(agentId, {
username: dto.username,
password: dto.password,
level: 2,
parentAgentId: agentId,
creditLimit: dto.creditLimit,
});
return jsonResponse(user);
}
@Post('players/:id/deposit')
async depositToPlayer(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
@Body() dto: TransferDto,
) {
const result = await this.agents.depositToPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
return jsonResponse(result);
}
@Post('players/:id/withdraw')
async withdrawFromPlayer(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
@Body() dto: TransferDto,
) {
const result = await this.agents.withdrawFromPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
return jsonResponse(result);
}
@Post('agents/:id/credit')
async allocateCredit(
@CurrentUser('id') agentId: bigint,
@Param('id') subAgentId: string,
@Body() dto: CreditDto,
) {
const subAgent = await this.prisma.agentProfile.findUnique({
where: { userId: BigInt(subAgentId) },
});
if (!subAgent || subAgent.parentAgentId !== agentId) {
return jsonResponse(null, 'Not your sub-agent');
}
const result = await this.agents.adjustCredit(
BigInt(subAgentId),
dto.amount,
agentId,
dto.requestId,
dto.remark,
);
return jsonResponse(result);
}
@Get('bets')
async listBets(@CurrentUser('id') agentId: bigint, @Query('page') page?: string) {
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
const descendants = await this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
});
const agentIds = descendants.map((d) => d.descendantId);
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where: { agentId: { in: agentIds } },
include: { selections: true, user: true },
orderBy: { placedAt: 'desc' },
skip,
take: 20,
}),
this.prisma.bet.count({ where: { agentId: { in: agentIds } } }),
]);
return jsonResponse({ items, total });
}
@Get('reports/summary')
async reportSummary(@CurrentUser('id') agentId: bigint) {
const summary = await this.agents.getReportSummary(agentId);
return jsonResponse(summary);
}
@Get('wallet-transactions')
async walletTransactions(@CurrentUser('id') agentId: bigint, @Query('playerId') playerId?: string) {
const players = playerId
? [BigInt(playerId)]
: (await this.agents.getDirectPlayers(agentId)).map((p) => p.id);
const transactions = await this.prisma.walletTransaction.findMany({
where: { userId: { in: players } },
orderBy: { createdAt: 'desc' },
take: 50,
});
return jsonResponse(transactions);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AgentPortalController } from './agent-portal.controller';
import { AgentsModule } from '../../domains/agent/agents.module';
import { WalletModule } from '../../domains/ledger/wallet.module';
import { BetsModule } from '../../domains/betting/bets.module';
@Module({
imports: [AgentsModule, WalletModule, BetsModule],
controllers: [AgentPortalController],
})
export class AgentPortalModule {}

View File

@@ -0,0 +1,179 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { UsersService } from '../../domains/identity/users.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.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 { 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;
}
@ApiTags('Player')
@Controller('player')
@UseGuards(JwtAuthGuard, PlayerGuard)
@ApiBearerAuth()
export class PlayerController {
constructor(
private users: UsersService,
private wallet: WalletService,
private matches: MatchesService,
private bets: BetsService,
private content: ContentService,
private cashback: CashbackService,
) {}
@Get('profile')
async profile(@CurrentUser('id') userId: bigint) {
const user = await this.users.findById(userId);
return jsonResponse(user);
}
@Post('language')
async setLanguage(@CurrentUser('id') userId: bigint, @Body() dto: LocaleDto) {
const result = await this.users.updateLocale(userId, dto.locale);
return jsonResponse(result);
}
@Get('home')
async home(@CurrentUser('locale') locale: string) {
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActive('NOTICE', locale),
this.content.listActive('TICKER', locale),
this.matches.listPublished(locale),
this.matches.listPublished(locale),
]);
return jsonResponse({
banners,
notices,
ticker,
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
todayMatches,
});
}
@Get('matches')
async listMatches(
@CurrentUser('locale') locale: string,
@Query('leagueId') leagueId?: string,
) {
const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined);
return jsonResponse(items);
}
@Get('matches/:id')
async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) {
const match = await this.matches.getMatchDetail(BigInt(id), locale);
return jsonResponse(match);
}
@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,
@Query('status') status?: string,
@Query('page') page?: string,
) {
const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Get('bets/:betNo')
async betDetail(@CurrentUser('id') userId: bigint, @Param('betNo') betNo: string) {
const bet = await this.bets.getBetByNo(betNo, userId);
return jsonResponse(bet);
}
@Get('wallet/transactions')
async transactions(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
) {
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Get('cashbacks')
async cashbacks(@CurrentUser('id') userId: bigint) {
const items = await this.cashback.getUserCashbacks(userId);
return jsonResponse(items);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PlayerController } from './player.controller';
import { UsersModule } from '../../domains/identity/users.module';
import { WalletModule } from '../../domains/ledger/wallet.module';
import { MatchesModule } from '../../domains/catalog/matches.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { ContentModule } from '../../domains/operations/content/content.module';
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
@Module({
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule],
controllers: [PlayerController],
})
export class PlayerModule {}