fix(admin,api,player): 返水注单去重、操作日志 i18n 与钱包紧凑金额
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user