Files
webman-buildadmin/app/common/service/GameLiveStuckDiagnostic.php

365 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use support\Log;
use support\Redis;
use support\think\Db;
use Throwable;
/**
* 游戏实时对局「卡住」诊断:独立日志通道 game_live_stuck便于 grep 排查。
*
* 写入runtime/logs/game-live-stuck-YYYY-MM-DD.log
* 调用Log::channel('game_live_stuck')->warning(...)
*/
final class GameLiveStuckDiagnostic
{
private const LOG_CHANNEL = 'game_live_stuck';
/** 同 phase+期 30 秒内不重复刷日志 */
private const THROTTLE_KEY_PREFIX = 'dfw:v1:log:game_live_stuck:';
private const THROTTLE_SECONDS = 30;
private const LAST_PHASE_KEY_PREFIX = 'dfw:v1:log:game_live_stuck:last:';
/** @var array{record_id?:int,period_no?:string,ok?:bool,msg?:string,at?:int}|null */
private static ?array $lastDrawAttempt = null;
private const STATUS_LABELS = [
0 => '下注中',
1 => '已封盘',
2 => '算票中(遗留)',
3 => '派彩中',
4 => '已结束',
5 => '已作废',
];
/**
* 记录最近一次自动开奖尝试tickAutoDraw → drawResult
*
* @param array{ok?:bool,msg?:string} $out
*/
public static function noteDrawAttempt(int $recordId, string $periodNo, array $out): void
{
if ($recordId <= 0) {
return;
}
self::$lastDrawAttempt = [
'record_id' => $recordId,
'period_no' => $periodNo,
'ok' => (bool) ($out['ok'] ?? false),
'msg' => is_string($out['msg'] ?? null) ? (string) $out['msg'] : '',
'at' => time(),
];
if ($out['ok'] ?? false) {
return;
}
$msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : '';
if ($msg === '') {
return;
}
self::emit('draw_attempt_failed', [
'record_id' => $recordId,
'period_no' => $periodNo,
'draw_ok' => false,
'draw_msg' => $msg,
'phase_label' => '自动开奖 drawResult 返回失败',
'hint' => self::hintForDrawMessage($msg),
], $recordId);
}
/**
* 在 recoverLiveRoundState结单 + 自动开奖)之后巡检当前进行中局。
*/
public static function inspectAfterRecovery(): void
{
try {
$row = Db::name('game_record')
->whereIn('status', [0, 1, 2, 3])
->order('id', 'desc')
->find();
if (!is_array($row)) {
self::maybeLogRecovered();
return;
}
$recordId = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT);
if ($recordId === false || $recordId <= 0) {
return;
}
$diagnosis = self::diagnoseRow($row);
if ($diagnosis === null) {
self::maybeLogRecovered($recordId);
return;
}
self::rememberLastPhase($recordId, $diagnosis['phase']);
self::emit($diagnosis['phase'], $diagnosis['context'], $recordId);
} catch (Throwable $e) {
self::emit('inspect_error', [
'phase_label' => '卡住巡检异常',
'error' => $e->getMessage(),
'hint' => '检查数据库与 Redis 连接',
], 0);
}
}
/**
* 即时上报(如 finalize 拿不到锁),仍受 30s 节流。
*
* @param array<string, mixed> $context
*/
public static function report(string $phase, array $context, int $recordId = 0): void
{
$context['phase_label'] = $context['phase_label'] ?? self::phaseLabel($phase);
self::emit($phase, $context, $recordId);
}
/**
* @param array<string, mixed> $row
* @return null|array{phase: string, context: array<string, mixed>}
*/
private static function diagnoseRow(array $row): ?array
{
$recordId = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT);
if ($recordId === false || $recordId <= 0) {
return null;
}
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
$status = filter_var($row['status'] ?? -1, FILTER_VALIDATE_INT);
if ($status === false) {
$status = -1;
}
$periodStartAt = filter_var($row['period_start_at'] ?? 0, FILTER_VALIDATE_INT);
if ($periodStartAt === false) {
$periodStartAt = 0;
}
$now = time();
$elapsed = $periodStartAt > 0 ? max(0, $now - $periodStartAt) : 0;
$periodSeconds = self::getConfigInt('period_seconds', 30);
$payoutSeconds = self::getConfigInt('payout_seconds', 3);
$abnormalAfter = $periodSeconds + $payoutSeconds + 10;
$resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
if ($resultNumber === false) {
$resultNumber = 0;
}
$payoutUntil = filter_var($row['payout_until'] ?? 0, FILTER_VALIDATE_INT);
if ($payoutUntil === false) {
$payoutUntil = 0;
}
$pendingBetCount = (int) Db::name('bet_order')
->where('period_id', $recordId)
->where('status', GameBetSettleService::PLAY_STATUS_PENDING_DRAW)
->count();
$base = [
'record_id' => $recordId,
'period_no' => $periodNo,
'db_status' => $status,
'db_status_label' => self::STATUS_LABELS[$status] ?? '未知',
'elapsed_sec' => $elapsed,
'period_seconds' => $periodSeconds,
'payout_seconds' => $payoutSeconds,
'result_number' => $resultNumber > 0 ? $resultNumber : null,
'payout_until' => $payoutUntil > 0 ? $payoutUntil : null,
'payout_until_at' => $payoutUntil > 0 ? date('Y-m-d H:i:s', $payoutUntil) : null,
'ai_locked_number' => filter_var($row['ai_locked_number'] ?? 0, FILTER_VALIDATE_INT) ?: null,
'pending_draw_number' => filter_var($row['pending_draw_number'] ?? 0, FILTER_VALIDATE_INT) ?: null,
'pending_bet_count' => $pendingBetCount,
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
'auto_create_enabled' => GameRecordService::isAutoCreateEnabled(),
'last_draw_attempt' => self::$lastDrawAttempt,
'server_time' => date('Y-m-d H:i:s', $now),
];
if ($status === 2) {
return [
'phase' => 'legacy_settling_status',
'context' => array_merge($base, [
'phase_label' => '遗留 status=2 算票中,自动开奖不会处理',
'stuck_sec' => $elapsed,
'hint' => '将 game_record.status 改为 0/1 或 3/4或重启后依赖启动自愈检查是否有人工改库',
]),
];
}
if (in_array($status, [0, 1], true)) {
if ($elapsed < $periodSeconds) {
return null;
}
$stuckSec = $elapsed - $periodSeconds;
if ($elapsed > $abnormalAfter && $resultNumber <= 0) {
return [
'phase' => 'await_abnormal_void',
'context' => array_merge($base, [
'phase_label' => '已超异常作废阈值但仍未开奖/作废',
'stuck_sec' => $stuckSec,
'abnormal_after_sec' => $abnormalAfter,
'hint' => '应触发 markAbnormalAndRefundOnStartup检查 gameLiveTicker 与 Redis 锁 dfw:v1:lock:mut:gr:' . $recordId,
]),
];
}
return [
'phase' => 'await_auto_draw',
'context' => array_merge($base, [
'phase_label' => '下注/封盘期已过开奖时刻但未进入派彩',
'stuck_sec' => $stuckSec,
'hint' => '确认 gameLiveTicker 进程在跑;查 Redis 锁与 webman 日志 tickAutoDrawHTTP snapshot 不会触发开奖',
]),
];
}
if ($status === 3) {
$payoutExpired = $payoutUntil <= 0 || $payoutUntil <= $now;
if ($payoutExpired) {
return [
'phase' => 'payout_finalize_pending',
'context' => array_merge($base, [
'phase_label' => '派彩宽限期已结束但未结单(status→4)',
'stuck_sec' => $payoutUntil > 0 ? max(0, $now - $payoutUntil) : $elapsed,
'hint' => 'finalizePayoutGrace 未成功;查 finalizePayoutGrace: lock not acquired 与 Redis 锁',
]),
];
}
if ($pendingBetCount > 0 && $resultNumber > 0) {
return [
'phase' => 'settle_pending',
'context' => array_merge($base, [
'phase_label' => '已开奖但仍有待结算注单',
'stuck_sec' => max(0, $now - ($periodStartAt + $periodSeconds)),
'hint' => '查 drawResult settle after lock failed必要时补偿结算或重推',
]),
];
}
if ($payoutUntil > $now && $pendingBetCount > 0 && $resultNumber <= 0) {
return [
'phase' => 'payout_without_result',
'context' => array_merge($base, [
'phase_label' => '派彩中但无有效开奖号码',
'stuck_sec' => max(0, $now - ($periodStartAt + $periodSeconds)),
'hint' => '数据异常status=3 但 result_number 无效,需人工核对 game_record',
]),
];
}
}
return null;
}
/**
* @param array<string, mixed> $context
*/
private static function emit(string $phase, array $context, int $recordId): void
{
$signature = $phase . ':' . ($recordId > 0 ? (string) $recordId : 'global');
if (!self::shouldEmit($signature)) {
return;
}
$payload = array_merge([
'phase' => $phase,
'phase_label' => self::phaseLabel($phase),
], $context);
Log::channel(self::LOG_CHANNEL)->warning('game_live_stuck', $payload);
}
private static function shouldEmit(string $signature): bool
{
try {
$key = self::THROTTLE_KEY_PREFIX . $signature;
$ok = Redis::set($key, '1', ['nx', 'ex' => self::THROTTLE_SECONDS]);
return $ok !== false && $ok !== null;
} catch (Throwable) {
return true;
}
}
private static function rememberLastPhase(int $recordId, string $phase): void
{
try {
Redis::setEx(self::LAST_PHASE_KEY_PREFIX . $recordId, 3600, $phase);
} catch (Throwable) {
}
}
private static function maybeLogRecovered(int $recordId = 0): void
{
if ($recordId <= 0) {
return;
}
try {
$key = self::LAST_PHASE_KEY_PREFIX . $recordId;
$last = Redis::get($key);
if (!is_string($last) || $last === '') {
return;
}
Redis::del($key);
$sig = 'recovered:' . $recordId;
if (!self::shouldEmit($sig)) {
return;
}
Log::channel(self::LOG_CHANNEL)->info('game_live_recovered', [
'phase' => 'recovered',
'phase_label' => '对局已从卡住状态恢复',
'record_id' => $recordId,
'previous_phase' => $last,
'server_time' => date('Y-m-d H:i:s'),
]);
} catch (Throwable) {
}
}
private static function phaseLabel(string $phase): string
{
return match ($phase) {
'await_auto_draw' => '待自动开奖',
'await_abnormal_void' => '待异常作废',
'payout_finalize_pending' => '待派彩结单',
'payout_finalize_lock' => '派彩结单拿不到锁',
'settle_pending' => '待结算注单',
'payout_without_result' => '派彩无开奖号',
'legacy_settling_status' => '遗留算票状态',
'draw_attempt_failed' => '开奖尝试失败',
'inspect_error' => '巡检异常',
default => $phase,
};
}
private static function hintForDrawMessage(string $msg): string
{
if (str_contains($msg, 'Another operation is in progress')) {
return 'Redis 行锁竞争或锁未释放,键名 dfw:v1:lock:mut:gr:{record_id}500ms 内未拿到锁则静默重试';
}
if (str_contains($msg, 'Lock wait timeout') || str_contains($msg, '1205')) {
return 'InnoDB 锁等待超时,检查是否有长事务占用 game_record / bet_order';
}
if (str_contains($msg, 'countdown has not ended')) {
return '服务端认为倒计时未结束,核对 period_start_at 与 period_seconds 配置';
}
return '见 draw_msg 与 webman 日志 tickAutoDraw: drawResult failed';
}
private static function getConfigInt(string $key, int $default): int
{
$row = GameHotDataRedis::gameConfigRow($key);
if (!$row) {
return $default;
}
$v = $row['config_value'] ?? null;
if ($v === null || $v === '') {
return $default;
}
if (!is_numeric((string) $v)) {
return $default;
}
$parsed = filter_var($v, FILTER_VALIDATE_INT);
return $parsed !== false ? $parsed : $default;
}
}