第三层(新增,config.php 的 SEC_ACCESS_TOKEN): - 只有 admin.php?token=<令牌> 才放行,缺/错令牌一律返回 nginx 原生 404 (与"文件真的不存在"逐字节一致,探测者无从判断),令牌检查置于白名单之前 - 登录接口在密码校验通过后才下发 token,2FA 通过与收银台跳转由服务端补拼 - 入口页加 Referrer-Policy: no-referrer,防令牌经外链 Referer 泄露 - 配置页展示后台唯一入口地址,支持一键重新生成令牌 第一层修复(2FA 验证码"无效"): - secTotpVerify 容错窗口 ±30 秒扩到 ±90 秒 - 新增 secTotpDiagnose:失败时区分"密钥没对上"与"设备时钟偏移多少秒"并给出处置指引 - Base32 解码兼容空格/连字符与 0/1/8/9 手误,避免密钥被静默截短 - 验证页倒计时与服务器时间显示改用服务端时钟基准 - 密钥生成改幂等,"重新生成"二次确认,防止已扫码密钥被误作废 第二层(二维码): - admin_2fa_setup.php 内联 qrcode-generator(MIT) 生成 otpauth 二维码,离线可用 - 后台侧边栏新增"安全配置"入口;admin_2fa_setup.php?boot= 在白名单 IP 下同样生效 已通过线上端到端验证 22 项 + 二维码 OpenCV 往返解码校验。
2134 lines
90 KiB
PHP
2134 lines
90 KiB
PHP
<?php
|
||
// ===== Web通信系统 - 核心引擎 =====
|
||
require_once __DIR__ . '/config.php';
|
||
// ★ 紧急安全加固(2026-09-29):TOTP 二次验证 + IP 白名单 + 会话纪元(见 security.php)
|
||
require_once __DIR__ . '/security.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);
|
||
}
|
||
|
||
// ★ 检查并修复数据库文件/目录权限(防止 "attempt to write a readonly database")
|
||
if (file_exists(DB_PATH)) {
|
||
if (!is_writable(DB_PATH)) {
|
||
@chmod(DB_PATH, 0666);
|
||
if (!is_writable(DB_PATH)) {
|
||
debugLog("getDB: 数据库文件不可写: " . DB_PATH);
|
||
}
|
||
}
|
||
}
|
||
if (is_dir($dbDir) && !is_writable($dbDir)) {
|
||
@chmod($dbDir, 0777);
|
||
}
|
||
// ★ 同时修复WAL/SHM文件权限
|
||
foreach (['-wal', '-shm'] as $suffix) {
|
||
$sideFile = DB_PATH . $suffix;
|
||
if (file_exists($sideFile) && !is_writable($sideFile)) {
|
||
@chmod($sideFile, 0666);
|
||
}
|
||
}
|
||
|
||
$db = new SQLite3(DB_PATH);
|
||
$db->enableExceptions(true);
|
||
// WAL模式 + 长等待超时防止database is locked
|
||
$db->exec('PRAGMA journal_mode=WAL');
|
||
$db->exec('PRAGMA busy_timeout=8000'); // ★ 8秒等待(原60秒太长,Java轮询锁时PHP会卡住近1分钟)
|
||
$db->exec('PRAGMA synchronous=NORMAL');
|
||
$db->exec('PRAGMA cache_size=-64000'); // 64MB 缓存
|
||
$db->exec('PRAGMA wal_autocheckpoint=100');// ★ 更频繁checkpoint(原1000),更快释放WAL锁减少竞争
|
||
|
||
// ★ initTables/migrateDatabase加try-catch,防止初始化写入失败导致整个请求500
|
||
try {
|
||
initTables($db);
|
||
} catch (\Throwable $e) {
|
||
debugLog("getDB: initTables失败(可能数据库只读): " . $e->getMessage());
|
||
}
|
||
try {
|
||
migrateDatabase($db); // 迁移旧数据库
|
||
} catch (\Throwable $e) {
|
||
debugLog("getDB: migrateDatabase失败: " . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ★ 安全网:仅在首次创建连接时清理上一次请求可能残留的未提交事务
|
||
// (PHP-FPM/opcache复用进程时,前一个请求异常退出可能遗留BEGIN但无COMMIT/ROLLBACK)
|
||
// 注意:不能每次调用都ROLLBACK,否则会中断当前请求正在进行的事务
|
||
static $rollbackDone = false;
|
||
if (!$rollbackDone) {
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
|
||
$rollbackDone = true;
|
||
}
|
||
|
||
return $db;
|
||
}
|
||
|
||
// ===== 收银台订单独立库 =====
|
||
// 订单是 PHP 收银台自己的数据,与 web.db(Java 通过 sync.php 高频写入)完全无关。
|
||
// 拆到独立 SQLite 文件后,Java 永不触碰本库,根除因 web.db 文件级锁竞争导致的订单读超时。
|
||
function getOrdersDB() {
|
||
static $db = null;
|
||
if ($db === null) {
|
||
$ordersDbPath = defined('ORDERS_DB_PATH') ? ORDERS_DB_PATH : (__DIR__ . '/db/orders.db');
|
||
$dbDir = dirname($ordersDbPath);
|
||
if (!is_dir($dbDir)) {
|
||
@mkdir($dbDir, 0755, true);
|
||
}
|
||
|
||
// 权限修复(与 getDB 一致,防止 "attempt to write a readonly database")
|
||
if (file_exists($ordersDbPath) && !is_writable($ordersDbPath)) {
|
||
@chmod($ordersDbPath, 0666);
|
||
}
|
||
if (is_dir($dbDir) && !is_writable($dbDir)) {
|
||
@chmod($dbDir, 0777);
|
||
}
|
||
foreach (['-wal', '-shm'] as $suffix) {
|
||
$sideFile = $ordersDbPath . $suffix;
|
||
if (file_exists($sideFile) && !is_writable($sideFile)) {
|
||
@chmod($sideFile, 0666);
|
||
}
|
||
}
|
||
|
||
$db = new SQLite3($ordersDbPath);
|
||
$db->enableExceptions(true);
|
||
$db->exec('PRAGMA journal_mode=WAL');
|
||
$db->exec('PRAGMA busy_timeout=5000'); // ★ 独立库,Java 不碰,5秒足矣
|
||
$db->exec('PRAGMA synchronous=NORMAL');
|
||
$db->exec('PRAGMA cache_size=-64000'); // 64MB 缓存
|
||
$db->exec('PRAGMA wal_autocheckpoint=100');
|
||
|
||
// 建表(仅 PHP 维护)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS cashier_orders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
order_no TEXT NOT NULL UNIQUE,
|
||
operator_type TEXT DEFAULT 'cashier',
|
||
operator_name TEXT DEFAULT '',
|
||
player_name TEXT NOT NULL,
|
||
items_detail TEXT DEFAULT '',
|
||
subtotal INTEGER DEFAULT 0,
|
||
total_price INTEGER DEFAULT 0,
|
||
discount_percent INTEGER DEFAULT 0,
|
||
discount_amount INTEGER DEFAULT 0,
|
||
settlement TEXT DEFAULT '',
|
||
payment_method TEXT DEFAULT 'bond',
|
||
status TEXT DEFAULT 'completed',
|
||
created_at INTEGER DEFAULT 0
|
||
)");
|
||
// ★ 幂等加列:payment_method 已在上方 CREATE TABLE 中声明,此处仅作极旧库兜底补加。
|
||
// 用 try/catch 包裹,避免 "duplicate column name" 异常经 enableExceptions(true) 上抛、
|
||
// 导致 getOrdersDB() 首次调用即失败、订单写不进、订单页查询报错。
|
||
try {
|
||
$db->exec("ALTER TABLE cashier_orders ADD COLUMN payment_method TEXT DEFAULT 'bond'");
|
||
} catch (\Throwable $e) {
|
||
// 列已存在 → 忽略
|
||
}
|
||
|
||
// 首次建库时把 web.db 历史订单一次性迁过来(幂等)
|
||
migrateCashierOrdersIfNeeded($db);
|
||
}
|
||
|
||
// 安全网:清理可能残留的悬挂事务(与 getDB 一致)
|
||
try { $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
|
||
|
||
return $db;
|
||
}
|
||
|
||
/**
|
||
* ★ 历史订单迁移:仅在 orders.db 为空、且 web.db 存在 cashier_orders 历史数据时执行一次。
|
||
* 幂等:orders.db 已有数据则跳过;使用 INSERT OR IGNORE 防止重复导入。
|
||
*/
|
||
function migrateCashierOrdersIfNeeded($ordersDb) {
|
||
static $done = false;
|
||
if ($done) return;
|
||
|
||
// orders.db 已有数据 → 永久跳过迁移
|
||
$existing = 0;
|
||
try {
|
||
$existing = (int)$ordersDb->querySingle("SELECT COUNT(*) FROM cashier_orders");
|
||
} catch (\Throwable $e) {
|
||
return; // 表还没建好,下次请求再试
|
||
}
|
||
if ($existing > 0) { $done = true; return; }
|
||
|
||
try {
|
||
$src = getDB();
|
||
// 仅迁移已存在的记录
|
||
$res = $src->query("SELECT order_no, operator_type, operator_name, player_name, items_detail, subtotal, total_price, discount_percent, discount_amount, settlement, payment_method, status, created_at FROM cashier_orders ORDER BY created_at ASC");
|
||
if (!$res) return;
|
||
$count = 0;
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$stmt = $ordersDb->prepare("INSERT OR IGNORE INTO cashier_orders (order_no, operator_type, operator_name, player_name, items_detail, subtotal, total_price, discount_percent, discount_amount, settlement, payment_method, status, created_at) VALUES (:no,:ot,:on,:pn,:det,:sub,:tp,:dp,:da,:st,:pm,:st2,:time)");
|
||
$stmt->bindValue(':no', $row['order_no'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':ot', $row['operator_type'] ?? 'cashier', SQLITE3_TEXT);
|
||
$stmt->bindValue(':on', $row['operator_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':pn', $row['player_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':det', $row['items_detail'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':sub', (int)($row['subtotal'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':tp', (int)($row['total_price'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':dp', (int)($row['discount_percent'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':da', (int)($row['discount_amount'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':st', $row['settlement'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':pm', $row['payment_method'] ?? 'bond', SQLITE3_TEXT);
|
||
$stmt->bindValue(':st2', $row['status'] ?? 'completed', SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', (int)($row['created_at'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
$count++;
|
||
}
|
||
if ($count > 0) {
|
||
debugLog("migrateCashierOrdersIfNeeded: 从 web.db 迁移 $count 条历史订单到 orders.db");
|
||
}
|
||
$done = true; // 已尝试迁移(成功或无需迁移均标记,避免每次请求重复SELECT)
|
||
} catch (\Throwable $e) {
|
||
debugLog("migrateCashierOrdersIfNeeded 失败(不影响新订单写入,下次请求重试): " . $e->getMessage());
|
||
// 不设置 $done,下次请求再试(如 web.db 临时锁住)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ★ 远程修复数据库权限(Java调用)
|
||
*/
|
||
function fixDbPermissions() {
|
||
$secret = getParam('secret');
|
||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||
|
||
$results = [];
|
||
$dbPath = DB_PATH;
|
||
$dbDir = dirname($dbPath);
|
||
|
||
// 修复目录权限
|
||
if (is_dir($dbDir)) {
|
||
$oldPerms = substr(sprintf('%o', fileperms($dbDir)), -4);
|
||
@chmod($dbDir, 0777);
|
||
$newPerms = substr(sprintf('%o', fileperms($dbDir)), -4);
|
||
$results[] = "目录 $dbDir: $oldPerms → $newPerms (writable=" . (is_writable($dbDir) ? 'Y' : 'N') . ")";
|
||
} else {
|
||
@mkdir($dbDir, 0777, true);
|
||
$results[] = "目录 $dbDir: 已创建";
|
||
}
|
||
|
||
// 修复数据库文件权限
|
||
if (file_exists($dbPath)) {
|
||
$oldPerms = substr(sprintf('%o', fileperms($dbPath)), -4);
|
||
@chmod($dbPath, 0666);
|
||
$newPerms = substr(sprintf('%o', fileperms($dbPath)), -4);
|
||
$results[] = "数据库 $dbPath: $oldPerms → $newPerms (writable=" . (is_writable($dbPath) ? 'Y' : 'N') . ")";
|
||
} else {
|
||
$results[] = "数据库 $dbPath: 不存在(首次访问时自动创建)";
|
||
}
|
||
|
||
// 修复WAL/SHM文件权限
|
||
foreach (['-wal', '-shm'] as $suffix) {
|
||
$sideFile = $dbPath . $suffix;
|
||
if (file_exists($sideFile)) {
|
||
$oldPerms = substr(sprintf('%o', fileperms($sideFile)), -4);
|
||
@chmod($sideFile, 0666);
|
||
$newPerms = substr(sprintf('%o', fileperms($sideFile)), -4);
|
||
$results[] = "WAL文件 $sideFile: $oldPerms → $newPerms";
|
||
}
|
||
}
|
||
|
||
// 尝试写入测试
|
||
try {
|
||
$db = getDB();
|
||
$db->exec("CREATE TABLE IF NOT EXISTS _perm_test (id INTEGER)");
|
||
$db->exec("DROP TABLE IF EXISTS _perm_test");
|
||
$results[] = "写入测试: 通过";
|
||
} catch (\Throwable $e) {
|
||
$results[] = "写入测试: 失败 - " . $e->getMessage();
|
||
}
|
||
|
||
success(['results' => $results], "权限修复完成," . count($results) . " 项");
|
||
}
|
||
|
||
/**
|
||
* ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||
* 在大量写操作后调用,或在请求结束时调用
|
||
*/
|
||
function walCheckpoint($db = null) {
|
||
if ($db === null) $db = getDB();
|
||
try {
|
||
// TRUNCATE模式:将WAL内容合并到主库并清空WAL文件
|
||
$db->exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||
} catch (Exception $e) {
|
||
// checkpoint失败不影响正常流程
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 数据库迁移:为旧数据库添加新字段(通过 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,
|
||
admin_buy_price INTEGER DEFAULT NULL,
|
||
admin_sell_price 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) { /* 字段已存在,忽略 */ }
|
||
// ★ 兼容升级:给shop_items加admin价格覆盖字段(PHP后台修改价格时写入,Java拉取后清除)
|
||
try {
|
||
$db->exec("ALTER TABLE shop_items ADD COLUMN admin_buy_price INTEGER DEFAULT NULL");
|
||
} catch (\Throwable $e) { /* 字段已存在,忽略 */ }
|
||
try {
|
||
$db->exec("ALTER TABLE shop_items ADD COLUMN admin_sell_price INTEGER DEFAULT NULL");
|
||
} 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
|
||
)");
|
||
|
||
// ===== 风控 / 封禁相关列(安全迁移,避免 ALTER 锁表) =====
|
||
$userCols = [];
|
||
$ci = $db->query("PRAGMA table_info(users)");
|
||
while ($c = $ci->fetchArray(SQLITE3_ASSOC)) {
|
||
$userCols[] = $c['name'];
|
||
}
|
||
$addCols = [
|
||
'last_login_location' => "TEXT DEFAULT ''",
|
||
'last_login_ip' => "TEXT DEFAULT ''",
|
||
'frozen' => "INTEGER DEFAULT 0",
|
||
'freeze_token' => "TEXT DEFAULT ''",
|
||
];
|
||
foreach ($addCols as $col => $type) {
|
||
if (!in_array($col, $userCols)) {
|
||
$db->exec("ALTER TABLE users ADD COLUMN $col $type");
|
||
}
|
||
}
|
||
|
||
// 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)");
|
||
|
||
// ★ 用户组相关表(从handler函数中提取到这里统一创建,避免并发请求锁竞争)
|
||
$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,
|
||
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 '{}',
|
||
synced_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 用户组成员表
|
||
$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)
|
||
)");
|
||
|
||
// 管理员变更队列
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_admin_changes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
change_type TEXT NOT NULL,
|
||
target_id TEXT DEFAULT '',
|
||
target_name TEXT DEFAULT '',
|
||
change_data TEXT DEFAULT '{}',
|
||
created_at INTEGER DEFAULT 0,
|
||
acknowledged INTEGER DEFAULT 0,
|
||
acked_at INTEGER DEFAULT 0,
|
||
status TEXT DEFAULT 'pending'
|
||
)");
|
||
|
||
// 待验证玩家表
|
||
$db->exec("CREATE TABLE IF NOT EXISTS pending_player_validations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
request_type TEXT DEFAULT 'general',
|
||
request_data TEXT DEFAULT '{}',
|
||
status TEXT DEFAULT 'pending',
|
||
created_at INTEGER DEFAULT 0,
|
||
validated_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 续费请求表
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_group_renew (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
group_name TEXT NOT NULL,
|
||
renew_price INTEGER DEFAULT 0,
|
||
duration_minutes INTEGER DEFAULT 0,
|
||
req_id TEXT DEFAULT '',
|
||
status TEXT DEFAULT 'pending',
|
||
created_at INTEGER DEFAULT 0,
|
||
processed_at INTEGER DEFAULT 0,
|
||
remark TEXT DEFAULT ''
|
||
)");
|
||
|
||
// 过户请求表
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_land_transfers (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
land_name TEXT NOT NULL,
|
||
old_owner TEXT NOT NULL,
|
||
new_owner TEXT NOT NULL,
|
||
status TEXT DEFAULT 'pending',
|
||
created_at INTEGER DEFAULT 0,
|
||
completed_at INTEGER DEFAULT 0,
|
||
expires_at INTEGER DEFAULT 0,
|
||
cooldown_until INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 领地表
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_lands (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE,
|
||
owner TEXT DEFAULT '',
|
||
world TEXT DEFAULT '',
|
||
x1 INTEGER DEFAULT 0,
|
||
z1 INTEGER DEFAULT 0,
|
||
x2 INTEGER DEFAULT 0,
|
||
z2 INTEGER DEFAULT 0,
|
||
y1 INTEGER DEFAULT 0,
|
||
y2 INTEGER DEFAULT 0,
|
||
area_size INTEGER DEFAULT 0,
|
||
created_at INTEGER DEFAULT 0,
|
||
admin_changed INTEGER DEFAULT 0,
|
||
deny_pvp INTEGER DEFAULT 0,
|
||
deny_fall_damage INTEGER DEFAULT 0,
|
||
deny_hunger INTEGER DEFAULT 0,
|
||
deny_all_damage INTEGER DEFAULT 0,
|
||
deny_fire_spread INTEGER DEFAULT 0,
|
||
deny_explosion INTEGER DEFAULT 0,
|
||
deny_mob_grief INTEGER DEFAULT 0,
|
||
deny_block_place INTEGER DEFAULT 0,
|
||
deny_block_break INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 领地权限表(★ 必须含 UNIQUE(land_id, player_name),否则 ON CONFLICT 不触发导致角色降级)
|
||
$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)
|
||
)");
|
||
// ★ 迁移:旧表可能缺少 UNIQUE 约束和字段,检测并重建
|
||
$pragma = $db->querySingle("SELECT sql FROM sqlite_master WHERE type='table' AND name='web_area_permissions'");
|
||
if ($pragma && strpos($pragma, 'UNIQUE') === false) {
|
||
$db->exec("BEGIN TRANSACTION");
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_area_permissions_new (
|
||
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)
|
||
)");
|
||
$db->exec("INSERT OR IGNORE INTO web_area_permissions_new (land_id, land_name, player_name, role, permissions, granted_at, synced_at)
|
||
SELECT land_id, '', player_name, role, permissions, granted_at, synced_at FROM web_area_permissions");
|
||
$db->exec("DROP TABLE web_area_permissions");
|
||
$db->exec("ALTER TABLE web_area_permissions_new RENAME TO web_area_permissions");
|
||
$db->exec("COMMIT");
|
||
error_log("[core] web_area_permissions 表已迁移:添加 UNIQUE 约束 + land_name + expires_at 字段");
|
||
}
|
||
|
||
// ===== 收银员账号表 =====
|
||
$db->exec("CREATE TABLE IF NOT EXISTS cashiers (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL,
|
||
salt TEXT DEFAULT '',
|
||
discount_limit_percent INTEGER DEFAULT 0,
|
||
created_at INTEGER DEFAULT 0,
|
||
created_by TEXT DEFAULT ''
|
||
)");
|
||
// 收银员现金收款权限字段迁移(已存在则静默忽略;注意:enableExceptions(true) 下
|
||
// 必须用 try/catch 兜住,@ 抑制符挡不住 SQLite3Exception,否则会触发 ROLLBACK 并导致
|
||
// 后续建表语句(如 cashier_orders)全部跳过,且每条请求都写一条 duplicate column 日志刷爆磁盘)
|
||
try {
|
||
$db->exec("ALTER TABLE cashiers ADD COLUMN can_cash INTEGER DEFAULT 0");
|
||
} catch (\Throwable $e) { /* 列已存在,忽略 */ }
|
||
|
||
// ===== 收银台订单表(代购/收银员操作记录)=====
|
||
$db->exec("CREATE TABLE IF NOT EXISTS cashier_orders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
order_no TEXT NOT NULL UNIQUE,
|
||
operator_type TEXT DEFAULT 'cashier',
|
||
operator_name TEXT DEFAULT '',
|
||
player_name TEXT NOT NULL,
|
||
items_detail TEXT DEFAULT '',
|
||
subtotal INTEGER DEFAULT 0,
|
||
total_price INTEGER DEFAULT 0,
|
||
discount_percent INTEGER DEFAULT 0,
|
||
discount_amount INTEGER DEFAULT 0,
|
||
settlement TEXT DEFAULT '',
|
||
payment_method TEXT DEFAULT 'bond',
|
||
status TEXT DEFAULT 'completed',
|
||
created_at INTEGER DEFAULT 0
|
||
)");
|
||
// 收款模式字段迁移(现金/债券):payment_method 已在 CREATE TABLE 声明,此处仅极旧库兜底,
|
||
// 用 try/catch 包裹避免 "duplicate column name" 异常中断事务提交。
|
||
try {
|
||
$db->exec("ALTER TABLE cashier_orders ADD COLUMN payment_method TEXT DEFAULT 'bond'");
|
||
} catch (\Throwable $e) {
|
||
// 列已存在 → 忽略
|
||
}
|
||
|
||
|
||
// ===== SN 防刷系统(PHP 端镜像 + 报失/命令队列)=====
|
||
// 物品 SN 全量镜像:由 Java 定时 push_catalog 同步
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_item_sn (
|
||
sn TEXT PRIMARY KEY,
|
||
item_type TEXT DEFAULT '',
|
||
owner TEXT DEFAULT '',
|
||
status TEXT DEFAULT 'active',
|
||
issue_time INTEGER DEFAULT 0,
|
||
loc_type TEXT DEFAULT '',
|
||
loc_player TEXT DEFAULT '',
|
||
loc_world TEXT DEFAULT '',
|
||
loc_x INTEGER DEFAULT 0,
|
||
loc_y INTEGER DEFAULT 0,
|
||
loc_z INTEGER DEFAULT 0,
|
||
container_type TEXT DEFAULT '',
|
||
in_land INTEGER DEFAULT 0,
|
||
land_name TEXT DEFAULT '',
|
||
last_seen INTEGER DEFAULT 0,
|
||
cancel_time INTEGER DEFAULT 0,
|
||
lost_count INTEGER DEFAULT 0,
|
||
lost_state TEXT DEFAULT '',
|
||
remark TEXT DEFAULT '',
|
||
bind_reason TEXT DEFAULT '',
|
||
bind_time INTEGER DEFAULT 0,
|
||
updated_at INTEGER DEFAULT 0
|
||
)");
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_item_sn_owner ON web_item_sn(owner, item_type)");
|
||
// 脱离自身管控起始时刻(秒;0=仍在本人管辖内)/ 容器是否在本人名下领地
|
||
// ——注销、报失、补办统一三条件判定的数据基础,由 Java push_catalog 同步
|
||
foreach (['detached_at INTEGER DEFAULT 0',
|
||
'own_chest INTEGER DEFAULT 0'] as $snCustodyCol) {
|
||
try {
|
||
$db->exec("ALTER TABLE web_item_sn ADD COLUMN " . $snCustodyCol);
|
||
} catch (\Throwable $e) {
|
||
// 列已存在 → 忽略
|
||
}
|
||
}
|
||
|
||
// SN 操作日志尾部(Java sn_log 镜像)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_log (
|
||
id INTEGER PRIMARY KEY,
|
||
sn TEXT DEFAULT '',
|
||
item_type TEXT DEFAULT '',
|
||
action TEXT DEFAULT '',
|
||
player TEXT DEFAULT '',
|
||
detail TEXT DEFAULT '',
|
||
time INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 出入库登记(容器坐标 / 是否在领地 / 领地名)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_stock (
|
||
id INTEGER PRIMARY KEY,
|
||
sn TEXT DEFAULT '',
|
||
item_type TEXT DEFAULT '',
|
||
action TEXT DEFAULT '',
|
||
world TEXT DEFAULT '',
|
||
x INTEGER DEFAULT 0,
|
||
y INTEGER DEFAULT 0,
|
||
z INTEGER DEFAULT 0,
|
||
container_type TEXT DEFAULT '',
|
||
in_land INTEGER DEFAULT 0,
|
||
land_name TEXT DEFAULT '',
|
||
player TEXT DEFAULT '',
|
||
time INTEGER DEFAULT 0
|
||
)");
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_stock_sn ON web_sn_stock(sn, time)");
|
||
|
||
// 手持/背包 SN 快照(每 10~30 秒上报)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_held (
|
||
sn TEXT PRIMARY KEY,
|
||
player TEXT DEFAULT '',
|
||
holder TEXT DEFAULT '',
|
||
owner TEXT DEFAULT '',
|
||
item_type TEXT DEFAULT '',
|
||
held INTEGER DEFAULT 0,
|
||
time INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// Java -> PHP 事件流(拾取归属不符 / 销毁 / 非法处理)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_event (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
action TEXT DEFAULT '',
|
||
sn TEXT DEFAULT '',
|
||
player TEXT DEFAULT '',
|
||
detail TEXT DEFAULT '',
|
||
created_at INTEGER DEFAULT 0
|
||
)");
|
||
|
||
// 玩家报失工单
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_lost (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
sn TEXT DEFAULT '',
|
||
player TEXT DEFAULT '',
|
||
reason TEXT DEFAULT '',
|
||
status TEXT DEFAULT 'checking',
|
||
result TEXT DEFAULT '',
|
||
report_count INTEGER DEFAULT 1,
|
||
created_at INTEGER DEFAULT 0,
|
||
handled_at INTEGER DEFAULT 0
|
||
)");
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_lost_sn ON web_sn_lost(sn, created_at)");
|
||
|
||
// PHP -> Java 命令队列(注销 / 补发 / 报失核查 / 永久绑定)
|
||
// is_force=1 → 管理员强制操作,Java 侧仅本次绕过冷静期
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_commands (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
cmd TEXT DEFAULT '',
|
||
sn TEXT DEFAULT '',
|
||
player TEXT DEFAULT '',
|
||
item_type TEXT DEFAULT '',
|
||
reason TEXT DEFAULT '',
|
||
is_force INTEGER DEFAULT 0,
|
||
link_type TEXT DEFAULT '',
|
||
link_id INTEGER DEFAULT 0,
|
||
status TEXT DEFAULT 'pending',
|
||
result TEXT DEFAULT '',
|
||
created_at INTEGER DEFAULT 0,
|
||
sent_at INTEGER DEFAULT 0,
|
||
done_at INTEGER DEFAULT 0
|
||
)");
|
||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_cmd_status ON web_sn_commands(status, id)");
|
||
|
||
// 冷静期(注销后 1 小时禁止申领,展示用;真正生效在 Java 侧)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_cooldown (
|
||
player TEXT PRIMARY KEY,
|
||
until INTEGER DEFAULT 0
|
||
)");
|
||
$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) {
|
||
if (empty($token)) {
|
||
return false;
|
||
}
|
||
|
||
// 先尝试普通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 (\Throwable $e) {
|
||
debugLog("[validateTokenSilent] EXCEPTION: " . $e->getMessage());
|
||
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]);
|
||
}
|
||
|
||
/**
|
||
* HTML 转义(PHP 模板输出用,防止 XSS)
|
||
* 注意:与前端 JS 里的 esc() 是不同作用域,互不冲突。
|
||
*/
|
||
function esc($s) {
|
||
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
}
|
||
|
||
// ===== 请求辅助 =====
|
||
|
||
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);
|
||
}
|
||
// ★ 紧急加固:安全纪元之前的会话一律作废(部署时同步踢掉入侵者的活会话)
|
||
if (function_exists('secSessionEpochOk') && !secSessionEpochOk()) {
|
||
$_SESSION = array();
|
||
@session_destroy();
|
||
error('登录已失效,请重新登录', 401);
|
||
}
|
||
// ★ 紧急加固:已启用二次验证但本会话未通过 6 位动态码 → 拒绝一切管理操作
|
||
if (function_exists('secSessionNeed2FA') && secSessionNeed2FA()) {
|
||
error('需要二次验证:请先完成6位动态码验证', 403);
|
||
}
|
||
}
|
||
|
||
// ===== 管理员认证 =====
|
||
|
||
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();
|
||
// ★ 紧急加固:每次重新登录都必须重新过二次验证
|
||
unset($_SESSION['admin_2fa_ok'], $_SESSION['admin_2fa_ip']);
|
||
return true;
|
||
}
|
||
|
||
function isAdminLoggedIn() {
|
||
if (session_status() === PHP_SESSION_NONE) {
|
||
session_start();
|
||
}
|
||
if (!isset($_SESSION['admin_auth']) || !$_SESSION['admin_auth']) {
|
||
return false;
|
||
}
|
||
// ★ 紧急加固:安全纪元之前的旧会话视为未登录(只读展示场景,不销毁)
|
||
if (function_exists('secSessionEpochOk') && !secSessionEpochOk()) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ===== 收银员认证 =====
|
||
|
||
/**
|
||
* 收银员登录:校验用户名+密码,成功写入会话
|
||
*/
|
||
function cashierLogin($username, $password) {
|
||
if (session_status() === PHP_SESSION_NONE) {
|
||
session_start();
|
||
}
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT * FROM cashiers WHERE username = :u");
|
||
$stmt->bindValue(':u', $username, SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) return false;
|
||
if (!password_verify($password, $row['password_hash'])) return false;
|
||
$_SESSION['cashier_auth'] = true;
|
||
$_SESSION['cashier_id'] = (int)$row['id'];
|
||
$_SESSION['cashier_name'] = $row['username'];
|
||
$_SESSION['cashier_discount_limit'] = (int)$row['discount_limit_percent'];
|
||
$_SESSION['cashier_can_cash'] = (int)($row['can_cash'] ?? 0);
|
||
$_SESSION['cashier_login_time'] = time();
|
||
return true;
|
||
}
|
||
|
||
function isCashierLoggedIn() {
|
||
if (session_status() === PHP_SESSION_NONE) {
|
||
session_start();
|
||
}
|
||
return isset($_SESSION['cashier_auth']) && $_SESSION['cashier_auth'];
|
||
}
|
||
|
||
function getCurrentCashier() {
|
||
if (!isCashierLoggedIn()) return null;
|
||
return [
|
||
'id' => $_SESSION['cashier_id'] ?? 0,
|
||
'username' => $_SESSION['cashier_name'] ?? '',
|
||
'discount_limit_percent' => (int)($_SESSION['cashier_discount_limit'] ?? 0),
|
||
'can_cash' => (int)($_SESSION['cashier_can_cash'] ?? 0)
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 收银台操作鉴权:管理员或收银员会话均可,返回操作者类型('admin'/'cashier')
|
||
*/
|
||
function requireCashierOrAdminSession() {
|
||
if (session_status() === PHP_SESSION_NONE) {
|
||
session_start();
|
||
}
|
||
if (isAdminLoggedIn()) return 'admin';
|
||
if (isCashierLoggedIn()) return 'cashier';
|
||
error('未登录收银台', 401);
|
||
}
|
||
|
||
function cashierLogout() {
|
||
if (session_status() === PHP_SESSION_NONE) {
|
||
session_start();
|
||
}
|
||
unset($_SESSION['cashier_auth'], $_SESSION['cashier_id'], $_SESSION['cashier_name'], $_SESSION['cashier_discount_limit'], $_SESSION['cashier_can_cash'], $_SESSION['cashier_login_time']);
|
||
}
|
||
|
||
/**
|
||
* 记录一笔收银台订单(写入独立的 orders.db,与 web.db 事务完全解耦)
|
||
* ★ 设计要点:
|
||
* - 订单是 PHP 收银台自己的数据,Java 永不读写本库,彻底消除 web.db 文件锁竞争
|
||
* - 写入失败仅记录日志、返回空单号,绝不阻断购买主流程(库存/发药依赖 web.db)
|
||
* - $db 参数为历史兼容保留,但实际一律路由到 orders.db,避免误写 web.db 死表
|
||
* @return string 订单号(失败返回空串)
|
||
*/
|
||
function recordCashierOrder($data, $db = null) {
|
||
try {
|
||
$odb = getOrdersDB();
|
||
} catch (\Throwable $e) {
|
||
debugLog("recordCashierOrder: 无法获取 orders.db 连接(订单未记录): " . $e->getMessage());
|
||
return '';
|
||
}
|
||
$orderNo = 'C' . date('YmdHis') . str_pad(mt_rand(0, 999), 3, '0', STR_PAD_LEFT);
|
||
try {
|
||
$stmt = $odb->prepare("INSERT INTO cashier_orders (order_no, operator_type, operator_name, player_name, items_detail, subtotal, total_price, discount_percent, discount_amount, settlement, payment_method, status, created_at) VALUES (:no,:ot,:on,:pn,:det,:sub,:tp,:dp,:da,:st,:pm,'completed',:time)");
|
||
$stmt->bindValue(':no', $orderNo, SQLITE3_TEXT);
|
||
$stmt->bindValue(':ot', $data['operator_type'] ?? 'cashier', SQLITE3_TEXT);
|
||
$stmt->bindValue(':on', $data['operator_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':pn', $data['player_name'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':det', json_encode($data['items_detail'] ?? [], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$stmt->bindValue(':sub', (int)($data['subtotal'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':tp', (int)($data['total_price'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':dp', (int)($data['discount_percent'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':da', (int)($data['discount_amount'] ?? 0), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':st', $data['settlement'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':pm', $data['payment_method'] ?? 'bond', SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
} catch (\Throwable $e) {
|
||
debugLog("recordCashierOrder: 订单写入失败(不影响购买主流程): " . $e->getMessage());
|
||
return '';
|
||
}
|
||
return $orderNo;
|
||
}
|
||
|
||
// ===== 定时清理(仅清理过期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
|
||
*/
|
||
/**
|
||
* ★ 封禁拦截:查询玩家是否处于【名字封禁】生效期(Java /ban 通过 sync_bans 同步的 web_player_bans)
|
||
* 返回 null = 未封禁(或数据不可用时放行);否则返回封禁原因字符串(供前端提示)。
|
||
* expire_time 单位兼容:Java 推送的是毫秒(BanEntry.getExpiration().getTime()),0 = 永久。
|
||
* 覆盖范围:token登录 / 密码登录 / 邮箱验证码登录 / 商城·CDK·余额等 validateWebAccess 入口。
|
||
*/
|
||
function playerWebBanReason($player, $db = null) {
|
||
if ($player === null || $player === '') return null;
|
||
try {
|
||
if ($db === null) $db = getDB();
|
||
// 表不存在 = 从未同步过封禁名单 → 直接放行(避免读路径执行DDL)
|
||
$exists = $db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='web_player_bans'");
|
||
if (!$exists) return null;
|
||
$stmt = $db->prepare("SELECT reason, expire_time FROM web_player_bans WHERE ban_type = 'name' AND LOWER(target) = LOWER(:p)");
|
||
if (!$stmt) return null;
|
||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||
$res = $stmt->execute();
|
||
if (!$res) return null;
|
||
$now = time();
|
||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
$exp = (int)$row['expire_time'];
|
||
if ($exp > 100000000000) $exp = intdiv($exp, 1000); // 毫秒 → 秒
|
||
if ($exp === 0 || $exp > $now) {
|
||
$reason = trim((string)($row['reason'] ?? ''));
|
||
return $reason !== '' ? $reason : '违反服务器规则';
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
@error_log('[playerWebBanReason] ' . $e->getMessage());
|
||
}
|
||
return null;
|
||
}
|
||
|
||
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'];
|
||
|
||
// ★ 封禁拦截:被封禁玩家禁止使用Web端(商城/CDK/余额/登录校验一律拒绝)
|
||
$banReason = playerWebBanReason($playerName, $db);
|
||
if ($banReason !== null) {
|
||
@error_log("[validateWebAccess] DENIED: player banned. player=$playerName");
|
||
return ['ok' => false, 'mode' => 'denied', 'message' => '账号已被封禁:' . $banReason];
|
||
}
|
||
|
||
// 在线玩家表(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有效 + 在线 + Java推送web_login_verified → 允许访问
|
||
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) {
|
||
// 玩家已注册但在游戏中未登录 → 需要密码
|
||
debugLog("validateWebAccess: 需要密码(不在线)", ['player' => $playerName]);
|
||
return ['ok' => false, 'mode' => 'need_password', 'player' => $playerName, 'registered' => true, 'online' => false, 'message' => '请输入游戏登录密码以同步登录游戏'];
|
||
}
|
||
// ★ 在线但未通过Java推送web_login_verified → 也需要密码(防止偷token自动登录)
|
||
$isWebVerified = false;
|
||
try {
|
||
// ★ 使用token的有效期作为窗口(而非硬编码5分钟),确保与token生命周期一致
|
||
$verifiedWindow = time() - $expireSeconds;
|
||
$wvStmt = $db->prepare("SELECT 1 FROM web_login_verified WHERE player_name = :player AND verified_at >= :expire");
|
||
$wvStmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$wvStmt->bindValue(':expire', $verifiedWindow, SQLITE3_INTEGER);
|
||
$wvResult = $wvStmt->execute();
|
||
$wvRow = $wvResult->fetchArray();
|
||
if ($wvRow) $isWebVerified = true;
|
||
} catch (\Throwable $e) {}
|
||
if (!$isWebVerified) {
|
||
debugLog("validateWebAccess: 需要密码(在线但未验证)", ['player' => $playerName]);
|
||
return ['ok' => false, 'mode' => 'need_password', 'player' => $playerName, 'registered' => true, 'online' => true, 'message' => '请输入游戏登录密码以验证身份'];
|
||
}
|
||
$sessionToken = recordWebSession($playerName, $ipAddress);
|
||
debugLog("validateWebAccess: 验证成功(Java已确认)", ['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,免验证'];
|
||
}
|
||
|
||
// ★ 安全修复:大额交易密码验证必须通过游戏服务器(web_login_verified),禁止PHP自验证
|
||
// 检查web_login_verified表:最近10分钟内通过Java验证过 → 允许
|
||
$db = getDB();
|
||
try {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_login_verified (player_name TEXT PRIMARY KEY, verified_at INTEGER NOT NULL)");
|
||
$stmt = $db->prepare("SELECT verified_at FROM web_login_verified WHERE player_name = :player");
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray();
|
||
$verifiedAt = $row ? (int)$row['verified_at'] : 0;
|
||
$validWindow = 600; // 10分钟内有效
|
||
if ($verifiedAt > 0 && (time() - $verifiedAt) < $validWindow) {
|
||
$sessionToken = recordWebSession($playerName, $ipAddress);
|
||
debugLog("validateWebAccess: Java验证有效", ['player' => $playerName, 'amount' => $amount, 'age' => time() - $verifiedAt]);
|
||
return ['ok' => true, 'mode' => 'full_verified', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '密码验证通过'];
|
||
}
|
||
} catch (\Throwable $e) {
|
||
debugLog("validateWebAccess: 查询web_login_verified异常", ['error' => $e->getMessage()]);
|
||
}
|
||
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' => '验证成功'];
|
||
}
|
||
|
||
// =====================================================================
|
||
// 异地登录检测 / 账号冻结 公共函数(security_alert.php / freeze.php 复用)
|
||
// =====================================================================
|
||
|
||
/**
|
||
* SMTP 邮件发送(已从 api/sync.php 上移至此处,统一复用,避免重复定义)
|
||
*/
|
||
function smtpSendEmail($host, $port, $user, $pass, $to, $subject, $htmlBody, $headers, $useSSL = true) {
|
||
$errno = 0;
|
||
$errstr = '';
|
||
|
||
// 创建socket连接
|
||
if ($useSSL) {
|
||
$socket = @fsockopen('ssl://' . $host, $port, $errno, $errstr, 30);
|
||
} else {
|
||
$socket = @fsockopen($host, $port, $errno, $errstr, 30);
|
||
}
|
||
|
||
if (!$socket) {
|
||
throw new Exception("SMTP连接失败: $errstr ($errno)");
|
||
}
|
||
|
||
// 读取服务器响应
|
||
$response = fgets($socket);
|
||
if (strpos($response, '220') !== 0) {
|
||
fclose($socket);
|
||
throw new Exception("SMTP服务器拒绝连接: $response");
|
||
}
|
||
|
||
// 发送EHLO
|
||
fwrite($socket, "EHLO localhost\r\n");
|
||
$response = fread($socket, 1024);
|
||
|
||
// 登录认证
|
||
fwrite($socket, "AUTH LOGIN\r\n");
|
||
$response = fgets($socket);
|
||
if (strpos($response, '334') !== 0) {
|
||
fclose($socket);
|
||
throw new Exception("SMTP认证失败: $response");
|
||
}
|
||
|
||
// 发送用户名
|
||
fwrite($socket, base64_encode($user) . "\r\n");
|
||
$response = fgets($socket);
|
||
if (strpos($response, '334') !== 0) {
|
||
fclose($socket);
|
||
throw new Exception("SMTP用户名错误: $response");
|
||
}
|
||
|
||
// 发送密码
|
||
fwrite($socket, base64_encode($pass) . "\r\n");
|
||
$response = fgets($socket);
|
||
if (strpos($response, '235') !== 0) {
|
||
fclose($socket);
|
||
throw new Exception("SMTP密码错误: $response");
|
||
}
|
||
|
||
// 发送邮件
|
||
fwrite($socket, "MAIL FROM:<$user>\r\n");
|
||
fgets($socket);
|
||
|
||
fwrite($socket, "RCPT TO:<$to>\r\n");
|
||
fgets($socket);
|
||
|
||
fwrite($socket, "DATA\r\n");
|
||
fgets($socket);
|
||
|
||
// 发送邮件头和正文
|
||
$emailData = "Subject: $subject\r\n";
|
||
$emailData .= $headers;
|
||
$emailData .= "\r\n";
|
||
$emailData .= $htmlBody;
|
||
$emailData .= "\r\n.\r\n";
|
||
|
||
fwrite($socket, $emailData);
|
||
$response = fgets($socket);
|
||
|
||
// 退出
|
||
fwrite($socket, "QUIT\r\n");
|
||
fgets($socket);
|
||
|
||
fclose($socket);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 查询登录 IP 归属(ip9.com.cn)
|
||
* 注意:与 admin.php 的 queryIpLocation 区分命名,避免重复定义冲突。
|
||
* @return string|null 归属字符串(如 "广东深圳"),失败或内网返回 null
|
||
*/
|
||
function queryLoginIpLocation($ip) {
|
||
if (empty($ip) || $ip === '-' || $ip === '127.0.0.1'
|
||
|| strpos($ip, '10.') === 0 || strpos($ip, '192.168.') === 0
|
||
|| !filter_var($ip, FILTER_VALIDATE_IP)) {
|
||
return null;
|
||
}
|
||
$url = 'https://ip9.com.cn/get?ip=' . urlencode($ip);
|
||
$ctx = stream_context_create([
|
||
'http' => [
|
||
'method' => 'GET',
|
||
'timeout' => 3,
|
||
'ignore_errors' => true,
|
||
'header' => "Accept: application/json, text/plain, */*\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\nReferer: https://ip9.com.cn/\r\nOrigin: https://ip9.com.cn"
|
||
],
|
||
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false]
|
||
]);
|
||
$response = @file_get_contents($url, false, $ctx);
|
||
if ($response === false) return null;
|
||
$response = trim($response);
|
||
if ($response === '') return null;
|
||
$data = json_decode($response, true);
|
||
if (!$data || !is_array($data)) return null;
|
||
$item = null;
|
||
if (isset($data['data'][0]) && is_array($data['data'][0])) {
|
||
$item = $data['data'][0];
|
||
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data']['prov'])) {
|
||
$item = $data['data'];
|
||
}
|
||
if (!$item) return null;
|
||
$loc = '';
|
||
if (isset($item['prov'])) $loc .= $item['prov'];
|
||
if (isset($item['city'])) $loc .= $item['city'];
|
||
if (isset($item['area']) && ($item['area'] !== ($item['city'] ?? '')) && ($item['area'] !== ($item['prov'] ?? ''))) {
|
||
$loc .= $item['area'];
|
||
}
|
||
$loc = trim($loc);
|
||
return $loc === '' ? null : $loc;
|
||
}
|
||
|
||
/**
|
||
* 构建站点绝对基址(用于邮件链接)
|
||
*/
|
||
function getBaseUrl() {
|
||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||
if ($host === '' || $host === '127.0.0.1' || $host === 'localhost') {
|
||
$host = 'caoyuan.ypshidifu.cn';
|
||
}
|
||
// 站点统一使用 HTTPS
|
||
$base = 'https://' . $host;
|
||
$sub = trim(WEBSUB_DIR, '/');
|
||
if ($sub !== '') $base .= '/' . $sub;
|
||
return rtrim($base, '/');
|
||
}
|
||
|
||
/**
|
||
* 发送异地登录提醒邮件(附带冻结 / 改密链接)
|
||
* @param string $freezeToken 已持久化到 users.freeze_token 的令牌
|
||
*/
|
||
function sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freezeToken) {
|
||
$db = getDB();
|
||
// 生成改密 token(30分钟内有效)
|
||
$resetToken = bin2hex(random_bytes(16));
|
||
$expire = time() + 1800;
|
||
$ins = $db->prepare("INSERT INTO password_reset_tokens (token, player_name, expire_at) VALUES (:t, :n, :e)");
|
||
$ins->bindValue(':t', $resetToken, SQLITE3_TEXT);
|
||
$ins->bindValue(':n', $name, SQLITE3_TEXT);
|
||
$ins->bindValue(':e', $expire, SQLITE3_INTEGER);
|
||
$ins->execute();
|
||
|
||
$base = getBaseUrl();
|
||
$freezeLink = $base . '/freeze.php?token=' . urlencode($freezeToken);
|
||
$resetLink = $base . '/reset_password.php?token=' . urlencode($resetToken);
|
||
|
||
$subject = '[Sdf1] 异地登录提醒:您的账号于 ' . $location . ' 登录';
|
||
|
||
$html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
|
||
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
||
. '<title>异地登录提醒</title></head><body style="margin:0;padding:0;background:#f4f6f9;font-family:Segoe UI,Tahoma,sans-serif;">'
|
||
. '<div style="max-width:560px;margin:0 auto;padding:24px;">'
|
||
. '<div style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,0.08);">'
|
||
. '<div style="background:linear-gradient(135deg,#e0533d,#c0392b);padding:28px 32px;color:#fff;">'
|
||
. '<h1 style="margin:0;font-size:22px;">⚠️ 异地登录提醒</h1>'
|
||
. '<p style="margin:8px 0 0;opacity:.9;font-size:14px;">Sdf1 Minecraft 账号安全中心</p></div>'
|
||
. '<div style="padding:32px;">'
|
||
. '<p style="margin:0 0 16px;font-size:15px;color:#333;">亲爱的 <b>' . htmlspecialchars($name) . '</b>:</p>'
|
||
. '<p style="margin:0 0 16px;font-size:15px;color:#555;line-height:1.7;">我们检测到您的账号于 <b style="color:#e0533d;">' . htmlspecialchars($location) . '</b> 发起了登录,'
|
||
. '而您上一次的登录地点为 <b>' . htmlspecialchars($lastLoc ?: '未知') . '</b>。如果该登录并非您本人操作,账号可能已泄露。</p>'
|
||
. '<div style="background:#fff7f5;border:1px solid #f3c4ba;border-radius:8px;padding:14px 16px;margin:0 0 20px;font-size:14px;color:#8a4b3a;">'
|
||
. '本次登录 IP:' . htmlspecialchars($ip) . '<br>登录时间:' . date('Y-m-d H:i:s') . '</div>'
|
||
. '<p style="margin:0 0 12px;font-size:14px;color:#555;">如确认存在风险,请点击下方按钮<b>冻结账号</b>(将立即封禁游戏登录,需改密后解冻):</p>'
|
||
. '<div style="text-align:center;margin:0 0 24px;"><a href="' . htmlspecialchars($freezeLink) . '" style="display:inline-block;background:linear-gradient(135deg,#e0533d,#c0392b);color:#fff;text-decoration:none;padding:13px 36px;border-radius:8px;font-size:15px;font-weight:600;">立即冻结账号</a></div>'
|
||
. '<p style="margin:0 0 12px;font-size:14px;color:#555;">如确认是本人操作,也建议您定期修改密码以保障安全:</p>'
|
||
. '<div style="text-align:center;margin:0 0 8px;"><a href="' . htmlspecialchars($resetLink) . '" style="display:inline-block;background:#fff;color:#e0533d;border:1px solid #e0533d;text-decoration:none;padding:12px 28px;border-radius:8px;font-size:14px;font-weight:600;">修改密码</a></div>'
|
||
. '<p style="margin:24px 0 0;font-size:12px;color:#999;border-top:1px solid #eee;padding-top:16px;">本邮件由系统自动发送,请勿直接回复。若您未触发任何登录,请尽快冻结账号并修改密码。</p>'
|
||
. '</div></div></div></body></html>';
|
||
|
||
$headers = "From: " . SMTP_SENDER_NAME . " <" . SMTP_USER . ">\r\n"
|
||
. "Reply-To: " . SMTP_USER . "\r\n"
|
||
. "MIME-Version: 1.0\r\n"
|
||
. "Content-Type: text/html; charset=UTF-8\r\n";
|
||
smtpSendEmail(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, $email, $subject, $html, $headers, SMTP_USE_SSL);
|
||
}
|
||
|
||
// =====================================================================================
|
||
// ===== IP黑名单(游戏内封禁IP → Java推送 → Web端拦截) =====
|
||
// 数据来源:api/sync.php?action=sync_bans 携带的 ips 负载写入 web_ip_blacklist 表。
|
||
// 拦截规则(核心要求):被封IP访问Web【必须】携带游戏内 /web 命令签发的有效token
|
||
// (weblogin_tokens 表,未过期),否则一律 403。
|
||
// 放行白名单:① Java插件 SECRET_KEY 同步调用 ② 已登录的管理后台/收银台会话
|
||
// (防止管理员把自己的IP锁在站外)③ 有效 /web token。
|
||
// 任何异常一律 fail-open 放行,避免误伤整站。
|
||
// =====================================================================================
|
||
|
||
/** 该IP是否在Web黑名单中(含过期时间判断) */
|
||
function isIpBlacklisted($ip) {
|
||
if (!$ip) return false;
|
||
try {
|
||
$db = getDB();
|
||
$chk = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='web_ip_blacklist'");
|
||
if (!$chk || !$chk->fetchArray()) return false;
|
||
$stmt = $db->prepare("SELECT 1 FROM web_ip_blacklist WHERE ip = :ip AND (expire = 0 OR expire > :now) LIMIT 1");
|
||
$stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
|
||
$row = $stmt->execute()->fetchArray();
|
||
return $row !== false;
|
||
} catch (\Throwable $e) {
|
||
@error_log("[ip_blacklist] 查询失败(放行): " . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** token 是否为游戏内 /web 签发且未过期 */
|
||
function hasWebTokenForBlacklist($token) {
|
||
if (!$token) return false;
|
||
try {
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT created_at, expire_seconds FROM weblogin_tokens WHERE web_token = :t LIMIT 1");
|
||
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) return false;
|
||
return (time() - (int)$row['created_at']) <= (int)$row['expire_seconds'];
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 入口拦截:命中黑名单且没有放行凭据 → 403 */
|
||
function enforceIpBlacklist() {
|
||
if (PHP_SAPI === 'cli') return;
|
||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||
if ($ip === '' || !isIpBlacklisted($ip)) return;
|
||
|
||
// ① Java插件同步(SECRET_KEY)
|
||
$secret = getParam('secret');
|
||
if ($secret && $secret === SECRET_KEY) return;
|
||
|
||
// ② 已登录的管理后台 / 收银台会话(仅在浏览器带了会话cookie时才查,避免给API请求种session)
|
||
if (!empty($_COOKIE[session_name()])) {
|
||
try { if (isAdminLoggedIn()) return; } catch (\Throwable $e) {}
|
||
try { if (isCashierLoggedIn()) return; } catch (\Throwable $e) {}
|
||
}
|
||
|
||
// ③ 游戏内 /web 签发的有效token
|
||
$token = getParam('web_token');
|
||
if (hasWebTokenForBlacklist($token)) {
|
||
@error_log("[ip_blacklist] 放行(持/webtoken): ip=$ip uri=" . ($_SERVER['REQUEST_URI'] ?? ''));
|
||
return;
|
||
}
|
||
|
||
@error_log("[ip_blacklist] 拦截: ip=$ip uri=" . ($_SERVER['REQUEST_URI'] ?? ''));
|
||
|
||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||
$isApi = strpos($uri, '/api/') !== false || strpos($accept, 'application/json') !== false;
|
||
if ($isApi) {
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
http_response_code(403);
|
||
echo json_encode([
|
||
'success' => false,
|
||
'blocked_ip' => true,
|
||
'message' => '该IP已被服务器封禁,需在游戏内执行 /web 获取授权token后才能访问'
|
||
], JSON_UNESCAPED_UNICODE);
|
||
} else {
|
||
http_response_code(403);
|
||
echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
|
||
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
||
. '<title>403 - IP已被封禁</title><style>'
|
||
. 'body{background:#0d1117;color:#e6edf3;font-family:"Segoe UI",system-ui,sans-serif;'
|
||
. 'min-height:100vh;display:flex;align-items:center;justify-content:center;margin:0}'
|
||
. '.c{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:40px;'
|
||
. 'max-width:460px;text-align:center}'
|
||
. 'h1{color:#f85149;font-size:48px;margin:0 0 12px}'
|
||
. 'p{color:#8b949e;line-height:1.8;font-size:14px}'
|
||
. 'code{color:#79c0ff;background:#0d1117;padding:2px 6px;border-radius:4px}'
|
||
. '</style></head><body><div class="c">'
|
||
. '<h1>403</h1><p><b>当前IP已被服务器封禁</b></p>'
|
||
. '<p>如需访问网页,请先在游戏内执行 <code>/web</code> 获取授权token,'
|
||
. '再携带 <code>web_token</code> 访问。</p></div></body></html>';
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// core.php 被所有页面/API引入 → 在此统一执行入口拦截
|
||
enforceIpBlacklist();
|