From 885e70f0f52ec396ef15818d1a73e6a2e9fc3b31 Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Fri, 19 Jun 2026 16:02:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0Web=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin.php | 54 ++- api/admin.php | 688 +++++++++++++++++++++++------ api/balance.php | 47 +- api/cdk.php | 43 +- api/register.php | 72 ++- api/shop.php | 73 ++-- api/sync.php | 947 ++++++++++++++++++++++++++++------------ core.php | 143 ++++-- debug_admin_api.php | 144 ++++++ debug_online.php | 210 +++++++++ debug_region.php | 142 ++++++ debug_region_search.php | 167 +++++++ login.php | 177 +++++++- player.php | 353 +++++++++++++-- self_update.php | 98 +++++ test_sync.php | 102 +++++ upload_debug.php | 32 ++ upload_debug.ps1 | 30 ++ upload_via_http.php | 294 +++++++++++++ version_check.php | 8 + 20 files changed, 3239 insertions(+), 585 deletions(-) create mode 100644 debug_admin_api.php create mode 100644 debug_online.php create mode 100644 debug_region.php create mode 100644 debug_region_search.php create mode 100644 self_update.php create mode 100644 test_sync.php create mode 100644 upload_debug.php create mode 100644 upload_debug.ps1 create mode 100644 upload_via_http.php create mode 100644 version_check.php diff --git a/admin.php b/admin.php index 8f9331a..797e94d 100644 --- a/admin.php +++ b/admin.php @@ -563,10 +563,11 @@ function renderAllUsers(tab, users, onlineSet, onlineMap) { const search = (window.currentSearch || '').toLowerCase(); div.innerHTML = ` -
- +
+
+
支持:纯文本→玩家名 | IP格式→IP搜索 | 日期格式→日期搜索 | 省/市名→地区搜索
加载中...
@@ -1127,17 +1128,26 @@ async function loadActivePlayers(el) { const r = await jsonApi('admin.php?action=list_active_players'); if (!r.success) { el.innerHTML='
'+r.message+'
'; return; } const players = r.data || []; + const totalBeforeDedup = r.total_before_dedup || players.length; + const dedupCount = totalBeforeDedup - players.length; + el.innerHTML = `
-

24小时活跃用户 (${players.length}人)

+

24小时活跃用户 (${players.length}人) + ${dedupCount > 0 ? `(已隐藏${dedupCount}个同子网用户)` : ''} +

- + ${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 ` + + `; }).join('')}
玩家名最后活跃总在线时长
玩家名最后活跃总在线时长IPIP属地
${p.player_name} ${loginTime} ${p.hours_online}小时${ip}${ipLoc}
@@ -1159,25 +1169,39 @@ function loadActiveTab(tab, seconds) { r.then(res => { if (!res.success) { div.innerHTML='
'+res.message+'
'; return; } const players = res.data || []; - - // 统计同IP玩家 - const ipCountMap = {}; + const totalBeforeDedup = res.total_before_dedup || players.length; + const dedupCount = totalBeforeDedup - players.length; + + // 统计同子网玩家 + 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 = ` -

${label}内活跃用户 (${players.length}人)

+

+ ${label}内活跃用户 (${players.length}人) + ${dedupCount > 0 ? `(已隐藏${dedupCount}个同子网用户)` : ''} +

${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 ? ` 同IP${ipCountMap[ip]}人` : ''; + const ipBadge = isShared ? ` 同网段${subnetCountMap[subnet]}人` : ''; return ` @@ -1209,15 +1233,15 @@ async function loadSameIpTab(tab) { } const groups = r.data; - let html = `

同IP多玩家 (${groups.length}个IP组)

`; + let html = `

同网段玩家 (${groups.length}个网段组)

`; groups.forEach(g => { html += `
`; - html += `

🌐 ${g.ip} (${g.player_count}人 | ${g.ip_location || '-'})

`; + html += `

🌐 ${g.subnet || g.ip} (${g.player_count}人 | ${g.ip_location || '-'})

`; html += `
玩家名最后活跃总在线时长IP地址IP属地
${p.player_name} ${loginTime}
`; - html += ``; + html += ``; g.players.forEach(p => { const loginTime = p.login_time ? new Date(p.login_time*1000).toLocaleString() : '-'; - html += ``; + html += ``; }); html += `
玩家名IP属地最后登录时间
玩家名IP地址IP属地最后登录时间
${p.player_name}${p.ip_location || '-'}${loginTime}
${p.player_name}${p.ip_address || '-'}${p.ip_location || '-'}${loginTime}
`; }); diff --git a/api/admin.php b/api/admin.php index 8d76277..9a6d6f7 100644 --- a/api/admin.php +++ b/api/admin.php @@ -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 = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row; + + // 查询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"); - $stmt->bindValue(':p', $player); - $result = $stmt->execute(); $txs = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row; + + // 查询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(); + 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; + } + } + $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 (Exception $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); - $stmt->bindValue(':search', $searchBound, SQLITE3_TEXT); + 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,55 +1930,28 @@ function adminListOnlinePlayers() { function batchGetPlayerIps($db, $playerNames) { $ipMap = []; - // 1. 从 player_ip_changes 批量获取(★ 优先取有效IP,避免取到"-") - if (!empty($playerNames)) { - $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 表可能不存在,降级为简单查询 - 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; - 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 $e2) {} + if (empty($playerNames)) return $ipMap; + $placeholders = implode(',', array_fill(0, count($playerNames), '?')); + + // 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; + 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 (\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); @@ -1543,24 +1959,11 @@ function batchGetPlayerIps($db, $playerNames) { $missing[] = $pn; } } - + 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 - "); + $stmt2 = $db->prepare("SELECT player_name, ip_address FROM web_session_log WHERE player_name IN ($placeholders2) ORDER BY login_time DESC"); $idx2 = 1; foreach ($missing as $mn) { $stmt2->bindValue($idx2++, $mn, SQLITE3_TEXT); @@ -1572,25 +1975,11 @@ function batchGetPlayerIps($db, $playerNames) { $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; - 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 $e2) {} + } catch (\Throwable $e2) { + @error_log("[batchGetPlayerIps] web_session_log error: " . $e2->getMessage()); } } - + return $ipMap; } @@ -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(); - if ($r) { $row = $r->fetchArray(SQLITE3_ASSOC); $onlineCount = (int)($row['cnt'] ?? 0); } + // ★ 先检查心跳:如果超过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()); } diff --git a/api/balance.php b/api/balance.php index 2b1a0fc..b08b228 100644 --- a/api/balance.php +++ b/api/balance.php @@ -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"); - $stmt->bindValue(':player', $player, SQLITE3_TEXT); - $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); + // 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"); - $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); + // 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); } diff --git a/api/cdk.php b/api/cdk.php index 6f752dd..e0b8287 100644 --- a/api/cdk.php +++ b/api/cdk.php @@ -148,14 +148,29 @@ function cdkExchange($token) { $stmt->execute(); } - // 记录流水 - $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); - $stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER); - $stmt->bindValue(':reason', "CDK兑换: {$code}", SQLITE3_TEXT); - $stmt->bindValue(':detail', json_encode(['code' => $code], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT); - $stmt->bindValue(':time', $now, SQLITE3_INTEGER); - $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); + $stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER); + $stmt->bindValue(':reason', "CDK兑换: {$code}", SQLITE3_TEXT); + $stmt->bindValue(':detail', json_encode(['code' => $code], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT); + $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, @@ -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 ===== diff --git a/api/register.php b/api/register.php index d076fd3..251df40 100644 --- a/api/register.php +++ b/api/register.php @@ -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, '预览模式 - 您只能查看自己的账号信息'); } diff --git a/api/shop.php b/api/shop.php index 4641ce4..89669d7 100644 --- a/api/shop.php +++ b/api/shop.php @@ -182,33 +182,45 @@ function shopBuy($token) { ], '预览模式 - 购买不会生效'); } - // 实际购买 - // 更新库存 - 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"); - $stmt->bindValue(':amount', $amount, SQLITE3_INTEGER); - $stmt->bindValue(':id', $itemId, SQLITE3_TEXT); - $stmt->execute(); - } else { - // 无限库存,只更新销量 - $stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id"); - $stmt->bindValue(':amount', $amount, SQLITE3_INTEGER); - $stmt->bindValue(':id', $itemId, SQLITE3_TEXT); + // ★ 实际购买 — 包装在事务中,防止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"); + $stmt->bindValue(':amount', $amount, SQLITE3_INTEGER); + $stmt->bindValue(':id', $itemId, SQLITE3_TEXT); + $stmt->execute(); + } else { + // 无限库存,只更新销量 + $stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id"); + $stmt->bindValue(':amount', $amount, SQLITE3_INTEGER); + $stmt->bindValue(':id', $itemId, SQLITE3_TEXT); + $stmt->execute(); + } + + // 记录交易 + $stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)"); + $stmt->bindValue(':player', $player, SQLITE3_TEXT); + $stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER); + $stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT); + $stmt->bindValue(':detail', json_encode(['item_id' => $itemId, 'amount' => $amount, 'unit_price' => $item['buy_price']], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT); + $stmt->bindValue(':time', time(), SQLITE3_INTEGER); $stmt->execute(); + + // ★ 写入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); } - // 记录交易 - $stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)"); - $stmt->bindValue(':player', $player, SQLITE3_TEXT); - $stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER); - $stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT); - $stmt->bindValue(':detail', json_encode(['item_id' => $itemId, 'amount' => $amount, 'unit_price' => $item['buy_price']], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT); - $stmt->bindValue(':time', time(), SQLITE3_INTEGER); - $stmt->execute(); - - // ★ 记录交易创建 - $txId = $db->lastInsertRowID(); - success([ 'item_id' => $itemId, 'item_name' => $item['display_name'], @@ -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验证(用于商城购买)===== diff --git a/api/sync.php b/api/sync.php index 86e1535..5e99db1 100644 --- a/api/sync.php +++ b/api/sync.php @@ -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'); - - $tokenInfo = validateToken($token); - if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { - error('同步需要管理权限token'); + $secret = getParam('secret'); + if ($token) { + $tokenInfo = validateToken($token); + if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { + 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'); - - $tokenInfo = validateToken($token); - if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { - error('同步需要管理权限token'); + $secret = getParam('secret'); + if ($token) { + $tokenInfo = validateToken($token); + if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { + 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 syncTransactions() { + try { + // ★ 支持token或SECRET_KEY认证 + $token = getParam('token'); + $secret = getParam('secret'); + if ($token) { + $tokenInfo = validateToken($token); + if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { + 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'); - if (!$token) error('同步需要token'); + $secret = getParam('secret'); - $tokenInfo = validateToken($token); - if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { - error('同步需要管理权限token'); + 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,131 +505,94 @@ function syncLogin() { $now = time(); $count = 0; - 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); - } - - $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); - $stmt->bindValue(':rt', $registerTime, SQLITE3_INTEGER); - $stmt->bindValue(':lt', $lastLoginTime, SQLITE3_INTEGER); - $stmt->bindValue(':email', $user['email'] ?? '', SQLITE3_TEXT); - $stmt->bindValue(':pts', (int)($user['points'] ?? 0), SQLITE3_INTEGER); - $stmt->bindValue(':gs', (int)($user['gift_stage'] ?? 0), SQLITE3_INTEGER); - $stmt->bindValue(':tot', (int)($user['total_online_time'] ?? 0), SQLITE3_INTEGER); - $stmt->execute(); - $count++; - } - - // ★ 收集推送的用户名列表,用于后续孤儿清理 - $namesInSync = array_map(function($u) { return $u['player_name'] ?? null; }, $users); - $namesInSync = array_filter($namesInSync, function($n) { return $n !== null; }); - - // ★ 清理 users 表中已不存在的用户(游戏内删号后同步) + // ★ 所有写操作(INSERT + 清理)包装在一个事务中 + $db->exec("BEGIN IMMEDIATE"); try { - $stmt = $db->prepare("SELECT player_name FROM users"); - $result = $stmt->execute(); - $phpUsers = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $phpUsers[] = $row['player_name']; - } - - $orphanedUsers = array_diff($phpUsers, $namesInSync); - if (!empty($orphanedUsers)) { - $nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphanedUsers)); - $db->exec("DELETE FROM users WHERE player_name IN ('" . $nameList . "')"); - debugLog("sync_login: 清理了 " . count($orphanedUsers) . " 个孤立的 users 记录", ['orphaned' => $orphanedUsers]); - } - } catch (Exception $e) { - debugLog("sync_login: 清理孤立 users 记录失败", ['error' => $e->getMessage()]); - } + foreach ($users as $user) { + $name = $user['player_name'] ?? null; + if (!$name) continue; - // ★ 清理 weblogin_credentials 中已不存在的用户(游戏内删号后同步) - try { - $stmt = $db->prepare("SELECT player_name FROM weblogin_credentials"); - $result = $stmt->execute(); - $webCredentials = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $webCredentials[] = $row['player_name']; + $registerTime = (int)($user['register_time'] ?? 0); + $lastLoginTime = (int)($user['last_login_time'] ?? 0); + 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); + $stmt->bindValue(':rt', $registerTime, SQLITE3_INTEGER); + $stmt->bindValue(':lt', $lastLoginTime, SQLITE3_INTEGER); + $stmt->bindValue(':email', $user['email'] ?? '', SQLITE3_TEXT); + $stmt->bindValue(':pts', (int)($user['points'] ?? 0), SQLITE3_INTEGER); + $stmt->bindValue(':gs', (int)($user['gift_stage'] ?? 0), SQLITE3_INTEGER); + $stmt->bindValue(':tot', (int)($user['total_online_time'] ?? 0), SQLITE3_INTEGER); + $stmt->execute(); + $count++; } - + $namesInSync = array_map(function($u) { return $u['player_name'] ?? null; }, $users); - $orphaned = array_diff($webCredentials, $namesInSync); - - if (!empty($orphaned)) { - $nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphaned)); - $db->exec("DELETE FROM weblogin_credentials WHERE player_name IN ('" . $nameList . "')"); - debugLog("sync_login: 清理了 " . count($orphaned) . " 个孤立的 weblogin_credentials 记录", ['orphaned' => $orphaned]); - } - } catch (Exception $e) { - debugLog("sync_login: 清理孤立 weblogin_credentials 记录失败", ['error' => $e->getMessage()]); - } + $namesInSync = array_filter($namesInSync, function($n) { return $n !== null; }); - // ★ 清理 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']; + // 清理 users 表中已不存在的用户 + try { + $stmt = $db->prepare("SELECT player_name FROM users"); + $result = $stmt->execute(); + $phpUsers = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $phpUsers[] = $row['player_name']; + } + + $orphanedUsers = array_diff($phpUsers, $namesInSync); + if (!empty($orphanedUsers)) { + $nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphanedUsers)); + $db->exec("DELETE FROM users WHERE player_name IN ('" . $nameList . "')"); + debugLog("sync_login: 清理了 " . count($orphanedUsers) . " 个孤立的 users 记录", ['orphaned' => $orphanedUsers]); + } + } catch (Exception $e) { + debugLog("sync_login: 清理孤立 users 记录失败", ['error' => $e->getMessage()]); } - - $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 中已不存在的用户 - 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']; + // 清理 weblogin_credentials 中已不存在的用户(游戏内删号后同步) + try { + $stmt = $db->prepare("SELECT player_name FROM weblogin_credentials"); + $result = $stmt->execute(); + $webCredentials = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $webCredentials[] = $row['player_name']; + } + + $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)); + $db->exec("DELETE FROM weblogin_credentials WHERE player_name IN ('" . $nameList . "')"); + debugLog("sync_login: 清理了 " . count($orphaned) . " 个孤立的 weblogin_credentials 记录", ['orphaned' => $orphaned]); + } + } catch (Exception $e) { + debugLog("sync_login: 清理孤立 weblogin_credentials 记录失败", ['error' => $e->getMessage()]); } - - $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 中已不存在的用户 - 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']; - } - - $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]); - } + // 清理 weblogin_tokens:只清理已过期的 + try { + $expireTime = time() - 600; + $db->exec("DELETE FROM weblogin_tokens WHERE created_at < " . $expireTime); + } catch (Exception $e) {} + + // 清理 web_login_verified:只清理已过期的 + try { + $expireTime = time() - 600; + $db->exec("DELETE FROM web_login_verified WHERE verified_at < " . $expireTime); + } catch (Exception $e) {} + + // 清理 web_session_log:只清理过期的 + try { + $expireTime = time() - 600; + $db->exec("DELETE FROM web_session_log WHERE login_time < " . $expireTime); + } catch (Exception $e) {} + + $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'); - - $tokenInfo = validateToken($token); - if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { - error('删除需要管理权限token'); + $secret = getParam('secret'); + if ($token) { + $tokenInfo = validateToken($token); + if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { + 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,24 +810,42 @@ function syncOnlinePlayers() { @error_log("[syncOnlinePlayers] Received $playerCount players: " . implode(', ', $playerNames)); @error_log("[syncOnlinePlayers] Login times: " . implode(', ', $playerTimes) . " | Server time: $now"); - // ★ 删除所有旧记录,然后重新插入(避免残留旧数据) - $db->exec("DELETE FROM online_players"); + // ★ 使用事务批量写入,防止 "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; - $name = $player['name']; - $loginTime = isset($player['login_time']) ? (int)$player['login_time'] : $now; - try { - $stmt = $db->prepare("INSERT OR REPLACE INTO online_players (player_name, login_time) VALUES (:name, :time)"); - $stmt->bindValue(':name', $name, SQLITE3_TEXT); - $stmt->bindValue(':time', $loginTime, SQLITE3_INTEGER); - $stmt->execute(); - $synced++; - } catch (Exception $e) { - @error_log("[syncOnlinePlayers] FAIL insert $name: " . $e->getMessage()); + // 逐条插入 + $synced = 0; + foreach ($players as $player) { + if (empty($player['name'])) continue; + $name = $player['name']; + $loginTime = isset($player['login_time']) ? (int)$player['login_time'] : $now; + try { + $stmt = $db->prepare("INSERT OR REPLACE INTO online_players (player_name, login_time) VALUES (:name, :time)"); + $stmt->bindValue(':name', $name, SQLITE3_TEXT); + $stmt->bindValue(':time', $loginTime, SQLITE3_INTEGER); + $stmt->execute(); + $synced++; + } catch (Exception $e) { + @error_log("[syncOnlinePlayers] FAIL insert $name: " . $e->getMessage()); + } } + + // 更新心跳时间戳(使用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()); } // ★ 验证:查询刚插入的数据 @@ -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,64 +993,55 @@ function syncPlayerIps() { $changedCount = 0; $skippedCount = 0; - foreach ($players as $player) { - if (empty($player['name']) || empty($player['ip'])) { - continue; - } + // ★ 事务包装防止 "database is locked" + $db->exec("BEGIN IMMEDIATE"); + try { + foreach ($players as $player) { + if (empty($player['name']) || empty($player['ip'])) { + continue; + } - $playerName = $player['name']; - $newIp = $player['ip']; + $playerName = $player['name']; + $newIp = $player['ip']; - // 检查该玩家是否有历史IP记录 - $stmt = $db->prepare("SELECT old_ip, synced_at FROM player_ip_changes WHERE player_name = :player"); - $stmt->bindValue(':player', $playerName, SQLITE3_TEXT); - $result = $stmt->execute(); - $row = $result->fetchArray(SQLITE3_ASSOC); - - $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)"); + // 检查该玩家是否有历史IP记录 + $stmt = $db->prepare("SELECT old_ip, synced_at FROM player_ip_changes WHERE player_name = :player"); $stmt->bindValue(':player', $playerName, SQLITE3_TEXT); - $stmt->bindValue(':old', $row ? $row['old_ip'] : '', SQLITE3_TEXT); - $stmt->bindValue(':new', $newIp, SQLITE3_TEXT); - $stmt->bindValue(':time', $now, SQLITE3_INTEGER); - $stmt->bindValue(':synced', $now, SQLITE3_INTEGER); - $stmt->execute(); + $result = $stmt->execute(); + $row = $result->fetchArray(SQLITE3_ASSOC); - // 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(); + $hasChanged = false; + if (!$row) { + $hasChanged = true; + } elseif ($row['old_ip'] !== $newIp) { + $hasChanged = true; } - // 更新在线玩家IP(不实时更新online_players,因为IP归属由PHP端查询) - $changedCount++; + if ($hasChanged) { + $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); + $stmt->bindValue(':new', $newIp, SQLITE3_TEXT); + $stmt->bindValue(':time', $now, SQLITE3_INTEGER); + $stmt->bindValue(':synced', $now, SQLITE3_INTEGER); + $stmt->execute(); - // 如果之前已同步过(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(); + $changedCount++; + + if ($row && $row['synced_at'] > 0) { + $stmtReset = $db->prepare("UPDATE player_ip_changes SET synced_at = 0 WHERE player_name = :player"); + $stmtReset->bindValue(':player', $playerName, SQLITE3_TEXT); + $stmtReset->execute(); + } + } else { + $skippedCount++; } - } else { - $skippedCount++; } + + $db->exec("COMMIT"); + } catch (Exception $e) { + try { $db->exec("ROLLBACK"); } catch (Exception $e2) {} + @error_log("[syncPlayerIps] Transaction failed: " . $e->getMessage()); } success([ @@ -822,25 +1107,34 @@ function receiveToken() { } $db = getDB(); + $db->exec('PRAGMA busy_timeout=10000'); $now = time(); $count = 0; - foreach ($tokens as $t) { - $tokenVal = $t['token'] ?? null; - $player = $t['player'] ?? 'system'; - $purpose = $t['purpose'] ?? 'general'; - $expireSeconds = (int)($t['expire_seconds'] ?: 600); + try { + $db->exec('BEGIN IMMEDIATE'); + foreach ($tokens as $t) { + $tokenVal = $t['token'] ?? null; + $player = $t['player'] ?? 'system'; + $purpose = $t['purpose'] ?? 'general'; + $expireSeconds = (int)($t['expire_seconds'] ?: 600); - if (!$tokenVal) continue; + if (!$tokenVal) continue; - $stmt = $db->prepare("INSERT OR IGNORE INTO tokens (token, player_name, purpose, created_at, expires_at, used) VALUES (:token, :player, :purpose, :created, :expires, 0)"); - $stmt->bindValue(':token', $tokenVal, SQLITE3_TEXT); - $stmt->bindValue(':player', $player, SQLITE3_TEXT); - $stmt->bindValue(':purpose', $purpose, SQLITE3_TEXT); - $stmt->bindValue(':created', $now, SQLITE3_INTEGER); - $stmt->bindValue(':expires', $now + $expireSeconds, SQLITE3_INTEGER); - $stmt->execute(); - $count++; + $stmt = $db->prepare("INSERT OR IGNORE INTO tokens (token, player_name, purpose, created_at, expires_at, used) VALUES (:token, :player, :purpose, :created, :expires, 0)"); + $stmt->bindValue(':token', $tokenVal, SQLITE3_TEXT); + $stmt->bindValue(':player', $player, SQLITE3_TEXT); + $stmt->bindValue(':purpose', $purpose, SQLITE3_TEXT); + $stmt->bindValue(':created', $now, SQLITE3_INTEGER); + $stmt->bindValue(':expires', $now + $expireSeconds, SQLITE3_INTEGER); + $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"); @@ -848,12 +1142,20 @@ function receiveToken() { // ===== 插件同步Token到PHP数据库(通过已有sync token) ===== function syncToken() { + // ★ 支持token或SECRET_KEY认证 $token = getParam('token'); - if (!$token) error('同步需要token'); - - $tokenInfo = validateToken($token); - if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { - error('同步需要管理权限token'); + $secret = getParam('secret'); + if ($token) { + $tokenInfo = validateToken($token); + if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) { + 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) - 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(); + // 获取所有pending的交易 + 标记为processing(原子操作,避免database is locked) $transactions = []; $ids = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $transactions[] = $row; - $ids[] = (int)$row['id']; - } + 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)); + } + + $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(); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $transactions[] = $row; + $ids[] = (int)$row['id']; + } // 立即标记为processing(防止5秒后重复拉取) - if ($hasProcessedAt && !empty($ids)) { - $idList = implode(',', $ids); - try { + if (!empty($ids)) { + $idList = implode(',', $ids); $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,14 +2011,21 @@ function confirmTransaction() { if ($row['name'] === 'processed_at') { $hasProcessedAt = true; break; } } - 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); - } else { - $stmt = $db->prepare("UPDATE web_transactions SET status = 'processed' WHERE id = :id AND status IN ('pending', 'processing')"); + 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); + } else { + $stmt = $db->prepare("UPDATE web_transactions SET status = 'processed' WHERE id = :id AND status IN ('pending', 'processing')"); + } + $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()); } - $stmt->bindValue(':id', (int)$txId, SQLITE3_INTEGER); - $stmt->execute(); 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,33 +2092,46 @@ 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)"); - // 恢复超时processing交易 - $db->exec("UPDATE web_transactions SET status = 'pending' WHERE status = 'processing' AND processed_at < " . (time() - 120)); + // ★ 事务包装防止 "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)); - $stmt = $db->prepare("SELECT id, player_name, type, amount, reason, detail FROM web_transactions WHERE status IN ('pending', 'processing') ORDER BY created_at ASC"); - $result = $stmt->execute(); - $transactions = []; - $ids = []; - while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $transactions[] = $row; - $ids[] = (int)$row['id']; - } + $stmt = $db->prepare("SELECT id, player_name, type, amount, reason, detail FROM web_transactions WHERE status IN ('pending', 'processing') ORDER BY created_at ASC"); + $result = $stmt->execute(); + $transactions = []; + $ids = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $transactions[] = $row; + $ids[] = (int)$row['id']; + } - // 重新标记为processing - if (!empty($ids)) { - $idList = implode(',', $ids); - $db->exec("UPDATE web_transactions SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)"); + // 重新标记为processing + if (!empty($ids)) { + $idList = implode(',', $ids); + $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; } // ===== 发送邮箱验证码 ===== diff --git a/core.php b/core.php index 0a30b09..ab4baeb 100644 --- a/core.php +++ b/core.php @@ -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,39 +863,54 @@ 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); - + debugLog("validateWebAccess: 状态检查", [ 'player' => $playerName, 'action' => $action, @@ -862,8 +918,8 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd 'registered' => $isRegistered, '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' => '请输入游戏登录密码以同步登录游戏']; } @@ -880,11 +935,29 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd debugLog("validateWebAccess: 验证成功", ['player' => $playerName, 'mode' => 'full']); 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) { - debugLog("validateWebAccess: 需要游戏登录", ['player' => $playerName, 'action' => $action]); - return ['ok' => false, 'mode' => 'need_game_login', 'player' => $playerName, 'registered' => $isRegistered, 'online' => false, 'message' => '请先在游戏中登录']; + // ★ 检查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):需要玩家已注册 diff --git a/debug_admin_api.php b/debug_admin_api.php new file mode 100644 index 0000000..3dfbc7f --- /dev/null +++ b/debug_admin_api.php @@ -0,0 +1,144 @@ + 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"; diff --git a/debug_online.php b/debug_online.php new file mode 100644 index 0000000..1c4a19f --- /dev/null +++ b/debug_online.php @@ -0,0 +1,210 @@ +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(); +} +?> + + + + 调试 - Online Players + + + +

🔍 Online Players 调试页面

+ +

当前时间:

+ +

1. online_players 表 (Java推送的游戏登录状态)

+ +

⚠️ 表为空!没有玩家被标记为游戏中已登录

+ + + + + + + + + + +
玩家名登录时间时间戳
+ +

总数:

+ +

2. 心跳 (online_player_hb)

+ +

上次心跳: ()

+ 120) echo '

⚠️ 心跳超过120秒,下次同步时PHP会清空online_players表!

'; + elseif ($ago > 60) echo '

⚠️ 心跳超过60秒

'; + else echo '

✅ 心跳正常

'; + ?> + +

⚠️ 无心跳记录

+ + +

3. weblogin_tokens (Web登录Token)

+ +

⚠️ 无Token记录

+ + + + + + + + + + +
玩家创建时间有效期(秒)
+ + +

4. web_session_log (Web会话记录)

+ +

⚠️ 无会话记录

+ + + + + + + + + + +
玩家登录时间时间戳
+ + +

5. web_login_verified (Web密码验证)

+ +

⚠️ 无验证记录

+ + + + + + + + + +
玩家验证时间
+ + +

6. users表 (玩家注册状态)

+ +

⚠️ 无注册玩家

+ + + + + + + + + +
玩家名logged_in
+ + +

原始JSON

+
+ + diff --git a/debug_region.php b/debug_region.php new file mode 100644 index 0000000..cc2d23e --- /dev/null +++ b/debug_region.php @@ -0,0 +1,142 @@ +调试地区搜索"; + +// 1. player_ip_locations 表内容 +echo "

player_ip_locations 表

"; +try { + $result = $db->query("SELECT COUNT(*) as cnt FROM player_ip_locations"); + $row = $result->fetchArray(SQLITE3_ASSOC); + echo "总记录数: " . $row['cnt'] . "
"; + + if ($row['cnt'] > 0) { + echo ""; + echo ""; + $result = $db->query("SELECT * FROM player_ip_locations ORDER BY updated_at DESC LIMIT 20"); + while ($r = $result->fetchArray(SQLITE3_ASSOC)) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
ip_addresslocationplayer_nameupdated_at
" . htmlspecialchars($r['ip_address']) . "" . htmlspecialchars($r['location']) . "" . htmlspecialchars($r['player_name']) . "" . date('Y-m-d H:i:s', $r['updated_at']) . "
"; + } else { + echo "表为空!
"; + } +} catch (Exception $e) { + echo "错误: " . $e->getMessage() . "
"; +} + +// 2. player_ip_changes 表内容 +echo "

player_ip_changes 表

"; +try { + $result = $db->query("SELECT COUNT(*) as cnt FROM player_ip_changes"); + $row = $result->fetchArray(SQLITE3_ASSOC); + echo "总记录数: " . $row['cnt'] . "
"; + + if ($row['cnt'] > 0) { + echo ""; + echo ""; + $result = $db->query("SELECT * FROM player_ip_changes ORDER BY changed_at DESC LIMIT 20"); + while ($r = $result->fetchArray(SQLITE3_ASSOC)) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
player_namenew_ipchanged_at
" . htmlspecialchars($r['player_name']) . "" . htmlspecialchars($r['new_ip']) . "" . date('Y-m-d H:i:s', $r['changed_at']) . "
"; + } else { + echo "表为空!
"; + } +} catch (Exception $e) { + echo "错误: " . $e->getMessage() . "
"; +} + +// 3. 地区搜索SQL测试 +echo "

地区搜索SQL测试

"; +$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:
" . htmlspecialchars($sql) . "
"; + +try { + $result = $db->query($sql); + $count = 0; + $players = []; + while ($r = $result->fetchArray(SQLITE3_ASSOC)) { + $count++; + $players[] = $r['player_name']; + } + echo "匹配玩家数: $count
"; + if ($count > 0) { + echo "匹配玩家: " . implode(', ', $players) . "
"; + } +} catch (Exception $e) { + echo "SQL错误: " . $e->getMessage() . "
"; +} + +// 4. 检查所有location中包含"长沙"的 +echo "

包含'长沙'的location记录

"; +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']) . "
"; + } + if (!$found) { + echo "没有找到包含'长沙'的location记录
"; + } +} catch (Exception $e) { + echo "错误: " . $e->getMessage() . "
"; +} + +// 5. 检查users表中是否有来自长沙的玩家(通过IP推断) +echo "

测试:直接搜索users表

"; +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']) . ")
"; + } +} catch (Exception $e) { + echo "错误: " . $e->getMessage() . "
"; +} + +// 6. 尝试手动查询一个IP的归属地 +echo "

测试:手动查询IP归属地

"; +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) . "
"; + require_once 'api/admin.php'; + foreach ($ips as $ip) { + $loc = queryIpLocationWithTimeout($ip, 3); + echo "IP: $ip → " . ($loc ? $loc['location'] : 'null') . "
"; + } + } else { + echo "没有找到可用的外部IP
"; + } +} catch (Exception $e) { + echo "错误: " . $e->getMessage() . "
"; +} +?> diff --git a/debug_region_search.php b/debug_region_search.php new file mode 100644 index 0000000..82a838d --- /dev/null +++ b/debug_region_search.php @@ -0,0 +1,167 @@ + 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"; +?> \ No newline at end of file diff --git a/login.php b/login.php index a7c0e90..97fe1e7 100644 --- a/login.php +++ b/login.php @@ -70,13 +70,35 @@
-
- - -

密码由游戏服务器验证,Web端不存储密码

+ +
+ + +
+ +
+
+ + +

密码由游戏服务器验证,Web端不存储密码

+
+ +
+ + -
@@ -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验证成功!

欢迎, ' + player + ''; - - // 延迟1.5秒后切换到密码输入 + statusBox.innerHTML = '✅ Token验证成功!

欢迎, ' + player + '
正在跳转到玩家商城...'; + + // ★ 同时写入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(() => {}); + 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 = '❌ 登录失败

' + (data.message || 'Token无效或已过期'); @@ -220,9 +245,9 @@ pwdStatusBox.className = 'status-box success'; pwdStatusBox.innerHTML = '✅ 登录成功!

欢迎, ' + player + '
正在跳转...'; - // ★ 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 = ' 正在验证邮箱验证码...'; + + 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 = '✅ 邮箱验证成功!

欢迎, ' + player + '
正在跳转...'; + 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; + } diff --git a/player.php b/player.php index 3e6fb2d..e06f38a 100644 --- a/player.php +++ b/player.php @@ -1,7 +1,35 @@ + + + + SDF1 - 玩家商城