- topbar: Livewire LegacySyncButton next to 写文章 (moved from user menu) - view modes: add 图文(card) grid with cover fallback chain (cover → attachment → first content image → placeholder); list mode now title-only compact - archives: year/month/day index grid - page cache admin: size + expiry columns, total size, cache policy note - theme inject: stylevars switch from KeyValue to Repeater with multi-line HTML editing - import: html_entity_decode for category/post-title/link/keywords/comment author
180 lines
5.3 KiB
PHP
180 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Blog\Support;
|
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* 前台页面响应缓存:
|
|
* - 仅缓存匿名 GET 请求的 HTML 页面(首页/列表/文章/归档/标签等)
|
|
* - 登录用户、后台、API、动态端点不缓存
|
|
* - 浏览量走独立的 /track-view 端点,不受整页缓存影响
|
|
*/
|
|
class PageCacheMiddleware
|
|
{
|
|
public const TAG = 'pages';
|
|
|
|
public function handle(Request $request, \Closure $next): Response
|
|
{
|
|
if (! $this->shouldCache($request)) {
|
|
return $next($request);
|
|
}
|
|
|
|
$key = $this->key($request);
|
|
$ttl = (int) config('blog.page_cache_ttl', 300);
|
|
|
|
if ($cached = Cache::get($key)) {
|
|
self::increment('hits');
|
|
|
|
return response($cached['body'], 200, [
|
|
'Content-Type' => $cached['type'],
|
|
'Cache-Control' => 'public, max-age='.$ttl,
|
|
]);
|
|
}
|
|
|
|
self::increment('misses');
|
|
|
|
$response = $next($request);
|
|
|
|
if ($response->getStatusCode() === 200) {
|
|
$body = $response->getContent();
|
|
Cache::put($key, [
|
|
'body' => $body,
|
|
'type' => $response->headers->get('Content-Type') ?: 'text/html; charset=utf-8',
|
|
], $ttl);
|
|
self::rememberKey($key, $request->fullUrl(), strlen($body), $ttl);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
public static function flush(): void
|
|
{
|
|
foreach (array_keys((array) Cache::get(self::TAG.'_keys', [])) as $key) {
|
|
Cache::forget((string) $key);
|
|
}
|
|
|
|
Cache::forget(self::TAG.'_keys');
|
|
}
|
|
|
|
public static function count(): int
|
|
{
|
|
return count((array) Cache::get(self::TAG.'_keys', []));
|
|
}
|
|
|
|
/**
|
|
* 命中/未命中统计(按日滚动),用于判断前台缓存效果。
|
|
*/
|
|
public static function stats(): array
|
|
{
|
|
$hits = (int) Cache::get(self::TAG.'_hits', 0);
|
|
$misses = (int) Cache::get(self::TAG.'_misses', 0);
|
|
$total = $hits + $misses;
|
|
|
|
return [
|
|
'hits' => $hits,
|
|
'misses' => $misses,
|
|
'count' => self::count(),
|
|
'rate' => $total > 0 ? round($hits / $total * 100, 1) : 0.0,
|
|
];
|
|
}
|
|
|
|
public static function resetStats(): void
|
|
{
|
|
Cache::forget(self::TAG.'_hits');
|
|
Cache::forget(self::TAG.'_misses');
|
|
}
|
|
|
|
/**
|
|
* @return list<array{key: string, url: string, cached_at: string, size: int, expires_at: string}>
|
|
*/
|
|
public static function entries(): array
|
|
{
|
|
$entries = [];
|
|
|
|
foreach ((array) Cache::get(self::TAG.'_keys', []) as $key => $meta) {
|
|
$entries[] = [
|
|
'key' => (string) $key,
|
|
'url' => is_array($meta) ? (string) ($meta['url'] ?? $key) : (string) $key,
|
|
'cached_at' => is_array($meta) ? (string) ($meta['cached_at'] ?? '') : '',
|
|
'size' => is_array($meta) ? (int) ($meta['size'] ?? 0) : 0,
|
|
'expires_at' => is_array($meta) ? (string) ($meta['expires_at'] ?? '') : '',
|
|
];
|
|
}
|
|
|
|
usort($entries, fn (array $left, array $right): int => strcmp($right['cached_at'], $left['cached_at']));
|
|
|
|
return $entries;
|
|
}
|
|
|
|
public static function forget(string $key): void
|
|
{
|
|
Cache::forget($key);
|
|
$keys = (array) Cache::get(self::TAG.'_keys', []);
|
|
unset($keys[$key]);
|
|
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
|
|
}
|
|
|
|
private static function increment(string $metric): void
|
|
{
|
|
$key = self::TAG.'_'.$metric;
|
|
Cache::put($key, (int) Cache::get($key, 0) + 1, now()->addDay());
|
|
}
|
|
|
|
private static function rememberKey(string $key, ?string $url = null, int $size = 0, int $ttl = 0): void
|
|
{
|
|
$keys = (array) Cache::get(self::TAG.'_keys', []);
|
|
$previous = $keys[$key] ?? [];
|
|
|
|
$keys[$key] = [
|
|
'url' => $url ?: (is_array($previous) ? (string) ($previous['url'] ?? '') : ''),
|
|
'cached_at' => now()->toDateTimeString(),
|
|
'size' => $size,
|
|
'expires_at' => $ttl > 0 ? now()->addSeconds($ttl)->toDateTimeString() : '',
|
|
];
|
|
|
|
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
|
|
}
|
|
|
|
private function shouldCache(Request $request): bool
|
|
{
|
|
if (! $request->isMethod('GET')) {
|
|
return false;
|
|
}
|
|
|
|
if (auth()->check()) {
|
|
return false;
|
|
}
|
|
|
|
$path = $request->path();
|
|
|
|
if (str_starts_with($path, 'admin') || str_starts_with($path, 'api') || str_starts_with($path, 'themes')) {
|
|
return false;
|
|
}
|
|
|
|
// 动态端点、订阅和 agent 索引不进整页缓存,新文章要马上能看到
|
|
if (str_starts_with($path, 'track-view')) {
|
|
return false;
|
|
}
|
|
|
|
if (in_array($path, ['rss.xml', 'rss.php', 'sitemap.xml', 'robots.txt', 'llms.txt'], true)) {
|
|
return false;
|
|
}
|
|
|
|
if (str_ends_with($path, '.md')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function key(Request $request): string
|
|
{
|
|
return self::TAG.':'.md5($request->fullUrl().'|view:'.HomeViewMode::resolve($request));
|
|
}
|
|
}
|