feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View 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 };
}
}