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);
|
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
|
public function getExcerptOrFallbackAttribute(): string
|
||||||
{
|
{
|
||||||
if ($this->excerpt) {
|
if ($this->excerpt) {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class MembershipController
|
|||||||
$user,
|
$user,
|
||||||
'会员套餐:'.$plan->name,
|
'会员套餐:'.$plan->name,
|
||||||
$plan->price,
|
$plan->price,
|
||||||
$channel,
|
$plan,
|
||||||
'MembershipPlan#'.$plan->id
|
$channel
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('pay.checkout', $payment);
|
return redirect()->route('pay.checkout', $payment);
|
||||||
@@ -77,8 +77,8 @@ class MembershipController
|
|||||||
$user,
|
$user,
|
||||||
'解锁文章:'.$post->title,
|
'解锁文章:'.$post->title,
|
||||||
$price,
|
$price,
|
||||||
$channel,
|
$post,
|
||||||
'PostUnlock#'.$post->id
|
$channel
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('pay.checkout', $payment);
|
return redirect()->route('pay.checkout', $payment);
|
||||||
|
|||||||
@@ -23,4 +23,17 @@ class MembershipPlan extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Subscription::class);
|
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\PluginManager;
|
||||||
use App\Blog\Support\PluginServiceProvider;
|
use App\Blog\Support\PluginServiceProvider;
|
||||||
|
use App\Models\Post;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@@ -14,7 +15,6 @@ use Filament\Schemas\Schema;
|
|||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||||
@@ -25,41 +25,31 @@ class ServiceProvider extends PluginServiceProvider
|
|||||||
{
|
{
|
||||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||||
|
|
||||||
// 支付成功:激活会员订阅 / 解锁单篇付费文章
|
// 支付成功:按购买实体分发(激活会员订阅 / 解锁单篇付费文章)
|
||||||
$manager->addAction('payment.paid', function (Payment $payment) {
|
$manager->addAction('payment.paid', function (Payment $payment) {
|
||||||
$description = (string) $payment->description;
|
|
||||||
$user = $payment->user;
|
$user = $payment->user;
|
||||||
|
$payable = $payment->payable;
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user || ! $payable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($description, 'MembershipPlan#')) {
|
if ($payable instanceof MembershipPlan) {
|
||||||
$planId = (int) substr($description, strlen('MembershipPlan#'));
|
$now = now();
|
||||||
$plan = MembershipPlan::find($planId);
|
Subscription::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
if ($plan) {
|
'membership_plan_id' => $payable->id,
|
||||||
$now = now();
|
'status' => 'active',
|
||||||
Subscription::create([
|
'starts_at' => $now,
|
||||||
'user_id' => $user->id,
|
'ends_at' => $now->copy()->addDays($payable->duration_days),
|
||||||
'membership_plan_id' => $plan->id,
|
]);
|
||||||
'status' => 'active',
|
|
||||||
'starts_at' => $now,
|
|
||||||
'ends_at' => $now->copy()->addDays($plan->duration_days),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($description, 'PostUnlock#')) {
|
if ($payable instanceof Post) {
|
||||||
$postId = (int) substr($description, strlen('PostUnlock#'));
|
$meta = $payable->meta ?? [];
|
||||||
$post = \App\Models\Post::find($postId);
|
$unlocked = $meta['unlocked_user_ids'] ?? [];
|
||||||
|
$unlocked[] = $user->id;
|
||||||
if ($post) {
|
$payable->update(['meta' => array_merge($meta, ['unlocked_user_ids' => array_values(array_unique($unlocked))])]);
|
||||||
$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))])]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 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([
|
->columns([
|
||||||
\Filament\Tables\Columns\TextColumn::make('order_no')->label('订单号')->searchable(),
|
\Filament\Tables\Columns\TextColumn::make('order_no')->label('订单号')->searchable(),
|
||||||
\Filament\Tables\Columns\TextColumn::make('user.name')->label('用户'),
|
\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('金额')
|
\Filament\Tables\Columns\TextColumn::make('amount')->label('金额')
|
||||||
->formatStateUsing(fn ($state) => '¥'.number_format((float) $state / 100, 2)),
|
->formatStateUsing(fn ($state) => '¥'.number_format((float) $state / 100, 2)),
|
||||||
\Filament\Tables\Columns\TextColumn::make('channel')->label('渠道')->badge(),
|
\Filament\Tables\Columns\TextColumn::make('channel')->label('渠道')->badge(),
|
||||||
@@ -61,7 +76,6 @@ class PaymentResource extends Resource
|
|||||||
default => 'gray',
|
default => 'gray',
|
||||||
}),
|
}),
|
||||||
\Filament\Tables\Columns\TextColumn::make('paid_at')->label('支付时间')->dateTime('Y-m-d H:i'),
|
\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([
|
->filters([
|
||||||
\Filament\Tables\Filters\SelectFilter::make('status')
|
\Filament\Tables\Filters\SelectFilter::make('status')
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ namespace Plugins\Neatstudio\Payment\Models;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
class Payment extends Model
|
class Payment extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'order_no', 'user_id', 'subject', 'description', 'amount', 'channel', 'status', 'gateway_trade_no', 'payload', 'paid_at',
|
'order_no', 'user_id', 'subject', 'description', 'amount', 'channel', 'status', 'gateway_trade_no', 'payload', 'paid_at',
|
||||||
|
'payable_type', 'payable_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -25,6 +27,40 @@ class Payment extends Model
|
|||||||
return $this->belongsTo(User::class);
|
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
|
public function markPaid(string $gatewayTradeNo = null): void
|
||||||
{
|
{
|
||||||
$this->update([
|
$this->update([
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Plugins\Neatstudio\Payment\Services;
|
|||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||||
use Yansongda\Pay\Pay;
|
use Yansongda\Pay\Pay;
|
||||||
@@ -15,7 +16,10 @@ use Yansongda\Pay\Provider\Wechat;
|
|||||||
|
|
||||||
class PaymentManager
|
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);
|
$orderNo = date('YmdHis').Str::random(8);
|
||||||
|
|
||||||
@@ -27,6 +31,8 @@ class PaymentManager
|
|||||||
'amount' => $amountCents,
|
'amount' => $amountCents,
|
||||||
'channel' => $channel,
|
'channel' => $channel,
|
||||||
'status' => 'pending',
|
'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();
|
$payment = Payment::query()->where('user_id', $this->user->id)->first();
|
||||||
$this->assertNotNull($payment);
|
$this->assertNotNull($payment);
|
||||||
$this->assertSame('pending', $payment->status);
|
$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
|
public function test_sandbox_payment_activates_subscription(): void
|
||||||
@@ -101,4 +102,49 @@ class MembershipFlowTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('付费隐藏内容');
|
->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