Files
thebet365/apps/api/src/domains/deposit/deposit.service.ts
Mars f9343b00af feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
API:
- 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板
- 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数
- User 表增加 visible_menus 字段;新增 player_messages 表及迁移
- 充值审核通过/拒绝时按系统配置自动写入玩家站内消息
- 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count
- 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置
- 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管)
- SystemConfig 增加 inbox 功能开关及各类通知开关

Admin:
- 员工管理:按角色默认菜单 + 可勾选可见菜单项
- ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询
- Contents:富文本编辑器、图片字段组件重构
- DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列
- 多页面 i18n 与权限细节调整

Player:
- 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除
- 公告列表与详情页;走马灯可跳转详情
- 客服 Modal 改为 Panel,与邮箱 Hub 整合
- 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认
- HomeView 今日赛事板块;FootballView 等体验优化

Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码
Docs: 玩家端缺失功能分析文档
Chore: 移除 .agents/skills 设计类 skill 文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:51:10 +08:00

987 lines
30 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';
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;
}
}