feat: 充值订单审计与重新申请,优化赛事展示和余额刷新
- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路 - 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请 - 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等 - 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口 - 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新 - 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
26
apps/player/src/utils/cashback.ts
Normal file
26
apps/player/src/utils/cashback.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type CashbackRecord = {
|
||||
id: string;
|
||||
batchNo: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
confirmedAt: string | null;
|
||||
effectiveStake: string;
|
||||
betCount: number;
|
||||
rate: string;
|
||||
amount: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** API returns a plain array; older clients may expect { items }. */
|
||||
export function parseCashbackApiData(data: unknown): CashbackRecord[] {
|
||||
if (Array.isArray(data)) return data as CashbackRecord[];
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = (data as { items?: unknown }).items;
|
||||
return Array.isArray(items) ? (items as CashbackRecord[]) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function sumCashbackAmount(rows: Array<{ amount: string }>): number {
|
||||
return rows.reduce((sum, row) => sum + Math.abs(parseFloat(row.amount) || 0), 0);
|
||||
}
|
||||
112
apps/player/src/utils/depositAuditDisplay.ts
Normal file
112
apps/player/src/utils/depositAuditDisplay.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** Player-facing deposit audit log remark formatting — strips internal order codes & API templates. */
|
||||
|
||||
const ORDER_NO_SUFFIX = /\s+[A-Z0-9]{10,}\s*$/i;
|
||||
|
||||
const INTERNAL_REMARK_EXACT = [
|
||||
/^Revoke approved deposit\s*[A-Z0-9]*\s*$/i,
|
||||
/^撤销已通过充值\s*[A-Z0-9]*\s*$/,
|
||||
/^Deposit order\s+[A-Z0-9]+\s*$/i,
|
||||
];
|
||||
|
||||
export type DepositAuditLogLike = {
|
||||
action: string;
|
||||
remark: string | null;
|
||||
};
|
||||
|
||||
export type DepositAuditRemarkDisplay =
|
||||
| { kind: 'reject'; text: string }
|
||||
| { kind: 'note'; text: string }
|
||||
| null;
|
||||
|
||||
function normalizeRemark(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
/** True when remark is empty or only internal/system text — hide from player UI. */
|
||||
export function isHiddenDepositAuditRemark(log: DepositAuditLogLike): boolean {
|
||||
const remark = normalizeRemark(log.remark);
|
||||
if (!remark) return true;
|
||||
|
||||
if (log.action === 'REVOKED') {
|
||||
return INTERNAL_REMARK_EXACT.some((pattern) => pattern.test(remark));
|
||||
}
|
||||
|
||||
return INTERNAL_REMARK_EXACT.some((pattern) => pattern.test(remark));
|
||||
}
|
||||
|
||||
function sanitizePlayerRemark(raw: string): string | null {
|
||||
let text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
if (/^Revoke approved deposit/i.test(text)) {
|
||||
return null;
|
||||
}
|
||||
if (/^撤销已通过充值/.test(text)) {
|
||||
return null;
|
||||
}
|
||||
if (/^Deposit order\s+/i.test(text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
text = text.replace(ORDER_NO_SUFFIX, '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/** Format remark for timeline; returns null when nothing meaningful to show. */
|
||||
export function formatDepositAuditRemark(
|
||||
log: DepositAuditLogLike,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): DepositAuditRemarkDisplay {
|
||||
const remark = normalizeRemark(log.remark);
|
||||
if (!remark) return null;
|
||||
|
||||
if (log.action === 'REVOKED' && isHiddenDepositAuditRemark(log)) {
|
||||
return { kind: 'note', text: t('wallet.remark_deposit_revoke_generic') };
|
||||
}
|
||||
|
||||
const sanitized = sanitizePlayerRemark(remark);
|
||||
if (!sanitized) return null;
|
||||
|
||||
if (log.action === 'REJECTED') {
|
||||
return { kind: 'reject', text: sanitized };
|
||||
}
|
||||
|
||||
return { kind: 'note', text: sanitized };
|
||||
}
|
||||
|
||||
/** Skip reject remark in timeline when card-level reject reason already shows the same text. */
|
||||
export function shouldShowAuditRejectInTimeline(
|
||||
log: DepositAuditLogLike,
|
||||
orderRejectReason: string | null | undefined,
|
||||
): boolean {
|
||||
if (log.action !== 'REJECTED') return true;
|
||||
const remark = normalizeRemark(log.remark);
|
||||
if (!remark) return false;
|
||||
const cardReason = normalizeRemark(orderRejectReason);
|
||||
return remark !== cardReason;
|
||||
}
|
||||
|
||||
export function auditActionTone(action: string): 'submitted' | 'approved' | 'rejected' | 'revoked' | 'reopened' | 'default' {
|
||||
switch (action.toUpperCase()) {
|
||||
case 'SUBMITTED':
|
||||
return 'submitted';
|
||||
case 'APPROVED':
|
||||
return 'approved';
|
||||
case 'REJECTED':
|
||||
return 'rejected';
|
||||
case 'REVOKED':
|
||||
return 'revoked';
|
||||
case 'REOPENED':
|
||||
return 'reopened';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
/** Secondary actor line — only when it adds meaning beyond the action title. */
|
||||
export function auditActorSecondary(log: { action: string; actorType: string }, t: (key: string) => string): string | null {
|
||||
if (log.actorType === 'PLAYER' && (log.action === 'SUBMITTED' || log.action === 'REOPENED')) {
|
||||
return t('recharge.audit_by_player');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export const TX_KEY_MAP: Record<string, string> = {
|
||||
DEPOSIT: 'wallet.tx_deposit',
|
||||
WITHDRAW: 'wallet.tx_withdraw',
|
||||
PLAYER_DEPOSIT: 'wallet.tx_player_deposit',
|
||||
PLAYER_DEPOSIT_REVERSAL: 'wallet.tx_player_deposit_reversal',
|
||||
};
|
||||
|
||||
export function txTypeKey(type: string): string {
|
||||
@@ -53,11 +54,29 @@ export function txSummaryLabel(
|
||||
|
||||
export function isDepositType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return (t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT') && !isCashbackType(type);
|
||||
return (t.includes('DEPOSIT') || t === 'CASHBACK_DEPOSIT') && !isCashbackType(type) && t !== 'PLAYER_DEPOSIT_REVERSAL';
|
||||
}
|
||||
|
||||
export function isDepositReversalType(type: string): boolean {
|
||||
return type.toUpperCase() === 'PLAYER_DEPOSIT_REVERSAL';
|
||||
}
|
||||
|
||||
export function txRemarkLabel(
|
||||
tx: { transactionType: string; remark?: string | null; referenceId?: string | null },
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const type = tx.transactionType.toUpperCase();
|
||||
const remark = tx.remark?.trim() ?? '';
|
||||
if (type === 'PLAYER_DEPOSIT_REVERSAL') {
|
||||
return t('wallet.remark_deposit_revoke_generic');
|
||||
}
|
||||
return remark;
|
||||
}
|
||||
|
||||
export function isWithdrawType(type: string): boolean {
|
||||
return type.toUpperCase().includes('WITHDRAW');
|
||||
const t = type.toUpperCase();
|
||||
if (t === 'PLAYER_DEPOSIT_REVERSAL') return false;
|
||||
return t.includes('WITHDRAW');
|
||||
}
|
||||
|
||||
export function isBetType(type: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user