修复4个问题: 用户组购买确认弹窗、在线曲线清理、高频出售缓冲、每小时活跃数据
This commit is contained in:
+33
-9
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user