diff --git a/app/admin/controller/game/Live.php b/app/admin/controller/game/Live.php index 6ec7553..f829376 100644 --- a/app/admin/controller/game/Live.php +++ b/app/admin/controller/game/Live.php @@ -55,4 +55,44 @@ class Live extends Backend 'event' => 'bet-updated', ]); } + + public function calculate(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $recordIdRaw = $request->post('record_id'); + $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; + $manualRaw = $request->post('manual_number'); + $manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null; + $res = GameLiveService::calculateResult($recordId, $manualNumber); + if (!($res['ok'] ?? false)) { + return $this->error((string) ($res['msg'] ?? '计算失败')); + } + return $this->success((string) $res['msg'], $res); + } + + public function draw(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + if ($request->method() !== 'POST') { + return $this->error(__('Parameter error')); + } + $recordIdRaw = $request->post('record_id'); + $recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null; + $manualRaw = $request->post('manual_number'); + $manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null; + $res = GameLiveService::drawResult($recordId, $manualNumber); + if (!($res['ok'] ?? false)) { + return $this->error((string) ($res['msg'] ?? '开奖失败')); + } + return $this->success((string) $res['msg'], $res); + } } diff --git a/app/admin/controller/game/Record.php b/app/admin/controller/game/Record.php index 00e8c37..b42096c 100644 --- a/app/admin/controller/game/Record.php +++ b/app/admin/controller/game/Record.php @@ -3,9 +3,7 @@ namespace app\admin\controller\game; use app\common\controller\Backend; -use app\common\service\GameRecordService; use support\Response; -use Throwable; use Webman\Http\Request as WebmanRequest; /** @@ -33,85 +31,39 @@ class Record extends Backend return null; } - public function recordSettings(WebmanRequest $request): Response + public function add(WebmanRequest $request): Response { $response = $this->initializeBackend($request); if ($response !== null) { return $response; } - if ($request->method() === 'GET') { - return $this->success('', GameRecordService::getRecordSettings()); + return $this->error('游戏对局记录由系统自动生成,禁止后台手工新增'); + } + + public function edit(WebmanRequest $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; } if ($request->method() === 'POST') { - $data = $request->post(); - if (!is_array($data)) { - return $this->error(__('Parameter %s can not be empty', [''])); - } - try { - GameRecordService::saveRecordSettings($data); - } catch (Throwable $e) { - return $this->error($e->getMessage()); - } - return $this->success(__('Saved successfully')); + return $this->error('游戏对局记录不可编辑'); } - return $this->error(__('Parameter error')); + $pk = $this->model->getPk(); + $id = $request->get($pk); + $row = $this->model->find($id); + if (!$row) { + return $this->error(__('Record not found')); + } + return $this->success('', ['row' => $row]); } - public function createNextManual(WebmanRequest $request): Response + public function del(WebmanRequest $request): Response { $response = $this->initializeBackend($request); if ($response !== null) { return $response; } - if ($request->method() !== 'POST') { - return $this->error(__('Parameter error')); - } - $result = GameRecordService::createNextRecordForManual(); - if ($result['ok']) { - return $this->success($result['msg'], ['period_no' => $result['period_no'] ?? '']); - } - return $this->error($result['msg']); - } - - protected function _add(): Response - { - if ($this->request && $this->request->method() === 'POST') { - $data = $this->request->post(); - if (!is_array($data)) { - return $this->error(__('Parameter %s can not be empty', [''])); - } - $data = $this->applyInputFilter($data); - $data = $this->excludeFields($data); - if (!isset($data['period_start_at']) || $data['period_start_at'] === '' || $data['period_start_at'] === null) { - $data['period_start_at'] = time(); - } - if ($this->dataLimit && $this->dataLimitFieldAutoFill) { - $data[$this->dataLimitField] = $this->auth->id; - } - $result = false; - $this->model->startTrans(); - try { - if ($this->modelValidate) { - $validate = str_replace("\\model\\", "\\validate\\", get_class($this->model)); - if (class_exists($validate)) { - $validate = new $validate(); - if ($this->modelSceneValidate) { - $validate->scene('add'); - } - $validate->check($data); - } - } - $result = $this->model->save($data); - $this->model->commit(); - } catch (Throwable $e) { - $this->model->rollback(); - return $this->error($e->getMessage()); - } - if ($result !== false) { - return $this->success(__('Added successfully')); - } - return $this->error(__('No rows were added')); - } - return $this->error(__('Parameter error')); + return $this->error('游戏对局记录不可删除'); } } diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 31436d2..4abcca4 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -13,6 +13,9 @@ final class GameLiveService private const BASE_ODDS = 33; private const CHANNEL = 'game-live'; private const EVENT = 'bet-updated'; + private const KEY_PERIOD_SECONDS = 'period_seconds'; + private const KEY_BET_SECONDS = 'bet_seconds'; + private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count'; public static function buildSnapshot(?int $recordId = null): array { @@ -23,10 +26,25 @@ final class GameLiveService 'bets' => [], 'candidate_numbers' => [], 'ai_default_number' => null, + 'calc_number' => null, + 'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30), + 'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20), + 'pick_max_number_count' => self::getPickMaxNumberCount(), + 'remaining_seconds' => 0, + 'bet_remaining_seconds' => 0, + 'can_calculate' => false, + 'can_draw' => false, 'server_time' => time(), ]; } + $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); + $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); + $pickMax = self::getPickMaxNumberCount(); + $elapsed = max(0, time() - (int) $record['period_start_at']); + $remaining = max(0, $periodSeconds - $elapsed); + $betRemaining = max(0, $betSeconds - $elapsed); + $bets = Db::name('bet_order') ->where('period_id', (int) $record['id']) ->order('id', 'desc') @@ -37,15 +55,19 @@ final class GameLiveService $candidates = []; $bestNumber = null; $bestLoss = null; - for ($n = 1; $n <= 36; $n++) { - $loss = self::estimateLossForNumber($bets, $n); - $candidates[] = [ - 'number' => $n, - 'estimated_loss' => $loss, - ]; - if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) { - $bestLoss = $loss; - $bestNumber = $n; + $status = (int) $record['status']; + $canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1); + if ($canCalculate) { + for ($n = 1; $n <= $pickMax; $n++) { + $loss = self::estimateLossForNumber($bets, $n); + $candidates[] = [ + 'number' => $n, + 'estimated_loss' => $loss, + ]; + if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) { + $bestLoss = $loss; + $bestNumber = $n; + } } } @@ -65,10 +87,134 @@ final class GameLiveService }, $bets), 'candidate_numbers' => $candidates, 'ai_default_number' => $bestNumber, + 'calc_number' => $bestNumber, + 'period_seconds' => $periodSeconds, + 'bet_seconds' => $betSeconds, + 'pick_max_number_count' => $pickMax, + 'remaining_seconds' => $remaining, + 'bet_remaining_seconds' => $betRemaining, + 'can_calculate' => $canCalculate, + 'can_draw' => $canCalculate, 'server_time' => time(), ]; } + public static function calculateResult(?int $recordId, ?int $manualNumber = null): array + { + $record = self::resolveRecord($recordId); + if (!$record) { + return ['ok' => false, 'msg' => '未找到进行中的对局']; + } + if (!in_array((int) $record['status'], [0, 1], true)) { + return ['ok' => false, 'msg' => '当前对局状态不可计算']; + } + $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); + $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); + $elapsed = max(0, time() - (int) $record['period_start_at']); + if ($elapsed < $betSeconds) { + return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算']; + } + if ((int) $record['status'] === 0) { + Db::name('game_record')->where('id', (int) $record['id'])->update([ + 'status' => 1, + 'update_time' => time(), + ]); + $record['status'] = 1; + } + + $pickMax = self::getPickMaxNumberCount(); + if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > $pickMax)) { + return ['ok' => false, 'msg' => '手动开奖号码超出允许范围']; + } + + $bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray(); + $candidates = []; + $bestNumber = null; + $bestLoss = null; + for ($n = 1; $n <= $pickMax; $n++) { + $loss = self::estimateLossForNumber($bets, $n); + $candidates[] = ['number' => $n, 'estimated_loss' => $loss]; + if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) { + $bestLoss = $loss; + $bestNumber = $n; + } + } + + $finalNumber = $manualNumber ?? $bestNumber; + $finalLoss = '0.0000'; + if ($finalNumber !== null) { + $finalLoss = self::estimateLossForNumber($bets, $finalNumber); + } + + return [ + 'ok' => true, + 'msg' => '计算完成', + 'record' => $record, + 'period_seconds' => $periodSeconds, + 'bet_seconds' => $betSeconds, + 'pick_max_number_count' => $pickMax, + 'candidate_numbers' => $candidates, + 'ai_default_number' => $bestNumber, + 'final_number' => $finalNumber, + 'final_estimated_loss' => $finalLoss, + ]; + } + + public static function drawResult(?int $recordId, ?int $manualNumber = null): array + { + $calc = self::calculateResult($recordId, $manualNumber); + if (!($calc['ok'] ?? false)) { + return $calc; + } + $record = $calc['record']; + $finalNumber = (int) $calc['final_number']; + $now = time(); + Db::startTrans(); + try { + Db::name('game_record')->where('id', (int) $record['id'])->update([ + 'status' => 4, + 'result_number' => $finalNumber, + 'draw_mode' => $manualNumber === null ? 0 : 1, + 'update_time' => $now, + ]); + GameRecordService::createNextRecordAfterDraw(); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + return ['ok' => false, 'msg' => $e->getMessage()]; + } + + self::publishSnapshot(null); + return [ + 'ok' => true, + 'msg' => '开奖完成', + 'result_number' => $finalNumber, + 'estimated_loss' => $calc['final_estimated_loss'], + ]; + } + + public static function tickAutoDraw(): void + { + $record = self::resolveRecord(null); + if (!$record || !in_array((int) $record['status'], [0, 1], true)) { + return; + } + $betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20); + $periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30); + $elapsed = max(0, time() - (int) $record['period_start_at']); + if ($elapsed >= $betSeconds && (int) $record['status'] === 0) { + Db::name('game_record')->where('id', (int) $record['id'])->update([ + 'status' => 1, + 'update_time' => time(), + ]); + $record['status'] = 1; + } + if ($elapsed < $periodSeconds) { + return; + } + self::drawResult((int) $record['id'], null); + } + public static function publishSnapshot(?int $recordId = null): void { try { @@ -94,6 +240,34 @@ final class GameLiveService return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find(); } + private static function getConfigInt(string $key, int $default): int + { + $row = Db::name('game_config')->where('config_key', $key)->find(); + if (!$row) { + return $default; + } + $v = $row['config_value'] ?? null; + if ($v === null || $v === '') { + return $default; + } + if (!is_numeric((string) $v)) { + return $default; + } + return (int) $v; + } + + private static function getPickMaxNumberCount(): int + { + $max = self::getConfigInt(self::KEY_PICK_MAX_NUMBER_COUNT, 36); + if ($max < 1) { + return 1; + } + if ($max > 36) { + return 36; + } + return $max; + } + private static function estimateLossForNumber(array $bets, int $number): string { $payout = '0.0000'; diff --git a/app/common/service/GameRecordService.php b/app/common/service/GameRecordService.php index 482a9ab..27703a8 100644 --- a/app/common/service/GameRecordService.php +++ b/app/common/service/GameRecordService.php @@ -78,6 +78,14 @@ final class GameRecordService } } + public static function createNextRecordAfterDraw(): ?string + { + if (self::hasActiveRecord()) { + return null; + } + return self::createNextRecordRow(); + } + private static function createNextRecordRow(): string { $periodNo = self::generatePeriodNo(); diff --git a/app/process/GameLiveTicker.php b/app/process/GameLiveTicker.php new file mode 100644 index 0000000..4c5c535 --- /dev/null +++ b/app/process/GameLiveTicker.php @@ -0,0 +1,20 @@ + 1, 'reloadable' => false, ], + 'gameLiveTicker' => [ + 'handler' => app\process\GameLiveTicker::class, + 'count' => 1, + 'reloadable' => false, + ], // File update detection and automatic reload 'monitor' => [ diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts index 0f6f383..d0e04c3 100644 --- a/web/src/lang/backend/en/game/live.ts +++ b/web/src/lang/backend/en/game/live.ts @@ -2,6 +2,15 @@ export default { tip: 'Listen to pushed bet stream in real time and show the AI default number (minimum estimated platform loss).', current_record: 'Current round', ai_default_number: 'AI default number', + countdown: 'Countdown', + bet_countdown: 'Bet left', + draw_countdown: 'Draw left', + btn_calc: 'Calculate PnL', + btn_draw: 'Draw now', + calc_result_number: 'Calculated number', + calc_estimated_loss: 'Estimated payout', + push_connected: 'Push connected, realtime updates running', + push_disconnected: 'Push disconnected, please check service status', candidate_title: 'Candidate payout estimates', number: 'Number', estimated_loss: 'Estimated payout', diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts index 68d414c..6fbeb74 100644 --- a/web/src/lang/backend/zh-cn/game/live.ts +++ b/web/src/lang/backend/zh-cn/game/live.ts @@ -2,6 +2,15 @@ export default { tip: '实时监听页面推送的压注记录,并展示AI默认最优开奖号码(平台预估亏损最少)', current_record: '当前对局', ai_default_number: 'AI默认开奖号码', + countdown: '倒计时', + bet_countdown: '下注剩余', + draw_countdown: '开奖剩余', + btn_calc: '计算法盈亏', + btn_draw: '开奖', + calc_result_number: '计算开奖号码', + calc_estimated_loss: '计算预估赔付', + push_connected: '推送服务已连接,页面数据实时更新中', + push_disconnected: '推送服务连接中断,请检查服务是否启动', candidate_title: '候选号码赔付预估', number: '号码', estimated_loss: '预估赔付', diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index 3c99903..e5a9412 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -1,14 +1,29 @@ diff --git a/web/src/views/backend/game/record/index.vue b/web/src/views/backend/game/record/index.vue index 769ee70..08d957e 100644 --- a/web/src/views/backend/game/record/index.vue +++ b/web/src/views/backend/game/record/index.vue @@ -2,31 +2,8 @@
- - -
-
- {{ t('game.record.auto_create_label') }} - - {{ t('game.record.auto_create_tip') }} -
-
- {{ t('game.record.manual_create_label') }} - - {{ t('game.record.manual_create_tip') }} -
-
- - {{ t('game.record.btn_create_next') }} - -
-
-
- @@ -37,16 +14,14 @@ - - + diff --git a/web/src/views/backend/game/record/popupForm.vue b/web/src/views/backend/game/record/popupForm.vue index 436589c..a27b1bb 100644 --- a/web/src/views/backend/game/record/popupForm.vue +++ b/web/src/views/backend/game/record/popupForm.vue @@ -7,7 +7,7 @@
- +
{{ t('Cancel') }} - - {{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }} -
diff --git a/web/vite.config.ts b/web/vite.config.ts index a881451..2082446 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -32,6 +32,7 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => { '/api': { target: 'http://localhost:8787', changeOrigin: true }, '/admin': { target: 'http://localhost:8787', changeOrigin: true }, '/install': { target: 'http://localhost:8787', changeOrigin: true }, + '/plugin': { target: 'http://localhost:8787', changeOrigin: true }, }, }, build: {