diff --git a/app/Models/Post.php b/app/Models/Post.php index 674dea6..bc9cee7 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -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); diff --git a/plugins/neatstudio.membership/src/ServiceProvider.php b/plugins/neatstudio.membership/src/ServiceProvider.php index 88caffe..c3f75ea 100644 --- a/plugins/neatstudio.membership/src/ServiceProvider.php +++ b/plugins/neatstudio.membership/src/ServiceProvider.php @@ -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', - '', + fn () => view('membership.paid-teaser', ['post' => $post])->render(), $html ); } if (! $service->canReadPost($user, $post)) { - return ''; + return view('membership.paywall', ['post' => $post])->render(); } return $html; diff --git a/resources/views/membership/paid-teaser.blade.php b/resources/views/membership/paid-teaser.blade.php new file mode 100644 index 0000000..2a75455 --- /dev/null +++ b/resources/views/membership/paid-teaser.blade.php @@ -0,0 +1,19 @@ +@php + $price = (int) ($post->meta['price'] ?? 0); +@endphp + + diff --git a/resources/views/membership/paywall.blade.php b/resources/views/membership/paywall.blade.php new file mode 100644 index 0000000..a0bc9ef --- /dev/null +++ b/resources/views/membership/paywall.blade.php @@ -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 + +
+ @if($cover = $post->getFirstMedia('cover')) +
+ {{ $post->title }} +
+ @endif + +
+

{{ $post->title }}

+ @if($post->excerpt_or_fallback) +

{{ $post->excerpt_or_fallback }}

+ @endif + + @if($price > 0) +

单篇解锁 ¥{{ number_format($price / 100, 2) }}

+ @endif + +

+ @if($isMember) + 你是会员,本文可直接阅读。 + @else + 本文为{{ $price > 0 ? '付费内容' : '会员专享内容' }},解锁或开通会员后可阅读全文。 + @endif +

+ +
+ @auth + @if(! $isMember) + @if($price > 0) +
+ @csrf + +
+ @endif + @if($plansExist) + 开通会员 + @endif + @endif + @else + 登录后解锁 + @endauth +
+
+
diff --git a/tests/Feature/MembershipFlowTest.php b/tests/Feature/MembershipFlowTest.php index 9578b73..24e6acf 100644 --- a/tests/Feature/MembershipFlowTest.php +++ b/tests/Feature/MembershipFlowTest.php @@ -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('正文内容不可见'); + } } diff --git a/themes/modern/assets/style.css b/themes/modern/assets/style.css index b787b82..48907f1 100644 --- a/themes/modern/assets/style.css +++ b/themes/modern/assets/style.css @@ -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; } diff --git a/themes/sablog/assets/style.css b/themes/sablog/assets/style.css index 86104af..6ccde5a 100644 --- a/themes/sablog/assets/style.css +++ b/themes/sablog/assets/style.css @@ -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; }