fix(admin,api,player): 返水注单去重、操作日志 i18n 与钱包紧凑金额

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 16:07:11 +08:00
parent 22535d4c27
commit 0d761db70b
8 changed files with 216 additions and 26 deletions

View File

@@ -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;

View File

@@ -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 {

View File

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