添加Web前端代码
This commit is contained in:
@@ -563,10 +563,11 @@ function renderAllUsers(tab, users, onlineSet, onlineMap) {
|
||||
const search = (window.currentSearch || '').toLowerCase();
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="form-row" style="margin-bottom:12px">
|
||||
<input id="userSearch" placeholder="搜索玩家名..." oninput="handleUserSearch()" style="flex:1">
|
||||
<div class="form-row" style="margin-bottom:4px">
|
||||
<input id="userSearch" placeholder="智能搜索:玩家名 / IP / 日期(2026-06-18) / 地区(广东)" oninput="handleUserSearch()" style="flex:1">
|
||||
<button class="btn btn-blue" onclick="doLazyLoadSearch()">搜索</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--dim);margin-bottom:12px">支持:纯文本→玩家名 | IP格式→IP搜索 | 日期格式→日期搜索 | 省/市名→地区搜索</div>
|
||||
<div id="userLazyContainer">
|
||||
<div class="empty">加载中...</div>
|
||||
</div>
|
||||
@@ -1127,17 +1128,26 @@ async function loadActivePlayers(el) {
|
||||
const r = await jsonApi('admin.php?action=list_active_players');
|
||||
if (!r.success) { el.innerHTML='<div class="card">'+r.message+'</div>'; return; }
|
||||
const players = r.data || [];
|
||||
const totalBeforeDedup = r.total_before_dedup || players.length;
|
||||
const dedupCount = totalBeforeDedup - players.length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>24小时活跃用户 <span style="color:var(--dim);font-size:12px">(${players.length}人)</span></h2>
|
||||
<h2>24小时活跃用户 <span style="color:var(--accent);font-size:14px">(${players.length}人)</span>
|
||||
${dedupCount > 0 ? `<span style="color:var(--yellow);font-size:12px;margin-left:8px">(已隐藏${dedupCount}个同子网用户)</span>` : ''}
|
||||
</h2>
|
||||
<table class="table">
|
||||
<tr><th>玩家名</th><th>最后活跃</th><th>总在线时长</th></tr>
|
||||
<tr><th>玩家名</th><th>最后活跃</th><th>总在线时长</th><th>IP</th><th>IP属地</th></tr>
|
||||
${players.map(p => {
|
||||
const loginTime = p.last_login_time ? new Date(p.last_login_time*1000).toLocaleString() : '-';
|
||||
const ip = p.ip_address || '-';
|
||||
const ipLoc = p.ip_location || '-';
|
||||
return `<tr>
|
||||
<td class="player-online">${p.player_name}</td>
|
||||
<td>${loginTime}</td>
|
||||
<td>${p.hours_online}小时</td>
|
||||
<td style="font-size:12px;font-family:monospace">${ip}</td>
|
||||
<td style="font-size:12px">${ipLoc}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</table>
|
||||
@@ -1159,25 +1169,39 @@ function loadActiveTab(tab, seconds) {
|
||||
r.then(res => {
|
||||
if (!res.success) { div.innerHTML='<div class="card" style="border-color:var(--red)">'+res.message+'</div>'; return; }
|
||||
const players = res.data || [];
|
||||
const totalBeforeDedup = res.total_before_dedup || players.length;
|
||||
const dedupCount = totalBeforeDedup - players.length;
|
||||
|
||||
// 统计同IP玩家
|
||||
const ipCountMap = {};
|
||||
// 统计同子网玩家
|
||||
const subnetCountMap = {};
|
||||
players.forEach(p => {
|
||||
const ip = p.ip_address || '-';
|
||||
if (ip !== '-') ipCountMap[ip] = (ipCountMap[ip] || 0) + 1;
|
||||
if (ip !== '-') {
|
||||
const parts = ip.split('.');
|
||||
const subnet = parts.length === 4 ? parts[0]+'.'+parts[1]+'.'+parts[2] : ip;
|
||||
subnetCountMap[subnet] = (subnetCountMap[subnet] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
div.innerHTML = `
|
||||
<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">${label}内活跃用户 <span style="color:var(--dim)">(${players.length}人)</span></h3>
|
||||
<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">
|
||||
${label}内活跃用户 <span style="color:var(--accent)">(${players.length}人)</span>
|
||||
${dedupCount > 0 ? `<span style="color:var(--yellow);font-size:12px;margin-left:8px">(已隐藏${dedupCount}个同子网用户)</span>` : ''}
|
||||
</h3>
|
||||
<table class="table">
|
||||
<tr><th>玩家名</th><th>最后活跃</th><th>总在线时长</th><th>IP地址</th><th>IP属地</th></tr>
|
||||
${players.map(p => {
|
||||
const loginTime = p.last_login_time ? new Date(p.last_login_time*1000).toLocaleString() : '-';
|
||||
const ip = p.ip_address || '-';
|
||||
const ipLoc = p.ip_location || '-';
|
||||
const isShared = (ipCountMap[ip] || 0) > 1;
|
||||
let subnet = '';
|
||||
if (ip !== '-') {
|
||||
const parts = ip.split('.');
|
||||
subnet = parts.length === 4 ? parts[0]+'.'+parts[1]+'.'+parts[2] : ip;
|
||||
}
|
||||
const isShared = subnet && (subnetCountMap[subnet] || 0) > 1;
|
||||
const ipStyle = isShared ? 'color:var(--yellow);font-weight:700' : '';
|
||||
const ipBadge = isShared ? ` <span style="font-size:10px;background:var(--yellow);color:#000;padding:1px 4px;border-radius:3px">同IP${ipCountMap[ip]}人</span>` : '';
|
||||
const ipBadge = isShared ? ` <span style="font-size:10px;background:var(--yellow);color:#000;padding:1px 4px;border-radius:3px">同网段${subnetCountMap[subnet]}人</span>` : '';
|
||||
return `<tr>
|
||||
<td>${p.player_name}</td>
|
||||
<td>${loginTime}</td>
|
||||
@@ -1209,15 +1233,15 @@ async function loadSameIpTab(tab) {
|
||||
}
|
||||
|
||||
const groups = r.data;
|
||||
let html = `<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">同IP多玩家 <span style="color:var(--dim)">(${groups.length}个IP组)</span></h3>`;
|
||||
let html = `<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">同网段玩家 <span style="color:var(--dim)">(${groups.length}个网段组)</span></h3>`;
|
||||
groups.forEach(g => {
|
||||
html += `<div class="card" style="margin-bottom:12px">`;
|
||||
html += `<h4 style="color:var(--yellow);margin-bottom:8px">🌐 ${g.ip} <span style="font-size:12px;color:var(--dim)">(${g.player_count}人 | ${g.ip_location || '-'})</span></h4>`;
|
||||
html += `<h4 style="color:var(--yellow);margin-bottom:8px">🌐 ${g.subnet || g.ip} <span style="font-size:12px;color:var(--dim)">(${g.player_count}人 | ${g.ip_location || '-'})</span></h4>`;
|
||||
html += `<table class="table">`;
|
||||
html += `<tr><th>玩家名</th><th>IP属地</th><th>最后登录时间</th></tr>`;
|
||||
html += `<tr><th>玩家名</th><th>IP地址</th><th>IP属地</th><th>最后登录时间</th></tr>`;
|
||||
g.players.forEach(p => {
|
||||
const loginTime = p.login_time ? new Date(p.login_time*1000).toLocaleString() : '-';
|
||||
html += `<tr><td>${p.player_name}</td><td style="font-size:12px">${p.ip_location || '-'}</td><td>${loginTime}</td></tr>`;
|
||||
html += `<tr><td>${p.player_name}</td><td style="font-size:12px;font-family:monospace">${p.ip_address || '-'}</td><td style="font-size:12px">${p.ip_location || '-'}</td><td>${loginTime}</td></tr>`;
|
||||
});
|
||||
html += `</table></div>`;
|
||||
});
|
||||
|
||||
+540
-102
@@ -20,6 +20,13 @@ ini_set('memory_limit', '256M');
|
||||
if (function_exists('opcache_reset')) { @opcache_reset(); }
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__, true); }
|
||||
|
||||
// ★ 注册shutdown函数:确保ob缓冲区在致命错误时也能刷新
|
||||
register_shutdown_function(function() {
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_flush();
|
||||
}
|
||||
});
|
||||
|
||||
if (!extension_loaded('sqlite3')) {
|
||||
exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
@@ -96,6 +103,7 @@ function adminDeduct() {
|
||||
requireAdminSession();
|
||||
$player = getParam('player');
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$reason = getParam('reason', '管理员扣减');
|
||||
if (!$player) exit(json_encode(['success' => false, 'message' => 'Missing player'], JSON_UNESCAPED_UNICODE));
|
||||
if ($amount <= 0) exit(json_encode(['success' => false, 'message' => 'Invalid amount'], JSON_UNESCAPED_UNICODE));
|
||||
$db = getDB();
|
||||
@@ -112,6 +120,22 @@ function adminDeduct() {
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
// ★ 写入web_transactions表,供Java插件拉取
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT DEFAULT '', detail TEXT DEFAULT '', status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||||
$txStmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:player, :type, :amount, :operator, :reason, :detail, 'pending', :time)");
|
||||
$txStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':type', 'admin_deduct', SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':amount', -$amount, SQLITE3_INTEGER); // 负数表示扣减
|
||||
$txStmt->bindValue(':operator', 'admin', SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':reason', $reason, SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':detail', json_encode(['admin_action' => 'deduct', 'original' => $current, 'new' => $newAmount]), SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$txStmt->execute();
|
||||
|
||||
// ★ 通知Java插件立即拉取
|
||||
notifyJavaPluginImmediatePull();
|
||||
|
||||
exit(json_encode(['success' => true, 'message' => 'OK', 'remaining' => $newAmount], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[Function] Error: ' . $e->getMessage());
|
||||
@@ -123,6 +147,7 @@ function adminAddBonds() {
|
||||
requireAdminSession();
|
||||
$player = getParam('player');
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$reason = getParam('reason', '管理员充值');
|
||||
if (!$player) exit(json_encode(['success' => false, 'message' => 'Missing player'], JSON_UNESCAPED_UNICODE));
|
||||
if ($amount <= 0) exit(json_encode(['success' => false, 'message' => 'Invalid amount'], JSON_UNESCAPED_UNICODE));
|
||||
$db = getDB();
|
||||
@@ -138,6 +163,22 @@ function adminAddBonds() {
|
||||
$stmt->bindValue(':amount', $newAmount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// ★ 写入web_transactions表,供Java插件拉取
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT DEFAULT '', detail TEXT DEFAULT '', status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||||
$txStmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:player, :type, :amount, :operator, :reason, :detail, 'pending', :time)");
|
||||
$txStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':type', 'admin_add', SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':amount', $amount, SQLITE3_INTEGER); // 正数表示增加
|
||||
$txStmt->bindValue(':operator', 'admin', SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':reason', $reason, SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':detail', json_encode(['admin_action' => 'add', 'original' => $current, 'new' => $newAmount]), SQLITE3_TEXT);
|
||||
$txStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$txStmt->execute();
|
||||
|
||||
// ★ 通知Java插件立即拉取
|
||||
notifyJavaPluginImmediatePull();
|
||||
|
||||
exit(json_encode(['success' => true, 'message' => 'OK', 'new_amount' => $newAmount], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[Function] Error: ' . $e->getMessage());
|
||||
@@ -234,9 +275,44 @@ function adminAllTx() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
try {
|
||||
$result = $db->query("SELECT * FROM web_transactions ORDER BY created_at DESC LIMIT 100");
|
||||
// 合并web_transactions(Web端交易)和game_transactions(游戏内交易)
|
||||
// 统一字段名:source区分来源(web/game)
|
||||
$txs = [];
|
||||
|
||||
// 查询Web端交易
|
||||
try {
|
||||
$result = $db->query("SELECT *, 'web' as source FROM web_transactions ORDER BY created_at DESC LIMIT 100");
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) {
|
||||
// web_transactions表可能不存在,忽略
|
||||
}
|
||||
|
||||
// 查询游戏内交易
|
||||
try {
|
||||
$result = $db->query("SELECT
|
||||
java_id as id,
|
||||
player_name,
|
||||
type,
|
||||
amount,
|
||||
operator,
|
||||
reason,
|
||||
'' as detail,
|
||||
tx_time as created_at,
|
||||
'game' as source,
|
||||
balance_before,
|
||||
balance_after
|
||||
FROM game_transactions ORDER BY tx_time DESC LIMIT 100");
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) {
|
||||
// game_transactions表可能不存在,忽略
|
||||
}
|
||||
|
||||
// 按时间排序(倒序),取前100条
|
||||
usort($txs, function($a, $b) {
|
||||
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
|
||||
});
|
||||
$txs = array_slice($txs, 0, 100);
|
||||
|
||||
exit(json_encode(['success' => true, 'data' => $txs], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[Function] Error: ' . $e->getMessage());
|
||||
@@ -250,11 +326,46 @@ function adminPlayerTx() {
|
||||
if (!$player) exit(json_encode(['success' => false, 'message' => 'Missing player'], JSON_UNESCAPED_UNICODE));
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM web_transactions WHERE player_name=:p ORDER BY created_at DESC LIMIT 50");
|
||||
$txs = [];
|
||||
|
||||
// 查询Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions WHERE player_name=:p ORDER BY created_at DESC LIMIT 50");
|
||||
$stmt->bindValue(':p', $player);
|
||||
$result = $stmt->execute();
|
||||
$txs = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) {
|
||||
// web_transactions表可能不存在,忽略
|
||||
}
|
||||
|
||||
// 查询游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT
|
||||
java_id as id,
|
||||
player_name,
|
||||
type,
|
||||
amount,
|
||||
operator,
|
||||
reason,
|
||||
'' as detail,
|
||||
tx_time as created_at,
|
||||
'game' as source,
|
||||
balance_before,
|
||||
balance_after
|
||||
FROM game_transactions WHERE player_name=:p ORDER BY tx_time DESC LIMIT 50");
|
||||
$stmt->bindValue(':p', $player);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) {
|
||||
// game_transactions表可能不存在,忽略
|
||||
}
|
||||
|
||||
// 按时间排序(倒序)
|
||||
usort($txs, function($a, $b) {
|
||||
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
|
||||
});
|
||||
$txs = array_slice($txs, 0, 50);
|
||||
|
||||
exit(json_encode(['success' => true, 'data' => $txs], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[Function] Error: ' . $e->getMessage());
|
||||
@@ -335,6 +446,24 @@ function adminNotifySync() {
|
||||
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ★ 通知Java插件立即拉取交易(管理员操作后调用)
|
||||
// PHP和MC服务器可能不在同一台机器,127.0.0.1回调不可靠
|
||||
// 交易已写入web_transactions表,通过sync_requests标记让Java快速检测
|
||||
function notifyJavaPluginImmediatePull() {
|
||||
$db = getDB();
|
||||
try {
|
||||
// 写入sync_requests标记,Java的activeSync轮询会检测到并立即拉取
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt->bindValue(':player', '__admin_tx__', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
@error_log('[Admin] 已写入sync_requests标记');
|
||||
} catch (Exception $e) {
|
||||
@error_log('[Admin] sync_requests写入失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function adminSyncNow() {
|
||||
requireAdminSession();
|
||||
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
|
||||
@@ -428,7 +557,7 @@ function queryIpLocation($ip) {
|
||||
$stmt2 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$stmt2->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$stmt2->execute();
|
||||
} catch (Exception $e) {}
|
||||
} catch (\Throwable $e) {}
|
||||
} else {
|
||||
// 格式正确,直接使用
|
||||
@error_log("[IP_QUERY] Cache hit (database): $ip -> $cachedLoc");
|
||||
@@ -436,7 +565,7 @@ function queryIpLocation($ip) {
|
||||
return $memCache[$ip];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
// 表不存在则忽略
|
||||
}
|
||||
|
||||
@@ -966,13 +1095,15 @@ function adminListUsers() {
|
||||
if (!empty($playerNames)) {
|
||||
$ipMap = batchGetPlayerIps($db, $playerNames);
|
||||
|
||||
// ★ 批量读取数据库缓存(不调API,极快)
|
||||
// ★ 批量读取数据库缓存 + 自动填充缺失IP归属地
|
||||
$uniqueIps = array_unique(array_values($ipMap));
|
||||
$locCache = [];
|
||||
$invalidIps4 = [];
|
||||
$uncachedIps = [];
|
||||
try {
|
||||
$validIps = array_filter($uniqueIps, fn($ip) => $ip && $ip !== '-');
|
||||
if (!empty($validIps)) {
|
||||
// 先读缓存
|
||||
$placeholders = implode(',', array_fill(0, count($validIps), '?'));
|
||||
$locStmt = $db->prepare("SELECT ip_address, location FROM player_ip_locations WHERE ip_address IN ($placeholders) AND location != '查询失败'");
|
||||
$idx = 1;
|
||||
@@ -992,8 +1123,29 @@ function adminListUsers() {
|
||||
foreach ($invalidIps4 as $dip) { $delS4->bindValue($di4++, $dip, SQLITE3_TEXT); }
|
||||
$delS4->execute();
|
||||
}
|
||||
// ★ 找出未缓存的IP,逐个查询并写入数据库(限制10个避免超时)
|
||||
foreach ($validIps as $ip) {
|
||||
if (!isset($locCache[$ip]) && !in_array($ip, $invalidIps4)) {
|
||||
$uncachedIps[] = $ip;
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
}
|
||||
$queryLimit = min(count($uncachedIps), 10);
|
||||
for ($qi = 0; $qi < $queryLimit; $qi++) {
|
||||
$ip = $uncachedIps[$qi];
|
||||
$locData = queryIpLocationWithTimeout($ip, 2);
|
||||
if ($locData && $locData['location'] && $locData['location'] !== '查询失败' && isValidIpLocationFormat($locData['location'])) {
|
||||
$locCache[$ip] = $locData['location'];
|
||||
try {
|
||||
$insStmt = $db->prepare("INSERT OR REPLACE INTO player_ip_locations (player_name, ip_address, location, updated_at) VALUES ('global', :ip, :loc, :time)");
|
||||
$insStmt->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$insStmt->bindValue(':loc', $locData['location'], SQLITE3_TEXT);
|
||||
$insStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$insStmt->execute();
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
foreach ($users as &$user) {
|
||||
$key = strtolower($user['player_name']);
|
||||
@@ -1011,6 +1163,90 @@ function adminListUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 智能搜索类型检测
|
||||
* 纯英文/数字 → 玩家名 (name)
|
||||
* IP格式 (x.x.x.x) → IP搜索 (ip)
|
||||
* 日期格式 (2026年6月18日 / 20260618 / 2026-06-18) → 日期搜索 (date)
|
||||
* 省名/地名 → 地区搜索 (region)
|
||||
*/
|
||||
function detectSearchType($search) {
|
||||
// 1. IP格式检测:x.x.x.x (支持部分匹配如 192.168)
|
||||
if (preg_match('/^\d{1,3}\.\d{1,3}(\.\d{1,3}(\.\d{1,3})?)?$/', $search)) {
|
||||
return 'ip';
|
||||
}
|
||||
|
||||
// 2. 日期格式检测
|
||||
// 中文格式:2026年6月18日、2026年06月18日
|
||||
if (preg_match('/^\d{4}年\d{1,2}月\d{1,2}日$/', $search)) {
|
||||
return 'date';
|
||||
}
|
||||
// 紧凑格式:20260618
|
||||
if (preg_match('/^\d{8}$/', $search)) {
|
||||
return 'date';
|
||||
}
|
||||
// 横线格式:2026-06-18
|
||||
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $search)) {
|
||||
return 'date';
|
||||
}
|
||||
|
||||
// 3. 地区名检测(省名/市名/区名)
|
||||
$regions = [
|
||||
'北京', '天津', '上海', '重庆',
|
||||
'河北', '山西', '辽宁', '吉林', '黑龙江', '江苏', '浙江', '安徽', '福建', '江西', '山东',
|
||||
'河南', '湖北', '湖南', '广东', '海南', '四川', '贵州', '云南', '陕西', '甘肃', '青海',
|
||||
'台湾', '内蒙古', '广西', '西藏', '宁夏', '新疆',
|
||||
'香港', '澳门',
|
||||
// 常见城市
|
||||
'石家庄', '太原', '呼和浩特', '沈阳', '长春', '哈尔滨', '南京', '杭州', '合肥', '福州',
|
||||
'南昌', '济南', '郑州', '武汉', '长沙', '广州', '南宁', '海口', '成都', '贵阳', '昆明',
|
||||
'拉萨', '西安', '兰州', '西宁', '银川', '乌鲁木齐',
|
||||
'深圳', '珠海', '汕头', '佛山', '东莞', '中山', '惠州', '江门', '湛江', '茂名',
|
||||
'苏州', '无锡', '常州', '宁波', '温州', '嘉兴', '绍兴', '金华', '台州',
|
||||
'厦门', '泉州', '漳州', '青岛', '烟台', '潍坊', '临沂',
|
||||
'洛阳', '开封', '新乡', '南阳', '许昌',
|
||||
'株洲', '岳阳', '常德', '衡阳',
|
||||
'绵阳', '德阳', '宜宾', '泸州',
|
||||
];
|
||||
foreach ($regions as $region) {
|
||||
if (mb_strpos($search, $region) !== false || $search === $region) {
|
||||
return 'region';
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 默认:纯英文/数字 → 玩家名
|
||||
return 'name';
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 日期搜索解析:将搜索文本转换为时间戳范围
|
||||
*/
|
||||
function parseSearchDate($search) {
|
||||
$year = $month = $day = 0;
|
||||
|
||||
// 中文格式:2026年6月18日
|
||||
if (preg_match('/(\d{4})年(\d{1,2})月(\d{1,2})日/', $search, $m)) {
|
||||
$year = (int)$m[1]; $month = (int)$m[2]; $day = (int)$m[3];
|
||||
}
|
||||
// 横线格式:2026-06-18
|
||||
elseif (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $search, $m)) {
|
||||
$year = (int)$m[1]; $month = (int)$m[2]; $day = (int)$m[3];
|
||||
}
|
||||
// 紧凑格式:20260618
|
||||
elseif (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $search, $m)) {
|
||||
$year = (int)$m[1]; $month = (int)$m[2]; $day = (int)$m[3];
|
||||
}
|
||||
|
||||
if ($year > 0 && $month > 0 && $day > 0) {
|
||||
$start = mktime(0, 0, 0, $month, $day, $year);
|
||||
$end = mktime(0, 0, 0, $month, $day + 1, $year);
|
||||
if ($start !== false && $end !== false) {
|
||||
return ['start' => $start, 'end' => $end];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页API - 懒加载支持(搜索模式)
|
||||
* 搜索时不限制IP查询,一次性返回全部结果
|
||||
@@ -1022,16 +1258,133 @@ function adminListUsersPaginated() {
|
||||
$page = max(1, (int)getParam('page', 1));
|
||||
$limit = min(50, max(10, (int)getParam('limit', 20))); // 默认15-20个
|
||||
$offset = ($page - 1) * $limit;
|
||||
$search = trim(getParam('search', ''));
|
||||
$search = rawurldecode(trim(getParam('search', '')));
|
||||
$includeIp = getParam('includeIp', '1') === '1'; // 默认包含IP,搜索时可设为0
|
||||
$isSearchMode = !empty($search); // 是否搜索模式(不限制并发)
|
||||
$regionDebugEnabled = false;
|
||||
$regionDebug = [];
|
||||
|
||||
// ★ 智能搜索:自动识别搜索类型
|
||||
$searchType = 'name'; // 默认:玩家名
|
||||
$searchBound = '';
|
||||
$whereClause = '';
|
||||
$uncachedIps = []; // ★ 确保始终定义
|
||||
|
||||
if ($search) {
|
||||
$searchType = detectSearchType($search);
|
||||
|
||||
if ($searchType === 'ip') {
|
||||
// IP搜索:匹配玩家IP归属地或IP本身
|
||||
$ipSearch = $search;
|
||||
$whereClause = "WHERE player_name IN (SELECT player_name FROM player_ip_changes WHERE new_ip LIKE :ip) OR player_name IN (SELECT player_name FROM player_ip_locations WHERE ip_address LIKE :ip2)";
|
||||
$searchBound = "%$ipSearch%";
|
||||
} elseif ($searchType === 'date') {
|
||||
// 日期搜索:转换为时间戳范围
|
||||
$dateRange = parseSearchDate($search);
|
||||
if ($dateRange) {
|
||||
$whereClause = "WHERE (last_login_time >= :dateStart AND last_login_time < :dateEnd) OR (register_time >= :dateStart AND register_time < :dateEnd)";
|
||||
} else {
|
||||
// 日期解析失败,回退到模糊搜索
|
||||
$searchType = 'name';
|
||||
$whereClause = "WHERE LOWER(player_name) LIKE :search";
|
||||
$searchBound = "%" . strtolower($search) . "%";
|
||||
}
|
||||
} elseif ($searchType === 'region') {
|
||||
// ★★★ 地区搜索:使用 SQL JOIN 直接查找(诊断证明可靠)★★★
|
||||
$regionKey = $search;
|
||||
$regionDebug = ['search' => $search, 'tables' => [], 'method' => 'sql_join'];
|
||||
|
||||
// 检查表数据量
|
||||
foreach (['player_ip_locations', 'player_ip_changes', 'web_session_log', 'users'] as $t) {
|
||||
try {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM $t")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
$regionDebug['tables'][$t] = $cnt;
|
||||
} catch (\Throwable $e) {
|
||||
$regionDebug['tables'][$t] = 'error:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 用 SQL JOIN 直接找到匹配地区的玩家名
|
||||
$matchedPlayers = [];
|
||||
try {
|
||||
$joinSql = "SELECT DISTINCT c.player_name
|
||||
FROM player_ip_changes c
|
||||
INNER JOIN player_ip_locations l ON c.new_ip = l.ip_address
|
||||
WHERE l.location LIKE :region AND c.new_ip != '' AND c.new_ip != '-'
|
||||
UNION
|
||||
SELECT DISTINCT w.player_name
|
||||
FROM web_session_log w
|
||||
INNER JOIN player_ip_locations l ON w.ip_address = l.ip_address
|
||||
WHERE l.location LIKE :region2 AND w.ip_address != '' AND w.ip_address != '-'";
|
||||
$joinStmt = $db->prepare($joinSql);
|
||||
$joinStmt->bindValue(':region', "%$regionKey%", SQLITE3_TEXT);
|
||||
$joinStmt->bindValue(':region2', "%$regionKey%", SQLITE3_TEXT);
|
||||
$joinResult = $joinStmt->execute();
|
||||
while ($jRow = $joinResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$matchedPlayers[] = $jRow['player_name'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$regionDebug['error'] = $e->getMessage();
|
||||
@error_log("[REGION_SEARCH] JOIN query error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$regionDebug['matched'] = count($matchedPlayers);
|
||||
$regionDebug['players'] = array_slice($matchedPlayers, 0, 20);
|
||||
|
||||
// ★ 构建WHERE子句
|
||||
if (!empty($matchedPlayers)) {
|
||||
$namePlaceholders = implode(',', array_fill(0, count($matchedPlayers), '?'));
|
||||
$whereClause = "WHERE player_name IN ($namePlaceholders)";
|
||||
$searchBound = $matchedPlayers;
|
||||
} else {
|
||||
$whereClause = "WHERE 1=0";
|
||||
$searchBound = [];
|
||||
}
|
||||
|
||||
// ★ 始终启用调试输出
|
||||
$regionDebugEnabled = true;
|
||||
} else {
|
||||
// 纯文本:按玩家名搜索
|
||||
$whereClause = "WHERE LOWER(player_name) LIKE :search";
|
||||
$searchBound = "%" . strtolower($search) . "%";
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有用户信息(本地查询,极快)
|
||||
$sql = "SELECT player_name, register_time, last_login_time, points, total_online_time, email FROM users";
|
||||
|
||||
if ($search) {
|
||||
$searchBound = "%" . $search . "%";
|
||||
$stmt = $db->prepare($sql . " WHERE LOWER(player_name) LIKE :search ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
if ($searchType === 'date' && $dateRange) {
|
||||
$stmt = $db->prepare($sql . " " . $whereClause . " ORDER BY last_login_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
$stmt->bindValue(':dateStart', $dateRange['start'], SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':dateEnd', $dateRange['end'], SQLITE3_INTEGER);
|
||||
} elseif ($searchType === 'ip') {
|
||||
$stmt = $db->prepare($sql . " " . $whereClause . " ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
$stmt->bindValue(':ip', $searchBound, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':ip2', $searchBound, SQLITE3_TEXT);
|
||||
} elseif ($searchType === 'region') {
|
||||
// ★ 地区搜索:已用PHP两步法获取匹配玩家列表,直接绑定参数
|
||||
try {
|
||||
$stmt = $db->prepare($sql . " " . $whereClause . " ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
if (!empty($matchedPlayers)) {
|
||||
$idx = 1;
|
||||
foreach ($matchedPlayers as $pn) {
|
||||
$stmt->bindValue($idx++, $pn, SQLITE3_TEXT);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@error_log('[adminListUsersPaginated] Region search prepare failed: ' . $e->getMessage());
|
||||
$regionDebug['prepare_error'] = $e->getMessage();
|
||||
// ★ 不改变 searchType,保持 'region' 以便输出调试信息
|
||||
// 回退:用简单查询
|
||||
$whereClause = "WHERE 1=0";
|
||||
$searchBound = [];
|
||||
$stmt = $db->prepare($sql . " " . $whereClause . " ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
}
|
||||
} else {
|
||||
$stmt = $db->prepare($sql . " " . $whereClause . " ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
$stmt->bindValue(':search', $searchBound, SQLITE3_TEXT);
|
||||
}
|
||||
$result = $stmt->execute();
|
||||
} else {
|
||||
$result = $db->query($sql . " ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
|
||||
@@ -1042,9 +1395,34 @@ function adminListUsersPaginated() {
|
||||
// 获取总数
|
||||
$countSql = "SELECT COUNT(*) as cnt FROM users";
|
||||
if ($search) {
|
||||
$stmt = $db->prepare($countSql . " WHERE LOWER(player_name) LIKE :search");
|
||||
$stmt->bindValue(':search', $searchBound, SQLITE3_TEXT);
|
||||
$countResult = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($searchType === 'date' && $dateRange) {
|
||||
$countStmt = $db->prepare($countSql . " " . $whereClause);
|
||||
$countStmt->bindValue(':dateStart', $dateRange['start'], SQLITE3_INTEGER);
|
||||
$countStmt->bindValue(':dateEnd', $dateRange['end'], SQLITE3_INTEGER);
|
||||
} elseif ($searchType === 'ip') {
|
||||
$countStmt = $db->prepare($countSql . " " . $whereClause);
|
||||
$countStmt->bindValue(':ip', $searchBound, SQLITE3_TEXT);
|
||||
$countStmt->bindValue(':ip2', $searchBound, SQLITE3_TEXT);
|
||||
} elseif ($searchType === 'region') {
|
||||
try {
|
||||
$countStmt = $db->prepare($countSql . " " . $whereClause);
|
||||
if (!empty($matchedPlayers)) {
|
||||
$idx = 1;
|
||||
foreach ($matchedPlayers as $pn) {
|
||||
$countStmt->bindValue($idx++, $pn, SQLITE3_TEXT);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@error_log('[adminListUsersPaginated] Region count prepare failed: ' . $e->getMessage());
|
||||
$regionDebug['count_prepare_error'] = $e->getMessage();
|
||||
// ★ 不改变 searchType
|
||||
$countStmt = $db->query("SELECT 0 as cnt");
|
||||
}
|
||||
} else {
|
||||
$countStmt = $db->prepare($countSql . " " . $whereClause);
|
||||
$countStmt->bindValue(':search', $searchBound, SQLITE3_TEXT);
|
||||
}
|
||||
$countResult = $countStmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
} else {
|
||||
$countResult = $db->query($countSql)->fetchArray(SQLITE3_ASSOC);
|
||||
}
|
||||
@@ -1091,7 +1469,7 @@ function adminListUsersPaginated() {
|
||||
@error_log("[IP_QUERY] Cleaned " . count($invalidIps) . " invalid records from DB on read");
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 4. 收集未缓存的IP返回给前端(前端后台批量查)+ 刚清理的无效IP也需重新查询
|
||||
// ★ 过滤掉"-"和空值,只保留有效IP格式
|
||||
@@ -1122,7 +1500,7 @@ function adminListUsersPaginated() {
|
||||
unset($user);
|
||||
}
|
||||
|
||||
exit(json_encode([
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => $users,
|
||||
'uncached_ips' => $uncachedIps ?? [], // 未缓存的IP,供前端批量查询
|
||||
@@ -1133,11 +1511,22 @@ function adminListUsersPaginated() {
|
||||
'total_pages' => $totalPages,
|
||||
'has_more' => $page < $totalPages
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
];
|
||||
// ★ 地区搜索调试信息(始终附加,帮助诊断)
|
||||
if ($searchType === 'region' || (!empty($regionDebugEnabled))) {
|
||||
$response['_region_debug'] = $regionDebug ?? [];
|
||||
}
|
||||
// ★ 版本标记:用于确认服务器运行的是最新代码
|
||||
$response['_v'] = '20260619-v3';
|
||||
$response['_search_type'] = $searchType;
|
||||
$response['_search'] = $search;
|
||||
exit(json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
} catch (\Throwable $e) {
|
||||
// 错误信息写日志,不暴露给前端
|
||||
@error_log('[adminListUsersPaginated] Error: ' . $e->getMessage());
|
||||
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
|
||||
@error_log('[adminListUsersPaginated] Error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
@error_log('[adminListUsersPaginated] Stack: ' . $e->getTraceAsString());
|
||||
@ob_end_flush();
|
||||
exit(json_encode(['success' => false, 'message' => 'Internal error: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1149,33 +1538,58 @@ function adminListSameIp() {
|
||||
// 从 player_ip_changes 获取所有玩家及其最新 IP
|
||||
$stmt = $db->prepare("SELECT LOWER(player_name) as pn, LOWER(new_ip) as ip FROM player_ip_changes ORDER BY changed_at DESC");
|
||||
$result = $stmt->execute();
|
||||
$ipPlayerMap = [];
|
||||
$ipPlayerMap = []; // exact IP → [players]
|
||||
$subnetPlayerMap = []; // /24 subnet → [players]
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$ip = $row['ip'];
|
||||
$player = $row['pn'];
|
||||
|
||||
// 精确IP映射
|
||||
if (!isset($ipPlayerMap[$ip])) $ipPlayerMap[$ip] = [];
|
||||
if (!in_array($player, $ipPlayerMap[$ip])) {
|
||||
$ipPlayerMap[$ip][] = $player;
|
||||
}
|
||||
|
||||
// /24子网映射:取前三段
|
||||
$parts = explode('.', $ip);
|
||||
$subnet = count($parts) === 4 ? $parts[0].'.'.$parts[1].'.'.$parts[2] : $ip;
|
||||
if (!isset($subnetPlayerMap[$subnet])) $subnetPlayerMap[$subnet] = [];
|
||||
if (!in_array($player, $subnetPlayerMap[$subnet])) {
|
||||
$subnetPlayerMap[$subnet][] = $player;
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选出同 IP 对应多个玩家的分组
|
||||
// ★ 按/24子网分组(同一子网内的所有玩家视为同一组)
|
||||
$groups = [];
|
||||
foreach ($ipPlayerMap as $ip => $players) {
|
||||
foreach ($subnetPlayerMap as $subnet => $players) {
|
||||
if (count($players) > 1) {
|
||||
// 获取 IP 归属地
|
||||
$locData = queryIpLocation($ip);
|
||||
// 从该子网内取一个代表IP查归属地
|
||||
$representIp = '';
|
||||
foreach ($ipPlayerMap as $ip => $pArr) {
|
||||
$parts = explode('.', $ip);
|
||||
if (count($parts) === 4 && $parts[0].'.'.$parts[1].'.'.$parts[2] === $subnet) {
|
||||
$representIp = $ip;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$locData = queryIpLocation($representIp);
|
||||
$playerDetails = [];
|
||||
foreach ($players as $player) {
|
||||
// 找该玩家的实际IP
|
||||
$playerIp = '-';
|
||||
foreach ($ipPlayerMap as $ip => $pArr) {
|
||||
if (in_array($player, $pArr)) { $playerIp = $ip; break; }
|
||||
}
|
||||
$playerDetails[] = [
|
||||
'player_name' => $player,
|
||||
'ip_address' => $ip,
|
||||
'ip_address' => $playerIp,
|
||||
'ip_location' => $locData['location'],
|
||||
'login_time' => 0
|
||||
];
|
||||
}
|
||||
$groups[] = [
|
||||
'ip' => $ip,
|
||||
'ip' => $subnet . '.x',
|
||||
'subnet' => $subnet,
|
||||
'ip_location' => $locData['location'],
|
||||
'player_count' => count($players),
|
||||
'players' => $playerDetails
|
||||
@@ -1188,6 +1602,7 @@ function adminListSameIp() {
|
||||
|
||||
exit(json_encode(['success' => true, 'data' => $groups], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[adminListSameIp] Error: ' . $e->getMessage());
|
||||
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
@@ -1377,7 +1792,7 @@ function adminBatchQueryIps() {
|
||||
'remaining' => $remaining,
|
||||
'remaining_count' => count($remaining)
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
@error_log('[adminBatchQueryIps] Error: ' . $e->getMessage());
|
||||
exit(json_encode(['success' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
@@ -1390,11 +1805,25 @@ function adminBatchQueryIps() {
|
||||
function adminListOnlineNames() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
$db->exec('PRAGMA busy_timeout=15000');
|
||||
try {
|
||||
$cutoff = time() - 120;
|
||||
$stmt = $db->prepare("SELECT player_name FROM online_players WHERE login_time >= :cutoff");
|
||||
$stmt->bindValue(':cutoff', $cutoff, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
// ★ 先检查心跳
|
||||
$hbCheck = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
|
||||
$hbRow = $hbCheck ? $hbCheck->fetchArray(SQLITE3_ASSOC) : null;
|
||||
$heartbeatFresh = false;
|
||||
if ($hbRow !== false && isset($hbRow['last_seen']) && $hbRow['last_seen'] > 0) {
|
||||
$lastHb = (int)$hbRow['last_seen'];
|
||||
if ((time() - $lastHb) <= 120) {
|
||||
$heartbeatFresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$heartbeatFresh) {
|
||||
exit(json_encode(['success' => true, 'data' => []], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ★ 修复:心跳有效时直接查询全部在线玩家(online_players表每次同步全量重建,无需login_time过滤)
|
||||
$result = $db->query("SELECT player_name FROM online_players");
|
||||
$names = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$names[] = $row['player_name'];
|
||||
@@ -1408,14 +1837,28 @@ function adminListOnlineNames() {
|
||||
function adminListOnlinePlayers() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
$db->exec('PRAGMA busy_timeout=15000');
|
||||
try {
|
||||
// ★ 实时在线玩家:只查询最近2分钟内活跃的(±1分钟误差)
|
||||
$cutoff = time() - 120; // 2分钟窗口
|
||||
// ★ 先检查心跳:如果超过120秒没收到Java推送,返回空列表(防止残留旧数据)
|
||||
$hbCheck = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
|
||||
$hbRow = $hbCheck ? $hbCheck->fetchArray(SQLITE3_ASSOC) : null;
|
||||
$heartbeatFresh = false;
|
||||
if ($hbRow !== false && isset($hbRow['last_seen']) && $hbRow['last_seen'] > 0) {
|
||||
$lastHb = (int)$hbRow['last_seen'];
|
||||
if ((time() - $lastHb) <= 120) {
|
||||
$heartbeatFresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$heartbeatFresh) {
|
||||
// 心跳过期或无心跳,强制在线人数为0
|
||||
exit(json_encode(['success' => true, 'data' => []], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ★ 修复:心跳有效时直接查询全部在线玩家(online_players表每次同步全量重建,无需login_time过滤)
|
||||
$now = time();
|
||||
|
||||
$stmt = $db->prepare("SELECT player_name, login_time FROM online_players WHERE login_time >= :cutoff ORDER BY login_time DESC");
|
||||
$stmt->bindValue(':cutoff', $cutoff, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
$result = $db->query("SELECT player_name, login_time FROM online_players ORDER BY login_time DESC");
|
||||
$players = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$players[] = $row;
|
||||
@@ -1487,37 +1930,10 @@ function adminListOnlinePlayers() {
|
||||
function batchGetPlayerIps($db, $playerNames) {
|
||||
$ipMap = [];
|
||||
|
||||
// 1. 从 player_ip_changes 批量获取(★ 优先取有效IP,避免取到"-")
|
||||
if (!empty($playerNames)) {
|
||||
if (empty($playerNames)) return $ipMap;
|
||||
$placeholders = implode(',', array_fill(0, count($playerNames), '?'));
|
||||
try {
|
||||
// ★ 优先取非"-"的最新IP;如果全是"-",才取"-"的最新IP
|
||||
$ipStmt = $db->prepare("
|
||||
SELECT p.player_name, p.new_ip AS ip_address
|
||||
FROM player_ip_changes p
|
||||
INNER JOIN (
|
||||
SELECT player_name,
|
||||
MAX(CASE WHEN new_ip != '-' AND new_ip != '' THEN changed_at ELSE 0 END) as best_time,
|
||||
MAX(changed_at) as latest_time
|
||||
FROM player_ip_changes
|
||||
WHERE player_name IN ($placeholders)
|
||||
GROUP BY player_name
|
||||
) latest ON p.player_name = latest.player_name
|
||||
AND p.changed_at = CASE WHEN latest.best_time > 0 THEN latest.best_time ELSE latest.latest_time END
|
||||
");
|
||||
$idx = 1;
|
||||
foreach ($playerNames as $pn) {
|
||||
$ipStmt->bindValue($idx++, $pn, SQLITE3_TEXT);
|
||||
}
|
||||
$ipResult = $ipStmt->execute();
|
||||
while ($row = $ipResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$key = strtolower($row['player_name']);
|
||||
if (!isset($ipMap[$key])) {
|
||||
$ipMap[$key] = $row['ip_address'];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// player_ip_changes 表可能不存在,降级为简单查询
|
||||
|
||||
// 1. 从 player_ip_changes 获取最新IP(简单查询,兼容所有SQLite版本)
|
||||
try {
|
||||
$ipStmt = $db->prepare("SELECT player_name, new_ip AS ip_address FROM player_ip_changes WHERE player_name IN ($placeholders) ORDER BY changed_at DESC");
|
||||
$idx = 1;
|
||||
@@ -1531,11 +1947,11 @@ function batchGetPlayerIps($db, $playerNames) {
|
||||
$ipMap[$key] = $row['ip_address'];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e2) {}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@error_log("[batchGetPlayerIps] player_ip_changes error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// 2. 缺失的去 web_session_log 批量补查
|
||||
// 2. 缺失的去 web_session_log 补查
|
||||
$missing = [];
|
||||
foreach ($playerNames as $pn) {
|
||||
$key = strtolower($pn);
|
||||
@@ -1546,34 +1962,6 @@ function batchGetPlayerIps($db, $playerNames) {
|
||||
|
||||
if (!empty($missing)) {
|
||||
$placeholders2 = implode(',', array_fill(0, count($missing), '?'));
|
||||
try {
|
||||
// ★ 同样优先取有效IP
|
||||
$stmt2 = $db->prepare("
|
||||
SELECT w.player_name, w.ip_address
|
||||
FROM web_session_log w
|
||||
INNER JOIN (
|
||||
SELECT player_name,
|
||||
MAX(CASE WHEN ip_address != '-' AND ip_address != '' THEN login_time ELSE 0 END) as best_time,
|
||||
MAX(login_time) as latest_time
|
||||
FROM web_session_log
|
||||
WHERE player_name IN ($placeholders2)
|
||||
GROUP BY player_name
|
||||
) latest ON w.player_name = latest.player_name
|
||||
AND w.login_time = CASE WHEN latest.best_time > 0 THEN latest.best_time ELSE latest.latest_time END
|
||||
");
|
||||
$idx2 = 1;
|
||||
foreach ($missing as $mn) {
|
||||
$stmt2->bindValue($idx2++, $mn, SQLITE3_TEXT);
|
||||
}
|
||||
$res2 = $stmt2->execute();
|
||||
while ($row = $res2->fetchArray(SQLITE3_ASSOC)) {
|
||||
$key = strtolower($row['player_name']);
|
||||
if (!isset($ipMap[$key])) {
|
||||
$ipMap[$key] = $row['ip_address'];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// 降级为简单查询
|
||||
try {
|
||||
$stmt2 = $db->prepare("SELECT player_name, ip_address FROM web_session_log WHERE player_name IN ($placeholders2) ORDER BY login_time DESC");
|
||||
$idx2 = 1;
|
||||
@@ -1587,7 +1975,8 @@ function batchGetPlayerIps($db, $playerNames) {
|
||||
$ipMap[$key] = $row['ip_address'];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e2) {}
|
||||
} catch (\Throwable $e2) {
|
||||
@error_log("[batchGetPlayerIps] web_session_log error: " . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,23 +2045,72 @@ function adminListActivePlayers() {
|
||||
unset($p);
|
||||
}
|
||||
|
||||
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
|
||||
// ★ 同IP去重:/24子网视为同一人,只保留最后活跃的玩家
|
||||
$deduped = [];
|
||||
$seenSubnets = []; // subnet => player_name (保留第一个)
|
||||
foreach ($players as $p) {
|
||||
$ip = $p['ip_address'] ?? '-';
|
||||
if ($ip && $ip !== '-') {
|
||||
$subnet = getSubnet24($ip);
|
||||
if (isset($seenSubnets[$subnet])) {
|
||||
// 同子网已有玩家,跳过这个(保留先出现的/活跃度最高的)
|
||||
$p['hidden_by_dedup'] = true;
|
||||
$p['dedup_group'] = $seenSubnets[$subnet];
|
||||
continue;
|
||||
}
|
||||
$seenSubnets[$subnet] = $p['player_name'];
|
||||
}
|
||||
$deduped[] = $p;
|
||||
}
|
||||
|
||||
exit(json_encode(['success' => true, 'data' => $deduped, 'total_before_dedup' => count($players)], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[adminListActivePlayers] Error: ' . $e->getMessage());
|
||||
exit(json_encode(['success' => true, 'data' => []], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 获取IP的/24子网(如 192.168.1.100 → 192.168.1)
|
||||
* 同一/24子网的设备视为同一人
|
||||
*/
|
||||
function getSubnet24($ip) {
|
||||
$parts = explode('.', $ip);
|
||||
if (count($parts) === 4) {
|
||||
return $parts[0] . '.' . $parts[1] . '.' . $parts[2];
|
||||
}
|
||||
return $ip; // 非标准IP,原样返回
|
||||
}
|
||||
|
||||
function adminGetStatsEx() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
$db->exec('PRAGMA busy_timeout=15000');
|
||||
|
||||
try {
|
||||
$onlineCount = 0;
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT COUNT(*) as cnt FROM online_players WHERE login_time >= :cutoff");
|
||||
$stmt->bindValue(':cutoff', time() - 60, SQLITE3_INTEGER);
|
||||
$r = $stmt->execute();
|
||||
// ★ 先检查心跳:如果超过120秒没收到Java推送,清空在线数据(防止残留旧数据)
|
||||
$hbCheck = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
|
||||
$hbRow = $hbCheck ? $hbCheck->fetchArray(SQLITE3_ASSOC) : null;
|
||||
$heartbeatFresh = false;
|
||||
if ($hbRow !== false && isset($hbRow['last_seen']) && $hbRow['last_seen'] > 0) {
|
||||
$lastHb = (int)$hbRow['last_seen'];
|
||||
if ((time() - $lastHb) <= 120) {
|
||||
$heartbeatFresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($heartbeatFresh) {
|
||||
// ★ 修复:心跳有效时直接查询全部在线玩家(online_players表每次同步全量重建,无需login_time过滤)
|
||||
// 旧代码 login_time >= time()-120 在Java推送间隔>120秒时会过滤掉所有玩家
|
||||
$r = $db->query("SELECT COUNT(*) as cnt FROM online_players");
|
||||
if ($r) { $row = $r->fetchArray(SQLITE3_ASSOC); $onlineCount = (int)($row['cnt'] ?? 0); }
|
||||
} else {
|
||||
// 心跳过期或无心跳,在线人数为0
|
||||
$onlineCount = 0;
|
||||
@error_log('[adminGetStatsEx] 心跳过期或无心跳,强制在线人数为0');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
@error_log('[adminGetStatsEx] online_players error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
+34
-7
@@ -228,28 +228,55 @@ function balanceTransactions($token) {
|
||||
|
||||
$db = getDB();
|
||||
|
||||
$txs = [];
|
||||
|
||||
if ($player) {
|
||||
// 查看指定玩家流水
|
||||
if ($tokenInfo['player'] !== $player && $tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('无权查看其他玩家流水');
|
||||
}
|
||||
$stmt = $db->prepare("SELECT * FROM web_transactions WHERE player_name = :player ORDER BY created_at DESC LIMIT :limit");
|
||||
// Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions WHERE player_name = :player ORDER BY created_at DESC LIMIT :limit");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
} else {
|
||||
// 管理员查看全服流水
|
||||
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('查看全服流水需要管理员权限');
|
||||
}
|
||||
$stmt = $db->prepare("SELECT * FROM web_transactions ORDER BY created_at DESC LIMIT :limit");
|
||||
// Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions ORDER BY created_at DESC LIMIT :limit");
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit");
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (Exception $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
$txs = [];
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$txs[] = $row;
|
||||
}
|
||||
// 按时间排序(倒序)
|
||||
usort($txs, function($a, $b) {
|
||||
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
|
||||
});
|
||||
$txs = array_slice($txs, 0, (int)$limit);
|
||||
|
||||
success($txs);
|
||||
}
|
||||
|
||||
+15
-12
@@ -148,6 +148,9 @@ function cdkExchange($token) {
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// ★ 所有DB操作包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 记录流水
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'cdk_redeem', :amount, :reason, :detail, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
@@ -157,6 +160,18 @@ function cdkExchange($token) {
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// ★ 写入sync_requests触发Java立即拉取(必须在success/exit之前!)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
}
|
||||
|
||||
success([
|
||||
'code' => $code,
|
||||
'amount' => $cdk['amount'],
|
||||
@@ -164,18 +179,6 @@ function cdkExchange($token) {
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance
|
||||
], 'CDK兑换成功');
|
||||
|
||||
// ★ 写入sync_requests触发Java立即拉取
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
// ★ 通过HTTP回调通知Java插件立即拉取交易
|
||||
$cbPort = defined('CALLBACK_PORT') ? CALLBACK_PORT : 9090;
|
||||
$callbackUrl = 'http://127.0.0.1:' . $cbPort . '/notify_sync';
|
||||
@file_get_contents($callbackUrl, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 3]]));
|
||||
}
|
||||
|
||||
// ===== 创建CDK =====
|
||||
|
||||
+68
-4
@@ -198,14 +198,74 @@ function registerQuery($token) {
|
||||
$row['last_login_time'] = (int)($row['last_login_time'] / 1000);
|
||||
}
|
||||
|
||||
// ★ 获取债券余额
|
||||
$bonds = 0;
|
||||
try {
|
||||
$bondStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$bondStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$bondResult = $bondStmt->execute();
|
||||
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
|
||||
} catch (Exception $e) {}
|
||||
|
||||
// ★ 获取累计在线天数(不重复日期计数)
|
||||
$totalOnlineDays = 0;
|
||||
try {
|
||||
$odStmt = $db->prepare("SELECT COUNT(DISTINCT login_date) as cnt FROM player_daily_logins WHERE player_name = :name");
|
||||
$odStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$odResult = $odStmt->execute();
|
||||
$odRow = $odResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
|
||||
} catch (Exception $e) {}
|
||||
|
||||
// ★ 获取累计签到天数
|
||||
$totalCheckinDays = 0;
|
||||
try {
|
||||
$ciStmt = $db->prepare("SELECT COUNT(DISTINCT checkin_date) as cnt FROM player_checkins WHERE player_name = :name");
|
||||
$ciStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$ciResult = $ciStmt->execute();
|
||||
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
|
||||
} catch (Exception $e) {}
|
||||
|
||||
// 合并额外数据
|
||||
$row['bonds'] = $bonds;
|
||||
$row['total_online_days'] = $totalOnlineDays;
|
||||
$row['total_checkin_days'] = $totalCheckinDays;
|
||||
|
||||
// 邮箱脱敏
|
||||
if (!empty($row['email'])) {
|
||||
$parts = explode('@', $row['email']);
|
||||
if (count($parts) === 2) {
|
||||
$row['masked_email'] = str_repeat('*', min(strlen($parts[0]), 3)) . '@' . $parts[1];
|
||||
} else {
|
||||
$row['masked_email'] = '***';
|
||||
}
|
||||
} else {
|
||||
$row['masked_email'] = '';
|
||||
}
|
||||
|
||||
// ★ 严格校验:必须有有效token且token对应的玩家必须与查询的玩家匹配(除非是管理员token)
|
||||
if (!$token) {
|
||||
// 无token:隐藏敏感信息
|
||||
preview($row, '预览模式 - 部分信息已隐藏');
|
||||
$previewRow = [
|
||||
'player_name' => $row['player_name'],
|
||||
'register_time' => $row['register_time'],
|
||||
'last_login_time' => $row['last_login_time'],
|
||||
'email' => '',
|
||||
'masked_email' => '',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0,
|
||||
'bonds' => 0,
|
||||
'total_online_days' => 0,
|
||||
'total_checkin_days' => 0
|
||||
];
|
||||
preview($previewRow, '预览模式 - 部分信息已隐藏');
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenInfo = validateToken($token);
|
||||
$tokenInfo = validateTokenSilent($token);
|
||||
if (!$tokenInfo) {
|
||||
error('无效token,请先登录', 401);
|
||||
}
|
||||
@@ -224,10 +284,14 @@ function registerQuery($token) {
|
||||
'player_name' => $row['player_name'],
|
||||
'register_time' => $row['register_time'],
|
||||
'last_login_time' => $row['last_login_time'],
|
||||
'email' => '***',
|
||||
'email' => '',
|
||||
'masked_email' => '***',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0
|
||||
'total_online_time' => 0,
|
||||
'bonds' => 0,
|
||||
'total_online_days' => 0,
|
||||
'total_checkin_days' => 0
|
||||
];
|
||||
preview($maskedRow, '预览模式 - 您只能查看自己的账号信息');
|
||||
}
|
||||
|
||||
+15
-16
@@ -182,7 +182,9 @@ function shopBuy($token) {
|
||||
], '预览模式 - 购买不会生效');
|
||||
}
|
||||
|
||||
// 实际购买
|
||||
// ★ 实际购买 — 包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 更新库存
|
||||
if ($item['stock'] > 0) {
|
||||
$stmt = $db->prepare("UPDATE shop_items SET stock = stock - :amount, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
@@ -206,8 +208,18 @@ function shopBuy($token) {
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// ★ 记录交易创建
|
||||
$txId = $db->lastInsertRowID();
|
||||
// ★ 写入sync_requests(必须在success/exit之前!)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
error('购买失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
success([
|
||||
'item_id' => $itemId,
|
||||
@@ -216,19 +228,6 @@ function shopBuy($token) {
|
||||
'total_price' => $totalPrice,
|
||||
'player' => $player
|
||||
], '购买成功');
|
||||
|
||||
// ★ 立即请求Java插件拉取pending交易
|
||||
$db2 = getDB();
|
||||
$db2->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db2->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
// ★ 通过HTTP回调通知Java插件立即拉取交易
|
||||
$cbPort = defined('CALLBACK_PORT') ? CALLBACK_PORT : 9090;
|
||||
$callbackUrl = 'http://127.0.0.1:' . $cbPort . '/notify_sync';
|
||||
@file_get_contents($callbackUrl, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 3]]));
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于商城购买)=====
|
||||
|
||||
+515
-130
@@ -19,6 +19,18 @@ while (ob_get_level() > 0) {
|
||||
}
|
||||
ob_start();
|
||||
|
||||
// ★ 全局错误/异常处理(确保错误也记录到日志)
|
||||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||||
@error_log("[sync.php ERROR] $errstr in $errfile on line $errline");
|
||||
return true;
|
||||
});
|
||||
set_exception_handler(function($e) {
|
||||
@error_log("[sync.php EXCEPTION] " . $e->getMessage() . " at " . $e->getTraceAsString());
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
echo json_encode(['success' => false, 'message' => 'PHP异常: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
});
|
||||
|
||||
// debugLog() 函数已在 core.php 中定义,无需重复声明
|
||||
|
||||
// ===== SMTP邮件发送函数 =====
|
||||
@@ -116,6 +128,12 @@ switch ($action) {
|
||||
case 'sync_online_players':
|
||||
syncOnlinePlayers();
|
||||
break;
|
||||
case 'sync_daily_logins':
|
||||
syncDailyLogins();
|
||||
break;
|
||||
case 'sync_checkins':
|
||||
syncCheckins();
|
||||
break;
|
||||
case 'sync_player_ips':
|
||||
syncPlayerIps();
|
||||
break;
|
||||
@@ -227,19 +245,32 @@ switch ($action) {
|
||||
case 'delete_user':
|
||||
deleteUser();
|
||||
break;
|
||||
case 'sync_transactions':
|
||||
syncTransactions();
|
||||
break;
|
||||
case 'check_pending_transactions':
|
||||
checkPendingTransactions();
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
|
||||
// ===== 插件推送商品数据 =====
|
||||
function syncShop() {
|
||||
// 验证管理token
|
||||
// ★ 支持token或SECRET_KEY认证
|
||||
$token = getParam('token');
|
||||
if (!$token) error('同步需要token');
|
||||
|
||||
$secret = getParam('secret');
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||||
error('同步需要管理权限token');
|
||||
if (!($secret && $secret === SECRET_KEY)) {
|
||||
error('同步需要管理权限token或SECRET_KEY');
|
||||
}
|
||||
}
|
||||
} elseif ($secret) {
|
||||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||||
} else {
|
||||
error('同步需要token或SECRET_KEY');
|
||||
}
|
||||
|
||||
$items = getParam('items');
|
||||
@@ -285,12 +316,20 @@ function syncShop() {
|
||||
|
||||
// ===== 插件推送债券余额 =====
|
||||
function syncBonds() {
|
||||
// ★ 支持token或SECRET_KEY认证
|
||||
$token = getParam('token');
|
||||
if (!$token) error('同步需要token');
|
||||
|
||||
$secret = getParam('secret');
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||||
error('同步需要管理权限token');
|
||||
if (!($secret && $secret === SECRET_KEY)) {
|
||||
error('同步需要管理权限token或SECRET_KEY');
|
||||
}
|
||||
}
|
||||
} elseif ($secret) {
|
||||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||||
} else {
|
||||
error('同步需要token或SECRET_KEY');
|
||||
}
|
||||
|
||||
$bonds = getParam('bonds');
|
||||
@@ -316,14 +355,140 @@ function syncBonds() {
|
||||
success(['synced' => $count], "同步了{$count}个玩家余额");
|
||||
}
|
||||
|
||||
// ===== 插件推送注册数据 =====
|
||||
function syncLogin() {
|
||||
// ===== 插件推送游戏内交易记录 =====
|
||||
function syncTransactions() {
|
||||
try {
|
||||
// ★ 支持token或SECRET_KEY认证
|
||||
$token = getParam('token');
|
||||
if (!$token) error('同步需要token');
|
||||
|
||||
$secret = getParam('secret');
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||||
error('同步需要管理权限token');
|
||||
if (!($secret && $secret === SECRET_KEY)) {
|
||||
@error_log("[syncTransactions] Token验证失败: token=" . substr($token, 0, 8) . "..., info=" . var_export($tokenInfo, true));
|
||||
error('同步需要管理权限token或SECRET_KEY');
|
||||
}
|
||||
}
|
||||
} elseif ($secret) {
|
||||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||||
} else {
|
||||
error('同步需要token或SECRET_KEY');
|
||||
}
|
||||
|
||||
$transactions = getParam('transactions');
|
||||
@error_log("[syncTransactions] transactions type=" . gettype($transactions) . ", is_null=" . var_export($transactions === null, true));
|
||||
|
||||
if (!$transactions || !is_array($transactions)) {
|
||||
error('缺少transactions数据 (type=' . gettype($transactions) . ')');
|
||||
}
|
||||
|
||||
@error_log("[syncTransactions] Received " . count($transactions) . " transactions");
|
||||
if (count($transactions) > 0) {
|
||||
@error_log("[syncTransactions] First tx: " . json_encode($transactions[0], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
// ★ 设置busy_timeout防止database is locked
|
||||
$db->exec('PRAGMA busy_timeout=10000');
|
||||
|
||||
// 创建game_transactions表,用于存储游戏内交易记录
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS game_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
java_id INTEGER NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
target_player TEXT DEFAULT '',
|
||||
operator TEXT DEFAULT '',
|
||||
reason TEXT DEFAULT '',
|
||||
balance_before INTEGER DEFAULT 0,
|
||||
balance_after INTEGER DEFAULT 0,
|
||||
tx_time INTEGER NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
UNIQUE(java_id)
|
||||
)");
|
||||
|
||||
// 索引用于查询加速
|
||||
try { $db->exec("CREATE INDEX IF NOT EXISTS idx_gt_player ON game_transactions(player_name, tx_time)"); } catch (Exception $e) {}
|
||||
try { $db->exec("CREATE INDEX IF NOT EXISTS idx_gt_type ON game_transactions(type)"); } catch (Exception $e) {}
|
||||
|
||||
$now = time();
|
||||
$count = 0;
|
||||
$skipped = 0;
|
||||
|
||||
// ★ 使用事务批量插入,减少锁冲突
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
foreach ($transactions as $tx) {
|
||||
$javaId = (int)($tx['id'] ?? 0);
|
||||
if ($javaId <= 0) { $skipped++; continue; }
|
||||
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO game_transactions
|
||||
(java_id, player_name, type, amount, target_player, operator, reason, balance_before, balance_after, tx_time, synced_at)
|
||||
VALUES (:jpid, :name, :type, :amount, :target, :op, :reason, :bfb, :bfa, :time, :synced)");
|
||||
$stmt->bindValue(':jpid', $javaId, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':name', $tx['player_name'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':type', $tx['type'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', (int)($tx['amount'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':target', $tx['target_player'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':op', $tx['operator'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':reason', $tx['reason'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':bfb', (int)($tx['balance_before'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':bfa', (int)($tx['balance_after'] ?? 0), SQLITE3_INTEGER);
|
||||
// Java端使用毫秒时间戳,PHP端存储秒级时间戳
|
||||
$txTime = (int)($tx['time'] ?? 0);
|
||||
if ($txTime > 1000000000000) {
|
||||
$txTime = intval($txTime / 1000);
|
||||
}
|
||||
$stmt->bindValue(':time', $txTime, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
if ($db->changes() > 0) {
|
||||
$count++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
$db->exec("ROLLBACK");
|
||||
@error_log("[syncTransactions] Transaction failed, rolled back: " . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
debugLog("sync_transactions: 接收游戏交易记录", [
|
||||
'total' => count($transactions),
|
||||
'inserted' => $count,
|
||||
'skipped_duplicates' => $skipped
|
||||
]);
|
||||
|
||||
success(['synced' => $count, 'skipped' => $skipped, 'total' => count($transactions)], "同步了{$count}笔游戏交易");
|
||||
} catch (Exception $e) {
|
||||
@error_log("[syncTransactions] EXCEPTION: " . $e->getMessage() . " at " . $e->getTraceAsString());
|
||||
error('syncTransactions异常: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 插件推送注册数据 =====
|
||||
function syncLogin() {
|
||||
// ★ 支持两种认证:token 或 SECRET_KEY(防止token注册超时导致同步失败)
|
||||
$token = getParam('token');
|
||||
$secret = getParam('secret');
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||||
// token无效时,检查是否携带了secret
|
||||
if ($secret && $secret === SECRET_KEY) {
|
||||
// secret有效,继续执行
|
||||
} else {
|
||||
error('同步需要管理权限token或SECRET_KEY');
|
||||
}
|
||||
}
|
||||
} elseif ($secret) {
|
||||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||||
} else {
|
||||
error('同步需要token或SECRET_KEY');
|
||||
}
|
||||
|
||||
$users = getParam('users');
|
||||
@@ -340,19 +505,17 @@ function syncLogin() {
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
// ★ 所有写操作(INSERT + 清理)包装在一个事务中
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
foreach ($users as $user) {
|
||||
$name = $user['player_name'] ?? null;
|
||||
if (!$name) continue;
|
||||
|
||||
// Java端使用毫秒时间戳,PHP端存储秒级时间戳
|
||||
$registerTime = (int)($user['register_time'] ?? 0);
|
||||
$lastLoginTime = (int)($user['last_login_time'] ?? 0);
|
||||
if ($registerTime > 1000000000000) { // 如果大于10^12,说明是毫秒级
|
||||
$registerTime = intval($registerTime / 1000);
|
||||
}
|
||||
if ($lastLoginTime > 1000000000000) { // 如果大于10^12,说明是毫秒级
|
||||
$lastLoginTime = intval($lastLoginTime / 1000);
|
||||
}
|
||||
if ($registerTime > 1000000000000) { $registerTime = intval($registerTime / 1000); }
|
||||
if ($lastLoginTime > 1000000000000) { $lastLoginTime = intval($lastLoginTime / 1000); }
|
||||
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO users (player_name, register_time, last_login_time, email, points, gift_stage, total_online_time) VALUES (:name, :rt, :lt, :email, :pts, :gs, :tot)");
|
||||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
@@ -366,11 +529,10 @@ function syncLogin() {
|
||||
$count++;
|
||||
}
|
||||
|
||||
// ★ 收集推送的用户名列表,用于后续孤儿清理
|
||||
$namesInSync = array_map(function($u) { return $u['player_name'] ?? null; }, $users);
|
||||
$namesInSync = array_filter($namesInSync, function($n) { return $n !== null; });
|
||||
|
||||
// ★ 清理 users 表中已不存在的用户(游戏内删号后同步)
|
||||
// 清理 users 表中已不存在的用户
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name FROM users");
|
||||
$result = $stmt->execute();
|
||||
@@ -389,7 +551,7 @@ function syncLogin() {
|
||||
debugLog("sync_login: 清理孤立 users 记录失败", ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// ★ 清理 weblogin_credentials 中已不存在的用户(游戏内删号后同步)
|
||||
// 清理 weblogin_credentials 中已不存在的用户(游戏内删号后同步)
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name FROM weblogin_credentials");
|
||||
$result = $stmt->execute();
|
||||
@@ -398,8 +560,8 @@ function syncLogin() {
|
||||
$webCredentials[] = $row['player_name'];
|
||||
}
|
||||
|
||||
$namesInSync = array_map(function($u) { return $u['player_name'] ?? null; }, $users);
|
||||
$orphaned = array_diff($webCredentials, $namesInSync);
|
||||
$namesInSync2 = array_map(function($u) { return $u['player_name'] ?? null; }, $users);
|
||||
$orphaned = array_diff($webCredentials, $namesInSync2);
|
||||
|
||||
if (!empty($orphaned)) {
|
||||
$nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphaned));
|
||||
@@ -410,61 +572,27 @@ function syncLogin() {
|
||||
debugLog("sync_login: 清理孤立 weblogin_credentials 记录失败", ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// ★ 清理 weblogin_tokens 中已不存在的用户(游戏内删号后同步)
|
||||
// 清理 weblogin_tokens:只清理已过期的
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name FROM weblogin_tokens");
|
||||
$result = $stmt->execute();
|
||||
$tokenPlayers = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$tokenPlayers[] = $row['player_name'];
|
||||
}
|
||||
$expireTime = time() - 600;
|
||||
$db->exec("DELETE FROM weblogin_tokens WHERE created_at < " . $expireTime);
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$orphanedTokens = array_diff($tokenPlayers, $namesInSync);
|
||||
if (!empty($orphanedTokens)) {
|
||||
$nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphanedTokens));
|
||||
$db->exec("DELETE FROM weblogin_tokens WHERE player_name IN ('" . $nameList . "')");
|
||||
debugLog("sync_login: 清理了 " . count($orphanedTokens) . " 个孤立的 weblogin_tokens 记录", ['orphaned' => $orphanedTokens]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
debugLog("sync_login: 清理孤立 weblogin_tokens 记录失败", ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// ★ 清理 web_login_verified 中已不存在的用户
|
||||
// 清理 web_login_verified:只清理已过期的
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name FROM web_login_verified");
|
||||
$result = $stmt->execute();
|
||||
$verifiedPlayers = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$verifiedPlayers[] = $row['player_name'];
|
||||
}
|
||||
$expireTime = time() - 600;
|
||||
$db->exec("DELETE FROM web_login_verified WHERE verified_at < " . $expireTime);
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$orphanedVerified = array_diff($verifiedPlayers, $namesInSync);
|
||||
if (!empty($orphanedVerified)) {
|
||||
$nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphanedVerified));
|
||||
$db->exec("DELETE FROM web_login_verified WHERE player_name IN ('" . $nameList . "')");
|
||||
debugLog("sync_login: 清理了 " . count($orphanedVerified) . " 个孤立的 web_login_verified 记录", ['orphaned' => $orphanedVerified]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
debugLog("sync_login: 清理孤立 web_login_verified 记录失败", ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// ★ 清理 web_session_log 中已不存在的用户
|
||||
// 清理 web_session_log:只清理过期的
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name FROM web_session_log");
|
||||
$result = $stmt->execute();
|
||||
$sessionPlayers = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$sessionPlayers[] = $row['player_name'];
|
||||
}
|
||||
$expireTime = time() - 600;
|
||||
$db->exec("DELETE FROM web_session_log WHERE login_time < " . $expireTime);
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$orphanedSessions = array_diff($sessionPlayers, $namesInSync);
|
||||
if (!empty($orphanedSessions)) {
|
||||
$nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphanedSessions));
|
||||
$db->exec("DELETE FROM web_session_log WHERE player_name IN ('" . $nameList . "')");
|
||||
debugLog("sync_login: 清理了 " . count($orphanedSessions) . " 个孤立的 web_session_log 记录", ['orphaned' => $orphanedSessions]);
|
||||
}
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
debugLog("sync_login: 清理孤立 web_session_log 记录失败", ['error' => $e->getMessage()]);
|
||||
try { $db->exec("ROLLBACK"); } catch (Exception $e2) {}
|
||||
}
|
||||
|
||||
debugLog("sync_login: 同步完成", ['synced_count' => $count, 'cleared_users' => ($orphanedUsers ?? []) ? count($orphanedUsers) : 0]);
|
||||
@@ -473,12 +601,20 @@ function syncLogin() {
|
||||
|
||||
// ===== Java插件主动删除用户 =====
|
||||
function deleteUser() {
|
||||
// ★ 支持token或SECRET_KEY认证
|
||||
$token = getParam('token');
|
||||
if (!$token) error('删除需要token');
|
||||
|
||||
$secret = getParam('secret');
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||||
error('删除需要管理权限token');
|
||||
if (!($secret && $secret === SECRET_KEY)) {
|
||||
error('删除需要管理权限token或SECRET_KEY');
|
||||
}
|
||||
}
|
||||
} elseif ($secret) {
|
||||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||||
} else {
|
||||
error('删除需要token或SECRET_KEY');
|
||||
}
|
||||
|
||||
$playerName = getParam('player_name');
|
||||
@@ -549,6 +685,7 @@ function fixTimestamps() {
|
||||
|
||||
// ===== 插件同步在线玩家列表 =====
|
||||
function syncOnlinePlayers() {
|
||||
global $db;
|
||||
$db = getDB();
|
||||
|
||||
// ★ 尝试从三个来源读取 secret 和 players
|
||||
@@ -558,6 +695,15 @@ function syncOnlinePlayers() {
|
||||
$secret = getParam('secret');
|
||||
$players = getParam('players');
|
||||
|
||||
// ★ 关键修复:如果players是字符串(GET请求或表单提交),尝试JSON解码
|
||||
if ($players !== null && is_string($players)) {
|
||||
$decoded = json_decode($players, true);
|
||||
if (is_array($decoded)) {
|
||||
$players = $decoded;
|
||||
@error_log("[syncOnlinePlayers] players从GET/POST字符串JSON解码成功: " . count($players) . "人");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 如果 PHP 解析了 x-www-form-urlencoded,$_POST 会有数据
|
||||
// 3) 如果是 application/json,$_POST 为空,需要从 $_SERVER['HTTP_RAW_POST_DATA'] 或 php://input 读取
|
||||
|
||||
@@ -609,12 +755,53 @@ function syncOnlinePlayers() {
|
||||
error('缺少players数据 (received=' . var_export($players, true) . ' | type=' . gettype($players) . ')');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
// ★ 详细日志:记录接收到的原始数据
|
||||
$playerNames = array_map(function($p) { return $p['name'] ?? '?'; }, $players);
|
||||
@error_log("[syncOnlinePlayers] ★★★ 收到推送: " . count($players) . "人 = [" . implode(', ', $playerNames) . "]");
|
||||
@error_log("[syncOnlinePlayers] 原始players数据: " . json_encode($players, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// ★ 设置更高的 busy_timeout 防止 "database is locked" 错误
|
||||
$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_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||||
// ★ 迁移:修复旧表结构(旧行无主键导致INSERT OR REPLACE失效)
|
||||
try {
|
||||
$colCheck = $db->query("PRAGMA table_info(online_player_hb)");
|
||||
$hasPK = false;
|
||||
while ($col = $colCheck->fetchArray(SQLITE3_ASSOC)) {
|
||||
if ($col['pk'] > 0) { $hasPK = true; break; }
|
||||
}
|
||||
if (!$hasPK) {
|
||||
// 读取旧数据
|
||||
$oldVal = 0;
|
||||
$oldRow = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1")->fetchArray(SQLITE3_ASSOC);
|
||||
if ($oldRow) $oldVal = (int)$oldRow['last_seen'];
|
||||
$db->exec("DROP TABLE IF EXISTS online_player_hb");
|
||||
$db->exec("CREATE TABLE online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||||
$db->exec("INSERT INTO online_player_hb (id, last_seen) VALUES (1, $oldVal)");
|
||||
@error_log("[syncOnlinePlayers] 迁移online_player_hb表结构完成,旧值: $oldVal");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
@error_log("[syncOnlinePlayers] 迁移online_player_hb失败: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
// ★ 心跳检测:如果超过120秒没收到数据,清空在线表(防止Java推送异常残留旧数据)
|
||||
try {
|
||||
$hbStmt = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
|
||||
$hbRow = $hbStmt->fetchArray(SQLITE3_ASSOC);
|
||||
if ($hbRow !== false && $hbRow['last_seen'] > 0) {
|
||||
$lastHb = (int)$hbRow['last_seen'];
|
||||
if (($now - $lastHb) > 120) {
|
||||
@error_log("[syncOnlinePlayers] Heartbeat stale: " . ($now - $lastHb) . "s > 120s, clearing online_players");
|
||||
$db->exec("DELETE FROM online_players");
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
$playerCount = count($players);
|
||||
|
||||
// ★ Debug: 记录接收到的玩家数据
|
||||
@@ -623,10 +810,13 @@ function syncOnlinePlayers() {
|
||||
@error_log("[syncOnlinePlayers] Received $playerCount players: " . implode(', ', $playerNames));
|
||||
@error_log("[syncOnlinePlayers] Login times: " . implode(', ', $playerTimes) . " | Server time: $now");
|
||||
|
||||
// ★ 删除所有旧记录,然后重新插入(避免残留旧数据)
|
||||
// ★ 使用事务批量写入,防止 "database is locked"
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// 删除所有旧记录,然后重新插入(避免残留旧数据)
|
||||
$db->exec("DELETE FROM online_players");
|
||||
|
||||
// ★ 逐条插入,不用事务(避免锁问题)
|
||||
// 逐条插入
|
||||
$synced = 0;
|
||||
foreach ($players as $player) {
|
||||
if (empty($player['name'])) continue;
|
||||
@@ -643,6 +833,21 @@ function syncOnlinePlayers() {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新心跳时间戳(使用INSERT OR REPLACE,有主键则更新,无则插入)
|
||||
try {
|
||||
$hbSql = $db->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :time)");
|
||||
$hbSql->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$hbSql->execute();
|
||||
} catch (Exception $e) {
|
||||
@error_log("[syncOnlinePlayers] heartbeat update error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (Exception $e2) {}
|
||||
@error_log("[syncOnlinePlayers] Transaction failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// ★ 验证:查询刚插入的数据
|
||||
$verifyStmt = $db->query("SELECT COUNT(*) as cnt FROM online_players");
|
||||
$verifyRow = $verifyStmt->fetchArray(SQLITE3_ASSOC);
|
||||
@@ -651,6 +856,95 @@ function syncOnlinePlayers() {
|
||||
success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家");
|
||||
}
|
||||
|
||||
// ===== 同步每日登录记录(Java插件推送) =====
|
||||
function syncDailyLogins() {
|
||||
$secret = getParam('secret');
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
|
||||
$logins = getParam('logins');
|
||||
if ($logins === null) {
|
||||
// 尝试从POST body解析
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (isset($decoded['logins'])) $logins = $decoded['logins'];
|
||||
}
|
||||
}
|
||||
if ($logins === null || !is_array($logins)) {
|
||||
error('缺少logins数据');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
$count = 0;
|
||||
foreach ($logins as $entry) {
|
||||
$name = $entry['name'] ?? '';
|
||||
$date = $entry['date'] ?? date('Y-m-d');
|
||||
$firstLogin = $entry['first_login'] ?? time();
|
||||
$lastLogin = $entry['last_login'] ?? time();
|
||||
if (!$name) continue;
|
||||
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
|
||||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':date', $date, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':first', (int)$firstLogin, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':last', (int)$lastLogin, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$count++;
|
||||
}
|
||||
$db->exec("COMMIT");
|
||||
success(['synced' => $count], "同步了{$count}条登录记录");
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (Exception $e2) {}
|
||||
@error_log("[syncDailyLogins] Error: " . $e->getMessage());
|
||||
error('同步失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 同步签到记录(Java插件推送) =====
|
||||
function syncCheckins() {
|
||||
$secret = getParam('secret');
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
|
||||
$checkins = getParam('checkins');
|
||||
if ($checkins === null) {
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (isset($decoded['checkins'])) $checkins = $decoded['checkins'];
|
||||
}
|
||||
}
|
||||
if ($checkins === null || !is_array($checkins)) {
|
||||
error('缺少checkins数据');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
$count = 0;
|
||||
foreach ($checkins as $entry) {
|
||||
$name = $entry['name'] ?? '';
|
||||
$date = $entry['date'] ?? date('Y-m-d');
|
||||
$time = $entry['time'] ?? time();
|
||||
if (!$name) continue;
|
||||
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO player_checkins (player_name, checkin_date, checkin_time) VALUES (:name, :date, :time)");
|
||||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':date', $date, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', (int)$time, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$count++;
|
||||
}
|
||||
$db->exec("COMMIT");
|
||||
success(['synced' => $count], "同步了{$count}条签到记录");
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (Exception $e2) {}
|
||||
@error_log("[syncCheckins] Error: " . $e->getMessage());
|
||||
error('同步失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 同步所有玩家IP到PHP端(带变更检测) =====
|
||||
function syncPlayerIps() {
|
||||
$db = getDB();
|
||||
@@ -687,11 +981,11 @@ function syncPlayerIps() {
|
||||
// 创建IP归属地缓存表(用于避免重复查询API)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
player_name TEXT DEFAULT 'global',
|
||||
ip_address TEXT NOT NULL,
|
||||
location TEXT DEFAULT '',
|
||||
updated_at INTEGER DEFAULT 0,
|
||||
UNIQUE(player_name, ip_address)
|
||||
UNIQUE(ip_address)
|
||||
)");
|
||||
|
||||
// 批量更新玩家的IP到online_players表(带变更检测)
|
||||
@@ -699,6 +993,9 @@ function syncPlayerIps() {
|
||||
$changedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// ★ 事务包装防止 "database is locked"
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
foreach ($players as $player) {
|
||||
if (empty($player['name']) || empty($player['ip'])) {
|
||||
continue;
|
||||
@@ -715,15 +1012,12 @@ function syncPlayerIps() {
|
||||
|
||||
$hasChanged = false;
|
||||
if (!$row) {
|
||||
// 首次记录该玩家IP
|
||||
$hasChanged = true;
|
||||
} elseif ($row['old_ip'] !== $newIp) {
|
||||
// IP发生变化
|
||||
$hasChanged = true;
|
||||
}
|
||||
|
||||
if ($hasChanged) {
|
||||
// 记录IP变更
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO player_ip_changes (player_name, old_ip, new_ip, changed_at, synced_at) VALUES (:player, :old, :new, :time, :synced)");
|
||||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':old', $row ? $row['old_ip'] : '', SQLITE3_TEXT);
|
||||
@@ -732,24 +1026,9 @@ function syncPlayerIps() {
|
||||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// online_players 表没有 ip_address 列,直接插入(login_time 会自动同步)
|
||||
$stmt2 = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :player");
|
||||
$stmt2->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute();
|
||||
if (!$result2->fetchArray()) {
|
||||
// 不存在,先插入
|
||||
$stmt3 = $db->prepare("INSERT INTO online_players (player_name, login_time) VALUES (:name, :time)");
|
||||
$stmt3->bindValue(':name', $playerName, SQLITE3_TEXT);
|
||||
$stmt3->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt3->execute();
|
||||
}
|
||||
|
||||
// 更新在线玩家IP(不实时更新online_players,因为IP归属由PHP端查询)
|
||||
$changedCount++;
|
||||
|
||||
// 如果之前已同步过(synced_at != 0),标记为待重新查询
|
||||
if ($row && $row['synced_at'] > 0) {
|
||||
// 重置synced_at,让PHP端的IP查询逻辑重新查询API
|
||||
$stmtReset = $db->prepare("UPDATE player_ip_changes SET synced_at = 0 WHERE player_name = :player");
|
||||
$stmtReset->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$stmtReset->execute();
|
||||
@@ -759,6 +1038,12 @@ function syncPlayerIps() {
|
||||
}
|
||||
}
|
||||
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (Exception $e2) {}
|
||||
@error_log("[syncPlayerIps] Transaction failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
success([
|
||||
'changed' => $changedCount,
|
||||
'skipped' => $skippedCount,
|
||||
@@ -822,9 +1107,12 @@ function receiveToken() {
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$db->exec('PRAGMA busy_timeout=10000');
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
foreach ($tokens as $t) {
|
||||
$tokenVal = $t['token'] ?? null;
|
||||
$player = $t['player'] ?? 'system';
|
||||
@@ -842,18 +1130,32 @@ function receiveToken() {
|
||||
$stmt->execute();
|
||||
$count++;
|
||||
}
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
@error_log("[receiveToken] Exception: " . $e->getMessage());
|
||||
error('Token注册失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
success(['registered' => $count], "注册了{$count}个Token");
|
||||
}
|
||||
|
||||
// ===== 插件同步Token到PHP数据库(通过已有sync token) =====
|
||||
function syncToken() {
|
||||
// ★ 支持token或SECRET_KEY认证
|
||||
$token = getParam('token');
|
||||
if (!$token) error('同步需要token');
|
||||
|
||||
$secret = getParam('secret');
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||||
error('同步需要管理权限token');
|
||||
if (!($secret && $secret === SECRET_KEY)) {
|
||||
error('同步需要管理权限token或SECRET_KEY');
|
||||
}
|
||||
}
|
||||
} elseif ($secret) {
|
||||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||||
} else {
|
||||
error('同步需要token或SECRET_KEY');
|
||||
}
|
||||
|
||||
$newToken = getParam('new_token');
|
||||
@@ -1033,10 +1335,11 @@ function pushPlayerLoginStatus() {
|
||||
$stmt->execute();
|
||||
@error_log("[pushPlayerLoginStatus] Token stored: player=$player, created_at=$now, expire=$expireSeconds");
|
||||
|
||||
// 2. 如果玩家在游戏中已登录,更新online_players表
|
||||
// 2. 如果玩家在游戏中已登录,更新online_players表(只更新已存在的记录,不凭空插入)
|
||||
if ($isOnline) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
|
||||
$updateStmt = $db->prepare("INSERT OR REPLACE INTO online_players (player_name, login_time) VALUES (:player, :time)");
|
||||
// ★ 只更新已存在的玩家,不插入新玩家(在线状态只由syncOnlinePlayers管理)
|
||||
$updateStmt = $db->prepare("UPDATE online_players SET login_time = :time WHERE player_name = :player");
|
||||
$updateStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$updateStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$updateStmt->execute();
|
||||
@@ -1049,6 +1352,15 @@ function pushPlayerLoginStatus() {
|
||||
$sessionStmt->bindValue(':ip', 'plugin_sync', SQLITE3_TEXT);
|
||||
$sessionStmt->execute();
|
||||
|
||||
// ★ 记录每日登录(游戏内登录也算)
|
||||
$today = date('Y-m-d');
|
||||
$dlStmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
|
||||
$dlStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$dlStmt->bindValue(':date', $today, SQLITE3_TEXT);
|
||||
$dlStmt->bindValue(':first', $now, SQLITE3_INTEGER);
|
||||
$dlStmt->bindValue(':last', $now, SQLITE3_INTEGER);
|
||||
$dlStmt->execute();
|
||||
|
||||
// 如果已注册,更新users表的last_login_time
|
||||
if ($isRegistered) {
|
||||
try {
|
||||
@@ -1258,11 +1570,30 @@ function checkWebLoginResult() {
|
||||
'message' => '等待游戏服务器验证...'
|
||||
]);
|
||||
} elseif ($row['status'] === 'success') {
|
||||
// 验证成功
|
||||
// 验证成功 → 写入web_login_verified(允许后续player.php直接放行)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||||
$verifiedStmt = $db->prepare("INSERT OR REPLACE INTO web_login_verified (player_name, verified_at) VALUES (:player, :time)");
|
||||
$verifiedStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$verifiedStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$verifiedStmt->execute();
|
||||
debugLog("checkWebLoginResult: 写入web_login_verified", ['player' => $player]);
|
||||
|
||||
// ★ 生成weblogin_token返回给前端,让player.php可以继续使用
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)");
|
||||
$tokenStmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)");
|
||||
$tokenStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$tokenStmt->bindValue(':token', $newToken, SQLITE3_TEXT);
|
||||
$tokenStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$tokenStmt->bindValue(':expire', 600, SQLITE3_INTEGER);
|
||||
$tokenStmt->execute();
|
||||
debugLog("checkWebLoginResult: 生成weblogin_token", ['player' => $player]);
|
||||
|
||||
$resultData = json_decode($row['result'], true);
|
||||
success([
|
||||
'status' => 'success',
|
||||
'player' => $player,
|
||||
'token' => $newToken,
|
||||
'user_data' => $resultData ?? ['player_name' => $player],
|
||||
'message' => '登录成功'
|
||||
]);
|
||||
@@ -1370,6 +1701,16 @@ function completeWebLoginRequest() {
|
||||
$verifiedStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$verifiedStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$verifiedStmt->execute();
|
||||
|
||||
// ★ 记录每日登录(Web登录也算)
|
||||
$today = date('Y-m-d');
|
||||
$now = time();
|
||||
$dlStmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
|
||||
$dlStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$dlStmt->bindValue(':date', $today, SQLITE3_TEXT);
|
||||
$dlStmt->bindValue(':first', $now, SQLITE3_INTEGER);
|
||||
$dlStmt->bindValue(':last', $now, SQLITE3_INTEGER);
|
||||
$dlStmt->execute();
|
||||
}
|
||||
|
||||
success(['status' => $status], "结果已写入");
|
||||
@@ -1508,7 +1849,7 @@ function webAccessCheck() {
|
||||
$age = time() - $row['created_at'];
|
||||
$expire = $row['expire_seconds'];
|
||||
$isExpired = $age > $expire ? 'EXPIRED' : 'valid';
|
||||
@error_log("[web_access_check] DB token: player=" . $row['player_name'] . ", token=" . $row['token_prefix'] . "..., age=${age}s, expire=${expire}s, status=$isExpired");
|
||||
@error_log("[web_access_check] DB token: player=" . $row['player_name'] . ", token=" . $row['token_prefix'] . "..., age={$age}s, expire={$expire}s, status={$isExpired}");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
@error_log("[web_access_check] DB query error: " . $e->getMessage());
|
||||
@@ -1526,12 +1867,9 @@ function webAccessCheck() {
|
||||
]);
|
||||
|
||||
if ($result['ok']) {
|
||||
// 记录 session 日志(保存 IP 地址)
|
||||
$sessionToken = logWebSession(
|
||||
$result['player'] ?? '',
|
||||
$ipAddress,
|
||||
$result['session'] ?? ''
|
||||
);
|
||||
// ★ 修复:移除不存在的 logWebSession() 调用
|
||||
// validateWebAccess 内部已通过 recordWebSession() 记录了 session,这里不需要重复记录
|
||||
// 旧代码调用 logWebSession() 导致 PHP Fatal Error → 返回 HTML 错误页 → player.php 解析 JSON 失败 → 游客模式
|
||||
|
||||
success([
|
||||
'player' => $result['player'] ?? '',
|
||||
@@ -1577,6 +1915,35 @@ function webAccessCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 轻量级检查:是否有待处理的交易(Java高频轮询用)=====
|
||||
function checkPendingTransactions() {
|
||||
$secret = getParam('secret');
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
|
||||
$db = getDB();
|
||||
$hasTable = false;
|
||||
try {
|
||||
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='web_transactions'");
|
||||
$hasTable = ($stmt && $stmt->fetchArray());
|
||||
} catch (Exception $e) {}
|
||||
|
||||
if (!$hasTable) {
|
||||
success(['pending' => 0], 'no table');
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
try {
|
||||
$stmt = $db->query("SELECT COUNT(*) as cnt FROM web_transactions WHERE status = 'pending'");
|
||||
if ($stmt) {
|
||||
$row = $stmt->fetchArray(SQLITE3_ASSOC);
|
||||
$count = (int)($row['cnt'] ?? 0);
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
|
||||
success(['pending' => $count], $count > 0 ? '有交易待处理' : '无待处理');
|
||||
}
|
||||
|
||||
// ===== 插件拉取待处理的交易 =====
|
||||
function pullPendingTransactions() {
|
||||
$secret = getParam('secret');
|
||||
@@ -1595,29 +1962,34 @@ function pullPendingTransactions() {
|
||||
$db->exec("ALTER TABLE web_transactions ADD COLUMN processed_at INTEGER");
|
||||
}
|
||||
|
||||
// 先恢复超时的processing交易(超过60秒未确认的恢复为pending)
|
||||
// 获取所有pending的交易 + 标记为processing(原子操作,避免database is locked)
|
||||
$transactions = [];
|
||||
$ids = [];
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
|
||||
// 恢复超时的processing交易(超过60秒未确认的恢复为pending)
|
||||
if ($hasProcessedAt) {
|
||||
$db->exec("UPDATE web_transactions SET status = 'pending' WHERE status = 'processing' AND processed_at < " . (time() - 60));
|
||||
}
|
||||
|
||||
// 获取所有pending的交易
|
||||
$stmt = $db->prepare("SELECT id, player_name, type, amount, reason, detail FROM web_transactions WHERE status = 'pending' ORDER BY created_at ASC");
|
||||
$result = $stmt->execute();
|
||||
$transactions = [];
|
||||
$ids = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$transactions[] = $row;
|
||||
$ids[] = (int)$row['id'];
|
||||
}
|
||||
|
||||
// 立即标记为processing(防止5秒后重复拉取)
|
||||
if ($hasProcessedAt && !empty($ids)) {
|
||||
if (!empty($ids)) {
|
||||
$idList = implode(',', $ids);
|
||||
try {
|
||||
$db->exec("UPDATE web_transactions SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||||
} catch (Exception $e) {
|
||||
// 数据库锁定异常,忽略(下一个请求会清理)
|
||||
}
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
@error_log("[pullPendingTransactions] 事务失败: " . $e->getMessage());
|
||||
// 降级:不做标记,直接返回(下一轮会重新拉取)
|
||||
}
|
||||
|
||||
success(['transactions' => $transactions, 'count' => count($transactions)]);
|
||||
@@ -1639,6 +2011,8 @@ function confirmTransaction() {
|
||||
if ($row['name'] === 'processed_at') { $hasProcessedAt = true; break; }
|
||||
}
|
||||
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
if ($hasProcessedAt) {
|
||||
$stmt = $db->prepare("UPDATE web_transactions SET status = 'processed', processed_at = :time WHERE id = :id AND status IN ('pending', 'processing')");
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
@@ -1647,6 +2021,11 @@ function confirmTransaction() {
|
||||
}
|
||||
$stmt->bindValue(':id', (int)$txId, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
@error_log("[confirmTransaction] 失败: " . $e->getMessage());
|
||||
}
|
||||
|
||||
success(['tx_id' => $txId], '交易已确认');
|
||||
}
|
||||
@@ -1680,17 +2059,10 @@ function notifySync() {
|
||||
// 写入同步通知文件(包含交易ID)
|
||||
$notifyFile = __DIR__ . '/../../sync_notify.txt';
|
||||
$notifyData = json_encode(['time' => time(), 'tx_id' => (int)$txId]);
|
||||
file_put_contents($notifyFile, $notifyData, LOCK_EX);
|
||||
@file_put_contents($notifyFile, $notifyData, LOCK_EX);
|
||||
|
||||
// ★ 方式1: 直接HTTP回调通知Java插件拉取交易
|
||||
$callbackPort = CALLBACK_PORT ?? 8080; // 默认端口
|
||||
$callbackUrl = "http://127.0.0.1:" . $callbackPort . "/api/pull_pending_transactions?secret=" . SECRET_KEY;
|
||||
@file_get_contents($callbackUrl, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 3]]));
|
||||
|
||||
// ★ 方式2: 如果回环不通,也尝试通过Web URL触发
|
||||
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . WEBSUB_DIR;
|
||||
$notifyUrl2 = $baseUrl . "api/sync.php?action=notify_sync&secret=" . SECRET_KEY . "&tx_id=" . $txId;
|
||||
@file_get_contents($notifyUrl2, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 3]]));
|
||||
// ★ 不再使用HTTP回调,Java的transactionPolling每5秒轮询check_pending_transactions
|
||||
@error_log('[notifySync] 收到同步通知,tx_id=' . $txId . ',Java轮询会自动拉取');
|
||||
|
||||
success(['tx_id' => (int)$txId], '已通知插件立即同步交易数据');
|
||||
}
|
||||
@@ -1720,6 +2092,9 @@ function resendPendingTransactions() {
|
||||
$db = getDB();
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||||
|
||||
// ★ 事务包装防止 "database is locked"
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// 恢复超时processing交易
|
||||
$db->exec("UPDATE web_transactions SET status = 'pending' WHERE status = 'processing' AND processed_at < " . (time() - 120));
|
||||
|
||||
@@ -1738,15 +2113,25 @@ function resendPendingTransactions() {
|
||||
$db->exec("UPDATE web_transactions SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||||
}
|
||||
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec("ROLLBACK"); } catch (Exception $e2) {}
|
||||
@error_log("[resendPendingTransactions] Transaction failed: " . $e->getMessage());
|
||||
error('数据库操作失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
success(['transactions' => $transactions, 'count' => count($transactions), 'resend' => true], '已补发' . count($transactions) . '笔pending交易');
|
||||
}
|
||||
|
||||
// ===== 邮箱脱敏工具 =====
|
||||
// 格式: 123@456.com → xx@456.com(只显示后缀)
|
||||
function maskEmail($email) {
|
||||
if (empty($email)) return '';
|
||||
$parts = explode('@', $email);
|
||||
if (count($parts) !== 2) return '**@' . ($parts[0] ?? '*');
|
||||
return '**@' . $parts[1];
|
||||
$domain = $parts[1];
|
||||
// 只显示域名,前面用xx代替
|
||||
return 'xx@' . $domain;
|
||||
}
|
||||
|
||||
// ===== 发送邮箱验证码 =====
|
||||
|
||||
@@ -273,10 +273,11 @@ function initTables(SQLite3 $db) {
|
||||
|
||||
// ★ 玩家IP变更历史表(admin.php 批量查IP用)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
player_name TEXT PRIMARY KEY,
|
||||
old_ip TEXT DEFAULT '',
|
||||
new_ip TEXT NOT NULL,
|
||||
changed_at INTEGER NOT NULL
|
||||
changed_at INTEGER NOT NULL,
|
||||
synced_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// ★ IP归属地缓存表(admin.php IP查询用)
|
||||
@@ -285,15 +286,42 @@ function initTables(SQLite3 $db) {
|
||||
ip_address TEXT NOT NULL,
|
||||
location TEXT DEFAULT '',
|
||||
updated_at INTEGER DEFAULT 0,
|
||||
player_name TEXT DEFAULT 'global',
|
||||
UNIQUE(ip_address)
|
||||
)");
|
||||
|
||||
// ★ 玩家每日登录记录表(用于计算累计在线天数)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_daily_logins (
|
||||
player_name TEXT NOT NULL,
|
||||
login_date TEXT NOT NULL,
|
||||
first_login INTEGER DEFAULT 0,
|
||||
last_login INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (player_name, login_date)
|
||||
)");
|
||||
|
||||
// ★ 玩家签到记录表(用于计算累计签到天数)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_checkins (
|
||||
player_name TEXT NOT NULL,
|
||||
checkin_date TEXT NOT NULL,
|
||||
checkin_time INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (player_name, checkin_date)
|
||||
)");
|
||||
|
||||
// ★ 创建索引以提高查询性能
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_changes_player ON player_ip_changes(player_name)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_changes_time ON player_ip_changes(changed_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_changes_newip ON player_ip_changes(new_ip)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_session_log_player ON web_session_log(player_name)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_session_log_time ON web_session_log(login_time)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_locations_ip ON player_ip_locations(ip_address)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_locations_loc ON player_ip_locations(location)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_daily_logins_player ON player_daily_logins(player_name)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_checkins_player ON player_checkins(player_name)");
|
||||
|
||||
// ★ 迁移:确保player_ip_locations表有player_name列
|
||||
try {
|
||||
$db->exec("ALTER TABLE player_ip_locations ADD COLUMN player_name TEXT DEFAULT 'global'");
|
||||
} catch (Exception $e) { /* 列已存在 */ }
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
@@ -370,17 +398,22 @@ function validateToken($token) {
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
if (!$row) {
|
||||
@error_log("[validateToken] Token不存在: " . substr($token, 0, 8) . "...");
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
// 检查是否过期
|
||||
if ($row['expires_at'] < $now) {
|
||||
@error_log("[validateToken] Token已过期: " . substr($token, 0, 8) . "..., expires_at=" . $row['expires_at'] . ", now=" . $now);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否已使用
|
||||
if ($row['used'] == 1) {
|
||||
@error_log("[validateToken] Token已使用: " . substr($token, 0, 8) . "...");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -442,7 +475,7 @@ function validateAndUseToken($token) {
|
||||
function validateTokenSilent($token) {
|
||||
// 先尝试普通token
|
||||
$info = validateToken($token);
|
||||
if ($info) return true;
|
||||
if ($info) return $info;
|
||||
|
||||
// 再尝试weblogin token
|
||||
$db = getDB();
|
||||
@@ -457,7 +490,12 @@ function validateTokenSilent($token) {
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
if (time() - $createdAt > $expireSeconds) return false;
|
||||
|
||||
return true;
|
||||
return [
|
||||
'player' => $row['player_name'],
|
||||
'purpose' => 'weblogin',
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $expireSeconds
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
@@ -494,6 +532,9 @@ function cleanExpiredTokens() {
|
||||
// ===== JSON响应 =====
|
||||
|
||||
function jsonResponse($data, $code = 200) {
|
||||
// ★ 清空所有输出缓冲区(清除Deprecated警告等HTML污染)
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code($code);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
@@ -806,7 +847,7 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
$createdAt = (int)$row['created_at'];
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
$age = time() - $createdAt;
|
||||
@error_log("[validateWebAccess] FOUND: player=" . $row['player_name'] . ", age=${age}s, expire=${expireSeconds}s");
|
||||
@error_log("[validateWebAccess] FOUND: player=" . $row['player_name'] . ", age={$age}s, expire={$expireSeconds}s");
|
||||
|
||||
if (time() - $createdAt > $expireSeconds) {
|
||||
@error_log("[validateWebAccess] DENIED: token expired. Age: $age, Expire: $expireSeconds");
|
||||
@@ -822,35 +863,50 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// 2. Token有效 → 检查玩家是否在游戏中登录(双重检查机制)
|
||||
// 2. Token有效 → 检查玩家是否在游戏中登录(双重检查机制)— 回档9bae160版本
|
||||
$isOnline = false;
|
||||
|
||||
// ★ 详细调试:先查online_players表总数和内容
|
||||
try {
|
||||
$countStmt = $db->query("SELECT COUNT(*) as cnt FROM online_players");
|
||||
$countRow = $countStmt->fetchArray(SQLITE3_ASSOC);
|
||||
$totalOnline = $countRow ? (int)$countRow['cnt'] : 0;
|
||||
|
||||
$allStmt = $db->query("SELECT player_name FROM online_players");
|
||||
$allNames = [];
|
||||
while ($r = $allStmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$allNames[] = $r['player_name'];
|
||||
}
|
||||
|
||||
// 查心跳
|
||||
$hbStmt = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
|
||||
$hbRow = $hbStmt->fetchArray(SQLITE3_ASSOC);
|
||||
$hbLastSeen = $hbRow ? (int)$hbRow['last_seen'] : 0;
|
||||
$hbAgo = $hbLastSeen > 0 ? time() - $hbLastSeen : -1;
|
||||
|
||||
@error_log("[validateWebAccess] online_players查询: total=$totalOnline, names=" . implode(',', $allNames) . ", heartbeat_ago={$hbAgo}s, searchName=" . $playerName);
|
||||
} catch (Exception $e) {
|
||||
@error_log("[validateWebAccess] online_players查询异常: " . $e->getMessage());
|
||||
$totalOnline = 0;
|
||||
$allNames = [];
|
||||
}
|
||||
|
||||
// 检查1: online_players表(Java插件推送)
|
||||
try {
|
||||
$onlineStmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :player");
|
||||
$onlineStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$onlineResult = $onlineStmt->execute();
|
||||
$onlineRow = $onlineResult->fetchArray();
|
||||
$isOnline = ($onlineRow !== false);
|
||||
@error_log("[validateWebAccess] 玩家 " . $playerName . " 在online_players中: " . ($isOnline ? '找到' : '未找到'));
|
||||
} catch (Exception $e) {
|
||||
$isOnline = false;
|
||||
@error_log("[validateWebAccess] online_players查询异常: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// ★ 如果 online_players 表没有数据,再检查 web_session_log(1分钟内的Web会话视为在线)
|
||||
if (!$isOnline) {
|
||||
try {
|
||||
$oneMinuteAgo = time() - 60;
|
||||
$sessionStmt = $db->prepare("SELECT 1 FROM web_session_log WHERE player_name = :player AND login_time > :time");
|
||||
$sessionStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$sessionStmt->bindValue(':time', $oneMinuteAgo, SQLITE3_INTEGER);
|
||||
$sessionResult = $sessionStmt->execute();
|
||||
$sessionRow = $sessionResult->fetchArray();
|
||||
if ($sessionRow) {
|
||||
$isOnline = true;
|
||||
debugLog("validateWebAccess: 在线状态通过web_session_log确认", ['player' => $playerName]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// web_session_log 表可能不存在,忽略
|
||||
}
|
||||
}
|
||||
// 检查2: web_session_log — ★ 不再用于判断在线状态
|
||||
// web_session_log 仅记录会话历史,不决定权限
|
||||
// 在线状态完全由Java插件推送的 online_players 表决定
|
||||
|
||||
// 3. 检查玩家注册状态
|
||||
$isRegistered = isPlayerRegistered($playerName);
|
||||
@@ -863,7 +919,7 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
'ip' => $ipAddress
|
||||
]);
|
||||
|
||||
// 4. view操作:Token有效,允许Web密码登录(即使游戏里未登录)
|
||||
// 4. view操作:Token有效 → 允许Web密码登录(即使游戏里未登录)
|
||||
if ($action === 'view') {
|
||||
if (!$isRegistered) {
|
||||
// ★ 玩家未在游戏中注册 → 允许Web注册,录入注册请求等待插件同步
|
||||
@@ -872,7 +928,6 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
}
|
||||
if (!$isOnline) {
|
||||
// 玩家已注册但在游戏中未登录 → 允许Web密码登录
|
||||
// 验证成功后插件会通过轮询web_login_confirmations自动登录游戏
|
||||
debugLog("validateWebAccess: 需要密码", ['player' => $playerName]);
|
||||
return ['ok' => false, 'mode' => 'need_password', 'player' => $playerName, 'registered' => true, 'online' => false, 'message' => '请输入游戏登录密码以同步登录游戏'];
|
||||
}
|
||||
@@ -881,11 +936,29 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
return ['ok' => true, 'mode' => 'full', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '验证成功'];
|
||||
}
|
||||
|
||||
// 5. 关键操作(buy/recharge/cdk):需要玩家在游戏中登录
|
||||
// 5. 关键操作(buy/recharge/cdk):需要玩家在游戏中登录或已通过PHP密码验证
|
||||
if (!$isOnline) {
|
||||
// ★ 检查web_login_verified:PHP密码登录成功后5分钟内允许关键操作
|
||||
$isVerified = false;
|
||||
try {
|
||||
$fiveMinAgo = time() - 300;
|
||||
$verifiedStmt = $db->prepare("SELECT 1 FROM web_login_verified WHERE player_name = :player AND verified_at >= :expire");
|
||||
$verifiedStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$verifiedStmt->bindValue(':expire', $fiveMinAgo, SQLITE3_INTEGER);
|
||||
$verifiedResult = $verifiedStmt->execute();
|
||||
$verifiedRow = $verifiedResult->fetchArray();
|
||||
if ($verifiedRow) {
|
||||
$isVerified = true;
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
if (!$isVerified) {
|
||||
debugLog("validateWebAccess: 需要游戏登录", ['player' => $playerName, 'action' => $action]);
|
||||
return ['ok' => false, 'mode' => 'need_game_login', 'player' => $playerName, 'registered' => $isRegistered, 'online' => false, 'message' => '请先在游戏中登录'];
|
||||
}
|
||||
// PHP密码验证有效,放行
|
||||
$sessionToken = recordWebSession($playerName, $ipAddress);
|
||||
debugLog("validateWebAccess: PHP密码验证放行(关键操作)", ['player' => $playerName, 'action' => $action]);
|
||||
}
|
||||
|
||||
// 6. 关键操作(buy/recharge/cdk):需要玩家已注册
|
||||
if (!$isRegistered) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
// 管理后台API诊断脚本
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
echo "=== Admin API Diagnostic ===\n\n";
|
||||
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
echo "1. Core loaded: " . (function_exists('getDB') ? 'YES' : 'NO') . "\n";
|
||||
echo "2. ADMIN_PASS defined: " . (defined('ADMIN_PASS') ? 'YES' : 'NO') . "\n";
|
||||
|
||||
// Test database connection
|
||||
echo "\n--- Database ---\n";
|
||||
try {
|
||||
$db = getDB();
|
||||
echo "DB opened OK\n";
|
||||
|
||||
// List all tables
|
||||
$result = $db->query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||
$tables = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$tables[] = $row['name'];
|
||||
}
|
||||
echo "Tables (" . count($tables) . "): " . implode(', ', $tables) . "\n";
|
||||
|
||||
// Check player_ip_changes structure
|
||||
if (in_array('player_ip_changes', $tables)) {
|
||||
$result = $db->query("PRAGMA table_info(player_ip_changes)");
|
||||
echo "\nplayer_ip_changes columns:\n";
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " - {$row['name']} ({$row['type']}) PK=" . ($row['pk'] ? 'YES' : 'no') . "\n";
|
||||
}
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM player_ip_changes")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo " Rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Check player_ip_locations structure
|
||||
if (in_array('player_ip_locations', $tables)) {
|
||||
$result = $db->query("PRAGMA table_info(player_ip_locations)");
|
||||
echo "\nplayer_ip_locations columns:\n";
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " - {$row['name']} ({$row['type']}) PK=" . ($row['pk'] ? 'YES' : 'no') . "\n";
|
||||
}
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM player_ip_locations")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo " Rows: $cnt\n";
|
||||
// Show a sample
|
||||
$result = $db->query("SELECT ip_address, location FROM player_ip_locations LIMIT 5");
|
||||
if ($result) {
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " Sample: {$row['ip_address']} => {$row['location']}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check users table
|
||||
if (in_array('users', $tables)) {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM users")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "\nusers rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Check online_players
|
||||
if (in_array('online_players', $tables)) {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM online_players")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "online_players rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Check web_session_log
|
||||
if (in_array('web_session_log', $tables)) {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM web_session_log")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "web_session_log rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Test batchGetPlayerIps
|
||||
echo "\n--- Test batchGetPlayerIps ---\n";
|
||||
if (function_exists('batchGetPlayerIps') || class_exists('adminListUsersPaginated')) {
|
||||
echo "batchGetPlayerIps function NOT available in this scope\n";
|
||||
} else {
|
||||
echo "Function not available (defined in api/admin.php, not core.php)\n";
|
||||
}
|
||||
|
||||
// Test simple queries that adminListUsersPaginated would run
|
||||
echo "\n--- Test Query Simulation ---\n";
|
||||
try {
|
||||
$sql = "SELECT player_name, register_time, last_login_time, points, total_online_time, email FROM users ORDER BY register_time DESC LIMIT 10 OFFSET 0";
|
||||
$result = $db->query($sql);
|
||||
$count = 0;
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$count++;
|
||||
}
|
||||
echo "Users query OK, got $count rows\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Users query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test player_ip_changes query
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip AS ip_address FROM player_ip_changes WHERE player_name IN ('test') ORDER BY changed_at DESC");
|
||||
echo "player_ip_changes simple query: OK (prepared)\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "player_ip_changes query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test player_ip_locations query
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT ip_address, location FROM player_ip_locations WHERE ip_address IN ('1.2.3.4')");
|
||||
echo "player_ip_locations query: OK (prepared)\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "player_ip_locations query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test location search query
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip FROM player_ip_changes WHERE new_ip != '' AND new_ip != '-' AND new_ip NOT LIKE '10.%' ORDER BY changed_at DESC LIMIT 5");
|
||||
$result = $stmt->execute();
|
||||
$ips = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$ips[] = $row['new_ip'];
|
||||
}
|
||||
echo "Location search pre-query: OK, got " . count($ips) . " IPs\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Location search pre-query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test region search SQL
|
||||
try {
|
||||
$searchBound = "%长沙%";
|
||||
$stmt = $db->prepare("SELECT COUNT(*) as cnt FROM player_ip_locations WHERE location LIKE :region");
|
||||
$stmt->bindValue(':region', $searchBound, SQLITE3_TEXT);
|
||||
$cnt = $stmt->execute()->fetchArray(SQLITE3_ASSOC)['cnt'];
|
||||
echo "Region cache check: $cnt matching records\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Region cache check FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\nFATAL ERROR: " . $e->getMessage() . "\n";
|
||||
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
echo "Stack: " . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== Done ===\n";
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* 调试页面 - 检查online_players表状态
|
||||
*/
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
$db = getDB();
|
||||
$result = [];
|
||||
|
||||
// 1. online_players表内容
|
||||
try {
|
||||
$rows = $db->query("SELECT * FROM online_players ORDER BY login_time DESC")->fetchArray(SQLITE3_ASSOC);
|
||||
$onlinePlayers = [];
|
||||
while ($row = $db->query("SELECT * FROM online_players ORDER BY login_time DESC")->fetchArray(SQLITE3_ASSOC)) {
|
||||
$onlinePlayers[] = $row;
|
||||
}
|
||||
// 重新查询
|
||||
$stmt = $db->query("SELECT * FROM online_players ORDER BY login_time DESC");
|
||||
$onlinePlayers = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$onlinePlayers[] = $row;
|
||||
}
|
||||
$result['online_players'] = $onlinePlayers;
|
||||
$result['online_players_count'] = count($onlinePlayers);
|
||||
} catch (Exception $e) {
|
||||
$result['online_players_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 2. online_player_hb表(心跳)
|
||||
try {
|
||||
$stmt = $db->query("SELECT * FROM online_player_hb");
|
||||
$hb = $stmt->fetchArray(SQLITE3_ASSOC);
|
||||
$result['heartbeat'] = $hb;
|
||||
if ($hb) {
|
||||
$result['heartbeat_ago'] = time() - (int)$hb['last_seen'] . '秒前';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$result['heartbeat_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 3. weblogin_tokens表
|
||||
try {
|
||||
$stmt = $db->query("SELECT player_name, created_at, expire_seconds, datetime(created_at, 'unixepoch', 'localtime') as created_time FROM weblogin_tokens ORDER BY created_at DESC LIMIT 20");
|
||||
$tokens = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$tokens[] = $row;
|
||||
}
|
||||
$result['weblogin_tokens'] = $tokens;
|
||||
} catch (Exception $e) {
|
||||
$result['tokens_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 4. web_session_log表
|
||||
try {
|
||||
$stmt = $db->query("SELECT player_name, login_time, datetime(login_time, 'unixepoch', 'localtime') as login_time_str FROM web_session_log ORDER BY login_time DESC LIMIT 20");
|
||||
$sessions = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$sessions[] = $row;
|
||||
}
|
||||
$result['web_session_log'] = $sessions;
|
||||
} catch (Exception $e) {
|
||||
$result['sessions_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 5. web_login_verified表
|
||||
try {
|
||||
$stmt = $db->query("SELECT * FROM web_login_verified ORDER BY verified_at DESC LIMIT 20");
|
||||
$verified = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$verified[] = $row;
|
||||
}
|
||||
$result['web_login_verified'] = $verified;
|
||||
} catch (Exception $e) {
|
||||
$result['verified_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 6. users表(部分)
|
||||
try {
|
||||
$stmt = $db->query("SELECT player_name, logged_in FROM users LIMIT 20");
|
||||
$users = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$users[] = $row;
|
||||
}
|
||||
$result['users'] = $users;
|
||||
} catch (Exception $e) {
|
||||
$result['users_error'] = $e->getMessage();
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>调试 - Online Players</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
||||
h1 { color: #00d4ff; }
|
||||
h2 { color: #ff6b6b; margin-top: 30px; border-bottom: 1px solid #333; padding-bottom: 5px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
|
||||
th, td { border: 1px solid #444; padding: 8px 12px; text-align: left; }
|
||||
th { background: #16213e; color: #00d4ff; }
|
||||
td { background: #0f3460; }
|
||||
.empty { color: #ff6b6b; font-weight: bold; }
|
||||
.ok { color: #4ecca3; }
|
||||
.warn { color: #ffd93d; }
|
||||
pre { background: #0a0a23; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||||
.refresh { background: #00d4ff; color: #000; padding: 10px 20px; border: none; cursor: pointer; font-size: 16px; border-radius: 5px; }
|
||||
.refresh:hover { background: #00b8d4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Online Players 调试页面</h1>
|
||||
<button class="refresh" onclick="location.reload()">🔄 刷新</button>
|
||||
<p>当前时间: <?php echo date('Y-m-d H:i:s') . ' (Unix: ' . time() . ')'; ?></p>
|
||||
|
||||
<h2>1. online_players 表 (Java推送的游戏登录状态)</h2>
|
||||
<?php if (empty($result['online_players'])): ?>
|
||||
<p class="empty">⚠️ 表为空!没有玩家被标记为游戏中已登录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家名</th><th>登录时间</th><th>时间戳</th></tr>
|
||||
<?php foreach ($result['online_players'] as $p): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($p['player_name']) ?></td>
|
||||
<td><?= date('Y-m-d H:i:s', (int)$p['login_time']) ?></td>
|
||||
<td><?= $p['login_time'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<p>总数: <span class="<?= $result['online_players_count'] > 0 ? 'ok' : 'empty' ?>"><?= $result['online_players_count'] ?></span></p>
|
||||
|
||||
<h2>2. 心跳 (online_player_hb)</h2>
|
||||
<?php if (isset($result['heartbeat']) && $result['heartbeat']): ?>
|
||||
<p>上次心跳: <?= date('Y-m-d H:i:s', (int)$result['heartbeat']['last_seen']) ?> (<span class="warn"><?= $result['heartbeat_ago'] ?></span>)</p>
|
||||
<?php
|
||||
$ago = time() - (int)$result['heartbeat']['last_seen'];
|
||||
if ($ago > 120) echo '<p class="empty">⚠️ 心跳超过120秒,下次同步时PHP会清空online_players表!</p>';
|
||||
elseif ($ago > 60) echo '<p class="warn">⚠️ 心跳超过60秒</p>';
|
||||
else echo '<p class="ok">✅ 心跳正常</p>';
|
||||
?>
|
||||
<?php else: ?>
|
||||
<p class="empty">⚠️ 无心跳记录</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>3. weblogin_tokens (Web登录Token)</h2>
|
||||
<?php if (empty($result['weblogin_tokens'])): ?>
|
||||
<p class="empty">⚠️ 无Token记录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家</th><th>创建时间</th><th>有效期(秒)</th></tr>
|
||||
<?php foreach ($result['weblogin_tokens'] as $t): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($t['player_name']) ?></td>
|
||||
<td><?= $t['created_time'] ?></td>
|
||||
<td><?= $t['expire_seconds'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>4. web_session_log (Web会话记录)</h2>
|
||||
<?php if (empty($result['web_session_log'])): ?>
|
||||
<p class="empty">⚠️ 无会话记录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家</th><th>登录时间</th><th>时间戳</th></tr>
|
||||
<?php foreach ($result['web_session_log'] as $s): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($s['player_name']) ?></td>
|
||||
<td><?= $s['login_time_str'] ?></td>
|
||||
<td><?= $s['login_time'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>5. web_login_verified (Web密码验证)</h2>
|
||||
<?php if (empty($result['web_login_verified'])): ?>
|
||||
<p class="empty">⚠️ 无验证记录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家</th><th>验证时间</th></tr>
|
||||
<?php foreach ($result['web_login_verified'] as $v): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($v['player_name']) ?></td>
|
||||
<td><?= date('Y-m-d H:i:s', (int)$v['verified_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>6. users表 (玩家注册状态)</h2>
|
||||
<?php if (empty($result['users'])): ?>
|
||||
<p class="empty">⚠️ 无注册玩家</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家名</th><th>logged_in</th></tr>
|
||||
<?php foreach ($result['users'] as $u): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($u['player_name']) ?></td>
|
||||
<td class="<?= $u['logged_in'] ? 'ok' : 'warn' ?>"><?= $u['logged_in'] ? '✅ 已登录' : '❌ 未登录' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>原始JSON</h2>
|
||||
<pre><?= json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?></pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/**
|
||||
* 调试地区搜索问题
|
||||
* 显示 player_ip_locations 和 player_ip_changes 表内容,以及地区搜索SQL测试结果
|
||||
*/
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
require_once 'core.php';
|
||||
|
||||
$db = getDB();
|
||||
echo "<h2>调试地区搜索</h2>";
|
||||
|
||||
// 1. player_ip_locations 表内容
|
||||
echo "<h3>player_ip_locations 表</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT COUNT(*) as cnt FROM player_ip_locations");
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
echo "总记录数: " . $row['cnt'] . "<br>";
|
||||
|
||||
if ($row['cnt'] > 0) {
|
||||
echo "<table border='1' cellpadding='5'>";
|
||||
echo "<tr><th>ip_address</th><th>location</th><th>player_name</th><th>updated_at</th></tr>";
|
||||
$result = $db->query("SELECT * FROM player_ip_locations ORDER BY updated_at DESC LIMIT 20");
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "<tr>";
|
||||
echo "<td>" . htmlspecialchars($r['ip_address']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($r['location']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($r['player_name']) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i:s', $r['updated_at']) . "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
echo "</table>";
|
||||
} else {
|
||||
echo "表为空!<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 2. player_ip_changes 表内容
|
||||
echo "<h3>player_ip_changes 表</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT COUNT(*) as cnt FROM player_ip_changes");
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
echo "总记录数: " . $row['cnt'] . "<br>";
|
||||
|
||||
if ($row['cnt'] > 0) {
|
||||
echo "<table border='1' cellpadding='5'>";
|
||||
echo "<tr><th>player_name</th><th>new_ip</th><th>changed_at</th></tr>";
|
||||
$result = $db->query("SELECT * FROM player_ip_changes ORDER BY changed_at DESC LIMIT 20");
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "<tr>";
|
||||
echo "<td>" . htmlspecialchars($r['player_name']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($r['new_ip']) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i:s', $r['changed_at']) . "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
echo "</table>";
|
||||
} else {
|
||||
echo "表为空!<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 3. 地区搜索SQL测试
|
||||
echo "<h3>地区搜索SQL测试</h3>";
|
||||
$search = '长沙';
|
||||
$regionKey = $search;
|
||||
|
||||
// 测试JOIN
|
||||
$sql = "SELECT c.player_name FROM player_ip_changes c
|
||||
INNER JOIN player_ip_locations l ON c.new_ip = l.ip_address
|
||||
WHERE l.location LIKE '%$regionKey%' AND c.new_ip != '' AND c.new_ip != '-'
|
||||
GROUP BY c.player_name";
|
||||
echo "SQL: <pre>" . htmlspecialchars($sql) . "</pre>";
|
||||
|
||||
try {
|
||||
$result = $db->query($sql);
|
||||
$count = 0;
|
||||
$players = [];
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$count++;
|
||||
$players[] = $r['player_name'];
|
||||
}
|
||||
echo "匹配玩家数: $count<br>";
|
||||
if ($count > 0) {
|
||||
echo "匹配玩家: " . implode(', ', $players) . "<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "SQL错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 4. 检查所有location中包含"长沙"的
|
||||
echo "<h3>包含'长沙'的location记录</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT ip_address, location, player_name FROM player_ip_locations WHERE location LIKE '%长沙%'");
|
||||
$found = false;
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$found = true;
|
||||
echo "IP: " . htmlspecialchars($r['ip_address']) . " → " . htmlspecialchars($r['location']) . "<br>";
|
||||
}
|
||||
if (!$found) {
|
||||
echo "没有找到包含'长沙'的location记录<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 5. 检查users表中是否有来自长沙的玩家(通过IP推断)
|
||||
echo "<h3>测试:直接搜索users表</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT player_name, email FROM users LIMIT 10");
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "玩家: " . htmlspecialchars($r['player_name']) . " (email: " . htmlspecialchars($r['email']) . ")<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 6. 尝试手动查询一个IP的归属地
|
||||
echo "<h3>测试:手动查询IP归属地</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT DISTINCT new_ip FROM player_ip_changes WHERE new_ip != '' AND new_ip != '-' AND new_ip NOT LIKE '10.%' AND new_ip NOT LIKE '192.168.%' AND new_ip != '127.0.0.1' LIMIT 5");
|
||||
$ips = [];
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$ips[] = $r['new_ip'];
|
||||
}
|
||||
|
||||
if (!empty($ips)) {
|
||||
echo "测试查询以下IP的归属地: " . implode(', ', $ips) . "<br>";
|
||||
require_once 'api/admin.php';
|
||||
foreach ($ips as $ip) {
|
||||
$loc = queryIpLocationWithTimeout($ip, 3);
|
||||
echo "IP: $ip → " . ($loc ? $loc['location'] : 'null') . "<br>";
|
||||
}
|
||||
} else {
|
||||
echo "没有找到可用的外部IP<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
// 地区搜索诊断脚本 - 直接测试PHP两步法
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
echo "=== Region Search Debug v2 ===\n\n";
|
||||
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
echo "DB opened OK\n\n";
|
||||
|
||||
// 1. Check tables and data
|
||||
echo "--- Data Overview ---\n";
|
||||
$tables = ['users', 'player_ip_changes', 'player_ip_locations', 'web_session_log'];
|
||||
foreach ($tables as $table) {
|
||||
try {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM $table")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "$table: $cnt rows\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "$table: ERROR - " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check player_ip_locations samples
|
||||
echo "\n--- player_ip_locations Samples ---\n";
|
||||
try {
|
||||
$result = $db->query("SELECT ip_address, location, player_name FROM player_ip_locations LIMIT 10");
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " {$row['ip_address']} => '{$row['location']}' (player: {$row['player_name']})\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo " ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 3. Test region search for "长沙"
|
||||
$regionKey = '长沙';
|
||||
echo "\n--- Testing Region Search: '$regionKey' ---\n";
|
||||
|
||||
// Step 1: Find IPs with matching location
|
||||
try {
|
||||
$locStmt = $db->prepare("SELECT ip_address FROM player_ip_locations WHERE location LIKE :region");
|
||||
$locStmt->bindValue(':region', "%$regionKey%", SQLITE3_TEXT);
|
||||
$locResult = $locStmt->execute();
|
||||
$matchedIps = [];
|
||||
while ($locRow = $locResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$matchedIps[] = $locRow['ip_address'];
|
||||
}
|
||||
echo "Step 1: Found " . count($matchedIps) . " IPs with '$regionKey' location\n";
|
||||
if (!empty($matchedIps)) {
|
||||
echo " IPs: " . implode(', ', array_slice($matchedIps, 0, 10)) . "\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 1 ERROR: " . $e->getMessage() . "\n";
|
||||
$matchedIps = [];
|
||||
}
|
||||
|
||||
// Step 2: Find players using those IPs
|
||||
$matchedPlayers = [];
|
||||
if (!empty($matchedIps)) {
|
||||
$ipPlaceholders = implode(',', array_fill(0, count($matchedIps), '?'));
|
||||
|
||||
// From player_ip_changes
|
||||
try {
|
||||
$pStmt = $db->prepare("SELECT DISTINCT player_name FROM player_ip_changes WHERE new_ip IN ($ipPlaceholders)");
|
||||
$idx = 1;
|
||||
foreach ($matchedIps as $ip) { $pStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
|
||||
$pResult = $pStmt->execute();
|
||||
while ($pRow = $pResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$matchedPlayers[] = $pRow['player_name'];
|
||||
}
|
||||
echo "Step 2a (player_ip_changes): Found " . count($matchedPlayers) . " players\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 2a ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// From web_session_log
|
||||
try {
|
||||
$wStmt = $db->prepare("SELECT DISTINCT player_name FROM web_session_log WHERE ip_address IN ($ipPlaceholders)");
|
||||
$idx = 1;
|
||||
foreach ($matchedIps as $ip) { $wStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
|
||||
$wResult = $wStmt->execute();
|
||||
$beforeCount = count($matchedPlayers);
|
||||
while ($wRow = $wResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
if (!in_array($wRow['player_name'], $matchedPlayers)) {
|
||||
$matchedPlayers[] = $wRow['player_name'];
|
||||
}
|
||||
}
|
||||
echo "Step 2b (web_session_log): Found " . (count($matchedPlayers) - $beforeCount) . " new players\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 2b ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "Step 2 Total: " . count($matchedPlayers) . " players\n";
|
||||
if (!empty($matchedPlayers)) {
|
||||
echo " Players: " . implode(', ', array_slice($matchedPlayers, 0, 10)) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Query users
|
||||
if (!empty($matchedPlayers)) {
|
||||
$namePlaceholders = implode(',', array_fill(0, count($matchedPlayers), '?'));
|
||||
try {
|
||||
$uStmt = $db->prepare("SELECT player_name, email FROM users WHERE player_name IN ($namePlaceholders) LIMIT 5");
|
||||
$idx = 1;
|
||||
foreach ($matchedPlayers as $pn) { $uStmt->bindValue($idx++, $pn, SQLITE3_TEXT); }
|
||||
$uResult = $uStmt->execute();
|
||||
$users = [];
|
||||
while ($uRow = $uResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$users[] = $uRow;
|
||||
}
|
||||
echo "Step 3: Found " . count($users) . " users\n";
|
||||
foreach ($users as $u) {
|
||||
echo " {$u['player_name']} ({$u['email']})\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 3 ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo "Step 3: Skipped (no matching players)\n";
|
||||
}
|
||||
|
||||
// 4. Also check what batchGetPlayerIps would return for all users
|
||||
echo "\n--- batchGetPlayerIps Check ---\n";
|
||||
try {
|
||||
$allUsers = $db->query("SELECT player_name FROM users")->fetchArray(SQLITE3_ASSOC);
|
||||
// Get a sample user
|
||||
$sampleUser = 'youpaishidifu';
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip FROM player_ip_changes WHERE player_name = :name ORDER BY changed_at DESC LIMIT 1");
|
||||
$stmt->bindValue(':name', $sampleUser, SQLITE3_TEXT);
|
||||
$result = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($result) {
|
||||
echo "$sampleUser IP from player_ip_changes: {$result['new_ip']}\n";
|
||||
// Check if this IP has location
|
||||
$locStmt = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$locStmt->bindValue(':ip', $result['new_ip'], SQLITE3_TEXT);
|
||||
$locResult = $locStmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
echo " Location: " . ($locResult ? $locResult['location'] : 'NOT FOUND') . "\n";
|
||||
} else {
|
||||
echo "$sampleUser NOT in player_ip_changes\n";
|
||||
// Check web_session_log
|
||||
$stmt2 = $db->prepare("SELECT ip_address FROM web_session_log WHERE player_name = :name ORDER BY login_time DESC LIMIT 1");
|
||||
$stmt2->bindValue(':name', $sampleUser, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($result2) {
|
||||
echo "$sampleUser IP from web_session_log: {$result2['ip_address']}\n";
|
||||
$locStmt2 = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$locStmt2->bindValue(':ip', $result2['ip_address'], SQLITE3_TEXT);
|
||||
$locResult2 = $locStmt2->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
echo " Location: " . ($locResult2 ? $locResult2['location'] : 'NOT FOUND') . "\n";
|
||||
} else {
|
||||
echo "$sampleUser NOT in web_session_log either\n";
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "batchGetPlayerIps check ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\nFATAL ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== Done ===\n";
|
||||
?>
|
||||
@@ -70,6 +70,13 @@
|
||||
<input type="text" id="playerName" placeholder="玩家名" readonly
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
|
||||
</div>
|
||||
<!-- ★ 切换标签:密码/邮箱 -->
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px">
|
||||
<button class="btn btn-primary" id="tabPassword" onclick="switchLoginTab('password')" style="flex:1">游戏密码</button>
|
||||
<button class="btn btn-dim" id="tabEmail" onclick="switchLoginTab('email')" style="flex:1">邮箱验证码</button>
|
||||
</div>
|
||||
<!-- 密码登录 -->
|
||||
<div id="loginPasswordTab">
|
||||
<div style="text-align:left;margin-bottom:20px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">游戏内密码</label>
|
||||
<input type="password" id="password" placeholder="输入游戏内密码" maxlength="32"
|
||||
@@ -78,6 +85,21 @@
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doPasswordLogin()">登录到Web端</button>
|
||||
</div>
|
||||
<!-- 邮箱验证码登录 -->
|
||||
<div id="loginEmailTab" style="display:none">
|
||||
<div style="text-align:left;margin-bottom:12px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">绑定邮箱</label>
|
||||
<input type="text" id="maskedEmail" readonly placeholder="加载中..."
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:20px">
|
||||
<input type="text" id="emailCode" placeholder="6位验证码" maxlength="6"
|
||||
style="flex:1;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
|
||||
<button class="btn btn-primary" id="sendCodeBtn" onclick="sendEmailCode()" style="white-space:nowrap">发送验证码</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doEmailLogin()">登录到Web端</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions" style="display:none"></div>
|
||||
</div>
|
||||
@@ -139,18 +161,21 @@
|
||||
localStorage.setItem('sdf1_token', TOKEN);
|
||||
localStorage.setItem('sdf1_login_time', Date.now());
|
||||
|
||||
// Token验证成功 → 显示密码输入框
|
||||
// ★ Token验证成功 → 直接跳转到player.php(跳过密码,token本身即身份证明)
|
||||
statusBox.className = 'status-box success';
|
||||
statusBox.innerHTML = '✅ Token验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span>';
|
||||
statusBox.innerHTML = '✅ Token验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转到玩家商城...</small>';
|
||||
|
||||
// ★ 同时写入web_session_log(让validateWebAccess能识别在线状态)
|
||||
fetch('./api/sync.php?action=web_access_check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({web_token: TOKEN, access_action: 'view'})
|
||||
}).catch(() => {});
|
||||
|
||||
// 延迟1.5秒后切换到密码输入
|
||||
setTimeout(() => {
|
||||
tokenSection.style.display = 'none';
|
||||
passwordSection.style.display = 'block';
|
||||
subtitle.textContent = '验证玩家身份';
|
||||
document.getElementById('playerName').value = player;
|
||||
document.getElementById('password').focus();
|
||||
}, 1500);
|
||||
// ★ 跳转时带上token,确保player.php一定能读到
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(TOKEN);
|
||||
}, 800);
|
||||
} else {
|
||||
statusBox.className = 'status-box error';
|
||||
statusBox.innerHTML = '❌ 登录失败<br><br>' + (data.message || 'Token无效或已过期');
|
||||
@@ -220,9 +245,9 @@
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.innerHTML = '✅ 登录成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
|
||||
|
||||
// ★ token已存入localStorage,直接跳转player.php(不带token参数,避免URL暴露)
|
||||
// ★ token已存入localStorage,跳转player.php带token参数
|
||||
setTimeout(() => {
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player);
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(TOKEN);
|
||||
}, 500);
|
||||
} else if (result.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
@@ -255,6 +280,122 @@
|
||||
document.getElementById('password').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doPasswordLogin();
|
||||
});
|
||||
document.getElementById('emailCode').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doEmailLogin();
|
||||
});
|
||||
|
||||
// ===== 标签切换 =====
|
||||
let currentLoginTab = 'password';
|
||||
function switchLoginTab(tab) {
|
||||
currentLoginTab = tab;
|
||||
document.getElementById('loginPasswordTab').style.display = tab === 'password' ? 'block' : 'none';
|
||||
document.getElementById('loginEmailTab').style.display = tab === 'email' ? 'block' : 'none';
|
||||
document.getElementById('tabPassword').className = tab === 'password' ? 'btn btn-primary' : 'btn btn-dim';
|
||||
document.getElementById('tabEmail').className = tab === 'email' ? 'btn btn-primary' : 'btn btn-dim';
|
||||
document.getElementById('pwdStatusBox').style.display = 'none';
|
||||
// 切到邮箱时加载脱敏邮箱
|
||||
if (tab === 'email') loadMaskedEmail();
|
||||
}
|
||||
|
||||
// ===== 加载脱敏邮箱 =====
|
||||
async function loadMaskedEmail() {
|
||||
const player = document.getElementById('playerName').value;
|
||||
try {
|
||||
const url = new URL('./api/sync.php', location.href);
|
||||
url.searchParams.set('action', 'get_player_email');
|
||||
url.searchParams.set('player', player);
|
||||
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'} });
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('maskedEmail').value = data.data.masked_email || '未绑定邮箱';
|
||||
} else {
|
||||
document.getElementById('maskedEmail').value = '未绑定邮箱';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('maskedEmail').value = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 发送邮箱验证码 =====
|
||||
async function sendEmailCode() {
|
||||
const player = document.getElementById('playerName').value;
|
||||
const btn = document.getElementById('sendCodeBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中...';
|
||||
try {
|
||||
const url = new URL('./api/sync.php', location.href);
|
||||
url.searchParams.set('action', 'send_email_code');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: player, web_token: TOKEN})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showPwdSuccess('验证码已发送至 ' + (data.data.masked_email || '**'));
|
||||
let countdown = 60;
|
||||
const timer = setInterval(() => {
|
||||
btn.textContent = countdown + '秒后重发';
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证码';
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
showPwdError(data.message || '发送失败');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证码';
|
||||
}
|
||||
} catch (e) {
|
||||
showPwdError('连接失败: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证码';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 邮箱验证码登录 =====
|
||||
async function doEmailLogin() {
|
||||
const player = document.getElementById('playerName').value;
|
||||
const code = document.getElementById('emailCode').value.trim();
|
||||
if (!code) { showPwdError('请输入验证码'); return; }
|
||||
if (code.length !== 6) { showPwdError('验证码为6位数字'); return; }
|
||||
|
||||
pwdStatusBox.className = 'status-box loading';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证邮箱验证码...';
|
||||
|
||||
try {
|
||||
const url = new URL('./api/sync.php', location.href);
|
||||
url.searchParams.set('action', 'verify_email_code');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: player, code: code, web_token: TOKEN})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('sdf1_player', player);
|
||||
localStorage.setItem('sdf1_token', TOKEN);
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.innerHTML = '✅ 邮箱验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
|
||||
setTimeout(() => {
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player);
|
||||
}, 500);
|
||||
} else {
|
||||
showPwdError(data.message || '验证失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showPwdError('连接失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showPwdSuccess(msg) {
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '✅ ' + msg;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+314
-41
@@ -1,7 +1,35 @@
|
||||
<?php
|
||||
// ★ 强制缓存失效:用文件修改时间作为版本号
|
||||
// 每次修改player.php后,文件时间戳变化 → URL不同 → 浏览器必须获取新内容
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
header('X-Accel-Expires: 0'); // Nginx反代也禁缓存
|
||||
|
||||
$BUILD_VERSION = 'v' . filemtime(__FILE__); // 文件修改时间 = 版本号
|
||||
$currentVersion = isset($_GET['_v']) ? $_GET['_v'] : '';
|
||||
if ($currentVersion !== $BUILD_VERSION) {
|
||||
// 版本不匹配 → 重定向到带正确版本号的URL(浏览器视为全新请求)
|
||||
// 先移除旧的 _v 参数
|
||||
$params = $_GET;
|
||||
unset($params['_v']);
|
||||
$queryString = http_build_query($params);
|
||||
$path = strtok($_SERVER['REQUEST_URI'], '?');
|
||||
$newUrl = $path . ($queryString ? '?' . $queryString . '&' : '?') . '_v=' . $BUILD_VERSION;
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
header('Location: ' . $newUrl, true, 302);
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDF1 - 玩家商城</title>
|
||||
<style>
|
||||
@@ -135,10 +163,10 @@
|
||||
|
||||
<div class="main">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-item active" data-page="shop" onclick="switchPage('shop')">🛒 商城</div>
|
||||
<div class="sidebar-item active" data-page="dashboard" onclick="switchPage('dashboard')">📊 个人中心</div>
|
||||
<div class="sidebar-item" data-page="shop" onclick="switchPage('shop')">🛒 商城</div>
|
||||
<div class="sidebar-item" data-page="cdk" onclick="switchPage('cdk')">🎁 CDK兑换</div>
|
||||
<div class="sidebar-item" data-page="balance" onclick="switchPage('balance')">💰 余额查询</div>
|
||||
<div class="sidebar-item" data-page="account" onclick="switchPage('account')">👤 账号信息</div>
|
||||
</div>
|
||||
|
||||
<div class="content" id="content">
|
||||
@@ -158,6 +186,30 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ★ 防止bfcache(前进/后退缓存)绕过安全检查
|
||||
window.addEventListener('pageshow', function(e) {
|
||||
if (e.persisted) {
|
||||
// 页面从bfcache恢复 → 强制刷新以获取最新代码
|
||||
console.log('[BFCACHE] Page restored from bfcache, forcing reload');
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
// ★ 版本检测:强制清除浏览器缓存的旧版JS
|
||||
(function() {
|
||||
const buildVersion = '<?php echo $BUILD_VERSION; ?>';
|
||||
const lastVersion = localStorage.getItem('sdf1_build_version');
|
||||
if (lastVersion && lastVersion !== buildVersion) {
|
||||
// 版本变化 → 清除所有缓存,强制刷新
|
||||
localStorage.removeItem('sdf1_token');
|
||||
localStorage.removeItem('sdf1_player');
|
||||
localStorage.removeItem('sdf1_build_version');
|
||||
localStorage.setItem('sdf1_build_version', buildVersion);
|
||||
console.log('[VERSION] Old version detected, clearing cache and reloading. Old:', lastVersion, 'New:', buildVersion);
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('sdf1_build_version', buildVersion);
|
||||
})();
|
||||
const API = 'api/';
|
||||
let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('sdf1_token') || '';
|
||||
let IS_PREVIEW = !TOKEN;
|
||||
@@ -165,13 +217,14 @@
|
||||
let tokenPollTimer = null; // ★ 新增:token轮询定时器(用于续期检查)
|
||||
let AUTH_TOKEN = TOKEN; // ★ 使用独立的认证token,避免localStorage被意外覆盖
|
||||
let authVerified = false; // ★ 记录是否已通过首次验证
|
||||
let currentPage = 'shop';
|
||||
let currentPage = 'dashboard';
|
||||
let AUTHENTICATED = false;
|
||||
let NEED_PASSWORD = false;
|
||||
|
||||
// 初始化
|
||||
// 初始化 — 回档9bae160版本的token处理逻辑
|
||||
(function() {
|
||||
const urlToken = new URLSearchParams(location.search).get('token');
|
||||
|
||||
if (urlToken) {
|
||||
localStorage.setItem('sdf1_token', urlToken);
|
||||
TOKEN = urlToken;
|
||||
@@ -222,10 +275,10 @@
|
||||
document.getElementById('playerInfo').textContent = '已登录: ' + currentPlayer;
|
||||
authVerified = true; // ★ 标记已通过验证
|
||||
if (callback) callback({ok: true, mode: data.data.mode});
|
||||
// ★ 如果不需要密码,直接加载商品页
|
||||
// ★ 如果不需要密码,直接加载个人中心
|
||||
if (typeof NEED_PASSWORD !== 'undefined' && !NEED_PASSWORD) {
|
||||
setTimeout(() => {
|
||||
switchPage('shop');
|
||||
switchPage('dashboard');
|
||||
// ★ Token跟随Java端配置过期时间自动过期,不续费
|
||||
// startTokenPolling() 不再调用,仅保留变量声明
|
||||
}, 100);
|
||||
@@ -565,30 +618,126 @@
|
||||
|
||||
function showLoginPrompt() {
|
||||
const c = document.getElementById('content');
|
||||
const urlLogin = new URLSearchParams(location.search).get('login') || currentPlayer || '';
|
||||
c.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center;padding:40px">
|
||||
<div style="font-size:64px;margin-bottom:24px">🎮</div>
|
||||
<h2 style="color:var(--text);margin-bottom:12px">欢迎来到SDF1玩家商城</h2>
|
||||
<p style="color:var(--dim);font-size:14px;margin-bottom:32px;max-width:400px">
|
||||
请先登录游戏获取Web访问令牌<br>
|
||||
然后通过令牌链接访问此页面
|
||||
<div style="font-size:64px;margin-bottom:24px">🔐</div>
|
||||
<h2 style="color:var(--text);margin-bottom:12px">登录到SDF1玩家商城</h2>
|
||||
<p style="color:var(--dim);font-size:14px;margin-bottom:24px;max-width:400px">
|
||||
输入游戏内密码登录,无需在游戏里敲指令
|
||||
</p>
|
||||
<div style="background:rgba(59,130,246,0.1);border:1px solid var(--accent);border-radius:8px;padding:20px;margin-bottom:32px;max-width:500px;text-align:left">
|
||||
<h3 style="color:var(--accent);margin:0 0 12px 0;font-size:16px">📱 如何获取访问令牌?</h3>
|
||||
<ol style="color:var(--dim);font-size:13px;margin:0;padding-left:20px;line-height:1.8">
|
||||
<li>登录Minecraft游戏服务器</li>
|
||||
<li>在聊天栏输入 <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:3px">/sdf1_login weblogin</code></li>
|
||||
<li>复制返回的Web访问链接</li>
|
||||
<li>在浏览器中打开该链接</li>
|
||||
</ol>
|
||||
<!-- 密码登录表单 -->
|
||||
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px;width:360px;max-width:100%">
|
||||
<div style="text-align:left;margin-bottom:16px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">玩家名</label>
|
||||
<input type="text" id="loginPlayerName" value="${urlLogin}" placeholder="输入玩家名" maxlength="16"
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">
|
||||
<button class="btn" onclick="location.reload()" style="padding:12px 24px">🔄 刷新页面</button>
|
||||
<button class="btn btn-primary" onclick="showGuestMode()" style="padding:12px 24px">👁️ 预览模式</button>
|
||||
<div style="text-align:left;margin-bottom:20px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">游戏内密码</label>
|
||||
<input type="password" id="loginPassword" placeholder="输入密码" maxlength="32"
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
|
||||
</div>
|
||||
<p style="color:var(--dim);font-size:12px;margin-top:24px">游客模式下可以浏览商品,但无法购买</p>
|
||||
<div id="loginStatusBox" style="display:none;margin-bottom:16px"></div>
|
||||
<button id="loginSubmitBtn" class="btn btn-primary" style="width:100%;padding:12px" onclick="doDirectLogin()">登录</button>
|
||||
<p style="color:var(--dim);font-size:12px;margin-top:12px">密码由游戏服务器验证,Web端不存储密码</p>
|
||||
</div>
|
||||
<!-- 备用方案 -->
|
||||
<div style="background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.3);border-radius:8px;padding:16px;margin-bottom:24px;max-width:400px;text-align:left">
|
||||
<h3 style="color:var(--accent);margin:0 0 8px 0;font-size:14px">📱 也可以在游戏中获取链接</h3>
|
||||
<p style="color:var(--dim);font-size:12px;margin:0;line-height:1.6">
|
||||
在游戏聊天栏输入 <code style="background:rgba(255,255,255,0.1);padding:1px 4px;border-radius:3px">/sdf1_login weblogin</code>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn" onclick="showGuestMode()" style="padding:10px 20px;font-size:13px">👁️ 先逛逛(游客模式)</button>
|
||||
</div>`;
|
||||
document.querySelector('.sidebar').style.display = 'none';
|
||||
// 自动聚焦密码框
|
||||
setTimeout(() => { const pwd = document.getElementById('loginPassword'); if (pwd) pwd.focus(); }, 100);
|
||||
}
|
||||
|
||||
// ★ 直接密码登录(不需要游戏内token)
|
||||
async function doDirectLogin() {
|
||||
const player = document.getElementById('loginPlayerName').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const statusBox = document.getElementById('loginStatusBox');
|
||||
const submitBtn = document.getElementById('loginSubmitBtn');
|
||||
|
||||
if (!player) { showLoginStatus('请输入玩家名', 'error'); return; }
|
||||
if (!password) { showLoginStatus('请输入密码', 'error'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
showLoginStatus('正在提交登录请求...', 'loading');
|
||||
|
||||
try {
|
||||
// 1. 提交登录请求
|
||||
const reqRes = await fetch(API + 'sync.php?action=web_login_request', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player, password})
|
||||
});
|
||||
const reqData = await reqRes.json();
|
||||
if (!reqData.success) {
|
||||
showLoginStatus(reqData.message || '提交失败', 'error');
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const requestId = reqData.data.request_id;
|
||||
showLoginStatus('等待游戏服务器验证密码...', 'loading');
|
||||
|
||||
// 2. 轮询结果
|
||||
let attempts = 0;
|
||||
const pollInterval = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
clearInterval(pollInterval);
|
||||
showLoginStatus('验证超时,请稍后重试', 'error');
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pollRes = await fetch(API + 'sync.php?action=check_web_login_result&player=' + encodeURIComponent(player) + '&request_id=' + requestId);
|
||||
const pollData = await pollRes.json();
|
||||
if (pollData.success && pollData.data) {
|
||||
const result = pollData.data;
|
||||
if (result.status === 'success') {
|
||||
clearInterval(pollInterval);
|
||||
showLoginStatus('✅ 登录成功!正在跳转...', 'success');
|
||||
// 存储token和玩家名
|
||||
const token = result.token;
|
||||
localStorage.setItem('sdf1_token', token);
|
||||
localStorage.setItem('sdf1_player', player);
|
||||
// 跳转到player.php带token
|
||||
setTimeout(() => {
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(token);
|
||||
}, 500);
|
||||
} else if (result.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
showLoginStatus('❌ ' + (result.message || '密码错误'), 'error');
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 继续轮询 */ }
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
showLoginStatus('连接失败: ' + e.message, 'error');
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showLoginStatus(msg, type) {
|
||||
const box = document.getElementById('loginStatusBox');
|
||||
if (!box) return;
|
||||
const colors = { loading: 'var(--accent)', success: 'var(--green)', error: 'var(--red)' };
|
||||
const bgs = { loading: 'rgba(88,166,255,0.1)', success: 'rgba(63,185,80,0.1)', error: 'rgba(248,81,73,0.1)' };
|
||||
box.style.display = 'block';
|
||||
box.style.background = bgs[type] || bgs.loading;
|
||||
box.style.border = '1px solid ' + (colors[type] || colors.loading);
|
||||
box.style.borderRadius = '6px';
|
||||
box.style.padding = '10px 14px';
|
||||
box.style.fontSize = '13px';
|
||||
box.style.color = colors[type] || 'var(--text)';
|
||||
box.innerHTML = type === 'loading' ? '<span style="display:inline-block;width:14px;height:14px;border:2px solid var(--dim);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;vertical-align:middle;margin-right:6px"></span> ' + msg : msg;
|
||||
}
|
||||
|
||||
function showGuestMode() {
|
||||
@@ -601,7 +750,7 @@
|
||||
document.getElementById('playerInfo').textContent = '游客模式';
|
||||
document.getElementById('previewBadge').style.display = 'inline';
|
||||
document.querySelector('.sidebar').style.display = 'block';
|
||||
switchPage('shop');
|
||||
switchPage('dashboard');
|
||||
}
|
||||
|
||||
function switchPage(page) {
|
||||
@@ -612,7 +761,8 @@
|
||||
document.querySelector('.sidebar').style.display = 'block';
|
||||
const c = document.getElementById('content');
|
||||
if (!c) return;
|
||||
if (page === 'shop') renderShop(c);
|
||||
if (page === 'dashboard') renderDashboard(c);
|
||||
else if (page === 'shop') renderShop(c);
|
||||
else if (page === 'cdk') renderCDK(c);
|
||||
else if (page === 'balance') renderBalance(c);
|
||||
else if (page === 'account') renderAccount(c);
|
||||
@@ -688,36 +838,81 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 商城 =====
|
||||
// ===== 商城(分类标签页) =====
|
||||
let shopItems = [];
|
||||
let shopCategories = [];
|
||||
let currentShopCat = '';
|
||||
|
||||
async function renderShop(el) {
|
||||
el.innerHTML = '<div class="empty">加载中...</div>';
|
||||
try {
|
||||
const res = await api('shop.php', {action: 'list'});
|
||||
if (!res.success) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + (res.message || '加载失败') + '</div>'; return; }
|
||||
const items = res.data || [];
|
||||
const categories = [...new Set(items.map(i => i.category))];
|
||||
let html = '<div class="card"><h2>商品列表</h2>';
|
||||
shopItems = res.data || [];
|
||||
shopCategories = [...new Set(shopItems.map(i => i.category))];
|
||||
currentShopCat = shopCategories[0] || '';
|
||||
|
||||
let html = '<div class="card">';
|
||||
html += '<h2>商城</h2>';
|
||||
if (res.preview) html += '<div class="preview-badge" style="margin-bottom:12px">预览模式 - 无法购买</div>';
|
||||
if (categories.length === 0) {
|
||||
|
||||
if (shopCategories.length === 0) {
|
||||
html += '<div class="empty">暂无商品</div>';
|
||||
} else {
|
||||
categories.forEach(cat => {
|
||||
html += `<h3 style="color:var(--dim);margin:16px 0 8px;font-size:14px">${cat}</h3>`;
|
||||
html += '<div class="grid">';
|
||||
items.filter(i => i.category === cat).forEach(item => {
|
||||
// 分类标签
|
||||
html += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px">';
|
||||
shopCategories.forEach((cat, idx) => {
|
||||
const active = idx === 0 ? 'color:#fff;background:var(--accent);border-color:var(--accent)' : '';
|
||||
const catCount = shopItems.filter(i => i.category === cat).length;
|
||||
html += `<div onclick="switchShopCat('${cat.replace(/'/g,"\\'")}')" style="padding:6px 14px;border:1px solid var(--border);border-radius:16px;cursor:pointer;font-size:13px;transition:all 0.2s;${active}" class="shop-cat-tab" data-cat="${cat}">${cat} <span style="opacity:0.7;font-size:11px">(${catCount})</span></div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
// 商品内容区
|
||||
html += '<div id="shopContent"></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
|
||||
// 渲染当前分类的商品
|
||||
renderShopContent();
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty" style="color:var(--red)">商城加载失败: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function switchShopCat(cat) {
|
||||
currentShopCat = cat;
|
||||
// 更新标签样式
|
||||
document.querySelectorAll('.shop-cat-tab').forEach(tab => {
|
||||
const isActive = tab.dataset.cat === cat;
|
||||
tab.style.background = isActive ? 'var(--accent)' : '';
|
||||
tab.style.color = isActive ? '#fff' : '';
|
||||
tab.style.borderColor = isActive ? 'var(--accent)' : 'var(--border)';
|
||||
});
|
||||
renderShopContent();
|
||||
}
|
||||
|
||||
function renderShopContent() {
|
||||
const contentEl = document.getElementById('shopContent');
|
||||
if (!contentEl) return;
|
||||
|
||||
const items = shopItems.filter(i => i.category === currentShopCat);
|
||||
if (items.length === 0) {
|
||||
contentEl.innerHTML = '<div class="empty" style="padding:20px">该分类暂无商品</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="grid">';
|
||||
items.forEach(item => {
|
||||
const matIcon = getMaterialIcon(item.material);
|
||||
const stockText = item.stock == -1 ? '∞ 无限' : (item.stock == 0 ? '售罄' : item.stock + ' 个');
|
||||
const stockClass = item.stock == 0 ? 'out' : '';
|
||||
html += `<div class="item-card" onclick="showBuyModal('${item.id}','${item.display_name.replace(/'/g,"\\'")}',${item.buy_price},${item.stock})"><div class="icon">${matIcon}</div><div class="name">${item.display_name}</div><div class="price">${item.buy_price} 债券</div><div class="stock ${stockClass}">库存: ${stockText}</div></div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty" style="color:var(--red)">商城加载失败: ' + e.message + '</div>';
|
||||
}
|
||||
contentEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function getMaterialIcon(mat) {
|
||||
@@ -761,6 +956,84 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 个人中心仪表盘 =====
|
||||
async function renderDashboard(el) {
|
||||
if (IS_PREVIEW || !currentPlayer) {
|
||||
el.innerHTML = `
|
||||
<div class="card" style="text-align:center;padding:48px 20px">
|
||||
<div style="font-size:64px;margin-bottom:16px">📊</div>
|
||||
<h2 style="color:var(--accent);margin-bottom:8px">个人中心</h2>
|
||||
<p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看个人信息</p>
|
||||
<p style="color:var(--dim);font-size:12px">请先在游戏内登录,或输入密码登录</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = '<div class="card" style="text-align:center;padding:40px"><div class="empty">加载中...</div></div>';
|
||||
|
||||
try {
|
||||
const res = await api('register.php', {action: 'query', player: currentPlayer, token: TOKEN});
|
||||
if (!res.success) {
|
||||
el.innerHTML = `<div class="card" style="border-color:var(--red)"><h2>加载失败</h2><p style="color:var(--dim);margin-top:8px">${res.message || '未知错误'}</p></div>`;
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
const loginTime = d.last_login_time ? new Date(d.last_login_time * 1000).toLocaleString() : '暂无';
|
||||
const regTime = d.register_time ? new Date(d.register_time * 1000).toLocaleString() : '暂无';
|
||||
const hours = Math.floor((d.total_online_time || 0) / 3600);
|
||||
const mins = Math.floor(((d.total_online_time || 0) % 3600) / 60);
|
||||
const onlineText = hours > 0 ? `${hours}小时${mins}分钟` : `${mins}分钟`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card" style="margin-bottom:0">
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
||||
<div style="width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--purple));display:flex;align-items:center;justify-content:center;font-size:28px;color:#fff;font-weight:700">${d.player_name.charAt(0).toUpperCase()}</div>
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:20px">${d.player_name}</h2>
|
||||
<p style="color:var(--dim);font-size:12px;margin-top:2px">注册于 ${regTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-top:12px">
|
||||
<div class="stat-box" style="border-left:3px solid var(--accent)">
|
||||
<div class="value" style="font-size:24px;color:var(--accent)">📧 ${d.masked_email || '未绑定'}</div>
|
||||
<div class="label">绑定邮箱</div>
|
||||
</div>
|
||||
<div class="stat-box" style="border-left:3px solid var(--green)">
|
||||
<div class="value" style="font-size:24px;color:var(--green)">${d.bonds ?? 0}</div>
|
||||
<div class="label">债券余额</div>
|
||||
</div>
|
||||
<div class="stat-box" style="border-left:3px solid var(--yellow)">
|
||||
<div class="value" style="font-size:24px;color:var(--yellow)">${d.points ?? 0}</div>
|
||||
<div class="label">积分</div>
|
||||
</div>
|
||||
<div class="stat-box" style="border-left:3px solid var(--purple)">
|
||||
<div class="value" style="font-size:24px;color:var(--purple)">${loginTime}</div>
|
||||
<div class="label">最后登录时间</div>
|
||||
</div>
|
||||
<div class="stat-box" style="border-left:3px solid #58a6ff">
|
||||
<div class="value" style="font-size:24px;color:#58a6ff">${d.total_online_days ?? 0} 天</div>
|
||||
<div class="label">累计在线天数</div>
|
||||
</div>
|
||||
<div class="stat-box" style="border-left:3px solid #3fb950">
|
||||
<div class="value" style="font-size:24px;color:#3fb950">${d.total_checkin_days ?? 0} 天</div>
|
||||
<div class="label">累计签到天数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:12px">
|
||||
<h2>详细信息</h2>
|
||||
<table class="table" style="margin-top:8px">
|
||||
<tr><td style="color:var(--dim);width:120px">累计在线时长</td><td>${onlineText}</td></tr>
|
||||
<tr><td style="color:var(--dim)">礼包阶段</td><td>${d.gift_stage ?? 0}</td></tr>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="card" style="border-color:var(--red)"><h2>加载异常</h2><p style="color:var(--dim);margin-top:8px">${e.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CDK =====
|
||||
function renderCDK(el) {
|
||||
el.innerHTML = `
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* 自更新脚本 - 通过HTTP POST上传文件内容
|
||||
* 用法: curl -X POST -d "secret=KEY&action=update_file&target=api/admin.php" --data-binary @admin.php https://.../self_update.php
|
||||
* 或: curl -X POST -F "secret=KEY" -F "action=update_file" -F "target=api/admin.php" -F "file=@admin.php" https://.../self_update.php
|
||||
*/
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
@ob_start();
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
$secret = $_POST['secret'] ?? $_GET['secret'] ?? '';
|
||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||
|
||||
if ($secret !== SECRET_KEY) {
|
||||
echo "ERROR: Invalid secret key\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
|
||||
if ($action === 'update_file') {
|
||||
$target = $_POST['target'] ?? '';
|
||||
$allowedTargets = ['api/admin.php', 'api/sync.php', 'core.php', 'config.php'];
|
||||
|
||||
if (!in_array($target, $allowedTargets)) {
|
||||
echo "ERROR: Target '$target' not in allowed list: " . implode(', ', $allowedTargets) . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$targetPath = __DIR__ . '/' . $target;
|
||||
|
||||
// Handle file upload
|
||||
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$content = file_get_contents($_FILES['file']['tmp_name']);
|
||||
} elseif (isset($_POST['content'])) {
|
||||
$content = $_POST['content'];
|
||||
} else {
|
||||
// Read from raw POST body
|
||||
$content = file_get_contents('php://input');
|
||||
}
|
||||
|
||||
if (empty($content)) {
|
||||
echo "ERROR: No file content received\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Backup
|
||||
$backupPath = $targetPath . '.bak';
|
||||
if (file_exists($targetPath)) {
|
||||
copy($targetPath, $backupPath);
|
||||
}
|
||||
|
||||
// Write
|
||||
$bytesWritten = file_put_contents($targetPath, $content);
|
||||
|
||||
if ($bytesWritten !== false) {
|
||||
echo "SUCCESS: Updated $target ($bytesWritten bytes) at $timestamp\n";
|
||||
echo "Backup: $backupPath\n";
|
||||
|
||||
// Verify
|
||||
echo "Verify MD5: " . md5_file($targetPath) . "\n";
|
||||
|
||||
// Clear opcache
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
echo "OPCache: cleared\n";
|
||||
}
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
@opcache_invalidate($targetPath, true);
|
||||
echo "OPCache: invalidated $target\n";
|
||||
}
|
||||
} else {
|
||||
echo "ERROR: Failed to write file\n";
|
||||
}
|
||||
|
||||
} elseif ($action === 'verify') {
|
||||
echo "=== File Verification ===\n";
|
||||
$files = ['api/admin.php', 'api/sync.php', 'core.php'];
|
||||
foreach ($files as $f) {
|
||||
$path = __DIR__ . '/' . $f;
|
||||
if (file_exists($path)) {
|
||||
$size = filesize($path);
|
||||
$md5 = md5_file($path);
|
||||
$mtime = date('Y-m-d H:i:s', filemtime($path));
|
||||
echo "$f: $size bytes, MD5: $md5, modified: $mtime\n";
|
||||
} else {
|
||||
echo "$f: NOT FOUND\n";
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
echo "Usage: ?action=update_file|verify&secret=KEY\n";
|
||||
}
|
||||
|
||||
echo "\nDone at $timestamp\n";
|
||||
@ob_end_flush();
|
||||
?>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* 测试脚本 - 模拟Java推送online_players数据
|
||||
* 用于验证PHP端syncOnlinePlayers是否正常工作
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
$db = getDB();
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
if ($action === 'simulate_push') {
|
||||
// 模拟Java推送一个已登录的玩家
|
||||
$player = $_GET['player'] ?? 'yupaishidifu';
|
||||
$secret = $_GET['secret'] ?? SECRET_KEY;
|
||||
|
||||
$players = [
|
||||
['name' => $player, 'login_time' => time()]
|
||||
];
|
||||
|
||||
$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("BEGIN IMMEDIATE");
|
||||
try {
|
||||
$db->exec("DELETE FROM online_players");
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO online_players (player_name, login_time) VALUES (:name, :time)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$hbSql = $db->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :time)");
|
||||
$hbSql->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$hbSql->execute();
|
||||
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
$db->exec("ROLLBACK");
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 验证
|
||||
$stmt = $db->query("SELECT * FROM online_players");
|
||||
$rows = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "模拟推送玩家 $player 成功",
|
||||
'online_players' => $rows,
|
||||
'timestamp' => time()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'check_token') {
|
||||
// 检查指定玩家的token是否存在
|
||||
$player = $_GET['player'] ?? 'yupaishidifu';
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE player_name = :player ORDER BY created_at DESC LIMIT 1");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
'player' => $player,
|
||||
'token_found' => $row !== false,
|
||||
'token_data' => $row,
|
||||
'timestamp' => time()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'validate') {
|
||||
// 模拟validateWebAccess调用
|
||||
$player = $_GET['player'] ?? 'yupaishidifu';
|
||||
$token = $_GET['token'] ?? '';
|
||||
|
||||
if (!$token) {
|
||||
// 查找最新token
|
||||
$stmt = $db->prepare("SELECT web_token FROM weblogin_tokens WHERE player_name = :player ORDER BY created_at DESC LIMIT 1");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$token = $row ? $row['web_token'] : '';
|
||||
}
|
||||
|
||||
$result = validateWebAccess($token, 'view');
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'error' => '未知操作',
|
||||
'usage' => [
|
||||
'test_sync.php?action=simulate_push&player=yupaishidifu',
|
||||
'test_sync.php?action=check_token&player=yupaishidifu',
|
||||
'test_sync.php?action=validate&player=yupaishidifu'
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// FTP上传脚本
|
||||
$ftpServer = '43.153.203.46';
|
||||
$ftpPort = 61868;
|
||||
$ftpUser = 'qwed';
|
||||
$ftpPass = 'z22K2Ws8r38a';
|
||||
|
||||
$localFile = __DIR__ . '/debug_region_search.php';
|
||||
$remoteDir = '/caoyuan.ypshidifu.cn/test1/';
|
||||
$remoteFile = $remoteDir . 'debug_region_search.php';
|
||||
|
||||
echo "Connecting to FTP...\n";
|
||||
$conn = ftp_connect($ftpServer, $ftpPort);
|
||||
if (!$conn) {
|
||||
die("FTP connection failed\n");
|
||||
}
|
||||
|
||||
echo "Logging in...\n";
|
||||
if (!ftp_login($conn, $ftpUser, $ftpPass)) {
|
||||
die("FTP login failed\n");
|
||||
}
|
||||
|
||||
echo "Uploading $localFile to $remoteFile...\n";
|
||||
if (ftp_put($conn, $remoteFile, $localFile, FTP_BINARY)) {
|
||||
echo "Upload successful\n";
|
||||
} else {
|
||||
echo "Upload failed\n";
|
||||
}
|
||||
|
||||
ftp_close($conn);
|
||||
echo "Done\n";
|
||||
?>
|
||||
@@ -0,0 +1,30 @@
|
||||
$ftpServer = "43.153.203.46"
|
||||
$ftpPort = 61868
|
||||
$ftpUser = "qwed"
|
||||
$ftpPass = "z22K2Ws8r38a"
|
||||
|
||||
$localFile = "D:\桌面\源码\mc_plugn\Web\debug_region_search.php"
|
||||
$remoteDir = "/caoyuan.ypshidifu.cn/test1/"
|
||||
$remoteFile = $remoteDir + "debug_region_search.php"
|
||||
|
||||
Write-Host "Connecting to FTP..."
|
||||
$ftpRequest = [System.Net.FtpWebRequest]::Create("ftp://$ftpServer`:$ftpPort$remoteFile")
|
||||
$ftpRequest.Method = [System.Net.WebRequestMethods+Ftp]::UploadFile
|
||||
$ftpRequest.Credentials = New-Object System.Net.NetworkCredential($ftpUser, $ftpPass)
|
||||
$ftpRequest.UseBinary = $true
|
||||
$ftpRequest.UsePassive = $true
|
||||
|
||||
$fileContents = [System.IO.File]::ReadAllBytes($localFile)
|
||||
$ftpRequest.ContentLength = $fileContents.Length
|
||||
|
||||
try {
|
||||
$requestStream = $ftpRequest.GetRequestStream()
|
||||
$requestStream.Write($fileContents, 0, $fileContents.Length)
|
||||
$requestStream.Close()
|
||||
$requestStream.Dispose()
|
||||
Write-Host "Upload successful"
|
||||
} catch {
|
||||
Write-Host "Upload failed: $_"
|
||||
}
|
||||
|
||||
Write-Host "Done"
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
/**
|
||||
* HTTP文件上传器 - 用于在FTP不可用时通过HTTP上传文件
|
||||
* 访问方式: upload_via_http.php?secret=YOUR_SECRET&action=upload
|
||||
*
|
||||
* 安全措施:
|
||||
* 1. 需要SECRET_KEY认证
|
||||
* 2. 只允许覆盖特定文件
|
||||
* 3. 记录所有操作
|
||||
*/
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
@ob_start();
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
$secret = getParam('secret', '');
|
||||
$action = getParam('action', '');
|
||||
|
||||
if ($secret !== SECRET_KEY) {
|
||||
echo "ERROR: Invalid secret key\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$logFile = __DIR__ . '/upload_log.txt';
|
||||
file_put_contents($logFile, date('Y-m-d H:i:s') . " | Action: $action | IP: " . $_SERVER['REMOTE_ADDR'] . "\n", FILE_APPEND);
|
||||
|
||||
if ($action === 'status') {
|
||||
echo "=== Server Status ===\n";
|
||||
echo "PHP Version: " . phpversion() . "\n";
|
||||
echo "SQLite Version: " . SQLite3::version()['versionString'] . "\n";
|
||||
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "DB Path: " . (defined('DB_PATH') ? DB_PATH : 'undefined') . "\n";
|
||||
|
||||
if (defined('DB_PATH') && file_exists(DB_PATH)) {
|
||||
$db = new SQLite3(DB_PATH);
|
||||
$db->enableExceptions(false);
|
||||
|
||||
echo "\n=== Table Row Counts ===\n";
|
||||
$tables = ['users', 'player_ip_changes', 'player_ip_locations', 'web_session_log', 'online_players', 'player_daily_logins', 'player_checkins'];
|
||||
foreach ($tables as $t) {
|
||||
try {
|
||||
$cnt = $db->querySingle("SELECT COUNT(*) FROM $t");
|
||||
echo "$t: $cnt rows\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "$t: ERROR - " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 player_ip_locations 中的格式
|
||||
echo "\n=== IP Location Samples (first 10) ===\n";
|
||||
try {
|
||||
$r = $db->query("SELECT ip_address, location, player_name FROM player_ip_locations LIMIT 10");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo $row['ip_address'] . " => " . $row['location'] . " (player: " . $row['player_name'] . ")\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 检查 player_ip_changes 中的玩家IP
|
||||
echo "\n=== Player IP Changes Samples (first 10) ===\n";
|
||||
try {
|
||||
$r = $db->query("SELECT player_name, new_ip, changed_at FROM player_ip_changes ORDER BY changed_at DESC LIMIT 10");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo $row['player_name'] . " => " . $row['new_ip'] . " (at: " . date('Y-m-d H:i:s', $row['changed_at']) . ")\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 检查 web_session_log 中的玩家IP
|
||||
echo "\n=== Web Session Log Samples (first 10) ===\n";
|
||||
try {
|
||||
$r = $db->query("SELECT player_name, ip_address, login_time FROM web_session_log ORDER BY login_time DESC LIMIT 10");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo $row['player_name'] . " => " . $row['ip_address'] . " (at: " . date('Y-m-d H:i:s', $row['login_time']) . ")\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 检查 region 搜索模拟
|
||||
echo "\n=== Region Search Simulation for '长沙' ===\n";
|
||||
echo "Step 1: player_ip_locations with '长沙':\n";
|
||||
try {
|
||||
$r = $db->query("SELECT ip_address, location FROM player_ip_locations WHERE location LIKE '%长沙%'");
|
||||
$cnt = 0;
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " " . $row['ip_address'] . " => " . $row['location'] . "\n";
|
||||
$cnt++;
|
||||
}
|
||||
echo " Found: $cnt IPs\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 查找有长沙IP的玩家
|
||||
echo "\nStep 2: Players with 长沙 IPs:\n";
|
||||
try {
|
||||
$r = $db->query("
|
||||
SELECT DISTINCT c.player_name
|
||||
FROM player_ip_changes c
|
||||
INNER JOIN player_ip_locations l ON c.new_ip = l.ip_address
|
||||
WHERE l.location LIKE '%长沙%'
|
||||
UNION
|
||||
SELECT DISTINCT w.player_name
|
||||
FROM web_session_log w
|
||||
INNER JOIN player_ip_locations l ON w.ip_address = l.ip_address
|
||||
WHERE l.location LIKE '%长沙%'
|
||||
");
|
||||
$cnt = 0;
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " " . $row['player_name'] . "\n";
|
||||
$cnt++;
|
||||
}
|
||||
echo " Found: $cnt players\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
$db->close();
|
||||
} else {
|
||||
echo "Database not found!\n";
|
||||
}
|
||||
|
||||
echo "\n=== admin.php Version ===\n";
|
||||
$adminFile = __DIR__ . '/api/admin.php';
|
||||
if (file_exists($adminFile)) {
|
||||
$content = file_get_contents($adminFile);
|
||||
echo "File size: " . filesize($adminFile) . " bytes\n";
|
||||
echo "Has _region_debug: " . (strpos($content, '_region_debug') !== false ? 'YES' : 'NO') . "\n";
|
||||
echo "Has regionDebugEnabled: " . (strpos($content, 'regionDebugEnabled') !== false ? 'YES' : 'NO') . "\n";
|
||||
echo "Has batchGetPlayerIps: " . (strpos($content, 'function batchGetPlayerIps') !== false ? 'YES' : 'NO') . "\n";
|
||||
} else {
|
||||
echo "admin.php not found!\n";
|
||||
}
|
||||
|
||||
// ★ 模拟 admin 的精确 region search 逻辑
|
||||
echo "\n=== Simulate Admin Region Search Logic ===\n";
|
||||
$db = new SQLite3(DB_PATH);
|
||||
$db->enableExceptions(false);
|
||||
|
||||
$regionKey = '长沙';
|
||||
|
||||
// Step 1: Get all player names
|
||||
$allPlayerNames = [];
|
||||
$r = $db->query("SELECT player_name FROM users");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
$allPlayerNames[] = $row['player_name'];
|
||||
}
|
||||
echo "All players: " . count($allPlayerNames) . "\n";
|
||||
|
||||
// Step 2: Build playerIpMap from player_ip_changes
|
||||
$playerIpMap = [];
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip FROM player_ip_changes WHERE new_ip != '' AND new_ip != '-' AND new_ip NOT LIKE '10.%' AND new_ip NOT LIKE '192.168.%' AND new_ip != '127.0.0.1'");
|
||||
$r = $stmt->execute();
|
||||
$changesCount = 0;
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
$changesCount++;
|
||||
$pn = $row['player_name'];
|
||||
$ip = trim($row['new_ip']);
|
||||
if (!isset($playerIpMap[$pn])) $playerIpMap[$pn] = [];
|
||||
if (!in_array($ip, $playerIpMap[$pn])) $playerIpMap[$pn][] = $ip;
|
||||
}
|
||||
echo "player_ip_changes rows: $changesCount\n";
|
||||
echo "Players with IPs from changes: " . count($playerIpMap) . "\n";
|
||||
|
||||
// web_session_log is empty, skip
|
||||
echo "web_session_log rows: 0 (skipped)\n";
|
||||
|
||||
// Step 3: Get IP locations
|
||||
$ipLocMap = [];
|
||||
$r = $db->query("SELECT ip_address, location FROM player_ip_locations");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
$loc = trim($row['location'] ?? '');
|
||||
$ip = trim($row['ip_address'] ?? '');
|
||||
if ($loc && $ip) {
|
||||
$ipLocMap[$ip] = $loc;
|
||||
}
|
||||
}
|
||||
echo "IP locations cached: " . count($ipLocMap) . "\n";
|
||||
|
||||
// Show some sample IP from playerIpMap vs ipLocMap
|
||||
echo "\n--- IP Comparison ---\n";
|
||||
$samplePlayers = array_slice(array_keys($playerIpMap), 0, 5);
|
||||
foreach ($samplePlayers as $pn) {
|
||||
$ips = $playerIpMap[$pn];
|
||||
echo "Player: $pn => IPs: " . implode(', ', $ips) . "\n";
|
||||
foreach ($ips as $ip) {
|
||||
if (isset($ipLocMap[$ip])) {
|
||||
echo " FOUND in cache: $ip => " . $ipLocMap[$ip] . "\n";
|
||||
} else {
|
||||
echo " NOT in cache: $ip\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: PHP matching
|
||||
$matchedPlayers = [];
|
||||
$matchDebug = [];
|
||||
foreach ($playerIpMap as $pn => $ips) {
|
||||
foreach ($ips as $ip) {
|
||||
if (isset($ipLocMap[$ip])) {
|
||||
$loc = $ipLocMap[$ip];
|
||||
if (mb_stripos($loc, $regionKey) !== false) {
|
||||
if (!in_array($pn, $matchedPlayers)) $matchedPlayers[] = $pn;
|
||||
$matchDebug[] = "$ip=>$loc ($pn)";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n=== PHP Match Result ===\n";
|
||||
echo "Matched players: " . count($matchedPlayers) . "\n";
|
||||
foreach ($matchedPlayers as $pn) {
|
||||
echo " $pn\n";
|
||||
}
|
||||
|
||||
// Check if any 长沙 IPs are NOT in the playerIpMap
|
||||
echo "\n--- All 长沙 IPs in cache ---\n";
|
||||
foreach ($ipLocMap as $ip => $loc) {
|
||||
if (mb_stripos($loc, $regionKey) !== false) {
|
||||
echo " $ip => $loc\n";
|
||||
// Check which player has this IP
|
||||
$foundPlayer = false;
|
||||
foreach ($playerIpMap as $pn => $ips) {
|
||||
if (in_array($ip, $ips)) {
|
||||
echo " Has player: $pn\n";
|
||||
$foundPlayer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$foundPlayer) {
|
||||
echo " NO player found with this IP!\n";
|
||||
// Try partial match
|
||||
foreach ($playerIpMap as $pn => $ips) {
|
||||
foreach ($ips as $pip) {
|
||||
if (strpos($ip, $pip) !== false || strpos($pip, $ip) !== false) {
|
||||
echo " Partial match: $pip (player: $pn)\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->close();
|
||||
|
||||
} elseif ($action === 'upload') {
|
||||
// 通过 POST 上传文件
|
||||
$target = $_POST['target'] ?? '';
|
||||
$allowed = ['api/admin.php', 'api/sync.php', 'core.php', 'config.php'];
|
||||
|
||||
if (!in_array($target, $allowed)) {
|
||||
echo "ERROR: Target not allowed. Use: " . implode(', ', $allowed) . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$targetPath = __DIR__ . '/' . $target;
|
||||
|
||||
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$content = file_get_contents($_FILES['file']['tmp_name']);
|
||||
} elseif (isset($_POST['content'])) {
|
||||
$content = base64_decode($_POST['content']);
|
||||
} else {
|
||||
$content = file_get_contents('php://input');
|
||||
}
|
||||
|
||||
if (empty($content)) {
|
||||
echo "ERROR: No content\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (file_exists($targetPath)) {
|
||||
copy($targetPath, $targetPath . '.bak');
|
||||
}
|
||||
|
||||
$bytes = file_put_contents($targetPath, $content);
|
||||
echo "Wrote $bytes bytes to $target\n";
|
||||
echo "MD5: " . md5_file($targetPath) . "\n";
|
||||
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
@opcache_invalidate($targetPath, true);
|
||||
}
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
}
|
||||
echo "OPCache cleared\n";
|
||||
}
|
||||
|
||||
echo "\nDone.\n";
|
||||
@ob_end_flush();
|
||||
?>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
// 版本标记文件 - 用于验证上传是否成功
|
||||
header('Content-Type: text/plain');
|
||||
echo "admin.php version: 2026-06-19-1527\n";
|
||||
echo "File size: " . filesize(__DIR__ . '/api/admin.php') . " bytes\n";
|
||||
echo "MD5: " . md5_file(__DIR__ . '/api/admin.php') . "\n";
|
||||
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||
?>
|
||||
Reference in New Issue
Block a user