添加Web前端代码

This commit is contained in:
ypsdf1
2026-06-19 16:02:14 +08:00
parent 9bae160ce0
commit 885e70f0f5
20 changed files with 3239 additions and 585 deletions
+38 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
// ===== 发送邮箱验证码 =====
+101 -28
View File
@@ -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) {
+144
View File
@@ -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";
+210
View File
@@ -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>
+142
View File
@@ -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>";
}
?>
+167
View File
@@ -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";
?>
+152 -11
View File
@@ -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
View File
@@ -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 = `
+98
View File
@@ -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
View File
@@ -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);
+32
View File
@@ -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";
?>
+30
View File
@@ -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"
+294
View File
@@ -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();
?>
+8
View File
@@ -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";
?>