feat: 合并PHP Web后端到仓库 - 统一代码管理\n\n将D:\桌面\源码\mc_plugn\Web\的PHP文件合并到web/子目录\n- 包含所有API端点(sync.php, register.php, shop.php等)\n- 包含前端页面(admin.php, player.php, login.php等)\n- 排除Web.db、调试日志、备份文件\n- .gitignore更新:忽略web/Web.db、debug_log_tail.txt、.bak文件
This commit is contained in:
+2423
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDF1 - 管理后台登录</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0d1117; color: #e6edf3;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-box {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
|
||||
padding: 40px; width: 380px; max-width: 90%;
|
||||
}
|
||||
.login-box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 24px; }
|
||||
.login-box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 28px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; }
|
||||
.field input {
|
||||
width: 100%; padding: 10px 14px; background: #0d1117; border: 1px solid #30363d;
|
||||
border-radius: 6px; color: #e6edf3; font-size: 14px; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus { border-color: #58a6ff; }
|
||||
.login-btn {
|
||||
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
|
||||
border-radius: 6px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.2s; margin-top: 8px;
|
||||
}
|
||||
.login-btn:hover { background: #79c0ff; }
|
||||
.login-btn:disabled { background: #30363d; cursor: not-allowed; }
|
||||
.error { color: #f85149; font-size: 13px; text-align: center; margin-top: 12px; display: none; }
|
||||
.links { text-align: center; margin-top: 20px; }
|
||||
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
|
||||
.links a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>🔐 管理后台</h1>
|
||||
<div class="sub">SDF1 插件管理系统</div>
|
||||
<div class="field">
|
||||
<label>管理密码</label>
|
||||
<input type="password" id="password" placeholder="请输入管理密码" autofocus>
|
||||
</div>
|
||||
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
<div class="error" id="errorMsg"></div>
|
||||
<div class="links"><a href="player.php">← 返回玩家商城</a></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('password').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
|
||||
async function doLogin() {
|
||||
const pw = document.getElementById('password').value;
|
||||
if (!pw) return;
|
||||
document.getElementById('loginBtn').disabled = true;
|
||||
document.getElementById('loginBtn').textContent = '登录中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('api/admin.php?action=login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({password: pw})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// 设置session cookie后跳转
|
||||
window.location.href = 'admin.php';
|
||||
} else {
|
||||
document.getElementById('errorMsg').textContent = data.message;
|
||||
document.getElementById('errorMsg').style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('errorMsg').textContent = '网络错误';
|
||||
document.getElementById('errorMsg').style.display = 'block';
|
||||
}
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
document.getElementById('loginBtn').textContent = '登 录';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
/**
|
||||
* 余额查询+充值API
|
||||
* GET ?action=query - 查询余额(需token)
|
||||
* POST ?action=recharge - 充值债券(需token)
|
||||
* GET ?action=transactions - 查看流水(需token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../core.php';
|
||||
ob_end_clean();
|
||||
|
||||
$action = getParam('action', 'query');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'query':
|
||||
balanceQuery($token);
|
||||
break;
|
||||
case 'recharge':
|
||||
balanceRecharge($token);
|
||||
break;
|
||||
case 'transactions':
|
||||
balanceTransactions($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== 查询余额 =====
|
||||
function balanceQuery($token) {
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
// ★ 安全验证:必须验证token且token对应的玩家必须与查询的玩家匹配
|
||||
$tokenInfo = null;
|
||||
$queriedPlayer = $player; // 最终查询的玩家名
|
||||
|
||||
// 1. 检查是否为管理后台session
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
$isAdmin = isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
|
||||
|
||||
if ($isAdmin) {
|
||||
// 管理员:可以查任意玩家
|
||||
$tokenInfo = ['player' => null, 'purpose' => 'admin'];
|
||||
} elseif ($token) {
|
||||
// 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']) {
|
||||
$queriedPlayer = $accessResult['player'];
|
||||
$tokenInfo = ['player' => $queriedPlayer, 'purpose' => 'weblogin'];
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_password' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
} else {
|
||||
error('无权查询其他玩家余额', 403);
|
||||
}
|
||||
} else {
|
||||
error('无效token,请先登录', 401);
|
||||
}
|
||||
} else {
|
||||
error('查询余额需要有效token,请先登录', 401);
|
||||
}
|
||||
|
||||
// 查询债券余额(从Web端的债券缓存表)
|
||||
$db = getDB();
|
||||
|
||||
// 先检查是否有缓存表
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
amount INTEGER DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
$stmt = $db->prepare("SELECT amount, updated_at FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
// 同时查询积分
|
||||
$stmt2 = $db->prepare("SELECT points FROM users WHERE player_name = :name");
|
||||
$stmt2->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute();
|
||||
$row2 = $result2->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
$bonds = $row ? $row['amount'] : 0;
|
||||
$points = $row2 ? $row2['points'] : 0;
|
||||
$bondsUpdated = $row ? $row['updated_at'] : 0;
|
||||
|
||||
// 计算数据新鲜度
|
||||
$freshness = '';
|
||||
if ($bondsUpdated > 0) {
|
||||
$age = time() - $bondsUpdated;
|
||||
if ($age < 300) $freshness = '刚刚同步';
|
||||
elseif ($age < 3600) $freshness = floor($age / 60) . '分钟前同步';
|
||||
elseif ($age < 86400) $freshness = floor($age / 3600) . '小时前同步';
|
||||
else $freshness = floor($age / 86400) . '天前同步(数据可能过期)';
|
||||
} else {
|
||||
$freshness = '尚未同步';
|
||||
}
|
||||
|
||||
success([
|
||||
'player' => $player,
|
||||
'bonds' => $bonds,
|
||||
'points' => $points,
|
||||
'bonds_updated' => $bondsUpdated,
|
||||
'freshness' => $freshness
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 充值债券 =====
|
||||
function balanceRecharge($token) {
|
||||
$player = getParam('player');
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$operator = getParam('operator', 'web_admin');
|
||||
|
||||
if (!$player) error('缺少player');
|
||||
if ($amount <= 0) error('充值金额必须大于0');
|
||||
|
||||
// 需要token
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo) {
|
||||
$isPreview = false;
|
||||
// 权限检查:只有管理员token才能充值
|
||||
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('充值需要管理员权限');
|
||||
}
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 确保缓存表存在
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
amount INTEGER DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// 查询当前余额
|
||||
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$currentBalance = $row ? $row['amount'] : 0;
|
||||
$newBalance = $currentBalance + $amount;
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'player' => $player,
|
||||
'amount' => $amount,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance,
|
||||
'operator' => $operator
|
||||
], '预览模式 - 充值不会生效');
|
||||
}
|
||||
|
||||
// 执行充值
|
||||
if ($row) {
|
||||
$stmt = $db->prepare("UPDATE bond_cache SET amount = :amount, updated_at = :time WHERE player_name = :name");
|
||||
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
} else {
|
||||
$stmt = $db->prepare("INSERT INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// 记录流水
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, status, created_at) VALUES (:player, 'admin_recharge', :amount, :reason, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "管理员充值: {$operator}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success([
|
||||
'player' => $player,
|
||||
'amount' => $amount,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance
|
||||
], '充值成功');
|
||||
}
|
||||
|
||||
// ===== 查看流水 =====
|
||||
function balanceTransactions($token) {
|
||||
$player = getParam('player');
|
||||
$limit = (int)getParam('limit', 50);
|
||||
|
||||
// 需要token
|
||||
$tokenInfo = null;
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
}
|
||||
|
||||
if (!$tokenInfo) {
|
||||
error('查看流水需要有效token', 401);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
$txs = [];
|
||||
|
||||
if ($player) {
|
||||
// 查看指定玩家流水
|
||||
if ($tokenInfo['player'] !== $player && $tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('无权查看其他玩家流水');
|
||||
}
|
||||
// Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions WHERE player_name = :player ORDER BY created_at DESC LIMIT :limit");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
} else {
|
||||
// 管理员查看全服流水
|
||||
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('查看全服流水需要管理员权限');
|
||||
}
|
||||
// Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions ORDER BY created_at DESC LIMIT :limit");
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit");
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 按时间排序(倒序)
|
||||
usort($txs, function($a, $b) {
|
||||
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
|
||||
});
|
||||
$txs = array_slice($txs, 0, (int)$limit);
|
||||
|
||||
success($txs);
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于API调用) =====
|
||||
function validateWebloginTokenForAPI($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;
|
||||
}
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
/**
|
||||
* CDK兑换API v2 - 20260620
|
||||
* POST ?action=exchange - CDK兑换(需token,由插件请求)
|
||||
* POST ?action=create - 创建CDK(需管理token)
|
||||
* GET ?action=list - CDK列表(需管理token)
|
||||
* POST ?action=delete - 删除CDK(需管理token)
|
||||
* POST ?action=batch - 批量生成CDK(需管理token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 再次确保清理前置输出
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
$action = getParam('action', 'exchange');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'exchange':
|
||||
cdkExchange($token);
|
||||
break;
|
||||
case 'create':
|
||||
cdkCreate($token);
|
||||
break;
|
||||
case 'list':
|
||||
cdkList($token);
|
||||
break;
|
||||
case 'delete':
|
||||
cdkDelete($token);
|
||||
break;
|
||||
case 'batch':
|
||||
cdkBatch($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== CDK兑换 =====
|
||||
function cdkExchange($token) {
|
||||
$code = getParam('code');
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$code) error('缺少code');
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
// ★ 双保险验证:CDK兑换需要密码确认
|
||||
$isPreview = true;
|
||||
$validated = false;
|
||||
|
||||
if ($token) {
|
||||
// 先检查是否为普通管理token
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
$validated = true;
|
||||
} else {
|
||||
// weblogin token → 走双保险验证
|
||||
$accessResult = validateWebAccess($token, 'cdk', $password, $ipAddress);
|
||||
if ($accessResult['ok'] || $accessResult['mode'] === 'full_verified') {
|
||||
$isPreview = false;
|
||||
$validated = true;
|
||||
$player = $accessResult['player'];
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_password' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有token也不是管理token,返回预览
|
||||
if (!$validated) {
|
||||
$isPreview = true;
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 查询CDK
|
||||
$stmt = $db->prepare("SELECT * FROM cdk WHERE code = :code");
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$cdk = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$cdk) {
|
||||
// ★ 本地没找到 → 写待验证请求,轮询Java Sdf1_login返回结果
|
||||
$cdk = tryRemoteCdkValidation($db, $code, $player);
|
||||
if (!$cdk) {
|
||||
error('CDK不存在: ' . $code);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cdk['used'] == 1) {
|
||||
error('CDK已被使用: ' . $cdk['used_by']);
|
||||
}
|
||||
|
||||
// 查询当前余额
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
amount INTEGER DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$currentBalance = $row ? $row['amount'] : 0;
|
||||
$newBalance = $currentBalance + $cdk['amount'];
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'code' => $code,
|
||||
'amount' => $cdk['amount'],
|
||||
'type' => $cdk['type'],
|
||||
'player' => $player,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance,
|
||||
'valid' => true
|
||||
], '预览模式 - CDK兑换不会生效');
|
||||
}
|
||||
|
||||
// 执行兑换
|
||||
$now = time();
|
||||
$isRemote = !empty($cdk['_remote']);
|
||||
|
||||
// ★ 全部DB操作包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 标记CDK已使用(仅本地CDK需要)
|
||||
if (!$isRemote) {
|
||||
$stmt = $db->prepare("UPDATE cdk SET used = 1, used_by = :player, used_at = :time WHERE code = :code");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// ★ 远程CDK:Java端(cdkManager.redeem)已处理加债券,Web端只做记录,不再加bond_cache
|
||||
if (!$isRemote) {
|
||||
// 更新余额
|
||||
if ($row) {
|
||||
$stmt = $db->prepare("UPDATE bond_cache SET amount = :amount, updated_at = :time WHERE player_name = :name");
|
||||
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
} else {
|
||||
$stmt = $db->prepare("INSERT INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// 记录流水 + sync_requests(仅本地CDK需要,远程CDK由Java记录流水)
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'cdk_redeem', :amount, :reason, :detail, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "CDK兑换: {$code}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':detail', json_encode(['code' => $code, 'remote' => false], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$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();
|
||||
} else {
|
||||
// 远程CDK:只记录日志流水(status=completed,不走pending同步)
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'cdk_redeem_remote', :amount, :reason, :detail, 'completed', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "CDK远程兑换: {$code}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':detail', json_encode(['code' => $code, 'remote' => true], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||||
@error_log("[cdk] 兑换事务失败: " . $e->getMessage());
|
||||
}
|
||||
|
||||
success([
|
||||
'code' => $code,
|
||||
'amount' => $cdk['amount'],
|
||||
'player' => $player,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance
|
||||
], 'CDK兑换成功');
|
||||
}
|
||||
|
||||
// ===== 创建CDK =====
|
||||
function cdkCreate($token) {
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$type = getParam('type', 'bond');
|
||||
$code = getParam('code');
|
||||
|
||||
if ($amount <= 0) error('金额必须大于0');
|
||||
|
||||
// 需要管理token
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
$code = strtoupper(bin2hex(random_bytes(4))); // 8位随机码
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
// 检查是否已存在
|
||||
$stmt = $db->prepare("SELECT 1 FROM cdk WHERE code = :code");
|
||||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
if ($result->fetchArray()) {
|
||||
error('CDK已存在: ' . $code);
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
'type' => $type
|
||||
], '预览模式 - CDK创建不会生效');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
|
||||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success(['code' => $code, 'amount' => $amount, 'type' => $type], 'CDK创建成功');
|
||||
}
|
||||
|
||||
// ===== CDK列表 =====
|
||||
function cdkList($token) {
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$result = $db->query("SELECT code, amount, type, used, used_by, created_at, used_at FROM cdk ORDER BY created_at DESC LIMIT 200");
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview($list, '预览模式');
|
||||
} else {
|
||||
success($list);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 删除CDK =====
|
||||
function cdkDelete($token) {
|
||||
$code = getParam('code');
|
||||
if (!$code) error('缺少code');
|
||||
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
if ($isPreview) {
|
||||
preview(['code' => $code], '预览模式 - 删除不会生效');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM cdk WHERE code = :code");
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
success(['code' => $code], 'CDK已删除');
|
||||
}
|
||||
|
||||
// ===== 批量生成CDK =====
|
||||
function cdkBatch($token) {
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$count = (int)getParam('count', 1);
|
||||
$type = getParam('type', 'bond');
|
||||
|
||||
if ($amount <= 0) error('金额必须大于0');
|
||||
if ($count < 1 || $count > 100) error('数量范围: 1-100');
|
||||
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = strtoupper(bin2hex(random_bytes(4)));
|
||||
$codes[] = $code;
|
||||
|
||||
if (!$isPreview) {
|
||||
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
|
||||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'codes' => $codes,
|
||||
'amount' => $amount,
|
||||
'count' => $count
|
||||
], '预览模式 - 批量创建不会生效');
|
||||
} else {
|
||||
success([
|
||||
'codes' => $codes,
|
||||
'amount' => $amount,
|
||||
'count' => $count
|
||||
], "成功创建{$count}个CDK");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于CDK兑换)=====
|
||||
function validateWebloginTokenForCDK($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 (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 远程CDK验证:写请求表,轮询Java Sdf1_login结果 =====
|
||||
function tryRemoteCdkValidation($db, $code, $player = '') {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_requests (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
player_name TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
// ★ 迁移:给已有表补 player_name 列
|
||||
$cols = [];
|
||||
$colResult = $db->query("PRAGMA table_info(cdk_validate_requests)");
|
||||
while ($col = $colResult->fetchArray(SQLITE3_ASSOC)) { $cols[] = $col['name']; }
|
||||
if (!in_array('player_name', $cols)) {
|
||||
$db->exec("ALTER TABLE cdk_validate_requests ADD COLUMN player_name TEXT DEFAULT ''");
|
||||
}
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_results (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
amount INTEGER DEFAULT 0,
|
||||
message TEXT DEFAULT '',
|
||||
used INTEGER DEFAULT 0,
|
||||
used_by TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
|
||||
$requestId = strtoupper(bin2hex(random_bytes(8)));
|
||||
$now = time();
|
||||
|
||||
// ★ 写入待验证请求 — 用BEGIN IMMEDIATE防止database is locked
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO cdk_validate_requests (request_id, code, player_name, status, created_at) VALUES (:id, :code, :player, 'pending', :time)");
|
||||
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
@error_log("[cdk] 远程验证写入失败: {$e->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@error_log("[cdk] 远程验证请求: {$requestId} code={$code} player={$player}");
|
||||
|
||||
// ★ 轮询结果(最多8秒,每300ms一次)
|
||||
$result = null;
|
||||
for ($i = 0; $i < 27; $i++) {
|
||||
usleep(300000); // 300ms
|
||||
$stmt = $db->prepare("SELECT * FROM cdk_validate_results WHERE request_id = :id");
|
||||
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
|
||||
$rs = $stmt->execute();
|
||||
$result = $rs->fetchArray(SQLITE3_ASSOC);
|
||||
if ($result) break;
|
||||
}
|
||||
|
||||
// 清理请求和结果
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
$db->exec("DELETE FROM cdk_validate_requests WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
|
||||
if ($result) {
|
||||
$db->exec("DELETE FROM cdk_validate_results WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
|
||||
}
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
}
|
||||
|
||||
if (!$result) {
|
||||
@error_log("[cdk] 远程验证超时: {$requestId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@error_log("[cdk] 远程验证结果: status={$result['status']} amount={$result['amount']}");
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
// ★ Java端已标记used并加债券,Web端只做记录不重复加钱
|
||||
return [
|
||||
'code' => $result['code'],
|
||||
'amount' => (int)$result['amount'],
|
||||
'type' => 'bond',
|
||||
'used' => 0,
|
||||
'used_by' => '',
|
||||
'created_at' => $now,
|
||||
'used_at' => 0,
|
||||
'_remote' => true
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
/**
|
||||
* 领地系统API - 接收Java端领地数据 + 管理面板
|
||||
* POST: Java端推送领地数据
|
||||
* GET: admin面板读取领地数据
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
|
||||
try {
|
||||
// ★ 加载core.php(与sync.php相同的加载方式)
|
||||
$coreFile = dirname(__DIR__) . '/core.php';
|
||||
if (!file_exists($coreFile)) {
|
||||
$coreFile = dirname(__DIR__) . '/plugin/core.php';
|
||||
}
|
||||
require_once $coreFile;
|
||||
|
||||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||
$secret = $_GET['secret'] ?? $_POST['secret'] ?? '';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ===== 验证 =====
|
||||
if ($method === 'POST') {
|
||||
if (!validateSecret($secret)) {
|
||||
echo json_encode(['success' => false, 'error' => 'invalid secret']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 建表
|
||||
initLandTables($db);
|
||||
|
||||
switch ($action) {
|
||||
// ===== Java端推送领地数据 =====
|
||||
case 'sync_lands':
|
||||
handleSyncLands($db, $_POST);
|
||||
break;
|
||||
|
||||
// ===== Java端推送权限商店数据 =====
|
||||
case 'sync_shop':
|
||||
handleSyncShop($db, $_POST);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:获取所有领地 =====
|
||||
case 'list_lands':
|
||||
if ($method !== 'GET') {
|
||||
echo json_encode(['success' => false, 'error' => 'GET only']);
|
||||
exit;
|
||||
}
|
||||
handleListLands($db);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:获取权限商店 =====
|
||||
case 'list_shop':
|
||||
if ($method !== 'GET') {
|
||||
echo json_encode(['success' => false, 'error' => 'GET only']);
|
||||
exit;
|
||||
}
|
||||
handleListShop($db);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:获取配置 =====
|
||||
case 'get_config':
|
||||
handleGetConfig($db);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:更新配置 =====
|
||||
case 'update_config':
|
||||
handleUpdateConfig($db, $_POST);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:删除领地 =====
|
||||
case 'delete_land':
|
||||
handleDeleteLand($db, $_POST);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:更新领地所有者 =====
|
||||
case 'update_land_owner':
|
||||
handleUpdateLandOwner($db, $_POST);
|
||||
break;
|
||||
|
||||
// ===== 管理面板:删除权限商品 =====
|
||||
case 'delete_shop_item':
|
||||
handleDeleteShopItem($db, $_POST);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['success' => false, 'error' => 'unknown action']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// ==================== 函数 ====================
|
||||
|
||||
function initLandTables($db) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_lands (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
owner TEXT DEFAULT '',
|
||||
world TEXT DEFAULT '',
|
||||
x1 INTEGER DEFAULT 0, z1 INTEGER DEFAULT 0,
|
||||
x2 INTEGER DEFAULT 0, z2 INTEGER DEFAULT 0,
|
||||
y_min INTEGER DEFAULT 0, y_max INTEGER DEFAULT 255,
|
||||
area_size INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT 0,
|
||||
synced_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_shop (
|
||||
id INTEGER PRIMARY KEY,
|
||||
land_id INTEGER NOT NULL,
|
||||
land_name TEXT DEFAULT '',
|
||||
seller TEXT NOT NULL,
|
||||
permission TEXT DEFAULT 'visitor',
|
||||
price INTEGER DEFAULT 0,
|
||||
duration INTEGER DEFAULT 86400,
|
||||
status TEXT DEFAULT 'active',
|
||||
buyer TEXT DEFAULT '',
|
||||
bought_at INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT 0,
|
||||
synced_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT DEFAULT ''
|
||||
)");
|
||||
}
|
||||
|
||||
function handleSyncLands($db, $post) {
|
||||
$lands = json_decode($post['lands'] ?? '[]', true);
|
||||
if (!is_array($lands)) {
|
||||
echo json_encode(['success' => false, 'error' => 'invalid lands data']);
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_area_lands
|
||||
(id, name, owner, world, x1, z1, x2, z2, y_min, y_max, area_size, created_at, synced_at)
|
||||
VALUES (:id, :name, :owner, :world, :x1, :z1, :x2, :z2, :ymin, :ymax, :size, :created, :synced)");
|
||||
|
||||
foreach ($lands as $land) {
|
||||
$stmt->bindValue(':id', (int)($land['id'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':name', $land['name'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':owner', $land['owner'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':world', $land['world'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':x1', (int)($land['x1'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':z1', (int)($land['z1'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':x2', (int)($land['x2'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':z2', (int)($land['z2'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':ymin', (int)($land['y_min'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':ymax', (int)($land['y_max'] ?? 255), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':size', (int)($land['area_size'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':created', (int)($land['created_at'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'count' => count($lands)]);
|
||||
}
|
||||
|
||||
function handleSyncShop($db, $post) {
|
||||
$items = json_decode($post['items'] ?? '[]', true);
|
||||
if (!is_array($items)) {
|
||||
echo json_encode(['success' => false, 'error' => 'invalid shop data']);
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_area_shop
|
||||
(id, land_id, land_name, seller, permission, price, duration, status, buyer, bought_at, created_at, synced_at)
|
||||
VALUES (:id, :land_id, :land_name, :seller, :perm, :price, :dur, :status, :buyer, :bought, :created, :synced)");
|
||||
|
||||
foreach ($items as $item) {
|
||||
$stmt->bindValue(':id', (int)($item['id'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':land_id', (int)($item['land_id'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':land_name', $item['land_name'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':seller', $item['seller'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':perm', $item['permission'] ?? 'visitor', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':price', (int)($item['price'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':dur', (int)($item['duration'] ?? 86400), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':status', $item['status'] ?? 'active', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':buyer', $item['buyer'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':bought', (int)($item['bought_at'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':created', (int)($item['created_at'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'count' => count($items)]);
|
||||
}
|
||||
|
||||
function handleListLands($db) {
|
||||
$result = $db->query("SELECT * FROM web_area_lands ORDER BY synced_at DESC");
|
||||
$lands = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$lands[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'lands' => $lands]);
|
||||
}
|
||||
|
||||
function handleListShop($db) {
|
||||
$result = $db->query("SELECT * FROM web_area_shop ORDER BY created_at DESC LIMIT 100");
|
||||
$items = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$items[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'items' => $items]);
|
||||
}
|
||||
|
||||
function handleGetConfig($db) {
|
||||
$config = [];
|
||||
$result = $db->query("SELECT * FROM web_area_config");
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$config[$row['key']] = $row['value'];
|
||||
}
|
||||
echo json_encode(['success' => true, 'config' => $config]);
|
||||
}
|
||||
|
||||
function handleUpdateConfig($db, $post) {
|
||||
$key = $post['key'] ?? '';
|
||||
$value = $post['value'] ?? '';
|
||||
if (empty($key)) {
|
||||
echo json_encode(['success' => false, 'error' => 'missing key']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_area_config (key, value) VALUES (:key, :val)");
|
||||
$stmt->bindValue(':key', $key, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':val', $value, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
function handleDeleteLand($db, $post) {
|
||||
$name = $post['name'] ?? '';
|
||||
if (empty($name)) {
|
||||
echo json_encode(['success' => false, 'error' => 'missing name']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM web_area_lands WHERE name = :name");
|
||||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
function handleUpdateLandOwner($db, $post) {
|
||||
$name = $post['name'] ?? '';
|
||||
$owner = $post['owner'] ?? '';
|
||||
if (empty($name)) {
|
||||
echo json_encode(['success' => false, 'error' => 'missing name']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE web_area_lands SET owner = :owner WHERE name = :name");
|
||||
$stmt->bindValue(':owner', $owner, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
function handleDeleteShopItem($db, $post) {
|
||||
$id = (int)($post['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['success' => false, 'error' => 'invalid id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE web_area_shop SET status = 'removed' WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
|
||||
function validateSecret($secret) {
|
||||
if (empty($secret)) return false;
|
||||
$validSecrets = [
|
||||
'sdf1_web_comm_2026_ypshidifu'
|
||||
];
|
||||
return in_array($secret, $validSecrets);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
/**
|
||||
* 注册API
|
||||
* POST ?action=check - 检查用户名是否已注册
|
||||
* POST ?action=register - 注册新账号(需token)
|
||||
* GET ?action=query - 查询账号信息(需token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../core.php';
|
||||
ob_end_clean();
|
||||
|
||||
$action = getParam('action', 'check');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'check':
|
||||
registerCheck();
|
||||
break;
|
||||
case 'register':
|
||||
registerAccount($token);
|
||||
break;
|
||||
case 'web_register':
|
||||
webRegister();
|
||||
break;
|
||||
case 'query':
|
||||
registerQuery($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 检查用户名 =====
|
||||
function registerCheck() {
|
||||
$player = getParam('player');
|
||||
if (!$player) error('缺少player参数');
|
||||
|
||||
$db = getDB();
|
||||
// 只查询users表实际存在的列
|
||||
$stmt = $db->prepare("SELECT player_name, register_time, email FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
success([
|
||||
'exists' => !!$row,
|
||||
'player' => $player,
|
||||
'info' => $row ?: null
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 注册账号 =====
|
||||
function registerAccount($token) {
|
||||
$player = getParam('player');
|
||||
$passwordHash = getParam('password_hash');
|
||||
$salt = getParam('salt');
|
||||
$email = getParam('email', '');
|
||||
$ip = getParam('ip', '');
|
||||
|
||||
if (!$player) error('缺少player');
|
||||
if (!$passwordHash) error('缺少password_hash');
|
||||
if (!$salt) error('缺少salt');
|
||||
|
||||
// 验证token(注册需要token)
|
||||
$isPreview = true;
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 检查是否已注册
|
||||
$stmt = $db->prepare("SELECT 1 FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
if ($result->fetchArray()) {
|
||||
error('账号已存在: ' . $player);
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'player' => $player,
|
||||
'email' => $email,
|
||||
'would_register' => true
|
||||
], '预览模式 - 注册不会生效');
|
||||
}
|
||||
|
||||
// 执行注册
|
||||
$now = time();
|
||||
$stmt = $db->prepare("INSERT INTO users (player_name, register_time, email) VALUES (:name, :time, :email)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
// 记录
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, created_at) VALUES (:player, 'register', 0, 'Web端注册', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success(['player' => $player], '注册成功');
|
||||
}
|
||||
|
||||
// ===== Web注册请求(由Web端提交,等待插件同步)=====
|
||||
function webRegister() {
|
||||
$webToken = getParam('web_token');
|
||||
$password = getParam('password');
|
||||
$email = getParam('email', '');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$webToken) error('缺少web_token');
|
||||
if (!$password) error('缺少password');
|
||||
if (strlen($password) < 6) error('密码长度至少6位');
|
||||
|
||||
// 验证web_token
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$tokenRow = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$tokenRow) error('无效的Web登录Token');
|
||||
|
||||
$createdAt = (int)($tokenRow['created_at'] ?? 0);
|
||||
$expireSeconds = (int)($tokenRow['expire_seconds'] ?? 600);
|
||||
if (time() - $createdAt > $expireSeconds) error('Web登录Token已过期');
|
||||
|
||||
$playerName = $tokenRow['player_name'];
|
||||
|
||||
// 检查是否已注册
|
||||
if (isPlayerRegistered($playerName)) {
|
||||
error('玩家已注册: ' . $playerName);
|
||||
}
|
||||
|
||||
// 生成密码凭证(与插件相同的SHA-256算法)
|
||||
$saltBytes = random_bytes(16);
|
||||
$salt = base64_encode($saltBytes);
|
||||
$passwordHash = base64_encode(hash('sha256', $saltBytes . $password, true));
|
||||
|
||||
// ★ 创建web_register_requests表记录(等待插件同步)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_register_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
email TEXT DEFAULT '',
|
||||
ip_address TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
processed_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// 插入注册请求
|
||||
$stmt = $db->prepare("INSERT INTO web_register_requests (player_name, password_hash, salt, email, ip_address, created_at) VALUES (:player, :hash, :salt, :email, :ip, :time)");
|
||||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':hash', $passwordHash, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':salt', $salt, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':ip', $ipAddress, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$requestId = $db->lastInsertRowID();
|
||||
|
||||
success([
|
||||
'player' => $playerName,
|
||||
'request_id' => $requestId,
|
||||
'email' => $email
|
||||
], '注册请求已提交,等待游戏同步');
|
||||
}
|
||||
|
||||
// ===== 查询账号信息 =====
|
||||
function registerQuery($token) {
|
||||
$player = getParam('player');
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
$db = getDB();
|
||||
// 只查询users表实际存在的列
|
||||
$stmt = $db->prepare("SELECT player_name, register_time, last_login_time, email, points, gift_stage, total_online_time FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
error('账号不存在: ' . $player);
|
||||
}
|
||||
|
||||
// 处理Java插件推送的毫秒级时间戳,转换为秒级
|
||||
if ($row['register_time'] && $row['register_time'] > 1000000000000) {
|
||||
$row['register_time'] = (int)($row['register_time'] / 1000);
|
||||
}
|
||||
if ($row['last_login_time'] && $row['last_login_time'] > 1000000000000) {
|
||||
$row['last_login_time'] = (int)($row['last_login_time'] / 1000);
|
||||
}
|
||||
|
||||
// ★ 获取债券余额
|
||||
$bonds = 0;
|
||||
try {
|
||||
$bondStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$bondStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$bondResult = $bondStmt->execute();
|
||||
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ★ 获取累计在线天数(不重复日期计数)
|
||||
$totalOnlineDays = 0;
|
||||
try {
|
||||
$odStmt = $db->prepare("SELECT COUNT(DISTINCT login_date) as cnt FROM player_daily_logins WHERE player_name = :name");
|
||||
$odStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$odResult = $odStmt->execute();
|
||||
$odRow = $odResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ★ 获取累计签到天数
|
||||
$totalCheckinDays = 0;
|
||||
try {
|
||||
$ciStmt = $db->prepare("SELECT COUNT(DISTINCT checkin_date) as cnt FROM player_checkins WHERE player_name = :name");
|
||||
$ciStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$ciResult = $ciStmt->execute();
|
||||
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 合并额外数据
|
||||
$row['bonds'] = $bonds;
|
||||
$row['total_online_days'] = $totalOnlineDays;
|
||||
$row['total_checkin_days'] = $totalCheckinDays;
|
||||
|
||||
// 邮箱脱敏
|
||||
if (!empty($row['email'])) {
|
||||
$parts = explode('@', $row['email']);
|
||||
if (count($parts) === 2) {
|
||||
$row['masked_email'] = str_repeat('*', min(strlen($parts[0]), 3)) . '@' . $parts[1];
|
||||
} else {
|
||||
$row['masked_email'] = '***';
|
||||
}
|
||||
} else {
|
||||
$row['masked_email'] = '';
|
||||
}
|
||||
|
||||
// ★ 严格校验:必须有有效token且token对应的玩家必须与查询的玩家匹配(除非是管理员token)
|
||||
if (!$token) {
|
||||
// 无token:隐藏敏感信息
|
||||
$previewRow = [
|
||||
'player_name' => $row['player_name'],
|
||||
'register_time' => $row['register_time'],
|
||||
'last_login_time' => $row['last_login_time'],
|
||||
'email' => '',
|
||||
'masked_email' => '',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0,
|
||||
'bonds' => 0,
|
||||
'total_online_days' => 0,
|
||||
'total_checkin_days' => 0
|
||||
];
|
||||
preview($previewRow, '预览模式 - 部分信息已隐藏');
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenInfo = validateTokenSilent($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' => '',
|
||||
'masked_email' => '***',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0,
|
||||
'bonds' => 0,
|
||||
'total_online_days' => 0,
|
||||
'total_checkin_days' => 0
|
||||
];
|
||||
preview($maskedRow, '预览模式 - 您只能查看自己的账号信息');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* 商城API
|
||||
* GET ?action=list - 商品列表(无需token,公开接口)
|
||||
* POST ?action=buy - 购买商品(需token)
|
||||
* GET ?action=categories - 分类列表(无需token,公开接口)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 再次确保清理前置输出
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
$action = getParam('action', 'list');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
shopList($token);
|
||||
break;
|
||||
case 'buy':
|
||||
shopBuy($token);
|
||||
break;
|
||||
case 'categories':
|
||||
shopCategories($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 商品列表(公开,无需token) =====
|
||||
function shopList($token = null) {
|
||||
try {
|
||||
$db = getDB();
|
||||
$category = getParam('category');
|
||||
|
||||
if ($category) {
|
||||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE category = :cat ORDER BY id");
|
||||
$stmt->bindValue(':cat', $category, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
} else {
|
||||
$result = $db->query("SELECT * FROM shop_items ORDER BY category, id");
|
||||
}
|
||||
|
||||
$items = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$items[] = $row;
|
||||
}
|
||||
|
||||
// 直接返回数据,不验证token(公开接口)
|
||||
$isPreview = !$token;
|
||||
if ($token && validateTokenSilent($token)) {
|
||||
$isPreview = false;
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview($items, '预览模式 - 无有效token');
|
||||
} else {
|
||||
success($items);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error('商城加载失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenSilent() 已在 core.php 中定义,不再重复声明
|
||||
|
||||
// ===== 分类列表 =====
|
||||
function shopCategories() {
|
||||
$db = getDB();
|
||||
$result = $db->query("SELECT DISTINCT category, COUNT(*) as item_count FROM shop_items GROUP BY category");
|
||||
$cats = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$cats[] = $row;
|
||||
}
|
||||
success($cats);
|
||||
}
|
||||
|
||||
// ===== 购买商品 =====
|
||||
function shopBuy($token) {
|
||||
$itemId = getParam('item_id');
|
||||
$amount = (int)getParam('amount', 1);
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$itemId) error('缺少item_id');
|
||||
if (!$player) error('缺少player');
|
||||
if ($amount < 1 || $amount > 64) error('数量无效');
|
||||
|
||||
// ★ 双保险验证:购买操作需要密码确认
|
||||
$isPreview = true;
|
||||
$isOnline = false;
|
||||
$isRegistered = false;
|
||||
|
||||
if ($token) {
|
||||
// 先检查是否为普通管理token
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
if (validateToken($token)) {
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
} else {
|
||||
// weblogin token → 走双保险验证
|
||||
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
|
||||
if ($accessResult['ok']) {
|
||||
$isPreview = false;
|
||||
$player = $accessResult['player'];
|
||||
$isOnline = $accessResult['online'] ?? false;
|
||||
$isRegistered = $accessResult['registered'] ?? false;
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_password' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} elseif ($accessResult['mode'] === 'need_game_login') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_game_login' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} elseif ($accessResult['mode'] === 'need_register') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_register' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询商品
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
|
||||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$item = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$item) error('商品不存在: ' . $itemId);
|
||||
|
||||
// 检查库存
|
||||
if ($item['stock'] == 0) error('商品已售罄');
|
||||
if ($item['stock'] > 0 && $item['stock'] < $amount) error('库存不足');
|
||||
|
||||
$totalPrice = $item['buy_price'] * $amount;
|
||||
|
||||
// 检查玩家余额
|
||||
if (!$isPreview && $totalPrice > 0) {
|
||||
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
|
||||
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$balanceResult = $balanceStmt->execute();
|
||||
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$balanceRow) {
|
||||
error('玩家余额查询失败');
|
||||
}
|
||||
|
||||
$balance = (int)$balanceRow['amount'];
|
||||
if ($balance < $totalPrice) {
|
||||
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $totalPrice . ' 债券');
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
// 预览模式:返回计算结果但不执行
|
||||
preview([
|
||||
'item_id' => $itemId,
|
||||
'item_name' => $item['display_name'],
|
||||
'amount' => $amount,
|
||||
'unit_price' => $item['buy_price'],
|
||||
'total_price' => $totalPrice,
|
||||
'stock_before' => $item['stock'],
|
||||
'stock_after' => $item['stock'] > 0 ? $item['stock'] - $amount : -1,
|
||||
'player' => $player
|
||||
], '预览模式 - 购买不会生效');
|
||||
}
|
||||
|
||||
// ★ 实际购买 — 包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 更新库存(同时写admin_stock确保Java同步)
|
||||
if ($item['stock'] > 0) {
|
||||
$newStock = $item['stock'] - $amount;
|
||||
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
} else {
|
||||
// 无限库存,只更新销量
|
||||
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// 记录交易
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':detail', json_encode(['item_id' => $itemId, 'amount' => $amount, 'unit_price' => $item['buy_price']], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// ★ 写入sync_requests(必须在success/exit之前!)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
error('购买失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
success([
|
||||
'item_id' => $itemId,
|
||||
'item_name' => $item['display_name'],
|
||||
'amount' => $amount,
|
||||
'total_price' => $totalPrice,
|
||||
'player' => $player
|
||||
], '购买成功');
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于商城购买)=====
|
||||
function validateWebloginTokenForShop($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;
|
||||
}
|
||||
}
|
||||
+3600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,825 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单系统API
|
||||
*
|
||||
* 用户端:提交工单、查看我的工单、查看详情、回复(MD)、撤销
|
||||
* 管理员端:查看所有工单、回复、驳回、分配服务商
|
||||
* 服务商端:抢单大厅、我处理的工单、回复、标记完结
|
||||
*/
|
||||
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
ob_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
|
||||
register_shutdown_function(function() {
|
||||
while (ob_get_level() > 0) { @ob_end_flush(); }
|
||||
});
|
||||
|
||||
require_once __DIR__ . '/../core.php';
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
||||
|
||||
$action = getParam('action', '');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
// ===== 用户端 =====
|
||||
case 'create': ticketCreate(); break;
|
||||
case 'my_list': ticketMyList(); break;
|
||||
case 'detail': ticketDetail(); break;
|
||||
case 'reply': ticketReply(); break;
|
||||
case 'withdraw': ticketWithdraw(); break;
|
||||
|
||||
// ===== 管理员端 =====
|
||||
case 'list_all': ticketListAll(); break;
|
||||
case 'admin_reply': ticketAdminReply(); break;
|
||||
case 'reject': ticketReject(); break;
|
||||
case 'assign': ticketAssign(); break;
|
||||
case 'admin_create': ticketAdminCreate(); break;
|
||||
case 'admin_complete': ticketAdminComplete(); break;
|
||||
|
||||
// ===== 服务商端 =====
|
||||
case 'provider_list': ticketProviderList(); break;
|
||||
case 'available': ticketAvailable(); break;
|
||||
case 'grab': ticketGrab(); break;
|
||||
case 'provider_reply': ticketProviderReply(); break;
|
||||
case 'complete': ticketProviderComplete(); break;
|
||||
|
||||
// ===== 通用 =====
|
||||
case 'providers': ticketProviders(); break;
|
||||
case 'provider_check': ticketProviderCheck(); break;
|
||||
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error('服务器错误: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
function requireWebToken() {
|
||||
$token = getParam('token');
|
||||
if (!$token) error('缺少token', 401);
|
||||
$info = validateTokenSilent($token);
|
||||
if (!$info) error('token无效或已过期', 401);
|
||||
return $info;
|
||||
}
|
||||
|
||||
function requireAdmin() {
|
||||
if (!isAdminLoggedIn()) error('未登录管理后台', 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为管理员或授权的服务商用户
|
||||
* 管理员通过session认证,用户通过token认证
|
||||
*/
|
||||
function getAuthUser() {
|
||||
if (isAdminLoggedIn()) return ['player' => 'admin', 'role' => 'admin'];
|
||||
return requireWebToken();
|
||||
}
|
||||
|
||||
function isServiceProviderInGame($playerName) {
|
||||
// 优先从Web.db的web_service_providers表读取(Java同步数据)
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT 1 FROM web_service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray();
|
||||
if ($row !== false) return true;
|
||||
} catch (\Throwable $e) { /* 表可能不存在 */ }
|
||||
|
||||
// 回退:尝试读取Java的login.db(本地开发环境)
|
||||
$loginDbPath = GAME_LOGIN_DB;
|
||||
if (!file_exists($loginDbPath)) return false;
|
||||
try {
|
||||
$loginDb = new SQLite3($loginDbPath);
|
||||
$loginDb->enableExceptions(true);
|
||||
$stmt = $loginDb->prepare("SELECT 1 FROM service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray();
|
||||
$loginDb->close();
|
||||
return $row !== false;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceProviderRole($playerName) {
|
||||
// 优先从Web.db读取
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT role FROM web_service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($row) return $row['role'];
|
||||
} catch (\Throwable $e) { /* 表可能不存在 */ }
|
||||
|
||||
// 回退:读取Java的login.db
|
||||
$loginDbPath = GAME_LOGIN_DB;
|
||||
if (!file_exists($loginDbPath)) return null;
|
||||
try {
|
||||
$loginDb = new SQLite3($loginDbPath);
|
||||
$loginDb->enableExceptions(true);
|
||||
$stmt = $loginDb->prepare("SELECT role FROM service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$loginDb->close();
|
||||
return $row ? $row['role'] : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ticketCreate() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
|
||||
$type = trim(getParam('type', ''));
|
||||
$title = trim(getParam('title', ''));
|
||||
$description = trim(getParam('description', ''));
|
||||
|
||||
if (empty($type)) error('请选择工单类型');
|
||||
if (empty($title)) error('请输入工单标题');
|
||||
if (mb_strlen($title) > 100) error('标题不能超过100字');
|
||||
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
|
||||
|
||||
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
|
||||
if (!in_array($type, $validTypes)) error('无效的工单类型');
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :now, :now)");
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$ticketId = $db->lastInsertRowID();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('创建失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
success(['id' => $ticketId], '工单提交成功');
|
||||
}
|
||||
|
||||
function ticketMyList() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$db = getDB();
|
||||
|
||||
$status = getParam('status', '');
|
||||
$page = max(1, (int)getParam('page', 1));
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$where = "WHERE requester = :player";
|
||||
$params = [':player' => $player];
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$where .= " AND status = :status";
|
||||
$params[':status'] = $status;
|
||||
}
|
||||
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
|
||||
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
success([
|
||||
'list' => $list,
|
||||
'total' => (int)$total,
|
||||
'page' => $page,
|
||||
'pages' => ceil($total / $limit)
|
||||
]);
|
||||
}
|
||||
|
||||
function ticketDetail() {
|
||||
$id = (int)getParam('id', 0);
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row) error('工单不存在');
|
||||
|
||||
// 权限检查:管理员可以直接查看,普通用户只能看自己的工单
|
||||
if (isAdminLoggedIn()) {
|
||||
// 管理员,直接放行
|
||||
} else {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
if ($row['requester'] !== $player && $row['assigned_to'] !== $player) {
|
||||
error('无权查看此工单');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取回复列表
|
||||
$replies = getTicketReplies($id);
|
||||
|
||||
$row['replies'] = $replies;
|
||||
success($row);
|
||||
}
|
||||
|
||||
function ticketReply() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
$message = trim(getParam('message', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($message)) error('回复内容不能为空');
|
||||
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 检查工单状态和权限
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['requester'] !== $player) error('只能回复自己的工单');
|
||||
if (in_array($ticket['status'], ['withdrawn', 'rejected'])) error('该工单已关闭,无法回复');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 插入回复
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'user', :msg, :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 更新工单状态为已回复
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('回复失败');
|
||||
}
|
||||
|
||||
success(null, '回复成功');
|
||||
}
|
||||
|
||||
function ticketWithdraw() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['requester'] !== $player) error('无权操作此工单');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'withdrawn', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('撤销失败');
|
||||
}
|
||||
|
||||
success(null, '工单已撤销');
|
||||
}
|
||||
|
||||
// ===== 管理员端 =====
|
||||
|
||||
function ticketListAll() {
|
||||
requireAdmin();
|
||||
$db = getDB();
|
||||
|
||||
// 兜底建表(服务器core.php可能版本旧)
|
||||
try {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'submitted',
|
||||
requester TEXT NOT NULL,
|
||||
assigned_to TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
reject_reason TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)");
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user',
|
||||
message TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_requester ON web_tickets(requester)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_status ON web_tickets(status)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_assigned ON web_tickets(assigned_to)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wtr_ticket ON web_ticket_replies(ticket_id)");
|
||||
} catch (\Throwable $e) {
|
||||
error('工单表初始化失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$status = getParam('status', 'all');
|
||||
$page = max(1, (int)getParam('page', 1));
|
||||
$search = trim(getParam('search', ''));
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$where = "WHERE 1=1";
|
||||
$params = [];
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$where .= " AND status = :status";
|
||||
$params[':status'] = $status;
|
||||
}
|
||||
if ($search) {
|
||||
$where .= " AND (requester LIKE :search OR title LIKE :search2)";
|
||||
$params[':search'] = "%$search%";
|
||||
$params[':search2'] = "%$search%";
|
||||
}
|
||||
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
|
||||
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
// 统计各状态数量
|
||||
$stats = [];
|
||||
foreach (['submitted', 'replied', 'completed', 'withdrawn', 'rejected'] as $s) {
|
||||
$cnt = $db->querySingle("SELECT COUNT(*) FROM web_tickets WHERE status = '$s'");
|
||||
$stats[$s] = (int)$cnt;
|
||||
}
|
||||
$stats['all'] = (int)$db->querySingle("SELECT COUNT(*) FROM web_tickets");
|
||||
|
||||
success([
|
||||
'list' => $list,
|
||||
'total' => (int)$total,
|
||||
'page' => $page,
|
||||
'pages' => ceil($total / $limit),
|
||||
'stats' => $stats
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
error('查询工单失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function ticketAdminReply() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
$message = trim(getParam('message', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($message)) error('回复内容不能为空');
|
||||
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', :msg, :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 管理员回复 → 状态更新为已回复(不改变assigned_to,保留原服务商所有权)
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('回复失败');
|
||||
}
|
||||
|
||||
success(null, '回复成功');
|
||||
}
|
||||
|
||||
function ticketReject() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
$reason = trim(getParam('reason', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($reason)) error('请输入驳回原因');
|
||||
if (mb_strlen($reason) > 500) error('驳回原因不能超过500字');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['status'] === 'withdrawn' || $ticket['status'] === 'rejected') error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'rejected', reject_reason = :reason, updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':reason', $reason, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('驳回失败');
|
||||
}
|
||||
|
||||
success(null, '工单已驳回');
|
||||
}
|
||||
|
||||
function ticketAssign() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
$provider = trim(getParam('provider', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($provider)) error('请输入服务商名称');
|
||||
|
||||
// 验证是否为服务商
|
||||
if (!isServiceProviderInGame($provider)) error("$provider 不是服务商");
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('分配失败');
|
||||
}
|
||||
|
||||
success(null, "已分配给 $provider");
|
||||
}
|
||||
|
||||
function ticketAdminCreate() {
|
||||
requireAdmin();
|
||||
$type = trim(getParam('type', ''));
|
||||
$title = trim(getParam('title', ''));
|
||||
$description = trim(getParam('description', ''));
|
||||
$provider = trim(getParam('provider', ''));
|
||||
|
||||
if (empty($type)) error('请选择工单类型');
|
||||
if (empty($title)) error('请输入工单标题');
|
||||
if (mb_strlen($title) > 100) error('标题不能超过100字');
|
||||
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
|
||||
|
||||
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
|
||||
if (!in_array($type, $validTypes)) error('无效的工单类型');
|
||||
|
||||
// 如果指定了服务商,验证身份
|
||||
if ($provider && !isServiceProviderInGame($provider)) {
|
||||
error("$provider 不是服务商");
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :now, :now)");
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$ticketId = $db->lastInsertRowID();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('创建失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$msg = '工单已创建';
|
||||
if ($provider) $msg .= ",已派发给 $provider";
|
||||
success(['id' => $ticketId], $msg);
|
||||
}
|
||||
|
||||
function ticketAdminComplete() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', '✅ 工单已由管理员标记完结', :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('操作失败');
|
||||
}
|
||||
|
||||
success(null, '已标记完结');
|
||||
}
|
||||
|
||||
// ===== 服务商端 =====
|
||||
|
||||
function ticketProviderList() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
|
||||
// 验证服务商身份
|
||||
$role = getServiceProviderRole($player);
|
||||
if (!$role) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$type = getParam('type', 'all');
|
||||
// processing: 活跃(已提交/已回复)
|
||||
// completed: 已完结/已撤销/已驳回
|
||||
// all: 全部跟过的工单
|
||||
|
||||
$where = "WHERE assigned_to = :player";
|
||||
$params = [':player' => $player];
|
||||
|
||||
if ($type === 'processing') {
|
||||
$where .= " AND status IN ('submitted', 'replied')";
|
||||
} elseif ($type === 'completed') {
|
||||
$where .= " AND status IN ('completed', 'withdrawn', 'rejected')";
|
||||
}
|
||||
// 'all' 不加status过滤
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY updated_at DESC LIMIT 100");
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
success(['list' => $list, 'role' => $role]);
|
||||
}
|
||||
|
||||
function ticketAvailable() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE status = 'submitted' AND assigned_to = '' ORDER BY created_at DESC LIMIT 50");
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
success(['list' => $list]);
|
||||
}
|
||||
|
||||
function ticketGrab() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['status'] !== 'submitted') error('该工单已被接单');
|
||||
if (!empty($ticket['assigned_to'])) error('该工单已被分配');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':provider', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('抢单失败');
|
||||
}
|
||||
|
||||
success(null, '抢单成功');
|
||||
}
|
||||
|
||||
function ticketProviderReply() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
$message = trim(getParam('message', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($message)) error('回复内容不能为空');
|
||||
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', :msg, :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('回复失败');
|
||||
}
|
||||
|
||||
success(null, '回复成功');
|
||||
}
|
||||
|
||||
function ticketProviderComplete() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 插入完结回复
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', '✅ 工单已处理完毕', :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 状态改为completed
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('操作失败');
|
||||
}
|
||||
|
||||
success(null, '已标记完结');
|
||||
}
|
||||
|
||||
// ===== 通用 =====
|
||||
|
||||
function ticketProviders() {
|
||||
if (!isAdminLoggedIn()) error('未登录管理后台');
|
||||
|
||||
$loginDbPath = GAME_LOGIN_DB;
|
||||
if (!file_exists($loginDbPath)) success(['list' => []]);
|
||||
|
||||
try {
|
||||
$loginDb = new SQLite3($loginDbPath);
|
||||
$result = $loginDb->query("SELECT player_name, role, join_time FROM service_providers WHERE active = 1");
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
$loginDb->close();
|
||||
success(['list' => $list]);
|
||||
} catch (Exception $e) {
|
||||
success(['list' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
function ticketProviderCheck() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$isProvider = isServiceProviderInGame($player);
|
||||
$role = $isProvider ? getServiceProviderRole($player) : null;
|
||||
success(['is_provider' => $isProvider, 'role' => $role]);
|
||||
}
|
||||
|
||||
// ===== 辅助函数 =====
|
||||
|
||||
function getTicketReplies($ticketId) {
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_ticket_replies WHERE ticket_id = :tid ORDER BY created_at ASC");
|
||||
$stmt->bindValue(':tid', $ticketId, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Web通信系统 - 配置文件
|
||||
*/
|
||||
|
||||
// ===== 安全密钥(插件和Web端必须一致) =====
|
||||
define('SECRET_KEY', 'sdf1_web_comm_2026_ypshidifu');
|
||||
|
||||
// ===== Token配置 =====
|
||||
define('TOKEN_EXPIRE_SECONDS', 600); // 10分钟
|
||||
|
||||
// ===== 数据库路径 =====
|
||||
define('DB_PATH', __DIR__ . '/db/web.db');
|
||||
|
||||
// ===== 管理员认证 =====
|
||||
define('ADMIN_USER', 'admin');
|
||||
define('ADMIN_PASS', 'ypshidifu2026'); // 管理员密码
|
||||
|
||||
// ===== 游戏数据库路径(用于同步) =====
|
||||
define('GAME_BOND_DB', 'D:/服务器/插件/bond.db');
|
||||
define('GAME_LOGIN_DB', 'D:/服务器/插件/login.db');
|
||||
define('GAME_SHOP_DIR', 'D:/服务器/插件/shop/');
|
||||
|
||||
// ===== Web子目录路径(如 /plugin 或 /test1,根目录则留空) =====
|
||||
define('WEBSUB_DIR', '/plugin');
|
||||
|
||||
// ===== Java插件回调端口 =====
|
||||
define('CALLBACK_PORT', 9090);
|
||||
|
||||
// ===== 辅助函数:构建相对路径 =====
|
||||
function webPath($path = '') {
|
||||
return trim(WEBSUB_DIR, '/') . ($path ? '/' . trim($path, '/') : '');
|
||||
}
|
||||
|
||||
// ===== SMTP邮件配置 =====
|
||||
define('SMTP_HOST', 'hwsmtp.exmail.qq.com');
|
||||
define('SMTP_PORT', '465');
|
||||
define('SMTP_USER', 'mcserver@ypshidifu.cn');
|
||||
define('SMTP_PASS', 'sQ2ZiCZGq96xi9Sv');
|
||||
define('SMTP_SENDER_NAME', 'Sdf1_login');
|
||||
define('SMTP_USE_SSL', true);
|
||||
+1064
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
1781490108
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
// 管理后台API诊断脚本
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
echo "=== Admin API Diagnostic ===\n\n";
|
||||
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
echo "1. Core loaded: " . (function_exists('getDB') ? 'YES' : 'NO') . "\n";
|
||||
echo "2. ADMIN_PASS defined: " . (defined('ADMIN_PASS') ? 'YES' : 'NO') . "\n";
|
||||
|
||||
// Test database connection
|
||||
echo "\n--- Database ---\n";
|
||||
try {
|
||||
$db = getDB();
|
||||
echo "DB opened OK\n";
|
||||
|
||||
// List all tables
|
||||
$result = $db->query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||
$tables = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$tables[] = $row['name'];
|
||||
}
|
||||
echo "Tables (" . count($tables) . "): " . implode(', ', $tables) . "\n";
|
||||
|
||||
// Check player_ip_changes structure
|
||||
if (in_array('player_ip_changes', $tables)) {
|
||||
$result = $db->query("PRAGMA table_info(player_ip_changes)");
|
||||
echo "\nplayer_ip_changes columns:\n";
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " - {$row['name']} ({$row['type']}) PK=" . ($row['pk'] ? 'YES' : 'no') . "\n";
|
||||
}
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM player_ip_changes")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo " Rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Check player_ip_locations structure
|
||||
if (in_array('player_ip_locations', $tables)) {
|
||||
$result = $db->query("PRAGMA table_info(player_ip_locations)");
|
||||
echo "\nplayer_ip_locations columns:\n";
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " - {$row['name']} ({$row['type']}) PK=" . ($row['pk'] ? 'YES' : 'no') . "\n";
|
||||
}
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM player_ip_locations")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo " Rows: $cnt\n";
|
||||
// Show a sample
|
||||
$result = $db->query("SELECT ip_address, location FROM player_ip_locations LIMIT 5");
|
||||
if ($result) {
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " Sample: {$row['ip_address']} => {$row['location']}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check users table
|
||||
if (in_array('users', $tables)) {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM users")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "\nusers rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Check online_players
|
||||
if (in_array('online_players', $tables)) {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM online_players")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "online_players rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Check web_session_log
|
||||
if (in_array('web_session_log', $tables)) {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM web_session_log")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "web_session_log rows: $cnt\n";
|
||||
}
|
||||
|
||||
// Test batchGetPlayerIps
|
||||
echo "\n--- Test batchGetPlayerIps ---\n";
|
||||
if (function_exists('batchGetPlayerIps') || class_exists('adminListUsersPaginated')) {
|
||||
echo "batchGetPlayerIps function NOT available in this scope\n";
|
||||
} else {
|
||||
echo "Function not available (defined in api/admin.php, not core.php)\n";
|
||||
}
|
||||
|
||||
// Test simple queries that adminListUsersPaginated would run
|
||||
echo "\n--- Test Query Simulation ---\n";
|
||||
try {
|
||||
$sql = "SELECT player_name, register_time, last_login_time, points, total_online_time, email FROM users ORDER BY register_time DESC LIMIT 10 OFFSET 0";
|
||||
$result = $db->query($sql);
|
||||
$count = 0;
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$count++;
|
||||
}
|
||||
echo "Users query OK, got $count rows\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Users query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test player_ip_changes query
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip AS ip_address FROM player_ip_changes WHERE player_name IN ('test') ORDER BY changed_at DESC");
|
||||
echo "player_ip_changes simple query: OK (prepared)\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "player_ip_changes query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test player_ip_locations query
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT ip_address, location FROM player_ip_locations WHERE ip_address IN ('1.2.3.4')");
|
||||
echo "player_ip_locations query: OK (prepared)\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "player_ip_locations query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test location search query
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip FROM player_ip_changes WHERE new_ip != '' AND new_ip != '-' AND new_ip NOT LIKE '10.%' ORDER BY changed_at DESC LIMIT 5");
|
||||
$result = $stmt->execute();
|
||||
$ips = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$ips[] = $row['new_ip'];
|
||||
}
|
||||
echo "Location search pre-query: OK, got " . count($ips) . " IPs\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Location search pre-query FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// Test region search SQL
|
||||
try {
|
||||
$searchBound = "%长沙%";
|
||||
$stmt = $db->prepare("SELECT COUNT(*) as cnt FROM player_ip_locations WHERE location LIKE :region");
|
||||
$stmt->bindValue(':region', $searchBound, SQLITE3_TEXT);
|
||||
$cnt = $stmt->execute()->fetchArray(SQLITE3_ASSOC)['cnt'];
|
||||
echo "Region cache check: $cnt matching records\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Region cache check FAILED: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\nFATAL ERROR: " . $e->getMessage() . "\n";
|
||||
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
|
||||
echo "Stack: " . $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== Done ===\n";
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* 调试页面 - 检查online_players表状态
|
||||
*/
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
$db = getDB();
|
||||
$result = [];
|
||||
|
||||
// 1. online_players表内容
|
||||
try {
|
||||
$rows = $db->query("SELECT * FROM online_players ORDER BY login_time DESC")->fetchArray(SQLITE3_ASSOC);
|
||||
$onlinePlayers = [];
|
||||
while ($row = $db->query("SELECT * FROM online_players ORDER BY login_time DESC")->fetchArray(SQLITE3_ASSOC)) {
|
||||
$onlinePlayers[] = $row;
|
||||
}
|
||||
// 重新查询
|
||||
$stmt = $db->query("SELECT * FROM online_players ORDER BY login_time DESC");
|
||||
$onlinePlayers = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$onlinePlayers[] = $row;
|
||||
}
|
||||
$result['online_players'] = $onlinePlayers;
|
||||
$result['online_players_count'] = count($onlinePlayers);
|
||||
} catch (Exception $e) {
|
||||
$result['online_players_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 2. online_player_hb表(心跳)
|
||||
try {
|
||||
$stmt = $db->query("SELECT * FROM online_player_hb");
|
||||
$hb = $stmt->fetchArray(SQLITE3_ASSOC);
|
||||
$result['heartbeat'] = $hb;
|
||||
if ($hb) {
|
||||
$result['heartbeat_ago'] = time() - (int)$hb['last_seen'] . '秒前';
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$result['heartbeat_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 3. weblogin_tokens表
|
||||
try {
|
||||
$stmt = $db->query("SELECT player_name, created_at, expire_seconds, datetime(created_at, 'unixepoch', 'localtime') as created_time FROM weblogin_tokens ORDER BY created_at DESC LIMIT 20");
|
||||
$tokens = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$tokens[] = $row;
|
||||
}
|
||||
$result['weblogin_tokens'] = $tokens;
|
||||
} catch (Exception $e) {
|
||||
$result['tokens_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 4. web_session_log表
|
||||
try {
|
||||
$stmt = $db->query("SELECT player_name, login_time, datetime(login_time, 'unixepoch', 'localtime') as login_time_str FROM web_session_log ORDER BY login_time DESC LIMIT 20");
|
||||
$sessions = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$sessions[] = $row;
|
||||
}
|
||||
$result['web_session_log'] = $sessions;
|
||||
} catch (Exception $e) {
|
||||
$result['sessions_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 5. web_login_verified表
|
||||
try {
|
||||
$stmt = $db->query("SELECT * FROM web_login_verified ORDER BY verified_at DESC LIMIT 20");
|
||||
$verified = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$verified[] = $row;
|
||||
}
|
||||
$result['web_login_verified'] = $verified;
|
||||
} catch (Exception $e) {
|
||||
$result['verified_error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
// 6. users表(部分)
|
||||
try {
|
||||
$stmt = $db->query("SELECT player_name, logged_in FROM users LIMIT 20");
|
||||
$users = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$users[] = $row;
|
||||
}
|
||||
$result['users'] = $users;
|
||||
} catch (Exception $e) {
|
||||
$result['users_error'] = $e->getMessage();
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>调试 - Online Players</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
||||
h1 { color: #00d4ff; }
|
||||
h2 { color: #ff6b6b; margin-top: 30px; border-bottom: 1px solid #333; padding-bottom: 5px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
|
||||
th, td { border: 1px solid #444; padding: 8px 12px; text-align: left; }
|
||||
th { background: #16213e; color: #00d4ff; }
|
||||
td { background: #0f3460; }
|
||||
.empty { color: #ff6b6b; font-weight: bold; }
|
||||
.ok { color: #4ecca3; }
|
||||
.warn { color: #ffd93d; }
|
||||
pre { background: #0a0a23; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||||
.refresh { background: #00d4ff; color: #000; padding: 10px 20px; border: none; cursor: pointer; font-size: 16px; border-radius: 5px; }
|
||||
.refresh:hover { background: #00b8d4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Online Players 调试页面</h1>
|
||||
<button class="refresh" onclick="location.reload()">🔄 刷新</button>
|
||||
<p>当前时间: <?php echo date('Y-m-d H:i:s') . ' (Unix: ' . time() . ')'; ?></p>
|
||||
|
||||
<h2>1. online_players 表 (Java推送的游戏登录状态)</h2>
|
||||
<?php if (empty($result['online_players'])): ?>
|
||||
<p class="empty">⚠️ 表为空!没有玩家被标记为游戏中已登录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家名</th><th>登录时间</th><th>时间戳</th></tr>
|
||||
<?php foreach ($result['online_players'] as $p): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($p['player_name']) ?></td>
|
||||
<td><?= date('Y-m-d H:i:s', (int)$p['login_time']) ?></td>
|
||||
<td><?= $p['login_time'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<p>总数: <span class="<?= $result['online_players_count'] > 0 ? 'ok' : 'empty' ?>"><?= $result['online_players_count'] ?></span></p>
|
||||
|
||||
<h2>2. 心跳 (online_player_hb)</h2>
|
||||
<?php if (isset($result['heartbeat']) && $result['heartbeat']): ?>
|
||||
<p>上次心跳: <?= date('Y-m-d H:i:s', (int)$result['heartbeat']['last_seen']) ?> (<span class="warn"><?= $result['heartbeat_ago'] ?></span>)</p>
|
||||
<?php
|
||||
$ago = time() - (int)$result['heartbeat']['last_seen'];
|
||||
if ($ago > 120) echo '<p class="empty">⚠️ 心跳超过120秒,下次同步时PHP会清空online_players表!</p>';
|
||||
elseif ($ago > 60) echo '<p class="warn">⚠️ 心跳超过60秒</p>';
|
||||
else echo '<p class="ok">✅ 心跳正常</p>';
|
||||
?>
|
||||
<?php else: ?>
|
||||
<p class="empty">⚠️ 无心跳记录</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>3. weblogin_tokens (Web登录Token)</h2>
|
||||
<?php if (empty($result['weblogin_tokens'])): ?>
|
||||
<p class="empty">⚠️ 无Token记录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家</th><th>创建时间</th><th>有效期(秒)</th></tr>
|
||||
<?php foreach ($result['weblogin_tokens'] as $t): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($t['player_name']) ?></td>
|
||||
<td><?= $t['created_time'] ?></td>
|
||||
<td><?= $t['expire_seconds'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>4. web_session_log (Web会话记录)</h2>
|
||||
<?php if (empty($result['web_session_log'])): ?>
|
||||
<p class="empty">⚠️ 无会话记录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家</th><th>登录时间</th><th>时间戳</th></tr>
|
||||
<?php foreach ($result['web_session_log'] as $s): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($s['player_name']) ?></td>
|
||||
<td><?= $s['login_time_str'] ?></td>
|
||||
<td><?= $s['login_time'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>5. web_login_verified (Web密码验证)</h2>
|
||||
<?php if (empty($result['web_login_verified'])): ?>
|
||||
<p class="empty">⚠️ 无验证记录</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家</th><th>验证时间</th></tr>
|
||||
<?php foreach ($result['web_login_verified'] as $v): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($v['player_name']) ?></td>
|
||||
<td><?= date('Y-m-d H:i:s', (int)$v['verified_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>6. users表 (玩家注册状态)</h2>
|
||||
<?php if (empty($result['users'])): ?>
|
||||
<p class="empty">⚠️ 无注册玩家</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<tr><th>玩家名</th><th>logged_in</th></tr>
|
||||
<?php foreach ($result['users'] as $u): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($u['player_name']) ?></td>
|
||||
<td class="<?= $u['logged_in'] ? 'ok' : 'warn' ?>"><?= $u['logged_in'] ? '✅ 已登录' : '❌ 未登录' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2>原始JSON</h2>
|
||||
<pre><?= json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?></pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
/**
|
||||
* 调试地区搜索问题
|
||||
* 显示 player_ip_locations 和 player_ip_changes 表内容,以及地区搜索SQL测试结果
|
||||
*/
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
require_once 'core.php';
|
||||
|
||||
$db = getDB();
|
||||
echo "<h2>调试地区搜索</h2>";
|
||||
|
||||
// 1. player_ip_locations 表内容
|
||||
echo "<h3>player_ip_locations 表</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT COUNT(*) as cnt FROM player_ip_locations");
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
echo "总记录数: " . $row['cnt'] . "<br>";
|
||||
|
||||
if ($row['cnt'] > 0) {
|
||||
echo "<table border='1' cellpadding='5'>";
|
||||
echo "<tr><th>ip_address</th><th>location</th><th>player_name</th><th>updated_at</th></tr>";
|
||||
$result = $db->query("SELECT * FROM player_ip_locations ORDER BY updated_at DESC LIMIT 20");
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "<tr>";
|
||||
echo "<td>" . htmlspecialchars($r['ip_address']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($r['location']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($r['player_name']) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i:s', $r['updated_at']) . "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
echo "</table>";
|
||||
} else {
|
||||
echo "表为空!<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 2. player_ip_changes 表内容
|
||||
echo "<h3>player_ip_changes 表</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT COUNT(*) as cnt FROM player_ip_changes");
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
echo "总记录数: " . $row['cnt'] . "<br>";
|
||||
|
||||
if ($row['cnt'] > 0) {
|
||||
echo "<table border='1' cellpadding='5'>";
|
||||
echo "<tr><th>player_name</th><th>new_ip</th><th>changed_at</th></tr>";
|
||||
$result = $db->query("SELECT * FROM player_ip_changes ORDER BY changed_at DESC LIMIT 20");
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "<tr>";
|
||||
echo "<td>" . htmlspecialchars($r['player_name']) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($r['new_ip']) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i:s', $r['changed_at']) . "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
echo "</table>";
|
||||
} else {
|
||||
echo "表为空!<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 3. 地区搜索SQL测试
|
||||
echo "<h3>地区搜索SQL测试</h3>";
|
||||
$search = '长沙';
|
||||
$regionKey = $search;
|
||||
|
||||
// 测试JOIN
|
||||
$sql = "SELECT c.player_name FROM player_ip_changes c
|
||||
INNER JOIN player_ip_locations l ON c.new_ip = l.ip_address
|
||||
WHERE l.location LIKE '%$regionKey%' AND c.new_ip != '' AND c.new_ip != '-'
|
||||
GROUP BY c.player_name";
|
||||
echo "SQL: <pre>" . htmlspecialchars($sql) . "</pre>";
|
||||
|
||||
try {
|
||||
$result = $db->query($sql);
|
||||
$count = 0;
|
||||
$players = [];
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$count++;
|
||||
$players[] = $r['player_name'];
|
||||
}
|
||||
echo "匹配玩家数: $count<br>";
|
||||
if ($count > 0) {
|
||||
echo "匹配玩家: " . implode(', ', $players) . "<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "SQL错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 4. 检查所有location中包含"长沙"的
|
||||
echo "<h3>包含'长沙'的location记录</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT ip_address, location, player_name FROM player_ip_locations WHERE location LIKE '%长沙%'");
|
||||
$found = false;
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$found = true;
|
||||
echo "IP: " . htmlspecialchars($r['ip_address']) . " → " . htmlspecialchars($r['location']) . "<br>";
|
||||
}
|
||||
if (!$found) {
|
||||
echo "没有找到包含'长沙'的location记录<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 5. 检查users表中是否有来自长沙的玩家(通过IP推断)
|
||||
echo "<h3>测试:直接搜索users表</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT player_name, email FROM users LIMIT 10");
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo "玩家: " . htmlspecialchars($r['player_name']) . " (email: " . htmlspecialchars($r['email']) . ")<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
|
||||
// 6. 尝试手动查询一个IP的归属地
|
||||
echo "<h3>测试:手动查询IP归属地</h3>";
|
||||
try {
|
||||
$result = $db->query("SELECT DISTINCT new_ip FROM player_ip_changes WHERE new_ip != '' AND new_ip != '-' AND new_ip NOT LIKE '10.%' AND new_ip NOT LIKE '192.168.%' AND new_ip != '127.0.0.1' LIMIT 5");
|
||||
$ips = [];
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$ips[] = $r['new_ip'];
|
||||
}
|
||||
|
||||
if (!empty($ips)) {
|
||||
echo "测试查询以下IP的归属地: " . implode(', ', $ips) . "<br>";
|
||||
require_once 'api/admin.php';
|
||||
foreach ($ips as $ip) {
|
||||
$loc = queryIpLocationWithTimeout($ip, 3);
|
||||
echo "IP: $ip → " . ($loc ? $loc['location'] : 'null') . "<br>";
|
||||
}
|
||||
} else {
|
||||
echo "没有找到可用的外部IP<br>";
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo "错误: " . $e->getMessage() . "<br>";
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
// 地区搜索诊断脚本 - 直接测试PHP两步法
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
echo "=== Region Search Debug v2 ===\n\n";
|
||||
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
try {
|
||||
$db = getDB();
|
||||
echo "DB opened OK\n\n";
|
||||
|
||||
// 1. Check tables and data
|
||||
echo "--- Data Overview ---\n";
|
||||
$tables = ['users', 'player_ip_changes', 'player_ip_locations', 'web_session_log'];
|
||||
foreach ($tables as $table) {
|
||||
try {
|
||||
$cnt = $db->query("SELECT COUNT(*) as c FROM $table")->fetchArray(SQLITE3_ASSOC)['c'];
|
||||
echo "$table: $cnt rows\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "$table: ERROR - " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check player_ip_locations samples
|
||||
echo "\n--- player_ip_locations Samples ---\n";
|
||||
try {
|
||||
$result = $db->query("SELECT ip_address, location, player_name FROM player_ip_locations LIMIT 10");
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " {$row['ip_address']} => '{$row['location']}' (player: {$row['player_name']})\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo " ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 3. Test region search for "长沙"
|
||||
$regionKey = '长沙';
|
||||
echo "\n--- Testing Region Search: '$regionKey' ---\n";
|
||||
|
||||
// Step 1: Find IPs with matching location
|
||||
try {
|
||||
$locStmt = $db->prepare("SELECT ip_address FROM player_ip_locations WHERE location LIKE :region");
|
||||
$locStmt->bindValue(':region', "%$regionKey%", SQLITE3_TEXT);
|
||||
$locResult = $locStmt->execute();
|
||||
$matchedIps = [];
|
||||
while ($locRow = $locResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$matchedIps[] = $locRow['ip_address'];
|
||||
}
|
||||
echo "Step 1: Found " . count($matchedIps) . " IPs with '$regionKey' location\n";
|
||||
if (!empty($matchedIps)) {
|
||||
echo " IPs: " . implode(', ', array_slice($matchedIps, 0, 10)) . "\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 1 ERROR: " . $e->getMessage() . "\n";
|
||||
$matchedIps = [];
|
||||
}
|
||||
|
||||
// Step 2: Find players using those IPs
|
||||
$matchedPlayers = [];
|
||||
if (!empty($matchedIps)) {
|
||||
$ipPlaceholders = implode(',', array_fill(0, count($matchedIps), '?'));
|
||||
|
||||
// From player_ip_changes
|
||||
try {
|
||||
$pStmt = $db->prepare("SELECT DISTINCT player_name FROM player_ip_changes WHERE new_ip IN ($ipPlaceholders)");
|
||||
$idx = 1;
|
||||
foreach ($matchedIps as $ip) { $pStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
|
||||
$pResult = $pStmt->execute();
|
||||
while ($pRow = $pResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$matchedPlayers[] = $pRow['player_name'];
|
||||
}
|
||||
echo "Step 2a (player_ip_changes): Found " . count($matchedPlayers) . " players\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 2a ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// From web_session_log
|
||||
try {
|
||||
$wStmt = $db->prepare("SELECT DISTINCT player_name FROM web_session_log WHERE ip_address IN ($ipPlaceholders)");
|
||||
$idx = 1;
|
||||
foreach ($matchedIps as $ip) { $wStmt->bindValue($idx++, $ip, SQLITE3_TEXT); }
|
||||
$wResult = $wStmt->execute();
|
||||
$beforeCount = count($matchedPlayers);
|
||||
while ($wRow = $wResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
if (!in_array($wRow['player_name'], $matchedPlayers)) {
|
||||
$matchedPlayers[] = $wRow['player_name'];
|
||||
}
|
||||
}
|
||||
echo "Step 2b (web_session_log): Found " . (count($matchedPlayers) - $beforeCount) . " new players\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 2b ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "Step 2 Total: " . count($matchedPlayers) . " players\n";
|
||||
if (!empty($matchedPlayers)) {
|
||||
echo " Players: " . implode(', ', array_slice($matchedPlayers, 0, 10)) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Query users
|
||||
if (!empty($matchedPlayers)) {
|
||||
$namePlaceholders = implode(',', array_fill(0, count($matchedPlayers), '?'));
|
||||
try {
|
||||
$uStmt = $db->prepare("SELECT player_name, email FROM users WHERE player_name IN ($namePlaceholders) LIMIT 5");
|
||||
$idx = 1;
|
||||
foreach ($matchedPlayers as $pn) { $uStmt->bindValue($idx++, $pn, SQLITE3_TEXT); }
|
||||
$uResult = $uStmt->execute();
|
||||
$users = [];
|
||||
while ($uRow = $uResult->fetchArray(SQLITE3_ASSOC)) {
|
||||
$users[] = $uRow;
|
||||
}
|
||||
echo "Step 3: Found " . count($users) . " users\n";
|
||||
foreach ($users as $u) {
|
||||
echo " {$u['player_name']} ({$u['email']})\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Step 3 ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo "Step 3: Skipped (no matching players)\n";
|
||||
}
|
||||
|
||||
// 4. Also check what batchGetPlayerIps would return for all users
|
||||
echo "\n--- batchGetPlayerIps Check ---\n";
|
||||
try {
|
||||
$allUsers = $db->query("SELECT player_name FROM users")->fetchArray(SQLITE3_ASSOC);
|
||||
// Get a sample user
|
||||
$sampleUser = 'youpaishidifu';
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip FROM player_ip_changes WHERE player_name = :name ORDER BY changed_at DESC LIMIT 1");
|
||||
$stmt->bindValue(':name', $sampleUser, SQLITE3_TEXT);
|
||||
$result = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($result) {
|
||||
echo "$sampleUser IP from player_ip_changes: {$result['new_ip']}\n";
|
||||
// Check if this IP has location
|
||||
$locStmt = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$locStmt->bindValue(':ip', $result['new_ip'], SQLITE3_TEXT);
|
||||
$locResult = $locStmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
echo " Location: " . ($locResult ? $locResult['location'] : 'NOT FOUND') . "\n";
|
||||
} else {
|
||||
echo "$sampleUser NOT in player_ip_changes\n";
|
||||
// Check web_session_log
|
||||
$stmt2 = $db->prepare("SELECT ip_address FROM web_session_log WHERE player_name = :name ORDER BY login_time DESC LIMIT 1");
|
||||
$stmt2->bindValue(':name', $sampleUser, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($result2) {
|
||||
echo "$sampleUser IP from web_session_log: {$result2['ip_address']}\n";
|
||||
$locStmt2 = $db->prepare("SELECT location FROM player_ip_locations WHERE ip_address = :ip");
|
||||
$locStmt2->bindValue(':ip', $result2['ip_address'], SQLITE3_TEXT);
|
||||
$locResult2 = $locStmt2->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
echo " Location: " . ($locResult2 ? $locResult2['location'] : 'NOT FOUND') . "\n";
|
||||
} else {
|
||||
echo "$sampleUser NOT in web_session_log either\n";
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "batchGetPlayerIps check ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\nFATAL ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
echo "\n=== Done ===\n";
|
||||
?>
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - 页面不存在</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0a0a0f;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.glitch {
|
||||
font-size: 120px;
|
||||
font-weight: 900;
|
||||
color: #ff2d55;
|
||||
text-shadow: 0 0 10px #ff2d55, 0 0 40px #ff2d55, 0 0 80px #ff2d55;
|
||||
animation: glitch 2s infinite;
|
||||
position: relative;
|
||||
}
|
||||
.glitch::before, .glitch::after {
|
||||
content: '404';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.glitch::before {
|
||||
color: #0ff;
|
||||
animation: glitch-top 3s infinite;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 33%, 0 33%);
|
||||
text-shadow: 0 0 10px #0ff;
|
||||
}
|
||||
.glitch::after {
|
||||
color: #f0f;
|
||||
animation: glitch-bottom 2.5s infinite;
|
||||
clip-path: polygon(0 67%, 100% 67%, 100% 100%, 0 100%);
|
||||
text-shadow: 0 0 10px #f0f;
|
||||
}
|
||||
@keyframes glitch {
|
||||
0%, 90%, 100% { transform: translate(0); }
|
||||
92% { transform: translate(-5px, 2px); }
|
||||
94% { transform: translate(5px, -2px); }
|
||||
96% { transform: translate(-3px, -1px); }
|
||||
98% { transform: translate(3px, 1px); }
|
||||
}
|
||||
@keyframes glitch-top {
|
||||
0%, 85%, 100% { transform: translate(0); }
|
||||
87% { transform: translate(3px, -2px); }
|
||||
89% { transform: translate(-3px, 1px); }
|
||||
}
|
||||
@keyframes glitch-bottom {
|
||||
0%, 88%, 100% { transform: translate(0); }
|
||||
90% { transform: translate(-4px, 2px); }
|
||||
93% { transform: translate(4px, -1px); }
|
||||
}
|
||||
.msg {
|
||||
font-size: 18px;
|
||||
color: #888;
|
||||
margin-top: 30px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.links {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
.links a {
|
||||
color: #ff2d55;
|
||||
text-decoration: none;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid #ff2d55;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.links a:hover {
|
||||
background: #ff2d55;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 20px rgba(255,45,85,0.4);
|
||||
}
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
animation: scan 8s linear infinite;
|
||||
}
|
||||
@keyframes scan {
|
||||
0% { top: 0; }
|
||||
100% { top: 100vh; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="scanline"></div>
|
||||
<div class="container">
|
||||
<div class="glitch">404</div>
|
||||
<div class="msg">页面不存在或已被移除</div>
|
||||
<div class="links">
|
||||
<a href="player.php">玩家商城</a>
|
||||
<a href="admin_login.php">管理后台</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDF1 - Web登录</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --card: #161b22; --border: #30363d;
|
||||
--text: #e6edf3; --dim: #8b949e; --accent: #58a6ff;
|
||||
--green: #3fb950; --red: #f85149; --yellow: #d29922;
|
||||
}
|
||||
* { 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; display: flex; justify-content: center; align-items: center;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 40px; width: 400px; max-width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
.login-card h1 { color: var(--accent); margin-bottom: 8px; font-size: 24px; }
|
||||
.login-card .subtitle { color: var(--dim); margin-bottom: 32px; font-size: 14px; }
|
||||
.status-box {
|
||||
padding: 20px; border-radius: 8px; margin-bottom: 24px;
|
||||
font-size: 14px; line-height: 1.8;
|
||||
}
|
||||
.status-box.loading { background: rgba(88,166,255,0.1); border: 1px solid var(--accent); }
|
||||
.status-box.success { background: rgba(63,185,80,0.1); border: 1px solid var(--green); }
|
||||
.status-box.error { background: rgba(248,81,73,0.1); border: 1px solid var(--red); }
|
||||
.spinner {
|
||||
display: inline-block; width: 20px; height: 20px;
|
||||
border: 2px solid var(--dim); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
vertical-align: middle; margin-right: 8px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.btn {
|
||||
padding: 12px 24px; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 14px; font-weight: 600;
|
||||
transition: all 0.2s; text-decoration: none; display: inline-block;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: #79c0ff; }
|
||||
.btn-dim { background: var(--border); color: var(--dim); }
|
||||
.btn-dim:hover { background: #484f58; color: var(--text); }
|
||||
.actions { display: flex; gap: 8px; justify-content: center; }
|
||||
.player-name { color: var(--green); font-weight: 600; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>SDF1 Web登录</h1>
|
||||
<p class="subtitle" id="subtitle">Token验证中...</p>
|
||||
|
||||
<!-- 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="玩家名" readonly
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
|
||||
</div>
|
||||
<!-- ★ 切换标签:密码/邮箱 -->
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px">
|
||||
<button class="btn btn-primary" id="tabPassword" onclick="switchLoginTab('password')" style="flex:1">游戏密码</button>
|
||||
<button class="btn btn-dim" id="tabEmail" onclick="switchLoginTab('email')" style="flex:1">邮箱验证码</button>
|
||||
</div>
|
||||
<!-- 密码登录 -->
|
||||
<div id="loginPasswordTab">
|
||||
<div style="text-align:left;margin-bottom:20px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">游戏内密码</label>
|
||||
<input type="password" id="password" placeholder="输入游戏内密码" maxlength="32"
|
||||
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()">登录到Web端</button>
|
||||
</div>
|
||||
<!-- 邮箱验证码登录 -->
|
||||
<div id="loginEmailTab" style="display:none">
|
||||
<div style="text-align:left;margin-bottom:12px">
|
||||
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">绑定邮箱</label>
|
||||
<input type="text" id="maskedEmail" readonly placeholder="加载中..."
|
||||
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:20px">
|
||||
<input type="text" id="emailCode" placeholder="6位验证码" maxlength="6"
|
||||
style="flex:1;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
|
||||
<button class="btn btn-primary" id="sendCodeBtn" onclick="sendEmailCode()" style="white-space:nowrap">发送验证码</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doEmailLogin()">登录到Web端</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = new URLSearchParams(location.search).get('token') || '';
|
||||
const statusBox = document.getElementById('statusBox');
|
||||
const actionsDiv = document.getElementById('actions');
|
||||
const tokenSection = document.getElementById('tokenSection');
|
||||
const passwordSection = document.getElementById('passwordSection');
|
||||
const subtitle = document.getElementById('subtitle');
|
||||
const pwdStatusBox = document.getElementById('pwdStatusBox');
|
||||
|
||||
// ===== 安全拦截:无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');
|
||||
}
|
||||
|
||||
// ===== 有Token → 验证Token =====
|
||||
tokenSection.style.display = 'block';
|
||||
doTokenLogin();
|
||||
|
||||
// ===== Token验证 =====
|
||||
async function doTokenLogin() {
|
||||
try {
|
||||
const apiUrl = './api/sync.php?action=validate_weblogin_token';
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({web_token: TOKEN})
|
||||
});
|
||||
const text = await res.text();
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (parseErr) {
|
||||
statusBox.className = 'status-box error';
|
||||
statusBox.innerHTML = '❌ 服务器响应异常<br><br><code style="font-size:11px;word-break:break-all;color:var(--dim)">' + text.substring(0, 300) + '</code>';
|
||||
actionsDiv.style.display = 'flex';
|
||||
actionsDiv.innerHTML = '<span style="color:var(--dim);font-size:13px">请在游戏中重新执行 <b>/sdf1_login weblogin</b></span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.success && data.data) {
|
||||
const player = data.data.player;
|
||||
localStorage.setItem('sdf1_player', player);
|
||||
localStorage.setItem('sdf1_token', TOKEN);
|
||||
localStorage.setItem('sdf1_login_time', Date.now());
|
||||
|
||||
// ★ Token验证成功 → 直接跳转到player.php(跳过密码,token本身即身份证明)
|
||||
statusBox.className = 'status-box success';
|
||||
statusBox.innerHTML = '✅ Token验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转到玩家商城...</small>';
|
||||
|
||||
// ★ 同时写入web_session_log(让validateWebAccess能识别在线状态)
|
||||
fetch('./api/sync.php?action=web_access_check', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({web_token: TOKEN, access_action: 'view'})
|
||||
}).catch(() => {});
|
||||
|
||||
setTimeout(() => {
|
||||
// ★ 跳转时带上token,确保player.php一定能读到
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(TOKEN);
|
||||
}, 800);
|
||||
} else {
|
||||
statusBox.className = 'status-box error';
|
||||
statusBox.innerHTML = '❌ 登录失败<br><br>' + (data.message || 'Token无效或已过期');
|
||||
actionsDiv.style.display = 'flex';
|
||||
actionsDiv.innerHTML = '<span style="color:var(--dim);font-size:13px">请在游戏中重新执行 <b>/sdf1_login weblogin</b></span>';
|
||||
}
|
||||
} catch (e) {
|
||||
statusBox.className = 'status-box error';
|
||||
statusBox.innerHTML = '❌ 连接失败<br><br>' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 密码登录 =====
|
||||
async function doPasswordLogin() {
|
||||
const player = document.getElementById('playerName').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!password) { showPwdError('请输入密码'); return; }
|
||||
|
||||
pwdStatusBox.className = 'status-box loading';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证密码...';
|
||||
|
||||
try {
|
||||
// 1. 提交登录请求
|
||||
const apiUrl = './api/sync.php';
|
||||
const reqRes = await fetch(apiUrl + '?action=web_login_request', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: player, password: password})
|
||||
});
|
||||
const reqData = await reqRes.json();
|
||||
|
||||
if (!reqData.success) {
|
||||
showPwdError(reqData.message || '提交失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = reqData.data.request_id;
|
||||
|
||||
// 2. 轮询结果
|
||||
pwdStatusBox.innerHTML = '<span class="spinner"></span> 等待游戏服务器验证密码...';
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // 最多等60秒
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(pollInterval);
|
||||
showPwdError('验证超时,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pollRes = await fetch(apiUrl + '?action=check_web_login_result&player=' + encodeURIComponent(player) + '&request_id=' + requestId);
|
||||
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);
|
||||
// 登录成功 → 进入玩家商城
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.innerHTML = '✅ 登录成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
|
||||
|
||||
// ★ token已存入localStorage,跳转player.php带token参数
|
||||
setTimeout(() => {
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(TOKEN);
|
||||
}, 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);
|
||||
|
||||
} catch (e) {
|
||||
showPwdError('连接失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showPwdError(msg) {
|
||||
pwdStatusBox.className = 'status-box error';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '❌ ' + msg;
|
||||
}
|
||||
|
||||
// 回车提交
|
||||
document.getElementById('password').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doPasswordLogin();
|
||||
});
|
||||
document.getElementById('emailCode').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doEmailLogin();
|
||||
});
|
||||
|
||||
// ===== 标签切换 =====
|
||||
let currentLoginTab = 'password';
|
||||
function switchLoginTab(tab) {
|
||||
currentLoginTab = tab;
|
||||
document.getElementById('loginPasswordTab').style.display = tab === 'password' ? 'block' : 'none';
|
||||
document.getElementById('loginEmailTab').style.display = tab === 'email' ? 'block' : 'none';
|
||||
document.getElementById('tabPassword').className = tab === 'password' ? 'btn btn-primary' : 'btn btn-dim';
|
||||
document.getElementById('tabEmail').className = tab === 'email' ? 'btn btn-primary' : 'btn btn-dim';
|
||||
document.getElementById('pwdStatusBox').style.display = 'none';
|
||||
// 切到邮箱时加载脱敏邮箱
|
||||
if (tab === 'email') loadMaskedEmail();
|
||||
}
|
||||
|
||||
// ===== 加载脱敏邮箱 =====
|
||||
async function loadMaskedEmail() {
|
||||
const player = document.getElementById('playerName').value;
|
||||
try {
|
||||
const url = new URL('./api/sync.php', location.href);
|
||||
url.searchParams.set('action', 'get_player_email');
|
||||
url.searchParams.set('player', player);
|
||||
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'} });
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('maskedEmail').value = data.data.masked_email || '未绑定邮箱';
|
||||
} else {
|
||||
document.getElementById('maskedEmail').value = '未绑定邮箱';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('maskedEmail').value = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 发送邮箱验证码 =====
|
||||
async function sendEmailCode() {
|
||||
const player = document.getElementById('playerName').value;
|
||||
const btn = document.getElementById('sendCodeBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中...';
|
||||
try {
|
||||
const url = new URL('./api/sync.php', location.href);
|
||||
url.searchParams.set('action', 'send_email_code');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: player, web_token: TOKEN})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showPwdSuccess('验证码已发送至 ' + (data.data.masked_email || '**'));
|
||||
let countdown = 60;
|
||||
const timer = setInterval(() => {
|
||||
btn.textContent = countdown + '秒后重发';
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证码';
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
showPwdError(data.message || '发送失败');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证码';
|
||||
}
|
||||
} catch (e) {
|
||||
showPwdError('连接失败: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '发送验证码';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 邮箱验证码登录 =====
|
||||
async function doEmailLogin() {
|
||||
const player = document.getElementById('playerName').value;
|
||||
const code = document.getElementById('emailCode').value.trim();
|
||||
if (!code) { showPwdError('请输入验证码'); return; }
|
||||
if (code.length !== 6) { showPwdError('验证码为6位数字'); return; }
|
||||
|
||||
pwdStatusBox.className = 'status-box loading';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证邮箱验证码...';
|
||||
|
||||
try {
|
||||
const url = new URL('./api/sync.php', location.href);
|
||||
url.searchParams.set('action', 'verify_email_code');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: player, code: code, web_token: TOKEN})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('sdf1_player', player);
|
||||
localStorage.setItem('sdf1_token', TOKEN);
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.innerHTML = '✅ 邮箱验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
|
||||
setTimeout(() => {
|
||||
window.location.href = 'player.php?login=' + encodeURIComponent(player);
|
||||
}, 500);
|
||||
} else {
|
||||
showPwdError(data.message || '验证失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showPwdError('连接失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showPwdSuccess(msg) {
|
||||
pwdStatusBox.className = 'status-box success';
|
||||
pwdStatusBox.style.display = 'block';
|
||||
pwdStatusBox.innerHTML = '✅ ' + msg;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2001
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
/**
|
||||
* 密码重置页面
|
||||
* 玩家通过邮件链接访问此页面重置密码
|
||||
*/
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
require_once __DIR__ . '/core.php';
|
||||
require_once __DIR__ . '/api/sync.php';
|
||||
|
||||
// 获取token参数
|
||||
$token = isset($_GET['token']) ? $_GET['token'] : '';
|
||||
|
||||
if (!$token) {
|
||||
die('无效的重置链接');
|
||||
}
|
||||
|
||||
// 验证token
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM password_reset_tokens WHERE token = :token");
|
||||
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
die('重置链接无效或已过期');
|
||||
}
|
||||
|
||||
if (time() > $row['expire_at']) {
|
||||
die('重置链接已过期,请重新申请');
|
||||
}
|
||||
|
||||
$player = $row['player_name'];
|
||||
|
||||
// 处理表单提交
|
||||
$message = '';
|
||||
$error = '';
|
||||
$success = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$newPassword = isset($_POST['password']) ? $_POST['password'] : '';
|
||||
$confirmPassword = isset($_POST['confirm_password']) ? $_POST['confirm_password'] : '';
|
||||
|
||||
if (!$newPassword || strlen($newPassword) < 6) {
|
||||
$error = '密码长度至少6位';
|
||||
} elseif ($newPassword !== $confirmPassword) {
|
||||
$error = '两次输入的密码不一致';
|
||||
} else {
|
||||
// 生成新的密码凭证
|
||||
$saltBytes = random_bytes(16);
|
||||
$salt = base64_encode($saltBytes);
|
||||
$passwordHash = base64_encode(hash('sha256', $saltBytes . $newPassword, true));
|
||||
|
||||
// 更新密码凭证
|
||||
storeWebLoginCredentials($player, $passwordHash, $salt);
|
||||
|
||||
// 删除重置token
|
||||
$stmt = $db->prepare("DELETE FROM password_reset_tokens WHERE token = :token");
|
||||
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
$success = true;
|
||||
$message = '密码重置成功!请在游戏中使用新密码登录';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>重置密码 - Sdf1 Minecraft</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.player-info {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.player-info strong {
|
||||
color: #667eea;
|
||||
font-size: 18px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.alert-error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
.alert-success {
|
||||
background: #efe;
|
||||
color: #363;
|
||||
border: 1px solid #cfc;
|
||||
}
|
||||
.success-container {
|
||||
text-align: center;
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.success-text {
|
||||
color: #363;
|
||||
font-size: 18px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.info-text {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<?php if ($success): ?>
|
||||
<div class="success-container">
|
||||
<div class="success-icon">✅</div>
|
||||
<h1>重置成功</h1>
|
||||
<p class="success-text"><?php echo htmlspecialchars($message); ?></p>
|
||||
<div class="info-text">
|
||||
<strong>提示:</strong><br>
|
||||
请在Minecraft游戏中使用新密码重新登录Web端
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<h1>重置密码</h1>
|
||||
<p class="subtitle">为您的账号设置新密码</p>
|
||||
|
||||
<div class="player-info">
|
||||
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">玩家</div>
|
||||
<strong><?php echo htmlspecialchars($player); ?></strong>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?php echo htmlspecialchars($error); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="">
|
||||
<div class="form-group">
|
||||
<label for="password">新密码</label>
|
||||
<input type="password" id="password" name="password" required minlength="6" placeholder="至少6位字符">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">确认密码</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="6" placeholder="再次输入密码">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">重置密码</button>
|
||||
</form>
|
||||
|
||||
<div class="info-text">
|
||||
<strong>注意:</strong><br>
|
||||
• 密码重置后,请使用新密码登录Web端<br>
|
||||
• 此链接30分钟内有效,使用后自动失效<br>
|
||||
• 如果这不是您的操作,请忽略此邮件
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 密码确认
|
||||
const form = document.querySelector('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirm_password').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
e.preventDefault();
|
||||
alert('两次输入的密码不一致');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* 自更新脚本 - 通过HTTP POST上传文件内容
|
||||
* 用法: curl -X POST -d "secret=KEY&action=update_file&target=api/admin.php" --data-binary @admin.php https://.../self_update.php
|
||||
* 或: curl -X POST -F "secret=KEY" -F "action=update_file" -F "target=api/admin.php" -F "file=@admin.php" https://.../self_update.php
|
||||
*/
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
@ob_start();
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
$secret = $_POST['secret'] ?? $_GET['secret'] ?? '';
|
||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||
|
||||
if ($secret !== SECRET_KEY) {
|
||||
echo "ERROR: Invalid secret key\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$timestamp = date('Y-m-d H:i:s');
|
||||
|
||||
if ($action === 'update_file') {
|
||||
$target = $_POST['target'] ?? '';
|
||||
$allowedTargets = ['api/admin.php', 'api/sync.php', 'core.php', 'config.php'];
|
||||
|
||||
if (!in_array($target, $allowedTargets)) {
|
||||
echo "ERROR: Target '$target' not in allowed list: " . implode(', ', $allowedTargets) . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$targetPath = __DIR__ . '/' . $target;
|
||||
|
||||
// Handle file upload
|
||||
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$content = file_get_contents($_FILES['file']['tmp_name']);
|
||||
} elseif (isset($_POST['content'])) {
|
||||
$content = $_POST['content'];
|
||||
} else {
|
||||
// Read from raw POST body
|
||||
$content = file_get_contents('php://input');
|
||||
}
|
||||
|
||||
if (empty($content)) {
|
||||
echo "ERROR: No file content received\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Backup
|
||||
$backupPath = $targetPath . '.bak';
|
||||
if (file_exists($targetPath)) {
|
||||
copy($targetPath, $backupPath);
|
||||
}
|
||||
|
||||
// Write
|
||||
$bytesWritten = file_put_contents($targetPath, $content);
|
||||
|
||||
if ($bytesWritten !== false) {
|
||||
echo "SUCCESS: Updated $target ($bytesWritten bytes) at $timestamp\n";
|
||||
echo "Backup: $backupPath\n";
|
||||
|
||||
// Verify
|
||||
echo "Verify MD5: " . md5_file($targetPath) . "\n";
|
||||
|
||||
// Clear opcache
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
echo "OPCache: cleared\n";
|
||||
}
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
@opcache_invalidate($targetPath, true);
|
||||
echo "OPCache: invalidated $target\n";
|
||||
}
|
||||
} else {
|
||||
echo "ERROR: Failed to write file\n";
|
||||
}
|
||||
|
||||
} elseif ($action === 'verify') {
|
||||
echo "=== File Verification ===\n";
|
||||
$files = ['api/admin.php', 'api/sync.php', 'core.php'];
|
||||
foreach ($files as $f) {
|
||||
$path = __DIR__ . '/' . $f;
|
||||
if (file_exists($path)) {
|
||||
$size = filesize($path);
|
||||
$md5 = md5_file($path);
|
||||
$mtime = date('Y-m-d H:i:s', filemtime($path));
|
||||
echo "$f: $size bytes, MD5: $md5, modified: $mtime\n";
|
||||
} else {
|
||||
echo "$f: NOT FOUND\n";
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
echo "Usage: ?action=update_file|verify&secret=KEY\n";
|
||||
}
|
||||
|
||||
echo "\nDone at $timestamp\n";
|
||||
@ob_end_flush();
|
||||
?>
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
$BUILD_VERSION = 'v' . filemtime(__FILE__);
|
||||
$currentVersion = isset($_GET['_v']) ? $_GET['_v'] : '';
|
||||
if ($currentVersion !== $BUILD_VERSION) {
|
||||
$params = $_GET;
|
||||
unset($params['_v']);
|
||||
$queryString = http_build_query($params);
|
||||
$path = strtok($_SERVER['REQUEST_URI'], '?');
|
||||
$newUrl = $path . ($queryString ? '?' . $queryString . '&' : '?') . '_v=' . $BUILD_VERSION;
|
||||
header('Location: ' . $newUrl, true, 302);
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>服务商面板 - 草原探险</title>
|
||||
<style>
|
||||
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
|
||||
* { 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; }
|
||||
.header { background:linear-gradient(135deg,#1a2e1a 0%,#0d1117 100%); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; }
|
||||
.header h1 { font-size:18px; color:var(--green); }
|
||||
.container { max-width:900px; margin:20px auto; padding:0 16px; }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:20px; margin-bottom:16px; }
|
||||
.card h2 { font-size:16px; margin-bottom:12px; color:var(--green); }
|
||||
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all 0.2s; }
|
||||
.btn-primary { background:var(--accent); color:#fff; }
|
||||
.btn-primary:hover { background:#79c0ff; }
|
||||
.btn-green { background:var(--green); color:#fff; }
|
||||
.btn-dim { background:var(--card); color:var(--dim); border:1px solid var(--border); }
|
||||
.btn-dim:hover { color:var(--text); border-color:var(--accent); }
|
||||
input, textarea, select { width:100%; padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); font-size:14px; }
|
||||
textarea { resize:vertical; font-family:monospace; }
|
||||
.tabs { display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
|
||||
.tab { padding:6px 14px; background:var(--bg); border:1px solid var(--border); border-radius:4px; cursor:pointer; font-size:12px; color:var(--dim); }
|
||||
.tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
.ticket-item { background:rgba(63,185,80,0.05); border:1px solid var(--border); border-radius:8px; padding:14px 16px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; }
|
||||
.ticket-item:hover { border-color:var(--green); }
|
||||
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
|
||||
.md-content { font-size:14px; line-height:1.6; }
|
||||
.md-content pre { background:rgba(63,185,80,0.1); padding:10px; border-radius:4px; overflow-x:auto; font-size:12px; }
|
||||
.md-content code { background:rgba(63,185,80,0.15); padding:1px 4px; border-radius:2px; font-size:12px; }
|
||||
.reply-box { background:rgba(63,185,80,0.05); border-radius:6px; padding:10px 14px; margin-bottom:8px; }
|
||||
.login-box { max-width:400px; margin:100px auto; }
|
||||
.login-box h2 { text-align:center; margin-bottom:20px; color:var(--green); }
|
||||
.error { color:var(--red); font-size:13px; margin-top:8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🧑💻 服务商面板</h1>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<span id="providerInfo" style="color:var(--dim);font-size:13px"></span>
|
||||
<button class="btn btn-dim" onclick="doLogout()" style="font-size:12px;padding:4px 12px">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="mainContainer">
|
||||
<!-- 登录/面板 动态渲染 -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = 'api/';
|
||||
let TOKEN = localStorage.getItem('sdf1_provider_token') || '';
|
||||
let PLAYER = localStorage.getItem('sdf1_provider_player') || '';
|
||||
let PROVIDER_ROLE = '';
|
||||
|
||||
(async function() {
|
||||
if (TOKEN && PLAYER) {
|
||||
await checkProviderAuth();
|
||||
} else {
|
||||
renderLogin();
|
||||
}
|
||||
})();
|
||||
|
||||
function renderLogin() {
|
||||
document.getElementById('mainContainer').innerHTML = `
|
||||
<div class="card login-box">
|
||||
<h2>🧑💻 服务商登录</h2>
|
||||
<p style="color:var(--dim);text-align:center;font-size:13px;margin-bottom:16px">使用游戏账号密码登录(仅限已授权的服务商)</p>
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="color:var(--dim);font-size:12px;display:block;margin-bottom:4px">玩家名</label>
|
||||
<input id="spUser" placeholder="游戏内玩家名" value="${escH(PLAYER)}">
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="color:var(--dim);font-size:12px;display:block;margin-bottom:4px">密码</label>
|
||||
<input id="spPass" type="password" placeholder="游戏登录密码" onkeydown="if(event.key==='Enter')doProviderLogin()">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="width:100%;padding:12px" onclick="doProviderLogin()">登录</button>
|
||||
<p id="spError" class="error"></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function doProviderLogin() {
|
||||
const user = document.getElementById('spUser').value.trim();
|
||||
const pass = document.getElementById('spPass').value;
|
||||
const errEl = document.getElementById('spError');
|
||||
if (!user || !pass) { errEl.textContent = '请输入用户名和密码'; return; }
|
||||
|
||||
try {
|
||||
// 0. 先清理可能残留的旧请求
|
||||
const cancelUrl = new URL(API + 'sync.php', location.href);
|
||||
cancelUrl.searchParams.set('action', 'cancel_web_login');
|
||||
cancelUrl.searchParams.set('player', user);
|
||||
await fetch(cancelUrl);
|
||||
|
||||
// 1. 先创建weblogin token
|
||||
const regUrl = new URL(API + 'sync.php', location.href);
|
||||
regUrl.searchParams.set('action', 'web_login_request');
|
||||
const regRes = await fetch(regUrl, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({player: user, password: pass})
|
||||
});
|
||||
const regData = await regRes.json();
|
||||
if (!regData.success) { errEl.textContent = regData.message || '登录请求失败'; return; }
|
||||
|
||||
// 2. 等待Java端处理(轮询)
|
||||
const requestId = regData.data && regData.data.request_id;
|
||||
if (!requestId) { errEl.textContent = '未获取请求ID'; return; }
|
||||
|
||||
let verified = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const checkUrl = new URL(API + 'sync.php', location.href);
|
||||
checkUrl.searchParams.set('action', 'check_web_login_result');
|
||||
checkUrl.searchParams.set('request_id', requestId);
|
||||
checkUrl.searchParams.set('player', user);
|
||||
const checkRes = await fetch(checkUrl);
|
||||
const checkData = await checkRes.json();
|
||||
if (checkData.success && checkData.data && checkData.data.status === 'success') {
|
||||
TOKEN = checkData.data.token || checkData.data.web_token || '';
|
||||
verified = true;
|
||||
break;
|
||||
}
|
||||
if (checkData.data && checkData.data.status === 'failed') {
|
||||
// 密码错误,清理pending请求
|
||||
const cancelUrl3 = new URL(API + 'sync.php', location.href);
|
||||
cancelUrl3.searchParams.set('action', 'cancel_web_login');
|
||||
cancelUrl3.searchParams.set('player', user);
|
||||
await fetch(cancelUrl3);
|
||||
errEl.textContent = checkData.data.message || '密码错误';
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
// 超时,清理pending请求
|
||||
const cancelUrl2 = new URL(API + 'sync.php', location.href);
|
||||
cancelUrl2.searchParams.set('action', 'cancel_web_login');
|
||||
cancelUrl2.searchParams.set('player', user);
|
||||
await fetch(cancelUrl2);
|
||||
errEl.textContent = '验证超时,请确认游戏服务器在线';
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 验证服务商身份
|
||||
PLAYER = user;
|
||||
localStorage.setItem('sdf1_provider_token', TOKEN);
|
||||
localStorage.setItem('sdf1_provider_player', PLAYER);
|
||||
await checkProviderAuth();
|
||||
} catch (e) {
|
||||
errEl.textContent = '登录失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProviderAuth() {
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'provider_check');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.success && data.data.is_provider) {
|
||||
PROVIDER_ROLE = data.data.role || 'waiter';
|
||||
document.getElementById('providerInfo').textContent = `${PLAYER} (${PROVIDER_ROLE})`;
|
||||
renderPanel();
|
||||
} else {
|
||||
TOKEN = '';
|
||||
localStorage.removeItem('sdf1_provider_token');
|
||||
renderLogin();
|
||||
setTimeout(() => {
|
||||
const errEl = document.getElementById('spError');
|
||||
if (errEl) errEl.textContent = '你不是授权的服务商,请联系管理员';
|
||||
}, 100);
|
||||
}
|
||||
} catch (e) {
|
||||
TOKEN = '';
|
||||
localStorage.removeItem('sdf1_provider_token');
|
||||
renderLogin();
|
||||
}
|
||||
}
|
||||
|
||||
let spView = 'available';
|
||||
function renderPanel() {
|
||||
const c = document.getElementById('mainContainer');
|
||||
c.innerHTML = `
|
||||
<div class="tabs">
|
||||
<div class="tab ${spView==='available'?'active':''}" onclick="spView='available';renderPanel()">📋 抢单大厅</div>
|
||||
<div class="tab ${spView==='my'?'active':''}" onclick="spView='my';renderPanel()">👤 我的工单</div>
|
||||
</div>
|
||||
<div id="spContent"><p style="color:var(--dim)">加载中...</p></div>`;
|
||||
if (spView === 'available') loadAvailableTickets();
|
||||
else loadMyProviderTickets();
|
||||
}
|
||||
|
||||
async function loadAvailableTickets() {
|
||||
const el = document.getElementById('spContent');
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'available');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
|
||||
const list = data.data.list;
|
||||
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
|
||||
if (list.length === 0) {
|
||||
el.innerHTML = `<div class="card"><p style="color:var(--dim);text-align:center;padding:30px">暂无待接工单</p></div>`;
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const t of list) {
|
||||
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
|
||||
html += `<div class="ticket-item" onclick="viewSPTicket(${t.id},'available')">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||||
<span style="font-weight:600;font-size:14px">#${t.id} ${escH(t.title)}</span>
|
||||
<span style="font-size:12px;color:var(--yellow)">⏳ 待接</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--dim)">${typeMap[t.type]||t.type} | 👤 ${escH(t.requester)} | 📅 ${date}</div>
|
||||
</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
async function loadMyProviderTickets() {
|
||||
const el = document.getElementById('spContent');
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'provider_list');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
url.searchParams.set('type', 'processing');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
|
||||
const list = data.data.list;
|
||||
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
|
||||
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
|
||||
if (list.length === 0) {
|
||||
el.innerHTML = `<div class="card"><p style="color:var(--dim);text-align:center;padding:30px">暂无处理中的工单</p></div>`;
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const t of list) {
|
||||
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
|
||||
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
|
||||
html += `<div class="ticket-item" onclick="viewSPTicket(${t.id},'my')">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||||
<span style="font-weight:600;font-size:14px">#${t.id} ${escH(t.title)}</span>
|
||||
<span style="font-size:12px;color:${s.color}">${s.text}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--dim)">${typeMap[t.type]||t.type} | 👤 ${escH(t.requester)} | 📅 ${date}</div>
|
||||
</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
async function viewSPTicket(id, from) {
|
||||
const el = document.getElementById('spContent');
|
||||
el.innerHTML = '<p style="color:var(--dim)">加载中...</p>';
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'detail');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
url.searchParams.set('id', id);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
|
||||
|
||||
const t = data.data;
|
||||
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
|
||||
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
|
||||
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
|
||||
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
|
||||
const isMyTicket = t.assigned_to === PLAYER;
|
||||
const canReply = !['withdrawn','rejected','completed'].includes(t.status) && isMyTicket;
|
||||
const canGrab = t.status === 'submitted' && !t.assigned_to;
|
||||
|
||||
let html = `<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<h2 style="margin:0">#${t.id} ${escH(t.title)}</h2>
|
||||
<span style="color:${s.color};font-size:13px">${s.text}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--dim);margin-bottom:12px">
|
||||
${typeMap[t.type]||t.type} | 提交者: ${escH(t.requester)} | 处理人: ${t.assigned_to ? escH(t.assigned_to) : '未分配'} | ${date}
|
||||
</div>
|
||||
<div style="background:rgba(63,185,80,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px">
|
||||
<div style="color:var(--dim);font-size:11px;margin-bottom:4px">描述</div>
|
||||
<div class="md-content">${spRenderMd(t.description || '无描述')}</div>
|
||||
</div>`;
|
||||
|
||||
if (t.reject_reason) {
|
||||
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:6px;padding:12px;margin-bottom:12px">
|
||||
<div style="color:var(--red);font-size:11px;margin-bottom:4px">🚫 驳回原因</div>
|
||||
<div class="md-content">${spRenderMd(t.reject_reason)}</div></div>`;
|
||||
}
|
||||
|
||||
// 回复列表
|
||||
html += `<div style="margin-bottom:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:6px">💬 回复 (${t.replies ? t.replies.length : 0})</div>`;
|
||||
if (t.replies && t.replies.length > 0) {
|
||||
for (const r of t.replies) {
|
||||
const rDate = new Date(r.created_at * 1000).toLocaleString('zh-CN');
|
||||
const roleColors = {user:'var(--accent)',admin:'var(--red)',provider:'var(--green)'};
|
||||
const roleNames = {user:'玩家',admin:'管理员',provider:'服务商'};
|
||||
const bgColor = r.role === 'admin' ? 'rgba(248,81,73,0.08)' : r.role === 'provider' ? 'rgba(63,185,80,0.08)' : 'rgba(88,166,255,0.08)';
|
||||
html += `<div class="reply-box" style="background:${bgColor}">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px">
|
||||
<span><b style="color:${roleColors[r.role]||'var(--text)'}">${escH(r.sender)}</b> <span style="color:var(--dim)">(${roleNames[r.role]||r.role})</span></span>
|
||||
<span style="color:var(--dim)">${rDate}</span>
|
||||
</div>
|
||||
<div class="md-content">${spRenderMd(r.message)}</div>
|
||||
</div>`;
|
||||
}
|
||||
} else {
|
||||
html += `<p style="color:var(--dim);font-size:12px">暂无回复</p>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
// 操作
|
||||
html += `<div style="display:flex;gap:8px;flex-wrap:wrap">`;
|
||||
if (canGrab) {
|
||||
html += `<button class="btn btn-green" onclick="grabSPTicket(${t.id})">📩 接单</button>`;
|
||||
}
|
||||
if (canReply) {
|
||||
html += `<div style="width:100%;margin-top:8px">
|
||||
<textarea id="spReplyMsg" rows="2" placeholder="输入回复内容(支持Markdown)" style="font-family:monospace"></textarea>
|
||||
<div style="margin-top:6px;display:flex;gap:8px">
|
||||
<button class="btn btn-primary" onclick="spReply(${t.id})">💬 回复</button>
|
||||
<button class="btn btn-green" onclick="spComplete(${t.id})">✅ 标记完结</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `<button class="btn btn-dim" onclick="spView='${from}';renderPanel()" style="margin-top:8px">← 返回</button></div>`;
|
||||
html += `</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
async function grabSPTicket(id) {
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'grab');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
url.searchParams.set('id', id);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.success) { viewSPTicket(id, 'available'); } else { alert(data.message); }
|
||||
} catch (e) { alert('接单失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function spReply(id) {
|
||||
const msg = document.getElementById('spReplyMsg').value.trim();
|
||||
if (!msg) { alert('请输入回复内容'); return; }
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'provider_reply');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
url.searchParams.set('id', id);
|
||||
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message: msg})});
|
||||
const data = await res.json();
|
||||
if (data.success) { viewSPTicket(id, 'my'); } else { alert(data.message); }
|
||||
} catch (e) { alert('回复失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function spComplete(id) {
|
||||
if (!confirm('确定标记此工单已完结?')) return;
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'complete');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
url.searchParams.set('id', id);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.success) { viewSPTicket(id, 'my'); } else { alert(data.message); }
|
||||
} catch (e) { alert('操作失败: ' + e.message); }
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
TOKEN = '';
|
||||
PLAYER = '';
|
||||
localStorage.removeItem('sdf1_provider_token');
|
||||
localStorage.removeItem('sdf1_provider_player');
|
||||
renderLogin();
|
||||
}
|
||||
|
||||
function escH(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function spRenderMd(text) {
|
||||
if (!text) return '';
|
||||
text = text.replace(/\r/g, '');
|
||||
try {
|
||||
let s = escH(text);
|
||||
// 代码块(提取保护,防止<br>污染)
|
||||
let cbs = [];
|
||||
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
|
||||
let idx = cbs.length;
|
||||
cbs.push('<pre style="background:rgba(63,185,80,0.1);padding:10px;border-radius:4px;overflow-x:auto;font-size:12px;white-space:pre-wrap">' + code.replace(/\n$/, '') + '</pre>');
|
||||
return '\x00CB' + idx + '\x00';
|
||||
});
|
||||
// 行内代码
|
||||
s = s.replace(/`([^`]+)`/g, '<code style="background:rgba(63,185,80,0.15);padding:1px 4px;border-radius:2px;font-size:12px">$1</code>');
|
||||
// 删除线 ~~text~~
|
||||
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||
// 粗体+斜体
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
||||
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
|
||||
// 图片 
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:6px 0">');
|
||||
// 链接(排除图片)
|
||||
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
|
||||
// 标题(从多到少匹配,严格匹配空格分隔)
|
||||
s = s.replace(/^######\s+(.+)$/gm, '<h6 style="margin:3px 0;font-size:11px">$1</h6>');
|
||||
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:4px 0;font-size:12px">$1</h5>');
|
||||
s = s.replace(/^####\s+(.+)$/gm, '<h5 style="margin:4px 0 2px;font-size:12px">$1</h5>');
|
||||
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:6px 0 2px;font-size:13px">$1</h4>');
|
||||
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:14px">$1</h3>');
|
||||
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:10px 0 6px;font-size:15px">$1</h2>');
|
||||
// 引用块(支持嵌套)
|
||||
s = s.replace(/^(?:>[ \t]*)+(.*)$/gm, function(m, content) {
|
||||
let nestLevel = (m.match(/>/g) || []).length;
|
||||
let leftPad = Math.min(nestLevel * 6, 24);
|
||||
let text = content || ' ';
|
||||
return '<div style="border-left:3px solid var(--accent);padding:2px 8px;margin:4px 0 4px ' + leftPad + 'px;color:#aaa;font-style:italic;background:rgba(255,255,255,0.03)">' + text + '</div>';
|
||||
});
|
||||
// 表格处理
|
||||
let tableLines = [];
|
||||
let inTable = false;
|
||||
let tableHtml = '';
|
||||
let lines = s.split('\n');
|
||||
let result = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i].trim();
|
||||
if (line.match(/^\|(.+)\|$/)) {
|
||||
// 分隔行 |---|---|
|
||||
if (line.match(/^\|[\s\-:|]+\|$/)) {
|
||||
inTable = true;
|
||||
continue;
|
||||
}
|
||||
if (!inTable && i + 1 < lines.length && lines[i+1].trim().match(/^\|[\s\-:|]+\|$/)) {
|
||||
// 下一行是分隔行,这是表头
|
||||
inTable = true;
|
||||
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
||||
tableHtml = '<table style="border-collapse:collapse;width:100%;margin:6px 0;font-size:12px"><tr style="background:rgba(63,185,80,0.15)">';
|
||||
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:4px 8px;text-align:left">' + c + '</th>';
|
||||
tableHtml += '</tr>';
|
||||
continue;
|
||||
}
|
||||
if (inTable) {
|
||||
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
||||
tableHtml += '<tr>';
|
||||
for (let c of cells) tableHtml += '<td style="border:1px solid var(--border);padding:4px 8px">' + c + '</td>';
|
||||
tableHtml += '</tr>';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inTable && tableHtml) {
|
||||
tableHtml += '</table>';
|
||||
result.push(tableHtml);
|
||||
tableHtml = '';
|
||||
inTable = false;
|
||||
}
|
||||
result.push(lines[i]);
|
||||
}
|
||||
if (tableHtml) {
|
||||
tableHtml += '</table>';
|
||||
result.push(tableHtml);
|
||||
}
|
||||
s = result.join('\n');
|
||||
// 无序列表
|
||||
s = s.replace(/^- (.+)$/gm, '<li style="margin-left:16px;font-size:12px">$1</li>');
|
||||
// 分割线
|
||||
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:8px 0">');
|
||||
// 换行
|
||||
s = s.replace(/\n/g, '<br>');
|
||||
// 还原代码块占位符
|
||||
for (let i = 0; i < cbs.length; i++) {
|
||||
s = s.replace('\x00CB' + i + '\x00', cbs[i]);
|
||||
}
|
||||
return s;
|
||||
} catch(e) { console.error('[MD_RENDER] Error:', e); return escH(text).replace(/\n/g, '<br>'); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* 测试脚本 - 模拟Java推送online_players数据
|
||||
* 用于验证PHP端syncOnlinePlayers是否正常工作
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
$db = getDB();
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
if ($action === 'simulate_push') {
|
||||
// 模拟Java推送一个已登录的玩家
|
||||
$player = $_GET['player'] ?? 'yupaishidifu';
|
||||
$secret = $_GET['secret'] ?? SECRET_KEY;
|
||||
|
||||
$players = [
|
||||
['name' => $player, 'login_time' => time()]
|
||||
];
|
||||
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL)");
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||||
|
||||
$db->exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
$db->exec("DELETE FROM online_players");
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO online_players (player_name, login_time) VALUES (:name, :time)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$hbSql = $db->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :time)");
|
||||
$hbSql->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$hbSql->execute();
|
||||
|
||||
$db->exec("COMMIT");
|
||||
} catch (Exception $e) {
|
||||
$db->exec("ROLLBACK");
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 验证
|
||||
$stmt = $db->query("SELECT * FROM online_players");
|
||||
$rows = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "模拟推送玩家 $player 成功",
|
||||
'online_players' => $rows,
|
||||
'timestamp' => time()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'check_token') {
|
||||
// 检查指定玩家的token是否存在
|
||||
$player = $_GET['player'] ?? 'yupaishidifu';
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE player_name = :player ORDER BY created_at DESC LIMIT 1");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
'player' => $player,
|
||||
'token_found' => $row !== false,
|
||||
'token_data' => $row,
|
||||
'timestamp' => time()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'validate') {
|
||||
// 模拟validateWebAccess调用
|
||||
$player = $_GET['player'] ?? 'yupaishidifu';
|
||||
$token = $_GET['token'] ?? '';
|
||||
|
||||
if (!$token) {
|
||||
// 查找最新token
|
||||
$stmt = $db->prepare("SELECT web_token FROM weblogin_tokens WHERE player_name = :player ORDER BY created_at DESC LIMIT 1");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$token = $row ? $row['web_token'] : '';
|
||||
}
|
||||
|
||||
$result = validateWebAccess($token, 'view');
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'error' => '未知操作',
|
||||
'usage' => [
|
||||
'test_sync.php?action=simulate_push&player=yupaishidifu',
|
||||
'test_sync.php?action=check_token&player=yupaishidifu',
|
||||
'test_sync.php?action=validate&player=yupaishidifu'
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// FTP上传脚本
|
||||
$ftpServer = '43.153.203.46';
|
||||
$ftpPort = 61868;
|
||||
$ftpUser = 'qwed';
|
||||
$ftpPass = 'z22K2Ws8r38a';
|
||||
|
||||
$localFile = __DIR__ . '/debug_region_search.php';
|
||||
$remoteDir = '/caoyuan.ypshidifu.cn/test1/';
|
||||
$remoteFile = $remoteDir . 'debug_region_search.php';
|
||||
|
||||
echo "Connecting to FTP...\n";
|
||||
$conn = ftp_connect($ftpServer, $ftpPort);
|
||||
if (!$conn) {
|
||||
die("FTP connection failed\n");
|
||||
}
|
||||
|
||||
echo "Logging in...\n";
|
||||
if (!ftp_login($conn, $ftpUser, $ftpPass)) {
|
||||
die("FTP login failed\n");
|
||||
}
|
||||
|
||||
echo "Uploading $localFile to $remoteFile...\n";
|
||||
if (ftp_put($conn, $remoteFile, $localFile, FTP_BINARY)) {
|
||||
echo "Upload successful\n";
|
||||
} else {
|
||||
echo "Upload failed\n";
|
||||
}
|
||||
|
||||
ftp_close($conn);
|
||||
echo "Done\n";
|
||||
?>
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
/**
|
||||
* HTTP文件上传器 - 用于在FTP不可用时通过HTTP上传文件
|
||||
* 访问方式: upload_via_http.php?secret=YOUR_SECRET&action=upload
|
||||
*
|
||||
* 安全措施:
|
||||
* 1. 需要SECRET_KEY认证
|
||||
* 2. 只允许覆盖特定文件
|
||||
* 3. 记录所有操作
|
||||
*/
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
@ob_start();
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/core.php';
|
||||
|
||||
$secret = getParam('secret', '');
|
||||
$action = getParam('action', '');
|
||||
|
||||
if ($secret !== SECRET_KEY) {
|
||||
echo "ERROR: Invalid secret key\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$logFile = __DIR__ . '/upload_log.txt';
|
||||
file_put_contents($logFile, date('Y-m-d H:i:s') . " | Action: $action | IP: " . $_SERVER['REMOTE_ADDR'] . "\n", FILE_APPEND);
|
||||
|
||||
if ($action === 'status') {
|
||||
echo "=== Server Status ===\n";
|
||||
echo "PHP Version: " . phpversion() . "\n";
|
||||
echo "SQLite Version: " . SQLite3::version()['versionString'] . "\n";
|
||||
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "DB Path: " . (defined('DB_PATH') ? DB_PATH : 'undefined') . "\n";
|
||||
|
||||
if (defined('DB_PATH') && file_exists(DB_PATH)) {
|
||||
$db = new SQLite3(DB_PATH);
|
||||
$db->enableExceptions(false);
|
||||
|
||||
echo "\n=== Table Row Counts ===\n";
|
||||
$tables = ['users', 'player_ip_changes', 'player_ip_locations', 'web_session_log', 'online_players', 'player_daily_logins', 'player_checkins'];
|
||||
foreach ($tables as $t) {
|
||||
try {
|
||||
$cnt = $db->querySingle("SELECT COUNT(*) FROM $t");
|
||||
echo "$t: $cnt rows\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "$t: ERROR - " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 player_ip_locations 中的格式
|
||||
echo "\n=== IP Location Samples (first 10) ===\n";
|
||||
try {
|
||||
$r = $db->query("SELECT ip_address, location, player_name FROM player_ip_locations LIMIT 10");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo $row['ip_address'] . " => " . $row['location'] . " (player: " . $row['player_name'] . ")\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 检查 player_ip_changes 中的玩家IP
|
||||
echo "\n=== Player IP Changes Samples (first 10) ===\n";
|
||||
try {
|
||||
$r = $db->query("SELECT player_name, new_ip, changed_at FROM player_ip_changes ORDER BY changed_at DESC LIMIT 10");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo $row['player_name'] . " => " . $row['new_ip'] . " (at: " . date('Y-m-d H:i:s', $row['changed_at']) . ")\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 检查 web_session_log 中的玩家IP
|
||||
echo "\n=== Web Session Log Samples (first 10) ===\n";
|
||||
try {
|
||||
$r = $db->query("SELECT player_name, ip_address, login_time FROM web_session_log ORDER BY login_time DESC LIMIT 10");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo $row['player_name'] . " => " . $row['ip_address'] . " (at: " . date('Y-m-d H:i:s', $row['login_time']) . ")\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 检查 region 搜索模拟
|
||||
echo "\n=== Region Search Simulation for '长沙' ===\n";
|
||||
echo "Step 1: player_ip_locations with '长沙':\n";
|
||||
try {
|
||||
$r = $db->query("SELECT ip_address, location FROM player_ip_locations WHERE location LIKE '%长沙%'");
|
||||
$cnt = 0;
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " " . $row['ip_address'] . " => " . $row['location'] . "\n";
|
||||
$cnt++;
|
||||
}
|
||||
echo " Found: $cnt IPs\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// 查找有长沙IP的玩家
|
||||
echo "\nStep 2: Players with 长沙 IPs:\n";
|
||||
try {
|
||||
$r = $db->query("
|
||||
SELECT DISTINCT c.player_name
|
||||
FROM player_ip_changes c
|
||||
INNER JOIN player_ip_locations l ON c.new_ip = l.ip_address
|
||||
WHERE l.location LIKE '%长沙%'
|
||||
UNION
|
||||
SELECT DISTINCT w.player_name
|
||||
FROM web_session_log w
|
||||
INNER JOIN player_ip_locations l ON w.ip_address = l.ip_address
|
||||
WHERE l.location LIKE '%长沙%'
|
||||
");
|
||||
$cnt = 0;
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
echo " " . $row['player_name'] . "\n";
|
||||
$cnt++;
|
||||
}
|
||||
echo " Found: $cnt players\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " ERROR: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
$db->close();
|
||||
} else {
|
||||
echo "Database not found!\n";
|
||||
}
|
||||
|
||||
echo "\n=== admin.php Version ===\n";
|
||||
$adminFile = __DIR__ . '/api/admin.php';
|
||||
if (file_exists($adminFile)) {
|
||||
$content = file_get_contents($adminFile);
|
||||
echo "File size: " . filesize($adminFile) . " bytes\n";
|
||||
echo "Has _region_debug: " . (strpos($content, '_region_debug') !== false ? 'YES' : 'NO') . "\n";
|
||||
echo "Has regionDebugEnabled: " . (strpos($content, 'regionDebugEnabled') !== false ? 'YES' : 'NO') . "\n";
|
||||
echo "Has batchGetPlayerIps: " . (strpos($content, 'function batchGetPlayerIps') !== false ? 'YES' : 'NO') . "\n";
|
||||
} else {
|
||||
echo "admin.php not found!\n";
|
||||
}
|
||||
|
||||
// ★ 模拟 admin 的精确 region search 逻辑
|
||||
echo "\n=== Simulate Admin Region Search Logic ===\n";
|
||||
$db = new SQLite3(DB_PATH);
|
||||
$db->enableExceptions(false);
|
||||
|
||||
$regionKey = '长沙';
|
||||
|
||||
// Step 1: Get all player names
|
||||
$allPlayerNames = [];
|
||||
$r = $db->query("SELECT player_name FROM users");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
$allPlayerNames[] = $row['player_name'];
|
||||
}
|
||||
echo "All players: " . count($allPlayerNames) . "\n";
|
||||
|
||||
// Step 2: Build playerIpMap from player_ip_changes
|
||||
$playerIpMap = [];
|
||||
$stmt = $db->prepare("SELECT player_name, new_ip FROM player_ip_changes WHERE new_ip != '' AND new_ip != '-' AND new_ip NOT LIKE '10.%' AND new_ip NOT LIKE '192.168.%' AND new_ip != '127.0.0.1'");
|
||||
$r = $stmt->execute();
|
||||
$changesCount = 0;
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
$changesCount++;
|
||||
$pn = $row['player_name'];
|
||||
$ip = trim($row['new_ip']);
|
||||
if (!isset($playerIpMap[$pn])) $playerIpMap[$pn] = [];
|
||||
if (!in_array($ip, $playerIpMap[$pn])) $playerIpMap[$pn][] = $ip;
|
||||
}
|
||||
echo "player_ip_changes rows: $changesCount\n";
|
||||
echo "Players with IPs from changes: " . count($playerIpMap) . "\n";
|
||||
|
||||
// web_session_log is empty, skip
|
||||
echo "web_session_log rows: 0 (skipped)\n";
|
||||
|
||||
// Step 3: Get IP locations
|
||||
$ipLocMap = [];
|
||||
$r = $db->query("SELECT ip_address, location FROM player_ip_locations");
|
||||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||||
$loc = trim($row['location'] ?? '');
|
||||
$ip = trim($row['ip_address'] ?? '');
|
||||
if ($loc && $ip) {
|
||||
$ipLocMap[$ip] = $loc;
|
||||
}
|
||||
}
|
||||
echo "IP locations cached: " . count($ipLocMap) . "\n";
|
||||
|
||||
// Show some sample IP from playerIpMap vs ipLocMap
|
||||
echo "\n--- IP Comparison ---\n";
|
||||
$samplePlayers = array_slice(array_keys($playerIpMap), 0, 5);
|
||||
foreach ($samplePlayers as $pn) {
|
||||
$ips = $playerIpMap[$pn];
|
||||
echo "Player: $pn => IPs: " . implode(', ', $ips) . "\n";
|
||||
foreach ($ips as $ip) {
|
||||
if (isset($ipLocMap[$ip])) {
|
||||
echo " FOUND in cache: $ip => " . $ipLocMap[$ip] . "\n";
|
||||
} else {
|
||||
echo " NOT in cache: $ip\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: PHP matching
|
||||
$matchedPlayers = [];
|
||||
$matchDebug = [];
|
||||
foreach ($playerIpMap as $pn => $ips) {
|
||||
foreach ($ips as $ip) {
|
||||
if (isset($ipLocMap[$ip])) {
|
||||
$loc = $ipLocMap[$ip];
|
||||
if (mb_stripos($loc, $regionKey) !== false) {
|
||||
if (!in_array($pn, $matchedPlayers)) $matchedPlayers[] = $pn;
|
||||
$matchDebug[] = "$ip=>$loc ($pn)";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "\n=== PHP Match Result ===\n";
|
||||
echo "Matched players: " . count($matchedPlayers) . "\n";
|
||||
foreach ($matchedPlayers as $pn) {
|
||||
echo " $pn\n";
|
||||
}
|
||||
|
||||
// Check if any 长沙 IPs are NOT in the playerIpMap
|
||||
echo "\n--- All 长沙 IPs in cache ---\n";
|
||||
foreach ($ipLocMap as $ip => $loc) {
|
||||
if (mb_stripos($loc, $regionKey) !== false) {
|
||||
echo " $ip => $loc\n";
|
||||
// Check which player has this IP
|
||||
$foundPlayer = false;
|
||||
foreach ($playerIpMap as $pn => $ips) {
|
||||
if (in_array($ip, $ips)) {
|
||||
echo " Has player: $pn\n";
|
||||
$foundPlayer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$foundPlayer) {
|
||||
echo " NO player found with this IP!\n";
|
||||
// Try partial match
|
||||
foreach ($playerIpMap as $pn => $ips) {
|
||||
foreach ($ips as $pip) {
|
||||
if (strpos($ip, $pip) !== false || strpos($pip, $ip) !== false) {
|
||||
echo " Partial match: $pip (player: $pn)\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db->close();
|
||||
|
||||
} elseif ($action === 'upload' || (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK)) {
|
||||
// 通过 POST 上传文件
|
||||
// 支持 target= 和 path= 两种参数名
|
||||
$target = $_POST['target'] ?? $_POST['path'] ?? '';
|
||||
$allowed = ['api/admin.php', 'api/sync.php', 'api/cdk.php', 'api/ticket.php', 'api/register.php', 'api/shop.php', 'api/balance.php', 'core.php', 'config.php', 'player.php', 'admin.php', 'service.php', 'upload_via_http.php'];
|
||||
|
||||
if (!in_array($target, $allowed)) {
|
||||
echo "ERROR: Target not allowed: $target\nAllowed: " . implode(', ', $allowed) . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$targetPath = __DIR__ . '/' . $target;
|
||||
|
||||
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$content = file_get_contents($_FILES['file']['tmp_name']);
|
||||
} elseif (isset($_POST['content'])) {
|
||||
$content = base64_decode($_POST['content']);
|
||||
} else {
|
||||
$content = file_get_contents('php://input');
|
||||
}
|
||||
|
||||
if (empty($content)) {
|
||||
echo "ERROR: No content\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (file_exists($targetPath)) {
|
||||
copy($targetPath, $targetPath . '.bak');
|
||||
}
|
||||
|
||||
$bytes = file_put_contents($targetPath, $content);
|
||||
echo "Wrote $bytes bytes to $target\n";
|
||||
echo "MD5: " . md5_file($targetPath) . "\n";
|
||||
|
||||
if (function_exists('opcache_invalidate')) {
|
||||
@opcache_invalidate($targetPath, true);
|
||||
}
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
}
|
||||
echo "OPCache cleared\n";
|
||||
}
|
||||
|
||||
echo "\nDone.\n";
|
||||
@ob_end_flush();
|
||||
?>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
// 版本标记文件 - 用于验证上传是否成功
|
||||
header('Content-Type: text/plain');
|
||||
echo "admin.php version: 2026-06-19-1527\n";
|
||||
echo "File size: " . filesize(__DIR__ . '/api/admin.php') . " bytes\n";
|
||||
echo "MD5: " . md5_file(__DIR__ . '/api/admin.php') . "\n";
|
||||
echo "Time: " . date('Y-m-d H:i:s') . "\n";
|
||||
?>
|
||||
Reference in New Issue
Block a user