新增 Exact Cache 模块,为已有的 ExactCache 核心类提供完整的管理界面和统计功能: - 模块骨架:module.json + ExactCacheModule(实现 ModuleInterface 全部 7 个方法) - AJAX 控制器:4 个 handler(保存设置/清空缓存/重置统计/获取统计),含安全三要素 - 成本估算器:基于 UsageTracker 历史数据估算缓存节省金额 - 日统计:7 天滚动统计,shutdown 批量写入,retry-once transient lock - 管理界面:统计卡片 + Chart.js 7 天趋势图 + 设置表单 + 缓存管理 - settings-page.php:添加精确缓存 tab(与 GEO/API Gateway 相同模式) - ExactCache.php:仅添加 get_entries() 只读方法,零核心影响 - uninstall.php:添加 option + transient 批量清理 经过 3 轮 Codex 架构审查(v1.0→v2.0→v2.1→v2.2),所有 P0/P1 问题已解决。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
540 lines
12 KiB
PHP
540 lines
12 KiB
PHP
<?php
|
||
/**
|
||
* Exact Cache Manager
|
||
*
|
||
* 为 AI 请求提供精确匹配缓存能力(请求哈希命中)。
|
||
*
|
||
* @package WPMind
|
||
* @subpackage Cache
|
||
* @since 4.0.0
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace WPMind\Cache;
|
||
|
||
/**
|
||
* Exact Cache 管理器
|
||
*
|
||
* @since 4.0.0
|
||
*/
|
||
final class ExactCache {
|
||
|
||
private const OPTION_ENABLED = 'wpmind_exact_cache_enabled';
|
||
private const OPTION_DEFAULT_TTL = 'wpmind_exact_cache_default_ttl';
|
||
private const OPTION_MAX_ENTRIES = 'wpmind_exact_cache_max_entries';
|
||
private const OPTION_INDEX = 'wpmind_exact_cache_index';
|
||
private const OPTION_STATS = 'wpmind_exact_cache_stats';
|
||
private const KEY_PREFIX = 'wpmind_ec_';
|
||
private const DEFAULT_MAX_ENTRIES = 500;
|
||
|
||
private static ?ExactCache $instance = null;
|
||
|
||
/**
|
||
* 内存中累积的统计增量,shutdown 时批量写入。
|
||
*
|
||
* @var array{hits:int,misses:int,writes:int,last_hit_at:int,last_miss_at:int,last_write_at:int,last_key:string}
|
||
*/
|
||
private array $pending_stats = [];
|
||
|
||
/**
|
||
* 内存中的索引快照(懒加载),shutdown 时批量写入。
|
||
*
|
||
* @var array<string,int>|null null 表示尚未加载
|
||
*/
|
||
private ?array $pending_index = null;
|
||
|
||
/**
|
||
* 索引是否有变更需要写入。
|
||
*
|
||
* @var bool
|
||
*/
|
||
private bool $index_dirty = false;
|
||
|
||
/**
|
||
* shutdown hook 是否已注册。
|
||
*
|
||
* @var bool
|
||
*/
|
||
private bool $shutdown_registered = false;
|
||
|
||
/**
|
||
* 获取单例
|
||
*
|
||
* @return ExactCache
|
||
*/
|
||
public static function instance(): ExactCache {
|
||
if (null === self::$instance) {
|
||
self::$instance = new self();
|
||
}
|
||
|
||
return self::$instance;
|
||
}
|
||
|
||
/**
|
||
* 禁止外部实例化
|
||
*/
|
||
private function __construct() {}
|
||
|
||
/**
|
||
* 缓存是否启用
|
||
*
|
||
* @return bool
|
||
*/
|
||
public function is_enabled(): bool {
|
||
$raw_value = get_option(self::OPTION_ENABLED, '1');
|
||
$disabled_values = [false, 0, '0', 'false', 'no', 'off'];
|
||
$enabled = !in_array($raw_value, $disabled_values, true);
|
||
|
||
return (bool) apply_filters('wpmind_exact_cache_enabled', $enabled);
|
||
}
|
||
|
||
/**
|
||
* 获取最大缓存条目限制
|
||
*
|
||
* @return int
|
||
*/
|
||
public function get_max_entries(): int {
|
||
$max_entries = (int) get_option(self::OPTION_MAX_ENTRIES, self::DEFAULT_MAX_ENTRIES);
|
||
if ($max_entries <= 0) {
|
||
$max_entries = self::DEFAULT_MAX_ENTRIES;
|
||
}
|
||
|
||
return (int) apply_filters('wpmind_exact_cache_max_entries', $max_entries);
|
||
}
|
||
|
||
/**
|
||
* 获取默认缓存 TTL
|
||
*
|
||
* @return int
|
||
*/
|
||
public function get_default_ttl(): int {
|
||
$default_ttl = (int) get_option(self::OPTION_DEFAULT_TTL, 900);
|
||
$default_ttl = max(0, min(86400, $default_ttl));
|
||
|
||
return (int) apply_filters('wpmind_exact_cache_default_ttl', $default_ttl);
|
||
}
|
||
|
||
/**
|
||
* 生成精确缓存键
|
||
*
|
||
* @param string $type 请求类型
|
||
* @param array $args 请求参数
|
||
* @param string $provider Provider
|
||
* @param string $model 模型
|
||
* @return string
|
||
*/
|
||
public function build_key(string $type, array $args, string $provider = '', string $model = ''): string {
|
||
$key_data = [
|
||
'v' => 1,
|
||
'type' => $type,
|
||
'provider' => $provider !== '' ? $provider : 'auto',
|
||
'model' => $model !== '' ? $model : 'auto',
|
||
'scope' => $this->build_scope(),
|
||
'args' => $this->normalize_for_hash($args),
|
||
];
|
||
|
||
$json = wp_json_encode($key_data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
if (!is_string($json) || $json === '') {
|
||
$json = serialize($key_data);
|
||
}
|
||
|
||
$key = self::KEY_PREFIX . substr(hash('sha256', $json), 0, 40);
|
||
|
||
return (string) apply_filters('wpmind_exact_cache_key', $key, $key_data);
|
||
}
|
||
|
||
/**
|
||
* 读取缓存
|
||
*
|
||
* @param string $cache_key 缓存键
|
||
* @return mixed|null
|
||
*/
|
||
public function get(string $cache_key) {
|
||
if (!$this->is_enabled()) {
|
||
return null;
|
||
}
|
||
|
||
$cached = get_transient($cache_key);
|
||
if ($cached === false || !is_array($cached) || !array_key_exists('payload', $cached)) {
|
||
$this->buffer_stat('misses', $cache_key);
|
||
if ($this->index_has($cache_key)) {
|
||
$this->remove_from_index($cache_key);
|
||
}
|
||
do_action('wpmind_exact_cache_miss', $cache_key);
|
||
|
||
return null;
|
||
}
|
||
|
||
$this->buffer_stat('hits', $cache_key);
|
||
do_action('wpmind_exact_cache_hit', $cache_key, $cached['meta'] ?? []);
|
||
|
||
return $cached['payload'];
|
||
}
|
||
|
||
/**
|
||
* 写入缓存
|
||
*
|
||
* @param string $cache_key 缓存键
|
||
* @param mixed $value 缓存值
|
||
* @param int $ttl TTL 秒数
|
||
* @param array $meta 元数据
|
||
* @return bool
|
||
*/
|
||
public function set(string $cache_key, $value, int $ttl, array $meta = []): bool {
|
||
if (!$this->is_enabled() || $ttl <= 0) {
|
||
return false;
|
||
}
|
||
|
||
$stored_payload = [
|
||
'payload' => $value,
|
||
'meta' => $meta,
|
||
'stored_at' => time(),
|
||
'expires_in' => $ttl,
|
||
];
|
||
|
||
$saved = set_transient($cache_key, $stored_payload, $ttl);
|
||
if (!$saved) {
|
||
return false;
|
||
}
|
||
|
||
$this->touch_index($cache_key);
|
||
$this->enforce_max_entries();
|
||
$this->buffer_stat('writes', $cache_key);
|
||
do_action('wpmind_exact_cache_store', $cache_key, $meta, $ttl);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 删除单个缓存
|
||
*
|
||
* @param string $cache_key 缓存键
|
||
* @return void
|
||
*/
|
||
public function delete(string $cache_key): void {
|
||
delete_transient($cache_key);
|
||
$this->remove_from_index($cache_key);
|
||
}
|
||
|
||
/**
|
||
* 清空所有精确缓存(按索引)
|
||
*
|
||
* @return void
|
||
*/
|
||
public function flush(): void {
|
||
$index = $this->load_index();
|
||
|
||
foreach (array_keys($index) as $cache_key) {
|
||
delete_transient($cache_key);
|
||
}
|
||
|
||
$this->pending_index = [];
|
||
$this->index_dirty = false;
|
||
$this->pending_stats = [];
|
||
|
||
delete_option(self::OPTION_INDEX);
|
||
update_option(self::OPTION_STATS, $this->get_default_stats(), false);
|
||
}
|
||
|
||
/**
|
||
* 获取缓存统计
|
||
*
|
||
* @return array
|
||
*/
|
||
public function get_stats(): array {
|
||
$stats = get_option(self::OPTION_STATS, []);
|
||
$stats = wp_parse_args(is_array($stats) ? $stats : [], $this->get_default_stats());
|
||
|
||
// 合并尚未写入的内存增量
|
||
foreach (['hits', 'misses', 'writes'] as $metric) {
|
||
if (isset($this->pending_stats[$metric])) {
|
||
$stats[$metric] = (int) $stats[$metric] + (int) $this->pending_stats[$metric];
|
||
}
|
||
}
|
||
|
||
$total_requests = (int) $stats['hits'] + (int) $stats['misses'];
|
||
$hit_rate = $total_requests > 0
|
||
? round(((int) $stats['hits'] / $total_requests) * 100, 2)
|
||
: 0.0;
|
||
|
||
$stats['enabled'] = $this->is_enabled();
|
||
$stats['hit_rate'] = $hit_rate;
|
||
$stats['entries'] = count($this->load_index());
|
||
$stats['max_entries'] = $this->get_max_entries();
|
||
|
||
return $stats;
|
||
}
|
||
|
||
/**
|
||
* 获取索引条目列表(供管理界面使用)
|
||
*
|
||
* @return array<int, array{key: string, last_access: int}>
|
||
*/
|
||
public function get_entries(): array {
|
||
$index = $this->load_index();
|
||
arsort($index, SORT_NUMERIC);
|
||
$entries = [];
|
||
foreach ($index as $key => $timestamp) {
|
||
$entries[] = [
|
||
'key' => $key,
|
||
'last_access' => (int) $timestamp,
|
||
];
|
||
}
|
||
return $entries;
|
||
}
|
||
|
||
/**
|
||
* 构建缓存作用域(避免跨站点/跨角色污染)
|
||
*
|
||
* @return array
|
||
*/
|
||
private function build_scope(): array {
|
||
$scope = [
|
||
'blog_id' => function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0,
|
||
'locale' => function_exists('get_locale') ? (string) get_locale() : '',
|
||
];
|
||
|
||
$scope_mode = (string) apply_filters('wpmind_exact_cache_scope_mode', 'role');
|
||
if ($scope_mode === 'user') {
|
||
$scope['user_id'] = get_current_user_id();
|
||
return $scope;
|
||
}
|
||
|
||
if ($scope_mode === 'none') {
|
||
$scope['segment'] = 'global';
|
||
return $scope;
|
||
}
|
||
|
||
$user = function_exists('wp_get_current_user') ? wp_get_current_user() : null;
|
||
$roles = [];
|
||
if ($user && !empty($user->roles) && is_array($user->roles)) {
|
||
$roles = array_values($user->roles);
|
||
sort($roles);
|
||
}
|
||
|
||
$scope['roles'] = $roles;
|
||
|
||
return $scope;
|
||
}
|
||
|
||
/**
|
||
* 归一化数组/对象,保证哈希稳定
|
||
*
|
||
* @param mixed $value 原始值
|
||
* @return mixed
|
||
*/
|
||
private function normalize_for_hash($value) {
|
||
if (is_array($value)) {
|
||
if ($this->is_assoc_array($value)) {
|
||
ksort($value);
|
||
}
|
||
|
||
foreach ($value as $key => $child) {
|
||
$value[$key] = $this->normalize_for_hash($child);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
if (is_object($value)) {
|
||
return $this->normalize_for_hash(get_object_vars($value));
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* 判断数组是否为关联数组
|
||
*
|
||
* @param array $array 数组
|
||
* @return bool
|
||
*/
|
||
private function is_assoc_array(array $array): bool {
|
||
if ($array === []) {
|
||
return false;
|
||
}
|
||
|
||
return array_keys($array) !== range(0, count($array) - 1);
|
||
}
|
||
|
||
/**
|
||
* 懒加载索引到内存
|
||
*
|
||
* @return array<string,int>
|
||
*/
|
||
private function load_index(): array {
|
||
if ($this->pending_index === null) {
|
||
$index = get_option(self::OPTION_INDEX, []);
|
||
$this->pending_index = is_array($index) ? $index : [];
|
||
}
|
||
|
||
return $this->pending_index;
|
||
}
|
||
|
||
/**
|
||
* 检查索引中是否存在指定键
|
||
*
|
||
* @param string $cache_key 缓存键
|
||
* @return bool
|
||
*/
|
||
private function index_has(string $cache_key): bool {
|
||
$index = $this->load_index();
|
||
return isset($index[$cache_key]);
|
||
}
|
||
|
||
/**
|
||
* 更新索引访问时间(内存操作,shutdown 时写入)
|
||
*
|
||
* @param string $cache_key 缓存键
|
||
* @return void
|
||
*/
|
||
private function touch_index(string $cache_key): void {
|
||
$this->load_index();
|
||
$this->pending_index[$cache_key] = time();
|
||
$this->index_dirty = true;
|
||
$this->register_shutdown();
|
||
}
|
||
|
||
/**
|
||
* 从索引中移除键(内存操作,shutdown 时写入)
|
||
*
|
||
* @param string $cache_key 缓存键
|
||
* @return void
|
||
*/
|
||
private function remove_from_index(string $cache_key): void {
|
||
$this->load_index();
|
||
if (!isset($this->pending_index[$cache_key])) {
|
||
return;
|
||
}
|
||
|
||
unset($this->pending_index[$cache_key]);
|
||
$this->index_dirty = true;
|
||
$this->register_shutdown();
|
||
}
|
||
|
||
/**
|
||
* 强制执行容量上限(LRU 近似:最早写入优先淘汰)
|
||
*
|
||
* @return void
|
||
*/
|
||
private function enforce_max_entries(): void {
|
||
$max_entries = $this->get_max_entries();
|
||
$index = $this->load_index();
|
||
$current_count = count($index);
|
||
|
||
if ($current_count <= $max_entries) {
|
||
return;
|
||
}
|
||
|
||
asort($index, SORT_NUMERIC);
|
||
$remove_count = $current_count - $max_entries;
|
||
$expired_keys = array_slice(array_keys($index), 0, $remove_count);
|
||
|
||
foreach ($expired_keys as $cache_key) {
|
||
delete_transient($cache_key);
|
||
unset($index[$cache_key]);
|
||
}
|
||
|
||
$this->pending_index = $index;
|
||
$this->index_dirty = true;
|
||
$this->register_shutdown();
|
||
}
|
||
|
||
/**
|
||
* 累积统计增量到内存(shutdown 时批量写入)
|
||
*
|
||
* @param string $metric 命中项(hits/misses/writes)
|
||
* @param string|null $cache_key 缓存键
|
||
* @return void
|
||
*/
|
||
private function buffer_stat(string $metric, ?string $cache_key = null): void {
|
||
if (!isset($this->pending_stats[$metric])) {
|
||
$this->pending_stats[$metric] = 0;
|
||
}
|
||
$this->pending_stats[$metric]++;
|
||
|
||
$timestamp_fields = [
|
||
'hits' => 'last_hit_at',
|
||
'misses' => 'last_miss_at',
|
||
'writes' => 'last_write_at',
|
||
];
|
||
|
||
if (isset($timestamp_fields[$metric])) {
|
||
$this->pending_stats[$timestamp_fields[$metric]] = time();
|
||
}
|
||
|
||
if ($cache_key !== null) {
|
||
$this->pending_stats['last_key'] = $cache_key;
|
||
}
|
||
|
||
$this->register_shutdown();
|
||
}
|
||
|
||
/**
|
||
* 注册 shutdown hook(仅一次)
|
||
*
|
||
* @return void
|
||
*/
|
||
private function register_shutdown(): void {
|
||
if ($this->shutdown_registered) {
|
||
return;
|
||
}
|
||
|
||
add_action('shutdown', [$this, 'flush_pending']);
|
||
$this->shutdown_registered = true;
|
||
}
|
||
|
||
/**
|
||
* 将内存中的统计和索引批量写入数据库。
|
||
*
|
||
* 由 shutdown hook 调用,每次请求最多写入 2 次 DB(stats + index)。
|
||
*
|
||
* @return void
|
||
*/
|
||
public function flush_pending(): void {
|
||
// 写入统计增量
|
||
if (!empty($this->pending_stats)) {
|
||
$stats = get_option(self::OPTION_STATS, []);
|
||
$stats = wp_parse_args(is_array($stats) ? $stats : [], $this->get_default_stats());
|
||
|
||
foreach (['hits', 'misses', 'writes'] as $metric) {
|
||
if (isset($this->pending_stats[$metric])) {
|
||
$stats[$metric] = (int) $stats[$metric] + (int) $this->pending_stats[$metric];
|
||
}
|
||
}
|
||
|
||
foreach (['last_hit_at', 'last_miss_at', 'last_write_at', 'last_key'] as $field) {
|
||
if (isset($this->pending_stats[$field])) {
|
||
$stats[$field] = $this->pending_stats[$field];
|
||
}
|
||
}
|
||
|
||
update_option(self::OPTION_STATS, $stats, false);
|
||
$this->pending_stats = [];
|
||
}
|
||
|
||
// 写入索引变更
|
||
if ($this->index_dirty && $this->pending_index !== null) {
|
||
update_option(self::OPTION_INDEX, $this->pending_index, false);
|
||
$this->index_dirty = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 默认统计结构
|
||
*
|
||
* @return array
|
||
*/
|
||
private function get_default_stats(): array {
|
||
return [
|
||
'hits' => 0,
|
||
'misses' => 0,
|
||
'writes' => 0,
|
||
'last_hit_at' => 0,
|
||
'last_miss_at' => 0,
|
||
'last_write_at' => 0,
|
||
'last_key' => '',
|
||
];
|
||
}
|
||
}
|