[游戏管理]游戏实时对局

This commit is contained in:
2026-04-16 15:10:12 +08:00
parent 15fdd3ba57
commit c7149e7058
29 changed files with 1158 additions and 157 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\service\GameLiveService;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏实时对局
*/
class Live extends Backend
{
protected ?object $model = null;
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
protected function _index(): Response
{
$recordIdRaw = $this->request ? $this->request->get('record_id') : null;
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
return $this->success('', GameLiveService::buildSnapshot($recordId));
}
public function snapshot(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$recordIdRaw = $request->get('record_id');
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
return $this->success('', GameLiveService::buildSnapshot($recordId));
}
public function pushConfig(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$ws = (string) config('plugin.webman.push.app.websocket');
$ws = str_replace('websocket://', 'ws://', $ws);
$ws = str_replace('0.0.0.0', '127.0.0.1', $ws);
return $this->success('', [
'url' => $ws,
'app_key' => (string) config('plugin.webman.push.app.app_key'),
'channel' => 'game-live',
'event' => 'bet-updated',
]);
}
}

View File

@@ -3,7 +3,7 @@
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\service\GamePeriodService;
use app\common\service\GameRecordService;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
@@ -29,7 +29,7 @@ class Period extends Backend
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GamePeriod();
$this->model = new \app\common\model\GameRecord();
return null;
}
@@ -44,7 +44,7 @@ class Period extends Backend
}
$method = $request->method();
if ($method === 'GET') {
return $this->success('', GamePeriodService::getPeriodSettings());
return $this->success('', GameRecordService::getRecordSettings());
}
if ($method === 'POST') {
$data = $request->post();
@@ -52,7 +52,7 @@ class Period extends Backend
return $this->error(__('Parameter %s can not be empty', ['']));
}
try {
GamePeriodService::savePeriodSettings($data);
GameRecordService::saveRecordSettings($data);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
@@ -73,7 +73,7 @@ class Period extends Backend
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$result = GamePeriodService::createNextPeriodForManual();
$result = GameRecordService::createNextRecordForManual();
if ($result['ok']) {
return $this->success($result['msg'], ['period_no' => $result['period_no'] ?? '']);
}

View File

@@ -0,0 +1,117 @@
<?php
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;
/**
* 游戏对局记录
*/
class Record extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'period_no'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameRecord();
return null;
}
public function recordSettings(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() === 'GET') {
return $this->success('', GameRecordService::getRecordSettings());
}
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(__('Parameter error'));
}
public function createNextManual(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'));
}
}

View File

@@ -22,7 +22,7 @@ class BetOrder extends Backend
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'gamePeriod'];
protected array $withJoinTable = ['user', 'channel', 'gameRecord'];
protected function initController(WebmanRequest $request): ?Response
{
@@ -89,7 +89,7 @@ class BetOrder extends Backend
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'gamePeriod' => ['period_no', 'status'],
'gameRecord' => ['period_no', 'status'],
])
->alias($alias)
->where($where)

View File

@@ -2,7 +2,9 @@
namespace app\common\model;
use app\common\service\GameLiveService;
use support\think\Model;
use Throwable;
class BetOrder extends Model
{
@@ -34,9 +36,18 @@ class BetOrder extends Model
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function gamePeriod(): \think\model\relation\BelongsTo
protected static function onAfterInsert($model): void
{
return $this->belongsTo(GamePeriod::class, 'period_id', 'id');
try {
$periodId = isset($model['period_id']) ? (int) $model['period_id'] : null;
GameLiveService::publishSnapshot($periodId);
} catch (Throwable) {
}
}
public function gameRecord(): \think\model\relation\BelongsTo
{
return $this->belongsTo(GameRecord::class, 'period_id', 'id');
}
}

View File

@@ -6,7 +6,7 @@ use support\think\Model;
class GamePeriod extends Model
{
protected $name = 'game_period';
protected $name = 'game_record';
protected $autoWriteTimestamp = true;

View File

@@ -0,0 +1,39 @@
<?php
namespace app\common\model;
use support\think\Model;
class GameRecord extends Model
{
protected $name = 'game_record';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
];
public function setPeriodStartAtAttr($value, $data = [])
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
$t = strtotime($value);
if ($t !== false) {
return $t;
}
}
return 0;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
final class GameLiveService
{
private const BASE_ODDS = 33;
private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated';
public static function buildSnapshot(?int $recordId = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return [
'record' => null,
'bets' => [],
'candidate_numbers' => [],
'ai_default_number' => null,
'server_time' => time(),
];
}
$bets = Db::name('bet_order')
->where('period_id', (int) $record['id'])
->order('id', 'desc')
->limit(200)
->select()
->toArray();
$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;
}
}
return [
'record' => $record,
'bets' => array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'user_id' => (int) $row['user_id'],
'period_no' => (string) $row['period_no'],
'pick_numbers' => $row['pick_numbers'],
'unit_amount' => (string) $row['unit_amount'],
'total_amount' => (string) $row['total_amount'],
'streak_at_bet' => (int) $row['streak_at_bet'],
'create_time' => (int) $row['create_time'],
];
}, $bets),
'candidate_numbers' => $candidates,
'ai_default_number' => $bestNumber,
'server_time' => time(),
];
}
public static function publishSnapshot(?int $recordId = null): void
{
try {
$payload = self::buildSnapshot($recordId);
$api = new Api(
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
(string) config('plugin.webman.push.app.app_key'),
(string) config('plugin.webman.push.app.app_secret')
);
$api->trigger(self::CHANNEL, self::EVENT, $payload);
} catch (Throwable) {
}
}
private static function resolveRecord(?int $recordId): ?array
{
if ($recordId !== null && $recordId > 0) {
$row = Db::name('game_record')->where('id', $recordId)->find();
if ($row) {
return $row;
}
}
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
}
private static function estimateLossForNumber(array $bets, int $number): string
{
$payout = '0.0000';
foreach ($bets as $bet) {
$pickNumbers = $bet['pick_numbers'];
if (is_string($pickNumbers)) {
$decoded = json_decode($pickNumbers, true);
$pickNumbers = is_array($decoded) ? $decoded : [];
}
if (!is_array($pickNumbers)) {
$pickNumbers = [];
}
if (!in_array($number, array_map('intval', $pickNumbers), true)) {
continue;
}
$unit = (string) ($bet['unit_amount'] ?? '0');
$streak = (int) ($bet['streak_at_bet'] ?? 0);
$odds = (string) (($streak + 1) * self::BASE_ODDS);
$orderPayout = bcmul($unit, $odds, 4);
$payout = bcadd($payout, $orderPayout, 4);
}
return $payout;
}
}

View File

@@ -4,145 +4,41 @@ declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
/**
* 全局期号:创建与 game_config 开关(自动新建下一期)
* 兼容层:保留旧类名,内部转发到 GameRecordService
*/
final class GamePeriodService
{
public const KEY_AUTO_CREATE = 'period_auto_create_enabled';
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
/** 进行中状态:未结束则不可再开新期 */
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public const KEY_AUTO_CREATE = GameRecordService::KEY_AUTO_CREATE;
public const KEY_MANUAL_CREATE = GameRecordService::KEY_MANUAL_CREATE;
public static function getConfigBool(string $key): bool
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return false;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
return GameRecordService::getConfigBool($key);
}
/**
* @return array{period_auto_create_enabled: int, period_manual_create_enabled: int}
*/
public static function getPeriodSettings(): array
{
return [
'period_auto_create_enabled' => self::getConfigBool(self::KEY_AUTO_CREATE) ? 1 : 0,
'period_manual_create_enabled' => self::getConfigBool(self::KEY_MANUAL_CREATE) ? 1 : 0,
];
return GameRecordService::getRecordSettings();
}
/**
* @param array{period_auto_create_enabled?: int|string, period_manual_create_enabled?: int|string} $data
*/
public static function savePeriodSettings(array $data): void
{
$now = time();
$auto = self::truthyConfigInput($data['period_auto_create_enabled'] ?? null) ? '1' : '0';
$manual = self::truthyConfigInput($data['period_manual_create_enabled'] ?? null) ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $auto, 'int', '是否允许定时任务自动创建下一期(全局仅一局)', $now);
self::upsertConfig(self::KEY_MANUAL_CREATE, $manual, 'int', '是否允许后台手动创建下一期', $now);
GameRecordService::saveRecordSettings($data);
}
public static function hasActivePeriod(): bool
{
$count = Db::name('game_period')->whereIn('status', self::ACTIVE_STATUSES)->count();
return $count > 0;
return GameRecordService::hasActiveRecord();
}
/**
* 自动开奖任务调用:开启自动创建且无进行中期号时插入新期
*/
public static function tickAutoCreate(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActivePeriod()) {
return;
}
try {
self::createNextPeriodRow();
} catch (Throwable) {
// 并发下可能重复,忽略
}
GameRecordService::tickAutoCreate();
}
/**
* @return array{ok: bool, msg: string, period_no?: string}
*/
public static function createNextPeriodForManual(): array
{
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
return ['ok' => false, 'msg' => '未开启「手动创建下一期」开关'];
}
if (self::hasActivePeriod()) {
return ['ok' => false, 'msg' => '存在未结束期号,无法新建'];
}
try {
$periodNo = self::createNextPeriodRow();
return ['ok' => true, 'msg' => '已创建新期', 'period_no' => $periodNo];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
/**
* @throws Throwable
*/
private static function createNextPeriodRow(): string
{
$periodNo = self::generatePeriodNo();
$now = time();
Db::name('game_period')->insert([
'period_no' => $periodNo,
'period_start_at' => $now,
'status' => 0,
'draw_mode' => 0,
'void_reason' => '',
'create_time' => $now,
'update_time' => $now,
]);
return $periodNo;
}
private static function generatePeriodNo(): string
{
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}
private static function truthyConfigInput(mixed $v): bool
{
return $v === 1 || $v === '1' || $v === true;
}
private static function upsertConfig(string $key, string $value, string $valueType, string $remark, int $now): void
{
$exists = Db::name('game_config')->where('config_key', $key)->find();
if ($exists) {
Db::name('game_config')->where('config_key', $key)->update([
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'update_time' => $now,
]);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
return GameRecordService::createNextRecordForManual();
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
final class GameRecordService
{
public const KEY_AUTO_CREATE = 'period_auto_create_enabled';
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public static function getConfigBool(string $key): bool
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return false;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
}
public static function getRecordSettings(): array
{
return [
'period_auto_create_enabled' => self::getConfigBool(self::KEY_AUTO_CREATE) ? 1 : 0,
'period_manual_create_enabled' => self::getConfigBool(self::KEY_MANUAL_CREATE) ? 1 : 0,
];
}
public static function saveRecordSettings(array $data): void
{
$now = time();
$auto = self::truthyConfigInput($data['period_auto_create_enabled'] ?? null) ? '1' : '0';
$manual = self::truthyConfigInput($data['period_manual_create_enabled'] ?? null) ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $auto, 'int', '是否允许定时任务自动创建下一局(全局仅一局)', $now);
self::upsertConfig(self::KEY_MANUAL_CREATE, $manual, 'int', '是否允许后台手动创建下一局', $now);
}
public static function hasActiveRecord(): bool
{
$count = Db::name('game_record')->whereIn('status', self::ACTIVE_STATUSES)->count();
return $count > 0;
}
public static function tickAutoCreate(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActiveRecord()) {
return;
}
try {
self::createNextRecordRow();
} catch (Throwable) {
}
}
public static function createNextRecordForManual(): array
{
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
return ['ok' => false, 'msg' => '未开启「手动创建下一局」开关'];
}
if (self::hasActiveRecord()) {
return ['ok' => false, 'msg' => '存在未结束对局,无法新建'];
}
try {
$periodNo = self::createNextRecordRow();
return ['ok' => true, 'msg' => '已创建新对局', 'period_no' => $periodNo];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
private static function createNextRecordRow(): string
{
$periodNo = self::generatePeriodNo();
$now = time();
Db::name('game_record')->insert([
'period_no' => $periodNo,
'period_start_at' => $now,
'status' => 0,
'draw_mode' => 0,
'void_reason' => '',
'create_time' => $now,
'update_time' => $now,
]);
return $periodNo;
}
private static function generatePeriodNo(): string
{
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}
private static function truthyConfigInput(mixed $v): bool
{
return $v === 1 || $v === '1' || $v === true;
}
private static function upsertConfig(string $key, string $value, string $valueType, string $remark, int $now): void
{
$exists = Db::name('game_config')->where('config_key', $key)->find();
if ($exists) {
Db::name('game_config')->where('config_key', $key)->update([
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'update_time' => $now,
]);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
}
}

View File

@@ -11,7 +11,7 @@ class GamePeriod extends Validate
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_period',
'period_no' => 'require|max:64|unique:game_record',
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class GameRecord extends Validate
{
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_record',
'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'],
];
}

View File

@@ -2,7 +2,7 @@
namespace app\process;
use app\common\service\GamePeriodService;
use app\common\service\GameRecordService;
use Workerman\Timer;
/**
@@ -13,7 +13,7 @@ class GamePeriodAutoTicker
public function onWorkerStart(): void
{
Timer::add(15, static function (): void {
GamePeriodService::tickAutoCreate();
GameRecordService::tickAutoCreate();
});
}
}

View File

@@ -40,7 +40,8 @@
"nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0",
"ext-bcmath": "*"
"ext-bcmath": "*",
"webman/push": "^1.1"
},
"suggest": {
"ext-event": "For better performance. "

View File

@@ -0,0 +1,10 @@
<?php
return [
'enable' => true,
'websocket' => 'websocket://0.0.0.0:3131',
'api' => 'http://0.0.0.0:3232',
'app_key' => '6d0af5971ad191f2dc8a500885cb79c7',
'app_secret' => 'c457f0be89cd48d481b37f16c0a97f5f',
'channel_hook' => 'http://127.0.0.1:8787/plugin/webman/push/hook',
'auth' => '/plugin/webman/push/auth'
];

View File

@@ -0,0 +1,21 @@
<?php
use Webman\Push\Server;
return [
'server' => [
'handler' => Server::class,
'listen' => config('plugin.webman.push.app.websocket'),
'count' => 1, // 必须是1
'reloadable' => false, // 执行reload不重启
'constructor' => [
'api_listen' => config('plugin.webman.push.app.api'),
'app_info' => [
config('plugin.webman.push.app.app_key') => [
'channel_hook' => config('plugin.webman.push.app.channel_hook'),
'app_secret' => config('plugin.webman.push.app.app_secret'),
],
]
]
]
];

View File

@@ -0,0 +1,87 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Request;
use Webman\Route;
use Webman\Push\Api;
/**
* 推送js客户端文件
*/
Route::get('/plugin/webman/push/push.js', function (Request $request) {
return response()->file(base_path().'/vendor/webman/push/src/push.js');
});
/**
* 私有频道鉴权这里应该使用session辨别当前用户身份然后确定该用户是否有权限监听channel_name
*/
Route::post(config('plugin.webman.push.app.auth'), function (Request $request) {
$pusher = new Api(str_replace('0.0.0.0', '127.0.0.1', config('plugin.webman.push.app.api')), config('plugin.webman.push.app.app_key'), config('plugin.webman.push.app.app_secret'));
$channel_name = $request->post('channel_name');
$session = $request->session();
// 这里应该通过session和channel_name判断当前用户是否有权限监听channel_name
$has_authority = true;
if ($has_authority) {
return response($pusher->socketAuth($channel_name, $request->post('socket_id')));
} else {
return response('Forbidden', 403);
}
});
/**
* 当频道上线以及下线时触发的回调
* 频道上线:是指某个频道从没有连接在线到有连接在线的事件
* 频道下线:是指某个频道的所有连接都断开触发的事件
*/
Route::post(parse_url(config('plugin.webman.push.app.channel_hook'), PHP_URL_PATH), function (Request $request) {
// 没有x-pusher-signature头视为伪造请求
if (!$webhook_signature = $request->header('x-pusher-signature')) {
return response('401 Not authenticated', 401);
}
$body = $request->rawBody();
// 计算签名,$app_secret 是双方使用的密钥,是保密的,外部无从得知
$expected_signature = hash_hmac('sha256', $body, config('plugin.webman.push.app.app_secret'), false);
// 安全校验如果签名不一致可能是伪造的请求返回401状态码
if ($webhook_signature !== $expected_signature) {
return response('401 Not authenticated', 401);
}
// 这里存储这上线 下线的channel数据
$payload = json_decode($body, true);
$channels_online = $channels_offline = [];
foreach ($payload['events'] as $event) {
if ($event['name'] === 'channel_added') {
$channels_online[] = $event['channel'];
} else if ($event['name'] === 'channel_removed') {
$channels_offline[] = $event['channel'];
}
}
// 业务根据需要处理上下线的channel例如将在线状态写入数据库通知其它channel等
// 上线的所有channel
echo 'online channels: ' . implode(',', $channels_online) . "\n";
// 下线的所有channel
echo 'offline channels: ' . implode(',', $channels_offline) . "\n";
return 'OK';
});

View File

@@ -22,8 +22,8 @@ export default {
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',
gamePeriod_period_no: 'Period (relation)',
gamePeriod_status: 'Period status',
gameRecord_period_no: 'Round (relation)',
gameRecord_status: 'Round status',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -0,0 +1,14 @@
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',
candidate_title: 'Candidate payout estimates',
number: 'Number',
estimated_loss: 'Estimated payout',
bet_stream_title: 'Realtime bet stream',
bet_id: 'Bet ID',
user_id: 'Player ID',
pick_numbers: 'Pick numbers',
unit_amount: 'Unit amount',
streak_at_bet: 'Streak at bet',
}

View File

@@ -0,0 +1,27 @@
export default {
'quick Search Fields': 'Round No. / ID',
id: 'ID',
period_no: 'Round No.',
period_start_at: 'Start time',
status: 'Status',
'status 0': 'Betting open',
'status 1': 'Closed',
'status 2': 'Settling',
'status 3': 'Paying',
'status 4': 'Ended',
'status 5': 'Void',
draw_mode: 'Draw mode',
'draw_mode 0': 'Auto AI',
'draw_mode 1': 'Manual preset',
preset_number: 'Preset number',
result_number: 'Result number',
void_reason: 'Void reason',
create_time: 'Created',
update_time: 'Updated',
section_auto: 'Auto draw & new round',
auto_create_label: 'Allow auto-create next round',
auto_create_tip: 'When enabled, ticker inserts next round if no active one exists',
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 File

@@ -22,14 +22,14 @@
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',
gamePeriod_period_no: 'Period (relation)',
gamePeriod_status: 'Period status',
'gamePeriod_status 0': 'Open for betting',
'gamePeriod_status 1': 'Closed',
'gamePeriod_status 2': 'Settling tickets',
'gamePeriod_status 3': 'Paying out',
'gamePeriod_status 4': 'Finished',
'gamePeriod_status 5': 'Voided',
gameRecord_period_no: 'Round (relation)',
gameRecord_status: 'Round status',
'gameRecord_status 0': 'Open for betting',
'gameRecord_status 1': 'Closed',
'gameRecord_status 2': 'Settling tickets',
'gameRecord_status 3': 'Paying out',
'gameRecord_status 4': 'Finished',
'gameRecord_status 5': 'Voided',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -22,8 +22,8 @@ export default {
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',
gamePeriod_period_no: '对局期号',
gamePeriod_status: '期状态',
gameRecord_period_no: '对局期号',
gameRecord_status: '期状态',
user_username: '用户名',
channel_name: '渠道',
}

View File

@@ -0,0 +1,14 @@
export default {
tip: '实时监听页面推送的压注记录并展示AI默认最优开奖号码平台预估亏损最少',
current_record: '当前对局',
ai_default_number: 'AI默认开奖号码',
candidate_title: '候选号码赔付预估',
number: '号码',
estimated_loss: '预估赔付',
bet_stream_title: '实时压注记录',
bet_id: '注单ID',
user_id: '玩家ID',
pick_numbers: '压注号码',
unit_amount: '单号金额',
streak_at_bet: '下注时连胜',
}

View File

@@ -0,0 +1,27 @@
export default {
'quick Search Fields': '局号/ID',
id: 'ID',
period_no: '局号',
period_start_at: '开始时间',
status: '状态',
'status 0': '下注开放',
'status 1': '已封盘',
'status 2': '算票中',
'status 3': '派彩中',
'status 4': '已结束',
'status 5': '已作废',
draw_mode: '开奖方式',
'draw_mode 0': '自动AI',
'draw_mode 1': '手动预设',
preset_number: '预设号码',
result_number: '开奖号码',
void_reason: '作废原因',
create_time: '创建时间',
update_time: '更新时间',
section_auto: '自动开奖与新建对局',
auto_create_label: '允许自动创建下一局',
auto_create_tip: '开启后由后台定时任务在无进行中对局时自动插入新局',
manual_create_label: '允许手动创建下一局',
manual_create_tip: '开启后可在本页使用「手动创建下一局」按钮',
btn_create_next: '手动创建下一局',
}

View File

@@ -22,14 +22,14 @@
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',
gamePeriod_period_no: '对局期号',
gamePeriod_status: '期状态',
'gamePeriod_status 0': '下注开放',
'gamePeriod_status 1': '已封盘',
'gamePeriod_status 2': '算票中',
'gamePeriod_status 3': '派彩中',
'gamePeriod_status 4': '已结束',
'gamePeriod_status 5': '已作废',
gameRecord_period_no: '对局期号',
gameRecord_status: '期状态',
'gameRecord_status 0': '下注开放',
'gameRecord_status 1': '已封盘',
'gameRecord_status 2': '算票中',
'gameRecord_status 3': '派彩中',
'gameRecord_status 4': '已结束',
'gameRecord_status 5': '已作废',
user_username: '用户名',
channel_name: '渠道',
}

View File

@@ -0,0 +1,140 @@
<template>
<div class="default-main">
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
<el-card shadow="never" class="mb-12">
<div class="header-row">
<div>
<div>{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}</div>
<div>{{ t('game.live.ai_default_number') }}: {{ snapshot.ai_default_number ?? '-' }}</div>
</div>
<el-button type="primary" :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
</div>
</el-card>
<el-row :gutter="12">
<el-col :span="12">
<el-card shadow="never">
<template #header>{{ t('game.live.candidate_title') }}</template>
<el-table :data="snapshot.candidate_numbers" height="420">
<el-table-column prop="number" :label="t('game.live.number')" width="100" />
<el-table-column prop="estimated_loss" :label="t('game.live.estimated_loss')" />
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>{{ t('game.live.bet_stream_title') }}</template>
<el-table :data="snapshot.bets" height="420">
<el-table-column prop="id" :label="t('game.live.bet_id')" width="90" />
<el-table-column prop="user_id" :label="t('game.live.user_id')" width="90" />
<el-table-column prop="pick_numbers" :label="t('game.live.pick_numbers')">
<template #default="scope">
{{ formatPicks(scope.row.pick_numbers) }}
</template>
</el-table-column>
<el-table-column prop="unit_amount" :label="t('game.live.unit_amount')" width="120" />
<el-table-column prop="streak_at_bet" :label="t('game.live.streak_at_bet')" width="90" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
interface Snapshot {
record: anyObj | null
bets: anyObj[]
candidate_numbers: anyObj[]
ai_default_number: number | null
}
const { t } = useI18n()
const loading = ref(false)
const snapshot = reactive<Snapshot>({
record: null,
bets: [],
candidate_numbers: [],
ai_default_number: null,
})
let pushClient: any = null
let pushChannel: any = null
function formatPicks(v: unknown): string {
if (Array.isArray(v)) return JSON.stringify(v)
if (typeof v === 'string') return v
return '-'
}
async function loadSnapshot() {
loading.value = true
try {
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
if (res.code === 1 && res.data) {
snapshot.record = res.data.record
snapshot.bets = res.data.bets || []
snapshot.candidate_numbers = res.data.candidate_numbers || []
snapshot.ai_default_number = res.data.ai_default_number
}
} finally {
loading.value = false
}
}
async function initPush() {
const cfgRes = await createAxios({ url: '/admin/game.Live/pushConfig', method: 'get', showCodeMessage: false })
if (cfgRes.code !== 1 || !cfgRes.data) {
return
}
const { url, app_key, channel, event } = cfgRes.data
await loadPushJs()
const PushCtor = (window as any).Push
if (!PushCtor) {
return
}
pushClient = new PushCtor({ url, app_key })
pushChannel = pushClient.subscribe(channel)
pushChannel.on(event, (payload: anyObj) => {
snapshot.record = payload.record || null
snapshot.bets = payload.bets || []
snapshot.candidate_numbers = payload.candidate_numbers || []
snapshot.ai_default_number = payload.ai_default_number ?? null
})
}
async function loadPushJs() {
if ((window as any).Push) {
return
}
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = '/plugin/webman/push/push.js'
script.onload = () => resolve()
script.onerror = () => reject(new Error('load push.js failed'))
document.head.appendChild(script)
})
}
onMounted(async () => {
await loadSnapshot()
await initPush()
})
</script>
<style scoped lang="scss">
.mb-12 {
margin-bottom: 12px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<el-card v-if="canSettings" class="record-settings-card" shadow="never">
<template #header>
<span>{{ t('game.record.section_auto') }}</span>
</template>
<div v-loading="settingsLoading" class="record-settings-body">
<div class="record-setting-row">
<span class="record-setting-label">{{ t('game.record.auto_create_label') }}</span>
<el-switch v-model="autoCreate" :disabled="settingsSaving" @change="onSwitchChange" />
<span class="record-setting-tip">{{ t('game.record.auto_create_tip') }}</span>
</div>
<div class="record-setting-row">
<span class="record-setting-label">{{ t('game.record.manual_create_label') }}</span>
<el-switch v-model="manualCreate" :disabled="settingsSaving" @change="onSwitchChange" />
<span class="record-setting-tip">{{ t('game.record.manual_create_tip') }}</span>
</div>
<div v-if="canManual" class="record-setting-actions">
<el-button type="primary" :loading="createLoading" @click="onCreateNextManual">
{{ t('game.record.btn_create_next') }}
</el-button>
</div>
</div>
</el-card>
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.record.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, provide, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import createAxios from '/@/utils/axios'
import baTableClass from '/@/utils/baTable'
import { auth } from '/@/utils/common'
defineOptions({
name: 'game/record',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const createLoading = ref(false)
const autoCreate = ref(false)
const manualCreate = ref(false)
const settingsReady = ref(false)
const canSettings = computed(() => auth('recordSettings'))
const canManual = computed(() => auth('createNextManual'))
const baTable = new baTableClass(
new baTableApi('/admin/game.Record/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.record.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('game.record.period_no'), prop: 'period_no', align: 'center', minWidth: 180, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('game.record.period_start_at'), prop: 'period_start_at', align: 'center', width: 170, render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('game.record.status'),
prop: 'status',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { '0': 'success', '1': 'warning', '2': 'info', '3': 'primary', '4': 'warning', '5': 'danger' },
replaceValue: { '0': t('game.record.status 0'), '1': t('game.record.status 1'), '2': t('game.record.status 2'), '3': t('game.record.status 3'), '4': t('game.record.status 4'), '5': t('game.record.status 5') },
},
{
label: t('game.record.draw_mode'),
prop: 'draw_mode',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: { '0': 'info', '1': 'warning' },
replaceValue: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') },
},
{ label: t('game.record.preset_number'), prop: 'preset_number', align: 'center', width: 100, operator: 'RANGE' },
{ label: t('game.record.result_number'), prop: 'result_number', align: 'center', width: 100, operator: 'RANGE' },
{ label: t('game.record.void_reason'), prop: 'void_reason', align: 'center', minWidth: 140, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE', showOverflowTooltip: true },
{ label: t('game.record.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('game.record.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { status: 0, draw_mode: 0, void_reason: '' },
}
)
provide('baTable', baTable)
async function loadRecordSettings() {
if (!canSettings.value) return
settingsLoading.value = true
try {
const res = await createAxios({ url: '/admin/game.Record/recordSettings', method: 'get', showCodeMessage: false })
if (res.code === 1 && res.data) {
autoCreate.value = res.data.period_auto_create_enabled === 1
manualCreate.value = res.data.period_manual_create_enabled === 1
settingsReady.value = true
}
} finally {
settingsLoading.value = false
}
}
async function onSaveSettings() {
if (!canSettings.value) return
settingsSaving.value = true
try {
await createAxios({
url: '/admin/game.Record/recordSettings',
method: 'post',
data: {
period_auto_create_enabled: autoCreate.value ? 1 : 0,
period_manual_create_enabled: manualCreate.value ? 1 : 0,
},
showSuccessMessage: true,
})
} finally {
settingsSaving.value = false
}
}
function onSwitchChange() {
if (!settingsReady.value) return
void onSaveSettings()
}
async function onCreateNextManual() {
if (!canManual.value) return
createLoading.value = true
try {
await createAxios({ url: '/admin/game.Record/createNextManual', method: 'post', showSuccessMessage: true })
await baTable.getData()
} finally {
createLoading.value = false
}
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
void loadRecordSettings()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss">
.record-settings-card {
margin-bottom: 12px;
}
.record-settings-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.record-setting-label {
min-width: 160px;
font-weight: 500;
}
.record-setting-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
flex: 1;
min-width: 200px;
}
.record-setting-actions {
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('game.record.period_no')" type="string" v-model="baTable.form.items!.period_no" prop="period_no" />
<FormItem :label="t('game.record.period_start_at')" type="datetime" v-model="baTable.form.items!.period_start_at" prop="period_start_at" />
<FormItem
:label="t('game.record.status')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('game.record.status 0'), '1': t('game.record.status 1'), '2': t('game.record.status 2'), '3': t('game.record.status 3'), '4': t('game.record.status 4'), '5': t('game.record.status 5') } }"
/>
<FormItem
:label="t('game.record.draw_mode')"
type="radio"
v-model="baTable.form.items!.draw_mode"
prop="draw_mode"
:input-attr="{ content: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') } }"
/>
<FormItem :label="t('game.record.preset_number')" type="number" v-model="baTable.form.items!.preset_number" prop="preset_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
<FormItem :label="t('game.record.result_number')" type="number" v-model="baTable.form.items!.result_number" prop="result_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
<FormItem :label="t('game.record.void_reason')" type="textarea" v-model="baTable.form.items!.void_reason" prop="void_reason" :input-attr="{ rows: 3 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
period_no: [{ required: true, message: t('Please input field', { field: t('game.record.period_no') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -68,8 +68,8 @@ const baTable = new baTableClass(
operator: 'LIKE',
},
{
label: t('order.betOrder.gamePeriod_period_no'),
prop: 'gamePeriod.period_no',
label: t('order.betOrder.gameRecord_period_no'),
prop: 'gameRecord.period_no',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
@@ -77,8 +77,8 @@ const baTable = new baTableClass(
render: 'tags',
},
{
label: t('order.betOrder.gamePeriod_status'),
prop: 'gamePeriod.status',
label: t('order.betOrder.gameRecord_status'),
prop: 'gameRecord.status',
align: 'center',
width: 100,
operator: 'eq',
@@ -93,12 +93,12 @@ const baTable = new baTableClass(
'5': 'danger',
},
replaceValue: {
'0': t('order.betOrder.gamePeriod_status 0'),
'1': t('order.betOrder.gamePeriod_status 1'),
'2': t('order.betOrder.gamePeriod_status 2'),
'3': t('order.betOrder.gamePeriod_status 3'),
'4': t('order.betOrder.gamePeriod_status 4'),
'5': t('order.betOrder.gamePeriod_status 5'),
'0': t('order.betOrder.gameRecord_status 0'),
'1': t('order.betOrder.gameRecord_status 1'),
'2': t('order.betOrder.gameRecord_status 2'),
'3': t('order.betOrder.gameRecord_status 3'),
'4': t('order.betOrder.gameRecord_status 4'),
'5': t('order.betOrder.gameRecord_status 5'),
},
},
{