From e06e7c4300cca032d5d106794346559c1ae58365 Mon Sep 17 00:00:00 2001 From: ak Date: Wed, 12 Aug 2026 01:34:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E5=9F=8E=E4=BB=98=E8=B4=B9?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E6=B5=81=20+=20=E5=B8=82=E5=9C=BA=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MarketPackage 模型/迁移:市场商品作为可购买实体(payable),复用 Payment 订单 - MarketPurchaseService:购买创建订单、已购判断、免费/付费门槛校验 - payment.paid 监听(核心注册,仅支付插件启用时)标记商品已购 - 市场契约:条目含 name/type/price(分)/download_url;插件市场 Tab 只显示 plugin、主题市场 Tab 只显示 theme;付费商品显示「购买」按钮与「已购买」徽章,未购买无法安装 - 新增主题市场 Tab(原仅插件页有市场) - 测试:购买/沙箱支付标记已购/重复购买/安装门槛/免费条目/支付插件禁用 --- app/Blog/Services/MarketPurchaseService.php | 114 ++++++++++++++++ app/Blog/Services/MarketplaceClient.php | 11 +- app/Filament/Pages/Plugins.php | 49 ++++++- app/Filament/Pages/Themes.php | 78 +++++++++++ app/Models/MarketPackage.php | 47 +++++++ app/Providers/AppServiceProvider.php | 20 +++ ...12_200000_create_market_packages_table.php | 32 +++++ docs/plugins.md | 25 ++++ .../views/filament/pages/plugins.blade.php | 21 ++- .../views/filament/pages/themes.blade.php | 36 +++++ tests/Feature/MarketPurchaseTest.php | 125 ++++++++++++++++++ 11 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 app/Blog/Services/MarketPurchaseService.php create mode 100644 app/Models/MarketPackage.php create mode 100644 database/migrations/2026_08_12_200000_create_market_packages_table.php create mode 100644 tests/Feature/MarketPurchaseTest.php diff --git a/app/Blog/Services/MarketPurchaseService.php b/app/Blog/Services/MarketPurchaseService.php new file mode 100644 index 0000000..4597254 --- /dev/null +++ b/app/Blog/Services/MarketPurchaseService.php @@ -0,0 +1,114 @@ + 0; + } + + /** + * 购买市场商品:创建订单并返回(null = 已购买过,无需重复支付)。 + */ + public function purchase(array $item): ?Payment + { + if (! $this->plugins->isEnabled('neatstudio.payment')) { + throw new RuntimeException('付费市场商品需要启用支付插件'); + } + + $price = (int) ($item['price'] ?? 0); + + if ($price <= 0) { + throw new RuntimeException('该商品免费,无需购买'); + } + + $key = $this->itemKey($item); + $type = $this->itemType($item); + $user = auth()->user(); + + $package = MarketPackage::query()->firstOrCreate( + ['item_key' => $key, 'item_type' => $type], + [ + 'title' => (string) ($item['title'] ?? $key), + 'price' => $price, + 'status' => 'pending', + 'user_id' => $user?->id, + ] + ); + + if ($package->isPaid()) { + return null; + } + + return $this->payment->createOrder( + $user ?? $this->systemUser(), + '购买商品:'.$package->title, + $price, + $package, + 'alipay' + ); + } + + public function isPurchased(string $key, string $type = 'plugin'): bool + { + return MarketPackage::query() + ->where('item_key', $key) + ->where('item_type', $type) + ->where('status', 'paid') + ->exists(); + } + + /** + * 校验市场条目可否安装:免费直接装,付费需已购买。 + */ + public function assertCanInstall(array $item): void + { + if ($this->requiresPayment($item) && ! $this->isPurchased($this->itemKey($item), $this->itemType($item))) { + throw new RuntimeException('该商品为付费商品,请先购买后再安装'); + } + } + + private function systemUser(): \App\Models\User + { + $user = \App\Models\User::query()->first(); + + if (! $user) { + throw new RuntimeException('无可用用户创建订单,请先登录'); + } + + return $user; + } +} diff --git a/app/Blog/Services/MarketplaceClient.php b/app/Blog/Services/MarketplaceClient.php index c70110c..7339599 100644 --- a/app/Blog/Services/MarketplaceClient.php +++ b/app/Blog/Services/MarketplaceClient.php @@ -29,7 +29,16 @@ class MarketplaceClient $response->throw(); - return $response->json('items', []); + $items = $response->json('items', []); + + // 规范化市场契约:price 分、type(plugin/theme)、name + return array_map(function (array $item) { + $item['price'] = (int) ($item['price'] ?? 0); + $item['type'] = $item['type'] ?? 'plugin'; + $item['name'] = $item['name'] ?? null; + + return $item; + }, is_array($items) ? $items : []); } public function download(string $url): string diff --git a/app/Filament/Pages/Plugins.php b/app/Filament/Pages/Plugins.php index 24fff39..ac993cf 100644 --- a/app/Filament/Pages/Plugins.php +++ b/app/Filament/Pages/Plugins.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace App\Filament\Pages; use App\Blog\Services\MarketplaceClient; +use App\Blog\Services\MarketPurchaseService; use App\Blog\Services\PackageInstaller; use App\Blog\Support\PluginManager; use Filament\Notifications\Notification; @@ -88,8 +89,12 @@ class Plugins extends Page public function loadMarket(): void { try { - $this->marketItems = app(MarketplaceClient::class)->items(); - Notification::make()->title('市场已刷新:'.count($this->marketItems).' 个包')->success()->send(); + $items = app(MarketplaceClient::class)->items(); + $this->marketItems = array_values(array_filter( + $items, + fn (array $item) => ($item['type'] ?? 'plugin') === 'plugin' + )); + Notification::make()->title('市场已刷新:'.count($this->marketItems).' 个插件')->success()->send(); } catch (\Throwable $e) { Notification::make()->title('市场加载失败')->body($e->getMessage())->danger()->send(); } @@ -104,6 +109,9 @@ class Plugins extends Page } try { + // 付费商品需先购买 + app(MarketPurchaseService::class)->assertCanInstall($item); + $result = app(PackageInstaller::class)->installFromMarket($item); Notification::make()->title('安装成功:'.$result['key'])->success()->send(); } catch (\Throwable $e) { @@ -111,6 +119,43 @@ class Plugins extends Page } } + public function purchaseMarketItem(string $index) + { + $item = $this->marketItems[(int) $index] ?? null; + + if (! $item) { + return; + } + + try { + $payment = app(MarketPurchaseService::class)->purchase($item); + + if (! $payment) { + Notification::make()->title('该商品已购买')->info()->send(); + + return; + } + + return redirect()->route('pay.checkout', $payment); + } catch (\Throwable $e) { + Notification::make()->title('购买失败')->body($e->getMessage())->danger()->send(); + } + } + + public function isMarketPurchased(string $index): bool + { + $item = $this->marketItems[(int) $index] ?? null; + + if (! $item) { + return false; + } + + return app(MarketPurchaseService::class)->isPurchased( + app(MarketPurchaseService::class)->itemKey($item), + app(MarketPurchaseService::class)->itemType($item) + ); + } + public function getMarketConfiguredProperty(): bool { return (bool) config('market.url'); diff --git a/app/Filament/Pages/Themes.php b/app/Filament/Pages/Themes.php index 0f6298b..01b4f3b 100644 --- a/app/Filament/Pages/Themes.php +++ b/app/Filament/Pages/Themes.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Filament\Pages; +use App\Blog\Services\MarketplaceClient; +use App\Blog\Services\MarketPurchaseService; use App\Blog\Services\PackageInstaller; use App\Blog\Support\ThemeManager; use Filament\Notifications\Notification; @@ -27,6 +29,8 @@ class Themes extends Page public $zip; + public array $marketItems = []; + public function getThemesProperty(): array { return app(ThemeManager::class)->all(); @@ -67,4 +71,78 @@ class Themes extends Page Notification::make()->title('删除失败')->body($e->getMessage())->danger()->send(); } } + + public function loadThemeMarket(): void + { + try { + $items = app(MarketplaceClient::class)->items(); + $this->marketItems = array_values(array_filter( + $items, + fn (array $item) => ($item['type'] ?? 'plugin') === 'theme' + )); + Notification::make()->title('市场已刷新:'.count($this->marketItems).' 个主题')->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('市场加载失败')->body($e->getMessage())->danger()->send(); + } + } + + public function installFromThemeMarket(string $index): void + { + $item = $this->marketItems[(int) $index] ?? null; + + if (! $item) { + return; + } + + try { + app(MarketPurchaseService::class)->assertCanInstall($item); + + $result = app(PackageInstaller::class)->installFromMarket($item); + Notification::make()->title('主题安装成功:'.$result['key'])->success()->send(); + } catch (\Throwable $e) { + Notification::make()->title('安装失败')->body($e->getMessage())->danger()->send(); + } + } + + public function purchaseThemeMarketItem(string $index) + { + $item = $this->marketItems[(int) $index] ?? null; + + if (! $item) { + return; + } + + try { + $payment = app(MarketPurchaseService::class)->purchase($item); + + if (! $payment) { + Notification::make()->title('该商品已购买')->info()->send(); + + return; + } + + return redirect()->route('pay.checkout', $payment); + } catch (\Throwable $e) { + Notification::make()->title('购买失败')->body($e->getMessage())->danger()->send(); + } + } + + public function isThemeMarketPurchased(string $index): bool + { + $item = $this->marketItems[(int) $index] ?? null; + + if (! $item) { + return false; + } + + return app(MarketPurchaseService::class)->isPurchased( + app(MarketPurchaseService::class)->itemKey($item), + app(MarketPurchaseService::class)->itemType($item) + ); + } + + public function getMarketConfiguredProperty(): bool + { + return (bool) config('market.url'); + } } diff --git a/app/Models/MarketPackage.php b/app/Models/MarketPackage.php new file mode 100644 index 0000000..88b37d8 --- /dev/null +++ b/app/Models/MarketPackage.php @@ -0,0 +1,47 @@ +belongsTo(Payment::class); + } + + public function isPaid(): bool + { + return $this->status === 'paid'; + } + + public function markPaid(Payment $payment): void + { + $this->update([ + 'status' => 'paid', + 'payment_id' => $payment->id, + ]); + } + + /** + * 作为可购买实体(市场商品)的展示约定。 + */ + public function payableLabel(): string + { + return '市场商品:'.$this->title; + } + + public function payableUrl(): ?string + { + return null; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 91e43ff..de18d7a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,6 +19,23 @@ class AppServiceProvider extends ServiceProvider // } + private function registerMarketPurchaseListener(): void + { + $manager = app(\App\Blog\Support\PluginManager::class); + + if (! $manager->isEnabled('neatstudio.payment')) { + return; + } + + $manager->addAction('payment.paid', function (\Plugins\Neatstudio\Payment\Models\Payment $payment) { + $payable = $payment->payable; + + if ($payable instanceof \App\Models\MarketPackage) { + $payable->markPaid($payment); + } + }, 10); + } + /** * Bootstrap any application services. */ @@ -29,6 +46,9 @@ class AppServiceProvider extends ServiceProvider $this->loadMigrationsFrom($path); } + // 商城付费:支付成功标记市场商品已购(仅当支付插件启用时注册) + $this->registerMarketPurchaseListener(); + // 前台页面缓存(匿名 GET,缓存含 SEO 注入后的最终 HTML) app('router')->pushMiddlewareToGroup('web', \App\Blog\Support\PageCacheMiddleware::class); diff --git a/database/migrations/2026_08_12_200000_create_market_packages_table.php b/database/migrations/2026_08_12_200000_create_market_packages_table.php new file mode 100644 index 0000000..e51d2a1 --- /dev/null +++ b/database/migrations/2026_08_12_200000_create_market_packages_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('item_key', 190); + $table->string('item_type', 20); // plugin / theme + $table->string('title', 255); + $table->unsignedBigInteger('price'); // 单位:分 + $table->string('status', 20)->default('pending'); // pending / paid + $table->foreignId('payment_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + + $table->unique(['item_key', 'item_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('market_packages'); + } +}; diff --git a/docs/plugins.md b/docs/plugins.md index b972734..d3d9795 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -158,6 +158,31 @@ public function payableUrl(): ?string { return route('filament.admin.resources. - 后台「插件管理」→ 上传 ZIP 安装,或配置远程市场 `MARKET_URL` - 内置插件(config/plugins.php enabled 列表)不可卸载,只能停用 +## 远程市场契约 + +配置 `MARKET_URL` 后,后台「插件管理 / 主题管理」会请求 `{MARKET_URL}/items`(`market.token` 作为 Bearer Token),响应格式: + +```json +{ + "items": [ + { + "name": "vendor.demo", + "type": "plugin", + "title": "演示插件", + "description": "描述", + "version": "1.0.0", + "author": "作者", + "price": 9900, + "download_url": "https://example.com/vendor.demo.zip" + } + ] +} +``` + +- `name`:包键(插件为 `vendor.name`,主题为主题名),**购买授权按此键记录** +- `type`:`plugin` / `theme`——插件市场 Tab 只显示 plugin,主题市场 Tab 只显示 theme +- `price`:价格(分),`0` = 免费;`> 0` 显示「购买」按钮,走 `Payment` 订单(可 `payable` 为 `MarketPackage`),支付成功后标记已购,未购买无法安装 + ## 异步任务(配合 Workerman) 实现 `App\Blog\Jobs\AiJob` 接口并在 Workerman 常驻进程执行: diff --git a/resources/views/filament/pages/plugins.blade.php b/resources/views/filament/pages/plugins.blade.php index 6806678..ce35941 100644 --- a/resources/views/filament/pages/plugins.blade.php +++ b/resources/views/filament/pages/plugins.blade.php @@ -66,19 +66,32 @@
@forelse($marketItems as $index => $item) + @php $marketPaid = $item['price'] > 0; @endphp
- +
+ + @if($marketPaid) + ¥{{ number_format($item['price'] / 100, 2) }} + @else + 免费 + @endif +

{{ $item['title'] ?? $item['name'] }}

{{ $item['description'] ?? '' }}

{{ $item['version'] ?? '' }} · {{ $item['author'] ?? '' }}

-
- 安装 +
+ @if($marketPaid && ! $this->isMarketPurchased($index)) + 购买 + @elseif($marketPaid) + 已购买 + @endif + 安装
@empty -

点击「刷新市场」拉取可用插件/主题

+

点击「刷新市场」拉取可用插件

@endforelse
diff --git a/resources/views/filament/pages/themes.blade.php b/resources/views/filament/pages/themes.blade.php index 4456323..9453492 100644 --- a/resources/views/filament/pages/themes.blade.php +++ b/resources/views/filament/pages/themes.blade.php @@ -64,4 +64,40 @@ 安装 + + +
+ 刷新市场 +
+
+ @forelse($marketItems as $index => $item) + @php $marketPaid = $item['price'] > 0; @endphp + +
+
+ + @if($marketPaid) + ¥{{ number_format($item['price'] / 100, 2) }} + @else + 免费 + @endif +
+

{{ $item['title'] ?? $item['name'] }}

+

{{ $item['description'] ?? '' }}

+

{{ $item['version'] ?? '' }} · {{ $item['author'] ?? '' }}

+
+ @if($marketPaid && ! $this->isThemeMarketPurchased($index)) + 购买 + @elseif($marketPaid) + 已购买 + @endif + 安装 +
+
+
+ @empty +

点击「刷新市场」拉取可用主题

+ @endforelse +
+
diff --git a/tests/Feature/MarketPurchaseTest.php b/tests/Feature/MarketPurchaseTest.php new file mode 100644 index 0000000..2859464 --- /dev/null +++ b/tests/Feature/MarketPurchaseTest.php @@ -0,0 +1,125 @@ + 'vendor.demo', + 'type' => 'plugin', + 'title' => '演示付费插件', + 'description' => '演示', + 'version' => '1.0.0', + 'price' => 9900, + 'download_url' => 'https://example.com/vendor.demo.zip', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->seed(); + Setting::set('pay_sandbox', '1'); + + $this->user = User::create([ + 'name' => '买家', + 'email' => 'buyer@test.com', + 'password' => bcrypt('password'), + ]); + } + + private function service(): MarketPurchaseService + { + return app(MarketPurchaseService::class); + } + + public function test_purchase_creates_order_with_payable_package(): void + { + $this->actingAs($this->user); + + $payment = $this->service()->purchase($this->item); + + $this->assertNotNull($payment); + $this->assertInstanceOf(MarketPackage::class, $payment->payable); + $this->assertSame('pending', $payment->payable->status); + $this->assertSame(9900, $payment->amount); + $this->assertSame('市场商品:演示付费插件', $payment->payable_label); + } + + public function test_sandbox_payment_marks_package_paid(): void + { + $this->actingAs($this->user); + + $payment = $this->service()->purchase($this->item); + $this->actingAs($this->user)->post('/pay/sandbox/'.$payment->order_no.'/confirm'); + + $package = MarketPackage::query()->first(); + $this->assertTrue($package->isPaid()); + $this->assertSame($payment->id, $package->payment_id); + $this->assertTrue($this->service()->isPurchased('vendor.demo', 'plugin')); + } + + public function test_purchase_returns_null_when_already_paid(): void + { + $this->actingAs($this->user); + + $this->service()->purchase($this->item); + $package = MarketPackage::query()->first(); + $package->markPaid($package->payment ?? Payment::first() ?? Payment::create([ + 'order_no' => 'X'.uniqid(), + 'user_id' => $this->user->id, + 'subject' => '占位', + 'amount' => 0, + 'status' => 'paid', + 'paid_at' => now(), + ])); + + $this->assertNull($this->service()->purchase($this->item)); + } + + public function test_assert_can_install_blocks_unpaid_paid_item(): void + { + $this->actingAs($this->user); + + $this->expectException(RuntimeException::class); + $this->service()->assertCanInstall($this->item); + } + + public function test_free_item_needs_no_purchase(): void + { + $this->actingAs($this->user); + + $free = ['name' => 'vendor.free', 'type' => 'plugin', 'title' => '免费插件', 'price' => 0]; + + $this->assertFalse($this->service()->requiresPayment($free)); + $this->service()->assertCanInstall($free); // 不抛异常 + + $this->assertTrue(true); + } + + public function test_purchase_requires_payment_plugin(): void + { + $this->actingAs($this->user); + + app(\App\Blog\Support\PluginManager::class)->disable('neatstudio.membership'); + app(\App\Blog\Support\PluginManager::class)->disable('neatstudio.payment'); + + $this->expectException(RuntimeException::class); + $this->service()->purchase($this->item); + } +}