之前研究了,高性能 PHP 緩存系統設計:智能清理與計數管理。
但我發現,這隻能緩存單獨的html,不能緩存一些想要緩存的數據。
同時,爲了解決inode的限制。
所以我嘗試着將緩存文件,放到db數據庫裏,從而實現高效存儲。
一、系統概述
核心類:
ApiCache(位於ApiCache.php)緩存模式:
普通讀寫(
get/set)—— 供內部或手動操作全頁面緩存(
page)—— 捕獲整個輸出並退出
清理機制:獨立 CLI 腳本
ClearCache.php,實現低頻淘汰 + 超限刪除
緩存代碼:
<?php
// ============================================================
// ApiCache.php - 高性能 SQLite 分片緩存(V6.4 定稿版)
// 分片:1=單庫 2=4096(默認) 3=65536
// 兼容:PHP 5.6 ~ 8.x
//
// V6.4 變更(相對 V6.3):
// - 消除冗餘的 $dbName 判空(構造函數已保證)
// - 魔數 500 → 常量 CHUNK_SIZE
// - page() 錯誤類型數組 → 常量 FATAL_ERRORS
// ============================================================
if (!defined('CACHE_ROOT')) {
throw new Exception("必須定義 CACHE_ROOT 常量");
}
if (!defined('CACHE_DEFAULT_SHARD')) {
define('CACHE_DEFAULT_SHARD', 2);
}
if (!defined('CACHE_DEFAULT_COUNT')) {
define('CACHE_DEFAULT_COUNT', 30);
}
class ApiCache
{
const COMPRESS_THRESHOLD = 200;
const COMPRESS_LEVEL = 6;
const CHUNK_SIZE = 500; // 批量查詢/刪除時每批條數
// 致命錯誤類型(page() 用於判斷是否緩存)
const FATAL_ERRORS = array(
E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR
);
private static $countConfig = null;
private $dirName;
private $root;
private $level1;
private $level2;
private $dbName;
private $dbPool = array();
private $probability = 0;
public function __construct($dirName, $shardType = null, $dbName = null)
{
// 清洗 dirName,防止路徑穿越
$clean = preg_replace('/[^a-zA-Z0-9_\-]/', '', (string)$dirName);
if ($clean === '') {
throw new Exception("無效的緩存目錄名: " . $dirName);
}
$this->dirName = $clean;
$this->root = rtrim(CACHE_ROOT, '/') . '/' . $clean . '/';
if (!is_dir($this->root)) {
if (!mkdir($this->root, 0755, true)) {
throw new Exception("無法創建目錄: " . $this->root);
}
}
if ($shardType === null) $shardType = CACHE_DEFAULT_SHARD;
$map = array(
1 => array(0, 0),
2 => array(1, 3),
3 => array(2, 4),
);
if (!isset($map[$shardType])) {
throw new Exception("無效分片類型: " . $shardType);
}
list($this->level1, $this->level2) = $map[$shardType];
self::loadCountConfig();
$this->probability = isset(self::$countConfig[$clean])
? max(0, min(100, (int)self::$countConfig[$clean]))
: (int) CACHE_DEFAULT_COUNT;
if ($this->level1 === 0) {
$name = ($dbName === null || $dbName === '') ? 'single' : (string)$dbName;
$name = preg_replace('/[^a-zA-Z0-9_\-]/', '', $name);
$this->dbName = ($name === '') ? 'single' : $name;
} else {
$this->dbName = null;
}
}
// ============================================================
// 配置
// ============================================================
private static function loadCountConfig()
{
if (self::$countConfig !== null) return;
$file = rtrim(CACHE_ROOT, '/') . '/_config.php';
self::$countConfig = file_exists($file) ? (array)@include $file : array();
}
// ============================================================
// 編解碼
// ============================================================
private function decodeContent($blob)
{
if ($blob === null || $blob === '') return false;
$raw = @gzuncompress($blob);
$content = ($raw !== false) ? $raw : $blob;
return ($content === '') ? false : $content;
}
private function encodeContent($data)
{
if (is_array($data) || is_object($data)) {
$flags = JSON_UNESCAPED_UNICODE;
if (defined('JSON_INVALID_UTF8_IGNORE')) $flags |= JSON_INVALID_UTF8_IGNORE;
$json = json_encode($data, $flags);
$data = ($json !== false) ? $json : serialize($data);
}
$data = (string) $data;
if ($data === '') return false;
return (strlen($data) < self::COMPRESS_THRESHOLD)
? $data
: gzcompress($data, self::COMPRESS_LEVEL);
}
private function isExpired($expireTime, $now = null)
{
if ($expireTime <= 0) return false;
if ($now === null) $now = time();
return $expireTime < $now;
}
/**
* 寫入單條記錄(set 和 setMulti 共用)
* UPDATE 優先(保留 access_count / first_time),不存在則 INSERT
*/
private function writeEntry($db, $md5, $compressed, $expire, $now)
{
try {
$stmt = $db->prepare("UPDATE cache SET content = ?, expire_time = ? WHERE md5 = ?");
$stmt->execute(array($compressed, $expire, $md5));
if ($stmt->rowCount() > 0) return true;
$stmt = $db->prepare("INSERT INTO cache (md5, content, access_count, first_time, expire_time) VALUES (?, ?, 0, ?, ?)");
return $stmt->execute(array($md5, $compressed, $now, $expire));
} catch (PDOException $e) {
try {
$stmt = $db->prepare("UPDATE cache SET content = ?, expire_time = ? WHERE md5 = ?");
$stmt->execute(array($compressed, $expire, $md5));
return true;
} catch (Exception $e2) {
error_log("ApiCache writeEntry error: " . $e2->getMessage());
return false;
}
}
}
// ============================================================
// 基礎讀寫
// ============================================================
public function get($key)
{
$md5 = md5($key);
$db = $this->getDb($md5);
if (!$db) return false;
try {
$stmt = $db->prepare("SELECT content, expire_time FROM cache WHERE md5 = ?");
$stmt->execute(array($md5));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return false;
if ($this->isExpired($row['expire_time'])) {
$del = $db->prepare("DELETE FROM cache WHERE md5 = ?");
$del->execute(array($md5));
return false;
}
if ($this->probability > 0) {
if ($this->probability >= 100 || mt_rand(1, 100) <= $this->probability) {
$upd = $db->prepare("UPDATE cache SET access_count = access_count + 1 WHERE md5 = ?");
$upd->execute(array($md5));
}
}
return $this->decodeContent($row['content']);
} catch (Exception $e) {
error_log("ApiCache get error: " . $e->getMessage());
return false;
}
}
public function set($key, $data, $ttl = 0)
{
$md5 = md5($key);
$db = $this->getDb($md5);
if (!$db) return false;
$compressed = $this->encodeContent($data);
if ($compressed === false) return false;
$expire = ($ttl > 0) ? time() + $ttl : 0;
return $this->writeEntry($db, $md5, $compressed, $expire, time());
}
public function delete($key)
{
$md5 = md5($key);
$db = $this->getDb($md5);
if (!$db) return false;
try {
$stmt = $db->prepare("DELETE FROM cache WHERE md5 = ?");
$stmt->execute(array($md5));
return $stmt->rowCount() > 0;
} catch (Exception $e) {
error_log("ApiCache delete error: " . $e->getMessage());
return false;
}
}
// ============================================================
// 批量方法(僅單庫模式支持)
//
// ⚠️ 爲什麼限制:
// - 分片2/3 下數據天然分散,批量無法"攤薄"連接開銷
// - 數據量小時批量 ≈ 逐條,數據量大時內存扛不住
// - 分片模式下請直接用 get / set / delete 循環
// ============================================================
/**
* 批量讀取(僅單庫)
* @return array ['key' => 'value', ...],未命中的 key 不在結果裏
*/
public function getMulti($keys)
{
$db = $this->getSingleModeDb('getMulti');
if (!$db) return array();
if (empty($keys) || !is_array($keys)) return array();
// md5 => 原 key
$md5ToKey = array();
foreach ($keys as $key) {
$md5 = md5($key);
if (!isset($md5ToKey[$md5])) $md5ToKey[$md5] = $key;
}
$result = array();
$now = time();
foreach (array_chunk(array_keys($md5ToKey), self::CHUNK_SIZE) as $chunk) {
try {
$ph = implode(',', array_fill(0, count($chunk), '?'));
$stmt = $db->prepare("SELECT md5, content, expire_time FROM cache WHERE md5 IN ($ph)");
$stmt->execute($chunk);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($this->isExpired($row['expire_time'], $now)) continue;
$value = $this->decodeContent($row['content']);
if ($value === false) continue;
$result[$md5ToKey[$row['md5']]] = $value;
}
} catch (Exception $e) {
error_log("ApiCache getMulti error: " . $e->getMessage());
}
}
return $result;
}
/**
* 批量寫入(僅單庫)
* @return int 成功寫入條數
*/
public function setMulti($items, $ttl = 0)
{
$db = $this->getSingleModeDb('setMulti');
if (!$db) return 0;
if (empty($items) || !is_array($items)) return 0;
$expire = ($ttl > 0) ? time() + $ttl : 0;
$now = time();
$success = 0;
try {
$db->beginTransaction();
foreach ($items as $key => $value) {
$compressed = $this->encodeContent($value);
if ($compressed === false) continue;
$md5 = md5($key);
if ($this->writeEntry($db, $md5, $compressed, $expire, $now)) {
$success++;
}
}
$db->commit();
} catch (Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("ApiCache setMulti error: " . $e->getMessage());
}
return $success;
}
/**
* 批量刪除(僅單庫)
* @return int 成功刪除條數
*/
public function deleteMulti($keys)
{
$db = $this->getSingleModeDb('deleteMulti');
if (!$db) return 0;
if (empty($keys) || !is_array($keys)) return 0;
$md5s = array();
foreach ($keys as $key) {
$md5s[] = md5($key);
}
$total = 0;
foreach (array_chunk($md5s, self::CHUNK_SIZE) as $chunk) {
try {
$ph = implode(',', array_fill(0, count($chunk), '?'));
$stmt = $db->prepare("DELETE FROM cache WHERE md5 IN ($ph)");
$stmt->execute($chunk);
$total += $stmt->rowCount();
} catch (Exception $e) {
error_log("ApiCache deleteMulti error: " . $e->getMessage());
}
}
return $total;
}
// ============================================================
// 全頁面緩存
// ============================================================
public function page($key, $ttl = 0)
{
$content = $this->get($key);
if ($content !== false) {
echo $content;
exit;
}
if (function_exists('error_clear_last')) error_clear_last();
ob_start(function($buffer) use ($key, $ttl) {
try {
if ($buffer === '') return $buffer;
$err = error_get_last();
if ($err !== null && in_array($err['type'], self::FATAL_ERRORS, true)) {
return $buffer;
}
if (stripos($buffer, 'Fatal error') !== false ||
stripos($buffer, 'Parse error') !== false) {
return $buffer;
}
$this->set($key, $buffer, $ttl);
} catch (Exception $e) {
error_log('ApiCache page flush failed: ' . $e->getMessage());
}
return $buffer;
});
}
// ============================================================
// 內部工具
// ============================================================
/**
* 獲取單庫連接(僅單庫模式有效)
*
* 三個批量方法共用,同時完成"單庫檢查 + 獲取連接"兩步。
*
* @param string $method 調用方方法名(僅用於日誌)
* @return PDO|false
*/
private function getSingleModeDb($method)
{
if ($this->level1 !== 0) {
error_log("ApiCache: $method 僅支持單庫模式(分片1),當前爲分片模式");
return false;
}
return $this->getDbByFile($this->root . $this->dbName . '.db', $this->root);
}
private function shardPaths($md5)
{
if ($this->level1 === 0) {
// 單庫模式:$this->dbName 由構造函數保證有效
return array('file' => $this->root . $this->dbName . '.db', 'dir' => $this->root);
}
$dir1 = substr($md5, 0, $this->level1);
$dir2 = substr($md5, 0, $this->level2);
$dir = $this->root . $dir1;
return array('file' => $dir . '/' . $dir2 . '.db', 'dir' => $dir);
}
private function getDbByFile($dbFile, $dirPath)
{
if (isset($this->dbPool[$dbFile])) return $this->dbPool[$dbFile];
$pdo = $this->openDb($dbFile, $dirPath);
if ($pdo) $this->dbPool[$dbFile] = $pdo;
return $pdo;
}
private function getDb($md5)
{
$p = $this->shardPaths($md5);
return $this->getDbByFile($p['file'], $p['dir']);
}
/**
* 打開數據庫連接
*
* 只保留"0KB 空文件刪除"(安全)。
* 打開失敗直接返回 false,不重試、不刪主庫。
* 壞庫交給清理腳本處理。
*/
private function openDb($dbFile, $dirPath)
{
if (!is_dir($dirPath)) {
if (!mkdir($dirPath, 0755, true)) {
error_log("無法創建目錄: $dirPath");
return false;
}
}
// 只刪"真正的空文件":0 字節 且 無 WAL
if (file_exists($dbFile) && filesize($dbFile) === 0
&& !file_exists($dbFile . '-wal')) {
@unlink($dbFile);
}
try {
$pdo = new PDO("sqlite:" . $dbFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE IF NOT EXISTS cache (
md5 TEXT PRIMARY KEY,
content BLOB,
access_count INTEGER DEFAULT 0,
first_time INTEGER,
expire_time INTEGER DEFAULT 0
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_access_count ON cache(access_count)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_expire_time ON cache(expire_time)");
$pdo->exec("PRAGMA journal_mode = WAL");
$pdo->exec("PRAGMA busy_timeout = 5000");
$pdo->exec("PRAGMA synchronous = OFF");
return $pdo;
} catch (PDOException $e) {
error_log("SQLite open failed: $dbFile - " . $e->getMessage());
return false;
}
}
}
// ============================================================
// 全局輔助函數
// ============================================================
function get_cache_instance($dirName, $shardType = null, $dbName = null)
{
static $instances = array();
$key = $dirName . '|' . $shardType . '|' . ($dbName !== null ? $dbName : '');
if (!isset($instances[$key])) {
$instances[$key] = new ApiCache($dirName, $shardType, $dbName);
}
return $instances[$key];
}
function cache_page($key, $dirName = 'Mpage', $shardType = null, $ttl = 0)
{
$cache = get_cache_instance($dirName, $shardType);
$cache->page($key, $ttl);
}清理代碼:
查看緩存的腳本
可在瀏覽器中,查看緩存後的腳本。
<?php
// ============================================================
// 緩存管理器 - 安全增強版
// 放置路徑:CACHE_ROOT 目錄下(例如 /Mcache/index.php)
// ============================================================
// ---------- 安全配置 ----------
// 建議在外部配置文件定義,這裏作爲示例
// 若未設置環境變量,則使用默認(並提示錯誤)
if (!defined('CACHE_ROOT')) {
define('CACHE_ROOT', __DIR__);
}
// 密碼哈希(請使用 password_hash('你的密碼', PASSWORD_DEFAULT) 生成後替換)
// 例如:$hash = '$2y$10$ABCDEFGHIJKLMNOPQRSTUVWXYZ...';
$ADMIN_HASH = getenv('CACHE_ADMIN_HASH') ?: ''; // 從環境變量讀取
if (empty($ADMIN_HASH)) {
// 如果沒有設置,則默認一個測試密碼(但會警告)
$ADMIN_HASH = '$2y$10$N6lQZ.UoMvlYIWrJ3AbjAu6.8pDZ1dY2rVfXcNlD43hW1A0wGk0C'; // 對應 "admin123"
// 生產環境務必在環境變量中設置!
}
define('ADMIN_HASH', $ADMIN_HASH);
define('PAGE_SIZE', 20);
define('MAX_LOGIN_ATTEMPTS', 5);
define('LOCKOUT_TIME', 1800); // 30分鐘
// ---------- 會話安全 ----------
ini_set('session.cookie_httponly', 1);
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
ini_set('session.cookie_secure', 1);
}
ini_set('session.cookie_samesite', 'Strict');
session_start();
// ---------- 登錄邏輯 ----------
function isAuthenticated() {
return isset($_SESSION['cache_manager_auth']) && $_SESSION['cache_manager_auth'] === true;
}
// 登錄失敗計數
if (!isset($_SESSION['login_attempts'])) {
$_SESSION['login_attempts'] = 0;
}
if (!isset($_SESSION['lockout_until'])) {
$_SESSION['lockout_until'] = 0;
}
// 登出
if (isset($_GET['logout'])) {
unset($_SESSION['cache_manager_auth']);
unset($_SESSION['login_attempts']);
unset($_SESSION['lockout_until']);
session_destroy();
header('Location: ?');
exit;
}
// 處理登錄
if (isset($_POST['password'])) {
$now = time();
// 檢查是否鎖定
if ($_SESSION['lockout_until'] > $now) {
$remain = $_SESSION['lockout_until'] - $now;
$loginError = "登錄嘗試過多,請等待 " . ceil($remain/60) . " 分鐘後再試。";
} else {
// 重置鎖定
if ($_SESSION['lockout_until'] > 0) {
$_SESSION['login_attempts'] = 0;
$_SESSION['lockout_until'] = 0;
}
$inputPass = $_POST['password'];
// 驗證密碼(使用 password_verify)
if (password_verify($inputPass, ADMIN_HASH)) {
$_SESSION['cache_manager_auth'] = true;
$_SESSION['login_attempts'] = 0;
header('Location: ?');
exit;
} else {
$_SESSION['login_attempts']++;
if ($_SESSION['login_attempts'] >= MAX_LOGIN_ATTEMPTS) {
$_SESSION['lockout_until'] = time() + LOCKOUT_TIME;
$loginError = "登錄嘗試過多,已鎖定 30 分鐘。";
} else {
$loginError = "密碼錯誤,還剩 " . (MAX_LOGIN_ATTEMPTS - $_SESSION['login_attempts']) . " 次嘗試機會。";
}
}
}
}
if (!isAuthenticated()) {
?>
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>緩存管理器登錄</title></head>
<body style="font-family:sans-serif;max-width:400px;margin:100px auto;text-align:center;">
<h2>登錄</h2>
<?php if (isset($loginError)) echo "<p style='color:red;'>$loginError</p>"; ?>
<form method="post">
<input type="password" name="password" placeholder="密碼" style="width:100%;padding:8px;margin:10px 0;">
<button type="submit">登錄</button>
</form>
</body></html>
<?php
exit;
}
// ---------- CSRF 保護 ----------
function generateCsrfToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verifyCsrfToken($token) {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
// 生成 token(用於表單)
$csrfToken = generateCsrfToken();
// ---------- 工具函數 ----------
function h($str) { return htmlspecialchars($str, ENT_QUOTES, 'UTF-8'); }
function decompress($data) { $raw = @gzuncompress($data); return ($raw !== false) ? $raw : $data; }
function compress($data) { return (strlen($data) < 200) ? $data : gzcompress($data, 6); }
function getParam($name, $default = '') { return isset($_GET[$name]) ? $_GET[$name] : $default; }
// ---------- 路徑安全函數 ----------
function safePath($path) {
$path = str_replace(['..', '\\'], '', $path);
$fullPath = __DIR__ . '/' . ltrim($path, '/');
$realPath = realpath($fullPath);
if ($realPath === false) return false;
// 必須位於 CACHE_ROOT 內
$rootReal = realpath(__DIR__);
if (strpos($realPath, $rootReal) !== 0) {
return false;
}
return $realPath;
}
// ---------- 請求處理 ----------
// 僅允許 POST 修改操作
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 驗證 CSRF token
if (!isset($_POST['csrf_token']) || !verifyCsrfToken($_POST['csrf_token'])) {
die('CSRF 驗證失敗,請刷新頁面重試。');
}
// 處理編輯
if (isset($_POST['action']) && $_POST['action'] === 'edit') {
$dbFile = basename($_POST['file'] ?? '');
$editMd5 = $_POST['md5'] ?? '';
$editContent = $_POST['content'] ?? '';
$currentDir = $_POST['dir'] ?? '';
$page = (int)($_POST['page'] ?? 1);
if ($dbFile && $editMd5) {
$fullDir = safePath($currentDir);
if (!$fullDir) {
die('非法目錄');
}
$fullDbPath = $fullDir . '/' . $dbFile;
if (!file_exists($fullDbPath) || pathinfo($fullDbPath, PATHINFO_EXTENSION) !== 'db') {
die('非法數據庫文件');
}
try {
$pdo = new PDO("sqlite:" . $fullDbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $pdo->prepare("SELECT content FROM cache WHERE md5 = ?");
$stmt->execute([$editMd5]);
if ($stmt->fetch()) {
$compressed = compress($editContent);
$upd = $pdo->prepare("UPDATE cache SET content = ? WHERE md5 = ?");
$upd->execute([$compressed, $editMd5]);
$msg = '記錄更新成功';
} else {
$msg = '記錄不存在,無法更新';
}
} catch (Exception $e) {
$msg = '更新失敗:' . $e->getMessage();
}
header('Location: ?dir=' . urlencode($currentDir) . '&file=' . urlencode($dbFile) . '&page=' . $page . '&msg=' . urlencode($msg));
exit;
}
}
// 處理刪除
if (isset($_POST['action']) && $_POST['action'] === 'delete') {
$dbFile = basename($_POST['file'] ?? '');
$editMd5 = $_POST['md5'] ?? '';
$currentDir = $_POST['dir'] ?? '';
$page = (int)($_POST['page'] ?? 1);
if ($dbFile && $editMd5) {
$fullDir = safePath($currentDir);
if (!$fullDir) {
die('非法目錄');
}
$fullDbPath = $fullDir . '/' . $dbFile;
if (!file_exists($fullDbPath) || pathinfo($fullDbPath, PATHINFO_EXTENSION) !== 'db') {
die('非法數據庫文件');
}
try {
$pdo = new PDO("sqlite:" . $fullDbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$del = $pdo->prepare("DELETE FROM cache WHERE md5 = ?");
$del->execute([$editMd5]);
$msg = '記錄已刪除';
} catch (Exception $e) {
$msg = '刪除失敗:' . $e->getMessage();
}
header('Location: ?dir=' . urlencode($currentDir) . '&file=' . urlencode($dbFile) . '&page=' . $page . '&msg=' . urlencode($msg));
exit;
}
}
}
// ---------- GET 參數處理 ----------
$currentDir = str_replace(['..', '\\'], '', getParam('dir', ''));
$fullDir = __DIR__ . '/' . $currentDir;
if (!is_dir($fullDir)) { $currentDir = ''; $fullDir = __DIR__; }
$dbFile = basename(getParam('file', ''));
$fullDbPath = $fullDir . '/' . $dbFile;
if ($dbFile && (!file_exists($fullDbPath) || !is_file($fullDbPath) || pathinfo($fullDbPath, PATHINFO_EXTENSION) !== 'db')) {
$dbFile = '';
}
$page = max(1, (int)getParam('page', 1));
$offset = ($page - 1) * PAGE_SIZE;
$msg = getParam('msg', '');
if ($msg) $msg = urldecode($msg);
// 編輯表單(GET 方式僅用於展示表單,不修改數據)
$editMd5 = getParam('md5', '');
$action = getParam('action');
$showEditForm = ($action === 'edit_form' && $editMd5 && $dbFile);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>緩存管理器</title>
<style>
body { font-family: "Segoe UI", Arial, sans-serif; margin: 20px; background: #f5f7fa; }
.container { max-width: 1200px; margin: 0 auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { font-size: 24px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.breadcrumb { margin: 15px 0; font-size: 14px; }
.breadcrumb a { color: #3498db; text-decoration: none; }
.msg { padding: 10px; background: #d4edda; color: #155724; border: 1px solid #c3e6cb; border-radius: 4px; margin: 10px 0; }
.msg.error { background: #f8d7da; color: #721c24; border-color: #f5c6cb; }
.nav { margin: 10px 0; }
.nav a { display: inline-block; padding: 6px 12px; background: #3498db; color: #fff; border-radius: 4px; text-decoration: none; margin-right: 5px; }
.nav a:hover { background: #2980b9; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 14px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: 600; }
tr:hover { background: #f1f5f9; }
.file-icon { color: #3498db; font-weight: bold; }
.dir-icon { color: #f39c12; font-weight: bold; }
.actions a { margin-right: 8px; color: #3498db; text-decoration: none; }
.actions a:hover { text-decoration: underline; }
.actions a.delete { color: #e74c3c; }
.pagination { margin-top: 20px; text-align: center; }
.pagination a, .pagination span { display: inline-block; padding: 5px 12px; margin: 0 3px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; color: #333; }
.pagination .active { background: #3498db; color: #fff; border-color: #3498db; }
.pagination a:hover { background: #eee; }
.content-preview { max-height: 150px; overflow: auto; background: #f8f9fa; padding: 6px; border-radius: 4px; font-family: monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
.edit-form { margin-top: 20px; border-top: 2px solid #eee; padding-top: 20px; }
.edit-form textarea { width: 100%; height: 300px; font-family: monospace; font-size: 14px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.edit-form .btn { padding: 8px 20px; background: #2ecc71; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.edit-form .btn:hover { background: #27ae60; }
.edit-form .btn.cancel { background: #95a5a6; }
.edit-form .btn.cancel:hover { background: #7f8c8d; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: bold; }
.badge-expired { background: #e74c3c; color: #fff; }
.badge-active { background: #2ecc71; color: #fff; }
.badge-never { background: #95a5a6; color: #fff; }
.footer { margin-top: 30px; text-align: center; color: #999; font-size: 12px; }
.logout { float: right; margin-top: 10px; }
.csrf-warning { color: #e74c3c; font-size: 13px; margin-top: 5px; }
</style>
</head>
<body>
<div class="container">
<h1>📁 緩存管理器
<span class="logout"><a href="?logout=1" style="color:#e74c3c; text-decoration:none;">退出</a></span>
</h1>
<?php if ($msg): ?>
<div class="msg <?= strpos($msg, '失敗') !== false || strpos($msg, '錯誤') !== false ? 'error' : '' ?>"><?= h($msg) ?></div>
<?php endif; ?>
<div class="breadcrumb">
<a href="?">根目錄</a>
<?php
if ($currentDir) {
$parts = explode('/', $currentDir);
$path = '';
foreach ($parts as $part) {
$path .= $part . '/';
echo ' / <a href="?dir=' . urlencode(rtrim($path, '/')) . '">' . h($part) . '</a>';
}
}
if ($dbFile) echo ' / <strong>' . h($dbFile) . '</strong>';
?>
</div>
<?php if ($dbFile): ?>
<?php
try {
$pdo = new PDO("sqlite:" . $fullDbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$tableExists = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='cache'")->fetch();
if (!$tableExists) {
echo '<p style="color:red;">此數據庫中沒有 cache 表。</p>';
} else {
$totalStmt = $pdo->query("SELECT COUNT(*) FROM cache");
$total = $totalStmt->fetchColumn();
$stmt = $pdo->prepare("SELECT md5, content, access_count, first_time, expire_time FROM cache ORDER BY first_time DESC LIMIT ? OFFSET ?");
$stmt->bindValue(1, PAGE_SIZE, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo '<div class="nav">';
echo '<a href="?dir=' . urlencode($currentDir) . '">⬅ 返回目錄</a>';
echo ' <span style="margin-left:20px;">總記錄數:' . $total . '</span>';
echo '</div>';
if ($total == 0) {
echo '<p>此緩存數據庫爲空。</p>';
} else {
echo '<table>';
echo '<tr><th>MD5</th><th>訪問數</th><th>首次時間</th><th>過期時間</th><th>內容預覽</th><th>操作</th></tr>';
foreach ($rows as $row) {
$expire = $row['expire_time'];
$now = time();
$status = $expire == 0 ? '<span class="badge badge-never">永不過期</span>' :
($expire < $now ? '<span class="badge badge-expired">已過期</span>' : '<span class="badge badge-active">有效</span>');
$raw = decompress($row['content']);
$preview = mb_strlen($raw) > 200 ? mb_substr($raw, 0, 200) . '...' : $raw;
$preview = h($preview);
?>
<tr>
<td style="font-family:monospace; font-size:12px;"><?= h($row['md5']) ?></td>
<td><?= h($row['access_count']) ?></td>
<td><?= date('Y-m-d H:i:s', $row['first_time']) ?></td>
<td><?= $row['expire_time'] ? date('Y-m-d H:i:s', $row['expire_time']) : '0(永久)' ?> <?= $status ?></td>
<td><div class="content-preview"><?= $preview ?></div></td>
<td class="actions">
<a href="?dir=<?= urlencode($currentDir) ?>&file=<?= urlencode($dbFile) ?>&page=<?= $page ?>&action=edit_form&md5=<?= h($row['md5']) ?>#edit-form">編輯</a>
<a href="#" class="delete" onclick="deleteRecord('<?= h($row['md5']) ?>')">刪除</a>
</td>
</tr>
<?php
}
echo '</table>';
// 分頁
if ($total > PAGE_SIZE) {
$totalPages = ceil($total / PAGE_SIZE);
echo '<div class="pagination">';
if ($page > 1) echo '<a href="?dir=' . urlencode($currentDir) . '&file=' . urlencode($dbFile) . '&page=' . ($page-1) . '">上一頁</a>';
for ($i = 1; $i <= $totalPages; $i++) {
echo $i == $page ? '<span class="active">' . $i . '</span>' :
'<a href="?dir=' . urlencode($currentDir) . '&file=' . urlencode($dbFile) . '&page=' . $i . '">' . $i . '</a>';
}
if ($page < $totalPages) echo '<a href="?dir=' . urlencode($currentDir) . '&file=' . urlencode($dbFile) . '&page=' . ($page+1) . '">下一頁</a>';
echo '</div>';
}
// ---------- 編輯表單(僅顯示,實際提交用 POST) ----------
if ($showEditForm) {
$stmt = $pdo->prepare("SELECT content FROM cache WHERE md5 = ?");
$stmt->execute([$editMd5]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$decrypted = decompress($row['content']);
?>
<div class="edit-form" id="edit-form">
<h3>編輯記錄 (MD5: <?= h($editMd5) ?>)</h3>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="<?= $csrfToken ?>">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="dir" value="<?= h($currentDir) ?>">
<input type="hidden" name="file" value="<?= h($dbFile) ?>">
<input type="hidden" name="page" value="<?= $page ?>">
<input type="hidden" name="md5" value="<?= h($editMd5) ?>">
<textarea name="content" rows="15"><?= h($decrypted) ?></textarea>
<br>
<button type="submit" class="btn">保存</button>
<a href="?dir=<?= urlencode($currentDir) ?>&file=<?= urlencode($dbFile) ?>&page=<?= $page ?>" class="btn cancel">取消</a>
</form>
</div>
<?php
} else {
echo '<p style="color:red;">錯誤:記錄不存在</p>';
}
}
}
}
} catch (PDOException $e) {
echo '<p style="color:red;">數據庫錯誤:' . h($e->getMessage()) . '</p>';
}
?>
<?php else: ?>
<!-- 目錄瀏覽 -->
<?php
$dirs = glob($fullDir . '/*', GLOB_ONLYDIR);
$files = glob($fullDir . '/*.db');
?>
<div class="nav">
<?php if ($currentDir): ?>
<a href="?dir=<?= urlencode(dirname($currentDir)) ?>">⬅ 上一級</a>
<?php endif; ?>
<a href="?">🏠 根目錄</a>
</div>
<?php if (empty($dirs) && empty($files)): ?>
<p>此目錄爲空</p>
<?php else: ?>
<table>
<tr><th>名稱</th><th>類型</th><th>操作</th></tr>
<?php foreach ($dirs as $dir): ?>
<?php $name = basename($dir); ?>
<tr>
<td><span class="dir-icon">📁</span> <a href="?dir=<?= urlencode($currentDir ? $currentDir . '/' . $name : $name) ?>"><?= h($name) ?></a></td>
<td>目錄</td>
<td><a href="?dir=<?= urlencode($currentDir ? $currentDir . '/' . $name : $name) ?>">進入</a></td>
</tr>
<?php endforeach; ?>
<?php foreach ($files as $file): ?>
<?php $name = basename($file); ?>
<tr>
<td><span class="file-icon">🗄️</span> <a href="?dir=<?= urlencode($currentDir) ?>&file=<?= urlencode($name) ?>"><?= h($name) ?></a></td>
<td>SQLite 數據庫</td>
<td><a href="?dir=<?= urlencode($currentDir) ?>&file=<?= urlencode($name) ?>">查看</a></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php endif; ?>
<div class="footer">
緩存管理器 • 編輯後保存會自動壓縮
</div>
</div>
<script>
// 刪除操作使用 POST + CSRF 保護
function deleteRecord(md5) {
if (!confirm('確定刪除此記錄嗎?')) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '';
var fields = {
'csrf_token': '<?= $csrfToken ?>',
'action': 'delete',
'dir': '<?= h($currentDir) ?>',
'file': '<?= h($dbFile) ?>',
'page': '<?= $page ?>',
'md5': md5
};
for (var key in fields) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = fields[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
</script>
</body>
</html>
使用教程
/www/wwwroot/你的站/
├── Mcache/ ← 緩存根目錄(自動創建)
│ ├── Mpage/ ← 頁面緩存(分片2)
│ │ ├── a/
│ │ │ └── a3f.db
│ │ └── ...
│ ├── Mtrans/ ← 翻譯緩存(分片1,單庫)
│ │ └── single.db
│ └── Mbig/ ← 大緩存(分片3)
├── lib/
│ └── ApiCache.php ← 緩存系統文件
└── index.php一句話速查表
| 需求 | 代碼 |
|---|---|
| 基礎寫 | $c->set('key', $data, 3600) |
| 基礎讀 | $c->get('key') |
| 刪除 | $c->delete('key') |
| 頁面緩存 | cache_page('key', 'Mpage', null, 600) |
| 翻譯緩存 | get_cache_instance('Mtrans', 1) |
| 大緩存 | get_cache_instance('Mbig', 3) |
| 判斷命中 | $v = $c->get('k'); if ($v !== false) {...} |
這套插件本質上是一個“不帶網絡開銷的本地 Redis”,適合資源有限但想大幅提升響應速度的場景。