feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
440
apps/api/src/domains/deposit/deposit.service.ts
Normal file
440
apps/api/src/domains/deposit/deposit.service.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
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 { WalletService } from '../ledger/wallet.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
|
||||
function generateOrderNo(): string {
|
||||
const ts = Date.now().toString(36).toUpperCase();
|
||||
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `DEP${ts}${rand}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DepositService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
// ============ Payment Methods (Admin CRUD) ============
|
||||
|
||||
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 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: data.isActive ?? true,
|
||||
showOnPlayer: data.showOnPlayer ?? true,
|
||||
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 method = await this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: rest,
|
||||
});
|
||||
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,
|
||||
showOnPlayer: 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 ============
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
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 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
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,
|
||||
})),
|
||||
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;
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedAmount: creditAmount,
|
||||
reviewerId: operatorId,
|
||||
reviewedAt: new Date(),
|
||||
remark: remark ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Credit player wallet
|
||||
await this.wallet.deposit(
|
||||
order.playerId,
|
||||
creditAmount,
|
||||
operatorId,
|
||||
remark ?? `Deposit order ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT',
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user