feat: 前台 paywall——整篇锁定渲染付费墙,付费块替换为 teaser
- membership/post.rendered 过滤:整篇锁定(members_only)渲染 paywall 视图(封面+标题+价格+单篇解锁/开通会员按钮,游客引导登录);[paid] 块替换为带解锁按钮的 teaser;替换改用 preg_replace_callback 避免 $ 转义问题 - 视图放 resources/views/membership/(主题同名文件可覆盖),两主题补充 .paywall/.paid-teaser 样式 - 修复泄漏:excerpt_or_fallback 对 members_only 文章不再回退截取正文,防止内容泄漏到 paywall 与 SEO meta - 测试:更新 teaser 断言,新增 guest 见 paywall、登录用户见解锁/会员按钮
This commit is contained in:
@@ -126,6 +126,11 @@ class Post extends Model implements HasMedia
|
||||
return $this->excerpt;
|
||||
}
|
||||
|
||||
// 会员专享文章不落入内容摘要,避免泄漏到 paywall/SEO meta
|
||||
if (! empty(($this->meta ?? [])['members_only'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = strip_tags($this->content);
|
||||
// 付费块不进入摘要,避免付费内容泄漏到列表页/SEO meta
|
||||
$text = preg_replace('/\[paid\].*?\[\/paid\]/s', '', $text);
|
||||
|
||||
@@ -53,21 +53,21 @@ class ServiceProvider extends PluginServiceProvider
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// 付费文章:[paid]...[/paid] 对无权限访客隐藏
|
||||
// 付费文章:[paid]...[/paid] 对无权限访客隐藏为 teaser;整篇锁定渲染 paywall
|
||||
$manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) {
|
||||
$service = app(Services\MembershipService::class);
|
||||
$user = auth()->user();
|
||||
|
||||
if (preg_match('/\[paid\](.*?)\[\/paid\]/s', $html) && ! $service->canReadPaidContent($user, $post)) {
|
||||
$html = preg_replace(
|
||||
$html = preg_replace_callback(
|
||||
'/\[paid\](.*?)\[\/paid\]/s',
|
||||
'<div class="paid-teaser">(付费内容已隐藏,开通会员或单篇解锁后可见)</div>',
|
||||
fn () => view('membership.paid-teaser', ['post' => $post])->render(),
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
if (! $service->canReadPost($user, $post)) {
|
||||
return '<div class="paid-teaser">(本文章为会员专享,开通会员后可见)</div>';
|
||||
return view('membership.paywall', ['post' => $post])->render();
|
||||
}
|
||||
|
||||
return $html;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@php
|
||||
$price = (int) ($post->meta['price'] ?? 0);
|
||||
@endphp
|
||||
|
||||
<div class="paid-teaser" role="note" aria-label="付费内容">
|
||||
<span>本文包含付费内容</span>
|
||||
@auth
|
||||
@if($price > 0)
|
||||
<form action="{{ route('posts.unlock', $post) }}" method="post" style="display:inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">单篇解锁 ¥{{ number_format($price / 100, 2) }}</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('membership.index') }}" class="btn btn-primary">开通会员</a>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="btn btn-primary">登录后查看</a>
|
||||
@endauth
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
@php
|
||||
$service = app(\Plugins\Neatstudio\Membership\Services\MembershipService::class);
|
||||
$user = auth()->user();
|
||||
$isMember = $user && $service->isMember($user);
|
||||
$price = (int) ($post->meta['price'] ?? 0);
|
||||
$plansExist = \Plugins\Neatstudio\Membership\Models\MembershipPlan::query()->where('active', true)->exists();
|
||||
@endphp
|
||||
|
||||
<div class="paywall" role="note" aria-label="付费内容">
|
||||
@if($cover = $post->getFirstMedia('cover'))
|
||||
<div class="paywall-cover">
|
||||
<img src="{{ $cover->getUrl('card') ?: $cover->getUrl() }}" alt="{{ $post->title }}">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="paywall-body">
|
||||
<h2 class="paywall-title">{{ $post->title }}</h2>
|
||||
@if($post->excerpt_or_fallback)
|
||||
<p class="paywall-excerpt">{{ $post->excerpt_or_fallback }}</p>
|
||||
@endif
|
||||
|
||||
@if($price > 0)
|
||||
<p class="paywall-price">单篇解锁 ¥{{ number_format($price / 100, 2) }}</p>
|
||||
@endif
|
||||
|
||||
<p class="paywall-hint">
|
||||
@if($isMember)
|
||||
你是会员,本文可直接阅读。
|
||||
@else
|
||||
本文为{{ $price > 0 ? '付费内容' : '会员专享内容' }},解锁或开通会员后可阅读全文。
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<div class="paywall-actions">
|
||||
@auth
|
||||
@if(! $isMember)
|
||||
@if($price > 0)
|
||||
<form action="{{ route('posts.unlock', $post) }}" method="post" style="display:inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">单篇解锁</button>
|
||||
</form>
|
||||
@endif
|
||||
@if($plansExist)
|
||||
<a href="{{ route('membership.index') }}" class="btn">开通会员</a>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="btn btn-primary">登录后解锁</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,11 +87,12 @@ class MembershipFlowTest extends TestCase
|
||||
'published_at' => now(),
|
||||
]);
|
||||
|
||||
// 游客:付费部分被替换
|
||||
// 游客:付费部分被替换为 teaser
|
||||
$this->get('/posts/paid-post.shtml')
|
||||
->assertOk()
|
||||
->assertDontSee('付费隐藏内容')
|
||||
->assertSee('付费内容已隐藏');
|
||||
->assertSee('本文包含付费内容')
|
||||
->assertSee('登录后查看');
|
||||
|
||||
// 会员:可见
|
||||
$this->actingAs($this->user)->post('/membership/'.$this->plan->id.'/subscribe');
|
||||
@@ -184,4 +185,43 @@ class MembershipFlowTest extends TestCase
|
||||
|
||||
$this->assertSame(0, Payment::query()->count());
|
||||
}
|
||||
|
||||
public function test_members_only_post_shows_paywall_to_guest(): void
|
||||
{
|
||||
$post = Post::create([
|
||||
'title' => '会员专享文章',
|
||||
'slug' => 'members-only-post',
|
||||
'content' => '这是会员才能看到的正文内容',
|
||||
'content_format' => 'markdown',
|
||||
'status' => 'published',
|
||||
'published_at' => now(),
|
||||
'meta' => ['members_only' => true, 'price' => 500],
|
||||
]);
|
||||
|
||||
$this->get('/posts/members-only-post.shtml')
|
||||
->assertOk()
|
||||
->assertSee('登录后解锁')
|
||||
->assertSee('单篇解锁')
|
||||
->assertSee('会员专享文章')
|
||||
->assertDontSee('这是会员才能看到的正文内容');
|
||||
}
|
||||
|
||||
public function test_members_only_post_paywall_shows_unlock_buttons_to_user(): void
|
||||
{
|
||||
$post = Post::create([
|
||||
'title' => '会员专享文章2',
|
||||
'slug' => 'members-only-post-2',
|
||||
'content' => '正文内容不可见',
|
||||
'content_format' => 'markdown',
|
||||
'status' => 'published',
|
||||
'published_at' => now(),
|
||||
'meta' => ['members_only' => true, 'price' => 9900],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)->get('/posts/members-only-post-2.shtml')
|
||||
->assertOk()
|
||||
->assertSee('单篇解锁')
|
||||
->assertSee('开通会员')
|
||||
->assertDontSee('正文内容不可见');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,3 +170,14 @@ a:hover { opacity: 0.8; }
|
||||
.toc-level-4, .toc-level-5, .toc-level-6 { padding-left: 28px; }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* Paywall */
|
||||
.paywall { border: 1px solid var(--border); border-radius: 12px; padding: 28px 22px; background: var(--bg); text-align: center; }
|
||||
.paywall-cover img { max-width: 100%; border-radius: 10px; margin-bottom: 16px; }
|
||||
.paywall-title { font-size: 22px; margin: 0 0 10px; }
|
||||
.paywall-excerpt { color: var(--muted); font-size: 14px; margin: 0 0 16px; }
|
||||
.paywall-price { font-size: 24px; font-weight: 800; color: var(--accent); margin: 0 0 8px; }
|
||||
.paywall-hint { color: var(--muted); font-size: 13px; margin: 0 0 18px; }
|
||||
.paywall-actions .btn { margin: 0 6px; }
|
||||
.paid-teaser { border: 1px dashed var(--border); background: var(--bg-muted, #f6f6f6); color: var(--muted); border-radius: 10px; padding: 14px 16px; margin: 14px 0; font-size: 14px; }
|
||||
.paid-teaser .btn { margin-left: 8px; }
|
||||
|
||||
@@ -144,3 +144,14 @@ a:hover { color: #f60; text-decoration: underline; }
|
||||
.toc-level-4, .toc-level-5, .toc-level-6 { padding-left: 28px; }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* Paywall */
|
||||
.paywall { border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px 20px; background: #fafafa; text-align: center; }
|
||||
.paywall-cover img { max-width: 100%; border-radius: 6px; margin-bottom: 14px; }
|
||||
.paywall-title { font-size: 20px; margin: 0 0 10px; }
|
||||
.paywall-excerpt { color: #666; font-size: 14px; margin: 0 0 14px; }
|
||||
.paywall-price { font-size: 22px; font-weight: 800; color: #f60; margin: 0 0 8px; }
|
||||
.paywall-hint { color: #888; font-size: 13px; margin: 0 0 16px; }
|
||||
.paywall-actions .btn { margin: 0 6px; }
|
||||
.paid-teaser { border: 1px dashed #f5c6cb; background: #fdecea; color: #c62828; border-radius: 6px; padding: 12px 14px; margin: 12px 0; font-size: 14px; }
|
||||
.paid-teaser .btn { margin-left: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user