修复4个问题: 用户组购买确认弹窗、在线曲线清理、高频出售缓冲、每小时活跃数据

This commit is contained in:
ypsdf1
2026-07-20 13:18:58 +08:00
parent 312f40c94a
commit c3d9c0925d
7 changed files with 392 additions and 31 deletions
+33 -9
View File
@@ -10,14 +10,26 @@ function getActivePlayers() {
try { try {
$db = getDB(); $db = getDB();
// 确保表存在 // 确保表存在
$db->exec("CREATE TABLE IF NOT EXISTS hourly_active_players (player_name TEXT PRIMARY KEY, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, hour_key TEXT NOT NULL)");
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)"); $db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
$res = $db->query("SELECT player_name, login_time FROM online_players ORDER BY login_time ASC"); $currentHourKey = date('Y-m-d H');
// ★ 查询本小时活跃过的所有玩家(包括已离线但本小时曾上线的)
$res = $db->query("SELECT player_name, first_seen, last_seen FROM hourly_active_players WHERE hour_key = '" . SQLite3::escapeString($currentHourKey) . "' ORDER BY first_seen ASC");
$now = time();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) { while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
// 检查玩家当前是否在线(从online_players表判断)
$checkStmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :name");
$checkStmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
$checkRes = $checkStmt->execute();
$isOnline = $checkRes->fetchArray() !== false;
$online[] = [ $online[] = [
'name' => $row['player_name'], 'name' => $row['player_name'],
'login_time' => (int)$row['login_time'], 'login_time' => (int)$row['first_seen'],
'login_time_fmt' => $row['login_time'] > 0 ? date('H:i:s', (int)$row['login_time']) : '-', 'login_time_fmt' => $row['first_seen'] > 0 ? date('H:i:s', (int)$row['first_seen']) : '-',
'duration' => $row['login_time'] > 0 ? time() - (int)$row['login_time'] : 0, 'duration' => $row['first_seen'] > 0 ? $now - (int)$row['first_seen'] : 0,
'is_online' => $isOnline,
'last_seen' => (int)$row['last_seen'],
]; ];
} }
$count = count($online); $count = count($online);
@@ -58,9 +70,14 @@ body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui
.player-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; } .player-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }
.player-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px 16px; transition:border-color .2s; } .player-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px 16px; transition:border-color .2s; }
.player-card:hover { border-color:var(--accent); } .player-card:hover { border-color:var(--accent); }
.player-card .name { font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px; } .player-card.offline { opacity:0.55; border-style:dashed; }
.player-card .name { font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:6px; }
.player-card .name .status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
.player-card .name .status-dot.on { background:var(--green); box-shadow:0 0 6px var(--green); }
.player-card .name .status-dot.off { background:var(--dim); }
.player-card .meta { font-size:12px; color:var(--dim); display:flex; justify-content:space-between; } .player-card .meta { font-size:12px; color:var(--dim); display:flex; justify-content:space-between; }
.player-card .meta .online-tag { color:var(--green); } .player-card .meta .online-tag { color:var(--green); }
.player-card .meta .offline-tag { color:var(--yellow); }
.player-card .time-bar { margin-top:8px; height:4px; background:var(--bg); border-radius:4px; overflow:hidden; } .player-card .time-bar { margin-top:8px; height:4px; background:var(--bg); border-radius:4px; overflow:hidden; }
.player-card .time-bar .fill { height:100%; background:var(--green); border-radius:4px; transition:width 1s; } .player-card .time-bar .fill { height:100%; background:var(--green); border-radius:4px; transition:width 1s; }
.empty { grid-column:1/-1; text-align:center; padding:40px; color:var(--dim); } .empty { grid-column:1/-1; text-align:center; padding:40px; color:var(--dim); }
@@ -100,12 +117,19 @@ function render(data) {
document.getElementById('countBadge').textContent = data.online_count + ' 人'; document.getElementById('countBadge').textContent = data.online_count + ' 人';
const grid = document.getElementById('playerGrid'); const grid = document.getElementById('playerGrid');
if (data.online_list.length === 0) { if (data.online_list.length === 0) {
grid.innerHTML = '<div class="empty">当前没有玩家在线</div>'; grid.innerHTML = '<div class="empty">本小时暂无玩家活跃</div>';
} else { } else {
grid.innerHTML = data.online_list.map(p => { // 在线玩家排前面,离线玩家排后面
const mx = 6 * 3600; // 最长6小时占满 const sorted = [...data.online_list].sort((a, b) => (b.is_online ? 1 : 0) - (a.is_online ? 1 : 0));
grid.innerHTML = sorted.map(p => {
const mx = 6 * 3600;
const pct = Math.min(100, Math.round(p.duration / mx * 100)); const pct = Math.min(100, Math.round(p.duration / mx * 100));
return `<div class="player-card"><div class="name">${esc(p.name)}</div><div class="meta"><span>登录:${p.login_time_fmt}</span><span class="online-tag">已在线 ${fmtDuration(p.duration)}</span></div><div class="time-bar"><div class="fill" style="width:${pct}%"></div></div></div>`; const cls = p.is_online ? 'player-card' : 'player-card offline';
const dot = p.is_online ? '<span class="status-dot on"></span>' : '<span class="status-dot off"></span>';
const tag = p.is_online
? `<span class="online-tag">在线 ${fmtDuration(p.duration)}</span>`
: `<span class="offline-tag">已离线</span>`;
return `<div class="${cls}"><div class="name">${dot}${esc(p.name)}</div><div class="meta"><span>首次:${p.login_time_fmt}</span>${tag}</div><div class="time-bar"><div class="fill" style="width:${pct}%;background:${p.is_online ? 'var(--green)' : 'var(--dim)'}"></div></div></div>`;
}).join(''); }).join('');
} }
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false}); document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
+102 -19
View File
@@ -217,11 +217,35 @@ function getAuthUrl($sessionId) {
* ★ 公共客户端(00000000402b5328)不需要client_secret * ★ 公共客户端(00000000402b5328)不需要client_secret
* redirect_uri必须与授权时一致 * redirect_uri必须与授权时一致
*/ */
/**
* 将Microsoft返回的OAuth错误映射为中文友好提示
*/
function mapMsTokenError($msErr, $msDesc = '') {
$msDesc = trim($msDesc);
if ($msErr === 'invalid_grant') {
return '授权码已失效(可能已过期,或已被使用过一次)。请重新点击"打开Microsoft登录"获取新的授权码,并在短时间内完成粘贴提交,不要重复使用旧授权码。';
}
if ($msErr === 'invalid_client') {
return 'OAuth客户端验证失败,请联系管理员检查 MS_CLIENT_ID 配置。';
}
if ($msErr === 'invalid_request') {
return '请求参数有误(' . ($msDesc ?: 'invalid_request') . '),请联系管理员。';
}
return 'Token兑换失败: ' . $msErr . ($msDesc ? '(' . $msDesc . ')' : '');
}
function exchangeToken($code) { function exchangeToken($code) {
$clientId = '00000000402b5328'; // Minecraft公共客户端ID $clientId = '00000000402b5328'; // Minecraft公共客户端ID
// 桌面端redirect_uri(与getAuthUrl一致) // 桌面端redirect_uri(与getAuthUrl一致)
$redirectUri = 'https://login.live.com/oauth20_desktop.srf'; $redirectUri = 'https://login.live.com/oauth20_desktop.srf';
// ★ 关键修复:对code做一次urldecode归一化。
// 前端JS提取code后会encodeURIComponent(! → %21),
// 若此处不解码直接交给http_build_query,PHP会再次编码成 %2521(双重编码),
// 导致Microsoft收到失真的授权码 → invalid_grant 400。
// 做一次urldecode保证最终只编码一次。urldecode对已是明文的内容是幂等的,安全。
$code = urldecode($code);
$result = msPost('https://login.live.com/oauth20_token.srf', [ $result = msPost('https://login.live.com/oauth20_token.srf', [
'client_id' => $clientId, 'client_id' => $clientId,
'code' => $code, 'code' => $code,
@@ -234,7 +258,10 @@ function exchangeToken($code) {
return ['error' => 'Token exchange failed: ' . $result['error']]; return ['error' => 'Token exchange failed: ' . $result['error']];
} }
if ($result['code'] !== 200) { if ($result['code'] !== 200) {
return ['error' => 'Token exchange HTTP ' . $result['code']]; $body = $result['body'] ?? [];
$msErr = $body['error'] ?? 'unknown';
$msDesc = $body['error_description'] ?? '';
return ['error' => mapMsTokenError($msErr, $msDesc), 'ms_error' => $msErr];
} }
$body = $result['body']; $body = $result['body'];
@@ -477,10 +504,16 @@ switch ($action) {
// 生成授权URL // 生成授权URL
$authUrl = getAuthUrl($sessionId); $authUrl = getAuthUrl($sessionId);
// ★ 动态构建paste_url:基于当前请求的域名和脚本路径,不硬编码
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
$scriptPath = $_SERVER['SCRIPT_NAME'] ?? '/api/minecraft_auth.php';
$pasteUrl = $scheme . '://' . $host . $scriptPath . '?action=paste_code_page&session_id=' . $sessionId;
success([ success([
'session_id' => $sessionId, 'session_id' => $sessionId,
'auth_url' => $authUrl, 'auth_url' => $authUrl,
'paste_url' => 'https://caoyuan.ypshidifu.cn/plugin/api/minecraft_auth.php?action=paste_code_page&session_id=' . $sessionId, 'paste_url' => $pasteUrl,
'expires_in' => 600, 'expires_in' => 600,
]); ]);
break; break;
@@ -620,24 +653,41 @@ switch ($action) {
// ===== 玩家手动粘贴授权码(公共客户端流程) ===== // ===== 玩家手动粘贴授权码(公共客户端流程) =====
case 'verify_code': { case 'verify_code': {
// 兼容JSON和表单两种POST方式 // 兼容JSON和表单两种POST方式
$json = json_decode(file_get_contents('php://input'), true); $rawInput = file_get_contents('php://input');
$json = json_decode($rawInput, true);
$sessionId = postParam('session_id') ?: ($json['session_id'] ?? ''); $sessionId = postParam('session_id') ?: ($json['session_id'] ?? '');
$code = postParam('code') ?: ($json['code'] ?? ''); $code = postParam('code') ?: ($json['code'] ?? '');
$secret = postParam('secret') ?: ($json['secret'] ?? ''); $secret = postParam('secret') ?: ($json['secret'] ?? '');
// ★ 详细调试日志
$debugLine = date('Y-m-d H:i:s') . " [verify_code] 来源: " . (postParam('code') !== '' ? 'POST表单' : 'JSON体') . "\n";
$debugLine .= " Raw input(前300): " . substr($rawInput, 0, 300) . "\n";
$debugLine .= " Session: $sessionId\n";
$debugLine .= " Code(pre-extract,前200): " . substr($code, 0, 200) . " (len=" . strlen($code) . ")\n";
$debugLine .= " Secret: " . substr($secret, 0, 30) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine, FILE_APPEND);
if (empty($sessionId)) error('缺少session_id参数'); if (empty($sessionId)) error('缺少session_id参数');
if (empty($code)) error('缺少code参数(授权码)'); if (empty($code)) error('缺少code参数(授权码)');
// 自动从URL中提取code参数 // 自动从URL中提取code参数(兼容 ?code= & #code= & 直接粘贴code)
if (strpos($code, 'code=') !== false || strpos($code, 'http') === 0) { if (strpos($code, 'code=') !== false || strpos($code, 'http') === 0 || strpos($code, '#') !== false) {
if (preg_match('/[?&]code=([^&]+)/', $code, $m)) { if (preg_match('/[#?&]code=([^&]+)/', $code, $m)) {
$code = urldecode($m[1]); $code = urldecode($m[1]);
} }
} }
// ★ 提取后日志
$debugLine2 = " Code(post-extract,前200): " . substr($code, 0, 200) . " (len=" . strlen($code) . ")\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine2, FILE_APPEND);
// 密钥验证:Java调用需要secret,浏览器表单用session_id自身认证 // 密钥验证:Java调用需要secret,浏览器表单用session_id自身认证
if (!empty($secret)) { if (!empty($secret)) {
if ($secret !== MC_AUTH_SECRET) error('密钥错误', 403); if ($secret !== MC_AUTH_SECRET) {
$debugLine3 = " ★ 密钥不匹配! 收到: " . substr($secret, 0, 30) . " 期望: " . substr(MC_AUTH_SECRET, 0, 30) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine3, FILE_APPEND);
error('密钥错误', 403);
}
} }
// 查询会话 // 查询会话
@@ -645,8 +695,16 @@ switch ($action) {
$stmt->execute([$sessionId]); $stmt->execute([$sessionId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC); $session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) error('会话不存在或已过期'); if (!$session) {
if ($session['status'] !== 'pending') error('会话状态异常: ' . $session['status']); $debugLine4 = " ★ 会话不存在! sessionId=$sessionId\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine4, FILE_APPEND);
error('会话不存在或已过期');
}
if ($session['status'] !== 'pending') {
$debugLine4 = " ★ 会话状态异常: " . $session['status'] . " (期望pending)\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine4, FILE_APPEND);
error('会话状态异常: ' . $session['status']);
}
if (time() > $session['expires_at']) { if (time() > $session['expires_at']) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'expired' WHERE session_id = ?"); $stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'expired' WHERE session_id = ?");
$stmt->execute([$sessionId]); $stmt->execute([$sessionId]);
@@ -654,7 +712,12 @@ switch ($action) {
} }
// 执行完整OAuth流程 // 执行完整OAuth流程
$debugLine5 = " → 调用 completeAuthFlow, code前50: " . substr($code, 0, 50) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine5, FILE_APPEND);
$result = completeAuthFlow($code); $result = completeAuthFlow($code);
$debugLine6 = " ← completeAuthFlow返回: " . (isset($result['error']) ? 'ERROR: ' . $result['error'] : 'SUCCESS uuid=' . ($result['uuid'] ?? '?') . ' name=' . ($result['name'] ?? '?')) . "\n";
$debugLine6 .= str_repeat('-', 80) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine6, FILE_APPEND);
if (isset($result['error'])) { if (isset($result['error'])) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'failed' WHERE session_id = ?"); $stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'failed' WHERE session_id = ?");
@@ -782,22 +845,24 @@ button:disabled{opacity:0.5;cursor:not-allowed;transform:none}
</div> </div>
<script> <script>
var sessionId="{$sessionId}"; var sessionId="{$sessionId}";
var submitting=false;
function extractCode(input){ function extractCode(input){
input=input.trim(); input=input.trim();
// 如果包含code=参数,从中提取 var m=input.match(/[#?&]code=([^&]+)/);
var m=input.match(/[?&]code=([^&]+)/);
if(m) return decodeURIComponent(m[1]); if(m) return decodeURIComponent(m[1]);
// 如果不包含http/https,当作原始code
if(input.indexOf('http')===-1 && input.indexOf('code=')===-1) return input; if(input.indexOf('http')===-1 && input.indexOf('code=')===-1) return input;
// 尝试解析URL try{
try{var u=new URL(input);var p=new URLSearchParams(u.search);if(p.has('code'))return p.get('code');}catch(e){} var u=new URL(input);
var p=new URLSearchParams(u.search);
if(p.has('code'))return p.get('code');
var ph=new URLSearchParams(u.hash.replace(/^#/,''));
if(ph.has('code'))return p.get('code');
}catch(e){}
return input; return input;
} }
function submitCode(){ function doSubmit(code){
var raw=document.getElementById('codeInput').value.trim(); if(submitting)return;
if(!raw){showMsg('请输入授权码或URL','err');return} submitting=true;
var code=extractCode(raw);
if(!code){showMsg('无法从输入中提取授权码','err');return}
var btn=document.getElementById('submitBtn'); var btn=document.getElementById('submitBtn');
btn.disabled=true;btn.textContent='验证中...'; btn.disabled=true;btn.textContent='验证中...';
var xhr=new XMLHttpRequest(); var xhr=new XMLHttpRequest();
@@ -805,6 +870,7 @@ xhr.open('POST','minecraft_auth.php?action=verify_code',true);
xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded'); xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhr.onreadystatechange=function(){ xhr.onreadystatechange=function(){
if(xhr.readyState===4){ if(xhr.readyState===4){
submitting=false;
try{var d=JSON.parse(xhr.responseText); try{var d=JSON.parse(xhr.responseText);
if(d.success){showMsg('✅ 验证成功!请关闭此页面返回游戏','ok');} if(d.success){showMsg('✅ 验证成功!请关闭此页面返回游戏','ok');}
else{showMsg('❌ '+(d.error||'验证失败'),'err');btn.disabled=false;btn.textContent='提交验证';} else{showMsg('❌ '+(d.error||'验证失败'),'err');btn.disabled=false;btn.textContent='提交验证';}
@@ -812,6 +878,23 @@ else{showMsg('❌ '+(d.error||'验证失败'),'err');btn.disabled=false;btn.text
}}; }};
xhr.send('session_id='+encodeURIComponent(sessionId)+'&code='+encodeURIComponent(code)); xhr.send('session_id='+encodeURIComponent(sessionId)+'&code='+encodeURIComponent(code));
} }
function submitCode(){
var raw=document.getElementById('codeInput').value.trim();
if(!raw){showMsg('请输入授权码或URL','err');return}
var code=extractCode(raw);
if(!code){showMsg('无法从输入中提取授权码','err');return}
doSubmit(code);
}
document.getElementById('codeInput').addEventListener('paste',function(){
var el=this;
setTimeout(function(){
var raw=el.value.trim();
if(raw.indexOf('login.live.com')!==-1||raw.indexOf('login.microsoftonline.com')!==-1){
var code=extractCode(raw);
if(code&&code.length>10){doSubmit(code);}
}
},100);
});
function showMsg(t,c){var m=document.getElementById('msg');m.textContent=t;m.className='msg '+c;} function showMsg(t,c){var m=document.getElementById('msg');m.textContent=t;m.className='msg '+c;}
</script> </script>
</body> </body>
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* ★ 支付密钥文件(服务器部署用)
*
* 安全说明:
* - 本文件被 .gitignore 拉黑(绝不进入版本库),且不会通过任何自动部署脚本上传,
* 必须手工 FTP 单独上传到 /plugin/api/ 目录。
* - 本文件是一个 .php:仅 define 常量、无任何 echo/输出。即便被直接 HTTP 访问,
* 服务器执行后也只返回空白页,私钥不会泄露。
* - 本地 txt 文件仅作为人工本地记录,pay.php 不再读取它;服务器端只认本文件。
*
* 密钥配对说明(已通过密码学验证):
* - PAY_MCH_PRIVATE_KEY(商户私钥)与 pay_user.publickey(商户公钥)是一对 → 用于【提交签名】。
* - PAY_PLATFORM_PUBLIC_KEY(平台公钥)与 pay_config.private_key(平台私钥)是一对 → 用于【验签平台回调】。
* - PAY_MD5_KEY 为商户 MD5 密钥(pay_user.key),注意【末尾无点】。
*/
if (!defined('PAY_PID')) define('PAY_PID', '1001');
if (!defined('PAY_MD5_KEY')) define('PAY_MD5_KEY', '');
if (!defined('PAY_MCH_PRIVATE_KEY')) define('PAY_MCH_PRIVATE_KEY', '');
if (!defined('PAY_PLATFORM_PUBLIC_KEY')) define('PAY_PLATFORM_PUBLIC_KEY', '');
// ★ 平台MySQL数据库连接凭据(彩虹易支付后台数据库)
if (!defined('PAY_MYSQL_HOST')) define('PAY_MYSQL_HOST', '127.0.0.1');
if (!defined('PAY_MYSQL_DBNAME')) define('PAY_MYSQL_DBNAME', '');
if (!defined('PAY_MYSQL_USER')) define('PAY_MYSQL_USER', '');
if (!defined('PAY_MYSQL_PASS')) define('PAY_MYSQL_PASS', '');
// ★ Microsoft Azure OAuth 配置(正版验证用)
// 客户端ID: caoyuan Azure App
if (!defined('MS_CLIENT_ID')) define('MS_CLIENT_ID', '');
// 客户端密钥: 从Azure Portal → 证书和密码 页面获取
if (!defined('MS_CLIENT_SECRET')) define('MS_CLIENT_SECRET', '');
// 回调地址: 必须与Azure Portal中注册的重定向URI一致
if (!defined('MS_REDIRECT_URI')) define('MS_REDIRECT_URI', '');
// Java通信密钥(与WebManager的secretKey一致)
if (!defined('MC_AUTH_SECRET')) define('MC_AUTH_SECRET', 'sdf1_web_comm_2026_ypshidifu');
+46
View File
@@ -1433,6 +1433,13 @@ function syncOnlinePlayers() {
$db->exec('PRAGMA busy_timeout=10000'); $db->exec('PRAGMA busy_timeout=10000');
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL)"); $db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL)");
$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)"); $db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
// ★ 每小时活跃玩家表:记录本小时内所有曾上线的玩家(离线不清空,跨小时清理)
$db->exec("CREATE TABLE IF NOT EXISTS hourly_active_players (
player_name TEXT PRIMARY KEY,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
hour_key TEXT NOT NULL
)");
// ★ 迁移:修复旧表结构(旧行无主键导致INSERT OR REPLACE失效) // ★ 迁移:修复旧表结构(旧行无主键导致INSERT OR REPLACE失效)
try { try {
$colCheck = $db->query("PRAGMA table_info(online_player_hb)"); $colCheck = $db->query("PRAGMA table_info(online_player_hb)");
@@ -1510,6 +1517,25 @@ function syncOnlinePlayers() {
@error_log("[syncOnlinePlayers] FAIL insert $name: " . $e->getMessage()); @error_log("[syncOnlinePlayers] FAIL insert $name: " . $e->getMessage());
} }
// ★ 每小时活跃玩家追踪:记录曾上线的玩家(离线不清空,跨小时清理)
try {
$hourKey = date('Y-m-d H', $now);
$haStmt = $db->prepare("INSERT INTO hourly_active_players (player_name, first_seen, last_seen, hour_key)
VALUES (:name, :now, :now, :hk)
ON CONFLICT(player_name) DO UPDATE SET
last_seen = :now2,
hour_key = CASE WHEN excluded.hour_key > hourly_active_players.hour_key THEN excluded.hour_key ELSE hourly_active_players.hour_key END,
first_seen = CASE WHEN excluded.hour_key > hourly_active_players.hour_key THEN :now3 ELSE hourly_active_players.first_seen END");
$haStmt->bindValue(':name', $name, SQLITE3_TEXT);
$haStmt->bindValue(':now', $now, SQLITE3_INTEGER);
$haStmt->bindValue(':now2', $now, SQLITE3_INTEGER);
$haStmt->bindValue(':now3', $now, SQLITE3_INTEGER);
$haStmt->bindValue(':hk', $hourKey, SQLITE3_TEXT);
$haStmt->execute();
} catch (\Throwable $e) {
@error_log("[syncOnlinePlayers] hourly_active记录失败 {$name}: " . $e->getMessage());
}
// ★ 如果Java推送了IP,同步更新 player_ip_changes(实时IP来源) // ★ 如果Java推送了IP,同步更新 player_ip_changes(实时IP来源)
$ip = $player['ip'] ?? ''; $ip = $player['ip'] ?? '';
if (!empty($ip)) { if (!empty($ip)) {
@@ -1546,6 +1572,26 @@ function syncOnlinePlayers() {
@error_log("[syncOnlinePlayers] heartbeat update error: " . $e->getMessage()); @error_log("[syncOnlinePlayers] heartbeat update error: " . $e->getMessage());
} }
// ★ 每小时清理:跨小时时,删除上一个小时不再在线的玩家
try {
$currentHourKey = date('Y-m-d H', $now);
$prevHourKey = date('Y-m-d H', $now - 3600);
// 只在刚进入新小时的前5分钟执行清理(避免每次推送都扫表)
$minuteOfHour = (int)date('i', $now);
if ($minuteOfHour < 5) {
// 删除上一个小时及更早的玩家(他们已不在本小时内)
$delStmt = $db->prepare("DELETE FROM hourly_active_players WHERE hour_key < :hk");
$delStmt->bindValue(':hk', $currentHourKey, SQLITE3_TEXT);
$delStmt->execute();
$deleted = $db->changes();
if ($deleted > 0) {
@error_log("[syncOnlinePlayers] ★ 跨小时清理:删除" . $deleted . "条上一个小时的活跃记录");
}
}
} catch (\Throwable $e) {
@error_log("[syncOnlinePlayers] hourly_active清理失败: " . $e->getMessage());
}
$db->exec("COMMIT"); $db->exec("COMMIT");
} catch (\Throwable $e) { } catch (\Throwable $e) {
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {} try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
PHP only deployment script for caoyuan.ypshidifu.cn
Uploads PHP files via FTP to /plugin directory
"""
import ftplib
import os
import sys
from pathlib import Path
# FTP Configuration
FTP_HOST = "43.153.203.46"
FTP_PORT = 38367
FTP_USER = "zxcvb"
FTP_PASS = "32RPNtkKF68j"
FTP_REMOTE_BASE = "/plugin"
# Local PHP directory
PHP_DIR = r"D:\桌面\源码\mc_plugn\Web"
# Files/directories to exclude from upload
EXCLUDE_PATTERNS = [
'db/',
'db\\',
'.git',
'.gitignore',
'deploy_*.py',
'__pycache__',
'*.pyc',
'密钥*.txt',
'密钥*.md',
'*.bak',
'node_modules',
'vendor',
]
def should_exclude(path):
"""Check if file should be excluded from upload"""
for pattern in EXCLUDE_PATTERNS:
if pattern in path:
return True
return False
def upload_file(ftp, local_path, remote_path):
"""Upload a single file via FTP"""
try:
with open(local_path, 'rb') as f:
ftp.storbinary(f'STOR {remote_path}', f)
print(f" Uploaded: {remote_path}")
return True
except Exception as e:
print(f" Failed: {remote_path} - {e}")
return False
def fix_root_config_db_path(config_path):
"""Fix DB_PATH in root config.php to point to /plugin/db/web.db"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if DB_PATH needs fixing
if 'DB_PATH' in content and '/plugin/db/web.db' not in content:
# Replace DB_PATH with correct path
import re
new_content = re.sub(
r"define\s*\(\s*['\"]DB_PATH['\"]\s*,\s*['\"][^'\"]*['\"]\s*\)",
"define('DB_PATH', '/plugin/db/web.db')",
content
)
if new_content != content:
with open(config_path, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f"Fixed DB_PATH in {config_path}")
except Exception as e:
print(f"Warning: Could not fix DB_PATH in {config_path}: {e}")
def main():
print("=" * 60)
print("PHP Deployment to caoyuan.ypshidifu.cn")
print("=" * 60)
# Fix root config.php DB_PATH
root_config = os.path.join(PHP_DIR, 'config.php')
if os.path.exists(root_config):
fix_root_config_db_path(root_config)
# Connect to FTP
try:
print(f"\nConnecting to FTP: {FTP_HOST}:{FTP_PORT}")
ftp = ftplib.FTP()
ftp.connect(FTP_HOST, FTP_PORT)
ftp.login(FTP_USER, FTP_PASS)
print("Connected successfully!")
# Ensure remote base directory exists
try:
ftp.cwd(FTP_REMOTE_BASE)
except:
try:
ftp.mkd(FTP_REMOTE_BASE)
ftp.cwd(FTP_REMOTE_BASE)
print(f"Created remote directory: {FTP_REMOTE_BASE}")
except Exception as e:
print(f"Warning: Could not create remote directory: {e}")
# Walk through local directory and upload files
uploaded = 0
skipped = 0
failed = 0
for root, dirs, files in os.walk(PHP_DIR):
# Skip excluded directories
dirs[:] = [d for d in dirs if not should_exclude(os.path.join(root, d))]
for file in files:
local_path = os.path.join(root, file)
# Skip excluded files
if should_exclude(local_path):
skipped += 1
continue
# Calculate relative path
rel_path = os.path.relpath(local_path, PHP_DIR)
remote_path = os.path.join(FTP_REMOTE_BASE, rel_path).replace('\\', '/')
# Create remote directory if needed
remote_dir = os.path.dirname(remote_path)
try:
ftp.cwd(remote_dir)
except:
try:
ftp.mkd(remote_dir)
ftp.cwd(remote_dir)
except:
pass
# Upload file
if upload_file(ftp, local_path, remote_path):
uploaded += 1
else:
failed += 1
# Close connection
ftp.quit()
print("\n" + "=" * 60)
print("Deployment Summary:")
print(f" Uploaded: {uploaded} files")
print(f" Skipped: {skipped} files")
print(f" Failed: {failed} files")
print("=" * 60)
if failed == 0:
print("\n✅ PHP deployment completed successfully!")
else:
print(f"\n⚠️ Deployment completed with {failed} failures")
return 1
return 0
except Exception as e:
print(f"\n❌ FTP connection failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
+5 -1
View File
@@ -15,6 +15,8 @@ function ensureSnapshotTable() {
online_count INTEGER NOT NULL DEFAULT 0 online_count INTEGER NOT NULL DEFAULT 0
)"); )");
$db->exec("CREATE INDEX IF NOT EXISTS idx_snap_time ON online_snapshots(snapshot_time)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_snap_time ON online_snapshots(snapshot_time)");
// 清理24小时前的旧快照(保留24小时数据)
$db->exec("DELETE FROM online_snapshots WHERE snapshot_time < " . (time() - 86400));
} catch (\Throwable $e) { } catch (\Throwable $e) {
debugLog('[online_curve] 建表异常: ' . $e->getMessage()); debugLog('[online_curve] 建表异常: ' . $e->getMessage());
} }
@@ -74,7 +76,9 @@ function getHourlyData() {
$stmt->bindValue(':since', $since, SQLITE3_INTEGER); $stmt->bindValue(':since', $since, SQLITE3_INTEGER);
$res = $stmt->execute(); $res = $stmt->execute();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) { while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$h = (int)date('G', (int)$row['snapshot_time']); // 0-23 无前导零 $snapshotTime = (int)$row['snapshot_time'];
// 使用本地时区获取小时(0-23)
$h = (int)date('G', $snapshotTime);
$buckets[$h]['count'] += (int)$row['online_count']; $buckets[$h]['count'] += (int)$row['online_count'];
$buckets[$h]['samples']++; $buckets[$h]['samples']++;
} }
+2 -2
View File
@@ -2291,7 +2291,7 @@ if ($currentVersion !== $BUILD_VERSION) {
const g = window._availGroupsMap?.[groupName]; const g = window._availGroupsMap?.[groupName];
const price = g ? g.join_price + ' 张债券' : '(价格未知)'; const price = g ? g.join_price + ' 张债券' : '(价格未知)';
const dur = g ? (g.duration_minutes == 0 ? '永久' : (g.duration_minutes >= 1440 ? Math.floor(g.duration_minutes/1440) + '天' : Math.floor(g.duration_minutes/60) + '小时')) : '未知'; const dur = g ? (g.duration_minutes == 0 ? '永久' : (g.duration_minutes >= 1440 ? Math.floor(g.duration_minutes/1440) + '天' : Math.floor(g.duration_minutes/60) + '小时')) : '未知';
if (!showConfirm(`确认付费加入用户组「${g ? (g.display_name || groupName) : groupName}」?\n价格: ${price}\n有效期: ${dur}\n将从债券余额扣费。`)) return; if (!(await showConfirm(`确认付费加入用户组「${g ? (g.display_name || groupName) : groupName}」?\n价格: ${price}\n有效期: ${dur}\n将从债券余额扣费。`))) return;
try { try {
const res = await api('land_api.php', {action: 'buy_group', group: groupName, player: currentPlayer, token: TOKEN}); const res = await api('land_api.php', {action: 'buy_group', group: groupName, player: currentPlayer, token: TOKEN});
if (res.success) { if (res.success) {
@@ -2310,7 +2310,7 @@ if ($currentVersion !== $BUILD_VERSION) {
const g = window._availGroupsMap?.[groupName]; const g = window._availGroupsMap?.[groupName];
const price = g ? (parseInt(g.renew_price) > 0 ? g.renew_price + ' 张债券' : '免费') : '未知'; const price = g ? (parseInt(g.renew_price) > 0 ? g.renew_price + ' 张债券' : '免费') : '未知';
const dur = g && g.duration_minutes ? (g.duration_minutes >= 1440 ? Math.floor(g.duration_minutes/1440) + '天' : Math.floor(g.duration_minutes/60) + '小时') : '由用户组配置决定'; const dur = g && g.duration_minutes ? (g.duration_minutes >= 1440 ? Math.floor(g.duration_minutes/1440) + '天' : Math.floor(g.duration_minutes/60) + '小时') : '由用户组配置决定';
if (!showConfirm(`确认续费用户组「${g ? (g.display_name || groupName) : groupName}」?\n续费价格: ${price}\n延长有效期: ${dur}\n将从债券余额扣费。`)) return; if (!(await showConfirm(`确认续费用户组「${g ? (g.display_name || groupName) : groupName}」?\n续费价格: ${price}\n延长有效期: ${dur}\n将从债券余额扣费。`))) return;
try { try {
const res = await api('land_api.php', {action: 'renew_group', group: groupName, player: currentPlayer, token: TOKEN}); const res = await api('land_api.php', {action: 'renew_group', group: groupName, player: currentPlayer, token: TOKEN});
if (res.success) { if (res.success) {