feat(admin,api,player): 结算预览分页、统计图表与返水限额

完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 13:54:33 +08:00
parent 6264b8806c
commit efff7c27e6
40 changed files with 3560 additions and 578 deletions

View File

@@ -0,0 +1,72 @@
import { resolveCashbackRateForBet } from './cashback-rate.resolver';
import { Decimal } from '@prisma/client/runtime/library';
describe('resolveCashbackRateForBet', () => {
const userId = BigInt(100);
const agentId = BigInt(200);
it('uses agent default when no rules', () => {
const rate = resolveCashbackRateForBet({
userId,
agentId,
marketTypes: ['FT_1X2'],
agentDefaultRate: new Decimal('0.02'),
rules: [],
});
expect(rate.toString()).toBe('0.02');
});
it('prefers USER rule over agent default', () => {
const rate = resolveCashbackRateForBet({
userId,
agentId,
marketTypes: ['FT_1X2'],
agentDefaultRate: new Decimal('0.01'),
rules: [
{
targetType: 'USER',
targetId: userId,
rate: new Decimal('0.03'),
marketType: null,
},
],
});
expect(rate.toString()).toBe('0.03');
});
it('applies market-specific rule when market matches', () => {
const rate = resolveCashbackRateForBet({
userId,
agentId,
marketTypes: ['FT_HANDICAP'],
agentDefaultRate: new Decimal('0.01'),
rules: [
{
targetType: 'GLOBAL',
targetId: null,
rate: new Decimal('0.005'),
marketType: 'FT_HANDICAP',
},
],
});
expect(rate.toString()).toBe('0.005');
});
it('skips market-specific rule when market does not match', () => {
const rate = resolveCashbackRateForBet({
userId,
agentId,
marketTypes: ['FT_1X2'],
agentDefaultRate: new Decimal('0.01'),
rules: [
{
targetType: 'GLOBAL',
targetId: null,
rate: new Decimal('0.005'),
marketType: 'FT_HANDICAP',
},
],
});
expect(rate.toString()).toBe('0.01');
});
});

View File

@@ -0,0 +1,50 @@
import { Decimal } from '@prisma/client/runtime/library';
export type CashbackRuleRow = {
targetType: string;
targetId: bigint | null;
rate: Decimal;
marketType: string | null;
};
const TARGET_PRIORITY: Record<string, number> = {
USER: 3,
AGENT: 2,
GLOBAL: 1,
};
export function resolveCashbackRateForBet(params: {
userId: bigint;
agentId: bigint | null;
marketTypes: string[];
agentDefaultRate: Decimal;
rules: CashbackRuleRow[];
}): Decimal {
const { userId, agentId, marketTypes, agentDefaultRate, rules } = params;
let best: { priority: number; rate: Decimal } | null = null;
for (const rule of rules) {
if (rule.marketType && !marketTypes.includes(rule.marketType)) continue;
let priority = 0;
if (rule.targetType === 'USER' && rule.targetId?.toString() === userId.toString()) {
priority = TARGET_PRIORITY.USER;
} else if (
rule.targetType === 'AGENT' &&
agentId != null &&
rule.targetId?.toString() === agentId.toString()
) {
priority = TARGET_PRIORITY.AGENT;
} else if (rule.targetType === 'GLOBAL') {
priority = TARGET_PRIORITY.GLOBAL;
} else {
continue;
}
if (!best || priority > best.priority) {
best = { priority, rate: rule.rate };
}
}
return best?.rate ?? agentDefaultRate;
}

View File

@@ -3,6 +3,10 @@ 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';
import {
resolveCashbackRateForBet,
type CashbackRuleRow,
} from './cashback-rate.resolver';
@Injectable()
export class CashbackService {
@@ -12,37 +16,98 @@ export class CashbackService {
) {}
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 [settledBets, rules, agentProfiles] = await Promise.all([
this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST'] },
settledAt: { gte: periodStart, lte: periodEnd },
},
include: {
user: { select: { id: true, parentId: true } },
selections: { select: { marketType: true } },
},
}),
this.prisma.cashbackRule.findMany({ where: { isActive: true } }),
this.prisma.agentProfile.findMany({
select: { userId: true, cashbackRate: 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 agentRateById = new Map(
agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]),
);
const ruleRows: CashbackRuleRow[] = rules.map((r) => ({
targetType: r.targetType,
targetId: r.targetId,
rate: new Decimal(r.rate),
marketType: r.marketType,
}));
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
const playerAgg = new Map<
string,
{ userId: bigint; stake: Decimal; amount: Decimal }
>();
for (const bet of settledBets) {
const agentId = bet.user.parentId;
const agentDefaultRate = agentId
? agentRateById.get(agentId.toString()) ?? new Decimal(0)
: new Decimal(0);
const marketTypes = bet.selections.map((s) => s.marketType);
const rate = resolveCashbackRateForBet({
userId: bet.userId,
agentId,
marketTypes,
agentDefaultRate,
rules: ruleRows,
});
if (rate.lte(0)) continue;
const key = bet.userId.toString();
const existing = playerAgg.get(key) ?? {
userId: bet.userId,
stake: new Decimal(0),
amount: new Decimal(0),
};
existing.stake = existing.stake.add(bet.stake);
existing.amount = existing.amount.add(bet.stake.mul(rate));
playerAgg.set(key, existing);
}
const items = Array.from(playerAgg.values())
.map((p) => ({
userId: p.userId,
effectiveStake: p.stake,
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 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]));
const enrichedItems = items.map((item) => {
const user = userById.get(item.userId.toString());
return {
...item,
username: user?.username ?? '',
agentUsername: user?.parent?.username ?? null,
};
});
const totalAmount = enrichedItems.reduce((s, i) => s.add(i.amount), new Decimal(0));
const batch = await this.prisma.cashbackBatch.create({
data: {
@@ -55,7 +120,7 @@ export class CashbackService {
},
});
for (const item of items) {
for (const item of enrichedItems) {
await this.prisma.cashbackItem.create({
data: {
batchId: batch.id,
@@ -67,7 +132,7 @@ export class CashbackService {
});
}
return { batch, items, totalAmount };
return { batch, items: enrichedItems, totalAmount };
}
async confirmBatch(batchId: bigint, operatorId: bigint) {