feat(admin,api,player): 返水流程优化、账单详情与数据库重置

优化返水预览/确认/作废,新增玩家账变详情与后台一键重置为 seed 数据,并修复 dev 启动时 3000 端口占用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 11:14:22 +08:00
parent 24fa1b275c
commit b2216abd0c
24 changed files with 2253 additions and 849 deletions

View File

@@ -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 },