987 lines
30 KiB
TypeScript
987 lines
30 KiB
TypeScript
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';
|
||
import { PlayerMessagesService } from '../player-messages/player-messages.service';
|
||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||
|
||
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,
|
||
private playerMessages: PlayerMessagesService,
|
||
private systemConfig: SystemConfigService,
|
||
) {}
|
||
|
||
// ============ 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 getPlayerLocale(playerId: bigint, tx: Prisma.TransactionClient | PrismaService = this.prisma) {
|
||
const user = await tx.user.findUnique({
|
||
where: { id: playerId },
|
||
select: { locale: true },
|
||
});
|
||
return user?.locale ?? 'en-US';
|
||
}
|
||
|
||
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 countPendingDepositOrders(): Promise<number> {
|
||
return this.prisma.depositOrder.count({ where: { status: 'PENDING' } });
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
const playerLocale = await this.getPlayerLocale(order.playerId, tx);
|
||
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
|
||
await this.playerMessages.createDepositApprovedMessage(
|
||
order.playerId,
|
||
{
|
||
depositOrderId: orderId,
|
||
orderNo: order.orderNo,
|
||
amount: order.amount.toString(),
|
||
approvedAmount: creditAmount.toString(),
|
||
locale: playerLocale,
|
||
},
|
||
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,
|
||
});
|
||
|
||
const playerLocale = await this.getPlayerLocale(order.playerId);
|
||
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
|
||
await this.playerMessages.createDepositRejectedMessage(order.playerId, {
|
||
depositOrderId: orderId,
|
||
orderNo: order.orderNo,
|
||
amount: order.amount.toString(),
|
||
rejectReason: reason,
|
||
locale: playerLocale,
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|