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 @@
{{ $item['description'] ?? '' }}
{{ $item['version'] ?? '' }} · {{ $item['author'] ?? '' }}
-点击「刷新市场」拉取可用插件/主题
+点击「刷新市场」拉取可用插件
@endforelse{{ $item['description'] ?? '' }}
+{{ $item['version'] ?? '' }} · {{ $item['author'] ?? '' }}
+点击「刷新市场」拉取可用主题
+ @endforelse +