- 根因: Java端JSON body发送"pwd_verified":1(整数) → PHP json_decode解析为整数1
→ === '1'严格字符串比较 → 整数1 !== 字符串'1' → $pwdVerified永远false
→ 续14修复无效(条件改对了但值永远是false)
- 修复: (string)getParam('pwd_verified','0') === '1'
5953 lines
266 KiB
PHP
5953 lines
266 KiB
PHP
<?php
|
||
// ★ 同步API - 插件与Web端数据同步
|
||
// ★ Version: 2026-06-22-v2 - added require_once guard + removed opcache_reset
|
||
// ★ 防止任何输出污染JSON响应
|
||
ob_start();
|
||
// 仅刷新当前文件的OPcache(不要调用opcache_reset——它会重置整个服务器缓存,导致并发竞态条件)
|
||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||
// 清理前置输出
|
||
while (ob_get_level() > 0) {
|
||
ob_end_clean();
|
||
}
|
||
ob_start();
|
||
|
||
// ★ 安全检查:确保core.php存在,避免require_once失败时输出HTML错误
|
||
$coreFile = __DIR__ . '/../core.php';
|
||
if (!file_exists($coreFile)) {
|
||
while (ob_get_level() > 0) ob_end_clean();
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
http_response_code(500);
|
||
echo json_encode(['success' => false, 'message' => 'core.php not found: ' . $coreFile], JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
// ★ 设置错误处理器:防止require_once失败时输出HTML
|
||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||
@error_log("[sync.php PRE-LOAD] $errstr in $errfile on line $errline");
|
||
return true; // 抑制输出
|
||
});
|
||
|
||
require_once $coreFile;
|
||
|
||
// ★ 恢复错误处理器 + 清理前置输出
|
||
restore_error_handler();
|
||
while (ob_get_level() > 0) {
|
||
ob_end_clean();
|
||
}
|
||
ob_start();
|
||
|
||
// ★ 全局错误/异常处理(确保错误也记录到日志)
|
||
set_error_handler(function($errno, $errstr, $errfile, $errline) {
|
||
@error_log("[sync.php ERROR] $errstr in $errfile on line $errline");
|
||
return true;
|
||
});
|
||
set_exception_handler(function($e) {
|
||
@error_log("[sync.php EXCEPTION] " . $e->getMessage() . " at " . $e->getTraceAsString());
|
||
while (ob_get_level() > 0) ob_end_clean();
|
||
echo json_encode(['success' => false, 'message' => 'PHP异常: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
});
|
||
|
||
// debugLog() 函数已在 core.php 中定义,无需重复声明
|
||
|
||
// ★ 人机验证码(2026-10-02):CF/VA 统一接入,见 captcha_guard.php
|
||
require_once __DIR__ . '/../captcha_guard.php';
|
||
|
||
// ===== SMTP邮件发送函数(已统一移至 core.php,通过 require_once core.php 复用,避免重复定义) =====
|
||
|
||
|
||
/**
|
||
* 检查玩家是否在 PHP 端已注册(供 Java 端回调查询)
|
||
* Java 端 PlayerJoinEvent 时调用此 action 拉取玩家数据
|
||
*/
|
||
function checkPlayerRegistered() {
|
||
$player = getParam('player', '');
|
||
$secret = getParam('secret', '');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
if (!$player) {
|
||
error('缺少玩家名称');
|
||
}
|
||
try {
|
||
$db = getDB();
|
||
// ★ 只查players_name,因为users表可能只有这两个字段
|
||
$stmt = $db->prepare("SELECT player_name, register_time, last_login_time, email, points, gift_stage, total_online_time FROM users WHERE LOWER(player_name)=LOWER(:name) LIMIT 1");
|
||
$stmt->bindValue(':name', mb_strtolower($player, 'UTF-8'), SQLITE3_TEXT);
|
||
$res = $stmt->execute();
|
||
$row = $res->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) {
|
||
success(['registered' => false, 'message' => 'not_registered']);
|
||
}
|
||
// 玩家已注册,返回完整数据
|
||
$userInfo = [
|
||
'registered' => true,
|
||
'player_name' => $row['player_name'],
|
||
'password_hash' => '', // users表无密码字段,留空
|
||
'salt' => '',
|
||
'email' => $row['email'] ?? null,
|
||
'register_time' => $row['register_time'] ?? 0,
|
||
'last_login_time' => $row['last_login_time'] ?? 0,
|
||
'points' => $row['points'] ?? 0,
|
||
'gift_stage' => $row['gift_stage'] ?? 0,
|
||
'total_online_time' => $row['total_online_time'] ?? 0,
|
||
'ip_address' => null,
|
||
];
|
||
success($userInfo);
|
||
} catch (\Throwable $e) {
|
||
error('查询失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
$action = getParam('action', '');
|
||
|
||
// ★ 数据安全护栏(2026-10-10):任一侧处于块状态 → 拒绝一切数据库操作与握手(含对账),
|
||
// 给 Java 明确失败回执;等管理员修好 sql.md 数据库配置后自动恢复放行。
|
||
if (function_exists('dfGuardBlockedSides')) {
|
||
$dfBlocked = dfGuardBlockedSides();
|
||
if ($dfBlocked) {
|
||
while (ob_get_level() > 0) ob_end_clean();
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
http_response_code(503);
|
||
echo json_encode(array(
|
||
'success' => false,
|
||
'guard' => 'blocked',
|
||
'message' => '数据库安全护栏:' . implode(';', $dfBlocked)
|
||
. ' 已暂停对该数据库的全部操作并拒绝握手,请管理员检查 sql.md 数据库配置段。',
|
||
), JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
}
|
||
|
||
try {
|
||
switch ($action) {
|
||
case 'sync_shop':
|
||
syncShop();
|
||
break;
|
||
case 'sync_service_providers':
|
||
syncServiceProviders();
|
||
break;
|
||
case 'sync_bonds':
|
||
syncBonds();
|
||
break;
|
||
case 'sync_login':
|
||
syncLogin();
|
||
break;
|
||
case 'sync_online_players':
|
||
syncOnlinePlayers();
|
||
break;
|
||
// ★ 2026-10-06 在线保活心跳:名单无变化时 Java 走这条轻量出口刷 last_seen,
|
||
// 否则 admin 后台会因为心跳超 120 秒而强制显示 0 人。
|
||
case 'online_heartbeat':
|
||
onlineHeartbeat();
|
||
break;
|
||
// ★ 2026-10-06 充值回执补推(一式两份 · 多退少补):Java 发现 PHP 侧 web_transactions
|
||
// 缺了它手上的镜像流水时,POST 这里重建。跟随全量同步调度器热同步。
|
||
case 'sync_webtx_receipts':
|
||
syncWebTxReceipts();
|
||
break;
|
||
case 'sync_daily_logins':
|
||
syncDailyLogins();
|
||
break;
|
||
case 'sync_checkins':
|
||
syncCheckins();
|
||
break;
|
||
case 'sync_player_ips':
|
||
syncPlayerIps();
|
||
break;
|
||
case 'pull_shop':
|
||
pullShop();
|
||
break;
|
||
case 'pull_bonds':
|
||
pullBonds();
|
||
break;
|
||
case 'sync_weblogin_token':
|
||
syncWebloginToken();
|
||
break;
|
||
case 'validate_weblogin_token':
|
||
apiValidateWebloginToken();
|
||
break;
|
||
case 'push_player_login_status':
|
||
pushPlayerLoginStatus();
|
||
break;
|
||
case 'revoke_weblogin_token':
|
||
revokeWebloginToken();
|
||
break;
|
||
case 'sync_token':
|
||
syncToken();
|
||
break;
|
||
case 'receive_token':
|
||
receiveToken();
|
||
break;
|
||
case 'check_web_login_confirmations':
|
||
checkWebLoginConfirmations();
|
||
break;
|
||
case 'check_web_login_verified':
|
||
checkWebLoginVerified();
|
||
break;
|
||
case 'web_login_request':
|
||
webLoginRequest();
|
||
break;
|
||
case 'cancel_web_login':
|
||
cancelWebLogin();
|
||
break;
|
||
case 'check_web_login_result':
|
||
checkWebLoginResult();
|
||
break;
|
||
case 'check_pending_web_logins':
|
||
checkPendingWebLogins();
|
||
break;
|
||
case 'complete_web_login_request':
|
||
completeWebLoginRequest();
|
||
break;
|
||
case 'clear_pending_web_logins':
|
||
clearPendingWebLogins();
|
||
break;
|
||
case 'push_web_credentials':
|
||
pushWebCredentials();
|
||
break;
|
||
case 'verify_web_password':
|
||
// ★ 安全修复:禁止PHP自验证密码,必须走web_login_request流程
|
||
error('安全限制: 密码验证必须通过游戏服务器,请使用web_login_request接口');
|
||
break;
|
||
case 'web_access_check':
|
||
webAccessCheck();
|
||
break;
|
||
case 'pull_pending_transactions':
|
||
pullPendingTransactions();
|
||
break;
|
||
case 'ack_transaction':
|
||
ackTransaction();
|
||
break;
|
||
case 'confirm_transaction':
|
||
confirmTransaction();
|
||
break;
|
||
case 'pull_shop_stock':
|
||
pullShopStock();
|
||
break;
|
||
case 'push_shop_stock':
|
||
pushShopStock();
|
||
break;
|
||
case 'clear_admin_stock':
|
||
clearAdminStock();
|
||
break;
|
||
case 'check_shop_stock_changed':
|
||
checkShopStockChanged();
|
||
break;
|
||
case 'pull_shop_prices':
|
||
pullShopPrices();
|
||
break;
|
||
case 'get_shop_config':
|
||
getShopConfigSync();
|
||
break;
|
||
case 'set_shop_config':
|
||
setShopConfigSync();
|
||
break;
|
||
case 'set_shop_catalog':
|
||
setShopCatalogSync();
|
||
break;
|
||
case 'clear_admin_prices':
|
||
clearAdminPrices();
|
||
break;
|
||
case 'write_shop_refund':
|
||
writeShopRefund();
|
||
break;
|
||
case 'check_shop_prices_changed':
|
||
checkShopPricesChanged();
|
||
break;
|
||
case 'notify_sync':
|
||
notifySync();
|
||
break;
|
||
case 'request_immediate_sync':
|
||
requestImmediateSync();
|
||
break;
|
||
case 'resend_pending':
|
||
resendPendingTransactions();
|
||
break;
|
||
case 'send_email_code':
|
||
sendEmailCode();
|
||
break;
|
||
case 'verify_email_code':
|
||
verifyEmailCode();
|
||
break;
|
||
case 'get_player_email':
|
||
getPlayerEmail();
|
||
break;
|
||
case 'fix_stock':
|
||
fixStock();
|
||
break;
|
||
case 'fix_timestamps':
|
||
fixTimestamps();
|
||
break;
|
||
case 'send_reset_password_link':
|
||
sendResetPasswordLink();
|
||
break;
|
||
case 'reset_password':
|
||
resetPassword();
|
||
break;
|
||
case 'admin_list_users':
|
||
adminListUsers();
|
||
break;
|
||
case 'admin_send_reset_link':
|
||
adminSendResetLink();
|
||
break;
|
||
case 'extend_web_token':
|
||
extendWebToken();
|
||
break;
|
||
case 'change_password':
|
||
changePassword();
|
||
break;
|
||
case 'check_pending_web_register_requests':
|
||
checkPendingWebRegisterRequests();
|
||
break;
|
||
case 'complete_web_register_request':
|
||
completeWebRegisterRequest();
|
||
break;
|
||
case 'check_pending_web_email_bind_requests':
|
||
checkPendingWebEmailBindRequests();
|
||
break;
|
||
case 'web_email_bind_result':
|
||
webEmailBindResult();
|
||
break;
|
||
case 'delete_user':
|
||
deleteUser();
|
||
break;
|
||
case 'sync_transactions':
|
||
syncTransactions();
|
||
break;
|
||
case 'check_tx_alignment':
|
||
checkTxAlignment();
|
||
break;
|
||
case 'pull_tx':
|
||
pullTx();
|
||
break;
|
||
case 'check_alignment':
|
||
checkAlignment();
|
||
break;
|
||
case 'check_pending_transactions':
|
||
checkPendingTransactions();
|
||
break;
|
||
case 'validate_cdk':
|
||
validateCdk();
|
||
break;
|
||
case 'check_cdk_exists':
|
||
checkCdkExists();
|
||
break;
|
||
case 'cdk_redeem_remote':
|
||
cdkRedeemRemote();
|
||
break;
|
||
case 'pull_cdk_validate_requests':
|
||
pullCdkValidateRequests();
|
||
break;
|
||
case 'migrate_cdk':
|
||
migrateCdkTable();
|
||
break;
|
||
case 'push_cdk_validate_result':
|
||
pushCdkValidateResult();
|
||
break;
|
||
case 'sync_lands':
|
||
syncLands();
|
||
break;
|
||
case 'sync_land_shop':
|
||
syncLandShop();
|
||
break;
|
||
case 'sync_config':
|
||
syncConfig();
|
||
break;
|
||
case 'sync_permissions':
|
||
syncPermissions();
|
||
break;
|
||
case 'sync_user_groups':
|
||
syncUserGroups();
|
||
break;
|
||
case 'fix_db_permissions':
|
||
fixDbPermissions();
|
||
break;
|
||
case 'check_player_registered':
|
||
checkPlayerRegistered();
|
||
break;
|
||
case 'sync_admins':
|
||
syncAdmins();
|
||
break;
|
||
case 'sync_bans':
|
||
syncBans();
|
||
|
||
// ★ 第57批:确认解封——清空PHP端web_player_bans,不写回banned-players.json
|
||
case 'clear_bans':
|
||
clearBans();
|
||
break;
|
||
break;
|
||
// ★ 第32批:拉黑名单同步(Java BlacklistManager → web_player_blacklist)
|
||
case 'sync_blacklist':
|
||
syncBlacklist();
|
||
break;
|
||
// ★ 第十六批:公开封禁公示(玩家端首页卡片数据源,无需SECRET,限流保护)
|
||
case 'get_ban_board':
|
||
getBanBoard();
|
||
break;
|
||
default:
|
||
error('未知操作: ' . $action);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
while (ob_get_level() > 0) ob_end_clean();
|
||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||
}
|
||
|
||
// ===== 插件推送商品数据 =====
|
||
function syncShop() {
|
||
// ★ 支持token或SECRET_KEY认证
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$items = getParam('items');
|
||
if (!$items || !is_array($items)) {
|
||
error('缺少items数据');
|
||
}
|
||
|
||
$db = getDB();
|
||
$now = time();
|
||
$count = 0;
|
||
|
||
foreach ($items as $item) {
|
||
$id = $item['id'] ?? null;
|
||
if (!$id) continue;
|
||
|
||
// 游戏端stock=0表示无限供应,Web端用-1表示无限
|
||
$stock = (int)($item['stock'] ?? -1);
|
||
// 仅≤-2的未定义值转为-1(无限),0和正数保留原值
|
||
|
||
// ★ 检查该商品是否有管理员库存改动(admin_stock非NULL)
|
||
$checkStmt = $db->prepare("SELECT admin_stock FROM shop_items WHERE id = :id");
|
||
$checkStmt->bindValue(':id', $id, SQLITE3_TEXT);
|
||
$checkResult = $checkStmt->execute();
|
||
$checkRow = $checkResult->fetchArray(SQLITE3_ASSOC);
|
||
$hasAdminStock = $checkRow && $checkRow['admin_stock'] !== null && $checkRow['admin_stock'] !== '';
|
||
|
||
if ($hasAdminStock) {
|
||
// ★ 管理员修改过库存:跳过stock字段,只更新其他字段
|
||
$stmt = $db->prepare("INSERT INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync) VALUES (:id, :cat, :name, :mat, :bp, :sp, :st, :hs, :ts, :time) ON CONFLICT(id) DO UPDATE SET category=:cat, display_name=:name, material=:mat, buy_price=:bp, sell_price=:sp, hourly_sales=:hs, total_sales=:ts, last_sync=:time");
|
||
} else {
|
||
// ★ 正常:更新所有字段包括stock
|
||
$stmt = $db->prepare("INSERT INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync) VALUES (:id, :cat, :name, :mat, :bp, :sp, :st, :hs, :ts, :time) ON CONFLICT(id) DO UPDATE SET category=:cat, display_name=:name, material=:mat, buy_price=:bp, sell_price=:sp, stock=:st, hourly_sales=:hs, total_sales=:ts, last_sync=:time");
|
||
}
|
||
$stmt->bindValue(':id', $id, SQLITE3_TEXT);
|
||
$stmt->bindValue(':cat', $item['category'] ?? '默认', SQLITE3_TEXT);
|
||
$stmt->bindValue(':name', $item['display_name'] ?? $id, SQLITE3_TEXT);
|
||
$stmt->bindValue(':mat', $item['material'] ?? 'STONE', SQLITE3_TEXT);
|
||
$stmt->bindValue(':bp', (int)($item['buy_price'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':sp', (int)($item['sell_price'] ?? -1), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':st', $stock, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':hs', (int)($item['hourly_sales'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':ts', (int)($item['total_sales'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
|
||
success(['synced' => $count], "同步了{$count}个商品");
|
||
}
|
||
|
||
// ===== 插件推送服务商列表 =====
|
||
function syncServiceProviders() {
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('缺少认证信息');
|
||
}
|
||
|
||
$db = getDB();
|
||
$data = getParam('data'); // 可能是JSON字符串或已解析数组(从php://input)
|
||
|
||
if (!$data) error('缺少data参数');
|
||
|
||
// getParam从php://input解析JSON body时可能直接返回数组
|
||
$providers = is_array($data) ? $data : json_decode($data, true);
|
||
if (!$providers || !is_array($providers)) error('data格式无效');
|
||
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
// 全量替换
|
||
$db->exec("DELETE FROM web_service_providers");
|
||
$stmt = $db->prepare("INSERT INTO web_service_providers (player_name, role, active, join_time) VALUES (:p, :r, :a, :t)");
|
||
$count = 0;
|
||
foreach ($providers as $p) {
|
||
$stmt->bindValue(':p', $p['player_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':r', $p['role'] ?? 'waiter', SQLITE3_TEXT);
|
||
$stmt->bindValue(':a', $p['active'] ?? 1, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':t', $p['join_time'] ?? 0, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
$db->exec("COMMIT");
|
||
debugLog("syncServiceProviders: 同步了 $count 个服务商");
|
||
success(['count' => $count], "同步了 $count 个服务商");
|
||
} catch (\Throwable $e) {
|
||
$db->exec("ROLLBACK");
|
||
error('同步失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 领地数据同步 =====
|
||
function syncLands() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
|
||
$landsRaw = getParam('lands');
|
||
if (!$landsRaw) error('缺少lands参数');
|
||
$lands = is_array($landsRaw) ? $landsRaw : json_decode($landsRaw, true);
|
||
if (!is_array($lands)) error('lands格式无效');
|
||
|
||
$db = getDB();
|
||
// 建表
|
||
$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
|
||
)");
|
||
|
||
// ★ 效果迁移
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN clear_effects TEXT DEFAULT ''"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN give_effects TEXT DEFAULT ''"); } catch (\Throwable $e) {}
|
||
// ★ 2026-10-04:负面效果从 give_effects 拆出,负面对应独立字段
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN bad_effects TEXT DEFAULT ''"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN clear_all_bad_effects INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_all_effects INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN admin_changed INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
// ★ 新增权限列迁移(与Java area_lands表完整对齐)
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_mount INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_ender_pearl INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_bow INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_potion INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_fire INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_raid INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_container INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_mob_attack INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_spawn_egg INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_wax INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN is_public_building INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN allow_visitor_teleport INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN confiscate_items TEXT DEFAULT ''"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN deny_use_items TEXT DEFAULT ''"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN punish_commands TEXT DEFAULT ''"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN clear_all_bad INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
// ★ 2026-10-05:老库建表早于 y_min/y_max/synced_at 引入,CREATE TABLE IF NOT EXISTS 不会补齐老表,
|
||
// 而 INSERT OR REPLACE 却要用这三列 → 领地同步 500 (table web_area_lands has no column named y_min)。迁移必须显式补列。
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN y_min INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN y_max INTEGER DEFAULT 255"); } catch (\Throwable $e) {}
|
||
try { $db->exec("ALTER TABLE web_area_lands ADD COLUMN synced_at INTEGER DEFAULT 0"); } catch (\Throwable $e) {}
|
||
$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,
|
||
peace_mode, peace_mode_duration, peace_whitelist, enforce_game_mode, mode_exempt,
|
||
enter_msg, leave_msg, confiscate_msg, enable_announce, announce_template, txt_content,
|
||
deny_block_break, deny_block_place, deny_sign_edit, deny_fluid, deny_pvp, deny_fire_spread, deny_all_effects,
|
||
deny_item_frame, deny_entity_interact, deny_move, deny_pickup, deny_drop, deny_explosion, deny_fall_damage, deny_hunger,
|
||
deny_all_damage, clear_effects, give_effects, bad_effects, clear_all_bad,
|
||
admin_changed, deny_thrown_projectiles, deny_glowing, deny_redstone_interaction, deny_door_interaction,
|
||
deny_noteblock_jukebox, deny_lead, deny_crop_harvest, deny_wool_shear, deny_animal_feeding, deny_spawn_egg, deny_wax,
|
||
warp_x, warp_y, warp_z, warp_yaw, warp_pitch, warp_world,
|
||
deny_mount, deny_ender_pearl, deny_bow, deny_potion, deny_fire, deny_raid,
|
||
deny_container, deny_mob_attack, is_public_building, allow_visitor_teleport,
|
||
confiscate_items, deny_use_items, punish_commands, clear_all_bad_effects)
|
||
VALUES (:id, :name, :owner, :world, :x1, :z1, :x2, :z2, :ymin, :ymax, :size, :created, :synced,
|
||
:peace_mode, :peace_dur, :peace_wl, :enforce_gm, :mode_exempt,
|
||
:enter_msg, :leave_msg, :confiscate_msg, :announce, :announce_tpl, :txt_content,
|
||
:deny_block_break, :deny_block_place, :deny_sign_edit, :deny_fluid, :deny_pvp, :deny_fire_spread, :deny_all_effects,
|
||
:deny_item_frame, :deny_entity_interact, :deny_move, :deny_pickup, :deny_drop, :deny_explosion, :deny_fall_damage, :deny_hunger,
|
||
:deny_all_damage, :clear_effects, :give_effects, :bad_effects, :clear_all_bad,
|
||
:admin_changed, :deny_thrown_projectiles, :deny_glowing, :deny_redstone_interaction, :deny_door_interaction,
|
||
:deny_noteblock_jukebox, :deny_lead, :deny_crop_harvest, :deny_wool_shear, :deny_animal_feeding, :deny_spawn_egg, :deny_wax,
|
||
:warp_x, :warp_y, :warp_z, :warp_yaw, :warp_pitch, :warp_world,
|
||
:deny_mount, :deny_ender_pearl, :deny_bow, :deny_potion, :deny_fire, :deny_raid,
|
||
:deny_container, :deny_mob_attack, :is_public_building, :allow_visitor_teleport,
|
||
:confiscate_items, :deny_use_items, :punish_commands, :clear_all_bad_effects)");
|
||
$count = 0;
|
||
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->bindValue(':peace_mode', (int)($land['peace_mode'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':peace_dur', (int)($land['peace_mode_duration'] ?? 5), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':peace_wl', $land['peace_whitelist'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':enforce_gm', $land['enforce_game_mode'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':mode_exempt', $land['mode_exempt'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':enter_msg', $land['enter_msg'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':leave_msg', $land['leave_msg'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':confiscate_msg', $land['confiscate_msg'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':announce', (int)($land['enable_announce'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':announce_tpl', $land['announce_template'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':txt_content', $land['txt_content'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':deny_block_break', (int)($land['deny_block_break'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_block_place', (int)($land['deny_block_place'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_sign_edit', (int)($land['deny_sign_edit'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_fluid', (int)($land['deny_fluid'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_pvp', (int)($land['deny_pvp'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_fire_spread', (int)($land['deny_fire_spread'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_all_effects', (int)($land['deny_all_effects'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_item_frame', (int)($land['deny_item_frame'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_entity_interact', (int)($land['deny_entity_interact'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_move', (int)($land['deny_move'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_pickup', (int)($land['deny_pickup'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_drop', (int)($land['deny_drop'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_explosion', (int)($land['deny_explosion'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_fall_damage', (int)($land['deny_fall_damage'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_hunger', (int)($land['deny_hunger'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_all_damage', (int)($land['deny_all_damage'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':clear_effects', $land['clear_effects'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':give_effects', $land['give_effects'] ?? '', SQLITE3_TEXT);
|
||
// ★ 2026-10-04:负面效果独立存储
|
||
$stmt->bindValue(':bad_effects', $land['bad_effects'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':clear_all_bad_effects', (int)($land['clear_all_bad_effects'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_all_effects', (int)($land['deny_all_effects'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':admin_changed', (int)($land['admin_changed'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_thrown_projectiles', (int)($land['deny_thrown_projectiles'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_glowing', (int)($land['deny_glowing'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_redstone_interaction', (int)($land['deny_redstone_interaction'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_door_interaction', (int)($land['deny_door_interaction'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_noteblock_jukebox', (int)($land['deny_noteblock_jukebox'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_lead', (int)($land['deny_lead'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_crop_harvest', (int)($land['deny_crop_harvest'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_wool_shear', (int)($land['deny_wool_shear'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_animal_feeding', (int)($land['deny_animal_feeding'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_spawn_egg', (int)($land['deny_spawn_egg'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_wax', (int)($land['deny_wax'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':warp_x', (double)($land['warp_x'] ?? 0), SQLITE3_FLOAT);
|
||
$stmt->bindValue(':warp_y', (double)($land['warp_y'] ?? 0), SQLITE3_FLOAT);
|
||
$stmt->bindValue(':warp_z', (double)($land['warp_z'] ?? 0), SQLITE3_FLOAT);
|
||
$stmt->bindValue(':warp_yaw', (float)($land['warp_yaw'] ?? 0), SQLITE3_FLOAT);
|
||
$stmt->bindValue(':warp_pitch', (float)($land['warp_pitch'] ?? 0), SQLITE3_FLOAT);
|
||
$stmt->bindValue(':warp_world', $land['warp_world'] ?? '', SQLITE3_TEXT);
|
||
// ★ 新增字段绑定(与Java area_lands表完整对齐)
|
||
$stmt->bindValue(':deny_mount', (int)($land['deny_mount'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_ender_pearl', (int)($land['deny_ender_pearl'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_bow', (int)($land['deny_bow'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_potion', (int)($land['deny_potion'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_fire', (int)($land['deny_fire'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_raid', (int)($land['deny_raid'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_container', (int)($land['deny_container'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':deny_mob_attack', (int)($land['deny_mob_attack'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':is_public_building', (int)($land['is_public_building'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':allow_visitor_teleport', (int)($land['allow_visitor_teleport'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':confiscate_items', $land['confiscate_items'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':deny_use_items', $land['deny_use_items'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':punish_commands', $land['punish_commands'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':clear_all_bad', (int)($land['clear_all_bad'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
|
||
// ★ 删除PHP中存在但Java同步列表中不存在的领地(解决Java删除后PHP残留问题)
|
||
$incomingNames = [];
|
||
foreach ($lands as $l) {
|
||
$n = $l['name'] ?? '';
|
||
if (!empty($n)) $incomingNames[] = $n;
|
||
}
|
||
$deletedCount = 0;
|
||
if (!empty($incomingNames)) {
|
||
$placeholders = implode(',', array_fill(0, count($incomingNames), '?'));
|
||
$delStmt = $db->prepare("DELETE FROM web_area_lands WHERE name NOT IN ($placeholders)");
|
||
foreach ($incomingNames as $i => $n) {
|
||
$delStmt->bindValue($i + 1, $n, SQLITE3_TEXT);
|
||
}
|
||
$delStmt->execute();
|
||
$deletedCount = $db->changes();
|
||
} else {
|
||
// Java同步列表为空,全删
|
||
$db->exec("DELETE FROM web_area_lands");
|
||
$deletedCount = $db->changes();
|
||
}
|
||
|
||
$msg = "领地同步成功: {$count}个";
|
||
if ($deletedCount > 0) {
|
||
$msg .= ", 清理PHP残留: {$deletedCount}个";
|
||
}
|
||
success($msg);
|
||
}
|
||
|
||
// ===== 领地权限商店同步 =====
|
||
function syncLandShop() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
|
||
$itemsRaw = getParam('items');
|
||
if (!$itemsRaw) error('缺少items参数');
|
||
$items = is_array($itemsRaw) ? $itemsRaw : json_decode($itemsRaw, true);
|
||
if (!is_array($items)) error('items格式无效');
|
||
|
||
$db = getDB();
|
||
$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
|
||
)");
|
||
|
||
$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)");
|
||
$count = 0;
|
||
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();
|
||
$count++;
|
||
}
|
||
success("权限商店同步成功: {$count}个");
|
||
}
|
||
|
||
// ===== 插件推送全局配置 =====
|
||
function syncConfig() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
|
||
$configRaw = getParam('config');
|
||
if (!$configRaw) error('缺少config参数');
|
||
$config = json_decode($configRaw, true);
|
||
if (!is_array($config)) error('config格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_config (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT DEFAULT ''
|
||
)");
|
||
|
||
$now = time();
|
||
$count = 0;
|
||
foreach ($config as $key => $val) {
|
||
// ★ 去除前导零:01 → 1,防止静默失败
|
||
if (is_numeric($val)) $val = (string)(int)$val;
|
||
$stmt = $db->prepare("INSERT INTO web_area_config (key, value) VALUES (:key, :val) "
|
||
. "ON CONFLICT(key) DO UPDATE SET value = :val2");
|
||
$stmt->bindValue(':key', $key, SQLITE3_TEXT);
|
||
$stmt->bindValue(':val', $val, SQLITE3_TEXT);
|
||
$stmt->bindValue(':val2', $val, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
success("配置同步成功: {$count}项");
|
||
}
|
||
|
||
// ===== 插件推送访客权限数据 =====
|
||
function syncPermissions() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
|
||
$permsRaw = getParam('permissions');
|
||
if (!$permsRaw) error('缺少permissions参数');
|
||
$perms = is_array($permsRaw) ? $permsRaw : json_decode($permsRaw, true);
|
||
if (!is_array($perms)) error('permissions格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_permissions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
land_id INTEGER NOT NULL,
|
||
land_name TEXT DEFAULT '',
|
||
player_name TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'visitor',
|
||
permissions TEXT DEFAULT '',
|
||
granted_at INTEGER DEFAULT 0,
|
||
expires_at INTEGER DEFAULT 0,
|
||
synced_at INTEGER DEFAULT 0,
|
||
UNIQUE(land_id, player_name)
|
||
)");
|
||
|
||
$now = time();
|
||
// ★ 用 ON CONFLICT 替代 INSERT OR REPLACE,避免角色降级:
|
||
// 如果已有管理员/成员角色,不接受被降级为访客
|
||
$stmt = $db->prepare("INSERT INTO web_area_permissions
|
||
(land_id, land_name, player_name, role, permissions, granted_at, expires_at, synced_at)
|
||
VALUES (:land_id, :land_name, :player, :role, :perms, :granted, :expires, :synced)
|
||
ON CONFLICT(land_id, player_name) DO UPDATE SET
|
||
land_name = excluded.land_name,
|
||
permissions = excluded.permissions,
|
||
granted_at = excluded.granted_at,
|
||
expires_at = excluded.expires_at,
|
||
synced_at = excluded.synced_at,
|
||
role = CASE
|
||
WHEN excluded.role IN ('admin', 'member') THEN excluded.role
|
||
WHEN excluded.role = 'visitor' AND web_area_permissions.role IN ('admin', 'member') THEN web_area_permissions.role
|
||
ELSE excluded.role
|
||
END");
|
||
$count = 0;
|
||
// 收集所有incoming的(land_id, player_name)对,用于后续删除清理
|
||
$incomingKeys = [];
|
||
foreach ($perms as $p) {
|
||
$lid = (int)($p['land_id'] ?? 0);
|
||
$player = $p['player_name'] ?? '';
|
||
$stmt->bindValue(':land_id', $lid, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':land_name', $p['land_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':role', $p['role'] ?? 'visitor', SQLITE3_TEXT);
|
||
$stmt->bindValue(':perms', $p['permissions'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':granted', (int)($p['granted_at'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':expires', (int)($p['expires_at'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
if ($lid > 0 && !empty($player)) {
|
||
$incomingKeys[$lid][] = $player;
|
||
}
|
||
}
|
||
|
||
// ★ 删除Java端已移除的成员(同步清理)
|
||
// 对于每个有incoming数据的land_id,删除不在incoming列表中的旧记录
|
||
// 但如果该玩家有待处理的PHP变更(add_visitor/perm_change),则跳过删除
|
||
// (防止PHP刚添加、Java还没处理就被删;同时避免旧120秒宽限期导致删除永远不生效)
|
||
$deleted = 0;
|
||
$skipped = 0;
|
||
// 先收集所有待处理的PHP变更的(land_name, player_name)对
|
||
$pendingChanges = [];
|
||
$pcStmt = $db->prepare("SELECT change_data FROM web_admin_changes WHERE change_type IN ('perm_change','add_visitor') AND acknowledged = 0");
|
||
$pcRes = $pcStmt->execute();
|
||
while ($pcRow = $pcRes->fetchArray(SQLITE3_ASSOC)) {
|
||
$pcData = json_decode($pcRow['change_data'] ?? '{}', true);
|
||
if (is_array($pcData)) {
|
||
$pcLand = $pcData['land_name'] ?? '';
|
||
$pcPlayer = $pcData['player'] ?? '';
|
||
if ($pcLand !== '' && $pcPlayer !== '') {
|
||
$pendingChanges[$pcLand][] = $pcPlayer;
|
||
}
|
||
}
|
||
}
|
||
foreach ($incomingKeys as $lid => $players) {
|
||
$placeholders = implode(',', array_fill(0, count($players), '?'));
|
||
// 查出待删除的玩家名单
|
||
$selStmt = $db->prepare("SELECT player_name, land_name FROM web_area_permissions
|
||
WHERE land_id = ? AND player_name NOT IN ($placeholders)");
|
||
$selStmt->bindValue(1, $lid, SQLITE3_INTEGER);
|
||
$idx = 2;
|
||
foreach ($players as $p) {
|
||
$selStmt->bindValue($idx++, $p, SQLITE3_TEXT);
|
||
}
|
||
$selRes = $selStmt->execute();
|
||
$toDelete = [];
|
||
while ($selRow = $selRes->fetchArray(SQLITE3_ASSOC)) {
|
||
$dPlayer = $selRow['player_name'];
|
||
$dLand = $selRow['land_name'] ?? '';
|
||
// 检查是否有待处理的PHP变更保护该玩家
|
||
$isProtected = false;
|
||
if (isset($pendingChanges[$dLand])) {
|
||
$isProtected = in_array($dPlayer, $pendingChanges[$dLand]);
|
||
}
|
||
if ($isProtected) {
|
||
$skipped++;
|
||
debugLog("[syncPermissions] 跳过删除(有待处理PHP变更)", ['land' => $dLand, 'player' => $dPlayer]);
|
||
} else {
|
||
$toDelete[] = $dPlayer;
|
||
}
|
||
}
|
||
if (!empty($toDelete)) {
|
||
$delPlaceholders = implode(',', array_fill(0, count($toDelete), '?'));
|
||
$delStmt = $db->prepare("DELETE FROM web_area_permissions
|
||
WHERE land_id = ? AND player_name IN ($delPlaceholders)");
|
||
$delStmt->bindValue(1, $lid, SQLITE3_INTEGER);
|
||
$dIdx = 2;
|
||
foreach ($toDelete as $dp) {
|
||
$delStmt->bindValue($dIdx++, $dp, SQLITE3_TEXT);
|
||
}
|
||
$delStmt->execute();
|
||
$deleted += $db->changes();
|
||
}
|
||
}
|
||
|
||
// ★ 清理与Java同步状态冲突的待处理PHP变更(防止 stale pending 覆盖Java最新状态)
|
||
// Java是角色/权限的权威源之一;当Java推送最新状态时,Ack掉PHP端尚未被Java轮询到的、
|
||
// 且与Java状态冲突的 pending perm_change / add_visitor 记录。
|
||
$conflictCleaned = 0;
|
||
$pendingStmt = $db->prepare("SELECT id, change_type, target_name, change_data, created_at FROM web_admin_changes WHERE change_type IN ('perm_change','add_visitor') AND acknowledged = 0 AND created_at < :now");
|
||
$pendingStmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||
$res = $pendingStmt->execute();
|
||
$toAck = [];
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$data = json_decode($row['change_data'] ?? '{}', true);
|
||
if (!is_array($data)) continue;
|
||
$cLand = $data['land_name'] ?? '';
|
||
$cPlayer = $data['player'] ?? $row['target_name'] ?? '';
|
||
$cRole = $data['role'] ?? '';
|
||
if ($cLand === '' || $cPlayer === '' || $cRole === '') continue;
|
||
// 查找对应incoming记录
|
||
$matchedRole = null;
|
||
foreach ($perms as $p) {
|
||
if (($p['land_name'] ?? '') === $cLand && ($p['player_name'] ?? '') === $cPlayer) {
|
||
$matchedRole = $p['role'] ?? 'visitor';
|
||
break;
|
||
}
|
||
}
|
||
if ($matchedRole !== null && $cRole !== $matchedRole) {
|
||
$toAck[] = (int)$row['id'];
|
||
debugLog("[syncPermissions] 清理冲突pending变更", [
|
||
'id' => $row['id'],
|
||
'type' => $row['change_type'],
|
||
'land' => $cLand,
|
||
'player' => $cPlayer,
|
||
'php_role' => $cRole,
|
||
'java_role' => $matchedRole
|
||
]);
|
||
}
|
||
}
|
||
if (!empty($toAck)) {
|
||
$placeholders = implode(',', array_fill(0, count($toAck), '?'));
|
||
$ackStmt = $db->prepare("UPDATE web_admin_changes SET acknowledged = 1, acked_at = :now WHERE id IN ($placeholders)");
|
||
$ackStmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||
$idx = 1;
|
||
foreach ($toAck as $id) {
|
||
$ackStmt->bindValue($idx++, $id, SQLITE3_INTEGER);
|
||
}
|
||
$ackStmt->execute();
|
||
$conflictCleaned = count($toAck);
|
||
}
|
||
|
||
$msg = "访客权限同步成功: {$count}个";
|
||
if ($deleted > 0) $msg .= ", 清理{$deleted}个已移除成员";
|
||
if ($skipped > 0) $msg .= ", 跳过{$skipped}个待处理变更保护";
|
||
if ($conflictCleaned > 0) $msg .= ", 忽略{$conflictCleaned}个冲突待处理变更";
|
||
success($msg);
|
||
}
|
||
|
||
// ===== 用户组同步 =====
|
||
function syncUserGroups() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
|
||
$groupsRaw = getParam('groups');
|
||
if (!$groupsRaw) error('缺少groups参数');
|
||
$groups = is_array($groupsRaw) ? $groupsRaw : json_decode($groupsRaw, true);
|
||
if (!is_array($groups)) error('groups格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_user_groups (
|
||
group_name TEXT PRIMARY KEY,
|
||
display_name TEXT DEFAULT '',
|
||
display_color TEXT DEFAULT '§f',
|
||
display_emoji TEXT DEFAULT '',
|
||
priority INTEGER DEFAULT 0,
|
||
land_price_per_sqm INTEGER DEFAULT -1,
|
||
max_lands INTEGER DEFAULT -1,
|
||
max_effects INTEGER DEFAULT 5,
|
||
max_effect_level INTEGER DEFAULT 256,
|
||
home_limit INTEGER DEFAULT 0,
|
||
join_price INTEGER DEFAULT 0,
|
||
auto_renew INTEGER DEFAULT 0,
|
||
renew_price INTEGER DEFAULT 0,
|
||
duration_minutes INTEGER DEFAULT 0,
|
||
default_perms TEXT DEFAULT '{}',
|
||
shop_max_listings INTEGER DEFAULT -1,
|
||
shop_min_price INTEGER DEFAULT -1,
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 迁移:添加新列(如果缺失)
|
||
$columns = [];
|
||
$rs = $db->query("PRAGMA table_info(web_user_groups)");
|
||
while ($row = $rs->fetchArray(SQLITE3_ASSOC)) {
|
||
$columns[] = $row['name'];
|
||
}
|
||
$migrations = [
|
||
'home_limit' => 'ALTER TABLE web_user_groups ADD COLUMN home_limit INTEGER DEFAULT 0',
|
||
'join_price' => 'ALTER TABLE web_user_groups ADD COLUMN join_price INTEGER DEFAULT 0',
|
||
'auto_renew' => 'ALTER TABLE web_user_groups ADD COLUMN auto_renew INTEGER DEFAULT 0',
|
||
'renew_price' => 'ALTER TABLE web_user_groups ADD COLUMN renew_price INTEGER DEFAULT 0',
|
||
'duration_minutes' => 'ALTER TABLE web_user_groups ADD COLUMN duration_minutes INTEGER DEFAULT 0',
|
||
'max_effects' => 'ALTER TABLE web_user_groups ADD COLUMN max_effects INTEGER DEFAULT 5',
|
||
'max_effect_level' => 'ALTER TABLE web_user_groups ADD COLUMN max_effect_level INTEGER DEFAULT 256',
|
||
'shop_max_listings' => 'ALTER TABLE web_user_groups ADD COLUMN shop_max_listings INTEGER DEFAULT -1',
|
||
'shop_min_price' => 'ALTER TABLE web_user_groups ADD COLUMN shop_min_price INTEGER DEFAULT -1',
|
||
];
|
||
foreach ($migrations as $col => $sql) {
|
||
if (!in_array($col, $columns)) {
|
||
try { $db->exec($sql); } catch (\Throwable $e) { /* 已存在 */ }
|
||
}
|
||
}
|
||
|
||
$now = time();
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_user_groups
|
||
(group_name, display_name, display_color, display_emoji, priority,
|
||
land_price_per_sqm, max_lands, max_effects, max_effect_level, home_limit, join_price, auto_renew,
|
||
renew_price, duration_minutes, default_perms, shop_max_listings, shop_min_price, synced_at)
|
||
VALUES (:name, :display, :color, :emoji, :priority,
|
||
:price, :maxlands, :maxeffects, :maxeffectlevel, :homelimit, :joinprice, :autorenew,
|
||
:renewprice, :duration, :perms, :shopmax, :shopmin, :synced)");
|
||
$count = 0;
|
||
foreach ($groups as $g) {
|
||
$stmt->bindValue(':name', $g['group_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':display', $g['display_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':color', $g['display_color'] ?? '§f', SQLITE3_TEXT);
|
||
$stmt->bindValue(':emoji', $g['display_emoji'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':priority', (int)($g['priority'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':price', (int)($g['land_price_per_sqm'] ?? -1), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':maxlands', (int)($g['max_lands'] ?? -1), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':maxeffects', (int)($g['max_effects'] ?? 5), SQLITE3_INTEGER);
|
||
// ★ 药效等级上限(2026-10-04 任务4):<=0 归一到原版上限 256
|
||
$mel = (int)($g['max_effect_level'] ?? 256);
|
||
if ($mel <= 0 || $mel > 256) $mel = 256;
|
||
$stmt->bindValue(':maxeffectlevel', $mel, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':homelimit', (int)($g['home_limit'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':joinprice', (int)($g['join_price'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':autorenew', (int)($g['auto_renew'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':renewprice', (int)($g['renew_price'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':duration', (int)($g['duration_minutes'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':perms', $g['default_perms'] ?? '{}', SQLITE3_TEXT);
|
||
$stmt->bindValue(':shopmax', (int)($g['shop_max_listings'] ?? -1), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':shopmin', (int)($g['shop_min_price'] ?? -1), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
success("用户组同步成功: {$count}个");
|
||
}
|
||
|
||
// ===== 用户组成员同步 =====
|
||
function syncUserGroupMembers() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
|
||
$membersRaw = getParam('members');
|
||
if (!$membersRaw) error('缺少members参数');
|
||
$members = is_array($membersRaw) ? $membersRaw : json_decode($membersRaw, true);
|
||
if (!is_array($members)) error('members格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_user_group_members (
|
||
player_name TEXT NOT NULL,
|
||
group_name TEXT NOT NULL,
|
||
added_by TEXT DEFAULT 'system',
|
||
added_time INTEGER DEFAULT 0,
|
||
expiry_time INTEGER DEFAULT 0,
|
||
PRIMARY KEY(player_name, group_name)
|
||
)");
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_user_group_members
|
||
(player_name, group_name, added_by, added_time, expiry_time)
|
||
VALUES (:player, :group, :addedby, :added, :expiry)");
|
||
$count = 0;
|
||
foreach ($members as $m) {
|
||
$stmt->bindValue(':player', $m['player_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':group', $m['group_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':addedby', $m['added_by'] ?? 'system', SQLITE3_TEXT);
|
||
$stmt->bindValue(':added', (int)($m['added_time'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':expiry', (int)($m['expiry_time'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
success("用户组成员同步成功: {$count}条");
|
||
}
|
||
|
||
// ===== 插件推送债券余额 =====
|
||
function syncBonds() {
|
||
// ★ 支持token或SECRET_KEY认证
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$bonds = getParam('bonds');
|
||
if (!$bonds || !is_array($bonds)) {
|
||
error('缺少bonds数据');
|
||
}
|
||
|
||
$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)");
|
||
|
||
$now = time();
|
||
$count = 0;
|
||
|
||
foreach ($bonds as $player => $amount) {
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
|
||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':amount', (int)$amount, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
|
||
success(['synced' => $count], "同步了{$count}个玩家余额");
|
||
}
|
||
|
||
// ===== 交易流水对账(Java 开服/分批推送时调用)=====
|
||
/**
|
||
* 与 Java 端「点一遍数据」:
|
||
* · 不传 ids → 返回 PHP 侧总笔数与最大 java_id,供 Java 判断两边是否已一致;
|
||
* · 传 ids → 返回这些序列号里 PHP 还没有的(missing),Java 只补推这部分。
|
||
* 交易序列号 = Java 侧 bond_transaction.id(PHP 侧存为 game_transactions.java_id)。
|
||
*/
|
||
// ===== 交易流水对账(Java 开服/分批推送时调用)=====
|
||
/**
|
||
* 与 Java 端「两边点一遍数据」(阶段E 2026-10-05 改为双向):
|
||
* · 不传 ids → 返回 PHP 侧总笔数与最大 java_id;
|
||
* · 传 ids → missing = 这些序列号里 PHP 还没有的(Java 只补推这部分);
|
||
* · extra_ids = PHP 有、Java 没有的序列号(Java 收回去导入本地)——
|
||
* full_scan=1(Java 传了全量序列号)时按「不在 ids 里」判;
|
||
* 否则按 java_id > java_max_id 判。
|
||
* 无论成功失败报错,15 秒内必定返回 JSON,绝不哑巴。
|
||
* 交易序列号 = Java 侧 bond_transaction.id(PHP 侧存为 game_transactions.java_id)。
|
||
*/
|
||
function checkTxAlignment() {
|
||
@set_time_limit(13);
|
||
try {
|
||
// ★ 认证与 syncTransactions 完全一致(token 或 SECRET_KEY)
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec('PRAGMA busy_timeout=6000');
|
||
ensureGameTransactionsTable($db);
|
||
|
||
$phpCount = (int)$db->querySingle("SELECT COUNT(*) FROM game_transactions");
|
||
$phpMax = (int)$db->querySingle("SELECT COALESCE(MAX(java_id),0) FROM game_transactions");
|
||
|
||
// ---- Java 传来的本机序列号(可选)----
|
||
$ids = getParam('ids');
|
||
$clean = [];
|
||
if (is_array($ids)) {
|
||
foreach ($ids as $v) {
|
||
$iv = (int)$v;
|
||
if ($iv > 0) $clean[] = $iv;
|
||
}
|
||
$clean = array_values(array_unique($clean));
|
||
}
|
||
$javaMax = (int)getParam('java_max_id', 0);
|
||
$fullScan = (int)getParam('full_scan', 0);
|
||
|
||
// ---- PHP 缺的(Java 要补推的)----
|
||
$missing = null;
|
||
if (!empty($clean)) {
|
||
$in = implode(',', $clean);
|
||
$have = [];
|
||
$res = $db->query("SELECT java_id FROM game_transactions WHERE java_id IN ($in)");
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$have[(int)$row['java_id']] = true;
|
||
}
|
||
$res->finalize();
|
||
$missing = [];
|
||
foreach ($clean as $iv) {
|
||
if (!isset($have[$iv])) $missing[] = $iv;
|
||
}
|
||
} elseif (is_array($ids)) {
|
||
$missing = [];
|
||
}
|
||
|
||
// ---- PHP 多的(Java 要收回去的)----
|
||
$extraIds = [];
|
||
if ($fullScan && !empty($clean)) {
|
||
$in = implode(',', $clean);
|
||
$res = $db->query("SELECT java_id FROM game_transactions WHERE java_id NOT IN ($in)"
|
||
. " ORDER BY java_id ASC LIMIT 1000");
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$extraIds[] = (int)$row['java_id'];
|
||
}
|
||
$res->finalize();
|
||
} elseif ($javaMax > 0) {
|
||
$res = $db->query("SELECT java_id FROM game_transactions WHERE java_id > {$javaMax}"
|
||
. " ORDER BY java_id ASC LIMIT 1000");
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$extraIds[] = (int)$row['java_id'];
|
||
}
|
||
$res->finalize();
|
||
}
|
||
|
||
debugLog("check_tx_alignment: 对账", [
|
||
'php_count' => $phpCount,
|
||
'php_max_id' => $phpMax,
|
||
'checked' => is_array($ids) ? count($ids) : 0,
|
||
'missing' => $missing === null ? -1 : count($missing),
|
||
'extra' => count($extraIds),
|
||
'java_max_id' => $javaMax,
|
||
'full_scan' => $fullScan
|
||
]);
|
||
|
||
success([
|
||
'php_count' => $phpCount,
|
||
'php_max_id' => $phpMax,
|
||
'checked' => is_array($ids) ? count($ids) : 0,
|
||
'missing' => $missing,
|
||
'extra_ids' => $extraIds
|
||
], 'ok');
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkTxAlignment] EXCEPTION: " . $e->getMessage());
|
||
error('checkTxAlignment异常: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ★ 阶段E 多退少补的「补」:Java 报上它缺的序列号,PHP 把这些流水的完整数据交还,
|
||
* 由 Java 导入本地 bond_transaction(只补记录、不触发业务)。
|
||
* 只读操作;15 秒内必定返回 JSON。
|
||
*/
|
||
function pullTx() {
|
||
@set_time_limit(13);
|
||
try {
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$want = getParam('java_ids');
|
||
if (!is_array($want) || count($want) === 0) error('缺少java_ids');
|
||
$clean = [];
|
||
foreach ($want as $v) {
|
||
$iv = (int)$v;
|
||
if ($iv > 0) $clean[] = $iv;
|
||
}
|
||
$clean = array_values(array_unique($clean));
|
||
if (count($clean) > 1000) $clean = array_slice($clean, 0, 1000);
|
||
if (empty($clean)) error('java_ids 无效');
|
||
|
||
$db = getDB();
|
||
$db->exec('PRAGMA busy_timeout=6000');
|
||
ensureGameTransactionsTable($db);
|
||
|
||
$in = implode(',', $clean);
|
||
$txs = [];
|
||
$res = $db->query("SELECT java_id, player_name, type, amount, target_player, operator,"
|
||
. " reason, balance_before, balance_after, tx_time"
|
||
. " FROM game_transactions WHERE java_id IN ($in) ORDER BY java_id ASC");
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$txs[] = [
|
||
'id' => (int)$row['java_id'],
|
||
'player_name' => $row['player_name'],
|
||
'type' => $row['type'],
|
||
'amount' => (int)$row['amount'],
|
||
'target_player' => $row['target_player'],
|
||
'operator' => $row['operator'],
|
||
'reason' => $row['reason'],
|
||
'balance_before' => (int)$row['balance_before'],
|
||
'balance_after' => (int)$row['balance_after'],
|
||
'time' => (int)$row['tx_time'] * 1000 // PHP 秒级 → Java 毫秒
|
||
];
|
||
}
|
||
$res->finalize();
|
||
|
||
debugLog("pull_tx: 交还游戏交易记录", ['want' => count($clean), 'returned' => count($txs)]);
|
||
success(['transactions' => $txs, 'count' => count($txs)], '返回' . count($txs) . '笔流水');
|
||
} catch (\Throwable $e) {
|
||
@error_log("[pullTx] EXCEPTION: " . $e->getMessage());
|
||
error('pullTx异常: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/** game_transactions 建表 + 索引(syncTransactions 与对账共用) */
|
||
function ensureGameTransactionsTable($db) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS game_transactions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
java_id INTEGER NOT NULL,
|
||
player_name TEXT NOT NULL,
|
||
type TEXT NOT NULL,
|
||
amount INTEGER NOT NULL,
|
||
target_player TEXT DEFAULT '',
|
||
operator TEXT DEFAULT '',
|
||
reason TEXT DEFAULT '',
|
||
balance_before INTEGER DEFAULT 0,
|
||
balance_after INTEGER DEFAULT 0,
|
||
tx_time INTEGER NOT NULL,
|
||
synced_at INTEGER NOT NULL,
|
||
UNIQUE(java_id)
|
||
)");
|
||
try { $db->exec("CREATE INDEX IF NOT EXISTS idx_gt_player ON game_transactions(player_name, tx_time)"); } catch (\Throwable $e) {}
|
||
try { $db->exec("CREATE INDEX IF NOT EXISTS idx_gt_type ON game_transactions(type)"); } catch (\Throwable $e) {}
|
||
}
|
||
|
||
// ==================== ★ 通用双向对账(多退少补,2026-10-05) ====================
|
||
/**
|
||
* Java 每轮同步前先与 PHP「两边点一遍数据」。本接口只读不写,15 秒内必定返回 JSON。
|
||
*
|
||
* 入参 cat = 数据类别(users/credentials/shop/bonds/ips/providers/admins/bans)
|
||
* 入参 records = [{k: 主键串, h: 内容指纹}, ...] —— Java 侧全量,只发指纹不发正文
|
||
*
|
||
* 出参 missing = Java 有、PHP 没有 → Java 补回去
|
||
* changed = 两边都有但内容不同 → Java 按「上次交换后谁改过」决定推还是收
|
||
* extra = PHP 有、Java 没有 → Java 收回来
|
||
* rows = changed∪extra 在 PHP 侧的完整行(字段名已换成 Java 侧叫法)
|
||
* php_count = PHP 侧总行数
|
||
*
|
||
* missing/changed/extra 全空 ⇒ 两边完全一致 ⇒ Java 一条数据都不发。
|
||
*
|
||
* ★ 指纹算法两端必须一模一样(Java 的 alignRowHash ↔ PHP 的 alignmentRowHash):
|
||
* 按 hashcols 顺序取值 → 整数统一十进制字符串、毫秒时间戳一律折成秒 → 以 chr(31) 连接 → md5
|
||
*/
|
||
function alignmentCatConfig($cat) {
|
||
static $cfgs = null;
|
||
if ($cfgs === null) {
|
||
$cfgs = array(
|
||
'users' => array(
|
||
'table' => 'users',
|
||
'keycols' => array('player_name'),
|
||
'map' => array(),
|
||
'cols' => array('player_name', 'register_time', 'last_login_time', 'email', 'points', 'gift_stage', 'total_online_time'),
|
||
'hashcols' => array('player_name', 'register_time', 'last_login_time', 'email', 'points', 'gift_stage', 'total_online_time'),
|
||
'intcols' => array('register_time', 'last_login_time', 'points', 'gift_stage', 'total_online_time'),
|
||
'pull' => 1,
|
||
),
|
||
'credentials' => array(
|
||
'table' => 'weblogin_credentials',
|
||
'keycols' => array('player_name'),
|
||
// ★ PHP 列名 -> Java(users 表)列名:rows 交出去时字段名必须是 Java 侧叫法
|
||
'map' => array('salt' => 'password_salt', 'temp_password_hash' => 'temp_password'),
|
||
'cols' => array('player_name', 'password_hash', 'salt', 'temp_password_hash', 'temp_pw_expire'),
|
||
'hashcols' => array('player_name', 'password_hash', 'salt', 'temp_password_hash', 'temp_pw_expire'),
|
||
'intcols' => array('temp_pw_expire'),
|
||
'pull' => 1,
|
||
),
|
||
// ★ 库存/销量由 Web 端独立变动,不参与指纹,否则每轮都判「变了」
|
||
'shop' => array(
|
||
'table' => 'shop_items',
|
||
'keycols' => array('id'),
|
||
'map' => array(),
|
||
'cols' => array('id', 'category', 'display_name', 'material', 'buy_price', 'sell_price', 'stock', 'hourly_sales', 'total_sales'),
|
||
'hashcols' => array('id', 'category', 'display_name', 'material', 'buy_price', 'sell_price'),
|
||
'intcols' => array('buy_price', 'sell_price'),
|
||
'pull' => 0,
|
||
),
|
||
'bonds' => array(
|
||
'table' => 'bond_cache',
|
||
'keycols' => array('player_name'),
|
||
'map' => array(),
|
||
'cols' => array('player_name', 'amount'),
|
||
'hashcols' => array('player_name', 'amount'),
|
||
'intcols' => array('amount'),
|
||
'pull' => 1,
|
||
),
|
||
'ips' => array(
|
||
'table' => 'player_ip_changes',
|
||
'keycols' => array('player_name'),
|
||
'map' => array('player_name' => 'name', 'new_ip' => 'ip'),
|
||
'cols' => array('player_name', 'new_ip'),
|
||
'hashcols' => array('player_name', 'new_ip'),
|
||
'intcols' => array(),
|
||
'pull' => 1,
|
||
// ★ 孤儿行清理(2026-10-06):只在 keycols=player_name 的类别上开
|
||
'prune_orphans' => 1,
|
||
),
|
||
'providers' => array(
|
||
'table' => 'web_service_providers',
|
||
'keycols' => array('player_name'),
|
||
'map' => array(),
|
||
'cols' => array('player_name', 'role', 'active', 'join_time'),
|
||
'hashcols' => array('player_name', 'role', 'active', 'join_time'),
|
||
'intcols' => array('active', 'join_time'),
|
||
'pull' => 0,
|
||
),
|
||
'admins' => array(
|
||
'table' => 'web_plugin_admins',
|
||
'keycols' => array('player_name'),
|
||
'map' => array(),
|
||
'cols' => array('player_name'),
|
||
'hashcols' => array('player_name'),
|
||
'intcols' => array(),
|
||
'pull' => 0,
|
||
),
|
||
// ★ 29批(2026-10-22):cols 增加 created_time(map→created)——Java 收回行时
|
||
// 把封禁创建时间回填进 Bukkit BanEntry(addBan 默认 created=now,不回填会导致
|
||
// 拷贝生产封禁数据到测试服后「收回」一轮把全员封禁刷成当天 → 播报乱叫)。
|
||
// hashcols 故意不含 created_time:指纹不变,防两端 created 精度/缺失差异恒 changed 死循环。
|
||
'bans' => array(
|
||
'table' => 'web_player_bans',
|
||
'keycols' => array('target', 'ban_type'),
|
||
'map' => array('ban_type' => 'type', 'expire_time' => 'expire', 'created_time' => 'created'),
|
||
'cols' => array('target', 'ban_type', 'reason', 'source', 'expire_time', 'created_time'),
|
||
'hashcols' => array('target', 'ban_type', 'reason', 'source', 'expire_time'),
|
||
'intcols' => array('expire_time'),
|
||
'pull' => 0,
|
||
),
|
||
// ★ 领地数据(2026-10-07 接入对账,多退少补):游戏内 area_lands 为唯一权威。
|
||
// pull=0 —— 后台的领地改动不回流 Java(与改造前一致)。
|
||
// 「少补」= missing → Java 整包 sync_lands(INSERT OR REPLACE)补上缺行;
|
||
// 「多退」= extra → Java 侧 pushOnExtra=1 触发一次整包推,由 syncLands() 的
|
||
// 「name NOT IN」退侧清掉 PHP 多出的行(游戏里已删/后台自建)→ 推一次即收敛。
|
||
// ★ hashcols/intcols 必须与 Java AlignCfg("lands") 逐字一致(72/54 列):
|
||
// - 排除 synced_at/admin_changed/area_size/clear_all_bad_effects
|
||
// (PHP 独有列,每轮写入都变 → 恒 changed)与 Java 独有的
|
||
// create_cost/deny_farmland_trample/deny_ender_teleport(本表没有);
|
||
// - warp_x/y/z/yaw/pitch 是 REAL,但必须当整数列截断比对:
|
||
// PHP (string)1.0="1" 而 Java String.valueOf(1.0)="1.0",按文本比恒 changed。
|
||
'lands' => array(
|
||
'table' => 'web_area_lands',
|
||
'keycols' => array('id'),
|
||
'map' => array(),
|
||
'cols' => array('id', 'name', 'owner', 'world', 'x1', 'z1', 'x2', 'z2', 'y_min', 'y_max', 'confiscate_items', 'deny_use_items', 'give_effects', 'clear_effects', 'clear_all_bad', 'punish_commands', 'deny_block_place', 'deny_block_break', 'deny_container', 'deny_pvp', 'deny_fall_damage', 'deny_hunger', 'deny_all_damage', 'deny_drop', 'deny_mount', 'deny_ender_pearl', 'deny_bow', 'deny_potion', 'deny_explosion', 'deny_raid', 'deny_fire_spread', 'deny_all_effects', 'deny_item_frame', 'deny_move', 'deny_pickup', 'deny_fire', 'peace_mode', 'peace_mode_duration', 'peace_whitelist', 'enforce_game_mode', 'mode_exempt', 'enter_msg', 'leave_msg', 'confiscate_msg', 'enable_announce', 'announce_template', 'txt_content', 'created_at', 'deny_thrown_projectiles', 'deny_glowing', 'deny_redstone_interaction', 'deny_door_interaction', 'deny_noteblock_jukebox', 'deny_lead', 'deny_crop_harvest', 'deny_wool_shear', 'deny_animal_feeding', 'warp_x', 'warp_y', 'warp_z', 'warp_yaw', 'warp_pitch', 'warp_world', 'deny_mob_attack', 'is_public_building', 'deny_fluid', 'allow_visitor_teleport', 'deny_entity_interact', 'deny_sign_edit', 'deny_spawn_egg', 'deny_wax', 'bad_effects'),
|
||
'hashcols' => array('id', 'name', 'owner', 'world', 'x1', 'z1', 'x2', 'z2', 'y_min', 'y_max', 'confiscate_items', 'deny_use_items', 'give_effects', 'clear_effects', 'clear_all_bad', 'punish_commands', 'deny_block_place', 'deny_block_break', 'deny_container', 'deny_pvp', 'deny_fall_damage', 'deny_hunger', 'deny_all_damage', 'deny_drop', 'deny_mount', 'deny_ender_pearl', 'deny_bow', 'deny_potion', 'deny_explosion', 'deny_raid', 'deny_fire_spread', 'deny_all_effects', 'deny_item_frame', 'deny_move', 'deny_pickup', 'deny_fire', 'peace_mode', 'peace_mode_duration', 'peace_whitelist', 'enforce_game_mode', 'mode_exempt', 'enter_msg', 'leave_msg', 'confiscate_msg', 'enable_announce', 'announce_template', 'txt_content', 'created_at', 'deny_thrown_projectiles', 'deny_glowing', 'deny_redstone_interaction', 'deny_door_interaction', 'deny_noteblock_jukebox', 'deny_lead', 'deny_crop_harvest', 'deny_wool_shear', 'deny_animal_feeding', 'warp_x', 'warp_y', 'warp_z', 'warp_yaw', 'warp_pitch', 'warp_world', 'deny_mob_attack', 'is_public_building', 'deny_fluid', 'allow_visitor_teleport', 'deny_entity_interact', 'deny_sign_edit', 'deny_spawn_egg', 'deny_wax', 'bad_effects'),
|
||
'intcols' => array('id', 'x1', 'z1', 'x2', 'z2', 'y_min', 'y_max', 'clear_all_bad', 'deny_block_place', 'deny_block_break', 'deny_container', 'deny_pvp', 'deny_fall_damage', 'deny_hunger', 'deny_all_damage', 'deny_drop', 'deny_mount', 'deny_ender_pearl', 'deny_bow', 'deny_potion', 'deny_explosion', 'deny_raid', 'deny_fire_spread', 'deny_all_effects', 'deny_item_frame', 'deny_move', 'deny_pickup', 'deny_fire', 'peace_mode', 'peace_mode_duration', 'enable_announce', 'created_at', 'deny_thrown_projectiles', 'deny_glowing', 'deny_redstone_interaction', 'deny_door_interaction', 'deny_noteblock_jukebox', 'deny_lead', 'deny_crop_harvest', 'deny_wool_shear', 'deny_animal_feeding', 'warp_x', 'warp_y', 'warp_z', 'warp_yaw', 'warp_pitch', 'deny_mob_attack', 'is_public_building', 'deny_fluid', 'allow_visitor_teleport', 'deny_entity_interact', 'deny_sign_edit', 'deny_spawn_egg', 'deny_wax'),
|
||
'pull' => 0,
|
||
),
|
||
// ★ 在线玩家:login_time 每次推送都变,不进指纹,只比「谁在线」
|
||
'online' => array(
|
||
'table' => 'online_players',
|
||
'keycols' => array('player_name'),
|
||
'map' => array(),
|
||
'cols' => array('player_name'),
|
||
'hashcols' => array('player_name'),
|
||
'intcols' => array(),
|
||
'pull' => 0,
|
||
// PHP 侧 sync_online_players 是「整表重建」:PHP 比 Java 多出的行
|
||
// (玩家已下线但没推过)必须靠一次全量推送才能清掉,所以 extra 要触发推送
|
||
'push_on_extra' => 1,
|
||
),
|
||
// ★ 充值回执(2026-10-06 一式两份):Java 侧持 web_tx_receipts 镜像。
|
||
// 两边按 id 点差集、多退少补:
|
||
// PHP 缺(missing)→ Java 补推重建流水,解决「运行中清空 PHP 后充值记录回不来」
|
||
// PHP 多(extra) → 收回给 Java 补齐基线
|
||
// ★ where 限定与 Java 侧写镜像的范围【必须完全一致】(recharge/pay_recharge 两种):
|
||
// 范围不一致会导致补推的行永远落在对账集合之外 → 每轮都判 missing → 无限补推。
|
||
// 不能对账的类型(cdk/shop_refund 等)本就不归充值回执管,各走各的链路。
|
||
'webtx' => array(
|
||
'table' => 'web_transactions',
|
||
'keycols' => array('id'),
|
||
'map' => array(),
|
||
'cols' => array('id', 'player_name', 'type', 'amount', 'detail', 'created_at'),
|
||
'hashcols' => array('player_name', 'type', 'amount', 'detail'),
|
||
'intcols' => array('amount'),
|
||
'pull' => 1,
|
||
'where' => "type IN ('recharge','pay_recharge')",
|
||
),
|
||
);
|
||
}
|
||
return isset($cfgs[$cat]) ? $cfgs[$cat] : null;
|
||
}
|
||
|
||
/** DB 列名 → Java 侧字段名(没有映射就原样) */
|
||
function alignmentJavaField($cfg, $dbCol) {
|
||
return (isset($cfg['map'][$dbCol]) && $cfg['map'][$dbCol] !== '') ? $cfg['map'][$dbCol] : $dbCol;
|
||
}
|
||
|
||
/** 主键串:按 keycols 顺序用 | 连接(Java 侧必须按同样顺序拼) */
|
||
function alignmentKeyOf($cfg, $row) {
|
||
$parts = array();
|
||
foreach ($cfg['keycols'] as $c) {
|
||
$v = isset($row[$c]) ? (string)$row[$c] : '';
|
||
if ($v === '') return '';
|
||
$parts[] = $v;
|
||
}
|
||
return implode('|', $parts);
|
||
}
|
||
|
||
/** 规范化:整数统一十进制字符串;毫秒时间戳一律折成秒(两端必须一模一样) */
|
||
function alignmentCanonVal($v, $isInt) {
|
||
// ★ null + 整数列归一为 "0":Java 行可能整个缺 key(=null),PHP 库可能是 NULL 或 0,
|
||
// 三种形态必须归一,否则 credentials 无临时密码时每轮都判 changed(2026-10-05)
|
||
if ($v === null) return $isInt ? '0' : '';
|
||
if ($isInt) {
|
||
$n = (int)$v;
|
||
if ($n > 1000000000000 || $n < -1000000000000) $n = (int)($n / 1000);
|
||
return (string)$n;
|
||
}
|
||
return (string)$v;
|
||
}
|
||
|
||
/** 行内容指纹(与 Java 的 alignRowHash 严格一致) */
|
||
function alignmentRowHash($cfg, $row) {
|
||
$parts = array();
|
||
foreach ($cfg['hashcols'] as $c) {
|
||
$isInt = in_array($c, $cfg['intcols'], true);
|
||
$parts[] = alignmentCanonVal(isset($row[$c]) ? $row[$c] : null, $isInt);
|
||
}
|
||
return md5(implode(chr(31), $parts));
|
||
}
|
||
|
||
function checkAlignment() {
|
||
// ★ 15 秒必答复:与 syncTransactions 同款,超时就地中断,Java 按「无答复」重试
|
||
@set_time_limit(13);
|
||
try {
|
||
// 认证与 syncTransactions 完全一致(token 或 SECRET_KEY)
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$cat = (string)getParam('cat', '');
|
||
$cfg = alignmentCatConfig($cat);
|
||
if ($cfg === null) error('未知的对账类别: ' . $cat);
|
||
|
||
// ★ 2026-10-06 心跳兜底:Java 端对「online」类别的对账请求,本身就证明
|
||
// 插件活着、且它手上那份在线名单刚刚跟 PHP 点过一遍。既然点过了,
|
||
// 就同步刷新 online_player_hb —— 这样即使专门的心跳请求丢了,
|
||
// 只要对账链路通,后台也不会因为 120 秒无 sync_online_players 而归零。
|
||
// (只对 online 类刷:别的类别跟在线数据无关,不该顺手改它的语义。)
|
||
if ($cat === 'online') {
|
||
try {
|
||
$hbDb = getDB();
|
||
$hbDb->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||
$hbStmt = $hbDb->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :t)");
|
||
$hbStmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$hbStmt->execute();
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkAlignment] online心跳兜底刷新失败: " . $e->getMessage());
|
||
// 失败不影响对账本身
|
||
}
|
||
// ★ 3.11内测版:对账链路通也视为 Java 活着,顺手留曲线样本
|
||
onlineCurveEnsureSnapshot(false, 'align');
|
||
}
|
||
|
||
// ---- Java 报上来的本机 key + 指纹 ----
|
||
$javaHash = array();
|
||
$records = getParam('records');
|
||
if (is_array($records)) {
|
||
foreach ($records as $r) {
|
||
if (!is_array($r)) continue;
|
||
$k = isset($r['k']) ? (string)$r['k'] : '';
|
||
if ($k === '') $k = alignmentKeyOf($cfg, $r);
|
||
if ($k === '') continue;
|
||
$javaHash[$k] = (string)($r['h'] === null ? '' : $r['h']);
|
||
}
|
||
}
|
||
|
||
// ---- PHP 侧全量 key + 指纹(表还没建就当空表)----
|
||
$phpHash = array();
|
||
$phpRows = array();
|
||
$db = getDB();
|
||
$db->exec('PRAGMA busy_timeout=6000');
|
||
try {
|
||
// ★ where 仅来自 alignmentCatConfig 的硬编码常量(非用户输入),可直接拼接。
|
||
// 没配 where 的类别行为与改造前完全一致。
|
||
$sql = 'SELECT * FROM ' . $cfg['table'];
|
||
if (!empty($cfg['where'])) $sql .= ' WHERE ' . $cfg['where'];
|
||
$res = $db->query($sql);
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$k = alignmentKeyOf($cfg, $row);
|
||
if ($k === '') continue;
|
||
$phpHash[$k] = alignmentRowHash($cfg, $row);
|
||
$phpRows[$k] = $row;
|
||
}
|
||
$res->finalize();
|
||
} catch (\Throwable $e) {
|
||
// 表还不存在 → 视为 PHP 侧为空
|
||
}
|
||
|
||
// ---- 三个差集 ----
|
||
$missing = array();
|
||
$changed = array();
|
||
foreach ($javaHash as $k => $h) {
|
||
if (!isset($phpHash[$k])) {
|
||
$missing[] = $k;
|
||
} elseif ($phpHash[$k] !== $h) {
|
||
$changed[] = $k;
|
||
}
|
||
}
|
||
$extra = array();
|
||
foreach ($phpHash as $k => $h) {
|
||
if (isset($javaHash[$k])) continue;
|
||
// ★ bans:已过期的封禁不算 extra(2026-10-07 Swight 死循环修复)。
|
||
// Web 侧 web_player_bans 永久保留历史(含过期),而 Bukkit 的 getEntries()
|
||
// 不认过期封禁 → 这类行永远是「PHP 多 1 条」,Java 每轮拉回又立刻失效,
|
||
// 日志永远「收回1」收敛不到 0。过期行只跳过 extra,不从 phpHash 删除,
|
||
// 两边都有该 key 时的 changed/指纹比对不受影响。
|
||
if ($cat === 'bans' && isset($phpRows[$k])
|
||
&& !empty($phpRows[$k]['expire_time'])) {
|
||
$et = (int)$phpRows[$k]['expire_time'];
|
||
if ($et > 1000000000000 || $et < -1000000000000) $et = (int)($et / 1000);
|
||
if ($et > 0 && $et <= time()) continue;
|
||
}
|
||
$extra[] = $k;
|
||
}
|
||
|
||
// ★ 2026-10-06 孤儿行清理(「多退少补」里的「退」):
|
||
// player_ip_changes 会残留「玩家已删除但行还在」的历史数据(线上实测 203 条:
|
||
// 这些 player_name 在 Java 的 users 表里没有,在 PHP 自己的 users 表里也没有)。
|
||
// Java 侧没有对应的用户行、无处可收,于是每轮对账都空转一次
|
||
// 「收回N / 实际写入本地0条」,两边永远差着 N 条,对账永远收敛不到 0。
|
||
// 三重保险,缺一不可:
|
||
// ① 只清 extra(Java 明确报了「我没有」的 key),missing/changed 一律不碰
|
||
// ② 只清 PHP 自己 users 表里也不存在的玩家 —— 在世玩家 / 网页注册用户绝不动
|
||
// ③ Java 一条记录都没报上来(count($javaHash)==0)时绝不动表,
|
||
// 防止 Java 库异常/被清空时把 PHP 的 IP 记录一锅端
|
||
// table 名来自 alignmentCatConfig 的硬编码常量,非用户输入,可直接拼接。
|
||
$pruned = 0;
|
||
if (!empty($cfg['prune_orphans']) && count($extra) > 0 && count($javaHash) > 0) {
|
||
try {
|
||
$chkStmt = $db->prepare('SELECT 1 FROM users WHERE player_name = :n LIMIT 1');
|
||
$delStmt = $db->prepare('DELETE FROM ' . $cfg['table'] . ' WHERE player_name = :n');
|
||
$keep = array();
|
||
foreach ($extra as $k) {
|
||
$chkStmt->bindValue(':n', $k, SQLITE3_TEXT);
|
||
$chkRes = $chkStmt->execute();
|
||
$alive = $chkRes ? $chkRes->fetchArray(SQLITE3_NUM) : null;
|
||
if ($chkRes) $chkRes->finalize();
|
||
if ($alive) {
|
||
$keep[] = $k; // 玩家还在 → 保留,交给 Java 的 users 类别收回
|
||
continue;
|
||
}
|
||
$delStmt->bindValue(':n', $k, SQLITE3_TEXT);
|
||
$delStmt->execute();
|
||
if ($db->changes() > 0) {
|
||
$pruned++;
|
||
unset($phpHash[$k], $phpRows[$k]);
|
||
} else {
|
||
$keep[] = $k;
|
||
}
|
||
}
|
||
$extra = $keep;
|
||
} catch (\Throwable $e) {
|
||
@error_log('[checkAlignment] prune orphans failed cat=' . $cat . ': ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ---- 把这些 key 在 PHP 侧的完整行交出去(字段名换成 Java 侧叫法)----
|
||
$rows = array();
|
||
$want = array_merge($changed, $extra);
|
||
if (count($want) > 3000) $want = array_slice($want, 0, 3000);
|
||
foreach ($want as $k) {
|
||
if (!isset($phpRows[$k])) continue;
|
||
$one = array();
|
||
foreach ($cfg['cols'] as $c) {
|
||
$one[alignmentJavaField($cfg, $c)] = isset($phpRows[$k][$c]) ? $phpRows[$k][$c] : null;
|
||
}
|
||
$rows[$k] = $one;
|
||
}
|
||
|
||
debugLog("check_alignment: 双向对账", array(
|
||
'cat' => $cat,
|
||
'java_count' => count($javaHash),
|
||
'php_count' => count($phpHash),
|
||
'missing' => count($missing),
|
||
'changed' => count($changed),
|
||
'extra' => count($extra),
|
||
'pruned_orphans' => $pruned,
|
||
));
|
||
|
||
success(array(
|
||
'cat' => $cat,
|
||
'missing' => $missing,
|
||
'changed' => $changed,
|
||
'extra' => $extra,
|
||
'rows' => $rows,
|
||
'php_count' => count($phpHash),
|
||
), '对账完成');
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkAlignment] EXCEPTION: " . $e->getMessage());
|
||
error('checkAlignment异常: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 插件推送游戏内交易记录 =====
|
||
function syncTransactions() {
|
||
// ★ 15 秒必答复:限制执行时间,锁等太久就地中断,Java 按「无答复」等 15 秒重试
|
||
@set_time_limit(13);
|
||
try {
|
||
// ★ 支持token或SECRET_KEY认证
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
@error_log("[syncTransactions] Token验证失败: token=" . substr($token, 0, 8) . "..., info=" . var_export($tokenInfo, true));
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$transactions = getParam('transactions');
|
||
@error_log("[syncTransactions] transactions type=" . gettype($transactions) . ", is_null=" . var_export($transactions === null, true));
|
||
|
||
if (!$transactions || !is_array($transactions)) {
|
||
error('缺少transactions数据 (type=' . gettype($transactions) . ')');
|
||
}
|
||
|
||
@error_log("[syncTransactions] Received " . count($transactions) . " transactions");
|
||
if (count($transactions) > 0) {
|
||
@error_log("[syncTransactions] First tx: " . json_encode($transactions[0], JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
$db = getDB();
|
||
// ★ 设置busy_timeout防止database is locked
|
||
$db->exec('PRAGMA busy_timeout=6000');
|
||
|
||
// 创建/补齐 game_transactions 表与索引(与对账接口共用同一份 DDL)
|
||
ensureGameTransactionsTable($db);
|
||
|
||
$now = time();
|
||
$count = 0;
|
||
$skipped = 0;
|
||
|
||
// ★ 使用事务批量插入,减少锁冲突
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
foreach ($transactions as $tx) {
|
||
$javaId = (int)($tx['id'] ?? 0);
|
||
if ($javaId <= 0) { $skipped++; continue; }
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO game_transactions
|
||
(java_id, player_name, type, amount, target_player, operator, reason, balance_before, balance_after, tx_time, synced_at)
|
||
VALUES (:jpid, :name, :type, :amount, :target, :op, :reason, :bfb, :bfa, :time, :synced)");
|
||
$stmt->bindValue(':jpid', $javaId, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':name', $tx['player_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':type', $tx['type'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':amount', (int)($tx['amount'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':target', $tx['target_player'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':op', $tx['operator'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':reason', $tx['reason'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':bfb', (int)($tx['balance_before'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':bfa', (int)($tx['balance_after'] ?? 0), SQLITE3_INTEGER);
|
||
// Java端使用毫秒时间戳,PHP端存储秒级时间戳
|
||
$txTime = (int)($tx['time'] ?? 0);
|
||
if ($txTime > 1000000000000) {
|
||
$txTime = intval($txTime / 1000);
|
||
}
|
||
$stmt->bindValue(':time', $txTime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
if ($db->changes() > 0) {
|
||
$count++;
|
||
} else {
|
||
$skipped++;
|
||
}
|
||
}
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
$db->exec("ROLLBACK");
|
||
@error_log("[syncTransactions] Transaction failed, rolled back: " . $e->getMessage());
|
||
throw $e;
|
||
}
|
||
|
||
debugLog("sync_transactions: 接收游戏交易记录", [
|
||
'total' => count($transactions),
|
||
'inserted' => $count,
|
||
'skipped_duplicates' => $skipped
|
||
]);
|
||
|
||
success(['synced' => $count, 'skipped' => $skipped, 'total' => count($transactions)], "同步了{$count}笔游戏交易");
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncTransactions] EXCEPTION: " . $e->getMessage() . " at " . $e->getTraceAsString());
|
||
error('syncTransactions异常: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 插件推送注册数据 =====
|
||
function syncLogin() {
|
||
// ★ 纯secret认证(不依赖token,避免token过期/已使用导致同步失败)
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('同步需要SECRET_KEY验证', 403);
|
||
}
|
||
|
||
$users = getParam('users');
|
||
if (!$users || !is_array($users)) {
|
||
error('缺少users数据');
|
||
}
|
||
|
||
// ★ 调试日志:记录插件推送的用户数据
|
||
debugLog("sync_login: 插件推送用户数据", [
|
||
'user_count' => count($users)
|
||
]);
|
||
|
||
$db = getDB();
|
||
$now = time();
|
||
$count = 0;
|
||
|
||
// ★ 所有写操作(INSERT + 清理)包装在一个事务中
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
foreach ($users as $user) {
|
||
$name = $user['player_name'] ?? null;
|
||
if (!$name) continue;
|
||
|
||
$registerTime = (int)($user['register_time'] ?? 0);
|
||
$lastLoginTime = (int)($user['last_login_time'] ?? 0);
|
||
if ($registerTime > 1000000000000) { $registerTime = intval($registerTime / 1000); }
|
||
if ($lastLoginTime > 1000000000000) { $lastLoginTime = intval($lastLoginTime / 1000); }
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO users (player_name, register_time, last_login_time, email, points, gift_stage, total_online_time) VALUES (:name, :rt, :lt, :email, :pts, :gs, :tot)");
|
||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$stmt->bindValue(':rt', $registerTime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':lt', $lastLoginTime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':email', $user['email'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':pts', (int)($user['points'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':gs', (int)($user['gift_stage'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':tot', (int)($user['total_online_time'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
|
||
$namesInSync = array_map(function($u) { return $u['player_name'] ?? null; }, $users);
|
||
$namesInSync = array_filter($namesInSync, function($n) { return $n !== null; });
|
||
|
||
// 清理 users 表中已不存在的用户
|
||
try {
|
||
$stmt = $db->prepare("SELECT player_name FROM users");
|
||
$result = $stmt->execute();
|
||
$phpUsers = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$phpUsers[] = $row['player_name'];
|
||
}
|
||
|
||
$orphanedUsers = array_diff($phpUsers, $namesInSync);
|
||
if (!empty($orphanedUsers)) {
|
||
$nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphanedUsers));
|
||
$db->exec("DELETE FROM users WHERE player_name IN ('" . $nameList . "')");
|
||
debugLog("sync_login: 清理了 " . count($orphanedUsers) . " 个孤立的 users 记录", ['orphaned' => $orphanedUsers]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
debugLog("sync_login: 清理孤立 users 记录失败", ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
// 清理 weblogin_credentials 中已不存在的用户(游戏内删号后同步)
|
||
try {
|
||
$stmt = $db->prepare("SELECT player_name FROM weblogin_credentials");
|
||
$result = $stmt->execute();
|
||
$webCredentials = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$webCredentials[] = $row['player_name'];
|
||
}
|
||
|
||
$namesInSync2 = array_map(function($u) { return $u['player_name'] ?? null; }, $users);
|
||
$orphaned = array_diff($webCredentials, $namesInSync2);
|
||
|
||
if (!empty($orphaned)) {
|
||
$nameList = implode("','", array_map(function($n) use ($db) { return addslashes($n); }, $orphaned));
|
||
$db->exec("DELETE FROM weblogin_credentials WHERE player_name IN ('" . $nameList . "')");
|
||
debugLog("sync_login: 清理了 " . count($orphaned) . " 个孤立的 weblogin_credentials 记录", ['orphaned' => $orphaned]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
debugLog("sync_login: 清理孤立 weblogin_credentials 记录失败", ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
// 清理 weblogin_tokens:只清理已过期的
|
||
try {
|
||
$expireTime = time() - 600;
|
||
$db->exec("DELETE FROM weblogin_tokens WHERE created_at < " . $expireTime);
|
||
} catch (\Throwable $e) {}
|
||
|
||
// 清理 web_login_verified:只清理已过期的
|
||
try {
|
||
$expireTime = time() - 600;
|
||
$db->exec("DELETE FROM web_login_verified WHERE verified_at < " . $expireTime);
|
||
} catch (\Throwable $e) {}
|
||
|
||
// 清理 web_session_log:只清理过期的
|
||
try {
|
||
$expireTime = time() - 600;
|
||
$db->exec("DELETE FROM web_session_log WHERE login_time < " . $expireTime);
|
||
} catch (\Throwable $e) {}
|
||
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
}
|
||
|
||
debugLog("sync_login: 同步完成", ['synced_count' => $count, 'cleared_users' => ($orphanedUsers ?? []) ? count($orphanedUsers) : 0]);
|
||
success(['synced' => $count], "同步了{$count}个用户");
|
||
}
|
||
|
||
// ===== Java插件主动删除用户 =====
|
||
function deleteUser() {
|
||
// ★ 支持token或SECRET_KEY认证
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('删除需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('删除需要token或SECRET_KEY');
|
||
}
|
||
|
||
$playerName = getParam('player_name');
|
||
if (!$playerName) error('缺少player_name');
|
||
|
||
$db = getDB();
|
||
|
||
// 清理所有相关表
|
||
$tables = ['users', 'weblogin_credentials', 'weblogin_tokens', 'web_login_verified', 'web_session_log', 'bond_cache', 'email_codes', 'password_reset_requests', 'web_login_requests', 'web_register_requests'];
|
||
$deleted = 0;
|
||
foreach ($tables as $table) {
|
||
try {
|
||
$stmt = $db->prepare("DELETE FROM $table WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
$deleted++;
|
||
} catch (\Throwable $e) {
|
||
// 表可能不存在,跳过
|
||
}
|
||
}
|
||
|
||
debugLog("deleteUser: 删除了用户 " . $playerName . " 及其 " . $deleted . " 个相关表的记录");
|
||
success(['player' => $playerName], "已删除用户 " . $playerName . " 的所有数据");
|
||
}
|
||
|
||
// ===== 修复时间戳 =====
|
||
function fixTimestamps() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
|
||
// 检测并修复毫秒级时间戳
|
||
// 正常秒级时间戳:10位(如1717900000)
|
||
// 毫秒级时间戳:13位(如1717900000000)
|
||
$threshold = 1000000000000; // 10^12
|
||
|
||
// 修复register_time
|
||
$fixRegister = 0;
|
||
$result = $db->query("SELECT player_name, register_time FROM users WHERE register_time > $threshold");
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$fixedTime = intval($row['register_time'] / 1000);
|
||
$stmt = $db->prepare("UPDATE users SET register_time = :time WHERE player_name = :name");
|
||
$stmt->bindValue(':time', $fixedTime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
$fixRegister++;
|
||
}
|
||
|
||
// 修复last_login_time
|
||
$fixLogin = 0;
|
||
$result = $db->query("SELECT player_name, last_login_time FROM users WHERE last_login_time > $threshold");
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$fixedTime = intval($row['last_login_time'] / 1000);
|
||
$stmt = $db->prepare("UPDATE users SET last_login_time = :time WHERE player_name = :name");
|
||
$stmt->bindValue(':time', $fixedTime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
$fixLogin++;
|
||
}
|
||
|
||
success([
|
||
'fixed_register' => $fixRegister,
|
||
'fixed_login' => $fixLogin,
|
||
'total' => $fixRegister + $fixLogin
|
||
], "修复了{$fixRegister}条注册时间、{$fixLogin}条登录时间");
|
||
}
|
||
|
||
// ===== 插件同步在线玩家列表 =====
|
||
function syncOnlinePlayers() {
|
||
global $db;
|
||
$db = getDB();
|
||
|
||
// ★ 尝试从三个来源读取 secret 和 players
|
||
$rawInput = null;
|
||
|
||
// 1) 先从 $_GET/$_POST 读
|
||
$secret = getParam('secret');
|
||
$players = getParam('players');
|
||
|
||
// ★ 关键修复:如果players是字符串(GET请求或表单提交),尝试JSON解码
|
||
if ($players !== null && is_string($players)) {
|
||
$decoded = json_decode($players, true);
|
||
if (is_array($decoded)) {
|
||
$players = $decoded;
|
||
@error_log("[syncOnlinePlayers] players从GET/POST字符串JSON解码成功: " . count($players) . "人");
|
||
}
|
||
}
|
||
|
||
// 2) 如果 PHP 解析了 x-www-form-urlencoded,$_POST 会有数据
|
||
// 3) 如果是 application/json,$_POST 为空,需要从 $_SERVER['HTTP_RAW_POST_DATA'] 或 php://input 读取
|
||
|
||
if (!$secret && isset($_POST['secret'])) {
|
||
$secret = $_POST['secret'];
|
||
}
|
||
if (($players === null || !is_array($players)) && isset($_POST['players'])) {
|
||
$pVal = $_POST['players'];
|
||
// 表单提交的 players 可能是 URL 编码的字符串
|
||
if (is_string($pVal)) {
|
||
$pVal = json_decode($pVal, true);
|
||
}
|
||
if (is_array($pVal)) $players = $pVal;
|
||
}
|
||
|
||
// 4) 如果还没有,手动解析 php://input
|
||
if (!$secret || !$players) {
|
||
static $_syncCached = false;
|
||
if (!$_syncCached) {
|
||
$_syncCached = true;
|
||
$rawInput = file_get_contents('php://input');
|
||
if ($rawInput) {
|
||
// 先试 JSON
|
||
$jsonParsed = json_decode($rawInput, true);
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($jsonParsed)) {
|
||
if (!isset($secret) && isset($jsonParsed['secret'])) $secret = $jsonParsed['secret'];
|
||
if (!isset($players) && isset($jsonParsed['players'])) {
|
||
$players = $jsonParsed['players'];
|
||
}
|
||
} else {
|
||
// 不是 JSON,解析为表单
|
||
parse_str($rawInput, $formVars);
|
||
if (!isset($secret) && isset($formVars['secret'])) $secret = $formVars['secret'];
|
||
if (!isset($players) && isset($formVars['players'])) {
|
||
$pVal = $formVars['players'];
|
||
if (is_string($pVal)) {
|
||
$pVal = json_decode($pVal, true);
|
||
}
|
||
if (is_array($pVal)) $players = $pVal;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
if ($players === null || !is_array($players)) {
|
||
@error_log("[syncOnlinePlayers] FAIL: invalid players data, type=" . gettype($players) . ", content=" . var_export($players, true));
|
||
error('缺少players数据 (received=' . var_export($players, true) . ' | type=' . gettype($players) . ')');
|
||
}
|
||
|
||
// ★ 详细日志:记录接收到的原始数据
|
||
$playerNames = array_map(function($p) { return $p['name'] ?? '?'; }, $players);
|
||
@error_log("[syncOnlinePlayers] ★★★ 收到推送: " . count($players) . "人 = [" . implode(', ', $playerNames) . "]");
|
||
@error_log("[syncOnlinePlayers] 原始players数据: " . json_encode($players, JSON_UNESCAPED_UNICODE));
|
||
|
||
// ★ 设置更高的 busy_timeout 防止 "database is locked" 错误
|
||
$db->exec('PRAGMA busy_timeout=10000');
|
||
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL)");
|
||
$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||
// ★ 每小时活跃玩家表:记录本小时内所有曾上线的玩家(离线不清空,跨小时清理)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS hourly_active_players (
|
||
player_name TEXT PRIMARY KEY,
|
||
first_seen INTEGER NOT NULL,
|
||
last_seen INTEGER NOT NULL,
|
||
hour_key TEXT NOT NULL
|
||
)");
|
||
// ★ 迁移:修复旧表结构(旧行无主键导致INSERT OR REPLACE失效)
|
||
try {
|
||
$colCheck = $db->query("PRAGMA table_info(online_player_hb)");
|
||
$hasPK = false;
|
||
while ($col = $colCheck->fetchArray(SQLITE3_ASSOC)) {
|
||
if ($col['pk'] > 0) { $hasPK = true; break; }
|
||
}
|
||
if (!$hasPK) {
|
||
// 读取旧数据
|
||
$oldVal = 0;
|
||
$oldRow = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1")->fetchArray(SQLITE3_ASSOC);
|
||
if ($oldRow) $oldVal = (int)$oldRow['last_seen'];
|
||
$db->exec("DROP TABLE IF EXISTS online_player_hb");
|
||
$db->exec("CREATE TABLE online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||
$db->exec("INSERT INTO online_player_hb (id, last_seen) VALUES (1, $oldVal)");
|
||
@error_log("[syncOnlinePlayers] 迁移online_player_hb表结构完成,旧值: $oldVal");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncOnlinePlayers] 迁移online_player_hb失败: " . $e->getMessage());
|
||
}
|
||
|
||
$now = time();
|
||
|
||
// ★ 心跳检测:如果超过120秒没收到数据,清空在线表(防止Java推送异常残留旧数据)
|
||
try {
|
||
$hbStmt = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
|
||
$hbRow = $hbStmt->fetchArray(SQLITE3_ASSOC);
|
||
if ($hbRow !== false && $hbRow['last_seen'] > 0) {
|
||
$lastHb = (int)$hbRow['last_seen'];
|
||
if (($now - $lastHb) > 120) {
|
||
@error_log("[syncOnlinePlayers] Heartbeat stale: " . ($now - $lastHb) . "s > 120s, clearing online_players");
|
||
$db->exec("DELETE FROM online_players");
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// ignore
|
||
}
|
||
|
||
$playerCount = count($players);
|
||
|
||
// ★ Debug: 记录接收到的玩家数据
|
||
$playerNames = array_map(function($p) { return $p['name'] ?? '?'; }, $players);
|
||
$playerTimes = array_map(function($p) { return $p['login_time'] ?? 0; }, $players);
|
||
@error_log("[syncOnlinePlayers] Received $playerCount players: " . implode(', ', $playerNames));
|
||
@error_log("[syncOnlinePlayers] Login times: " . implode(', ', $playerTimes) . " | Server time: $now");
|
||
|
||
// ★ 确保 player_ip_changes 表存在
|
||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
|
||
player_name TEXT PRIMARY KEY,
|
||
old_ip TEXT DEFAULT '',
|
||
new_ip TEXT NOT NULL,
|
||
changed_at INTEGER NOT NULL,
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// ★ 使用事务批量写入,防止 "database is locked"
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
// 删除所有旧记录,然后重新插入(避免残留旧数据)
|
||
$db->exec("DELETE FROM online_players");
|
||
|
||
// 逐条插入
|
||
$synced = 0;
|
||
foreach ($players as $player) {
|
||
if (empty($player['name'])) continue;
|
||
$name = $player['name'];
|
||
$loginTime = isset($player['login_time']) ? (int)$player['login_time'] : $now;
|
||
try {
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO online_players (player_name, login_time) VALUES (:name, :time)");
|
||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', $loginTime, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$synced++;
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncOnlinePlayers] FAIL insert $name: " . $e->getMessage());
|
||
}
|
||
|
||
// ★ 每小时活跃玩家追踪:记录曾上线的玩家(离线不清空,跨小时清理)
|
||
try {
|
||
$hourKey = date('Y-m-d H', $now);
|
||
$haStmt = $db->prepare("INSERT INTO hourly_active_players (player_name, first_seen, last_seen, hour_key)
|
||
VALUES (:name, :now, :now, :hk)
|
||
ON CONFLICT(player_name) DO UPDATE SET
|
||
last_seen = :now2,
|
||
hour_key = CASE WHEN excluded.hour_key > hourly_active_players.hour_key THEN excluded.hour_key ELSE hourly_active_players.hour_key END,
|
||
first_seen = CASE WHEN excluded.hour_key > hourly_active_players.hour_key THEN :now3 ELSE hourly_active_players.first_seen END");
|
||
$haStmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$haStmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||
$haStmt->bindValue(':now2', $now, SQLITE3_INTEGER);
|
||
$haStmt->bindValue(':now3', $now, SQLITE3_INTEGER);
|
||
$haStmt->bindValue(':hk', $hourKey, SQLITE3_TEXT);
|
||
$haStmt->execute();
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncOnlinePlayers] hourly_active记录失败 {$name}: " . $e->getMessage());
|
||
}
|
||
|
||
// ★ 如果Java推送了IP,同步更新 player_ip_changes(实时IP来源)
|
||
$ip = $player['ip'] ?? '';
|
||
if (!empty($ip)) {
|
||
try {
|
||
// 检查是否已有记录且IP相同
|
||
$checkStmt = $db->prepare("SELECT new_ip FROM player_ip_changes WHERE player_name = :name");
|
||
$checkStmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$checkResult = $checkStmt->execute();
|
||
$existing = $checkResult->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$existing || $existing['new_ip'] !== $ip) {
|
||
$oldIp = $existing ? $existing['new_ip'] : '';
|
||
$ipStmt = $db->prepare("INSERT OR REPLACE INTO player_ip_changes (player_name, old_ip, new_ip, changed_at, synced_at) VALUES (:name, :old, :new, :time, :synced)");
|
||
$ipStmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$ipStmt->bindValue(':old', $oldIp, SQLITE3_TEXT);
|
||
$ipStmt->bindValue(':new', $ip, SQLITE3_TEXT);
|
||
$ipStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$ipStmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||
$ipStmt->execute();
|
||
@error_log("[syncOnlinePlayers] IP更新: {$name} {$oldIp}→{$ip}");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncOnlinePlayers] IP写入失败 {$name}: " . $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新心跳时间戳(使用INSERT OR REPLACE,有主键则更新,无则插入)
|
||
try {
|
||
$hbSql = $db->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :time)");
|
||
$hbSql->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$hbSql->execute();
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncOnlinePlayers] heartbeat update error: " . $e->getMessage());
|
||
}
|
||
|
||
// ★ 每小时清理:跨小时时,删除上一个小时不再在线的玩家
|
||
try {
|
||
$currentHourKey = date('Y-m-d H', $now);
|
||
$prevHourKey = date('Y-m-d H', $now - 3600);
|
||
// 只在刚进入新小时的前5分钟执行清理(避免每次推送都扫表)
|
||
$minuteOfHour = (int)date('i', $now);
|
||
if ($minuteOfHour < 5) {
|
||
// 删除上一个小时及更早的玩家(他们已不在本小时内)
|
||
$delStmt = $db->prepare("DELETE FROM hourly_active_players WHERE hour_key < :hk");
|
||
$delStmt->bindValue(':hk', $currentHourKey, SQLITE3_TEXT);
|
||
$delStmt->execute();
|
||
$deleted = $db->changes();
|
||
if ($deleted > 0) {
|
||
@error_log("[syncOnlinePlayers] ★ 跨小时清理:删除" . $deleted . "条上一个小时的活跃记录");
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log("[syncOnlinePlayers] hourly_active清理失败: " . $e->getMessage());
|
||
}
|
||
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[syncOnlinePlayers] Transaction failed: " . $e->getMessage());
|
||
}
|
||
|
||
// ★ 验证:查询刚插入的数据
|
||
$verifyStmt = $db->query("SELECT COUNT(*) as cnt FROM online_players");
|
||
$verifyRow = $verifyStmt->fetchArray(SQLITE3_ASSOC);
|
||
@error_log("[syncOnlinePlayers] Synced $synced/$playerCount, DB now has " . ($verifyRow['cnt'] ?? 0) . " records");
|
||
|
||
// ★ 3.11内测版:在线曲线快照统一采集(名单有变化的全量同步路径)
|
||
onlineCurveEnsureSnapshot(false, 'sync');
|
||
|
||
success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家");
|
||
}
|
||
|
||
// ===== 在线心跳(2026-10-06 新增,Java 轻量保活端点)=====
|
||
/**
|
||
* 背景:admin 后台显示在线人数前会检查 online_player_hb(120 秒内才算新鲜),
|
||
* 而这个心跳**只在 sync_online_players 被调用时**才更新。
|
||
* 2026-10-06 起 Java 端 syncOnlinePlayers() 走了「双向对账」:在线名单与 PHP
|
||
* 完全一致时一条数据都不发就 return —— 于是玩家稳定在线、名单不变时,PHP 永远
|
||
* 收不到 sync_online_players → 心跳停在 120 秒前 → 后台强制显示 0 人,
|
||
* 而 online_players 表里的人还在(清表动作本身也在 sync_online_players 里),
|
||
* 所以下一轮对账仍判「一致」→ 仍然不发 → **永不自愈**。
|
||
*
|
||
* 本端点就是给那条「零发送」路径用的保活出口:只刷心跳时间戳,绝不改 online_players
|
||
* 的数据(名单没变本来就不该动),请求体极小(GET + secret),不会被 URL 长度限制打回。
|
||
*
|
||
* 心跳语义 = 「Java 插件还活着、且它手上的在线名单与 PHP 一致」,
|
||
* 因此收到心跳即认为在线数据可信,admin 侧照常展示 online_players 现有内容。
|
||
*/
|
||
function onlineHeartbeat() {
|
||
$secret = getParam('secret');
|
||
$token = getParam('token');
|
||
// 与 syncOnlinePlayers 同款认证:SECRET_KEY 或 sync 用途 token 都接受
|
||
$authed = false;
|
||
if ($secret && $secret === SECRET_KEY) {
|
||
$authed = true;
|
||
} elseif ($token) {
|
||
$info = validateToken($token);
|
||
if ($info && ($info['purpose'] === 'admin' || $info['purpose'] === 'all' || $info['purpose'] === 'sync')) {
|
||
$authed = true;
|
||
}
|
||
}
|
||
if (!$authed) error('认证失败');
|
||
|
||
$now = time();
|
||
$fresh = 0;
|
||
$count = 0;
|
||
try {
|
||
$db = getDB();
|
||
$db->exec('PRAGMA busy_timeout=5000');
|
||
$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)");
|
||
|
||
// ★ 只刷新心跳,不动 online_players:名单是否变化由 Java 端对账裁决,
|
||
// 这里插手反而会把「Java 报的人数」和「对账结果」搅在一起。
|
||
$hb = $db->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :t)");
|
||
$hb->bindValue(':t', $now, SQLITE3_INTEGER);
|
||
$hb->execute();
|
||
|
||
$r = $db->query("SELECT COUNT(*) AS cnt FROM online_players");
|
||
$row = $r ? $r->fetchArray(SQLITE3_ASSOC) : null;
|
||
$count = (int)($row['cnt'] ?? 0);
|
||
$fresh = $now;
|
||
} catch (\Throwable $e) {
|
||
@error_log("[onlineHeartbeat] 刷新失败: " . $e->getMessage());
|
||
// 心跳失败不致命:Java 下轮仍会重试
|
||
error('心跳写入失败: ' . $e->getMessage());
|
||
}
|
||
|
||
// ★ 3.11内测版:在线曲线快照统一采集(名单无变化的轻量保活路径——
|
||
// 玩家稳定在线时 Java 只走这条出口,采集必须挂这里才不会整段丢样本)
|
||
onlineCurveEnsureSnapshot(false, 'heartbeat');
|
||
|
||
success([
|
||
'last_seen' => $fresh,
|
||
'server_time' => $now,
|
||
'online_count'=> $count,
|
||
// 供 Java 端判断自己是否已超过 PHP 的新鲜阈值(120 秒)
|
||
'hb_ttl' => 120,
|
||
], 'ok');
|
||
}
|
||
|
||
// ===== 充值回执补推(Java插件 → PHP,一式两份 · 多退少补) =====
|
||
/**
|
||
* ★ 2026-10-06 Bug1 热同步的 PHP 接收端。
|
||
*
|
||
* 背景:充值流水一式两份 —— PHP 持 web_transactions(type=recharge/pay_recharge),
|
||
* Java 持 web_tx_receipts 镜像。运行中 PHP 后台数据被清空时,Java 侧对账点出差集
|
||
* (PHP 缺、Java 有)后把缺的行 POST 到这里重建,实现「多退少补」的热回补。
|
||
*
|
||
* 三条铁律(改动前先读完):
|
||
* 1. status 一律写死 'processed'。这些流水 Java 早已 confirm 过(镜像只在 confirm
|
||
* 之后落),写成 pending 会被 pull_pending_transactions 再拉一次 → 重复发货/重复加钱。
|
||
* 2. INSERT OR REPLACE 按主键 id 幂等:重复补推、并发补推都不会产生重复流水。
|
||
* 注意 web_transactions.id 是显式主键(非 AUTOINCREMENT 语义冲突),Java 推来的
|
||
* id 就是原行 id,直接带上即可;id 与现存行撞车时整行替换 —— 指纹哈希已保证
|
||
* 撞车行在对账口径下就是同一条。
|
||
* 3. 只收 type IN ('recharge','pay_recharge'):与 alignmentCatConfig('webtx') 的 where
|
||
* 严格一致。收了别的类型,该行永远落在对账集合之外 → 下轮仍判 missing → 无限补推。
|
||
*
|
||
* 认证与 syncDailyLogins 同款(SECRET_KEY),请求体 {"secret":...,"receipts":[...]}。
|
||
*/
|
||
function syncWebTxReceipts() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$receipts = getParam('receipts');
|
||
if ($receipts === null) {
|
||
// POST body 走 php://input(与 syncDailyLogins 同款双通道)
|
||
$raw = file_get_contents('php://input');
|
||
if ($raw) {
|
||
$decoded = json_decode($raw, true);
|
||
if (isset($decoded['receipts'])) $receipts = $decoded['receipts'];
|
||
}
|
||
}
|
||
if ($receipts === null || !is_array($receipts)) {
|
||
error('缺少receipts数据');
|
||
}
|
||
if (count($receipts) === 0) {
|
||
success(['inserted' => 0, 'skipped' => 0], '无需补推');
|
||
return;
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec('PRAGMA busy_timeout=6000');
|
||
// 表随数据一起被清掉/删表的极端情况:先确保表在,再谈补推
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||
// processed_at 列可能在旧库缺失(历史 ALTER 迁移路径),补齐防 INSERT 报错
|
||
$hasProcessedAt = false;
|
||
$pr = $db->query("PRAGMA table_info(web_transactions)");
|
||
while ($pr && ($c = $pr->fetchArray(SQLITE3_ASSOC))) {
|
||
if (isset($c['name']) && $c['name'] === 'processed_at') { $hasProcessedAt = true; break; }
|
||
}
|
||
if ($pr) $pr->finalize();
|
||
if (!$hasProcessedAt) {
|
||
try { $db->exec("ALTER TABLE web_transactions ADD COLUMN processed_at INTEGER"); }
|
||
catch (\Throwable $e) { /* 并发下另一请求已加过,忽略 */ }
|
||
}
|
||
|
||
$now = time();
|
||
$inserted = 0;
|
||
$skipped = 0;
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_transactions (id, player_name, type, amount, operator, reason, detail, status, created_at, processed_at) VALUES (:id, :player, :type, :amount, :operator, :reason, :detail, 'processed', :ctime, :ptime)");
|
||
foreach ($receipts as $r) {
|
||
if (!is_array($r)) { $skipped++; continue; }
|
||
$id = (int)($r['id'] ?? 0);
|
||
$player = trim((string)($r['player_name'] ?? ''));
|
||
$type = (string)($r['type'] ?? '');
|
||
$amount = (int)($r['amount'] ?? 0);
|
||
$detail = (string)($r['detail'] ?? '');
|
||
$ctime = (int)($r['created_at'] ?? $now);
|
||
|
||
// 铁律3:类型必须落在 webtx 对账 where 里,否则补了也白补(还会无限补推)
|
||
if ($id <= 0 || $player === '' || !in_array($type, array('recharge', 'pay_recharge'), true)) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
if ($ctime <= 0) $ctime = $now;
|
||
|
||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
// operator/reason 是 Java 镜像没有的展示字段:按正常充值流水的口径补默认值,
|
||
// 不参与指纹(hashcols 只含 player_name/type/amount/detail),不影响对账。
|
||
$stmt->bindValue(':operator', 'Java回执补推', SQLITE3_TEXT);
|
||
$stmt->bindValue(':reason', '充值流水回补(一式两份)', SQLITE3_TEXT);
|
||
$stmt->bindValue(':detail', $detail, SQLITE3_TEXT);
|
||
$stmt->bindValue(':ctime', $ctime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':ptime', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$inserted++;
|
||
}
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[syncWebTxReceipts] Error: " . $e->getMessage());
|
||
error('回执补推失败: ' . $e->getMessage());
|
||
}
|
||
|
||
if ($inserted > 0) {
|
||
@error_log("[syncWebTxReceipts] Java补推充值流水 " . $inserted . " 条(跳过 " . $skipped . " 条),status 均为 processed");
|
||
}
|
||
success(['inserted' => $inserted, 'skipped' => $skipped], "补推{$inserted}条充值流水");
|
||
}
|
||
|
||
// ===== 同步每日登录记录(Java插件推送) =====
|
||
function syncDailyLogins() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$logins = getParam('logins');
|
||
if ($logins === null) {
|
||
// 尝试从POST body解析
|
||
$raw = file_get_contents('php://input');
|
||
if ($raw) {
|
||
$decoded = json_decode($raw, true);
|
||
if (isset($decoded['logins'])) $logins = $decoded['logins'];
|
||
}
|
||
}
|
||
if ($logins === null || !is_array($logins)) {
|
||
error('缺少logins数据');
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
$count = 0;
|
||
foreach ($logins as $entry) {
|
||
$name = $entry['name'] ?? '';
|
||
$date = $entry['date'] ?? date('Y-m-d');
|
||
$firstLogin = $entry['first_login'] ?? time();
|
||
$lastLogin = $entry['last_login'] ?? time();
|
||
if (!$name) continue;
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
|
||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$stmt->bindValue(':date', $date, SQLITE3_TEXT);
|
||
$stmt->bindValue(':first', (int)$firstLogin, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':last', (int)$lastLogin, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
$db->exec("COMMIT");
|
||
success(['synced' => $count], "同步了{$count}条登录记录");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[syncDailyLogins] Error: " . $e->getMessage());
|
||
error('同步失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 同步签到记录(Java插件推送) =====
|
||
function syncCheckins() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$checkins = getParam('checkins');
|
||
if ($checkins === null) {
|
||
$raw = file_get_contents('php://input');
|
||
if ($raw) {
|
||
$decoded = json_decode($raw, true);
|
||
if (isset($decoded['checkins'])) $checkins = $decoded['checkins'];
|
||
}
|
||
}
|
||
if ($checkins === null || !is_array($checkins)) {
|
||
error('缺少checkins数据');
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
$count = 0;
|
||
foreach ($checkins as $entry) {
|
||
$name = $entry['name'] ?? '';
|
||
$date = $entry['date'] ?? date('Y-m-d');
|
||
$time = $entry['time'] ?? time();
|
||
if (!$name) continue;
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO player_checkins (player_name, checkin_date, checkin_time) VALUES (:name, :date, :time)");
|
||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$stmt->bindValue(':date', $date, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', (int)$time, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
$db->exec("COMMIT");
|
||
success(['synced' => $count], "同步了{$count}条签到记录");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[syncCheckins] Error: " . $e->getMessage());
|
||
error('同步失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 同步所有玩家IP到PHP端(带变更检测) =====
|
||
function syncPlayerIps() {
|
||
$db = getDB();
|
||
|
||
// ★ 解析参数
|
||
$secret = getParam('secret');
|
||
$players = getParam('players');
|
||
|
||
if (!$secret && isset($_POST['secret'])) {
|
||
$secret = $_POST['secret'];
|
||
}
|
||
if (($players === null || !is_array($players)) && isset($_POST['players'])) {
|
||
$pVal = $_POST['players'];
|
||
if (is_string($pVal)) {
|
||
$pVal = json_decode($pVal, true);
|
||
}
|
||
if (is_array($pVal)) $players = $pVal;
|
||
}
|
||
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
if ($players === null || !is_array($players)) {
|
||
error('缺少players数据');
|
||
}
|
||
|
||
// 创建IP变更日志表(用于追踪IP变化)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
|
||
player_name TEXT PRIMARY KEY,
|
||
old_ip TEXT DEFAULT '',
|
||
new_ip TEXT NOT NULL,
|
||
changed_at INTEGER NOT NULL,
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 创建IP归属地缓存表(用于避免重复查询API)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_locations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT DEFAULT 'global',
|
||
ip_address TEXT NOT NULL,
|
||
location TEXT DEFAULT '',
|
||
updated_at INTEGER DEFAULT 0,
|
||
UNIQUE(ip_address)
|
||
)");
|
||
|
||
// 批量更新玩家的IP到online_players表(带变更检测)
|
||
$now = time();
|
||
$changedCount = 0;
|
||
$skippedCount = 0;
|
||
|
||
// ★ 事务包装防止 "database is locked"
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
foreach ($players as $player) {
|
||
if (empty($player['name']) || empty($player['ip'])) {
|
||
continue;
|
||
}
|
||
|
||
$playerName = $player['name'];
|
||
$newIp = $player['ip'];
|
||
|
||
// 检查该玩家是否有历史IP记录
|
||
$stmt = $db->prepare("SELECT new_ip, old_ip, synced_at FROM player_ip_changes WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
$hasChanged = false;
|
||
if (!$row) {
|
||
$hasChanged = true;
|
||
} elseif ($row['new_ip'] !== $newIp) {
|
||
$hasChanged = true;
|
||
}
|
||
|
||
if ($hasChanged) {
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO player_ip_changes (player_name, old_ip, new_ip, changed_at, synced_at) VALUES (:player, :old, :new, :time, :synced)");
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$stmt->bindValue(':old', $row ? $row['new_ip'] : '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':new', $newIp, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
$changedCount++;
|
||
|
||
if ($row && $row['synced_at'] > 0) {
|
||
$stmtReset = $db->prepare("UPDATE player_ip_changes SET synced_at = 0 WHERE player_name = :player");
|
||
$stmtReset->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$stmtReset->execute();
|
||
}
|
||
} else {
|
||
$skippedCount++;
|
||
}
|
||
}
|
||
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[syncPlayerIps] Transaction failed: " . $e->getMessage());
|
||
}
|
||
|
||
success([
|
||
'changed' => $changedCount,
|
||
'skipped' => $skippedCount,
|
||
'total' => count($players)
|
||
], "同步IP: {$changedCount}个变更, {$skippedCount}个跳过");
|
||
}
|
||
|
||
// ===== 插件拉取商品 =====
|
||
function pullShop() {
|
||
$db = getDB();
|
||
$result = $db->query("SELECT * FROM shop_items ORDER BY category, id");
|
||
|
||
$items = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$items[] = $row;
|
||
}
|
||
|
||
success($items);
|
||
}
|
||
|
||
// ===== 插件拉取债券修改 =====
|
||
function pullBonds() {
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
|
||
// 支持两种认证方式:token或SECRET_KEY
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
error('拉取需要管理权限token');
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('认证失败');
|
||
} else {
|
||
error('缺少认证参数');
|
||
}
|
||
|
||
$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)");
|
||
|
||
$result = $db->query("SELECT * FROM bond_cache ORDER BY updated_at DESC");
|
||
|
||
$bonds = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$bonds[] = $row;
|
||
}
|
||
|
||
success($bonds);
|
||
}
|
||
|
||
// ===== 插件通过SECRET_KEY注册Token到PHP数据库 =====
|
||
function receiveToken() {
|
||
$secret = getParam('secret');
|
||
if ($secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$tokens = getParam('tokens');
|
||
if (!$tokens || !is_array($tokens)) {
|
||
error('缺少tokens数组');
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec('PRAGMA busy_timeout=10000');
|
||
$now = time();
|
||
$count = 0;
|
||
|
||
try {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
foreach ($tokens as $t) {
|
||
$tokenVal = $t['token'] ?? null;
|
||
$player = $t['player'] ?? 'system';
|
||
$purpose = $t['purpose'] ?? 'general';
|
||
$expireSeconds = (int)($t['expire_seconds'] ?: 600);
|
||
|
||
if (!$tokenVal) continue;
|
||
|
||
$stmt = $db->prepare("INSERT OR IGNORE INTO tokens (token, player_name, purpose, created_at, expires_at, used) VALUES (:token, :player, :purpose, :created, :expires, 0)");
|
||
$stmt->bindValue(':token', $tokenVal, SQLITE3_TEXT);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':purpose', $purpose, SQLITE3_TEXT);
|
||
$stmt->bindValue(':created', $now, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':expires', $now + $expireSeconds, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
$db->exec('COMMIT');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||
@error_log("[receiveToken] Exception: " . $e->getMessage());
|
||
error('Token注册失败: ' . $e->getMessage());
|
||
}
|
||
|
||
success(['registered' => $count], "注册了{$count}个Token");
|
||
}
|
||
|
||
// ===== 插件同步Token到PHP数据库(通过已有sync token) =====
|
||
function syncToken() {
|
||
// ★ 支持token或SECRET_KEY认证
|
||
$token = getParam('token');
|
||
$secret = getParam('secret');
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync')) {
|
||
if (!($secret && $secret === SECRET_KEY)) {
|
||
error('同步需要管理权限token或SECRET_KEY');
|
||
}
|
||
}
|
||
} elseif ($secret) {
|
||
if ($secret !== SECRET_KEY) error('密钥验证失败', 403);
|
||
} else {
|
||
error('同步需要token或SECRET_KEY');
|
||
}
|
||
|
||
$newToken = getParam('new_token');
|
||
$player = getParam('player');
|
||
$purpose = getParam('purpose', 'general');
|
||
$expireSeconds = (int)(getParam('expire_seconds') ?: 600);
|
||
|
||
if (!$newToken || !$player) {
|
||
error('缺少new_token或player参数');
|
||
}
|
||
|
||
$db = getDB();
|
||
$now = time();
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO tokens (token, player_name, purpose, created_at, expires_at, used) VALUES (:token, :player, :purpose, :created, :expires, 0)");
|
||
$stmt->bindValue(':token', $newToken, SQLITE3_TEXT);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':purpose', $purpose, SQLITE3_TEXT);
|
||
$stmt->bindValue(':created', $now, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':expires', $now + $expireSeconds, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
success(['token' => substr($newToken, 0, 8) . '...'], "Token已同步");
|
||
}
|
||
|
||
// ===== 验证Web登录Token(不消费,仅验证有效性)=====
|
||
function syncWebloginToken() {
|
||
// 支持两种认证方式:SECRET_KEY(插件直接调用)或 token(通用认证)
|
||
$secret = getParam('secret');
|
||
$token = getParam('token');
|
||
|
||
if ($secret) {
|
||
// SECRET_KEY认证(插件端直接调用,无需中间sync token)
|
||
if ($secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
} elseif ($token) {
|
||
// Token认证(兼容旧方式)
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all' && $tokenInfo['purpose'] !== 'sync' && $tokenInfo['purpose'] !== 'weblogin')) {
|
||
error('同步需要管理权限token');
|
||
}
|
||
} else {
|
||
error('缺少secret或token参数');
|
||
}
|
||
|
||
$player = getParam('player');
|
||
$webToken = getParam('web_token');
|
||
$expireSeconds = (int)(getParam('expire_seconds') ?: 600);
|
||
|
||
if (!$player || !$webToken) {
|
||
error('缺少player或web_token参数');
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)");
|
||
|
||
$now = time();
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':expire', $expireSeconds, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
success(['player' => $player], "Web登录Token已同步");
|
||
}
|
||
|
||
// ===== 验证Web登录Token(用于前端登录页面API)=====
|
||
function apiValidateWebloginToken() {
|
||
$webToken = getParam('web_token');
|
||
if (!$webToken) {
|
||
error('缺少web_token参数');
|
||
}
|
||
|
||
$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) {
|
||
error('无效的登录Token');
|
||
}
|
||
|
||
$createdAt = (int)$row['created_at'];
|
||
$expireSeconds = (int)$row['expire_seconds'];
|
||
if (time() - $createdAt > $expireSeconds) {
|
||
error('登录Token已过期');
|
||
}
|
||
|
||
// ★ 封禁拦截:被封禁玩家禁止登录Web后台(永久封禁/未过期封禁均拦截)
|
||
$banReason = playerWebBanReason($row['player_name'], $db);
|
||
if ($banReason !== null) {
|
||
error('账号已被封禁:' . $banReason);
|
||
}
|
||
|
||
// ★ 第32批拉黑拦截:被拉黑玩家禁止登录Web后台
|
||
$blackReason = playerWebBlacklistReason($row['player_name'], $db);
|
||
if ($blackReason !== null) {
|
||
error('你已被拉黑:' . $blackReason);
|
||
}
|
||
|
||
success(['player' => $row['player_name']], 'Token有效');
|
||
} catch (\Throwable $e) {
|
||
error('Token验证失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 推送玩家登录状态到PHP(Java插件调用)=====
|
||
function pushPlayerLoginStatus() {
|
||
// 优先从GET参数读取(Java端使用GET请求)
|
||
if (isset($_GET['secret'])) {
|
||
$secret = $_GET['secret'];
|
||
} elseif (isset($_POST['secret'])) {
|
||
$secret = $_POST['secret'];
|
||
} else {
|
||
$secret = getParam('secret');
|
||
}
|
||
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
if (isset($_GET['player'])) {
|
||
$player = $_GET['player'];
|
||
} elseif (isset($_POST['player'])) {
|
||
$player = $_POST['player'];
|
||
} else {
|
||
$player = getParam('player');
|
||
}
|
||
|
||
if (isset($_GET['web_token'])) {
|
||
$webToken = $_GET['web_token'];
|
||
} elseif (isset($_POST['web_token'])) {
|
||
$webToken = $_POST['web_token'];
|
||
} else {
|
||
$webToken = getParam('web_token');
|
||
}
|
||
|
||
$expireSeconds = 600;
|
||
if (isset($_GET['expire_seconds'])) {
|
||
$expireSeconds = (int)$_GET['expire_seconds'];
|
||
} elseif (isset($_POST['expire_seconds'])) {
|
||
$expireSeconds = (int)$_POST['expire_seconds'];
|
||
} else {
|
||
$val = getParam('expire_seconds');
|
||
$expireSeconds = $val ? (int)$val : 600;
|
||
}
|
||
|
||
$isOnline = 0;
|
||
if (isset($_GET['online'])) {
|
||
$isOnline = (int)$_GET['online'];
|
||
} elseif (isset($_POST['online'])) {
|
||
$isOnline = (int)$_POST['online'];
|
||
} else {
|
||
$val = getParam('online');
|
||
$isOnline = $val ? (int)$val : 0;
|
||
}
|
||
|
||
$isRegistered = 0;
|
||
if (isset($_GET['registered'])) {
|
||
$isRegistered = (int)$_GET['registered'];
|
||
} elseif (isset($_POST['registered'])) {
|
||
$isRegistered = (int)$_POST['registered'];
|
||
} else {
|
||
$val = getParam('registered');
|
||
$isRegistered = $val ? (int)$val : 0;
|
||
}
|
||
|
||
// ★ 读取玩家IP地址(Java端push_player_login_status携带)
|
||
$playerIp = '';
|
||
if (isset($_GET['ip'])) {
|
||
$playerIp = $_GET['ip'];
|
||
} elseif (isset($_POST['ip'])) {
|
||
$playerIp = $_POST['ip'];
|
||
} else {
|
||
$playerIp = getParam('ip') ?: '';
|
||
}
|
||
|
||
// ★ 读取login_verified参数:Java生成token时标记玩家已通过密码验证
|
||
$loginVerified = 0;
|
||
if (isset($_GET['login_verified'])) {
|
||
$loginVerified = (int)$_GET['login_verified'];
|
||
} elseif (isset($_POST['login_verified'])) {
|
||
$loginVerified = (int)$_POST['login_verified'];
|
||
} else {
|
||
$val = getParam('login_verified');
|
||
$loginVerified = $val ? (int)$val : 0;
|
||
}
|
||
|
||
// ★ 第56批:读取邮箱后缀配置(Java端配置为唯一权威)
|
||
$emailSuffixMode = (string)getParam('email_suffix_mode', '默认');
|
||
$emailSuffixList = (string)getParam('email_suffix_list', '');
|
||
|
||
// ★ 存储后缀配置(供PHP注册/绑邮箱时校验)
|
||
$cfgDb = getDB();
|
||
$cfgDb->exec("CREATE TABLE IF NOT EXISTS email_suffix_config (id INTEGER PRIMARY KEY DEFAULT 1, mode TEXT DEFAULT '默认', suffix_list TEXT DEFAULT '', updated_at INTEGER DEFAULT 0)");
|
||
$cfgUpd = $cfgDb->prepare("INSERT OR REPLACE INTO email_suffix_config (id, mode, suffix_list, updated_at) VALUES (1, :mode, :list, :time)");
|
||
$cfgUpd->bindValue(':mode', $emailSuffixMode, SQLITE3_TEXT);
|
||
$cfgUpd->bindValue(':list', $emailSuffixList, SQLITE3_TEXT);
|
||
$cfgUpd->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$cfgUpd->execute();
|
||
|
||
if (!$player || !$webToken) {
|
||
@error_log("[pushPlayerLoginStatus] FAIL: missing player or web_token. player=" . var_export($player, true) . ", web_token=" . var_export($webToken, true));
|
||
error('缺少player或web_token参数');
|
||
}
|
||
|
||
@error_log("[pushPlayerLoginStatus] SUCCESS: player=$player, token=" . substr($webToken, 0, 20) . "..., online=$isOnline, registered=$isRegistered, expire=$expireSeconds");
|
||
|
||
$db = getDB();
|
||
$now = time();
|
||
|
||
// ★ 事务包裹所有写操作,防止 "database is locked"
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
// 1. 同步weblogin_tokens表(登录页面需要的token)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':expire', $expireSeconds, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 1.5 ★ 如果Java标记login_verified=1,写入web_login_verified表(安全锁:Java验证过的玩家PHP直接放行)
|
||
if ($loginVerified) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
$verifiedStmt = $db->prepare("INSERT OR REPLACE INTO web_login_verified (player_name, verified_at) VALUES (:player, :time)");
|
||
$verifiedStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$verifiedStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$verifiedStmt->execute();
|
||
@error_log("[pushPlayerLoginStatus] 写入web_login_verified: player=$player (Java已验证)");
|
||
}
|
||
// ★ login_verified=0:Java标记未登录,清除旧记录防止Web端误放行
|
||
else {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
$delStmt = $db->prepare("DELETE FROM web_login_verified WHERE player_name = :player");
|
||
$delStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$delStmt->execute();
|
||
@error_log("[pushPlayerLoginStatus] 清除web_login_verified: player=$player (Java未登录)");
|
||
}
|
||
|
||
// 2. 如果玩家在游戏中已登录,更新online_players表(只更新已存在的记录,不凭空插入)
|
||
if ($isOnline) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
|
||
// ★ 只更新已存在的玩家,不插入新玩家(在线状态只由syncOnlinePlayers管理)
|
||
$updateStmt = $db->prepare("UPDATE online_players SET login_time = :time WHERE player_name = :player");
|
||
$updateStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$updateStmt->execute();
|
||
|
||
// 3. 同时更新web_session_log,标记Web会话活跃
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_session_log (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL, ip_address TEXT DEFAULT '')");
|
||
$sessionStmt = $db->prepare("INSERT OR REPLACE INTO web_session_log (player_name, login_time, ip_address) VALUES (:player, :time, :ip)");
|
||
$sessionStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$sessionStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$sessionStmt->bindValue(':ip', !empty($playerIp) ? $playerIp : 'plugin_sync', SQLITE3_TEXT);
|
||
$sessionStmt->execute();
|
||
|
||
// ★ 如果有IP,更新player_ip_changes表
|
||
if (!empty($playerIp)) {
|
||
try {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_changes (
|
||
player_name TEXT PRIMARY KEY,
|
||
old_ip TEXT DEFAULT '',
|
||
new_ip TEXT NOT NULL,
|
||
changed_at INTEGER NOT NULL,
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
$checkStmt = $db->prepare("SELECT new_ip FROM player_ip_changes WHERE player_name = :player");
|
||
$checkStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$checkResult = $checkStmt->execute();
|
||
$existing = $checkResult->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$existing || $existing['new_ip'] !== $playerIp) {
|
||
$oldIp = $existing ? $existing['new_ip'] : '';
|
||
$ipStmt = $db->prepare("INSERT OR REPLACE INTO player_ip_changes (player_name, old_ip, new_ip, changed_at, synced_at) VALUES (:name, :old, :new, :time, :synced)");
|
||
$ipStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||
$ipStmt->bindValue(':old', $oldIp, SQLITE3_TEXT);
|
||
$ipStmt->bindValue(':new', $playerIp, SQLITE3_TEXT);
|
||
$ipStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$ipStmt->bindValue(':synced', $now, SQLITE3_INTEGER);
|
||
$ipStmt->execute();
|
||
@error_log("[pushPlayerLoginStatus] IP更新: {$player} {$oldIp}→{$playerIp}");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log("[pushPlayerLoginStatus] IP写入失败 {$player}: " . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ★ 记录每日登录(游戏内登录也算)
|
||
$today = date('Y-m-d');
|
||
$dlStmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
|
||
$dlStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||
$dlStmt->bindValue(':date', $today, SQLITE3_TEXT);
|
||
$dlStmt->bindValue(':first', $now, SQLITE3_INTEGER);
|
||
$dlStmt->bindValue(':last', $now, SQLITE3_INTEGER);
|
||
$dlStmt->execute();
|
||
|
||
// 如果已注册,更新users表的last_login_time
|
||
if ($isRegistered) {
|
||
try {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS users (player_name TEXT PRIMARY KEY, last_login_time INTEGER DEFAULT 0)");
|
||
$userStmt = $db->prepare("UPDATE users SET last_login_time = :time WHERE player_name = :player");
|
||
$userStmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$userStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$userStmt->execute();
|
||
} catch (\Throwable $e) {
|
||
// users表可能不存在或其他错误,忽略
|
||
}
|
||
}
|
||
}
|
||
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[pushPlayerLoginStatus] Transaction failed: " . $e->getMessage());
|
||
// 仍然返回成功,因为token已写入(或即将写入),不让Java端重试
|
||
}
|
||
|
||
success(['player' => $player], '玩家登录状态已同步');
|
||
}
|
||
|
||
// ===== 安全防线:作废WebLogin Token(公屏泄露时Java→PHP联控)=====
|
||
function revokeWebloginToken() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$token = getParam('token');
|
||
if (!$token) {
|
||
error('缺少token参数');
|
||
}
|
||
|
||
$db = getDB();
|
||
|
||
// 从weblogin_tokens表删除
|
||
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)");
|
||
$stmt = $db->prepare("DELETE FROM weblogin_tokens WHERE web_token = :token");
|
||
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
|
||
// 同时清理web_login_verified表中的该玩家记录(防止已泄露的token自动登录)
|
||
if ($stmt->changes() > 0) {
|
||
// 通过token反查player_name
|
||
$findStmt = $db->prepare("SELECT player_name FROM weblogin_tokens WHERE web_token = :token");
|
||
$findStmt->bindValue(':token', $token, SQLITE3_TEXT);
|
||
// token已被删除,无法反查——直接清理所有过期token即可
|
||
}
|
||
|
||
// 清理过期token(顺便)
|
||
$db->exec("DELETE FROM weblogin_tokens WHERE created_at < " . (time() - 86400));
|
||
|
||
success([], 'Token已作废');
|
||
}
|
||
|
||
// ===== 插件轮询Web登录确认(SECRET_KEY认证)=====
|
||
// 插件定期调用此接口,获取已通过Web验证的玩家列表,用于自动登录游戏
|
||
// ★ 修复:增加时间窗口验证,防止旧记录导致误自动登录
|
||
function checkWebLoginConfirmations() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)");
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
|
||
// ★ 不使用BEGIN IMMEDIATE(纯读+更新,减少锁冲突)
|
||
try {
|
||
// ★ 清理超过10分钟的过期确认记录
|
||
$expireTime = time() - 600;
|
||
$db->exec("DELETE FROM web_login_confirmations WHERE confirmed_at < " . $expireTime);
|
||
|
||
// 获取所有未消费且未过期的确认记录
|
||
$stmt = $db->prepare("SELECT player_name, confirmed_at FROM web_login_confirmations WHERE consumed = 0 AND confirmed_at >= :expire");
|
||
$stmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
$result = $stmt->execute();
|
||
|
||
$confirmations = [];
|
||
$playerNames = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$confirmations[] = $row;
|
||
$playerNames[] = $row['player_name'];
|
||
}
|
||
|
||
// ★ 为每个玩家查询web_login_verified状态(供Java判断是否信任此确认)
|
||
if (!empty($confirmations)) {
|
||
$verifiedStmt = $db->prepare("SELECT verified_at FROM web_login_verified WHERE player_name = :player AND verified_at >= :expire");
|
||
$verifiedStmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
foreach ($confirmations as &$conf) {
|
||
$verifiedStmt->bindValue(':player', $conf['player_name'], SQLITE3_TEXT);
|
||
$vResult = $verifiedStmt->execute();
|
||
$vRow = $vResult->fetchArray(SQLITE3_ASSOC);
|
||
$conf['php_verified'] = $vRow ? true : false;
|
||
$conf['php_verified_at'] = $vRow ? (int)$vRow['verified_at'] : 0;
|
||
}
|
||
unset($conf);
|
||
}
|
||
|
||
// ★ 2026-10-05 日志收敛:count=0(没有待处理的确认数据)时静默返回,不写日志。
|
||
// 该接口被 Java 每 4 秒轮询一次,count=0 占绝大多数,是 debug.log 膨胀主因之一。
|
||
// 只有真有待处理的确认记录(= 有数据可消费)或出错才写日志。
|
||
if (!empty($confirmations)) {
|
||
debugLog("checkWebLoginConfirmations: 轮询", ['count' => count($confirmations), 'players' => $playerNames]);
|
||
}
|
||
|
||
// 标记为已消费(一次性)
|
||
if (!empty($playerNames)) {
|
||
$db->exec("UPDATE web_login_confirmations SET consumed = 1 WHERE consumed = 0 AND confirmed_at >= " . $expireTime);
|
||
// 清理30分钟前的已消费记录
|
||
$db->exec("DELETE FROM web_login_confirmations WHERE consumed = 1 AND confirmed_at < " . (time() - 1800));
|
||
}
|
||
} catch (\Throwable $e) {
|
||
debugLog("checkWebLoginConfirmations: 错误", ['error' => $e->getMessage()]);
|
||
@error_log("[checkWebLoginConfirmations] error: " . $e->getMessage());
|
||
}
|
||
|
||
success($confirmations);
|
||
}
|
||
|
||
// ===== 插件检查Web登录验证记录(SECRET_KEY认证)=====
|
||
// 玩家进游戏时调用,检查是否有通过Web验证待自动登录的玩家
|
||
// ★ 修复:增加时间窗口验证,防止旧记录导致误自动登录
|
||
function checkWebLoginVerified() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$playerName = getParam('player_name');
|
||
// ★ 2026-10-05 日志收敛:"插件查询Web验证记录"只是进查询、还没有数据,不再写日志。
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
|
||
// ★ 事务包裹所有读写操作
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
// ★ 清理超过10分钟的过期验证记录(防止残留数据导致误登录)
|
||
$expireTime = time() - 600; // 10分钟
|
||
$db->exec("DELETE FROM web_login_verified WHERE verified_at < " . $expireTime);
|
||
|
||
$verified = [];
|
||
|
||
if ($playerName) {
|
||
// ★ 检查单个玩家,且验证记录必须在10分钟内
|
||
$stmt = $db->prepare("SELECT player_name, verified_at FROM web_login_verified WHERE player_name = :player AND verified_at >= :expire");
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$stmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
if ($row) {
|
||
$verified[] = $row;
|
||
// 消费验证记录(一次性)
|
||
$delStmt = $db->prepare("DELETE FROM web_login_verified WHERE player_name = :player");
|
||
$delStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$delStmt->execute();
|
||
debugLog("check_web_login_verified: 找到验证记录并消费", ['player_name' => $playerName, 'verified_at' => $row['verified_at']]);
|
||
}
|
||
// ★ 2026-10-05 日志收敛:未找到验证记录 = 没有合适数据,静默(不写日志)。
|
||
} else {
|
||
// 批量获取所有验证记录(用于轮询)
|
||
$stmt = $db->prepare("SELECT player_name, verified_at FROM web_login_verified WHERE verified_at >= :expire");
|
||
$stmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
$result = $stmt->execute();
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$verified[] = $row;
|
||
}
|
||
// 全部消费
|
||
if (!empty($verified)) {
|
||
$db->exec("DELETE FROM web_login_verified WHERE verified_at >= " . $expireTime);
|
||
debugLog("check_web_login_verified: 批量消费验证记录", ['count' => count($verified), 'players' => array_column($verified, 'player_name')]);
|
||
}
|
||
// ★ 2026-10-05 日志收敛:无验证记录 = 没有合适数据,静默(不写日志)。
|
||
}
|
||
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
debugLog("check_web_login_verified: 错误", ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
success($verified);
|
||
}
|
||
|
||
// ===== Web端提交密码登录请求 =====
|
||
// 玩家在Web端输入用户名+密码,PHP存储请求,等待插件验证
|
||
function webLoginRequest() {
|
||
$player = getParam('player');
|
||
$password = getParam('password');
|
||
|
||
if (!$player || !$password) {
|
||
error('缺少player或password参数');
|
||
}
|
||
|
||
// 简单验证玩家名格式(1-16位字母数字下划线)
|
||
if (!preg_match('/^[a-zA-Z0-9_]{1,16}$/', $player)) {
|
||
error('玩家名格式不正确(1-16位字母数字下划线)');
|
||
}
|
||
|
||
// ★ 人机验证码(2026-10-02):账密登录提交前校验;
|
||
// token 登录 / 邮箱验证码登录不经过这里,不受影响。
|
||
cgEnforce('web_login_request');
|
||
|
||
$db = getDB();
|
||
|
||
// ★ 封禁拦截:被封禁玩家禁止走密码登录
|
||
$banReason = playerWebBanReason($player, $db);
|
||
if ($banReason !== null) {
|
||
error('账号已被封禁:' . $banReason);
|
||
}
|
||
|
||
// ★ 第32批拉黑拦截:被拉黑玩家禁止走密码登录
|
||
$blackReason = playerWebBlacklistReason($player, $db);
|
||
if ($blackReason !== null) {
|
||
error('你已被拉黑:' . $blackReason);
|
||
}
|
||
|
||
// 检查玩家是否已注册
|
||
if (!isPlayerRegistered($player)) {
|
||
error('玩家未注册,请先在游戏中注册');
|
||
}
|
||
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
password TEXT NOT NULL,
|
||
request_time INTEGER NOT NULL,
|
||
status TEXT DEFAULT 'pending',
|
||
result TEXT DEFAULT '',
|
||
result_time INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// ★ 事务包裹所有写操作
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
// 清理10分钟前的过期请求
|
||
$db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600));
|
||
|
||
// 清理该玩家超过2分钟的旧pending请求(防止永久阻塞)
|
||
$cleanupStmt = $db->prepare("DELETE FROM web_login_requests WHERE player_name = :player AND status = 'pending' AND request_time < :cutoff");
|
||
$cleanupStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$cleanupStmt->bindValue(':cutoff', time() - 120, SQLITE3_INTEGER);
|
||
$cleanupStmt->execute();
|
||
|
||
// 检查是否有最近2分钟内的进行中请求
|
||
$checkStmt2 = $db->prepare("SELECT id FROM web_login_requests WHERE player_name = :player AND status = 'pending' AND request_time > :cutoff2");
|
||
$checkStmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$checkStmt2->bindValue(':cutoff2', time() - 120, SQLITE3_INTEGER);
|
||
$checkResult2 = $checkStmt2->execute();
|
||
if ($checkResult2->fetchArray()) {
|
||
$db->exec("ROLLBACK");
|
||
error('已有进行中的登录请求,请稍后再试');
|
||
}
|
||
|
||
$stmt = $db->prepare("INSERT INTO web_login_requests (player_name, password, request_time, status) VALUES (:player, :pwd, :time, 'pending')");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':pwd', $password, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
$requestId = $db->lastInsertRowID();
|
||
|
||
$db->exec("COMMIT");
|
||
debugLog("webLoginRequest: ✓ 新请求已创建", ['player' => $player, 'request_id' => $requestId, 'pwd_len' => strlen($password)]);
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[webLoginRequest] Transaction failed: " . $e->getMessage());
|
||
error('提交失败: ' . $e->getMessage());
|
||
}
|
||
|
||
debugLog("webLoginRequest: 新请求", ['player' => $player, 'request_id' => $requestId]);
|
||
|
||
success([
|
||
'request_id' => $requestId,
|
||
'player' => $player,
|
||
'message' => '登录请求已提交,等待游戏服务器验证...'
|
||
], "请求已提交");
|
||
}
|
||
|
||
// ===== 取消/清理待处理的登录请求 =====
|
||
function cancelWebLogin() {
|
||
$player = getParam('player');
|
||
if (!$player) error('缺少player参数');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
password TEXT NOT NULL,
|
||
request_time INTEGER NOT NULL,
|
||
status TEXT DEFAULT 'pending',
|
||
result TEXT DEFAULT '',
|
||
result_time INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 将该玩家所有pending请求标记为cancelled
|
||
$stmt = $db->prepare("UPDATE web_login_requests SET status = 'cancelled', result = '{\"success\":false,\"message\":\"用户取消\"}', result_time = :time WHERE player_name = :player AND status = 'pending'");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
debugLog("cancelWebLogin: 清理了玩家 $player 的pending请求");
|
||
success([], "已清理");
|
||
}
|
||
|
||
// ===== Web端轮询密码登录结果 =====
|
||
function checkWebLoginResult() {
|
||
$player = getParam('player');
|
||
$requestId = getParam('request_id');
|
||
|
||
if (!$player || !$requestId) {
|
||
error('缺少player或request_id参数');
|
||
}
|
||
|
||
$db = getDB();
|
||
try {
|
||
$stmt = $db->prepare("SELECT * FROM web_login_requests WHERE id = :id AND player_name = :player");
|
||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row) {
|
||
// 请求不存在,可能已被清理
|
||
debugLog("checkWebLoginResult: 请求不存在", ['player' => $player, 'request_id' => $requestId]);
|
||
success([
|
||
'status' => 'pending',
|
||
'message' => '请求已过期,请刷新页面重试'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
debugLog("checkWebLoginResult: 查询结果", ['player' => $player, 'request_id' => $requestId, 'status' => $row['status']]);
|
||
|
||
if ($row['status'] === 'pending') {
|
||
// 还在等待
|
||
success([
|
||
'status' => 'pending',
|
||
'message' => '等待游戏服务器验证...'
|
||
]);
|
||
} elseif ($row['status'] === 'success') {
|
||
// 验证成功 → 写入web_login_verified(允许后续player.php直接放行)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
$verifiedStmt = $db->prepare("INSERT OR REPLACE INTO web_login_verified (player_name, verified_at) VALUES (:player, :time)");
|
||
$verifiedStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$verifiedStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$verifiedStmt->execute();
|
||
debugLog("checkWebLoginResult: 写入web_login_verified", ['player' => $player]);
|
||
|
||
// ★ 生成weblogin_token返回给前端,让player.php可以继续使用
|
||
$newToken = bin2hex(random_bytes(32));
|
||
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens (player_name TEXT PRIMARY KEY, web_token TEXT NOT NULL, created_at INTEGER NOT NULL, expire_seconds INTEGER DEFAULT 600)");
|
||
$tokenStmt = $db->prepare("INSERT OR REPLACE INTO weblogin_tokens (player_name, web_token, created_at, expire_seconds) VALUES (:player, :token, :time, :expire)");
|
||
$tokenStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$tokenStmt->bindValue(':token', $newToken, SQLITE3_TEXT);
|
||
$tokenStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$tokenStmt->bindValue(':expire', 600, SQLITE3_INTEGER);
|
||
$tokenStmt->execute();
|
||
debugLog("checkWebLoginResult: 生成weblogin_token", ['player' => $player]);
|
||
|
||
$resultData = json_decode($row['result'], true);
|
||
success([
|
||
'status' => 'success',
|
||
'player' => $player,
|
||
'token' => $newToken,
|
||
'user_data' => $resultData ?? ['player_name' => $player],
|
||
'message' => '登录成功'
|
||
]);
|
||
} else {
|
||
// 验证失败
|
||
$resultData = json_decode($row['result'], true);
|
||
success([
|
||
'status' => 'failed',
|
||
'message' => $resultData['error'] ?? '密码错误'
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
error('数据库查询失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 插件轮询待处理的密码登录请求(SECRET_KEY认证)=====
|
||
function checkPendingWebLogins() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
password TEXT NOT NULL,
|
||
request_time INTEGER NOT NULL,
|
||
status TEXT DEFAULT 'pending',
|
||
result TEXT DEFAULT '',
|
||
result_time INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// ★ 只读查询不需要BEGIN IMMEDIATE(避免与其它并发请求抢锁导致500)
|
||
// 清理10分钟前的过期请求(DELETE即使失败也不影响读取pending)
|
||
try {
|
||
$db->exec("DELETE FROM web_login_requests WHERE request_time < " . (time() - 600));
|
||
} catch (\Throwable $e) {
|
||
// ★ 2026-10-05 日志收敛:报错(清理失败)写日志
|
||
debugLog("checkPendingWebLogins: 清理过期请求失败", ['error' => $e->getMessage()]);
|
||
@error_log("[checkPendingWebLogins] cleanup error: " . $e->getMessage());
|
||
}
|
||
|
||
// 获取所有pending请求
|
||
$stmt = $db->prepare("SELECT id, player_name, password FROM web_login_requests WHERE status = 'pending'");
|
||
$result = $stmt->execute();
|
||
|
||
$requests = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$requests[] = $row;
|
||
}
|
||
|
||
// ★ 2026-10-05 日志收敛:count=0(没有待处理的密码登录请求)时静默,不写日志;
|
||
// 该接口被 Java 每 4 秒轮询一次,count=0 是常态,是 debug.log 膨胀主因之一。
|
||
// 只有真有待处理请求(有数据)或出错才写日志。
|
||
if (!empty($requests)) {
|
||
debugLog("checkPendingWebLogins: 轮询", ['count' => count($requests), 'players' => array_column($requests, 'player_name')]);
|
||
}
|
||
success($requests);
|
||
}
|
||
|
||
// ===== 插件写回密码验证结果(SECRET_KEY认证)=====
|
||
function completeWebLoginRequest() {
|
||
// 优先从GET参数读取(Java端使用GET请求)
|
||
$secret = isset($_GET['secret']) ? $_GET['secret'] : (isset($_POST['secret']) ? $_POST['secret'] : getParam('secret'));
|
||
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$requestId = isset($_GET['request_id']) ? $_GET['request_id'] : (isset($_POST['request_id']) ? $_POST['request_id'] : getParam('request_id'));
|
||
$player = isset($_GET['player']) ? $_GET['player'] : (isset($_POST['player']) ? $_POST['player'] : getParam('player'));
|
||
$result = isset($_GET['result']) ? $_GET['result'] : (isset($_POST['result']) ? $_POST['result'] : getParam('result'));
|
||
|
||
debugLog("completeWebLoginRequest: 收到结果", ['request_id' => $requestId, 'player' => $player, 'result_raw' => $result]);
|
||
|
||
if (!$requestId || !$player) {
|
||
error('缺少request_id或player参数');
|
||
}
|
||
|
||
$db = getDB();
|
||
// ★ 事务包裹所有写操作,防止database is locked
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
$stmt = $db->prepare("SELECT id FROM web_login_requests WHERE id = :id AND player_name = :player");
|
||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray();
|
||
|
||
if (!$row) {
|
||
$db->exec("ROLLBACK");
|
||
debugLog("completeWebLoginRequest: 请求不存在", ['request_id' => $requestId, 'player' => $player]);
|
||
error('请求不存在');
|
||
}
|
||
|
||
// 判断验证结果
|
||
$resultData = json_decode($result, true);
|
||
$status = isset($resultData['success']) && $resultData['success'] ? 'success' : 'failed';
|
||
|
||
$updateStmt = $db->prepare("UPDATE web_login_requests SET status = :status, result = :result, result_time = :time WHERE id = :id");
|
||
$updateStmt->bindValue(':status', $status, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':result', $result, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$updateStmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$updateStmt->execute();
|
||
|
||
// 如果验证成功,写入web_login_confirmations和web_login_verified
|
||
if ($status === 'success') {
|
||
// ★ 冻结账号禁止 Web 登录(异地登录自冻结保护)
|
||
$fz = $db->prepare("SELECT frozen FROM users WHERE player_name = :name");
|
||
$fz->bindValue(':name', $player, SQLITE3_TEXT);
|
||
$fzr = $fz->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if ($fzr && (int)$fzr['frozen'] === 1) {
|
||
error('账号已被冻结,请修改密码后重试');
|
||
}
|
||
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)");
|
||
$confirmStmt = $db->prepare("INSERT OR REPLACE INTO web_login_confirmations (player_name, confirmed_at, consumed) VALUES (:player, :time, 0)");
|
||
$confirmStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$confirmStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$confirmStmt->execute();
|
||
|
||
// ★ 记录Web登录验证成功,供玩家进游戏时自动登录(持久化)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
$verifiedStmt = $db->prepare("INSERT OR REPLACE INTO web_login_verified (player_name, verified_at) VALUES (:player, :time)");
|
||
$verifiedStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$verifiedStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$verifiedStmt->execute();
|
||
|
||
// ★ 记录每日登录(Web登录也算)
|
||
$today = date('Y-m-d');
|
||
$now = time();
|
||
$dlStmt = $db->prepare("INSERT OR REPLACE INTO player_daily_logins (player_name, login_date, first_login, last_login) VALUES (:name, :date, :first, :last)");
|
||
$dlStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||
$dlStmt->bindValue(':date', $today, SQLITE3_TEXT);
|
||
$dlStmt->bindValue(':first', $now, SQLITE3_INTEGER);
|
||
$dlStmt->bindValue(':last', $now, SQLITE3_INTEGER);
|
||
$dlStmt->execute();
|
||
}
|
||
|
||
$db->exec("COMMIT");
|
||
debugLog("completeWebLoginRequest: ✓ 写入成功", ['request_id' => $requestId, 'player' => $player, 'status' => $status, 'wrote_confirmations' => ($status === 'success')]);
|
||
success(['status' => $status], "结果已写入");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
debugLog("completeWebLoginRequest: 数据库异常", ['error' => $e->getMessage()]);
|
||
error('数据库错误: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 插件推送玩家密码凭证(SECRET_KEY认证)=====
|
||
function pushWebCredentials() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$players = getParam('players');
|
||
if (!$players || !is_array($players)) {
|
||
error('缺少players数组');
|
||
}
|
||
|
||
$db = getDB();
|
||
$count = 0;
|
||
|
||
foreach ($players as $p) {
|
||
$name = $p['player_name'] ?? null;
|
||
$hash = $p['password_hash'] ?? null;
|
||
$salt = $p['salt'] ?? null;
|
||
$tempHash = $p['temp_password_hash'] ?? null;
|
||
$tempExpire = $p['temp_pw_expire'] ?? 0;
|
||
|
||
if (!$name || !$hash || !$salt) continue;
|
||
|
||
storeWebLoginCredentials($name, $hash, $salt);
|
||
|
||
// ★ 如果有临时密码,单独存储;没有就清掉 PHP 侧残留——
|
||
// 否则 Java 每次只推「有临时密码」的玩家,旧 temp 永远留着,
|
||
// 双向对账会每一轮都判「changed」,变成永远在推(改造要消灭的正是这个)。
|
||
if (!empty($tempHash) && !empty($tempExpire)) {
|
||
storeTempPassword($name, $tempHash, $tempExpire);
|
||
} else {
|
||
try {
|
||
$clr = $db->prepare("UPDATE weblogin_credentials SET temp_password_hash = '', temp_pw_expire = 0 WHERE player_name = :n");
|
||
$clr->bindValue(':n', $name, SQLITE3_TEXT);
|
||
$clr->execute();
|
||
} catch (\Throwable $e) { /* 表未建等,忽略 */ }
|
||
}
|
||
|
||
$count++;
|
||
}
|
||
|
||
success(['pushed' => $count], "推送了{$count}个玩家密码凭证");
|
||
}
|
||
|
||
// ===== Web端验证登录密码 =====
|
||
// ★★★ 安全加固:此函数已废弃,禁止PHP自验证密码 ★★★
|
||
// 所有密码验证必须通过 web_login_request → Java插件本地验证 → complete_web_login_request 回调
|
||
// PHP端自验证密码会导致:假密码通过验证 + 游戏内未登录 + 快速重连绕过验证
|
||
function verifyWebPassword() {
|
||
error('安全限制: PHP禁止直接验证密码,必须通过游戏服务器验证。请使用web_login_request接口');
|
||
}
|
||
|
||
// ===== Web端安全验证入口(双保险)=====
|
||
function webAccessCheck() {
|
||
$webToken = getParam('web_token');
|
||
$accessAction = getParam('access_action', 'view');
|
||
$password = getParam('password');
|
||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||
|
||
if (!$webToken) {
|
||
@error_log("[web_access_check] FAIL: no web_token provided");
|
||
error('缺少web_token');
|
||
}
|
||
|
||
// ★ Debug: 记录完整的token信息
|
||
@error_log("[web_access_check] Token: " . substr($webToken, 0, 20) . "... (len=" . strlen($webToken) . ")");
|
||
@error_log("[web_access_check] Action: $accessAction, IP: $ipAddress");
|
||
|
||
// ★ 检查weblogin_tokens表是否存在及数据
|
||
$db = getDB();
|
||
try {
|
||
$countStmt = $db->query("SELECT COUNT(*) as cnt FROM weblogin_tokens");
|
||
$countRow = $countStmt->fetchArray(SQLITE3_ASSOC);
|
||
@error_log("[web_access_check] weblogin_tokens table has " . ($countRow['cnt'] ?? 0) . " records");
|
||
|
||
// 列出所有token(前20个字符)
|
||
$listStmt = $db->query("SELECT player_name, substr(web_token, 1, 20) as token_prefix, created_at, expire_seconds FROM weblogin_tokens ORDER BY created_at DESC LIMIT 10");
|
||
while ($row = $listStmt->fetchArray(SQLITE3_ASSOC)) {
|
||
$age = time() - $row['created_at'];
|
||
$expire = $row['expire_seconds'];
|
||
$isExpired = $age > $expire ? 'EXPIRED' : 'valid';
|
||
@error_log("[web_access_check] DB token: player=" . $row['player_name'] . ", token=" . $row['token_prefix'] . "..., age={$age}s, expire={$expire}s, status={$isExpired}");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log("[web_access_check] DB query error: " . $e->getMessage());
|
||
}
|
||
|
||
$result = validateWebAccess($webToken, $accessAction, $password, $ipAddress);
|
||
|
||
debugLog("web_access_check: 验证结果", [
|
||
'ok' => $result['ok'],
|
||
'mode' => $result['mode'] ?? 'unknown',
|
||
'player' => $result['player'] ?? 'unknown',
|
||
'registered' => $result['registered'] ?? false,
|
||
'online' => $result['online'] ?? false,
|
||
'message' => $result['message'] ?? ''
|
||
]);
|
||
|
||
if ($result['ok']) {
|
||
// ★ 修复:移除不存在的 logWebSession() 调用
|
||
// validateWebAccess 内部已通过 recordWebSession() 记录了 session,这里不需要重复记录
|
||
// 旧代码调用 logWebSession() 导致 PHP Fatal Error → 返回 HTML 错误页 → player.php 解析 JSON 失败 → 游客模式
|
||
|
||
success([
|
||
'player' => $result['player'] ?? '',
|
||
'mode' => $result['mode'] ?? '',
|
||
'session' => $result['session'] ?? '',
|
||
'online' => $result['online'] ?? false,
|
||
'registered' => $result['registered'] ?? false
|
||
], $result['message']);
|
||
} else {
|
||
if ($result['mode'] === 'need_password') {
|
||
// 需要密码但未提供
|
||
jsonResponse([
|
||
'success' => false,
|
||
'need_password' => true,
|
||
'player' => $result['player'] ?? '',
|
||
'online' => $result['online'] ?? false,
|
||
'registered' => $result['registered'] ?? false,
|
||
'message' => $result['message']
|
||
], 401);
|
||
} elseif ($result['mode'] === 'need_game_login') {
|
||
// 游戏里未登录
|
||
jsonResponse([
|
||
'success' => false,
|
||
'need_game_login' => true,
|
||
'player' => $result['player'] ?? '',
|
||
'online' => $result['online'] ?? false,
|
||
'registered' => $result['registered'] ?? false,
|
||
'message' => $result['message']
|
||
], 401);
|
||
} elseif ($result['mode'] === 'blacklisted') {
|
||
// ★ 第32批:玩家已被拉黑(Java BlacklistManager 同步)
|
||
jsonResponse([
|
||
'success' => false,
|
||
'blacklisted' => true,
|
||
'player' => $result['player'] ?? '',
|
||
'message' => $result['message']
|
||
], 401);
|
||
} elseif ($result['mode'] === 'need_register') {
|
||
// 玩家未注册
|
||
jsonResponse([
|
||
'success' => false,
|
||
'need_register' => true,
|
||
'player' => $result['player'] ?? '',
|
||
'online' => $result['online'] ?? false,
|
||
'registered' => $result['registered'] ?? false,
|
||
'message' => $result['message']
|
||
], 401);
|
||
} else {
|
||
error($result['message'], 401);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 轻量级检查:是否有待处理的交易(Java高频轮询用)=====
|
||
function checkPendingTransactions() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$hasTable = false;
|
||
try {
|
||
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='web_transactions'");
|
||
$hasTable = ($stmt && $stmt->fetchArray());
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkPendingTransactions] table check error: " . $e->getMessage());
|
||
}
|
||
|
||
if (!$hasTable) {
|
||
success(['pending' => 0], 'no table');
|
||
return;
|
||
}
|
||
|
||
$count = 0;
|
||
try {
|
||
$stmt = $db->query("SELECT COUNT(*) as cnt FROM web_transactions WHERE status = 'pending'");
|
||
if ($stmt) {
|
||
$row = $stmt->fetchArray(SQLITE3_ASSOC);
|
||
$count = (int)($row['cnt'] ?? 0);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkPendingTransactions] count error: " . $e->getMessage());
|
||
}
|
||
|
||
success(['pending' => $count], $count > 0 ? '有交易待处理' : '无待处理');
|
||
}
|
||
|
||
// ===== 插件拉取待处理的交易 =====
|
||
function pullPendingTransactions() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||
|
||
// 检查processed_at字段是否存在(旧库可能缺失)
|
||
$hasProcessedAt = false;
|
||
$stmt = $db->query("PRAGMA table_info(web_transactions)");
|
||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||
if ($row['name'] === 'processed_at') { $hasProcessedAt = true; break; }
|
||
}
|
||
if (!$hasProcessedAt) {
|
||
$db->exec("ALTER TABLE web_transactions ADD COLUMN processed_at INTEGER");
|
||
}
|
||
|
||
// 获取所有pending的交易 + 标记为processing(原子操作,避免database is locked)
|
||
$transactions = [];
|
||
$ids = [];
|
||
try {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
|
||
// 确保confirmed_transactions表存在
|
||
$db->exec("CREATE TABLE IF NOT EXISTS confirmed_transactions (
|
||
tx_id TEXT NOT NULL,
|
||
tx_type TEXT NOT NULL,
|
||
confirmed_at INTEGER NOT NULL,
|
||
PRIMARY KEY (tx_id, tx_type)
|
||
)");
|
||
|
||
// ★ 移除processing→pending超时恢复(防止已处理交易被重复拉取)
|
||
// confirmed_transactions表+Java端ackTransaction双重保险
|
||
|
||
// ★ LEFT JOIN confirmed_transactions跳过已被Java确认的交易
|
||
$stmt = $db->prepare("SELECT wt.id, wt.player_name, wt.type, wt.amount, wt.reason, wt.detail
|
||
FROM web_transactions wt
|
||
LEFT JOIN confirmed_transactions ct ON wt.id = ct.tx_id AND wt.type = ct.tx_type
|
||
WHERE wt.status = 'pending' AND ct.tx_id IS NULL
|
||
ORDER BY wt.created_at ASC");
|
||
$result = $stmt->execute();
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$transactions[] = $row;
|
||
$ids[] = (int)$row['id'];
|
||
}
|
||
|
||
// 立即标记为processing(防止5秒后重复拉取)
|
||
if (!empty($ids)) {
|
||
$idList = implode(',', $ids);
|
||
$db->exec("UPDATE web_transactions SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||
}
|
||
$db->exec('COMMIT');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||
@error_log("[pullPendingTransactions] 事务失败: " . $e->getMessage());
|
||
// 降级:不做标记,直接返回(下一轮会重新拉取)
|
||
}
|
||
|
||
success(['transactions' => $transactions, 'count' => count($transactions)]);
|
||
}
|
||
|
||
// ===== 插件确认交易已处理 =====
|
||
function confirmTransaction() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$txId = getParam('tx_id');
|
||
if (!$txId) error('缺少tx_id');
|
||
|
||
$db = getDB();
|
||
// 检查processed_at字段是否存在
|
||
$hasProcessedAt = false;
|
||
$stmt = $db->query("PRAGMA table_info(web_transactions)");
|
||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||
if ($row['name'] === 'processed_at') { $hasProcessedAt = true; break; }
|
||
}
|
||
|
||
try {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
if ($hasProcessedAt) {
|
||
$stmt = $db->prepare("UPDATE web_transactions SET status = 'processed', processed_at = :time WHERE id = :id AND status IN ('pending', 'processing')");
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
} else {
|
||
$stmt = $db->prepare("UPDATE web_transactions SET status = 'processed' WHERE id = :id AND status IN ('pending', 'processing')");
|
||
}
|
||
$stmt->bindValue(':id', (int)$txId, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$db->exec('COMMIT');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||
@error_log("[confirmTransaction] 失败: " . $e->getMessage());
|
||
}
|
||
|
||
success(['tx_id' => $txId], '交易已确认');
|
||
}
|
||
|
||
// ===== 插件拉取交易后立即贴标确认(防重复处理) =====
|
||
function ackTransaction() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$txId = getParam('tx_id');
|
||
if (!$txId) error('缺少tx_id');
|
||
|
||
$txType = getParam('tx_type');
|
||
|
||
$db = getDB();
|
||
|
||
// tx_type缺失时从web_transactions反查
|
||
if (!$txType) {
|
||
$stmt = $db->prepare("SELECT type FROM web_transactions WHERE id = :id");
|
||
$stmt->bindValue(':id', (int)$txId, SQLITE3_INTEGER);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
if ($row && !empty($row['type'])) {
|
||
$txType = $row['type'];
|
||
} else {
|
||
$txType = 'shop_cart'; // 兜底默认
|
||
}
|
||
}
|
||
|
||
// 确保confirmed_transactions表存在
|
||
$db->exec("CREATE TABLE IF NOT EXISTS confirmed_transactions (
|
||
tx_id TEXT NOT NULL,
|
||
tx_type TEXT NOT NULL,
|
||
confirmed_at INTEGER NOT NULL,
|
||
PRIMARY KEY (tx_id, tx_type)
|
||
)");
|
||
|
||
try {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
$stmt = $db->prepare("INSERT OR IGNORE INTO confirmed_transactions (tx_id, tx_type, confirmed_at) VALUES (:tx_id, :tx_type, :time)");
|
||
$stmt->bindValue(':tx_id', $txId);
|
||
$stmt->bindValue(':tx_type', $txType);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$db->exec('COMMIT');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||
debugLog("[ackTransaction] 失败: " . $e->getMessage());
|
||
}
|
||
|
||
success(['tx_id' => $txId, 'tx_type' => $txType], '已贴标确认');
|
||
}
|
||
|
||
// ===== 插件拉取Web端修改的商品库存 =====
|
||
function pullShopStock() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
// ★ admin_stock非NULL时优先返回(管理员手动改的库存)
|
||
// 防止Java全量同步覆盖管理员的修改
|
||
$stmt = $db->prepare("SELECT id, stock, last_sync, admin_stock FROM shop_items");
|
||
$result = $stmt->execute();
|
||
$items = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
// ★ 如果admin_stock有值,用admin_stock替代stock返回给Java
|
||
if ($row['admin_stock'] !== null && $row['admin_stock'] !== '') {
|
||
$row['stock'] = (int)$row['admin_stock'];
|
||
$row['admin_stock_override'] = true;
|
||
}
|
||
unset($row['admin_stock']); // 不暴露内部字段
|
||
$items[] = $row;
|
||
}
|
||
|
||
// ★ 成功获取数据后立即清除admin_stock标记(同一次请求完成,消除竞态)
|
||
// 防止Java端clearAdminStock()单独请求失败导致admin_stock永远非NULL
|
||
$db->exec("UPDATE shop_items SET admin_stock = NULL WHERE admin_stock IS NOT NULL");
|
||
|
||
success(['items' => $items, 'count' => count($items)]);
|
||
}
|
||
|
||
// ===== 插件推送本地库存改动(双向同步:数据分叉以 Java 为主)=====
|
||
function pushShopStock() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS shop_items (
|
||
id TEXT PRIMARY KEY, category TEXT NOT NULL, display_name TEXT NOT NULL,
|
||
material TEXT NOT NULL, buy_price INTEGER DEFAULT 0, sell_price INTEGER DEFAULT -1,
|
||
stock INTEGER DEFAULT -1, hourly_sales INTEGER DEFAULT 0, total_sales INTEGER DEFAULT 0,
|
||
last_sync INTEGER DEFAULT 0, admin_stock INTEGER DEFAULT NULL,
|
||
admin_buy_price INTEGER DEFAULT NULL, admin_sell_price INTEGER DEFAULT NULL
|
||
)");
|
||
|
||
$items = getParam('items');
|
||
if (!$items || !is_array($items)) error('缺少库存数据');
|
||
|
||
// ★ 回执单(2026-10-11):Java 不再无条件相信 PHP——必须在响应里原样回显
|
||
// push_id + 内容指纹 fp + 条数,Java 才认为「PHP 确实收到」;否则 Java 重推一次。
|
||
// 指纹与 Java 同款算法:条目 "id|stock" 按字典序排序、"\n" 连接、CRC32 无符号十进制字符串。
|
||
$pushId = (string)getParam('push_id', '');
|
||
$fpEntries = [];
|
||
foreach ($items as $it) {
|
||
if (!is_array($it) || !isset($it['id'])) continue;
|
||
$fpEntries[] = (string)$it['id'] . '|' . (isset($it['stock']) ? (int)$it['stock'] : 0);
|
||
}
|
||
sort($fpEntries, SORT_STRING);
|
||
$fp = (string)sprintf('%u', crc32(implode("\n", $fpEntries)));
|
||
|
||
$time = time();
|
||
$applied = 0;
|
||
$kept = 0;
|
||
// Java 主:直接写 stock 并清 admin_stock(Java 值即最新权威)。
|
||
// 竞态护栏:PHP 侧该行若有与本次推送同秒更新的未拉取改动(web 购买/后台改库存),
|
||
// 保留不覆盖,等 Java 下一轮 pull 时按「哪边有改动对齐哪边」处理。
|
||
$stmt = $db->prepare("UPDATE shop_items SET stock = :s, admin_stock = NULL, last_modified = :t
|
||
WHERE id = :id AND (admin_stock IS NULL OR last_modified <= :t)");
|
||
foreach ($items as $it) {
|
||
if (!is_array($it) || !isset($it['id'])) continue;
|
||
$stmt->bindValue(':s', (int)$it['stock'], SQLITE3_INTEGER);
|
||
$stmt->bindValue(':t', $time, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':id', (string)$it['id'], SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
if ($db->changes() > 0) { $applied++; } else { $kept++; }
|
||
}
|
||
success([
|
||
'count' => count($items), 'applied' => $applied, 'kept' => $kept,
|
||
// ★ 回执单:原样回显 Java 的 push_id / 内容指纹 / 条数,Java 逐项比对通过才落本地基线
|
||
'receipt' => [
|
||
'push_id' => $pushId,
|
||
'count' => count($items),
|
||
'fp' => $fp,
|
||
'applied' => $applied,
|
||
'kept' => $kept,
|
||
'time' => $time,
|
||
],
|
||
], '库存已接收');
|
||
}
|
||
|
||
// ===== 插件确认已应用管理员库存改动 =====
|
||
function clearAdminStock() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$db->exec("UPDATE shop_items SET admin_stock = NULL WHERE admin_stock IS NOT NULL");
|
||
success([], '已清除管理员库存标记');
|
||
}
|
||
|
||
// ===== 拉取价格改动:admin_buy_price/admin_sell_price非NULL时返回给Java =====
|
||
function pullShopPrices() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
// ★ 只返回有价格改动的商品(admin_buy_price或admin_sell_price非NULL)
|
||
$stmt = $db->prepare("SELECT id, buy_price, sell_price, admin_buy_price, admin_sell_price FROM shop_items WHERE admin_buy_price IS NOT NULL OR admin_sell_price IS NOT NULL");
|
||
$result = $stmt->execute();
|
||
$items = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
// ★ 如果admin字段有值,用admin值替代原始价格返回给Java
|
||
if ($row['admin_buy_price'] !== null && $row['admin_buy_price'] !== '') {
|
||
$row['buy_price'] = (int)$row['admin_buy_price'];
|
||
$row['admin_buy_price_override'] = true;
|
||
}
|
||
if ($row['admin_sell_price'] !== null && $row['admin_sell_price'] !== '') {
|
||
$row['sell_price'] = (int)$row['admin_sell_price'];
|
||
$row['admin_sell_price_override'] = true;
|
||
}
|
||
unset($row['admin_buy_price'], $row['admin_sell_price']); // 不暴露内部字段
|
||
$items[] = $row;
|
||
}
|
||
|
||
// ★ 成功获取数据后立即清除admin价格标记(同一次请求完成,消除竞态)
|
||
$db->exec("UPDATE shop_items SET admin_buy_price = NULL, admin_sell_price = NULL WHERE admin_buy_price IS NOT NULL OR admin_sell_price IS NOT NULL");
|
||
|
||
success(['items' => $items, 'count' => count($items)]);
|
||
}
|
||
|
||
// ===== 插件拉取商店打包配置(打包费 / 环保单减免)=====
|
||
function getShopConfigSync() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
// ★ 直接读取 shop_config 表(getShopConfig 定义在 shop.php,sync.php 未加载,故内联避免 500)
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS shop_config (cfg_key TEXT PRIMARY KEY, cfg_value TEXT NOT NULL)");
|
||
$read = function($k, $def) use ($db) {
|
||
try {
|
||
$stmt = $db->prepare("SELECT cfg_value FROM shop_config WHERE cfg_key = :k");
|
||
$stmt->bindValue(':k', $k, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
return $row ? $row['cfg_value'] : $def;
|
||
} catch (\Throwable $e) {
|
||
return $def;
|
||
}
|
||
};
|
||
success([
|
||
'packmoney' => (float)$read('packmoney', '5'),
|
||
'green_discount' => (float)$read('green_discount', '10')
|
||
]);
|
||
}
|
||
|
||
// ===== 插件推送商店配置(Java命令 /sdf1_login set packmoney、shop setgreen 写入)=====
|
||
function setShopConfigSync() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$key = getParam('key');
|
||
$value = getParam('value');
|
||
$allowed = ['packmoney', 'green_discount'];
|
||
if (!in_array($key, $allowed, true)) error('不支持的配置项');
|
||
if ($value === null) error('缺少value');
|
||
|
||
if ($key === 'packmoney') {
|
||
$v = (int)$value;
|
||
if ($v < 0 || $v > 999) error('打包费需介于 0 ~ 999');
|
||
} else {
|
||
$v = (float)$value;
|
||
if ($v < 0 || $v > 10) error('环保单折扣(折数)需介于 0 ~ 10');
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS shop_config (cfg_key TEXT PRIMARY KEY, cfg_value TEXT NOT NULL)");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO shop_config (cfg_key, cfg_value) VALUES (:k, :v)");
|
||
$stmt->bindValue(':k', $key, SQLITE3_TEXT);
|
||
$stmt->bindValue(':v', (string)$v, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
success([], '配置已保存: ' . $key . '=' . $v);
|
||
}
|
||
|
||
// ===== 插件推送完整商品目录(Java 商城定时同步:游戏内增删分类/商品 → Web 端镜像)=====
|
||
function setShopCatalogSync() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$raw = file_get_contents('php://input');
|
||
$data = json_decode($raw, true);
|
||
if (!is_array($data) || !isset($data['categories']) || !is_array($data['categories'])) {
|
||
error('缺少分类数据');
|
||
}
|
||
$categories = $data['categories'];
|
||
|
||
$db = getDB();
|
||
// 确保表结构存在(兼容旧库)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS shop_items (
|
||
id TEXT PRIMARY KEY, category TEXT NOT NULL, display_name TEXT NOT NULL,
|
||
material TEXT NOT NULL, buy_price INTEGER DEFAULT 0, sell_price INTEGER DEFAULT -1,
|
||
stock INTEGER DEFAULT -1, hourly_sales INTEGER DEFAULT 0, total_sales INTEGER DEFAULT 0,
|
||
last_sync INTEGER DEFAULT 0, admin_stock INTEGER DEFAULT NULL,
|
||
admin_buy_price INTEGER DEFAULT NULL, admin_sell_price INTEGER DEFAULT NULL
|
||
)");
|
||
|
||
$time = time();
|
||
$validIds = [];
|
||
$countItems = 0;
|
||
|
||
// ★ 仅更新目录字段(分类/名称/材质/价格/同步时间),保留 PHP 端管理的库存与管理员覆盖列
|
||
$upsert = $db->prepare("INSERT INTO shop_items (id, category, display_name, material, buy_price, sell_price, stock, hourly_sales, total_sales, last_sync)
|
||
VALUES (:id, :cat, :name, :mat, :bp, :sp, -1, 0, 0, :time)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
category=:cat, display_name=:name, material=:mat, buy_price=:bp, sell_price=:sp, last_sync=:time");
|
||
|
||
foreach ($categories as $cat) {
|
||
if (!is_array($cat)) continue;
|
||
$catName = isset($cat['name']) ? (string)$cat['name'] : '';
|
||
if ($catName === '') continue;
|
||
if (!isset($cat['items']) || !is_array($cat['items'])) continue;
|
||
foreach ($cat['items'] as $it) {
|
||
if (!is_array($it)) continue;
|
||
$id = isset($it['id']) ? (string)$it['id'] : '';
|
||
if ($id === '') continue;
|
||
$name = isset($it['display_name']) ? (string)$it['display_name'] : $id;
|
||
$mat = isset($it['material']) ? (string)$it['material'] : 'STONE';
|
||
$bp = isset($it['buy_price']) ? (int)$it['buy_price'] : 0;
|
||
$sp = isset($it['sell_price']) ? (int)$it['sell_price'] : -1;
|
||
|
||
$upsert->bindValue(':id', $id, SQLITE3_TEXT);
|
||
$upsert->bindValue(':cat', $catName, SQLITE3_TEXT);
|
||
$upsert->bindValue(':name', $name, SQLITE3_TEXT);
|
||
$upsert->bindValue(':mat', $mat, SQLITE3_TEXT);
|
||
$upsert->bindValue(':bp', $bp, SQLITE3_INTEGER);
|
||
$upsert->bindValue(':sp', $sp, SQLITE3_INTEGER);
|
||
$upsert->bindValue(':time', $time, SQLITE3_INTEGER);
|
||
$upsert->execute();
|
||
$validIds[] = $id;
|
||
$countItems++;
|
||
}
|
||
}
|
||
|
||
// ★ 安全删除:仅当本次同步确实带来了商品时,才删除不在游戏目录中的旧项,避免空负载误清空
|
||
$deleted = 0;
|
||
if ($countItems > 0 && count($validIds) > 0) {
|
||
$ph = str_repeat('?,', count($validIds) - 1) . '?';
|
||
$del = $db->prepare("DELETE FROM shop_items WHERE id NOT IN ($ph)");
|
||
$i = 1;
|
||
foreach ($validIds as $vid) {
|
||
$del->bindValue($i, $vid, SQLITE3_TEXT);
|
||
$i++;
|
||
}
|
||
$del->execute();
|
||
$deleted = $db->changes();
|
||
}
|
||
|
||
success([
|
||
'categories' => count($categories),
|
||
'items' => $countItems,
|
||
'deleted_old' => $deleted,
|
||
], '商品目录已同步(新增/更新 ' . $countItems . ' 项,移除不在游戏目录中的旧项 ' . $deleted . ' 个)');
|
||
}
|
||
|
||
// ===== 插件确认已应用管理员价格改动 =====
|
||
function clearAdminPrices() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$db->exec("UPDATE shop_items SET admin_buy_price = NULL, admin_sell_price = NULL WHERE admin_buy_price IS NOT NULL OR admin_sell_price IS NOT NULL");
|
||
success([], '已清除管理员价格标记');
|
||
}
|
||
|
||
// ===== Java端写入购物车退款请求(发货失败时触发) =====
|
||
function writeShopRefund() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$rawInput = file_get_contents('php://input');
|
||
$data = json_decode($rawInput, true);
|
||
if (!$data) error('无效JSON');
|
||
|
||
$playerName = $data['player_name'] ?? '';
|
||
$amount = (int)($data['amount'] ?? 0);
|
||
$origTxId = $data['orig_tx_id'] ?? '';
|
||
$reason = $data['reason'] ?? '';
|
||
$payMode = $data['pay_mode'] ?? 'bond';
|
||
|
||
if (!$playerName || $amount <= 0) error('退款参数缺失');
|
||
if ($payMode !== 'cash') {
|
||
// 非现金模式:退还债券
|
||
$db = getDB();
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
try {
|
||
$stmt = $db->prepare("UPDATE bond_cache SET amount = amount + :amt WHERE player_name = :player");
|
||
$stmt->bindValue(':amt', $amount, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
if ($stmt->changes() === 0) {
|
||
// 玩家不在缓存中,插入
|
||
$ins = $db->prepare("INSERT OR IGNORE INTO bond_cache (player_name, amount) VALUES (:player, :amt)");
|
||
$ins->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$ins->bindValue(':amt', $amount, SQLITE3_INTEGER);
|
||
$ins->execute();
|
||
}
|
||
// 记录退款交易
|
||
$refStmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_refund', :amount, :reason, :detail, 'completed', :time)");
|
||
$refStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$refStmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
$refStmt->bindValue(':reason', "购物车自动退款(发货失败): " . $reason . " [原交易#" . $origTxId . "]", SQLITE3_TEXT);
|
||
$refStmt->bindValue(':detail', json_encode(['orig_tx_id' => $origTxId, 'refund_reason' => $reason, 'pay_mode' => $payMode], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$refStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$refStmt->execute();
|
||
|
||
// 标记原交易为已退款(避免重复处理)
|
||
$markStmt = $db->prepare("UPDATE web_transactions SET status = 'refunded' WHERE id = :txid OR reason LIKE :txidLike");
|
||
$markStmt->bindValue(':txid', $origTxId, SQLITE3_TEXT);
|
||
$markStmt->bindValue(':txidLike', '%' . $origTxId . '%', SQLITE3_TEXT);
|
||
$markStmt->execute();
|
||
|
||
$db->exec('COMMIT');
|
||
debugLog('[shop_refund] 退款成功: 玩家=' . $playerName . ' 金额=' . $amount . ' 原因=' . $reason);
|
||
success(['refunded_amount' => $amount, 'player' => $playerName], '退款成功');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
|
||
error('退款处理失败: ' . $e->getMessage(), 500);
|
||
}
|
||
} else {
|
||
// 现金模式:仅记账退款标记,不操作债券
|
||
$db = getDB();
|
||
try {
|
||
$refStmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_refund', 0, :reason, :detail, 'completed', :time)");
|
||
$refStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$refStmt->bindValue(':reason', "购物车退款标记(现金模式/发货失败): " . $reason, SQLITE3_TEXT);
|
||
$refStmt->bindValue(':detail', json_encode(['orig_tx_id' => $origTxId, 'refund_reason' => $reason, 'pay_mode' => 'cash'], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$refStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$refStmt->execute();
|
||
success(['refunded_amount' => 0, 'player' => $playerName, 'note' => '现金模式仅记账'], '退款标记已记录(现金模式不扣债券)');
|
||
} catch (\Throwable $e) {
|
||
error('退款标记失败: ' . $e->getMessage(), 500);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 高频轮询:检测价格是否有改动(轻量级接口) =====
|
||
function checkShopPricesChanged() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
try {
|
||
$stmt = $db->prepare("SELECT SUM(CASE WHEN admin_buy_price IS NOT NULL OR admin_sell_price IS NOT NULL THEN 1 ELSE 0 END) as admin_price_count FROM shop_items");
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
$adminPriceCount = $row['admin_price_count'] ? (int)$row['admin_price_count'] : 0;
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkShopPricesChanged] error: " . $e->getMessage());
|
||
$adminPriceCount = 0;
|
||
}
|
||
|
||
// ★ 如果admin价格非NULL(管理员改了价格但Java还没拉取),返回changed=true
|
||
$changed = ($adminPriceCount > 0);
|
||
success(['changed' => $changed, 'admin_price_count' => $adminPriceCount]);
|
||
}
|
||
|
||
// ===== 高频轮询:检测库存是否有改动(轻量级接口) =====
|
||
function checkShopStockChanged() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
try {
|
||
$stmt = $db->prepare("SELECT MAX(last_modified) as max_lm, SUM(CASE WHEN admin_stock IS NOT NULL THEN 1 ELSE 0 END) as admin_count FROM shop_items");
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
$maxModified = $row['max_lm'] ? (int)$row['max_lm'] : 0;
|
||
$adminCount = $row['admin_count'] ? (int)$row['admin_count'] : 0;
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkShopStockChanged] error: " . $e->getMessage());
|
||
$maxModified = 0;
|
||
$adminCount = 0;
|
||
}
|
||
|
||
// ★ 如果admin_stock非NULL(管理员改了库存但Java还没拉取),强制返回changed=true
|
||
$clientLastModified = (int)getParam('last_modified', 0);
|
||
$changed = ($maxModified > $clientLastModified) || ($adminCount > 0);
|
||
|
||
success([
|
||
'changed' => $changed,
|
||
'last_modified' => $maxModified,
|
||
'admin_pending' => $adminCount
|
||
]);
|
||
}
|
||
|
||
// ===== 通知插件立即同步数据 =====
|
||
function notifySync() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
// ★ 获取最新交易ID
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||
$txId = getParam('tx_id', 0);
|
||
|
||
// 写入同步通知文件(包含交易ID)
|
||
$notifyFile = __DIR__ . '/../../sync_notify.txt';
|
||
$notifyData = json_encode(['time' => time(), 'tx_id' => (int)$txId]);
|
||
@file_put_contents($notifyFile, $notifyData, LOCK_EX);
|
||
|
||
// ★ 不再使用HTTP回调,Java的transactionPolling每5秒轮询check_pending_transactions
|
||
@error_log('[notifySync] 收到同步通知,tx_id=' . $txId . ',Java轮询会自动拉取');
|
||
|
||
success(['tx_id' => (int)$txId], '已通知插件立即同步交易数据');
|
||
}
|
||
|
||
// ===== 客户端请求立即同步(玩家购物/CDK后调用)=====
|
||
function requestImmediateSync() {
|
||
// ★ 不需要secret验证,前端玩家直接调用
|
||
$player = getParam('player');
|
||
if (!$player) error('缺少player参数');
|
||
|
||
$db = getDB();
|
||
// 记录同步请求(Java插件会轮询这个表)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
success(['sync_requested' => true], '已请求立即同步');
|
||
}
|
||
|
||
// ===== 补发pending交易(Java重拉未确认交易)=====
|
||
function resendPendingTransactions() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||
|
||
// ★ 事务包装防止 "database is locked"
|
||
$db->exec("BEGIN IMMEDIATE");
|
||
try {
|
||
// 恢复超时processing交易(1小时超时,防止重启后重复发货)
|
||
$db->exec("UPDATE web_transactions SET status = 'pending' WHERE status = 'processing' AND processed_at < " . (time() - 3600));
|
||
|
||
$stmt = $db->prepare("SELECT id, player_name, type, amount, reason, detail FROM web_transactions WHERE status = 'pending' ORDER BY created_at ASC");
|
||
$result = $stmt->execute();
|
||
$transactions = [];
|
||
$ids = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$transactions[] = $row;
|
||
$ids[] = (int)$row['id'];
|
||
}
|
||
|
||
// 重新标记为processing
|
||
if (!empty($ids)) {
|
||
$idList = implode(',', $ids);
|
||
$db->exec("UPDATE web_transactions SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||
}
|
||
|
||
$db->exec("COMMIT");
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||
@error_log("[resendPendingTransactions] Transaction failed: " . $e->getMessage());
|
||
error('数据库操作失败: ' . $e->getMessage());
|
||
}
|
||
|
||
success(['transactions' => $transactions, 'count' => count($transactions), 'resend' => true], '已补发' . count($transactions) . '笔pending交易');
|
||
}
|
||
|
||
// ===== 邮箱脱敏工具 =====
|
||
// 格式: 123@456.com → xx@456.com(只显示后缀)
|
||
function maskEmail($email) {
|
||
if (empty($email)) return '';
|
||
$parts = explode('@', $email);
|
||
if (count($parts) !== 2) return '**@' . ($parts[0] ?? '*');
|
||
$domain = $parts[1];
|
||
// 只显示域名,前面用xx代替
|
||
return 'xx@' . $domain;
|
||
}
|
||
|
||
// ===== 第57批续7:Java端密码验证通过后直接完成邮箱绑定 =====
|
||
function acctCompleteEmailBind($requestId, $player, $email) {
|
||
$db = getDB();
|
||
$upd = $db->prepare("UPDATE users SET email = :e WHERE player_name = :p");
|
||
$upd->bindValue(':e', $email, SQLITE3_TEXT);
|
||
$upd->bindValue(':p', $player, SQLITE3_TEXT);
|
||
$upd->execute();
|
||
if ($db->changes() <= 0) return false;
|
||
$done = $db->prepare("UPDATE web_email_bind_requests SET status = 'completed', processed_at = :t WHERE id = :id");
|
||
$done->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$done->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$done->execute();
|
||
debugLog('[sync.php] Java端验证通过,直接完成邮箱绑定', ['player' => $player, 'id' => $requestId]);
|
||
return true;
|
||
}
|
||
|
||
// ===== 发送邮箱验证码 =====
|
||
function sendEmailCode() {
|
||
$player = getParam('player');
|
||
|
||
if (!$player) error('缺少player参数');
|
||
|
||
// ★ 安全检查:玩家必须已注册
|
||
if (!isPlayerRegistered($player)) {
|
||
error('玩家未在游戏中注册,请先在游戏中使用 /register 注册账号');
|
||
}
|
||
|
||
// ★ 封禁拦截:被封禁玩家禁止发送登录验证码
|
||
$banReason = playerWebBanReason($player);
|
||
if ($banReason !== null) {
|
||
error('账号已被封禁:' . $banReason);
|
||
}
|
||
|
||
// ★ 第32批拉黑拦截:被拉黑玩家禁止发送登录验证码
|
||
$blackReason = playerWebBlacklistReason($player);
|
||
if ($blackReason !== null) {
|
||
error('你已被拉黑:' . $blackReason);
|
||
}
|
||
|
||
// ★ 从数据库读取玩家绑定的邮箱(唯一可信来源)
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||
error('该玩家尚未绑定邮箱,请先在游戏中完成邮箱绑定');
|
||
}
|
||
|
||
// ★ 邮箱只读:不允许前端传入,直接取数据库的值
|
||
$email = $row['email'];
|
||
$maskedEmail = maskEmail($email);
|
||
|
||
// 使用config.php中的SMTP配置
|
||
$smtpHost = SMTP_HOST;
|
||
$smtpPort = SMTP_PORT;
|
||
$smtpUser = SMTP_USER;
|
||
$smtpPass = SMTP_PASS;
|
||
$senderName = SMTP_SENDER_NAME;
|
||
|
||
if (empty($smtpHost) || empty($smtpUser) || empty($smtpPass)) {
|
||
error('SMTP配置不完整');
|
||
}
|
||
|
||
// 生成6位验证码
|
||
$code = str_pad(mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
|
||
$expireTime = time() + 300; // 5分钟有效
|
||
|
||
// 存储验证码到数据库
|
||
$db->exec("CREATE TABLE IF NOT EXISTS email_codes (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, email TEXT NOT NULL, code TEXT NOT NULL, expire_at INTEGER NOT NULL, used INTEGER DEFAULT 0, created_at INTEGER NOT NULL)");
|
||
|
||
// 清理过期验证码
|
||
$db->exec("DELETE FROM email_codes WHERE expire_at < " . time());
|
||
|
||
// 检查是否有未过期的验证码
|
||
$checkStmt = $db->prepare("SELECT id FROM email_codes WHERE player_name = :player AND email = :email AND expire_at > :time AND used = 0");
|
||
$checkStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$checkStmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||
$checkStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$checkResult = $checkStmt->execute();
|
||
if ($checkResult->fetchArray()) {
|
||
error('验证码已发送至 ' . $maskedEmail . ',请查看邮箱(5分钟内有效)');
|
||
}
|
||
|
||
// 插入新验证码
|
||
$stmt = $db->prepare("INSERT INTO email_codes (player_name, email, code, expire_at, created_at) VALUES (:player, :email, :code, :expire, :time)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$stmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
try {
|
||
// 构建邮件内容
|
||
$subject = "【" . $senderName . "】登录验证码";
|
||
$htmlBody = "<div style='font-family:Arial,sans-serif;max-width:400px;margin:0 auto;padding:20px;background:#f5f5f5;border-radius:10px'>"
|
||
. "<h2 style='color:#333;text-align:center'>🔐 登录验证码</h2>"
|
||
. "<div style='background:#fff;padding:20px;border-radius:8px;text-align:center;margin:20px 0'>"
|
||
. "<p style='font-size:14px;color:#666;margin-bottom:10px'>玩家 <b>" . htmlspecialchars($player) . "</b> 的验证码:</p>"
|
||
. "<p style='font-size:32px;font-weight:bold;color:#4CAF50;letter-spacing:8px;margin:0'>" . $code . "</p>"
|
||
. "</div>"
|
||
. "<p style='font-size:12px;color:#999;text-align:center'>验证码5分钟内有效,请勿泄露给他人</p>"
|
||
. "<p style='font-size:12px;color:#999;text-align:center'>如果这不是您的操作,请忽略此邮件</p>"
|
||
. "</div>";
|
||
|
||
$headers = "MIME-Version: 1.0\r\n"
|
||
. "Content-type: text/html; charset=UTF-8\r\n"
|
||
. "From: " . $senderName . " <" . $smtpUser . ">\r\n";
|
||
|
||
// 使用PHPMailer或自定义SMTP发送
|
||
$sent = smtpSendEmail($smtpHost, $smtpPort, $smtpUser, $smtpPass, $email, $subject, $htmlBody, $headers, SMTP_USE_SSL);
|
||
|
||
if ($sent) {
|
||
success(['player' => $player, 'masked_email' => $maskedEmail], '验证码已发送至 ' . $maskedEmail);
|
||
} else {
|
||
// SMTP失败,返回验证码供调试
|
||
success(['player' => $player, 'masked_email' => $maskedEmail, 'code' => $code], '邮件发送中(验证码:' . $code . ')');
|
||
}
|
||
} catch (\Throwable $e) {
|
||
error('邮件发送失败:' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 验证邮箱验证码 =====
|
||
function verifyEmailCode() {
|
||
$player = getParam('player');
|
||
$code = getParam('code');
|
||
$webToken = getParam('web_token');
|
||
|
||
if (!$player) error('缺少player参数');
|
||
if (!$code) error('缺少验证码');
|
||
if (!$webToken) error('缺少web_token');
|
||
|
||
// ★ 安全检查:玩家必须在游戏中注册过
|
||
if (!isPlayerRegistered($player)) {
|
||
error('玩家未在游戏中注册,请先在游戏中使用 /register 注册账号');
|
||
}
|
||
|
||
// ★ 验证token(weblogin token或普通token都可以)
|
||
$tokenInfo = validateTokenSilent($webToken);
|
||
if (!$tokenInfo) error('无效的登录token');
|
||
|
||
// ★ 验证玩家名是否匹配
|
||
if ($tokenInfo['player'] !== $player) error('玩家名不匹配');
|
||
|
||
// ★ 封禁拦截:被封禁玩家禁止走邮箱验证码登录
|
||
$banReason = playerWebBanReason($player);
|
||
if ($banReason !== null) {
|
||
error('账号已被封禁:' . $banReason);
|
||
}
|
||
|
||
// ★ 第32批拉黑拦截:被拉黑玩家禁止走邮箱验证码登录
|
||
$blackReason = playerWebBlacklistReason($player);
|
||
if ($blackReason !== null) {
|
||
error('你已被拉黑:' . $blackReason);
|
||
}
|
||
|
||
// ★ 从数据库读取玩家绑定的邮箱(唯一可信来源)
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||
error('该玩家尚未绑定邮箱');
|
||
}
|
||
|
||
$email = $row['email'];
|
||
|
||
// 查找未使用且未过期的验证码(只验证该玩家自己的邮箱记录)
|
||
$stmt = $db->prepare("SELECT * FROM email_codes WHERE player_name = :player AND email = :email AND code = :code AND expire_at > :time AND used = 0");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row) {
|
||
error('验证码无效或已过期');
|
||
}
|
||
|
||
// 标记验证码已使用
|
||
$updateStmt = $db->prepare("UPDATE email_codes SET used = 1 WHERE id = :id");
|
||
$updateStmt->bindValue(':id', $row['id'], SQLITE3_INTEGER);
|
||
$updateStmt->execute();
|
||
|
||
// 验证码正确,创建登录确认记录
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)");
|
||
$confirmStmt = $db->prepare("INSERT OR REPLACE INTO web_login_confirmations (player_name, confirmed_at, consumed) VALUES (:player, :time, 0)");
|
||
$confirmStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$confirmStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$confirmStmt->execute();
|
||
|
||
// ★ 记录Web登录验证成功,供玩家进游戏时自动登录(持久化,不随轮询消费而消失)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
$verifiedStmt = $db->prepare("INSERT OR REPLACE INTO web_login_verified (player_name, verified_at) VALUES (:player, :time)");
|
||
$verifiedStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$verifiedStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$verifiedStmt->execute();
|
||
|
||
// 记录会话
|
||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||
$sessionToken = recordWebSession($player, $ipAddress);
|
||
|
||
success([
|
||
'player' => $player,
|
||
'session' => $sessionToken,
|
||
'mode' => 'email_verified'
|
||
], '邮箱验证成功');
|
||
}
|
||
|
||
// ===== 获取玩家绑定的邮箱(脱敏) =====
|
||
function getPlayerEmail() {
|
||
$player = getParam('player');
|
||
if (!$player) error('缺少player参数');
|
||
|
||
// 检查玩家是否注册
|
||
if (!isPlayerRegistered($player)) {
|
||
error('玩家未注册');
|
||
}
|
||
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||
success(['email' => '', 'masked_email' => ''], '未绑定邮箱');
|
||
}
|
||
|
||
$email = $row['email'];
|
||
success([
|
||
'email' => $email,
|
||
'masked_email' => maskEmail($email)
|
||
], '获取成功');
|
||
}
|
||
|
||
// ===== 修复库存数据(stock=0→-1) =====
|
||
function fixStock() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
// 将所有stock=0改为-1(游戏端0表示无限供应)
|
||
$before = $db->querySingle("SELECT COUNT(*) FROM shop_items WHERE stock = 0");
|
||
$db->exec("UPDATE shop_items SET stock = -1 WHERE stock = 0");
|
||
$after = $db->querySingle("SELECT COUNT(*) FROM shop_items WHERE stock = 0");
|
||
|
||
success([
|
||
'before_zero' => (int)$before,
|
||
'after_zero' => (int)$after,
|
||
'fixed' => (int)$before - (int)$after
|
||
], "修复了{$before}个商品的库存");
|
||
}
|
||
|
||
// ===== 发送重置密码链接 =====
|
||
function sendResetPasswordLink() {
|
||
$player = getParam('player');
|
||
|
||
if (!$player) error('缺少player参数');
|
||
|
||
$db = getDB();
|
||
|
||
// 检查玩家是否注册
|
||
if (!isPlayerRegistered($player)) {
|
||
error('玩家未注册');
|
||
}
|
||
|
||
// 从数据库获取玩家邮箱
|
||
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row || !$row['email'] || $row['email'] === '') {
|
||
// 玩家未绑定邮箱,创建待管理员审核的记录
|
||
$db->exec("CREATE TABLE IF NOT EXISTS password_reset_requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
requested_email TEXT DEFAULT '',
|
||
status TEXT DEFAULT 'pending',
|
||
admin_approved INTEGER DEFAULT 0,
|
||
admin_email TEXT DEFAULT '',
|
||
created_at INTEGER NOT NULL,
|
||
processed_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
$stmt = $db->prepare("INSERT INTO password_reset_requests (player_name, status, created_at) VALUES (:player, 'pending', :time)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
error('该玩家未绑定邮箱,需要管理员审核。管理员将在后台验明身份后处理您的请求。');
|
||
}
|
||
$email = $row['email'];
|
||
|
||
// 生成重置token
|
||
$resetToken = bin2hex(random_bytes(32));
|
||
$expireTime = time() + 1800; // 30分钟有效
|
||
|
||
// 存储重置token
|
||
$db->exec("CREATE TABLE IF NOT EXISTS password_reset_tokens (token TEXT PRIMARY KEY, player_name TEXT NOT NULL, expire_at INTEGER NOT NULL)");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO password_reset_tokens (token, player_name, expire_at) VALUES (:token, :player, :expire)");
|
||
$stmt->bindValue(':token', $resetToken, SQLITE3_TEXT);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 发送重置邮件
|
||
$smtpHost = SMTP_HOST;
|
||
$smtpPort = SMTP_PORT;
|
||
$smtpUser = SMTP_USER;
|
||
$smtpPass = SMTP_PASS;
|
||
$senderName = SMTP_SENDER_NAME;
|
||
|
||
$subject = "【" . $senderName . "】密码重置";
|
||
$resetUrl = WEBSUB_DIR . 'reset_password.php?token=' . $resetToken;
|
||
$htmlBody = "<div style='font-family:Arial,sans-serif;max-width:400px;margin:0 auto;padding:20px;background:#f5f5f5;border-radius:10px'>"
|
||
. "<h2 style='color:#333;text-align:center'>🔑 密码重置</h2>"
|
||
. "<div style='background:#fff;padding:20px;border-radius:8px;text-align:center;margin:20px 0'>"
|
||
. "<p style='font-size:14px;color:#666;margin-bottom:10px'>玩家 <b>" . htmlspecialchars($player) . "</b>,您请求了密码重置</p>"
|
||
. "<p style='font-size:13px;color:#666;margin-bottom:20px'>点击下方按钮重置密码(30分钟内有效)</p>"
|
||
. "<a href='" . $resetUrl . "' style='display:inline-block;padding:12px 24px;background:#4CAF50;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold'>重置密码</a>"
|
||
. "</div>"
|
||
. "<p style='font-size:12px;color:#999;text-align:center'>如果这不是您的操作,请忽略此邮件</p>"
|
||
. "</div>";
|
||
|
||
$headers = "MIME-Version: 1.0\r\n"
|
||
. "Content-type: text/html; charset=UTF-8\r\n"
|
||
. "From: " . $senderName . " <" . $smtpUser . ">\r\n";
|
||
|
||
try {
|
||
smtpSendEmail($smtpHost, $smtpPort, $smtpUser, $smtpPass, $email, $subject, $htmlBody, $headers, SMTP_USE_SSL);
|
||
success(['player' => $player, 'email' => $email], '重置链接已发送到邮箱');
|
||
} catch (\Throwable $e) {
|
||
error('邮件发送失败:' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 重置密码 =====
|
||
function resetPassword() {
|
||
$resetToken = getParam('reset_token');
|
||
$newPassword = getParam('new_password');
|
||
|
||
if (!$resetToken) error('缺少reset_token参数');
|
||
if (!$newPassword) error('缺少new_password参数');
|
||
// ★第48批#245:与游戏 PasswordUtils.validate 同一把尺子(6-20位+大写+小写+数字)
|
||
$ruleErr = pwdRuleError($newPassword);
|
||
if ($ruleErr !== null) error($ruleErr);
|
||
|
||
$db = getDB();
|
||
|
||
// 验证重置token
|
||
$stmt = $db->prepare("SELECT * FROM password_reset_tokens WHERE token = :token");
|
||
$stmt->bindValue(':token', $resetToken, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row) error('无效的重置链接');
|
||
|
||
if (time() > $row['expire_at']) {
|
||
error('重置链接已过期');
|
||
}
|
||
|
||
$player = $row['player_name'];
|
||
|
||
// 生成新的密码凭证
|
||
$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', $resetToken, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
|
||
success(['player' => $player], '密码重置成功');
|
||
}
|
||
|
||
// ===== 管理员列出用户 =====
|
||
function adminListUsers() {
|
||
$token = getParam('token');
|
||
if (!$token) error('缺少token参数');
|
||
|
||
$tokenInfo = validateToken($token);
|
||
if (!$tokenInfo || ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all')) {
|
||
error('需要管理员权限');
|
||
}
|
||
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT * FROM users ORDER BY register_time DESC");
|
||
$result = $stmt->execute();
|
||
|
||
$users = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$users[] = $row;
|
||
}
|
||
|
||
success($users);
|
||
}
|
||
|
||
// ===== 管理员发送重置链接 =====
|
||
function adminSendResetLink() {
|
||
$token = getParam('token');
|
||
$player = getParam('player');
|
||
$email = getParam('email');
|
||
|
||
if (!$player) error('缺少player参数');
|
||
if (!$email) error('缺少email参数');
|
||
|
||
// 支持两种认证方式:token认证 或 管理员会话认证
|
||
$authenticated = false;
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||
$authenticated = true;
|
||
}
|
||
}
|
||
// 尝试管理员认证(通过admin_sessions表)
|
||
if (!$authenticated) {
|
||
requireAdminSession();
|
||
$authenticated = true;
|
||
}
|
||
|
||
if (!$authenticated) {
|
||
error('需要管理员权限');
|
||
}
|
||
|
||
$db = getDB();
|
||
|
||
// 检查玩家是否存在
|
||
if (!isPlayerRegistered($player)) {
|
||
error('玩家不存在');
|
||
}
|
||
|
||
// 绑定邮箱(如果玩家没有邮箱)
|
||
$emailStmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
|
||
$emailStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$emailResult = $emailStmt->execute();
|
||
$emailRow = $emailResult->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$emailRow || !$emailRow['email'] || $emailRow['email'] === '') {
|
||
$updateEmailStmt = $db->prepare("UPDATE users SET email = :email WHERE player_name = :player");
|
||
$updateEmailStmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||
$updateEmailStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$updateEmailStmt->execute();
|
||
}
|
||
|
||
// 生成重置token
|
||
$resetToken = bin2hex(random_bytes(32));
|
||
$expireTime = time() + 1800; // 30分钟有效
|
||
|
||
// 存储重置token
|
||
$db->exec("CREATE TABLE IF NOT EXISTS password_reset_tokens (token TEXT PRIMARY KEY, player_name TEXT NOT NULL, expire_at INTEGER NOT NULL)");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO password_reset_tokens (token, player_name, expire_at) VALUES (:token, :player, :expire)");
|
||
$stmt->bindValue(':token', $resetToken, SQLITE3_TEXT);
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':expire', $expireTime, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 发送重置邮件
|
||
$smtpHost = SMTP_HOST;
|
||
$smtpPort = SMTP_PORT;
|
||
$smtpUser = SMTP_USER;
|
||
$smtpPass = SMTP_PASS;
|
||
$senderName = SMTP_SENDER_NAME;
|
||
|
||
$subject = "【" . $senderName . "】密码重置";
|
||
$resetUrl = WEBSUB_DIR . 'reset_password.php?token=' . $resetToken;
|
||
$htmlBody = "<div style='font-family:Arial,sans-serif;max-width:400px;margin:0 auto;padding:20px;background:#f5f5f5;border-radius:10px'>"
|
||
. "<h2 style='color:#333;text-align:center'>🔑 密码重置</h2>"
|
||
. "<div style='background:#fff;padding:20px;border-radius:8px;text-align:center;margin:20px 0'>"
|
||
. "<p style='font-size:14px;color:#666;margin-bottom:10px'>玩家 <b>" . htmlspecialchars($player) . "</b>,您请求了密码重置</p>"
|
||
. "<p style='font-size:13px;color:#666;margin-bottom:20px'>点击下方按钮重置密码(30分钟内有效)</p>"
|
||
. "<a href='" . $resetUrl . "' style='display:inline-block;padding:12px 24px;background:#4CAF50;color:#fff;text-decoration:none;border-radius:6px;font-weight:bold'>重置密码</a>"
|
||
. "</div>"
|
||
. "<p style='font-size:12px;color:#999;text-align:center'>如果这不是您的操作,请忽略此邮件</p>"
|
||
. "</div>";
|
||
|
||
$headers = "MIME-Version: 1.0\r\n"
|
||
. "Content-type: text/html; charset=UTF-8\r\n"
|
||
. "From: " . $senderName . " <" . $smtpUser . ">\r\n";
|
||
|
||
try {
|
||
smtpSendEmail($smtpHost, $smtpPort, $smtpUser, $smtpPass, $email, $subject, $htmlBody, $headers, SMTP_USE_SSL);
|
||
success(['player' => $player, 'email' => $email], '重置链接已发送到邮箱');
|
||
} catch (\Throwable $e) {
|
||
error('邮件发送失败:' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 修改密码 =====
|
||
function changePassword() {
|
||
$player = getParam('player');
|
||
$oldPwd = getParam('old_password');
|
||
$newPwd = getParam('new_password');
|
||
|
||
if (!$player || !$oldPwd || !$newPwd) {
|
||
error('缺少必要参数');
|
||
}
|
||
|
||
// ★第48批#245:与游戏 PasswordUtils.validate 同一把尺子(6-20位+大写+小写+数字)
|
||
$ruleErr = pwdRuleError($newPwd);
|
||
if ($ruleErr !== null) {
|
||
error($ruleErr);
|
||
}
|
||
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT password_hash, salt, temp_password_hash, temp_pw_expire FROM weblogin_credentials WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row) {
|
||
error('玩家不存在');
|
||
}
|
||
|
||
// 验证旧密码(★第48批#244:容错解盐,脏盐玩家也能在网页改密)
|
||
$saltBytes = saltDecode($row['salt']);
|
||
if ($saltBytes === false || $saltBytes === '') {
|
||
error('密码盐值无效');
|
||
}
|
||
|
||
$oldHashComputed = base64_encode(hash('sha256', $saltBytes . $oldPwd, true));
|
||
if ($oldHashComputed !== b64Clean($row['password_hash'])) { // ★第52批:存量 \/ 脏值容错
|
||
error('当前密码错误');
|
||
}
|
||
|
||
// 生成新盐值
|
||
$newSaltBytes = random_bytes(16);
|
||
$newSalt = base64_encode($newSaltBytes);
|
||
|
||
// 计算新密码哈希
|
||
$newHash = base64_encode(hash('sha256', $saltBytes . $newPwd, true));
|
||
|
||
// 实际上应该用新盐值,但为了简单先用旧盐值计算
|
||
// 正确做法:$newHash = base64_encode(hash('sha256', $newSaltBytes . $newPwd, true));
|
||
$newHash = base64_encode(hash('sha256', $newSaltBytes . $newPwd, true));
|
||
|
||
// 更新密码
|
||
$updateStmt = $db->prepare("UPDATE weblogin_credentials SET password_hash = :hash, salt = :salt, temp_password_hash = '', temp_pw_expire = 0 WHERE player_name = :player");
|
||
$updateStmt->bindValue(':hash', $newHash, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':salt', $newSalt, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$updateStmt->execute();
|
||
|
||
success(['message' => '密码修改成功'], '密码修改成功');
|
||
}
|
||
|
||
// ===== 延长Web登录Token有效期 =====
|
||
function extendWebToken() {
|
||
$webToken = getParam('web_token');
|
||
|
||
if (!$webToken) error('缺少web_token参数');
|
||
|
||
$db = getDB();
|
||
$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) error('无效的登录Token');
|
||
|
||
$playerName = $row['player_name'];
|
||
|
||
// 延长有效期:重置created_at为当前时间
|
||
$updateStmt = $db->prepare("UPDATE weblogin_tokens SET created_at = :time WHERE web_token = :token");
|
||
$updateStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$updateStmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||
$updateStmt->execute();
|
||
|
||
success(['player' => $playerName], 'Token有效期已延长');
|
||
}
|
||
|
||
// ===== 插件轮询待处理的Web注册请求(SECRET_KEY认证)=====
|
||
function checkPendingWebRegisterRequests() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$db = getDB();
|
||
try {
|
||
$db->exec("SELECT id FROM web_register_requests LIMIT 0");
|
||
} catch (\Throwable $e) {
|
||
$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
|
||
)");
|
||
}
|
||
|
||
// ★ 只读查询不需要BEGIN IMMEDIATE(避免与其它并发请求抢锁导致500)
|
||
// 清理过期请求 + 标记processing(非原子操作也OK,最坏情况是多拉一次)
|
||
try {
|
||
$db->exec("DELETE FROM web_register_requests WHERE created_at < " . (time() - 1800));
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkPendingWebRegisterRequests] cleanup error: " . $e->getMessage());
|
||
}
|
||
|
||
$stmt = $db->prepare("SELECT id, player_name, password_hash, salt, email, ip_address, created_at FROM web_register_requests WHERE status = 'pending' ORDER BY created_at ASC");
|
||
$result = $stmt->execute();
|
||
|
||
$requests = [];
|
||
$ids = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$requests[] = $row;
|
||
$ids[] = (int)$row['id'];
|
||
}
|
||
|
||
// 标记为processing(允许失败,下次还会拉到)
|
||
if (!empty($ids)) {
|
||
try {
|
||
$idList = implode(',', $ids);
|
||
$db->exec("UPDATE web_register_requests SET status = 'processing', processed_at = " . time() . " WHERE id IN ($idList)");
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkPendingWebRegisterRequests] mark processing error: " . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
success(['requests' => $requests, 'count' => count($requests)]);
|
||
}
|
||
|
||
// ===== 插件确认注册请求已处理(SECRET_KEY认证)=====
|
||
function completeWebRegisterRequest() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$requestId = getParam('request_id');
|
||
$result = getParam('result'); // 'success' 或 'failed'
|
||
$errorMsg = getParam('error', '');
|
||
|
||
if (!$requestId) error('缺少request_id');
|
||
|
||
$db = getDB();
|
||
$status = ($result === 'success') ? 'completed' : 'failed';
|
||
|
||
$stmt = $db->prepare("UPDATE web_register_requests SET status = :status, processed_at = :time WHERE id = :id");
|
||
$stmt->bindValue(':status', $status, SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 如果注册成功,将用户数据写入users表
|
||
if ($result === 'success') {
|
||
// 获取注册请求的详细信息
|
||
$stmt = $db->prepare("SELECT player_name, email, ip_address, created_at FROM web_register_requests WHERE id = :id");
|
||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if ($row) {
|
||
// 检查users表中是否已存在该用户
|
||
$checkStmt = $db->prepare("SELECT 1 FROM users WHERE player_name = :name");
|
||
$checkStmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
|
||
$checkResult = $checkStmt->execute();
|
||
|
||
if (!$checkResult->fetchArray()) {
|
||
// 用户不存在,插入新记录
|
||
$insertStmt = $db->prepare("INSERT INTO users (player_name, register_time, email) VALUES (:name, :time, :email)");
|
||
$insertStmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
|
||
$insertStmt->bindValue(':time', $row['created_at'], SQLITE3_INTEGER);
|
||
$insertStmt->bindValue(':email', $row['email'], SQLITE3_TEXT);
|
||
$insertStmt->execute();
|
||
|
||
debugLog("completeWebRegisterRequest: 将用户写入users表", [
|
||
'player' => $row['player_name'],
|
||
'register_time' => $row['created_at'],
|
||
'email' => $row['email']
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
success(['request_id' => $requestId, 'status' => $status]);
|
||
}
|
||
|
||
// ===== 第53批:插件轮询待处理的网页邮箱绑定请求(SECRET_KEY认证)=====
|
||
function checkPendingWebEmailBindRequests() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$db = getDB();
|
||
try {
|
||
$db->exec("SELECT id FROM web_email_bind_requests LIMIT 0");
|
||
} catch (\Throwable $e) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_email_bind_requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
email TEXT NOT NULL,
|
||
ip_address TEXT DEFAULT '',
|
||
status TEXT DEFAULT 'pending',
|
||
created_at INTEGER NOT NULL,
|
||
expire_at INTEGER NOT NULL,
|
||
approved_at INTEGER DEFAULT 0,
|
||
processed_at INTEGER DEFAULT 0,
|
||
fail_reason TEXT DEFAULT ''
|
||
)");
|
||
}
|
||
|
||
$now = time();
|
||
// 惰性终结超时未处理的请求(CLI通知1分钟时效)
|
||
try {
|
||
$db->exec("UPDATE web_email_bind_requests SET status = 'expired', processed_at = " . $now . " WHERE status IN ('pending','notified') AND expire_at < " . $now);
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkPendingWebEmailBindRequests] expire error: " . $e->getMessage());
|
||
}
|
||
|
||
// ★ 只读查询不需要BEGIN IMMEDIATE(避免与其它并发请求抢锁导致500)
|
||
$stmt = $db->prepare("SELECT id, player_name, email, created_at, expire_at FROM web_email_bind_requests WHERE status = 'pending' ORDER BY created_at ASC");
|
||
$result = $stmt->execute();
|
||
|
||
$requests = array();
|
||
$ids = array();
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$requests[] = $row;
|
||
$ids[] = (int)$row['id'];
|
||
}
|
||
|
||
// 拉取即标记notified(防重复通知;允许失败,下次还会拉到)
|
||
if (!empty($ids)) {
|
||
try {
|
||
$idList = implode(',', $ids);
|
||
$db->exec("UPDATE web_email_bind_requests SET status = 'notified' WHERE id IN ($idList) AND status = 'pending'");
|
||
} catch (\Throwable $e) {
|
||
@error_log("[checkPendingWebEmailBindRequests] mark notified error: " . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
success(['requests' => $requests, 'count' => count($requests)]);
|
||
}
|
||
|
||
// ===== 第53批:插件回传游戏内玩家对邮箱绑定请求的处理结果(SECRET_KEY认证)=====
|
||
// 注:checkEmailSuffix() 已移至 core.php 共享(第56批)
|
||
function webEmailBindResult() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
// ★ action 只从 POST body 读:URL 路由参数 action=web_email_bind_result 会污染 getParam($_GET优先)
|
||
$savedGetAction = $_GET['action'] ?? null;
|
||
unset($_GET['action']);
|
||
$requestId = getParam('request_id');
|
||
$action = getParam('action', ''); // approve / reject / offline / failed
|
||
$player = getParam('player', '');
|
||
$failReason = getParam('fail_reason', '');
|
||
$pwdVerified = ((string)getParam('pwd_verified', '0') === '1');
|
||
if ($savedGetAction !== null) $_GET['action'] = $savedGetAction;
|
||
|
||
if (!$requestId) error('缺少request_id');
|
||
if (!in_array($action, array('approve', 'reject', 'offline', 'failed'), true)) error('非法action');
|
||
if (!$player) error('缺少player');
|
||
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT id, player_name, email, status, expire_at FROM web_email_bind_requests WHERE id = :id");
|
||
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) error('请求不存在');
|
||
if ((string)$row['player_name'] !== (string)$player) error('玩家名不匹配');
|
||
|
||
$now = time();
|
||
$current = (string)$row['status'];
|
||
$open = in_array($current, array('pending', 'notified'), true);
|
||
|
||
if (!$open) {
|
||
// 已终结:幂等返回当前状态(重复点击/迟到回调直接忽略,不做任何处置)
|
||
success(['request_id' => (int)$requestId, 'status' => $current], '请求已终结,忽略');
|
||
return;
|
||
}
|
||
|
||
if ($action === 'approve') {
|
||
if ($pwdVerified) {
|
||
// ★ 第57批续14: pwdVerified=true→Java已验密码,直接完成,expire_at只约束pending/notified阶段
|
||
$bindEmail = (string)$row['email'];
|
||
if (!$bindEmail || !filter_var($bindEmail, FILTER_VALIDATE_EMAIL)) error('请求数据异常');
|
||
if (acctCompleteEmailBind($requestId, $player, $bindEmail)) {
|
||
success(['request_id' => (int)$requestId, 'status' => 'completed', 'email' => $bindEmail]);
|
||
return;
|
||
}
|
||
error('绑定失败,请重试');
|
||
}
|
||
$newStatus = ($now > (int)$row['expire_at']) ? 'expired' : 'approved';
|
||
} elseif ($action === 'reject') {
|
||
$newStatus = 'rejected';
|
||
} elseif ($action === 'offline') {
|
||
$newStatus = 'offline';
|
||
} else {
|
||
$newStatus = 'failed';
|
||
}
|
||
|
||
$approvedAt = ($newStatus === 'approved') ? $now : 0;
|
||
$reason = ($newStatus === 'failed') ? (string)$failReason : '';
|
||
|
||
$upd = $db->prepare("UPDATE web_email_bind_requests SET status = :status, approved_at = :approved, processed_at = :time, fail_reason = :reason WHERE id = :id");
|
||
$upd->bindValue(':status', $newStatus, SQLITE3_TEXT);
|
||
$upd->bindValue(':approved', $approvedAt, SQLITE3_INTEGER);
|
||
$upd->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$upd->bindValue(':reason', $reason, SQLITE3_TEXT);
|
||
$upd->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
|
||
$upd->execute();
|
||
|
||
success(['request_id' => (int)$requestId, 'status' => $newStatus]);
|
||
}
|
||
|
||
// ===== CDK验证(供sdf1插件调用)=====
|
||
// sdf1插件本地CDK未找到时,请求Web后端验证
|
||
function validateCdk() {
|
||
$secret = getParam('secret');
|
||
$code = getParam('code', '');
|
||
$player = getParam('player', ''); // ★ 必须传player,否则无法加债券
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
if (empty($code)) error('缺少CDK码');
|
||
if (empty($player)) error('缺少player参数');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS cdk (code TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, used INTEGER DEFAULT 0, used_by TEXT DEFAULT '', created_at INTEGER DEFAULT 0, used_at INTEGER DEFAULT 0)");
|
||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (player_name TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)");
|
||
|
||
try {
|
||
$stmt = $db->prepare("SELECT code, amount, used, used_by FROM cdk WHERE code = :code");
|
||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row) {
|
||
success(['found' => false, 'status' => 'not_found']);
|
||
return;
|
||
}
|
||
|
||
if ($row['used'] == 1) {
|
||
success([
|
||
'found' => true,
|
||
'status' => 'already_used',
|
||
'used_by' => $row['used_by'] ?? ''
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// ★ 原子标记:防止并发双重兑换
|
||
$updateStmt = $db->prepare("UPDATE cdk SET used = 1, used_by = :player, used_at = :time WHERE code = :code AND used = 0");
|
||
$updateStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$updateStmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$updateStmt->execute();
|
||
|
||
if ($db->changes() == 0) {
|
||
// 并发竞争:已被其他请求使用
|
||
success(['found' => true, 'status' => 'already_used']);
|
||
return;
|
||
}
|
||
|
||
// ★ CDK有效:加债券 + 写流水 + 写sync_requests
|
||
$amount = (int)$row['amount'];
|
||
$now = time();
|
||
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
try {
|
||
// 1. 加债券到bond_cache
|
||
$chk = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||
$chk->bindValue(':name', $player, SQLITE3_TEXT);
|
||
$chkR = $chk->execute();
|
||
$existing = $chkR->fetchArray(SQLITE3_ASSOC);
|
||
$curBal = $existing ? (int)$existing['amount'] : 0;
|
||
$newBal = $curBal + $amount;
|
||
|
||
if ($existing) {
|
||
$u = $db->prepare("UPDATE bond_cache SET amount = :amt, updated_at = :t WHERE player_name = :n");
|
||
$u->bindValue(':amt', $newBal, SQLITE3_INTEGER);
|
||
$u->bindValue(':t', $now, SQLITE3_INTEGER);
|
||
$u->bindValue(':n', $player, SQLITE3_TEXT);
|
||
$u->execute();
|
||
} else {
|
||
$ins = $db->prepare("INSERT INTO bond_cache (player_name, amount, updated_at) VALUES (:n, :amt, :t)");
|
||
$ins->bindValue(':n', $player, SQLITE3_TEXT);
|
||
$ins->bindValue(':amt', $newBal, SQLITE3_INTEGER);
|
||
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
|
||
$ins->execute();
|
||
}
|
||
|
||
// 2. 写web_transactions流水(completed,因为Java端已标记used)
|
||
$tx = $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)");
|
||
$tx->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$tx->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
$tx->bindValue(':reason', "CDK兑换: {$code}", SQLITE3_TEXT);
|
||
$tx->bindValue(':detail', json_encode(['code' => $code, 'source' => 'web_validate'], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$tx->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$tx->execute();
|
||
|
||
// 3. 写sync_requests触发Java立即拉取
|
||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||
$sr = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||
$sr->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$sr->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$sr->execute();
|
||
|
||
$db->exec('COMMIT');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||
@error_log("[validateCdk] 事务失败: " . $e->getMessage());
|
||
}
|
||
|
||
@error_log("[validateCdk] 成功: code={$code} player={$player} amount={$amount}");
|
||
success([
|
||
'found' => true,
|
||
'status' => 'success',
|
||
'amount' => $amount
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
@error_log('[validateCdk] Error: ' . $e->getMessage());
|
||
error('验证失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== CDK存在性检查(只检查不消耗)=====
|
||
// 供Sdf1_login轮询时判断CDK是否存在于Web,不标记已使用
|
||
function checkCdkExists() {
|
||
$secret = getParam('secret');
|
||
$code = getParam('code', '');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
if (empty($code)) error('缺少CDK码');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS cdk (code TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, used INTEGER DEFAULT 0, used_by TEXT DEFAULT '', created_at INTEGER DEFAULT 0, used_at INTEGER DEFAULT 0)");
|
||
|
||
try {
|
||
$stmt = $db->prepare("SELECT code, amount, used, used_by FROM cdk WHERE code = :code");
|
||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
// ★ 返回扁平JSON(不用success()包装),found用字符串(Java extractJsonStr只支持字符串值)
|
||
if (!$row) {
|
||
jsonResponse(['found' => 'false', 'status' => 'not_found']);
|
||
return;
|
||
}
|
||
|
||
if ($row['used'] == 1) {
|
||
jsonResponse([
|
||
'found' => 'true',
|
||
'status' => 'already_used',
|
||
'used_by' => $row['used_by'] ?? ''
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// CDK存在且未使用,只返回信息,不标记已使用
|
||
// ★ amount必须返回字符串(Java extractJsonStr只解析字符串值,数字会被跳过)
|
||
@error_log("[checkCdkExists] found: code={$code} amount={$row['amount']}");
|
||
jsonResponse([
|
||
'found' => 'true',
|
||
'status' => 'available',
|
||
'amount' => (string)$row['amount']
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
@error_log('[checkCdkExists] Error: ' . $e->getMessage());
|
||
error('检查失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== 远程CDK兑换:标记已使用 + 写流水(不加bond_cache,由Java本地处理) =====
|
||
function cdkRedeemRemote() {
|
||
$secret = getParam('secret');
|
||
$code = getParam('code', '');
|
||
$player = getParam('player', '');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
if (empty($code)) error('缺少CDK码');
|
||
if (empty($player)) error('缺少player参数');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS cdk (code TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, used INTEGER DEFAULT 0, used_by TEXT DEFAULT '', created_at INTEGER DEFAULT 0, used_at INTEGER DEFAULT 0)");
|
||
// ★ web_transactions表结构必须与其他函数一致
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||
|
||
try {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
|
||
// 1. 检查CDK是否存在且未使用
|
||
$stmt = $db->prepare("SELECT code, amount, used FROM cdk WHERE code = :code");
|
||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$row) {
|
||
$db->exec('ROLLBACK');
|
||
jsonResponse(['success' => 'false', 'status' => 'not_found']);
|
||
return;
|
||
}
|
||
|
||
if ($row['used'] == 1) {
|
||
$db->exec('ROLLBACK');
|
||
jsonResponse(['success' => 'true', 'status' => 'already_used']);
|
||
return;
|
||
}
|
||
|
||
// 2. 原子标记为已使用
|
||
$updateStmt = $db->prepare("UPDATE cdk SET used = 1, used_by = :player, used_at = :time WHERE code = :code AND used = 0");
|
||
$updateStmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$updateStmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$updateStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$updateStmt->execute();
|
||
|
||
if ($db->changes() == 0) {
|
||
// 并发竞争:已被其他请求使用
|
||
$db->exec('ROLLBACK');
|
||
jsonResponse(['success' => 'true', 'status' => 'already_used']);
|
||
return;
|
||
}
|
||
|
||
// 3. 写入流水记录(与validateCdk格式一致)
|
||
$amount = (int)$row['amount'];
|
||
$now = time();
|
||
$ins = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:player, :type, :amount, :operator, :reason, :detail, :status, :time)");
|
||
$ins->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$ins->bindValue(':type', 'cdk_redeem_remote', SQLITE3_TEXT);
|
||
$ins->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
$ins->bindValue(':operator', '', SQLITE3_TEXT);
|
||
$ins->bindValue(':reason', "CDK兑换: {$code}", SQLITE3_TEXT);
|
||
$ins->bindValue(':detail', json_encode(['code' => $code, 'source' => 'game_remote'], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$ins->bindValue(':status', 'completed', SQLITE3_TEXT);
|
||
$ins->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$ins->execute();
|
||
|
||
$db->exec('COMMIT');
|
||
|
||
@error_log("[cdkRedeemRemote] success: code={$code} amount={$amount} player={$player}");
|
||
jsonResponse([
|
||
'success' => 'true',
|
||
'status' => 'success',
|
||
'amount' => (string)$amount
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $ignored) {}
|
||
@error_log('[cdkRedeemRemote] Error: ' . $e->getMessage());
|
||
error('兑换失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== Java拉取CDK验证请求 =====
|
||
// ===== CDK表迁移修复 =====
|
||
function migrateCdkTable() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$log = [];
|
||
|
||
// 1. 确保cdk_validate_requests表存在且有player_name列
|
||
$r = $db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='cdk_validate_requests'");
|
||
if (!$r) {
|
||
$db->exec("CREATE TABLE 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
|
||
)");
|
||
$log[] = 'Created cdk_validate_requests table';
|
||
} else {
|
||
$cols = [];
|
||
$colResult = $db->query("PRAGMA table_info(cdk_validate_requests)");
|
||
while ($col = $colResult->fetchArray(SQLITE3_ASSOC)) { $cols[] = $col['name']; }
|
||
$log[] = 'Existing columns: ' . implode(', ', $cols);
|
||
if (!in_array('player_name', $cols)) {
|
||
$db->exec("ALTER TABLE cdk_validate_requests ADD COLUMN player_name TEXT DEFAULT ''");
|
||
$log[] = 'Added player_name column';
|
||
} else {
|
||
$log[] = 'player_name column already exists';
|
||
}
|
||
}
|
||
|
||
// 2. 确保cdk_validate_results表存在
|
||
$r2 = $db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='cdk_validate_results'");
|
||
if (!$r2) {
|
||
$db->exec("CREATE TABLE 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
|
||
)");
|
||
$log[] = 'Created cdk_validate_results table';
|
||
} else {
|
||
$log[] = 'cdk_validate_results table exists';
|
||
}
|
||
|
||
// 3. 确保cdk表存在
|
||
$r3 = $db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='cdk'");
|
||
if (!$r3) {
|
||
$db->exec("CREATE TABLE cdk (
|
||
code TEXT PRIMARY KEY,
|
||
amount INTEGER DEFAULT 0,
|
||
type TEXT DEFAULT 'bond',
|
||
used INTEGER DEFAULT 0,
|
||
used_by TEXT DEFAULT '',
|
||
created_at INTEGER DEFAULT 0,
|
||
used_at INTEGER DEFAULT 0
|
||
)");
|
||
$log[] = 'Created cdk table';
|
||
} else {
|
||
$log[] = 'cdk table exists';
|
||
}
|
||
|
||
success(['log' => $log], '迁移完成');
|
||
}
|
||
|
||
function pullCdkValidateRequests() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
// ★ 迁移操作用事务包装防锁
|
||
try {
|
||
$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
|
||
)");
|
||
$colResult = $db->query("PRAGMA table_info(cdk_validate_requests)");
|
||
$cols = [];
|
||
while ($col = $colResult->fetchArray(SQLITE3_ASSOC)) { $cols[] = $col['name']; }
|
||
if (!in_array('player_name', $cols)) {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
$db->exec("ALTER TABLE cdk_validate_requests ADD COLUMN player_name TEXT DEFAULT ''");
|
||
$db->exec('COMMIT');
|
||
}
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||
}
|
||
|
||
try {
|
||
// ★ 返回player_name
|
||
$result = $db->query("SELECT request_id, code, player_name FROM cdk_validate_requests WHERE status = 'pending' ORDER BY created_at ASC LIMIT 10");
|
||
$requests = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$requests[] = $row;
|
||
}
|
||
success(['requests' => $requests]);
|
||
} catch (\Throwable $e) {
|
||
@error_log('[pullCdkValidateRequests] Error: ' . $e->getMessage());
|
||
error('拉取失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== Java推送CDK验证结果 =====
|
||
function pushCdkValidateResult() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$requestId = getParam('request_id', '');
|
||
$status = getParam('status', 'not_found'); // success / not_found / already_used
|
||
$code = getParam('code', '');
|
||
$amount = (int)getParam('amount', 0);
|
||
|
||
if (empty($requestId)) error('缺少request_id');
|
||
|
||
$db = getDB();
|
||
$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
|
||
)");
|
||
|
||
try {
|
||
// ★ BEGIN IMMEDIATE防database is locked
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
$now = time();
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO cdk_validate_results (request_id, code, status, amount, message, created_at) VALUES (:id, :code, :status, :amount, :msg, :time)");
|
||
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
|
||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||
$stmt->bindValue(':status', $status, SQLITE3_TEXT);
|
||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':msg', '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 同时标记请求为已完成
|
||
$stmt2 = $db->prepare("UPDATE cdk_validate_requests SET status = 'done' WHERE request_id = :id");
|
||
$stmt2->bindValue(':id', $requestId, SQLITE3_TEXT);
|
||
$stmt2->execute();
|
||
$db->exec('COMMIT');
|
||
|
||
@error_log("[pushCdkValidateResult] request_id={$requestId} code={$code} status={$status} amount={$amount}");
|
||
success(['request_id' => $requestId]);
|
||
} catch (\Throwable $e) {
|
||
$db->exec('ROLLBACK');
|
||
@error_log('[pushCdkValidateResult] Error: ' . $e->getMessage());
|
||
error('推送失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ===== Java端推送插件管理员列表 =====
|
||
function syncAdmins() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$adminsRaw = getParam('admins', '[]');
|
||
$admins = json_decode($adminsRaw, true);
|
||
if (!is_array($admins)) error('admins格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_plugin_admins (
|
||
player_name TEXT PRIMARY KEY,
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
$now = time();
|
||
// ★ 只增改、不整表删除(2026-10-05 对账改造):
|
||
// Java 端只收集得到【在线】管理员,整表 DELETE 会把离线管理员一条条抹掉。
|
||
// 改为 upsert 后:Java 有的补进去;PHP 多出的(离线管理员)保留不动。
|
||
// 取消管理员的场景由 Java 端对账 extra 日志暴露,不再静默丢数据。
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_plugin_admins (player_name, synced_at) VALUES (:name, :now)");
|
||
foreach ($admins as $name) {
|
||
if (empty($name)) continue;
|
||
$stmt->bindValue(':name', strtolower($name), SQLITE3_TEXT);
|
||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
}
|
||
success(['count' => count($admins)]);
|
||
}
|
||
|
||
// ===== 第十六批:公开封禁公示(玩家端首页卡片,从近到远取前9条) =====
|
||
// 无需 SECRET / token:web_player_bans 是已公开的公示名单(不含敏感字段),
|
||
// 返回 target/ban_type/reason/expire_time。用 synced_at 作为近似封禁时间排序。
|
||
function getBanBoard() {
|
||
$limit = (int) getParam('limit', 9);
|
||
if ($limit <= 0 || $limit > 50) $limit = 9;
|
||
|
||
try { $db = getDB(); } catch (Throwable $e) { $db = null; }
|
||
if (!$db) { success(['total' => 0, 'bans' => []], 'db unavailable'); return; }
|
||
|
||
// ★ 第十六批缺陷修复:禁止用 sqlite_master 探测表是否存在 ——
|
||
// 那条 SQL 抽不出 web_player_bans(只抽得到 sqlite_master),SdfDbRouter::side()
|
||
// 会把语句判成 web 侧;而 web_player_bans 在 dbJavaTables() 清单里,实际建在 java 侧,
|
||
// 于是探测恒为空 → 公示永远返回"暂无"(后台却挂着几十条封禁)。
|
||
// 改为直接查目标表:SQL 自带表名 → 路由到正确一侧;
|
||
// 若路由到的那侧根本没有这张表(个别站点库未切分/表在另一侧)→ 显式换侧再查一次。
|
||
// 仅公示名字封禁(ban_type='name'),IP 封禁不公开(IP 属敏感信息)
|
||
$now = time();
|
||
// ★ 第十九批:只公示「播报起点」之后创建的封禁 —— 与游戏内播报同源(Java推送时下发),
|
||
// 插件上服务器之前的历史封禁不上公示;起点未下发(0)时不过滤,行为与旧版一致。
|
||
try { $db->exec("ALTER TABLE web_player_bans ADD COLUMN created_time INTEGER DEFAULT 0"); } catch (Throwable $e) {}
|
||
$cutoff = 0;
|
||
try {
|
||
$cr = $db->query("SELECT start_cutoff FROM web_ban_board_cfg WHERE id = 1");
|
||
if ($cr) {
|
||
$crow = $cr->fetchArray(SQLITE3_ASSOC);
|
||
if ($crow) $cutoff = (int) ($crow['start_cutoff'] ?? 0);
|
||
}
|
||
} catch (Throwable $e) { $cutoff = 0; }
|
||
$sql = "SELECT target, ban_type, reason, expire_time, synced_at
|
||
FROM web_player_bans
|
||
WHERE ban_type = 'name' AND created_time >= " . $cutoff . "
|
||
ORDER BY synced_at DESC
|
||
LIMIT " . $limit;
|
||
|
||
// 返回 [查询是否成功, 公示数组]:false 表示该侧没有这张表/语句失败(区别于 0 条)
|
||
$fetch = function ($conn) use ($sql, $now) {
|
||
if (!$conn) return array(false, array());
|
||
$res = $conn->query($sql);
|
||
if (!$res) return array(false, array());
|
||
$out = array();
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$expire = (int) ($row['expire_time'] ?? 0);
|
||
// ★ 单位归一:Java 端 BanEntry 推的是毫秒(13位),公示接口对外统一按秒;
|
||
// 不归一的话「已过期跳过」判断恒不成立,前端还会把日期显示成 68260 年
|
||
if ($expire > 100000000000) $expire = intdiv($expire, 1000);
|
||
if ($expire > 0 && $expire <= $now) continue; // 跳过已过期的临时封禁
|
||
$out[] = array(
|
||
'target' => (string) ($row['target'] ?? ''),
|
||
'reason' => (string) ($row['reason'] ?? ''),
|
||
'expire_time'=> $expire, // 0 = 永久
|
||
'synced_at' => (int) ($row['synced_at'] ?? 0),
|
||
);
|
||
}
|
||
return array(true, $out);
|
||
};
|
||
|
||
$ok = false;
|
||
$bans = array();
|
||
try { list($ok, $bans) = $fetch($db); } catch (Throwable $e) { $ok = false; $bans = array(); }
|
||
|
||
// 兜底换侧:只在"查询失败"(表不在这一侧)时才换,避免把切分留在 web 库的旧副本当新数据读出来
|
||
if (!$ok && function_exists('dbOpen')) {
|
||
foreach (array('java', 'web') as $side) {
|
||
$conn = null;
|
||
try { $conn = dbOpen($side); } catch (Throwable $e) { $conn = null; }
|
||
if (!$conn) continue;
|
||
try {
|
||
list($ok, $bans) = $fetch($conn);
|
||
if ($ok) break;
|
||
} catch (Throwable $e) { /* 该侧也没有这张表,继续换下一侧 */ }
|
||
}
|
||
}
|
||
|
||
// ok=true 但 0 条 = 确实没人被封;ok=false = 两侧都没这张表(数据链路问题)
|
||
success(array('total' => count($bans), 'bans' => $bans),
|
||
count($bans) ? 'ok' : ($ok ? 'empty' : 'unavailable'));
|
||
}
|
||
|
||
// ===== Java端推送封禁名单到PHP =====
|
||
function syncBans() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$bansRaw = getParam('bans', '[]');
|
||
$bans = json_decode($bansRaw, true);
|
||
if (!is_array($bans)) error('bans格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_player_bans (
|
||
target TEXT NOT NULL,
|
||
ban_type TEXT NOT NULL DEFAULT 'name',
|
||
reason TEXT DEFAULT '',
|
||
source TEXT DEFAULT '',
|
||
expire_time INTEGER DEFAULT 0,
|
||
synced_at INTEGER DEFAULT 0,
|
||
created_time INTEGER DEFAULT 0,
|
||
PRIMARY KEY (target, ban_type)
|
||
)");
|
||
// ★ 第十九批:老库补列(CREATE TABLE IF NOT EXISTS 不会给老表补列)——
|
||
// created_time = 封禁创建时间(毫秒),公示按它过滤插件上架前的历史封禁
|
||
try { $db->exec("ALTER TABLE web_player_bans ADD COLUMN created_time INTEGER DEFAULT 0"); } catch (Throwable $e) {}
|
||
|
||
$now = time();
|
||
// ★ 第十六批:保留旧 synced_at 作为「首见时间」,供封禁公示按时间从近到远排序
|
||
// (新封禁用本次同步时间,老封禁保持首次出现时间,避免全量重写抹平时间轴)
|
||
$firstSeen = array();
|
||
$oldRes = $db->query("SELECT target, ban_type, synced_at FROM web_player_bans");
|
||
if ($oldRes) {
|
||
while ($orow = $oldRes->fetchArray(SQLITE3_ASSOC)) {
|
||
$firstSeen[$orow['target'] . "\x1f" . $orow['ban_type']] = (int) ($orow['synced_at'] ?? 0);
|
||
}
|
||
}
|
||
// 先清空旧数据再全量写入
|
||
$db->exec("DELETE FROM web_player_bans");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_player_bans (target, ban_type, reason, source, expire_time, synced_at, created_time) VALUES (:target, :type, :reason, :source, :expire, :now, :created)");
|
||
foreach ($bans as $ban) {
|
||
if (empty($ban['target'])) continue;
|
||
$stmt->bindValue(':target', $ban['target'], SQLITE3_TEXT);
|
||
$stmt->bindValue(':type', $ban['type'] ?? 'name', SQLITE3_TEXT);
|
||
$stmt->bindValue(':reason', $ban['reason'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':source', $ban['source'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':expire', (int)($ban['expire'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':created', (int)($ban['created'] ?? 0), SQLITE3_INTEGER);
|
||
$fk = $ban['target'] . "\x1f" . ($ban['type'] ?? 'name');
|
||
$stmt->bindValue(':now', (isset($firstSeen[$fk]) && $firstSeen[$fk] > 0) ? $firstSeen[$fk] : $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
}
|
||
|
||
// ★ v2:Java封禁IP时顺手把IP写入 web_ip_blacklist(Web端按IP拦截)
|
||
// ips 缺省时保持现有黑名单不动(兼容旧版Java端)
|
||
$ipCount = -1;
|
||
$ipsRaw = getParam('ips');
|
||
if ($ipsRaw !== null && $ipsRaw !== '') {
|
||
$ips = json_decode($ipsRaw, true);
|
||
if (is_array($ips)) {
|
||
$ipCount = syncIpBlacklist($db, $ips, $now);
|
||
}
|
||
}
|
||
|
||
// ★ 第十九批:接收 Java 端「播报起点」(与游戏内封禁播报同源),
|
||
// get_ban_board 用它过滤插件上服务器之前的历史封禁;Java 没传/传0 = 不过滤
|
||
$cutoffRaw = getParam('ban_cutoff', '');
|
||
if ($cutoffRaw !== null && $cutoffRaw !== '') {
|
||
$cutoff = (int) $cutoffRaw;
|
||
if ($cutoff < 0) $cutoff = 0;
|
||
try {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_ban_board_cfg (id INTEGER PRIMARY KEY, start_cutoff INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)");
|
||
$cst = $db->prepare("INSERT OR REPLACE INTO web_ban_board_cfg (id, start_cutoff, updated_at) VALUES (1, :c, :t)");
|
||
$cst->bindValue(':c', $cutoff, SQLITE3_INTEGER);
|
||
$cst->bindValue(':t', $now, SQLITE3_INTEGER);
|
||
$cst->execute();
|
||
} catch (Throwable $e) {}
|
||
}
|
||
|
||
success(['count' => count($bans), 'ip_blacklist' => $ipCount]);
|
||
}
|
||
|
||
// ===== Java端推送拉黑名单到PHP(第32批)=====
|
||
// 数据源唯一:Java BlacklistManager(黑名单.md),PHP 只读不写。
|
||
// 全量覆盖式同步(DELETE + INSERT),已过期条目不入表。
|
||
|
||
|
||
// ===== 第57批:确认解封——清空PHP端封禁数据 =====
|
||
// cancel ban 时调用:DELETE FROM web_player_bans,不写回banned-players.json
|
||
function clearBans() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_player_bans (
|
||
target TEXT NOT NULL,
|
||
ban_type TEXT NOT NULL DEFAULT 'name',
|
||
reason TEXT DEFAULT '',
|
||
source TEXT DEFAULT '',
|
||
expire_time INTEGER DEFAULT 0,
|
||
synced_at INTEGER DEFAULT 0,
|
||
created_time INTEGER DEFAULT 0,
|
||
PRIMARY KEY (target, ban_type)
|
||
)");
|
||
$db->exec("DELETE FROM web_player_bans");
|
||
|
||
success(['count' => 0]);
|
||
}
|
||
|
||
function syncBlacklist() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$blacksRaw = getParam('blacks', '[]');
|
||
$blacks = json_decode($blacksRaw, true);
|
||
if (!is_array($blacks)) error('blacks格式无效');
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_player_blacklist (
|
||
name TEXT PRIMARY KEY,
|
||
reason TEXT DEFAULT '',
|
||
time_text TEXT DEFAULT '',
|
||
expire_ms INTEGER DEFAULT 0,
|
||
created_ms INTEGER DEFAULT 0,
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
$now = time();
|
||
$nowMs = $now * 1000;
|
||
$count = 0;
|
||
try {
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
$db->exec("DELETE FROM web_player_blacklist");
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_player_blacklist (name, reason, time_text, expire_ms, created_ms, synced_at) VALUES (:n, :r, :t, :e, :c, :s)");
|
||
foreach ($blacks as $b) {
|
||
if (empty($b['name'])) continue;
|
||
$exp = (int)($b['expire'] ?? 0);
|
||
if ($exp > 0 && $exp <= $nowMs) continue; // 已过期不入表
|
||
$stmt->bindValue(':n', (string)$b['name'], SQLITE3_TEXT);
|
||
$stmt->bindValue(':r', (string)($b['reason'] ?? ''), SQLITE3_TEXT);
|
||
$stmt->bindValue(':t', (string)($b['time'] ?? ''), SQLITE3_TEXT);
|
||
$stmt->bindValue(':e', $exp, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':c', (int)($b['created'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':s', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
$db->exec('COMMIT');
|
||
} catch (\Throwable $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $ignored) {}
|
||
@error_log('[syncBlacklist] Error: ' . $e->getMessage());
|
||
error('拉黑名单写入失败: ' . $e->getMessage());
|
||
}
|
||
|
||
success(['count' => $count]);
|
||
}
|
||
|
||
/**
|
||
* ★ 把Java推送的IP封禁全量同步到 web_ip_blacklist
|
||
* 规则:当前封禁列表里的IP全部入表;表里已不存在于本次推送的IP自动移除(解封即放行)
|
||
* @return int 生效中的黑名单IP数
|
||
*/
|
||
function syncIpBlacklist($db, $ips, $now) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_ip_blacklist (
|
||
ip TEXT PRIMARY KEY,
|
||
reason TEXT DEFAULT '',
|
||
source TEXT DEFAULT '',
|
||
expire INTEGER DEFAULT 0,
|
||
banned_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 已过期的条目直接丢弃
|
||
$keep = [];
|
||
foreach ($ips as $item) {
|
||
$ip = is_array($item) ? ($item['ip'] ?? ($item['target'] ?? '')) : (string)$item;
|
||
$ip = trim($ip);
|
||
if ($ip === '') continue;
|
||
$expire = is_array($item) ? (int)($item['expire'] ?? 0) : 0;
|
||
if ($expire > 0 && $expire <= $now) continue; // 临时封禁已到期
|
||
$keep[$ip] = [
|
||
'reason' => is_array($item) ? (string)($item['reason'] ?? '') : '',
|
||
'source' => is_array($item) ? (string)($item['source'] ?? '') : '',
|
||
'expire' => $expire,
|
||
];
|
||
}
|
||
|
||
// 移除本次推送中已消失的IP(解封)
|
||
$existing = [];
|
||
$res = $db->query("SELECT ip FROM web_ip_blacklist");
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) $existing[$row['ip']] = true;
|
||
$del = $db->prepare("DELETE FROM web_ip_blacklist WHERE ip = :ip");
|
||
foreach (array_keys($existing) as $ip) {
|
||
if (!isset($keep[$ip])) {
|
||
$del->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||
$del->execute();
|
||
}
|
||
}
|
||
|
||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_ip_blacklist (ip, reason, source, expire, banned_at)
|
||
VALUES (:ip, :reason, :source, :expire, :banned_at)");
|
||
foreach ($keep as $ip => $meta) {
|
||
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||
$stmt->bindValue(':reason', $meta['reason'], SQLITE3_TEXT);
|
||
$stmt->bindValue(':source', $meta['source'], SQLITE3_TEXT);
|
||
$stmt->bindValue(':expire', (int)$meta['expire'], SQLITE3_INTEGER);
|
||
$stmt->bindValue(':banned_at', $now, SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
}
|
||
debugLog("syncIpBlacklist: 黑名单更新", ['count' => count($keep)]);
|
||
return count($keep);
|
||
}
|
||
|
||
// ===== 清空所有待处理的Web登录请求(游戏内「清空队列」联动调用,SECRET_KEY认证)=====
|
||
function clearPendingWebLogins() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) {
|
||
error('密钥验证失败', 403);
|
||
}
|
||
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_requests (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
password TEXT NOT NULL,
|
||
request_time INTEGER NOT NULL,
|
||
status TEXT DEFAULT 'pending',
|
||
result TEXT DEFAULT '',
|
||
result_time INTEGER DEFAULT 0
|
||
)");
|
||
|
||
$before = 0;
|
||
try {
|
||
$cnt = $db->query("SELECT COUNT(*) AS c FROM web_login_requests WHERE status = 'pending'");
|
||
$row = $cnt ? $cnt->fetchArray(SQLITE3_ASSOC) : null;
|
||
$before = $row ? (int)$row['c'] : 0;
|
||
} catch (\Throwable $e) {}
|
||
|
||
$now = time();
|
||
// 标记为cancelled(保留历史行,便于排查),Web端轮询立即看到已结束
|
||
$db->exec("UPDATE web_login_requests SET status = 'cancelled',
|
||
result = '{\"success\":false,\"message\":\"已由游戏内清空队列清理\"}',
|
||
result_time = " . (int)$now . " WHERE status = 'pending'");
|
||
|
||
debugLog("clearPendingWebLogins: 清理pending请求", ['cleared' => $before]);
|
||
success(['cleared' => $before]);
|
||
}
|