Compare commits
4 Commits
15fdd3ba57
...
545a818094
| Author | SHA1 | Date | |
|---|---|---|---|
| 545a818094 | |||
| 7b39a2a505 | |||
| 015d1e4d5b | |||
| c7149e7058 |
@@ -21,3 +21,6 @@ DATABASE_PREFIX =
|
||||
|
||||
# 缓存(config/cache.php)
|
||||
CACHE_DRIVER = file
|
||||
|
||||
# 移动端接口鉴权(/api/v1/authToken)
|
||||
AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a
|
||||
|
||||
@@ -161,7 +161,7 @@ php webman migrate
|
||||
|
||||
**开发环境需同时启动后端与前端:**
|
||||
|
||||
1. **启动后端(API 服务,端口 8787):**
|
||||
1. **启动后端(API 服务,端口 7979):**
|
||||
|
||||
**Linux / Mac:**
|
||||
```bash
|
||||
@@ -184,11 +184,11 @@ php webman migrate
|
||||
- 前台地址:http://localhost:1818/index.html/#/
|
||||
- 后台地址:http://localhost:1818/index.html/#/admin
|
||||
|
||||
> 注意:前端通过 Vite 代理将 `/api`、`/admin`、`/install` 转发到后端 8787 端口,请勿直接访问 8787 端口的前端页面,否则可能出现 404。
|
||||
> 注意:前端通过 Vite 代理将 `/api`、`/admin`、`/install` 转发到后端 7979 端口,请勿直接访问 8787 端口的前端页面,否则可能出现 404。
|
||||
|
||||
### 5.6 生产环境 Nginx(反向代理 Webman)
|
||||
|
||||
部署到服务器时,若使用 **Nginx** 作为站点入口,需将请求转发到本机 **Webman** 进程(默认监听端口与 `config/process.php` 中 `listen` 一致,一般为 `8787`,反代目标使用 `127.0.0.1:8787`)。
|
||||
部署到服务器时,若使用 **Nginx** 作为站点入口,需将请求转发到本机 **Webman** 进程(默认监听端口与 `config/process.php` 中 `listen` 一致,一般为 `7979`,反代目标使用 `127.0.0.1:8787`)。
|
||||
|
||||
在站点 **`server { }`** 块中可增加如下写法:**先由 Nginx 根据 `root` 判断是否存在对应静态文件;不存在则转发到 Webman**(`root` 建议指向项目 `public` 目录)。
|
||||
|
||||
@@ -201,7 +201,7 @@ location ^~ / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_pass http://127.0.0.1:7979;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
98
app/admin/controller/game/Live.php
Normal file
98
app/admin/controller/game/Live.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?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',
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '']);
|
||||
}
|
||||
|
||||
69
app/admin/controller/game/Record.php
Normal file
69
app/admin/controller/game/Record.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\game;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
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 add(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游戏对局记录由系统自动生成,禁止后台手工新增');
|
||||
}
|
||||
|
||||
public function edit(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() === 'POST') {
|
||||
return $this->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 del(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游戏对局记录不可删除');
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ use app\common\facade\Token;
|
||||
use app\common\model\UserScoreLog;
|
||||
use app\common\model\UserMoneyLog;
|
||||
use app\common\controller\Frontend;
|
||||
use app\common\facade\Token as TokenFacade;
|
||||
use support\validation\Validator;
|
||||
use support\validation\ValidationException;
|
||||
use Webman\Http\Request;
|
||||
@@ -20,6 +21,51 @@ class Account extends Frontend
|
||||
protected array $noNeedLogin = ['retrievePassword'];
|
||||
protected array $noNeedPermission = ['verification', 'changeBind'];
|
||||
|
||||
public function userProfile(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeFrontend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$authToken = trim((string) $request->header('auth-token', ''));
|
||||
if ($authToken === '') {
|
||||
return $this->mobileResult(1101, 'Missing auth-token');
|
||||
}
|
||||
$tokenData = TokenFacade::get($authToken);
|
||||
$type = $tokenData['type'] ?? '';
|
||||
$expireTime = $tokenData['expire_time'] ?? 0;
|
||||
if ($type !== 'auth-token' || !is_numeric($expireTime) || $expireTime < time()) {
|
||||
return $this->mobileResult(1101, 'auth-token is invalid or expired');
|
||||
}
|
||||
|
||||
$user = $this->auth->getUser();
|
||||
$payload = [
|
||||
'code' => 1,
|
||||
'message' => __('ok'),
|
||||
'data' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'head_image' => $user->avatar ?? '',
|
||||
'coin' => $user->coin,
|
||||
'current_streak' => $user->current_streak ?? 0,
|
||||
'channel_id' => $user->channel_id,
|
||||
'risk_flags' => $user->risk_flags ?? 0,
|
||||
],
|
||||
];
|
||||
return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
private function mobileResult(int $code, string $message, array $data = []): Response
|
||||
{
|
||||
$payload = [
|
||||
'code' => $code,
|
||||
'message' => __($message),
|
||||
'data' => $data,
|
||||
];
|
||||
return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
public function overview(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeFrontend($request);
|
||||
|
||||
138
app/api/controller/Auth.php
Normal file
138
app/api/controller/Auth.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\facade\Token;
|
||||
use app\common\library\Auth as UserAuth;
|
||||
use app\common\model\User;
|
||||
use ba\Random;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
class Auth extends MobileBase
|
||||
{
|
||||
protected array $noNeedLogin = ['userRegister', 'userLogin', 'tokenRefresh'];
|
||||
|
||||
public function userRegister(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$account = trim((string) $request->post('account', ''));
|
||||
$accountType = trim((string) $request->post('account_type', ''));
|
||||
$password = (string) $request->post('password', '');
|
||||
$inviteCode = trim((string) $request->post('invite_code', ''));
|
||||
|
||||
if ($account === '' || $accountType === '' || $password === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if ($accountType !== 'phone' && $accountType !== 'email') {
|
||||
return $this->mobileError(1003, 'Invalid parameter value');
|
||||
}
|
||||
|
||||
$username = $account;
|
||||
$mobile = '';
|
||||
$email = '';
|
||||
if ($accountType === 'phone') {
|
||||
$mobile = $account;
|
||||
}
|
||||
if ($accountType === 'email') {
|
||||
$email = $account;
|
||||
}
|
||||
|
||||
$extend = [];
|
||||
if ($inviteCode !== '') {
|
||||
$inviterAdmin = Db::name('admin')->field(['id', 'channel_id'])->where('invite_code', $inviteCode)->find();
|
||||
if (!$inviterAdmin) {
|
||||
return $this->mobileError(2002, 'Invite code does not exist');
|
||||
}
|
||||
$extend['register_invite_code'] = $inviteCode;
|
||||
$extend['admin_id'] = $inviterAdmin['id'];
|
||||
$extend['channel_id'] = $inviterAdmin['channel_id'] ?? null;
|
||||
}
|
||||
|
||||
$registered = $this->auth->register($username, $password, $mobile, $email, 1, $extend);
|
||||
if (!$registered) {
|
||||
return $this->mobileError(2000, (string) $this->auth->getError());
|
||||
}
|
||||
|
||||
$loggedIn = $this->auth->login($username, $password, true);
|
||||
if (!$loggedIn) {
|
||||
return $this->mobileError(2000, 'Registered successfully but login failed');
|
||||
}
|
||||
|
||||
$userInfo = $this->auth->getUserInfo();
|
||||
return $this->mobileSuccess([
|
||||
'user_id' => $userInfo['id'] ?? null,
|
||||
'access_token' => $userInfo['token'] ?? '',
|
||||
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
|
||||
'profile' => [
|
||||
'username' => $userInfo['username'] ?? '',
|
||||
'coin' => $userInfo['coin'] ?? '0.0000',
|
||||
'channel_id' => $userInfo['channel_id'] ?? null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function userLogin(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$account = trim((string) $request->post('account', ''));
|
||||
$password = (string) $request->post('password', '');
|
||||
if ($account === '' || $password === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
|
||||
$ok = $this->auth->login($account, $password, true);
|
||||
if (!$ok) {
|
||||
return $this->mobileError(1101, 'Incorrect account or password');
|
||||
}
|
||||
$userInfo = $this->auth->getUserInfo();
|
||||
return $this->mobileSuccess([
|
||||
'access_token' => $userInfo['token'] ?? '',
|
||||
'refresh_token' => $userInfo['refresh_token'] ?? '',
|
||||
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
|
||||
'user' => [
|
||||
'id' => $userInfo['id'] ?? null,
|
||||
'username' => $userInfo['username'] ?? '',
|
||||
'coin' => $userInfo['coin'] ?? '0.0000',
|
||||
'risk_flags' => $userInfo['risk_flags'] ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function tokenRefresh(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$refreshToken = trim((string) $request->post('refresh_token', ''));
|
||||
if ($refreshToken === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
|
||||
$tokenData = Token::get($refreshToken);
|
||||
if (!$tokenData || $tokenData['type'] !== UserAuth::TOKEN_TYPE . '-refresh' || $tokenData['expire_time'] < time()) {
|
||||
return $this->mobileError(1101, 'Login status has expired');
|
||||
}
|
||||
|
||||
$newToken = Random::uuid();
|
||||
Token::set($newToken, UserAuth::TOKEN_TYPE, $tokenData['user_id'], config('buildadmin.user_token_keep_time', 259200));
|
||||
return $this->mobileSuccess([
|
||||
'access_token' => $newToken,
|
||||
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
173
app/api/controller/Finance.php
Normal file
173
app/api/controller/Finance.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\model\DepositOrder;
|
||||
use app\common\model\WithdrawOrder;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
class Finance extends MobileBase
|
||||
{
|
||||
public function depositCreate(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$payAmountFiat = (string) $request->post('pay_amount_fiat', '');
|
||||
$fiatCurrency = trim((string) $request->post('fiat_currency', ''));
|
||||
$channel = trim((string) $request->post('channel', ''));
|
||||
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
|
||||
if ($payAmountFiat === '' || $fiatCurrency === '' || $channel === '' || $idempotencyKey === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
|
||||
$orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
|
||||
$coinAmount = $payAmountFiat;
|
||||
DepositOrder::create([
|
||||
'order_no' => $orderNo,
|
||||
'user_id' => $this->auth->id,
|
||||
'fiat_currency' => $fiatCurrency,
|
||||
'fiat_amount' => $payAmountFiat,
|
||||
'fx_rate' => '1.00000000',
|
||||
'coin_amount' => $coinAmount,
|
||||
'gateway' => $channel,
|
||||
'status' => 0,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return $this->mobileSuccess([
|
||||
'order_no' => $orderNo,
|
||||
'coin_amount' => $coinAmount,
|
||||
'pay_url' => '',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
public function depositDetail(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$orderNo = trim((string) $request->get('order_no', ''));
|
||||
if ($orderNo === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
$order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find();
|
||||
if (!$order) {
|
||||
return $this->mobileError(2003, 'Order does not exist');
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'order_no' => $order->order_no,
|
||||
'status' => $this->mapDepositStatus($order->status),
|
||||
'coin_amount' => $order->coin_amount,
|
||||
'create_time' => $order->create_time,
|
||||
'finish_time' => $order->paid_at,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withdrawCreate(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$withdrawCoin = (string) $request->post('withdraw_coin', '');
|
||||
$receiveAccount = trim((string) $request->post('receive_account', ''));
|
||||
$receiveType = trim((string) $request->post('receive_type', ''));
|
||||
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
|
||||
if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
$user = $this->auth->getUser();
|
||||
if (bccomp((string) $user->coin, $withdrawCoin, 4) < 0) {
|
||||
return $this->mobileError(2001, 'Insufficient balance');
|
||||
}
|
||||
|
||||
$orderNo = 'WD' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
|
||||
$feeCoin = bcmul($withdrawCoin, '0.005', 4);
|
||||
$actualArrivalCoin = bcsub($withdrawCoin, $feeCoin, 4);
|
||||
WithdrawOrder::create([
|
||||
'order_no' => $orderNo,
|
||||
'user_id' => $user->id,
|
||||
'apply_amount' => $withdrawCoin,
|
||||
'fee_amount' => $feeCoin,
|
||||
'actual_amount' => $actualArrivalCoin,
|
||||
'fiat_currency' => '',
|
||||
'need_audit' => 1,
|
||||
'audit_status' => 0,
|
||||
'reject_reason' => '',
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return $this->mobileSuccess([
|
||||
'order_no' => $orderNo,
|
||||
'status' => 'pending_review',
|
||||
'fee_coin' => $feeCoin,
|
||||
'actual_arrival_coin' => $actualArrivalCoin,
|
||||
'risk_review_required' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withdrawDetail(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$orderNo = trim((string) $request->get('order_no', ''));
|
||||
if ($orderNo === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
$order = WithdrawOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find();
|
||||
if (!$order) {
|
||||
return $this->mobileError(2003, 'Order does not exist');
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'order_no' => $order->order_no,
|
||||
'status' => $this->mapWithdrawStatus($order->audit_status),
|
||||
'withdraw_coin' => $order->apply_amount,
|
||||
'fee_coin' => $order->fee_amount,
|
||||
'reject_reason' => $order->reject_reason === '' ? null : $order->reject_reason,
|
||||
'create_time' => $order->create_time,
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapDepositStatus($status): string
|
||||
{
|
||||
if ($this->intValue($status) === 1) {
|
||||
return 'paid';
|
||||
}
|
||||
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
private function mapWithdrawStatus($auditStatus): string
|
||||
{
|
||||
if ($this->intValue($auditStatus) === 1) {
|
||||
return 'approved';
|
||||
}
|
||||
if ($this->intValue($auditStatus) === 2) {
|
||||
return 'rejected';
|
||||
}
|
||||
return 'pending_review';
|
||||
}
|
||||
|
||||
private function intValue($value): int
|
||||
{
|
||||
$result = filter_var($value, FILTER_VALIDATE_INT);
|
||||
if ($result === false) {
|
||||
return 0;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
320
app/api/controller/Game.php
Normal file
320
app/api/controller/Game.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\library\game\ZiHuaDictionary;
|
||||
use app\common\model\BetOrder;
|
||||
use app\common\model\GameConfig;
|
||||
use app\common\model\GameRecord;
|
||||
use app\common\model\UserWalletRecord;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
class Game extends MobileBase
|
||||
{
|
||||
protected array $noNeedLogin = ['dictionaryList', 'periodHistory'];
|
||||
|
||||
public function lobbyInit(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$period = GameRecord::order('id', 'desc')->find();
|
||||
$now = time();
|
||||
$startAt = $period ? $this->intValue($period->period_start_at) : $now;
|
||||
$lockAt = $startAt + 20;
|
||||
$openAt = $startAt + 22;
|
||||
$countdown = $period ? max(0, ($startAt + 30) - $now) : 0;
|
||||
|
||||
$dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find();
|
||||
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null);
|
||||
$items = [];
|
||||
foreach ($dictionaryItems as $row) {
|
||||
$items[] = [
|
||||
'number' => $row['no'],
|
||||
'name' => $row['name'],
|
||||
'category' => $row['category'],
|
||||
'icon' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$user = $this->auth->getUser();
|
||||
return $this->mobileSuccess([
|
||||
'server_time' => $now,
|
||||
'period' => [
|
||||
'period_no' => $period->period_no ?? '',
|
||||
'status' => $this->mapPeriodStatus($period->status ?? null),
|
||||
'countdown' => $countdown,
|
||||
'lock_at' => $lockAt,
|
||||
'open_at' => $openAt,
|
||||
],
|
||||
'bet_config' => [
|
||||
'max_select_count' => $this->intValue($this->getConfigValue('max_select_count', '5')),
|
||||
'chips' => ['1.0000', '5.0000', '10.0000', '25.0000', '50.0000', '100.0000'],
|
||||
'single_number_max_bet' => $this->getConfigValue('single_number_max_bet', '500.0000'),
|
||||
],
|
||||
'dictionary' => $items,
|
||||
'user_snapshot' => [
|
||||
'coin' => $user->coin,
|
||||
'current_streak' => $user->current_streak ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function dictionaryList(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find();
|
||||
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null);
|
||||
$items = [];
|
||||
foreach ($dictionaryItems as $row) {
|
||||
$items[] = [
|
||||
'number' => $row['no'],
|
||||
'name' => $row['name'],
|
||||
'category' => $row['category'],
|
||||
'icon' => '',
|
||||
];
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'version' => (string) ($dictionaryConfig->update_time ?? '1'),
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
public function periodHistory(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$limit = $this->intValue($request->get('limit', 30));
|
||||
if ($limit < 1) {
|
||||
$limit = 30;
|
||||
}
|
||||
$list = GameRecord::whereNotNull('result_number')->order('id', 'desc')->limit($limit)->select();
|
||||
$rows = [];
|
||||
foreach ($list as $item) {
|
||||
$rows[] = [
|
||||
'period_no' => $item->period_no,
|
||||
'result_number' => $item->result_number,
|
||||
'open_time' => $item->update_time,
|
||||
];
|
||||
}
|
||||
return $this->mobileSuccess(['list' => $rows]);
|
||||
}
|
||||
|
||||
public function periodCurrent(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$period = GameRecord::order('id', 'desc')->find();
|
||||
if (!$period) {
|
||||
return $this->mobileError(2002, 'Game period does not exist');
|
||||
}
|
||||
$now = time();
|
||||
$startAt = $this->intValue($period->period_start_at);
|
||||
return $this->mobileSuccess([
|
||||
'period_id' => $period->id,
|
||||
'period_no' => $period->period_no,
|
||||
'status' => $this->mapPeriodStatus($period->status),
|
||||
'countdown' => max(0, ($startAt + 30) - $now),
|
||||
'bet_close_in' => max(0, ($startAt + 20) - $now),
|
||||
'result_number' => $period->result_number,
|
||||
]);
|
||||
}
|
||||
|
||||
public function betPlace(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$periodNo = trim((string) $request->post('period_no', ''));
|
||||
$numbers = $request->post('numbers', []);
|
||||
$betAmount = (string) $request->post('bet_amount', '');
|
||||
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
|
||||
if ($periodNo === '' || !is_array($numbers) || $betAmount === '' || $idempotencyKey === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if (count($numbers) < 1) {
|
||||
return $this->mobileError(1003, 'Invalid parameter value');
|
||||
}
|
||||
|
||||
$period = GameRecord::where('period_no', $periodNo)->find();
|
||||
if (!$period) {
|
||||
return $this->mobileError(2002, 'Game period does not exist');
|
||||
}
|
||||
if ($this->intValue($period->status) !== 0) {
|
||||
return $this->mobileError(3002, 'Betting is closed');
|
||||
}
|
||||
|
||||
$user = $this->auth->getUser();
|
||||
$pickCount = count($numbers);
|
||||
$totalAmount = bcmul($betAmount, (string) $pickCount, 4);
|
||||
if (bccomp((string) $user->coin, $totalAmount, 4) < 0) {
|
||||
return $this->mobileError(2001, 'Insufficient balance');
|
||||
}
|
||||
|
||||
$exists = BetOrder::where('idempotency_key', $idempotencyKey)->find();
|
||||
if ($exists) {
|
||||
return $this->mobileError(3003, 'Duplicate request');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$before = (string) $user->coin;
|
||||
$after = bcsub($before, $totalAmount, 4);
|
||||
UserWalletRecord::create([
|
||||
'user_id' => $user->id,
|
||||
'channel_id' => $user->channel_id,
|
||||
'biz_type' => 'bet',
|
||||
'direction' => 2,
|
||||
'amount' => $totalAmount,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'ref_type' => 'bet_order',
|
||||
'remark' => '移动端下注',
|
||||
'create_time' => time(),
|
||||
]);
|
||||
Db::name('user')->where('id', $user->id)->update(['coin' => $after, 'update_time' => time()]);
|
||||
$orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
|
||||
BetOrder::create([
|
||||
'period_id' => $period->id,
|
||||
'period_no' => $period->period_no,
|
||||
'user_id' => $user->id,
|
||||
'channel_id' => $user->channel_id,
|
||||
'pick_numbers' => $numbers,
|
||||
'unit_amount' => $betAmount,
|
||||
'pick_count' => $pickCount,
|
||||
'total_amount' => $totalAmount,
|
||||
'streak_at_bet' => $user->current_streak ?? 0,
|
||||
'is_auto' => 0,
|
||||
'status' => 1,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->mobileSuccess([
|
||||
'order_no' => $orderNo,
|
||||
'period_no' => $period->period_no,
|
||||
'status' => 'accepted',
|
||||
'locked_balance' => '0.0000',
|
||||
'balance_after' => $after,
|
||||
'current_streak' => $user->current_streak ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function betRebet(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->mobileError(3001, 'Current process does not allow this operation');
|
||||
}
|
||||
|
||||
public function autoBetCreate(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->mobileError(3001, 'Current process does not allow this operation');
|
||||
}
|
||||
|
||||
public function autoBetStop(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->mobileError(3001, 'Current process does not allow this operation');
|
||||
}
|
||||
|
||||
public function betMyOrders(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$page = $this->intValue($request->get('page', 1));
|
||||
$pageSize = $this->intValue($request->get('page_size', 20));
|
||||
$paginate = BetOrder::where('user_id', $this->auth->id)->order('id', 'desc')->paginate([
|
||||
'page' => $page,
|
||||
'list_rows' => $pageSize,
|
||||
]);
|
||||
|
||||
$rows = [];
|
||||
foreach ($paginate->items() as $item) {
|
||||
$rows[] = [
|
||||
'order_no' => (string) $item->id,
|
||||
'period_no' => $item->period_no,
|
||||
'numbers' => $item->pick_numbers ?? [],
|
||||
'bet_amount' => $item->unit_amount,
|
||||
'total_amount' => $item->total_amount,
|
||||
'result_number' => null,
|
||||
'win_amount' => $item->win_amount,
|
||||
'status' => (string) $item->status,
|
||||
'create_time' => $item->create_time,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->mobileSuccess([
|
||||
'list' => $rows,
|
||||
'pagination' => [
|
||||
'page' => $paginate->currentPage(),
|
||||
'page_size' => $paginate->listRows(),
|
||||
'total' => $paginate->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function mapPeriodStatus($status): string
|
||||
{
|
||||
if ($this->intValue($status) === 0) {
|
||||
return 'betting';
|
||||
}
|
||||
if ($this->intValue($status) === 1) {
|
||||
return 'locked';
|
||||
}
|
||||
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
|
||||
return 'settling';
|
||||
}
|
||||
return 'finished';
|
||||
}
|
||||
|
||||
private function getConfigValue(string $key, string $default): string
|
||||
{
|
||||
$value = GameConfig::where('config_key', $key)->value('config_value');
|
||||
if ($value === null || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function intValue($value): int
|
||||
{
|
||||
$result = filter_var($value, FILTER_VALIDATE_INT);
|
||||
if ($result === false) {
|
||||
return 0;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,14 +663,14 @@ class Install extends Api
|
||||
|
||||
/**
|
||||
* 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式)
|
||||
* - 通过 API 访问(8787):index#/admin、index#/(无 index 与 # 之间的斜杠)
|
||||
* - 通过 API 访问(7979):index#/admin、index#/(无 index 与 # 之间的斜杠)
|
||||
* - 通过前端开发服务访问(1818):/#/admin、/#/
|
||||
*/
|
||||
public function accessUrls(Request $request): Response
|
||||
{
|
||||
$this->setRequest($request);
|
||||
$host = $request->header('host', '127.0.0.1:8787');
|
||||
$port = '8787';
|
||||
$host = $request->header('host', '127.0.0.1:7979');
|
||||
$port = '7979';
|
||||
if (str_contains($host, ':')) {
|
||||
$port = substr($host, strrpos($host, ':') + 1);
|
||||
}
|
||||
|
||||
72
app/api/controller/MobileBase.php
Normal file
72
app/api/controller/MobileBase.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Frontend;
|
||||
use app\common\facade\Token;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
use function response;
|
||||
|
||||
abstract class MobileBase extends Frontend
|
||||
{
|
||||
protected array $noNeedPermission = ['*'];
|
||||
protected array $noNeedAuthToken = [];
|
||||
|
||||
/**
|
||||
* 移动端统一初始化:
|
||||
* - 校验请求头 auth-token
|
||||
* - 再走会员中心 Frontend 初始化(登录态/权限等)
|
||||
*/
|
||||
protected function initializeMobile(Request $request): ?Response
|
||||
{
|
||||
$this->setRequest($request);
|
||||
|
||||
$path = trim($request->path(), '/');
|
||||
$parts = explode('/', $path);
|
||||
$action = $parts[array_key_last($parts)] ?? '';
|
||||
$needAuthToken = !action_in_arr($this->noNeedAuthToken, $action);
|
||||
if ($needAuthToken) {
|
||||
$authToken = trim((string) $request->header('auth-token', ''));
|
||||
if ($authToken === '') {
|
||||
return $this->mobileError(1101, 'Missing auth-token');
|
||||
}
|
||||
$tokenData = Token::get($authToken);
|
||||
$type = $tokenData['type'] ?? '';
|
||||
$expireTime = $tokenData['expire_time'] ?? 0;
|
||||
if ($type !== 'auth-token' || !is_numeric($expireTime) || $expireTime < time()) {
|
||||
return $this->mobileError(1101, 'auth-token is invalid or expired');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->initializeFrontend($request);
|
||||
}
|
||||
|
||||
protected function mobileSuccess(array $data = [], string $message = 'ok'): Response
|
||||
{
|
||||
if ($message === '') {
|
||||
$message = __('ok');
|
||||
} else {
|
||||
$message = __($message);
|
||||
}
|
||||
$payload = [
|
||||
'code' => 1,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
];
|
||||
return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
protected function mobileError(int $code, string $message, array $data = []): Response
|
||||
{
|
||||
$payload = [
|
||||
'code' => $code,
|
||||
'message' => __($message),
|
||||
'data' => $data,
|
||||
];
|
||||
return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
}
|
||||
|
||||
123
app/api/controller/Notice.php
Normal file
123
app/api/controller/Notice.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\model\OperationNotice;
|
||||
use app\common\model\UserNoticeRead;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
class Notice extends MobileBase
|
||||
{
|
||||
public function noticeList(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$page = $this->intValue($request->get('page', 1), 1);
|
||||
$pageSize = $this->intValue($request->get('page_size', 20), 20);
|
||||
|
||||
$paginate = OperationNotice::where('status', 1)->order('id', 'desc')->paginate([
|
||||
'page' => $page,
|
||||
'list_rows' => $pageSize,
|
||||
]);
|
||||
|
||||
$noticeIds = [];
|
||||
foreach ($paginate->items() as $row) {
|
||||
$noticeIds[] = $row->id;
|
||||
}
|
||||
$readRows = [];
|
||||
if ($noticeIds !== []) {
|
||||
$readRows = UserNoticeRead::where('user_id', $this->auth->id)->whereIn('notice_id', $noticeIds)->column('notice_id');
|
||||
}
|
||||
$readMap = array_flip($readRows);
|
||||
|
||||
$list = [];
|
||||
foreach ($paginate->items() as $row) {
|
||||
$list[] = [
|
||||
'notice_id' => $row->id,
|
||||
'title' => $row->title,
|
||||
'notice_type' => $this->intValue($row->notice_type, 0) === 1 ? 'popout' : 'silent',
|
||||
'is_read' => isset($readMap[$row->id]),
|
||||
'publish_time' => $row->publish_at,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->mobileSuccess(['list' => $list]);
|
||||
}
|
||||
|
||||
public function noticeDetail(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$id = $this->intValue($request->get('id', 0), 0);
|
||||
if ($id < 1) {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
$notice = OperationNotice::where('id', $id)->where('status', 1)->find();
|
||||
if (!$notice) {
|
||||
return $this->mobileError(2004, 'Notice does not exist');
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'notice_id' => $notice->id,
|
||||
'title' => $notice->title,
|
||||
'content' => $notice->content,
|
||||
'notice_type' => $this->intValue($notice->notice_type, 0) === 1 ? 'popout' : 'silent',
|
||||
'must_confirm' => $this->intValue($notice->notice_type, 0) === 1,
|
||||
'publish_time' => $notice->publish_at,
|
||||
]);
|
||||
}
|
||||
|
||||
public function noticeConfirm(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$noticeId = $this->intValue($request->post('notice_id', 0), 0);
|
||||
if ($noticeId < 1) {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
$notice = OperationNotice::where('id', $noticeId)->where('status', 1)->find();
|
||||
if (!$notice) {
|
||||
return $this->mobileError(2004, 'Notice does not exist');
|
||||
}
|
||||
|
||||
$exists = UserNoticeRead::where('user_id', $this->auth->id)->where('notice_id', $noticeId)->find();
|
||||
$now = time();
|
||||
if ($exists) {
|
||||
$exists->save([
|
||||
'confirmed' => 1,
|
||||
'read_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
UserNoticeRead::create([
|
||||
'user_id' => $this->auth->id,
|
||||
'notice_id' => $noticeId,
|
||||
'confirmed' => 1,
|
||||
'read_at' => $now,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'notice_id' => $noticeId,
|
||||
'confirmed' => true,
|
||||
'confirm_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function intValue($value, int $default): int
|
||||
{
|
||||
$result = filter_var($value, FILTER_VALIDATE_INT);
|
||||
if ($result === false) {
|
||||
return $default;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
85
app/api/controller/V1.php
Normal file
85
app/api/controller/V1.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Api;
|
||||
use app\common\facade\Token;
|
||||
use ba\Random;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
use function response;
|
||||
|
||||
class V1 extends Api
|
||||
{
|
||||
public function authToken(Request $request): Response
|
||||
{
|
||||
$responseInit = $this->initializeApi($request);
|
||||
if ($responseInit !== null) {
|
||||
return $responseInit;
|
||||
}
|
||||
|
||||
$secret = trim((string) $request->get('secret', ''));
|
||||
$timestampRaw = $request->get('timestamp', '');
|
||||
$deviceId = trim((string) $request->get('device_id', ''));
|
||||
$signature = trim((string) $request->get('signature', ''));
|
||||
|
||||
if ($secret === '' || $timestampRaw === '' || $deviceId === '' || $signature === '') {
|
||||
return $this->mobileResult(1001, 'Missing parameters');
|
||||
}
|
||||
|
||||
$serverSecret = (string) env('AUTH_TOKEN_SECRET', '');
|
||||
if ($serverSecret === '' || !hash_equals($serverSecret, $secret)) {
|
||||
return $this->mobileResult(1103, 'Invalid secret');
|
||||
}
|
||||
|
||||
$timestamp = filter_var($timestampRaw, FILTER_VALIDATE_INT);
|
||||
if ($timestamp === false) {
|
||||
return $this->mobileResult(1002, 'Invalid parameter format');
|
||||
}
|
||||
$now = time();
|
||||
$skew = abs($now - $timestamp);
|
||||
if ($skew > 300) {
|
||||
return $this->mobileResult(3001, 'Invalid timestamp');
|
||||
}
|
||||
|
||||
$params = [
|
||||
'device_id' => $deviceId,
|
||||
'secret' => $secret,
|
||||
'timestamp' => (string) $timestamp,
|
||||
];
|
||||
ksort($params);
|
||||
$pairs = [];
|
||||
foreach ($params as $k => $v) {
|
||||
$pairs[] = $k . '=' . $v;
|
||||
}
|
||||
$plain = implode('&', $pairs);
|
||||
$expected = strtoupper(md5($plain));
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
return $this->mobileResult(1103, 'Invalid signature');
|
||||
}
|
||||
|
||||
$token = Random::uuid();
|
||||
$expire = 60 * 60 * 24;
|
||||
Token::set($token, 'auth-token', 0, $expire);
|
||||
|
||||
return $this->mobileResult(1, 'ok', [
|
||||
'auth_token' => $token,
|
||||
'expires_in' => $expire,
|
||||
'server_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function mobileResult(int $code, string $message, array $data = []): Response
|
||||
{
|
||||
$payload = [
|
||||
'code' => $code,
|
||||
'message' => __($message),
|
||||
'data' => $data,
|
||||
];
|
||||
return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
}
|
||||
|
||||
80
app/api/controller/Wallet.php
Normal file
80
app/api/controller/Wallet.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\model\UserWalletRecord;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
class Wallet extends MobileBase
|
||||
{
|
||||
public function balanceSummary(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$user = $this->auth->getUser();
|
||||
return $this->mobileSuccess([
|
||||
'coin_balance' => $user->coin,
|
||||
'frozen_balance' => '0.0000',
|
||||
'total_deposit_coin' => $user->total_deposit_coin ?? '0.0000',
|
||||
'total_valid_bet_coin' => $user->total_valid_bet_coin ?? '0.0000',
|
||||
'withdrawable_balance' => $user->coin,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordList(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$type = trim((string) $request->get('type', 'all'));
|
||||
$page = $this->intValue($request->get('page', 1), 1);
|
||||
$pageSize = $this->intValue($request->get('page_size', 20), 20);
|
||||
|
||||
$query = UserWalletRecord::where('user_id', $this->auth->id)->order('id', 'desc');
|
||||
if ($type !== '' && $type !== 'all') {
|
||||
$query->where('biz_type', $type);
|
||||
}
|
||||
$paginate = $query->paginate([
|
||||
'page' => $page,
|
||||
'list_rows' => $pageSize,
|
||||
]);
|
||||
$list = [];
|
||||
foreach ($paginate->items() as $row) {
|
||||
$list[] = [
|
||||
'record_id' => $row->id,
|
||||
'biz_type' => $row->biz_type,
|
||||
'direction' => $row->direction,
|
||||
'amount' => $row->amount,
|
||||
'balance_before' => $row->balance_before,
|
||||
'balance_after' => $row->balance_after,
|
||||
'ref_type' => $row->ref_type,
|
||||
'ref_id' => $row->ref_id,
|
||||
'create_time' => $row->create_time,
|
||||
];
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'list' => $list,
|
||||
'pagination' => [
|
||||
'page' => $paginate->currentPage(),
|
||||
'page_size' => $paginate->listRows(),
|
||||
'total' => $paginate->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function intValue($value, int $default): int
|
||||
{
|
||||
$result = filter_var($value, FILTER_VALIDATE_INT);
|
||||
if ($result === false) {
|
||||
return $default;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,27 @@ return [
|
||||
'Please login first' => 'Please login first!',
|
||||
'You have no permission' => 'No permission to operate!',
|
||||
'Captcha error' => 'Captcha error!',
|
||||
'ok' => 'ok',
|
||||
'Missing parameters' => 'Missing parameters',
|
||||
'Invalid parameter format' => 'Invalid parameter format',
|
||||
'Invalid parameter value' => 'Invalid parameter value',
|
||||
'Missing auth-token' => 'Missing auth-token',
|
||||
'auth-token is invalid or expired' => 'auth-token is invalid or expired',
|
||||
'Invalid secret' => 'Invalid secret',
|
||||
'Invalid signature' => 'Invalid signature',
|
||||
'Invalid timestamp' => 'Invalid timestamp',
|
||||
'Invite code does not exist' => 'Invite code does not exist',
|
||||
'Registered successfully but login failed' => 'Registered successfully but login failed',
|
||||
'Incorrect account or password' => 'Incorrect account or password',
|
||||
'Login status has expired' => 'Login status has expired',
|
||||
'Game period does not exist' => 'Game period does not exist',
|
||||
'Betting is closed' => 'Betting is closed',
|
||||
'Insufficient balance' => 'Insufficient balance',
|
||||
'Duplicate request' => 'Duplicate request',
|
||||
'System is busy, please try again later' => 'System is busy, please try again later',
|
||||
'Current process does not allow this operation' => 'Current process does not allow this operation',
|
||||
'Order does not exist' => 'Order does not exist',
|
||||
'Notice does not exist' => 'Notice does not exist',
|
||||
// Member center account
|
||||
'Data updated successfully~' => 'Data updated successfully~',
|
||||
'Password has been changed~' => 'Password has been changed~',
|
||||
|
||||
@@ -44,6 +44,27 @@ return [
|
||||
'Parameter error' => '参数错误!',
|
||||
'Token expiration' => '登录态过期,请重新登录!',
|
||||
'Captcha error' => '验证码错误!',
|
||||
'ok' => '成功',
|
||||
'Missing parameters' => '参数缺失',
|
||||
'Invalid parameter format' => '参数格式错误',
|
||||
'Invalid parameter value' => '参数取值非法',
|
||||
'Missing auth-token' => '缺少 auth-token',
|
||||
'auth-token is invalid or expired' => 'auth-token 无效或已过期',
|
||||
'Invalid secret' => '密钥无效',
|
||||
'Invalid signature' => '签名错误',
|
||||
'Invalid timestamp' => '时间戳无效',
|
||||
'Invite code does not exist' => '邀请码不存在',
|
||||
'Registered successfully but login failed' => '注册成功但登录失败',
|
||||
'Incorrect account or password' => '账号或密码错误',
|
||||
'Login status has expired' => '登录状态已过期',
|
||||
'Game period does not exist' => '对局不存在',
|
||||
'Betting is closed' => '已封盘,禁止下注',
|
||||
'Insufficient balance' => '余额不足',
|
||||
'Duplicate request' => '重复请求(幂等冲突)',
|
||||
'System is busy, please try again later' => '系统繁忙,请稍后重试',
|
||||
'Current process does not allow this operation' => '当前流程不允许该操作',
|
||||
'Order does not exist' => '订单不存在',
|
||||
'Notice does not exist' => '公告不存在',
|
||||
// 会员中心 account
|
||||
'Data updated successfully~' => '资料更新成功~',
|
||||
'Password has been changed~' => '密码已修改~',
|
||||
|
||||
@@ -25,12 +25,20 @@ class LoadLangPack implements MiddlewareInterface
|
||||
|
||||
protected function loadLang(Request $request): void
|
||||
{
|
||||
// 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应)
|
||||
// 安装页等未发送 think-lang 时,回退到 Accept-Language 或配置默认值
|
||||
$headerLang = $request->header('think-lang');
|
||||
// 优先从请求头 lang / think-lang 获取前端选择的语言
|
||||
// 支持:lang=en / lang=zh / think-lang=en / think-lang=zh-cn
|
||||
// 未发送时回退到 Accept-Language 或配置默认值
|
||||
$headerLang = $request->header('lang', '');
|
||||
if ($headerLang === '') {
|
||||
$headerLang = $request->header('think-lang', '');
|
||||
}
|
||||
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
|
||||
if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) {
|
||||
$langSet = str_replace('_', '-', strtolower($headerLang));
|
||||
$normalizedHeaderLang = str_replace('_', '-', strtolower($headerLang));
|
||||
if ($normalizedHeaderLang === 'zh') {
|
||||
$normalizedHeaderLang = 'zh-cn';
|
||||
}
|
||||
if ($headerLang && in_array($normalizedHeaderLang, $allowLangList)) {
|
||||
$langSet = $normalizedHeaderLang;
|
||||
} else {
|
||||
$acceptLang = $request->header('accept-language', '');
|
||||
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use support\think\Model;
|
||||
|
||||
class GamePeriod extends Model
|
||||
{
|
||||
protected $name = 'game_period';
|
||||
protected $name = 'game_record';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
|
||||
39
app/common/model/GameRecord.php
Normal file
39
app/common/model/GameRecord.php
Normal 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;
|
||||
}
|
||||
}
|
||||
294
app/common/service/GameLiveService.php
Normal file
294
app/common/service/GameLiveService.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?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';
|
||||
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
|
||||
{
|
||||
$record = self::resolveRecord($recordId);
|
||||
if (!$record) {
|
||||
return [
|
||||
'record' => null,
|
||||
'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')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$candidates = [];
|
||||
$bestNumber = null;
|
||||
$bestLoss = null;
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
'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 {
|
||||
$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 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';
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
136
app/common/service/GameRecordService.php
Normal file
136
app/common/service/GameRecordService.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?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()];
|
||||
}
|
||||
}
|
||||
|
||||
public static function createNextRecordAfterDraw(): ?string
|
||||
{
|
||||
if (self::hasActiveRecord()) {
|
||||
return null;
|
||||
}
|
||||
return self::createNextRecordRow();
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
26
app/common/validate/GameRecord.php
Normal file
26
app/common/validate/GameRecord.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
20
app/process/GameLiveTicker.php
Normal file
20
app/process/GameLiveTicker.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\service\GameLiveService;
|
||||
use Workerman\Timer;
|
||||
|
||||
/**
|
||||
* 实时对局:按单局时长自动开奖
|
||||
*/
|
||||
class GameLiveTicker
|
||||
{
|
||||
public function onWorkerStart(): void
|
||||
{
|
||||
Timer::add(1, static function (): void {
|
||||
GameLiveService::tickAutoDraw();
|
||||
GameLiveService::publishSnapshot(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. "
|
||||
|
||||
10
config/plugin/webman/push/app.php
Normal file
10
config/plugin/webman/push/app.php
Normal 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:7979/plugin/webman/push/hook',
|
||||
'auth' => '/plugin/webman/push/auth'
|
||||
];
|
||||
21
config/plugin/webman/push/process.php
Normal file
21
config/plugin/webman/push/process.php
Normal 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'),
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
87
config/plugin/webman/push/route.php
Normal file
87
config/plugin/webman/push/route.php
Normal 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';
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ global $argv;
|
||||
return [
|
||||
'webman' => [
|
||||
'handler' => Http::class,
|
||||
'listen' => 'http://0.0.0.0:8787',
|
||||
'listen' => 'http://0.0.0.0:7979',
|
||||
'count' => cpu_count() * 4,
|
||||
'user' => '',
|
||||
'group' => '',
|
||||
@@ -41,6 +41,11 @@ return [
|
||||
'count' => 1,
|
||||
'reloadable' => false,
|
||||
],
|
||||
'gameLiveTicker' => [
|
||||
'handler' => app\process\GameLiveTicker::class,
|
||||
'count' => 1,
|
||||
'reloadable' => false,
|
||||
],
|
||||
|
||||
// File update detection and automatic reload
|
||||
'monitor' => [
|
||||
|
||||
@@ -68,6 +68,9 @@ Route::get('/install/index', function () use ($installLockFileForInstall, $insta
|
||||
// api/index
|
||||
Route::get('/api/index/index', [\app\api\controller\Index::class, 'index']);
|
||||
|
||||
// api/v1
|
||||
Route::get('/api/v1/authToken', [\app\api\controller\V1::class, 'authToken']);
|
||||
|
||||
// api/user(GET 获取配置,POST 登录/注册)
|
||||
Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']);
|
||||
Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']);
|
||||
@@ -108,6 +111,35 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class
|
||||
// api/ems
|
||||
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
|
||||
|
||||
// ==================== 移动端游戏接口(36字花) ====================
|
||||
Route::post('/api/auth/userRegister', [\app\api\controller\Auth::class, 'userRegister']);
|
||||
Route::post('/api/auth/userLogin', [\app\api\controller\Auth::class, 'userLogin']);
|
||||
Route::post('/api/auth/tokenRefresh', [\app\api\controller\Auth::class, 'tokenRefresh']);
|
||||
|
||||
Route::get('/api/account/userProfile', [\app\api\controller\Account::class, 'userProfile']);
|
||||
|
||||
Route::get('/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']);
|
||||
Route::get('/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']);
|
||||
Route::get('/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']);
|
||||
Route::get('/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']);
|
||||
Route::post('/api/game/betPlace', [\app\api\controller\Game::class, 'betPlace']);
|
||||
Route::post('/api/game/betRebet', [\app\api\controller\Game::class, 'betRebet']);
|
||||
Route::post('/api/game/autoBetCreate', [\app\api\controller\Game::class, 'autoBetCreate']);
|
||||
Route::post('/api/game/autoBetStop', [\app\api\controller\Game::class, 'autoBetStop']);
|
||||
Route::get('/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']);
|
||||
|
||||
Route::get('/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']);
|
||||
Route::get('/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']);
|
||||
|
||||
Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']);
|
||||
Route::get('/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']);
|
||||
Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']);
|
||||
Route::get('/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']);
|
||||
|
||||
Route::get('/api/notice/noticeList', [\app\api\controller\Notice::class, 'noticeList']);
|
||||
Route::get('/api/notice/noticeDetail', [\app\api\controller\Notice::class, 'noticeDetail']);
|
||||
Route::post('/api/notice/noticeConfirm', [\app\api\controller\Notice::class, 'noticeConfirm']);
|
||||
|
||||
// ==================== Admin 路由 ====================
|
||||
// Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,5 +7,5 @@ services:
|
||||
volumes:
|
||||
- "./:/app"
|
||||
ports:
|
||||
- "8787:8787"
|
||||
- "7979:7979"
|
||||
command: ["php", "start.php", "start" ]
|
||||
@@ -2,7 +2,7 @@
|
||||
# 将 server_name 和 root 改为实际值后,放入 nginx 的 conf.d 或 sites-available
|
||||
|
||||
upstream webman {
|
||||
server 127.0.0.1:8787;
|
||||
server 127.0.0.1:7979;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
|
||||
36
scripts/generate_auth_signature.php
Normal file
36
scripts/generate_auth_signature.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 执行方法
|
||||
* php scripts/generate_auth_signature.php
|
||||
* php scripts/generate_auth_signature.php 设备码 密钥 时间戳
|
||||
* php scripts/generate_auth_signature.php 1 564d14asdasd113e46542asd6das1a2a 1776331077
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$deviceId = $argv[1] ?? '1';
|
||||
$secret = $argv[2] ?? ((string) getenv('AUTH_TOKEN_SECRET') ?: '564d14asdasd113e46542asd6das1a2a');
|
||||
$timestamp = $argv[3] ?? (string) time();
|
||||
|
||||
$params = [
|
||||
'device_id' => (string) $deviceId,
|
||||
'secret' => (string) $secret,
|
||||
'timestamp' => (string) $timestamp,
|
||||
];
|
||||
|
||||
ksort($params);
|
||||
|
||||
$pairs = [];
|
||||
foreach ($params as $key => $value) {
|
||||
$pairs[] = $key . '=' . $value;
|
||||
}
|
||||
|
||||
$plain = implode('&', $pairs);
|
||||
$signature = strtoupper(md5($plain));
|
||||
|
||||
echo 'device_id: ' . $params['device_id'] . PHP_EOL;
|
||||
echo 'secret: ' . $params['secret'] . PHP_EOL;
|
||||
echo 'timestamp: ' . $params['timestamp'] . PHP_EOL;
|
||||
echo 'signature: ' . $signature . PHP_EOL;
|
||||
echo 'url: /api/v1/authToken?secret=' . rawurlencode($params['secret']) . '×tamp=' . rawurlencode($params['timestamp']) . '&device_id=' . rawurlencode($params['device_id']) . '&signature=' . rawurlencode($signature) . PHP_EOL;
|
||||
|
||||
@@ -4,5 +4,5 @@ ENV = 'development'
|
||||
# base路径
|
||||
VITE_BASE_PATH = './'
|
||||
|
||||
# 本地环境接口地址 - 用空字符串走同源,由 vite 代理到 8787,避免 CORS
|
||||
# 本地环境接口地址 - 用空字符串走同源,由 vite 代理到 7979,避免 CORS
|
||||
VITE_AXIOS_BASE_URL = ''
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
23
web/src/lang/backend/en/game/live.ts
Normal file
23
web/src/lang/backend/en/game/live.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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',
|
||||
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',
|
||||
}
|
||||
27
web/src/lang/backend/en/game/record.ts
Normal file
27
web/src/lang/backend/en/game/record.ts
Normal 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)',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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: '渠道',
|
||||
}
|
||||
|
||||
23
web/src/lang/backend/zh-cn/game/live.ts
Normal file
23
web/src/lang/backend/zh-cn/game/live.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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: '预估赔付',
|
||||
bet_stream_title: '实时压注记录',
|
||||
bet_id: '注单ID',
|
||||
user_id: '玩家ID',
|
||||
pick_numbers: '压注号码',
|
||||
unit_amount: '单号金额',
|
||||
streak_at_bet: '下注时连胜',
|
||||
}
|
||||
27
web/src/lang/backend/zh-cn/game/record.ts
Normal file
27
web/src/lang/backend/zh-cn/game/record.ts
Normal 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: '手动创建下一局',
|
||||
}
|
||||
@@ -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: '渠道',
|
||||
}
|
||||
|
||||
326
web/src/views/backend/game/live/index.vue
Normal file
326
web/src/views/backend/game/live/index.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="default-main">
|
||||
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
|
||||
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_disconnected')" 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>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-input-number v-model="manualNumber" :min="1" :max="36" :step="1" />
|
||||
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
||||
{{ t('game.live.btn_calc') }}
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_draw" @click="onDraw">
|
||||
{{ t('game.live.btn_draw') }}
|
||||
</el-button>
|
||||
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>{{ t('game.live.calc_result_number') }}: {{ calcResultNumber ?? '-' }}</span>
|
||||
<span>{{ t('game.live.calc_estimated_loss') }}: {{ calcEstimatedLoss }}</span>
|
||||
</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 { computed, onMounted, onUnmounted, 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
|
||||
period_seconds?: number
|
||||
bet_seconds?: number
|
||||
pick_max_number_count?: number
|
||||
remaining_seconds?: number
|
||||
bet_remaining_seconds?: number
|
||||
can_calculate?: boolean
|
||||
can_draw?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const pushConnected = ref(false)
|
||||
const lastPushAt = ref(0)
|
||||
const snapshot = reactive<Snapshot>({
|
||||
record: null,
|
||||
bets: [],
|
||||
candidate_numbers: [],
|
||||
ai_default_number: null,
|
||||
period_seconds: 30,
|
||||
bet_seconds: 20,
|
||||
pick_max_number_count: 36,
|
||||
remaining_seconds: 0,
|
||||
bet_remaining_seconds: 0,
|
||||
can_calculate: false,
|
||||
can_draw: false,
|
||||
})
|
||||
const calcLoading = ref(false)
|
||||
const drawLoading = ref(false)
|
||||
const manualNumber = ref<number | null>(1)
|
||||
const calcResultNumber = ref<number | null>(null)
|
||||
const calcEstimatedLoss = ref<string>('0.0000')
|
||||
|
||||
let pushClient: any = null
|
||||
let pushChannel: any = null
|
||||
let pollTimer: number | null = null
|
||||
let pushWatchdogTimer: number | null = 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
|
||||
snapshot.period_seconds = res.data.period_seconds ?? 30
|
||||
snapshot.bet_seconds = res.data.bet_seconds ?? 20
|
||||
snapshot.pick_max_number_count = 36
|
||||
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
|
||||
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
|
||||
snapshot.can_calculate = !!res.data.can_calculate
|
||||
snapshot.can_draw = !!res.data.can_draw
|
||||
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > 36) manualNumber.value = 1
|
||||
}
|
||||
} 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) {
|
||||
pushConnected.value = false
|
||||
return
|
||||
}
|
||||
const { url, app_key, channel, event } = cfgRes.data
|
||||
|
||||
try {
|
||||
await loadPushJs()
|
||||
} catch {
|
||||
pushConnected.value = false
|
||||
startPolling()
|
||||
return
|
||||
}
|
||||
const PushCtor = (window as any).Push
|
||||
if (!PushCtor) {
|
||||
pushConnected.value = false
|
||||
startPolling()
|
||||
return
|
||||
}
|
||||
try {
|
||||
pushClient = new PushCtor({ url, app_key })
|
||||
pushChannel = pushClient.subscribe(channel)
|
||||
pushConnected.value = false
|
||||
startPushWatchdog()
|
||||
stopPolling()
|
||||
pushChannel.on(event, (payload: anyObj) => {
|
||||
lastPushAt.value = Date.now()
|
||||
pushConnected.value = true
|
||||
snapshot.record = payload.record || null
|
||||
snapshot.bets = payload.bets || []
|
||||
snapshot.candidate_numbers = payload.candidate_numbers || []
|
||||
snapshot.ai_default_number = payload.ai_default_number ?? null
|
||||
snapshot.period_seconds = payload.period_seconds ?? 30
|
||||
snapshot.bet_seconds = payload.bet_seconds ?? 20
|
||||
snapshot.pick_max_number_count = 36
|
||||
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
|
||||
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
|
||||
snapshot.can_calculate = !!payload.can_calculate
|
||||
snapshot.can_draw = !!payload.can_draw
|
||||
})
|
||||
} catch {
|
||||
pushConnected.value = false
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
async function onCalculate() {
|
||||
if (!snapshot.record) return
|
||||
calcLoading.value = true
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/game.Live/calculate',
|
||||
method: 'post',
|
||||
data: {
|
||||
record_id: snapshot.record.id,
|
||||
manual_number: manualNumber.value,
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
if (res.code === 1 && res.data) {
|
||||
snapshot.candidate_numbers = res.data.candidate_numbers || []
|
||||
snapshot.ai_default_number = res.data.ai_default_number ?? null
|
||||
calcResultNumber.value = res.data.final_number ?? null
|
||||
calcEstimatedLoss.value = String(res.data.final_estimated_loss ?? '0.0000')
|
||||
}
|
||||
} finally {
|
||||
calcLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDraw() {
|
||||
if (!snapshot.record) return
|
||||
drawLoading.value = true
|
||||
try {
|
||||
await createAxios({
|
||||
url: '/admin/game.Live/draw',
|
||||
method: 'post',
|
||||
data: {
|
||||
record_id: snapshot.record.id,
|
||||
manual_number: manualNumber.value,
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
await loadSnapshot()
|
||||
} finally {
|
||||
drawLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const total = snapshot.remaining_seconds ?? 0
|
||||
const bet = snapshot.bet_remaining_seconds ?? 0
|
||||
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${total}s`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSnapshot()
|
||||
try {
|
||||
await initPush()
|
||||
} catch {
|
||||
pushConnected.value = false
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
try {
|
||||
if (pushClient && typeof pushClient.disconnect === 'function') {
|
||||
pushClient.disconnect()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
stopPolling()
|
||||
stopPushWatchdog()
|
||||
})
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer !== null) {
|
||||
return
|
||||
}
|
||||
pollTimer = window.setInterval(() => {
|
||||
void loadSnapshot()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPushWatchdog() {
|
||||
if (pushWatchdogTimer !== null) {
|
||||
return
|
||||
}
|
||||
pushWatchdogTimer = window.setInterval(() => {
|
||||
const state = pushClient && pushClient.connection ? String(pushClient.connection.state || '') : ''
|
||||
const stateConnected = state === 'connected' || state === 'connecting'
|
||||
const hasRecentPush = lastPushAt.value > 0 && Date.now() - lastPushAt.value <= 6000
|
||||
if (!stateConnected || !hasRecentPush) {
|
||||
pushConnected.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopPushWatchdog() {
|
||||
if (pushWatchdogTimer !== null) {
|
||||
window.clearInterval(pushWatchdogTimer)
|
||||
pushWatchdogTimer = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.result-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
89
web/src/views/backend/game/record/index.vue
Normal file
89
web/src/views/backend/game/record/index.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<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 />
|
||||
|
||||
<TableHeader
|
||||
:buttons="['refresh', '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 { onMounted, provide, 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 baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'game/record',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit'])
|
||||
|
||||
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)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss"></style>
|
||||
59
web/src/views/backend/game/record/popupForm.vue
Normal file
59
web/src/views/backend/game/record/popupForm.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules" :disabled="true">
|
||||
<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>
|
||||
</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>
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,9 +29,10 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => {
|
||||
open: VITE_OPEN != 'false',
|
||||
// 开发时把 /api、/admin、/install 代理到 webman,避免跨域
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8787', changeOrigin: true },
|
||||
'/admin': { target: 'http://localhost:8787', changeOrigin: true },
|
||||
'/install': { target: 'http://localhost:8787', changeOrigin: true },
|
||||
'/api': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
'/admin': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
'/install': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
'/plugin': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user