refactor: 外链中转拆为内置插件 neatstudio.link-tracker

- 插件承载全部逻辑:redirects 迁移(hasTable 兼容旧库)、Redirect 模型、UrlRedirector、/go 路由+控制器、Filament 外链中转资源
- 核心只留 hook 接入点:
  - tracked_url() 助手走 link.redirect 过滤器(评论正文/作者网址/友链模板统一调用)
  - 正文外链改写移到插件的 post.rendered 过滤器(优先级 20,晚于会员付费过滤)
- 停用插件:链接恢复直连、内容不受影响;后台「管理 → 外链中转」查看点击量/启停
- 测试更新为插件上下文(含无过滤器降级断言);97 通过
This commit is contained in:
ak
2026-08-13 02:05:32 +08:00
parent 8f1ff6e3f9
commit f1b9d1e1c3
20 changed files with 118 additions and 62 deletions
+4 -2
View File
@@ -104,11 +104,13 @@ php artisan workerman:serve stop
- 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs` - 失败重试:`WORKERMAN_MAX_TRIES`(默认 3),超时 `WORKERMAN_TIMEOUT`(默认 60s),重试耗尽进 `failed_jobs`
- 降级路径:`php artisan queue:work` 照常可用(同一队列) - 降级路径:`php artisan queue:work` 照常可用(同一队列)
## 外链中转(/go ## 外链中转(/go,内置插件
内置插件 `neatstudio.link-tracker`(外链中转):
- 正文外链、评论正文 URL、评论作者网址、友链统一改写为 `/go/{hash}`302 跳转 + **点击统计**、可**停用拦截**(返回 410)、隐藏真实链接、为未来广告位预留 - 正文外链、评论正文 URL、评论作者网址、友链统一改写为 `/go/{hash}`302 跳转 + **点击统计**、可**停用拦截**(返回 410)、隐藏真实链接、为未来广告位预留
- 站内链接/相对路径/mailto/tel/锚点不中转;同一 URL 复用同一短码 - 站内链接/相对路径/mailto/tel/锚点不中转;同一 URL 复用同一短码
- 后台「外链中转」可查看点击量、按状态筛选、启停 - 后台「管理 → 外链中转」可查看点击量、启停、删除
- **插件化设计**:核心提供 `tracked_url()` 钩子(`link.redirect` 过滤器)+ 正文 `post.rendered` 过滤器接入点,停用插件后链接恢复直连、不影响内容显示
## 前台缓存 ## 前台缓存
+1 -25
View File
@@ -46,31 +46,7 @@ class PostContentRenderer
$html = app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post); $html = app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post);
// 兜底:任何情况下不把裸 [paid] 标签输出到页面(如会员插件被停用时) // 兜底:任何情况下不把裸 [paid] 标签输出到页面(如会员插件被停用时)
$html = preg_replace('/\[(\/?paid)\]/i', '', $html); return preg_replace('/\[(\/?paid)\]/i', '', $html);
// 外链中转:站外链接改写为 /go/{hash}(点击统计/拦截)
return $this->rewriteExternalLinks($html);
}
/**
* 把正文里的站外 <a href> 改写为 /go 中转地址(站内/相对/锚点/mailto 不变)。
*/
private function rewriteExternalLinks(string $html): string
{
return preg_replace_callback(
'/<a\s+([^>]*?href=["\'])([^"\']+)(["\'][^>]*)>(.*?)<\/a>/is',
function (array $m): string {
$url = html_entity_decode(trim($m[2]));
$go = go_url($url);
if ($go === $url) {
return $m[0];
}
return '<a '.$m[1].e($go).$m[3].'>'.$m[4].'</a>';
},
$html
);
} }
/** /**
+7 -6
View File
@@ -43,19 +43,20 @@ if (! function_exists('blog_setting')) {
} }
} }
if (! function_exists('go_url')) { if (! function_exists('tracked_url')) {
/** /**
* 外链中转:站外链接改写为 /go/{hash}(点击统计/拦截),站内链接原样返回。 * 外链追踪钩子:核心视图统一调用,由插件(外链中转)改写为 /go/{hash}
* 插件停用时原样返回。站内链接等由插件自行判断。
*/ */
function go_url(string $url): string function tracked_url(string $url): string
{ {
return app(\App\Blog\Services\UrlRedirector::class)->goUrl($url); return app(\App\Blog\Support\PluginManager::class)->applyFilters('link.redirect', $url);
} }
} }
if (! function_exists('comment_body')) { if (! function_exists('comment_body')) {
/** /**
* 评论正文:转义 + 自动链接 URL /go 中转,nofollow)。 * 评论正文:转义 + 自动链接 URL tracked_url 中转,nofollow)。
*/ */
function comment_body(string $text): string function comment_body(string $text): string
{ {
@@ -63,7 +64,7 @@ if (! function_exists('comment_body')) {
$text = preg_replace_callback( $text = preg_replace_callback(
'#(https?://[^\s<>"\'()]+)#i', '#(https?://[^\s<>"\'()]+)#i',
fn (array $m) => '<a href="'.e(go_url($m[1])).'" target="_blank" rel="nofollow noopener">'.$m[1].'</a>', fn (array $m) => '<a href="'.e(tracked_url($m[1])).'" target="_blank" rel="nofollow noopener">'.$m[1].'</a>',
$text $text
); );
+1
View File
@@ -25,6 +25,7 @@ return [
'neatstudio.ai-moderation', 'neatstudio.ai-moderation',
'neatstudio.payment', 'neatstudio.payment',
'neatstudio.membership', 'neatstudio.membership',
'neatstudio.link-tracker',
], ],
/* /*
@@ -10,6 +10,11 @@ return new class extends Migration
{ {
public function up(): void public function up(): void
{ {
// 兼容已由核心迁移建过表的旧库
if (Schema::hasTable('redirects')) {
return;
}
Schema::create('redirects', function (Blueprint $table) { Schema::create('redirects', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('hash', 16)->unique(); $table->string('hash', 16)->unique();
@@ -0,0 +1,12 @@
{
"title": "外链中转",
"version": "1.0.0",
"description": "站外链接改写为 /go/{hash}:302 跳转 + 点击统计 + 可停用拦截 + 隐藏真实链接,为广告位预留",
"usage": "启用后自动生效:\n1. 正文外链、评论正文 URL、评论作者网址、友链统一改写为 /go/{hash}\n2. 点击 /go/{hash} 302 跳转并计数;后台「管理 → 外链中转」查看点击量/启停\n3. 停用插件后链接恢复直连(不影响正文显示)",
"author": "LaraLog",
"type": "core",
"provider": "Plugins\\Neatstudio\\LinkTracker\\ServiceProvider",
"filament_resources": [
"Plugins\\Neatstudio\\LinkTracker\\Filament\\Resources\\RedirectResource"
]
}
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Plugins\Neatstudio\LinkTracker\Http\GoController;
Route::get('/go/{hash}', [GoController::class, 'show'])->name('go.show');
@@ -2,10 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Filament\Resources\RedirectResource\Pages; namespace Plugins\Neatstudio\LinkTracker\Filament\Resources\Pages;
use App\Filament\Resources\RedirectResource;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Plugins\Neatstudio\LinkTracker\Filament\Resources\RedirectResource;
class ListRedirects extends ListRecords class ListRedirects extends ListRecords
{ {
@@ -2,10 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Filament\Resources; namespace Plugins\Neatstudio\LinkTracker\Filament\Resources;
use App\Filament\Resources\RedirectResource\Pages\ListRedirects;
use App\Models\Redirect;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -14,6 +12,8 @@ use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon; use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Plugins\Neatstudio\LinkTracker\Filament\Resources\Pages\ListRedirects;
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
class RedirectResource extends Resource class RedirectResource extends Resource
{ {
@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Blog\Controllers; namespace Plugins\Neatstudio\LinkTracker\Http;
use App\Models\Redirect; use Plugins\Neatstudio\LinkTracker\Models\Redirect;
class GoController class GoController
{ {
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Models; namespace Plugins\Neatstudio\LinkTracker\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Plugins\Neatstudio\LinkTracker;
use App\Blog\Support\PluginManager;
use App\Blog\Support\PluginServiceProvider;
use Plugins\Neatstudio\LinkTracker\Services\UrlRedirector;
class ServiceProvider extends PluginServiceProvider
{
protected function boot(PluginManager $manager): void
{
$this->loadRoutes(__DIR__.'/../routes/web.php');
// 核心视图(评论正文/作者网址/友链)通过 tracked_url() 走此过滤器改写
$manager->addFilter('link.redirect', function (string $url) {
return app(UrlRedirector::class)->goUrl($url);
}, 10);
// 正文外链改写:优先级 20,晚于会员付费内容过滤(10),保证 paywall 也覆盖
$manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) {
return app(UrlRedirector::class)->rewriteExternalLinks($html);
}, 20);
}
}
@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Blog\Services; namespace Plugins\Neatstudio\LinkTracker\Services;
use App\Models\Redirect;
use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
/** /**
* 外链中转:把站外链接改写为 /go/{hash},用于点击统计、二次拦截与未来广告位。 * 外链中转:把站外链接改写为 /go/{hash},用于点击统计、二次拦截与未来广告位。
@@ -69,4 +69,25 @@ class UrlRedirector
return $hash; return $hash;
}); });
} }
/**
* HTML 里的站外 <a href> 改写为 /go 中转地址(站内/相对/锚点/mailto 不变)。
*/
public function rewriteExternalLinks(string $html): string
{
return preg_replace_callback(
'/<a\s+([^>]*?href=["\'])([^"\']+)(["\'][^>]*)>(.*?)<\/a>/is',
function (array $m): string {
$url = html_entity_decode(trim($m[2]));
$go = $this->goUrl($url);
if ($go === $url) {
return $m[0];
}
return '<a '.$m[1].e($go).$m[3].'>'.$m[4].'</a>';
},
$html
);
}
} }
+1 -1
View File
@@ -8,7 +8,7 @@
<ul class="links-list"> <ul class="links-list">
@forelse($links as $link) @forelse($links as $link)
<li> <li>
<a href="{{ go_url($link->url) }}" target="_blank" rel="noopener nofollow">{{ $link->name }}</a> <a href="{{ tracked_url($link->url) }}" target="_blank" rel="noopener nofollow">{{ $link->name }}</a>
@if($link->note)<span class="link-note"> {{ $link->note }}</span>@endif @if($link->note)<span class="link-note"> {{ $link->note }}</span>@endif
</li> </li>
@empty @empty
+1 -1
View File
@@ -55,7 +55,7 @@
<h3 class="widget-title">友情链接</h3> <h3 class="widget-title">友情链接</h3>
<ul class="widget-list"> <ul class="widget-list">
@forelse($links as $link) @forelse($links as $link)
<li><a href="{{ go_url($link->url) }}" target="_blank" rel="noopener">{{ $link->name }}</a></li> <li><a href="{{ tracked_url($link->url) }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
@empty @empty
<li>暂无链接</li> <li>暂无链接</li>
@endforelse @endforelse
+1 -1
View File
@@ -68,7 +68,7 @@
<div class="comment-head"> <div class="comment-head">
<strong>{{ $comment->author_name }}</strong> <strong>{{ $comment->author_name }}</strong>
@if($comment->author_url) @if($comment->author_url)
<span>· <a href="{{ go_url($comment->author_url) }}" target="_blank" rel="noopener nofollow">访问主页</a></span> <span>· <a href="{{ tracked_url($comment->author_url) }}" target="_blank" rel="noopener nofollow">访问主页</a></span>
@endif @endif
<span class="comment-time">· {{ $comment->created_at->format(blog_setting('comment_timeformat', 'Y-m-d H:i')) }}</span> <span class="comment-time">· {{ $comment->created_at->format(blog_setting('comment_timeformat', 'Y-m-d H:i')) }}</span>
</div> </div>
-3
View File
@@ -7,7 +7,6 @@ use App\Blog\Controllers\AuthController;
use App\Blog\Controllers\CategoryController; use App\Blog\Controllers\CategoryController;
use App\Blog\Controllers\CommentController; use App\Blog\Controllers\CommentController;
use App\Blog\Controllers\FeedController; use App\Blog\Controllers\FeedController;
use App\Blog\Controllers\GoController;
use App\Blog\Controllers\HomeController; use App\Blog\Controllers\HomeController;
use App\Blog\Controllers\LegacyController; use App\Blog\Controllers\LegacyController;
use App\Blog\Controllers\LinkController; use App\Blog\Controllers\LinkController;
@@ -39,8 +38,6 @@ Route::post('/posts/{post}/comments', [CommentController::class, 'store'])->name
// 浏览量统计(独立端点,避开整页缓存,保证实时) // 浏览量统计(独立端点,避开整页缓存,保证实时)
Route::get('/track-view/{post}', fn (\App\Models\Post $post) => response($post->increment('views') ? '' : '', 204))->name('track-view'); Route::get('/track-view/{post}', fn (\App\Models\Post $post) => response($post->increment('views') ? '' : '', 204))->name('track-view');
// 外链中转:/go/{hash} 302 跳转,记录点击量;停用状态返回 410
Route::get('/go/{hash}', [GoController::class, 'show'])->name('go.show');
Route::get('/category/{slug}.shtml', [CategoryController::class, 'show'])->where('slug', '[^/]+')->name('category.show'); Route::get('/category/{slug}.shtml', [CategoryController::class, 'show'])->where('slug', '[^/]+')->name('category.show');
Route::get('/category/{slug}', fn (string $slug) => redirect()->route('category.show', $slug, 301))->where('slug', '[^/]+'); Route::get('/category/{slug}', fn (string $slug) => redirect()->route('category.show', $slug, 301))->where('slug', '[^/]+');
+17 -11
View File
@@ -5,25 +5,31 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Blog\Services\PostContentRenderer; use App\Blog\Services\PostContentRenderer;
use App\Blog\Services\UrlRedirector;
use App\Models\Post; use App\Models\Post;
use App\Models\Redirect;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Plugins\Neatstudio\LinkTracker\Models\Redirect;
use Tests\TestCase; use Tests\TestCase;
class GoRedirectTest extends TestCase class GoRedirectTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_go_url_rewrites_external_but_keeps_internal(): void public function test_tracked_url_rewrites_external_but_keeps_internal(): void
{ {
$go = app(UrlRedirector::class); // 外链中转插件默认启用,link.redirect 过滤器生效
$this->assertStringContainsString('/go/', tracked_url('https://example.com/path'));
$this->assertSame('http://laralog.test/posts/1', tracked_url('http://laralog.test/posts/1'));
$this->assertSame('/posts/1', tracked_url('/posts/1'));
$this->assertSame('mailto:a@b.com', tracked_url('mailto:a@b.com'));
$this->assertSame('#anchor', tracked_url('#anchor'));
}
$this->assertStringContainsString('/go/', $go->goUrl('https://example.com/path')); public function test_tracked_url_returns_original_without_plugin_filter(): void
$this->assertSame('http://laralog.test/posts/1', $go->goUrl('http://laralog.test/posts/1')); {
$this->assertSame('/posts/1', $go->goUrl('/posts/1')); // 无插件注册 link.redirect 过滤器时(插件停用/未装),核心视图原样返回外链
$this->assertSame('mailto:a@b.com', $go->goUrl('mailto:a@b.com')); $manager = new \App\Blog\Support\PluginManager();
$this->assertSame('#anchor', $go->goUrl('#anchor'));
$this->assertSame('https://example.com/path', $manager->applyFilters('link.redirect', 'https://example.com/path'));
} }
public function test_go_redirect_counts_clicks(): void public function test_go_redirect_counts_clicks(): void
@@ -54,8 +60,8 @@ class GoRedirectTest extends TestCase
public function test_same_url_reuses_same_hash(): void public function test_same_url_reuses_same_hash(): void
{ {
$a = app(UrlRedirector::class)->goUrl('https://example.com/dup'); $a = tracked_url('https://example.com/dup');
$b = app(UrlRedirector::class)->goUrl('https://example.com/dup'); $b = tracked_url('https://example.com/dup');
$this->assertSame($a, $b); $this->assertSame($a, $b);
$this->assertSame(1, Redirect::query()->where('target_url', 'https://example.com/dup')->count()); $this->assertSame(1, Redirect::query()->where('target_url', 'https://example.com/dup')->count());
@@ -55,7 +55,7 @@
<h3>友情链接</h3> <h3>友情链接</h3>
<ul> <ul>
@forelse($links as $link) @forelse($links as $link)
<li><a href="{{ go_url($link->url) }}" target="_blank" rel="noopener">{{ $link->name }}</a></li> <li><a href="{{ tracked_url($link->url) }}" target="_blank" rel="noopener">{{ $link->name }}</a></li>
@empty @empty
<li>暂无链接</li> <li>暂无链接</li>
@endforelse @endforelse
+1 -1
View File
@@ -66,7 +66,7 @@
<div class="comment" id="comment-{{ $comment->id }}"> <div class="comment" id="comment-{{ $comment->id }}">
<div class="comment-head"> <div class="comment-head">
<strong>{{ $comment->author_name }}</strong> <strong>{{ $comment->author_name }}</strong>
@if($comment->author_url)<span>· <a href="{{ go_url($comment->author_url) }}" target="_blank" rel="noopener nofollow">访问主页</a></span>@endif @if($comment->author_url)<span>· <a href="{{ tracked_url($comment->author_url) }}" target="_blank" rel="noopener nofollow">访问主页</a></span>@endif
<span class="comment-time">· {{ $comment->created_at->format(blog_setting('comment_timeformat', 'Y-m-d H:i')) }}</span> <span class="comment-time">· {{ $comment->created_at->format(blog_setting('comment_timeformat', 'Y-m-d H:i')) }}</span>
</div> </div>
<div class="comment-body">{!! comment_body($comment->content) !!}</div> <div class="comment-body">{!! comment_body($comment->content) !!}</div>