feat: 充值订单审计与重新申请,优化赛事展示和余额刷新

- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路
- 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请
- 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等
- 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口
- 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新
- 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 14:52:05 +08:00
parent 73a94e6be3
commit afb5c5437e
55 changed files with 3050 additions and 160 deletions

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

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

View File

@@ -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 {