紅穆ノート
レコメンド 落とし穴のまとめ 高効率なWebページキャッシュ用ページコード
落とし穴のまとめ

高効率なWebページキャッシュ用ページコード

高効率なWebページキャッシュ用ページコード

之前研究了,高性能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”,适合资源有限但想大幅提升响应速度的场景。

微信赞赏

WeChat

支付宝赞赏

Alipay

✍️ クリエイター: 紅穆

ウェブサイト管理者 · ご覧いただきありがとうございます!さらに多くの魅力的なコンテンツをお楽しみいただけますよう、引き続きご支援をお願いいたします。

著者プロフィール プロフィールを見る →

関連記事

ブラウザのキャッシュ利用により、お客様のウェブサイトへの再アクセス速度が向上します。

ブラウザのキャッシュ利用により、お客様のウェブサイトへの再アクセス速度が向上します。 落とし穴のまとめ

ブラウザキャッシュの活用:ユーザーが同サイトを複数回アクセスする場合、静的リソースのブラウザキャッシュを活用することで、ユーザーの時間の節約が可能になります。キャッシュヘッダーは、画像などの一部の静的リソースに限定せず、すべてのキャッシュ可能な静的リソースに適用されるべきです。キャッシュ可能なリソースには、JSファイルやCSSファイル、画像ファイル、その他のバイナリ形式のファイル(メディアファイルやPDFファイルなど)が含まれます。通常、HTML自体は…
👁 208
よく使用される正規表現

よく使用される正規表現 落とし穴のまとめ

正規表現による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+)*…
👁 249
`file_get_contents` ページで表示内容が乱れている場合の解決方法

`file_get_contents` ページで表示内容が乱れている場合の解決方法 落とし穴のまとめ

私のページでは UTF-8を使用していますが、`file_get_contents`で取得したページデータはGB2312です。この場合、中文字が乱コードとして表示されます。解決方法は以下の通りです:以下のコードをコピーしてください:`<?php header("Content-Type: text/html; charset=utf-8"); $keyworld = "煤层"; …`
👁 205

おすすめ読書

xunsearchをPHPコードでMySQLデータベースにインポートする方法は?

xunsearchをPHPコードでMySQLデータベースにインポートする方法は? プログラムノート sphinx

Xunsearchを用いてMySQLデータベースをインポートするには、XunsearchのPHP拡張機能および関連するAPIを使用する必要があります。以下は、Xunsearchを既にインストールし、インデックス設定およびデータベース接続を完了していると仮定した簡単なサンプルコードです。<?php // Xunsearch拡張機能をインポートする require_once '/path/to/xun…'
👁 263
(適応型モバイル版)レスポンシブ設計の代理会計・財政コンサルティング向けPBootCMSウェブサイトテンプレート・HTML5財務会計系ウェブサイトソースコードダウンロード:0145

(適応型モバイル版)レスポンシブ設計の代理会計・財政コンサルティング向けPBootCMSウェブサイトテンプレート・HTML5財務会計系ウェブサイトソースコードダウンロード:0145 実用収集 pbootcmsテンプレート

HTML5技術を採用した、モバイル端末向けのレスポンシブ設計による代理会計処理および財務・税務コンサルティングサービス用PbootCMSウェブサイトテンプレートです。専門的で厳密なデザインスタイルにより、モバイル端末上で財務・税務サービス項目や業界における強みを明確に表示できます。これにより、財務・税務会社がモバイル端末を通じて効率的に顧客を獲得し、ブランドの専門性を高めることができます。テンプレート表示・インストール手順:ウェブサイトバックエンド:/admin.php、ユーザー名:admin、パスワード:admin、解凍用パスワード:…
👁 60
(スマートフォン向け適応型)pbootCMS用レスポンシブ英語対応の海外貿易企業向けウェブサイトテンプレート – 蓝色調の高級感のある汎用海外貿易ウェブサイトソースコードのダウンロード(0217)

(スマートフォン向け適応型)pbootCMS用レスポンシブ英語対応の海外貿易企業向けウェブサイトテンプレート – 蓝色調の高級感のある汎用海外貿易ウェブサイトソースコードのダウンロード(0217) 実用収集 pbootcmsテンプレート

本套自适应手机端的PbootCMS响应式英文外贸企业网站模板,采用蓝色高端风格。设计风格国际化专业,适合各类企业搭建英文外贸官网。有助于中国制造企业在全球市场上展示品牌形象与产品实力。模板展示 安装说明 网站后台:/admin.php 账号:admin 密码:admin 解压密码:www.4s5.c…
👁 67
(自适应手机端)餐饮小吃火锅店网站pbootcms模板 餐饮火锅加盟网站源码下载 0515

(自适应手机端)餐饮小吃火锅店网站pbootcms模板 餐饮火锅加盟网站源码下载 0515 実用収集 pbootcmsテンプレート

一款餐饮小吃与火锅加盟店PbootCMS网站模板,支持PC与WAP端。设计风格热烈食欲,适合展示火锅品牌、特色菜品及加盟政策。有助于餐饮品牌在线上吸引食客与加盟商,拓展市场。模板展示 安装说明 网站后台:/admin.php 账号:admin 密码:admin 解压密码:www.4s5.cn相关文章…
👁 66
(スマートフォン向け適応型)カメラ搭載電子機器用PBootCMSウェブサイトテンプレート/防犯電子機器用ウェブサイトソースコードダウンロード:0597

(スマートフォン向け適応型)カメラ搭載電子機器用PBootCMSウェブサイトテンプレート/防犯電子機器用ウェブサイトソースコードダウンロード:0597 実用収集 pbootcmsテンプレート

カメラおよびセキュリティ電子機器向けのPbootCMSウェブサイトテンプレートです。PCおよびWAP端末に対応しています。テクノロジー感とセキュリティ感を兼ね備えたデザインで、監視カメラ、セキュリティシステム、スマートハードウェアの紹介に最適です。セキュリティテクノロジー企業が自社製品をオンラインで紹介し、家庭用および商業用顧客を惹きつけるのに役立ちます。テンプレートの表示・設置説明:ウェブサイトバックエンド:/admin.php、ユーザー名:admin、パスワード:admin、解凍用パスワード:www.4s5.cn…
👁 52
(適応型モバイル版)レスポンシブデザインを活用したクリエイティブな飲食店・ホテル装飾デザイン向けウェブサイト用PBootCMSテンプレート(HTML5対応・青色系飲食店・ホテルデザインウェブサイトのソースコードダウンロード:0710)

(適応型モバイル版)レスポンシブデザインを活用したクリエイティブな飲食店・ホテル装飾デザイン向けウェブサイト用PBootCMSテンプレート(HTML5対応・青色系飲食店・ホテルデザインウェブサイトのソースコードダウンロード:0710) 実用収集 pbootcmsテンプレート

レスポンシブ設計に対応した、クリエイティブな飲食店・ホテルのインテリアデザイン用PbootCMSウェブサイトテンプレートです。PCおよびWAP端末に対応しています。モダンでスタイリッシュなデザインスタイルを採用しており、ホテルや飲食店の空間デザイン事例やクリエイティブなコンセプトを紹介するのに最適です。インテリアデザイン会社がオンラインで高級商業プロジェクトの顧客を獲得するのを支援します。テンプレート表示・インストール手順:ウェブサイト管理画面:/admin.php、ユーザー名:admin、パスワード:admin、解凍用パスワード:www.4s5.…
👁 39