From 5ab9172b3127064c5dbce19b530ecbb2035e7632 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Fri, 24 Apr 2026 17:22:38 +0800 Subject: [PATCH] =?UTF-8?q?1.=E5=AF=B9=E5=B1=80=E6=96=B0=E5=A2=9E=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=BC=82=E5=B8=B8=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/game/Record.php | 90 ++++++++++++++ app/common/service/GameLiveService.php | 131 +++++++++++++++++++- app/process/GameLiveTicker.php | 2 + web/src/lang/backend/en/game/record.ts | 9 ++ web/src/lang/backend/zh-cn/game/record.ts | 9 ++ web/src/views/backend/game/record/index.vue | 87 ++++++++++++- 6 files changed, 325 insertions(+), 3 deletions(-) diff --git a/app/admin/controller/game/Record.php b/app/admin/controller/game/Record.php index 16a7fd0..801e54e 100644 --- a/app/admin/controller/game/Record.php +++ b/app/admin/controller/game/Record.php @@ -3,6 +3,7 @@ namespace app\admin\controller\game; use app\common\controller\Backend; +use support\think\Db; use support\Response; use Webman\Http\Request as WebmanRequest; @@ -66,4 +67,93 @@ class Record extends Backend } return $this->error('游戏对局记录不可删除'); } + + public function abnormalList(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $limitRaw = $request->get('limit', 30); + $limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 30; + if ($limit < 1) { + $limit = 1; + } + if ($limit > 200) { + $limit = 200; + } + + $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) + ->select() + ->toArray(); + + $list = []; + foreach ($rows as $row) { + $meta = $this->parseRecoverVoidReason(is_string($row['void_reason'] ?? null) ? $row['void_reason'] : ''); + $list[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'period_no' => (string) ($row['period_no'] ?? ''), + 'abnormal_from_status' => $meta['from_status'], + 'refunded_user_count' => $meta['users'], + 'refunded_order_count' => $meta['orders'], + 'refunded_total_amount' => $meta['amount'], + 'recovered_at' => (int) ($row['update_time'] ?? 0), + 'void_reason' => (string) ($row['void_reason'] ?? ''), + ]; + } + + return $this->success('', [ + 'list' => $list, + 'total' => count($list), + ]); + } + + /** + * @return array{from_status:int,users:int,orders:int,amount:string} + */ + private function parseRecoverVoidReason(string $reason): array + { + $meta = [ + 'from_status' => -1, + 'users' => 0, + 'orders' => 0, + 'amount' => '0.00', + ]; + if ($reason === '' || str_starts_with($reason, 'system_recover:') === false) { + return $meta; + } + $payload = substr($reason, strlen('system_recover:')); + $parts = explode('|', $payload); + foreach ($parts as $part) { + $item = trim($part); + if ($item === '' || !str_contains($item, '=')) { + continue; + } + [$key, $value] = explode('=', $item, 2); + $key = trim($key); + $value = trim($value); + if ($key === 'from' && is_numeric($value)) { + $meta['from_status'] = (int) $value; + continue; + } + if ($key === 'users' && is_numeric($value)) { + $meta['users'] = (int) $value; + continue; + } + if ($key === 'orders' && is_numeric($value)) { + $meta['orders'] = (int) $value; + continue; + } + if ($key === 'amount' && $value !== '') { + $meta['amount'] = $value; + } + } + return $meta; + } } diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 0ce08dd..65881a6 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -8,6 +8,7 @@ use app\common\library\game\StreakWinReward; use app\common\model\UserWalletRecord; use app\common\service\GameHotDataCoordinator; use app\common\service\GameHotDataLock; +use support\Log; use support\think\Db; use Throwable; @@ -31,6 +32,114 @@ final class GameLiveService /** 开奖后派彩展示宽限期(秒),之后再创建下一期 */ private const PAYOUT_GRACE_SECONDS = 3; + /** 启动自愈:判定“异常卡局”的最小超时冗余秒数 */ + private const STARTUP_RECOVER_GRACE_SECONDS = 10; + + /** + * 服务重启后自动巡检上一局:若长时间卡在进行中状态,则自动作废并退款待开奖注单。 + */ + public static function recoverAbnormalPeriodOnStartup(): void + { + $row = Db::name('game_record') + ->whereIn('status', [0, 1, 2, 3]) + ->order('id', 'desc') + ->find(); + if (!$row) { + return; + } + + $recordId = (int) ($row['id'] ?? 0); + if ($recordId <= 0) { + 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; + } + + $status = (int) ($row['status'] ?? 0); + $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) { + return; + } + $freshStatus = (int) ($fresh['status'] ?? 0); + 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) { + return; + } + + $now = time(); + $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00']; + 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, + $refundedUserCount, + $refundedOrderCount, + $refundedTotalAmount + ); + Db::name('game_record')->where('id', $recordId)->update([ + 'status' => 5, + 'void_reason' => $reason, + 'pending_draw_number' => null, + 'payout_until' => null, + 'ai_locked_number' => null, + 'update_time' => $now, + ]); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + Log::warning('game live startup recover failed', [ + 'record_id' => $recordId, + 'error' => $e->getMessage(), + ]); + return; + } + + GameHotDataCoordinator::afterGameRecordCommitted($recordId); + foreach ($refund['user_ids'] as $uid) { + if ($uid > 0) { + GameHotDataCoordinator::afterUserCommitted($uid); + } + } + GameRecordService::bootstrapPeriodWhenRuntimeEnabled(); + self::publishSnapshot(null); + Log::info('game live startup recovered abnormal period', [ + 'record_id' => $recordId, + 'old_status' => $freshStatus, + 'refunded_user_count' => count($refund['user_ids']), + 'refunded_order_count' => (int) ($refund['order_count'] ?? 0), + 'refunded_total_amount' => is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00', + ]); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']); + } + } public static function buildSnapshot(?int $recordId = null): array { @@ -519,7 +628,8 @@ final class GameLiveService $now = time(); Db::startTrans(); try { - $refundedUserIds = self::refundPendingBetsForPeriodLocked($rid, $now); + $refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now); + $refundedUserIds = $refund['user_ids']; Db::name('game_record')->where('id', $rid)->update([ 'status' => 5, 'void_reason' => $reason, @@ -557,8 +667,19 @@ final class GameLiveService * @return list */ private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array + { + $summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now); + return $summary['user_ids']; + } + + /** + * @return array{user_ids:list,order_count:int,total_amount:string} + */ + private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array { $userIdSet = []; + $orderCount = 0; + $totalAmount = '0.00'; $bets = Db::name('bet_order') ->where('period_id', $periodId) ->where('status', 1) @@ -614,6 +735,8 @@ final class GameLiveService 'create_time' => $now, ]); $userIdSet[$userId] = true; + $orderCount++; + $totalAmount = bcadd($totalAmount, $total, 2); } $out = []; @@ -621,7 +744,11 @@ final class GameLiveService $out[] = (int) $uid; } - return $out; + return [ + 'user_ids' => $out, + 'order_count' => $orderCount, + 'total_amount' => $totalAmount, + ]; } public static function publishSnapshot(?int $recordId = null): void diff --git a/app/process/GameLiveTicker.php b/app/process/GameLiveTicker.php index f335b63..b93efc4 100644 --- a/app/process/GameLiveTicker.php +++ b/app/process/GameLiveTicker.php @@ -12,6 +12,8 @@ class GameLiveTicker { public function onWorkerStart(): void { + GameLiveService::recoverAbnormalPeriodOnStartup(); + Timer::add(1, static function (): void { GameLiveService::finalizePayoutGrace(); GameLiveService::tickAutoDraw(); diff --git a/web/src/lang/backend/en/game/record.ts b/web/src/lang/backend/en/game/record.ts index 0a768b3..c90077f 100644 --- a/web/src/lang/backend/en/game/record.ts +++ b/web/src/lang/backend/en/game/record.ts @@ -26,4 +26,13 @@ export default { manual_create_label: 'Allow manual create next round', manual_create_tip: 'When enabled, button below can create next round manually', btn_create_next: 'Create next round (manual)', + view_abnormal_rounds: 'View abnormal rounds', + abnormal_dialog_title: 'Abnormal round recovery logs', + abnormal_dialog_tip: 'Shows rounds auto-recovered after service restart (auto-void + refund).', + abnormal_from_status: 'Status before recovery', + refunded_user_count: 'Refunded users', + refunded_order_count: 'Refunded orders', + refunded_total_amount: 'Total refunded amount', + recovered_at: 'Recovered at', + load_abnormal_failed: 'Failed to load abnormal rounds', } diff --git a/web/src/lang/backend/zh-cn/game/record.ts b/web/src/lang/backend/zh-cn/game/record.ts index 11fc0fa..f20fb30 100644 --- a/web/src/lang/backend/zh-cn/game/record.ts +++ b/web/src/lang/backend/zh-cn/game/record.ts @@ -26,4 +26,13 @@ export default { manual_create_label: '允许手动创建下一局', manual_create_tip: '开启后可在本页使用「手动创建下一局」按钮', btn_create_next: '手动创建下一局', + view_abnormal_rounds: '查看异常对局', + abnormal_dialog_title: '异常对局恢复记录', + abnormal_dialog_tip: '展示服务重启后自动恢复的异常对局(自动作废并退款)。', + abnormal_from_status: '异常前状态', + refunded_user_count: '退款用户数', + refunded_order_count: '退款注单数', + refunded_total_amount: '退款总金额', + recovered_at: '恢复时间', + load_abnormal_failed: '加载异常对局失败', } diff --git a/web/src/views/backend/game/record/index.vue b/web/src/views/backend/game/record/index.vue index 8906d56..626cf7c 100644 --- a/web/src/views/backend/game/record/index.vue +++ b/web/src/views/backend/game/record/index.vue @@ -6,22 +6,58 @@ :buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']" :quick-search-placeholder="t('Quick search placeholder', { fields: t('game.record.quick Search Fields') })" > +
+ {{ t('game.record.view_abnormal_rounds') }} +
+ + +
+ + {{ t('game.record.abnormal_dialog_tip') }} + + + + + + + + + + + + + +
+ +