- 拆分 HomeController → TransactionController, ReportWebController, FollowPlanController - 新增 Service 层: TransactionService, ReportService, FollowPlanService - pk10.php JS 抽离为 4 个独立文件 (sound/race/bet/poll) - 前台新增报表查询页面 (/report) + 跟单计划页面 (/follow-plan) - 后台新增跟单计划管理 + 用户输赢明细统计 - 封盘状态显示倒计时 (x:xx) - 音效仅在开奖弹窗打开时播放 - 路由按模块分组整理 - autoload 支持 App\Services 命名空间
519 lines
18 KiB
PHP
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());
|
|
}
|
|
}
|