1.增加互斥锁:保证缓存和数据库数据一致性
2.增加消费队列,保证mysql数据的正常保存
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
47
app/common/service/GameHotDataCoordinator.php
Normal file
47
app/common/service/GameHotDataCoordinator.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 缓存与队列统一入口:落库后先同步回源 Redis,再入队幂等任务削峰。
|
||||
*/
|
||||
final class GameHotDataCoordinator
|
||||
{
|
||||
public static function afterUserCommitted(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
GameHotDataRedis::userReplaceCacheFromDb($userId);
|
||||
GameHotDataWriteQueue::enqueue([
|
||||
'op' => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
132
app/common/service/GameHotDataLock.php
Normal file
132
app/common/service/GameHotDataLock.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 热点实体互斥锁(Redis SET NX EX),按资源串行化写入,避免多管理员/多进程同时改同一缓存键对应的数据。
|
||||
*/
|
||||
final class GameHotDataLock
|
||||
{
|
||||
public const TYPE_USER = 'user';
|
||||
|
||||
public const TYPE_GAME_CONFIG = 'gc';
|
||||
|
||||
public const TYPE_GAME_RECORD = 'gr';
|
||||
|
||||
private const KEY_PREFIX = 'dfw:v1:lock:mut:';
|
||||
|
||||
/**
|
||||
* @return array{acquired: bool, token: ?string, redis_lock: bool}
|
||||
*/
|
||||
public static function tryAcquire(string $type, string $resourceKey): array
|
||||
{
|
||||
if ($resourceKey === '') {
|
||||
return ['acquired' => 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed>|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 等高频读)
|
||||
*/
|
||||
|
||||
66
app/common/service/GameHotDataWriteQueue.php
Normal file
66
app/common/service/GameHotDataWriteQueue.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 热点缓存回源刷新队列:LPUSH / RPOP,由独立进程消费,削峰 Redis 与 DB 回读。
|
||||
* 业务线程在落库后仍会同步刷新缓存;队列任务为幂等补偿,避免瞬时高峰打满连接。
|
||||
*/
|
||||
final class GameHotDataWriteQueue
|
||||
{
|
||||
public const OP_USER_REFRESH = 'user.refresh';
|
||||
|
||||
public const OP_GC_REFRESH = 'gc.refresh';
|
||||
|
||||
public const OP_GR_SYNC = 'gr.sync';
|
||||
|
||||
public static function queueListKey(): string
|
||||
{
|
||||
$k = config('game_hot_cache.queue_list_key', 'dfw:q:hot_data_write');
|
||||
return is_string($k) && $k !== '' ? $k : 'dfw:q:hot_data_write';
|
||||
}
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return filter_var(config('game_hot_cache.enable_cache_write_queue', true), FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
83
app/process/GameHotDataQueueConsumer.php
Normal file
83
app/process/GameHotDataQueueConsumer.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use app\common\service\GameHotDataWriteQueue;
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
use Workerman\Timer;
|
||||
|
||||
/**
|
||||
* 消费热点缓存回源队列(RPOP 批量),与 HTTP 进程分离以削峰。
|
||||
*/
|
||||
class GameHotDataQueueConsumer
|
||||
{
|
||||
public function onWorkerStart(): void
|
||||
{
|
||||
$interval = config('game_hot_cache.queue_consumer_tick_seconds', 0.1);
|
||||
if (!is_float($interval) && !is_int($interval)) {
|
||||
$interval = 0.1;
|
||||
}
|
||||
$interval = (float) $interval;
|
||||
if ($interval < 0.02) {
|
||||
$interval = 0.02;
|
||||
}
|
||||
|
||||
Timer::add($interval, static function (): void {
|
||||
if (!GameHotDataWriteQueue::enabled()) {
|
||||
return;
|
||||
}
|
||||
$key = GameHotDataWriteQueue::queueListKey();
|
||||
$batch = config('game_hot_cache.queue_consumer_batch', 80);
|
||||
$n = filter_var($batch, FILTER_VALIDATE_INT);
|
||||
if ($n === false || $n < 1) {
|
||||
$n = 80;
|
||||
}
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
try {
|
||||
$raw = Redis::rPop($key);
|
||||
} catch (Throwable) {
|
||||
break;
|
||||
}
|
||||
if ($raw === false || $raw === null || $raw === '') {
|
||||
break;
|
||||
}
|
||||
if (!is_string($raw)) {
|
||||
continue;
|
||||
}
|
||||
$job = json_decode($raw, true);
|
||||
if (!is_array($job)) {
|
||||
continue;
|
||||
}
|
||||
$op = $job['op'] ?? '';
|
||||
if (!is_string($op) || $op === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ($op === GameHotDataWriteQueue::OP_USER_REFRESH) {
|
||||
$uid = filter_var($job['id'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($uid !== false && $uid > 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) {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user