From ec087fe2a8ad836d8c3455bb6e385e4937444181 Mon Sep 17 00:00:00 2001 From: ak Date: Wed, 12 Aug 2026 00:22:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Payment=20=E8=AE=A2=E5=8D=95=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=8C=96=E2=80=94=E2=80=94=E5=A4=9A=E6=80=81=20payabl?= =?UTF-8?q?e=20=E5=85=B3=E8=81=94=E8=B4=AD=E4=B9=B0=E5=AE=9E=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - payments 表新增 payable_type/payable_id(nullableMorphs),订单可关联文章/套餐等可购买实体 - createOrder 接收 $payable 模型,取代 description 字符串解析 - membership 支付回调按 payable instanceof 分发(激活订阅 / 解锁单篇) - Post/MembershipPlan 提供 payableLabel/payableUrl 约定方法;Payment 用约定方法生成展示标签与后台链接(皮肤等新实体可直接接入) - 订单表格新增「类型」徽章(文章/会员套餐),商品列链接到对应后台编辑页;无 payable 的旧订单显示 — --- app/Models/Post.php | 13 +++++ .../src/Http/MembershipController.php | 8 ++-- .../src/Models/MembershipPlan.php | 13 +++++ .../src/ServiceProvider.php | 46 +++++++----------- ...2_100000_add_payable_to_payments_table.php | 24 ++++++++++ .../Filament/Resources/PaymentResource.php | 18 ++++++- .../neatstudio.payment/src/Models/Payment.php | 36 ++++++++++++++ .../src/Services/PaymentManager.php | 8 +++- tests/Feature/MembershipFlowTest.php | 48 ++++++++++++++++++- 9 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 plugins/neatstudio.payment/database/migrations/2026_08_12_100000_add_payable_to_payments_table.php diff --git a/app/Models/Post.php b/app/Models/Post.php index 31b8559..674dea6 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -107,6 +107,19 @@ class Post extends Model implements HasMedia return route('posts.show', $this->slug ?? $this->id); } + /** + * 作为可购买实体(单篇解锁)的展示约定。 + */ + public function payableLabel(): string + { + return '文章:'.$this->title; + } + + public function payableUrl(): ?string + { + return route('filament.admin.resources.posts.edit', $this); + } + public function getExcerptOrFallbackAttribute(): string { if ($this->excerpt) { diff --git a/plugins/neatstudio.membership/src/Http/MembershipController.php b/plugins/neatstudio.membership/src/Http/MembershipController.php index 12cba51..052d22a 100644 --- a/plugins/neatstudio.membership/src/Http/MembershipController.php +++ b/plugins/neatstudio.membership/src/Http/MembershipController.php @@ -43,8 +43,8 @@ class MembershipController $user, '会员套餐:'.$plan->name, $plan->price, - $channel, - 'MembershipPlan#'.$plan->id + $plan, + $channel ); return redirect()->route('pay.checkout', $payment); @@ -77,8 +77,8 @@ class MembershipController $user, '解锁文章:'.$post->title, $price, - $channel, - 'PostUnlock#'.$post->id + $post, + $channel ); return redirect()->route('pay.checkout', $payment); diff --git a/plugins/neatstudio.membership/src/Models/MembershipPlan.php b/plugins/neatstudio.membership/src/Models/MembershipPlan.php index 1c88ba1..6ecf6c4 100644 --- a/plugins/neatstudio.membership/src/Models/MembershipPlan.php +++ b/plugins/neatstudio.membership/src/Models/MembershipPlan.php @@ -23,4 +23,17 @@ class MembershipPlan extends Model { return $this->hasMany(Subscription::class); } + + /** + * 作为可购买实体的展示约定。 + */ + public function payableLabel(): string + { + return '会员套餐:'.$this->name; + } + + public function payableUrl(): ?string + { + return route('filament.admin.resources.membership-plans.edit', $this); + } } diff --git a/plugins/neatstudio.membership/src/ServiceProvider.php b/plugins/neatstudio.membership/src/ServiceProvider.php index 39c6c6b..88caffe 100644 --- a/plugins/neatstudio.membership/src/ServiceProvider.php +++ b/plugins/neatstudio.membership/src/ServiceProvider.php @@ -7,6 +7,7 @@ namespace Plugins\Neatstudio\Membership; use App\Blog\Support\PluginManager; use App\Blog\Support\PluginServiceProvider; +use App\Models\Post; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Section; @@ -14,7 +15,6 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Support\Str; use Plugins\Neatstudio\Membership\Models\MembershipPlan; use Plugins\Neatstudio\Membership\Models\Subscription; use Plugins\Neatstudio\Payment\Models\Payment; @@ -25,41 +25,31 @@ class ServiceProvider extends PluginServiceProvider { $this->loadRoutes(__DIR__.'/../routes/web.php'); - // 支付成功:激活会员订阅 / 解锁单篇付费文章 + // 支付成功:按购买实体分发(激活会员订阅 / 解锁单篇付费文章) $manager->addAction('payment.paid', function (Payment $payment) { - $description = (string) $payment->description; $user = $payment->user; + $payable = $payment->payable; - if (! $user) { + if (! $user || ! $payable) { return; } - if (str_starts_with($description, 'MembershipPlan#')) { - $planId = (int) substr($description, strlen('MembershipPlan#')); - $plan = MembershipPlan::find($planId); - - if ($plan) { - $now = now(); - Subscription::create([ - 'user_id' => $user->id, - 'membership_plan_id' => $plan->id, - 'status' => 'active', - 'starts_at' => $now, - 'ends_at' => $now->copy()->addDays($plan->duration_days), - ]); - } + if ($payable instanceof MembershipPlan) { + $now = now(); + Subscription::create([ + 'user_id' => $user->id, + 'membership_plan_id' => $payable->id, + 'status' => 'active', + 'starts_at' => $now, + 'ends_at' => $now->copy()->addDays($payable->duration_days), + ]); } - if (str_starts_with($description, 'PostUnlock#')) { - $postId = (int) substr($description, strlen('PostUnlock#')); - $post = \App\Models\Post::find($postId); - - if ($post) { - $meta = $post->meta ?? []; - $unlocked = $meta['unlocked_user_ids'] ?? []; - $unlocked[] = $user->id; - $post->update(['meta' => array_merge($meta, ['unlocked_user_ids' => array_values(array_unique($unlocked))])]); - } + if ($payable instanceof Post) { + $meta = $payable->meta ?? []; + $unlocked = $meta['unlocked_user_ids'] ?? []; + $unlocked[] = $user->id; + $payable->update(['meta' => array_merge($meta, ['unlocked_user_ids' => array_values(array_unique($unlocked))])]); } }, 10); diff --git a/plugins/neatstudio.payment/database/migrations/2026_08_12_100000_add_payable_to_payments_table.php b/plugins/neatstudio.payment/database/migrations/2026_08_12_100000_add_payable_to_payments_table.php new file mode 100644 index 0000000..8dbcf50 --- /dev/null +++ b/plugins/neatstudio.payment/database/migrations/2026_08_12_100000_add_payable_to_payments_table.php @@ -0,0 +1,24 @@ +nullableMorphs('payable'); + }); + } + + public function down(): void + { + Schema::table('payments', function (Blueprint $table) { + $table->dropMorphs('payable'); + }); + } +}; diff --git a/plugins/neatstudio.payment/src/Filament/Resources/PaymentResource.php b/plugins/neatstudio.payment/src/Filament/Resources/PaymentResource.php index 787b5a5..54ac428 100644 --- a/plugins/neatstudio.payment/src/Filament/Resources/PaymentResource.php +++ b/plugins/neatstudio.payment/src/Filament/Resources/PaymentResource.php @@ -42,7 +42,22 @@ class PaymentResource extends Resource ->columns([ \Filament\Tables\Columns\TextColumn::make('order_no')->label('订单号')->searchable(), \Filament\Tables\Columns\TextColumn::make('user.name')->label('用户'), - \Filament\Tables\Columns\TextColumn::make('subject')->label('商品')->limit(30), + \Filament\Tables\Columns\TextColumn::make('subject')->label('商品') + ->limit(30) + ->url(fn (Payment $record) => $record->payable_url), + \Filament\Tables\Columns\TextColumn::make('payable_type')->label('类型') + ->badge() + ->placeholder('—') + ->formatStateUsing(fn ($state) => match (class_basename((string) $state)) { + 'Post' => '文章', + 'MembershipPlan' => '会员套餐', + default => $state ? class_basename((string) $state) : '—', + }) + ->color(fn ($state) => match (class_basename((string) $state)) { + 'Post' => 'info', + 'MembershipPlan' => 'success', + default => 'gray', + }), \Filament\Tables\Columns\TextColumn::make('amount')->label('金额') ->formatStateUsing(fn ($state) => '¥'.number_format((float) $state / 100, 2)), \Filament\Tables\Columns\TextColumn::make('channel')->label('渠道')->badge(), @@ -61,7 +76,6 @@ class PaymentResource extends Resource default => 'gray', }), \Filament\Tables\Columns\TextColumn::make('paid_at')->label('支付时间')->dateTime('Y-m-d H:i'), - \Filament\Tables\Columns\TextColumn::make('created_at')->label('创建时间')->dateTime('Y-m-d H:i'), ]) ->filters([ \Filament\Tables\Filters\SelectFilter::make('status') diff --git a/plugins/neatstudio.payment/src/Models/Payment.php b/plugins/neatstudio.payment/src/Models/Payment.php index bc603a5..b2fe8df 100644 --- a/plugins/neatstudio.payment/src/Models/Payment.php +++ b/plugins/neatstudio.payment/src/Models/Payment.php @@ -8,11 +8,13 @@ namespace Plugins\Neatstudio\Payment\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; class Payment extends Model { protected $fillable = [ 'order_no', 'user_id', 'subject', 'description', 'amount', 'channel', 'status', 'gateway_trade_no', 'payload', 'paid_at', + 'payable_type', 'payable_id', ]; protected $casts = [ @@ -25,6 +27,40 @@ class Payment extends Model return $this->belongsTo(User::class); } + /** + * 可购买实体:文章(单篇解锁)、会员套餐、皮肤等。 + */ + public function payable(): MorphTo + { + return $this->morphTo(); + } + + public function getPayableLabelAttribute(): string + { + $payable = $this->payable; + + if (! $payable) { + return '—'; + } + + if (method_exists($payable, 'payableLabel')) { + return $payable->payableLabel(); + } + + return $payable->title ?? $payable->name ?? ('#'.$payable->getKey()); + } + + public function getPayableUrlAttribute(): ?string + { + $payable = $this->payable; + + if (! $payable || ! method_exists($payable, 'payableUrl')) { + return null; + } + + return $payable->payableUrl(); + } + public function markPaid(string $gatewayTradeNo = null): void { $this->update([ diff --git a/plugins/neatstudio.payment/src/Services/PaymentManager.php b/plugins/neatstudio.payment/src/Services/PaymentManager.php index 947580b..f3da66e 100644 --- a/plugins/neatstudio.payment/src/Services/PaymentManager.php +++ b/plugins/neatstudio.payment/src/Services/PaymentManager.php @@ -7,6 +7,7 @@ namespace Plugins\Neatstudio\Payment\Services; use App\Models\Setting; use App\Models\User; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use Plugins\Neatstudio\Payment\Models\Payment; use Yansongda\Pay\Pay; @@ -15,7 +16,10 @@ use Yansongda\Pay\Provider\Wechat; class PaymentManager { - public function createOrder(User $user, string $subject, int $amountCents, string $channel = 'alipay', string $description = null): Payment + /** + * 创建订单。$payable 为可购买实体(文章/套餐/皮肤),支付成功后经 payment.paid 钩子分发。 + */ + public function createOrder(User $user, string $subject, int $amountCents, ?Model $payable = null, string $channel = 'alipay', string $description = null): Payment { $orderNo = date('YmdHis').Str::random(8); @@ -27,6 +31,8 @@ class PaymentManager 'amount' => $amountCents, 'channel' => $channel, 'status' => 'pending', + 'payable_type' => $payable?->getMorphClass(), + 'payable_id' => $payable?->getKey(), ]); } diff --git a/tests/Feature/MembershipFlowTest.php b/tests/Feature/MembershipFlowTest.php index 19d0a13..9925552 100644 --- a/tests/Feature/MembershipFlowTest.php +++ b/tests/Feature/MembershipFlowTest.php @@ -56,7 +56,8 @@ class MembershipFlowTest extends TestCase $payment = Payment::query()->where('user_id', $this->user->id)->first(); $this->assertNotNull($payment); $this->assertSame('pending', $payment->status); - $this->assertSame('MembershipPlan#'.$this->plan->id, $payment->description); + $this->assertTrue($payment->payable->is($this->plan)); + $this->assertNull($payment->description); } public function test_sandbox_payment_activates_subscription(): void @@ -101,4 +102,49 @@ class MembershipFlowTest extends TestCase ->assertOk() ->assertSee('付费隐藏内容'); } + + public function test_unlock_post_creates_payment_with_payable_and_grants_access(): void + { + $post = Post::create([ + 'title' => '单篇付费', + 'slug' => 'single-paid', + 'content' => '付费内容正文', + 'content_format' => 'markdown', + 'status' => 'published', + 'published_at' => now(), + 'meta' => ['price' => 500], + ]); + + $this->actingAs($this->user) + ->post('/posts/'.$post->id.'/unlock') + ->assertRedirect(); + + $payment = Payment::query()->where('user_id', $this->user->id)->first(); + $this->assertNotNull($payment); + $this->assertTrue($payment->payable->is($post)); + $this->assertSame(500, $payment->amount); + $this->assertStringContainsString('单篇付费', $payment->payable_label); + $this->assertNotNull($payment->payable_url); + + // 沙箱支付后写入解锁用户 + $this->actingAs($this->user)->post('/pay/sandbox/'.$payment->order_no.'/confirm'); + + $post->refresh(); + $this->assertContains($this->user->id, $post->meta['unlocked_user_ids']); + } + + public function test_legacy_order_without_payable_shows_placeholder(): void + { + $payment = Payment::create([ + 'order_no' => 'LEGACY0001', + 'user_id' => $this->user->id, + 'subject' => '历史订单', + 'amount' => 100, + 'status' => 'paid', + 'paid_at' => now(), + ]); + + $this->assertSame('—', $payment->payable_label); + $this->assertNull($payment->payable_url); + } }