diff --git a/app/admin/controller/game/Record.php b/app/admin/controller/game/Record.php index 801e54e..8f8a3b6 100644 --- a/app/admin/controller/game/Record.php +++ b/app/admin/controller/game/Record.php @@ -86,7 +86,6 @@ class Record extends Backend $rows = Db::name('game_record') ->where('status', 5) - ->whereLike('void_reason', 'system_recover:%') ->field(['id', 'period_no', 'void_reason', 'update_time']) ->order('id', 'desc') ->limit($limit) @@ -96,6 +95,8 @@ class Record extends Backend $list = []; foreach ($rows as $row) { $meta = $this->parseRecoverVoidReason(is_string($row['void_reason'] ?? null) ? $row['void_reason'] : ''); + $reason = is_string($row['void_reason'] ?? null) ? $row['void_reason'] : ''; + $isAutoRecover = $this->isSystemRecoverReason($reason); $list[] = [ 'id' => (int) ($row['id'] ?? 0), 'period_no' => (string) ($row['period_no'] ?? ''), @@ -104,7 +105,8 @@ class Record extends Backend 'refunded_order_count' => $meta['orders'], 'refunded_total_amount' => $meta['amount'], 'recovered_at' => (int) ($row['update_time'] ?? 0), - 'void_reason' => (string) ($row['void_reason'] ?? ''), + 'void_reason' => $reason, + 'is_auto_recover' => $isAutoRecover ? 1 : 0, ]; } @@ -125,10 +127,13 @@ class Record extends Backend 'orders' => 0, 'amount' => '0.00', ]; - if ($reason === '' || str_starts_with($reason, 'system_recover:') === false) { + if (!$this->isSystemRecoverReason($reason)) { return $meta; } $payload = substr($reason, strlen('system_recover:')); + if (!str_contains($payload, '=')) { + return $meta; + } $parts = explode('|', $payload); foreach ($parts as $part) { $item = trim($part); @@ -156,4 +161,9 @@ class Record extends Backend } return $meta; } + + private function isSystemRecoverReason(string $reason): bool + { + return $reason !== '' && str_starts_with($reason, 'system_recover:'); + } } diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 65881a6..89cf4f7 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -52,23 +52,102 @@ final class GameLiveService if ($recordId <= 0) { return; } + $status = (int) ($row['status'] ?? 0); + $resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0; + if ($resultNumber > 0 && in_array($status, [0, 1, 2, 3], true)) { + self::recoverPayoutForRecordOnStartup($recordId); + return; + } + $periodStartAt = (int) ($row['period_start_at'] ?? 0); if ($periodStartAt <= 0) { return; } - $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); $timeoutAt = $periodStartAt + $periodSeconds + self::PAYOUT_GRACE_SECONDS + self::STARTUP_RECOVER_GRACE_SECONDS; if (time() <= $timeoutAt) { return; } + self::markAbnormalAndRefundOnStartup($recordId, $status); + } - $status = (int) ($row['status'] ?? 0); + private static function recoverPayoutForRecordOnStartup(int $recordId): void + { $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000); if (!$lock['acquired']) { return; } + try { + $row = Db::name('game_record')->where('id', $recordId)->find(); + if (!$row) { + return; + } + $status = (int) ($row['status'] ?? 0); + if (!in_array($status, [0, 1, 2, 3], true)) { + return; + } + $resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0; + if ($resultNumber <= 0) { + return; + } + $now = time(); + $payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0; + Db::startTrans(); + try { + GameBetSettleService::settleBetsForDraw($recordId, $resultNumber); + if ($status === 2) { + if ($payoutUntil <= 0) { + $payoutUntil = $now + self::PAYOUT_GRACE_SECONDS; + } + Db::name('game_record')->where('id', $recordId)->update([ + 'status' => 3, + 'payout_until' => $payoutUntil, + 'update_time' => $now, + ]); + } elseif ($status === 3) { + if ($payoutUntil <= 0) { + $payoutUntil = $now; + Db::name('game_record')->where('id', $recordId)->update([ + 'payout_until' => $payoutUntil, + 'update_time' => $now, + ]); + } + } else { + $payoutUntil = $now; + Db::name('game_record')->where('id', $recordId)->update([ + 'status' => 3, + 'payout_until' => $payoutUntil, + 'update_time' => $now, + ]); + } + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + Log::warning('game live startup payout recover failed', [ + 'record_id' => $recordId, + 'error' => $e->getMessage(), + ]); + return; + } + + GameHotDataCoordinator::afterGameRecordCommitted($recordId); + self::publishSnapshot(null); + + if ($payoutUntil <= $now) { + self::finalizePayoutForRecordLocked($recordId); + } + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); + } + } + + private static function markAbnormalAndRefundOnStartup(int $recordId, int $status): void + { + $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000); + if (!$lock['acquired']) { + return; + } try { $fresh = Db::name('game_record')->where('id', $recordId)->find(); if (!$fresh) { @@ -78,12 +157,8 @@ final class GameLiveService if (!in_array($freshStatus, [0, 1, 2, 3], true)) { return; } - $freshPeriodStartAt = (int) ($fresh['period_start_at'] ?? 0); - if ($freshPeriodStartAt <= 0) { - return; - } - $freshTimeoutAt = $freshPeriodStartAt + $periodSeconds + self::PAYOUT_GRACE_SECONDS + self::STARTUP_RECOVER_GRACE_SECONDS; - if (time() <= $freshTimeoutAt) { + $freshResultNumber = isset($fresh['result_number']) ? (int) $fresh['result_number'] : 0; + if ($freshResultNumber > 0) { return; } @@ -92,13 +167,12 @@ final class GameLiveService Db::startTrans(); try { $refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now); - $oldStatus = $freshStatus; $refundedUserCount = count($refund['user_ids']); $refundedOrderCount = (int) ($refund['order_count'] ?? 0); $refundedTotalAmount = is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00'; $reason = sprintf( 'system_recover:from=%d|users=%d|orders=%d|amount=%s', - $oldStatus, + $freshStatus, $refundedUserCount, $refundedOrderCount, $refundedTotalAmount @@ -114,8 +188,9 @@ final class GameLiveService Db::commit(); } catch (Throwable $e) { Db::rollback(); - Log::warning('game live startup recover failed', [ + Log::warning('game live startup abnormal recover failed', [ 'record_id' => $recordId, + 'status' => $status, 'error' => $e->getMessage(), ]); return; @@ -129,7 +204,7 @@ final class GameLiveService } GameRecordService::bootstrapPeriodWhenRuntimeEnabled(); self::publishSnapshot(null); - Log::info('game live startup recovered abnormal period', [ + Log::info('game live startup marked abnormal and refunded', [ 'record_id' => $recordId, 'old_status' => $freshStatus, 'refunded_user_count' => count($refund['user_ids']), @@ -141,6 +216,34 @@ final class GameLiveService } } + private static function finalizePayoutForRecordLocked(int $recordId): void + { + $now = time(); + Db::startTrans(); + try { + Db::name('game_record')->where('id', $recordId)->where('status', 3)->update([ + 'status' => 4, + 'payout_until' => null, + 'update_time' => $now, + ]); + GameRecordService::createNextRecordAfterDraw(); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + Log::warning('game live startup finalize payout failed', [ + 'record_id' => $recordId, + 'error' => $e->getMessage(), + ]); + return; + } + GameHotDataCoordinator::afterGameRecordCommitted($recordId); + try { + GameRecordStatService::refreshForRecordId($recordId); + } catch (Throwable) { + } + self::publishSnapshot(null); + } + public static function buildSnapshot(?int $recordId = null): array { $record = self::resolveRecord($recordId); @@ -170,9 +273,12 @@ final class GameLiveService } $bets = Db::name('bet_order') - ->where('period_id', $rid) - ->order('id', 'desc') + ->alias('bo') + ->leftJoin('user gu', 'gu.id = bo.user_id') + ->where('bo.period_id', $rid) + ->order('bo.id', 'desc') ->limit(200) + ->field('bo.id,bo.user_id,bo.period_no,bo.pick_numbers,bo.total_amount,bo.streak_at_bet,bo.create_time,gu.username as user_username') ->select() ->toArray(); @@ -218,6 +324,7 @@ final class GameLiveService return [ 'id' => (int) $row['id'], 'user_id' => (int) $row['user_id'], + 'username' => isset($row['user_username']) && is_string($row['user_username']) ? $row['user_username'] : '', 'period_no' => (string) $row['period_no'], 'pick_numbers' => $row['pick_numbers'], 'total_amount' => (string) $row['total_amount'], diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index 6d82449..35bbf9e 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -31,6 +31,7 @@ export default { bet_stream_title: 'Realtime bet stream', bet_id: 'Bet ID', user_id: 'Player ID', + username: 'Username', pick_numbers: 'Pick numbers', total_amount: 'Total bet amount', streak_at_bet: 'Streak at bet', diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 109477d..16b06c9 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -31,6 +31,7 @@ export default { bet_stream_title: '实时下注记录', bet_id: '注单ID', user_id: '玩家ID', + username: '用户名', pick_numbers: '下注号码', total_amount: '下注总额', streak_at_bet: '下注时连胜', diff --git a/web/src/lang/index.ts b/web/src/lang/index.ts index 114bb44..d90bd90 100644 --- a/web/src/lang/index.ts +++ b/web/src/lang/index.ts @@ -25,7 +25,7 @@ const assignLocale: anyObj = { export async function loadLang(app: App) { const config = useConfig() - const locale = config.lang.defaultLang + const locale = config.lang.defaultLang === 'zh' ? 'zh-cn' : config.lang.defaultLang // 加载框架全局语言包 const lang = await import(`./globs-${locale}.ts`) @@ -70,7 +70,7 @@ export async function loadLang(app: App) { locale: locale, legacy: false, // 组合式api globalInjection: true, // 挂载$t,$d等到全局 - fallbackLocale: config.lang.fallbackLang, + fallbackLocale: config.lang.fallbackLang === 'zh' ? 'zh-cn' : config.lang.fallbackLang, messages, }) diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 4d179c9..1e10e82 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -44,12 +44,13 @@ router.beforeEach((to, from, next) => { .join('/') } const config = useConfig() + const langCode = config.lang.defaultLang === 'zh' ? 'zh-cn' : config.lang.defaultLang if (to.path in langAutoLoadMap) { loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap]) } let prefix = '' if (isAdminApp(to.fullPath)) { - prefix = './backend/' + config.lang.defaultLang + prefix = './backend/' + langCode // 去除 path 中的 /admin const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length) @@ -58,7 +59,7 @@ router.beforeEach((to, from, next) => { loadPath.push(prefix + toCamelPath(adminPath) + '.ts') } } else { - prefix = './frontend/' + config.lang.defaultLang + prefix = './frontend/' + langCode loadPath.push(prefix + to.path + '.ts') } @@ -80,7 +81,7 @@ router.beforeEach((to, from, next) => { loadPath = uniq(loadPath) for (const key in loadPath) { - loadPath[key] = loadPath[key].replaceAll('${lang}', config.lang.defaultLang) + loadPath[key] = loadPath[key].replaceAll('${lang}', langCode) if (loadPath[key] in window.loadLangHandle) { window.loadLangHandle[loadPath[key]]().then((res: { default: anyObj }) => { const pathName = loadPath[key].slice(loadPath[key].lastIndexOf(prefix) + (prefix.length + 1), loadPath[key].lastIndexOf('.')) diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 6878249..5464370 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -27,6 +27,18 @@ @change="onRuntimeSwitch" /> {{ t('game.live.runtime_off_tip') }} +
+ + {{ t('game.live.void_btn') }} + + {{ t('Refresh') }} +
@@ -78,75 +90,62 @@ - - - - + + - - - + + + + + + + + - + - - - - + + - - + + + + @@ -241,11 +240,11 @@ const snapshot = reactive({ }) const calcLoading = ref(false) const drawLoading = ref(false) +const pendingSwitchNumber = ref(null) const runtimeSwitchLoading = ref(false) const voidDialogVisible = ref(false) const voidReason = ref('') const voidSubmitting = ref(false) -const manualNumber = ref(1) const calcResultNumber = ref(null) const calcEstimatedLoss = ref('0.00') @@ -261,6 +260,13 @@ const wsConnected = ref(false) const wsUrl = ref('') const wsTopics = ref([]) const wsClient = ref(null) +const isMobile = ref(false) + +function updateIsMobile(): void { + isMobile.value = window.matchMedia('(max-width: 768px)').matches +} + +const tableHeight = computed(() => (isMobile.value ? 320 : 420)) async function reloadWsConfig(): Promise { wsLoading.value = true @@ -365,6 +371,118 @@ function formatPicks(v: unknown): string { return '-' } +function parsePickNumbers(v: unknown): Array { + if (Array.isArray(v)) { + return v + .map((item) => { + if (typeof item === 'number' || typeof item === 'string') { + return item + } + return null + }) + .filter((item): item is number | string => item !== null) + } + if (typeof v === 'string') { + const s = v.trim() + if (s === '') { + return [] + } + try { + const parsed = JSON.parse(s) + if (Array.isArray(parsed)) { + return parsed + .map((item) => { + if (typeof item === 'number' || typeof item === 'string') { + return item + } + return null + }) + .filter((item): item is number | string => item !== null) + } + } catch { + return [s] + } + return [s] + } + + return [] +} + +function numberValue(v: unknown): number | null { + if (typeof v === 'number' && Number.isFinite(v)) { + return v + } + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v) + if (!Number.isNaN(n) && Number.isFinite(n)) { + return n + } + } + return null +} + +function estimatedLossSortValue(v: unknown): number { + if (typeof v === 'number' && Number.isFinite(v)) { + return v + } + if (typeof v === 'string' && v.trim() !== '') { + const n = Number(v) + if (!Number.isNaN(n) && Number.isFinite(n)) { + return n + } + } + return Number.MAX_SAFE_INTEGER +} + +const candidateNumbersSorted = computed(() => { + const list = Array.isArray(snapshot.candidate_numbers) ? [...snapshot.candidate_numbers] : [] + list.sort((a, b) => { + const ea = estimatedLossSortValue(a?.estimated_loss) + const eb = estimatedLossSortValue(b?.estimated_loss) + if (ea !== eb) { + return ea - eb + } + const na = numberValue(a?.number) + const nb = numberValue(b?.number) + if (na !== null && nb !== null && na !== nb) { + return na - nb + } + return String(a?.number ?? '').localeCompare(String(b?.number ?? '')) + }) + return list +}) + +function isScheduledNumber(v: unknown): boolean { + const n = numberValue(v) + if (n === null) { + return false + } + return snapshot.pending_draw_number === n +} + +function candidateRowClassName(arg: { row: anyObj }): string { + return isScheduledNumber(arg.row?.number) ? 'is-scheduled-row' : '' +} + +async function onPickSwitchChange(val: boolean, rowNumber: unknown): Promise { + const target = numberValue(rowNumber) + if (target === null) { + return + } + if (!val) { + return + } + if (snapshot.pending_draw_number === target) { + return + } + pendingSwitchNumber.value = target + try { + await onDraw(target) + } finally { + pendingSwitchNumber.value = null + } +} + const canVoidPeriod = computed(() => { const r = snapshot.record if (!r) { @@ -451,8 +569,6 @@ async function loadSnapshot() { const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false }) if (res.code === 1 && res.data) { mergeLiveSnapshot(res.data as anyObj) - const dmax = res.data.draw_number_max ?? 36 - if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1 } } finally { loading.value = false @@ -528,7 +644,7 @@ async function onCalculate() { method: 'post', data: { record_id: snapshot.record.id, - manual_number: manualNumber.value, + manual_number: numberValue(snapshot.pending_draw_number) ?? numberValue(candidateNumbersSorted.value[0]?.number) ?? 1, }, showSuccessMessage: true, }) @@ -543,7 +659,7 @@ async function onCalculate() { } } -async function onDraw() { +async function onDrawWithNumber(targetNumber: number) { if (!snapshot.record) return drawLoading.value = true try { @@ -552,7 +668,7 @@ async function onDraw() { method: 'post', data: { record_id: snapshot.record.id, - manual_number: manualNumber.value, + manual_number: targetNumber, }, showSuccessMessage: true, }) @@ -562,6 +678,19 @@ async function onDraw() { } } +async function onDraw(targetNumber?: number) { + if (targetNumber !== undefined) { + return onDrawWithNumber(targetNumber) + } + const pending = numberValue(snapshot.pending_draw_number) + if (pending !== null) { + return onDrawWithNumber(pending) + } + const first = candidateNumbersSorted.value[0] + const fallback = numberValue(first?.number) ?? 1 + return onDrawWithNumber(fallback) +} + const countdownParts = computed(() => { const bet = snapshot.bet_remaining_seconds ?? 0 const draw = snapshot.remaining_seconds ?? 0 @@ -573,6 +702,8 @@ const countdownParts = computed(() => { }) onMounted(async () => { + updateIsMobile() + window.addEventListener('resize', updateIsMobile) clockTimer = window.setInterval(() => { clockTick.value++ }, 1000) @@ -582,6 +713,7 @@ onMounted(async () => { }) onUnmounted(() => { + window.removeEventListener('resize', updateIsMobile) disconnectWs() if (clockTimer !== null) { window.clearInterval(clockTimer) @@ -624,6 +756,17 @@ onUnmounted(() => { line-height: 1.45; } +.live-top-toolbar__actions { + margin-left: auto; + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.live-top-toolbar__btn-void { + border-color: var(--el-color-danger-light-7); +} + .live-control-card { :deep(.el-card__body) { padding-top: 8px; @@ -631,14 +774,10 @@ onUnmounted(() => { } .live-control-layout { - display: flex; - flex-wrap: wrap; - gap: 20px 24px; - align-items: flex-start; + display: block; } .live-control-main { - flex: 1 1 320px; min-width: 0; } @@ -786,76 +925,43 @@ onUnmounted(() => { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } -.live-control-aside { - flex: 0 0 240px; - padding-left: 20px; - border-left: 1px solid var(--el-border-color-lighter); +.number-tag { + min-width: 44px; + justify-content: center; + font-variant-numeric: tabular-nums; +} + +.pick-tags { display: flex; - flex-direction: column; - gap: 12px; - - &.is-locked { - opacity: 0.9; - filter: grayscale(0.08); - } -} - -@media (max-width: 992px) { - .live-control-aside { - flex: 1 1 100%; - padding-left: 0; - border-left: none; - padding-top: 16px; - border-top: 1px solid var(--el-border-color-lighter); - } -} - -.aside-title { - font-size: 14px; - font-weight: 600; - color: var(--el-text-color-regular); -} - -.aside-void-btn { - width: 100%; - margin-bottom: 12px; -} - -.aside-field { - display: flex; - flex-direction: column; + flex-wrap: wrap; + justify-content: center; gap: 6px; } -.aside-field__label { - font-size: 12px; - color: var(--el-text-color-secondary); +.pick-tags__item { + font-variant-numeric: tabular-nums; } -.aside-field__input { - width: 100%; +.bet-user { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.aside-btns { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - gap: 8px; - align-items: stretch; +.candidate-table :deep(.is-scheduled-row td) { + background: var(--el-color-primary-light-9); } -.aside-btns .el-button { - flex: 1; - min-width: 0; - margin-left: 0; - padding-left: 8px; - padding-right: 8px; -} +@media (max-width: 768px) { + .live-tables-row .el-col { + margin-bottom: 12px; + } -.aside-btns .el-button :deep(span) { - white-space: normal; - line-height: 1.25; - text-align: center; + .pick-tags { + gap: 4px; + } } diff --git a/web/src/views/backend/game/record/index.vue b/web/src/views/backend/game/record/index.vue index 626cf7c..d5d6c3d 100644 --- a/web/src/views/backend/game/record/index.vue +++ b/web/src/views/backend/game/record/index.vue @@ -5,10 +5,12 @@ -
- {{ t('game.record.view_abnormal_rounds') }} -
+ > + + + {{ t('game.record.view_abnormal_rounds') }} + +
@@ -162,7 +164,7 @@ const openAbnormalDialog = async () => { showSuccessMessage: false, } ) - const data = response?.data?.data ?? {} + const data = response?.data ?? {} abnormalDialog.list = Array.isArray(data.list) ? data.list : [] } catch (error: any) { const message = typeof error?.message === 'string' && error.message !== '' ? error.message : t('game.record.load_abnormal_failed')