feat: 开户备注、账单展示优化与后台代理管理增强

- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示

- 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分

- 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端

- 后台代理/玩家表格操作栏重构,充值订单增加备注列

- 前台个人中心、充值、账单与验证码组件体验优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:23:58 +08:00
parent 10485ecfaf
commit 03e72ca9b2
46 changed files with 3721 additions and 1059 deletions

View File

@@ -83,6 +83,7 @@ export class WalletService {
operatorId: bigint,
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_WITHDRAW',
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
@@ -106,7 +107,7 @@ export class WalletService {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_WITHDRAW',
transactionType,
amount: amt.neg(),
balanceBefore,
balanceAfter,
@@ -260,26 +261,178 @@ export class WalletService {
return this.prisma.$transaction(run);
}
private static readonly DEPOSIT_TX_TYPES = [
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'MANUAL_ADJUST',
'PLAYER_DEPOSIT',
] as const;
private static readonly WITHDRAW_TX_TYPES = [
'MANUAL_WITHDRAW',
'ADMIN_WITHDRAW',
'AGENT_WITHDRAW',
'WITHDRAW',
] as const;
private static readonly SYSTEM_REMARKS = new Set([
'管理员上分',
'管理员下分',
'代理上分',
'代理下分',
'开户初始余额',
'Resettlement adjustment',
]);
private resolveDisplayType(
transactionType: string,
operatorType?: string | null,
): string {
const type = transactionType.toUpperCase();
if (type === 'INITIAL_DEPOSIT') {
if (operatorType === 'AGENT') return 'AGENT_DEPOSIT';
return 'ADMIN_DEPOSIT';
}
if (
[
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'ADMIN_WITHDRAW',
'AGENT_WITHDRAW',
'PLAYER_DEPOSIT',
].includes(type)
) {
return type;
}
if (type === 'MANUAL_DEPOSIT') {
if (operatorType === 'ADMIN') return 'ADMIN_DEPOSIT';
if (operatorType === 'AGENT') return 'AGENT_DEPOSIT';
return type;
}
if (type === 'MANUAL_WITHDRAW') {
if (operatorType === 'ADMIN') return 'ADMIN_WITHDRAW';
if (operatorType === 'AGENT') return 'AGENT_WITHDRAW';
return type;
}
return type;
}
private isCustomRemark(remark: string | null | undefined): boolean {
const r = remark?.trim();
if (!r) return false;
if (WalletService.SYSTEM_REMARKS.has(r)) return false;
if (r.startsWith('Cashback batch ')) return false;
if (r.startsWith('Deposit order ')) return false;
return true;
}
private buildPlayerTxSummary(
tx: {
transactionType: string;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
},
depositMethodName?: string | null,
): string | null {
const type = tx.transactionType.toUpperCase();
if (tx.referenceType === 'BET' && tx.referenceId) {
return tx.referenceId;
}
if (type === 'CASHBACK' || type === 'CASHBACK_DEPOSIT') {
return tx.referenceId ?? null;
}
if (type === 'PLAYER_DEPOSIT') {
const parts = [depositMethodName?.trim(), tx.referenceId?.trim()].filter(Boolean);
return parts.length ? parts.join(' · ') : null;
}
if (this.isCustomRemark(tx.remark)) return tx.remark!.trim();
return null;
}
private resolveSummaryKind(remark: string | null | undefined): 'opening_bonus' | null {
const r = remark?.trim();
if (r === '开户初始余额') return 'opening_bonus';
return null;
}
private async enrichPlayerTransactions(
rows: Array<{
id: bigint;
transactionId: string;
transactionType: string;
amount: Decimal;
balanceBefore: Decimal;
balanceAfter: Decimal;
frozenBefore: Decimal;
frozenAfter: Decimal;
referenceType: string | null;
referenceId: string | null;
remark: string | null;
operatorId: bigint | null;
createdAt: Date;
}>,
) {
const operatorIds = [
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
];
const operators =
operatorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, userType: true },
})
: [];
const operatorTypeById = new Map(operators.map((o) => [o.id.toString(), o.userType]));
const depositMethodByRowId = await this.resolveDepositMethodsForRows(rows);
return rows.map((row) => {
const operatorType = row.operatorId
? operatorTypeById.get(row.operatorId.toString())
: null;
const displayType = this.resolveDisplayType(row.transactionType, operatorType);
const depositMethod = depositMethodByRowId.get(row.id.toString());
const summary = this.buildPlayerTxSummary(
row,
depositMethod?.depositMethodName,
);
const summaryKind = summary ? null : this.resolveSummaryKind(row.remark);
return {
transactionId: row.transactionId,
transactionType: row.transactionType,
displayType,
summaryKind,
amount: row.amount.toString(),
balanceBefore: row.balanceBefore.toString(),
balanceAfter: row.balanceAfter.toString(),
frozenBefore: row.frozenBefore.toString(),
frozenAfter: row.frozenAfter.toString(),
referenceType: row.referenceType,
referenceId: row.referenceId,
remark: row.remark,
summary,
createdAt: row.createdAt.toISOString(),
betNo: row.referenceType === 'BET' ? row.referenceId : null,
cashbackBatchNo:
row.transactionType === 'CASHBACK' || row.transactionType === 'CASHBACK_DEPOSIT'
? row.referenceId
: null,
};
});
}
async getTransactionDetail(userId: bigint, transactionId: string) {
const tx = await this.prisma.walletTransaction.findFirst({
where: { userId, transactionId },
});
if (!tx) return null;
return {
transactionId: tx.transactionId,
transactionType: tx.transactionType,
amount: tx.amount.toString(),
balanceBefore: tx.balanceBefore.toString(),
balanceAfter: tx.balanceAfter.toString(),
frozenBefore: tx.frozenBefore.toString(),
frozenAfter: tx.frozenAfter.toString(),
referenceType: tx.referenceType,
referenceId: tx.referenceId,
remark: tx.remark,
createdAt: tx.createdAt.toISOString(),
betNo: tx.referenceType === 'BET' ? tx.referenceId : null,
};
const [enriched] = await this.enrichPlayerTransactions([tx]);
return enriched;
}
async getTransactions(userId: bigint, page = 1, pageSize = 20, typeFilter?: string) {
@@ -287,9 +440,9 @@ export class WalletService {
let typeWhere: Record<string, unknown> = {};
if (typeFilter === 'deposit') {
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
typeWhere = { transactionType: { in: [...WalletService.DEPOSIT_TX_TYPES] } };
} else if (typeFilter === 'withdraw') {
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
typeWhere = { transactionType: { in: [...WalletService.WITHDRAW_TX_TYPES] } };
} else if (typeFilter === 'bet') {
typeWhere = { transactionType: { in: ['BET_FREEZE', 'BET_DEDUCT', 'BET_SETTLE_WIN', 'BET_SETTLE_LOSE', 'BET_SETTLE_PUSH', 'BET_WIN', 'BET_REFUND', 'BET_VOID', 'BET_VOID_REFUND', 'RESETTLE_REVERSE'] } };
} else if (typeFilter === 'cashback') {
@@ -306,16 +459,21 @@ export class WalletService {
}),
this.prisma.walletTransaction.count({ where }),
]);
return { items, total, page, pageSize };
return {
items: await this.enrichPlayerTransactions(items),
total,
page,
pageSize,
};
}
private walletTypeCategoryWhere(category?: string): Prisma.WalletTransactionWhereInput {
const cat = category?.trim();
if (cat === 'deposit') {
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST', 'PLAYER_DEPOSIT'] } };
return { transactionType: { in: [...WalletService.DEPOSIT_TX_TYPES] } };
}
if (cat === 'withdraw') {
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
return { transactionType: { in: [...WalletService.WITHDRAW_TX_TYPES] } };
}
if (cat === 'bet') {
return {
@@ -343,6 +501,9 @@ export class WalletService {
private static readonly DEPOSIT_RECHARGE_TYPES = new Set([
'MANUAL_DEPOSIT',
'ADMIN_DEPOSIT',
'AGENT_DEPOSIT',
'INITIAL_DEPOSIT',
'DEPOSIT',
'PLAYER_DEPOSIT',
]);
@@ -644,7 +805,15 @@ export class WalletService {
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const transferTypes = ['MANUAL_DEPOSIT', 'MANUAL_WITHDRAW'];
const transferTypes = [
'MANUAL_DEPOSIT',
'MANUAL_WITHDRAW',
'ADMIN_DEPOSIT',
'ADMIN_WITHDRAW',
'AGENT_DEPOSIT',
'AGENT_WITHDRAW',
'INITIAL_DEPOSIT',
];
const where: Prisma.WalletTransactionWhereInput = {
transactionType: params.transactionType?.trim()
? params.transactionType.trim()