diff --git a/.env-example b/.env-example index 640566e..6002299 100644 --- a/.env-example +++ b/.env-example @@ -28,6 +28,14 @@ GAME_HOT_CACHE_ENABLED = true GAME_HOT_CACHE_TTL_GAME_CONFIG = 86400 GAME_HOT_CACHE_TTL_GAME_RECORD = 60 GAME_HOT_CACHE_TTL_USER = 90 +# 后台对同一用户钱包等互斥锁持有时间(秒),需小于业务事务最长耗时 +GAME_HOT_CACHE_ADMIN_USER_LOCK_TTL = 30 +# 是否启用热点缓存回源队列(webman 进程 gameHotDataQueueConsumer) +GAME_HOT_CACHE_ENABLE_WRITE_QUEUE = true +GAME_HOT_CACHE_QUEUE_LIST_KEY = dfw:q:hot_data_write +GAME_HOT_CACHE_QUEUE_MAX_LENGTH = 50000 +GAME_HOT_CACHE_QUEUE_CONSUMER_TICK = 0.1 +GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH = 80 # 移动端接口鉴权(/api/v1/authToken) AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a diff --git a/app/admin/controller/config/DepositTier.php b/app/admin/controller/config/DepositTier.php index 6ab2725..b142016 100644 --- a/app/admin/controller/config/DepositTier.php +++ b/app/admin/controller/config/DepositTier.php @@ -4,7 +4,8 @@ namespace app\admin\controller\config; use app\common\controller\Backend; use app\common\library\game\DepositTier as DepositTierLib; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; +use app\common\service\GameHotDataLock; use InvalidArgumentException; use support\think\Db; use support\Response; @@ -104,30 +105,39 @@ class DepositTier extends Backend } $now = time(); - try { - $exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); - if ($exists) { - Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([ - 'config_value' => $json, - 'value_type' => 'json', - 'update_time' => $now, - ]); - } else { - Db::name('game_config')->insert([ - 'config_key' => DepositTierLib::CONFIG_KEY, - 'config_value' => $json, - 'value_type' => 'json', - 'remark' => '充值档位 JSON 数组(独立表单维护)', - 'create_time' => $now, - 'update_time' => $now, - ]); - } - } catch (Throwable $e) { - return $this->error($e->getMessage()); + $resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositTierLib::CONFIG_KEY); + $lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey); + if (!$lock['acquired']) { + return $this->error('该配置正在被其他操作占用,请稍后再试'); } + try { + try { + $exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([ + 'config_value' => $json, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => DepositTierLib::CONFIG_KEY, + 'config_value' => $json, + 'value_type' => 'json', + 'remark' => '充值档位 JSON 数组(独立表单维护)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } - GameHotDataRedis::gameConfigForget(DepositTierLib::CONFIG_KEY); + GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositTierLib::CONFIG_KEY); - return $this->success(__('Saved successfully')); + return $this->success(__('Saved successfully')); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); + } } } diff --git a/app/admin/controller/config/StreakWinReward.php b/app/admin/controller/config/StreakWinReward.php index c9a92aa..e16bf7a 100644 --- a/app/admin/controller/config/StreakWinReward.php +++ b/app/admin/controller/config/StreakWinReward.php @@ -6,7 +6,8 @@ namespace app\admin\controller\config; use app\common\controller\Backend; use app\common\library\game\StreakWinReward as StreakWinRewardLib; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; +use app\common\service\GameHotDataLock; use support\think\Db; use support\Response; use Throwable; @@ -91,33 +92,42 @@ class StreakWinReward extends Backend } $encoded = StreakWinRewardLib::encodeForDb($payload); $now = time(); - Db::startTrans(); - try { - $exists = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find(); - if ($exists) { - Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->update([ - 'config_value' => $encoded, - 'update_time' => $now, - ]); - } else { - Db::name('game_config')->insert([ - 'config_key' => StreakWinRewardLib::CONFIG_KEY, - 'config_value' => $encoded, - 'value_type' => 'json', - 'remark' => '连胜奖励', - 'create_time' => $now, - 'update_time' => $now, - ]); - } - Db::commit(); - } catch (Throwable $e) { - Db::rollback(); - - return $this->error($e->getMessage()); + $resourceKey = GameHotDataLock::safeResourceKeyForConfig(StreakWinRewardLib::CONFIG_KEY); + $lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey); + if (!$lock['acquired']) { + return $this->error('该配置正在被其他操作占用,请稍后再试'); } - StreakWinRewardLib::clearCache(); - GameHotDataRedis::gameConfigForget(StreakWinRewardLib::CONFIG_KEY); + try { + Db::startTrans(); + try { + $exists = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->update([ + 'config_value' => $encoded, + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => StreakWinRewardLib::CONFIG_KEY, + 'config_value' => $encoded, + 'value_type' => 'json', + 'remark' => '连胜奖励', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); - return $this->success('保存成功'); + return $this->error($e->getMessage()); + } + StreakWinRewardLib::clearCache(); + GameHotDataCoordinator::afterGameConfigKeyCommitted(StreakWinRewardLib::CONFIG_KEY); + + return $this->success('保存成功'); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); + } } } diff --git a/app/admin/controller/config/ZiHuaDictionary.php b/app/admin/controller/config/ZiHuaDictionary.php index 46064f0..740d65b 100644 --- a/app/admin/controller/config/ZiHuaDictionary.php +++ b/app/admin/controller/config/ZiHuaDictionary.php @@ -3,7 +3,8 @@ namespace app\admin\controller\config; use app\common\controller\Backend; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; +use app\common\service\GameHotDataLock; use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib; use InvalidArgumentException; use support\think\Db; @@ -104,30 +105,39 @@ class ZiHuaDictionary extends Backend } $now = time(); - try { - $exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find(); - if ($exists) { - Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([ - 'config_value' => $json, - 'value_type' => 'json', - 'update_time' => $now, - ]); - } else { - Db::name('game_config')->insert([ - 'config_key' => ZiHuaDictionaryLib::CONFIG_KEY, - 'config_value' => $json, - 'value_type' => 'json', - 'remark' => '36字花字典 JSON 数组(独立表单维护)', - 'create_time' => $now, - 'update_time' => $now, - ]); - } - } catch (Throwable $e) { - return $this->error($e->getMessage()); + $resourceKey = GameHotDataLock::safeResourceKeyForConfig(ZiHuaDictionaryLib::CONFIG_KEY); + $lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey); + if (!$lock['acquired']) { + return $this->error('该配置正在被其他操作占用,请稍后再试'); } + try { + try { + $exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([ + 'config_value' => $json, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => ZiHuaDictionaryLib::CONFIG_KEY, + 'config_value' => $json, + 'value_type' => 'json', + 'remark' => '36字花字典 JSON 数组(独立表单维护)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } - GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY); + GameHotDataCoordinator::afterGameConfigKeyCommitted(ZiHuaDictionaryLib::CONFIG_KEY); - return $this->success(__('Saved successfully')); + return $this->success(__('Saved successfully')); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); + } } } diff --git a/app/admin/controller/game/ZiHuaDictionary.php b/app/admin/controller/game/ZiHuaDictionary.php index b001d8a..c145648 100644 --- a/app/admin/controller/game/ZiHuaDictionary.php +++ b/app/admin/controller/game/ZiHuaDictionary.php @@ -4,7 +4,8 @@ namespace app\admin\controller\game; use app\common\controller\Backend; use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; +use app\common\service\GameHotDataLock; use InvalidArgumentException; use support\think\Db; use support\Response; @@ -90,30 +91,39 @@ class ZiHuaDictionary extends Backend } $now = time(); - try { - $exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find(); - if ($exists) { - Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([ - 'config_value' => $json, - 'value_type' => 'json', - 'update_time' => $now, - ]); - } else { - Db::name('game_config')->insert([ - 'config_key' => ZiHuaDictionaryLib::CONFIG_KEY, - 'config_value' => $json, - 'value_type' => 'json', - 'remark' => '36字花字典 JSON 数组(独立表单维护)', - 'create_time' => $now, - 'update_time' => $now, - ]); - } - } catch (Throwable $e) { - return $this->error($e->getMessage()); + $resourceKey = GameHotDataLock::safeResourceKeyForConfig(ZiHuaDictionaryLib::CONFIG_KEY); + $lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey); + if (!$lock['acquired']) { + return $this->error('该配置正在被其他操作占用,请稍后再试'); } + try { + try { + $exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find(); + if ($exists) { + Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([ + 'config_value' => $json, + 'value_type' => 'json', + 'update_time' => $now, + ]); + } else { + Db::name('game_config')->insert([ + 'config_key' => ZiHuaDictionaryLib::CONFIG_KEY, + 'config_value' => $json, + 'value_type' => 'json', + 'remark' => '36字花字典 JSON 数组(独立表单维护)', + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } catch (Throwable $e) { + return $this->error($e->getMessage()); + } - GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY); + GameHotDataCoordinator::afterGameConfigKeyCommitted(ZiHuaDictionaryLib::CONFIG_KEY); - return $this->success(__('Saved successfully')); + return $this->success(__('Saved successfully')); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']); + } } } diff --git a/app/admin/controller/user/User.php b/app/admin/controller/user/User.php index e5c4cbe..feaf940 100644 --- a/app/admin/controller/user/User.php +++ b/app/admin/controller/user/User.php @@ -4,6 +4,8 @@ namespace app\admin\controller\user; use Throwable; use app\common\controller\Backend; +use app\common\service\GameHotDataCoordinator; +use app\common\service\GameHotDataRedis; use support\think\Db; use support\Response; use Webman\Http\Request as WebmanRequest; @@ -214,8 +216,8 @@ class User extends Backend } $userIdRaw = $request->post('user_id'); - $userId = is_numeric(strval($userIdRaw)) ? intval(strval($userIdRaw)) : 0; - if ($userId <= 0) { + $userId = filter_var($userIdRaw, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { return $this->error(__('Parameter error')); } @@ -243,69 +245,93 @@ class User extends Backend $remark = '后台管理员(' . $adminName . ')' . $actionText . $amountForRemark . '(值)'; } - $user = $this->model->where('id', $userId)->find(); - if (!$user) { - return $this->error(__('Record not found')); - } - $dataLimitAdminIds = $this->getDataLimitAdminIds(); - if ($dataLimitAdminIds && !in_array($user[$this->dataLimitField], $dataLimitAdminIds)) { - return $this->error(__('You have no permission')); + $lock = GameHotDataRedis::userAdminMutationLockTry($userId); + if (!$lock['acquired']) { + return $this->error('该用户正在被其他管理员操作(钱包/并发保存),请稍后再试'); } - $channelIdRaw = $user['channel_id'] ?? null; - $channelId = is_numeric(strval($channelIdRaw)) ? intval(strval($channelIdRaw)) : null; - $before = strval($user['coin'] ?? '0'); - $delta = self::normalizeAmountScale($amountText, 4); - if ($op === 'credit') { - $after = bcadd($before, $delta, 4); - $bizType = 'admin_credit'; - $direction = 1; - } else { - if (bccomp($before, $delta, 4) < 0) { - return $this->error('余额不足,扣点失败'); - } - $after = bcsub($before, $delta, 4); - $bizType = 'admin_deduct'; - $direction = 2; - } - - $now = time(); - $idem = 'admin_adjust_' . $userId . '_' . $this->auth->id . '_' . $now . '_' . random_int(1000, 9999); - Db::startTrans(); try { - Db::name('user')->where('id', $userId)->update([ - 'coin' => $after, - 'update_time' => $now, - ]); + $user = $this->model->where('id', $userId)->find(); + if (!$user) { + return $this->error(__('Record not found')); + } + $dataLimitAdminIds = $this->getDataLimitAdminIds(); + if ($dataLimitAdminIds && !in_array($user[$this->dataLimitField], $dataLimitAdminIds)) { + return $this->error(__('You have no permission')); + } - Db::name('user_wallet_record')->insert([ + $channelIdRaw = $user['channel_id'] ?? null; + $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT); + if ($channelId === false) { + $channelId = null; + } + + $before = strval($user['coin'] ?? '0'); + $delta = self::normalizeAmountScale($amountText, 4); + if ($op === 'credit') { + $after = bcadd($before, $delta, 4); + $bizType = 'admin_credit'; + $direction = 1; + } else { + if (bccomp($before, $delta, 4) < 0) { + return $this->error('余额不足,扣点失败'); + } + $after = bcsub($before, $delta, 4); + $bizType = 'admin_deduct'; + $direction = 2; + } + + $now = time(); + $idem = 'admin_adjust_' . $userId . '_' . $this->auth->id . '_' . $now . '_' . random_int(1000, 9999); + $operatorId = filter_var($this->auth->id, FILTER_VALIDATE_INT); + if ($operatorId === false) { + $operatorId = 0; + } + + Db::startTrans(); + try { + $affected = Db::name('user')->where('id', $userId)->where('coin', $before)->update([ + 'coin' => $after, + 'update_time' => $now, + ]); + if ($affected !== 1) { + Db::rollback(); + return $this->error('保存失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试'); + } + + Db::name('user_wallet_record')->insert([ + 'user_id' => $userId, + 'channel_id' => $channelId, + 'biz_type' => $bizType, + 'direction' => $direction, + 'amount' => $delta, + 'balance_before' => $before, + 'balance_after' => $after, + 'ref_type' => 'admin_user_wallet_adjust', + 'ref_id' => null, + 'idempotency_key' => $idem, + 'operator_admin_id' => $operatorId, + 'remark' => substr($remark, 0, 500), + 'create_time' => $now, + ]); + Db::commit(); + } catch (Throwable $e) { + Db::rollback(); + return $this->error($e->getMessage()); + } + + GameHotDataCoordinator::afterUserCommitted($userId); + + return $this->success('钱包调整成功', [ 'user_id' => $userId, - 'channel_id' => $channelId, - 'biz_type' => $bizType, - 'direction' => $direction, - 'amount' => $delta, - 'balance_before' => $before, - 'balance_after' => $after, - 'ref_type' => 'admin_user_wallet_adjust', - 'ref_id' => null, - 'idempotency_key' => $idem, - 'operator_admin_id' => intval(strval($this->auth->id)), - 'remark' => substr($remark, 0, 500), - 'create_time' => $now, + 'coin_before' => self::formatAmountForDisplay($before), + 'coin_after' => self::formatAmountForDisplay($after), + 'amount' => self::formatAmountForDisplay($delta), + 'op' => $op, ]); - Db::commit(); - } catch (Throwable $e) { - Db::rollback(); - return $this->error($e->getMessage()); + } finally { + GameHotDataRedis::userAdminMutationLockRelease($userId, $lock['token'], $lock['redis_lock']); } - - return $this->success('钱包调整成功', [ - 'user_id' => $userId, - 'coin_before' => self::formatAmountForDisplay($before), - 'coin_after' => self::formatAmountForDisplay($after), - 'amount' => self::formatAmountForDisplay($delta), - 'op' => $op, - ]); } private static function normalizeAmountScale(string $amount, int $scale): string diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php index 44f2950..458a4c9 100644 --- a/app/api/controller/Game.php +++ b/app/api/controller/Game.php @@ -8,6 +8,7 @@ use app\common\library\game\ZiHuaDictionary; use app\common\model\BetOrder; use app\common\model\GameRecord; use app\common\model\UserWalletRecord; +use app\common\service\GameHotDataCoordinator; use app\common\service\GameHotDataRedis; use app\common\service\UserPushService; use support\think\Db; @@ -175,6 +176,12 @@ class Game extends MobileBase return $this->mobileError(3002, 'Betting is closed'); } + $userIdRaw = $this->auth->id ?? null; + $userId = filter_var($userIdRaw, FILTER_VALIDATE_INT); + if ($userId === false || $userId <= 0) { + return $this->mobileError(1001, 'Missing parameters'); + } + $user = $this->auth->getUser(); if (bccomp((string) $user->coin, $totalAmount, 4) < 0) { return $this->mobileError(2001, 'Insufficient balance'); @@ -185,64 +192,101 @@ class Game extends MobileBase return $this->mobileError(3003, 'Duplicate request'); } - Db::startTrans(); + $lock = GameHotDataRedis::userAdminMutationLockTry($userId); + if (!$lock['acquired']) { + return $this->mobileError(5000, '该用户正在被其他管理员操作(钱包/并发保存),请稍后再试'); + } + 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, - '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(); - $uid = filter_var($user->id, FILTER_VALIDATE_INT); - if ($uid !== false) { - GameHotDataRedis::userForget($uid); + $coinRow = Db::name('user')->where('id', $userId)->field(['coin', 'channel_id', 'current_streak'])->find(); + if (!$coinRow) { + return $this->mobileError(5000, 'System is busy, please try again later'); } - UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [ + $before = (string) ($coinRow['coin'] ?? '0'); + if (bccomp($before, $totalAmount, 4) < 0) { + return $this->mobileError(2001, 'Insufficient balance'); + } + + $existsLocked = BetOrder::where('idempotency_key', $idempotencyKey)->find(); + if ($existsLocked) { + return $this->mobileError(3003, 'Duplicate request'); + } + + $channelIdRaw = $coinRow['channel_id'] ?? null; + $channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT); + if ($channelId === false) { + $channelId = null; + } + + $now = time(); + $after = bcsub($before, $totalAmount, 4); + $orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); + $streakAtBet = (int) ($coinRow['current_streak'] ?? 0); + + Db::startTrans(); + try { + $affected = Db::name('user')->where('id', $userId)->where('coin', $before)->update([ + 'coin' => $after, + 'update_time' => $now, + ]); + if ($affected !== 1) { + Db::rollback(); + return $this->mobileError(5000, '扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试'); + } + + UserWalletRecord::create([ + 'user_id' => $userId, + 'channel_id' => $channelId, + 'biz_type' => 'bet', + 'direction' => 2, + 'amount' => $totalAmount, + 'balance_before' => $before, + 'balance_after' => $after, + 'ref_type' => 'bet_order', + 'remark' => '移动端下注', + 'create_time' => $now, + ]); + BetOrder::create([ + 'period_id' => $period->id, + 'period_no' => $period->period_no, + 'user_id' => $userId, + 'channel_id' => $channelId, + 'pick_numbers' => $numbers, + 'total_amount' => $totalAmount, + 'streak_at_bet' => $streakAtBet, + 'is_auto' => 0, + 'status' => 1, + 'idempotency_key' => $idempotencyKey, + 'create_time' => $now, + 'update_time' => $now, + ]); + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]); + } + + GameHotDataCoordinator::afterUserCommitted($userId); + UserPushService::publish($userId, UserPushService::EVT_BET_ACCEPTED, [ 'order_no' => $orderNo, 'period_no' => (string) $period->period_no, 'status' => 'accepted', 'balance_after' => $after, 'total_amount' => $totalAmount, - 'current_streak' => (int) ($user->current_streak ?? 0), + 'current_streak' => $streakAtBet, ]); - } 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, - ]); + return $this->mobileSuccess([ + 'order_no' => $orderNo, + 'period_no' => $period->period_no, + 'status' => 'accepted', + 'locked_balance' => '0.0000', + 'balance_after' => $after, + 'current_streak' => $streakAtBet, + ]); + } finally { + GameHotDataRedis::userAdminMutationLockRelease($userId, $lock['token'], $lock['redis_lock']); + } } public function betMyOrders(Request $request): Response diff --git a/app/common/model/GameConfig.php b/app/common/model/GameConfig.php index 6833d12..34f7fdf 100644 --- a/app/common/model/GameConfig.php +++ b/app/common/model/GameConfig.php @@ -3,7 +3,7 @@ namespace app\common\model; use app\common\library\game\StreakWinReward; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; use support\think\Model; /** @@ -24,7 +24,7 @@ class GameConfig extends Model { $key = trim((string) ($model->getAttr('config_key') ?? '')); if ($key !== '') { - GameHotDataRedis::gameConfigForget($key); + GameHotDataCoordinator::afterGameConfigKeyCommitted($key); } if ($key === StreakWinReward::CONFIG_KEY) { StreakWinReward::clearCache(); @@ -35,7 +35,7 @@ class GameConfig extends Model { $key = trim((string) ($model->getAttr('config_key') ?? '')); if ($key !== '') { - GameHotDataRedis::gameConfigForget($key); + GameHotDataCoordinator::afterGameConfigKeyCommitted($key); } if ($key === StreakWinReward::CONFIG_KEY) { StreakWinReward::clearCache(); diff --git a/app/common/model/GameRecord.php b/app/common/model/GameRecord.php index 69771b2..bb2f44c 100644 --- a/app/common/model/GameRecord.php +++ b/app/common/model/GameRecord.php @@ -2,7 +2,7 @@ namespace app\common\model; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; use support\think\Model; class GameRecord extends Model @@ -46,12 +46,12 @@ class GameRecord extends Model public static function onAfterWrite(GameRecord $model): void { $id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT); - GameHotDataRedis::gameRecordForget($id === false ? null : $id); + GameHotDataCoordinator::afterGameRecordCommitted($id === false ? null : $id); } public static function onAfterDelete(GameRecord $model): void { $id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT); - GameHotDataRedis::gameRecordForget($id === false ? null : $id); + GameHotDataCoordinator::afterGameRecordCommitted($id === false ? null : $id); } } diff --git a/app/common/model/User.php b/app/common/model/User.php index 380d3d0..66f2bc5 100644 --- a/app/common/model/User.php +++ b/app/common/model/User.php @@ -2,7 +2,7 @@ namespace app\common\model; -use app\common\service\GameHotDataRedis; +use app\common\service\GameHotDataCoordinator; use support\think\Model; /** @@ -68,7 +68,7 @@ class User extends Model { $id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT); if ($id !== false && $id > 0) { - GameHotDataRedis::userForget($id); + GameHotDataCoordinator::afterUserCommitted($id); } } @@ -76,7 +76,7 @@ class User extends Model { $id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT); if ($id !== false && $id > 0) { - GameHotDataRedis::userForget($id); + GameHotDataCoordinator::afterUserCommitted($id); } } } diff --git a/app/common/service/GameBetSettleService.php b/app/common/service/GameBetSettleService.php index 7c101c7..a61e734 100644 --- a/app/common/service/GameBetSettleService.php +++ b/app/common/service/GameBetSettleService.php @@ -131,7 +131,7 @@ final class GameBetSettleService 'current_streak' => $next, 'update_time' => $now, ]); - GameHotDataRedis::userForget($userId); + GameHotDataCoordinator::afterUserCommitted($userId); } foreach ($aggregateByUser as $userId => $agg) { @@ -273,7 +273,7 @@ final class GameBetSettleService 'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow), 'update_time' => $now, ]); - GameHotDataRedis::userForget($userId); + GameHotDataCoordinator::afterUserCommitted($userId); } /** @@ -321,7 +321,7 @@ final class GameBetSettleService 'coin' => $after, 'update_time' => $now, ]); - GameHotDataRedis::userForget($userId); + GameHotDataCoordinator::afterUserCommitted($userId); return $after; } diff --git a/app/common/service/GameHotDataCoordinator.php b/app/common/service/GameHotDataCoordinator.php new file mode 100644 index 0000000..5bcd39f --- /dev/null +++ b/app/common/service/GameHotDataCoordinator.php @@ -0,0 +1,47 @@ + GameHotDataWriteQueue::OP_USER_REFRESH, + 'id' => $userId, + ]); + } + + public static function afterGameConfigKeyCommitted(string $configKey): void + { + if ($configKey === '') { + return; + } + GameHotDataRedis::gameConfigReplaceFromDb($configKey); + GameHotDataWriteQueue::enqueue([ + 'op' => GameHotDataWriteQueue::OP_GC_REFRESH, + 'key' => $configKey, + ]); + } + + /** + * @param int|null $recordId 有 id 时刷新该行并清除活跃/最新聚合键;null 时仅清除聚合键 + */ + public static function afterGameRecordCommitted(?int $recordId): void + { + GameHotDataRedis::gameRecordSyncCachesAfterDbWrite($recordId); + GameHotDataWriteQueue::enqueue([ + 'op' => GameHotDataWriteQueue::OP_GR_SYNC, + 'id' => $recordId, + ]); + } +} diff --git a/app/common/service/GameHotDataLock.php b/app/common/service/GameHotDataLock.php new file mode 100644 index 0000000..266860a --- /dev/null +++ b/app/common/service/GameHotDataLock.php @@ -0,0 +1,132 @@ + false, 'token' => null, 'redis_lock' => false]; + } + $lockKey = self::lockKey($type, $resourceKey); + $ttl = self::lockTtl(); + $token = bin2hex(random_bytes(16)); + try { + $client = Redis::connection()->client(); + if (!is_object($client) || !method_exists($client, 'set')) { + return ['acquired' => true, 'token' => null, 'redis_lock' => false]; + } + $ok = $client->set($lockKey, $token, ['nx', 'ex' => $ttl]); + if ($ok === true) { + return ['acquired' => true, 'token' => $token, 'redis_lock' => true]; + } + } catch (Throwable) { + return ['acquired' => true, 'token' => null, 'redis_lock' => false]; + } + + return ['acquired' => false, 'token' => null, 'redis_lock' => true]; + } + + /** + * 带短等待的重试(毫秒),用于对局写入与 ensureAiLocked 等可能交叉的路径。 + * + * @return array{acquired: bool, token: ?string, redis_lock: bool} + */ + public static function tryAcquireWithWait(string $type, string $resourceKey, int $maxWaitMs = 800): array + { + $deadline = (int) (microtime(true) * 1000) + max(0, $maxWaitMs); + while (true) { + $r = self::tryAcquire($type, $resourceKey); + if ($r['acquired']) { + return $r; + } + $now = (int) (microtime(true) * 1000); + if ($now >= $deadline) { + return $r; + } + usleep(25_000); + } + } + + public static function release(string $type, string $resourceKey, ?string $token, bool $redisLock): void + { + if ($resourceKey === '' || !$redisLock || $token === null || $token === '') { + return; + } + $key = self::lockKey($type, $resourceKey); + $script = <<<'LUA' +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +end +return 0 +LUA; + try { + $client = Redis::connection()->client(); + if (is_object($client) && method_exists($client, 'eval')) { + $client->eval($script, [$key, $token], 1); + } + } catch (Throwable) { + } + } + + /** + * @template T + * @param callable(): T $fn + * @return T|null + */ + public static function runExclusive(string $type, string $resourceKey, callable $fn): mixed + { + $lock = self::tryAcquire($type, $resourceKey); + if (!$lock['acquired']) { + return null; + } + try { + return $fn(); + } finally { + self::release($type, $resourceKey, $lock['token'], $lock['redis_lock']); + } + } + + public static function safeResourceKeyForConfig(string $configKey): string + { + return rtrim(strtr(base64_encode($configKey), '+/', '-_'), '='); + } + + public static function lockKeyFromConfigKey(string $configKey): string + { + return self::lockKey(self::TYPE_GAME_CONFIG, self::safeResourceKeyForConfig($configKey)); + } + + private static function lockKey(string $type, string $resourceKey): string + { + return self::KEY_PREFIX . $type . ':' . $resourceKey; + } + + private static function lockTtl(): int + { + $v = config('game_hot_cache.admin_user_mutation_lock_ttl', 30); + $n = filter_var($v, FILTER_VALIDATE_INT); + + return ($n === false || $n < 5) ? 30 : $n; + } +} diff --git a/app/common/service/GameHotDataRedis.php b/app/common/service/GameHotDataRedis.php index 953007a..d1d33a5 100644 --- a/app/common/service/GameHotDataRedis.php +++ b/app/common/service/GameHotDataRedis.php @@ -66,6 +66,45 @@ final class GameHotDataRedis self::redisDel(self::KEY_GC . $configKey); } + /** + * 按库中当前行覆盖 game_config 缓存(无行则删缓存) + */ + public static function gameConfigReplaceFromDb(string $configKey): void + { + if ($configKey === '' || !self::enabled()) { + return; + } + $row = Db::name('game_config')->where('config_key', $configKey)->find(); + if (!$row) { + self::gameConfigForget($configKey); + return; + } + $ttl = self::intConfig('ttl_game_config', 86400); + self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE)); + } + + /** + * 对局写入后:刷新指定 id 的行缓存,并删除「活跃局 / 最新局」聚合键以免脏读 + * + * @param int|null $id 可为 null(仅清聚合键) + */ + public static function gameRecordSyncCachesAfterDbWrite(?int $id): void + { + if (!self::enabled()) { + return; + } + if ($id !== null && $id > 0) { + $row = Db::name('game_record')->where('id', $id)->find(); + if ($row) { + $ttl = self::intConfig('ttl_game_record', 60); + self::redisSetEx(self::KEY_GR_ID . $id, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE)); + } else { + self::redisDel(self::KEY_GR_ID . $id); + } + } + self::redisDel(self::KEY_GR_ACTIVE, self::KEY_GR_LATEST); + } + /** * @return array|null */ @@ -212,6 +251,49 @@ final class GameHotDataRedis self::redisDel(self::KEY_USER . $userId); } + /** + * 从数据库读取最新 user 行并覆盖写入 Redis(与 DB 同事务后调用,保持缓存与库一致) + */ + public static function userReplaceCacheFromDb(int $userId): void + { + if ($userId <= 0 || !self::enabled()) { + return; + } + $row = Db::name('user')->where('id', $userId)->find(); + if (!$row) { + self::userForget($userId); + return; + } + $ttl = self::intConfig('ttl_user', 90); + self::redisSetEx(self::KEY_USER . $userId, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE)); + } + + /** + * 尝试获取「后台修改该用户」互斥锁(Redis SET NX EX)。 + * 与热点缓存开关无关:只要 Redis 可用即加锁;连接失败时降级为仅依赖数据库乐观锁(WHERE coin=)。 + * + * @return array{acquired: bool, token: ?string, redis_lock: bool} + */ + public static function userAdminMutationLockTry(int $userId): array + { + if ($userId <= 0) { + return ['acquired' => false, 'token' => null, 'redis_lock' => false]; + } + + return GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_USER, (string) $userId); + } + + /** + * 释放 userAdminMutationLockTry 取得的锁(仅当 redis_lock 且 token 非空) + */ + public static function userAdminMutationLockRelease(int $userId, ?string $token, bool $redisLock): void + { + if ($userId <= 0) { + return; + } + GameHotDataLock::release(GameHotDataLock::TYPE_USER, (string) $userId, $token, $redisLock); + } + /** * 用缓存行构造已存在库的 User(供 Auth 等高频读) */ diff --git a/app/common/service/GameHotDataWriteQueue.php b/app/common/service/GameHotDataWriteQueue.php new file mode 100644 index 0000000..01d2b1f --- /dev/null +++ b/app/common/service/GameHotDataWriteQueue.php @@ -0,0 +1,66 @@ + $job 须含 op 字段 + */ + public static function enqueue(array $job): void + { + if (!self::enabled()) { + return; + } + if (!isset($job['op']) || !is_string($job['op']) || $job['op'] === '') { + return; + } + $job['v'] = 1; + $job['ts'] = time(); + $max = self::maxQueueLength(); + try { + if ($max > 0) { + $len = Redis::lLen(self::queueListKey()); + if (is_int($len) && $len >= $max) { + Redis::rPop(self::queueListKey()); + } + } + Redis::lPush(self::queueListKey(), json_encode($job, JSON_UNESCAPED_UNICODE)); + } catch (Throwable) { + } + } + + public static function maxQueueLength(): int + { + $v = config('game_hot_cache.queue_max_length', 50000); + $n = filter_var($v, FILTER_VALIDATE_INT); + + return ($n === false || $n < 0) ? 50000 : $n; + } +} diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php index 4ad0482..c8407d0 100644 --- a/app/common/service/GameLiveService.php +++ b/app/common/service/GameLiveService.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace app\common\service; use app\common\library\game\StreakWinReward; +use app\common\service\GameHotDataCoordinator; +use app\common\service\GameHotDataLock; use support\think\Db; use Throwable; use Webman\Push\Api; @@ -253,13 +255,22 @@ final class GameLiveService return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')]; } - self::ensureAiLocked((int) $record['id']); - Db::name('game_record')->where('id', (int) $record['id'])->update([ - 'pending_draw_number' => $manualNumber, - 'update_time' => time(), - ]); - GameHotDataRedis::gameRecordForget((int) $record['id']); - self::publishSnapshot(null); + $rid = (int) $record['id']; + $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 1200); + if (!$lock['acquired']) { + return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; + } + try { + self::ensureAiLocked($rid); + Db::name('game_record')->where('id', $rid)->update([ + 'pending_draw_number' => $manualNumber, + 'update_time' => time(), + ]); + GameHotDataCoordinator::afterGameRecordCommitted($rid); + self::publishSnapshot(null); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); + } return [ 'ok' => true, @@ -289,8 +300,14 @@ final class GameLiveService return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')]; } - self::ensureAiLocked((int) $record['id']); - $record = self::reloadRecord((int) $record['id']); + $rid = (int) $record['id']; + $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000); + if (!$lock['acquired']) { + return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')]; + } + try { + self::ensureAiLocked($rid); + $record = self::reloadRecord($rid); if (!$record) { return ['ok' => false, 'msg' => __('No active game in progress')]; } @@ -346,10 +363,10 @@ final class GameLiveService return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()]; } - GameHotDataRedis::gameRecordForget((int) $record['id']); + GameHotDataCoordinator::afterGameRecordCommitted($rid); try { - GameRecordStatService::refreshForRecordId((int) $record['id']); + GameRecordStatService::refreshForRecordId($rid); } catch (Throwable) { } JackpotPushService::publishHits($settleOut['jackpot_hits'] ?? []); @@ -365,6 +382,9 @@ final class GameLiveService 'estimated_loss' => $finalLoss, 'payout_until' => $payoutUntil, ]; + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']); + } } /** @@ -382,22 +402,30 @@ final class GameLiveService return; } $id = (int) $row['id']; - Db::startTrans(); - try { - Db::name('game_record')->where('id', $id)->update([ - 'status' => 4, - 'payout_until' => null, - 'update_time' => time(), - ]); - GameRecordService::createNextRecordAfterDraw(); - Db::commit(); - } catch (Throwable) { - Db::rollback(); + $lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, 2000); + if (!$lock['acquired']) { return; } - GameHotDataRedis::gameRecordForget($id); - GameRecordStatService::refreshForRecordId($id); - self::publishSnapshot(null); + try { + Db::startTrans(); + try { + Db::name('game_record')->where('id', $id)->update([ + 'status' => 4, + 'payout_until' => null, + 'update_time' => time(), + ]); + GameRecordService::createNextRecordAfterDraw(); + Db::commit(); + } catch (Throwable) { + Db::rollback(); + return; + } + GameHotDataCoordinator::afterGameRecordCommitted($id); + GameRecordStatService::refreshForRecordId($id); + self::publishSnapshot(null); + } finally { + GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']); + } } public static function tickAutoDraw(): void @@ -609,7 +637,7 @@ final class GameLiveService 'status' => 1, 'update_time' => time(), ]); - GameHotDataRedis::gameRecordForget($recordId); + GameHotDataCoordinator::afterGameRecordCommitted($recordId); $record['status'] = 1; self::publishPublicPeriodLocked($record); } @@ -629,7 +657,7 @@ final class GameLiveService $update['status'] = 1; } Db::name('game_record')->where('id', $recordId)->update($update); - GameHotDataRedis::gameRecordForget($recordId); + GameHotDataCoordinator::afterGameRecordCommitted($recordId); $record = array_merge($record, $update); if ($st === 0) { self::publishPublicPeriodLocked($record); diff --git a/app/common/service/GameRecordService.php b/app/common/service/GameRecordService.php index c9abad1..3ad2a4e 100644 --- a/app/common/service/GameRecordService.php +++ b/app/common/service/GameRecordService.php @@ -99,7 +99,7 @@ final class GameRecordService 'create_time' => $now, 'update_time' => $now, ]); - GameHotDataRedis::gameRecordForget(); + GameHotDataCoordinator::afterGameRecordCommitted(null); return $periodNo; } @@ -123,7 +123,7 @@ final class GameRecordService 'remark' => $remark, 'update_time' => $now, ]); - GameHotDataRedis::gameConfigForget($key); + GameHotDataCoordinator::afterGameConfigKeyCommitted($key); return; } Db::name('game_config')->insert([ @@ -134,6 +134,6 @@ final class GameRecordService 'create_time' => $now, 'update_time' => $now, ]); - GameHotDataRedis::gameConfigForget($key); + GameHotDataCoordinator::afterGameConfigKeyCommitted($key); } } diff --git a/app/common/service/GameRecordStatService.php b/app/common/service/GameRecordStatService.php index b2cb2b1..1f6b2c9 100644 --- a/app/common/service/GameRecordStatService.php +++ b/app/common/service/GameRecordStatService.php @@ -33,7 +33,7 @@ final class GameRecordStatService 'winner_user_count' => 0, 'update_time' => $now, ]); - GameHotDataRedis::gameRecordForget($recordId); + GameHotDataCoordinator::afterGameRecordCommitted($recordId); return; } @@ -79,7 +79,7 @@ final class GameRecordStatService 'winner_user_count' => count($winnerUserIds), 'update_time' => $now, ]); - GameHotDataRedis::gameRecordForget($recordId); + GameHotDataCoordinator::afterGameRecordCommitted($recordId); } /** diff --git a/app/process/GameHotDataQueueConsumer.php b/app/process/GameHotDataQueueConsumer.php new file mode 100644 index 0000000..397d7d6 --- /dev/null +++ b/app/process/GameHotDataQueueConsumer.php @@ -0,0 +1,83 @@ + 0) { + GameHotDataRedis::userReplaceCacheFromDb($uid); + } + continue; + } + if ($op === GameHotDataWriteQueue::OP_GC_REFRESH) { + $k = $job['key'] ?? ''; + if (is_string($k) && $k !== '') { + GameHotDataRedis::gameConfigReplaceFromDb($k); + } + continue; + } + if ($op === GameHotDataWriteQueue::OP_GR_SYNC) { + $rid = filter_var($job['id'] ?? null, FILTER_VALIDATE_INT); + GameHotDataRedis::gameRecordSyncCachesAfterDbWrite($rid === false ? null : $rid); + } + } catch (Throwable) { + } + } + }); + } +} diff --git a/config/game_hot_cache.php b/config/game_hot_cache.php index 37b22b1..e34e8de 100644 --- a/config/game_hot_cache.php +++ b/config/game_hot_cache.php @@ -25,4 +25,16 @@ return [ 'ttl_game_record' => $envInt('GAME_HOT_CACHE_TTL_GAME_RECORD', 60), /** user 行缓存(秒),余额/连胜变更会主动删除 */ 'ttl_user' => $envInt('GAME_HOT_CACHE_TTL_USER', 90), + /** 后台对同一用户互斥操作(如钱包加减点)的 Redis 锁 TTL(秒) */ + 'admin_user_mutation_lock_ttl' => $envInt('GAME_HOT_CACHE_ADMIN_USER_LOCK_TTL', 30), + /** 是否启用缓存回源队列(独立进程消费) */ + 'enable_cache_write_queue' => filter_var(env('GAME_HOT_CACHE_ENABLE_WRITE_QUEUE', true), FILTER_VALIDATE_BOOLEAN), + /** 队列 Redis List 键名 */ + 'queue_list_key' => env('GAME_HOT_CACHE_QUEUE_LIST_KEY', 'dfw:q:hot_data_write'), + /** 队列最大长度,超出则丢弃最旧一条再入队 */ + 'queue_max_length' => $envInt('GAME_HOT_CACHE_QUEUE_MAX_LENGTH', 50000), + /** 消费者 Timer 间隔(秒) */ + 'queue_consumer_tick_seconds' => (float) (env('GAME_HOT_CACHE_QUEUE_CONSUMER_TICK', '0.1')), + /** 每轮最多处理任务数 */ + 'queue_consumer_batch' => $envInt('GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH', 80), ]; diff --git a/config/process.php b/config/process.php index 0d49ea4..ac045c3 100644 --- a/config/process.php +++ b/config/process.php @@ -46,6 +46,12 @@ return [ 'count' => 1, 'reloadable' => false, ], + /** 热点缓存写队列消费者(与 GAME_HOT_CACHE_ENABLE_WRITE_QUEUE 配合) */ + 'gameHotDataQueueConsumer' => [ + 'handler' => app\process\GameHotDataQueueConsumer::class, + 'count' => 1, + 'reloadable' => false, + ], // File update detection and automatic reload 'monitor' => [ diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index 30d4c95..12e4cc9 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -279,7 +279,7 @@ ### 8.4 非功能 - **Redis(已落地)** - - **热点数据缓存**:`app/common/service/GameHotDataRedis.php`,依赖 `webman/redis` 与 `config/redis.php`。缓存对象:`user` 行(鉴权与余额类读路径)、`game_config` 行(按 `config_key`)、`game_record`(活跃局、按 id、最新一条)。写库后通过模型事件或服务内 **`gameRecordForget` / `gameConfigForget` / `userForget`** 失效;环境变量 `GAME_HOT_CACHE_*` 控制开关与 TTL(见 `.env-example`)。 + - **热点数据缓存**:`app/common/service/GameHotDataRedis.php`,依赖 `webman/redis` 与 `config/redis.php`。缓存对象:`user` 行(鉴权与余额类读路径)、`game_config` 行(按 `config_key`)、`game_record`(活跃局、按 id、最新一条)。**写库后一致性**:统一经 **`GameHotDataCoordinator`**(`afterUserCommitted` / `afterGameConfigKeyCommitted` / `afterGameRecordCommitted`)先 **同步回源**(`userReplaceCacheFromDb`、`gameConfigReplaceFromDb`、`gameRecordSyncCachesAfterDbWrite`),再视配置将幂等任务写入 **`GameHotDataWriteQueue`**,由进程 **`GameHotDataQueueConsumer`** 批量消化以削峰。互斥:**`GameHotDataLock`** 用于同一用户、同一 `game_config` 键、同一对局记录等资源的并发写串行化(后台钱包加减点、独立配置表单、开奖相关服务、**移动端 `betPlace` 扣款**等与后台共用用户锁)。环境变量 `GAME_HOT_CACHE_*` 控制开关、TTL、队列开关与背压(见 `.env-example` 与 `config/game_hot_cache.php`)。 - **规划/增强**(未替代上述):当前期注单池、近 30 期开奖列表缓存、纯 Redis 状态机等,可按压测需求迭代。 - **与 `CACHE_DRIVER`**:`config/cache.php` 默认 `file` 仅影响 **系统配置表 `config`**(如 `get_sys_config`),**不**控制 `GameHotDataRedis`。 - MQ:派彩异步消费**幂等**(如 `period_id` + 用户 + 业务单号)。 @@ -349,6 +349,7 @@ | V1.11 | 2026-04-16 | 落地运营公告:`operation_notice`、`user_notice_read` 表与菜单目录 `operation`(运营公告、用户阅读记录) | | V1.12 | 2026-04-18 | 下注口径:`bet_order` 仅保留 `total_amount`(整笔压注),删除 `unit_amount`、`pick_count`;DDL 与 Phinx 迁移 `20260418270000_bet_order_drop_unit_amount_pick_count` 对齐 | | V1.14 | 2026-04-20 | 服务端 Redis 热点缓存:`GameHotDataRedis`(`user` / `game_config` / `game_record`),`config/game_hot_cache.php` 与 `.env-example` 中 `GAME_HOT_CACHE_*`;更新 §1.1、§2.1、§8.4、第九章风险与依赖、附录 `user`;与 `CACHE_DRIVER`(系统 `config` 表文件缓存)区分说明 | +| V1.15 | 2026-04-20 | 热点写路径收口:`GameHotDataCoordinator` + `GameHotDataLock` + `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`;文档与实现对齐(替代仅 `*Forget` 描述);移动端 `betPlace` 与后台钱包共用用户互斥锁及 `coin` 乐观更新 | --- @@ -381,7 +382,7 @@ | `total_deposit_coin` / `total_withdraw_coin` | 累计充值入账、累计提现出账(提现受理时累加);与「净充值」口径配合校验提现门槛 | | `bet_flow_coin` | 打码量/流水(历史名 `total_valid_bet_coin`,2026-04-18 迁移重命名);开奖结算成功时按注单 `total_amount` 1:1 累加,提现门槛 `bet_flow_coin >= (total_deposit_coin - total_withdraw_coin) × withdraw_bet_flow_ratio` | | `risk_flags` | 风控位(如禁止登录/下注/提现,按位定义) | -| `current_streak` / `last_bet_period_no` | 连胜与期号兜底;**读路径**可通过 **`GameHotDataRedis` 缓存 `user` 行** 降低压力,**写路径**仍以下注/结算后落库为准并主动失效缓存 | +| `current_streak` / `last_bet_period_no` | 连胜与期号兜底;**读路径**可通过 **`GameHotDataRedis` 缓存 `user` 行** 降低压力,**写路径**以下注/结算后落库为准,并经 **`GameHotDataCoordinator::afterUserCommitted`** 同步刷新 Redis 与写队列入队 | | `admin_id` | 归属子代理管理员 | | 其余 | 账号、头像、状态、时间戳等见 DDL | diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index 0473bd1..ad2c978 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -105,19 +105,21 @@ **服务端缓存覆盖(与移动端直接相关的读路径)** -- **用户**:会员鉴权 `Auth::init` 优先读 Redis 中的 `user` 行快照,未命中再查库并回填;**余额、连胜、打码量等变更**后服务端会删除该用户缓存,下次请求重新加载。 -- **游戏配置**:`game_config` 按 `config_key` 缓存(如字典、`pick_max_number_count`、`withdraw_bet_flow_ratio`、连胜配置等);后台或脚本直连 `Db` 更新配置时,保存逻辑需调用 **`GameHotDataRedis::gameConfigForget($key)`**(已实现于模型事件及部分后台控制器),否则最长延迟为 TTL。 -- **对局**:当前活跃局、按 `id` 加载的局、以及「最新一条 `game_record`」(与 `order id desc limit 1` 一致)缓存;**开奖、封盘、新建下一期**等写库后会 **`gameRecordForget`**,保证关键状态尽快一致。 +- **用户**:会员鉴权优先读 Redis 中的 `user` 行快照,未命中再查库并回填。**余额、连胜、打码量等变更**落库后,统一经 **`GameHotDataCoordinator::afterUserCommitted($userId)`**:先 **`GameHotDataRedis::userReplaceCacheFromDb`** 与 DB 对齐,再向 Redis 写队列投递幂等刷新任务(见 `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`),用于削峰而非替代同步回源。 +- **游戏配置**:`game_config` 按 `config_key` 缓存。后台直连 `Db` 更新时须 **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`**(模型 `GameConfig` 事件与独立表单控制器已接入);独立保存接口在写入前对同一 `config_key` 使用 **`GameHotDataLock`(`TYPE_GAME_CONFIG`)** 互斥。勿仅删除缓存键而不回源,否则最长不一致窗口为 TTL。 +- **对局**:当前活跃局、按 `id` 的局、最新一条 `game_record` 等;写库后经 **`GameHotDataCoordinator::afterGameRecordCommitted`** 同步刷新相关 Redis 键并入队。开奖/封盘等路径另可按记录 id 使用 **`GameHotDataLock`(`TYPE_GAME_RECORD`)** 串行化。 **环境变量(示例见仓库根目录 `.env-example`)** - `GAME_HOT_CACHE_ENABLED`:是否启用上述 Redis 热点缓存(`false` 时全程回退数据库)。 -- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`:各类缓存 TTL(秒);仍应以**写后失效**为主,TTL 仅作兜底。 +- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`:各类缓存 TTL(秒);**以写后同步回源为主**,TTL 仅作兜底。 +- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` 及队列长度、消费进程间隔等:控制写库后的**幂等刷新任务**是否入队及背压策略(见 `config/game_hot_cache.php`)。 **一致性提示(联调/测试)** -- 在 TTL 窗口内,若存在**绕过模型事件、且未调用 forget** 的手工 SQL 改库,可能出现短时不一致;生产环境应避免。 -- 客户端仍可按 **§3.2 `dictionaryList` 的 `version`** 做本地缓存;服务端字典数据另有 Redis 加速,二者可同时存在。 +- 任何绕过协调入口、只改 DB 不调用 **`GameHotDataCoordinator`** 的手工脚本,都可能与 Redis 短期不一致;生产环境应避免。 +- **`POST /api/game/betPlace`** 扣款路径使用与后台钱包加减点相同的 **用户维度 Redis 锁**(`GameHotDataRedis::userAdminMutationLockTry`)及 **`WHERE coin = ?` 条件更新**,与并发派彩/后台调账互斥;失败时返回 **§4.2** 所列中文说明。 +- 客户端仍可按 **§3.2 `dictionaryList` 的 `version`** 做本地缓存;服务端字典另有 Redis 加速,二者可同时存在。 --- @@ -289,6 +291,10 @@ - `balance_after`:string(含义:下单后余额) - `current_streak`:int(含义:下单后连胜快照) +**可能错误码(补充)**(其余见文档头部错误码分段;扣款与缓存一致性强相关): + +- `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。 + > 说明:一键重复上一注、自动托管开启/停止均由前端控制,客户端在相应时机调用 `/api/game/betPlace` 即可完成,不再提供独立接口。 ### 4.3 查询我的下注记录(最近1个月)