feat(admin,api,player): 返水流程优化、账单详情与数据库重置
优化返水预览/确认/作废,新增玩家账变详情与后台一键重置为 seed 数据,并修复 dev 启动时 3000 端口占用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -8,6 +9,16 @@ import {
|
||||
type CashbackRuleRow,
|
||||
} from './cashback-rate.resolver';
|
||||
|
||||
type AggregatedItem = {
|
||||
userId: bigint;
|
||||
effectiveStake: Decimal;
|
||||
betCount: number;
|
||||
rate: Decimal;
|
||||
amount: Decimal;
|
||||
username: string;
|
||||
agentUsername: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
@@ -15,7 +26,12 @@ export class CashbackService {
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{
|
||||
items: AggregatedItem[];
|
||||
totalAmount: Decimal;
|
||||
totalEffectiveStake: Decimal;
|
||||
totalBetCount: number;
|
||||
}> {
|
||||
const [settledBets, rules, agentProfiles] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where: {
|
||||
@@ -45,7 +61,7 @@ export class CashbackService {
|
||||
|
||||
const playerAgg = new Map<
|
||||
string,
|
||||
{ userId: bigint; stake: Decimal; amount: Decimal }
|
||||
{ userId: bigint; stake: Decimal; amount: Decimal; betCount: number }
|
||||
>();
|
||||
|
||||
for (const bet of settledBets) {
|
||||
@@ -69,22 +85,25 @@ export class CashbackService {
|
||||
userId: bet.userId,
|
||||
stake: new Decimal(0),
|
||||
amount: new Decimal(0),
|
||||
betCount: 0,
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
existing.amount = existing.amount.add(bet.stake.mul(rate));
|
||||
existing.betCount += 1;
|
||||
playerAgg.set(key, existing);
|
||||
}
|
||||
|
||||
const items = Array.from(playerAgg.values())
|
||||
const rawItems = Array.from(playerAgg.values())
|
||||
.map((p) => ({
|
||||
userId: p.userId,
|
||||
effectiveStake: p.stake,
|
||||
betCount: p.betCount,
|
||||
rate: p.stake.gt(0) ? p.amount.div(p.stake) : new Decimal(0),
|
||||
amount: p.amount,
|
||||
}))
|
||||
.sort((a, b) => (b.amount.gt(a.amount) ? 1 : a.amount.gt(b.amount) ? -1 : 0));
|
||||
|
||||
const userIds = items.map((i) => i.userId);
|
||||
const userIds = rawItems.map((i) => i.userId);
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
@@ -98,7 +117,7 @@ export class CashbackService {
|
||||
: [];
|
||||
const userById = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
const enrichedItems = items.map((item) => {
|
||||
const items: AggregatedItem[] = rawItems.map((item) => {
|
||||
const user = userById.get(item.userId.toString());
|
||||
return {
|
||||
...item,
|
||||
@@ -107,32 +126,205 @@ export class CashbackService {
|
||||
};
|
||||
});
|
||||
|
||||
const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0));
|
||||
const totalBetCount = items.reduce((s, i) => s + i.betCount, 0);
|
||||
|
||||
const batch = await this.prisma.cashbackBatch.create({
|
||||
data: {
|
||||
batchNo: generateBatchNo('CB'),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
status: 'PREVIEW',
|
||||
totalAmount,
|
||||
playerCount: items.length,
|
||||
},
|
||||
return { items, totalAmount, totalEffectiveStake, totalBetCount };
|
||||
}
|
||||
|
||||
private normalizePeriodStart(input: Date): Date {
|
||||
const d = new Date(input);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
private normalizePeriodEnd(input: Date): Date {
|
||||
const d = new Date(input);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
private async removePreviewBatchesForPeriod(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
tx: Prisma.TransactionClient,
|
||||
) {
|
||||
const stale = await tx.cashbackBatch.findMany({
|
||||
where: { status: 'PREVIEW', periodStart, periodEnd },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const b of stale) {
|
||||
await tx.cashbackItem.deleteMany({ where: { batchId: b.id } });
|
||||
await tx.cashbackBatch.delete({ where: { id: b.id } });
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
for (const item of enrichedItems) {
|
||||
await this.prisma.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
userId: item.userId,
|
||||
effectiveStake: item.effectiveStake,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
},
|
||||
});
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
const start = this.normalizePeriodStart(periodStart);
|
||||
const end = this.normalizePeriodEnd(periodEnd);
|
||||
if (start > end) {
|
||||
throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
}
|
||||
|
||||
return { batch, items: enrichedItems, totalAmount };
|
||||
const alreadyPaid = await this.prisma.cashbackBatch.findFirst({
|
||||
where: { status: 'CONFIRMED', periodStart: start, periodEnd: end },
|
||||
});
|
||||
if (alreadyPaid) {
|
||||
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
|
||||
}
|
||||
|
||||
const { items, totalAmount, totalEffectiveStake, totalBetCount } =
|
||||
await this.aggregatePeriod(start, end);
|
||||
|
||||
if (items.length === 0 || totalAmount.lte(0)) {
|
||||
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
|
||||
}
|
||||
|
||||
let batch!: Awaited<ReturnType<typeof this.prisma.cashbackBatch.create>>;
|
||||
const replacedPreviewCount = await this.prisma.$transaction(async (tx) => {
|
||||
const replaced = await this.removePreviewBatchesForPeriod(start, end, tx);
|
||||
|
||||
batch = await tx.cashbackBatch.create({
|
||||
data: {
|
||||
batchNo: generateBatchNo('CB'),
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
status: 'PREVIEW',
|
||||
totalAmount,
|
||||
totalEffectiveStake,
|
||||
totalBetCount,
|
||||
playerCount: items.length,
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
await tx.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
userId: item.userId,
|
||||
effectiveStake: item.effectiveStake,
|
||||
betCount: item.betCount,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return replaced;
|
||||
});
|
||||
|
||||
const avgRate = totalEffectiveStake.gt(0)
|
||||
? totalAmount.div(totalEffectiveStake)
|
||||
: new Decimal(0);
|
||||
|
||||
return {
|
||||
batch,
|
||||
items,
|
||||
totalAmount,
|
||||
totalEffectiveStake,
|
||||
totalBetCount,
|
||||
avgRate,
|
||||
replacedPreviewCount,
|
||||
};
|
||||
}
|
||||
|
||||
async listBatches(params: { page: number; pageSize: number; status?: string }) {
|
||||
const page = Math.max(1, params.page);
|
||||
const pageSize = Math.min(100, Math.max(1, params.pageSize));
|
||||
const where = params.status?.trim() ? { status: params.status.trim() } : {};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.cashbackBatch.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.cashbackBatch.count({ where }),
|
||||
]);
|
||||
|
||||
const operatorIds = rows
|
||||
.map((r) => r.operatorId)
|
||||
.filter((id): id is bigint => id != null);
|
||||
const operators =
|
||||
operatorIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
...row,
|
||||
operatorUsername: row.operatorId
|
||||
? operatorById.get(row.operatorId.toString()) ?? null
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getBatchDetail(batchId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: {
|
||||
items: { orderBy: { amount: 'desc' } },
|
||||
},
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
|
||||
const userIds = batch.items.map((i) => i.userId);
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
let operatorUsername: string | null = null;
|
||||
if (batch.operatorId) {
|
||||
const op = await this.prisma.user.findUnique({
|
||||
where: { id: batch.operatorId },
|
||||
select: { username: true },
|
||||
});
|
||||
operatorUsername = op?.username ?? null;
|
||||
}
|
||||
|
||||
const items = batch.items.map((item) => {
|
||||
const user = userById.get(item.userId.toString());
|
||||
return {
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
username: user?.username ?? '',
|
||||
agentUsername: user?.parent?.username ?? null,
|
||||
effectiveStake: item.effectiveStake,
|
||||
betCount: item.betCount,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
};
|
||||
});
|
||||
|
||||
const avgRate = batch.totalEffectiveStake.gt(0)
|
||||
? batch.totalAmount.div(batch.totalEffectiveStake)
|
||||
: new Decimal(0);
|
||||
|
||||
return {
|
||||
batch: { ...batch, operatorUsername },
|
||||
items,
|
||||
totalAmount: batch.totalAmount,
|
||||
totalEffectiveStake: batch.totalEffectiveStake,
|
||||
totalBetCount: batch.totalBetCount,
|
||||
avgRate,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||
@@ -141,7 +333,22 @@ export class CashbackService {
|
||||
include: { items: true },
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
|
||||
if (batch.items.length === 0 || batch.totalAmount.lte(0)) {
|
||||
throw new BadRequestException('批次无有效返水金额');
|
||||
}
|
||||
|
||||
const duplicate = await this.prisma.cashbackBatch.findFirst({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
periodStart: batch.periodStart,
|
||||
periodEnd: batch.periodEnd,
|
||||
id: { not: batchId },
|
||||
},
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new BadRequestException('该统计周期已发放返水');
|
||||
}
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
@@ -164,6 +371,21 @@ export class CashbackService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async cancelBatch(batchId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') {
|
||||
throw new BadRequestException('只能作废待发放批次');
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getUserCashbacks(userId: bigint) {
|
||||
return this.prisma.cashbackItem.findMany({
|
||||
where: { userId },
|
||||
|
||||
Reference in New Issue
Block a user