fix(admin,api,player): 返水注单去重、操作日志 i18n 与钱包紧凑金额
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "cashback_bets" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"batch_id" BIGINT NOT NULL,
|
||||
"bet_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"stake" DECIMAL(18,4) NOT NULL,
|
||||
"amount" DECIMAL(18,4) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "cashback_bets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cashback_bets_bet_id_key" ON "cashback_bets"("bet_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cashback_bets_batch_id_idx" ON "cashback_bets"("batch_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cashback_bets_user_id_idx" ON "cashback_bets"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cashback_bets" ADD CONSTRAINT "cashback_bets_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "cashback_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cashback_bets" ADD CONSTRAINT "cashback_bets_bet_id_fkey" FOREIGN KEY ("bet_id") REFERENCES "bets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -400,6 +400,7 @@ model Bet {
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
selections BetSelection[]
|
||||
cashbackClaims CashbackBet[]
|
||||
|
||||
@@unique([userId, requestId])
|
||||
@@index([userId])
|
||||
@@ -508,6 +509,7 @@ model CashbackBatch {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
items CashbackItem[]
|
||||
bets CashbackBet[]
|
||||
|
||||
@@map("cashback_batches")
|
||||
}
|
||||
@@ -529,6 +531,24 @@ model CashbackItem {
|
||||
@@map("cashback_items")
|
||||
}
|
||||
|
||||
/** 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次) */
|
||||
model CashbackBet {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
betId BigInt @unique @map("bet_id")
|
||||
userId BigInt @map("user_id")
|
||||
stake Decimal @db.Decimal(18, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([userId])
|
||||
@@map("cashback_bets")
|
||||
}
|
||||
|
||||
// ============ Content & i18n ============
|
||||
|
||||
model Content {
|
||||
|
||||
@@ -19,6 +19,13 @@ type AggregatedItem = {
|
||||
agentUsername: string | null;
|
||||
};
|
||||
|
||||
type BetCashbackLine = {
|
||||
betId: bigint;
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
amount: Decimal;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
@@ -26,13 +33,27 @@ export class CashbackService {
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
/** 已被待发放/已发放返水批次占用的注单 */
|
||||
private async loadClaimedBetIds(): Promise<Set<string>> {
|
||||
const rows = await this.prisma.cashbackBet.findMany({
|
||||
where: {
|
||||
batch: { status: { in: ['PREVIEW', 'CONFIRMED'] } },
|
||||
},
|
||||
select: { betId: true },
|
||||
});
|
||||
return new Set(rows.map((r) => r.betId.toString()));
|
||||
}
|
||||
|
||||
private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{
|
||||
items: AggregatedItem[];
|
||||
betLines: BetCashbackLine[];
|
||||
totalAmount: Decimal;
|
||||
totalEffectiveStake: Decimal;
|
||||
totalBetCount: number;
|
||||
eligibleBetCount: number;
|
||||
skippedClaimedCount: number;
|
||||
}> {
|
||||
const [settledBets, rules, agentProfiles] = await Promise.all([
|
||||
const [settledBets, rules, agentProfiles, claimedBetIds] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST'] },
|
||||
@@ -47,6 +68,7 @@ export class CashbackService {
|
||||
this.prisma.agentProfile.findMany({
|
||||
select: { userId: true, cashbackRate: true },
|
||||
}),
|
||||
this.loadClaimedBetIds(),
|
||||
]);
|
||||
|
||||
const agentRateById = new Map(
|
||||
@@ -63,6 +85,9 @@ export class CashbackService {
|
||||
string,
|
||||
{ userId: bigint; stake: Decimal; amount: Decimal; betCount: number }
|
||||
>();
|
||||
const betLines: BetCashbackLine[] = [];
|
||||
let eligibleBetCount = 0;
|
||||
let skippedClaimedCount = 0;
|
||||
|
||||
for (const bet of settledBets) {
|
||||
const agentId = bet.user.parentId;
|
||||
@@ -80,6 +105,21 @@ export class CashbackService {
|
||||
|
||||
if (rate.lte(0)) continue;
|
||||
|
||||
eligibleBetCount += 1;
|
||||
|
||||
if (claimedBetIds.has(bet.id.toString())) {
|
||||
skippedClaimedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineAmount = bet.stake.mul(rate);
|
||||
betLines.push({
|
||||
betId: bet.id,
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
amount: lineAmount,
|
||||
});
|
||||
|
||||
const key = bet.userId.toString();
|
||||
const existing = playerAgg.get(key) ?? {
|
||||
userId: bet.userId,
|
||||
@@ -88,7 +128,7 @@ export class CashbackService {
|
||||
betCount: 0,
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
existing.amount = existing.amount.add(bet.stake.mul(rate));
|
||||
existing.amount = existing.amount.add(lineAmount);
|
||||
existing.betCount += 1;
|
||||
playerAgg.set(key, existing);
|
||||
}
|
||||
@@ -130,7 +170,15 @@ export class CashbackService {
|
||||
const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0));
|
||||
const totalBetCount = items.reduce((s, i) => s + i.betCount, 0);
|
||||
|
||||
return { items, totalAmount, totalEffectiveStake, totalBetCount };
|
||||
return {
|
||||
items,
|
||||
betLines,
|
||||
totalAmount,
|
||||
totalEffectiveStake,
|
||||
totalBetCount,
|
||||
eligibleBetCount,
|
||||
skippedClaimedCount,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizePeriodStart(input: Date): Date {
|
||||
@@ -155,6 +203,7 @@ export class CashbackService {
|
||||
select: { id: true },
|
||||
});
|
||||
for (const b of stale) {
|
||||
await tx.cashbackBet.deleteMany({ where: { batchId: b.id } });
|
||||
await tx.cashbackItem.deleteMany({ where: { batchId: b.id } });
|
||||
await tx.cashbackBatch.delete({ where: { id: b.id } });
|
||||
}
|
||||
@@ -175,10 +224,20 @@ export class CashbackService {
|
||||
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
|
||||
}
|
||||
|
||||
const { items, totalAmount, totalEffectiveStake, totalBetCount } =
|
||||
await this.aggregatePeriod(start, end);
|
||||
const {
|
||||
items,
|
||||
betLines,
|
||||
totalAmount,
|
||||
totalEffectiveStake,
|
||||
totalBetCount,
|
||||
eligibleBetCount,
|
||||
skippedClaimedCount,
|
||||
} = await this.aggregatePeriod(start, end);
|
||||
|
||||
if (items.length === 0 || totalAmount.lte(0)) {
|
||||
if (eligibleBetCount > 0 && skippedClaimedCount >= eligibleBetCount) {
|
||||
throw new BadRequestException('该周期内的有效注单均已计入其他返水批次,无法生成预览');
|
||||
}
|
||||
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
|
||||
}
|
||||
|
||||
@@ -212,6 +271,18 @@ export class CashbackService {
|
||||
});
|
||||
}
|
||||
|
||||
for (const line of betLines) {
|
||||
await tx.cashbackBet.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
betId: line.betId,
|
||||
userId: line.userId,
|
||||
stake: line.stake,
|
||||
amount: line.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return replaced;
|
||||
});
|
||||
|
||||
@@ -330,7 +401,7 @@ export class CashbackService {
|
||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { items: true },
|
||||
include: { items: true, bets: true },
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
|
||||
@@ -350,6 +421,19 @@ export class CashbackService {
|
||||
throw new BadRequestException('该统计周期已发放返水');
|
||||
}
|
||||
|
||||
const betIds = batch.bets.map((b) => b.betId);
|
||||
if (betIds.length > 0) {
|
||||
const conflict = await this.prisma.cashbackBet.findFirst({
|
||||
where: {
|
||||
betId: { in: betIds },
|
||||
batch: { status: 'CONFIRMED' },
|
||||
},
|
||||
});
|
||||
if (conflict) {
|
||||
throw new BadRequestException('部分注单已在其他批次发放返水,请作废本预览后重新生成');
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.wallet.deposit(
|
||||
@@ -378,9 +462,12 @@ export class CashbackService {
|
||||
throw new BadRequestException('只能作废待发放批次');
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.cashbackBet.deleteMany({ where: { batchId } });
|
||||
await tx.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
Reference in New Issue
Block a user