From 687257adaa9a940c17736038d3781665e7610331 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Tue, 28 Apr 2026 18:25:44 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96=E5=AE=9E=E6=97=B6=E5=AF=B9?= =?UTF-8?q?=E5=B1=80=E9=A1=B5=E9=9D=A2=E6=A0=B7=E5=BC=8F=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E4=B8=8B=E4=B8=80=E5=B1=80?= =?UTF-8?q?=E5=92=8C=E4=BD=9C=E5=BA=9F=E6=9C=AC=E5=B1=80=E7=9A=84=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=202.=E6=96=B0=E5=A2=9E=E6=B4=BE=E5=BD=A9=E8=BE=BE?= =?UTF-8?q?=E5=88=B0game=5Fconfig.jackpot=5Fmax=5Famount=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E6=89=8D=E8=83=BD=E5=8F=91=E6=94=BE=203.?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B8=B8=E6=88=8F=E5=AF=B9=E5=B1=80=E8=AE=B0?= =?UTF-8?q?=E5=BD=95-=E6=9F=A5=E7=9C=8B=E6=B8=B8=E7=8E=A9=E8=AE=B0?= =?UTF-8?q?=E5=BD=95btn=203.=E5=A4=87=E4=BB=BDMySQL=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/game/Live.php | 13 +- app/admin/controller/game/PlayRecord.php | 210 +++++++++++++++++- app/admin/controller/game/Record.php | 186 +++++++++++++++- app/api/controller/Game.php | 6 +- app/common/model/GamePeriod.php | 1 - app/common/model/GameRecord.php | 1 - app/common/service/GameBetSettleService.php | 163 +++++++++++++- app/common/service/GameHotDataRedis.php | 24 +- app/common/service/GameLiveService.php | 19 +- app/common/service/GameRecordService.php | 38 ++-- app/common/validate/GamePeriod.php | 5 +- app/common/validate/GameRecord.php | 5 +- web/src/lang/backend/en/game/live.ts | 6 +- web/src/lang/backend/en/game/playRecord.ts | 12 + web/src/lang/backend/en/game/record.ts | 30 ++- web/src/lang/backend/zh-cn/game/live.ts | 6 +- web/src/lang/backend/zh-cn/game/playRecord.ts | 12 + web/src/lang/backend/zh-cn/game/record.ts | 30 ++- web/src/views/backend/game/live/index.vue | 53 ++++- .../views/backend/game/playRecord/index.vue | 146 +++++++++++- web/src/views/backend/game/record/index.vue | 186 ++++++++-------- .../views/backend/game/record/popupForm.vue | 1 - 22 files changed, 945 insertions(+), 208 deletions(-) diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 24c794c..01eb851 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -139,7 +139,7 @@ class Live extends Backend } $raw = $request->post('enabled'); $enabled = $raw === true || $raw === '1' || $raw === 1; - GameRecordService::setLiveRuntimeEnabled($enabled); + GameRecordService::setAutoCreateEnabled($enabled); if ($enabled) { GameRecordService::bootstrapPeriodWhenRuntimeEnabled(); } @@ -168,6 +168,15 @@ class Live extends Backend return $this->error(is_string($errMsg) ? $errMsg : __('Void failed')); } $okMsg = $res['msg'] ?? ''; - return $this->success(is_string($okMsg) ? $okMsg : '', GameLiveService::buildSnapshot(null)); + $snapshot = GameLiveService::buildSnapshot(null); + // 作废本局后:必须关闭自动创建下一局开关(允许管理员后续手动重新开启) + $snapshot['runtime_enabled'] = false; + // 作废后一般不存在进行中的局,直接进入维护态(用于前端展示“维护中”倒计时) + $snapshot['maintenance_ui'] = true; + $refund = $res['refund'] ?? null; + if (is_array($refund)) { + $snapshot['void_refund'] = $refund; + } + return $this->success(is_string($okMsg) ? $okMsg : '', $snapshot); } } diff --git a/app/admin/controller/game/PlayRecord.php b/app/admin/controller/game/PlayRecord.php index 785f059..a8cc162 100644 --- a/app/admin/controller/game/PlayRecord.php +++ b/app/admin/controller/game/PlayRecord.php @@ -3,6 +3,8 @@ namespace app\admin\controller\game; use app\common\controller\Backend; +use app\common\service\GameBetSettleService; +use support\think\Db; use support\Response; use Webman\Http\Request as WebmanRequest; @@ -78,26 +80,191 @@ class PlayRecord extends Backend $where[] = ['user.admin_id', 'in', $this->scopedAdminIds()]; } - $res = $this->model - ->withJoin($this->withJoinTable, $this->withJoinType) - ->with($this->withJoinTable) - ->visible([ - 'user' => ['username', 'phone'], - 'channel' => ['name'], - 'gameRecord' => ['period_no', 'status'], + // 避免 ThinkORM withJoin 对 game_record 的字段缓存导致 select 出已删除列(如 preset_number) + // 这里改为手写 join + 明确 field 列表,保证数据库字段变更后不受 schema 缓存影响。 + $query = Db::name($table)->alias($mainShort !== '' ? $mainShort : 'play_record') + ->leftJoin('user user', 'user.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.user_id') + ->leftJoin('channel channel', 'channel.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.channel_id') + ->leftJoin('game_record game_record', 'game_record.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.period_id') + ->where($where); + + $res = $query + ->field([ + ($mainShort !== '' ? $mainShort : 'play_record') . '.*', + 'user.username as user__username', + 'user.phone as user__phone', + 'channel.name as channel__name', + 'game_record.period_no as gameRecord__period_no', + 'game_record.status as gameRecord__status', ]) - ->alias($alias) - ->where($where) ->order($order) ->paginate($limit); + $list = $res->items(); + $total = $res->total(); + + // 将 join 扁平字段还原为原页面所需结构:user/channel/gameRecord + foreach ($list as $idx => $row) { + if (!is_array($row)) { + continue; + } + $row['user'] = [ + 'username' => isset($row['user__username']) ? (string) $row['user__username'] : '', + 'phone' => isset($row['user__phone']) ? (string) $row['user__phone'] : '', + ]; + $row['channel'] = [ + 'name' => isset($row['channel__name']) ? (string) $row['channel__name'] : '', + ]; + $row['gameRecord'] = [ + 'period_no' => isset($row['gameRecord__period_no']) ? (string) $row['gameRecord__period_no'] : '', + 'status' => isset($row['gameRecord__status']) && is_numeric((string) $row['gameRecord__status']) + ? (int) $row['gameRecord__status'] + : null, + ]; + unset( + $row['user__username'], + $row['user__phone'], + $row['channel__name'], + $row['gameRecord__period_no'], + $row['gameRecord__status'], + ); + $list[$idx] = $row; + } + + $threshold = $this->jackpotMaxAmount(); + foreach ($list as $idx => $row) { + if (!is_array($row)) { + continue; + } + $status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0; + $win = bcadd(strval($row['win_amount'] ?? '0'), '0', 2); + $needReview = bccomp($threshold, '0', 2) > 0 && bccomp($win, $threshold, 2) >= 0; + $canApprove = $needReview && $status === GameBetSettleService::PLAY_STATUS_PENDING_REVIEW; + $row['jackpot_need_review'] = $needReview ? 1 : 0; + $row['can_jackpot_approve'] = $canApprove ? 1 : 0; + $list[$idx] = $row; + } + return $this->success('', [ - 'list' => $res->items(), - 'total' => $res->total(), + 'list' => $list, + 'total' => $total, 'remark' => get_route_remark(), ]); } + /** + * 大奖审核通过并派彩(仅 win_amount >= game_config.jackpot_max_amount 且 status=待审核 才可操作) + */ + public function approveJackpot(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + + $idRaw = $request->post('id'); + if ($idRaw === null || $idRaw === '' || !is_numeric(strval($idRaw))) { + return $this->error(__('Parameter error')); + } + $id = (int) $idRaw; + if ($id <= 0) { + return $this->error(__('Parameter error')); + } + $remarkRaw = $request->post('remark'); + $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; + + // 权限范围校验:复用列表逻辑(非超管只能操作其下辖用户) + if ($this->auth && !$this->auth->isSuperAdmin()) { + $uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id'); + $uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw; + if ($uid <= 0) { + return $this->error(__('Record not found')); + } + $ownerAdminId = Db::name('user')->where('id', $uid)->value('admin_id'); + $aid = ($ownerAdminId === null || $ownerAdminId === '' || !is_numeric(strval($ownerAdminId))) ? 0 : (int) $ownerAdminId; + if ($aid <= 0 || !in_array($aid, $this->scopedAdminIds(), true)) { + return $this->error(__('You have no permission')); + } + } + + $adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0; + + Db::startTrans(); + try { + $result = GameBetSettleService::approveJackpotPlayRecord($id, $adminId, $remark); + if (($result['ok'] ?? false) !== true) { + Db::rollback(); + $msg = is_string($result['msg'] ?? null) ? $result['msg'] : __('Parameter error'); + return $this->error($msg); + } + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return $this->error($e->getMessage()); + } + + return $this->success(__('Approved')); + } + + /** + * 大奖审核拒绝(remark 必填) + */ + public function rejectJackpot(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $idRaw = $request->post('id'); + if ($idRaw === null || $idRaw === '' || !is_numeric(strval($idRaw))) { + return $this->error(__('Parameter error')); + } + $id = (int) $idRaw; + if ($id <= 0) { + return $this->error(__('Parameter error')); + } + $remarkRaw = $request->post('remark'); + $remark = is_string($remarkRaw) ? trim($remarkRaw) : ''; + if ($remark === '') { + return $this->error(__('Please provide reject reason')); + } + + if ($this->auth && !$this->auth->isSuperAdmin()) { + $uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id'); + $uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw; + if ($uid <= 0) { + return $this->error(__('Record not found')); + } + $ownerAdminId = Db::name('user')->where('id', $uid)->value('admin_id'); + $aid = ($ownerAdminId === null || $ownerAdminId === '' || !is_numeric(strval($ownerAdminId))) ? 0 : (int) $ownerAdminId; + if ($aid <= 0 || !in_array($aid, $this->scopedAdminIds(), true)) { + return $this->error(__('You have no permission')); + } + } + + $adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0; + Db::startTrans(); + try { + $result = GameBetSettleService::rejectJackpotPlayRecord($id, $adminId, $remark); + if (($result['ok'] ?? false) !== true) { + Db::rollback(); + $msg = is_string($result['msg'] ?? null) ? $result['msg'] : __('Parameter error'); + return $this->error($msg); + } + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return $this->error($e->getMessage()); + } + return $this->success(__('Rejected')); + } + /** * @return int[] */ @@ -116,5 +283,26 @@ class PlayRecord extends Backend $adminIds = array_values(array_unique(array_filter($adminIds, static fn($id) => $id > 0))); return $adminIds === [] ? [0] : $adminIds; } + + private function jackpotMaxAmount(): string + { + $row = Db::name('game_config')->where('config_key', GameBetSettleService::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find(); + if (!is_array($row)) { + return '0.00'; + } + $raw = $row['config_value'] ?? null; + if ($raw === null || $raw === '') { + return '0.00'; + } + $v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : ''); + if ($v === '' || !is_numeric($v)) { + return '0.00'; + } + $normalized = bcadd($v, '0', 2); + if (bccomp($normalized, '0', 2) <= 0) { + return '0.00'; + } + return $normalized; + } } diff --git a/app/admin/controller/game/Record.php b/app/admin/controller/game/Record.php index 7bcc8d5..2dfd21c 100644 --- a/app/admin/controller/game/Record.php +++ b/app/admin/controller/game/Record.php @@ -32,6 +32,44 @@ class Record extends Backend return null; } + protected function _index(): Response + { + if ($this->request && $this->request->get('select')) { + return $this->select($this->request); + } + + list($where, $alias, $limit, $order) = $this->queryBuilder(); + + $res = $this->model + ->field($this->indexField) + ->withJoin($this->withJoinTable, $this->withJoinType) + ->with($this->withJoinTable) + ->alias($alias) + ->where($where) + ->order($order) + ->paginate($limit); + + $list = $res->items(); + foreach ($list as $idx => $row) { + if (!is_array($row)) { + continue; + } + $status = isset($row['status']) && is_numeric((string) $row['status']) ? (int) $row['status'] : 0; + $reason = isset($row['void_reason']) && is_string($row['void_reason']) ? $row['void_reason'] : ''; + // 将“系统自愈作废”的对局在列表中标记为【异常】(展示态,不改库中 status=5 的事实) + if ($status === 5 && $reason !== '' && str_starts_with($reason, 'system_recover:')) { + $row['status'] = 6; + $list[$idx] = $row; + } + } + + return $this->success('', [ + 'list' => $list, + 'total' => $res->total(), + 'remark' => get_route_remark(), + ]); + } + public function add(WebmanRequest $request): Response { $response = $this->initializeBackend($request); @@ -86,24 +124,95 @@ 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) ->select() ->toArray(); + $periodIds = []; + foreach ($rows as $row) { + $pid = (int) ($row['id'] ?? 0); + if ($pid > 0) { + $periodIds[] = $pid; + } + } + $refundAggByPeriod = []; + $refundIdsByPeriod = []; + if ($periodIds !== []) { + $aggRows = Db::name('bet_order') + ->whereIn('period_id', $periodIds) + ->where('status', 3) + ->fieldRaw('period_id as pid, COUNT(*) as cnt, COUNT(DISTINCT user_id) as users, COALESCE(SUM(total_amount), 0) as amt') + ->group('period_id') + ->select() + ->toArray(); + foreach ($aggRows as $a) { + $pid = (int) ($a['pid'] ?? 0); + if ($pid <= 0) { + continue; + } + $refundAggByPeriod[$pid] = [ + 'orders' => (int) ($a['cnt'] ?? 0), + 'users' => (int) ($a['users'] ?? 0), + 'amount' => (string) ($a['amt'] ?? '0.00'), + ]; + } + + $idRows = Db::name('bet_order') + ->whereIn('period_id', $periodIds) + ->where('status', 3) + ->field(['period_id', 'id']) + ->order('id', 'desc') + ->limit(2000) + ->select() + ->toArray(); + foreach ($idRows as $r) { + $pid = (int) ($r['period_id'] ?? 0); + $bid = (int) ($r['id'] ?? 0); + if ($pid <= 0 || $bid <= 0) { + continue; + } + if (!isset($refundIdsByPeriod[$pid])) { + $refundIdsByPeriod[$pid] = []; + } + if (count($refundIdsByPeriod[$pid]) < 50) { + $refundIdsByPeriod[$pid][] = $bid; + } + } + } + $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); + $pid = (int) ($row['id'] ?? 0); + $agg = $refundAggByPeriod[$pid] ?? null; + $users = (int) ($meta['users'] ?? 0); + $orders = (int) ($meta['orders'] ?? 0); + $amount = is_string($meta['amount'] ?? null) ? $meta['amount'] : '0.00'; + if (is_array($agg)) { + if ($orders <= 0 && ($agg['orders'] ?? 0) > 0) { + $orders = (int) ($agg['orders'] ?? 0); + } + if ($users <= 0 && ($agg['users'] ?? 0) > 0) { + $users = (int) ($agg['users'] ?? 0); + } + if (bccomp($amount, '0', 2) <= 0 && is_string($agg['amount'] ?? null)) { + $amount = (string) $agg['amount']; + } + } $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'], + 'refunded_user_count' => $users, + 'refunded_order_count' => $orders, + 'refunded_total_amount' => $amount, + 'refunded_order_ids' => $refundIdsByPeriod[$pid] ?? [], 'recovered_at' => (int) ($row['update_time'] ?? 0), 'void_reason' => $reason, 'is_auto_recover' => $isAutoRecover ? 1 : 0, @@ -116,6 +225,77 @@ class Record extends Backend ]); } + /** + * 某期对局的游玩(压注)记录列表(用于对局记录页弹窗查看)。 + */ + public function playRecordList(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $periodIdRaw = $request->get('period_id'); + if (!is_numeric((string) $periodIdRaw)) { + return $this->error(__('Parameter error')); + } + $periodId = (int) $periodIdRaw; + if ($periodId <= 0) { + return $this->error(__('Parameter error')); + } + + $pageRaw = $request->get('page', 1); + $page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1; + if ($page < 1) { + $page = 1; + } + + $limitRaw = $request->get('limit', 30); + $limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 30; + if ($limit < 1) { + $limit = 1; + } + if ($limit > 200) { + $limit = 200; + } + + $query = Db::name('game_play_record') + ->alias('pr') + ->leftJoin('user u', 'u.id = pr.user_id') + ->leftJoin('channel c', 'c.id = pr.channel_id') + ->where('pr.period_id', $periodId); + + $total = (int) $query->count('pr.id'); + $list = $query + ->field([ + 'pr.id', + 'pr.period_id', + 'pr.user_id', + 'pr.channel_id', + 'pr.pick_numbers', + 'pr.total_amount', + 'pr.streak_at_bet', + 'pr.is_auto', + 'pr.win_amount', + 'pr.jackpot_extra_amount', + 'pr.status', + 'pr.idempotency_key', + 'pr.create_time', + 'pr.update_time', + 'u.username as user_username', + 'c.name as channel_name', + ]) + ->order('pr.id', 'desc') + ->page($page, $limit) + ->select() + ->toArray(); + + return $this->success('', [ + 'list' => $list, + 'total' => $total, + ]); + } + /** * @return array{from_status:int,users:int,orders:int,amount:string} */ diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php index 02ea830..12c1c51 100644 --- a/app/api/controller/Game.php +++ b/app/api/controller/Game.php @@ -49,7 +49,7 @@ class Game extends MobileBase $user = $this->auth->getUser(); return $this->mobileSuccess([ 'server_time' => $now, - 'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(), + 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), 'period' => [ 'period_no' => (string) ($periodRow['period_no'] ?? ''), 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), @@ -107,7 +107,7 @@ class Game extends MobileBase $now = time(); $startAt = $this->intValue($periodRow['period_start_at'] ?? 0); return $this->mobileSuccess([ - 'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(), + 'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE), 'period_id' => $periodRow['id'], 'period_no' => $periodRow['period_no'], 'status' => $this->mapPeriodStatus($periodRow['status'] ?? null), @@ -169,7 +169,7 @@ class Game extends MobileBase $numberCount = (string) count($numbers); $totalAmount = bcmul($singleAmount, $numberCount, 2); - if (!GameRecordService::isLiveRuntimeEnabled()) { + if (!GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE)) { return $this->mobileError(3001, 'Game is paused'); } $period = GameRecord::where('period_no', $periodNo)->find(); diff --git a/app/common/model/GamePeriod.php b/app/common/model/GamePeriod.php index 4f907e7..f513cc4 100644 --- a/app/common/model/GamePeriod.php +++ b/app/common/model/GamePeriod.php @@ -16,7 +16,6 @@ class GamePeriod extends Model 'period_start_at' => 'integer', 'status' => 'integer', 'draw_mode' => 'integer', - 'preset_number' => 'integer', 'result_number' => 'integer', ]; diff --git a/app/common/model/GameRecord.php b/app/common/model/GameRecord.php index bb2f44c..dad492d 100644 --- a/app/common/model/GameRecord.php +++ b/app/common/model/GameRecord.php @@ -17,7 +17,6 @@ class GameRecord extends Model 'period_start_at' => 'integer', 'status' => 'integer', 'draw_mode' => 'integer', - 'preset_number' => 'integer', 'result_number' => 'integer', 'ai_locked_number' => 'integer', 'pending_draw_number' => 'integer', diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 3216f69..0ed2a49 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -14,6 +14,14 @@ use Throwable; */ final class GameBetSettleService { + public const PLAY_STATUS_PENDING_DRAW = 1; + public const PLAY_STATUS_SETTLED = 2; + public const PLAY_STATUS_REFUNDED = 3; + public const PLAY_STATUS_RETURNED = 4; + public const PLAY_STATUS_PENDING_REVIEW = 5; + + public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount'; + /** * 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。 * @@ -28,9 +36,10 @@ final class GameBetSettleService } $now = time(); + $jackpotMaxAmount = self::jackpotMaxAmount(); $bets = Db::name('bet_order') ->where('period_id', $recordId) - ->where('status', 1) + ->where('status', self::PLAY_STATUS_PENDING_DRAW) ->order('id', 'asc') ->select() ->toArray(); @@ -60,14 +69,16 @@ final class GameBetSettleService $win = self::computeWinAmount($bet, $resultNumber); $jackpot = '0.00'; + $needReview = self::shouldRequireJackpotReview($win, $jackpotMaxAmount); + $nextStatus = $needReview ? self::PLAY_STATUS_PENDING_REVIEW : self::PLAY_STATUS_SETTLED; $affected = Db::name('bet_order') ->where('id', $betId) - ->where('status', 1) + ->where('status', self::PLAY_STATUS_PENDING_DRAW) ->update([ 'win_amount' => $win, 'jackpot_extra_amount' => $jackpot, - 'status' => 2, + 'status' => $nextStatus, 'update_time' => $now, ]); @@ -91,8 +102,8 @@ final class GameBetSettleService } $balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); - if (bccomp($win, '0', 2) > 0) { - $paid = self::creditUserPayout($bet, $betId, $win, $now); + if (!$needReview && bccomp($win, '0', 2) > 0) { + $paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩'); if ($paid !== null) { $balanceAfter = $paid; } @@ -154,6 +165,109 @@ final class GameBetSettleService return ['jackpot_hits' => $jackpotHits]; } + /** + * 大奖审核通过后派彩(幂等):仅当 play_record.status=待审核 且 win_amount>=阈值时执行。 + * + * @return array{ok: bool, msg: string, balance_after?: string} + */ + public static function approveJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark = ''): array + { + if ($playRecordId <= 0) { + return ['ok' => false, 'msg' => __('Parameter error')]; + } + // 兼容:bet_order 可能是 VIEW,且 * 列表会固化;审核字段始终以 game_play_record 为准 + $row = Db::name('game_play_record')->where('id', $playRecordId)->find(); + if (!is_array($row)) { + $row = Db::name('bet_order')->where('id', $playRecordId)->find(); + } + if (!is_array($row)) { + return ['ok' => false, 'msg' => __('Record not found')]; + } + $status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0; + if ($status !== self::PLAY_STATUS_PENDING_REVIEW) { + return ['ok' => false, 'msg' => __('This record does not require review')]; + } + $winAmount = bcadd((string) ($row['win_amount'] ?? '0'), '0', 2); + $threshold = self::jackpotMaxAmount(); + if (!self::shouldRequireJackpotReview($winAmount, $threshold)) { + return ['ok' => false, 'msg' => __('This record does not meet jackpot review threshold')]; + } + $userId = isset($row['user_id']) && is_numeric($row['user_id']) ? (int) $row['user_id'] : 0; + if ($userId <= 0) { + return ['ok' => false, 'msg' => __('Order is missing user info')]; + } + $now = time(); + $balanceAfter = null; + if (bccomp($winAmount, '0', 2) > 0) { + $balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩'); + } + $reviewRemark = trim($remark); + if ($reviewRemark === '') { + $reviewRemark = 'approved'; + } + $update = [ + 'status' => self::PLAY_STATUS_SETTLED, + 'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null, + 'review_time' => $now, + 'review_remark' => substr($reviewRemark, 0, 255), + 'update_time' => $now, + ]; + // 优先写主表 + Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update); + // 兼容写 view 场景(若存在且可写) + try { + Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update); + } catch (\Throwable) { + } + + $out = ['ok' => true, 'msg' => __('Approved')]; + if (is_string($balanceAfter)) { + $out['balance_after'] = $balanceAfter; + } + return $out; + } + + /** + * 大奖审核拒绝:仅当 status=待审核 才可操作;拒绝后不派彩,标记为已退回(status=4)。 + * + * @return array{ok: bool, msg: string} + */ + public static function rejectJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark): array + { + if ($playRecordId <= 0) { + return ['ok' => false, 'msg' => __('Parameter error')]; + } + $reason = trim($remark); + if ($reason === '') { + return ['ok' => false, 'msg' => __('Please provide reject reason')]; + } + $row = Db::name('game_play_record')->where('id', $playRecordId)->find(); + if (!is_array($row)) { + $row = Db::name('bet_order')->where('id', $playRecordId)->find(); + } + if (!is_array($row)) { + return ['ok' => false, 'msg' => __('Record not found')]; + } + $status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0; + if ($status !== self::PLAY_STATUS_PENDING_REVIEW) { + return ['ok' => false, 'msg' => __('This record does not require review')]; + } + $now = time(); + $update = [ + 'status' => self::PLAY_STATUS_RETURNED, + 'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null, + 'review_time' => $now, + 'review_remark' => substr($reason, 0, 255), + 'update_time' => $now, + ]; + Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update); + try { + Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update); + } catch (\Throwable) { + } + return ['ok' => true, 'msg' => __('Rejected')]; + } + /** * 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。 */ @@ -176,7 +290,7 @@ final class GameBetSettleService } $pending = Db::name('bet_order') ->where('period_id', $rid) - ->where('status', 1) + ->where('status', self::PLAY_STATUS_PENDING_DRAW) ->count(); if ($pending === 0) { continue; @@ -251,7 +365,7 @@ final class GameBetSettleService /** * @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null */ - private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string + private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark): ?string { $userId = (int) ($bet['user_id'] ?? 0); if ($userId <= 0) { @@ -284,8 +398,8 @@ final class GameBetSettleService 'ref_type' => 'bet_order', 'ref_id' => $betId, 'idempotency_key' => $idem, - 'operator_admin_id' => null, - 'remark' => '压注派彩', + 'operator_admin_id' => $operatorAdminId, + 'remark' => $remark !== '' ? $remark : '压注派彩', 'create_time' => $now, ]); @@ -304,4 +418,35 @@ final class GameBetSettleService return $after; } + + private static function jackpotMaxAmount(): string + { + // 结算属于高频长驻进程逻辑:为避免 GameHotDataRedis::$gcLocal 进程内静态缓存导致阈值更新不生效, + // 这里直接读库拿最新值(本方法在 settleBetsForDraw 中仅调用一次)。 + $row = Db::name('game_config')->where('config_key', self::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find(); + if (!is_array($row)) { + return '0.00'; + } + $raw = $row['config_value'] ?? null; + if ($raw === null || $raw === '') { + return '0.00'; + } + $v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : ''); + if ($v === '' || !is_numeric($v)) { + return '0.00'; + } + $normalized = bcadd($v, '0', 2); + if (bccomp($normalized, '0', 2) <= 0) { + return '0.00'; + } + return $normalized; + } + + private static function shouldRequireJackpotReview(string $winAmount, string $threshold): bool + { + if (bccomp($threshold, '0', 2) <= 0) { + return false; + } + return bccomp($winAmount, $threshold, 2) >= 0; + } } diff --git a/app/common/service/GameHotDataRedis.php b/app/common/service/GameHotDataRedis.php index aac7184..9934941 100644 --- a/app/common/service/GameHotDataRedis.php +++ b/app/common/service/GameHotDataRedis.php @@ -47,30 +47,25 @@ final class GameHotDataRedis if ($configKey === '') { return null; } - if (array_key_exists($configKey, self::$gcLocal)) { - $cachedLocal = self::$gcLocal[$configKey]; - return is_array($cachedLocal) ? $cachedLocal : null; - } + // game_config 为全局配置,多进程/多 worker 间必须强一致; + // 因此不使用进程内本地缓存(gcLocal),避免某个进程读到旧值导致前端回弹/行为冲突。 if (self::enabled()) { $cached = self::redisGet(self::KEY_GC . $configKey); if ($cached !== null && $cached !== '') { $decoded = json_decode($cached, true); if (is_array($decoded)) { - self::$gcLocal[$configKey] = $decoded; return $decoded; } } } $row = Db::name('game_config')->where('config_key', $configKey)->find(); if (!$row) { - self::$gcLocal[$configKey] = null; return null; } if (self::enabled()) { $ttl = self::intConfig('ttl_game_config', 86400); self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE)); } - self::$gcLocal[$configKey] = $row; return $row; } @@ -88,17 +83,24 @@ final class GameHotDataRedis */ public static function gameConfigReplaceFromDb(string $configKey): void { - if ($configKey === '' || !self::enabled()) { + if ($configKey === '') { return; } + // 无论是否启用 Redis 热点缓存,都要刷新进程内缓存,避免同一 worker 读到旧值 $row = Db::name('game_config')->where('config_key', $configKey)->find(); if (!$row) { - self::gameConfigForget($configKey); + self::$gcLocal[$configKey] = null; + if (self::enabled()) { + self::redisDel(self::KEY_GC . $configKey); + } return; } - $ttl = self::intConfig('ttl_game_config', 86400); - self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE)); + self::$gcLocal[$configKey] = $row; + if (self::enabled()) { + $ttl = self::intConfig('ttl_game_config', 86400); + self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE)); + } } /** diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 89cf4f7..70d6262 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -202,7 +202,9 @@ final class GameLiveService GameHotDataCoordinator::afterUserCommitted($uid); } } - GameRecordService::bootstrapPeriodWhenRuntimeEnabled(); + // 异常对局作废后:自动暂停游戏,不自动创建新一期;需管理员手动开启「游戏运行」才会重新开局 + GameRecordService::setAutoCreateEnabled(false); + GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE); self::publishSnapshot(null); Log::info('game live startup marked abnormal and refunded', [ 'record_id' => $recordId, @@ -733,6 +735,7 @@ final class GameLiveService $refundedUserIds = []; try { $now = time(); + $refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []]; Db::startTrans(); try { $refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now); @@ -750,9 +753,9 @@ final class GameLiveService Db::rollback(); return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()]; } - GameRecordService::setLiveRuntimeEnabled(false); + GameRecordService::setAutoCreateEnabled(false); GameHotDataCoordinator::afterGameRecordCommitted($rid); - GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME); + GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE); foreach ($refundedUserIds as $uid) { if ($uid > 0) { GameHotDataCoordinator::afterUserCommitted($uid); @@ -764,6 +767,7 @@ final class GameLiveService 'ok' => true, 'msg' => __('Period voided'), 'record' => self::reloadRecord($rid), + 'refund' => $refund, ]; } finally { GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); @@ -780,13 +784,14 @@ final class GameLiveService } /** - * @return array{user_ids:list,order_count:int,total_amount:string} + * @return array{user_ids:list,order_count:int,total_amount:string,order_ids:list} */ private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array { $userIdSet = []; $orderCount = 0; $totalAmount = '0.00'; + $orderIds = []; $bets = Db::name('bet_order') ->where('period_id', $periodId) ->where('status', 1) @@ -806,6 +811,8 @@ final class GameLiveService 'status' => 3, 'update_time' => $now, ]); + $orderCount++; + $orderIds[] = $betId; continue; } $before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'); @@ -832,7 +839,7 @@ final class GameLiveService UserWalletRecord::create([ 'user_id' => $userId, 'channel_id' => $channelId, - 'biz_type' => 'bet_void', + 'biz_type' => 'void_refund', 'direction' => 1, 'amount' => $total, 'balance_before' => $before, @@ -844,6 +851,7 @@ final class GameLiveService $userIdSet[$userId] = true; $orderCount++; $totalAmount = bcadd($totalAmount, $total, 2); + $orderIds[] = $betId; } $out = []; @@ -855,6 +863,7 @@ final class GameLiveService 'user_ids' => $out, 'order_count' => $orderCount, 'total_amount' => $totalAmount, + 'order_ids' => $orderIds, ]; } diff --git a/app/common/service/GameRecordService.php b/app/common/service/GameRecordService.php index 9d4845b..ed3a184 100644 --- a/app/common/service/GameRecordService.php +++ b/app/common/service/GameRecordService.php @@ -13,9 +13,6 @@ final class GameRecordService public const KEY_MANUAL_CREATE = 'period_manual_create_enabled'; - /** 后台「游戏实时对局」运行开关:0=暂停自动开奖与派彩后自动创建下一期;1=运行 */ - public const KEY_LIVE_RUNTIME = 'game_live_runtime_enabled'; - private const ACTIVE_STATUSES = [0, 1, 2, 3]; public static function getConfigBool(string $key): bool @@ -53,9 +50,6 @@ final class GameRecordService public static function tickAutoCreate(): void { - if (!self::isLiveRuntimeEnabled()) { - return; - } if (!self::getConfigBool(self::KEY_AUTO_CREATE)) { return; } @@ -86,7 +80,8 @@ final class GameRecordService public static function createNextRecordAfterDraw(): ?string { - if (!self::isLiveRuntimeEnabled()) { + // 派彩结束后是否自动开新局:由 period_auto_create_enabled 控制 + if (!self::getConfigBool(self::KEY_AUTO_CREATE)) { return null; } if (self::hasActiveRecord()) { @@ -96,29 +91,16 @@ final class GameRecordService } /** - * 未配置键时视为开启(兼容旧库未跑迁移)。 + * 实时对局页「自动创建下一局」开关(兼容旧命名 runtime)。 */ public static function isLiveRuntimeEnabled(): bool { - $row = GameHotDataRedis::gameConfigRow(self::KEY_LIVE_RUNTIME); - if ($row === null) { - return true; - } - $v = $row['config_value'] ?? ''; - return $v === '1' || $v === 1; + return self::getConfigBool(self::KEY_AUTO_CREATE); } public static function setLiveRuntimeEnabled(bool $enabled): void { - $now = time(); - $v = $enabled ? '1' : '0'; - self::upsertConfig( - self::KEY_LIVE_RUNTIME, - $v, - 'int', - '后台「游戏实时对局」运行开关:0=维护(禁止下注、结束后不自动开新期,当局仍自动开奖并结算);1=运行', - $now - ); + self::setAutoCreateEnabled($enabled); } /** @@ -126,6 +108,9 @@ final class GameRecordService */ public static function bootstrapPeriodWhenRuntimeEnabled(): void { + if (!self::getConfigBool(self::KEY_AUTO_CREATE)) { + return; + } if (self::hasActiveRecord()) { return; } @@ -135,6 +120,13 @@ final class GameRecordService } } + public static function setAutoCreateEnabled(bool $enabled): void + { + $now = time(); + $v = $enabled ? '1' : '0'; + self::upsertConfig(self::KEY_AUTO_CREATE, $v, 'int', '是否允许自动创建下一局(全局仅一局)', $now); + } + private static function createNextRecordRow(): string { $periodNo = self::generatePeriodNo(); diff --git a/app/common/validate/GamePeriod.php b/app/common/validate/GamePeriod.php index 3d3732f..12a380f 100644 --- a/app/common/validate/GamePeriod.php +++ b/app/common/validate/GamePeriod.php @@ -15,12 +15,11 @@ class GamePeriod extends Validate 'period_start_at' => 'integer', 'status' => 'require|in:0,1,2,3,4,5', 'draw_mode' => 'in:0,1', - 'preset_number' => 'between:1,36', 'result_number' => 'between:1,36', ]; protected $scene = [ - 'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'], - 'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'], + 'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'result_number'], + 'edit' => ['period_start_at', 'status', 'draw_mode', 'result_number'], ]; } diff --git a/app/common/validate/GameRecord.php b/app/common/validate/GameRecord.php index 350ed7d..a49dd7f 100644 --- a/app/common/validate/GameRecord.php +++ b/app/common/validate/GameRecord.php @@ -15,12 +15,11 @@ class GameRecord extends Validate 'period_start_at' => 'integer', 'status' => 'require|in:0,1,2,3,4,5', 'draw_mode' => 'in:0,1', - 'preset_number' => 'between:1,36', 'result_number' => 'between:1,36', ]; protected $scene = [ - 'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'], - 'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'], + 'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'result_number'], + 'edit' => ['period_start_at', 'status', 'draw_mode', 'result_number'], ]; } diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index 35bbf9e..1a43557 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -35,12 +35,12 @@ export default { pick_numbers: 'Pick numbers', total_amount: 'Total bet amount', streak_at_bet: 'Streak at bet', - runtime_switch: 'Game runtime', + runtime_switch: 'Auto-create next round', countdown_maintenance: 'Maintenance', runtime_draining_banner: 'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.', - runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.', - runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.', + runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn on auto-create to resume; a new round is created when idle.', + runtime_off_tip: 'When enabling auto-create with no active round, a new period is created immediately.', void_btn: 'Void round', void_dialog_title: 'Void current round', void_reason_label: 'Reason', diff --git a/web/src/lang/backend/en/game/playRecord.ts b/web/src/lang/backend/en/game/playRecord.ts index 17726ac..d22cd64 100644 --- a/web/src/lang/backend/en/game/playRecord.ts +++ b/web/src/lang/backend/en/game/playRecord.ts @@ -17,6 +17,18 @@ export default { 'status 1': 'Pending draw', 'status 2': 'Settled', 'status 3': 'Refunded', + 'status 4': 'Returned', + 'status 5': 'Pending review', + review_title: 'Win review', + review_open: 'Review', + review_approve: 'Approve', + review_reject: 'Reject', + review_remark: 'Reject remark', + review_remark_placeholder: 'Please enter reject reason', + review_remark_required: 'Remark is required when rejecting', + review_approve_confirm: 'Confirm approve and pay out?', + review_approve_success: 'Approved and paid out', + review_reject_success: 'Rejected', idempotency_key: 'Idempotency key', create_time: 'Created', update_time: 'Updated', diff --git a/web/src/lang/backend/en/game/record.ts b/web/src/lang/backend/en/game/record.ts index c90077f..f860ff7 100644 --- a/web/src/lang/backend/en/game/record.ts +++ b/web/src/lang/backend/en/game/record.ts @@ -10,10 +10,10 @@ export default { 'status 3': 'Paying', 'status 4': 'Ended', 'status 5': 'Void', + 'status 6': 'Abnormal', draw_mode: 'Draw mode', 'draw_mode 0': 'Auto AI', 'draw_mode 1': 'Manual preset', - preset_number: 'Preset number', result_number: 'Result number', platform_profit_amount: 'Round P/L (platform)', winner_user_count: 'Winners', @@ -26,13 +26,23 @@ 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', + view_play_records: 'View play records', + play_record_dialog_title: 'Play (bet) records', + play_record_col_id: 'ID', + play_record_col_user: 'Username', + play_record_col_channel: 'Channel', + play_record_col_pick_numbers: 'Picks', + play_record_col_total_amount: 'Play amount', + play_record_col_is_auto: 'Auto', + play_record_col_win_amount: 'Payout', + play_record_col_jackpot_extra_amount: 'Jackpot extra', + play_record_col_status: 'Status', + play_record_col_create_time: 'Created', + play_record_is_auto_0: 'Manual', + play_record_is_auto_1: 'Auto bet', + play_record_status_1: 'Pending draw', + play_record_status_2: 'Settled', + play_record_status_3: 'Refunded', + play_record_status_4: 'Returned', + load_play_record_failed: 'Failed to load play records', } diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 16b06c9..eadeecb 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -35,11 +35,11 @@ export default { pick_numbers: '下注号码', total_amount: '下注总额', streak_at_bet: '下注时连胜', - runtime_switch: '游戏运行', + runtime_switch: '自动创建下一局', countdown_maintenance: '维护中', runtime_draining_banner: '已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。', - runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「游戏运行」恢复;若无进行中的局将自动创建新一期。', - runtime_off_tip: '开启「游戏运行」后,若无进行中的局将立即创建新一期。', + runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「自动创建下一局」恢复;若无进行中的局将自动创建新一期。', + runtime_off_tip: '开启「自动创建下一局」后,若无进行中的局将立即创建新一期。', void_btn: '作废本局', void_dialog_title: '作废本局', void_reason_label: '作废原因', diff --git a/web/src/lang/backend/zh-cn/game/playRecord.ts b/web/src/lang/backend/zh-cn/game/playRecord.ts index 47cf854..494100d 100644 --- a/web/src/lang/backend/zh-cn/game/playRecord.ts +++ b/web/src/lang/backend/zh-cn/game/playRecord.ts @@ -17,6 +17,18 @@ export default { 'status 1': '待开奖', 'status 2': '已结算', 'status 3': '已退款', + 'status 4': '已退回', + 'status 5': '待审核', + review_title: '中奖审核', + review_open: '审核', + review_approve: '通过', + review_reject: '拒绝', + review_remark: '拒绝备注', + review_remark_placeholder: '请输入拒绝原因', + review_remark_required: '拒绝时必须填写备注', + review_approve_confirm: '确认审核通过并派彩提现吗?', + review_approve_success: '审核通过,已派彩', + review_reject_success: '已拒绝派彩', idempotency_key: '幂等键', create_time: '创建时间', update_time: '更新时间', diff --git a/web/src/lang/backend/zh-cn/game/record.ts b/web/src/lang/backend/zh-cn/game/record.ts index f20fb30..b962946 100644 --- a/web/src/lang/backend/zh-cn/game/record.ts +++ b/web/src/lang/backend/zh-cn/game/record.ts @@ -10,10 +10,10 @@ export default { 'status 3': '派彩中', 'status 4': '已结束', 'status 5': '已作废', + 'status 6': '异常', draw_mode: '开奖方式', 'draw_mode 0': '自动AI', 'draw_mode 1': '手动预设', - preset_number: '预设号码', result_number: '开奖号码', platform_profit_amount: '对局盈亏(平台)', winner_user_count: '中奖人数', @@ -26,13 +26,23 @@ 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: '加载异常对局失败', + view_play_records: '查看游玩记录', + play_record_dialog_title: '游玩(压注)记录', + play_record_col_id: 'ID', + play_record_col_user: '用户名', + play_record_col_channel: '渠道', + play_record_col_pick_numbers: '选号', + play_record_col_total_amount: '游玩金额', + play_record_col_is_auto: '托管', + play_record_col_win_amount: '派彩', + play_record_col_jackpot_extra_amount: 'Jackpot', + play_record_col_status: '状态', + play_record_col_create_time: '创建时间', + play_record_is_auto_0: '手动', + play_record_is_auto_1: '托管', + play_record_status_1: '待开奖', + play_record_status_2: '已结算', + play_record_status_3: '已退款', + play_record_status_4: '已退回', + load_play_record_failed: '加载游玩记录失败', } diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 66bb1a8..796f767 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -256,6 +256,7 @@ const calcLoading = ref(false) const drawLoading = ref(false) const pendingSwitchNumber = ref(null) const runtimeSwitchLoading = ref(false) +const pendingRuntimeTarget = ref(null) const voidDialogVisible = ref(false) const voidReason = ref('') const voidSubmitting = ref(false) @@ -534,6 +535,23 @@ const canVoidPeriod = computed(() => { /** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */ const asideOperationLocked = computed(() => snapshot.maintenance_ui === true) +function toBool(v: unknown): boolean | null { + if (typeof v === 'boolean') { + return v + } + if (typeof v === 'number' && Number.isFinite(v)) { + if (v === 1) return true + if (v === 0) return false + return null + } + if (typeof v === 'string') { + const s = v.trim().toLowerCase() + if (s === '1' || s === 'true') return true + if (s === '0' || s === 'false') return false + } + return null +} + function mergeLiveSnapshot(data: anyObj): void { if (data.record !== undefined) { snapshot.record = data.record @@ -553,11 +571,20 @@ function mergeLiveSnapshot(data: anyObj): void { snapshot.can_calculate = !!data.can_calculate snapshot.can_draw = !!data.can_draw snapshot.can_schedule_draw = !!(data.can_schedule_draw || data.can_draw) - if (typeof data.runtime_enabled === 'boolean') { - snapshot.runtime_enabled = data.runtime_enabled + const runtimeEnabled = toBool(data.runtime_enabled) + if (pendingRuntimeTarget.value !== null) { + // 开关请求进行中:避免 ws 的旧快照覆盖用户刚切换的状态,导致 UI 回弹 + snapshot.runtime_enabled = pendingRuntimeTarget.value + } else if (runtimeEnabled !== null) { + snapshot.runtime_enabled = runtimeEnabled } if (typeof data.maintenance_ui === 'boolean') { - snapshot.maintenance_ui = data.maintenance_ui + if (pendingRuntimeTarget.value !== null) { + // 开启时不应显示维护中;关闭后的维护中应由服务端在对局完全结束后下发 + snapshot.maintenance_ui = pendingRuntimeTarget.value ? false : snapshot.maintenance_ui + } else { + snapshot.maintenance_ui = data.maintenance_ui + } } syncServerClock(data.server_time) } @@ -615,11 +642,18 @@ async function loadSnapshot() { } async function onRuntimeSwitch(val: boolean | string | number): void { - const on = val === true || val === 'true' || val === 1 + const on = toBool(val) === true // 防止某些场景下 model-value 变化触发重复 change 事件,造成 runtime 接口循环调用 - if (on === !!snapshot.runtime_enabled) { + const current = toBool(snapshot.runtime_enabled) === true + if (on === current) { return } + // el-switch 为受控组件(model-value 来自 snapshot),接口返回前先乐观更新,避免点击后立刻回弹 + snapshot.runtime_enabled = on + if (on) { + snapshot.maintenance_ui = false + } + pendingRuntimeTarget.value = on runtimeSwitchLoading.value = true try { const res = await createAxios({ @@ -637,6 +671,7 @@ async function onRuntimeSwitch(val: boolean | string | number): void { await loadSnapshot() } finally { runtimeSwitchLoading.value = false + pendingRuntimeTarget.value = null } } @@ -667,6 +702,14 @@ async function submitVoidPeriod(): Promise { }) if (res.code === 1 && res.data) { mergeLiveSnapshot(res.data as anyObj) + const refund = (res.data as anyObj).void_refund + if (refund && typeof refund === 'object') { + const orderCount = Number(refund.order_count ?? 0) + const totalAmount = String(refund.total_amount ?? '0.00') + if (orderCount > 0) { + ElMessage.success(`已退款 ${orderCount} 笔注单,合计 ${totalAmount}`) + } + } } voidDialogVisible.value = false } finally { diff --git a/web/src/views/backend/game/playRecord/index.vue b/web/src/views/backend/game/playRecord/index.vue index 0263f0a..a846795 100644 --- a/web/src/views/backend/game/playRecord/index.vue +++ b/web/src/views/backend/game/playRecord/index.vue @@ -8,16 +8,49 @@ >
+ + +
+ + {{ reviewDialog.row?.id ?? '-' }} + {{ reviewDialog.row?.gameRecord?.period_no ?? '-' }} + {{ reviewDialog.row?.user?.username ?? '-' }} + {{ reviewDialog.row?.channel?.name ?? '-' }} + {{ formatPickNumbers({}, {}, reviewDialog.row?.pick_numbers) }} + {{ formatAmount({}, {}, reviewDialog.row?.total_amount) }} + {{ formatAmount({}, {}, reviewDialog.row?.win_amount) }} + {{ reviewDialog.row?.status ? t(`game.playRecord.status ${reviewDialog.row.status}`) : '-' }} + + + + + + +
+ +