feat: 前台整页响应缓存(匿名 GET,浏览量走 track-view 独立端点)+ 后台缓存管理页 + 侧边栏 nav-groups 负 margin 归零
This commit is contained in:
@@ -101,6 +101,13 @@ php artisan workerman:serve stop
|
||||
- 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs` 表
|
||||
- 降级路径:`php artisan queue:work` 照常可用(同一队列)
|
||||
|
||||
## 前台缓存
|
||||
|
||||
- 匿名 GET 页面整页缓存(首页/列表/文章/归档/标签等),TTL 默认 300 秒(`PAGE_CACHE_TTL`),命中时零数据库查询
|
||||
- 文章浏览量由独立 `/track-view` 端点统计,不受整页缓存影响(实时)
|
||||
- 后台「缓存管理」:清空页面/侧边栏/设置/全部缓存、重建编译缓存
|
||||
- 侧边栏数据与站点设置另有 1 小时缓存
|
||||
|
||||
## 对外 API
|
||||
|
||||
- 交互式文档(OpenAPI/Swagger 风格):`GET /docs/api`
|
||||
|
||||
@@ -25,7 +25,7 @@ class PostController extends Controller
|
||||
|
||||
$this->ensureReadable($post);
|
||||
|
||||
$post->increment('views');
|
||||
// 浏览量由 /track-view 端点统计(页面整页缓存下控制器不执行,避免双计)
|
||||
|
||||
$comments = $post->comments()
|
||||
->where('status', 'published')
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?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)) {
|
||||
return response($cached['body'], 200, [
|
||||
'Content-Type' => $cached['type'],
|
||||
'Cache-Control' => 'public, max-age='.$ttl,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
if ($response->getStatusCode() === 200) {
|
||||
Cache::put($key, [
|
||||
'body' => $response->getContent(),
|
||||
'type' => $response->headers->get('Content-Type') ?: 'text/html; charset=utf-8',
|
||||
], $ttl);
|
||||
self::rememberKey($key);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public static function flush(): void
|
||||
{
|
||||
foreach ((array) Cache::get(self::TAG.'_keys', []) as $key) {
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
Cache::forget(self::TAG.'_keys');
|
||||
}
|
||||
|
||||
public static function count(): int
|
||||
{
|
||||
return count((array) Cache::get(self::TAG.'_keys', []));
|
||||
}
|
||||
|
||||
private static function rememberKey(string $key): void
|
||||
{
|
||||
$keys = (array) Cache::get(self::TAG.'_keys', []);
|
||||
$keys[$key] = true;
|
||||
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;
|
||||
}
|
||||
|
||||
// 动态端点
|
||||
if (str_starts_with($path, 'track-view')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function key(Request $request): string
|
||||
{
|
||||
return self::TAG.':'.md5($request->fullUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Blog\Support\PageCacheMiddleware;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CacheManager extends Page
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bolt';
|
||||
|
||||
protected static ?string $navigationLabel = '缓存管理';
|
||||
|
||||
protected string $view = 'filament.pages.cache-manager';
|
||||
|
||||
public function flushPages(): void
|
||||
{
|
||||
PageCacheMiddleware::flush();
|
||||
Notification::make()->title('前台页面缓存已清空')->success()->send();
|
||||
}
|
||||
|
||||
public function flushSidebar(): void
|
||||
{
|
||||
foreach (['blog.sidebar.categories', 'blog.sidebar.recent_posts', 'blog.sidebar.recent_comments', 'blog.sidebar.hot_tags', 'blog.sidebar.links', 'blog.sidebar.stats'] as $key) {
|
||||
Cache::forget($key);
|
||||
}
|
||||
Notification::make()->title('侧边栏缓存已清空')->success()->send();
|
||||
}
|
||||
|
||||
public function flushSettings(): void
|
||||
{
|
||||
\App\Models\Setting::flushCache();
|
||||
Cache::forget('attach.legacy_ids');
|
||||
Notification::make()->title('设置缓存已清空')->success()->send();
|
||||
}
|
||||
|
||||
public function flushAll(): void
|
||||
{
|
||||
PageCacheMiddleware::flush();
|
||||
\App\Models\Setting::flushCache();
|
||||
Cache::flush();
|
||||
Notification::make()->title('全部缓存已清空')->success()->send();
|
||||
}
|
||||
|
||||
public function recompile(): void
|
||||
{
|
||||
Artisan::call('view:clear');
|
||||
Artisan::call('route:clear');
|
||||
Artisan::call('config:clear');
|
||||
Notification::make()->title('视图/路由/配置缓存已重建')->success()->send();
|
||||
}
|
||||
|
||||
public function getPageCacheCountProperty(): int
|
||||
{
|
||||
return PageCacheMiddleware::count();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->loadMigrationsFrom($path);
|
||||
}
|
||||
|
||||
// 前台页面缓存(匿名 GET,缓存含 SEO 注入后的最终 HTML)
|
||||
app('router')->pushMiddlewareToGroup('web', \App\Blog\Support\PageCacheMiddleware::class);
|
||||
|
||||
// 后台侧边栏紧凑化(更窄的菜单项)
|
||||
Filament::serving(function () {
|
||||
Filament::registerRenderHook(
|
||||
@@ -37,6 +40,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
<style>
|
||||
.fi-sidebar { padding-inline: 0.75rem; }
|
||||
.fi-sidebar-nav { padding-inline: 0; }
|
||||
.fi-sidebar-nav-groups { margin-inline: 0; }
|
||||
.fi-sidebar-nav .fi-sidebar-item-button { padding-inline: 0.6rem; border-radius: 0.5rem; }
|
||||
.fi-sidebar-nav .fi-sidebar-item-label { font-size: 0.82rem; }
|
||||
.fi-sidebar-nav .fi-sidebar-group-label { font-size: 0.68rem; letter-spacing: 0.04em; }
|
||||
|
||||
@@ -48,4 +48,12 @@ return [
|
||||
*/
|
||||
|
||||
'default_post_status' => 'published',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 前台页面缓存(秒)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'page_cache_ttl' => env('PAGE_CACHE_TTL', 300),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-filament::section heading="前台页面缓存" :description="'当前缓存页面数:'.$this->page_cache_count">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
匿名访客的首页/列表/文章/归档等页面按 URL 缓存(TTL {{ config('blog.page_cache_ttl') }} 秒),
|
||||
文章浏览量由独立端点统计,不受缓存影响。发布/编辑文章后建议清空本缓存。
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<x-filament::button color="primary" wire:click="flushPages">清空页面缓存</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="侧边栏与设置缓存">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
侧边栏(分类/最新文章/评论/标签/友链/统计)与站点设置、附件映射的缓存,最长 1 小时。
|
||||
</p>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<x-filament::button color="gray" wire:click="flushSidebar">清空侧边栏</x-filament::button>
|
||||
<x-filament::button color="gray" wire:click="flushSettings">清空设置</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="全部缓存">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">清空应用全部缓存(含上述所有 + 其他临时缓存)。</p>
|
||||
<div class="mt-4">
|
||||
<x-filament::button color="danger" wire:click="flushAll" wire:confirm="确定清空全部缓存?">清空全部缓存</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="编译缓存">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">清除并重建 Blade 视图 / 路由 / 配置编译缓存(发布或更新代码后使用)。</p>
|
||||
<div class="mt-4">
|
||||
<x-filament::button color="gray" wire:click="recompile">重建编译缓存</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -91,5 +91,6 @@
|
||||
@include('partials.sidebar')
|
||||
</div>
|
||||
@include('partials.footer')
|
||||
<script>fetch("{{ route('track-view', $post) }}")</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,6 +35,9 @@ Route::get('/posts/{slug}.shtml', [PostController::class, 'show'])->where('slug'
|
||||
Route::get('/posts/{slug}', fn (string $slug) => redirect()->route('posts.show', $slug, 301))->where('slug', '[A-Za-z0-9\-]+');
|
||||
Route::post('/posts/{post}/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||
|
||||
// 浏览量统计(独立端点,避开整页缓存,保证实时)
|
||||
Route::get('/track-view/{post}', fn (\App\Models\Post $post) => response($post->increment('views') ? '' : '', 204))->name('track-view');
|
||||
|
||||
Route::get('/category/{slug}.shtml', [CategoryController::class, 'show'])->where('slug', '[A-Za-z0-9\-]+')->name('category.show');
|
||||
Route::get('/category/{slug}', fn (string $slug) => redirect()->route('category.show', $slug, 301))->where('slug', '[A-Za-z0-9\-]+');
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake-jpg
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Blog\Support\PageCacheMiddleware;
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PageCacheTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed();
|
||||
|
||||
$user = User::create(['name' => '作者', 'email' => 'w@t.com', 'password' => bcrypt('x')]);
|
||||
$cat = Category::create(['name' => '技术', 'slug' => 'tech']);
|
||||
Post::create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $cat->id,
|
||||
'title' => '缓存测试',
|
||||
'slug' => 'cache-test',
|
||||
'content' => '内容',
|
||||
'content_format' => 'markdown',
|
||||
'status' => 'published',
|
||||
'published_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_anonymous_page_is_cached(): void
|
||||
{
|
||||
PageCacheMiddleware::flush();
|
||||
|
||||
$this->get('/')->assertOk();
|
||||
$this->assertSame(1, PageCacheMiddleware::count());
|
||||
|
||||
// 第二次命中缓存
|
||||
$this->get('/')->assertOk();
|
||||
$this->assertSame(1, PageCacheMiddleware::count());
|
||||
}
|
||||
|
||||
public function test_authenticated_user_not_cached(): void
|
||||
{
|
||||
PageCacheMiddleware::flush();
|
||||
|
||||
$user = User::where('email', 'admin@laralog.test')->first();
|
||||
$this->actingAs($user)->get('/')->assertOk();
|
||||
|
||||
$this->assertSame(0, PageCacheMiddleware::count());
|
||||
}
|
||||
|
||||
public function test_track_view_increments_without_caching(): void
|
||||
{
|
||||
PageCacheMiddleware::flush();
|
||||
|
||||
$post = Post::where('slug', 'cache-test')->first();
|
||||
$views = $post->views;
|
||||
|
||||
$this->get('/posts/cache-test.shtml')->assertOk();
|
||||
$this->get('/track-view/'.$post->id)->assertStatus(204);
|
||||
|
||||
$post->refresh();
|
||||
$this->assertSame($views + 1, $post->views);
|
||||
}
|
||||
|
||||
public function test_flush_clears_pages(): void
|
||||
{
|
||||
$this->get('/')->assertOk();
|
||||
$this->assertSame(1, PageCacheMiddleware::count());
|
||||
|
||||
PageCacheMiddleware::flush();
|
||||
|
||||
$this->assertSame(0, PageCacheMiddleware::count());
|
||||
}
|
||||
}
|
||||
@@ -85,5 +85,6 @@
|
||||
@include('partials.sidebar')
|
||||
</div>{{-- #page --}}
|
||||
@include('partials.footer')
|
||||
<script>fetch("{{ route('track-view', $post) }}")</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user