重构 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,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View 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 };
}
}

View 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 {}

View 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' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ContentService } from './content.service';
@Module({
providers: [ContentService],
exports: [ContentService],
})
export class ContentModule {}

View 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' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}

View 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;
}
}

View 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 {}