红穆笔记
首頁 踩坑總結 高效率網頁緩存頁面代碼
踩坑總結

高效率網頁緩存頁面代碼

高效率网页缓存页面代码

之前研究了,高性能 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);
}

清理代碼:

🔒 隱藏內容:評論後查看

#!/usr/bin/env php
<?php
if (php_sapi_name() !== 'cli') {
    die("僅允許 CLI 運行\n");
}

// ---------- 配置區域 ----------
define('CACHE_ROOT', '/www/wwwroot/musictxt.4s5.cn/Mpage');

// 清理任務列表
$CLEAN_TASKS = [
    [
        'path'        => CACHE_ROOT . '/WpObject',
        'max_records' => 200000,   // 超過此數量則強制淘汰最冷數據
    ],
];
// ---------- 執行清理 ----------

echo "[" . date('Y-m-d H:i:s') . "] 開始清理(過期刪除 + 超限冷淘汰)...\n";
$totalExpired = 0;
$totalOverLimit = 0;

foreach ($CLEAN_TASKS as $task) {
    $path = $task['path'];
    echo "\n--- 清理任務: $path ---\n";
    if (!is_dir($path)) {
        echo "  目錄不存在,跳過\n";
        continue;
    }

    $dbFiles = getDbFiles($path);
    if (empty($dbFiles)) {
        echo "  沒有找到 .db 文件\n";
        continue;
    }
    echo "  發現 " . count($dbFiles) . " 個 .db 文件\n";

    // 第一步:刪除所有已過期的記錄
    $deletedExpired = 0;
    $fileCount = 0;
    foreach ($dbFiles as $dbFile) {
        $fileCount++;
        $deleted = cleanExpiredRecords($dbFile);
        if ($deleted > 0) {
            echo "  過期清理 $dbFile : 刪除 $deleted 條\n";
        }
        $deletedExpired += $deleted;
        if ($fileCount % 10 == 0) {
            echo "  已處理 $fileCount 個文件...\n";
        }
    }
    echo "第一步完成,共刪除過期緩存 $deletedExpired 條\n";
    $totalExpired += $deletedExpired;

    // 第二步:如果總數仍超限,按訪問量刪除最冷數據
    $totalRecords = countAllRecords($dbFiles);
    $max = $task['max_records'];
    if ($totalRecords > $max) {
        $need = $totalRecords - $max;
        echo "記錄數 $totalRecords 超過限制($max),需刪除 $need 條最冷數據\n";
        $deletedCold = deleteColdestRecords($dbFiles, $need);
        echo "第二步刪除 $deletedCold 條\n";
        $totalOverLimit += $deletedCold;
    } else {
        echo "記錄數 $totalRecords 未超限 $max\n";
    }

    // 清理空目錄(可選)
    removeEmptyDirs($path, false);
}

echo "\n[" . date('Y-m-d H:i:s') . "] 清理完成\n";
echo "彙總:過期刪除 $totalExpired 條,超限冷淘汰 $totalOverLimit 條\n";

// ---------- 輔助函數 ----------

/**
 * 遞歸獲取指定目錄下所有 .db 文件
 */
function getDbFiles($root) {
    $files = [];
    if (!is_dir($root)) return $files;
    $iter = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
    );
    foreach ($iter as $file) {
        if ($file->isFile() && $file->getExtension() === 'db') {
            $files[] = $file->getPathname();
        }
    }
    return $files;
}

/**
 * 刪除某個 .db 文件中所有已過期的記錄(基於 expire_time)
 * 分批處理,避免長事務鎖表
 */
function cleanExpiredRecords($dbFile) {
    try {
        $pdo = new PDO("sqlite:" . $dbFile);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $now = time();

        $limit = 1000;
        $lastMd5 = '';
        $totalDeleted = 0;

        do {
            $stmt = $pdo->prepare("SELECT md5, expire_time FROM cache WHERE md5 > ? ORDER BY md5 LIMIT ?");
            $stmt->execute([$lastMd5, $limit]);
            $rows = $stmt->fetchAll();
            if (empty($rows)) break;

            $toDelete = [];
            foreach ($rows as $row) {
                if ($row['expire_time'] > 0 && $row['expire_time'] < $now) {
                    $toDelete[] = $row['md5'];
                }
            }
            if (!empty($toDelete)) {
                $in = implode(',', array_fill(0, count($toDelete), '?'));
                $del = $pdo->prepare("DELETE FROM cache WHERE md5 IN ($in)");
                $del->execute($toDelete);
                $totalDeleted += $del->rowCount();
            }
            $lastMd5 = end($rows)['md5'];
        } while (true);

        unset($pdo);
        return $totalDeleted;
    } catch (Exception $e) {
        error_log("清理失敗 $dbFile: " . $e->getMessage());
        return 0;
    }
}

/**
 * 統計所有 .db 文件中的總記錄數
 */
function countAllRecords($dbFiles) {
    $total = 0;
    foreach ($dbFiles as $dbFile) {
        try {
            $pdo = new PDO("sqlite:" . $dbFile);
            $total += (int) $pdo->query("SELECT COUNT(*) FROM cache")->fetchColumn();
            unset($pdo);
        } catch (Exception $e) {
            // 忽略損壞的數據庫
        }
    }
    return $total;
}

/**
 * 在多個 .db 文件中按比例刪除訪問量最低的記錄
 */
function deleteColdestRecords($dbFiles, $need) {
    // 先統計每個文件的記錄數
    $dbStats = [];
    $totalRecords = 0;
    foreach ($dbFiles as $dbFile) {
        try {
            $pdo = new PDO("sqlite:" . $dbFile);
            $count = (int) $pdo->query("SELECT COUNT(*) FROM cache")->fetchColumn();
            $dbStats[$dbFile] = $count;
            $totalRecords += $count;
            unset($pdo);
        } catch (Exception $e) {
            // 忽略
        }
    }
    if ($totalRecords == 0) return 0;

    $deleted = 0;
    // 按比例分配刪除名額
    foreach ($dbStats as $dbFile => $count) {
        if ($count == 0) continue;
        $deleteCount = (int) ceil($need * ($count / $totalRecords));
        if ($deleteCount <= 0) continue;
        try {
            $pdo = new PDO("sqlite:" . $dbFile);
            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $del = $pdo->prepare("DELETE FROM cache ORDER BY access_count ASC, first_time ASC LIMIT ?");
            $del->execute([$deleteCount]);
            $deleted += $del->rowCount();
            unset($pdo);
        } catch (Exception $e) {
            // 忽略
        }
    }

    // 如果還沒刪夠,補刪(每個文件再刪一條,直到滿足需求)
    if ($deleted < $need) {
        $remaining = $need - $deleted;
        foreach ($dbFiles as $dbFile) {
            if ($remaining <= 0) break;
            try {
                $pdo = new PDO("sqlite:" . $dbFile);
                $del = $pdo->prepare("DELETE FROM cache ORDER BY access_count ASC, first_time ASC LIMIT 1");
                $del->execute();
                if ($del->rowCount() > 0) {
                    $deleted++;
                    $remaining--;
                }
                unset($pdo);
            } catch (Exception $e) {
                // 忽略
            }
        }
    }
    return $deleted;
}

/**
 * 刪除空目錄(保留根目錄本身)
 */
function removeEmptyDirs($dir, $isRoot = true) {
    if (!is_dir($dir)) return;
    foreach (scandir($dir) as $item) {
        if ($item === '.' || $item === '..') continue;
        $path = $dir . DIRECTORY_SEPARATOR . $item;
        if (is_dir($path)) {
            removeEmptyDirs($path, false);
        }
    }
    if (!$isRoot && count(scandir($dir)) == 2) {
        @rmdir($dir);
    }
}

查看緩存的腳本

可在瀏覽器中,查看緩存後的腳本。

<?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">
        緩存管理器 &bull; 編輯後保存會自動壓縮
    </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>

20260817210451658-image

使用教程

/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”,適合資源有限但想大幅提升響應速度的場景。

微信赞赏

微信

支付宝赞赏

支付寶

✍️ 作者: 紅穆

網站管理員 · 感謝閱讀,更多精彩內容敬請關注

作者主頁 查看主頁 →

相關文章

浏览器缓存增加你网站二次访问速度

瀏覽器緩存增加你網站二次訪問速度 踩坑總結

使用瀏覽器緩存官方話語:如果用戶會多次訪問您的網站,那麼靜態資源的瀏覽器緩存可以節省用戶的時間。緩存標頭應當應用到所有可緩存的靜態資源中,而不僅僅是應用到一小部分靜態資源(例如,圖片)中。可緩存的資源包括JS和CSS文件、圖像文件及其他二進制對象文件(媒體文件和PDF文件等)。通常情況下,HTML不…
👁 202
常用正则表达式

常用正則表達式 踩坑總結

正則表達式網址(URL)[a-zA-z]+://[^\s]*IP地址(IP Address)((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)電子郵件(Email)\w+([-+.]\w+)*@\w+([-.]\w+)*…
👁 243

推薦閱讀

(自适应手机端)电子眼电子监控设备pbootcms网站模板 监控安防电子探头网站源码下载 0368

(自適應手機端)電子眼電子監控設備pbootcms網站模板 監控安防電子探頭網站源碼下載 0368 實用收藏 pbootcms模板

一款自適應手機端的電子眼監控與安防電子設備PbootCMS網站模板。設計風格科技安全,適合展示監控探頭、安防系統及智能硬件。有助於安防科技企業在線上展示產品,吸引家庭與商業客戶。模板展示 安裝說明 網站後臺:/admin.php 賬號:admin 密碼:admin 解壓密碼:www.4s5.cn相關…
👁 63
(自适应手机端)工业废水处理设备pbootcms网站模板 环保科技网站源码下载 0848

(自適應手機端)工業廢水處理設備pbootcms網站模板 環保科技網站源碼下載 0848 實用收藏 pbootcms模板

一款工業廢水處理設備與環保科技PbootCMS網站模板,支持PC與WAP端。設計風格專業科技,適合展示廢水處理技術、設備產品及工程案例。有助於環保工程公司在線上展示解決方案,吸引工業企業客戶。模板展示 安裝說明 網站後臺:/admin.php 賬號:admin 密碼:admin 解壓密碼:www.4…
👁 51
特色美食餐饮加盟网站模板 0167

特色美食餐飲加盟網站模板 0167 實用收藏 易優模板

此套eyoucms模板適用於特色美食與餐飲加盟行業,設計風格時尚食慾,能夠展示特色美食、餐飲品牌、加盟政策及門店形象。有助於餐飲連鎖品牌在線上吸引加盟商與消費者。模板展示 安裝說明 網站後臺:/login.php 賬號:admin 密碼:admin 相關文章易優CMS安裝常見問題總結易優CMS(Ey…
👁 39
制冷设备机械企业网站模板 0659

製冷設備機械企業網站模板 0659 實用收藏 易優模板

此套eyoucms模板適用於製冷設備與機械企業,設計風格專業工業,能夠展示製冷設備產品、機械系統、技術參數及工業應用。有助於製冷企業在線上展示產品,吸引商業與工業客戶。模板展示 安裝說明 網站後臺:/login.php 賬號:admin 密碼:admin 相關文章易優CMS安裝常見問題總結易優CMS…
👁 48
(PC+WAP)门窗定制pbootcms网站模板 铝合金门窗网站源码下载 0126

(PC+WAP)門窗定製pbootcms網站模板 鋁合金門窗網站源碼下載 0126 實用收藏 pbootcms模板

一款關於門窗定製與鋁合金門窗行業的PbootCMS網站模板,支持PC與WAP端訪問。設計風格現代簡潔,能夠很好地展示門窗產品系列與安裝案例。是門窗品牌或鋁合金加工企業搭建品牌官網、提升線上獲客能力的優質選擇。模板展示 安裝說明 網站後臺:/admin.php 賬號:admin 密碼:admin 解壓…
👁 41