wip: article AI polish, category SEO fields, cover generator, membership plan seeder
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# larablog/membership
|
||||
|
||||
Paid membership plans on top of `larablog/payment`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Enable `larablog/payment`.
|
||||
2. Enable this plugin.
|
||||
3. `php artisan migrate`
|
||||
4. Seed demo plans (optional):
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=Plugins\\Larablog\\Membership\\Database\\Seeders\\MembershipPlanSeeder
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- Admin → Plugins group → Membership plans
|
||||
- Public: `/plugins/membership`
|
||||
- Status JSON: `/plugins/membership/status`
|
||||
- Mark an article as members-only in the article form (mutually exclusive with read password and paid-content)
|
||||
|
||||
Checkout uses Stub payment: `/plugins/payment/checkout?product_type=membership&product_id={planId}`.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 会员(`larablog/membership`)
|
||||
|
||||
在「支付」插件之上提供付费会员套餐,以及会员可见文章。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
1. 启用 `larablog/payment`。
|
||||
2. 启用本插件。
|
||||
3. 执行迁移:
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
4. 可选:写入演示套餐
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=Plugins\\Larablog\\Membership\\Database\\Seeders\\MembershipPlanSeeder
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
- 后台 → 插件分组 → 会员套餐
|
||||
- 前台套餐页:`/plugins/membership`
|
||||
- 当前会员状态 JSON:`/plugins/membership/status`
|
||||
- 在文章表单中把文章标为「会员可见」(与阅读密码、单篇付费互斥)
|
||||
|
||||
结账走 Stub 支付:`/plugins/payment/checkout?product_type=membership&product_id={套餐ID}`。
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?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::create('membership_plans', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->decimal('price', 10, 2);
|
||||
$table->string('currency', 8)->default('CNY');
|
||||
$table->unsignedInteger('duration_days')->nullable();
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('membership_plans');
|
||||
}
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?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::create('article_membership', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('article_id')->unique()->constrained('articles')->cascadeOnDelete();
|
||||
$table->boolean('enabled')->default(false);
|
||||
$table->foreignId('required_plan_id')
|
||||
->nullable()
|
||||
->constrained('membership_plans')
|
||||
->restrictOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('article_membership');
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"name": "larablog/membership",
|
||||
"title": "Membership",
|
||||
"version": "1.0.0",
|
||||
"description": "Membership plugin skeleton.",
|
||||
"provider": "Plugins\\Larablog\\Membership\\PluginServiceProvider"
|
||||
"name": "larablog/membership",
|
||||
"title": "Membership",
|
||||
"version": "1.1.0",
|
||||
"description": "Membership plans, stub subscribe via payment, and members-only articles.",
|
||||
"provider": "Plugins\\Larablog\\Membership\\PluginServiceProvider",
|
||||
"requires": ["larablog/payment"],
|
||||
"docs": "README.md"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('frontend.membership.title') }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a2a33; }
|
||||
.plan { border: 1px solid #d5dee3; border-radius: 8px; padding: 1rem 1.25rem; margin: 1rem 0; }
|
||||
.price { font-size: 1.4rem; font-weight: 700; }
|
||||
.meta { color: #5b6b75; font-size: 0.92rem; }
|
||||
.btn { display: inline-block; margin-top: 0.75rem; padding: 0.55rem 1rem; background: #0f3d4c; color: #fff; text-decoration: none; border-radius: 6px; }
|
||||
.status { background: #eef7f6; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1.25rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ __('frontend.membership.title') }}</h1>
|
||||
|
||||
<div class="status">
|
||||
@if(($status['active'] ?? false))
|
||||
<p>{{ __('frontend.membership.active', ['plan' => $status['plan_name'] ?? '—']) }}</p>
|
||||
@if(!empty($status['expires_at']))
|
||||
<p class="meta">{{ __('frontend.membership.expires', ['date' => $status['expires_at']]) }}</p>
|
||||
@else
|
||||
<p class="meta">{{ __('frontend.membership.lifetime') }}</p>
|
||||
@endif
|
||||
@else
|
||||
<p>{{ __('frontend.membership.inactive') }}</p>
|
||||
@endif
|
||||
<p class="meta"><a href="{{ url('/?action=profile') }}">{{ __('frontend.membership.back_profile') }}</a></p>
|
||||
</div>
|
||||
|
||||
@forelse($plans as $plan)
|
||||
<article class="plan">
|
||||
<h2>{{ $plan->name }}</h2>
|
||||
<p class="price">{{ $plan->currency }} {{ $plan->price }}</p>
|
||||
<p class="meta">
|
||||
@if($plan->duration_days)
|
||||
{{ __('frontend.membership.days', ['days' => $plan->duration_days]) }}
|
||||
@else
|
||||
{{ __('frontend.membership.lifetime') }}
|
||||
@endif
|
||||
</p>
|
||||
@if($plan->description)
|
||||
<p>{{ $plan->description }}</p>
|
||||
@endif
|
||||
<a class="btn" href="{{ url('/plugins/payment/checkout').'?'.http_build_query([
|
||||
'product_type' => 'membership',
|
||||
'product_id' => $plan->id,
|
||||
'return_url' => url('/plugins/membership'),
|
||||
]) }}">{{ __('frontend.membership.subscribe') }}</a>
|
||||
</article>
|
||||
@empty
|
||||
<p>{{ __('frontend.membership.no_plans') }}</p>
|
||||
@endforelse
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Plugins\Larablog\Membership\Models\MembershipPlan;
|
||||
|
||||
class MembershipPlanSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
MembershipPlan::query()->updateOrCreate(
|
||||
['slug' => 'monthly'],
|
||||
[
|
||||
'name' => '月度会员',
|
||||
'description' => '30 天会员,可阅读会员可见文章。',
|
||||
'price' => '9.90',
|
||||
'currency' => 'CNY',
|
||||
'duration_days' => 30,
|
||||
'enabled' => true,
|
||||
'sort_order' => 10,
|
||||
],
|
||||
);
|
||||
|
||||
MembershipPlan::query()->updateOrCreate(
|
||||
['slug' => 'lifetime'],
|
||||
[
|
||||
'name' => '终身会员',
|
||||
'description' => '一次购买,长期有效。',
|
||||
'price' => '99.00',
|
||||
'currency' => 'CNY',
|
||||
'duration_days' => null,
|
||||
'enabled' => true,
|
||||
'sort_order' => 20,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Domain;
|
||||
|
||||
use App\Models\User;
|
||||
use Plugins\Larablog\Membership\Models\MembershipPlan;
|
||||
use Plugins\Larablog\Payment\Domain\OrderService;
|
||||
use Plugins\Larablog\Payment\Domain\ProductType;
|
||||
use Plugins\Larablog\Payment\Models\Entitlement;
|
||||
|
||||
class MembershipService
|
||||
{
|
||||
public function __construct(
|
||||
protected OrderService $orders,
|
||||
) {}
|
||||
|
||||
public function isActive(?User $user): bool
|
||||
{
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->orders->hasActiveAny((int) $user->id, ProductType::MEMBERSHIP);
|
||||
}
|
||||
|
||||
public function hasPlan(?User $user, int $planId): bool
|
||||
{
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->orders->hasEntitlement((int) $user->id, ProductType::MEMBERSHIP, $planId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{active: bool, plan_id: ?int, plan_slug: ?string, plan_name: ?string, expires_at: ?string}
|
||||
*/
|
||||
public function statusFor(?User $user): array
|
||||
{
|
||||
if ($user === null) {
|
||||
return [
|
||||
'active' => false,
|
||||
'plan_id' => null,
|
||||
'plan_slug' => null,
|
||||
'plan_name' => null,
|
||||
'expires_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$entitlement = Entitlement::query()
|
||||
->active()
|
||||
->where('user_id', $user->id)
|
||||
->where('product_type', ProductType::MEMBERSHIP)
|
||||
->orderByRaw('expires_at is null desc')
|
||||
->orderByDesc('expires_at')
|
||||
->first();
|
||||
|
||||
if ($entitlement === null) {
|
||||
return [
|
||||
'active' => false,
|
||||
'plan_id' => null,
|
||||
'plan_slug' => null,
|
||||
'plan_name' => null,
|
||||
'expires_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$plan = MembershipPlan::query()->find($entitlement->product_id);
|
||||
|
||||
return [
|
||||
'active' => true,
|
||||
'plan_id' => (int) $entitlement->product_id,
|
||||
'plan_slug' => $plan?->slug,
|
||||
'plan_name' => $plan?->name,
|
||||
'expires_at' => optional($entitlement->expires_at)?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
public function cheapestEnabledPlan(): ?MembershipPlan
|
||||
{
|
||||
return MembershipPlan::query()
|
||||
->enabled()
|
||||
->orderBy('price')
|
||||
->orderBy('sort_order')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Filament\Resources;
|
||||
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use App\Filament\Support\AdminTable;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource\Pages\CreateMembershipPlan;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource\Pages\EditMembershipPlan;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource\Pages\ListMembershipPlans;
|
||||
use Plugins\Larablog\Membership\Models\MembershipPlan;
|
||||
|
||||
class MembershipPlanResource extends Resource
|
||||
{
|
||||
protected static ?string $model = MembershipPlan::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedIdentification;
|
||||
|
||||
protected static ?int $navigationSort = 102;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return __('admin.groups.plugins');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.nav.membership_plans');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('admin.models.membership_plan');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('admin.models.membership_plans');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return static::pluginEnabled();
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return static::pluginEnabled();
|
||||
}
|
||||
|
||||
protected static function pluginEnabled(): bool
|
||||
{
|
||||
return app(PluginManager::class)->isEnabled('larablog/membership');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextInput::make('slug')->label(__('admin.fields.slug'))->required()->unique(ignoreRecord: true),
|
||||
TextInput::make('name')->label(__('admin.fields.name'))->required(),
|
||||
Textarea::make('description')->label(__('admin.fields.description'))->columnSpanFull(),
|
||||
TextInput::make('price')->label(__('admin.fields.price'))->numeric()->required(),
|
||||
TextInput::make('currency')->label(__('admin.fields.currency'))->default('CNY')->required(),
|
||||
TextInput::make('duration_days')
|
||||
->label(__('admin.fields.duration_days'))
|
||||
->numeric()
|
||||
->helperText(__('admin.helpers.duration_days')),
|
||||
TextInput::make('sort_order')->label(__('admin.fields.display_order'))->numeric()->default(0),
|
||||
Toggle::make('enabled')->label(__('admin.fields.enabled'))->default(true),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
AdminTable::ellipsis(
|
||||
TextColumn::make('name')->label(__('admin.fields.name'))->searchable(),
|
||||
),
|
||||
TextColumn::make('slug')->label(__('admin.fields.slug')),
|
||||
TextColumn::make('price')->label(__('admin.fields.price')),
|
||||
TextColumn::make('duration_days')
|
||||
->label(__('admin.fields.duration_days'))
|
||||
->formatStateUsing(fn ($state) => $state === null ? __('admin.options.lifetime') : (string) $state),
|
||||
IconColumn::make('enabled')->label(__('admin.fields.enabled'))->boolean(),
|
||||
TextColumn::make('sort_order')->label(__('admin.fields.display_order')),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->before(function (MembershipPlan $record, DeleteAction $action): void {
|
||||
if ($record->articleGates()->exists()) {
|
||||
Notification::make()
|
||||
->title(__('admin.messages.membership_plan_in_use_articles'))
|
||||
->danger()
|
||||
->send();
|
||||
$action->cancel();
|
||||
}
|
||||
|
||||
if ($record->hasEntitlements()) {
|
||||
Notification::make()
|
||||
->title(__('admin.messages.membership_plan_has_entitlements'))
|
||||
->danger()
|
||||
->send();
|
||||
$action->cancel();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->defaultSort('sort_order');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListMembershipPlans::route('/'),
|
||||
'create' => CreateMembershipPlan::route('/create'),
|
||||
'edit' => EditMembershipPlan::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class CreateMembershipPlan extends CreateRecord
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource;
|
||||
use Plugins\Larablog\Membership\Models\MembershipPlan;
|
||||
|
||||
class EditMembershipPlan extends EditRecord
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make()
|
||||
->before(function (DeleteAction $action): void {
|
||||
/** @var MembershipPlan $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
if ($record->articleGates()->exists()) {
|
||||
Notification::make()
|
||||
->title(__('admin.messages.membership_plan_in_use_articles'))
|
||||
->danger()
|
||||
->send();
|
||||
$action->cancel();
|
||||
}
|
||||
|
||||
if ($record->hasEntitlements()) {
|
||||
Notification::make()
|
||||
->title(__('admin.messages.membership_plan_has_entitlements'))
|
||||
->danger()
|
||||
->send();
|
||||
$action->cancel();
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class ListMembershipPlans extends ListRecords
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Models;
|
||||
|
||||
use App\Models\Article;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ArticleMembership extends Model
|
||||
{
|
||||
protected $table = 'article_membership';
|
||||
|
||||
protected $fillable = [
|
||||
'article_id',
|
||||
'enabled',
|
||||
'required_plan_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'required_plan_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function article(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Article::class);
|
||||
}
|
||||
|
||||
public function requiredPlan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipPlan::class, 'required_plan_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Plugins\Larablog\Payment\Domain\ProductType;
|
||||
use Plugins\Larablog\Payment\Models\Entitlement;
|
||||
|
||||
class MembershipPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'price',
|
||||
'currency',
|
||||
'duration_days',
|
||||
'enabled',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'decimal:2',
|
||||
'duration_days' => 'integer',
|
||||
'enabled' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function articleGates(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArticleMembership::class, 'required_plan_id');
|
||||
}
|
||||
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
}
|
||||
|
||||
public function isLifetime(): bool
|
||||
{
|
||||
return $this->duration_days === null;
|
||||
}
|
||||
|
||||
public function hasEntitlements(): bool
|
||||
{
|
||||
return Entitlement::query()
|
||||
->where('product_type', ProductType::MEMBERSHIP)
|
||||
->where('product_id', $this->id)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Membership;
|
||||
|
||||
use App\Domain\Blog\AccessDecision;
|
||||
use App\Domain\Blog\HtmlTeaser;
|
||||
use App\Domain\Plugin\Hook;
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use App\Models\Article;
|
||||
use App\Models\User;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Panel;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Plugins\Larablog\Membership\Domain\MembershipService;
|
||||
use Plugins\Larablog\Membership\Filament\Resources\MembershipPlanResource;
|
||||
use Plugins\Larablog\Membership\Models\ArticleMembership;
|
||||
use Plugins\Larablog\Membership\Models\MembershipPlan;
|
||||
use Plugins\Larablog\Payment\Domain\ProductType;
|
||||
use Plugins\Larablog\Payment\Models\Entitlement;
|
||||
use Plugins\Larablog\Payment\Models\Order;
|
||||
|
||||
class PluginServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(MembershipService::class);
|
||||
|
||||
Panel::configureUsing(function (Panel $panel): void {
|
||||
if ($panel->getId() !== 'admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
$panel->resources([MembershipPlanResource::class]);
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Hook::listen('theme.sidebar', function (string $html): string {
|
||||
$title = __('admin.plugins.larablog/membership.title');
|
||||
$desc = __('admin.plugins.larablog/membership.description');
|
||||
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'membership');
|
||||
|
||||
return $html.'<h3>'.e($title).'</h3><p class="note">'.e($desc).' <code>/plugins/membership/status</code></p>';
|
||||
});
|
||||
$this->registerWebRoutes();
|
||||
$this->registerFilamentHooks();
|
||||
$this->registerAccessHook();
|
||||
$this->registerOrderPaidHook();
|
||||
}
|
||||
|
||||
protected function registerWebRoutes(): void
|
||||
{
|
||||
Route::middleware('web')->prefix('plugins/membership')->group(function (): void {
|
||||
Route::get('/', function () {
|
||||
$plans = MembershipPlan::query()
|
||||
->enabled()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return view('membership::plans', [
|
||||
'plans' => $plans,
|
||||
'status' => app(MembershipService::class)->statusFor(Auth::user()),
|
||||
]);
|
||||
})->name('plugins.membership.plans');
|
||||
|
||||
Route::get('/status', function () {
|
||||
$user = auth()->user();
|
||||
$user = Auth::user();
|
||||
$status = app(MembershipService::class)->statusFor($user);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'plugin' => 'larablog/membership',
|
||||
'authenticated' => $user !== null,
|
||||
'roles' => $user?->getRoleNames() ?? [],
|
||||
'message' => 'Membership skeleton. Billing tiers come in phase 2.',
|
||||
...$status,
|
||||
]);
|
||||
});
|
||||
})->name('plugins.membership.status');
|
||||
});
|
||||
}
|
||||
|
||||
protected function registerFilamentHooks(): void
|
||||
{
|
||||
Hook::listen('filament.article.form', function (array $components): array {
|
||||
if (! app(PluginManager::class)->isEnabled('larablog/membership')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Section::make(__('admin.settings.membership'))
|
||||
->schema([
|
||||
Toggle::make('membership.enabled')
|
||||
->label(__('admin.fields.membership_enabled'))
|
||||
->default(false),
|
||||
Select::make('membership.required_plan_id')
|
||||
->label(__('admin.fields.required_plan'))
|
||||
->options(
|
||||
MembershipPlan::query()
|
||||
->orderBy('sort_order')
|
||||
->pluck('name', 'id')
|
||||
->all()
|
||||
)
|
||||
->placeholder(__('admin.options.any_membership'))
|
||||
->nullable(),
|
||||
])
|
||||
->collapsible(),
|
||||
];
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.mutate_before_fill', function (array $data, mixed $record): array {
|
||||
if (! $record instanceof Article) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$row = ArticleMembership::query()->where('article_id', $record->id)->first();
|
||||
$data['membership'] = [
|
||||
'enabled' => (bool) ($row?->enabled ?? false),
|
||||
'required_plan_id' => $row?->required_plan_id,
|
||||
];
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.validate_access_restrictions', function (array $data, mixed $record): array {
|
||||
$membership = is_array($data['membership'] ?? null) ? $data['membership'] : [];
|
||||
$membershipEnabled = (bool) ($membership['enabled'] ?? false);
|
||||
$paid = is_array($data['paid_content'] ?? null) ? $data['paid_content'] : [];
|
||||
$paidEnabled = (bool) ($paid['enabled'] ?? false);
|
||||
$hasPassword = filled($data['read_password'] ?? null);
|
||||
|
||||
$flags = array_filter([
|
||||
'password' => $hasPassword,
|
||||
'paid' => $paidEnabled,
|
||||
'membership' => $membershipEnabled,
|
||||
]);
|
||||
|
||||
if (count($flags) > 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'read_password' => __('admin.messages.access_restriction_mutex'),
|
||||
'paid_content.enabled' => __('admin.messages.access_restriction_mutex'),
|
||||
'membership.enabled' => __('admin.messages.access_restriction_mutex'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.after_save', function (Article $record, array $data): void {
|
||||
$membership = $data['membership'] ?? null;
|
||||
if (! is_array($membership)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ArticleMembership::query()->updateOrCreate(
|
||||
['article_id' => $record->id],
|
||||
[
|
||||
'enabled' => (bool) ($membership['enabled'] ?? false),
|
||||
'required_plan_id' => filled($membership['required_plan_id'] ?? null)
|
||||
? (int) $membership['required_plan_id']
|
||||
: null,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.table.columns', function (array $columns): array {
|
||||
return [
|
||||
IconColumn::make('membership_enabled')
|
||||
->label(__('admin.fields.membership_enabled'))
|
||||
->boolean()
|
||||
->getStateUsing(function (Article $record): bool {
|
||||
return ArticleMembership::query()
|
||||
->where('article_id', $record->id)
|
||||
->where('enabled', true)
|
||||
->exists();
|
||||
}),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
protected function registerAccessHook(): void
|
||||
{
|
||||
Hook::listen('article.access', function (AccessDecision $decision, array $context): AccessDecision {
|
||||
if (! app(PluginManager::class)->isEnabled('larablog/membership')) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
$article = $context['article'] ?? null;
|
||||
$user = $context['user'] ?? null;
|
||||
|
||||
if (! $article instanceof Article) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
$gate = ArticleMembership::query()
|
||||
->where('article_id', $article->id)
|
||||
->where('enabled', true)
|
||||
->first();
|
||||
|
||||
if ($gate === null) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
$service = app(MembershipService::class);
|
||||
$allowed = $gate->required_plan_id
|
||||
? $service->hasPlan($user instanceof User ? $user : null, (int) $gate->required_plan_id)
|
||||
: $service->isActive($user instanceof User ? $user : null);
|
||||
|
||||
if ($allowed) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
$teaser = app(HtmlTeaser::class)->truncate($article->renderedHtml(), 200);
|
||||
$checkoutUrl = $this->checkoutUrlForGate($gate, $article);
|
||||
|
||||
return $decision->tightenWith(AccessDecision::needPurchase(
|
||||
$teaser,
|
||||
$checkoutUrl,
|
||||
__('frontend.article.membership_required'),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
protected function registerOrderPaidHook(): void
|
||||
{
|
||||
Hook::listen('order.paid', function (Order $order): void {
|
||||
$order->loadMissing('items');
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->product_type !== ProductType::MEMBERSHIP) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plan = MembershipPlan::query()->find($item->product_id);
|
||||
$entitlement = Entitlement::query()
|
||||
->where('user_id', $order->user_id)
|
||||
->where('product_type', ProductType::MEMBERSHIP)
|
||||
->where('product_id', $item->product_id)
|
||||
->first();
|
||||
|
||||
if ($entitlement === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($plan === null) {
|
||||
// Fail-closed: never turn a missing plan into a lifetime grant.
|
||||
$entitlement->forceFill([
|
||||
'revoked_at' => now(),
|
||||
'expires_at' => null,
|
||||
])->save();
|
||||
|
||||
Log::warning('Membership order.paid revoked: plan missing.', [
|
||||
'order_id' => $order->id,
|
||||
'plan_id' => $item->product_id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$entitlement->forceFill([
|
||||
'expires_at' => $plan->duration_days !== null
|
||||
? now()->addDays((int) $plan->duration_days)
|
||||
: null,
|
||||
'revoked_at' => null,
|
||||
])->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function checkoutUrlForGate(ArticleMembership $gate, Article $article): ?string
|
||||
{
|
||||
$plan = null;
|
||||
|
||||
if ($gate->required_plan_id) {
|
||||
$plan = MembershipPlan::query()
|
||||
->whereKey($gate->required_plan_id)
|
||||
->where('enabled', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
$plan ??= app(MembershipService::class)->cheapestEnabledPlan();
|
||||
|
||||
if ($plan === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = http_build_query([
|
||||
'product_type' => ProductType::MEMBERSHIP,
|
||||
'product_id' => $plan->id,
|
||||
'return_url' => url('/show-'.$article->id.'.shtml'),
|
||||
]);
|
||||
|
||||
return url('/plugins/payment/checkout').'?'.$query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 付费内容(`larablog/paid-content`)
|
||||
|
||||
按篇售卖文章:未购买的读者只能看到试读,结账走「支付」插件(`larablog/payment`)。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
1. 先启用 **支付**(`larablog/payment`)。本插件把它列为硬依赖。
|
||||
2. 执行迁移,创建 `article_products` 以及支付相关表:
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
3. 在后台 → 插件 中启用 **付费内容**。
|
||||
|
||||
## 给文章定价
|
||||
|
||||
1. 打开后台 → 文章 → 新建 / 编辑。
|
||||
2. 在 **付费内容** 区块中:
|
||||
- 打开付费开关
|
||||
- 填写价格与货币(默认 `CNY`)
|
||||
- 设置试读字数(默认 `200`;按渲染后的 HTML 文本截取)
|
||||
3. 保存。
|
||||
|
||||
**注意:** 阅读密码与付费内容不能同时开启。私下分享用密码,对外售卖用付费。
|
||||
|
||||
## 前台效果
|
||||
|
||||
- 未购买的读者看到试读和购买按钮。
|
||||
- 结账地址为 `/plugins/payment/checkout`。
|
||||
- 已登录且已购买的读者、文章作者、以及拥有 `admin` 角色的用户可以看到全文。
|
||||
|
||||
## 停用
|
||||
|
||||
在插件列表中禁用本插件后,文章表单里的付费区块、列表列和前台付费墙会撤掉。已有的 `article_products` 记录不会自动删除,需要时请自行清理。
|
||||
@@ -13,6 +13,9 @@ use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Plugins\Larablog\PaidContent\Models\ArticleProduct;
|
||||
@@ -72,19 +75,27 @@ class PluginServiceProvider extends ServiceProvider
|
||||
return $data;
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.mutate_before_save', function (array $data, mixed $record): array {
|
||||
Hook::listen('filament.article.validate_access_restrictions', function (array $data, mixed $record): array {
|
||||
$paid = is_array($data['paid_content'] ?? null) ? $data['paid_content'] : [];
|
||||
$enabled = (bool) ($paid['enabled'] ?? false);
|
||||
$paidEnabled = (bool) ($paid['enabled'] ?? false);
|
||||
$membership = is_array($data['membership'] ?? null) ? $data['membership'] : [];
|
||||
$membershipEnabled = (bool) ($membership['enabled'] ?? false);
|
||||
$hasPassword = filled($data['read_password'] ?? null);
|
||||
|
||||
if ($enabled && filled($data['read_password'] ?? null)) {
|
||||
$flags = array_filter([
|
||||
'password' => $hasPassword,
|
||||
'paid' => $paidEnabled,
|
||||
'membership' => $membershipEnabled,
|
||||
]);
|
||||
|
||||
if (count($flags) > 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'read_password' => __('admin.messages.paid_password_mutex'),
|
||||
'paid_content.enabled' => __('admin.messages.paid_password_mutex'),
|
||||
'read_password' => __('admin.messages.access_restriction_mutex'),
|
||||
'paid_content.enabled' => __('admin.messages.access_restriction_mutex'),
|
||||
'membership.enabled' => __('admin.messages.access_restriction_mutex'),
|
||||
]);
|
||||
}
|
||||
|
||||
unset($data['paid_content']);
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
@@ -113,15 +124,60 @@ class PluginServiceProvider extends ServiceProvider
|
||||
IconColumn::make('paid_content_enabled')
|
||||
->label(__('admin.fields.paid_enabled'))
|
||||
->boolean()
|
||||
->getStateUsing(function (Article $record): bool {
|
||||
return ArticleProduct::query()
|
||||
->where('article_id', $record->id)
|
||||
->where('enabled', true)
|
||||
->exists();
|
||||
->getStateUsing(fn (Article $record): bool => filled($record->getAttribute('paid_content_price'))),
|
||||
TextColumn::make('paid_content_price')
|
||||
->label(__('admin.fields.price'))
|
||||
->sortable()
|
||||
->formatStateUsing(function (mixed $state, Article $record): string {
|
||||
if ($state === null || $state === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$currency = (string) ($record->getAttribute('paid_content_currency') ?: 'CNY');
|
||||
|
||||
return $state.' '.$currency;
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.table.filters', function (array $filters): array {
|
||||
return [
|
||||
TernaryFilter::make('paid_enabled')
|
||||
->label(__('admin.fields.paid_enabled'))
|
||||
->queries(
|
||||
true: fn (Builder $query): Builder => $query->whereExists(
|
||||
fn ($sub) => $sub->from('article_products')
|
||||
->whereColumn('article_products.article_id', 'articles.id')
|
||||
->where('enabled', true),
|
||||
),
|
||||
false: fn (Builder $query): Builder => $query->whereNotExists(
|
||||
fn ($sub) => $sub->from('article_products')
|
||||
->whereColumn('article_products.article_id', 'articles.id')
|
||||
->where('enabled', true),
|
||||
),
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
Hook::listen('filament.article.table.query', function (mixed $query): mixed {
|
||||
if (! $query instanceof Builder) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->addSelect([
|
||||
'paid_content_price' => ArticleProduct::query()
|
||||
->select('price')
|
||||
->whereColumn('article_id', 'articles.id')
|
||||
->where('enabled', true)
|
||||
->limit(1),
|
||||
'paid_content_currency' => ArticleProduct::query()
|
||||
->select('currency')
|
||||
->whereColumn('article_id', 'articles.id')
|
||||
->where('enabled', true)
|
||||
->limit(1),
|
||||
]);
|
||||
});
|
||||
|
||||
Hook::listen('article.access', function (AccessDecision $decision, array $context): AccessDecision {
|
||||
$article = $context['article'] ?? null;
|
||||
$user = $context['user'] ?? null;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 支付(`larablog/payment`)
|
||||
|
||||
支付基础能力:订单、流水、权益,以及后台订单工具。当前网关为 **Stub 模拟支付**,用于打通下单与开通流程。
|
||||
|
||||
## 启用
|
||||
|
||||
1. 从磁盘同步插件(`php artisan plugins:sync`,或后台 → 插件 → 同步磁盘插件)。
|
||||
2. 启用 **支付**(`larablog/payment`)。
|
||||
3. 执行迁移,创建插件表:
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
会创建:`orders`、`order_items`、`entitlements`、`payment_transactions`。
|
||||
|
||||
## Stub 结账流程
|
||||
|
||||
必须先登录。
|
||||
|
||||
```text
|
||||
GET /plugins/payment/checkout
|
||||
?product_type=article
|
||||
&product_id=1
|
||||
&title=示例文章
|
||||
&amount=9.90
|
||||
¤cy=CNY
|
||||
&return_url=/
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. 创建一张 `pending` 订单(含一条明细);若该商品已有有效权益,则提示错误、不重复建单。
|
||||
2. 跳转到 `/plugins/payment/orders/{id}` 确认页。
|
||||
3. 点击 **模拟支付成功** → `POST .../pay`:标记已支付、写入流水、开通权益,并触发 `order.paid`。
|
||||
4. 跳回 `return_url`(仅允许本站同源地址),否则回首页。
|
||||
|
||||
## 后台
|
||||
|
||||
启用本插件后会出现:
|
||||
|
||||
- **订单** — 查看订单列表与详情
|
||||
- **标记已支付** — 在待支付订单详情页
|
||||
- **支付说明** — 本页(插件使用说明)
|
||||
|
||||
## 说明
|
||||
|
||||
- 本期网关固定为 `stub`。
|
||||
- `product_type` 预留:`article`、`theme`、`membership`。
|
||||
- 微信 / 支付宝 / Stripe 等真实网关不在本期范围。
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?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('entitlements', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('entitlements', 'expires_at')) {
|
||||
$table->timestamp('expires_at')->nullable()->after('revoked_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('entitlements', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('entitlements', 'expires_at')) {
|
||||
$table->dropColumn('expires_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,8 +4,6 @@
|
||||
:description="__('admin.plugins.larablog/payment.title')"
|
||||
icon="heroicon-o-credit-card"
|
||||
>
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap text-sm leading-6 text-gray-700 dark:text-gray-200">
|
||||
{{ $readmeExcerpt }}
|
||||
</div>
|
||||
@include('filament.partials.plugin-docs', ['html' => $readmeHtml])
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -25,8 +25,6 @@ class OrderService
|
||||
string $gateway = 'stub',
|
||||
): Order {
|
||||
return DB::transaction(function () use ($user, $productType, $productId, $title, $amount, $currency, $gateway): Order {
|
||||
// Re-check inside the transaction: two concurrent checkouts must not
|
||||
// both end up with a payable order for an already owned product.
|
||||
if ($this->hasEntitlement((int) $user->id, $productType, $productId)) {
|
||||
throw new RuntimeException(
|
||||
__('payment.errors.already_entitled', [
|
||||
@@ -38,8 +36,6 @@ class OrderService
|
||||
|
||||
$pending = $this->pendingOrderFor((int) $user->id, $productType, $productId);
|
||||
if ($pending !== null) {
|
||||
// Reuse the order but re-price it, so an unpaid order never locks
|
||||
// in a stale amount after the seller changes the price.
|
||||
$pending->forceFill([
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
@@ -73,9 +69,6 @@ class OrderService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse an existing payable order instead of stacking duplicates.
|
||||
*/
|
||||
public function pendingOrderFor(int $userId, string $productType, int $productId): ?Order
|
||||
{
|
||||
return Order::query()
|
||||
@@ -119,14 +112,12 @@ class OrderService
|
||||
|
||||
$order->loadMissing('items');
|
||||
|
||||
// Refuse to charge again for something the buyer already owns from
|
||||
// another order (duplicate pending orders, replayed callbacks).
|
||||
foreach ($order->items as $item) {
|
||||
$ownedElsewhere = Entitlement::query()
|
||||
->active()
|
||||
->where('user_id', $order->user_id)
|
||||
->where('product_type', $item->product_type)
|
||||
->where('product_id', $item->product_id)
|
||||
->whereNull('revoked_at')
|
||||
->where(function ($query) use ($order): void {
|
||||
$query->whereNull('source_order_id')
|
||||
->orWhere('source_order_id', '!=', $order->id);
|
||||
@@ -158,8 +149,6 @@ class OrderService
|
||||
'status' => 'succeeded',
|
||||
]);
|
||||
|
||||
$order->loadMissing('items');
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
Entitlement::query()->updateOrCreate(
|
||||
[
|
||||
@@ -171,6 +160,8 @@ class OrderService
|
||||
'source_order_id' => $order->id,
|
||||
'granted_at' => now(),
|
||||
'revoked_at' => null,
|
||||
// expires_at is set by product plugins via order.paid when needed.
|
||||
'expires_at' => null,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -186,10 +177,19 @@ class OrderService
|
||||
public function hasEntitlement(int $userId, string $productType, int $productId): bool
|
||||
{
|
||||
return Entitlement::query()
|
||||
->active()
|
||||
->where('user_id', $userId)
|
||||
->where('product_type', $productType)
|
||||
->where('product_id', $productId)
|
||||
->whereNull('revoked_at')
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function hasActiveAny(int $userId, string $productType): bool
|
||||
{
|
||||
return Entitlement::query()
|
||||
->active()
|
||||
->where('user_id', $userId)
|
||||
->where('product_type', $productType)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,8 @@ class PaymentSettingsPage extends Page
|
||||
*/
|
||||
public function getViewData(): array
|
||||
{
|
||||
$docs = app(PluginManager::class)->readDocs('larablog/payment') ?? '';
|
||||
|
||||
return [
|
||||
'readmeExcerpt' => $docs !== '' ? $docs : __('admin.pages.payment_settings_empty'),
|
||||
'readmeHtml' => app(PluginManager::class)->readDocsHtml('larablog/payment') ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Larablog\Payment\Filament\Resources;
|
||||
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use App\Filament\Support\AdminTable;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@@ -109,9 +110,11 @@ class OrderResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
AdminTable::stickyStart(
|
||||
TextColumn::make('id')
|
||||
->label(__('admin.fields.id'))
|
||||
->sortable(),
|
||||
),
|
||||
TextColumn::make('user.name')
|
||||
->label(__('admin.fields.user'))
|
||||
->searchable(),
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Larablog\Payment\Filament\Resources\OrderResource\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use App\Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Larablog\Payment\Filament\Resources\OrderResource;
|
||||
|
||||
class ListOrders extends ListRecords
|
||||
|
||||
@@ -18,6 +18,7 @@ class Entitlement extends Model
|
||||
'source_order_id',
|
||||
'granted_at',
|
||||
'revoked_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -27,6 +28,7 @@ class Entitlement extends Model
|
||||
'source_order_id' => 'integer',
|
||||
'granted_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,11 +44,24 @@ class Entitlement extends Model
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('revoked_at');
|
||||
return $query
|
||||
->whereNull('revoked_at')
|
||||
->where(function (Builder $inner): void {
|
||||
$inner->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->revoked_at === null;
|
||||
if ($this->revoked_at !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->expires_at->isFuture();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,16 +164,30 @@ class PluginServiceProvider extends ServiceProvider
|
||||
];
|
||||
}
|
||||
|
||||
// Non-article stubs may still pass title/amount (theme/membership later).
|
||||
$title = trim((string) $request->query('title', ''));
|
||||
$amount = (string) $request->query('amount', '');
|
||||
$currency = (string) $request->query('currency', 'CNY');
|
||||
if ($productType === ProductType::MEMBERSHIP) {
|
||||
$planClass = 'Plugins\\Larablog\\Membership\\Models\\MembershipPlan';
|
||||
if (! class_exists($planClass) || ! Schema::hasTable('membership_plans')) {
|
||||
throw new RuntimeException(__('payment.errors.invalid_checkout'));
|
||||
}
|
||||
|
||||
if ($title === '' || $amount === '') {
|
||||
throw new RuntimeException(__('payment.errors.invalid_checkout'));
|
||||
$plan = $planClass::query()
|
||||
->whereKey($productId)
|
||||
->where('enabled', true)
|
||||
->first();
|
||||
|
||||
if ($plan === null) {
|
||||
throw new RuntimeException(__('payment.errors.invalid_checkout'));
|
||||
}
|
||||
|
||||
return [
|
||||
(string) $plan->name,
|
||||
(string) $plan->price,
|
||||
(string) ($plan->currency ?: 'CNY'),
|
||||
];
|
||||
}
|
||||
|
||||
return [$title, $amount, $currency !== '' ? $currency : 'CNY'];
|
||||
// Never trust client-supplied title/amount for unknown product types.
|
||||
throw new RuntimeException(__('payment.errors.invalid_checkout'));
|
||||
}
|
||||
|
||||
protected static function safeReturnUrl(mixed $value): string
|
||||
|
||||
Reference in New Issue
Block a user