重构 API 为 8 领域 + 应用层架构
将后端模块拆分为 domains、applications、shared 三层,结算计算器移入 domain 纯函数目录,API 路径与测试保持不变。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
9
apps/api/src/domains/operations/audit/audit.module.ts
Normal file
9
apps/api/src/domains/operations/audit/audit.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
48
apps/api/src/domains/operations/audit/audit.service.ts
Normal file
48
apps/api/src/domains/operations/audit/audit.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async log(data: {
|
||||
operatorId?: bigint;
|
||||
operatorType: string;
|
||||
action: string;
|
||||
module: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
beforeData?: unknown;
|
||||
afterData?: unknown;
|
||||
ipAddress?: string;
|
||||
}) {
|
||||
return this.prisma.auditLog.create({
|
||||
data: {
|
||||
operatorId: data.operatorId,
|
||||
operatorType: data.operatorType,
|
||||
action: data.action,
|
||||
module: data.module,
|
||||
targetType: data.targetType,
|
||||
targetId: data.targetId,
|
||||
beforeData: data.beforeData ? JSON.stringify(data.beforeData) : null,
|
||||
afterData: data.afterData ? JSON.stringify(data.afterData) : null,
|
||||
ipAddress: data.ipAddress,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(page = 1, pageSize = 50, module?: string) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = module ? { module } : {};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
10
apps/api/src/domains/operations/cashback/cashback.module.ts
Normal file
10
apps/api/src/domains/operations/cashback/cashback.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CashbackService } from './cashback.service';
|
||||
import { WalletModule } from '../../ledger/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
providers: [CashbackService],
|
||||
exports: [CashbackService],
|
||||
})
|
||||
export class CashbackModule {}
|
||||
108
apps/api/src/domains/operations/cashback/cashback.service.ts
Normal file
108
apps/api/src/domains/operations/cashback/cashback.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../../shared/common/decorators';
|
||||
|
||||
@Injectable()
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'SETTLED'] },
|
||||
settledAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
include: { user: { include: { agentProfile: true } } },
|
||||
});
|
||||
|
||||
const playerStakes = new Map<string, { userId: bigint; stake: Decimal; rate: Decimal }>();
|
||||
|
||||
for (const bet of settledBets) {
|
||||
if (bet.status === 'PUSH' || bet.status === 'VOID') continue;
|
||||
|
||||
const key = bet.userId.toString();
|
||||
const existing = playerStakes.get(key) ?? {
|
||||
userId: bet.userId,
|
||||
stake: new Decimal(0),
|
||||
rate: new Decimal(0.01),
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
playerStakes.set(key, existing);
|
||||
}
|
||||
|
||||
const items = Array.from(playerStakes.values()).map((p) => ({
|
||||
userId: p.userId,
|
||||
effectiveStake: p.stake,
|
||||
rate: p.rate,
|
||||
amount: p.stake.mul(p.rate),
|
||||
}));
|
||||
|
||||
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
|
||||
const batch = await this.prisma.cashbackBatch.create({
|
||||
data: {
|
||||
batchNo: generateBatchNo('CB'),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
status: 'PREVIEW',
|
||||
totalAmount,
|
||||
playerCount: items.length,
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
await this.prisma.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
userId: item.userId,
|
||||
effectiveStake: item.effectiveStake,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { batch, items, totalAmount };
|
||||
}
|
||||
|
||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.wallet.deposit(
|
||||
item.userId,
|
||||
item.amount,
|
||||
operatorId,
|
||||
`Cashback batch ${batch.batchNo}`,
|
||||
batch.batchNo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getUserCashbacks(userId: bigint) {
|
||||
return this.prisma.cashbackItem.findMany({
|
||||
where: { userId },
|
||||
include: { batch: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContentService } from './content.service';
|
||||
|
||||
@Module({
|
||||
providers: [ContentService],
|
||||
exports: [ContentService],
|
||||
})
|
||||
export class ContentModule {}
|
||||
59
apps/api/src/domains/operations/content/content.service.ts
Normal file
59
apps/api/src/domains/operations/content/content.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async listActive(contentType: string, locale: string) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.content.findMany({
|
||||
where: {
|
||||
contentType,
|
||||
status: 'ACTIVE',
|
||||
OR: [{ startTime: null }, { startTime: { lte: now } }],
|
||||
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
|
||||
},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t =
|
||||
item.translations.find((tr) => tr.locale === locale) ||
|
||||
item.translations.find((tr) => tr.locale === 'en-US') ||
|
||||
item.translations[0];
|
||||
return { ...item, translation: t };
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
contentType: string;
|
||||
sortOrder?: number;
|
||||
linkType?: string;
|
||||
linkTarget?: string;
|
||||
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
|
||||
}) {
|
||||
return this.prisma.content.create({
|
||||
data: {
|
||||
contentType: data.contentType,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
linkType: data.linkType,
|
||||
linkTarget: data.linkTarget,
|
||||
status: 'ACTIVE',
|
||||
translations: {
|
||||
create: data.translations,
|
||||
},
|
||||
},
|
||||
include: { translations: true },
|
||||
});
|
||||
}
|
||||
|
||||
async listAll(contentType?: string) {
|
||||
return this.prisma.content.findMany({
|
||||
where: contentType ? { contentType } : {},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
8
apps/api/src/domains/operations/i18n/i18n.module.ts
Normal file
8
apps/api/src/domains/operations/i18n/i18n.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
@Module({
|
||||
providers: [I18nService],
|
||||
exports: [I18nService],
|
||||
})
|
||||
export class I18nModule {}
|
||||
55
apps/api/src/domains/operations/i18n/i18n.service.ts
Normal file
55
apps/api/src/domains/operations/i18n/i18n.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { DEFAULT_LOCALE } from '@thebet365/shared';
|
||||
|
||||
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getMessages(locale: string) {
|
||||
const messages = await this.prisma.i18nMessage.findMany({
|
||||
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
|
||||
});
|
||||
|
||||
const byKey: Record<string, Record<string, string>> = {};
|
||||
for (const m of messages) {
|
||||
if (!byKey[m.msgKey]) byKey[m.msgKey] = {};
|
||||
byKey[m.msgKey][m.locale] = m.value;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, locales] of Object.entries(byKey)) {
|
||||
result[key] =
|
||||
locales[locale] ||
|
||||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
|
||||
key;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async upsertMessage(msgKey: string, locale: string, value: string) {
|
||||
return this.prisma.i18nMessage.upsert({
|
||||
where: { msgKey_locale: { msgKey, locale } },
|
||||
create: { msgKey, locale, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
async listMissing() {
|
||||
const keys = await this.prisma.i18nMessage.groupBy({ by: ['msgKey'] });
|
||||
const locales = ['zh-CN', 'ms-MY', 'en-US'];
|
||||
const missing = [];
|
||||
|
||||
for (const { msgKey } of keys) {
|
||||
for (const locale of locales) {
|
||||
const exists = await this.prisma.i18nMessage.findUnique({
|
||||
where: { msgKey_locale: { msgKey, locale } },
|
||||
});
|
||||
if (!exists) missing.push({ msgKey, locale });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
11
apps/api/src/domains/operations/operations.module.ts
Normal file
11
apps/api/src/domains/operations/operations.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { CashbackModule } from './cashback/cashback.module';
|
||||
import { ContentModule } from './content/content.module';
|
||||
import { I18nModule } from './i18n/i18n.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, CashbackModule, ContentModule, I18nModule],
|
||||
exports: [AuditModule, CashbackModule, ContentModule, I18nModule],
|
||||
})
|
||||
export class OperationsModule {}
|
||||
Reference in New Issue
Block a user