Files
Sdf1_login/api/admin.php
T
ypsdf1 9bae160ce0 Java:新增ip推送端口,后端可以查看玩家ip及归属
php:新增ip属地、实时在线数据,可以查看玩家ip、归属和实时在线情况
2026-06-18 12:35:22 +08:00

1745 lines
74 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 管理API
*/
// ★ 清除所有缓冲 + 发送 JSON Content-Type
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
// 确保不会有任何前置输出污染JSON
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('memory_limit', '256M');
if (function_exists('opcache_reset')) { @opcache_reset(); }
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__, true); }
if (!extension_loaded('sqlite3')) {
exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE));
}
require_once __DIR__ . '/../core.php';
if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) {
exit(json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE));
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$action = getParam('action', 'status');
switch ($action) {
case 'login': adminDoLogin(); break;
case 'logout': adminDoLogout(); break;
case 'status': adminStatus(); break;
case 'deduct': adminDeduct(); break;
case 'add_bonds': adminAddBonds(); break;
case 'shop_update': adminShopUpdate(); break;
case 'shop_add': adminShopAdd(); break;
case 'shop_remove': adminShopRemove(); break;
case 'stats': adminStats(); break;
case 'all_tx': adminAllTx(); break;
case 'player_tx': adminPlayerTx(); break;
case 'gen_token': adminGenToken(); break;
case 'cdk_batch': adminGenCDK(); break;
case 'notify_sync': adminNotifySync(); break;
case 'sync_now': adminSyncNow(); break;
case 'push_shop': adminPushShop(); break;
case 'push_bonds': adminPushBonds(); break;
case 'list_users': adminListUsers(); break;
case 'admin_approve_reset': adminApproveReset(); break;
case 'admin_reject_reset': adminRejectReset(); break;
case 'list_reset_requests': adminListResetRequests(); break;
case 'list_online_names': adminListOnlineNames(); break;
case 'list_online_players': adminListOnlinePlayers(); break;
case 'clean_collect_ips': adminCleanAndCollectIps(); break;
case 'batch_query_ips': adminBatchQueryIps(); break;
case 'list_active_players': adminListActivePlayers(); break;
case 'get_stats_ex': adminGetStatsEx(); break;
case 'list_same_ip': adminListSameIp(); break;
case 'list_users_paginated': adminListUsersPaginated(); break;
default: exit(json_encode(['success' => false, 'message' => 'Unknown action: ' . $action], JSON_UNESCAPED_UNICODE));
}
// ===== 各函数定义 =====
function adminDoLogin() {
$password = getParam('password');
if (!$password) exit(json_encode(['success' => false, 'message' => 'Missing password'], JSON_UNESCAPED_UNICODE));
if (adminLogin($password)) {
exit(json_encode(['success' => true, 'data' => ['login_time' => time()], 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '密码错误', 'code' => 401], JSON_UNESCAPED_UNICODE));
}
function adminDoLogout() {
session_destroy();
exit(json_encode(['success' => true, 'message' => 'logout'], JSON_UNESCAPED_UNICODE));
}
function adminStatus() {
exit(json_encode([
'success' => true,
'data' => ['logged_in' => isAdminLoggedIn(), 'login_time' => $_SESSION['admin_login_time'] ?? 0],
'message' => 'ok'
], JSON_UNESCAPED_UNICODE));
}
function adminDeduct() {
requireAdminSession();
$player = getParam('player');
$amount = (int)getParam('amount', 0);
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();
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (player_name TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)");
try {
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$current = $row ? $row['amount'] : 0;
if ($current < $amount) exit(json_encode(['success' => false, 'message' => "Insufficient: $current"], JSON_UNESCAPED_UNICODE));
$newAmount = $current - $amount;
$stmt = $db->prepare("UPDATE bond_cache SET amount = :amount, updated_at = :time WHERE player_name = :name");
$stmt->bindValue(':amount', $newAmount, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK', 'remaining' => $newAmount], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminAddBonds() {
requireAdminSession();
$player = getParam('player');
$amount = (int)getParam('amount', 0);
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();
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (player_name TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)");
try {
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$current = $row ? $row['amount'] : 0;
$newAmount = $current + $amount;
$stmt = $db->prepare("INSERT OR REPLACE INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $newAmount, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK', 'new_amount' => $newAmount], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminShopUpdate() {
requireAdminSession();
$db = getDB();
try {
$stmt = $db->prepare("UPDATE shop_items SET display_name=:dn, buy_price=:bp, sell_price=:sp, stock=:st WHERE id=:id");
$stmt->bindValue(':dn', getParam('display_name'));
$stmt->bindValue(':bp', getParam('buy_price'));
$stmt->bindValue(':sp', getParam('sell_price'));
$stmt->bindValue(':st', getParam('stock'));
$stmt->bindValue(':id', getParam('item_id'));
$stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminShopAdd() {
requireAdminSession();
$db = getDB();
try {
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_items VALUES (:id,:cat,:dn,:mat,:bp,:sp,:st,0,0,0)");
$stmt->bindValue(':id', getParam('item_id'));
$stmt->bindValue(':cat', getParam('category', 'weapon'));
$stmt->bindValue(':dn', getParam('display_name', ''));
$stmt->bindValue(':mat', getParam('material', 'stone_sword'));
$stmt->bindValue(':bp', getParam('buy_price', 0));
$stmt->bindValue(':sp', getParam('sell_price', -1));
$stmt->bindValue(':st', getParam('stock', -1));
$stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminShopRemove() {
requireAdminSession();
$db = getDB();
try {
$stmt = $db->prepare("DELETE FROM shop_items WHERE id=:id");
$stmt->bindValue(':id', getParam('item_id'));
$stmt->execute();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminStats() {
requireAdminSession();
$db = getDB();
try {
$r = [];
// 每个SQL独立try-catch,防止某个表不存在导致整体报错
try {
$row = $db->query("SELECT SUM(amount) as t FROM bond_cache")->fetchArray(SQLITE3_ASSOC);
$r['bonds'] = (int)($row['t'] ?? 0);
} catch (Exception $e) { $r['bonds'] = 0; }
try {
$row = $db->query("SELECT COUNT(*) as t FROM cdk WHERE used=0")->fetchArray(SQLITE3_ASSOC);
$r['cdk'] = (int)($row['t'] ?? 0);
} catch (Exception $e) { $r['cdk'] = 0; }
try {
$row = $db->query("SELECT COUNT(*) as t FROM web_transactions")->fetchArray(SQLITE3_ASSOC);
$r['tx'] = (int)($row['t'] ?? 0);
} catch (Exception $e) { $r['tx'] = 0; }
try {
$row = $db->query("SELECT COUNT(*) as t FROM shop_items")->fetchArray(SQLITE3_ASSOC);
$r['shop'] = (int)($row['t'] ?? 0);
} catch (Exception $e) { $r['shop'] = 0; }
try {
$row = $db->query("SELECT COUNT(*) as t FROM users")->fetchArray(SQLITE3_ASSOC);
$r['users'] = (int)($row['t'] ?? 0);
} catch (Exception $e) { $r['users'] = 0; }
exit(json_encode(['success' => true, 'data' => $r], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminAllTx() {
requireAdminSession();
$db = getDB();
try {
$result = $db->query("SELECT * FROM web_transactions ORDER BY created_at DESC LIMIT 100");
$txs = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
exit(json_encode(['success' => true, 'data' => $txs], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminPlayerTx() {
requireAdminSession();
$player = getParam('player');
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;
exit(json_encode(['success' => true, 'data' => $txs], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminGenToken() {
requireAdminSession();
$player = getParam('player');
if (!$player) exit(json_encode(['success' => false, 'message' => 'Missing player'], JSON_UNESCAPED_UNICODE));
$db = getDB();
try {
$token = bin2hex(random_bytes(32));
$expires = time() + TOKEN_EXPIRE_SECONDS;
$stmt = $db->prepare("INSERT OR REPLACE INTO tokens VALUES (:t,:p,:pur,:c,:e,0,0)");
$stmt->bindValue(':t', $token);
$stmt->bindValue(':p', $player);
$stmt->bindValue(':pur', getParam('purpose', 'general'));
$stmt->bindValue(':c', time());
$stmt->bindValue(':e', $expires);
$stmt->execute();
exit(json_encode(['success' => true, 'data' => ['token' => $token]], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
// ===== 批量生成CDK =====
function adminGenCDK() {
requireAdminSession();
$amount = (int)getParam('amount', 0);
$count = (int)getParam('count', 0);
if (!$amount || !$count) exit(json_encode(['success' => false, 'message' => '缺少amount或count参数'], JSON_UNESCAPED_UNICODE));
if ($count > 100) exit(json_encode(['success' => false, 'message' => '单次最多生成100个CDK'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS cdk (
code TEXT PRIMARY KEY,
amount INTEGER NOT NULL,
used INTEGER DEFAULT 0,
used_by TEXT DEFAULT '',
created_at INTEGER NOT NULL
)");
$generated = [];
$now = time();
try {
$db->exec('BEGIN TRANSACTION');
for ($i = 0; $i < $count; $i++) {
// 生成6位随机CDK码
$code = strtoupper(substr(str_shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 0, 6));
$stmt = $db->prepare("INSERT OR IGNORE INTO cdk (code, amount, used, created_at) VALUES (:code, :amount, 0, :time)");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
$generated[] = $code;
}
$db->exec('COMMIT');
exit(json_encode([
'success' => true,
'data' => ['codes' => $generated],
'message' => '生成了' . count($generated) . '个CDK'
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
$db->exec('ROLLBACK');
exit(json_encode(['success' => false, 'message' => '数据库错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}
function adminNotifySync() {
requireAdminSession();
$player = getParam('player');
if (!$player) exit(json_encode(['success' => false, 'message' => 'Missing player'], JSON_UNESCAPED_UNICODE));
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
}
function adminSyncNow() {
requireAdminSession();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
}
function adminPushShop() {
requireAdminSession();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
}
function adminPushBonds() {
requireAdminSession();
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
}
// ============================================================
// IP归属地查询 - 四线国内免费大厂API + 统一中文输出
// 线路1: 太平洋电脑网 (pconline.com.cn - DNSPod底层) ← 首选,最快最稳
// 线路2: 百度OpenData (baidu.com) ← 备用1
// 线路3: aa1.cn IP查百度 (aa1.cn) ← 备用2
// 线路4: IP9免费查询 (ip9.com.cn) ← 备用3,串行请求(免费版60次/分钟限制)
// 统一格式: "省份城市 运营商" 或 "省份 运营商"(无城市时)
// 特殊:机房/数据中心只显示机房所在省份 + 运营商
// ============================================================
/**
* 清理旧格式 IP 归属缓存(懒清理,按需执行)
* 只在查询到新 IP 时才清理数据库中对应 IP 的旧缓存
* 避免全表扫描导致后台卡顿
*/
function cleanOldIpLocationForIp($ip, $db) {
if (!$ip || $ip === '-' || $ip === '127.0.0.1') return;
try {
$stmt = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip LIMIT 1");
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if ($row && isset($row['location'])) {
$cachedLoc = $row['location'];
// ★ 只清理无效格式(内网IP、查询失败、短横线),不清理有效格式
// 有效格式是 "省份城市 运营商" 或 "省份 运营商"
$isValid = (strpos($cachedLoc, 'China - ') === 0
|| strpos($cachedLoc, '中国 ') === 0
|| strpos($cachedLoc, '省') !== false || strpos($cachedLoc, '市') !== false
|| strpos($cachedLoc, '广东') !== false || strpos($cachedLoc, '北京') !== false
|| strpos($cachedLoc, '上海') !== false || strpos($cachedLoc, '浙江') !== false
|| strpos($cachedLoc, '江苏') !== false || strpos($cachedLoc, '四川') !== false);
if (!$isValid && ($cachedLoc === '内网IP' || $cachedLoc === '查询失败' || $cachedLoc === '-' || $cachedLoc === '--')) {
// 只有明确无效的才删除
$delStmt = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
$delStmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$delStmt->execute();
}
// 如果无法判断格式有效性,不做任何删除操作
}
} catch (Exception $e) {
// 表不存在则忽略
}
}
function queryIpLocation($ip) {
if (!$ip || $ip === '-' || $ip === '127.0.0.1' || strpos($ip, '10.') === 0 || strpos($ip, '192.168.') === 0) {
return ['location' => '内网IP'];
}
// 进程内内存缓存(static 变量,同一请求内不重复查API)
static $memCache = [];
if (isset($memCache[$ip])) {
$cached = $memCache[$ip];
@error_log("[IP_QUERY] Cache hit (memory): $ip -> " . ($cached['location'] ?? 'null'));
return $cached;
}
// 按IP查询本地缓存表
try {
$db = getDB();
$stmt = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip LIMIT 1");
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if ($row && isset($row['location'])) {
$cachedLoc = $row['location'];
// ★ 使用统一格式校验
$isValidFormat = isValidIpLocationFormat($cachedLoc);
if (!$isValidFormat) {
// 无效缓存,直接删除
@error_log("[IP_QUERY] Invalid cache (will query API): $ip -> $cachedLoc");
try {
$stmt2 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
$stmt2->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt2->execute();
} catch (Exception $e) {}
} else {
// 格式正确,直接使用
@error_log("[IP_QUERY] Cache hit (database): $ip -> $cachedLoc");
$memCache[$ip] = ['location' => $cachedLoc, 'ip' => $ip];
return $memCache[$ip];
}
}
} catch (Exception $e) {
// 表不存在则忽略
}
// ★ 直接调用 doQueryIpLocation(内含4-API链式查询 + 格式验证)
@error_log("[IP_QUERY] Querying API: $ip");
$location = doQueryIpLocation($ip);
if ($location && isset($location['location'])) {
@error_log("[IP_QUERY] API RESULT: $ip -> " . $location['location']);
} else {
@error_log("[IP_QUERY] API RESULT: $ip -> null/failure");
}
// 存入本地缓存表
if ($location && $location['location'] !== '查询失败') {
// ★ 格式验证和修复
$loc = $location['location'];
// 1. 过滤英文格式
if (strpos($loc, 'China - ') === 0 || strpos($loc, 'China -') === 0) {
@error_log("[IP_QUERY] Rejected English format: $ip -> $loc");
$location = null;
}
// 2. 过滤不完整的中文格式
if ($location && (strpos($loc, '中国 - -') === 0 || strpos($loc, '中国 - -') === 0)) {
@error_log("[IP_QUERY] Rejected incomplete Chinese format: $ip -> $loc");
$location = null;
}
// 3. 格式修复:确保"位置"和"运营商"之间有空格
// 仅在没有空格时修复(如"广东省移动"→"广东省 移动")
// 已有空格的(如"湖北省武汉市 移动")不做修改
if (strpos($loc, ' ') === false) {
// 无空格,尝试添加空格
// 匹配:省/市/州/盟/地区/区/县 后面紧跟汉字(运营商)
if (preg_match('/^(.*?(?:省|市|州|盟|地区|区|县))(.+)$/u', $loc, $matches)) {
$loc = $matches[1] . ' ' . $matches[2];
@error_log("[IP_QUERY] Fixed format (no space): $ip -> " . $location['location'] . " => $loc");
$location['location'] = $loc;
}
}
// ★ 存储前格式验证
$storeLoc = $location['location'];
$shouldStore = isValidIpLocationFormat($storeLoc);
if (!$shouldStore) {
@error_log("[IP_QUERY] Rejected invalid format: $ip -> $storeLoc");
$location = null; // ★ 关键:拒绝后必须置null,防止无效格式泄露到前端
}
if ($shouldStore) {
try {
$db = getDB();
$stmt = $db->prepare("INSERT OR REPLACE INTO player_ip_locations (player_name, ip_address, location, updated_at) VALUES (:player, :ip, :loc, :time)");
$stmt->bindValue(':player', 'global', SQLITE3_TEXT);
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt->bindValue(':loc', $storeLoc, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
} catch (Exception $e) {
// 表不存在则忽略
}
} else {
@error_log("[IP_QUERY] Skipped storage (invalid format): $ip -> $storeLoc");
// ★ doQueryIpLocation() 已经尝试过所有4个API(含IP9),无需再重复调用
}
}
// 存入内存缓存
$memCache[$ip] = $location;
return $location;
}
/**
* 重试查询失败IP的归属地(串行调用IP9,避免并发超限)
* 只针对之前查询失败的IP
*/
function retryQueryFailedIpLocations() {
static $retryDone = false;
if ($retryDone) return;
$retryDone = true;
try {
$db = getDB();
// 查找所有标记为"查询失败"的IP
$stmt = $db->prepare("SELECT ip_address FROM player_ip_locations WHERE location = '查询失败' LIMIT 50");
$result = $stmt->execute();
$failedIps = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$failedIps[] = $row['ip_address'];
}
if (empty($failedIps)) return;
// 串行请求(免费版60次/分钟限制)
foreach ($failedIps as $ip) {
// 写日志
@error_log('[IP9 Retry] Processing IP: ' . $ip);
$location = queryIpLocationFromIp9($ip);
@error_log('[IP9 Retry] IP: ' . $ip . ' -> ' . ($location['location'] ?? 'null'));
if ($location && $location['location'] !== '查询失败' && $location['location'] !== '内网IP') {
// ★ 格式验证(统一函数)
$retryLoc = $location['location'];
$retryValid = isValidIpLocationFormat($retryLoc);
if ($retryValid) {
// 更新缓存表
try {
$stmt2 = $db->prepare("UPDATE player_ip_locations SET location = :loc, updated_at = :time WHERE ip_address = :ip");
$stmt2->bindValue(':loc', $retryLoc, SQLITE3_TEXT);
$stmt2->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt2->execute();
} catch (Exception $e) {}
} else {
@error_log("[IP9 Retry] Skipped storage (invalid format): $ip -> $retryLoc");
}
}
// 串行:每次请求间隔0.5秒
usleep(500000);
}
} catch (Exception $e) {
// 表不存在则忽略
}
}
/**
* 实际执行IP查询(四线国内免费大厂API + IP9兜底)
* ★ 核心策略:每个接口返回结果后先验证格式,无效则继续尝试下一个
* 避免太平洋返回"省份 运营商"(无城市)就直接结束
*/
function doQueryIpLocation($ip) {
if (!$ip || $ip === '-' || $ip === '127.0.0.1' || strpos($ip, '10.') === 0 || strpos($ip, '192.168.') === 0) {
return ['location' => '内网IP'];
}
static $cache = [];
if (isset($cache[$ip])) return $cache[$ip];
// 收集所有结果,优先选有效格式
$bestResult = null;
// ========== 线路1: 太平洋电脑网 ==========
@error_log("[IP_QUERY_API] Trying PConline: $ip");
$r1 = fetchPconline($ip);
if ($r1 !== null) {
$r1Loc = trim($r1);
@error_log("[IP_QUERY_API] PConline RESULT: $ip -> $r1Loc");
if (isValidIpLocationFormat($r1Loc)) {
// 有效格式,直接返回
@error_log("[IP_QUERY_API] PConline VALID: $ip -> $r1Loc");
$cache[$ip] = ['location' => $r1Loc, 'ip' => $ip];
return $cache[$ip];
} else {
@error_log("[IP_QUERY_API] PConline INVALID FORMAT (will try next): $ip -> $r1Loc");
$bestResult = $r1Loc; // 暂存,万一后面都没结果就用这个
}
} else {
@error_log("[IP_QUERY_API] PConline FAILED: $ip");
}
// ========== 线路2: 百度OpenData ==========
@error_log("[IP_QUERY_API] Trying Baidu: $ip");
$r2 = fetchIpApiWithTimeout('https://opendata.baidu.com/api.php?query=' . urlencode($ip) . '&resource_id=6006&oe=utf8&format=json', function($d) {
if ($d && isset($d['data']) && is_array($d['data']) && !empty($d['data'])) {
$loc = $d['data'][0]['location'] ?? null;
@error_log("[IP_QUERY_API] Baidu RAW DATA: " . json_encode($d, JSON_UNESCAPED_UNICODE));
if ($loc && $loc !== '--' && strpos($loc, '中国 ') !== 0) return $loc;
}
return null;
}, 3);
if ($r2 !== null) {
@error_log("[IP_QUERY_API] Baidu RESULT: $ip -> $r2");
if (isValidIpLocationFormat($r2)) {
@error_log("[IP_QUERY_API] Baidu VALID: $ip -> $r2");
$cache[$ip] = ['location' => $r2, 'ip' => $ip];
return $cache[$ip];
} else {
@error_log("[IP_QUERY_API] Baidu INVALID FORMAT: $ip -> $r2");
if (!$bestResult) $bestResult = $r2;
}
} else {
@error_log("[IP_QUERY_API] Baidu FAILED: $ip");
}
// ========== 线路3: aa1.cn ==========
@error_log("[IP_QUERY_API] Trying aa1.cn: $ip");
$r3 = fetchIpApiWithTimeout('https://v.api.aa1.cn/api/ipcha-baidu/?ip=' . urlencode($ip), function($d) {
if ($d && isset($d['code']) && $d['code'] == 1) {
$loc = $d['ip_add'] ?? null;
@error_log("[IP_QUERY_API] aa1.cn RAW DATA: " . json_encode($d, JSON_UNESCAPED_UNICODE));
if ($loc && $loc !== '未知' && strpos($loc, '中国 ') !== 0) return $loc;
}
return null;
}, 3);
if ($r3 !== null) {
@error_log("[IP_QUERY_API] aa1.cn RESULT: $ip -> $r3");
if (isValidIpLocationFormat($r3)) {
@error_log("[IP_QUERY_API] aa1.cn VALID: $ip -> $r3");
$cache[$ip] = ['location' => $r3, 'ip' => $ip];
return $cache[$ip];
} else {
@error_log("[IP_QUERY_API] aa1.cn INVALID FORMAT: $ip -> $r3");
if (!$bestResult) $bestResult = $r3;
}
} else {
@error_log("[IP_QUERY_API] aa1.cn FAILED: $ip");
}
// ========== 线路4: IP9(兜底,格式最准) ==========
@error_log("[IP_QUERY_API] Trying IP9 (fallback): $ip");
$ip9Result = queryIpLocationFromIp9($ip);
if ($ip9Result && $ip9Result['location'] !== '查询失败' && $ip9Result['location'] !== '内网IP') {
$r4Loc = $ip9Result['location'];
@error_log("[IP_QUERY_API] IP9 RESULT: $ip -> $r4Loc");
if (isValidIpLocationFormat($r4Loc)) {
@error_log("[IP_QUERY_API] IP9 VALID: $ip -> $r4Loc");
$cache[$ip] = ['location' => $r4Loc, 'ip' => $ip];
return $cache[$ip];
} else {
@error_log("[IP_QUERY_API] IP9 INVALID FORMAT: $ip -> $r4Loc");
if (!$bestResult) $bestResult = $r4Loc;
}
} else {
@error_log("[IP_QUERY_API] IP9 FAILED: $ip -> " . ($ip9Result['location'] ?? 'null'));
}
// ★ 所有接口都没返回有效格式:返回"查询失败",不返回无效格式
@error_log("[IP_QUERY_API] ALL FAILED/INVALID: $ip (bestResult=" . ($bestResult ?: 'null') . ")");
$cache[$ip] = ['location' => '查询失败'];
return $cache[$ip];
}
/**
* 带超时的IP归属地查询(缓存优先 + 超时降级)
*/
function queryIpLocationWithTimeout($ip, $timeout) {
// 1. 查内存缓存(最快)
static $memCache2 = [];
if (isset($memCache2[$ip])) {
$entry = $memCache2[$ip];
// 检查格式是否有效
if (strpos($entry['location'], 'China - ') === 0
|| strpos($entry['location'], '中国 ') === 0
|| $entry['location'] === '内网IP'
|| $entry['location'] === '查询失败'
|| $entry['location'] === '-'
|| $entry['location'] === '--') {
// 无效缓存,删除
unset($memCache2[$ip]);
try {
$db = getDB();
$stmt = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt->execute();
} catch (Exception $e) {}
} else {
return $entry;
}
}
// 2. 查数据库缓存
try {
$db = getDB();
$stmt = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip LIMIT 1");
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if ($row && isset($row['location'])) {
$cachedLoc = $row['location'];
// 格式校验
if (strpos($cachedLoc, 'China - ') === 0
|| strpos($cachedLoc, '中国 ') === 0
|| $cachedLoc === '内网IP'
|| $cachedLoc === '查询失败'
|| $cachedLoc === '-'
|| $cachedLoc === '--') {
// 无效,删除
try {
$stmt2 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
$stmt2->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt2->execute();
} catch (Exception $e) {}
} else {
// 有效缓存,返回
$memCache2[$ip] = ['location' => $cachedLoc, 'ip' => $ip];
return $memCache2[$ip];
}
}
} catch (Exception $e) {}
// 3. 缓存未命中或无效,请求API(带超时)
$result = fetchIpApiWithTimeout('https://whois.pconline.com.cn/ipJson.jsp?ip=' . urlencode($ip) . '&json=true', function($d) {
if (isset($d['addr']) && trim($d['addr']) !== '' && strpos(trim($d['addr']), '中国 ') !== 0) {
return trim($d['addr']);
}
if (isset($d['pro']) && trim($d['pro']) !== '') {
$loc = trim($d['pro']);
if (isset($d['city']) && trim($d['city']) !== '') {
$loc .= trim($d['city']);
}
if (isset($d['addr'])) {
$parts = preg_split('/\s+/', trim($d['addr']), -1, PREG_SPLIT_NO_EMPTY);
if (!empty($parts)) {
$op = end($parts);
if ($op !== '中国') $loc .= ' ' . $op;
}
}
return strlen($loc) > 1 ? $loc : null;
}
return null;
}, $timeout);
if ($result !== null) {
$entry = ['location' => $result, 'ip' => $ip];
$memCache2[$ip] = $entry;
// ★ 存储前格式验证(统一函数)
$storeLoc = $result;
$shouldStore = isValidIpLocationFormat($storeLoc);
if ($shouldStore) {
// 存库
try {
$db = getDB();
$stmt = $db->prepare("INSERT OR REPLACE INTO player_ip_locations (player_name, ip_address, location, updated_at) VALUES (:player, :ip, :loc, :time)");
$stmt->bindValue(':player', 'global', SQLITE3_TEXT);
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
$stmt->bindValue(':loc', $storeLoc, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
} catch (Exception $e) {}
}
return $entry;
}
// 4. API请求失败,返回查询失败
return ['location' => '查询失败'];
}
/**
* 带超时的通用HTTP请求封装
*/
function fetchIpApiWithTimeout($url, $parser, $timeout = 5) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 SDF1_IP_Lookup/1.0');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($response)) return null;
$data = json_decode($response, true);
if (!$data) return null;
return $parser($data);
}
/**
* 太平洋电脑网 API 专用解析(JSONP → 统一格式)
* ★ 优先使用,最快最稳,返回 "省份城市 运营商" 或 "省份 运营商"(无城市时)
*/
function fetchPconline($ip) {
$url = 'https://whois.pconline.com.cn/ipJson.jsp?ip=' . urlencode($ip) . '&json=true';
// ★ 加 stream 超时,防止挂起
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 3,
'ignore_errors' => true,
'header' => "Accept: application/json\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 SDF1_IP_Lookup/1.0"
]
]);
$response = @file_get_contents($url, false, $ctx);
if ($response === false || empty(trim($response))) return null;
// ★ 修复编码问题:太平洋返回GBK编码,需要转换为UTF-8
$response = trim($response);
if (mb_detect_encoding($response, 'UTF-8', true) === false) {
$response = mb_convert_encoding($response, 'UTF-8', 'GBK');
}
// 去除 JSONP 前缀 (可能包含括号)
$json = $response;
if (preg_match('/^\s*\(?[\'"]?(.*?)[\'"]?\)?\s*$/s', $json, $matches)) {
$json = $matches[1];
}
$data = json_decode($json, true);
if (!$data || !is_array($data)) return null;
// 优先使用 addr 字段
if (isset($data['addr']) && trim($data['addr']) !== '') {
$addr = trim($data['addr']);
// ★ 过滤掉"中国 X"这种不完整格式
if (strpos($addr, '中国 ') !== 0) {
return $addr;
}
}
// addr 不完整(如"中国 移动"或"湖南省郴州市 "),尝试用 pro + city 手动拼接
if (isset($data['pro']) && trim($data['pro']) !== '') {
$loc = trim($data['pro']);
if (isset($data['city']) && trim($data['city']) !== '') {
$loc .= trim($data['city']);
}
// 从 addr 提取运营商(addr 格式: "湖南省郴州市 电信" 或 "中国 移动")
if (isset($data['addr'])) {
$parts = preg_split('/\s+/', trim($data['addr']), -1, PREG_SPLIT_NO_EMPTY);
if (!empty($parts)) {
// 取最后一个非"中国"的词作为运营商
$op = end($parts);
if ($op !== '中国') {
$loc .= ' ' . $op;
}
}
}
// ★ 如果 addr 有"中国 X"格式但 pro+city 能拼出省市,就用 pro+city+op
// 如果 addr 本身已经是"省份城市 运营商"且完整,上面已经 return 了
return strlen($loc) > 1 ? $loc : null;
}
return null;
}
/**
* IP9 备用查询(串行调用,避免并发超限)
* ★ 使用 ip9.com.cn/get 接口(与浏览器访问一致)
*/
function queryIpLocationFromIp9($ip) {
// ★ 使用正确的 API 地址:ip9.com.cn/get
$url = 'https://ip9.com.cn/get?ip=' . urlencode($ip);
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 5,
'ignore_errors' => true,
'header' => "Accept: application/json, text/plain, */*\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\nReferer: https://ip9.com.cn/\r\nOrigin: https://ip9.com.cn"
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false
]
]);
@error_log("[IP9] Requesting: $url");
$response = @file_get_contents($url, false, $ctx);
if ($response === false) {
@error_log("[IP9] FAILED: No response for $ip");
return ['location' => '查询失败'];
}
$response = trim($response);
if (empty($response)) {
@error_log("[IP9] FAILED: Empty response for $ip");
return ['location' => '查询失败'];
}
@error_log("[IP9] Response: " . substr($response, 0, 500));
$data = json_decode($response, true);
if (!$data || !is_array($data)) {
@error_log("[IP9] FAILED: Invalid JSON for $ip: " . substr($response, 0, 200));
return ['location' => '查询失败'];
}
// IP9 返回格式有两种可能:
// 旧版: {"ret":"200","data":[{"ip":"...","prov":"广东","city":"深圳","isp":"中国移动"}]}
// 新版: {"ret":"200","data":{"ip":"...","prov":"广东","city":"深圳","isp":"中国移动"}}
// ★ 兼容两种格式
$item = null;
if (isset($data['data'][0]) && is_array($data['data'][0])) {
$item = $data['data'][0]; // 旧版:数组
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data']['prov'])) {
$item = $data['data']; // 新版:直接是对象
}
if ($item) {
$loc = '';
// ★ 优先使用 prov + city 格式
if (isset($item['prov'])) $loc .= $item['prov'];
if (isset($item['city'])) $loc .= $item['city'];
if (isset($item['isp'])) $loc .= ' ' . $item['isp'];
if (strlen($loc) > 0) {
@error_log("[IP9] SUCCESS: $ip -> $loc");
return ['location' => $loc, 'ip' => $ip];
}
}
@error_log("[IP9] FAILED: No valid data for $ip. data keys: " . implode(',', array_keys($data)));
return ['location' => '查询失败'];
}
/**
* 通用HTTP请求封装(支持POST/GET)
* 已弃用,改用 fetchIpApiWithTimeout
*/
function fetchIpApi($url, $parser) {
return fetchIpApiWithTimeout($url, $parser, 5);
}
function adminListUsers() {
requireAdminSession();
$db = getDB();
try {
$result = $db->query("SELECT player_name, register_time, last_login_time, points, total_online_time, email FROM users ORDER BY register_time DESC");
$users = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $users[] = $row;
$playerNames = array_column($users, 'player_name');
if (!empty($playerNames)) {
$ipMap = batchGetPlayerIps($db, $playerNames);
// ★ 批量读取数据库缓存(不调API,极快)
$uniqueIps = array_unique(array_values($ipMap));
$locCache = [];
$invalidIps4 = [];
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;
foreach ($validIps as $ip) { $locStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
$locResult = $locStmt->execute();
while ($row = $locResult->fetchArray(SQLITE3_ASSOC)) {
if (!isValidIpLocationFormat($row['location'])) {
$invalidIps4[] = $row['ip_address'];
} else {
$locCache[$row['ip_address']] = $row['location'];
}
}
if (!empty($invalidIps4)) {
$delP4 = implode(',', array_fill(0, count($invalidIps4), '?'));
$delS4 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address IN ($delP4)");
$di4 = 1;
foreach ($invalidIps4 as $dip) { $delS4->bindValue($di4++, $dip, SQLITE3_TEXT); }
$delS4->execute();
}
}
} catch (Exception $e) {}
foreach ($users as &$user) {
$key = strtolower($user['player_name']);
$ip = $ipMap[$key] ?? '-';
$user['ip_address'] = $ip;
$user['ip_location'] = $locCache[$ip] ?? '-';
}
unset($user);
}
exit(json_encode(['success' => true, 'data' => $users], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[adminListUsers] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
/**
* 分页API - 懒加载支持(搜索模式)
* 搜索时不限制IP查询,一次性返回全部结果
*/
function adminListUsersPaginated() {
requireAdminSession();
$db = getDB();
try {
$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', ''));
$includeIp = getParam('includeIp', '1') === '1'; // 默认包含IP,搜索时可设为0
$isSearchMode = !empty($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);
$result = $stmt->execute();
} else {
$result = $db->query($sql . " ORDER BY register_time DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset);
}
$users = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $users[] = $row;
// 获取总数
$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);
} else {
$countResult = $db->query($countSql)->fetchArray(SQLITE3_ASSOC);
}
$totalUsers = $countResult['cnt'] ?? 0;
$totalPages = ceil($totalUsers / $limit);
// 如果有IP需求,只读数据库缓存,不调API
if ($includeIp && !empty($users)) {
$playerNames = array_column($users, 'player_name');
// 1. 批量获取IP(本地查询,极快)
$ipMap = batchGetPlayerIps($db, $playerNames);
// 2. 收集唯一IP
$uniqueIps = array_unique(array_values($ipMap));
// 3. ★ 只从数据库批量读取缓存的IP归属地(零API调用,极快)
$locCache = [];
$invalidIps = []; // 需要清理的无效IP
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)");
$idx = 1;
foreach ($validIps as $ip) { $locStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
$locResult = $locStmt->execute();
while ($row = $locResult->fetchArray(SQLITE3_ASSOC)) {
// ★ 格式校验:无效格式直接标记清理,不返回给前端
if (!isValidIpLocationFormat($row['location'])) {
$invalidIps[] = $row['ip_address'];
@error_log("[IP_QUERY] adminListUsersPaginated: invalid format detected: " . $row['ip_address'] . " => " . $row['location']);
} else {
$locCache[$row['ip_address']] = $row['location'];
}
}
// ★ 批量清理无效记录
if (!empty($invalidIps)) {
$delPlaceholders = implode(',', array_fill(0, count($invalidIps), '?'));
$delStmt = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address IN ($delPlaceholders)");
$dIdx = 1;
foreach ($invalidIps as $dip) { $delStmt->bindValue($dIdx++, $dip, SQLITE3_TEXT); }
$delStmt->execute();
@error_log("[IP_QUERY] Cleaned " . count($invalidIps) . " invalid records from DB on read");
}
}
} catch (Exception $e) {}
// 4. 收集未缓存的IP返回给前端(前端后台批量查)+ 刚清理的无效IP也需重新查询
// ★ 过滤掉"-"和空值,只保留有效IP格式
$uncachedIps = array_merge(
array_values(array_filter($uniqueIps, fn($ip) => $ip && $ip !== '-' && $ip !== '--' && preg_match('/^\d+\.\d+\.\d+\.\d+$/', $ip) && !isset($locCache[$ip]))),
$invalidIps
);
$uncachedIps = array_unique($uncachedIps);
// 5. 合并数据
foreach ($users as &$user) {
$key = strtolower($user['player_name']);
if (isset($ipMap[$key])) {
$rawIp = $ipMap[$key];
// ★ 如果IP是"-",显示为"-"但标记需要查询
if ($rawIp === '-' || $rawIp === '' || $rawIp === null) {
$user['ip_address'] = '-';
$user['ip_location'] = '查询中...';
} else {
$user['ip_address'] = $rawIp;
$user['ip_location'] = $locCache[$rawIp] ?? '查询中...';
}
} else {
$user['ip_address'] = '-';
$user['ip_location'] = '-';
}
}
unset($user);
}
exit(json_encode([
'success' => true,
'data' => $users,
'uncached_ips' => $uncachedIps ?? [], // 未缓存的IP,供前端批量查询
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => $totalUsers,
'total_pages' => $totalPages,
'has_more' => $page < $totalPages
]
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
// 错误信息写日志,不暴露给前端
@error_log('[adminListUsersPaginated] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
// ===== 按 IP 分组查询玩家 =====
function adminListSameIp() {
requireAdminSession();
$db = getDB();
try {
// 从 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 = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$ip = $row['ip'];
$player = $row['pn'];
if (!isset($ipPlayerMap[$ip])) $ipPlayerMap[$ip] = [];
if (!in_array($player, $ipPlayerMap[$ip])) {
$ipPlayerMap[$ip][] = $player;
}
}
// 筛选出同 IP 对应多个玩家的分组
$groups = [];
foreach ($ipPlayerMap as $ip => $players) {
if (count($players) > 1) {
// 获取 IP 归属地
$locData = queryIpLocation($ip);
$playerDetails = [];
foreach ($players as $player) {
$playerDetails[] = [
'player_name' => $player,
'ip_address' => $ip,
'ip_location' => $locData['location'],
'login_time' => 0
];
}
$groups[] = [
'ip' => $ip,
'ip_location' => $locData['location'],
'player_count' => count($players),
'players' => $playerDetails
];
}
}
// 按玩家数降序
usort($groups, function($a, $b) { return $b['player_count'] - $a['player_count']; });
exit(json_encode(['success' => true, 'data' => $groups], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminApproveReset() {
requireAdminSession();
$reqId = (int)getParam('id', 0);
if (!$reqId) exit(json_encode(['success' => false, 'message' => 'Missing ID'], JSON_UNESCAPED_UNICODE));
$db = getDB();
try {
$stmt = $db->prepare("SELECT player_name FROM password_reset_requests WHERE id = :id AND status = 'pending'");
$stmt->bindValue(':id', $reqId, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) exit(json_encode(['success' => false, 'message' => 'Not found'], JSON_UNESCAPED_UNICODE));
$tempPassword = bin2hex(random_bytes(4));
$tempHash = password_hash($tempPassword, PASSWORD_DEFAULT);
$tempExpire = time() + 3600;
$stmt = $db->prepare("UPDATE password_reset_requests SET status='approved', processed_at=:t WHERE id=:id");
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->bindValue(':id', $reqId, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("INSERT OR REPLACE INTO web_login_credentials (player_name, temp_password_hash, temp_pw_expire) VALUES (:pn,:th,:te)");
$stmt->bindValue(':pn', $row['player_name']);
$stmt->bindValue(':th', $tempHash);
$stmt->bindValue(':te', $tempExpire);
$stmt->execute();
exit(json_encode(['success' => true, 'data' => ['temp_password' => $tempPassword]], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
function adminRejectReset() {
requireAdminSession();
$reqId = (int)getParam('id', 0);
if (!$reqId) exit(json_encode(['success' => false, 'message' => 'Missing ID'], JSON_UNESCAPED_UNICODE));
$db = getDB();
try {
$stmt = $db->prepare("UPDATE password_reset_requests SET status='rejected', processed_at=:t WHERE id=:id");
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->bindValue(':id', $reqId, SQLITE3_INTEGER);
$stmt->execute();
} catch (Exception $e) {}
exit(json_encode(['success' => true, 'message' => 'OK'], JSON_UNESCAPED_UNICODE));
}
function adminListResetRequests() {
requireAdminSession();
$db = getDB();
try {
$result = $db->query("SELECT * FROM password_reset_requests WHERE status = 'pending' ORDER BY created_at DESC");
$requests = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $requests[] = $row;
exit(json_encode(['success' => true, 'data' => $requests], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[Function] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => 'Internal error'], JSON_UNESCAPED_UNICODE));
}
}
/**
* 清理所有无效格式的IP归属地缓存(China-开头、仅省份、内网IP等)
* 收集需要重新查询的IP列表返回给前端
*/
function adminCleanAndCollectIps() {
requireAdminSession();
$db = getDB();
try {
// 1. 删除所有无效格式的缓存
$invalidPatterns = [
"location = '内网IP'",
"location = '查询失败'",
"location = '-'",
"location = '--'",
"location LIKE 'China - %'", // China - 开头(国外IP格式)
"location LIKE '中国 %'", // 中国 开头但无省份
// 仅有省份的(如"广东省"、"北京市"但无城市和运营商)
// 这些需要单独判断,先不删
];
$totalDeleted = 0;
foreach ($invalidPatterns as $pattern) {
try {
$delStmt = $db->exec("DELETE FROM player_ip_locations WHERE $pattern");
$totalDeleted += $db->changes();
} catch (Exception $e) {}
}
// 2. 删除内网IP(192.168.*.*、10.*.*.*、127.0.0.1)
try {
$db->exec("DELETE FROM player_ip_locations WHERE ip_address LIKE '192.168.%'");
$totalDeleted += $db->changes();
$db->exec("DELETE FROM player_ip_locations WHERE ip_address LIKE '10.%'");
$totalDeleted += $db->changes();
$db->exec("DELETE FROM player_ip_locations WHERE ip_address = '127.0.0.1'");
$totalDeleted += $db->changes();
$db->exec("DELETE FROM player_ip_locations WHERE ip_address = '-'");
$totalDeleted += $db->changes();
} catch (Exception $e) {}
// 3. 收集所有玩家当前IP中尚未缓存的IP
$uncachedIps = [];
try {
// 从 player_ip_changes 取最新IP
$stmt = $db->query("SELECT DISTINCT new_ip FROM player_ip_changes WHERE new_ip != '-' AND new_ip != '' AND new_ip NOT LIKE '192.168.%' AND new_ip NOT LIKE '10.%' AND new_ip != '127.0.0.1'");
$allIps = [];
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
$allIps[] = $row['new_ip'];
}
// 从缓存表中找出已有的
if (!empty($allIps)) {
$placeholders = implode(',', array_fill(0, count($allIps), '?'));
$cacheStmt = $db->prepare("SELECT ip_address FROM player_ip_locations WHERE ip_address IN ($placeholders) AND location != '查询失败'");
$idx = 1;
foreach ($allIps as $ip) { $cacheStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
$cachedResult = $cacheStmt->execute();
$cachedSet = [];
while ($row = $cachedResult->fetchArray(SQLITE3_ASSOC)) {
$cachedSet[$row['ip_address']] = true;
}
foreach ($allIps as $ip) {
if (!isset($cachedSet[$ip])) {
$uncachedIps[] = $ip;
}
}
}
} catch (Exception $e) {}
@error_log("[adminCleanAndCollectIps] Deleted: $totalDeleted, Uncached: " . count($uncachedIps));
exit(json_encode([
'success' => true,
'data' => [
'deleted' => $totalDeleted,
'uncached_ips' => $uncachedIps,
'total_uncached' => count($uncachedIps)
]
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
exit(json_encode(['success' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}
/**
* 批量查询IP归属地(每次最多3个,串行调API)
* 前端轮询调用,直到所有IP都查完
*/
function adminBatchQueryIps() {
requireAdminSession();
$db = getDB();
try {
$ips = getParam('ips');
// 调试日志
@error_log('[adminBatchQueryIps] Received ips: ' . print_r($ips, true));
if (!$ips) {
exit(json_encode(['success' => false, 'message' => 'Missing ips parameter'], JSON_UNESCAPED_UNICODE));
}
// 确保是数组
if (!is_array($ips)) {
// 如果是逗号分隔的字符串,转换为数组
$ips = explode(',', $ips);
$ips = array_map('trim', $ips);
}
// 限制每次最多3个
$batch = array_slice($ips, 0, 3);
$results = [];
foreach ($batch as $ip) {
if (!$ip || $ip === '-' || $ip === '127.0.0.1' || strpos($ip, '192.168.') === 0 || strpos($ip, '10.') === 0) {
$results[$ip] = '内网IP';
continue;
}
$loc = queryIpLocation($ip);
// ★ 返回有效结果或"查询失败",不返回"-"
$results[$ip] = ($loc && isset($loc['location']) && $loc['location'] !== '查询失败') ? $loc['location'] : '查询失败';
}
// 返回结果和剩余未查询的IP
$remaining = array_slice($ips, 3);
exit(json_encode([
'success' => true,
'data' => $results,
'remaining' => $remaining,
'remaining_count' => count($remaining)
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[adminBatchQueryIps] Error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}
/**
* 轻量级在线玩家名列表(不含IP查询,极快)
* 用于用户管理页面初始化,只需知道谁在线
*/
function adminListOnlineNames() {
requireAdminSession();
$db = getDB();
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();
$names = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$names[] = $row['player_name'];
}
exit(json_encode(['success' => true, 'data' => $names], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
exit(json_encode(['success' => true, 'data' => []], JSON_UNESCAPED_UNICODE));
}
}
function adminListOnlinePlayers() {
requireAdminSession();
$db = getDB();
try {
// ★ 实时在线玩家:只查询最近2分钟内活跃的(±1分钟误差)
$cutoff = time() - 120; // 2分钟窗口
$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();
$players = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$players[] = $row;
}
// 获取 IP(只从数据库缓存读取,不调API)
$playerNames = array_column($players, 'player_name');
if (!empty($playerNames)) {
$ipMap = batchGetPlayerIps($db, $playerNames);
$ipCountMap = [];
foreach ($players as &$p) {
$key = strtolower($p['player_name']);
$ip = $ipMap[$key] ?? '-';
$p['ip_address'] = $ip;
$ipCountMap[$ip] = ($ipCountMap[$ip] ?? 0) + 1;
}
unset($p);
// ★ 批量读取数据库缓存的IP归属地(不调API)
$uniqueIps = array_unique(array_values($ipMap));
$locCache = [];
$invalidIps2 = [];
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;
foreach ($validIps as $ip) {
$locStmt->bindValue($idx++, $ip, SQLITE3_TEXT);
}
$locResult = $locStmt->execute();
while ($row = $locResult->fetchArray(SQLITE3_ASSOC)) {
// ★ 格式校验:无效格式不返回,标记清理
if (!isValidIpLocationFormat($row['location'])) {
$invalidIps2[] = $row['ip_address'];
} else {
$locCache[$row['ip_address']] = $row['location'];
}
}
if (!empty($invalidIps2)) {
$delP2 = implode(',', array_fill(0, count($invalidIps2), '?'));
$delS2 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address IN ($delP2)");
$di2 = 1;
foreach ($invalidIps2 as $dip) { $delS2->bindValue($di2++, $dip, SQLITE3_TEXT); }
$delS2->execute();
}
}
} catch (Exception $e) {}
foreach ($players as &$p) {
$ip = $p['ip_address'];
$p['ip_location'] = $locCache[$ip] ?? '-';
$p['ip_is_shared'] = ($ipCountMap[$ip] ?? 1) > 1;
}
unset($p);
}
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[adminListOnlinePlayers] Error: ' . $e->getMessage());
exit(json_encode(['success' => true, 'data' => []], JSON_UNESCAPED_UNICODE));
}
}
/**
* 批量获取玩家 IP(一次SQL搞定,避免 N+1 查询)
*/
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) {}
}
}
// 2. 缺失的去 web_session_log 批量补查
$missing = [];
foreach ($playerNames as $pn) {
$key = strtolower($pn);
if (!isset($ipMap[$key])) {
$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
");
$idx2 = 1;
foreach ($missing as $mn) {
$stmt2->bindValue($idx2++, $mn, SQLITE3_TEXT);
}
$res2 = $stmt2->execute();
while ($row = $res2->fetchArray(SQLITE3_ASSOC)) {
$key = strtolower($row['player_name']);
if (!isset($ipMap[$key])) {
$ipMap[$key] = $row['ip_address'];
}
}
} catch (Exception $e) {
// 降级为简单查询
try {
$stmt2 = $db->prepare("SELECT player_name, ip_address FROM web_session_log WHERE player_name IN ($placeholders2) ORDER BY login_time DESC");
$idx2 = 1;
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) {}
}
}
return $ipMap;
}
function adminListActivePlayers() {
requireAdminSession();
$period = (int)getParam('period', 86400);
if ($period <= 0) $period = 86400;
$db = getDB();
$cutoff = time() - $period;
try {
$stmt = $db->prepare("SELECT player_name, last_login_time, total_online_time FROM users WHERE last_login_time >= :cutoff ORDER BY last_login_time DESC");
$stmt->bindValue(':cutoff', $cutoff, SQLITE3_INTEGER);
$result = $stmt->execute();
$players = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$players[] = [
'player_name' => $row['player_name'],
'last_login_time' => $row['last_login_time'],
'hours_online' => round(($row['total_online_time'] ?? 0) / 3600, 1)
];
}
// 获取 IP(只从数据库缓存读取)
$playerNames = array_column($players, 'player_name');
if (!empty($playerNames)) {
$ipMap = batchGetPlayerIps($db, $playerNames);
// ★ 批量读取数据库缓存的IP归属地(不调API)
$uniqueIps = array_unique(array_values($ipMap));
$locCache = [];
$invalidIps3 = [];
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;
foreach ($validIps as $ip) { $locStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
$locResult = $locStmt->execute();
while ($row = $locResult->fetchArray(SQLITE3_ASSOC)) {
if (!isValidIpLocationFormat($row['location'])) {
$invalidIps3[] = $row['ip_address'];
} else {
$locCache[$row['ip_address']] = $row['location'];
}
}
if (!empty($invalidIps3)) {
$delP3 = implode(',', array_fill(0, count($invalidIps3), '?'));
$delS3 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address IN ($delP3)");
$di3 = 1;
foreach ($invalidIps3 as $dip) { $delS3->bindValue($di3++, $dip, SQLITE3_TEXT); }
$delS3->execute();
}
}
} catch (Exception $e) {}
foreach ($players as &$p) {
$key = strtolower($p['player_name']);
$ip = $ipMap[$key] ?? '-';
$p['ip_address'] = $ip;
$p['ip_location'] = $locCache[$ip] ?? '-';
}
unset($p);
}
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[adminListActivePlayers] Error: ' . $e->getMessage());
exit(json_encode(['success' => true, 'data' => []], JSON_UNESCAPED_UNICODE));
}
}
function adminGetStatsEx() {
requireAdminSession();
$db = getDB();
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); }
} catch (Exception $e) {
@error_log('[adminGetStatsEx] online_players error: ' . $e->getMessage());
}
$activeCount = 0;
try {
$stmt = $db->prepare("SELECT COUNT(*) as cnt FROM users WHERE last_login_time >= :cutoff");
$stmt->bindValue(':cutoff', time() - 86400, SQLITE3_INTEGER);
$r = $stmt->execute();
if ($r) { $row = $r->fetchArray(SQLITE3_ASSOC); $activeCount = (int)($row['cnt'] ?? 0); }
} catch (Exception $e) {
@error_log('[adminGetStatsEx] users error: ' . $e->getMessage());
}
$userCount = 0;
try {
$r = $db->query("SELECT COUNT(*) as cnt FROM users");
if ($r) { $row = $r->fetchArray(SQLITE3_ASSOC); $userCount = (int)($row['cnt'] ?? 0); }
} catch (Exception $e) {
@error_log('[adminGetStatsEx] users count error: ' . $e->getMessage());
}
$totalBonds = 0;
try {
// ★ 检查 bond_cache 表是否存在
$tableCheck = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='bond_cache'");
$hasBondCache = false;
if ($tableCheck) {
$tableRow = $tableCheck->fetchArray();
$hasBondCache = ($tableRow && !empty($tableRow['name']));
}
if ($hasBondCache) {
$r = $db->query("SELECT COALESCE(SUM(amount),0) as total FROM bond_cache");
if ($r) { $row = $r->fetchArray(SQLITE3_ASSOC); $totalBonds = (int)($row['total'] ?? 0); }
}
} catch (Exception $e) {
@error_log('[adminGetStatsEx] bond_cache error: ' . $e->getMessage());
}
$todayCount = 0;
try {
$todayStart = strtotime('today');
$stmt = $db->prepare("SELECT COUNT(*) as cnt FROM users WHERE register_time >= :cutoff");
$stmt->bindValue(':cutoff', $todayStart, SQLITE3_INTEGER);
$r = $stmt->execute();
if ($r) { $row = $r->fetchArray(SQLITE3_ASSOC); $todayCount = (int)($row['cnt'] ?? 0); }
} catch (Exception $e) {
@error_log('[adminGetStatsEx] today count error: ' . $e->getMessage());
}
@error_log('[adminGetStatsEx] Success: users=' . $userCount . ', online=' . $onlineCount . ', active=' . $activeCount);
exit(json_encode([
'success' => true,
'data' => [
'total_users' => $userCount,
'online_count' => $onlineCount,
'active_count_24h' => $activeCount,
'total_bonds' => $totalBonds,
'today_registered' => $todayCount
],
'message' => 'ok'
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[adminGetStatsEx] Error: ' . $e->getMessage() . ' ' . $e->getTraceAsString());
exit(json_encode(['success' => false, 'message' => 'Internal error: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}