Files
pk10/cron/auto_period_task.php
li 4a94fe36cf feat: 重构代码架构 + 新增报表/跟单/输赢统计功能
- 拆分 HomeController → TransactionController, ReportWebController, FollowPlanController
- 新增 Service 层: TransactionService, ReportService, FollowPlanService
- pk10.php JS 抽离为 4 个独立文件 (sound/race/bet/poll)
- 前台新增报表查询页面 (/report) + 跟单计划页面 (/follow-plan)
- 后台新增跟单计划管理 + 用户输赢明细统计
- 封盘状态显示倒计时 (x:xx)
- 音效仅在开奖弹窗打开时播放
- 路由按模块分组整理
- autoload 支持 App\Services 命名空间
2026-03-27 18:41:06 +08:00

519 lines
18 KiB
PHP

#!/usr/bin/env php
<?php
/**
* =============================================
* 自动开期 & 开奖 & 结算 统一定时任务
* =============================================
*
* 【宝塔面板配置方法】
* 1. 登录宝塔面板 → 计划任务
* 2. 任务类型: Shell脚本
* 3. 任务名称: PK10自动开期
* 4. 执行周期: 每N分钟 → 1分钟
* 5. 脚本内容:
* cd /www/wwwroot/你的项目目录 && /usr/bin/php cron/auto_period_task.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());
}
}