From e1010927e59c0c2d59e865b16ee8f9b82d13dddf Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Fri, 9 Oct 2026 18:16:56 +0800 Subject: [PATCH] =?UTF-8?q?PHP=20MySQL=20=E9=80=82=E9=85=8D=E5=B1=82?= =?UTF-8?q?=E4=B8=8E=E5=8F=8C=E5=BA=93=E5=88=87=E5=88=86=E6=94=B6=E5=B0=BE?= =?UTF-8?q?,=20=E6=96=B0=E5=A2=9E=E8=87=AA=E6=AF=81=E5=BC=8F=20install.php?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=AE=89=E8=A3=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core.php: sqlDialectMySql 方言翻译 + SdfMySqlDb/Stmt/Result 复刻 SQLite3 API, SdfDbRouter 路由与侧别过滤器, 软依赖兜底; 修复 dbExtractTables 把 CREATE INDEX 索引名当表名导致 web 库 0 张表的真 bug - security.php: sqlMdDbConfig/sqlDbDir/sqlDbFile 双库配置函数, web.db 给 PHP / Java.db 给 Java - api/minecraft_auth.php, api/poller_online.php: Java 专属表改走 sqlDbFile('java') - install.php: 自毁式数据库配置页, 只依赖 security.php, 哨兵块替换 + 备份原子写回读校验 + install.lock + unlink 自删, 门禁 404 - .gitignore: 屏蔽 .workbuddy/ 工作目录 - 测试: db 回归 26/26, MySQL 63/63, schema 对拍 11/11, install 端到端 32/32 全绿 --- .gitignore | 10 + api/minecraft_auth.php | 3 +- api/poller_online.php | 2 +- core.php | 1431 +++++++++++++++++++++++++++++++++++++--- install.php | 551 ++++++++++++++++ security.php | 185 +++++- 6 files changed, 2102 insertions(+), 80 deletions(-) create mode 100644 install.php diff --git a/.gitignore b/.gitignore index a51c2e3..1938fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,13 @@ db/ !readme.md !pay.md !sql.md +# 屏蔽所有的python脚本 +/*.py +# 屏蔽备份文件 +/*.*.back.* +# 屏蔽所有的测试文件 +/*_*.* +/_test*/*.* +# ===== .workbuddy/ 工作目录(用户保留区)===== +# 存放测试脚本 / 探针 / 测试数据 / 备份文件,已被 git拉黑(2026-10-09) +.workbuddy/ diff --git a/api/minecraft_auth.php b/api/minecraft_auth.php index e0e8bf8..b1abbf8 100644 --- a/api/minecraft_auth.php +++ b/api/minecraft_auth.php @@ -48,7 +48,8 @@ function mcAuthIpBlacklisted() { // 放行通道:Java插件 secret if (isset($_REQUEST['secret']) && $_REQUEST['secret'] === SECRET_KEY) return false; try { - $path = dirname(__DIR__) . '/db/web.db'; + // ★ web_ip_blacklist / weblogin_tokens 都是 Java 专属表 → 必须开 Java.db + $path = sqlDbFile('java'); if (!file_exists($path)) return false; $w = new PDO('sqlite:' . $path); $w->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); diff --git a/api/poller_online.php b/api/poller_online.php index 9049b14..b3893a2 100644 --- a/api/poller_online.php +++ b/api/poller_online.php @@ -40,7 +40,7 @@ $PLATFORM_DB_PASS = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2 $GLOBALS['PLATFORM_DB_PREFIX'] = 'pay_'; $PLATFORM_DB_PREFIX = 'pay_'; // SQLite 数据库路径 -$GLOBALS['SQLITE_DB_PATH'] = __DIR__ . '/../db/web.db'; // web_transactions (Java读写) +$GLOBALS['SQLITE_DB_PATH'] = sqlDbFile('java'); // web_transactions (Java专属表 → Java.db) $GLOBALS['ORDERS_DB_PATH'] = __DIR__ . '/../db/orders.db'; // pay_orders (PHP读写) if (!function_exists('debugLog')) { diff --git a/core.php b/core.php index e49923f..880deac 100644 --- a/core.php +++ b/core.php @@ -34,70 +34,1338 @@ function debugLog($message, $context = []) { } } -// ===== 数据库初始化 ===== -function getDB() { - static $db = null; - if ($db === null) { - // 确保db目录存在 - $dbDir = dirname(DB_PATH); - if (!is_dir($dbDir)) { - @mkdir($dbDir, 0755, true); +// ===== 双库切分(2026-10-09 大重构):Java.db(Java 专属)/ web.db(PHP 专属)===== +// 归属规则:Java 专属表 = api/sync.php 通信组(Java 走 HTTP 读写的 40 张表), +// 其余表归 PHP 专属。跨组 SQL 全仓为 0(IP 两张表已同归 Java 组), +// 所以按表名路由是安全的,任何一方都不需要跨库 JOIN。 +// 两侧可以各自是 SQLite 文件或 MySQL 地址(由 sql.md 的「归属」字段决定连谁), +// 本层只负责「按语句里的表名判侧别」,真正给哪个连接由 dbOpen() 按运行模式决定。 + +if (!function_exists('dbJavaTables')) { + /** Java 专属表清单(api/sync.php 通信组,40 张,精确匹配不做前缀匹配) */ + function dbJavaTables() { + static $t = null; + if ($t === null) { + $t = array_flip(array( + 'bond_cache', 'cdk', 'cdk_validate_requests', 'cdk_validate_results', + 'confirmed_transactions', 'email_codes', 'game_transactions', + 'hourly_active_players', 'online_player_hb', 'online_players', + 'password_reset_requests', 'password_reset_tokens', 'player_checkins', + 'player_daily_logins', 'player_ip_changes', 'player_ip_locations', + 'shop_config', 'shop_items', 'sync_requests', 'tokens', 'users', + 'web_admin_changes', 'web_area_config', 'web_area_lands', + 'web_area_permissions', 'web_area_shop', 'web_ip_blacklist', + 'web_login_confirmations', 'web_login_requests', 'web_login_verified', + 'web_player_bans', 'web_plugin_admins', 'web_register_requests', + 'web_service_providers', 'web_session_log', 'web_transactions', + 'web_user_group_members', 'web_user_groups', 'weblogin_credentials', + 'weblogin_tokens', + )); + } + return $t; + } +} + +if (!function_exists('dbSideOfTable')) { + /** 表名 → 'java' | 'web'(未知表一律归 PHP 专属,保证新表始终可被后台访问) */ + function dbSideOfTable($name) { + $n = strtolower(trim((string)$name, " `\"'\t\n\r")); + if ($n === '') return 'web'; + $map = dbJavaTables(); + return isset($map[$n]) ? 'java' : 'web'; + } +} + +if (!function_exists('dbExtractTables')) { + /** + * 抽出一条 SQL 语句涉及的表名(小写、去重)。抽不到返回空数组(PRAGMA/BEGIN 等)。 + * ★ CREATE/DROP INDEX 必须特殊处理: + * `CREATE INDEX IF NOT EXISTS idx_xxx ON tbl(...)` 里紧跟关键字的是**索引名**, + * 直接套通用正则会把索引名当表名 → 索引名不在 Java 清单 → 语句被判成 web 侧 → + * 在 web 库上给 java 侧表建索引 → 1146 / "no such table" → initTables 整段中断。 + */ + function dbExtractTables($sql) { + $sql = (string)$sql; + $out = array(); + + // ① CREATE [UNIQUE] INDEX [IF NOT EXISTS] <索引名> ON <表名>(...):只取 ON 之后的表名 + $patIdx = '/\bCREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+NOT\s+EXISTS)?\s+[`"\']?[\w$]+[`"\']?\s+ON\s+[`"\']?([\w$]+)/i'; + if (preg_match_all($patIdx, $sql, $mi)) { + foreach ($mi[1] as $n) $out[strtolower($n)] = true; + // 抹成 `CREATE INDEX ON`(占位名仍非 Java 表,且已在①里取过真表名) + $sql = preg_replace($patIdx, 'CREATE INDEX ON ', $sql); + } + // ② DROP INDEX <索引名>:后面跟的是索引名而非表名,整段抹掉避免误判 + $sql = preg_replace('/\bDROP\s+INDEX(?:\s+IF\s+EXISTS)?\s+[`"\']?[\w$]+[`"\']?/i', 'DROP INDEX ', $sql); + + // ③ 其余 DDL/DML + $pat = '/\b(?:CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?' + . '|INSERT\s+(?:OR\s+\w+\s+)?INTO|UPDATE|DELETE\s+FROM|FROM|JOIN)\s+[`"\']?([\w$]+)/i'; + if (preg_match_all($pat, $sql, $m)) { + foreach ($m[1] as $n) $out[strtolower($n)] = true; + } + return array_keys($out); + } +} + +if (!function_exists('dbPickSide')) { + /** + * 语句 → 侧别:纯 Java 组表 → 'java';只要混进一张非 Java 表(或抽不到表名)→ $fallback。 + * fallback 默认维持上一条语句的侧别(让 BEGIN/COMMIT/PRAGMA 跟着当前连接走)。 + */ + function dbPickSide($sql, $fallback = 'web') { + $tabs = dbExtractTables($sql); + if (!$tabs) return $fallback; + foreach ($tabs as $t) { + if (dbSideOfTable($t) !== 'java') return 'web'; + } + return 'java'; + } +} + +if (!class_exists('SdfDbSideFilter')) { + /** + * 侧别过滤器:只执行属于本侧的语句,其余原样跳过。 + * 存在的意义是让 initTables()/migrateDatabase() 的源码一行都不用改就能分家 + * (它们拿到本侧连接后,凡是碰另一侧表的建表/改表语句都被这层拦掉)。 + */ + class SdfDbSideFilter { + private $db; + private $side; + public function __construct($db, $side) { $this->db = $db; $this->side = $side; } + public function exec($sql) { + $tabs = dbExtractTables($sql); + if ($tabs) { + foreach ($tabs as $t) { + if (dbSideOfTable($t) !== $this->side) return true; // 别侧的表:跳过 + } + } + return $this->db->exec($sql); + } + public function __call($m, $a) { return call_user_func_array(array($this->db, $m), $a); } + } +} + +if (!class_exists('SdfDbRouter')) { + /** + * 按表名路由的连接外观(SQLite3 同形 API)——全仓 224 处 getDB() 调用零改动。 + * lastInsertRowID()/changes() 依赖「上一次写入在哪一侧」,故每条语句都记账。 + */ + class SdfDbRouter { + private $last = 'web'; + private $txn = null; // 事务已绑定的侧别 + private $txnWait = false; // 收到 BEGIN,但还没绑定到侧别 + + /** + * 定一条语句该走哪一侧。 + * ★ 事务处理是重点:BEGIN/COMMIT 里没有表名,猜错侧别就会把事务撕成两半。 + * 故首个 BEGIN 先挂起,等事务内第一条真正操作表的语句出现时再绑定侧别; + * 之后语句若与事务侧不一致,一律按事务侧执行(宁可报"表不存在"暴露问题, + * 也绝不让一半写入提交、另一半丢失)。全仓已核实跨侧 SQL 为 0,此为兜底。 + */ + private function side($sql) { + $tabs = dbExtractTables($sql); + if (!$tabs) { + if ($this->txn !== null) return $this->txn; // 事务中的 PRAGMA 等跟着事务走 + return $this->last; + } + $s = dbPickSide($sql, $this->last); + $this->last = $s; + if ($this->txnWait) { + dbOpen($s)->exec('BEGIN TRANSACTION'); + $this->txn = $s; + $this->txnWait = false; + } elseif ($this->txn !== null && $this->txn !== $s) { + debugLog('dbRouter: 语句侧别与事务侧不一致,按事务侧执行', + array('txn' => $this->txn, 'want' => $s, 'sql' => substr((string)$sql, 0, 140))); + return $this->txn; + } + return $s; } - // ★ 检查并修复数据库文件/目录权限(防止 "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); + public function exec($sql) { + $k = strtoupper(preg_replace('/\s+/', ' ', trim((string)$sql))); + if (preg_match('/^BEGIN\b/', $k)) { + if ($this->txnWait) return true; // 重复 BEGIN:忽略 + if ($this->txn !== null) return dbOpen($this->txn)->exec($sql); // 嵌套:与改造前一致地抛错 + $this->txnWait = true; // 首个 BEGIN:挂起待绑定 + return true; + } + if (preg_match('/^(COMMIT|ROLLBACK)\b/', $k)) { + if ($this->txnWait) { $this->txnWait = false; return true; } // BEGIN 没绑上就结束了:空事务丢弃 + $side = ($this->txn !== null) ? $this->txn : $this->last; + $this->txn = null; + return dbOpen($side)->exec($sql); + } + return dbOpen($this->side($sql))->exec($sql); + } + public function query($sql) { return dbOpen($this->side($sql))->query($sql); } + public function prepare($sql) { return dbOpen($this->side($sql))->prepare($sql); } + public function querySingle($sql, $assoc = false) { return dbOpen($this->side($sql))->querySingle($sql, $assoc); } + public function lastInsertRowID() { return dbOpen($this->last)->lastInsertRowID(); } + public function changes() { return dbOpen($this->last)->changes(); } + public function escapeString($v) { return dbOpen($this->last)->escapeString($v); } + public function busyTimeout($ms) { foreach (array('java', 'web') as $s) { @dbOpen($s)->busyTimeout($ms); } return true; } + public function enableExceptions($f) { foreach (array('java', 'web') as $s) { @dbOpen($s)->enableExceptions($f); } return true; } + public function close() { return true; } // 连接由 dbOpen 的 static 缓存复用,不在此关闭 + public function __call($m, $a) { return call_user_func_array(array(dbOpen($this->last), $m), $a); } + } +} + +if (!function_exists('dbSplitMigrateIfNeeded')) { + /** + * 首次切分迁移:把 web.db 里 Java 专属的 40 张表(含索引)搬到新建的 Java.db。 + * 幂等 / 原子 / 绝不递归:先写 Java.db.new,校验无残留 WAL 后再 rename 成 Java.db; + * 中途任何一步失败就删掉 .new 整体放弃,下次请求自动重试,web.db 原样保留不动 + * (旧数据留在 web.db 作为回退副本,路由层之后不会再读写它们)。 + * 全新部署(web.db 还不存在)直接跳过 —— 没有存量可搬。 + */ + function dbSplitMigrateIfNeeded() { + static $done = false, $running = false; + if ($done || $running) return; + $webFile = str_replace('\\', '/', sqlDbFile('web')); + $javaFile = str_replace('\\', '/', sqlDbFile('java')); + if ($webFile === $javaFile || !is_file($webFile) || is_file($javaFile)) { $done = true; return; } + + $running = true; + $tmp = $javaFile . '.new'; + $q = function ($v) { return "'" . str_replace("'", "''", (string)$v) . "'"; }; + $moved = 0; $errs = ''; + try { + @unlink($tmp); @unlink($tmp . '-wal'); @unlink($tmp . '-shm'); + $src = new SQLite3($webFile); + $src->enableExceptions(true); + $src->exec('PRAGMA journal_mode=WAL'); + $src->exec('PRAGMA busy_timeout=8000'); + $src->exec('ATTACH DATABASE ' . $q($tmp) . ' AS jd'); + $rs = $src->query("SELECT name, type, sql FROM sqlite_master" + . " WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'"); + while ($row = $rs->fetchArray(SQLITE3_ASSOC)) { + $name = trim((string)$row['name']); + $type = strtolower((string)$row['type']); + $ddl = (string)$row['sql']; + // 索引的侧别跟随它的表(索引名本身不在表清单里) + $judge = $name; + if ($type === 'index') { + if (!preg_match('/\bON\s+[\'"`]?(\w+)/i', $ddl, $mm)) continue; + $judge = $mm[1]; + } + if ($type !== 'table' && $type !== 'index') continue; + if (dbSideOfTable($judge) !== 'java') continue; // 只搬 Java 专属的 + $exists = $src->querySingle("SELECT COUNT(*) FROM jd.sqlite_master WHERE name = " . $q($name)); + if ((int)$exists > 0) continue; // 已搬过(续跑) + try { + if ($type === 'table') { + $new = preg_replace('/^(CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)([`\"]?)(\w+)/i', + '$1jd.$2$3', $ddl, 1); + $src->exec($new); // 结构(含主键/自增/默认值) + $src->exec('INSERT INTO jd.' . $name . ' SELECT * FROM ' . $name); // 数据 + $moved++; + } else { + $new = preg_replace('/^(CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?)([`\"]?)(\w+)(\s+ON\s+)([`\"]?)(\w+)/i', + '$1jd.$2$3$4jd.$5$6', $ddl, 1); + $src->exec($new); + } + } catch (\Throwable $e) { + $errs .= $name . ':' . $e->getMessage() . '; '; + } + } + try { $src->exec('PRAGMA jd.wal_checkpoint(TRUNCATE)'); } catch (\Throwable $_) {} + $src->exec('DETACH DATABASE jd'); + $src->close(); + if (is_file($tmp . '-wal') && @filesize($tmp . '-wal') > 0) { + // 新库仍有未落盘的 WAL → 不改名(改名会让 -wal 与主文件脱钩),保留 .new 待人工处理 + debugLog('dbSplit: 新库残留 WAL,放弃本次切分', array('wal' => @filesize($tmp . '-wal'))); + $done = true; + return; + } + if (!@rename($tmp, $javaFile)) { + debugLog('dbSplit: rename 失败,保留 .new 供下次重试'); + return; // 不置 done:web.db 还在,下次请求自动重来 + } + $done = true; + debugLog('dbSplit: 已切分出 Java.db', array('tables' => $moved, 'err' => $errs)); + } catch (\Throwable $e) { + @unlink($tmp); @unlink($tmp . '-wal'); @unlink($tmp . '-shm'); + $done = true; + debugLog('dbSplit: 切分失败,回退共库运行', array('error' => $e->getMessage())); + } finally { + $running = false; + } + } +} + +// ===== MySQL 适配层(2026-10-09 大重构 · Task103/104/105)===== +// 目的:sql.md 运行模式 = mysql 且该侧地址齐全时,全站 224 处 getDB() / 1500+ 处 +// bindValue / 1400+ 处 SQLite 方言**一行都不用改**地跑在 MySQL 上。 +// 组成:① sqlDialectMySql() —— SQLite → MySQL 方言翻译器(纯函数,逐条语句) +// ② sqlMySqlNormalize() —— :name / $1 占位符归一为 ? 并产出参数位置表 +// ③ SdfMySqlDb / SdfMySqlStmt / SdfMySqlResult —— 复刻 SQLite3 系 API 形状 +// 软依赖:mode=mysql 但 mysqli 扩展缺失 / 地址不全 / 连接失败 → dbOpen() 自动回退 SQLite。 + +if (!defined('SQLITE3_ASSOC')) define('SQLITE3_ASSOC', 1); +if (!defined('SQLITE3_NUM')) define('SQLITE3_NUM', 2); +if (!defined('SQLITE3_BOTH')) define('SQLITE3_BOTH', 3); +if (!defined('SQLITE3_INTEGER')) define('SQLITE3_INTEGER', 1); +if (!defined('SQLITE3_FLOAT')) define('SQLITE3_FLOAT', 2); +if (!defined('SQLITE3_TEXT')) define('SQLITE3_TEXT', 3); +if (!defined('SQLITE3_BLOB')) define('SQLITE3_BLOB', 4); +if (!defined('SQLITE3_NULL')) define('SQLITE3_NULL', 5); + +// 方言翻译产物标记(不是合法 SQL,只有适配器认识) +define('SDF_SQL_NOOP', "\x01SDF_NOOP\x01"); // 整条语句无需执行(PRAGMA journal_mode 之类) +define('SDF_SQL_SHOWCREATE', "\x01SDF_SHOWCREATE\x01"); // 需要执行 SHOW CREATE TABLE 取建表文本 + +if (!function_exists('sqlMySqlSplitSegments')) { + /** + * 把 SQL 切成 [是否字符串字面量, 文本] 两段序列。 + * 字符串识别规则与 MySQL 一致:'' / "" 是转义的引号,\x 是转义符,反引号用 `` 转义。 + * 所有"只在 SQL 结构上做替换"的步骤(key 反引号、占位符归一、? 渲染)都必须走它, + * 否则会把 WHERE name='key' 里的 key 也改掉。 + */ + function sqlMySqlSplitSegments($sql) { + $sql = (string)$sql; + $segs = array(); $buf = ''; $n = strlen($sql); $i = 0; + while ($i < $n) { + $ch = $sql[$i]; + if ($ch === "'" || $ch === '"' || $ch === '`') { + if ($buf !== '') { $segs[] = array(false, $buf); $buf = ''; } + $start = $i; $q = $ch; $i++; + while ($i < $n) { + if ($sql[$i] === '\\' && $q !== '`') { $i += 2; continue; } + if ($sql[$i] === $q) { + if ($i + 1 < $n && $sql[$i + 1] === $q) { $i += 2; continue; } + $i++; break; + } + $i++; + } + $segs[] = array(true, substr($sql, $start, $i - $start)); + } else { + $buf .= $ch; $i++; + } + } + if ($buf !== '') $segs[] = array(false, $buf); + return $segs; + } +} + +if (!function_exists('sqlMySqlMapLiterals')) { + /** 只对非字面量片段跑 $fn(回调接收一段 SQL 结构文本,返回替换后文本)。 */ + function sqlMySqlMapLiterals($sql, $fn) { + $out = ''; + foreach (sqlMySqlSplitSegments($sql) as $seg) { + $out .= $seg[0] ? $seg[1] : call_user_func($fn, $seg[1]); + } + return $out; + } +} + +if (!function_exists('sqlMySqlSplitTop')) { + /** 按顶层逗号切分(尊重括号与字符串)—— CREATE TABLE 列定义、索引列清单都用它。 */ + function sqlMySqlSplitTop($s) { + $parts = array(); $depth = 0; $cur = ''; $n = strlen($s); $i = 0; $in = null; + while ($i < $n) { + $ch = $s[$i]; + if ($in !== null) { + $cur .= $ch; + if ($ch === $in) { + if ($in !== '`' && $i + 1 < $n && $s[$i + 1] === $in) { $cur .= $s[$i + 1]; $i += 2; continue; } + $in = null; + } + $i++; continue; + } + if ($ch === "'" || $ch === '"' || $ch === '`') { $in = $ch; $cur .= $ch; $i++; continue; } + if ($ch === '(') $depth++; + elseif ($ch === ')') $depth--; + if ($ch === ',' && $depth === 0) { $parts[] = $cur; $cur = ''; $i++; continue; } + $cur .= $ch; $i++; + } + if (trim($cur) !== '') $parts[] = $cur; + return $parts; + } +} + +if (!function_exists('sqlMySqlReservedCols')) { + /** 撞 MySQL 保留字的列名清单(命中就加反引号;反引号对非保留字也无害,故宁多勿漏)。 */ + function sqlMySqlReservedCols() { + static $r = null; + if ($r === null) { + $r = array_flip(array( + 'key', 'keys', 'desc', 'rank', 'rows', 'groups', 'group', 'system', 'systems', + 'column', 'columns', 'data', 'index', 'fulltext', 'usage', 'read', 'write', + 'lock', 'mode', 'zones', 'zone', 'sonic', 'sound', 'equal', 'empty', 'begin', + 'end', 'top', 'level', 'speed', 'engine', 'gravity', 'start', 'stop', 'kill', + 'apply', 'disable', 'enable', 'master', 'second', 'seconds', 'minute', 'hour', + 'day', 'month', 'year', 'year_month', 'current', 'shared', 'specific', 'tables', + )); + } + return $r; + } +} + +if (!function_exists('sqlMySqlQuoteKeyCol')) { + /** + * web_area_config 的列名叫 key(MySQL 保留字),DML 里裸写会 1064。 + * 只对字符串字面量之外、且不是 primary/foreign/unique key 的裸 key 加反引号。 + * :key 绑定符被 (?token=>[0基位置...], 'count'=>占位符总数] + */ + function sqlMySqlNormalize($sql) { + $map = array('named' => array(), 'count' => 0); + $out = ''; $pos = 0; + foreach (sqlMySqlSplitSegments($sql) as $seg) { + if ($seg[0]) { $out .= $seg[1]; continue; } + $s = $seg[1]; $len = strlen($s); $buf = ''; $i = 0; + while ($i < $len) { + $ch = $s[$i]; + if ($ch === '?') { $buf .= '?'; $pos++; $i++; continue; } + if ($ch === ':') { + if ($i + 1 < $len && $s[$i + 1] !== ':' && preg_match('/[A-Za-z_]/', $s[$i + 1])) { + if (preg_match('/\G[A-Za-z_]\w*/', $s, $mm, 0, $i + 1)) { + $tok = ':' . $mm[0]; + if (!isset($map['named'][$tok])) $map['named'][$tok] = array(); + $map['named'][$tok][] = $pos; + $buf .= '?'; $pos++; $i += 1 + strlen($mm[0]); continue; + } + } + $buf .= $ch; $i++; continue; + } + if ($ch === '$' && $i + 1 < $len) { + if (preg_match('/\G\$[A-Za-z_]\w*/', $s, $mm, 0, $i) + || preg_match('/\G\$\d+/', $s, $mm, 0, $i)) { + $tok = $mm[0]; + if (!isset($map['named'][$tok])) $map['named'][$tok] = array(); + $map['named'][$tok][] = $pos; + $buf .= '?'; $pos++; $i += strlen($tok); continue; + } + } + $buf .= $ch; $i++; + } + $out .= $buf; + } + $map['count'] = $pos; + return array($out, $map); + } +} + +if (!function_exists('sqlMySqlRenderValue')) { + /** 单个绑定值 → SQL 字面量(走连接的 real_escape_string,连接已 SET NAMES utf8mb4)。 */ + function sqlMySqlRenderValue($v, $type, $esc) { + if ($v === null) return 'NULL'; + if ($type === SQLITE3_NULL) return 'NULL'; + if ($type === SQLITE3_INTEGER) { + return is_scalar($v) ? (string)(int)$v : 'NULL'; + } + if ($type === SQLITE3_FLOAT) { + if (!is_scalar($v)) return 'NULL'; + $f = (float)$v; + if (is_nan($f) || is_infinite($f)) return 'NULL'; + return rtrim(rtrim(sprintf('%.17G', $f), '0'), '.'); + } + if ($type === SQLITE3_BLOB) { return "x'" . bin2hex((string)$v) . "'"; } + $s = (string)$v; + return "'" . call_user_func($esc, $s) . "'"; + } +} + +if (!function_exists('sqlMySqlRender')) { + /** 把归一后的 SQL 里的第 n 个 ? 换成第 n 个绑定值(字面量之外的 ? 才算占位符)。 */ + function sqlMySqlRender($mysqlSql, $vals, $types, $esc) { + $out = ''; $n = 0; + foreach (sqlMySqlSplitSegments($mysqlSql) as $seg) { + if ($seg[0]) { $out .= $seg[1]; continue; } + $s = $seg[1]; $len = strlen($s); + for ($k = 0; $k < $len; $k++) { + if ($s[$k] === '?') { + $v = array_key_exists($n, $vals) ? $vals[$n] : null; // 未绑定 = NULL(与 SQLite 一致) + $t = array_key_exists($n, $types) ? $types[$n] : SQLITE3_TEXT; + $out .= sqlMySqlRenderValue($v, $t, $esc); + $n++; + } else { + $out .= $s[$k]; } } } - if (is_dir($dbDir) && !is_writable($dbDir)) { - @chmod($dbDir, 0777); + return $out; + } +} + +if (!function_exists('sqlMySqlColumnPart')) { + /** + * CREATE TABLE 里的一段(一列 或 一条表级约束)→ MySQL 版。 + * 变换规则(对齐 SQLite 语义): + * TEXT 作键(PRIMARY KEY / UNIQUE,含表级约束里的引用)→ VARCHAR(长度按索引字节预算算) + * TEXT 带 DEFAULT → 8.0.13+ 写成 DEFAULT ('x');MariaDB 10.2+ 原样;老 MySQL 退化为 VARCHAR + * INTEGER → BIGINT(SQLite 的 INTEGER 是 64 位,毫秒时间戳塞进 INT 会溢出) + * INTEGER PRIMARY KEY 且无 DEFAULT → 补 AUTO_INCREMENT(SQLite 行号语义) + * AUTO_INCREMENT 列带 DEFAULT → 去掉 DEFAULT(MySQL 1067 非法) + * 撞保留字的列名 → 反引号 + * + * @param string $part 一段 DDL + * @param array $opt ['textDefaultMode'=>..., 'keyLen'=>array(列名=>VARCHAR长度)] + */ + function sqlMySqlColumnPart($part, $opt = array()) { + $t = trim($part); + if ($t === '') return $part; + $keyLen = isset($opt['keyLen']) && is_array($opt['keyLen']) ? $opt['keyLen'] : array(); + if (preg_match('/^(PRIMARY\s+KEY|UNIQUE)\b/i', $t)) { + // 表级键约束:只给列清单里的列名加反引号(UNIQUE/PRIMARY/KEY 这些关键字必须保持裸写) + if (!preg_match('/^(PRIMARY\s+KEY|UNIQUE)\s*\(([\s\S]*)\)\s*$/i', $t, $m)) return $t; + $cols = array(); + foreach (sqlMySqlSplitTop($m[2]) as $c) { + $c = trim($c); + $cols[] = preg_match('/^([`\"]?)(\w+)\1$/', $c, $mm) ? '`' . $mm[2] . '`' : $c; + } + return strtoupper(substr($m[1], 0, 1)) . substr($m[1], 1) . '(' . implode(',', $cols) . ')'; } - // ★ 同时修复WAL/SHM文件权限 - foreach (['-wal', '-shm'] as $suffix) { - $sideFile = DB_PATH . $suffix; - if (file_exists($sideFile) && !is_writable($sideFile)) { - @chmod($sideFile, 0666); + if (preg_match('/^(KEY|INDEX|CHECK|CONSTRAINT|FOREIGN)\b/i', $t)) { + return $t; // 表级非键约束:CHECK/CONSTRAINT/FOREIGN 本仓不用,原样保留 + } + if (!preg_match('/^([`\"]?)(\w+)\1\s+(\w+)([\s\S]*)$/', $t, $m)) return $t; + $name = $m[2]; + $type = strtoupper($m[3]); + $rest = $m[4]; + $pk = (bool)preg_match('/\bPRIMARY\s+KEY\b/i', $rest); + $uniq = (bool)preg_match('/\bUNIQUE\b/i', $rest); + // ★ 被表级 PRIMARY KEY(...) / UNIQUE(...) 引用的列同样是"键列", + // 但它自己的定义里没有 PRIMARY KEY/UNIQUE 字样,只能靠 sqlMySqlPlanKeyLengths + // 预算出来的 keyLen 反查判定 —— 漏了这一步就是 1170(BLOB/TEXT used in key)。 + $inTableKey = isset($keyLen[strtolower($name)]); + $keyed = $pk || $uniq || $inTableKey; + $hasDefault = (bool)preg_match('/\bDEFAULT\b/i', $rest); + $mode = isset($opt['textDefaultMode']) ? $opt['textDefaultMode'] : 'expr'; + + if ($type === 'TEXT' || $type === 'TINYTEXT' || $type === 'MEDIUMTEXT' || $type === 'LONGTEXT' + || $type === 'BLOB' || $type === 'TINYBLOB' || $type === 'MEDIUMBLOB' || $type === 'LONGBLOB') { + $isText = ($type !== 'BLOB' && $type !== 'TINYBLOB' && $type !== 'MEDIUMBLOB' && $type !== 'LONGBLOB'); + if ($keyed) { + // 键列长度由表级预算统一决定(单列 500 / 多列按 3072 字节均分并留余量) + $len = $inTableKey ? (int)$keyLen[strtolower($name)] : 500; + if ($len < 1) $len = 1; + if ($len > 500) $len = 500; + $type = $isText ? 'VARCHAR(' . $len . ')' : 'VARBINARY(' . $len . ')'; + // 键列的 DEFAULT 一律去掉(VARCHAR 带默认值没问题,但 PRIMARY KEY 列带默认值在 + // MySQL 里语义混乱,且 SQLite 的 PK 列默认值毫无意义)—— 只在 PK 时去 + if ($pk) { + $rest = preg_replace('/\s*\bDEFAULT\s+(?:\'(?:[^\']|\'\')*\'|"(?:[^"]|"")*"|[^\s,]+)/i', '', $rest); + $hasDefault = false; + } + } elseif ($hasDefault) { + if ($mode === 'expr') { + // MySQL ≥8.0.13:字面量默认值必须包在括号里才算表达式 + $rest = preg_replace_callback( + '/\bDEFAULT\s+(\'(?:[^\']|\'\')*\'|"(?:[^"]|"")*"|NULL|\d+(?:\.\d+)?)/i', + function ($mm) { + $lit = $mm[1]; + if (strcasecmp($lit, 'NULL') === 0) return ''; + return 'DEFAULT (' . $lit . ')'; + }, + $rest, 1 + ); + } elseif ($mode === 'none') { + // 老 MySQL(<8.0.13):TEXT 一律不能有默认值 → 有默认值的列退化成 VARCHAR(500) + // (严格模式下超长会**报错**而不是静默截断,宁可大声失败) + $type = 'VARCHAR(500)'; + } + // mode=plain(MariaDB ≥10.2):TEXT DEFAULT 'x' 本来就合法,原样保留 + } + } elseif ($type === 'INTEGER' || $type === 'INT') { + $type = 'BIGINT'; + } elseif ($type === 'REAL') { + $type = 'DOUBLE'; + } + + if (preg_match('/\bAUTO_INCREMENT\b/i', $rest)) { + $rest = preg_replace('/\s*\bDEFAULT\s+(?:\'(?:[^\']|\'\')*\'|"(?:[^"]|"")*"|[^\s,]+)/i', '', $rest); + } elseif ($pk && !$hasDefault && ($type === 'BIGINT' || $type === 'INT')) { + $rest = rtrim($rest) . ' AUTO_INCREMENT'; // SQLite 的 INTEGER PRIMARY KEY = 行号自增 + } + + $reserved = sqlMySqlReservedCols(); + $name = isset($reserved[strtolower($name)]) ? '`' . $name . '`' : '`' . $name . '`'; // 统一加反引号 + + return $name . ' ' . $type . $rest; + } +} + +if (!function_exists('sqlMySqlPlanKeyLengths')) { + /** + * 预算表级键约束里各 TEXT 键列该用多长的 VARCHAR。 + * InnoDB 单索引键上限 3072 字节(DYNAMIC 行格式),utf8mb4 每字符最多 4 字节, + * 每列还要 2 字节变长长度前缀 → 可用字符预算 = (3072 - 固定宽列字节 - 2*n) / 4。 + * 单 TEXT 键列 → 500(远在预算内) + * 多个 TEXT 键列 → 均分可用预算,并留 15% 余量(实测 384×2 恰好 3072 会 1071) + * 返回 array(列名小写 => 长度)。 + */ + function sqlMySqlPlanKeyLengths($parts) { + // 先收列定义 + $types = array(); + $tblKeys = array(); + foreach ($parts as $part) { + $t = trim($part); + if ($t === '') continue; + if (preg_match('/^(PRIMARY\s+KEY|UNIQUE)\b/i', $t)) { + if (preg_match('/^(PRIMARY\s+KEY|UNIQUE)\s*\(([\s\S]*)\)\s*$/i', $t, $m)) { + $names = array(); + foreach (sqlMySqlSplitTop($m[2]) as $c) { + $c = trim($c); + if (preg_match('/^([`\"]?)(\w+)\1$/', $c, $mm)) $names[] = $mm[2]; + } + if ($names) $tblKeys[] = $names; + } + continue; + } + if (preg_match('/^([`\"]?)(\w+)\1\s+([A-Za-z]+)/', $t, $m)) { + $rest = substr($t, strlen($m[0])); + $types[strtolower($m[2])] = array( + 'type' => strtoupper($m[3]), + 'keyed' => (bool)(preg_match('/\bPRIMARY\s+KEY\b/i', $rest) || preg_match('/\bUNIQUE\b/i', $rest)), + ); + } + } + $isTextish = function ($ty) { + return in_array($ty, array('TEXT', 'BLOB', 'TINYTEXT', 'MEDIUMTEXT', 'LONGTEXT', + 'TINYBLOB', 'MEDIUMBLOB', 'LONGBLOB'), true); + }; + $fixed = array('INTEGER' => 8, 'INT' => 8, 'BIGINT' => 8, 'SMALLINT' => 2, 'TINYINT' => 1, + 'REAL' => 8, 'DOUBLE' => 8, 'FLOAT' => 4, 'BOOLEAN' => 1); + $out = array(); + + // ① 表级键约束:逐个独立预算(每个键都是独立索引,不共享 3072) + foreach ($tblKeys as $names) { + $txt = array(); $fx = 0; + foreach ($names as $cn) { + $k = strtolower($cn); + if (isset($types[$k])) { + if ($isTextish($types[$k]['type'])) { $txt[] = $k; continue; } + $fx += isset($fixed[$types[$k]['type']]) ? $fixed[$types[$k]['type']] : 8; + } else { + $fx += 8; + } + } + $n = count($txt); + if ($n === 0) continue; + if ($n === 1) { if (!isset($out[$txt[0]])) $out[$txt[0]] = 500; continue; } + $avail = 3072 - $fx - 2 * $n; // 扣掉每列 2 字节长度前缀 + $per = (int)floor($avail / 4 / $n * 0.85); // 再留 15% 余量 + if ($per > 500) $per = 500; + if ($per < 32) $per = 32; + foreach ($txt as $k) { + $cur = isset($out[$k]) ? $out[$k] : 500; + $out[$k] = min($cur, $per); // 多处引用同一列时取最严的 + } + } + // ② 内联单列键(PRIMARY KEY / UNIQUE 写在列定义里) + foreach ($types as $cn => $info) { + if ($info['keyed'] && $isTextish($info['type']) && !isset($out[$cn])) $out[$cn] = 500; + } + return $out; + } +} + +if (!function_exists('sqlMySqlTranslateCreateTable')) { + /** CREATE TABLE 全句翻译:换掉列定义 + 补表选项(InnoDB/utf8mb4_bin 与 SQLite 语义对齐)。 */ + function sqlMySqlTranslateCreateTable($sql, $opt = array()) { + if (!preg_match('/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`\"]?)(\w+)\1\s*\(/i', $sql, $m, PREG_OFFSET_CAPTURE)) { + return $sql; + } + $openPos = $m[0][1] + strlen($m[0][0]); + $depth = 1; $i = $openPos; $len = strlen($sql); $in = null; + for (; $i < $len; $i++) { + $ch = $sql[$i]; + if ($in !== null) { + if ($ch === $in) { + if ($in !== '`' && $i + 1 < $len && $sql[$i + 1] === $in) { $i++; continue; } + $in = null; + } + continue; + } + if ($ch === "'" || $ch === '"' || $ch === '`') { $in = $ch; continue; } + if ($ch === '(') $depth++; + elseif ($ch === ')') { $depth--; if ($depth === 0) break; } + } + if ($depth !== 0) return $sql; + $body = substr($sql, $openPos, $i - $openPos); + $tail = substr($sql, $i + 1); + + $parts = sqlMySqlSplitTop($body); + // 先算表级键里各 TEXT 键列的 VARCHAR 长度(3072 字节索引上限均分),再逐段翻译 + $opt['keyLen'] = sqlMySqlPlanKeyLengths($parts); + $out = array(); + foreach ($parts as $part) { + $out[] = sqlMySqlColumnPart($part, $opt); + } + $head = substr($sql, 0, $openPos); + $tail = trim($tail); + $tail = preg_replace('/^;\s*/', '', $tail); + if (preg_match('/\bENGINE\s*=/i', $tail)) { + return $head . implode(',', $out) . ') ' . $tail; + } + return $head . implode(',', $out) . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ' . $tail; + } +} + +if (!function_exists('sqlMySqlTranslateAlterTable')) { + /** ALTER TABLE:只翻 ADD COLUMN 的列定义(ALTER ... RENAME / DROP COLUMN 原样可用)。 */ + function sqlMySqlTranslateAlterTable($sql, $opt = array()) { + if (!preg_match('/^(.*?\bALTER\s+TABLE\s+[`\"]?\w+[`\"]?\s+(?:ADD|CHANGE|MODIFY)\s+(?:COLUMN\s+)?)([\s\S]*)$/i', $sql, $m)) { + return $sql; + } + $head = $m[1]; $defs = trim($m[2]); + $out = array(); + foreach (sqlMySqlSplitTop($defs) as $part) { + $out[] = sqlMySqlColumnPart($part, $opt); + } + return $head . implode(',', $out); + } +} + +if (!function_exists('sqlDialectMySql')) { + /** + * SQLite → MySQL 方言翻译器。纯函数:不碰连接、不查库,输入什么语句输出什么语句。 + * 覆盖面按全仓实测:PRAGMA 21 种 / sqlite_master 13 形态 / AUTOINCREMENT 65 处 / + * INSERT OR REPLACE 73 处 / INSERT OR IGNORE 19 处 / ON CONFLICT 15 处 / + * CREATE INDEX IF NOT EXISTS 27 处 / TEXT 主键 22 处 / TEXT DEFAULT 73 处 / key 保留字 1 处。 + * `||` 拼接(SQL 内 14 处)不在这里翻:连库时把 sql_mode 设成 PIPES_AS_CONCAT 一次性解决, + * 逐处翻成 CONCAT 在没有完整表达式解析器的情况下极易翻错。 + * + * @param string $sql + * @param array $opt ['textDefaultMode' => 'expr'|'plain'|'none'] + * @return string SDF_SQL_NOOP / SDF_SQL_SHOWCREATE表名 / 翻译后的 MySQL 语句 + */ + function sqlDialectMySql($sql, $opt = array()) { + $sql = (string)$sql; + if ($sql === '') return SDF_SQL_NOOP; + $trim = ltrim($sql); + + // ---------- 1. PRAGMA ---------- + if (preg_match('/^PRAGMA\s+table_info\s*\(\s*[\'"]?(\w+)[\'"]?\s*\)\s*;?\s*$/is', $trim, $m)) { + $t = str_replace("'", "''", $m[1]); + // ★ 列顺序必须与 SQLite 的 PRAGMA table_info 完全一致(cid, name, type, notnull, dflt_value, pk), + // 否则 SQLITE3_NUM 取值形态会整体错位(消费点 core.php:1023 一类按 $row['name'] 取值则不受影响)。 + // cid/notnull/pk 一律 CAST 成整数:MySQL 表达式默认给字符串,原生给 int(=== 对拍会挂)。 + return "SELECT CAST(ORDINAL_POSITION - 1 AS SIGNED) AS cid," + . " COLUMN_NAME AS `name`," + . " COLUMN_TYPE AS `type`," + . " CAST(IF(IS_NULLABLE='NO' AND COLUMN_KEY<>'PRI',1,0) AS SIGNED) AS notnull," + . " COLUMN_DEFAULT AS dflt_value," + . " CAST(IF(COLUMN_KEY='PRI',1,0) AS SIGNED) AS pk" + . " FROM information_schema.COLUMNS" + . " WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='" . $t . "'" + . " ORDER BY ORDINAL_POSITION"; + } + if (preg_match('/^PRAGMA\b/i', $trim)) { + return SDF_SQL_NOOP; // journal_mode / busy_timeout / synchronous / cache_size / wal_* 全是 SQLite 专有调优项 + } + + // ---------- 2. sqlite_master ---------- + if (stripos($sql, 'sqlite_master') !== false) { + // 取建表文本(core.php 用它检测老表有没有 UNIQUE 约束)→ SHOW CREATE TABLE 替身 + if (preg_match('/SELECT\s+sql\s+FROM\s+sqlite_master\s+WHERE[\s\S]*?name\s*=\s*\'([^\']+)\'/i', $sql, $m)) { + return SDF_SQL_SHOWCREATE . $m[1]; + } + // 表存在性 / 表名清单 → information_schema + if (preg_match('/SELECT\s+(?:name|1)\s+FROM\s+sqlite_master\s+WHERE\s+type\s*=\s*\'table\'([\s\S]*?)(?:;|$)/i', $sql, $m)) { + if (preg_match('/\bname\s*=\s*\'([^\']+)\'/i', $m[1], $mm)) { + $cond = "TABLE_SCHEMA=DATABASE() AND TABLE_NAME='" . str_replace("'", "''", $mm[1]) . "'"; + } else { + $cond = "TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE'"; + } + return "SELECT TABLE_NAME AS `name` FROM information_schema.TABLES WHERE " . $cond; + } + // 剩余形态(带 sql 列的全量扫描等)只在 SQLite 文件切分流程里出现; + // 原样返回让它报错并进日志,比静默改错安全。 + return $sql; + } + + // ---------- 3. 事务 ---------- + if (preg_match('/^BEGIN\b/i', $trim)) { + // MySQL 只认 BEGIN [WORK] / START TRANSACTION;BEGIN TRANSACTION / BEGIN IMMEDIATE 都是 1064 + $sql = preg_replace('/^(\s*)BEGIN\b(\s+(?:TRANSACTION|IMMEDIATE|DEFERRED))?(\s+WORK)?\b/i', '$1START TRANSACTION', $sql, 1); + } elseif (preg_match('/^\s*END\s*;?\s*$/i', $sql)) { + $sql = 'COMMIT'; // SQLite 的 END = 提交;MySQL 的 END 是存储程序块结束符 + } + + // ---------- 4. 插入冲突语义 ---------- + $sql = preg_replace('/\bINSERT\s+OR\s+REPLACE\s+INTO\b/i', 'REPLACE INTO', $sql); + $sql = preg_replace('/\bINSERT\s+OR\s+IGNORE\s+INTO\b/i', 'INSERT IGNORE INTO', $sql); + $sql = preg_replace('/\bINSERT\s+OR\s+(?:FAIL|ABORT|ROLLBACK)\s+INTO\b/i', 'INSERT INTO', $sql); + if (preg_match('/\bON\s+CONFLICT\s*\(/i', $sql)) { + $sql = preg_replace('/\bON\s+CONFLICT\s*\([^)]*\)\s*DO\s+UPDATE\s+SET\b/i', 'ON DUPLICATE KEY UPDATE', $sql); + // excluded.x = 新行的值;MySQL 里等价物是 VALUES(x) + $sql = preg_replace('/\bexcluded\s*\.\s*([`\"]?\w+[`\"]?)/i', 'VALUES($1)', $sql); + $sql = preg_replace('/\bON\s+CONFLICT\s*\([^)]*\)\s*DO\s+NOTHING\b/i', '', $sql); // 全仓 0 处,兜底 + } + + // ---------- 5. AUTOINCREMENT(拼写差异) ---------- + $sql = preg_replace('/\bAUTOINCREMENT\b/i', 'AUTO_INCREMENT', $sql); + + // ---------- 6. CREATE INDEX:MySQL 不支持 IF NOT EXISTS(重复索引由适配器吞 1061) ---------- + if (preg_match('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/i', $trim)) { + $sql = preg_replace('/^(CREATE\s+(?:UNIQUE\s+)?INDEX\s+)IF\s+NOT\s+EXISTS\s+/i', '$1', $sql); + $sql = ltrim($sql); + } + + // ---------- 7. DDL ---------- + if (preg_match('/^CREATE\s+TABLE\b/i', $trim)) { + $sql = sqlMySqlTranslateCreateTable($sql, $opt); + } elseif (preg_match('/^ALTER\s+TABLE\b/i', $trim)) { + $sql = sqlMySqlTranslateAlterTable($sql, $opt); + } + + // ---------- 8. 时间 / 随机函数 ---------- + $sql = preg_replace("/\bdatetime\s*\(\s*([^,()]+?)\s*,\s*'unixepoch'\s*,\s*'localtime'\s*\)/i", 'FROM_UNIXTIME($1)', $sql); + $sql = preg_replace("/\bdatetime\s*\(\s*([^,()]+?)\s*,\s*'unixepoch'\s*\)/i", 'FROM_UNIXTIME($1)', $sql); + $sql = preg_replace("/\bdatetime\s*\(\s*'now'\s*\)/i", 'UTC_TIMESTAMP()', $sql); + $sql = preg_replace("/\bdate\s*\(\s*'now'\s*\)/i", 'CURDATE()', $sql); + $sql = preg_replace("/\btime\s*\(\s*'now'\s*\)/i", 'CURTIME()', $sql); + $sql = preg_replace("/\bstrftime\s*\(\s*'%s'\s*,\s*'now'\s*\)/i", 'UNIX_TIMESTAMP()', $sql); + $sql = preg_replace('/\bRANDOM\s*\(\s*\)/i', 'RAND()', $sql); + + // ---------- 9. web_area_config 的 key 列(唯一撞保留字的列) ---------- + if (preg_match('/\bweb_area_config\b/i', $sql)) { + $sql = sqlMySqlQuoteKeyCol($sql); + } + + // ---------- 10. 占位符归一 :name → ? ---------- + $sql = sqlMySqlNormalize($sql)[0]; + return $sql; + } +} + +if (!class_exists('SdfMySqlResult', false)) { + /** SQLite3Result 同形:fetchArray(SQLITE3_ASSOC|SQLITE3_NUM) / finalize / numColumns。 */ + class SdfMySqlResult { + private $res; // mysqli_result | null + private $rows = null; // 数组后备(SHOW CREATE TABLE 这类需要改列名的结果) + private $idx = 0; + private $done = false; + + public function __construct($res, $rows = null) { $this->res = $res; $this->rows = $rows; } + + public function fetchArray($mode = SQLITE3_ASSOC) { + if ($this->rows !== null) { + if ($this->done || !isset($this->rows[$this->idx])) { $this->done = true; return false; } + $row = $this->rows[$this->idx++]; + return ($mode === SQLITE3_NUM) ? array_values($row) : $row; + } + if ($this->res === null || $this->done) return false; + $row = ($mode === SQLITE3_NUM) ? $this->res->fetch_row() : $this->res->fetch_assoc(); + if ($row === null || $row === false) { $this->done = true; return false; } + return $row; + } + + public function numColumns() { + if ($this->rows !== null) { $k = array_keys($this->rows); return $k ? count($this->rows[0]) : 0; } + return ($this->res instanceof mysqli_result) ? (int)$this->res->field_count : 0; + } + + public function finalize() { $this->free(); return true; } + public function free() { + if ($this->res instanceof mysqli_result) { @$this->res->free(); } + $this->res = null; $this->rows = null; $this->done = true; + return true; + } + public function close() { return $this->free(); } + public function __destruct() { $this->free(); } + public function __call($m, $a) { throw new Exception('SdfMySqlResult::' . $m . '() 未实现'); } + } +} + +if (!class_exists('SdfMySqlStmt', false)) { + /** + * SQLite3Stmt 同形:bindValue(:name / $1 / 1) + execute()。 + * 实现选择「客户端渲染」而非 mysqli 预处理:占位符位置表由 sqlMySqlNormalize 给出, + * 值经连接的 real_escape_string 转义后原位替换。这样 + * ① 不受 mysqli bind_param 引用/类型串/重复 execute 限制; + * ② 未绑定的参数按 NULL 渲染,与 SQLite3 行为一致; + * ③ 重绑重跑零成本(while 循环里反复 bind+execute 的写法全仓都有)。 + * 安全前提:连接已 SET NAMES utf8mb4(否则多字节注入),构造函数里强制设置。 + */ + class SdfMySqlStmt { + private $db; + private $sql; // 归一后的 MySQL 语句 + private $map; // ['named'=>..., 'count'=>...] + private $vals = array(); + private $types = array(); + + public function __construct($db, $sql, $map) { + $this->db = $db; $this->sql = $sql; $this->map = $map; + } + + /** SQLite 的参数序号是 1 基的;命名参数按其在语句里首次出现的位置编号。 */ + private function resolve($key) { + if (is_int($key) || (is_string($key) && ctype_digit($key))) { + $p = (int)$key - 1; + if ($p < 0 || $p >= (int)$this->map['count']) return null; + return array($p); + } + $k = (string)$key; + if (isset($this->map['named'][$k])) return $this->map['named'][$k]; + if (strncmp($k, ':', 1) === 0 && isset($this->map['named'][':' . substr($k, 1)])) { + return $this->map['named'][':' . substr($k, 1)]; + } + if (isset($this->map['named'][':' . $k])) return $this->map['named'][':' . $k]; + return null; + } + + public function bindValue($key, $value, $type = SQLITE3_TEXT) { + $p = $this->resolve($key); + if ($p === null) return false; + foreach ($p as $i) { $this->vals[$i] = $value; $this->types[$i] = $type; } + return true; + } + + public function bindParam($key, &$ref, $type = SQLITE3_TEXT) { + return $this->bindValue($key, $ref, $type); // 渲染式实现里按值取即可 + } + + public function bind($key, $value, $type = SQLITE3_TEXT) { return $this->bindValue($key, $value, $type); } + + public function execute() { return $this->db->stmtExecute($this->sql, $this->vals, $this->types); } + + public function close() { $this->vals = array(); $this->types = array(); return true; } + public function reset() { $this->vals = array(); $this->types = array(); return true; } + public function finalize() { return $this->close(); } + public function __destruct() { $this->close(); } + public function __call($m, $a) { throw new Exception('SdfMySqlStmt::' . $m . '() 未实现'); } + } +} + +if (!class_exists('SdfMySqlDb', false)) { + /** + * mysqli 封装,对外暴露 SQLite3 同形 API: + * exec / query / prepare / querySingle / lastInsertRowID / changes / escapeString / + * busyTimeout / enableExceptions / lastErrorMsg / close + * 错误模型对齐 SQLite3:enableExceptions(true)(dbOpen 的默认)时抛异常,false 时返回 false。 + * close() 刻意做成 no-op —— 连接由 dbOpen 的 static 缓存共享,业务代码习惯性 close 不该把它掐断。 + */ + class SdfMySqlDb { + private $m; + private $exc = true; + private $lastError = ''; + private $lastErrno = 0; + private $opt = array('textDefaultMode' => 'expr'); + private $colCache = array(); + private $colLoaded = false; + private $side = ''; + + public function __construct($cfg, $side = '') { + $this->side = $side; + if (!function_exists('mysqli_connect')) { + throw new Exception('mysqli 扩展未安装,无法使用 MySQL 模式'); + } + if (function_exists('mysqli_report')) { mysqli_report(MYSQLI_REPORT_OFF); } + $port = isset($cfg['port']) ? (int)$cfg['port'] : 3306; + $this->m = @mysqli_connect($cfg['host'], $cfg['user'], $cfg['pass'], $cfg['name'], $port); + if (!$this->m) { + throw new Exception('MySQL 连接失败[' . $cfg['host'] . ':' . $port . ']: ' . mysqli_connect_error()); + } + mysqli_set_charset($this->m, 'utf8mb4'); // ★ 必须在 real_escape_string 之前,否则转义不安全 + + // ★ 让 fetch 出来的整型/浮点列直接以 PHP int/float 返回,而不是字符串。 + // 不开的话 SELECT COUNT(*) / CAST(x AS SIGNED) 全是 string,业务代码里 + // `(int)$n` 之外的用法(如 $x === 1、array_sum、json 数字输出)会静默走字符串语义, + // 与 SQLite3 原生(返回 int)不一致 —— 这是跨库切换最容易踩的暗坑。 + if (defined('MYSQLI_OPT_INT_AND_FLOAT_NATIVE')) { + @mysqli_options($this->m, MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true); + } + + // 语义对齐 SQLite: + // PIPES_AS_CONCAT —— || 变字符串拼接(SQL 内 14 处,逐处翻 CONCAT 风险更大) + // 去掉 ONLY_FULL_GROUP_BY —— SQLite 允许 SELECT 非聚合列 + // 保留 STRICT_TRANS_TABLES —— 类型/非空错误像 SQLite 一样当场炸,不静默截断 + @mysqli_query($this->m, "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,PIPES_AS_CONCAT'"); + @mysqli_query($this->m, "SET NAMES utf8mb4"); + @mysqli_query($this->m, 'SET SESSION time_zone = \'+00:00\''); // 与 SQLite 的 UTC 'now' 对齐 + + $this->detectVersion(); + } + + /** 判定 TEXT 默认值的写法:MySQL≥8.0.13 用括号表达式 / MariaDB≥10.2 原样 / 更老的退化 VARCHAR。 */ + private function detectVersion() { + $mode = 'none'; + $r = @mysqli_query($this->m, 'SELECT VERSION()'); + if ($r) { + $row = $r->fetch_row(); $r->free(); + $v = isset($row[0]) ? (string)$row[0] : ''; + if ($v !== '') { + if (stripos($v, 'mariadb') !== false) { + if (preg_match('/(\d+)\.(\d+)\.(\d+)/', $v, $mm)) { + $mode = ((int)$mm[1] > 10 || ((int)$mm[1] === 10 && (int)$mm[2] >= 2)) ? 'plain' : 'none'; + } else { $mode = 'plain'; } + } elseif (preg_match('/(\d+)\.(\d+)\.(\d+)/', $v, $mm)) { + $big = (int)$mm[1] * 10000 + (int)$mm[2] * 100 + (int)$mm[3]; + $mode = ($big >= 80013) ? 'expr' : 'none'; + } + } + } + $this->opt['textDefaultMode'] = $mode; + } + + // ---------------- 方言层 ---------------- + + private function translate($sql) { return sqlDialectMySql($sql, $this->opt); } + + private function showCreateSql($t) { + $name = substr($t, strlen(SDF_SQL_SHOWCREATE)); + return 'SHOW CREATE TABLE `' . str_replace('`', '', $name) . '`'; + } + + /** + * SHOW CREATE TABLE 的行(name/type/sql 三列)按原 sqlite_master 查询的 SELECT 投影裁剪。 + * 例:SELECT sql FROM sqlite_master WHERE ... → 只留 ['sql'=>...], + * 否则 querySingle 取首行首列会错拿到表名。解析不了表达式投影时保底给全列。 + */ + private function projectShowCreate($origSql, $rows) { + if (!preg_match('/\bSELECT\s+([\s\S]*?)\s+FROM\s+sqlite_master\b/i', (string)$origSql, $m)) return $rows; + $list = trim($m[1]); + if ($list === '' || $list === '*' ) return $rows; + if (preg_match('/^\s*(COUNT|SUM|MAX|MIN|AVG|GROUP_CONCAT)\s*\(/i', $list)) return $rows; + $keep = array(); + foreach (sqlMySqlSplitTop($list) as $c) { + $c = trim($c); + if (!preg_match('/^([`\"]?)(\w+)\1(?:\s+AS\s+([`\"]?\w+[`\"]?))?$/i', $c, $mm)) return $rows; + $alias = (isset($mm[3]) && $mm[3] !== '') ? trim($mm[3], '`"') : $mm[2]; + $keep[] = array(strtolower($mm[2]), $alias); + } + $out = array(); + foreach ($rows as $row) { + $o = array(); + foreach ($keep as $ka) { $o[$ka[1]] = array_key_exists($ka[0], $row) ? $row[$ka[0]] : null; } + $out[] = $o; + } + return $out; + } + + /** CREATE INDEX 的 TEXT 列必须带前缀长度(否则 1170)。列类型从 information_schema 查。 */ + private function applyIndexPrefix($sql) { + if (!preg_match('/^CREATE\s+(UNIQUE\s+)?INDEX\s+([`\"]?\w+[`\"]?)\s+ON\s+([`\"]?\w+[`\"]?)\s*\(([\s\S]*?)\)\s*;?\s*$/i', $sql, $m)) { + return $sql; + } + $uniq = $m[1]; $idx = $m[2]; $tbl = trim($m[3], '`"'); $cols = $m[4]; + $out = array(); + foreach (sqlMySqlSplitTop($cols) as $c) { + $c = trim($c); + if (!preg_match('/^([`\"]?)(\w+)\1\s*((?:COLLATE\s+\S+\s*)?(?:ASC|DESC)?)$/i', $c, $mm)) { $out[] = $c; continue; } + $col = $mm[2]; + $dt = $this->colDataType($tbl, $col); + if ($dt !== null && in_array($dt, array('text', 'tinytext', 'mediumtext', 'longtext', + 'blob', 'tinyblob', 'mediumblob', 'longblob', + 'json', 'geometry', 'set', 'enum'))) { + $out[] = '`' . $col . '`(191)'; + } else { + $out[] = '`' . $col . '`' . ($mm[3] !== '' ? ' ' . $mm[3] : ''); + } + } + return 'CREATE ' . ($uniq !== '' ? 'UNIQUE ' : '') . 'INDEX ' . $idx . ' ON `' . $tbl . '` (' . implode(',', $out) . ')'; + } + + private function colDataType($table, $col) { + if (!$this->colLoaded) $this->loadColCache(); + $k = strtolower($table) . '.' . strtolower($col); + return isset($this->colCache[$k]) ? $this->colCache[$k] : null; + } + + private function loadColCache() { + $this->colCache = array(); $this->colLoaded = true; + $db = mysqli_get_charset($this->m) ? '' : ''; + $r = @mysqli_query($this->m, 'SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()'); + if ($r) { + while ($row = $r->fetch_assoc()) { + $this->colCache[strtolower($row['TABLE_NAME']) . '.' . strtolower($row['COLUMN_NAME'])] = strtolower($row['DATA_TYPE']); + } + $r->free(); } } - $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锁减少竞争 + private function invalidateColCache() { $this->colLoaded = false; $this->colCache = array(); } - // ★ initTables/migrateDatabase加try-catch,防止初始化写入失败导致整个请求500 - try { - initTables($db); - } catch (\Throwable $e) { - debugLog("getDB: initTables失败(可能数据库只读): " . $e->getMessage()); + private function noteDdl($orig) { + if (preg_match('/^\s*(CREATE\s+TABLE|ALTER\s+TABLE|DROP\s+TABLE|RENAME\s+TABLE)\b/i', (string)$orig)) { + $this->invalidateColCache(); + } } + + /** + * DDL(CREATE/ALTER/DROP INDEX|TABLE)失败时**不抛异常**,只记日志返回 false。 + * 理由:initTables 是一个事务里几十条建表语句,SQLite 侧靠 `IF NOT EXISTS` 天然幂等; + * MySQL 侧一旦某条 DDL 报错就抛异常,会把整批建表从中断在半路(实测只建成 19/66 张, + * 后面的表全部连带失败)。降级后:能建的全建好,坏的单独进日志,代码侧 try/catch 语义不变。 + */ + private function isDdl($sql) { + return (bool)preg_match('/^\s*(CREATE|ALTER|DROP|RENAME|TRUNCATE)\b/i', (string)$sql); + } + + /** 统一的错误出口:吞掉"可重试/已存在"类错误,其余按 enableExceptions 决定抛或返回 false。 */ + private function fail($where, $orig) { + $this->lastErrno = $this->m ? (int)$this->m->errno : 0; + $this->lastError = $this->m ? (string)$this->m->error : 'no connection'; + $errno = $this->lastErrno; + $o = (string)$orig; + // 我们把 CREATE INDEX 的 IF NOT EXISTS 摘掉了(MySQL 不认这个子句),重复即视为成功 + if ($errno === 1061 && preg_match('/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\b/i', $o)) { + return true; + } + // ALTER TABLE ... ADD COLUMN 的重复列(1060)与重复键名(1061):源码里本就是 try/catch 兜着, + // 这里直接当成功,否则每次请求都会往 debug.log 灌一屏噪声 + if (($errno === 1060 || $errno === 1061) && preg_match('/^\s*ALTER\s+TABLE\b/i', $o)) { + return true; + } + $msg = 'MySQL ' . $where . ' 失败 #' . $errno . ': ' . $this->lastError + . ' | SQL: ' . preg_replace('/\s+/', ' ', substr($o, 0, 300)); + debugLog('mysql[' . $this->side . ']', array('err' => $msg)); + // DDL 永不抛异常:一条建表失败不该拖垮同批剩下的表 + if ($this->exc && !$this->isDdl($o)) { throw new Exception($msg); } + return false; + } + + // ---------------- SQLite3 同形 API ---------------- + + public function exec($sql) { + if (!($this->m instanceof mysqli)) return false; + $t = $this->translate($sql); + if ($t === SDF_SQL_NOOP) return true; + if (strncmp($t, SDF_SQL_SHOWCREATE, strlen(SDF_SQL_SHOWCREATE)) === 0) { $t = $this->showCreateSql($t); } + else { $t = $this->applyIndexPrefix($t); } + $r = @mysqli_query($this->m, $t); + if ($r === false) return $this->fail('exec', $sql); + if ($r instanceof mysqli_result) $r->free(); + $this->noteDdl($sql); + return true; + } + + public function query($sql) { + if (!($this->m instanceof mysqli)) return false; + $t = $this->translate($sql); + if ($t === SDF_SQL_NOOP) return new SdfMySqlResult(null); + if (strncmp($t, SDF_SQL_SHOWCREATE, strlen(SDF_SQL_SHOWCREATE)) === 0) { + // sqlite_master 取建表文本的替身:SHOW CREATE TABLE 的列名要改写回 name/sql, + // 且必须按原查询的投影裁剪(SELECT sql ... 的消费点拿首行首列,多给 name 会错位) + $r = @mysqli_query($this->m, $this->showCreateSql($t)); + if ($r === false) { + if ((int)$this->m->errno === 1146) return new SdfMySqlResult(null, array()); // 表不存在 = SQLite 的空结果 + return $this->fail('query', $sql); + } + $rows = array(); + if ($r instanceof mysqli_result) { + while ($row = $r->fetch_assoc()) { + $rows[] = array( + 'name' => isset($row['Table']) ? $row['Table'] : null, + 'type' => 'table', + 'sql' => isset($row['Create Table']) ? $row['Create Table'] : null, + ); + } + $r->free(); + } + $rows = $this->projectShowCreate($sql, $rows); + $this->noteDdl($sql); + return new SdfMySqlResult(null, $rows); + } + $t = $this->applyIndexPrefix($t); + $r = @mysqli_query($this->m, $t); + if ($r === false) return $this->fail('query', $sql); + $this->noteDdl($sql); + if (!($r instanceof mysqli_result)) return new SdfMySqlResult(null); // UPDATE/INSERT 走 query() 也返回可 fetch 的空结果 + return new SdfMySqlResult($r); + } + + public function prepare($sql) { + // ★ 占位符位置表必须从**原始 SQL** 解析。sqlDialectMySql() 的最后一步已把 + // :name / $1 全归一成 ?,若对归一后的语句再解析,named 表会是空的 + // (只剩 ? 的计数)→ 命名绑定全部找不到位置 → 渲染成 NULL(1048 之类)。 + $ph = sqlMySqlNormalize($sql); + $t = $this->translate($sql); + if ($t === SDF_SQL_NOOP) { return new SdfMySqlStmt($this, $t, array('named' => array(), 'count' => 0)); } + if (strncmp($t, SDF_SQL_SHOWCREATE, strlen(SDF_SQL_SHOWCREATE)) === 0) { + $t = $this->showCreateSql($t); // prepare 里取建表文本:先换成可执行语句再归一 + } + // 翻译理论上不改变占位符数量;真对不上时以翻译后的实际数量为准(保底不越界) + $cnt = substr_count($this->stripLiterals($t), '?'); + if ((int)$ph[1]['count'] !== $cnt) { + debugLog('mysql[' . $this->side . ']', array('warn' => '占位符数量与原文不一致,' + . '原句 ' . (int)$ph[1]['count'] . ' 个 / 译后 ' . $cnt . ' 个', + 'sql' => substr((string)$sql, 0, 200))); + } + $ph[1]['count'] = $cnt; + return new SdfMySqlStmt($this, $t, $ph[1]); + } + + /** 统计字符串字面量之外的 ? 个数(字面量里的问号不算占位符)。 */ + private function stripLiterals($sql) { + $out = ''; + foreach (sqlMySqlSplitSegments((string)$sql) as $seg) { if (!$seg[0]) $out .= $seg[1]; } + return $out; + } + + /** 与 SQLite3::querySingle 同义:$assoc=false 取首行首列,true 取首行数组。 */ + public function querySingle($sql, $assoc = false) { + if (!($this->m instanceof mysqli)) return null; + $r = $this->query($sql); + if ($r === false || $r === null) return null; + $row = $r->fetchArray($assoc ? SQLITE3_ASSOC : SQLITE3_NUM); + $r->finalize(); + if ($row === false) return null; + if ($assoc) return $row; + $v = reset($row); + return $v === false ? null : $v; + } + + /** SdfMySqlStmt::execute() 的落点。 */ + public function stmtExecute($mysqlSql, $vals, $types) { + if (!($this->m instanceof mysqli)) return false; + if ($mysqlSql === SDF_SQL_NOOP) return new SdfMySqlResult(null); + $esc = function ($s) { return $this->m ? $this->m->real_escape_string($s) : addslashes($s); }; + $t = sqlMySqlRender($mysqlSql, $vals, $types, $esc); + $r = @mysqli_query($this->m, $t); + if ($r === false) return $this->fail('execute', $t); + $this->noteDdl($t); + if (!($r instanceof mysqli_result)) return new SdfMySqlResult(null); + return new SdfMySqlResult($r); + } + + public function lastInsertRowID() { return ($this->m instanceof mysqli) ? (int)$this->m->insert_id : 0; } + public function changes() { + if (!($this->m instanceof mysqli)) return 0; + $n = (int)$this->m->affected_rows; + return $n < 0 ? 0 : $n; + } + public function escapeString($v) { return ($this->m instanceof mysqli) ? $this->m->real_escape_string((string)$v) : addslashes((string)$v); } + public function lastErrorMsg() { return $this->lastError; } + public function lastErrorCode() { return $this->lastErrno; } + + /** busy_timeout → InnoDB 行锁等待(秒)。8000ms/15000ms 这类调用语句在 MySQL 上无害。 */ + public function busyTimeout($ms) { + if (!($this->m instanceof mysqli)) return false; + $sec = (int)ceil(((int)$ms) / 1000); + if ($sec < 1) $sec = 1; + if ($sec > 3600) $sec = 3600; // MySQL innodb_lock_wait_timeout 上限 1024s,超了会 1232,这里先收敛 + if ($sec > 1024) $sec = 1024; + $ok = @mysqli_query($this->m, 'SET SESSION innodb_lock_wait_timeout = ' . $sec); + return $ok !== false; + } + + public function enableExceptions($f = true) { $this->exc = (bool)$f; return $this->exc; } + + /** 刻意 no-op:连接由 dbOpen() 的 static 缓存共享,业务代码习惯性 close() 不该掐断它。 */ + public function close() { return true; } + + /** 供诊断脚本查看底层连接。 */ + public function getMysqli() { return $this->m; } + + public function __call($m, $a) { throw new Exception('SdfMySqlDb::' . $m . '() 未实现'); } + } +} +// ===== 数据库初始化 ===== +/** + * 打开某一侧的底层连接(SQLite 文件或 MySQL 适配器,由 sql.md 运行模式决定)。 + * ★ 建连前先跑一次 dbSplitMigrateIfNeeded():迁移必须发生在 Java.db 被创建之前, + * 否则空库一建出来就被判定"已迁移",存量数据永远搬不过去。 + */ +function dbOpen($side) { + static $cache = array(); + if (isset($cache[$side])) return $cache[$side]; + + dbSplitMigrateIfNeeded(); + + // ★ MySQL 模式(2026-10-09):mode=mysql 且该侧地址齐全 → 走适配层; + // mysqli 缺失 / 连接失败 / 任何异常都软回退 SQLite(软依赖,全站不感知)。 + $mcfg = sqlMdDbConfig(); + if (isset($mcfg['mode']) && $mcfg['mode'] === 'mysql' + && isset($mcfg[$side]) && is_array($mcfg[$side])) { try { - migrateDatabase($db); // 迁移旧数据库 + $mdb = new SdfMySqlDb($mcfg[$side], $side); + $mdb->enableExceptions(true); + $mdb->busyTimeout(8000); + try { $mdb->exec('ROLLBACK'); } catch (\Throwable $_) {} // 清上一请求残留事务 + try { initTables($mdb, $side); } catch (\Throwable $e) { + debugLog('dbOpen[' . $side . ']: initTables失败: ' . $e->getMessage()); + } + try { migrateDatabase($mdb, $side); } catch (\Throwable $e) { + debugLog('dbOpen[' . $side . ']: migrateDatabase失败: ' . $e->getMessage()); + } + $cache[$side] = $mdb; + return $mdb; } catch (\Throwable $e) { - debugLog("getDB: migrateDatabase失败: " . $e->getMessage()); + debugLog('dbOpen[' . $side . ']: MySQL 连接失败,回退 SQLite: ' . $e->getMessage()); } } - // ★ 安全网:仅在首次创建连接时清理上一次请求可能残留的未提交事务 - // (PHP-FPM/opcache复用进程时,前一个请求异常退出可能遗留BEGIN但无COMMIT/ROLLBACK) - // 注意:不能每次调用都ROLLBACK,否则会中断当前请求正在进行的事务 - static $rollbackDone = false; - if (!$rollbackDone) { + $file = str_replace('\\', '/', sqlDbFile($side)); + $dbDir = dirname($file); + if (!is_dir($dbDir)) { @mkdir($dbDir, 0755, true); } + + // 修复文件/目录/WAL 权限,防止 "attempt to write a readonly database" + if (file_exists($file) && !is_writable($file)) { + @chmod($file, 0666); + if (!is_writable($file)) { debugLog('dbOpen[' . $side . ']: 数据库文件不可写: ' . $file); } + } + if (is_dir($dbDir) && !is_writable($dbDir)) { @chmod($dbDir, 0777); } + foreach (array('-wal', '-shm') as $suffix) { + $sideFile = $file . $suffix; + if (file_exists($sideFile) && !is_writable($sideFile)) { @chmod($sideFile, 0666); } + } + + $db = new SQLite3($file); + $db->enableExceptions(true); + $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'); + + // initTables/migrateDatabase 加 try-catch,防止初始化写入失败导致整个请求 500 + try { + initTables($db, $side); + } catch (\Throwable $e) { + debugLog('dbOpen[' . $side . ']: initTables失败(可能数据库只读): ' . $e->getMessage()); + } + try { + migrateDatabase($db, $side); + } catch (\Throwable $e) { + debugLog('dbOpen[' . $side . ']: migrateDatabase失败: ' . $e->getMessage()); + } + + // 安全网:仅在首次建连时清掉上一次请求可能残留的未提交事务 + $rbKey = 'rb:' . $side; + if (empty($cache[$rbKey])) { try { $db->exec('ROLLBACK'); } catch (\Throwable $_) {} - $rollbackDone = true; + $cache[$rbKey] = true; } + $cache[$side] = $db; return $db; } +/** + * 全站唯一的数据库入口。 + * getDB() → 按表名自动路由的外观(Java 组表走 Java.db,其余走 web.db) + * getDB('java') → 直接拿 Java 专属连接 + * getDB('web') → 直接拿 PHP 专属连接 + * 调用方无需感知切分:224 处无参调用行为不变,只是语句被派到了正确的库。 + */ +function getDB($side = null) { + if ($side === 'java' || $side === 'web') return dbOpen($side); + static $router = null; + if ($router === null) { $router = new SdfDbRouter(); } + return $router; +} + // ===== 收银台订单独立库 ===== // 订单是 PHP 收银台自己的数据,与 web.db(Java 通过 sync.php 高频写入)完全无关。 // 拆到独立 SQLite 文件后,Java 永不触碰本库,根除因 web.db 文件级锁竞争导致的订单读超时。 @@ -227,38 +1495,49 @@ function fixDbPermissions() { 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: 已创建"; - } + // ★ 双库切分(2026-10-09):Java.db 与 web.db 可能在不同目录,两侧各修一遍 + foreach (array('web', 'java') as $side) { + // MySQL 模式该侧无本地文件,chmod/mkdir 无意义也无从谈起 + $mcfg = sqlMdDbConfig(); + if (isset($mcfg['mode']) && $mcfg['mode'] === 'mysql' + && isset($mcfg[$side]) && is_array($mcfg[$side])) { + $results[] = "[$side] MySQL 模式:无本地数据库文件,跳过权限修复"; + continue; + } + $dbPath = sqlDbFile($side); + $dbDir = dirname($dbPath); - // 修复数据库文件权限 - 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: 不存在(首次访问时自动创建)"; - } + // 修复目录权限 + if (is_dir($dbDir)) { + $oldPerms = substr(sprintf('%o', fileperms($dbDir)), -4); + @chmod($dbDir, 0777); + $newPerms = substr(sprintf('%o', fileperms($dbDir)), -4); + $results[] = "[$side] 目录 $dbDir: $oldPerms → $newPerms (writable=" . (is_writable($dbDir) ? 'Y' : 'N') . ")"; + } else { + @mkdir($dbDir, 0777, true); + $results[] = "[$side] 目录 $dbDir: 已创建"; + } - // 修复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"; + // 修复数据库文件权限 + if (file_exists($dbPath)) { + $oldPerms = substr(sprintf('%o', fileperms($dbPath)), -4); + @chmod($dbPath, 0666); + $newPerms = substr(sprintf('%o', fileperms($dbPath)), -4); + $results[] = "[$side] 数据库 $dbPath: $oldPerms → $newPerms (writable=" . (is_writable($dbPath) ? 'Y' : 'N') . ")"; + } else { + $results[] = "[$side] 数据库 $dbPath: 不存在(首次访问时自动创建)"; + } + + // 修复WAL/SHM文件权限 + foreach (array('-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[] = "[$side] WAL文件 $sideFile: $oldPerms → $newPerms"; + } } } @@ -293,9 +1572,10 @@ function walCheckpoint($db = null) { * 数据库迁移:为旧数据库添加新字段(通过 ALTER TABLE) * 仅在表存在但缺少新字段时执行,且只执行一次(用标记文件) */ -function migrateDatabase(SQLite3 $db) { +function migrateDatabase($db, $side = null) { + if ($side !== null) { $db = new SdfDbSideFilter($db, $side); } // 检查是否已迁移(用文件标记,避免每次都做 PRAGMA 查询) - $migrateLock = DB_PATH . '.migrated'; + $migrateLock = sqlDbFile($side === 'java' ? 'java' : 'web') . '.migrated'; if (file_exists($migrateLock)) { return; // 已迁移,直接跳过 } @@ -335,7 +1615,8 @@ function migrateDatabase(SQLite3 $db) { } } -function initTables(SQLite3 $db) { +function initTables($db, $side = null) { + if ($side !== null) { $db = new SdfDbSideFilter($db, $side); } $db->exec('BEGIN TRANSACTION'); try { // Token表 @@ -1390,7 +2671,7 @@ function recordCashierOrder($data, $db = null) { // ===== 定时清理(仅清理过期token,每次请求执行一次) ===== function cleanExpiredTokensLazy() { // 频率限制:每5分钟最多清理一次 - $lockFile = DB_PATH . '.clean_lock'; + $lockFile = sqlDbFile('web') . '.clean_lock'; if (file_exists($lockFile)) { $lastClean = (int)file_get_contents($lockFile); if (time() - $lastClean < 300) return; diff --git a/install.php b/install.php new file mode 100644 index 0000000..1af11fe --- /dev/null +++ b/install.php @@ -0,0 +1,551 @@ +'db'|'mysql', 'dir'=>..., 'java'=>..., 'web'=>..., 'warn'=>[]) + * 校验不过抛 Exception,异常消息给用户看。 + */ +function instValidate(array $post) { + $warn = array(); + $mode = isset($post['mode']) ? strtolower(trim((string)$post['mode'])) : 'db'; + if ($mode !== 'db' && $mode !== 'mysql') { + throw new Exception('运行模式取值非法(只接受 db 或 mysql)。'); + } + $dir = isset($post['dir']) ? trim((string)$post['dir']) : ''; + + $side = function ($k) use ($post) { + $g = function ($f) use ($k, $post) { + $key = $k . '_' . $f; + return isset($post[$key]) ? trim((string)$post[$key]) : ''; + }; + return array('host' => $g('host'), 'port' => $g('port'), 'name' => $g('name'), + 'user' => $g('user'), 'pass' => $g('pass')); + }; + + $java = $side('java'); + $web = $side('web'); + $entries = array('java' => $java, 'web' => $web); + + if ($mode === 'db') { + // db 模式下地址允许全空(也允许填 —— 保存但不生效,回显时给警告) + foreach ($entries as $k => $e) { + $filled = array_filter($e, function ($v) { return $v !== ''; }); + if (!empty($filled)) { + $warn[] = '当前是 db 模式,' . strtoupper($k) . ' 侧的 MySQL 地址不会生效,已原样保存。'; + } + } + // 存储位置必须是合法相对/绝对路径,且目录可写 + if ($dir !== '') { + $dir = str_replace('\\', '/', $dir); + if ($dir[0] !== '/' && strpos($dir, './') !== 0 && strpos($dir, '../') !== 0 + && strpos($dir, '/') !== false && !preg_match('#^[A-Za-z]:/#', $dir)) { + throw new Exception('数据库存储位置写法不合法。用 / 开头=绝对路径,./ 开头=相对本目录,../ 开头=相对上层。'); + } + $abs = instResolveDir($dir); + if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) { + throw new Exception('无法创建数据库目录:' . $abs . '(请检查 Web 目录权限)。'); + } + if (!is_writable($abs)) { + throw new Exception('数据库目录不可写:' . $abs); + } + } + } else { + // mysql 模式:mysqli 扩展必须在 + if (!extension_loaded('mysqli')) { + throw new Exception('当前 PHP 没有安装 mysqli 扩展,无法使用 mysql 模式。' + . '请先安装扩展,或改用 db 模式。'); + } + // 每侧地址要么全填、要么全空 + $complete = 0; + foreach ($entries as $k => $e) { + $empty = 0; + foreach ($e as $v) { if ($v === '') $empty++; } + if ($empty === count($e)) continue; // 全空 = 该侧回退 db,允许 + if ($empty > 0) { + $throw = array(); + foreach ($e as $fk => $fv) { if ($fv === '') $throw[] = $fk; } + throw new Exception(strtoupper($k) . ' 侧 MySQL 地址信息不完整,缺少:' + . implode('、', $throw) . '。每侧要么填全、要么全空。'); + } + if (!ctype_digit($e['port'])) { + throw new Exception(strtoupper($k) . ' 侧端口必须是纯数字。'); + } + $p = (int)$e['port']; + if ($p < 1 || $p > 65535) { + throw new Exception(strtoupper($k) . ' 侧端口超出范围(1-65535)。'); + } + $complete++; + } + if ($complete === 0) { + throw new Exception('mysql 模式下至少要有一侧地址填全,否则会静默回退 db 模式,等于白配。'); + } + // 实连 + 建表权限探测:MySQL 模式下建表失败是降级不抛异常的(站点会悄悄跑在空库上), + // 所以必须在这里就替部署者确认「连得上、库在、能建表」。 + foreach ($entries as $k => $e) { + if (array_sum(array_map(function ($v) { return $v === '' ? 1 : 0; }, $e)) === count($e)) continue; + instProbeMySql($k, $e); + } + } + return array('mode' => $mode, 'dir' => $dir, 'java' => $java, 'web' => $web, 'warn' => $warn); +} + +/** 解析存储位置为绝对路径(与 security.php 的 sqlDbDir() 同一套规则,独立实现避免耦合)。 */ +function instResolveDir($p) { + $p = str_replace('\\', '/', $p); + $base = str_replace('\\', '/', __DIR__); + while (strpos($p, '../') === 0) { $base = dirname($base); $p = substr($p, 3); } + if (strpos($p, './') === 0) $p = substr($p, 2); + if ($p !== '' && $p[0] === '/') return rtrim($p, '/'); + if ($p === '') return $base; + return rtrim($base, '/') . '/' . trim($p, '/'); +} + +/** + * MySQL 实连探测:连得上 + 库存在 + 能建表(建完立刻删)。 + * 失败抛 Exception,消息带 PHP/mysqli 原始错误码,方便部署者定位。 + */ +function instProbeMySql($slot, array $e) { + $m = mysqli_init(); + if (!($m instanceof mysqli)) { + throw new Exception(strtoupper($slot) . ' 侧:mysqli_init 失败。'); + } + @mysqli_options($m, MYSQLI_OPT_CONNECT_TIMEOUT, 6); + $okc = @mysqli_real_connect($m, $e['host'], $e['user'], $e['pass'], $e['name'], (int)$e['port']); + if (!$okc) { + $errno = $m->connect_errno; + $msg = $m->connect_error; + throw new Exception(strtoupper($slot) . ' 侧连接失败 #' . $errno . ':' . $msg + . '(常见原因:MySQL 服务未启动、账密错误、库名不存在、端口不对)'); + } + // 建表权限探测(软依赖下建表失败只记日志,站点会悄悄跑在空库上,这里必须硬报) + $tbl = '_install_probe'; + $failed = ''; + if (!@mysqli_query($m, 'DROP TABLE IF EXISTS `' . $tbl . '`')) { + $failed = 'DROP: ' . mysqli_error($m); + } elseif (!@mysqli_query($m, 'CREATE TABLE `' . $tbl . "` (\n `id` BIGINT NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)\n) ENGINE=InnoDB")) { + $failed = 'CREATE: ' . mysqli_error($m) . '(' . mysqli_errno($m) . ')'; + } else { + @mysqli_query($m, 'DROP TABLE IF EXISTS `' . $tbl . '`'); + } + @mysqli_close($m); + if ($failed !== '') { + throw new Exception(strtoupper($slot) . ' 侧连得上,但建表失败:' . $failed + . ' —— 站点初始化需要 CREATE 权限,请检查账号授权。'); + } +} + +// ============================ 生成 sql.md 配置区 ============================ +/** 把本次配置序列化成带哨兵的 json 数组(与 sql.md 模板同形)。 */ +function instBuildArray(array $c) { + $mkEntry = function ($slot, $e) use ($c) { + return array( + '归属' => $slot, + '数据库地址' => $e['host'], + '端口' => $e['port'], + '数据库名' => $e['name'], + '数据库账号' => $e['user'], + '数据库密码' => $e['pass'], + ); + }; + return array( + array('text' => '配置区开始'), + array('运行模式' => ($c['mode'] === 'mysql' ? 'mysql' : 'true'), + '数据库存储位置' => $c['dir']), + $mkEntry('java', $c['java']), + $mkEntry('web', $c['web']), + array('text' => '配置区域结束'), + ); +} + +/** + * 只替换 sql.md 里含「配置区开始」的那个 ```json 代码块,其余文本原样保留。 + * 找不到哨兵块(文件不存在 / 格式坏)→ 用模板整个重建(这时没有说明书内容可保留)。 + * @return string 新的 sql.md 全文 + */ +function instSpliceMd($md, array $arr) { + $json = json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) throw new Exception('配置序列化失败:' . json_last_error_msg()); + if (!is_string($md) || trim($md) === '') { + return instFallbackMd($json); + } + // 逐块匹配 ```json ... ```,只换含哨兵的那块 + $out = ''; + $last = 0; + $re = '/```(?:json|JSON)?\s*(.*?)```/s'; + if (!preg_match_all($re, $md, $mm, PREG_OFFSET_CAPTURE)) { + return instFallbackMd($json); + } + $replaced = false; + foreach ($mm[1] as $hit) { + $body = $hit[0]; + $off = $hit[1]; + $a = json_decode(trim($body), true); + if (!is_array($a)) continue; + $has = false; + foreach ($a as $el) { + if (is_array($el) && isset($el['text']) + && strpos((string)$el['text'], '配置区开始') !== false) { $has = true; break; } + } + if (!$has) continue; + $s = $off; + $e = $off + strlen($body); + $out .= substr($md, $last, $s - $last) . "\n" . $json . "\n"; + $last = $e; + $replaced = true; + } + if (!$replaced) return instFallbackMd($json); + $out .= substr($md, $last); + return $out; +} + +/** 无哨兵块时的完整模板(与仓库内 sql.md 分发版同结构)。 */ +function instFallbackMd($json) { + return "```json\n" . $json . "\n```\n" + . "\n\n| 配置值 | 意义 | 示例 |\n| :---: | :---: | :---: |\n" + . "| 运行模式 | 选择db/mysql运行。不区分大小写,`true和db`表示使用db,`false和mysql`表示mysql | true |\n" + . "| 数据库位置 | db模式专用,`/xxx`绝对路径,`./xxx`相对当前目录,`../xxx`相对上层 | ./db/ |\n" + . "| 数据库地址 | mysql专用,连接地址。一般填127.0.0.1或localhost | localhost |\n" + . "| 端口 | mysql专用,默认3306 | 3306 |\n" + . "| 数据库名 | mysql专用 | sqlname |\n" + . "| 数据库账号 | mysql专用 | mysqluser |\n" + . "| 数据库密码 | mysql专用,必须与账号成对 | mysqlpassword |\n" + . "\n## 常见问题\n\n" + . "问:db不可用
\n答: 检查php扩展-sqllite是否安装
\n" + . "问: mysql连不上
\n答: 检查mysql服务是否启动、账密端口是否正确
\n" + . "问: 解析配置失败
\n答: 把整个json配置区丢入json校验器校验一次json格式\n"; +} + +// ============================ POST:写配置 ============================ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $act = isset($_POST['act']) ? (string)$_POST['act'] : ''; + $got = isset($_POST['csrf']) ? (string)$_POST['csrf'] : ''; + if ($act !== 'save' || !hash_equals($csrf, $got)) { + $err = '请求校验失败,请刷新页面后重试。'; + } else { + try { + $cfg = instValidate($_POST); + $new = instSpliceMd(is_file(INSTALL_MD) ? @file_get_contents(INSTALL_MD) : '', + instBuildArray($cfg)); + + // --- 备份(*.bak* 在 gitignore 内,不会污染仓库) --- + $bak = ''; + if (is_file(INSTALL_MD)) { + $bak = INSTALL_MD . '.bak.' . date('YmdHis'); + if (!@copy(INSTALL_MD, $bak)) $bak = ''; + } + + // --- 原子写:先写 .tmp 再 rename,避免中途失败留下半份配置 --- + $tmp = INSTALL_MD . '.insttmp'; + if (@file_put_contents($tmp, $new, LOCK_EX) === false) { + throw new Exception('写入临时文件失败(目录不可写?):' . $tmp); + } + if (!@rename($tmp, INSTALL_MD)) { + @unlink($tmp); + // Windows 上 rename 覆盖已存在的目标可能失败 → 退化为直接写 + if (@file_put_contents(INSTALL_MD, $new, LOCK_EX) === false) { + throw new Exception('写入 sql.md 失败。'); + } + } + + // --- 回读校验:解出来必须与提交的一致,否则还原备份 --- + $chk = instVerifyWritten($cfg); + if ($chk !== true) { + if ($bak !== '') @copy($bak, INSTALL_MD); + @unlink($tmp); + throw new Exception('回读校验失败,已还原原配置。原因:' . $chk); + } + + // --- 锁定 + 自毁 --- + @file_put_contents(INSTALL_LOCK, date('c') . "\n" + . 'ip=' . secClientIp() . "\n" + . 'mode=' . $cfg['mode'] . "\n" + . 'md=' . INSTALL_MD . "\n", LOCK_EX); + @chmod(INSTALL_LOCK, 0644); + @unlink(__FILE__); + + $ok = $cfg['mode'] === 'mysql' ? 'MySQL 模式配置已写入' : 'db 模式配置已写入'; + } catch (Exception $e) { + $err = $e->getMessage(); + } + } +} + +/** + * 回读 sql.md 确认写进去的就是我们提交的那份(绕开 sqlMdDbConfig 的 static 缓存)。 + * @return true|string true=通过;否则返回失败原因 + */ +function instVerifyWritten(array $want) { + $md = @file_get_contents(INSTALL_MD); + if (!is_string($md)) return '读不回 sql.md'; + if (!preg_match_all('/```(?:json|JSON)?\s*(.*?)```/s', $md, $mm) || empty($mm[1])) { + return '读不回配置块'; + } + $arr = null; + foreach ($mm[1] as $body) { + $a = json_decode(trim($body), true); + if (!is_array($a)) continue; + foreach ($a as $el) { + if (is_array($el) && isset($el['text']) + && strpos((string)$el['text'], '配置区开始') !== false) { $arr = $a; break; } + } + if ($arr !== null) break; + } + if ($arr === null) return '找不到配置区开始哨兵'; + + $modeGot = null; + $ents = array(); + $seq = 0; + foreach ($arr as $el) { + if (!is_array($el)) continue; + if (array_key_exists('运行模式', $el)) { + $modeGot = strtolower(trim((string)$el['运行模式'])); + continue; + } + if (!array_key_exists('数据库地址', $el) && !array_key_exists('数据库名', $el)) continue; + $own = strtolower(trim(isset($el['归属']) ? (string)$el['归属'] : '')); + $slot = ($own === '') ? (($seq++ === 0) ? 'java' : 'web') : $own; + $slot = (strpos($slot, 'java') !== false || strpos($slot, 'plugin') !== false) ? 'java' + : ((strpos($slot, 'web') !== false || strpos($slot, 'php') !== false) ? 'web' : $slot); + $ents[$slot] = $el; + } + $wantMode = ($want['mode'] === 'mysql') ? 'mysql' : 'true'; + if ($modeGot !== strtolower($wantMode)) { + return '运行模式没写对(读回=' . var_export($modeGot, true) . ')'; + } + foreach (array('java', 'web') as $slot) { + if (!isset($ents[$slot])) return $slot . ' 侧地址条目丢了'; + $e = $ents[$slot]; + foreach (array('数据库地址' => 'host', '端口' => 'port', '数据库名' => 'name', + '数据库账号' => 'user', '数据库密码' => 'pass') as $jk => $wk) { + $rv = array_key_exists($jk, $e) ? (string)$e[$jk] : ''; + if ($rv !== (string)$want[$slot][$wk]) { + return $slot . ' 侧 ' . $jk . ' 读回不一致'; + } + } + } + return true; +} + +// ============================ 渲染 ============================ +$modeNow = $cur['mode']; +$modeSel = ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '') + ? (isset($_POST['mode']) ? strtolower(trim((string)$_POST['mode'])) : 'db') + : $modeNow; +$val = function ($k, $d = '') use ($cur, $err) { + if ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '' && isset($_POST[$k])) { + return htmlspecialchars((string)$_POST[$k], ENT_QUOTES, 'UTF-8'); + } + if ($k === 'dir') return htmlspecialchars($cur['dir'], ENT_QUOTES, 'UTF-8'); + if ($k === 'mode') return ''; + if (preg_match('/^(java|web)_(host|port|name|user|pass)$/', $k, $m)) { + $e = $cur[$m[1]]; + $map = array('host' => 'host', 'port' => 'port', 'name' => 'name', 'user' => 'user', 'pass' => 'pass'); + return htmlspecialchars((string)$e[$map[$m[2]]], ENT_QUOTES, 'UTF-8'); + } + return htmlspecialchars($d, ENT_QUOTES, 'UTF-8'); +}; +?> + + + + + + +数据库配置 · 初始化 + + + +
+ +
+

数据库配置初始化

+

本页把运行模式与 MySQL 地址写进 sql.md 的配置区, + 写入成功后会自动删除本文件并生成 install.lock 锁定。 + 页面配置好前请勿关闭;只保留本机或持引导令牌可访问。

+ + +
+ +
    +
  • 配置已写入 sql.md 并回读校验通过。
  • +
  • 已生成 install.lock,本页再次访问将返回 404。
  • +
  • install.php 已自我删除。
  • +
+ 下一步: +
    +
  • db 模式 → 直接访问 后台登录页。
  • +
  • mysql 模式 → 后台首访会自动建表(已在本页实连并验证过建表权限)。
  • +
  • 记得让 Java 端(api/sync.php 所在的独立站点)也用同一份配置:改 sync.php 时两处都要传。
  • +
+
+ + + +
写入失败:
+ + +
+ + + +

运行模式

+
+ + +
+ +
+ + +

/ 开头=绝对路径;./ 开头=相对本目录;../ 开头=相对上层目录。

+
+ +
+
每侧地址要么填全、要么全空;留空的一侧会自动回退 db 文件。 + 本页会真实连接并尝试建表,失败会直接报错,不会悄悄写入一份跑不通的配置。
+ +

Java 侧(api/sync.php 通信组 · Java.db)

+
+
+
+
+
+
+
+
+ + + +

PHP 侧(web.db)

+
+
+
+
+
+
+
+
+ + +
+ + +
+ + +

+ 当前 sql.md 已有配置 —— 模式 , + 存储位置 。 + 重新保存会覆盖它。 +

+ +
+ +
+ 访问要求: + + 你当前来自 127.0.0.1/::1(本机),可直接配置。 + + 需要 ?boot=引导令牌 或你的 IP 已在白名单内,否则返回 nginx 404。 + +
本页不加载 core.php;写入前会自动备份 sql.md.bak.时间戳,失败会自动还原。 +
+ +
+ + + diff --git a/security.php b/security.php index 91a0d5d..97e862f 100644 --- a/security.php +++ b/security.php @@ -32,6 +32,185 @@ */ require_once __DIR__ . '/config.php'; +/* ============================================================================ + * sql.md 数据库配置解析(2026-10-09 双库切分重构) + * + * 为什么放 security.php 而不是 config.php: + * 1) security.php 是全站唯一的公共加载点 —— core.php、admin_2fa_setup.php、 + * poller_online.php、minecraft_auth.php 全部会 require 它,任何入口都拿得到 + * sqlDbFile();而 config.php 只有部分入口直接加载。 + * 2) 线上 config.php 不随部署覆盖(deploy 的 EXCLUDE 永不上传它,它只由 + * admin_2fa_setup.php 就地改写 SEC-UPDATE 区)—— 放 config.php 线上根本不会生效。 + * ========================================================================== */ +// ===== sql.md 运行配置(2026-10-09 大重构:配置文件 + 说明书二合一)===== +// ★ sql.md 整个文档就是数据库配置文件:只读「配置区开始」哨兵所在的那个 json 代码块, +// 说明区表格 / 常见问题一律忽略 —— 文档里不管有多少个代码块,都只认这一个。 +// ★ 软依赖(全部回退 db 兜底,绝不因配置问题让站点 500): +// sql.md 缺失 / json 解码失败 / 运行模式非法 / MySQL 地址任一字段为空 → 一律按 db 运行。 +// ★ 运行模式四个取值不区分大小写:true|db → db(SQLite);false|mysql → mysql。 +// ★ 归属决定地址给谁用:web|php → PHP 专属库;java|plugin → Java 专属库; +// 归属留空时按模板出现顺序分配(第 1 个给 Java,第 2 个给 PHP)。 +// ★ 可用 define('SQL_MD_PATH', '/path/sql.md') 覆盖路径(仅测试用,与 PAY_MD_PATH 同规矩)。 + +if (!function_exists('sqlModeNorm')) { + /** + * 运行模式归一化:true/db → 'db';false/mysql → 'mysql';未知值 → 'db' 兜底。 + * 兼容 json 里的布尔 true/false 与字符串 "true"/"DB"/"MySQL" 等写法。 + */ + function sqlModeNorm($v) { + if (is_bool($v)) return $v ? 'db' : 'mysql'; + if (is_int($v) || is_float($v)) return ((int)$v) ? 'db' : 'mysql'; + $s = strtolower(trim((string)$v)); + if ($s === 'true' || $s === 'db') return 'db'; + if ($s === 'false' || $s === 'mysql') return 'mysql'; + return 'db'; + } +} + +if (!function_exists('sqlMySqlEntry')) { + /** + * 解析 sql.md 里的一个 MySQL 地址条目(一对 {} = 一个地址)。 + * 软依赖:地址 / 端口 / 库名 / 账号 / 密码 任意一项为空 → 返回 null(该归属回退 db)。 + * @return array|null ['host','port','name','user','pass'] 或 null + */ + function sqlMySqlEntry($el) { + $g = function ($k, $d = '') use ($el) { + return array_key_exists($k, $el) ? (string)$el[$k] : $d; + }; + $host = trim($g('数据库地址')); + $port = trim($g('端口')); + $name = trim($g('数据库名')); + $user = trim($g('数据库账号')); + $pass = $g('数据库密码'); + if ($host === '' || $port === '' || $name === '' || $user === '' || $pass === '') return null; + if (!ctype_digit($port)) return null; + $p = (int)$port; + if ($p < 1 || $p > 65535) return null; + return array('host' => $host, 'port' => $p, 'name' => $name, 'user' => $user, 'pass' => $pass); + } +} + +if (!function_exists('sqlMdDbConfig')) { + /** + * 读 sql.md 配置模块的 json,返回运行配置(static 缓存,一次请求只读一次盘)。 + * + * @return array { + * 'mode' => 'db'|'mysql', 运行模式(已归一化) + * 'dir' => string, db 模式的存储位置(未配置 = '') + * 'java' => array|null, Java 专属 MySQL 连接参数(null = 该侧兜底 db) + * 'web' => array|null, PHP 专属 MySQL 连接参数(null = 该侧兜底 db) + * 'raw' => array|null, 解析出的原始 json(install 页回显用) + * 'ok' => bool, 是否成功读到配置模块 + * } + */ + function sqlMdDbConfig() { + static $cfg = null; + if ($cfg !== null) return $cfg; + $cfg = array( + 'mode' => 'db', 'dir' => '', 'java' => null, 'web' => null, 'raw' => null, 'ok' => false, + ); + + $path = defined('SQL_MD_PATH') ? SQL_MD_PATH : __DIR__ . '/sql.md'; + if (!is_file($path)) return $cfg; + $md = @file_get_contents($path); + if (!is_string($md) || $md === '') return $cfg; + if (!preg_match_all('/```(?:json|JSON)?\s*(.*?)```/s', $md, $mm) || empty($mm[1])) return $cfg; + + // 只认含「配置区开始」哨兵的代码块;没找到哨兵时退而取第一个可解码的块(老格式兜底) + $arr = null; + foreach ($mm[1] as $body) { + $a = json_decode(trim($body), true); + if (!is_array($a)) continue; // 解码失败的块直接跳过,不影响下一个块 + $hit = false; + foreach ($a as $el) { + if (is_array($el) && isset($el['text']) + && strpos((string)$el['text'], '配置区开始') !== false) { + $hit = true; + break; + } + } + if ($hit) { $arr = $a; break; } + if ($arr === null) $arr = $a; + } + if (!is_array($arr)) return $cfg; // 整体解码失败 → 走 db 兜底 + + $cfg['ok'] = true; + $cfg['raw'] = $arr; + $slotSeq = 0; // 归属留空时按出现顺序分配槽位 + foreach ($arr as $el) { + if (!is_array($el)) continue; + + // 模式条目:{运行模式, 数据库存储位置} + if (array_key_exists('运行模式', $el)) { + $cfg['mode'] = sqlModeNorm($el['运行模式']); + if (array_key_exists('数据库存储位置', $el)) { + $cfg['dir'] = trim((string)$el['数据库存储位置']); + } + continue; + } + + // 地址条目:含 MySQL 字段才算(一对 {} = 一个地址) + if (!array_key_exists('数据库地址', $el) && !array_key_exists('数据库名', $el)) continue; + + $own = strtolower(trim(isset($el['归属']) ? (string)$el['归属'] : '')); + if ($own !== '' && (strpos($own, 'java') !== false || strpos($own, 'plugin') !== false)) { + $slot = 'java'; + } elseif ($own !== '' && (strpos($own, 'web') !== false || strpos($own, 'php') !== false)) { + $slot = 'web'; + } else { + $slot = ($slotSeq === 0) ? 'java' : 'web'; + } + $slotSeq++; + + $conn = sqlMySqlEntry($el); // 字段不全 → null → 该侧兜底 db + if ($conn !== null && $cfg[$slot] === null) $cfg[$slot] = $conn; + } + return $cfg; + } +} + +if (!function_exists('sqlDbDir')) { + /** + * 解析 db 模式的存储位置: + * /xxx → 绝对路径(从根目录开始) + * ./xxx → 相对当前目录(Web 根) + * ../xxx → 相对上层目录 + * 空 → 默认 __DIR__/db(与改造前 DB_PATH 完全一致,老部署零变化) + * @return string 已归一为 / 分隔的绝对路径 + */ + function sqlDbDir() { + $p = trim(sqlMdDbConfig()['dir']); + if ($p === '') return str_replace('\\', '/', __DIR__ . '/db'); + $p = str_replace('\\', '/', $p); + $base = str_replace('\\', '/', __DIR__); + while (strpos($p, '../') === 0) { $base = dirname($base); $p = substr($p, 3); } + if (strpos($p, './') === 0) $p = substr($p, 2); + if ($p !== '' && $p[0] === '/') return rtrim($p, '/'); // 绝对路径 + if ($p === '') return $base; + return rtrim($base, '/') . '/' . trim($p, '/'); // 相对当前目录 + } +} + +if (!function_exists('sqlDbFile')) { + /** + * 取某侧的 SQLite 文件路径(db 模式 / mysql 模式下该侧兜底时都用它)。 + * 'web' → web.db(PHP 专属);未配置存储位置时沿用 config.php 的 DB_PATH,老部署零变化 + * 'java' → Java.db(Java 专属) + */ + function sqlDbFile($side) { + $c = sqlMdDbConfig(); + if ($side === 'web') { + if ($c['dir'] === '' && defined('DB_PATH') && DB_PATH !== '') { + return str_replace('\\', '/', DB_PATH); + } + return sqlDbDir() . '/web.db'; + } + return sqlDbDir() . '/Java.db'; + } +} + + + // ====================================================================== // 基础:客户端 IP // ====================================================================== @@ -262,7 +441,7 @@ function secTokenUrl($page = 'admin.php', $extra = '') { function secBootstrapDb($write = false) { if (!class_exists('SQLite3')) return null; if (!defined('DB_PATH') || DB_PATH === '') return null; - $path = DB_PATH; + $path = sqlDbFile('web'); if ($write) { $dir = dirname($path); if (!is_dir($dir)) { @mkdir($dir, 0755, true); } @@ -294,7 +473,7 @@ function secBootstrapDb($write = false) { function secMarkBootstrapped($note = '') { $db = secBootstrapDb(true); if ($db === null) { - secLog('bootstrap_mark_fail', 'open failed path=' . (defined('DB_PATH') ? DB_PATH : '(undefined)')); + secLog('bootstrap_mark_fail', 'open failed path=' . (function_exists('sqlDbFile') ? sqlDbFile('web') : '(no sqlDbFile)')); return false; } try { @@ -335,7 +514,7 @@ function secIsBootstrapped() { return true; } // 判据 2:web.db 还没建 = 全新部署第一次访问 → 引导模式 - if (!defined('DB_PATH') || DB_PATH === '' || !file_exists(DB_PATH)) { + if (!function_exists('sqlDbFile') || !file_exists(sqlDbFile('web'))) { $cached = false; return false; }