Files
thebet365/apps/api/src/domains/deposit/deposit.service.ts
Mars afb5c5437e feat: 充值订单审计与重新申请,优化赛事展示和余额刷新
- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路
- 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请
- 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等
- 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口
- 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新
- 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 14:52:05 +08:00

943 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from '@nestjs/common';
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 { 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';
function generateOrderNo(): string {
const ts = Date.now().toString(36).toUpperCase();
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
return `DEP${ts}${rand}`;
}
/** 已通过充值订单允许撤回的时间窗口 */
const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
export type DepositOrderAuditAction =
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'REVOKED'
| 'REOPENED'
| 'DELETED';
type AuditLogWriter = {
depositOrderAuditLog: {
create: (args: {
data: {
depositOrderId: bigint;
action: DepositOrderAuditAction;
actorId?: bigint | null;
actorType: string;
statusBefore?: string | null;
statusAfter: string;
amount?: Decimal | null;
approvedAmount?: Decimal | null;
remark?: string | null;
};
}) => Promise<{ id: bigint }>;
};
};
@Injectable()
export class DepositService {
constructor(
private prisma: PrismaService,
private funds: FundsPostingService,
private credit: AgentCreditService,
) {}
// ============ Payment Methods (Admin CRUD) ============
/** isActive 与 showOnPlayer 合并为同一开关DB 两列保持同步以兼容旧数据。 */
private normalizePaymentMethodActive(data: {
isActive?: boolean;
showOnPlayer?: boolean;
}): { isActive?: boolean; showOnPlayer?: boolean } {
if (data.isActive !== undefined) {
return { isActive: data.isActive, showOnPlayer: data.isActive };
}
if (data.showOnPlayer !== undefined) {
return { isActive: data.showOnPlayer, showOnPlayer: data.showOnPlayer };
}
return {};
}
async createPaymentMethod(data: {
methodType: string;
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
createdBy?: bigint;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
}) {
const active = data.isActive ?? data.showOnPlayer ?? true;
const method = await this.prisma.paymentMethod.create({
data: {
methodType: data.methodType,
bankName: data.bankName,
accountHolder: data.accountHolder,
accountNumber: data.accountNumber,
usdtAddress: data.usdtAddress,
qrCodeUrl: data.qrCodeUrl,
displayName: data.displayName,
sortOrder: data.sortOrder ?? 0,
isActive: active,
showOnPlayer: active,
createdBy: data.createdBy,
},
});
if (data.translations) {
await this.upsertPaymentMethodTranslations(method.id, data.translations);
}
return method;
}
async updatePaymentMethod(
id: bigint,
data: {
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
const { translations, ...rest } = data;
const activePatch = this.normalizePaymentMethodActive(rest);
const method = await this.prisma.paymentMethod.update({
where: { id },
data: { ...rest, ...activePatch },
});
if (translations) {
await this.upsertPaymentMethodTranslations(id, translations);
}
return method;
}
async deletePaymentMethod(id: bigint) {
return this.prisma.paymentMethod.update({
where: { id },
data: { isActive: false, showOnPlayer: false },
});
}
async listPaymentMethods(filters?: { methodType?: string }) {
const where: Prisma.PaymentMethodWhereInput = {};
if (filters?.methodType) {
where.methodType = filters.methodType;
}
const items = await this.prisma.paymentMethod.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// Attach translations for admin editing
const ids = items.map((m) => m.id);
const translations = ids.length
? await this.prisma.entityTranslation.findMany({
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
})
: [];
const tMap = new Map<string, Record<string, Record<string, string>>>();
for (const t of translations) {
const key = t.entityId.toString();
if (!tMap.has(key)) tMap.set(key, {});
const entityMap = tMap.get(key)!;
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
entityMap[t.fieldName][t.locale] = t.value;
}
return items.map((m) => ({
...m,
translations: tMap.get(m.id.toString()) ?? {},
}));
}
async listPlayerPaymentMethods(methodType?: string, locale?: string) {
const where: Prisma.PaymentMethodWhereInput = {
isActive: true,
};
if (methodType) {
where.methodType = methodType;
}
const items = await this.prisma.paymentMethod.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
select: {
id: true,
methodType: true,
bankName: true,
accountHolder: true,
accountNumber: true,
usdtAddress: true,
qrCodeUrl: true,
displayName: true,
sortOrder: true,
},
});
// Resolve translations for player locale
if (locale && items.length) {
const ids = items.map((m) => m.id);
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
});
const tMap = new Map<string, Record<string, Record<string, string>>>();
for (const t of translations) {
const key = t.entityId.toString();
if (!tMap.has(key)) tMap.set(key, {});
const entityMap = tMap.get(key)!;
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
entityMap[t.fieldName][t.locale] = t.value;
}
return items.map((m) => {
const t = tMap.get(m.id.toString());
const resolvedDisplayName = t?.displayName
? resolveTranslationFallback(t.displayName, locale) || m.displayName
: m.displayName;
const resolvedBankName = t?.bankName
? resolveTranslationFallback(t.bankName, locale) || m.bankName
: m.bankName;
return { ...m, displayName: resolvedDisplayName, bankName: resolvedBankName };
});
}
return items;
}
// ============ Translation helpers ============
private async upsertPaymentMethodTranslations(
entityId: bigint,
translations: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
},
) {
for (const fieldName of ['displayName', 'bankName'] as const) {
const fieldTranslations = translations[fieldName];
if (!fieldTranslations) continue;
for (const [locale, value] of Object.entries(fieldTranslations)) {
await this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'PAYMENT_METHOD',
entityId,
locale,
fieldName,
},
},
create: { entityType: 'PAYMENT_METHOD', entityId, locale, fieldName, value },
update: { value },
});
}
}
}
// ============ Deposit Orders ============
private async recordDepositAudit(
client: AuditLogWriter,
data: {
depositOrderId: bigint;
action: DepositOrderAuditAction;
actorId?: bigint | null;
actorType: 'PLAYER' | 'ADMIN';
statusBefore?: string | null;
statusAfter: string;
amount?: Decimal | null;
approvedAmount?: Decimal | null;
remark?: string | null;
},
) {
return client.depositOrderAuditLog.create({
data: {
depositOrderId: data.depositOrderId,
action: data.action,
actorId: data.actorId ?? null,
actorType: data.actorType,
statusBefore: data.statusBefore ?? null,
statusAfter: data.statusAfter,
amount: data.amount ?? null,
approvedAmount: data.approvedAmount ?? null,
remark: data.remark ?? null,
},
});
}
private mapAuditLogRow(
row: {
id: bigint;
action: string;
actorId: bigint | null;
actorType: string;
statusBefore: string | null;
statusAfter: string;
amount: Decimal | null;
approvedAmount: Decimal | null;
remark: string | null;
createdAt: Date;
},
actorUsername?: string | null,
) {
return {
id: row.id.toString(),
action: row.action,
actorId: row.actorId?.toString() ?? null,
actorType: row.actorType,
actorUsername: actorUsername ?? null,
statusBefore: row.statusBefore,
statusAfter: row.statusAfter,
amount: row.amount?.toString() ?? null,
approvedAmount: row.approvedAmount?.toString() ?? null,
remark: row.remark,
createdAt: row.createdAt.toISOString(),
};
}
async getDepositOrderAuditLogs(orderId: bigint) {
const order = await this.prisma.depositOrder.findUnique({
where: { id: orderId },
select: { id: true },
});
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
const rows = await this.prisma.depositOrderAuditLog.findMany({
where: { depositOrderId: orderId },
orderBy: { createdAt: 'asc' },
});
const actorIds = [...new Set(rows.map((r) => r.actorId).filter((id): id is bigint => id != null))];
const actors =
actorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, username: true },
})
: [];
const actorMap = new Map(actors.map((a) => [a.id.toString(), a.username]));
return rows.map((row) =>
this.mapAuditLogRow(
row,
row.actorId ? actorMap.get(row.actorId.toString()) ?? null : null,
),
);
}
async getPlayerDepositOrderAuditLogs(orderId: bigint, playerId: bigint) {
const order = await this.prisma.depositOrder.findFirst({
where: { id: orderId, playerId },
select: { id: true },
});
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
const rows = await this.prisma.depositOrderAuditLog.findMany({
where: { depositOrderId: orderId },
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => {
const mapped = this.mapAuditLogRow(row);
return {
id: mapped.id,
action: mapped.action,
actorType: mapped.actorType,
statusBefore: mapped.statusBefore,
statusAfter: mapped.statusAfter,
amount: mapped.amount,
approvedAmount: mapped.approvedAmount,
remark: mapped.remark,
createdAt: mapped.createdAt,
};
});
}
private async attachPlayerAuditLogs(
orders: Array<{ id: bigint }>,
) {
if (!orders.length) return new Map<string, ReturnType<typeof this.mapAuditLogRow>[]>();
const orderIds = orders.map((o) => o.id);
const rows = await this.prisma.depositOrderAuditLog.findMany({
where: { depositOrderId: { in: orderIds } },
orderBy: { createdAt: 'asc' },
});
const map = new Map<string, ReturnType<typeof this.mapAuditLogRow>[]>();
for (const row of rows) {
const key = row.depositOrderId.toString();
if (!map.has(key)) map.set(key, []);
const mapped = this.mapAuditLogRow(row);
map.get(key)!.push({
id: mapped.id,
action: mapped.action,
actorId: null,
actorType: mapped.actorType,
actorUsername: null,
statusBefore: mapped.statusBefore,
statusAfter: mapped.statusAfter,
amount: mapped.amount,
approvedAmount: mapped.approvedAmount,
remark: mapped.remark,
createdAt: mapped.createdAt,
});
}
return map;
}
async createDepositOrder(
playerId: bigint,
paymentMethodId: bigint,
amount: number,
screenshotUrl: string,
) {
const method = await this.prisma.paymentMethod.findUnique({
where: { id: paymentMethodId },
});
if (!method || !method.isActive) {
throw appBadRequest('PAYMENT_METHOD_NOT_FOUND');
}
const order = await this.prisma.depositOrder.create({
data: {
orderNo: generateOrderNo(),
playerId,
paymentMethodId,
methodType: method.methodType,
amount: new Decimal(amount),
screenshotUrl,
status: 'PENDING',
},
});
await this.recordDepositAudit(this.prisma, {
depositOrderId: order.id,
action: 'SUBMITTED',
actorId: playerId,
actorType: 'PLAYER',
statusBefore: null,
statusAfter: 'PENDING',
amount: order.amount,
remark: null,
});
return order;
}
/** 玩家对已拒绝订单重新提交:原订单号不变,状态恢复待审核,需新转账截图。 */
async reapplyDepositOrder(
playerId: bigint,
orderId: bigint,
screenshotUrl: string,
amount?: number,
paymentMethodId?: bigint,
) {
return this.prisma.$transaction(async (tx) => {
const order = await this.lockDepositOrder(tx, orderId);
if (order.playerId !== playerId) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status !== 'REJECTED') throw appBadRequest('ORDER_NOT_REJECTED');
const otherPending = await tx.depositOrder.findFirst({
where: {
playerId,
status: 'PENDING',
id: { not: orderId },
},
select: { id: true },
});
if (otherPending) throw appBadRequest('DEPOSIT_PENDING_ORDER_EXISTS');
const targetMethodId = paymentMethodId ?? order.paymentMethodId;
const method = await tx.paymentMethod.findUnique({ where: { id: targetMethodId } });
if (!method || !method.isActive) {
throw appBadRequest('PAYMENT_METHOD_NOT_FOUND');
}
const creditAmount = amount != null ? new Decimal(amount) : order.amount;
if (creditAmount.lte(0)) throw appBadRequest('INVALID_AMOUNT');
const oldScreenshotUrl = order.screenshotUrl;
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
screenshotUrl,
amount: creditAmount,
paymentMethodId: targetMethodId,
methodType: method.methodType,
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'REOPENED',
actorId: playerId,
actorType: 'PLAYER',
statusBefore: order.status,
statusAfter: 'PENDING',
amount: creditAmount,
remark: null,
});
await deleteUploadFileByUrl(oldScreenshotUrl);
return tx.depositOrder.findUnique({ where: { id: orderId } });
});
}
async getPlayerDepositOrders(playerId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const where = { playerId };
const [items, total] = await Promise.all([
this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
paymentMethod: {
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
},
},
}),
this.prisma.depositOrder.count({ where }),
]);
const auditMap = await this.attachPlayerAuditLogs(items);
return {
items: items.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
paymentMethodId: o.paymentMethodId.toString(),
methodType: o.methodType,
amount: o.amount.toString(),
screenshotUrl: o.screenshotUrl,
status: o.status,
approvedAmount: o.approvedAmount?.toString() ?? null,
rejectReason: o.rejectReason,
remark: o.remark,
createdAt: o.createdAt,
reviewedAt: o.reviewedAt,
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
auditLogs: (auditMap.get(o.id.toString()) ?? []).map((log) => ({
id: log.id,
action: log.action,
actorType: log.actorType,
statusBefore: log.statusBefore,
statusAfter: log.statusAfter,
amount: log.amount,
approvedAmount: log.approvedAmount,
remark: log.remark,
createdAt: log.createdAt,
})),
})),
total,
page,
pageSize,
};
}
async listDepositOrders(params: {
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
methodType?: string;
dateFrom?: Date;
dateTo?: Date;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.DepositOrderWhereInput = {};
if (params.status) {
where.status = params.status;
}
if (params.methodType) {
where.methodType = params.methodType;
}
if (params.dateFrom || params.dateTo) {
where.createdAt = {};
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
if (params.dateTo) where.createdAt.lte = params.dateTo;
}
if (params.keyword?.trim()) {
const players = await this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
username: { contains: params.keyword.trim(), mode: 'insensitive' },
},
select: { id: true },
take: 100,
});
if (!players.length) {
return { items: [], total: 0, page, pageSize };
}
where.playerId = { in: players.map((p) => p.id) };
}
const [rows, total] = await Promise.all([
this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
paymentMethod: {
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
},
},
}),
this.prisma.depositOrder.count({ where }),
]);
// Enrich with player usernames and reviewer info
const playerIds = [...new Set(rows.map((r) => r.playerId))];
const reviewerIds = [...new Set(rows.map((r) => r.reviewerId).filter((id): id is bigint => id != null))];
const [players, reviewers] = await Promise.all([
playerIds.length
? this.prisma.user.findMany({
where: { id: { in: playerIds } },
select: { id: true, username: true },
})
: [],
reviewerIds.length
? this.prisma.user.findMany({
where: { id: { in: reviewerIds } },
select: { id: true, username: true },
})
: [],
]);
const playerMap = new Map(players.map((p) => [p.id.toString(), p.username]));
const reviewerMap = new Map(reviewers.map((r) => [r.id.toString(), r.username]));
return {
items: rows.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
playerId: o.playerId.toString(),
playerUsername: playerMap.get(o.playerId.toString()) ?? null,
methodType: o.methodType,
amount: o.amount.toString(),
screenshotUrl: o.screenshotUrl,
status: o.status,
approvedAmount: o.approvedAmount?.toString() ?? null,
reviewerId: o.reviewerId?.toString() ?? null,
reviewerUsername: o.reviewerId ? (reviewerMap.get(o.reviewerId.toString()) ?? null) : null,
rejectReason: o.rejectReason,
remark: o.remark,
createdAt: o.createdAt,
reviewedAt: o.reviewedAt,
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
})),
total,
page,
pageSize,
};
}
async approveDepositOrder(
orderId: bigint,
operatorId: bigint,
approvedAmount?: number,
remark?: string,
) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
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 },
data: {
status: 'APPROVED',
approvedAmount: creditAmount,
reviewerId: operatorId,
reviewedAt: new Date(),
remark: remark ?? null,
},
});
const auditLog = await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'APPROVED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'APPROVED',
amount: order.amount,
approvedAmount: creditAmount,
remark: remark ?? null,
});
// Credit player wallet
await this.funds.deposit({
userId: order.playerId,
amount: creditAmount,
operatorId,
remark: remark ?? `Deposit order ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT',
tx,
businessKey: `deposit:${order.orderNo}:approve:${auditLog.id}`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
return { success: true };
});
}
async rejectDepositOrder(orderId: bigint, operatorId: bigint, reason: string) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'REJECTED',
reviewerId: operatorId,
reviewedAt: new Date(),
rejectReason: reason,
remark: reason,
},
});
await this.recordDepositAudit(this.prisma, {
depositOrderId: orderId,
action: 'REJECTED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'REJECTED',
amount: order.amount,
remark: reason,
});
return { success: true };
}
private async reverseApprovedDepositCredit(
order: {
playerId: bigint;
orderNo: string;
approvedAmount: Decimal | null;
amount: Decimal;
},
operatorId: bigint,
remark: string,
) {
const credit = order.approvedAmount ?? order.amount;
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
remark,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
businessKey: `deposit:${order.orderNo}:reverse`,
});
}
/** 已拒绝恢复待审核已通过5 分钟内且未产生下注):扣回入账并恢复待审核 */
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
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,
},
});
await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'REOPENED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'PENDING',
amount: order.amount,
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 },
});
if (betsAfterReview.length > 0) {
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
}
const credit = order.approvedAmount ?? order.amount;
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
const approvalCycleKey = order.reviewedAt?.getTime();
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
remark: `Revoke approved deposit ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
referenceType: 'DEPOSIT',
tx,
businessKey: `deposit:${order.orderNo}:reopen-reverse:${approvalCycleKey}`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'REVOKED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'PENDING',
amount: order.amount,
approvedAmount: credit,
remark: `Revoke approved deposit ${order.orderNo}`,
});
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 } });
await deleteUploadFileByUrl(screenshotUrl);
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;
}
}