$s) { if (is_array($s) && isset($s['mode']) && $s['mode'] === 'block' && !empty($s['alert'])) { $out[] = $side . '库:' . $s['alert']; } } return $out; } /** 某侧的块原因(dbOpen 抛异常用) */ function dfGuardBlockReason($side) { $st = dfGuardReadState(); if (isset($st[$side]['alert']) && $st[$side]['alert']) return (string)$st[$side]['alert']; return '数据库状态异常(身份与上次运行不一致),已停止操作并拒绝握手,请检查 sql.md 数据库配置段。'; } /** * 纯判定函数(可单测):给定上次体检快照与本次观测,返回 mode。 * @param array|null $prev 上次快照:db/host/port/tables/rows/core/ts * @param array $now 本次观测:db/host/port/tables(array)/rows(int)/core(array 表=>行数) * @return array array('mode' => 'ok'|'recover'|'block', 'why' => string) */ function dfGuardDecide($prev, $now) { $db = isset($now['db']) ? (string)$now['db'] : ''; $host = isset($now['host']) ? (string)$now['host'] : ''; $port = isset($now['port']) ? (int)$now['port'] : 0; // 没上次快照 → 首次见,先记账不报警(部署/状态文件被删后的正常冷启动) if (!is_array($prev) || empty($prev)) { return array('mode' => 'ok', 'why' => 'bootstrap'); } $pdb = isset($prev['db']) ? (string)$prev['db'] : ''; $phost = isset($prev['host']) ? (string)$prev['host'] : ''; $pport = isset($prev['port']) ? (int)$prev['port'] : 0; // 身份变化(库名/主机/端口)→ 指错了地方:块,绝不往里写 if ($pdb !== $db || $phost !== $host || $pport !== $port) { return array('mode' => 'block', 'why' => 'identity_mismatch'); } $core = dfGuardCoreTables($pdb === 'caoyuanphp' || strpos($pdb, 'php') !== false ? 'web' : 'java'); // 用 prev 记忆的侧别更稳;这里退化用库名猜,调用方会以 $side 覆盖 core 集合 $tables = isset($now['tables']) ? $now['tables'] : array(); $coreNow = isset($now['core']) && is_array($now['core']) ? $now['core'] : array(); $rows = isset($now['rows']) ? (int)$now['rows'] : -1; $prevRows = isset($prev['rows']) ? (int)$prev['rows'] : 0; // 核心表一张不剩 → 库被连根清空/删库:走 recover(重建+补推+等 Java) $haveCore = 0; foreach ($core as $t) { if (in_array($t, $tables, true)) $haveCore++; } if ($haveCore === 0 && !empty($prev['tables'])) { return array('mode' => 'recover', 'why' => 'tables_wiped'); } // 表都在但整体行数塌了(<=2% 或绝对空)→ 被清空:recover if ($rows >= 0 && $prevRows > 0) { $floor = max(50, (int)($prevRows * 0.02)); if ($rows <= $floor) { return array('mode' => 'recover', 'why' => 'rows_wiped'); } // 核心表精确计数佐证(TABLE_ROWS 是估计值,避免误判) $zeroCore = 0; $checkedCore = 0; foreach ($coreNow as $t => $c) { $checkedCore++; if ((int)$c <= 0) $zeroCore++; } if ($checkedCore > 0 && $zeroCore === $checkedCore) { return array('mode' => 'recover', 'why' => 'core_zeroed'); } } return array('mode' => 'ok', 'why' => 'healthy'); } /** * 连接后体检(在 initTables 之前调用,块模式直接抛,避免往可疑库里建表)。 * @return string 'ok'|'recover'|'block' */ function dfGuardCheck($mdb, $conn, $side) { $st = dfGuardReadState(); $prev = isset($st[$side]) && is_array($st[$side]) ? $st[$side] : null; $now = time(); // 节流:刚体检过 → 直接沿用缓存判定(块/恢复态也一样缓存,行为一致) if (is_array($prev) && isset($prev['ts']) && ($now - (int)$prev['ts']) < DF_GUARD_TTL && in_array(isset($prev['mode']) ? $prev['mode'] : '', array('ok', 'recover', 'block'), true)) { return $prev['mode']; } $dbName = isset($conn['name']) ? (string)$conn['name'] : ''; $host = isset($conn['host']) ? (string)$conn['host'] : ''; $port = isset($conn['port']) ? (int)$conn['port'] : 3306; // ---- 完整体检 ---- $tables = array(); $rs = $mdb->query("SELECT table_name FROM information_schema.tables WHERE table_schema='" . $mdb->escapeString($dbName) . "'"); if ($rs) { while ($row = $rs->fetchArray(SQLITE3_ASSOC)) $tables[] = (string)$row['table_name']; } $estRows = 0; $rs = $mdb->query("SELECT SUM(TABLE_ROWS) s FROM information_schema.tables WHERE table_schema='" . $mdb->escapeString($dbName) . "'"); if ($rs && ($row = $rs->fetchArray(SQLITE3_ASSOC))) $estRows = (int)$row['s']; $core = dfGuardCoreTables($side); $coreNow = array(); foreach ($core as $t) { if (!in_array($t, $tables, true)) continue; $v = dfGuardScalar($mdb, "SELECT COUNT(*) FROM `" . $t . "`"); if ($v !== null) $coreNow[$t] = (int)$v; } $nowInfo = array( 'db' => $dbName, 'host' => $host, 'port' => $port, 'tables' => $tables, 'rows' => $estRows, 'core' => $coreNow, ); $decide = dfGuardDecide($prev, $nowInfo, $side); $mode = $decide['mode']; $entry = array( 'db' => $dbName, 'host' => $host, 'port' => $port, 'tables' => count($tables), 'rows' => $estRows, 'core' => $coreNow, 'ts' => $now, 'mode' => $mode, ); if ($mode === 'block') { $entry['alert'] = 'sql.md 里这一侧指向的数据库与上次运行不一致(上次 ' . (is_array($prev) ? ($prev['host'] . ':' . $prev['port'] . '/' . $prev['db']) : '?') . ' → 现在 ' . $host . ':' . $port . '/' . $dbName . ')。' . '为避免写错库破坏数据,已停止对该库的一切操作并拒绝与 Java 握手。' . '请登录服务器核对 sql.md 的数据库配置段(地址/端口/库名/账号),改对后本护栏会自动恢复。'; } elseif ($mode === 'recover') { $entry['alert'] = '检测到该库被清空(' . $decide['why'] . '),已执行自愈:补表结构并尝试从最近备份补推 SQL 数据,' . '随后放行 Java 握手补推其余数据。PHP 侧自有表(工单/订单/流水等)只能从备份恢复,请检查 debug.log 的补推明细。'; } else { $entry['alert'] = null; } $st[$side] = $entry; dfGuardWriteState($st); if ($mode === 'block') { dfGuardLog('dbGuard[' . $side . '] ★ 块模式:' . $entry['alert']); } elseif ($mode === 'recover') { dfGuardLog('dbGuard[' . $side . '] ★ 恢复模式:' . $decide['why'] . ',行数 ' . $estRows . '(上次 ' . (is_array($prev) ? (int)$prev['rows'] : 0) . ')'); } return $mode; } function dfGuardScalar($mdb, $sql) { try { $rs = $mdb->query($sql); if ($rs && ($row = $rs->fetchArray(SQLITE3_ASSOC))) { foreach ($row as $v) return $v; } } catch (\Throwable $e) { return null; } return null; } function dfGuardLog($msg) { if (function_exists('debugLog')) { debugLog($msg); return; } @error_log($msg); } /** 恢复模式动作:补表结构(db_schema_mysql.sql)+ 把仍为空的表从最近可读备份补推数据 */ function dfGuardRecover($mdb, $side) { $done = array('schema' => 0, 'restored' => array(), 'skipped' => array(), 'err' => array()); // 1) 缺的表用内置 schema 补(initTables 通常已经补过,这里是双保险) $schemaFile = __DIR__ . '/db_schema_mysql.sql'; if (is_file($schemaFile) && is_readable($schemaFile)) { $done['schema'] = dfGuardApplySchema($mdb, $side, $schemaFile); } // 2) 仍为空的表 → 从最近一次可读备份补推(只补 0 行表,绝不覆盖有数据的表) try { $dump = dfGuardPickDump($side); if ($dump === null) { $done['skipped'][] = 'no_readable_dump'; } else { $done['restored'] = dfGuardRestoreEmptyTables($mdb, $dump); } } catch (\Throwable $e) { $done['err'][] = $e->getMessage(); } dfGuardLog('dbGuard[' . $side . '] 自愈执行明细: ' . json_encode($done, JSON_UNESCAPED_UNICODE)); return $done; } /** 从 db_schema_mysql.sql 里把本侧缺失的表建出来(CREATE TABLE IF NOT EXISTS) */ function dfGuardApplySchema($mdb, $side, $schemaFile) { $javaTables = function_exists('dbJavaTables') ? dbJavaTables() : array(); $sql = (string)@file_get_contents($schemaFile); if ($sql === '') return 0; // 只认 CREATE TABLE 段:去掉 DROP/LOCK/UNLOCK(恢复期绝不能删表) $lines = preg_split('/\r\n|\r|\n/', $sql); $buf = ''; $statements = array(); foreach ($lines as $ln) { $t = trim($ln); if ($t === '' || $t[0] === '#' || strpos($t, '--') === 0) continue; if (preg_match('/^(DROP|LOCK|UNLOCK)\b/i', $t)) continue; $buf .= $ln . "\n"; if (substr(rtrim($t), -1) === ';') { $statements[] = $buf; $buf = ''; } } if (trim($buf) !== '') $statements[] = $buf; $created = 0; foreach ($statements as $s) { if (!preg_match('/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?([A-Za-z0-9_]+)`?/i', ltrim($s), $m)) continue; $tbl = $m[1]; // 归属过滤:java 组表只准建到 java 侧,其余建到 web 侧 if ($side === 'java') { if (!in_array($tbl, $javaTables, true)) continue; } else { if (in_array($tbl, $javaTables, true)) continue; } // 表已存在就跳过 $v = dfGuardScalar($mdb, "SELECT COUNT(*) c FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='" . $mdb->escapeString($tbl) . "'"); if ($v !== null && (int)$v > 0) continue; try { $stmt = preg_replace('/^CREATE TABLE\s+(?!IF NOT EXISTS)/i', 'CREATE TABLE IF NOT EXISTS ', ltrim($s), 1); $mdb->exec($stmt); $created++; } catch (\Throwable $e) { dfGuardLog('dbGuard: 建表失败 ' . $tbl . ': ' . $e->getMessage()); } } return $created; } /** 挑一份最近的可读备份(库名出现在文件名里、年龄合规、按 mtime 新优先) */ function dfGuardPickDump($side) { $st = dfGuardReadState(); $dbName = isset($st[$side]['db']) ? (string)$st[$side]['db'] : ''; if ($dbName === '') return null; $cands = array_merge( glob('/www/backup/dr*/*' . $dbName . '*.sql') ?: array(), glob('/www/backup/*' . $dbName . '*.sql') ?: array(), glob(__DIR__ . '/../backup/*' . $dbName . '*.sql') ?: array() ); $best = null; $bestMt = 0; $now = time(); foreach ($cands as $f) { if (!is_file($f) || !is_readable($f)) continue; $mt = @filemtime($f); if (!$mt || ($now - $mt) > DF_GUARD_BACKUP_MAX_AGE) continue; if ($mt > $bestMt) { $bestMt = $mt; $best = $f; } } return $best; } /** * 备份补推:只执行「目标表当前 0 行」的 INSERT / 缺失表的 CREATE,其余语句一律跳过。 * 返回 array('表名' => 补推行数)。 */ function dfGuardRestoreEmptyTables($mdb, $dumpFile) { $out = array(); $fh = @fopen($dumpFile, 'r'); if (!$fh) return $out; $buf = ''; $executed = 0; while (!feof($fh)) { $line = fgets($fh); if ($line === false) break; $buf .= $line; if (rtrim($line) === '' || substr(rtrim($line), -1) !== ';') continue; $stmt = trim($buf); $buf = ''; if ($stmt === '') continue; if ($executed >= DF_GUARD_MAX_STMTS) break; if (preg_match('/^INSERT INTO\s+`?([A-Za-z0-9_]+)`?/i', $stmt, $m)) { $tbl = $m[1]; $v = dfGuardScalar($mdb, "SELECT COUNT(*) c FROM `" . $tbl . "`"); if ($v === null) continue; // 表不存在:跳过(不该由备份建表) if ((int)$v > 0) continue; // 有数据的表绝不覆盖 try { $mdb->exec($stmt); $n = 1; $out[$tbl] = isset($out[$tbl]) ? $out[$tbl] + $n : $n; $executed++; } catch (\Throwable $e) { dfGuardLog('dbGuard: 补推 ' . $tbl . ' 失败: ' . $e->getMessage()); } } elseif (preg_match('/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?([A-Za-z0-9_]+)`?/i', $stmt, $m)) { $tbl = $m[1]; $v = dfGuardScalar($mdb, "SELECT COUNT(*) c FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='" . $mdb->escapeString($tbl) . "'"); if ($v !== null && (int)$v > 0) continue; try { $stmt2 = preg_replace('/^CREATE TABLE\s+(?!IF NOT EXISTS)/i', 'CREATE TABLE IF NOT EXISTS ', $stmt, 1); $mdb->exec($stmt2); $executed++; } catch (\Throwable $e) { dfGuardLog('dbGuard: 恢复期建表 ' . $tbl . ' 失败: ' . $e->getMessage()); } } } fclose($fh); return $out; } /** * 管理登录页横幅:有未消除的护栏告警时就亮出来(块/恢复态都会显示)。 * 告警是派生状态:数据库恢复健康后下一次体检自动转 ok,横幅随之消失。 */ function dfGuardAdminBanner() { $st = dfGuardReadState(); $msgs = array(); foreach ($st as $side => $s) { if (is_array($s) && !empty($s['alert']) && in_array($s['mode'], array('block', 'recover'), true)) { $label = ($side === 'java') ? 'Java 库(caoyuanjava)' : 'PHP 库(caoyuanphp)'; $msgs[] = '[' . htmlspecialchars($label) . '] ' . htmlspecialchars((string)$s['alert']) . ' (' . htmlspecialchars($s['mode'] === 'block' ? '已停止操作并拒绝握手' : '自愈进行中') . ')'; } } if (!$msgs) return ''; $css = ''; return $css . '
'; }