This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -3,7 +3,8 @@ import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { resolveTranslationFallback } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { AgentCreditService } from '../agent/agent-credit.service';
import { appBadRequest } from '../../shared/common/app-error';
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
@@ -20,7 +21,8 @@ const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
export class DepositService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private credit: AgentCreditService,
) {}
// ============ Payment Methods (Admin CRUD) ============
@@ -413,6 +415,7 @@ export class DepositService {
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
const parentAgentId = await this.assertParentCreditForDeposit(tx, order.playerId, creditAmount);
await tx.depositOrder.update({
where: { id: orderId },
@@ -426,14 +429,19 @@ export class DepositService {
});
// Credit player wallet
await this.wallet.deposit(
order.playerId,
creditAmount,
await this.funds.deposit({
userId: order.playerId,
amount: creditAmount,
operatorId,
remark ?? `Deposit order ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT',
);
remark: remark ?? `Deposit order ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT',
tx,
businessKey: `deposit:${order.orderNo}:approve`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
return { success: true };
});
@@ -469,86 +477,76 @@ export class DepositService {
remark: string,
) {
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
remark,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
);
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
businessKey: `deposit:${order.orderNo}:reverse`,
});
}
/** 已拒绝恢复待审核已通过5 分钟内):作废期间待结算注单并扣回入账 */
/** 已拒绝恢复待审核已通过5 分钟内且未产生下注):扣回入账并恢复待审核 */
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
if (order.status === 'REJECTED') {
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true };
}
if (order.status !== 'APPROVED') {
throw appBadRequest('ORDER_NOT_APPROVED');
}
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
}
const reviewedAt = order.reviewedAt;
return this.prisma.$transaction(async (tx) => {
const order = await this.lockDepositOrder(tx, orderId);
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
if (order.status === 'REJECTED') {
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true };
}
if (order.status !== 'APPROVED') {
throw appBadRequest('ORDER_NOT_APPROVED');
}
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
}
await this.lockPlayerWallet(tx, order.playerId);
const reviewedAt = order.reviewedAt;
const betsAfterReview = await tx.bet.findMany({
where: {
userId: order.playerId,
placedAt: { gte: reviewedAt },
status: { not: 'VOID' },
},
select: { id: true },
});
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
if (settled.length > 0) {
if (betsAfterReview.length > 0) {
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
}
for (const bet of betsAfterReview) {
await this.wallet.settleBet(
bet.userId,
bet.stake,
bet.stake,
bet.betNo,
'VOID',
tx,
);
await tx.bet.update({
where: { id: bet.id },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
}
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
`Revoke approved deposit ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
remark: `Revoke approved deposit ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
tx,
);
businessKey: `deposit:${order.orderNo}:reopen-reverse`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
await tx.depositOrder.update({
where: { id: orderId },
@@ -562,14 +560,17 @@ export class DepositService {
},
});
return { success: true, voidedBets: betsAfterReview.length };
return { success: true, voidedBets: 0 };
});
}
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
/** 删除充值订单记录及截图,仅允许删除未资金化订单。 */
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status === 'APPROVED') {
throw appBadRequest('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN');
}
const screenshotUrl = order.screenshotUrl;
await this.prisma.depositOrder.delete({ where: { id: orderId } });
@@ -577,4 +578,51 @@ export class DepositService {
return { success: true };
}
private async lockDepositOrder(tx: Prisma.TransactionClient, orderId: bigint) {
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
SELECT id FROM deposit_orders WHERE id = ${orderId} FOR UPDATE
`;
if (!rows.length) throw appBadRequest('ORDER_NOT_FOUND');
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
return order;
}
private async lockPlayerWallet(tx: Prisma.TransactionClient, playerId: bigint) {
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
SELECT id FROM wallets WHERE user_id = ${playerId} FOR UPDATE
`;
if (!rows.length) throw appBadRequest('WALLET_NOT_FOUND');
}
private async findPlayerParentAgentId(tx: Prisma.TransactionClient, playerId: bigint) {
const player = await tx.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
select: { parentId: true },
});
return player?.parentId ?? null;
}
private async assertParentCreditForDeposit(
tx: Prisma.TransactionClient,
playerId: bigint,
amount: Decimal,
) {
const parentAgentId = await this.findPlayerParentAgentId(tx, playerId);
if (!parentAgentId) return null;
await this.credit.recalculateUsedCredit(parentAgentId, tx);
const profile = await tx.agentProfile.findUnique({
where: { userId: parentAgentId },
select: { creditLimit: true, usedCredit: true },
});
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
if (available.lt(amount)) {
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
return parentAgentId;
}
}