Files
Sdf1_login/api/sync.php
T
ypsdf1 165adcb532 第57批续15: 修复pwd_verified类型不匹配——Java发数字1,PHP=== '1'严格比较永远false
- 根因: Java端JSON body发送"pwd_verified":1(整数) → PHP json_decode解析为整数1
  → === '1'严格字符串比较 → 整数1 !== 字符串'1' → $pwdVerified永远false
  → 续14修复无效(条件改对了但值永远是false)
- 修复: (string)getParam('pwd_verified','0') === '1'
2026-10-26 23:24:32 +08:00

5953 lines
266 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ★ 同步API - 插件与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]);
}