M5: Workerman 常驻服务 + AI 审核/润色插件 + 支付插件(支付宝/微信/沙箱)+ 会员插件(套餐/订阅/付费文章)

This commit is contained in:
ak
2026-08-11 18:29:35 +08:00
parent f77c96d3aa
commit 1d1df43cee
52 changed files with 1914 additions and 20 deletions
@@ -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');
}
};
+13
View File
@@ -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);
}
}