#!/usr/bin/env php > Storage/log/auto_period.log 2>&1 * * 或者直接填: * /usr/bin/php /www/wwwroot/你的项目目录/cron/auto_period_task.php * * 【工作流程】 * 每分钟执行一次,对每个启用了自动开期的游戏: * 1. 检查是否有活跃期号 * 2. 没有 → 开启新一期 * 3. 有 → 检查是否到封盘时间 → 封盘 * 4. 检查是否到开奖时间 → 开奖 * 5. 已开奖 → 结算 → 开新一期 * * 【安全机制】 * - 文件锁防止并发执行 * - 数据库事务保证原子性 * - SELECT FOR UPDATE 防止并发扣款 */ // ============================ // 环境初始化 // ============================ error_reporting(E_ALL); ini_set('display_errors', 0); date_default_timezone_set('Asia/Shanghai'); // 自动检测项目根目录 $rootDir = dirname(__DIR__); chdir($rootDir); // 加载依赖 require_once $rootDir . '/Db/Database.php'; require_once $rootDir . '/Db/Medoo.php'; require_once $rootDir . '/App/Core/GameAlgorithmInterface.php'; require_once $rootDir . '/App/Core/GameFactory.php'; require_once $rootDir . '/App/Core/PK10Algorithm.php'; require_once $rootDir . '/App/Core/DiceAlgorithm.php'; require_once $rootDir . '/App/Core/XocDiaAlgorithm.php'; require_once $rootDir . '/App/Core/SettingsHelper.php'; use Db\Database; use App\Core\GameFactory; // ============================ // 日志 // ============================ $logDir = $rootDir . '/Storage/log'; if (!is_dir($logDir)) { @mkdir($logDir, 0755, true); } function logInfo($msg) { echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL; } function logToDb($db, $gameId, $periodId, $action, $message) { try { $db->insert('auto_period_log', [ 'game_id' => $gameId, 'period_id' => $periodId, 'action' => $action, 'message' => $message, 'created_at' => date('Y-m-d H:i:s'), ]); } catch (\Throwable $e) { // 日志表可能不存在,忽略 } } // ============================ // 文件锁防并发 // ============================ $lockFile = $logDir . '/auto_period_task.lock'; $lockHandle = fopen($lockFile, 'w'); if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { logInfo('⚠ 任务已在运行中,本次跳过'); exit(0); } // 写入 PID fwrite($lockHandle, (string)getmypid()); logInfo('========== 自动开期任务开始 =========='); try { $db = new Database(); // 获取所有启用自动开期的游戏 $games = $db->select('games', '*', [ 'status' => 1, 'auto_period_enabled' => 1 ]); if (empty($games)) { logInfo('没有启用自动开期的游戏,退出'); goto cleanup; } logInfo('发现 ' . count($games) . ' 个启用自动开期的游戏'); foreach ($games as $game) { processGame($db, $game); } logInfo('========== 自动开期任务完成 =========='); } catch (\Throwable $e) { logInfo('❌ 致命错误: ' . $e->getMessage()); logInfo($e->getTraceAsString()); } cleanup: flock($lockHandle, LOCK_UN); fclose($lockHandle); @unlink($lockFile); exit(0); // ============================ // 处理单个游戏 // ============================ function processGame($db, $game) { $gameId = (int)$game['id']; $gameName = $game['name']; $gameType = $game['type'] ?? 'pk10'; $periodDuration = max(60, (int)($game['period_duration'] ?? 300)); // 最少60秒 $lockBeforeEnd = max(5, (int)($game['lock_before_end'] ?? 30)); // 最少5秒 logInfo("--- 处理游戏: [{$gameName}] (ID:{$gameId}, 类型:{$gameType}, 周期:{$periodDuration}秒) ---"); // 获取当前活跃期号 $activePeriod = $db->get('periods', '*', [ 'game_id' => $gameId, 'status' => ['pending', 'locked', 'drawn'], 'ORDER' => ['id' => 'DESC'] ]); if (!$activePeriod) { logInfo(" 无活跃期号,开启新一期"); $newId = doStartNewPeriod($db, $game, $periodDuration); if ($newId) { logToDb($db, $gameId, $newId, 'start', '自动开启新期号'); } return; } $periodId = (int)$activePeriod['id']; $periodNum = $activePeriod['period_number']; $status = $activePeriod['status']; $startTime = strtotime($activePeriod['start_time'] ?? $activePeriod['created_at']); $elapsed = time() - $startTime; logInfo(" 当前期号: {$periodNum} | 状态: {$status} | 已过 {$elapsed}/{$periodDuration} 秒"); // 获取算法类 $algoClass = GameFactory::getAlgorithm($gameType); // === 阶段1:封盘 + 预开奖 === if ($status === 'pending' && $elapsed >= ($periodDuration - $lockBeforeEnd)) { logInfo(" → 执行封盘 + 预开奖"); $preResult = doPreDraw($db, $periodId, $gameId, $algoClass); $updateData = ['status' => 'locked', 'updated_at' => date('Y-m-d H:i:s')]; if ($preResult) { $updateData['result'] = json_encode($preResult); } $db->update('periods', $updateData, ['id' => $periodId]); // 写入结果存储 if ($preResult) { $resultTable = $algoClass::getResultTable(); $storageData = $algoClass::formatResultForStorage($preResult, $periodId); if ($resultTable) { $db->delete($resultTable, ['period_id' => $periodId]); $db->insert($resultTable, $storageData); } else { $db->update('periods', $storageData, ['id' => $periodId]); } logInfo(" ✓ 预开奖: " . json_encode($preResult)); } logToDb($db, $gameId, $periodId, 'lock', '自动封盘+预开奖'); $status = 'locked'; } // === 阶段2:正式开奖(仅更新状态为 drawn) === if ($status === 'locked' && $elapsed >= $periodDuration) { logInfo(" → 正式标记开奖"); $existingResult = $db->get('periods', 'result', ['id' => $periodId]); if ($existingResult) { $db->update('periods', [ 'status' => 'drawn', 'draw_time' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ], ['id' => $periodId]); logInfo(" ✓ 使用预开奖结果标记 drawn"); } else { doDraw($db, $periodId, $gameId, $algoClass); } // 推送开奖结果到 bot 群组 $drawResult = json_decode($db->get('periods', 'result', ['id' => $periodId]) ?: '[]', true); if (!empty($drawResult)) { $pk10Row = $db->get('pk10_results', '*', ['period_id' => $periodId]); $championSum = $pk10Row ? (int)$pk10Row['champion_sum'] : ($drawResult[0] + $drawResult[1]); queueBotPush($db, $gameId, 'draw', [ 'event' => 'period_drawn', 'period_id' => $periodId, 'period_number' => $periodNum, 'result' => $drawResult, 'champion_sum' => $championSum, 'manual' => false, ], ['remind_draw_result' => 1], $periodNum); } logToDb($db, $gameId, $periodId, 'draw', '自动开奖'); $status = 'drawn'; // fall through to settle immediately } // === 阶段3:结算 + 开新期 === if ($status === 'drawn') { $period = $db->get('periods', '*', ['id' => $periodId]); if ($period && $period['status'] === 'drawn') { logInfo(" → 执行结算"); doSettle($db, $period, $algoClass, $gameType); logToDb($db, $gameId, $periodId, 'settle', '自动结算'); logInfo(" → 结算完毕,开新一期"); $newId = doStartNewPeriod($db, $game, $periodDuration); if ($newId) { logToDb($db, $gameId, $newId, 'start', '结算后自动开启新期号'); } } } } // ============================ // 开启新一期 // ============================ function doStartNewPeriod($db, $game, $periodDuration) { $gameId = (int)$game['id']; $gameName = $game['name']; // 生成期号 $now = new \DateTime(); $dateStr = $now->format('Ymd'); $prefix = "G{$gameId}{$dateStr}"; try { $lastPeriod = $db->get('periods', 'period_number', [ 'period_number[~]' => $prefix . '%', 'ORDER' => ['period_number' => 'DESC'] ]); if ($lastPeriod) { $lastSeq = substr($lastPeriod, strlen($prefix)); $newSeq = (int)$lastSeq + 1; $seqStr = str_pad((string)$newSeq, 4, '0', STR_PAD_LEFT); } else { $seqStr = '0001'; } $periodNumber = $prefix . $seqStr; } catch (\Throwable $e) { $periodNumber = $prefix . str_pad((string)time() % 10000, 4, '0', STR_PAD_LEFT); } $streamUrl = !empty($game['stream_url']) ? $game['stream_url'] : null; $periodData = [ 'period_number' => $periodNumber, 'status' => 'pending', 'game_id' => $gameId, 'stream_url' => $streamUrl, 'start_time' => date('Y-m-d H:i:s'), 'end_time' => date('Y-m-d H:i:s', time() + $periodDuration), 'auto_generated' => 1, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]; try { $insertId = $db->insert('periods', $periodData); if ($insertId) { logInfo(" ✓ 新期号: {$periodNumber} (ID:{$insertId}) 时长:{$periodDuration}秒"); return $insertId; } } catch (\Throwable $e) { logInfo(" ✗ 开期失败: " . $e->getMessage()); } return 0; } // ============================ // 通用预开奖 // ============================ function doPreDraw($db, $periodId, $gameId, $algoClass) { $waterConfig = []; $wc = $db->select('water_control', '*', ['game_id' => $gameId, 'enabled' => 1]); foreach ($wc as $w) { $waterConfig[$w['bet_type']] = (float)$w['win_rate_pct']; } $bets = $db->select('bets', '*', [ 'period_id' => $periodId, 'status' => 'pending', 'is_virtual' => 0 ]); if (!empty($waterConfig) && !empty($bets)) { $targetProfitRate = floatval(\App\Core\SettingsHelper::get('target_profit_rate')); return $algoClass::generateControlledResult($bets, $waterConfig, 100, $targetProfitRate); } return $algoClass::generateResult(); } // ============================ // 通用开奖(无预开奖时的 fallback) // ============================ function doDraw($db, $periodId, $gameId, $algoClass) { $result = doPreDraw($db, $periodId, $gameId, $algoClass); $resultJson = json_encode($result); $db->medoo->pdo->beginTransaction(); try { $updateData = [ 'status' => 'drawn', 'result' => $resultJson, 'draw_time' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), ]; $resultTable = $algoClass::getResultTable(); $storageData = $algoClass::formatResultForStorage($result, $periodId); if (!$resultTable) { // dice/xocdia: 合并到 periods 更新 $updateData = array_merge($updateData, $storageData); } $db->update('periods', $updateData, ['id' => $periodId]); if ($resultTable) { $db->delete($resultTable, ['period_id' => $periodId]); $db->insert($resultTable, $storageData); } $db->medoo->pdo->commit(); logInfo(" ✓ 开奖: " . $resultJson); } catch (\Throwable $e) { $db->medoo->pdo->rollBack(); logInfo(" ✗ 开奖失败: " . $e->getMessage()); } } // ============================ // 通用结算 // ============================ function doSettle($db, $period, $algoClass, $gameType) { $periodId = (int)$period['id']; $result = json_decode($period['result'], true); if (!$result) { logInfo(" ✗ 结算: 无开奖结果"); return; } $bets = $db->select('bets', '*', ['period_id' => $periodId, 'status' => 'pending']); if (empty($bets)) { $db->update('periods', ['status' => 'settled', 'updated_at' => date('Y-m-d H:i:s')], ['id' => $periodId]); $gameId = (int)$period['game_id']; queueBotPush($db, $gameId, 'system', [ 'event' => 'period_settled', 'period_id' => $periodId, 'period_number' => $period['period_number'], 'wins' => 0, 'losses' => 0, 'total_payout' => 0, 'message' => 'No bets to settle', ], [], $period['period_number']); logInfo(" ✓ 结算: 无投注,直接结算"); return; } $wins = 0; $losses = 0; $totalPayout = 0; $gameName = ucfirst($gameType); $db->medoo->pdo->beginTransaction(); try { foreach ($bets as $bet) { $isWin = $algoClass::checkWin($result, $bet['bet_type'], $bet['bet_value']); $betAmount = (float)$bet['amount']; $odds = (float)$bet['odds']; if ($isWin) { $winAmount = $betAmount * $odds; $payout = $betAmount + $winAmount; $db->update('bets', [ 'status' => 'win', 'win_amount' => $winAmount, 'settled_at' => date('Y-m-d H:i:s'), ], ['id' => $bet['id']]); $stmt = $db->medoo->pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE"); $stmt->execute([':id' => $bet['user_id']]); $user = $stmt->fetch(\PDO::FETCH_ASSOC); $newBalance = (float)$user['balance'] + $payout; $db->update('users', ['balance' => $newBalance], ['id' => $bet['user_id']]); $db->insert('transactions', [ 'user_id' => $bet['user_id'], 'type' => 'win', 'amount' => $payout, 'balance_before' => $user['balance'], 'balance_after' => $newBalance, 'related_id' => $periodId, 'description' => $gameName . ' Auto Win - ' . $period['period_number'], 'is_virtual' => $bet['is_virtual'], 'created_at' => date('Y-m-d H:i:s'), ]); $totalPayout += $payout; $wins++; } else { $db->update('bets', [ 'status' => 'lose', 'win_amount' => 0, 'settled_at' => date('Y-m-d H:i:s'), ], ['id' => $bet['id']]); $losses++; } } doSettleAgentCommissions($db, $bets, $periodId); $db->update('periods', ['status' => 'settled', 'updated_at' => date('Y-m-d H:i:s')], ['id' => $periodId]); // 推送结算结果到 bot 群组 $gameId = (int)$period['game_id']; queueBotPush($db, $gameId, 'system', [ 'event' => 'period_settled', 'period_id' => $periodId, 'period_number' => $period['period_number'], 'wins' => $wins, 'losses' => $losses, 'total_payout' => (float)$totalPayout, ], [], $period['period_number']); $db->medoo->pdo->commit(); logInfo(" ✓ {$gameName}结算: 中{$wins}笔 负{$losses}笔 派奖{$totalPayout}"); } catch (\Throwable $e) { $db->medoo->pdo->rollBack(); logInfo(" ✗ {$gameName}结算失败: " . $e->getMessage()); } } // ============================ // 代理佣金 // ============================ function doSettleAgentCommissions($db, $bets, $periodId) { $agentBets = []; foreach ($bets as $bet) { if ((int)$bet['is_virtual']) continue; $user = $db->get('users', ['agent_id'], ['id' => $bet['user_id']]); if (!$user || !$user['agent_id']) continue; $agentId = $user['agent_id']; if (!isset($agentBets[$agentId])) $agentBets[$agentId] = 0; $agentBets[$agentId] += (float)$bet['amount']; } foreach ($agentBets as $agentId => $totalBet) { $agent = $db->get('agents', '*', ['id' => $agentId, 'status' => 1]); if (!$agent) continue; $commission = $totalBet * $agent['commission_rate'] / 100; if ($commission <= 0) continue; $db->insert('agent_commissions', [ 'agent_id' => $agentId, 'from_user_id' => 0, 'period_id' => $periodId, 'bet_amount' => $totalBet, 'commission' => $commission, 'type' => 'bet', 'created_at' => date('Y-m-d H:i:s'), ]); $agentUserId = $agent['user_id']; $stmt = $db->medoo->pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE"); $stmt->execute([':id' => $agentUserId]); $au = $stmt->fetch(\PDO::FETCH_ASSOC); $newBal = (float)$au['balance'] + $commission; $db->update('users', ['balance' => $newBal], ['id' => $agentUserId]); $db->insert('transactions', [ 'user_id' => $agentUserId, 'type' => 'commission', 'amount' => $commission, 'balance_before' => $au['balance'], 'balance_after' => $newBal, 'related_id' => $periodId, 'description' => 'Auto agent commission', 'created_at' => date('Y-m-d H:i:s'), ]); } } // ============================ // Bot 推送队列 // ============================ function queueBotPush($db, int $gameId, string $pushType, array $payload, array $groupFilters = [], ?string $periodNumber = null): void { if ($gameId <= 0) return; try { $where = array_merge(['game_id' => $gameId, 'status' => 1], $groupFilters); $groups = $db->select('bot_groups', ['id'], $where) ?: []; $now = date('Y-m-d H:i:s'); foreach ($groups as $group) { $db->insert('bot_push_logs', [ 'group_id' => (int)$group['id'], 'period_number' => $periodNumber, 'push_type' => $pushType, 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE), 'status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ]); } if (!empty($groups)) { logInfo(" ✓ Bot推送: {$pushType} → " . count($groups) . "个群组"); } } catch (\Throwable $e) { logInfo(" ⚠ Bot推送失败: " . $e->getMessage()); } }