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 命名空间
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
namespace App\Core;
|
||||
|
||||
use Db\Database;
|
||||
|
||||
class BotApiBaseController extends BaseController
|
||||
{
|
||||
protected ?Database $db = null;
|
||||
protected ?array $botInstance = null;
|
||||
protected ?array $groupConfig = null;
|
||||
protected array $requestJson = [];
|
||||
protected string $rawRequestBody = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = new Database();
|
||||
$this->rawRequestBody = file_get_contents('php://input') ?: '';
|
||||
$this->requestJson = $this->getJsonInput();
|
||||
}
|
||||
|
||||
protected function getJsonInput(): array
|
||||
{
|
||||
$raw = $this->rawRequestBody;
|
||||
if (!$raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
protected function jsonResponse(bool $success, string $message = '', array $data = [], int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => $success,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function auditAndRespond(bool $success, string $message = '', array $data = [], int $statusCode = 200, ?int $groupId = null, ?string $idempotencyKey = null): void
|
||||
{
|
||||
$payload = [
|
||||
'success' => $success,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
];
|
||||
$responseBody = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
$this->logApiRequest(
|
||||
(int)($this->botInstance['id'] ?? 0) ?: null,
|
||||
$groupId,
|
||||
$idempotencyKey,
|
||||
$statusCode,
|
||||
$this->botInstance !== null,
|
||||
$this->rawRequestBody,
|
||||
$responseBody
|
||||
);
|
||||
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo $responseBody;
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function getHeader(string $name): string
|
||||
{
|
||||
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||
return trim($_SERVER[$key] ?? '');
|
||||
}
|
||||
|
||||
protected function authenticateBotRequest(): void
|
||||
{
|
||||
$botKey = $this->getHeader('X-Bot-Key');
|
||||
$timestamp = $this->getHeader('X-Timestamp');
|
||||
$signature = $this->getHeader('X-Signature');
|
||||
|
||||
if ($botKey === '' || $timestamp === '' || $signature === '') {
|
||||
$this->jsonResponse(false, 'Missing bot auth headers', [], 401);
|
||||
}
|
||||
|
||||
if (!ctype_digit($timestamp)) {
|
||||
$this->jsonResponse(false, 'Invalid timestamp', [], 401);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if (abs($now - (int)$timestamp) > 300) {
|
||||
$this->jsonResponse(false, 'Timestamp expired', [], 401);
|
||||
}
|
||||
|
||||
$bot = $this->db->get('bot_instances', '*', [
|
||||
'bot_key' => $botKey,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
if (!$bot) {
|
||||
$this->jsonResponse(false, 'Bot auth failed', [], 401);
|
||||
}
|
||||
|
||||
$rawBody = $this->rawRequestBody;
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$signPayload = $timestamp . "\n" . $method . "\n" . $path . "\n" . $rawBody;
|
||||
$expected = hash_hmac('sha256', $signPayload, $bot['bot_secret']);
|
||||
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
$this->logApiRequest((int)$bot['id'], null, null, 0, false, $rawBody, '');
|
||||
$this->jsonResponse(false, 'Signature verify failed', [], 401);
|
||||
}
|
||||
|
||||
$this->botInstance = $bot;
|
||||
}
|
||||
|
||||
protected function requireGroupByTelegramId(string $tgGroupId): array
|
||||
{
|
||||
$group = $this->db->get('bot_groups', '*', [
|
||||
'tg_group_id' => $tgGroupId,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
if (!$group) {
|
||||
$this->jsonResponse(false, 'Group config not found', [], 404);
|
||||
}
|
||||
|
||||
if ((int)$group['bot_id'] !== (int)($this->botInstance['id'] ?? 0)) {
|
||||
$this->jsonResponse(false, 'Group does not belong to this bot', [], 403);
|
||||
}
|
||||
|
||||
$this->groupConfig = $group;
|
||||
return $group;
|
||||
}
|
||||
|
||||
protected function getGroupWallet(int $groupId): array
|
||||
{
|
||||
$wallet = $this->db->get('bot_group_wallets', '*', [
|
||||
'group_id' => $groupId,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
if (!$wallet) {
|
||||
$this->jsonResponse(false, 'Group wallet not configured', [], 422);
|
||||
}
|
||||
|
||||
return $wallet;
|
||||
}
|
||||
|
||||
protected function buildIdempotencyKey(string $fallbackPrefix = 'bot'): string
|
||||
{
|
||||
$key = trim((string)($this->requestJson['idempotency_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$groupId = (string)($this->groupConfig['tg_group_id'] ?? 'unknown');
|
||||
$messageId = (string)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? uniqid());
|
||||
return $fallbackPrefix . ':' . $groupId . ':' . $messageId;
|
||||
}
|
||||
|
||||
protected function logApiRequest(?int $botId, ?int $groupId, ?string $idempotencyKey, int $responseCode, bool $signatureOk, string $requestBody, string $responseBody): void
|
||||
{
|
||||
try {
|
||||
$this->db->insert('bot_api_request_logs', [
|
||||
'bot_id' => $botId,
|
||||
'group_id' => $groupId,
|
||||
'request_uri' => parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/',
|
||||
'http_method' => strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'request_body' => $requestBody,
|
||||
'response_body' => $responseBody,
|
||||
'response_code' => $responseCode,
|
||||
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'signature_ok' => $signatureOk ? 1 : 0,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 审计失败不阻断主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,26 +14,56 @@ class PK10Algorithm implements GameAlgorithmInterface {
|
||||
|
||||
/**
|
||||
* 带放水机制的开奖结果生成
|
||||
*
|
||||
* 算法策略:
|
||||
* - targetProfitRate > 0 时:选择利润最接近「总投注额 × 目标盈利率」的结果
|
||||
* - targetProfitRate == 0 时:选择平台利润最高的结果(原逻辑)
|
||||
*
|
||||
* @param array $bets 当期所有投注 [{bet_type, bet_target, amount, odds}, ...]
|
||||
* @param array $waterConfig [{bet_type => win_rate_pct}, ...]
|
||||
* 当前版本中 waterConfig 的 win_rate_pct 未直接参与结果选择,
|
||||
* 因为开奖结果是全局排列,无法单独控制某个投注类型的胜率。
|
||||
* 保留此参数供未来扩展(如按类型加权评分、分类型概率偏移等)。
|
||||
* @param int $attempts 最大尝试次数
|
||||
* @param float $targetProfitRate 目标盈利率(百分比,如 5.0 表示 5%)
|
||||
*/
|
||||
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array {
|
||||
if (empty($bets) || empty($waterConfig)) {
|
||||
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100, float $targetProfitRate = 0): array {
|
||||
if (empty($bets)) {
|
||||
return self::generateResult();
|
||||
}
|
||||
|
||||
// 计算目标利润(仅当 targetProfitRate > 0 时生效)
|
||||
$targetProfit = 0;
|
||||
if ($targetProfitRate > 0) {
|
||||
$totalBet = 0;
|
||||
foreach ($bets as $bet) {
|
||||
$totalBet += (float)$bet['amount'];
|
||||
}
|
||||
$targetProfit = $totalBet * ($targetProfitRate / 100);
|
||||
}
|
||||
|
||||
$bestResult = null;
|
||||
$bestProfit = PHP_INT_MIN;
|
||||
$bestDistance = PHP_FLOAT_MAX;
|
||||
|
||||
for ($i = 0; $i < $attempts; $i++) {
|
||||
$result = self::generateResult();
|
||||
$profit = self::calculatePlatformProfit($result, $bets);
|
||||
|
||||
// 选择平台利润最高的结果
|
||||
if ($profit > $bestProfit) {
|
||||
$bestProfit = $profit;
|
||||
$bestResult = $result;
|
||||
if ($targetProfitRate > 0) {
|
||||
// 目标盈利率模式:选择利润最接近目标值的结果
|
||||
$distance = abs($profit - $targetProfit);
|
||||
if ($distance < $bestDistance) {
|
||||
$bestDistance = $distance;
|
||||
$bestProfit = $profit;
|
||||
$bestResult = $result;
|
||||
}
|
||||
} else {
|
||||
// 原逻辑:选择平台利润最高的结果
|
||||
if ($profit > $bestProfit) {
|
||||
$bestProfit = $profit;
|
||||
$bestResult = $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-29
@@ -38,21 +38,21 @@ class PluginManager {
|
||||
|
||||
$rootDir = realpath(__DIR__ . '/../../');
|
||||
$logDir = $rootDir . '/Storage/log';
|
||||
|
||||
// 确保日志目录(如不存在则创建)
|
||||
if (!is_dir($logDir)) {
|
||||
if (!mkdir($logDir, 0755, true) && !is_dir($logDir)) {
|
||||
throw new \RuntimeException("无法创建日志目录: $logDir ,请检查权限");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查目录可写性
|
||||
if (!is_writable($logDir)) {
|
||||
throw new \RuntimeException("日志目录不可写: $logDir ,请检查权限");
|
||||
}
|
||||
|
||||
$this->logFile = $logDir . '/plugin_manager.log';
|
||||
|
||||
|
||||
// 尝试准备日志目录;失败时降级到 PHP error_log,不能阻断主流程
|
||||
if (!is_dir($logDir) && !@mkdir($logDir, 0755, true) && !is_dir($logDir)) {
|
||||
$this->logFile = null;
|
||||
error_log("PluginManager log directory create failed: $logDir");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->canWriteLogFile()) {
|
||||
$this->logFile = null;
|
||||
error_log("PluginManager log path not writable: $logDir");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查日志文件大小并自动清理(大于2MB时)
|
||||
$this->cleanupLogFile(2); // 传入最大允许的MB数
|
||||
}
|
||||
@@ -62,32 +62,47 @@ class PluginManager {
|
||||
* @param int $maxSizeMB 最大允许的文件大小(MB)
|
||||
*/
|
||||
private function cleanupLogFile(int $maxSizeMB) {
|
||||
// 检查文件是否存在
|
||||
if (!file_exists($this->logFile)) {
|
||||
if (!$this->canWriteLogFile() || !file_exists($this->logFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 转换MB为字节
|
||||
$maxSizeBytes = $maxSizeMB * 1024 * 1024;
|
||||
|
||||
|
||||
// 获取当前文件大小
|
||||
$currentSize = filesize($this->logFile);
|
||||
|
||||
$currentSize = @filesize($this->logFile);
|
||||
if ($currentSize === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果文件大小超过限制,清空文件
|
||||
if ($currentSize > $maxSizeBytes) {
|
||||
// 先备份当前日志内容(可选)
|
||||
$backupFile = $this->logFile . '.bak_' . date('YmdHis');
|
||||
copy($this->logFile, $backupFile);
|
||||
|
||||
@copy($this->logFile, $backupFile);
|
||||
|
||||
// 清空日志文件
|
||||
file_put_contents($this->logFile, '');
|
||||
|
||||
@file_put_contents($this->logFile, '');
|
||||
|
||||
// 记录清理日志
|
||||
$message = "[" . date('Y-m-d H:i:s') . "] 日志文件超过{$maxSizeMB}MB,已自动清理\n";
|
||||
file_put_contents($this->logFile, $message, FILE_APPEND);
|
||||
@file_put_contents($this->logFile, $message, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
private function canWriteLogFile(): bool {
|
||||
if (empty($this->logFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$logDir = dirname($this->logFile);
|
||||
if (!is_dir($logDir) || !is_writable($logDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !file_exists($this->logFile) || is_writable($this->logFile);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 设置系统核心路由
|
||||
@@ -121,13 +136,12 @@ class PluginManager {
|
||||
protected function log(string $msg, string $level = 'INFO'): void {
|
||||
$date = date('Y-m-d H:i:s');
|
||||
$logMsg = "[$date] [$level] $msg\n";
|
||||
$logDir = dirname($this->logFile);
|
||||
|
||||
try {
|
||||
if (is_dir($logDir) && is_writable($logDir)) {
|
||||
file_put_contents($this->logFile, $logMsg, FILE_APPEND);
|
||||
if ($this->canWriteLogFile()) {
|
||||
@file_put_contents($this->logFile, $logMsg, FILE_APPEND);
|
||||
} else {
|
||||
error_log("PluginManager log directory not writable: $logDir");
|
||||
error_log("PluginManager: " . trim($logMsg));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Failed to write plugin log: " . $e->getMessage());
|
||||
|
||||
@@ -60,12 +60,14 @@ class SettingsHelper {
|
||||
*/
|
||||
private static function getDefaults() {
|
||||
return [
|
||||
'site_title' => 'PK10 Speed Racing',
|
||||
'site_description' => 'PK10 Speed Racing - Online Betting Platform',
|
||||
'site_keywords' => 'pk10, speed racing, betting, online game',
|
||||
'site_title' => 'F1 Racing',
|
||||
'site_description' => 'F1 Racing - Online Betting Platform',
|
||||
'site_keywords' => 'f1, racing, betting, online game',
|
||||
'site_logo' => '/Static/images/logo.png',
|
||||
'site_favicon' => '/Static/css/favicon.ico',
|
||||
'site_copyright' => '© 2025 PK10 Racing. All rights reserved.'
|
||||
'site_copyright' => '© 2025 F1 Racing. All rights reserved.',
|
||||
'target_profit_rate' => '15', // 目标盈利率,百分比,如15表示15%
|
||||
'customer_service_url' => '', // 客服链接(充值时跳转)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user