feat: 充值订单审计与重新申请,优化赛事展示和余额刷新
- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路 - 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请 - 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等 - 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口 - 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新 - 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -877,6 +877,16 @@ export const API_ERROR_MESSAGES = {
|
||||
'en-US': 'Order is already pending review',
|
||||
'ms-MY': 'Pesanan sudah menunggu semakan',
|
||||
},
|
||||
ORDER_NOT_REJECTED: {
|
||||
'zh-CN': '仅已拒绝的充值订单可重新申请',
|
||||
'en-US': 'Only rejected deposit orders can be resubmitted',
|
||||
'ms-MY': 'Hanya pesanan deposit yang ditolak boleh dihantar semula',
|
||||
},
|
||||
DEPOSIT_PENDING_ORDER_EXISTS: {
|
||||
'zh-CN': '您已有待审核的充值订单,请等待审核完成后再提交',
|
||||
'en-US': 'You already have a pending deposit order. Wait for review before submitting another.',
|
||||
'ms-MY': 'Anda sudah ada pesanan deposit menunggu. Tunggu semakan selesai sebelum hantar lagi.',
|
||||
},
|
||||
ORDER_NOT_APPROVED: {
|
||||
'zh-CN': '仅已通过的充值订单可撤销',
|
||||
'en-US': 'Only approved deposit orders can be revoked',
|
||||
|
||||
@@ -879,6 +879,16 @@ export const API_ERROR_MESSAGES = {
|
||||
'en-US': 'Order is already pending review',
|
||||
'ms-MY': 'Pesanan sudah menunggu semakan',
|
||||
},
|
||||
ORDER_NOT_REJECTED: {
|
||||
'zh-CN': '仅已拒绝的充值订单可重新申请',
|
||||
'en-US': 'Only rejected deposit orders can be resubmitted',
|
||||
'ms-MY': 'Hanya pesanan deposit yang ditolak boleh dihantar semula',
|
||||
},
|
||||
DEPOSIT_PENDING_ORDER_EXISTS: {
|
||||
'zh-CN': '您已有待审核的充值订单,请等待审核完成后再提交',
|
||||
'en-US': 'You already have a pending deposit order. Wait for review before submitting another.',
|
||||
'ms-MY': 'Anda sudah ada pesanan deposit menunggu. Tunggu semakan selesai sebelum hantar lagi.',
|
||||
},
|
||||
ORDER_NOT_APPROVED: {
|
||||
'zh-CN': '仅已通过的充值订单可撤销',
|
||||
'en-US': 'Only approved deposit orders can be revoked',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const PLATFORM_TIME_ZONE = 'Asia/Kuala_Lumpur';
|
||||
export const PLATFORM_TIME_ZONE_OFFSET_MINUTES = 8 * 60;
|
||||
export const PLATFORM_TIME_ZONE_OFFSET_LABEL = 'UTC+8';
|
||||
export const MATCH_TODAY_NEXT_DAY_CUTOFF_HOUR = 12;
|
||||
const PICKER_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
@@ -19,6 +20,68 @@ function formatDateTime(date, locale, options) {
|
||||
return new Intl.DateTimeFormat('en-US', options).format(date);
|
||||
}
|
||||
}
|
||||
function timeZoneParts(date, timeZone) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
return new Map(parts.map((part) => [part.type, part.value]));
|
||||
}
|
||||
function dayKeyInTimeZone(date, timeZone) {
|
||||
const parts = timeZoneParts(date, timeZone);
|
||||
return `${parts.get('year')}-${parts.get('month')}-${parts.get('day')}`;
|
||||
}
|
||||
function offsetMinutesInTimeZone(date, timeZone) {
|
||||
const parts = timeZoneParts(date, timeZone);
|
||||
const asUtc = Date.UTC(Number(parts.get('year')), Number(parts.get('month')) - 1, Number(parts.get('day')), Number(parts.get('hour')), Number(parts.get('minute')), Number(parts.get('second')));
|
||||
return Math.round((asUtc - date.getTime()) / 60000);
|
||||
}
|
||||
function zonedWallTimeToUtc(parts, timeZone) {
|
||||
const wallMs = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute ?? 0, parts.second ?? 0);
|
||||
let utcMs = wallMs;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
utcMs = wallMs - offsetMinutesInTimeZone(new Date(utcMs), timeZone) * 60 * 1000;
|
||||
}
|
||||
return new Date(utcMs);
|
||||
}
|
||||
function localDayParts(date, timeZone) {
|
||||
const parts = timeZoneParts(date, timeZone);
|
||||
return {
|
||||
year: Number(parts.get('year')),
|
||||
month: Number(parts.get('month')),
|
||||
day: Number(parts.get('day')),
|
||||
};
|
||||
}
|
||||
function addDaysToParts(parts, days) {
|
||||
const d = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days));
|
||||
return {
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
day: d.getUTCDate(),
|
||||
};
|
||||
}
|
||||
function localTodayMatchWindow(now = new Date(), timeZone) {
|
||||
if (timeZone) {
|
||||
const today = localDayParts(now, timeZone);
|
||||
const tomorrow = addDaysToParts(today, 1);
|
||||
return {
|
||||
start: zonedWallTimeToUtc({ ...today, hour: 0 }, timeZone),
|
||||
end: zonedWallTimeToUtc({ ...tomorrow, hour: MATCH_TODAY_NEXT_DAY_CUTOFF_HOUR }, timeZone),
|
||||
};
|
||||
}
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
end.setHours(MATCH_TODAY_NEXT_DAY_CUTOFF_HOUR, 0, 0, 0);
|
||||
return { start, end };
|
||||
}
|
||||
function parsePickerParts(value) {
|
||||
const match = PICKER_DATETIME_RE.exec(value.trim());
|
||||
if (!match)
|
||||
@@ -83,64 +146,95 @@ export function formatPlatformMatchDateTime(value, locale = 'en-US') {
|
||||
});
|
||||
return `${formatted} ${PLATFORM_TIME_ZONE_OFFSET_LABEL}`;
|
||||
}
|
||||
export function getLocalGmtOffsetLabel(value = new Date()) {
|
||||
export function getLocalGmtOffsetLabel(value = new Date(), timeZone) {
|
||||
const date = validDate(value) ?? new Date();
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const offsetMinutes = timeZone
|
||||
? offsetMinutesInTimeZone(date, timeZone)
|
||||
: -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(offsetMinutes);
|
||||
const hours = Math.floor(abs / 60);
|
||||
const minutes = abs % 60;
|
||||
return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${pad2(minutes)}`;
|
||||
}
|
||||
export function isSameLocalCalendarDay(value, now = new Date()) {
|
||||
export function isSameLocalCalendarDay(value, now = new Date(), timeZone) {
|
||||
const date = validDate(value);
|
||||
if (!date)
|
||||
return false;
|
||||
if (timeZone) {
|
||||
return dayKeyInTimeZone(date, timeZone) === dayKeyInTimeZone(now, timeZone);
|
||||
}
|
||||
return (date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate());
|
||||
}
|
||||
export function isInLocalToday(value, now = new Date()) {
|
||||
export function isInLocalToday(value, now = new Date(), timeZone) {
|
||||
const date = validDate(value);
|
||||
if (!date)
|
||||
return false;
|
||||
if (timeZone) {
|
||||
return dayKeyInTimeZone(date, timeZone) === dayKeyInTimeZone(now, timeZone);
|
||||
}
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return date >= start && date < end;
|
||||
}
|
||||
export function isAfterLocalToday(value, now = new Date()) {
|
||||
export function isAfterLocalToday(value, now = new Date(), timeZone) {
|
||||
const date = validDate(value);
|
||||
if (!date)
|
||||
return false;
|
||||
if (timeZone) {
|
||||
return dayKeyInTimeZone(date, timeZone) > dayKeyInTimeZone(now, timeZone);
|
||||
}
|
||||
const end = new Date(now);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return date >= end;
|
||||
}
|
||||
export function isInLocalTodayMatchWindow(value, now = new Date(), timeZone) {
|
||||
const date = validDate(value);
|
||||
if (!date)
|
||||
return false;
|
||||
const { start, end } = localTodayMatchWindow(now, timeZone);
|
||||
return date >= start && date < end;
|
||||
}
|
||||
export function isAfterLocalTodayMatchWindow(value, now = new Date(), timeZone) {
|
||||
const date = validDate(value);
|
||||
if (!date)
|
||||
return false;
|
||||
const { end } = localTodayMatchWindow(now, timeZone);
|
||||
return date >= end;
|
||||
}
|
||||
export function formatLocalMatchDateTime(value, locale = 'en-US', options = {}) {
|
||||
const date = validDate(value);
|
||||
if (!date)
|
||||
return '';
|
||||
const variant = options.variant ?? 'compact';
|
||||
const time = formatDateTime(date, locale, {
|
||||
...(options.timeZone ? { timeZone: options.timeZone } : {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(options.includeSeconds ? { second: '2-digit' } : {}),
|
||||
});
|
||||
let text;
|
||||
if (variant === 'compact') {
|
||||
if (options.todayLabel && isSameLocalCalendarDay(date)) {
|
||||
if (options.todayLabel && isSameLocalCalendarDay(date, new Date(), options.timeZone)) {
|
||||
text = `${options.todayLabel} ${time}`;
|
||||
}
|
||||
else {
|
||||
const day = formatDateTime(date, locale, { month: 'numeric', day: 'numeric' });
|
||||
const day = formatDateTime(date, locale, {
|
||||
...(options.timeZone ? { timeZone: options.timeZone } : {}),
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
});
|
||||
text = `${day} ${time}`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const day = formatDateTime(date, locale, {
|
||||
...(options.timeZone ? { timeZone: options.timeZone } : {}),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -149,5 +243,5 @@ export function formatLocalMatchDateTime(value, locale = 'en-US', options = {})
|
||||
}
|
||||
if (options.includeTimeZone === false)
|
||||
return text;
|
||||
return `${text} ${getLocalGmtOffsetLabel(date)}`;
|
||||
return `${text} ${getLocalGmtOffsetLabel(date, options.timeZone)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user