feat: Payment 订单商品化——多态 payable 关联购买实体

- payments 表新增 payable_type/payable_id(nullableMorphs),订单可关联文章/套餐等可购买实体
- createOrder 接收 $payable 模型,取代 description 字符串解析
- membership 支付回调按 payable instanceof 分发(激活订阅 / 解锁单篇)
- Post/MembershipPlan 提供 payableLabel/payableUrl 约定方法;Payment 用约定方法生成展示标签与后台链接(皮肤等新实体可直接接入)
- 订单表格新增「类型」徽章(文章/会员套餐),商品列链接到对应后台编辑页;无 payable 的旧订单显示 —
This commit is contained in:
ak
2026-08-12 00:22:50 +08:00
parent 4f59afb7ce
commit ec087fe2a8
9 changed files with 178 additions and 36 deletions
+13
View File
@@ -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) {
@@ -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);
@@ -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);
}
}
@@ -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);
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payments', function (Blueprint $table) {
$table->nullableMorphs('payable');
});
}
public function down(): void
{
Schema::table('payments', function (Blueprint $table) {
$table->dropMorphs('payable');
});
}
};
@@ -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')
@@ -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([
@@ -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(),
]);
}
+47 -1
View File
@@ -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);
}
}