1.优化实时对局页面样式以及自动创建下一局和作废本局的记录

2.新增派彩达到game_config.jackpot_max_amount必须审核才能发放
3.新增游戏对局记录-查看游玩记录btn
3.备份MySQL数据库
This commit is contained in:
2026-04-28 18:25:44 +08:00
parent 4ea83d2818
commit 687257adaa
22 changed files with 945 additions and 208 deletions

View File

@@ -14,6 +14,14 @@ use Throwable;
*/
final class GameBetSettleService
{
public const PLAY_STATUS_PENDING_DRAW = 1;
public const PLAY_STATUS_SETTLED = 2;
public const PLAY_STATUS_REFUNDED = 3;
public const PLAY_STATUS_RETURNED = 4;
public const PLAY_STATUS_PENDING_REVIEW = 5;
public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount';
/**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
*
@@ -28,9 +36,10 @@ final class GameBetSettleService
}
$now = time();
$jackpotMaxAmount = self::jackpotMaxAmount();
$bets = Db::name('bet_order')
->where('period_id', $recordId)
->where('status', 1)
->where('status', self::PLAY_STATUS_PENDING_DRAW)
->order('id', 'asc')
->select()
->toArray();
@@ -60,14 +69,16 @@ final class GameBetSettleService
$win = self::computeWinAmount($bet, $resultNumber);
$jackpot = '0.00';
$needReview = self::shouldRequireJackpotReview($win, $jackpotMaxAmount);
$nextStatus = $needReview ? self::PLAY_STATUS_PENDING_REVIEW : self::PLAY_STATUS_SETTLED;
$affected = Db::name('bet_order')
->where('id', $betId)
->where('status', 1)
->where('status', self::PLAY_STATUS_PENDING_DRAW)
->update([
'win_amount' => $win,
'jackpot_extra_amount' => $jackpot,
'status' => 2,
'status' => $nextStatus,
'update_time' => $now,
]);
@@ -91,8 +102,8 @@ final class GameBetSettleService
}
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
if (bccomp($win, '0', 2) > 0) {
$paid = self::creditUserPayout($bet, $betId, $win, $now);
if (!$needReview && bccomp($win, '0', 2) > 0) {
$paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩');
if ($paid !== null) {
$balanceAfter = $paid;
}
@@ -154,6 +165,109 @@ final class GameBetSettleService
return ['jackpot_hits' => $jackpotHits];
}
/**
* 大奖审核通过后派彩(幂等):仅当 play_record.status=待审核 且 win_amount>=阈值时执行。
*
* @return array{ok: bool, msg: string, balance_after?: string}
*/
public static function approveJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark = ''): array
{
if ($playRecordId <= 0) {
return ['ok' => false, 'msg' => __('Parameter error')];
}
// 兼容bet_order 可能是 VIEW且 * 列表会固化;审核字段始终以 game_play_record 为准
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
if (!is_array($row)) {
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
}
if (!is_array($row)) {
return ['ok' => false, 'msg' => __('Record not found')];
}
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
return ['ok' => false, 'msg' => __('This record does not require review')];
}
$winAmount = bcadd((string) ($row['win_amount'] ?? '0'), '0', 2);
$threshold = self::jackpotMaxAmount();
if (!self::shouldRequireJackpotReview($winAmount, $threshold)) {
return ['ok' => false, 'msg' => __('This record does not meet jackpot review threshold')];
}
$userId = isset($row['user_id']) && is_numeric($row['user_id']) ? (int) $row['user_id'] : 0;
if ($userId <= 0) {
return ['ok' => false, 'msg' => __('Order is missing user info')];
}
$now = time();
$balanceAfter = null;
if (bccomp($winAmount, '0', 2) > 0) {
$balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩');
}
$reviewRemark = trim($remark);
if ($reviewRemark === '') {
$reviewRemark = 'approved';
}
$update = [
'status' => self::PLAY_STATUS_SETTLED,
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
'review_time' => $now,
'review_remark' => substr($reviewRemark, 0, 255),
'update_time' => $now,
];
// 优先写主表
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
// 兼容写 view 场景(若存在且可写)
try {
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
} catch (\Throwable) {
}
$out = ['ok' => true, 'msg' => __('Approved')];
if (is_string($balanceAfter)) {
$out['balance_after'] = $balanceAfter;
}
return $out;
}
/**
* 大奖审核拒绝:仅当 status=待审核 才可操作;拒绝后不派彩,标记为已退回(status=4)。
*
* @return array{ok: bool, msg: string}
*/
public static function rejectJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark): array
{
if ($playRecordId <= 0) {
return ['ok' => false, 'msg' => __('Parameter error')];
}
$reason = trim($remark);
if ($reason === '') {
return ['ok' => false, 'msg' => __('Please provide reject reason')];
}
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
if (!is_array($row)) {
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
}
if (!is_array($row)) {
return ['ok' => false, 'msg' => __('Record not found')];
}
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
return ['ok' => false, 'msg' => __('This record does not require review')];
}
$now = time();
$update = [
'status' => self::PLAY_STATUS_RETURNED,
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
'review_time' => $now,
'review_remark' => substr($reason, 0, 255),
'update_time' => $now,
];
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
try {
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
} catch (\Throwable) {
}
return ['ok' => true, 'msg' => __('Rejected')];
}
/**
* 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。
*/
@@ -176,7 +290,7 @@ final class GameBetSettleService
}
$pending = Db::name('bet_order')
->where('period_id', $rid)
->where('status', 1)
->where('status', self::PLAY_STATUS_PENDING_DRAW)
->count();
if ($pending === 0) {
continue;
@@ -251,7 +365,7 @@ final class GameBetSettleService
/**
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
*/
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark): ?string
{
$userId = (int) ($bet['user_id'] ?? 0);
if ($userId <= 0) {
@@ -284,8 +398,8 @@ final class GameBetSettleService
'ref_type' => 'bet_order',
'ref_id' => $betId,
'idempotency_key' => $idem,
'operator_admin_id' => null,
'remark' => '压注派彩',
'operator_admin_id' => $operatorAdminId,
'remark' => $remark !== '' ? $remark : '压注派彩',
'create_time' => $now,
]);
@@ -304,4 +418,35 @@ final class GameBetSettleService
return $after;
}
private static function jackpotMaxAmount(): string
{
// 结算属于高频长驻进程逻辑:为避免 GameHotDataRedis::$gcLocal 进程内静态缓存导致阈值更新不生效,
// 这里直接读库拿最新值(本方法在 settleBetsForDraw 中仅调用一次)。
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
if (!is_array($row)) {
return '0.00';
}
$raw = $row['config_value'] ?? null;
if ($raw === null || $raw === '') {
return '0.00';
}
$v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
if ($v === '' || !is_numeric($v)) {
return '0.00';
}
$normalized = bcadd($v, '0', 2);
if (bccomp($normalized, '0', 2) <= 0) {
return '0.00';
}
return $normalized;
}
private static function shouldRequireJackpotReview(string $winAmount, string $threshold): bool
{
if (bccomp($threshold, '0', 2) <= 0) {
return false;
}
return bccomp($winAmount, $threshold, 2) >= 0;
}
}

View File

@@ -47,30 +47,25 @@ final class GameHotDataRedis
if ($configKey === '') {
return null;
}
if (array_key_exists($configKey, self::$gcLocal)) {
$cachedLocal = self::$gcLocal[$configKey];
return is_array($cachedLocal) ? $cachedLocal : null;
}
// game_config 为全局配置,多进程/多 worker 间必须强一致;
// 因此不使用进程内本地缓存gcLocal避免某个进程读到旧值导致前端回弹/行为冲突。
if (self::enabled()) {
$cached = self::redisGet(self::KEY_GC . $configKey);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
self::$gcLocal[$configKey] = $decoded;
return $decoded;
}
}
}
$row = Db::name('game_config')->where('config_key', $configKey)->find();
if (!$row) {
self::$gcLocal[$configKey] = null;
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
self::$gcLocal[$configKey] = $row;
return $row;
}
@@ -88,17 +83,24 @@ final class GameHotDataRedis
*/
public static function gameConfigReplaceFromDb(string $configKey): void
{
if ($configKey === '' || !self::enabled()) {
if ($configKey === '') {
return;
}
// 无论是否启用 Redis 热点缓存,都要刷新进程内缓存,避免同一 worker 读到旧值
$row = Db::name('game_config')->where('config_key', $configKey)->find();
if (!$row) {
self::gameConfigForget($configKey);
self::$gcLocal[$configKey] = null;
if (self::enabled()) {
self::redisDel(self::KEY_GC . $configKey);
}
return;
}
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
self::$gcLocal[$configKey] = $row;
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
}
/**

View File

@@ -202,7 +202,9 @@ final class GameLiveService
GameHotDataCoordinator::afterUserCommitted($uid);
}
}
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
// 异常对局作废后:自动暂停游戏,不自动创建新一期;需管理员手动开启「游戏运行」才会重新开局
GameRecordService::setAutoCreateEnabled(false);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
self::publishSnapshot(null);
Log::info('game live startup marked abnormal and refunded', [
'record_id' => $recordId,
@@ -733,6 +735,7 @@ final class GameLiveService
$refundedUserIds = [];
try {
$now = time();
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []];
Db::startTrans();
try {
$refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now);
@@ -750,9 +753,9 @@ final class GameLiveService
Db::rollback();
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
}
GameRecordService::setLiveRuntimeEnabled(false);
GameRecordService::setAutoCreateEnabled(false);
GameHotDataCoordinator::afterGameRecordCommitted($rid);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
foreach ($refundedUserIds as $uid) {
if ($uid > 0) {
GameHotDataCoordinator::afterUserCommitted($uid);
@@ -764,6 +767,7 @@ final class GameLiveService
'ok' => true,
'msg' => __('Period voided'),
'record' => self::reloadRecord($rid),
'refund' => $refund,
];
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
@@ -780,13 +784,14 @@ final class GameLiveService
}
/**
* @return array{user_ids:list<int>,order_count:int,total_amount:string}
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
*/
private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
{
$userIdSet = [];
$orderCount = 0;
$totalAmount = '0.00';
$orderIds = [];
$bets = Db::name('bet_order')
->where('period_id', $periodId)
->where('status', 1)
@@ -806,6 +811,8 @@ final class GameLiveService
'status' => 3,
'update_time' => $now,
]);
$orderCount++;
$orderIds[] = $betId;
continue;
}
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
@@ -832,7 +839,7 @@ final class GameLiveService
UserWalletRecord::create([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'bet_void',
'biz_type' => 'void_refund',
'direction' => 1,
'amount' => $total,
'balance_before' => $before,
@@ -844,6 +851,7 @@ final class GameLiveService
$userIdSet[$userId] = true;
$orderCount++;
$totalAmount = bcadd($totalAmount, $total, 2);
$orderIds[] = $betId;
}
$out = [];
@@ -855,6 +863,7 @@ final class GameLiveService
'user_ids' => $out,
'order_count' => $orderCount,
'total_amount' => $totalAmount,
'order_ids' => $orderIds,
];
}

View File

@@ -13,9 +13,6 @@ final class GameRecordService
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
/** 后台「游戏实时对局」运行开关0=暂停自动开奖与派彩后自动创建下一期1=运行 */
public const KEY_LIVE_RUNTIME = 'game_live_runtime_enabled';
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public static function getConfigBool(string $key): bool
@@ -53,9 +50,6 @@ final class GameRecordService
public static function tickAutoCreate(): void
{
if (!self::isLiveRuntimeEnabled()) {
return;
}
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
@@ -86,7 +80,8 @@ final class GameRecordService
public static function createNextRecordAfterDraw(): ?string
{
if (!self::isLiveRuntimeEnabled()) {
// 派彩结束后是否自动开新局:由 period_auto_create_enabled 控制
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return null;
}
if (self::hasActiveRecord()) {
@@ -96,29 +91,16 @@ final class GameRecordService
}
/**
* 未配置键时视为开启(兼容旧库未跑迁移)。
* 实时对局页「自动创建下一局」开关(兼容旧命名 runtime)。
*/
public static function isLiveRuntimeEnabled(): bool
{
$row = GameHotDataRedis::gameConfigRow(self::KEY_LIVE_RUNTIME);
if ($row === null) {
return true;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
return self::getConfigBool(self::KEY_AUTO_CREATE);
}
public static function setLiveRuntimeEnabled(bool $enabled): void
{
$now = time();
$v = $enabled ? '1' : '0';
self::upsertConfig(
self::KEY_LIVE_RUNTIME,
$v,
'int',
'后台「游戏实时对局」运行开关0=维护禁止下注、结束后不自动开新期当局仍自动开奖并结算1=运行',
$now
);
self::setAutoCreateEnabled($enabled);
}
/**
@@ -126,6 +108,9 @@ final class GameRecordService
*/
public static function bootstrapPeriodWhenRuntimeEnabled(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActiveRecord()) {
return;
}
@@ -135,6 +120,13 @@ final class GameRecordService
}
}
public static function setAutoCreateEnabled(bool $enabled): void
{
$now = time();
$v = $enabled ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $v, 'int', '是否允许自动创建下一局(全局仅一局)', $now);
}
private static function createNextRecordRow(): string
{
$periodNo = self::generatePeriodNo();