Files
Sdf1_login/core.php
T
ypsdf1 98f9cb6410 @
领地系统大重构:全局配置+数据入库+新权限+传送点+安全删除+容器扩展

- 全局配置改为area_config表(替代txt文件)
- area_lands扩展到61列,新增10个权限布尔+5个传送点坐标
- 新增投掷物/发光/红石/门禁/音频/拴绳/农作物/剪毛/投喂等权限
- /protect settp/tp传送点系统
- 删除1分钟延迟+二次确认
- 安全传送判定(空气+实体+边界3格)
- 容器权限覆盖所有UI类型
- .gitignore清理敏感文件
@
2026-06-23 16:51:30 +08:00

1066 lines
42 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
// ===== Web通信系统 - 核心引擎 =====
require_once __DIR__ . '/config.php';
// ★ 确保任何前置输出(包括 BOM、空格、Warning)都被丢弃
if (ob_get_level() > 0) {
ob_end_clean();
}
// ===== 调试日志函数 =====
function debugLog($message, $context = []) {
$logFile = __DIR__ . '/db/debug.log';
$timestamp = date('Y-m-d H:i:s');
$logEntry = "[$timestamp] $message";
if (!empty($context)) {
$logEntry .= " | Context: " . json_encode($context, JSON_UNESCAPED_UNICODE);
}
$logEntry .= "\n";
// 确保db目录存在
$dbDir = dirname($logFile);
if (!is_dir($dbDir)) {
mkdir($dbDir, 0755, true);
}
file_put_contents($logFile, $logEntry, FILE_APPEND | LOCK_EX);
}
// ===== 数据库初始化 =====
function getDB() {
static $db = null;
if ($db === null) {
// 确保db目录存在
$dbDir = dirname(DB_PATH);
if (!is_dir($dbDir)) {
@mkdir($dbDir, 0755, true);
}
$db = new SQLite3(DB_PATH);
$db->enableExceptions(true);
// WAL模式 + 长等待超时防止database is locked
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA busy_timeout=30000'); // 30秒等待
$db->exec('PRAGMA synchronous=NORMAL');
$db->exec('PRAGMA cache_size=-64000'); // 64MB 缓存
$db->exec('PRAGMA wal_autocheckpoint=1000');
initTables($db);
migrateDatabase($db); // 迁移旧数据库
}
return $db;
}
/**
* 数据库迁移:为旧数据库添加新字段(通过 ALTER TABLE)
* 仅在表存在但缺少新字段时执行,且只执行一次(用标记文件)
*/
function migrateDatabase(SQLite3 $db) {
// 检查是否已迁移(用文件标记,避免每次都做 PRAGMA 查询)
$migrateLock = DB_PATH . '.migrated';
if (file_exists($migrateLock)) {
return; // 已迁移,直接跳过
}
// 检查 weblogin_credentials 表是否存在且是否有 temp_password_hash 字段
try {
$stmt = $db->prepare("PRAGMA table_info(weblogin_credentials)");
$result = $stmt->execute();
$columns = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$columns[] = $row['name'];
}
if (in_array('temp_password_hash', $columns)) {
// 字段已存在,创建标记
file_put_contents($migrateLock, time());
return;
}
debugLog("migrateDatabase: 开始迁移 weblogin_credentials");
// 使用事务包裹 ALTER TABLE,减少锁时间
$db->exec('BEGIN TRANSACTION');
try {
$db->exec("ALTER TABLE weblogin_credentials ADD COLUMN temp_password_hash TEXT DEFAULT ''");
$db->exec("ALTER TABLE weblogin_credentials ADD COLUMN temp_pw_expire INTEGER DEFAULT 0");
$db->exec('COMMIT');
// 迁移成功,创建标记文件
file_put_contents($migrateLock, time());
debugLog("migrateDatabase: 迁移成功,已创建标记文件");
} catch (Exception $e) {
$db->exec('ROLLBACK');
debugLog("migrateDatabase: 迁移失败", ['error' => $e->getMessage()]);
// 字段可能已存在(其他请求先执行了),也创建标记
file_put_contents($migrateLock, time());
}
} catch (Exception $e) {
// 表不存在,initTables 会创建,忽略
}
}
function initTables(SQLite3 $db) {
$db->exec('BEGIN TRANSACTION');
try {
// Token表
$db->exec("CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
player_name TEXT NOT NULL,
purpose TEXT DEFAULT 'general',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used INTEGER DEFAULT 0,
used_at INTEGER DEFAULT 0
)");
// 商品表
$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
)");
// ★ 兼容升级:给shop_items加admin_stock字段(已存在则跳过)
try {
$db->exec("ALTER TABLE shop_items ADD COLUMN admin_stock INTEGER DEFAULT NULL");
} catch (\Throwable $e) { /* 字段已存在,忽略 */ }
// ★ 兼容升级:给shop_items加last_modified字段(高频轮询检测改动用)
try {
$db->exec("ALTER TABLE shop_items ADD COLUMN last_modified INTEGER DEFAULT 0");
} catch (\Throwable $e) { /* 字段已存在,忽略 */ }
// CDK表
$db->exec("CREATE TABLE IF NOT EXISTS 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
)");
// 交易流水表(Web端记录)
$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 DEFAULT '',
detail TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
created_at INTEGER DEFAULT 0
)");
// 管理员登录会话
$db->exec("CREATE TABLE IF NOT EXISTS admin_sessions (
session_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)");
// 用户表(插件同步注册数据)
$db->exec("CREATE TABLE IF NOT EXISTS users (
player_name TEXT PRIMARY KEY,
register_time INTEGER DEFAULT 0,
last_login_time INTEGER DEFAULT 0,
email TEXT DEFAULT '',
points INTEGER DEFAULT 0,
gift_stage INTEGER DEFAULT 0,
total_online_time INTEGER DEFAULT 0
)");
// Web登录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
)");
// 债券缓存表
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
// ★ Web登录密码凭证表(含临时密码字段,一步到位,避免 ALTER TABLE 导致排他锁)
$db->exec("CREATE TABLE IF NOT EXISTS weblogin_credentials (
player_name TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
temp_password_hash TEXT DEFAULT '',
temp_pw_expire INTEGER DEFAULT 0,
pushed_at INTEGER NOT NULL
)");
// Web会话日志表(记录成功登录,用于快速重连判定)
$db->exec("CREATE TABLE IF NOT EXISTS web_session_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
ip_address TEXT NOT NULL,
login_time INTEGER NOT NULL,
session_token TEXT DEFAULT ''
)");
// Web登录验证记录表(玩家在Web端验证成功后,等待进游戏自动登录)
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (
player_name TEXT PRIMARY KEY,
verified_at INTEGER NOT NULL
)");
// 邮件验证码表
$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
)");
// 密码重置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
)");
// 密码重置请求表(无邮箱时需要管理员审核)
$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
)");
// 在线玩家表(Java插件同步,这里确保表存在)
$db->exec("CREATE TABLE IF NOT EXISTS online_players (
player_name TEXT PRIMARY KEY,
login_time INTEGER DEFAULT 0
)");
// Web登录确认表(插件轮询用)
$db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (
player_name TEXT PRIMARY KEY,
confirmed_at INTEGER NOT NULL,
consumed INTEGER DEFAULT 0
)");
// Web登录请求表(Web端提交密码登录请求)
$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
)");
// Web注册请求表(Web端提交注册请求)
$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
)");
// ★ 玩家IP变更历史表(admin.php 批量查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归属地缓存表(admin.php IP查询用)
$db->exec("CREATE TABLE IF NOT EXISTS player_ip_locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
location TEXT DEFAULT '',
updated_at INTEGER DEFAULT 0,
player_name TEXT DEFAULT 'global',
UNIQUE(ip_address)
)");
// ★ 玩家每日登录记录表(用于计算累计在线天数)
$db->exec("CREATE TABLE IF NOT EXISTS player_daily_logins (
player_name TEXT NOT NULL,
login_date TEXT NOT NULL,
first_login INTEGER DEFAULT 0,
last_login INTEGER DEFAULT 0,
PRIMARY KEY (player_name, login_date)
)");
// ★ 玩家签到记录表(用于计算累计签到天数)
$db->exec("CREATE TABLE IF NOT EXISTS player_checkins (
player_name TEXT NOT NULL,
checkin_date TEXT NOT NULL,
checkin_time INTEGER DEFAULT 0,
PRIMARY KEY (player_name, checkin_date)
)");
// ★ 创建索引以提高查询性能
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_changes_player ON player_ip_changes(player_name)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_changes_time ON player_ip_changes(changed_at)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_changes_newip ON player_ip_changes(new_ip)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_session_log_player ON web_session_log(player_name)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_session_log_time ON web_session_log(login_time)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_locations_ip ON player_ip_locations(ip_address)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_ip_locations_loc ON player_ip_locations(location)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_daily_logins_player ON player_daily_logins(player_name)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_player_checkins_player ON player_checkins(player_name)");
// ★ 迁移:确保player_ip_locations表有player_name列
try {
$db->exec("ALTER TABLE player_ip_locations ADD COLUMN player_name TEXT DEFAULT 'global'");
} catch (Exception $e) { /* 列已存在 */ }
// ★ 服务商同步表(Java端推送service_providers到Web.db)
$db->exec("CREATE TABLE IF NOT EXISTS web_service_providers (
player_name TEXT PRIMARY KEY,
role TEXT DEFAULT 'waiter',
active INTEGER DEFAULT 1,
join_time INTEGER DEFAULT 0
)");
// ★ 工单系统表
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT DEFAULT 'submitted',
requester TEXT NOT NULL,
assigned_to TEXT DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
reject_reason TEXT DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)");
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
sender TEXT NOT NULL,
role TEXT DEFAULT 'user',
message TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_requester ON web_tickets(requester)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_status ON web_tickets(status)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_assigned ON web_tickets(assigned_to)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_ticket_replies_ticket ON web_ticket_replies(ticket_id)");
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
throw $e;
}
}
// ===== IP归属地格式校验(统一函数,所有脚本共用) =====
// 支持格式:
// 标准:广东省广州市 移动、北京市 海淀区 电信
// 无后缀:湖北武汉 移通、广东广州 联通
// 直辖市:北京市 海淀区、上海 移动
// 自治区:广西南宁 移动、内蒙古呼和浩特市 移动
// 自治州:四川省凉山州 移通
function isValidIpLocationFormat($loc) {
if (!$loc || $loc === '' || $loc === '-' || $loc === '--' || $loc === 'NULL') return false;
if ($loc === '内网IP' || $loc === '查询失败' || $loc === '查询中') return false;
if (stripos($loc, 'China') === 0) return false;
if (strpos($loc, '中国 - -') === 0 || strpos($loc, '中国 - -') === 0) return false;
if (preg_match('/^中国\s*$/u', $loc) || preg_match('/^中国\s+[^\s省市区州盟]+$/u', $loc)) return false;
if (strpos($loc, ' ') === false) return false;
// ★ 拆分为"位置部分"和"运营商部分"(以第一个空格分隔)
$parts = explode(' ', $loc, 2);
$locationPart = trim($parts[0]);
$operatorPart = trim($parts[1] ?? '');
// 位置部分不能为空
if (empty($locationPart)) return false;
// 直辖市特殊处理(4个直辖市名称只有2个字)
$directControlledCities = ['北京', '天津', '上海', '重庆'];
foreach ($directControlledCities as $city) {
if ($locationPart === $city || $locationPart === $city . '市') {
return true; // "北京 移动" 或 "北京市 海淀区" 都有效
}
}
// ★ 有后缀格式验证
// 城市级后缀:武汉市、凉山州、呼和浩特市、朝阳区、安新县 → 直接有效
if (preg_match('/(市|州|盟|地区|区|县)$/', $locationPart)) {
return true;
}
// 省级后缀:广东省、湖北省 → 必须同时有城市级后缀(广东省广州市 ✅,湖北省 ❌)
if (preg_match('/(省|自治区|内蒙古)$/', $locationPart)) {
// 检查"省"后面是否还有城市名(如"广东省广州市"中的"广州市")
if (preg_match('/(省|自治区|内蒙古)(.*?(市|州|盟|地区|区|县))$/', $locationPart)) {
return true; // "广东省广州市"、"四川省凉山州" → 有效
}
return false; // "湖北省"、"广东省" → 只有省份无城市,无效
}
// ★ 无后缀格式:如"湖北武汉"、"广东广州"、"四川凉山"
// 省份名(2-3字) + 城市名(2-4字),至少4字
// 匹配:湖北武汉、广东广州、四川凉山、山东济南、河北石家庄
if (preg_match('/^[\x{4e00}-\x{9fa5}]{2,3}[\x{4e00}-\x{9fa5}]{2,4}$/u', $locationPart) && mb_strlen($locationPart) >= 4) {
return true;
}
return false;
}
// ===== Token操作 =====
/**
* 验证Token
* @return array|false 成功返回['player'=>..., 'purpose'=>...],失败返回false
*/
function validateToken($token) {
$db = getDB();
$stmt = $db->prepare("SELECT * FROM tokens WHERE token = :token");
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) {
@error_log("[validateToken] Token不存在: " . substr($token, 0, 8) . "...");
return false;
}
$now = time();
// 检查是否过期
if ($row['expires_at'] < $now) {
@error_log("[validateToken] Token已过期: " . substr($token, 0, 8) . "..., expires_at=" . $row['expires_at'] . ", now=" . $now);
return false;
}
// 检查是否已使用
if ($row['used'] == 1) {
@error_log("[validateToken] Token已使用: " . substr($token, 0, 8) . "...");
return false;
}
return [
'player' => $row['player_name'],
'purpose' => $row['purpose'],
'created_at' => $row['created_at'],
'expires_at' => $row['expires_at']
];
}
/**
* 验证weblogin token(游戏内登录token,用于web登录流程)
*/
function validateWebloginToken($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (Exception $e) {
return false;
}
}
/**
* 验证Token并标记已使用(一次性)
*/
function validateAndUseToken($token) {
$info = validateToken($token);
if (!$info) return false;
$db = getDB();
$stmt = $db->prepare("UPDATE tokens SET used = 1, used_at = :now WHERE token = :token");
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
$stmt->execute();
return $info;
}
/**
* 静默验证Token(不报错,返回bool)
* 同时支持普通token和weblogin token
*/
function validateTokenSilent($token) {
// 先尝试普通token
$info = validateToken($token);
if ($info) return $info;
// 再尝试weblogin token
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (Exception $e) {
return false;
}
}
/**
* 创建Token
*/
function createToken($playerName, $purpose = 'general', $expireSeconds = null) {
if ($expireSeconds === null) $expireSeconds = TOKEN_EXPIRE_SECONDS;
$token = bin2hex(random_bytes(32));
$now = time();
$db = getDB();
$stmt = $db->prepare("INSERT INTO tokens (token, player_name, purpose, created_at, expires_at) VALUES (:token, :player, :purpose, :created, :expires)");
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':purpose', $purpose, SQLITE3_TEXT);
$stmt->bindValue(':created', $now, SQLITE3_INTEGER);
$stmt->bindValue(':expires', $now + $expireSeconds, SQLITE3_INTEGER);
$stmt->execute();
return $token;
}
/**
* 清理过期Token
*/
function cleanExpiredTokens() {
$db = getDB();
$db->exec("DELETE FROM tokens WHERE expires_at < " . time());
}
// ===== JSON响应 =====
function jsonResponse($data, $code = 200) {
// ★ 清空所有输出缓冲区(清除Deprecated警告等HTML污染)
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
function success($data = null, $message = 'ok') {
jsonResponse(['success' => true, 'message' => $message, 'data' => $data]);
}
function error($message, $code = 400) {
jsonResponse(['success' => false, 'message' => $message], $code);
}
function preview($data, $message = 'preview mode') {
jsonResponse(['success' => true, 'preview' => true, 'message' => $message, 'data' => $data]);
}
// ===== 请求辅助 =====
function getParam($key, $default = null) {
if (isset($_GET[$key])) return $_GET[$key];
if (isset($_POST[$key])) return $_POST[$key];
// ★ 缓存php://input解析结果(PHP 7.0+中流只能读取一次)
static $cachedJson = null;
static $cached = false;
if (!$cached) {
$cached = true;
$raw = file_get_contents('php://input');
if ($raw) {
$cachedJson = json_decode($raw, true);
}
}
if ($cachedJson && isset($cachedJson[$key])) return $cachedJson[$key];
return $default;
}
function requireToken($purpose = 'general') {
$token = getParam('token');
if (!$token) {
error('缺少token参数', 401);
}
$info = validateToken($token);
if (!$info) {
error('token无效或已过期', 401);
}
if ($info['purpose'] !== $purpose && $info['purpose'] !== 'admin' && $info['purpose'] !== 'all') {
error('token用途不匹配: 需要' . $purpose . ',实际' . $info['purpose'], 403);
}
return $info;
}
function requireAdminSession() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['admin_auth']) || !$_SESSION['admin_auth']) {
error('未登录管理后台', 401);
}
}
// ===== 管理员认证 =====
function adminLogin($password) {
if ($password !== ADMIN_PASS) {
return false;
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['admin_auth'] = true;
$_SESSION['admin_login_time'] = time();
return true;
}
function isAdminLoggedIn() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
}
// ===== 定时清理(仅清理过期token,每次请求执行一次) =====
function cleanExpiredTokensLazy() {
// 频率限制:每5分钟最多清理一次
$lockFile = DB_PATH . '.clean_lock';
if (file_exists($lockFile)) {
$lastClean = (int)file_get_contents($lockFile);
if (time() - $lastClean < 300) return;
}
file_put_contents($lockFile, (string)time());
try {
$db = getDB();
$db->exec("DELETE FROM tokens WHERE expires_at < " . time());
$db->exec("DELETE FROM weblogin_tokens WHERE created_at < " . (time() - 86400));
// 清理30分钟前的会话日志
$db->exec("DELETE FROM web_session_log WHERE login_time < " . (time() - 1800));
} catch (Exception $e) {
// 静默处理数据库锁定错误,不影响正常请求
}
}
cleanExpiredTokensLazy();
// ===== Web登录安全验证(双保险机制)=====
/**
* 存储插件推送的玩家密码凭证
*/
function storeWebLoginCredentials($playerName, $passwordHash, $salt) {
$db = getDB();
// ★ 先尝试插入(新表结构,包含 temp 字段)
try {
$stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_credentials (player_name, password_hash, salt, temp_password_hash, temp_pw_expire, pushed_at) VALUES (:player, :hash, :salt, :temp, :tempExp, :time)");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':hash', $passwordHash, SQLITE3_TEXT);
$stmt->bindValue(':salt', $salt, SQLITE3_TEXT);
$stmt->bindValue(':temp', '', SQLITE3_TEXT);
$stmt->bindValue(':tempExp', 0, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
return; // 成功则返回
} catch (Exception $e) {
// ★ 如果是因为缺少 temp_password_hash 字段(旧数据库),降级为老字段插入
debugLog("storeWebLoginCredentials: 降级兼容旧数据库", ['error' => $e->getMessage()]);
}
// 降级方案:只插入老字段
try {
$stmt = $db->prepare("INSERT OR REPLACE INTO weblogin_credentials (player_name, password_hash, salt, pushed_at) VALUES (:player, :hash, :salt, :time)");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':hash', $passwordHash, SQLITE3_TEXT);
$stmt->bindValue(':salt', $salt, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
} catch (Exception $e) {
debugLog("storeWebLoginCredentials: 降级插入也失败", ['error' => $e->getMessage()]);
}
}
/**
* 存储插件推送的临时密码(独立于主密码)
*/
function storeTempPassword($playerName, $tempHash, $expire) {
$db = getDB();
// ★ 先尝试更新(新表结构)
try {
$stmt = $db->prepare("UPDATE weblogin_credentials SET temp_password_hash = :temp, temp_pw_expire = :expire WHERE player_name = :player");
$stmt->bindValue(':temp', $tempHash, SQLITE3_TEXT);
$stmt->bindValue(':expire', (int)$expire, SQLITE3_INTEGER);
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->execute();
return; // 成功则返回
} catch (Exception $e) {
// 缺少字段则忽略(旧数据库,不支持临时密码)
debugLog("storeTempPassword: 字段不存在,跳过", ['error' => $e->getMessage()]);
return;
}
}
/**
* 验证Web登录密码(优先校验临时密码,再校验主密码)
* 返回: 'temp' 表示临时密码, 'main' 表示主密码, false 表示失败
*/
function verifyWebLoginPassword($playerName, $password) {
// 确保数据库已迁移
$db = getDB();
migrateDatabase($db);
try {
$stmt = $db->prepare("SELECT password_hash, salt, temp_password_hash, temp_pw_expire FROM weblogin_credentials WHERE player_name = :player");
} catch (Exception $e) {
// 表不存在,返回 false
debugLog("verifyWebLoginPassword: 表不存在", ['player' => $playerName]);
return false;
}
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
// 1. 先检查临时密码
if (!empty($row['temp_password_hash'])) {
$tempExpire = (int)$row['temp_pw_expire'];
if (time() < $tempExpire && $row['temp_password_hash'] !== '') {
$salt = $row['salt'];
$saltBytes = base64_decode($salt, true);
if ($saltBytes !== false && $saltBytes !== '') {
$rawHash = hash('sha256', $saltBytes . $password, true);
$computedHash = base64_encode($rawHash);
if ($computedHash === $row['temp_password_hash']) {
return 'temp'; // 临时密码匹配
}
}
}
}
// 2. 再检查主密码
$storedHash = $row['password_hash'];
$salt = $row['salt'];
$saltBytes = base64_decode($salt, true);
if ($saltBytes === false || $saltBytes === '') return false;
$rawHash = hash('sha256', $saltBytes . $password, true);
$computedHash = base64_encode($rawHash);
return ($computedHash === $storedHash) ? 'main' : false;
}
/**
* 记录Web登录成功会话
*/
function recordWebSession($playerName, $ipAddress) {
$db = getDB();
$sessionToken = bin2hex(random_bytes(16));
$stmt = $db->prepare("INSERT INTO web_session_log (player_name, ip_address, login_time, session_token) VALUES (:player, :ip, :time, :token)");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':ip', $ipAddress, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->bindValue(':token', $sessionToken, SQLITE3_TEXT);
$stmt->execute();
return $sessionToken;
}
/**
* 检查是否有3分钟内的快速重连记录(同IP)
* @return bool 是否可以快速重连
*/
function canQuickReconnect($playerName, $ipAddress) {
$db = getDB();
$threeMinutesAgo = time() - 180;
$stmt = $db->prepare("SELECT id FROM web_session_log WHERE player_name = :player AND ip_address = :ip AND login_time > :time LIMIT 1");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':ip', $ipAddress, SQLITE3_TEXT);
$stmt->bindValue(':time', $threeMinutesAgo, SQLITE3_INTEGER);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
return $row !== false;
}
/**
* 检查玩家是否在游戏里注册过
* ★ 修复:优先检查weblogin_credentials表(由插件推送,最准确),
* 同时检查users表(由sync_login同步,可能有滞后)
* @return bool 是否已注册
*/
function isPlayerRegistered($playerName) {
$db = getDB();
migrateDatabase($db); // 确保表结构正确
// ★ 关键修复:只检查 users 表(由插件同步,最权威)
// weblogin_credentials 只存凭证,不用于判断注册状态
try {
$stmt = $db->prepare("SELECT 1 FROM users WHERE player_name = :player");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray();
$found = $row !== false;
if (!$found) {
// ★ 玩家不在 users 表中,清理 weblogin_credentials 中的孤立记录
try {
$delStmt = $db->prepare("DELETE FROM weblogin_credentials WHERE player_name = :player");
$delStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$delStmt->execute();
debugLog("isPlayerRegistered: 清理了 weblogin_credentials 中的孤立记录", ['player' => $playerName]);
} catch (Exception $e2) {
// 忽略
}
}
debugLog("isPlayerRegistered: 检查users表", ['player' => $playerName, 'found' => $found, 'source' => 'users']);
return $found;
} catch (Exception $e) {
debugLog("isPlayerRegistered: users表查询失败", ['error' => $e->getMessage()]);
return false;
}
}
/**
* Web请求安全验证入口
* 返回格式:['ok' => bool, 'mode' => 'full'|'quick'|'denied', 'message' => string]
*
* @param string $webToken weblogin token
* @param string $action 操作类型:'view'|'buy'|'recharge'|'cdk'
* @param string $password 用户输入的密码(关键操作时需要)
* @param string $ipAddress 客户端IP
*/
function validateWebAccess($webToken, $action = 'view', $password = null, $ipAddress = null) {
if (!$ipAddress) $ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$db = getDB();
// 1. 验证token
$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_log("[validateWebAccess] DENIED: token not found in DB. Token prefix: " . substr($webToken, 0, 20) . "...");
return ['ok' => false, 'mode' => 'denied', 'message' => '无效的登录Token'];
}
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
$age = time() - $createdAt;
@error_log("[validateWebAccess] FOUND: player=" . $row['player_name'] . ", age={$age}s, expire={$expireSeconds}s");
if (time() - $createdAt > $expireSeconds) {
@error_log("[validateWebAccess] DENIED: token expired. Age: $age, Expire: $expireSeconds");
return ['ok' => false, 'mode' => 'denied', 'message' => '登录Token已过期'];
}
$playerName = $row['player_name'];
// 在线玩家表(Java插件同步,这里确保表存在)
try {
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
} catch (Exception $e) {
// 忽略
}
// 2. Token有效 → 检查玩家是否在游戏中登录(双重检查机制)— 回档9bae160版本
$isOnline = false;
// ★ 详细调试:先查online_players表总数和内容
try {
$countStmt = $db->query("SELECT COUNT(*) as cnt FROM online_players");
$countRow = $countStmt->fetchArray(SQLITE3_ASSOC);
$totalOnline = $countRow ? (int)$countRow['cnt'] : 0;
$allStmt = $db->query("SELECT player_name FROM online_players");
$allNames = [];
while ($r = $allStmt->fetchArray(SQLITE3_ASSOC)) {
$allNames[] = $r['player_name'];
}
// 查心跳
$hbStmt = $db->query("SELECT last_seen FROM online_player_hb LIMIT 1");
$hbRow = $hbStmt->fetchArray(SQLITE3_ASSOC);
$hbLastSeen = $hbRow ? (int)$hbRow['last_seen'] : 0;
$hbAgo = $hbLastSeen > 0 ? time() - $hbLastSeen : -1;
@error_log("[validateWebAccess] online_players查询: total=$totalOnline, names=" . implode(',', $allNames) . ", heartbeat_ago={$hbAgo}s, searchName=" . $playerName);
} catch (Exception $e) {
@error_log("[validateWebAccess] online_players查询异常: " . $e->getMessage());
$totalOnline = 0;
$allNames = [];
}
// 检查1: online_players表(Java插件推送)
try {
$onlineStmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :player");
$onlineStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$onlineResult = $onlineStmt->execute();
$onlineRow = $onlineResult->fetchArray();
$isOnline = ($onlineRow !== false);
@error_log("[validateWebAccess] 玩家 " . $playerName . " 在online_players中: " . ($isOnline ? '找到' : '未找到'));
} catch (Exception $e) {
$isOnline = false;
@error_log("[validateWebAccess] online_players查询异常: " . $e->getMessage());
}
// 检查2: web_session_log — ★ 不再用于判断在线状态
// web_session_log 仅记录会话历史,不决定权限
// 在线状态完全由Java插件推送的 online_players 表决定
// 3. 检查玩家注册状态
$isRegistered = isPlayerRegistered($playerName);
debugLog("validateWebAccess: 状态检查", [
'player' => $playerName,
'action' => $action,
'online' => $isOnline,
'registered' => $isRegistered,
'ip' => $ipAddress
]);
// 4. view操作:Token有效 → 允许Web密码登录(即使游戏里未登录)
if ($action === 'view') {
if (!$isRegistered) {
// ★ 玩家未在游戏中注册 → 允许Web注册,录入注册请求等待插件同步
debugLog("validateWebAccess: 需要注册", ['player' => $playerName]);
return ['ok' => false, 'mode' => 'need_register', 'player' => $playerName, 'registered' => false, 'online' => $isOnline, 'message' => '玩家未注册,可以在Web端注册账号'];
}
if (!$isOnline) {
// 玩家已注册但在游戏中未登录 → 允许Web密码登录
debugLog("validateWebAccess: 需要密码", ['player' => $playerName]);
return ['ok' => false, 'mode' => 'need_password', 'player' => $playerName, 'registered' => true, 'online' => false, 'message' => '请输入游戏登录密码以同步登录游戏'];
}
$sessionToken = recordWebSession($playerName, $ipAddress);
debugLog("validateWebAccess: 验证成功", ['player' => $playerName, 'mode' => 'full']);
return ['ok' => true, 'mode' => 'full', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '验证成功'];
}
// 5. 关键操作(buy/recharge/cdk):需要玩家在游戏中登录或已通过PHP密码验证
if (!$isOnline) {
// ★ 检查web_login_verified:PHP密码登录成功后5分钟内允许关键操作
$isVerified = false;
try {
$fiveMinAgo = time() - 300;
$verifiedStmt = $db->prepare("SELECT 1 FROM web_login_verified WHERE player_name = :player AND verified_at >= :expire");
$verifiedStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$verifiedStmt->bindValue(':expire', $fiveMinAgo, SQLITE3_INTEGER);
$verifiedResult = $verifiedStmt->execute();
$verifiedRow = $verifiedResult->fetchArray();
if ($verifiedRow) {
$isVerified = true;
}
} catch (Exception $e) {}
if (!$isVerified) {
debugLog("validateWebAccess: 需要游戏登录", ['player' => $playerName, 'action' => $action]);
return ['ok' => false, 'mode' => 'need_game_login', 'player' => $playerName, 'registered' => $isRegistered, 'online' => false, 'message' => '请先在游戏中登录'];
}
// PHP密码验证有效,放行
$sessionToken = recordWebSession($playerName, $ipAddress);
debugLog("validateWebAccess: PHP密码验证放行(关键操作)", ['player' => $playerName, 'action' => $action]);
}
// 6. 关键操作(buy/recharge/cdk):需要玩家已注册
if (!$isRegistered) {
debugLog("validateWebAccess: 需要注册", ['player' => $playerName, 'action' => $action]);
return ['ok' => false, 'mode' => 'need_register', 'player' => $playerName, 'registered' => false, 'online' => $isOnline, 'message' => '玩家未注册,请先在游戏中注册'];
}
// 7. 关键操作(buy/recharge/cdk):敏感操作二次验证密码/邮箱验证码,≤1000债券交易不再验证密码
if (in_array($action, ['buy', 'recharge', 'cdk'])) {
// CDK兑换不需要额外密码验证(CDK本身就是验证)
if ($action === 'cdk') {
$sessionToken = recordWebSession($playerName, $ipAddress);
debugLog("validateWebAccess: CDK兑换免验证", ['player' => $playerName]);
return ['ok' => true, 'mode' => 'full_verified', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => 'CDK兑换免验证'];
}
// 查询交易金额
$amount = 0;
if ($action === 'buy') {
$itemId = getParam('item_id');
if ($itemId) {
$itemStmt = $db->prepare("SELECT buy_price FROM shop_items WHERE id = :id");
$itemStmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$itemResult = $itemStmt->execute();
$itemRow = $itemResult->fetchArray(SQLITE3_ASSOC);
if ($itemRow) {
$amount = (int)$itemRow['buy_price'];
}
}
} elseif ($action === 'recharge') {
$amount = (int)getParam('amount', 0);
}
// ≤1000债券交易不再验证密码
if ($amount > 0 && $amount <= 1000) {
$sessionToken = recordWebSession($playerName, $ipAddress);
debugLog("validateWebAccess: 小额交易免验证", ['player' => $playerName, 'amount' => $amount]);
return ['ok' => true, 'mode' => 'full_verified', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '交易金额≤1000,免验证'];
}
// >1000债券交易需要密码验证
if ($password) {
if (verifyWebLoginPassword($playerName, $password)) {
$sessionToken = recordWebSession($playerName, $ipAddress);
debugLog("validateWebAccess: 密码验证通过", ['player' => $playerName, 'amount' => $amount]);
return ['ok' => true, 'mode' => 'full_verified', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '密码验证通过'];
}
debugLog("validateWebAccess: 密码错误", ['player' => $playerName]);
return ['ok' => false, 'mode' => 'denied', 'message' => '密码错误'];
}
debugLog("validateWebAccess: 需要密码", ['player' => $playerName, 'amount' => $amount]);
return ['ok' => false, 'mode' => 'need_password', 'player' => $playerName, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '大额交易需要输入密码确认'];
}
// 8. 其他操作:Token有效,直接允许访问
$sessionToken = recordWebSession($playerName, $ipAddress);
debugLog("validateWebAccess: 验证成功", ['player' => $playerName, 'mode' => 'full']);
return ['ok' => true, 'mode' => 'full', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '验证成功'];
}