wip: article AI polish, category SEO fields, cover generator, membership plan seeder
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped

This commit is contained in:
2026-09-07 18:48:37 +00:00
parent 263b98b218
commit 3cec4c5e18
121 changed files with 4701 additions and 313 deletions
+23
View File
@@ -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}`
@@ -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');
}
};
@@ -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');
}
};
+7 -5
View File
@@ -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'),
];
}
}
@@ -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;
}
@@ -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();
}
}),
];
}
}
@@ -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;
+50
View File
@@ -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
&currency=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 等真实网关不在本期范围。
@@ -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