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" /> +
@@ -78,75 +90,62 @@ - - -