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

@@ -226,10 +226,22 @@ export const adminPagesMs: Record<string, string> = {
'audit.col.time': 'Masa',
'audit.action.CREATE_PLAYER': 'Cipta pemain',
'audit.action.UPDATE_PLAYER': 'Kemas kini pemain',
'audit.action.RESET_DATABASE': 'Set semula pangkalan data',
'audit.action.CREATE_AGENT': 'Cipta ejen',
'audit.action.UPDATE_AGENT': 'Kemas kini ejen',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Kemas kini tetapan akaun pemain',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Kemas kini tetapan penggantungan ejen',
'audit.action.UPDATE_BETTING_LIMITS': 'Kemas kini had pertaruhan',
'audit.action.CONFIRM_SETTLEMENT': 'Sahkan penyelesaian',
'audit.action.CONFIRM_RESETTLE': 'Sahkan penyelesaian semula',
'audit.action.CONFIRM_CASHBACK': 'Sahkan bayaran rebat',
'audit.action.CANCEL_CASHBACK': 'Batalkan kelompok rebat',
'audit.module.USERS': 'Pemain',
'audit.module.AGENTS': 'Ejen',
'audit.module.SYSTEM': 'Sistem',
'audit.module.SETTINGS': 'Tetapan',
'audit.module.SETTLEMENT': 'Penyelesaian',
'audit.module.CASHBACK': 'Rebat',
'cashback.start_date': 'Tarikh mula',
'cashback.end_date': 'Tarikh tamat',

View File

@@ -245,8 +245,19 @@ export const adminPagesZh: Record<string, string> = {
'audit.action.RESET_DATABASE': '重置数据库',
'audit.action.CREATE_AGENT': '新建代理',
'audit.action.UPDATE_AGENT': '更新代理',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': '更新玩家账号设置',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': '更新代理停押设置',
'audit.action.UPDATE_BETTING_LIMITS': '更新投注限额',
'audit.action.CONFIRM_SETTLEMENT': '确认结算',
'audit.action.CONFIRM_RESETTLE': '确认重结算',
'audit.action.CONFIRM_CASHBACK': '确认发放返水',
'audit.action.CANCEL_CASHBACK': '作废返水批次',
'audit.module.USERS': '玩家',
'audit.module.AGENTS': '代理',
'audit.module.SYSTEM': '系统',
'audit.module.SETTINGS': '系统设置',
'audit.module.SETTLEMENT': '结算',
'audit.module.CASHBACK': '返水',
'cashback.start_date': '开始日期',
'cashback.end_date': '结束日期',
@@ -929,8 +940,19 @@ export const adminPagesEn: Record<string, string> = {
'audit.action.RESET_DATABASE': 'Reset database',
'audit.action.CREATE_AGENT': 'Create agent',
'audit.action.UPDATE_AGENT': 'Update agent',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Update player account settings',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Update agent suspend settings',
'audit.action.UPDATE_BETTING_LIMITS': 'Update betting limits',
'audit.action.CONFIRM_SETTLEMENT': 'Confirm settlement',
'audit.action.CONFIRM_RESETTLE': 'Confirm resettlement',
'audit.action.CONFIRM_CASHBACK': 'Confirm cashback payout',
'audit.action.CANCEL_CASHBACK': 'Cancel cashback batch',
'audit.module.USERS': 'Players',
'audit.module.AGENTS': 'Agents',
'audit.module.SYSTEM': 'System',
'audit.module.SETTINGS': 'Settings',
'audit.module.SETTLEMENT': 'Settlement',
'audit.module.CASHBACK': 'Cashback',
'cashback.start_date': 'Start date',
'cashback.end_date': 'End date',

View File

@@ -1,22 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, locale, localeTag } = useAdminLocale();
function auditActionLabel(action: string) {
const key = `audit.action.${action}`;
const label = t(key);
return label === key ? action : label;
}
function auditModuleLabel(module: string) {
const key = `audit.module.${module}`;
const label = t(key);
return label === key ? module : label;
}
const { auditActionLabel, auditModuleLabel } = useAuditLabels();
interface AuditRow {
action: string;

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

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatMoney, parseAmount } from '../utils/localeDisplay';
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
interface Transaction {
transactionType: string;
@@ -50,24 +50,24 @@ const stats = computed(() => {
<div class="wallet-stats-panel">
<div class="stats-row">
<div class="stat-item">
<span class="stat-val income">{{ formatMoney(stats.income, locale) }}</span>
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-val expense">{{ formatMoney(stats.expense, locale) }}</span>
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
{{ formatMoney(stats.net, locale) }}
{{ formatMoneyCompact(stats.net, locale) }}
</span>
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-val cashback">{{ formatMoney(stats.cashback, locale) }}</span>
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
</div>
</div>

View File

@@ -55,3 +55,35 @@ export function formatMoney(amount: unknown, locale: string): string {
return `${getLocaleDisplay(locale).currency} ${value.toFixed(2)}`;
}
}
export function formatMoneyCompact(amount: unknown, locale: string): string {
const value = parseAmount(amount);
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
const { currency } = getLocaleDisplay(locale);
const sym = currency === 'CNY' ? '¥' : currency === 'MYR' ? 'RM' : '$';
if (abs >= 10000 && locale === 'zh-CN') {
const v = abs / 10000;
return `${sign}${sym}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}`;
}
if (abs >= 1000000) {
const v = abs / 1000000;
return `${sign}${sym}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}M`;
}
if (abs >= 10000) {
const v = abs / 1000;
return `${sign}${sym}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}K`;
}
try {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
} catch {
return `${sym}${value.toFixed(2)}`;
}
}