M5: Workerman 常驻服务 + AI 审核/润色插件 + 支付插件(支付宝/微信/沙箱)+ 会员插件(套餐/订阅/付费文章)
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
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) {
|
||||
$table->id();
|
||||
$table->string('name', 60);
|
||||
$table->string('slug', 60)->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedBigInteger('price'); // 单位:分
|
||||
$table->unsignedInteger('duration_days')->default(30);
|
||||
$table->json('permissions')->nullable(); // 会员权限位
|
||||
$table->boolean('active')->default(true);
|
||||
$table->unsignedInteger('sort')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('membership_plan_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('status', 20)->default('active'); // active / expired / cancelled
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('subscriptions');
|
||||
Schema::dropIfExists('membership_plans');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"title": "会员",
|
||||
"version": "1.0.0",
|
||||
"description": "会员套餐订阅 + 付费文章([paid] 短代码),依赖支付插件闭环",
|
||||
"author": "LaraLog",
|
||||
"type": "core",
|
||||
"provider": "Plugins\\Neatstudio\\Membership\\ServiceProvider",
|
||||
"requires": ["neatstudio.payment"],
|
||||
"filament_resources": [
|
||||
"Plugins\\Neatstudio\\Membership\\Filament\\Resources\\MembershipPlanResource",
|
||||
"Plugins\\Neatstudio\\Membership\\Filament\\Resources\\SubscriptionResource"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Neatstudio\Membership\Http\MembershipController;
|
||||
|
||||
Route::get('/membership', [MembershipController::class, 'index'])->name('membership.index');
|
||||
Route::get('/membership/mine', [MembershipController::class, 'mine'])->middleware('auth')->name('membership.mine');
|
||||
Route::post('/membership/{plan}/subscribe', [MembershipController::class, 'subscribe'])->middleware('auth')->name('membership.subscribe');
|
||||
Route::post('/posts/{post}/unlock', [MembershipController::class, 'unlockPost'])->middleware('auth')->name('posts.unlock');
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\ListMembershipPlans;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\EditMembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\CreateMembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
|
||||
class MembershipPlanResource extends Resource
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static ?string $model = MembershipPlan::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedStar;
|
||||
|
||||
protected static ?string $navigationLabel = '会员套餐';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')->label('套餐名')->required(),
|
||||
TextInput::make('slug')->label('Slug')->required()->unique(ignoreRecord: true),
|
||||
Textarea::make('description')->label('描述')->rows(2)->columnSpanFull(),
|
||||
TextInput::make('price')->label('价格(分)')->numeric()->required()->default(0)->helperText('例:9900 = ¥99'),
|
||||
TextInput::make('duration_days')->label('时长(天)')->numeric()->required()->default(30),
|
||||
KeyValue::make('permissions')->label('权限位')->columnSpanFull(),
|
||||
Toggle::make('active')->label('上架')->default(true),
|
||||
TextInput::make('sort')->label('排序')->numeric()->default(0),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
\Filament\Tables\Columns\TextColumn::make('name')->label('套餐'),
|
||||
\Filament\Tables\Columns\TextColumn::make('price')->label('价格')
|
||||
->formatStateUsing(fn ($state) => '¥'.number_format((float) $state / 100, 2)),
|
||||
\Filament\Tables\Columns\TextColumn::make('duration_days')->label('时长')->suffix(' 天'),
|
||||
\Filament\Tables\Columns\IconColumn::make('active')->label('上架')->boolean(),
|
||||
\Filament\Tables\Columns\TextColumn::make('sort')->label('排序')->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
\Filament\Actions\BulkActionGroup::make([
|
||||
\Filament\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListMembershipPlans::route('/'),
|
||||
'create' => CreateMembershipPlan::route('/create'),
|
||||
'edit' => EditMembershipPlan::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class CreateMembershipPlan extends CreateRecord
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class EditMembershipPlan extends EditRecord
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\SubscriptionResource;
|
||||
|
||||
class EditSubscription extends EditRecord
|
||||
{
|
||||
protected static string $resource = SubscriptionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class ListMembershipPlans extends ListRecords
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\SubscriptionResource;
|
||||
|
||||
class ListSubscriptions extends ListRecords
|
||||
{
|
||||
protected static string $resource = SubscriptionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\ListSubscriptions;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
|
||||
class SubscriptionResource extends Resource
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static ?string $model = Subscription::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
|
||||
|
||||
protected static ?string $navigationLabel = '订阅记录';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('user_id')->label('用户')->relationship('user', 'name')->disabled(),
|
||||
Select::make('membership_plan_id')->label('套餐')->relationship('plan', 'name')->disabled(),
|
||||
Select::make('status')->label('状态')->options([
|
||||
'active' => '生效中',
|
||||
'expired' => '已过期',
|
||||
'cancelled' => '已取消',
|
||||
])->required(),
|
||||
DateTimePicker::make('starts_at')->label('开始'),
|
||||
DateTimePicker::make('ends_at')->label('结束'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
\Filament\Tables\Columns\TextColumn::make('user.name')->label('用户'),
|
||||
\Filament\Tables\Columns\TextColumn::make('plan.name')->label('套餐'),
|
||||
\Filament\Tables\Columns\TextColumn::make('status')->label('状态')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => match ($state) {
|
||||
'active' => '生效中',
|
||||
'expired' => '已过期',
|
||||
'cancelled' => '已取消',
|
||||
default => $state,
|
||||
})
|
||||
->color(fn ($state) => $state === 'active' ? 'success' : 'gray'),
|
||||
\Filament\Tables\Columns\TextColumn::make('ends_at')->label('到期')->dateTime('Y-m-d'),
|
||||
\Filament\Tables\Columns\TextColumn::make('created_at')->label('购买时间')->dateTime('Y-m-d H:i'),
|
||||
])
|
||||
->filters([])
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListSubscriptions::route('/'),
|
||||
'edit' => \Plugins\Neatstudio\Membership\Filament\Resources\Pages\EditSubscription::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Http;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
use Plugins\Neatstudio\Membership\Services\MembershipService;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
use Plugins\Neatstudio\Payment\Services\PaymentManager;
|
||||
|
||||
class MembershipController
|
||||
{
|
||||
public function __construct(private MembershipService $service, private PaymentManager $payment)
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$plans = MembershipPlan::query()->where('active', true)->orderBy('sort')->get();
|
||||
|
||||
return theme_view('membership.index', compact('plans'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买套餐:创建支付订单并跳转支付。
|
||||
*/
|
||||
public function subscribe(Request $request, MembershipPlan $plan)
|
||||
{
|
||||
if (! $plan->active) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$channel = $request->input('channel', 'alipay');
|
||||
|
||||
$payment = $this->payment->createOrder(
|
||||
$user,
|
||||
'会员套餐:'.$plan->name,
|
||||
$plan->price,
|
||||
$channel,
|
||||
'MembershipPlan#'.$plan->id
|
||||
);
|
||||
|
||||
return redirect()->route('pay.checkout', $payment);
|
||||
}
|
||||
|
||||
public function mine(Request $request)
|
||||
{
|
||||
$subscriptions = Subscription::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with('plan')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return theme_view('membership.mine', compact('subscriptions'));
|
||||
}
|
||||
|
||||
public function unlockPost(Request $request, Post $post)
|
||||
{
|
||||
// 单篇付费解锁:创建一笔定向支付
|
||||
$price = (int) ($post->meta['price'] ?? 0);
|
||||
|
||||
if ($price <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$channel = $request->input('channel', 'alipay');
|
||||
|
||||
$payment = $this->payment->createOrder(
|
||||
$user,
|
||||
'解锁文章:'.$post->title,
|
||||
$price,
|
||||
$channel,
|
||||
'PostUnlock#'.$post->id
|
||||
);
|
||||
|
||||
return redirect()->route('pay.checkout', $payment);
|
||||
}
|
||||
|
||||
public function userIsMember(?User $user = null): bool
|
||||
{
|
||||
return $this->service->isMember($user ?? auth()->user());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MembershipPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'description', 'price', 'duration_days', 'permissions', 'active', 'sort',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subscription::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'membership_plan_id', 'status', 'starts_at', 'ends_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipPlan::class, 'membership_plan_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active' && $this->ends_at && $this->ends_at->isFuture();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||
|
||||
// 支付成功:激活会员订阅 / 解锁单篇付费文章
|
||||
$manager->addAction('payment.paid', function (Payment $payment) {
|
||||
$description = (string) $payment->description;
|
||||
$user = $payment->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($description, 'MembershipPlan#')) {
|
||||
$planId = (int) substr($description, strlen('MembershipPlan#'));
|
||||
$plan = MembershipPlan::find($planId);
|
||||
|
||||
if ($plan) {
|
||||
$now = now();
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'membership_plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => $now,
|
||||
'ends_at' => $now->copy()->addDays($plan->duration_days),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($description, 'PostUnlock#')) {
|
||||
$postId = (int) substr($description, strlen('PostUnlock#'));
|
||||
$post = \App\Models\Post::find($postId);
|
||||
|
||||
if ($post) {
|
||||
$meta = $post->meta ?? [];
|
||||
$unlocked = $meta['unlocked_user_ids'] ?? [];
|
||||
$unlocked[] = $user->id;
|
||||
$post->update(['meta' => array_merge($meta, ['unlocked_user_ids' => array_values(array_unique($unlocked))])]);
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// 付费文章:[paid]...[/paid] 对无权限访客隐藏
|
||||
$manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) {
|
||||
$service = app(Services\MembershipService::class);
|
||||
$user = auth()->user();
|
||||
|
||||
if (preg_match('/\[paid\](.*?)\[\/paid\]/s', $html) && ! $service->canReadPaidContent($user, $post)) {
|
||||
$html = preg_replace(
|
||||
'/\[paid\](.*?)\[\/paid\]/s',
|
||||
'<div class="paid-teaser">(付费内容已隐藏,开通会员或单篇解锁后可见)</div>',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
if (! $service->canReadPost($user, $post)) {
|
||||
return '<div class="paid-teaser">(本文章为会员专享,开通会员后可见)</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
|
||||
class MembershipService
|
||||
{
|
||||
public function isMember(?User $user): bool
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Subscription::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->where('ends_at', '>', now())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权阅读某篇文章(会员 或 作者 或 已单篇解锁)。
|
||||
*/
|
||||
public function canReadPost(?User $user, \App\Models\Post $post): bool
|
||||
{
|
||||
if ($user && $post->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$meta = $post->meta ?? [];
|
||||
|
||||
// 单篇解锁:meta.unlocked_user_ids
|
||||
if ($user && in_array($user->id, $meta['unlocked_user_ids'] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 会员专享文章
|
||||
if (! empty($meta['members_only'])) {
|
||||
return $user !== null && $this->isMember($user);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权阅读 [paid] 付费块:会员 或 作者 或 已单篇解锁。
|
||||
*/
|
||||
public function canReadPaidContent(?User $user, \App\Models\Post $post): bool
|
||||
{
|
||||
if ($user && $post->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user && in_array($user->id, $post->meta['unlocked_user_ids'] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user !== null && $this->isMember($user);
|
||||
}
|
||||
|
||||
public function hasPermission(User $user, string $permission): bool
|
||||
{
|
||||
if (! $this->isMember($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscription = Subscription::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->where('ends_at', '>', now())
|
||||
->with('plan')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
return $subscription && in_array($permission, $subscription->plan?->permissions ?? [], true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user