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:
@@ -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);
|
||||
|
||||
|
||||
+24
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user