diff --git a/.gitignore b/.gitignore index b71b1ea..7881d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ Homestead.json Homestead.yaml Thumbs.db + +/storage/media-library/temp/ diff --git a/app/Blog/Support/PluginManager.php b/app/Blog/Support/PluginManager.php index d35321e..1766b08 100644 --- a/app/Blog/Support/PluginManager.php +++ b/app/Blog/Support/PluginManager.php @@ -178,6 +178,12 @@ class PluginManager public function uninstall(string $plugin): void { + $dependents = $this->dependents($plugin); + + if ($dependents) { + throw new RuntimeException('无法卸载 '.$plugin.':以下已启用插件依赖它:'.implode('、', $dependents).'。请先停用这些插件。'); + } + [$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin); PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->delete(); } diff --git a/app/Filament/Resources/Posts/Pages/EditPost.php b/app/Filament/Resources/Posts/Pages/EditPost.php index 9a5b4be..36e3984 100644 --- a/app/Filament/Resources/Posts/Pages/EditPost.php +++ b/app/Filament/Resources/Posts/Pages/EditPost.php @@ -19,4 +19,14 @@ class EditPost extends EditRecord DeleteAction::make(), ]; } + + protected function mutateFormDataBeforeSave(array $data): array + { + // 表单只包含部分 meta 键(插件注入的价格/会员标记等),合并保留其他插件写入的键(如解锁用户) + if (array_key_exists('meta', $data)) { + $data['meta'] = array_merge($this->record->meta ?? [], $data['meta'] ?? []); + } + + return $data; + } } diff --git a/plugins/neatstudio.membership/src/Http/MembershipController.php b/plugins/neatstudio.membership/src/Http/MembershipController.php index 052d22a..d55b596 100644 --- a/plugins/neatstudio.membership/src/Http/MembershipController.php +++ b/plugins/neatstudio.membership/src/Http/MembershipController.php @@ -37,6 +37,19 @@ class MembershipController } $user = $request->user(); + + // 已有该套餐的有效订阅:直接返回我的订阅页,避免重复支付 + $active = Subscription::query() + ->where('user_id', $user->id) + ->where('membership_plan_id', $plan->id) + ->where('status', 'active') + ->where('ends_at', '>', now()) + ->exists(); + + if ($active) { + return redirect()->route('membership.mine'); + } + $channel = $request->input('channel', 'alipay'); $payment = $this->payment->createOrder( @@ -63,6 +76,13 @@ class MembershipController public function unlockPost(Request $request, Post $post) { + $user = $request->user(); + + // 已有阅读权限(会员 / 作者 / 已解锁):直接查看文章,避免重复支付 + if ($this->service->canReadPost($user, $post)) { + return redirect()->route('posts.show', $post->slug ?? $post->id); + } + // 单篇付费解锁:创建一笔定向支付 $price = (int) ($post->meta['price'] ?? 0); @@ -70,7 +90,6 @@ class MembershipController abort(404); } - $user = $request->user(); $channel = $request->input('channel', 'alipay'); $payment = $this->payment->createOrder( diff --git a/tests/Feature/AdminPagesTest.php b/tests/Feature/AdminPagesTest.php index 3e7d6e5..c6e5816 100644 --- a/tests/Feature/AdminPagesTest.php +++ b/tests/Feature/AdminPagesTest.php @@ -50,6 +50,12 @@ class AdminPagesTest extends TestCase $this->actingAs($this->admin)->get('/admin/media')->assertOk(); } + public function test_plugin_and_payment_pages_load(): void + { + $this->actingAs($this->admin)->get('/admin/plugins')->assertOk(); + $this->actingAs($this->admin)->get('/admin/payments')->assertOk(); + } + public function test_guest_is_redirected_to_login(): void { $this->get('/admin')->assertRedirect('/admin/login'); diff --git a/tests/Feature/MembershipFlowTest.php b/tests/Feature/MembershipFlowTest.php index 9925552..9578b73 100644 --- a/tests/Feature/MembershipFlowTest.php +++ b/tests/Feature/MembershipFlowTest.php @@ -112,7 +112,8 @@ class MembershipFlowTest extends TestCase 'content_format' => 'markdown', 'status' => 'published', 'published_at' => now(), - 'meta' => ['price' => 500], + // members_only:非会员被拦截,可单篇支付解锁 + 'meta' => ['price' => 500, 'members_only' => true], ]); $this->actingAs($this->user) @@ -147,4 +148,40 @@ class MembershipFlowTest extends TestCase $this->assertSame('—', $payment->payable_label); $this->assertNull($payment->payable_url); } + + public function test_unlock_post_skips_payment_when_already_unlocked(): void + { + $post = Post::create([ + 'title' => '已解锁', + 'slug' => 'already-unlocked', + 'content' => '内容', + 'content_format' => 'markdown', + 'status' => 'published', + 'published_at' => now(), + 'meta' => ['price' => 500, 'unlocked_user_ids' => [$this->user->id]], + ]); + + $this->actingAs($this->user) + ->post('/posts/'.$post->id.'/unlock') + ->assertRedirect('/posts/already-unlocked.shtml'); + + $this->assertSame(0, Payment::query()->count()); + } + + public function test_subscribe_skips_payment_when_plan_already_active(): void + { + Subscription::create([ + 'user_id' => $this->user->id, + 'membership_plan_id' => $this->plan->id, + 'status' => 'active', + 'starts_at' => now(), + 'ends_at' => now()->addDays(30), + ]); + + $this->actingAs($this->user) + ->post('/membership/'.$this->plan->id.'/subscribe') + ->assertRedirect(route('membership.mine')); + + $this->assertSame(0, Payment::query()->count()); + } } diff --git a/tests/Feature/PluginDependencyTest.php b/tests/Feature/PluginDependencyTest.php index 9903f2b..baf6d47 100644 --- a/tests/Feature/PluginDependencyTest.php +++ b/tests/Feature/PluginDependencyTest.php @@ -83,6 +83,21 @@ class PluginDependencyTest extends TestCase $this->assertSame(['neatstudio.payment'], $plugins['neatstudio.membership']['dependency_errors']); } + public function test_uninstall_plugin_with_enabled_dependent_throws(): void + { + $manager = $this->manager(); + + try { + $manager->uninstall('neatstudio.payment'); + $this->fail('存在已启用的依赖者时应抛出异常'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('neatstudio.membership', $e->getMessage()); + } + + // 卸载失败,记录仍在 + $this->assertTrue($manager->isEnabled('neatstudio.payment')); + } + public function test_dependency_problems_for_missing_plugin(): void { $manager = $this->manager(); diff --git a/tests/Feature/PostFormInjectionTest.php b/tests/Feature/PostFormInjectionTest.php index ee76fc9..b2c2f74 100644 --- a/tests/Feature/PostFormInjectionTest.php +++ b/tests/Feature/PostFormInjectionTest.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Filament\Resources\Posts\Pages\EditPost; use App\Filament\Resources\Posts\PostResource; +use App\Models\Post; use Filament\Schemas\Schema; use Filament\Tables\Table; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; use ReflectionClass; use Tests\TestCase; @@ -55,7 +58,7 @@ class PostFormInjectionTest extends TestCase public function test_meta_price_maps_to_post_meta_json(): void { - $post = \App\Models\Post::create([ + $post = Post::create([ 'title' => '付费文章', 'content' => '内容', 'content_format' => 'markdown', @@ -67,4 +70,27 @@ class PostFormInjectionTest extends TestCase $this->assertSame(9900, $post->meta['price']); $this->assertTrue($post->meta['members_only']); } + + public function test_edit_post_save_preserves_other_meta_keys(): void + { + $post = Post::create([ + 'title' => '已解锁文章', + 'slug' => 'unlocked-post', + 'content' => '内容', + 'content_format' => 'markdown', + 'status' => 'published', + 'published_at' => now(), + 'meta' => ['price' => 9900, 'members_only' => true, 'unlocked_user_ids' => [1, 2]], + ]); + + Livewire::test(EditPost::class, ['record' => $post->getRouteKey()]) + ->call('save') + ->assertHasNoFormErrors(); + + $post->refresh(); + // 表单只含 price/members_only,保存时不得覆盖解锁用户等其他 meta 键 + $this->assertSame([1, 2], $post->meta['unlocked_user_ids']); + $this->assertSame(9900, $post->meta['price']); + $this->assertTrue($post->meta['members_only']); + } }