Java:新增ip推送端口,后端可以查看玩家ip及归属
php:新增ip属地、实时在线数据,可以查看玩家ip、归属和实时在线情况
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
*.bak*
|
||||
*.bak2
|
||||
*.clean_lock
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
+1577
-525
File diff suppressed because it is too large
Load Diff
+27
-20
@@ -34,25 +34,34 @@ function balanceQuery($token) {
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
// ★ 安全验证:余额查询只需token验证即可(view级别)
|
||||
// ★ 安全验证:必须验证token且token对应的玩家必须与查询的玩家匹配
|
||||
$tokenInfo = null;
|
||||
$queriedPlayer = $player; // 最终查询的玩家名
|
||||
|
||||
// 1. 检查是否为管理后台session
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
if (isset($_SESSION['admin_auth']) && $_SESSION['admin_auth']) {
|
||||
$tokenInfo = ['player' => $player, 'purpose' => 'admin'];
|
||||
$isAdmin = isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
|
||||
|
||||
if ($isAdmin) {
|
||||
// 管理员:可以查任意玩家
|
||||
$tokenInfo = ['player' => null, 'purpose' => 'admin'];
|
||||
} elseif ($token) {
|
||||
// 2. 检查token
|
||||
// 先检查是否为普通管理token
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
// admin token,直接通过
|
||||
} else {
|
||||
// weblogin token → 走安全验证(view级别,可快速重连)
|
||||
// 2. 验证token并获取token对应的玩家名
|
||||
$tempTokenInfo = validateToken($token);
|
||||
if ($tempTokenInfo) {
|
||||
$tokenPlayer = $tempTokenInfo['player'];
|
||||
$tokenPurpose = $tempTokenInfo['purpose'] ?? '';
|
||||
|
||||
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
|
||||
// 管理员token:可以查任意玩家
|
||||
$tokenInfo = $tempTokenInfo;
|
||||
} elseif ($tokenPlayer === $player) {
|
||||
// 普通token:只能查自己的余额
|
||||
// 进一步验证weblogin token的访问权限
|
||||
$accessResult = validateWebAccess($token, 'view', $password, $ipAddress);
|
||||
if ($accessResult['ok']) {
|
||||
$player = $accessResult['player'];
|
||||
$tokenInfo = ['player' => $player, 'purpose' => 'weblogin'];
|
||||
$queriedPlayer = $accessResult['player'];
|
||||
$tokenInfo = ['player' => $queriedPlayer, 'purpose' => 'weblogin'];
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
@@ -63,16 +72,14 @@ function balanceQuery($token) {
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
} else {
|
||||
error('无权查询其他玩家余额', 403);
|
||||
}
|
||||
} else {
|
||||
error('无效token,请先登录', 401);
|
||||
}
|
||||
|
||||
if (!$tokenInfo) {
|
||||
error('查询余额需要有效token', 401);
|
||||
}
|
||||
|
||||
// 权限检查:只能查自己的余额,除非是管理员
|
||||
if ($tokenInfo['player'] !== $player && $tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('无权查询其他玩家余额');
|
||||
} else {
|
||||
error('查询余额需要有效token,请先登录', 401);
|
||||
}
|
||||
|
||||
// 查询债券余额(从Web端的债券缓存表)
|
||||
|
||||
+23
-12
@@ -8,9 +8,10 @@
|
||||
* POST ?action=batch - 批量生成CDK(需管理token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
require_once __DIR__ . '/../core.php';
|
||||
ob_end_clean();
|
||||
// 再次确保清理前置输出
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
$action = getParam('action', 'exchange');
|
||||
$token = getParam('token');
|
||||
@@ -47,20 +48,20 @@ function cdkExchange($token) {
|
||||
|
||||
// ★ 双保险验证:CDK兑换需要密码确认
|
||||
$isPreview = true;
|
||||
$validated = false;
|
||||
|
||||
if ($token) {
|
||||
// 先检查是否为普通管理token
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
if (validateToken($token)) {
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
$validated = true;
|
||||
} else {
|
||||
// weblogin token → 走双保险验证
|
||||
$accessResult = validateWebAccess($token, 'cdk', $password, $ipAddress);
|
||||
if ($accessResult['ok']) {
|
||||
if ($accessResult['ok'] || $accessResult['mode'] === 'full_verified') {
|
||||
$isPreview = false;
|
||||
$validated = true;
|
||||
$player = $accessResult['player'];
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
@@ -75,6 +76,11 @@ function cdkExchange($token) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有token也不是管理token,返回预览
|
||||
if (!$validated) {
|
||||
$isPreview = true;
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 查询CDK
|
||||
@@ -159,12 +165,17 @@ function cdkExchange($token) {
|
||||
'balance_after' => $newBalance
|
||||
], 'CDK兑换成功');
|
||||
|
||||
// ★ 立即通知Java插件拉取交易
|
||||
$notifyUrl = "http://127.0.0.1:" . CALLBACK_PORT . "/api/notify_sync?secret=" . SECRET_KEY;
|
||||
@file_get_contents($notifyUrl, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 2]]));
|
||||
$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;
|
||||
@file_get_contents($notifyUrl2, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 2]]));
|
||||
// ★ 写入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 =====
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../inc/function.php';
|
||||
|
||||
/** @var PDO $db */
|
||||
function getPlayerList() {
|
||||
global $db;
|
||||
$stmt = $db->prepare("SELECT player_name FROM users");
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$players = [];
|
||||
foreach ($rows as $row) {
|
||||
$players[] = $row['player_name'];
|
||||
}
|
||||
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
if (!isLogin()) error('请先登录', 401);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') error('方法不允许');
|
||||
getPlayerList();
|
||||
+30
-9
@@ -198,16 +198,37 @@ function registerQuery($token) {
|
||||
$row['last_login_time'] = (int)($row['last_login_time'] / 1000);
|
||||
}
|
||||
|
||||
// 有token才返回完整信息
|
||||
$tokenInfo = null;
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
}
|
||||
|
||||
if ($tokenInfo) {
|
||||
success($row);
|
||||
} else {
|
||||
// ★ 严格校验:必须有有效token且token对应的玩家必须与查询的玩家匹配(除非是管理员token)
|
||||
if (!$token) {
|
||||
// 无token:隐藏敏感信息
|
||||
preview($row, '预览模式 - 部分信息已隐藏');
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenInfo = validateToken($token);
|
||||
if (!$tokenInfo) {
|
||||
error('无效token,请先登录', 401);
|
||||
}
|
||||
|
||||
$tokenPurpose = $tokenInfo['purpose'] ?? '';
|
||||
|
||||
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
|
||||
// 管理员token:返回完整信息
|
||||
success($row);
|
||||
} elseif ($tokenInfo['player'] === $player) {
|
||||
// 普通token:只能查自己的完整信息
|
||||
success($row);
|
||||
} else {
|
||||
// 不是自己的账号,返回脱敏信息
|
||||
$maskedRow = [
|
||||
'player_name' => $row['player_name'],
|
||||
'register_time' => $row['register_time'],
|
||||
'last_login_time' => $row['last_login_time'],
|
||||
'email' => '***',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0
|
||||
];
|
||||
preview($maskedRow, '预览模式 - 您只能查看自己的账号信息');
|
||||
}
|
||||
}
|
||||
|
||||
+14
-16
@@ -6,10 +6,10 @@
|
||||
* GET ?action=categories - 分类列表(无需token,公开接口)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 清理可能的前置输出
|
||||
ob_end_clean();
|
||||
// 再次确保清理前置输出
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
$action = getParam('action', 'list');
|
||||
$token = getParam('token');
|
||||
@@ -217,20 +217,18 @@ function shopBuy($token) {
|
||||
'player' => $player
|
||||
], '购买成功');
|
||||
|
||||
// ★ 立即通知Java插件拉取交易(3种方式)
|
||||
// 方式1:直接HTTP请求到Java插件(回调端口)
|
||||
$notifyUrl = "http://127.0.0.1:" . CALLBACK_PORT . "/api/notify_sync?secret=" . SECRET_KEY . "&tx_id=" . $txId;
|
||||
@file_get_contents($notifyUrl, false, stream_context_create(['http' => ['method' => 'POST', 'timeout' => 2]]));
|
||||
// ★ 立即请求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();
|
||||
|
||||
// 方式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' => 2]]));
|
||||
|
||||
// 方式3:写入通知文件(供插件文件系统检测)
|
||||
$notifyFile = __DIR__ . '/../db/tx_notify.json';
|
||||
$notifyData = json_encode(['tx_id' => (int)$txId, 'time' => time()], JSON_UNESCAPED_UNICODE);
|
||||
@file_put_contents($notifyFile, $notifyData, LOCK_EX);
|
||||
// ★ 通过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验证(用于商城购买)=====
|
||||
|
||||
+532
-74
@@ -116,6 +116,9 @@ switch ($action) {
|
||||
case 'sync_online_players':
|
||||
syncOnlinePlayers();
|
||||
break;
|
||||
case 'sync_player_ips':
|
||||
syncPlayerIps();
|
||||
break;
|
||||
case 'pull_shop':
|
||||
pullShop();
|
||||
break;
|
||||
@@ -126,7 +129,10 @@ switch ($action) {
|
||||
syncWebloginToken();
|
||||
break;
|
||||
case 'validate_weblogin_token':
|
||||
validateWebloginToken();
|
||||
apiValidateWebloginToken();
|
||||
break;
|
||||
case 'push_player_login_status':
|
||||
pushPlayerLoginStatus();
|
||||
break;
|
||||
case 'sync_token':
|
||||
syncToken();
|
||||
@@ -173,12 +179,21 @@ switch ($action) {
|
||||
case 'notify_sync':
|
||||
notifySync();
|
||||
break;
|
||||
case 'request_immediate_sync':
|
||||
requestImmediateSync();
|
||||
break;
|
||||
case 'resend_pending':
|
||||
resendPendingTransactions();
|
||||
break;
|
||||
case 'send_email_code':
|
||||
sendEmailCode();
|
||||
break;
|
||||
case 'verify_email_code':
|
||||
verifyEmailCode();
|
||||
break;
|
||||
case 'get_player_email':
|
||||
getPlayerEmail();
|
||||
break;
|
||||
case 'fix_stock':
|
||||
fixStock();
|
||||
break;
|
||||
@@ -534,31 +549,221 @@ function fixTimestamps() {
|
||||
|
||||
// ===== 插件同步在线玩家列表 =====
|
||||
function syncOnlinePlayers() {
|
||||
$secret = getParam('secret');
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
$db = getDB();
|
||||
|
||||
// ★ 尝试从三个来源读取 secret 和 players
|
||||
$rawInput = null;
|
||||
|
||||
// 1) 先从 $_GET/$_POST 读
|
||||
$secret = getParam('secret');
|
||||
$players = getParam('players');
|
||||
if (!$players || !is_array($players)) {
|
||||
error('缺少players数据');
|
||||
|
||||
// 2) 如果 PHP 解析了 x-www-form-urlencoded,$_POST 会有数据
|
||||
// 3) 如果是 application/json,$_POST 为空,需要从 $_SERVER['HTTP_RAW_POST_DATA'] 或 php://input 读取
|
||||
|
||||
if (!$secret && isset($_POST['secret'])) {
|
||||
$secret = $_POST['secret'];
|
||||
}
|
||||
if (($players === null || !is_array($players)) && isset($_POST['players'])) {
|
||||
$pVal = $_POST['players'];
|
||||
// 表单提交的 players 可能是 URL 编码的字符串
|
||||
if (is_string($pVal)) {
|
||||
$pVal = json_decode($pVal, true);
|
||||
}
|
||||
if (is_array($pVal)) $players = $pVal;
|
||||
}
|
||||
|
||||
// 4) 如果还没有,手动解析 php://input
|
||||
if (!$secret || !$players) {
|
||||
static $_syncCached = false;
|
||||
if (!$_syncCached) {
|
||||
$_syncCached = true;
|
||||
$rawInput = file_get_contents('php://input');
|
||||
if ($rawInput) {
|
||||
// 先试 JSON
|
||||
$jsonParsed = json_decode($rawInput, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($jsonParsed)) {
|
||||
if (!isset($secret) && isset($jsonParsed['secret'])) $secret = $jsonParsed['secret'];
|
||||
if (!isset($players) && isset($jsonParsed['players'])) {
|
||||
$players = $jsonParsed['players'];
|
||||
}
|
||||
} else {
|
||||
// 不是 JSON,解析为表单
|
||||
parse_str($rawInput, $formVars);
|
||||
if (!isset($secret) && isset($formVars['secret'])) $secret = $formVars['secret'];
|
||||
if (!isset($players) && isset($formVars['players'])) {
|
||||
$pVal = $formVars['players'];
|
||||
if (is_string($pVal)) {
|
||||
$pVal = json_decode($pVal, true);
|
||||
}
|
||||
if (is_array($pVal)) $players = $pVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
if ($players === null || !is_array($players)) {
|
||||
@error_log("[syncOnlinePlayers] FAIL: invalid players data, type=" . gettype($players) . ", content=" . var_export($players, true));
|
||||
error('缺少players数据 (received=' . var_export($players, true) . ' | type=' . gettype($players) . ')');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
// ★ 设置更高的 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)");
|
||||
|
||||
// 清空旧数据
|
||||
$now = time();
|
||||
$playerCount = count($players);
|
||||
|
||||
// ★ Debug: 记录接收到的玩家数据
|
||||
$playerNames = array_map(function($p) { return $p['name'] ?? '?'; }, $players);
|
||||
$playerTimes = array_map(function($p) { return $p['login_time'] ?? 0; }, $players);
|
||||
@error_log("[syncOnlinePlayers] Received $playerCount players: " . implode(', ', $playerNames));
|
||||
@error_log("[syncOnlinePlayers] Login times: " . implode(', ', $playerTimes) . " | Server time: $now");
|
||||
|
||||
// ★ 删除所有旧记录,然后重新插入(避免残留旧数据)
|
||||
$db->exec("DELETE FROM online_players");
|
||||
|
||||
// 插入新数据
|
||||
$now = time();
|
||||
// ★ 逐条插入,不用事务(避免锁问题)
|
||||
$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', $player['name'], SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $player['login_time'] ?? $now, SQLITE3_INTEGER);
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
||||
success(['synced' => count($players)], "同步了" . count($players) . "个在线玩家");
|
||||
// ★ 验证:查询刚插入的数据
|
||||
$verifyStmt = $db->query("SELECT COUNT(*) as cnt FROM online_players");
|
||||
$verifyRow = $verifyStmt->fetchArray(SQLITE3_ASSOC);
|
||||
@error_log("[syncOnlinePlayers] Synced $synced/$playerCount, DB now has " . ($verifyRow['cnt'] ?? 0) . " records");
|
||||
|
||||
success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家");
|
||||
}
|
||||
|
||||
// ===== 同步所有玩家IP到PHP端(带变更检测) =====
|
||||
function syncPlayerIps() {
|
||||
$db = getDB();
|
||||
|
||||
// ★ 解析参数
|
||||
$secret = getParam('secret');
|
||||
$players = getParam('players');
|
||||
|
||||
if (!$secret && isset($_POST['secret'])) {
|
||||
$secret = $_POST['secret'];
|
||||
}
|
||||
if (($players === null || !is_array($players)) && isset($_POST['players'])) {
|
||||
$pVal = $_POST['players'];
|
||||
if (is_string($pVal)) {
|
||||
$pVal = json_decode($pVal, true);
|
||||
}
|
||||
if (is_array($pVal)) $players = $pVal;
|
||||
}
|
||||
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
if ($players === null || !is_array($players)) {
|
||||
error('缺少players数据');
|
||||
}
|
||||
|
||||
// 创建IP变更日志表(用于追踪IP变化)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
old_ip TEXT DEFAULT '',
|
||||
new_ip TEXT NOT NULL,
|
||||
changed_at INTEGER NOT NULL,
|
||||
synced_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// 创建IP归属地缓存表(用于避免重复查询API)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
location TEXT DEFAULT '',
|
||||
updated_at INTEGER DEFAULT 0,
|
||||
UNIQUE(player_name, ip_address)
|
||||
)");
|
||||
|
||||
// 批量更新玩家的IP到online_players表(带变更检测)
|
||||
$now = time();
|
||||
$changedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($players as $player) {
|
||||
if (empty($player['name']) || empty($player['ip'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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)");
|
||||
$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();
|
||||
|
||||
// online_players 表没有 ip_address 列,直接插入(login_time 会自动同步)
|
||||
$stmt2 = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :player");
|
||||
$stmt2->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute();
|
||||
if (!$result2->fetchArray()) {
|
||||
// 不存在,先插入
|
||||
$stmt3 = $db->prepare("INSERT INTO online_players (player_name, login_time) VALUES (:name, :time)");
|
||||
$stmt3->bindValue(':name', $playerName, SQLITE3_TEXT);
|
||||
$stmt3->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt3->execute();
|
||||
}
|
||||
|
||||
// 更新在线玩家IP(不实时更新online_players,因为IP归属由PHP端查询)
|
||||
$changedCount++;
|
||||
|
||||
// 如果之前已同步过(synced_at != 0),标记为待重新查询
|
||||
if ($row && $row['synced_at'] > 0) {
|
||||
// 重置synced_at,让PHP端的IP查询逻辑重新查询API
|
||||
$stmtReset = $db->prepare("UPDATE player_ip_changes SET synced_at = 0 WHERE player_name = :player");
|
||||
$stmtReset->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$stmtReset->execute();
|
||||
}
|
||||
} else {
|
||||
$skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
success([
|
||||
'changed' => $changedCount,
|
||||
'skipped' => $skippedCount,
|
||||
'total' => count($players)
|
||||
], "同步IP: {$changedCount}个变更, {$skippedCount}个跳过");
|
||||
}
|
||||
|
||||
// ===== 插件拉取商品 =====
|
||||
@@ -674,7 +879,7 @@ function syncToken() {
|
||||
success(['token' => substr($newToken, 0, 8) . '...'], "Token已同步");
|
||||
}
|
||||
|
||||
// ===== 插件推送Web登录Token =====
|
||||
// ===== 验证Web登录Token(不消费,仅验证有效性)=====
|
||||
function syncWebloginToken() {
|
||||
// 支持两种认证方式:SECRET_KEY(插件直接调用)或 token(通用认证)
|
||||
$secret = getParam('secret');
|
||||
@@ -717,14 +922,15 @@ function syncWebloginToken() {
|
||||
success(['player' => $player], "Web登录Token已同步");
|
||||
}
|
||||
|
||||
// ===== Web端验证登录Token =====
|
||||
function validateWebloginToken() {
|
||||
// ===== 验证Web登录Token(用于前端登录页面API)=====
|
||||
function apiValidateWebloginToken() {
|
||||
$webToken = getParam('web_token');
|
||||
if (!$webToken) error('缺少web_token参数');
|
||||
if (!$webToken) {
|
||||
error('缺少web_token参数');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$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)");
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
@@ -736,40 +942,128 @@ function validateWebloginToken() {
|
||||
|
||||
$createdAt = (int)$row['created_at'];
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
$now = time();
|
||||
|
||||
if ($now - $createdAt > $expireSeconds) {
|
||||
// Token已过期,删除
|
||||
$delStmt = $db->prepare("DELETE FROM weblogin_tokens WHERE web_token = :token");
|
||||
$delStmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$delStmt->execute();
|
||||
if (time() - $createdAt > $expireSeconds) {
|
||||
error('登录Token已过期');
|
||||
}
|
||||
|
||||
$player = $row['player_name'];
|
||||
|
||||
// ★ 不删除Token - 保持有效供后续API调用(player.php需要token鉴权)
|
||||
// Token会在过期时间后自动失效
|
||||
|
||||
// ★ 不自动创建登录确认记录 - 等待用户在Web端输入密码验证通过后再创建
|
||||
// 登录确认记录会在verify_web_password端点中创建
|
||||
|
||||
// 查询玩家信息(users表可能不存在,安全降级)
|
||||
$userData = ['player_name' => $player];
|
||||
try {
|
||||
$stmt2 = $db->prepare("SELECT * FROM users WHERE player_name = :player");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute();
|
||||
$userRow = $result2->fetchArray(SQLITE3_ASSOC);
|
||||
if ($userRow) $userData = $userRow;
|
||||
success(['player' => $row['player_name']], 'Token有效');
|
||||
} catch (Exception $e) {
|
||||
// users表不存在或查询失败,仅返回玩家名
|
||||
error('Token验证失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
success([
|
||||
'player' => $player,
|
||||
'user_data' => $userData
|
||||
], "登录成功");
|
||||
// ===== 推送玩家登录状态到PHP(Java插件调用)=====
|
||||
function pushPlayerLoginStatus() {
|
||||
// 优先从GET参数读取(Java端使用GET请求)
|
||||
if (isset($_GET['secret'])) {
|
||||
$secret = $_GET['secret'];
|
||||
} elseif (isset($_POST['secret'])) {
|
||||
$secret = $_POST['secret'];
|
||||
} else {
|
||||
$secret = getParam('secret');
|
||||
}
|
||||
|
||||
if (!$secret || $secret !== SECRET_KEY) {
|
||||
error('密钥验证失败', 403);
|
||||
}
|
||||
|
||||
if (isset($_GET['player'])) {
|
||||
$player = $_GET['player'];
|
||||
} elseif (isset($_POST['player'])) {
|
||||
$player = $_POST['player'];
|
||||
} else {
|
||||
$player = getParam('player');
|
||||
}
|
||||
|
||||
if (isset($_GET['web_token'])) {
|
||||
$webToken = $_GET['web_token'];
|
||||
} elseif (isset($_POST['web_token'])) {
|
||||
$webToken = $_POST['web_token'];
|
||||
} else {
|
||||
$webToken = getParam('web_token');
|
||||
}
|
||||
|
||||
$expireSeconds = 600;
|
||||
if (isset($_GET['expire_seconds'])) {
|
||||
$expireSeconds = (int)$_GET['expire_seconds'];
|
||||
} elseif (isset($_POST['expire_seconds'])) {
|
||||
$expireSeconds = (int)$_POST['expire_seconds'];
|
||||
} else {
|
||||
$val = getParam('expire_seconds');
|
||||
$expireSeconds = $val ? (int)$val : 600;
|
||||
}
|
||||
|
||||
$isOnline = 0;
|
||||
if (isset($_GET['online'])) {
|
||||
$isOnline = (int)$_GET['online'];
|
||||
} elseif (isset($_POST['online'])) {
|
||||
$isOnline = (int)$_POST['online'];
|
||||
} else {
|
||||
$val = getParam('online');
|
||||
$isOnline = $val ? (int)$val : 0;
|
||||
}
|
||||
|
||||
$isRegistered = 0;
|
||||
if (isset($_GET['registered'])) {
|
||||
$isRegistered = (int)$_GET['registered'];
|
||||
} elseif (isset($_POST['registered'])) {
|
||||
$isRegistered = (int)$_POST['registered'];
|
||||
} else {
|
||||
$val = getParam('registered');
|
||||
$isRegistered = $val ? (int)$val : 0;
|
||||
}
|
||||
|
||||
if (!$player || !$webToken) {
|
||||
@error_log("[pushPlayerLoginStatus] FAIL: missing player or web_token. player=" . var_export($player, true) . ", web_token=" . var_export($webToken, true));
|
||||
error('缺少player或web_token参数');
|
||||
}
|
||||
|
||||
@error_log("[pushPlayerLoginStatus] SUCCESS: player=$player, token=" . substr($webToken, 0, 20) . "..., online=$isOnline, registered=$isRegistered, expire=$expireSeconds");
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 1. 同步weblogin_tokens表(登录页面需要的token)
|
||||
$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)");
|
||||
$now = time();
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':expire', $expireSeconds, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
@error_log("[pushPlayerLoginStatus] Token stored: player=$player, created_at=$now, expire=$expireSeconds");
|
||||
|
||||
// 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)");
|
||||
$updateStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$updateStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$updateStmt->execute();
|
||||
|
||||
// 3. 同时更新web_session_log,标记Web会话活跃
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_session_log (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL, ip_address TEXT DEFAULT '')");
|
||||
$sessionStmt = $db->prepare("INSERT OR REPLACE INTO web_session_log (player_name, login_time, ip_address) VALUES (:player, :time, :ip)");
|
||||
$sessionStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$sessionStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$sessionStmt->bindValue(':ip', 'plugin_sync', SQLITE3_TEXT);
|
||||
$sessionStmt->execute();
|
||||
|
||||
// 如果已注册,更新users表的last_login_time
|
||||
if ($isRegistered) {
|
||||
try {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS users (player_name TEXT PRIMARY KEY, last_login_time INTEGER DEFAULT 0)");
|
||||
$userStmt = $db->prepare("UPDATE users SET last_login_time = :time WHERE player_name = :player");
|
||||
$userStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$userStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$userStmt->execute();
|
||||
} catch (Exception $e) {
|
||||
// users表可能不存在或其他错误,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
success(['player' => $player], '玩家登录状态已同步');
|
||||
}
|
||||
|
||||
// ===== 插件轮询Web登录确认(SECRET_KEY认证)=====
|
||||
@@ -919,6 +1213,8 @@ function webLoginRequest() {
|
||||
|
||||
$requestId = $db->lastInsertRowID();
|
||||
|
||||
debugLog("webLoginRequest: 新请求", ['player' => $player, 'request_id' => $requestId]);
|
||||
|
||||
success([
|
||||
'request_id' => $requestId,
|
||||
'player' => $player,
|
||||
@@ -936,6 +1232,7 @@ function checkWebLoginResult() {
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM web_login_requests WHERE id = :id AND player_name = :player");
|
||||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
@@ -943,9 +1240,17 @@ function checkWebLoginResult() {
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
error('请求不存在');
|
||||
// 请求不存在,可能已被清理
|
||||
debugLog("checkWebLoginResult: 请求不存在", ['player' => $player, 'request_id' => $requestId]);
|
||||
success([
|
||||
'status' => 'pending',
|
||||
'message' => '请求已过期,请刷新页面重试'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("checkWebLoginResult: 查询结果", ['player' => $player, 'request_id' => $requestId, 'status' => $row['status']]);
|
||||
|
||||
if ($row['status'] === 'pending') {
|
||||
// 还在等待
|
||||
success([
|
||||
@@ -969,6 +1274,9 @@ function checkWebLoginResult() {
|
||||
'message' => $resultData['error'] ?? '密码错误'
|
||||
]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error('数据库查询失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 插件轮询待处理的密码登录请求(SECRET_KEY认证)=====
|
||||
@@ -1006,26 +1314,32 @@ function checkPendingWebLogins() {
|
||||
|
||||
// ===== 插件写回密码验证结果(SECRET_KEY认证)=====
|
||||
function completeWebLoginRequest() {
|
||||
$secret = getParam('secret');
|
||||
// 优先从GET参数读取(Java端使用GET请求)
|
||||
$secret = isset($_GET['secret']) ? $_GET['secret'] : (isset($_POST['secret']) ? $_POST['secret'] : getParam('secret'));
|
||||
|
||||
if (!$secret || $secret !== SECRET_KEY) {
|
||||
error('密钥验证失败', 403);
|
||||
}
|
||||
|
||||
$requestId = getParam('request_id');
|
||||
$player = getParam('player');
|
||||
$result = getParam('result'); // JSON字符串
|
||||
$requestId = isset($_GET['request_id']) ? $_GET['request_id'] : (isset($_POST['request_id']) ? $_POST['request_id'] : getParam('request_id'));
|
||||
$player = isset($_GET['player']) ? $_GET['player'] : (isset($_POST['player']) ? $_POST['player'] : getParam('player'));
|
||||
$result = isset($_GET['result']) ? $_GET['result'] : (isset($_POST['result']) ? $_POST['result'] : getParam('result'));
|
||||
|
||||
debugLog("completeWebLoginRequest: 收到结果", ['request_id' => $requestId, 'player' => $player, 'result_raw' => $result]);
|
||||
|
||||
if (!$requestId || !$player) {
|
||||
error('缺少request_id或player参数');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT id FROM web_login_requests WHERE id = :id AND player_name = :player");
|
||||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray();
|
||||
|
||||
if (!$row) {
|
||||
debugLog("completeWebLoginRequest: 请求不存在", ['request_id' => $requestId, 'player' => $player]);
|
||||
error('请求不存在');
|
||||
}
|
||||
|
||||
@@ -1040,6 +1354,8 @@ function completeWebLoginRequest() {
|
||||
$updateStmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||||
$updateStmt->execute();
|
||||
|
||||
debugLog("completeWebLoginRequest: 更新成功", ['request_id' => $requestId, 'status' => $status]);
|
||||
|
||||
// 如果验证成功,写入web_login_confirmations和web_login_verified
|
||||
if ($status === 'success') {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)");
|
||||
@@ -1057,6 +1373,10 @@ function completeWebLoginRequest() {
|
||||
}
|
||||
|
||||
success(['status' => $status], "结果已写入");
|
||||
} catch (Exception $e) {
|
||||
debugLog("completeWebLoginRequest: 数据库异常", ['error' => $e->getMessage()]);
|
||||
error('数据库错误: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 插件推送玩家密码凭证(SECRET_KEY认证)=====
|
||||
@@ -1166,14 +1486,33 @@ function webAccessCheck() {
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$webToken) error('缺少web_token');
|
||||
if (!$webToken) {
|
||||
@error_log("[web_access_check] FAIL: no web_token provided");
|
||||
error('缺少web_token');
|
||||
}
|
||||
|
||||
debugLog("web_access_check: 开始验证", [
|
||||
'token' => substr($webToken, 0, 16) . '...',
|
||||
'action' => $accessAction,
|
||||
'ip' => $ipAddress,
|
||||
'has_password' => !empty($password)
|
||||
]);
|
||||
// ★ Debug: 记录完整的token信息
|
||||
@error_log("[web_access_check] Token: " . substr($webToken, 0, 20) . "... (len=" . strlen($webToken) . ")");
|
||||
@error_log("[web_access_check] Action: $accessAction, IP: $ipAddress");
|
||||
|
||||
// ★ 检查weblogin_tokens表是否存在及数据
|
||||
$db = getDB();
|
||||
try {
|
||||
$countStmt = $db->query("SELECT COUNT(*) as cnt FROM weblogin_tokens");
|
||||
$countRow = $countStmt->fetchArray(SQLITE3_ASSOC);
|
||||
@error_log("[web_access_check] weblogin_tokens table has " . ($countRow['cnt'] ?? 0) . " records");
|
||||
|
||||
// 列出所有token(前20个字符)
|
||||
$listStmt = $db->query("SELECT player_name, substr(web_token, 1, 20) as token_prefix, created_at, expire_seconds FROM weblogin_tokens ORDER BY created_at DESC LIMIT 10");
|
||||
while ($row = $listStmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$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");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
@error_log("[web_access_check] DB query error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$result = validateWebAccess($webToken, $accessAction, $password, $ipAddress);
|
||||
|
||||
@@ -1187,6 +1526,13 @@ function webAccessCheck() {
|
||||
]);
|
||||
|
||||
if ($result['ok']) {
|
||||
// 记录 session 日志(保存 IP 地址)
|
||||
$sessionToken = logWebSession(
|
||||
$result['player'] ?? '',
|
||||
$ipAddress,
|
||||
$result['session'] ?? ''
|
||||
);
|
||||
|
||||
success([
|
||||
'player' => $result['player'] ?? '',
|
||||
'mode' => $result['mode'] ?? '',
|
||||
@@ -1267,7 +1613,11 @@ function pullPendingTransactions() {
|
||||
// 立即标记为processing(防止5秒后重复拉取)
|
||||
if ($hasProcessedAt && !empty($ids)) {
|
||||
$idList = implode(',', $ids);
|
||||
try {
|
||||
$db->exec("UPDATE web_transactions SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||||
} catch (Exception $e) {
|
||||
// 数据库锁定异常,忽略(下一个请求会清理)
|
||||
}
|
||||
}
|
||||
|
||||
success(['transactions' => $transactions, 'count' => count($transactions)]);
|
||||
@@ -1345,14 +1695,85 @@ function notifySync() {
|
||||
success(['tx_id' => (int)$txId], '已通知插件立即同步交易数据');
|
||||
}
|
||||
|
||||
// ===== 客户端请求立即同步(玩家购物/CDK后调用)=====
|
||||
function requestImmediateSync() {
|
||||
// ★ 不需要secret验证,前端玩家直接调用
|
||||
$player = getParam('player');
|
||||
if (!$player) error('缺少player参数');
|
||||
|
||||
$db = getDB();
|
||||
// 记录同步请求(Java插件会轮询这个表)
|
||||
$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', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success(['sync_requested' => true], '已请求立即同步');
|
||||
}
|
||||
|
||||
// ===== 补发pending交易(Java重拉未确认交易)=====
|
||||
function resendPendingTransactions() {
|
||||
$secret = getParam('secret');
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
|
||||
$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));
|
||||
|
||||
$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)");
|
||||
}
|
||||
|
||||
success(['transactions' => $transactions, 'count' => count($transactions), 'resend' => true], '已补发' . count($transactions) . '笔pending交易');
|
||||
}
|
||||
|
||||
// ===== 邮箱脱敏工具 =====
|
||||
function maskEmail($email) {
|
||||
if (empty($email)) return '';
|
||||
$parts = explode('@', $email);
|
||||
if (count($parts) !== 2) return '**@' . ($parts[0] ?? '*');
|
||||
return '**@' . $parts[1];
|
||||
}
|
||||
|
||||
// ===== 发送邮箱验证码 =====
|
||||
function sendEmailCode() {
|
||||
$player = getParam('player');
|
||||
$email = getParam('email');
|
||||
|
||||
if (!$player) error('缺少player参数');
|
||||
if (!$email) error('缺少email参数');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) error('邮箱格式不正确');
|
||||
|
||||
// ★ 安全检查:玩家必须已注册
|
||||
if (!isPlayerRegistered($player)) {
|
||||
error('玩家未在游戏中注册,请先在游戏中使用 /register 注册账号');
|
||||
}
|
||||
|
||||
// ★ 从数据库读取玩家绑定的邮箱(唯一可信来源)
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||||
error('该玩家尚未绑定邮箱,请先在游戏中完成邮箱绑定');
|
||||
}
|
||||
|
||||
// ★ 邮箱只读:不允许前端传入,直接取数据库的值
|
||||
$email = $row['email'];
|
||||
$maskedEmail = maskEmail($email);
|
||||
|
||||
// 使用config.php中的SMTP配置
|
||||
$smtpHost = SMTP_HOST;
|
||||
@@ -1370,7 +1791,6 @@ function sendEmailCode() {
|
||||
$expireTime = time() + 300; // 5分钟有效
|
||||
|
||||
// 存储验证码到数据库
|
||||
$db = getDB();
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS email_codes (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, email TEXT NOT NULL, code TEXT NOT NULL, expire_at INTEGER NOT NULL, used INTEGER DEFAULT 0, created_at INTEGER NOT NULL)");
|
||||
|
||||
// 清理过期验证码
|
||||
@@ -1383,7 +1803,7 @@ function sendEmailCode() {
|
||||
$checkStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$checkResult = $checkStmt->execute();
|
||||
if ($checkResult->fetchArray()) {
|
||||
error('验证码已发送,请查看邮箱(5分钟内有效)');
|
||||
error('验证码已发送至 ' . $maskedEmail . ',请查看邮箱(5分钟内有效)');
|
||||
}
|
||||
|
||||
// 插入新验证码
|
||||
@@ -1416,10 +1836,10 @@ function sendEmailCode() {
|
||||
$sent = smtpSendEmail($smtpHost, $smtpPort, $smtpUser, $smtpPass, $email, $subject, $htmlBody, $headers, SMTP_USE_SSL);
|
||||
|
||||
if ($sent) {
|
||||
success(['player' => $player, 'email' => $email, 'code' => $code], '验证码已发送到邮箱');
|
||||
success(['player' => $player, 'masked_email' => $maskedEmail], '验证码已发送至 ' . $maskedEmail);
|
||||
} else {
|
||||
// SMTP失败,返回验证码供调试
|
||||
success(['player' => $player, 'email' => $email, 'code' => $code], '邮件发送中(验证码:' . $code . ')');
|
||||
success(['player' => $player, 'masked_email' => $maskedEmail, 'code' => $code], '邮件发送中(验证码:' . $code . ')');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error('邮件发送失败:' . $e->getMessage());
|
||||
@@ -1429,12 +1849,10 @@ function sendEmailCode() {
|
||||
// ===== 验证邮箱验证码 =====
|
||||
function verifyEmailCode() {
|
||||
$player = getParam('player');
|
||||
$email = getParam('email');
|
||||
$code = getParam('code');
|
||||
$webToken = getParam('web_token');
|
||||
|
||||
if (!$player) error('缺少player参数');
|
||||
if (!$email) error('缺少email参数');
|
||||
if (!$code) error('缺少验证码');
|
||||
if (!$webToken) error('缺少web_token');
|
||||
|
||||
@@ -1443,16 +1861,27 @@ function verifyEmailCode() {
|
||||
error('玩家未在游戏中注册,请先在游戏中使用 /register 注册账号');
|
||||
}
|
||||
|
||||
// 验证web_token
|
||||
$tokenInfo = validateToken($webToken);
|
||||
// ★ 验证token(weblogin token或普通token都可以)
|
||||
$tokenInfo = validateTokenSilent($webToken);
|
||||
if (!$tokenInfo) error('无效的登录token');
|
||||
|
||||
// 验证玩家名是否匹配
|
||||
if ($tokenInfo['player'] !== $player) error('玩家名不匹配');
|
||||
|
||||
// ★ 从数据库读取玩家绑定的邮箱(唯一可信来源)
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
// 查找未使用且未过期的验证码
|
||||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||||
error('该玩家尚未绑定邮箱');
|
||||
}
|
||||
|
||||
$email = $row['email'];
|
||||
|
||||
// 查找未使用且未过期的验证码(只验证该玩家自己的邮箱记录)
|
||||
$stmt = $db->prepare("SELECT * FROM email_codes WHERE player_name = :player AND email = :email AND code = :code AND expire_at > :time AND used = 0");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||||
@@ -1495,6 +1924,33 @@ function verifyEmailCode() {
|
||||
], '邮箱验证成功');
|
||||
}
|
||||
|
||||
// ===== 获取玩家绑定的邮箱(脱敏) =====
|
||||
function getPlayerEmail() {
|
||||
$player = getParam('player');
|
||||
if (!$player) error('缺少player参数');
|
||||
|
||||
// 检查玩家是否注册
|
||||
if (!isPlayerRegistered($player)) {
|
||||
error('玩家未注册');
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||||
success(['email' => '', 'masked_email' => ''], '未绑定邮箱');
|
||||
}
|
||||
|
||||
$email = $row['email'];
|
||||
success([
|
||||
'email' => $email,
|
||||
'masked_email' => maskEmail($email)
|
||||
], '获取成功');
|
||||
}
|
||||
|
||||
// ===== 修复库存数据(stock=0→-1) =====
|
||||
function fixStock() {
|
||||
$secret = getParam('secret');
|
||||
@@ -1840,11 +2296,9 @@ function checkPendingWebRegisterRequests() {
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
// ★ 不再使用 CREATE TABLE IF NOT EXISTS,表结构已在 initTables 中创建
|
||||
try {
|
||||
$db->exec("SELECT id FROM web_register_requests LIMIT 0");
|
||||
} catch (Exception $e) {
|
||||
// 表不存在则创建(仅首次)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_register_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
@@ -1858,10 +2312,8 @@ function checkPendingWebRegisterRequests() {
|
||||
)");
|
||||
}
|
||||
|
||||
// 清理30分钟前的过期请求(防止永久堆积)
|
||||
$db->exec("DELETE FROM web_register_requests WHERE created_at < " . (time() - 1800));
|
||||
|
||||
// 获取所有pending请求
|
||||
$stmt = $db->prepare("SELECT id, player_name, password_hash, salt, email, ip_address, created_at FROM web_register_requests WHERE status = 'pending' ORDER BY created_at ASC");
|
||||
$result = $stmt->execute();
|
||||
|
||||
@@ -1872,10 +2324,16 @@ function checkPendingWebRegisterRequests() {
|
||||
$ids[] = (int)$row['id'];
|
||||
}
|
||||
|
||||
// 立即标记为processing(防止重复拉取)
|
||||
// 使用事务标记为processing
|
||||
if (!empty($ids)) {
|
||||
try {
|
||||
$db->exec('BEGIN TRANSACTION');
|
||||
$idList = implode(',', $ids);
|
||||
$db->exec("UPDATE web_register_requests SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
success(['requests' => $requests, 'count' => count($requests)]);
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ define('GAME_LOGIN_DB', 'D:/服务器/插件/login.db');
|
||||
define('GAME_SHOP_DIR', 'D:/服务器/插件/shop/');
|
||||
|
||||
// ===== Web子目录路径(如 /plugin 或 /test1,根目录则留空) =====
|
||||
define('WEBSUB_DIR', '/test1');
|
||||
define('WEBSUB_DIR', '/plugin');
|
||||
|
||||
// ===== Java插件回调端口 =====
|
||||
define('CALLBACK_PORT', 9090);
|
||||
|
||||
@@ -33,15 +33,15 @@ function getDB() {
|
||||
// 确保db目录存在
|
||||
$dbDir = dirname(DB_PATH);
|
||||
if (!is_dir($dbDir)) {
|
||||
mkdir($dbDir, 0755, true);
|
||||
@mkdir($dbDir, 0755, true);
|
||||
}
|
||||
$db = new SQLite3(DB_PATH);
|
||||
$db->enableExceptions(true);
|
||||
// 减少 PRAGMA 调用,避免可能的锁问题
|
||||
$db->exec('PRAGMA journal_mode=WAL');
|
||||
$db->exec('PRAGMA busy_timeout=10000');
|
||||
$db->exec('PRAGMA busy_timeout=15000'); // 数据库锁等待15秒
|
||||
$db->exec('PRAGMA synchronous=NORMAL');
|
||||
$db->exec('PRAGMA cache_size=-64000');
|
||||
$db->exec('PRAGMA temp_store=MEMORY');
|
||||
$db->exec('PRAGMA cache_size=-64000'); // 64MB 缓存
|
||||
initTables($db);
|
||||
migrateDatabase($db); // 迁移旧数据库
|
||||
}
|
||||
@@ -271,6 +271,30 @@ function initTables(SQLite3 $db) {
|
||||
processed_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// ★ 玩家IP变更历史表(admin.php 批量查IP用)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
new_ip TEXT NOT NULL,
|
||||
changed_at INTEGER NOT NULL
|
||||
)");
|
||||
|
||||
// ★ IP归属地缓存表(admin.php IP查询用)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
location TEXT DEFAULT '',
|
||||
updated_at INTEGER DEFAULT 0,
|
||||
UNIQUE(ip_address)
|
||||
)");
|
||||
|
||||
// ★ 创建索引以提高查询性能
|
||||
$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_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('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
@@ -278,6 +302,61 @@ function initTables(SQLite3 $db) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IP归属地格式校验(统一函数,所有脚本共用) =====
|
||||
// 支持格式:
|
||||
// 标准:广东省广州市 移动、北京市 海淀区 电信
|
||||
// 无后缀:湖北武汉 移通、广东广州 联通
|
||||
// 直辖市:北京市 海淀区、上海 移动
|
||||
// 自治区:广西南宁 移动、内蒙古呼和浩特市 移动
|
||||
// 自治州:四川省凉山州 移通
|
||||
function isValidIpLocationFormat($loc) {
|
||||
if (!$loc || $loc === '' || $loc === '-' || $loc === '--' || $loc === 'NULL') return false;
|
||||
if ($loc === '内网IP' || $loc === '查询失败' || $loc === '查询中') return false;
|
||||
if (stripos($loc, 'China') === 0) return false;
|
||||
if (strpos($loc, '中国 - -') === 0 || strpos($loc, '中国 - -') === 0) return false;
|
||||
if (preg_match('/^中国\s*$/u', $loc) || preg_match('/^中国\s+[^\s省市区州盟]+$/u', $loc)) return false;
|
||||
if (strpos($loc, ' ') === false) return false;
|
||||
|
||||
// ★ 拆分为"位置部分"和"运营商部分"(以第一个空格分隔)
|
||||
$parts = explode(' ', $loc, 2);
|
||||
$locationPart = trim($parts[0]);
|
||||
$operatorPart = trim($parts[1] ?? '');
|
||||
|
||||
// 位置部分不能为空
|
||||
if (empty($locationPart)) return false;
|
||||
|
||||
// 直辖市特殊处理(4个直辖市名称只有2个字)
|
||||
$directControlledCities = ['北京', '天津', '上海', '重庆'];
|
||||
foreach ($directControlledCities as $city) {
|
||||
if ($locationPart === $city || $locationPart === $city . '市') {
|
||||
return true; // "北京 移动" 或 "北京市 海淀区" 都有效
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 有后缀格式验证
|
||||
// 城市级后缀:武汉市、凉山州、呼和浩特市、朝阳区、安新县 → 直接有效
|
||||
if (preg_match('/(市|州|盟|地区|区|县)$/', $locationPart)) {
|
||||
return true;
|
||||
}
|
||||
// 省级后缀:广东省、湖北省 → 必须同时有城市级后缀(广东省广州市 ✅,湖北省 ❌)
|
||||
if (preg_match('/(省|自治区|内蒙古)$/', $locationPart)) {
|
||||
// 检查"省"后面是否还有城市名(如"广东省广州市"中的"广州市")
|
||||
if (preg_match('/(省|自治区|内蒙古)(.*?(市|州|盟|地区|区|县))$/', $locationPart)) {
|
||||
return true; // "广东省广州市"、"四川省凉山州" → 有效
|
||||
}
|
||||
return false; // "湖北省"、"广东省" → 只有省份无城市,无效
|
||||
}
|
||||
|
||||
// ★ 无后缀格式:如"湖北武汉"、"广东广州"、"四川凉山"
|
||||
// 省份名(2-3字) + 城市名(2-4字),至少4字
|
||||
// 匹配:湖北武汉、广东广州、四川凉山、山东济南、河北石家庄
|
||||
if (preg_match('/^[\x{4e00}-\x{9fa5}]{2,3}[\x{4e00}-\x{9fa5}]{2,4}$/u', $locationPart) && mb_strlen($locationPart) >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===== Token操作 =====
|
||||
|
||||
/**
|
||||
@@ -313,6 +392,33 @@ function validateToken($token) {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证weblogin token(游戏内登录token,用于web登录流程)
|
||||
*/
|
||||
function validateWebloginToken($webToken) {
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row) return false;
|
||||
|
||||
$createdAt = (int)$row['created_at'];
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
if (time() - $createdAt > $expireSeconds) return false;
|
||||
|
||||
return [
|
||||
'player' => $row['player_name'],
|
||||
'purpose' => 'weblogin',
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $expireSeconds
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token并标记已使用(一次性)
|
||||
*/
|
||||
@@ -440,7 +546,9 @@ function requireToken($purpose = 'general') {
|
||||
}
|
||||
|
||||
function requireAdminSession() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (!isset($_SESSION['admin_auth']) || !$_SESSION['admin_auth']) {
|
||||
error('未登录管理后台', 401);
|
||||
}
|
||||
@@ -452,14 +560,18 @@ function adminLogin($password) {
|
||||
if ($password !== ADMIN_PASS) {
|
||||
return false;
|
||||
}
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$_SESSION['admin_auth'] = true;
|
||||
$_SESSION['admin_login_time'] = time();
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAdminLoggedIn() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
return isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
|
||||
}
|
||||
|
||||
@@ -687,14 +799,17 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
debugLog("validateWebAccess: 无效的登录Token", ['token' => substr($webToken, 0, 16) . '...']);
|
||||
@error_log("[validateWebAccess] DENIED: token not found in DB. Token prefix: " . substr($webToken, 0, 20) . "...");
|
||||
return ['ok' => false, 'mode' => 'denied', 'message' => '无效的登录Token'];
|
||||
}
|
||||
|
||||
$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");
|
||||
|
||||
if (time() - $createdAt > $expireSeconds) {
|
||||
debugLog("validateWebAccess: 登录Token已过期", ['player' => $row['player_name'], 'created' => $createdAt, 'expire' => $expireSeconds]);
|
||||
@error_log("[validateWebAccess] DENIED: token expired. Age: $age, Expire: $expireSeconds");
|
||||
return ['ok' => false, 'mode' => 'denied', 'message' => '登录Token已过期'];
|
||||
}
|
||||
|
||||
@@ -719,13 +834,13 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd
|
||||
$isOnline = false;
|
||||
}
|
||||
|
||||
// ★ 如果 online_players 表没有数据,再检查 web_session_log(5分钟内的Web会话视为在线)
|
||||
// ★ 如果 online_players 表没有数据,再检查 web_session_log(1分钟内的Web会话视为在线)
|
||||
if (!$isOnline) {
|
||||
try {
|
||||
$fiveMinutesAgo = time() - 300;
|
||||
$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', $fiveMinutesAgo, SQLITE3_INTEGER);
|
||||
$sessionStmt->bindValue(':time', $oneMinuteAgo, SQLITE3_INTEGER);
|
||||
$sessionResult = $sessionStmt->execute();
|
||||
$sessionRow = $sessionResult->fetchArray();
|
||||
if ($sessionRow) {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
// ★ IP归属地清理脚本 v2 - 使用统一校验函数
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('max_execution_time', 300);
|
||||
|
||||
require_once __DIR__ . '/../core.php';
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>IP归属地清理 v2</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #0f0; }
|
||||
.ok { color: #0f0; }
|
||||
.err { color: #f00; }
|
||||
.warn { color: #ff0; }
|
||||
.info { color: #00f; }
|
||||
pre { background: #333; padding: 10px; overflow-x: auto; max-height: 300px; overflow-y: auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
|
||||
th, td { border: 1px solid #444; padding: 4px 8px; text-align: left; font-size: 12px; }
|
||||
th { background: #333; }
|
||||
.row-invalid { background: #300; }
|
||||
.row-valid { background: #030; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 IP归属地清理工具 v2</h1>
|
||||
|
||||
<?php
|
||||
try {
|
||||
$db = getDB();
|
||||
|
||||
echo "<h2>0. 当前数据库状态</h2>";
|
||||
$totalBefore = $db->query("SELECT COUNT(*) as cnt FROM player_ip_locations")->fetchArray(SQLITE3_ASSOC)['cnt'] ?? 0;
|
||||
echo "<p>清理前总记录数: $totalBefore</p>";
|
||||
|
||||
// ★ 列出所有记录 + 逐条验证
|
||||
echo "<h2>📋 全部记录逐条验证(使用统一校验函数 isValidIpLocationFormat)</h2>";
|
||||
echo "<table><tr><th>IP</th><th>位置</th><th>验证结果</th><th>删除原因</th></tr>";
|
||||
|
||||
$allStmt = $db->query("SELECT ip_address, location FROM player_ip_locations ORDER BY ip_address");
|
||||
$records = [];
|
||||
while ($row = $allStmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$records[] = $row;
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
$checkedCount = 0;
|
||||
|
||||
foreach ($records as $row) {
|
||||
$ip = $row['ip_address'];
|
||||
$loc = $row['location'];
|
||||
$checkedCount++;
|
||||
|
||||
// ★ 直接使用统一校验函数
|
||||
$isValid = isValidIpLocationFormat($loc);
|
||||
|
||||
// 判断删除原因(用于显示)
|
||||
$reason = '';
|
||||
if (!$isValid) {
|
||||
if ($loc === '' || $loc === null || $loc === 'NULL' || $loc === '-' || $loc === '--') {
|
||||
$reason = "空值/短杠";
|
||||
} elseif ($loc === '查询失败' || $loc === '查询中' || $loc === '内网IP') {
|
||||
$reason = "特殊标记";
|
||||
} elseif (stripos($loc, 'China') === 0) {
|
||||
$reason = "英文格式";
|
||||
} elseif (strpos($loc, '中国 - -') === 0 || strpos($loc, '中国 - -') === 0) {
|
||||
$reason = "不完整中文格式";
|
||||
} elseif (preg_match('/^中国\s*$/u', $loc) || preg_match('/^中国\s+[^\s省市区州盟]+$/u', $loc)) {
|
||||
$reason = "不完整中文格式";
|
||||
} elseif (strpos($loc, ' ') === false) {
|
||||
$reason = "无空格";
|
||||
} else {
|
||||
$reason = "省市格式不符合";
|
||||
}
|
||||
}
|
||||
|
||||
$shouldDelete = !$isValid;
|
||||
$funcStr = $isValid ? '✅' : '❌';
|
||||
$rowClass = $shouldDelete ? 'row-invalid' : 'row-valid';
|
||||
|
||||
echo "<tr class='$rowClass'>";
|
||||
echo "<td>$ip</td>";
|
||||
echo "<td>" . htmlspecialchars($loc) . "</td>";
|
||||
echo "<td>$funcStr</td>";
|
||||
echo "<td>" . ($shouldDelete ? "<b class='err'>$reason</b>" : '-') . "</td>";
|
||||
echo "</tr>";
|
||||
|
||||
if ($shouldDelete) {
|
||||
$delStmt = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$delStmt->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$delStmt->execute();
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "</table>";
|
||||
|
||||
echo "<p class='info'>检查了 $checkedCount 条记录</p>";
|
||||
if ($deletedCount > 0) {
|
||||
echo "<p class='ok'>✅ 删除 $deletedCount 条无效记录</p>";
|
||||
} else {
|
||||
echo "<p class='ok'>无无效记录</p>";
|
||||
}
|
||||
|
||||
// 统计
|
||||
$totalAfter = $db->query("SELECT COUNT(*) as cnt FROM player_ip_locations")->fetchArray(SQLITE3_ASSOC)['cnt'] ?? 0;
|
||||
echo "<p>清理前: $totalBefore → 清理后: $totalAfter</p>";
|
||||
|
||||
// 需要重新查询的IP(过滤掉短杠和空值)
|
||||
echo "<hr><h2>6. 需要重新查询的IP(player_ip_changes 中有但 player_ip_locations 中没有的)</h2>";
|
||||
$needQuery = $db->query("
|
||||
SELECT DISTINCT pic.new_ip
|
||||
FROM player_ip_changes pic
|
||||
LEFT JOIN player_ip_locations pil ON pic.new_ip = pil.ip_address
|
||||
WHERE pil.ip_address IS NULL
|
||||
AND pic.new_ip IS NOT NULL
|
||||
AND pic.new_ip != ''
|
||||
AND pic.new_ip NOT IN ('-', '--')
|
||||
AND pic.new_ip LIKE '%.%'
|
||||
ORDER BY pic.new_ip
|
||||
LIMIT 50
|
||||
");
|
||||
$needQueryList = [];
|
||||
while ($row = $needQuery->fetchArray(SQLITE3_ASSOC)) {
|
||||
$needQueryList[] = $row['new_ip'];
|
||||
}
|
||||
echo "<p>找到 " . count($needQueryList) . " 个需要查询的IP</p>";
|
||||
if (!empty($needQueryList)) {
|
||||
echo "<pre>";
|
||||
foreach ($needQueryList as $ip) {
|
||||
echo $ip . "\n";
|
||||
}
|
||||
echo "</pre>";
|
||||
}
|
||||
|
||||
// 额外:player_ip_locations 中存储了短杠位置的记录
|
||||
echo "<h2>7. 数据库中存储了短杠位置的记录</h2>";
|
||||
$dashLocQuery = $db->query("SELECT ip_address, location FROM player_ip_locations WHERE location IN ('-', '--', '') OR location IS NULL");
|
||||
$dashLocCount = 0;
|
||||
while ($row = $dashLocQuery->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "<p>" . $row['ip_address'] . " => " . htmlspecialchars($row['location'] ?? 'NULL') . "</p>";
|
||||
$dashLocCount++;
|
||||
}
|
||||
if ($dashLocCount === 0) {
|
||||
echo "<p class='ok'>无短杠位置记录</p>";
|
||||
} else {
|
||||
echo "<p class='warn'>找到 $dashLocCount 条短杠位置记录,将清理</p>";
|
||||
$db->exec("DELETE FROM player_ip_locations WHERE location IN ('-', '--', '') OR location IS NULL");
|
||||
echo "<p class='ok'>已清理短杠位置记录</p>";
|
||||
}
|
||||
|
||||
echo "<hr><p class='ok'>✅ 清理完成!请刷新用户管理页面查看效果。</p>";
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "<p class='err'>错误: " . $e->getMessage() . "</p>";
|
||||
echo "<pre>" . $e->getTraceAsString() . "</pre>";
|
||||
}
|
||||
?>
|
||||
|
||||
<hr>
|
||||
<p>💡 清理规则(使用统一校验函数 isValidIpLocationFormat):</p>
|
||||
<ol>
|
||||
<li>空值、短杠、双短杠、NULL</li>
|
||||
<li>查询失败、查询中、内网IP</li>
|
||||
<li>英文格式(China - xxx)</li>
|
||||
<li>不完整的中文格式(中国 - -xxx)</li>
|
||||
<li>无空格(缺少运营商分隔)</li>
|
||||
<li>直辖市缺少"市"(如"北京 移动"→应为"北京市 移动")</li>
|
||||
<li>非直辖市缺少城市后缀(如"湖北省 移动"→应为"湖北省武汉市 移动")</li>
|
||||
<li>支持无后缀格式(如"湖北武汉 移通")</li>
|
||||
</ol>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,615 @@
|
||||
<?php
|
||||
// ★ IP查询调试页面 v2 - 完整追踪查询流程
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('max_execution_time', 120);
|
||||
|
||||
// ★ 关键:在任何输出之前设置缓冲区
|
||||
ob_start();
|
||||
|
||||
// 不要包含 admin.php(它会输出JSON并exit)
|
||||
// 只包含 core.php
|
||||
require_once __DIR__ . '/../core.php';
|
||||
|
||||
// 清空输出缓冲区
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// 现在开始输出HTML
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>IP查询调试 v2</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #0f0; }
|
||||
.ok { color: #0f0; }
|
||||
.err { color: #f00; }
|
||||
.warn { color: #ff0; }
|
||||
.info { color: #00f; }
|
||||
pre { background: #333; padding: 10px; overflow-x: auto; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.step { margin: 10px 0; padding: 10px; border-left: 3px solid #0f0; }
|
||||
.step.error { border-left-color: #f00; }
|
||||
.step.warn { border-left-color: #ff0; }
|
||||
.api-result { margin: 5px 0; padding: 5px; background: #222; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 IP查询调试页面 v2</h1>
|
||||
|
||||
<?php
|
||||
// 获取要测试的IP(支持逗号分隔多IP)
|
||||
$allIps = [];
|
||||
$ipParam = trim($_GET['ip'] ?? '');
|
||||
if ($ipParam) {
|
||||
$allIps = array_map('trim', explode(',', $ipParam));
|
||||
}
|
||||
$ip2Param = trim($_GET['ip2'] ?? '');
|
||||
if ($ip2Param) {
|
||||
$allIps = array_merge($allIps, array_map('trim', explode(',', $ip2Param)));
|
||||
}
|
||||
// 去重并过滤空值
|
||||
$allIps = array_unique(array_filter($allIps));
|
||||
if (empty($allIps)) {
|
||||
$allIps = ['36.148.150.193', '120.231.236.80'];
|
||||
}
|
||||
|
||||
echo "<h2>测试IP (" . count($allIps) . "个): " . implode(', ', $allIps) . "</h2>";
|
||||
|
||||
// ===== 第1步:检查数据库中的当前状态 =====
|
||||
echo "<div class='step'>";
|
||||
echo "<h3>📊 步骤1: 检查数据库当前状态</h3>";
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
|
||||
foreach ($allIps as $ip) {
|
||||
echo "<p><strong>$ip:</strong></p>";
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
echo "<pre>" . json_encode($row, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "</pre>";
|
||||
} else {
|
||||
echo "<p class='warn'>数据库中无此IP记录</p>";
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "<p class='err'>错误: " . $e->getMessage() . "</p>";
|
||||
}
|
||||
echo "</div>";
|
||||
|
||||
// ===== 第2步:测试4个API接口(直接调用,不包含admin.php) =====
|
||||
echo "<div class='step'>";
|
||||
echo "<h3>🌐 步骤2: 测试4个API接口</h3>";
|
||||
|
||||
// ★ 直接定义函数,不包含 admin.php
|
||||
function testFetchPconline($ip) {
|
||||
$url = 'https://whois.pconline.com.cn/ipJson.jsp?ip=' . urlencode($ip) . '&json=true';
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 5,
|
||||
'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);
|
||||
|
||||
$result = [
|
||||
'api' => 'PConline',
|
||||
'url' => $url,
|
||||
'raw_response' => $response ?: 'FAILED',
|
||||
'parsed' => null,
|
||||
'location' => null,
|
||||
'error' => null
|
||||
];
|
||||
|
||||
if ($response === false || empty(trim($response))) {
|
||||
$result['error'] = '请求失败';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ★ 修复编码问题:太平洋返回GBK编码,需要转换为UTF-8
|
||||
$response = trim($response);
|
||||
if (mb_detect_encoding($response, 'UTF-8', true) === false) {
|
||||
$response = mb_convert_encoding($response, 'UTF-8', 'GBK');
|
||||
$result['raw_response'] = $response; // 更新原始响应为转换后的
|
||||
}
|
||||
|
||||
$json = $response;
|
||||
if (preg_match('/^\s*\(?[\'"]?(.*?)[\'"]?\)?\s*$/s', $json, $matches)) {
|
||||
$json = $matches[1];
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
$result['parsed'] = $data;
|
||||
|
||||
if (!$data || !is_array($data)) {
|
||||
$result['error'] = 'JSON解析失败';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 优先使用 addr 字段
|
||||
if (isset($data['addr']) && trim($data['addr']) !== '') {
|
||||
$addr = trim($data['addr']);
|
||||
if (strpos($addr, '中国 ') !== 0) {
|
||||
$result['location'] = $addr;
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试用 pro + city 手动拼接
|
||||
if (isset($data['pro']) && trim($data['pro']) !== '') {
|
||||
$loc = trim($data['pro']);
|
||||
if (isset($data['city']) && trim($data['city']) !== '') {
|
||||
$loc .= trim($data['city']);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['location'] = strlen($loc) > 1 ? $loc : null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function testFetchBaidu($ip) {
|
||||
$url = 'https://opendata.baidu.com/api.php?query=' . urlencode($ip) . '&resource_id=6006&oe=utf8&format=json';
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
'header' => "Accept: application/json\r\nUser-Agent: Mozilla/5.0"
|
||||
]
|
||||
]);
|
||||
$response = @file_get_contents($url, false, $ctx);
|
||||
|
||||
$result = [
|
||||
'api' => 'Baidu',
|
||||
'url' => $url,
|
||||
'raw_response' => $response ?: 'FAILED',
|
||||
'parsed' => null,
|
||||
'location' => null,
|
||||
'error' => null
|
||||
];
|
||||
|
||||
if ($response === false || empty(trim($response))) {
|
||||
$result['error'] = '请求失败';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$data = json_decode(trim($response), true);
|
||||
$result['parsed'] = $data;
|
||||
|
||||
if ($data && isset($data['data']) && is_array($data['data']) && !empty($data['data'])) {
|
||||
$loc = $data['data'][0]['location'] ?? null;
|
||||
if ($loc && $loc !== '--' && strpos($loc, '中国 ') !== 0) {
|
||||
$result['location'] = $loc;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function testFetchAa1($ip) {
|
||||
$url = 'https://v.api.aa1.cn/api/ipcha-baidu/?ip=' . urlencode($ip);
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
'header' => "Accept: application/json\r\nUser-Agent: Mozilla/5.0"
|
||||
]
|
||||
]);
|
||||
$response = @file_get_contents($url, false, $ctx);
|
||||
|
||||
$result = [
|
||||
'api' => 'aa1.cn',
|
||||
'url' => $url,
|
||||
'raw_response' => $response ?: 'FAILED',
|
||||
'parsed' => null,
|
||||
'location' => null,
|
||||
'error' => null
|
||||
];
|
||||
|
||||
if ($response === false || empty(trim($response))) {
|
||||
$result['error'] = '请求失败';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$data = json_decode(trim($response), true);
|
||||
$result['parsed'] = $data;
|
||||
|
||||
if ($data && isset($data['code']) && $data['code'] == 1) {
|
||||
$loc = $data['ip_add'] ?? null;
|
||||
if ($loc && $loc !== '未知' && strpos($loc, '中国 ') !== 0) {
|
||||
$result['location'] = $loc;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function testFetchIp9($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
|
||||
]
|
||||
]);
|
||||
$response = @file_get_contents($url, false, $ctx);
|
||||
|
||||
$result = [
|
||||
'api' => 'IP9',
|
||||
'url' => $url,
|
||||
'raw_response' => $response ?: 'FAILED',
|
||||
'parsed' => null,
|
||||
'location' => null,
|
||||
'error' => null
|
||||
];
|
||||
|
||||
if ($response === false || empty(trim($response))) {
|
||||
$result['error'] = '请求失败';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$data = json_decode(trim($response), true);
|
||||
$result['parsed'] = $data;
|
||||
|
||||
$debug = [];
|
||||
|
||||
// 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];
|
||||
$debug['format'] = 'array';
|
||||
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data']['prov'])) {
|
||||
$item = $data['data'];
|
||||
$debug['format'] = 'object';
|
||||
} else {
|
||||
$debug['format'] = 'unknown';
|
||||
$debug['data_type'] = gettype($data['data'] ?? null);
|
||||
}
|
||||
|
||||
if ($item) {
|
||||
$loc = '';
|
||||
$debug['item_prov'] = $item['prov'] ?? 'NOT_SET';
|
||||
$debug['item_city'] = $item['city'] ?? 'NOT_SET';
|
||||
$debug['item_isp'] = $item['isp'] ?? 'NOT_SET';
|
||||
|
||||
if (isset($item['prov'])) $loc .= $item['prov'];
|
||||
if (isset($item['city'])) $loc .= $item['city'];
|
||||
if (isset($item['isp'])) $loc .= ' ' . $item['isp'];
|
||||
|
||||
$debug['loc'] = $loc;
|
||||
$debug['loc_len'] = strlen($loc);
|
||||
|
||||
if (strlen($loc) > 0) {
|
||||
$result['location'] = $loc;
|
||||
}
|
||||
}
|
||||
|
||||
$result['debug'] = $debug;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 测试每个IP
|
||||
foreach ($allIps as $ip) {
|
||||
echo "<h4>测试IP: $ip</h4>";
|
||||
|
||||
// API1: PConline
|
||||
echo "<div class='api-result'>";
|
||||
echo "<p><strong>API1 - PConline:</strong></p>";
|
||||
$start = microtime(true);
|
||||
$r1 = testFetchPconline($ip);
|
||||
$time1 = (microtime(true) - $start) * 1000;
|
||||
echo "<pre>";
|
||||
echo "耗时: " . round($time1, 2) . "ms\n";
|
||||
echo "原始响应: " . substr($r1['raw_response'], 0, 500) . "\n";
|
||||
echo "解析结果: " . json_encode($r1['parsed'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo "提取位置: " . ($r1['location'] ?? 'null') . "\n";
|
||||
echo "错误: " . ($r1['error'] ?? '无');
|
||||
echo "</pre>";
|
||||
echo "</div>";
|
||||
|
||||
// API2: Baidu
|
||||
echo "<div class='api-result'>";
|
||||
echo "<p><strong>API2 - Baidu:</strong></p>";
|
||||
$start = microtime(true);
|
||||
$r2 = testFetchBaidu($ip);
|
||||
$time2 = (microtime(true) - $start) * 1000;
|
||||
echo "<pre>";
|
||||
echo "耗时: " . round($time2, 2) . "ms\n";
|
||||
echo "原始响应: " . substr($r2['raw_response'], 0, 500) . "\n";
|
||||
echo "解析结果: " . json_encode($r2['parsed'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo "提取位置: " . ($r2['location'] ?? 'null') . "\n";
|
||||
echo "错误: " . ($r2['error'] ?? '无');
|
||||
echo "</pre>";
|
||||
echo "</div>";
|
||||
|
||||
// API3: aa1.cn
|
||||
echo "<div class='api-result'>";
|
||||
echo "<p><strong>API3 - aa1.cn:</strong></p>";
|
||||
$start = microtime(true);
|
||||
$r3 = testFetchAa1($ip);
|
||||
$time3 = (microtime(true) - $start) * 1000;
|
||||
echo "<pre>";
|
||||
echo "耗时: " . round($time3, 2) . "ms\n";
|
||||
echo "原始响应: " . substr($r3['raw_response'], 0, 500) . "\n";
|
||||
echo "解析结果: " . json_encode($r3['parsed'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo "提取位置: " . ($r3['location'] ?? 'null') . "\n";
|
||||
echo "错误: " . ($r3['error'] ?? '无');
|
||||
echo "</pre>";
|
||||
echo "</div>";
|
||||
|
||||
// API4: IP9
|
||||
echo "<div class='api-result'>";
|
||||
echo "<p><strong>API4 - IP9:</strong></p>";
|
||||
$start = microtime(true);
|
||||
$r4 = testFetchIp9($ip);
|
||||
$time4 = (microtime(true) - $start) * 1000;
|
||||
echo "<pre>";
|
||||
echo "耗时: " . round($time4, 2) . "ms\n";
|
||||
echo "原始响应: " . substr($r4['raw_response'], 0, 500) . "\n";
|
||||
echo "解析结果: " . json_encode($r4['parsed'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo "提取位置: " . ($r4['location'] ?? 'null') . "\n";
|
||||
echo "错误: " . ($r4['error'] ?? '无') . "\n";
|
||||
echo "调试信息: " . json_encode($r4['debug'] ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
echo "</pre>";
|
||||
echo "</div>";
|
||||
|
||||
echo "<hr>";
|
||||
}
|
||||
echo "</div>";
|
||||
|
||||
// ===== 第3步:模拟入库逻辑 =====
|
||||
echo "<div class='step'>";
|
||||
echo "<h3>🔄 步骤3: 模拟入库逻辑</h3>";
|
||||
|
||||
echo "<p>以下是服务器处理IP归属地的完整流程:</p>";
|
||||
|
||||
// 模拟 queryIpLocation 函数的逻辑
|
||||
foreach ($allIps as $ip) {
|
||||
echo "<h4>模拟IP: $ip</h4>";
|
||||
|
||||
echo "<p><strong>1. 检查是否是内网IP</strong></p>";
|
||||
if (!$ip || $ip === '-' || $ip === '127.0.0.1' || strpos($ip, '10.') === 0 || strpos($ip, '192.168.') === 0) {
|
||||
echo "<p class='warn'>是内网IP,返回 '内网IP'</p>";
|
||||
continue;
|
||||
}
|
||||
echo "<p>不是内网IP,继续查询</p>";
|
||||
|
||||
echo "<p><strong>2. 检查数据库缓存</strong></p>";
|
||||
$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'];
|
||||
echo "<p>找到缓存: $cachedLoc</p>";
|
||||
|
||||
// ★ 使用统一校验函数
|
||||
$isValidFormat = isValidIpLocationFormat($cachedLoc);
|
||||
if (!$isValidFormat) {
|
||||
echo "<p class='err'>无效格式: " . htmlspecialchars($cachedLoc) . "</p>";
|
||||
}
|
||||
|
||||
echo "<p>格式验证结果: " . ($isValidFormat ? "✅ 有效" : "❌ 无效") . "</p>";
|
||||
|
||||
if (!$isValidFormat) {
|
||||
echo "<p>将删除此缓存并重新查询</p>";
|
||||
$stmt2 = $db->prepare("DELETE FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$stmt2->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$stmt2->execute();
|
||||
} else {
|
||||
echo "<p class='ok'>格式有效,直接使用缓存</p>";
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
echo "<p>无缓存,需要查询</p>";
|
||||
}
|
||||
|
||||
echo "<p><strong>3. 调用API查询(格式无效则继续下一个)</strong></p>";
|
||||
|
||||
// ★ 与 admin.php 的 doQueryIpLocation() 一致:每个API返回后先验证格式
|
||||
$location = null;
|
||||
$bestResult = null;
|
||||
|
||||
// API1: PConline
|
||||
echo "<p>尝试 API1 (PConline)...</p>";
|
||||
$r1 = testFetchPconline($ip);
|
||||
if ($r1['location']) {
|
||||
echo "<p>返回: " . htmlspecialchars($r1['location']) . "</p>";
|
||||
if (isValidIpLocationFormat($r1['location'])) {
|
||||
$location = $r1['location'];
|
||||
echo "<p class='ok'>✅ 格式有效: $location</p>";
|
||||
} else {
|
||||
echo "<p class='warn'>⚠️ 格式无效,继续尝试下一个API</p>";
|
||||
$bestResult = $r1['location'];
|
||||
}
|
||||
} else {
|
||||
echo "<p class='err'>❌ API1 无结果</p>";
|
||||
}
|
||||
|
||||
// API2: Baidu(仅当前面没有有效结果时才尝试)
|
||||
if (!$location) {
|
||||
echo "<p>尝试 API2 (Baidu)...</p>";
|
||||
$r2 = testFetchBaidu($ip);
|
||||
if ($r2['location']) {
|
||||
echo "<p>返回: " . htmlspecialchars($r2['location']) . "</p>";
|
||||
if (isValidIpLocationFormat($r2['location'])) {
|
||||
$location = $r2['location'];
|
||||
echo "<p class='ok'>✅ 格式有效: $location</p>";
|
||||
} else {
|
||||
echo "<p class='warn'>⚠️ 格式无效,继续尝试下一个API</p>";
|
||||
if (!$bestResult) $bestResult = $r2['location'];
|
||||
}
|
||||
} else {
|
||||
echo "<p class='err'>❌ API2 无结果</p>";
|
||||
}
|
||||
}
|
||||
|
||||
// API3: aa1.cn(仅当前面没有有效结果时才尝试)
|
||||
if (!$location) {
|
||||
echo "<p>尝试 API3 (aa1.cn)...</p>";
|
||||
$r3 = testFetchAa1($ip);
|
||||
if ($r3['location']) {
|
||||
echo "<p>返回: " . htmlspecialchars($r3['location']) . "</p>";
|
||||
if (isValidIpLocationFormat($r3['location'])) {
|
||||
$location = $r3['location'];
|
||||
echo "<p class='ok'>✅ 格式有效: $location</p>";
|
||||
} else {
|
||||
echo "<p class='warn'>⚠️ 格式无效,继续尝试下一个API</p>";
|
||||
if (!$bestResult) $bestResult = $r3['location'];
|
||||
}
|
||||
} else {
|
||||
echo "<p class='err'>❌ API3 无结果</p>";
|
||||
}
|
||||
}
|
||||
|
||||
// API4: IP9(仅当前面没有有效结果时才尝试)
|
||||
if (!$location) {
|
||||
echo "<p>尝试 API4 (IP9)...</p>";
|
||||
$r4 = testFetchIp9($ip);
|
||||
if ($r4['location']) {
|
||||
echo "<p>返回: " . htmlspecialchars($r4['location']) . "</p>";
|
||||
if (isValidIpLocationFormat($r4['location'])) {
|
||||
$location = $r4['location'];
|
||||
echo "<p class='ok'>✅ 格式有效: $location</p>";
|
||||
} else {
|
||||
echo "<p class='warn'>⚠️ 格式无效,继续尝试下一个API</p>";
|
||||
if (!$bestResult) $bestResult = $r4['location'];
|
||||
}
|
||||
} else {
|
||||
echo "<p class='err'>❌ API4 无结果</p>";
|
||||
}
|
||||
}
|
||||
|
||||
// 所有API都没返回有效格式:标记为查询失败
|
||||
if (!$location) {
|
||||
$location = '查询失败';
|
||||
echo '<p class="err">❌ 所有API均失败或格式无效,标记为"查询失败"</p>';
|
||||
}
|
||||
|
||||
echo "<p><strong>4. 格式验证和修复(与 admin.php 一致)</strong></p>";
|
||||
echo "<p>原始位置: $location</p>";
|
||||
|
||||
// 格式修复逻辑
|
||||
if ($location && $location !== '查询失败') {
|
||||
$loc = $location;
|
||||
|
||||
// 1. 过滤英文格式
|
||||
if (strpos($loc, 'China - ') === 0 || strpos($loc, 'China -') === 0) {
|
||||
echo "<p class='warn'>拒绝英文格式: $loc</p>";
|
||||
$location = null;
|
||||
}
|
||||
|
||||
// 2. 过滤不完整的中文格式
|
||||
if (strpos($loc, '中国 - -') === 0 || strpos($loc, '中国 - -') === 0) {
|
||||
echo "<p class='warn'>拒绝不完整格式: $loc</p>";
|
||||
$location = null;
|
||||
}
|
||||
|
||||
// 3. 格式修复:确保"位置"和"运营商"之间有空格
|
||||
// 仅在没有空格时修复(如"广东省移动"→"广东省 移动")
|
||||
if ($location && strpos($loc, ' ') === false) {
|
||||
// 无空格,尝试添加空格
|
||||
if (preg_match('/^(.*?(?:省|市|州|盟|地区|区|县))(.+)$/u', $loc, $matches)) {
|
||||
$loc = $matches[1] . ' ' . $matches[2];
|
||||
echo "<p>格式修复: $location => $loc</p>";
|
||||
$location = $loc;
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 4. 存储前格式验证(使用统一校验函数)
|
||||
if ($location) {
|
||||
$shouldStore = isValidIpLocationFormat($location);
|
||||
if (!$shouldStore) {
|
||||
echo "<p class='err'>格式无效(不满足入库条件): " . htmlspecialchars($location) . "</p>";
|
||||
$location = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "<p><strong>5. 存入数据库</strong></p>";
|
||||
if ($location && $location !== '查询失败') {
|
||||
echo "<p class='ok'>将存储: $location</p>";
|
||||
|
||||
// 模拟存储(不实际执行)
|
||||
echo "<pre>INSERT OR REPLACE INTO player_ip_locations (player_name, ip_address, location, updated_at) VALUES ('global', '$ip', '$location', " . time() . ")</pre>";
|
||||
} else {
|
||||
echo "<p class='err'>不存储(查询失败或格式无效)</p>";
|
||||
}
|
||||
|
||||
echo "<hr>";
|
||||
}
|
||||
echo "</div>";
|
||||
|
||||
// ===== 第4步:显示所有无效格式的记录 =====
|
||||
echo "<div class='step'>";
|
||||
echo "<h3>❌ 步骤4: 显示所有无效格式的记录</h3>";
|
||||
|
||||
$invalidStmt = $db->query("
|
||||
SELECT ip_address, location
|
||||
FROM player_ip_locations
|
||||
WHERE location LIKE 'China%'
|
||||
OR location = '-'
|
||||
OR location = '--'
|
||||
OR location = '查询失败'
|
||||
OR location = ''
|
||||
OR ip_address LIKE '127.%'
|
||||
OR ip_address LIKE '192.168.%'
|
||||
OR ip_address LIKE '10.%'
|
||||
OR location LIKE '中国 - -%'
|
||||
OR location LIKE '中国 - -%'
|
||||
OR (location NOT LIKE '% %' AND (location LIKE '%省%' OR location LIKE '%市%'))
|
||||
");
|
||||
$invalidCount = 0;
|
||||
while ($row = $invalidStmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "<p>" . $row['ip_address'] . " => " . $row['location'] . "</p>";
|
||||
$invalidCount++;
|
||||
}
|
||||
|
||||
if ($invalidCount == 0) {
|
||||
echo "<p class='ok'>没有发现无效格式的记录</p>";
|
||||
} else {
|
||||
echo "<p class='warn'>发现 $invalidCount 条无效格式的记录</p>";
|
||||
}
|
||||
echo "</div>";
|
||||
|
||||
echo "<hr>";
|
||||
echo "<p>💡 此页面用于调试IP查询流程,检查每个API的返回结果和入库逻辑。</p>";
|
||||
?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
// 诊断脚本 - 测试PHP响应是否干净
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('error_log', __DIR__ . '/debug_error.log');
|
||||
|
||||
// 开始输出缓冲
|
||||
ob_start();
|
||||
|
||||
// 引入 core.php
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
// 清理缓冲
|
||||
if (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// 设置正确的响应头
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
// 输出 JSON
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'core_loaded' => function_exists('validateWebAccess'),
|
||||
'db_path' => defined('DB_PATH') ? DB_PATH : 'undefined',
|
||||
'php_version' => phpversion(),
|
||||
'time' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
@@ -53,35 +53,30 @@
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>SDF1 Web登录</h1>
|
||||
<p class="subtitle" id="subtitle">选择登录方式</p>
|
||||
<p class="subtitle" id="subtitle">Token验证中...</p>
|
||||
|
||||
<!-- Token登录(自动) -->
|
||||
<div id="tokenSection" style="display:none">
|
||||
<!-- Token验证状态 -->
|
||||
<div id="tokenSection">
|
||||
<div id="statusBox" class="status-box loading">
|
||||
<span class="spinner"></span> 正在验证登录Token...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码登录(手动) -->
|
||||
<!-- 密码验证(Token通过后显示) -->
|
||||
<div id="passwordSection" style="display:none">
|
||||
<div id="pwdStatusBox" class="status-box" style="display:none"></div>
|
||||
<div style="text-align:left;margin-bottom:16px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">玩家名</label>
|
||||
<input type="text" id="playerName" placeholder="输入游戏内玩家名" maxlength="16"
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
|
||||
<input type="text" id="playerName" placeholder="玩家名" readonly
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
|
||||
</div>
|
||||
<div style="text-align:left;margin-bottom:20px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">密码</label>
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">游戏内密码</label>
|
||||
<input type="password" id="password" placeholder="输入游戏内密码" maxlength="32"
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
|
||||
<p style="color:var(--dim);font-size:12px;margin-top:8px;text-align:left">密码由游戏服务器验证,Web端不存储密码</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doPasswordLogin()">登录</button>
|
||||
<p style="color:var(--dim);font-size:12px;margin-top:12px">密码由游戏服务器验证,Web端不存储密码</p>
|
||||
</div>
|
||||
|
||||
<!-- 切换按钮 -->
|
||||
<div style="margin-top:20px;text-align:center">
|
||||
<button id="switchBtn" class="btn btn-dim" style="font-size:12px;padding:8px 16px;display:none" onclick="switchMode()">切换登录方式</button>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doPasswordLogin()">登录到Web端</button>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions" style="display:none"></div>
|
||||
@@ -93,45 +88,30 @@
|
||||
const actionsDiv = document.getElementById('actions');
|
||||
const tokenSection = document.getElementById('tokenSection');
|
||||
const passwordSection = document.getElementById('passwordSection');
|
||||
const switchBtn = document.getElementById('switchBtn');
|
||||
const subtitle = document.getElementById('subtitle');
|
||||
const pwdStatusBox = document.getElementById('pwdStatusBox');
|
||||
let currentMode = TOKEN ? 'token' : 'password';
|
||||
|
||||
function switchMode() {
|
||||
if (currentMode === 'token') {
|
||||
currentMode = 'password';
|
||||
tokenSection.style.display = 'none';
|
||||
passwordSection.style.display = 'block';
|
||||
switchBtn.textContent = '使用Token登录';
|
||||
document.getElementById('subtitle').textContent = '输入玩家名和密码登录';
|
||||
} else {
|
||||
currentMode = 'token';
|
||||
tokenSection.style.display = 'block';
|
||||
passwordSection.style.display = 'none';
|
||||
switchBtn.textContent = '使用密码登录';
|
||||
document.getElementById('subtitle').textContent = '通过游戏内Token安全登录Web端';
|
||||
}
|
||||
// ===== 安全拦截:无Token则拒绝访问 =====
|
||||
if (!TOKEN) {
|
||||
document.body.innerHTML = `
|
||||
<div style="text-align:center;padding:60px 20px;">
|
||||
<h1 style="color:#f85149;margin-bottom:20px">🚫 访问被拒绝</h1>
|
||||
<p style="color:var(--dim);font-size:16px;line-height:1.8">
|
||||
Web登录必须通过游戏内Token验证。<br>
|
||||
请在游戏中执行 <b style="color:var(--accent)">/sdf1_login weblogin</b> 或 <b>/web</b><br>
|
||||
然后点击上方链接登录Web端。
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
console.error('[登录页面] 无Token访问被拒绝');
|
||||
throw new Error('NoToken');
|
||||
}
|
||||
|
||||
// 初始化
|
||||
if (TOKEN) {
|
||||
// 有Token → 直接Token登录
|
||||
// ===== 有Token → 验证Token =====
|
||||
tokenSection.style.display = 'block';
|
||||
passwordSection.style.display = 'none';
|
||||
document.getElementById('subtitle').textContent = '通过游戏内Token安全登录Web端';
|
||||
switchBtn.style.display = 'inline-block';
|
||||
switchBtn.textContent = '使用密码登录';
|
||||
doTokenLogin();
|
||||
} else {
|
||||
// 无Token → 显示密码登录
|
||||
tokenSection.style.display = 'none';
|
||||
passwordSection.style.display = 'block';
|
||||
document.getElementById('subtitle').textContent = '输入玩家名和密码登录';
|
||||
switchBtn.style.display = 'inline-block';
|
||||
switchBtn.textContent = '使用Token登录';
|
||||
}
|
||||
|
||||
// ===== Token登录 =====
|
||||
// ===== Token验证 =====
|
||||
async function doTokenLogin() {
|
||||
try {
|
||||
const apiUrl = './api/sync.php?action=validate_weblogin_token';
|
||||
@@ -141,9 +121,7 @@
|
||||
body: JSON.stringify({web_token: TOKEN})
|
||||
});
|
||||
const text = await res.text();
|
||||
// ★ 调试:把PHP返回的内容全部打印出来
|
||||
console.log('doTokenLogin response:', text);
|
||||
console.log('doTokenLogin status:', res.status);
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
@@ -161,10 +139,18 @@
|
||||
localStorage.setItem('sdf1_token', TOKEN);
|
||||
localStorage.setItem('sdf1_login_time', Date.now());
|
||||
|
||||
// Token验证成功 → 显示密码输入框
|
||||
statusBox.className = 'status-box success';
|
||||
statusBox.innerHTML = '✅ 登录成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">游戏内将自动登录</small>';
|
||||
actionsDiv.style.display = 'flex';
|
||||
actionsDiv.innerHTML = '<a href="player.php?login=' + encodeURIComponent(player) + '&token=' + TOKEN + '" class="btn btn-primary">进入玩家商城</a>';
|
||||
statusBox.innerHTML = '✅ Token验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span>';
|
||||
|
||||
// 延迟1.5秒后切换到密码输入
|
||||
setTimeout(() => {
|
||||
tokenSection.style.display = 'none';
|
||||
passwordSection.style.display = 'block';
|
||||
subtitle.textContent = '验证玩家身份';
|
||||
document.getElementById('playerName').value = player;
|
||||
document.getElementById('password').focus();
|
||||
}, 1500);
|
||||
} else {
|
||||
statusBox.className = 'status-box error';
|
||||
statusBox.innerHTML = '❌ 登录失败<br><br>' + (data.message || 'Token无效或已过期');
|
||||
@@ -182,13 +168,11 @@
|
||||
const player = document.getElementById('playerName').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!player) { showPwdError('请输入玩家名'); return; }
|
||||
if (!password) { showPwdError('请输入密码'); return; }
|
||||
if (!/^[a-zA-Z0-9_]{3,16}$/.test(player)) { showPwdError('玩家名格式不正确'); return; }
|
||||
|
||||
pwdStatusBox.className = 'status-box loading';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在提交登录请求...';
|
||||
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证密码...';
|
||||
|
||||
try {
|
||||
// 1. 提交登录请求
|
||||
@@ -222,31 +206,37 @@
|
||||
|
||||
try {
|
||||
const pollRes = await fetch(apiUrl + '?action=check_web_login_result&player=' + encodeURIComponent(player) + '&request_id=' + requestId);
|
||||
const pollData = await pollRes.json();
|
||||
const pollText = await pollRes.text();
|
||||
const pollData = JSON.parse(pollText);
|
||||
|
||||
console.log('[poll] status:', pollData.status || pollData.data?.status, 'data:', JSON.stringify(pollData.data).substring(0, 100));
|
||||
|
||||
if (pollData.success && pollData.data) {
|
||||
const result = pollData.data;
|
||||
|
||||
if (result.status === 'success') {
|
||||
clearInterval(pollInterval);
|
||||
// 登录成功
|
||||
const fakeToken = 'webpwd_' + Date.now();
|
||||
localStorage.setItem('sdf1_player', player);
|
||||
localStorage.setItem('sdf1_token', fakeToken);
|
||||
localStorage.setItem('sdf1_login_time', Date.now());
|
||||
|
||||
// 登录成功 → 进入玩家商城
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.innerHTML = '✅ 登录成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">游戏内将自动登录</small>';
|
||||
actionsDiv.style.display = 'flex';
|
||||
actionsDiv.innerHTML = '<a href="player.php?login=' + encodeURIComponent(player) + '&token=' + fakeToken + '" class="btn btn-primary">进入玩家商城</a>';
|
||||
pwdStatusBox.innerHTML = '✅ 登录成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
|
||||
|
||||
// ★ token已存入localStorage,直接跳转player.php(不带token参数,避免URL暴露)
|
||||
setTimeout(() => {
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player);
|
||||
}, 500);
|
||||
} else if (result.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
showPwdError(result.message || '密码错误');
|
||||
}
|
||||
// status === 'pending' → 继续轮询
|
||||
} else if (pollData.data && pollData.data.status === 'pending') {
|
||||
// 仍在pending状态,可能是请求过期,继续轮询
|
||||
console.log('[poll] 请求pending或过期,继续等待...');
|
||||
} else {
|
||||
console.warn('[poll] 轮询响应异常:', JSON.stringify(pollData).substring(0, 200));
|
||||
}
|
||||
} catch (pollErr) {
|
||||
// 轮询出错,继续尝试
|
||||
console.error('[poll] 轮询异常:', pollErr);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -265,9 +255,6 @@
|
||||
document.getElementById('password').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doPasswordLogin();
|
||||
});
|
||||
document.getElementById('playerName').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('password').focus();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+275
-37
@@ -11,8 +11,14 @@
|
||||
--green: #3fb950; --red: #f85149; --yellow: #d29922;
|
||||
--purple: #bc8cff;
|
||||
}
|
||||
.light-theme {
|
||||
--bg: #f6f8fa; --card: #ffffff; --border: #d0d7de;
|
||||
--text: #1f2328; --dim: #656d76; --accent: #0969da;
|
||||
--green: #1a7f37; --red: #cf222e; --yellow: #9a6700;
|
||||
--purple: #8250df;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; transition: background 0.3s, color 0.3s; }
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -30,7 +36,7 @@
|
||||
.main { display: flex; gap: 0; min-height: calc(100vh - 56px); }
|
||||
.sidebar {
|
||||
width: 220px; background: var(--card); border-right: 1px solid var(--border);
|
||||
padding: 12px 0; flex-shrink: 0;
|
||||
padding: 12px 0; flex-shrink: 0; transition: background 0.3s;
|
||||
}
|
||||
.sidebar-item {
|
||||
padding: 10px 20px; cursor: pointer; color: var(--dim);
|
||||
@@ -89,7 +95,7 @@
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.6); display: flex; justify-content: center;
|
||||
align-items: center; z-index: 100; display: none;
|
||||
align-items: center; z-index: 999; display: none;
|
||||
}
|
||||
.modal {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
@@ -123,6 +129,7 @@
|
||||
<div class="status">
|
||||
<span id="previewBadge" class="preview-badge" style="display:none">预览模式</span>
|
||||
<span id="playerInfo" style="margin-left:12px;color:var(--dim)"></span>
|
||||
<button class="btn btn-yellow" onclick="showThemePicker()" style="margin-left:16px;font-size:12px;padding:4px 8px">🎨 主题</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +162,9 @@
|
||||
let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('sdf1_token') || '';
|
||||
let IS_PREVIEW = !TOKEN;
|
||||
let currentPlayer = localStorage.getItem('sdf1_player') || new URLSearchParams(location.search).get('login') || '';
|
||||
let tokenPollTimer = null; // ★ 新增:token轮询定时器(用于续期检查)
|
||||
let AUTH_TOKEN = TOKEN; // ★ 使用独立的认证token,避免localStorage被意外覆盖
|
||||
let authVerified = false; // ★ 记录是否已通过首次验证
|
||||
let currentPage = 'shop';
|
||||
let AUTHENTICATED = false;
|
||||
let NEED_PASSWORD = false;
|
||||
@@ -166,8 +176,11 @@
|
||||
localStorage.setItem('sdf1_token', urlToken);
|
||||
TOKEN = urlToken;
|
||||
IS_PREVIEW = false;
|
||||
const cleanUrl = location.pathname + location.search;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
// ★ 从URL中移除token参数,避免URL暴露
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete('token');
|
||||
const newUrl = location.pathname + (params.toString() ? '?' + params.toString() : '');
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
// ★ 安全机制:没有token → 进入游客模式(预览)
|
||||
@@ -207,7 +220,16 @@
|
||||
currentPlayer = data.data.player || currentPlayer;
|
||||
localStorage.setItem('sdf1_player', currentPlayer);
|
||||
document.getElementById('playerInfo').textContent = '已登录: ' + currentPlayer;
|
||||
authVerified = true; // ★ 标记已通过验证
|
||||
if (callback) callback({ok: true, mode: data.data.mode});
|
||||
// ★ 如果不需要密码,直接加载商品页
|
||||
if (typeof NEED_PASSWORD !== 'undefined' && !NEED_PASSWORD) {
|
||||
setTimeout(() => {
|
||||
switchPage('shop');
|
||||
// ★ Token跟随Java端配置过期时间自动过期,不续费
|
||||
// startTokenPolling() 不再调用,仅保留变量声明
|
||||
}, 100);
|
||||
}
|
||||
} else if (data.need_password) {
|
||||
NEED_PASSWORD = true;
|
||||
currentPlayer = data.player || currentPlayer;
|
||||
@@ -338,6 +360,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoundEmail() {
|
||||
try {
|
||||
const url = new URL(API + 'sync.php', location.href);
|
||||
url.searchParams.set('action', 'get_player_email');
|
||||
url.searchParams.set('player', currentPlayer);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('authEmail').value = data.data.masked_email || data.data.email || '';
|
||||
if (data.data.masked_email) {
|
||||
document.getElementById('authEmail').placeholder = '已绑定邮箱';
|
||||
} else {
|
||||
document.getElementById('authEmail').placeholder = '未绑定邮箱';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('authEmail').value = '未绑定邮箱';
|
||||
document.getElementById('authEmail').placeholder = '未绑定邮箱';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('authEmail').value = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function showPasswordModal(message) {
|
||||
document.getElementById('modalTitle').textContent = '安全验证';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
@@ -352,7 +400,9 @@
|
||||
<div style="text-align:right;margin-top:4px"><a href="javascript:void(0)" onclick="showResetPasswordModal()" style="color:var(--accent);font-size:12px">忘记密码?</a></div>
|
||||
</div>
|
||||
<div id="authEmailTab" style="display:none">
|
||||
<div class="row"><label>绑定邮箱</label><input type="email" id="authEmail" placeholder="输入注册时绑定的邮箱"></div>
|
||||
<div class="row"><label>绑定邮箱</label>
|
||||
<input type="text" id="authEmail" readonly style="background:#21262d;cursor:not-allowed;opacity:0.7" placeholder="加载中...">
|
||||
</div>
|
||||
<div class="row" style="display:flex;gap:8px">
|
||||
<div style="flex:1"><label>验证码</label><input type="text" id="authCode" placeholder="6位验证码" maxlength="6"></div>
|
||||
<div style="display:flex;align-items:flex-end"><button class="btn btn-blue" id="sendCodeBtn" onclick="sendEmailCode()" style="white-space:nowrap">发送验证码</button></div>
|
||||
@@ -363,7 +413,10 @@
|
||||
document.getElementById('authPassword').addEventListener('keydown', e => { if (e.key === 'Enter') doAuth(); });
|
||||
document.getElementById('authCode').addEventListener('keydown', e => { if (e.key === 'Enter') doAuth(); });
|
||||
document.getElementById('modalOverlay').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('authPassword').focus(), 100);
|
||||
setTimeout(() => {
|
||||
document.getElementById('authPassword').focus();
|
||||
loadBoundEmail();
|
||||
}, 100);
|
||||
switchAuthTab('password');
|
||||
}
|
||||
|
||||
@@ -378,8 +431,6 @@
|
||||
}
|
||||
|
||||
async function sendEmailCode() {
|
||||
const email = document.getElementById('authEmail').value.trim();
|
||||
if (!email) { document.getElementById('authError').textContent = '请输入邮箱'; return; }
|
||||
const btn = document.getElementById('sendCodeBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中...';
|
||||
@@ -390,11 +441,12 @@
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: currentPlayer, email: email, web_token: TOKEN})
|
||||
body: JSON.stringify({player: currentPlayer, web_token: TOKEN})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('authError').innerHTML = '<span style="color:var(--green)">验证码已发送到邮箱</span>';
|
||||
const maskedEmail = data.data.masked_email || '**@*';
|
||||
document.getElementById('authError').innerHTML = '<span style="color:var(--green)">验证码已发送至 ' + maskedEmail + '</span>';
|
||||
let countdown = 60;
|
||||
const timer = setInterval(() => {
|
||||
btn.textContent = countdown + '秒后重发';
|
||||
@@ -471,9 +523,7 @@
|
||||
}
|
||||
|
||||
async function doEmailAuth() {
|
||||
const email = document.getElementById('authEmail').value.trim();
|
||||
const code = document.getElementById('authCode').value.trim();
|
||||
if (!email) { document.getElementById('authError').textContent = '请输入邮箱'; return; }
|
||||
if (!code || code.length !== 6) { document.getElementById('authError').textContent = '请输入6位验证码'; return; }
|
||||
document.getElementById('authError').textContent = '验证中...';
|
||||
try {
|
||||
@@ -482,7 +532,7 @@
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: currentPlayer, email: email, code: code, web_token: TOKEN})
|
||||
body: JSON.stringify({player: currentPlayer, code: code, web_token: TOKEN})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -561,12 +611,63 @@
|
||||
});
|
||||
document.querySelector('.sidebar').style.display = 'block';
|
||||
const c = document.getElementById('content');
|
||||
if (!c) return;
|
||||
if (page === 'shop') renderShop(c);
|
||||
else if (page === 'cdk') renderCDK(c);
|
||||
else if (page === 'balance') renderBalance(c);
|
||||
else if (page === 'account') renderAccount(c);
|
||||
}
|
||||
|
||||
// ★ 新增:Token轮询检查(不续期,只检查是否被Java端主动销毁)
|
||||
function startTokenPolling() {
|
||||
if (tokenPollTimer) clearInterval(tokenPollTimer);
|
||||
|
||||
// 每1分钟检查一次token是否仍然有效
|
||||
tokenPollTimer = setInterval(() => {
|
||||
if (TOKEN && !IS_PREVIEW && authVerified) {
|
||||
checkTokenValidity();
|
||||
}
|
||||
}, 60 * 1000); // 1分钟
|
||||
}
|
||||
|
||||
// ★ 检查token有效性(不续期,只检查)
|
||||
async function checkTokenValidity() {
|
||||
if (!TOKEN || IS_PREVIEW) return;
|
||||
|
||||
try {
|
||||
const url = new URL(API + 'sync.php', location.href);
|
||||
url.searchParams.set('action', 'web_access_check');
|
||||
url.searchParams.set('web_token', TOKEN);
|
||||
url.searchParams.set('access_action', 'view');
|
||||
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
// Token可能已失效或过期
|
||||
if (data.need_password || data.need_game_login || data.need_register) {
|
||||
// 需要进一步验证,不视为token无效
|
||||
return;
|
||||
}
|
||||
|
||||
// Token确实无效(可能被Java端主动销毁)
|
||||
console.log('[Token] Token验证失败:', data.message);
|
||||
TOKEN = '';
|
||||
AUTHENTICATED = false;
|
||||
localStorage.removeItem('sdf1_token');
|
||||
localStorage.removeItem('sdf1_player');
|
||||
currentPlayer = '';
|
||||
IS_PREVIEW = true;
|
||||
authVerified = false;
|
||||
document.getElementById('playerInfo').textContent = '游客模式';
|
||||
document.getElementById('previewBadge').style.display = 'inline';
|
||||
showLoginPrompt();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Token] Token检查异常:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function api(endpoint, params = {}) {
|
||||
if (TOKEN) params.token = TOKEN;
|
||||
const url = new URL(API + endpoint, location.href);
|
||||
@@ -575,6 +676,18 @@
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// 通知Java插件立即同步数据
|
||||
async function notifyJavaSync() {
|
||||
try {
|
||||
const url = new URL(API + 'sync.php', location.href);
|
||||
url.searchParams.set('action', 'request_immediate_sync');
|
||||
url.searchParams.set('player', currentPlayer);
|
||||
await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'} });
|
||||
} catch(e) {
|
||||
// 静默失败,不影响用户体验
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 商城 =====
|
||||
async function renderShop(el) {
|
||||
el.innerHTML = '<div class="empty">加载中...</div>';
|
||||
@@ -638,7 +751,14 @@
|
||||
if (data.need_password) { showPasswordModal(data.message || '请输入游戏登录密码'); return; }
|
||||
closeModal();
|
||||
toast(data.message, data.success ? 'success' : 'error');
|
||||
if (data.success) switchPage('shop');
|
||||
if (data.success) {
|
||||
// 购买成功 → 通知Java插件立即同步 + 刷新当前页
|
||||
setTimeout(() => {
|
||||
notifyJavaSync();
|
||||
document.getElementById('balanceResult')?.parentElement && switchPage('balance');
|
||||
setTimeout(() => switchPage('shop'), 500);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CDK =====
|
||||
@@ -669,7 +789,10 @@
|
||||
const data = await res.json();
|
||||
if (data.need_password) { showPasswordModal(data.message || '请输入游戏登录密码'); return; }
|
||||
if (data.success) {
|
||||
document.getElementById('cdkResult').innerHTML = `<div class="card" style="border-color:var(--green)"><h2 style="color:var(--green)">兑换成功!</h2><p>获得 ${data.data.amount} 债券</p><p>当前余额: ${data.data.balance_after} 债券</p></div>`;
|
||||
document.getElementById('cdkResult').innerHTML = `<div class="card" style="border-color:var(--green)"><h2 style="color:var(--green)">兑换成功!</h2><p>获得 ${data.data.amount} 债券</p><p>当前余额: ${data.data.balance_after} 债券</p><p style="color:var(--dim);font-size:12px;margin-top:8px">数据已同步,请刷新页面查看最新余额</p></div>`;
|
||||
// CDK兑换成功后 → 通知Java同步 + 3秒刷新余额
|
||||
setTimeout(() => notifyJavaSync(), 500);
|
||||
setTimeout(() => doQueryBalance(), 3000);
|
||||
} else {
|
||||
document.getElementById('cdkResult').innerHTML = `<div class="card" style="border-color:var(--red)"><h2 style="color:var(--red)">兑换失败</h2><p>${data.message}</p></div>`;
|
||||
}
|
||||
@@ -677,27 +800,24 @@
|
||||
|
||||
// ===== 余额 =====
|
||||
function renderBalance(el) {
|
||||
if (!currentPlayer) {
|
||||
el.innerHTML = '<div class="card"><h2>余额查询</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>余额查询</h2>
|
||||
${IS_PREVIEW ? '<div class="preview-badge" style="margin-bottom:12px">预览模式 - 需要token才能查询</div>' : ''}
|
||||
<div style="margin-top:12px">
|
||||
<label style="color:var(--dim);font-size:13px">玩家名</label>
|
||||
<div style="display:flex;gap:8px;margin-top:4px">
|
||||
<input type="text" id="queryPlayer" value="${currentPlayer}" style="flex:1;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px">
|
||||
<button class="btn btn-primary" onclick="doQueryBalance()">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="balanceResult" style="margin-top:16px"></div>
|
||||
</div>`;
|
||||
doQueryBalance();
|
||||
}
|
||||
|
||||
async function doQueryBalance() {
|
||||
const player = document.getElementById('queryPlayer').value.trim();
|
||||
if (!player) { toast('请输入玩家名', 'error'); return; }
|
||||
if (!currentPlayer) return;
|
||||
try {
|
||||
const url = new URL(API + 'balance.php', location.href);
|
||||
url.searchParams.set('action', 'query');
|
||||
url.searchParams.set('player', player);
|
||||
url.searchParams.set('player', currentPlayer);
|
||||
if (TOKEN) url.searchParams.set('token', TOKEN);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
@@ -709,6 +829,9 @@
|
||||
} else {
|
||||
div.innerHTML = `<div class="card" style="border-color:var(--red)">${data.message}</div>`;
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('balanceResult').innerHTML = '<div class="card" style="border-color:var(--red)">查询失败: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 账号 =====
|
||||
@@ -717,24 +840,21 @@
|
||||
el.innerHTML = `<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看账号信息</p><p style="color:var(--dim);font-size:12px">请先登录游戏获取Web访问令牌</p></div></div>`;
|
||||
return;
|
||||
}
|
||||
if (!currentPlayer) {
|
||||
el.innerHTML = '<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>账号信息</h2>
|
||||
<div style="margin-top:12px">
|
||||
<label style="color:var(--dim);font-size:13px">玩家名</label>
|
||||
<div style="display:flex;gap:8px;margin-top:4px">
|
||||
<input type="text" id="accountPlayer" value="${currentPlayer}" style="flex:1;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px">
|
||||
<button class="btn btn-primary" onclick="doQueryAccount()">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accountResult" style="margin-top:16px"></div>
|
||||
</div>`;
|
||||
doQueryAccount();
|
||||
}
|
||||
|
||||
async function doQueryAccount() {
|
||||
const player = document.getElementById('accountPlayer').value.trim();
|
||||
if (!player) { toast('请输入玩家名', 'error'); return; }
|
||||
const res = await api('register.php', {action: 'query', player: player, token: TOKEN});
|
||||
if (!currentPlayer) return;
|
||||
const res = await api('register.php', {action: 'query', player: currentPlayer, token: TOKEN});
|
||||
const div = document.getElementById('accountResult');
|
||||
if (res.success) {
|
||||
const d = res.data;
|
||||
@@ -755,6 +875,124 @@
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
// ===== 主题切换 =====
|
||||
function toggleLightTheme() {
|
||||
document.body.classList.toggle('light-theme');
|
||||
const isLight = document.body.classList.contains('light-theme');
|
||||
localStorage.setItem('sdf1_player_theme', isLight ? 'light' : 'dark');
|
||||
toast(isLight ? '浅色主题' : '深色主题', 'success');
|
||||
}
|
||||
|
||||
function showThemePicker() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:2000';
|
||||
overlay.id = 'themePickerOverlay';
|
||||
overlay.innerHTML = `
|
||||
<div id="themePickerModal" style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:500px;max-width:90%;position:relative">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<h3 style="margin:0">🎨 选择背景颜色</h3>
|
||||
<button onclick="document.getElementById('themePickerOverlay').remove()" style="background:none;border:none;color:var(--dim);font-size:20px;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div class="theme-picker" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#0d1117;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#0d1117')"></div>
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#1a1e2e;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#1a1e2e')"></div>
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#0f4c75;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#0f4c75')"></div>
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#1b2631;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#1b2631')"></div>
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#2c3e50;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#2c3e50')"></div>
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#23272a;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#23272a')"></div>
|
||||
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#ffffff;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#ffffff')"></div>
|
||||
</div>
|
||||
<div style="margin-top:16px">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<span style="color:var(--dim);font-size:12px;white-space:nowrap">R</span>
|
||||
<input type="number" id="rgbR" min="0" max="255" placeholder="0-255" style="width:70px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;text-align:center">
|
||||
<span style="color:var(--dim);font-size:12px;white-space:nowrap">G</span>
|
||||
<input type="number" id="rgbG" min="0" max="255" placeholder="0-255" style="width:70px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;text-align:center">
|
||||
<span style="color:var(--dim);font-size:12px;white-space:nowrap">B</span>
|
||||
<input type="number" id="rgbB" min="0" max="255" placeholder="0-255" style="width:70px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;text-align:center">
|
||||
<span style="color:var(--dim);font-size:11px">或</span>
|
||||
<input type="text" id="customColor" placeholder="#1a237e" class="color-input" style="flex:1;min-width:120px">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
||||
<button class="btn" onclick="document.getElementById('themePickerOverlay').remove()">取消</button>
|
||||
<button class="btn btn-blue" onclick="applyPlayerCustomColor()">应用</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
}
|
||||
|
||||
function setPlayerTheme(color) {
|
||||
document.documentElement.style.setProperty('--bg', color);
|
||||
document.body.style.background = color;
|
||||
localStorage.setItem('sdf1_player_bg', color);
|
||||
toast('背景颜色已应用', 'success');
|
||||
}
|
||||
|
||||
// 浅色主题下的卡片/边框颜色也自动调整
|
||||
document.querySelectorAll('.light-theme').forEach(el => {});
|
||||
|
||||
// 自动切换card/border颜色配合自定义背景
|
||||
function applyBgToTheme(bgColor) {
|
||||
// 如果背景是浅色,自动切换card颜色
|
||||
document.body.classList.add('light-theme');
|
||||
document.body.style.background = bgColor;
|
||||
document.documentElement.style.setProperty('--bg', bgColor);
|
||||
}
|
||||
|
||||
function applyPlayerCustomColor() {
|
||||
let rVal = document.getElementById('rgbR').value.trim();
|
||||
let gVal = document.getElementById('rgbG').value.trim();
|
||||
let bVal = document.getElementById('rgbB').value.trim();
|
||||
const hexVal = document.getElementById('customColor').value.trim();
|
||||
|
||||
let color = null;
|
||||
|
||||
if (hexVal) {
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(hexVal)) {
|
||||
color = '#' + hexVal;
|
||||
} else if (/^#[0-9A-Fa-f]{6}$/.test(hexVal)) {
|
||||
color = hexVal;
|
||||
} else {
|
||||
toast('请输入有效的十六进制颜色,如 1a237e 或 #1a237e', 'err');
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (rVal !== '' || gVal !== '' || bVal !== '') {
|
||||
if (rVal === '') rVal = 0;
|
||||
if (gVal === '') gVal = 0;
|
||||
if (bVal === '') bVal = 0;
|
||||
const r = parseInt(rVal);
|
||||
const g = parseInt(gVal);
|
||||
const b = parseInt(bVal);
|
||||
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
|
||||
color = 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||
} else {
|
||||
toast('RGB 值必须在 0-255 之间', 'err');
|
||||
return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast('请输入十六进制颜色(如 1a237e)或填写任意一个 RGB 值', 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
if (color) {
|
||||
setPlayerTheme(color);
|
||||
document.querySelector('[onclick*="overlay.remove"]').click();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的主题
|
||||
(function() {
|
||||
const savedBg = localStorage.getItem('sdf1_player_bg');
|
||||
if (savedBg) {
|
||||
document.documentElement.style.setProperty('--bg', savedBg);
|
||||
document.body.style.background = savedBg;
|
||||
}
|
||||
})();
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user