From 1d1df43cee595dab63b17eb1e8f8e7c6e51975ac Mon Sep 17 00:00:00 2001 From: ak Date: Tue, 11 Aug 2026 18:29:35 +0800 Subject: [PATCH] =?UTF-8?q?M5:=20Workerman=20=E5=B8=B8=E9=A9=BB=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=20+=20AI=20=E5=AE=A1=E6=A0=B8/=E6=B6=A6=E8=89=B2?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20+=20=E6=94=AF=E4=BB=98=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E6=94=AF=E4=BB=98=E5=AE=9D/=E5=BE=AE=E4=BF=A1/?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=EF=BC=89+=20=E4=BC=9A=E5=91=98=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=EF=BC=88=E5=A5=97=E9=A4=90/=E8=AE=A2=E9=98=85/?= =?UTF-8?q?=E4=BB=98=E8=B4=B9=E6=96=87=E7=AB=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Blog/Controllers/CommentController.php | 3 + app/Blog/Controllers/LegacyController.php | 2 + app/Blog/Jobs/AiJob.php | 13 ++ .../PluginManagerServiceProvider.php | 5 - app/Blog/Providers/ThemeServiceProvider.php | 2 +- app/Blog/Services/LlmClient.php | 63 ++++++++ app/Blog/Services/PostContentRenderer.php | 5 +- app/Blog/Support/PluginManager.php | 10 +- app/Blog/Support/PluginPages.php | 41 +++++ app/Blog/Support/WorkermanBroadcaster.php | 33 ++++ app/Console/Commands/WorkermanServe.php | 106 +++++++++++++ app/Models/Post.php | 2 + app/Providers/AppServiceProvider.php | 5 +- app/Providers/Filament/AdminPanelProvider.php | 2 + plugins/neatstudio.ai-moderation/plugin.json | 11 ++ .../src/Filament/Pages/AiSettings.php | 129 ++++++++++++++++ .../src/Jobs/AiModerateCommentJob.php | 64 ++++++++ .../src/Jobs/AiPolishContentJob.php | 35 +++++ .../src/ServiceProvider.php | 31 ++++ .../views/ai-settings.blade.php | 31 ++++ ..._08_11_100000_create_membership_tables.php | 42 ++++++ plugins/neatstudio.membership/plugin.json | 13 ++ plugins/neatstudio.membership/routes/web.php | 9 ++ .../Resources/MembershipPlanResource.php | 74 +++++++++ .../Resources/Pages/CreateMembershipPlan.php | 11 ++ .../Resources/Pages/EditMembershipPlan.php | 19 +++ .../Resources/Pages/EditSubscription.php | 11 ++ .../Resources/Pages/ListMembershipPlans.php | 11 ++ .../Resources/Pages/ListSubscriptions.php | 11 ++ .../Resources/SubscriptionResource.php | 73 +++++++++ .../src/Http/MembershipController.php | 88 +++++++++++ .../src/Models/MembershipPlan.php | 23 +++ .../src/Models/Subscription.php | 34 +++++ .../src/ServiceProvider.php | 76 ++++++++++ .../src/Services/MembershipService.php | 79 ++++++++++ ...026_08_11_100000_create_payments_table.php | 33 ++++ plugins/neatstudio.payment/plugin.json | 14 ++ plugins/neatstudio.payment/routes/web.php | 11 ++ .../src/Filament/Pages/PaymentSettings.php | 83 +++++++++++ .../Filament/Resources/Pages/ListPayments.php | 11 ++ .../Filament/Resources/PaymentResource.php | 77 ++++++++++ .../src/Http/PaymentController.php | 85 +++++++++++ .../neatstudio.payment/src/Models/Payment.php | 35 +++++ .../src/ServiceProvider.php | 22 +++ .../src/Services/PaymentManager.php | 141 ++++++++++++++++++ .../filament/pages/plugin-settings.blade.php | 8 + resources/views/membership/index.blade.php | 44 ++++++ resources/views/membership/mine.blade.php | 31 ++++ resources/views/payments/result.blade.php | 22 +++ resources/views/payments/sandbox.blade.php | 24 +++ tests/Feature/ExampleTest.php | 22 ++- tests/Feature/MembershipFlowTest.php | 104 +++++++++++++ 52 files changed, 1914 insertions(+), 20 deletions(-) create mode 100644 app/Blog/Jobs/AiJob.php create mode 100644 app/Blog/Services/LlmClient.php create mode 100644 app/Blog/Support/PluginPages.php create mode 100644 app/Blog/Support/WorkermanBroadcaster.php create mode 100644 app/Console/Commands/WorkermanServe.php create mode 100644 plugins/neatstudio.ai-moderation/plugin.json create mode 100644 plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php create mode 100644 plugins/neatstudio.ai-moderation/src/Jobs/AiModerateCommentJob.php create mode 100644 plugins/neatstudio.ai-moderation/src/Jobs/AiPolishContentJob.php create mode 100644 plugins/neatstudio.ai-moderation/src/ServiceProvider.php create mode 100644 plugins/neatstudio.ai-moderation/views/ai-settings.blade.php create mode 100644 plugins/neatstudio.membership/database/migrations/2026_08_11_100000_create_membership_tables.php create mode 100644 plugins/neatstudio.membership/plugin.json create mode 100644 plugins/neatstudio.membership/routes/web.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/MembershipPlanResource.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/Pages/CreateMembershipPlan.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/Pages/EditMembershipPlan.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/Pages/EditSubscription.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/Pages/ListMembershipPlans.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/Pages/ListSubscriptions.php create mode 100644 plugins/neatstudio.membership/src/Filament/Resources/SubscriptionResource.php create mode 100644 plugins/neatstudio.membership/src/Http/MembershipController.php create mode 100644 plugins/neatstudio.membership/src/Models/MembershipPlan.php create mode 100644 plugins/neatstudio.membership/src/Models/Subscription.php create mode 100644 plugins/neatstudio.membership/src/ServiceProvider.php create mode 100644 plugins/neatstudio.membership/src/Services/MembershipService.php create mode 100644 plugins/neatstudio.payment/database/migrations/2026_08_11_100000_create_payments_table.php create mode 100644 plugins/neatstudio.payment/plugin.json create mode 100644 plugins/neatstudio.payment/routes/web.php create mode 100644 plugins/neatstudio.payment/src/Filament/Pages/PaymentSettings.php create mode 100644 plugins/neatstudio.payment/src/Filament/Resources/Pages/ListPayments.php create mode 100644 plugins/neatstudio.payment/src/Filament/Resources/PaymentResource.php create mode 100644 plugins/neatstudio.payment/src/Http/PaymentController.php create mode 100644 plugins/neatstudio.payment/src/Models/Payment.php create mode 100644 plugins/neatstudio.payment/src/ServiceProvider.php create mode 100644 plugins/neatstudio.payment/src/Services/PaymentManager.php create mode 100644 resources/views/filament/pages/plugin-settings.blade.php create mode 100644 resources/views/membership/index.blade.php create mode 100644 resources/views/membership/mine.blade.php create mode 100644 resources/views/payments/result.blade.php create mode 100644 resources/views/payments/sandbox.blade.php create mode 100644 tests/Feature/MembershipFlowTest.php diff --git a/app/Blog/Controllers/CommentController.php b/app/Blog/Controllers/CommentController.php index 9fd165e..ad85e8b 100644 --- a/app/Blog/Controllers/CommentController.php +++ b/app/Blog/Controllers/CommentController.php @@ -68,6 +68,9 @@ class CommentController extends Controller return $comment; }); + // 插件钩子:AI 审核等 + app(\App\Blog\Support\PluginManager::class)->doAction('comment.created', $comment); + if ($status === 'pending') { return back()->with('success', '评论已提交,等待审核通过后显示')->withFragment('comments'); } diff --git a/app/Blog/Controllers/LegacyController.php b/app/Blog/Controllers/LegacyController.php index 65a169e..a1bf363 100644 --- a/app/Blog/Controllers/LegacyController.php +++ b/app/Blog/Controllers/LegacyController.php @@ -147,6 +147,8 @@ class LegacyController extends Controller $post->increment('comment_count'); } + app(\App\Blog\Support\PluginManager::class)->doAction('comment.created', $comment); + return redirect()->route('posts.show', $post->slug ?: $post->id) ->with($status === 'published' ? 'success' : 'error', $status === 'published' ? '评论已发布' : '评论已提交,等待审核') ->withFragment('comments'); diff --git a/app/Blog/Jobs/AiJob.php b/app/Blog/Jobs/AiJob.php new file mode 100644 index 0000000..9f6c704 --- /dev/null +++ b/app/Blog/Jobs/AiJob.php @@ -0,0 +1,13 @@ +app->make(PluginManager::class); - // 迁移尚未执行(migrate:fresh 首轮)时跳过,避免查询不存在的表 - if (! \Illuminate\Support\Facades\Schema::hasTable('plugin_records')) { - return; - } - if (! is_dir(config('plugins.path'))) { return; } diff --git a/app/Blog/Providers/ThemeServiceProvider.php b/app/Blog/Providers/ThemeServiceProvider.php index fdff64b..bd213f0 100644 --- a/app/Blog/Providers/ThemeServiceProvider.php +++ b/app/Blog/Providers/ThemeServiceProvider.php @@ -22,7 +22,7 @@ class ThemeServiceProvider extends ServiceProvider // 侧边栏数据共享:所有前台视图自动获得 $categories/$recentPosts/$hotTags/$links 等 View::composer( - ['index', 'show', 'list', 'archives', 'tags', 'tag', 'search', 'links', 'comments', 'login', 'register', 'profile'], + ['index', 'show', 'list', 'archives', 'tags', 'tag', 'search', 'links', 'comments', 'login', 'register', 'profile', 'membership.*', 'payments.*'], SidebarComposer::class ); } diff --git a/app/Blog/Services/LlmClient.php b/app/Blog/Services/LlmClient.php new file mode 100644 index 0000000..c62d6d6 --- /dev/null +++ b/app/Blog/Services/LlmClient.php @@ -0,0 +1,63 @@ +baseUrl() !== '' && $this->apiKey() !== ''; + } + + /** + * 简单的聊天补全。 + * + * @param array $messages + */ + public function chat(array $messages, array $options = []): string + { + if (! $this->isConfigured()) { + throw new \RuntimeException('LLM 未配置:请设置 LLM_BASE_URL 与 LLM_API_KEY'); + } + + $response = Http::timeout(60) + ->withToken($this->apiKey()) + ->post($this->baseUrl().'/chat/completions', [ + 'model' => $options['model'] ?? $this->model(), + 'messages' => $messages, + 'temperature' => $options['temperature'] ?? 0.3, + 'max_tokens' => $options['max_tokens'] ?? 1024, + ]); + + if ($response->failed()) { + Log::error('LLM 请求失败', ['status' => $response->status(), 'body' => $response->body()]); + + throw new \RuntimeException('LLM 请求失败:HTTP '.$response->status()); + } + + $content = $response->json('choices.0.message.content', ''); + + return is_array($content) ? json_encode($content, JSON_UNESCAPED_UNICODE) : (string) $content; + } +} diff --git a/app/Blog/Services/PostContentRenderer.php b/app/Blog/Services/PostContentRenderer.php index ce9ba8d..917dc8c 100644 --- a/app/Blog/Services/PostContentRenderer.php +++ b/app/Blog/Services/PostContentRenderer.php @@ -35,7 +35,10 @@ class PostContentRenderer ? $this->toHtml($post->content) : $post->content; - return $this->renderShortcodes($content, $post); + $html = $this->renderShortcodes($content, $post); + + // 插件钩子:付费内容过滤、AI 处理等 + return app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post); } public function toHtml(string $markdown): string diff --git a/app/Blog/Support/PluginManager.php b/app/Blog/Support/PluginManager.php index 7fb413b..91fda8d 100644 --- a/app/Blog/Support/PluginManager.php +++ b/app/Blog/Support/PluginManager.php @@ -68,12 +68,14 @@ class PluginManager public function isEnabled(string $plugin): bool { - [$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin); + if (\Illuminate\Support\Facades\Schema::hasTable('plugin_records')) { + [$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin); - $record = PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->first(); + $record = PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->first(); - if ($record) { - return (bool) $record->enabled; + if ($record) { + return (bool) $record->enabled; + } } return in_array($plugin, config('plugins.enabled', []), true); diff --git a/app/Blog/Support/PluginPages.php b/app/Blog/Support/PluginPages.php new file mode 100644 index 0000000..44c104d --- /dev/null +++ b/app/Blog/Support/PluginPages.php @@ -0,0 +1,41 @@ +all() as $pluginKey => $plugin) { + if (! $plugin['enabled']) { + continue; + } + + $manifest = $manager->manifest($pluginKey); + + foreach ($manifest[$key] ?? [] as $class) { + if (class_exists($class)) { + $classes[] = $class; + } + } + } + + return $classes; + } +} diff --git a/app/Blog/Support/WorkermanBroadcaster.php b/app/Blog/Support/WorkermanBroadcaster.php new file mode 100644 index 0000000..8ae9078 --- /dev/null +++ b/app/Blog/Support/WorkermanBroadcaster.php @@ -0,0 +1,33 @@ + */ + public static array $connections = []; + + public static function add(TcpConnection $connection): void + { + self::$connections[spl_object_id($connection)] = $connection; + } + + public static function remove(TcpConnection $connection): void + { + unset(self::$connections[spl_object_id($connection)]); + } + + public static function send(string $event, array $data = []): void + { + $message = json_encode(['event' => $event, 'data' => $data], JSON_UNESCAPED_UNICODE); + + foreach (self::$connections as $connection) { + $connection->send($message); + } + } +} diff --git a/app/Console/Commands/WorkermanServe.php b/app/Console/Commands/WorkermanServe.php new file mode 100644 index 0000000..9cd767d --- /dev/null +++ b/app/Console/Commands/WorkermanServe.php @@ -0,0 +1,106 @@ +info('Workerman 启动中('.config('workerman.name').')...'); + + // 队列消费者:常驻进程内复用 Laravel 容器,避免每次任务重新启动框架 + $queueWorker = new Worker(); + $queueWorker->name = 'laralog-queue'; + $queueWorker->count = (int) config('workerman.queue_workers', 2); + $queueWorker->onWorkerStart = function ($worker) { + $this->info("队列消费者 {$worker->id} 启动"); + Timer::add(1, function () { + try { + $this->consumeNextJob(); + } catch (\Throwable $e) { + Log::error('Workerman 任务执行失败', ['error' => $e->getMessage()]); + } + }); + }; + + // WebSocket:向后台推送任务进度 + $ws = new Worker('websocket://'.config('workerman.websocket_host', '0.0.0.0').':'.config('workerman.websocket_port', 8787)); + $ws->name = 'laralog-ws'; + $ws->onConnect = function (TcpConnection $connection) { + \App\Blog\Support\WorkermanBroadcaster::add($connection); + $connection->send(json_encode(['event' => 'connected'])); + }; + $ws->onClose = fn (TcpConnection $connection) => \App\Blog\Support\WorkermanBroadcaster::remove($connection); + + Worker::runAll(); + + return self::SUCCESS; + } + + private function consumeNextJob(): void + { + $queue = config('workerman.queue_connection', 'database'); + + if ($queue === 'database') { + $job = DB::table('jobs') + ->whereNull('reserved_at') + ->orderBy('id') + ->lockForUpdate() + ->first(); + + if (! $job) { + return; + } + + DB::table('jobs')->where('id', $job->id)->update([ + 'reserved_at' => now()->getTimestamp(), + 'attempts' => $job->attempts + 1, + ]); + + $payload = json_decode($job->payload, true); + + try { + $this->runJob($payload); + DB::table('jobs')->where('id', $job->id)->delete(); + } catch (\Throwable $e) { + Log::error('任务失败', ['job' => $job->id, 'error' => $e->getMessage()]); + + $attempts = $job->attempts + 1; + if ($attempts >= 3) { + DB::table('jobs')->where('id', $job->id)->update(['reserved_at' => null, 'attempts' => $attempts]); + } else { + DB::table('jobs')->where('id', $job->id)->delete(); + } + } + + return; + } + + // Redis 队列:降级到 artisan queue:work + Artisan::call('queue:work', ['--once' => true, '--stop-when-empty' => true]); + } + + private function runJob(array $payload): void + { + $command = unserialize($payload['data']['command'] ?? ''); + + if ($command instanceof \App\Blog\Jobs\AiJob) { + $command->handle(app(LlmClient::class)); + } + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index 44488a6..7f5cd8a 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -110,6 +110,8 @@ class Post extends Model implements HasMedia } $text = strip_tags($this->content); + // 付费块不进入摘要,避免付费内容泄漏到列表页/SEO meta + $text = preg_replace('/\[paid\].*?\[\/paid\]/s', '', $text); $text = preg_replace('/\[[^\]]*\]/', '', $text); return mb_substr($text, 0, 200); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..4d7e3aa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,6 +19,9 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + // 自动加载所有插件的迁移(插件目录下 database/migrations) + foreach (glob(base_path('plugins/*/database/migrations')) ?: [] as $path) { + $this->loadMigrationsFrom($path); + } } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 978c825..ef8ad75 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -47,6 +47,7 @@ class AdminPanelProvider extends PanelProvider ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->pages([ Dashboard::class, + ...\App\Blog\Support\PluginPages::pages(), ]) ->resources([ PostResource::class, @@ -55,6 +56,7 @@ class AdminPanelProvider extends PanelProvider LinkResource::class, MediaResource::class, UserResource::class, + ...\App\Blog\Support\PluginPages::resources(), ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') ->widgets([ diff --git a/plugins/neatstudio.ai-moderation/plugin.json b/plugins/neatstudio.ai-moderation/plugin.json new file mode 100644 index 0000000..1fe2f1f --- /dev/null +++ b/plugins/neatstudio.ai-moderation/plugin.json @@ -0,0 +1,11 @@ +{ + "title": "AI 评论审核", + "version": "1.0.0", + "description": "新评论经 LLM 自动审核(OpenAI 兼容 API),后台文章支持 AI 润色;由 Workerman 常驻进程异步执行", + "author": "LaraLog", + "type": "core", + "provider": "Plugins\\Neatstudio\\AiModeration\\ServiceProvider", + "filament_pages": [ + "Plugins\\Neatstudio\\AiModeration\\Filament\\Pages\\AiSettings" + ] +} diff --git a/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php b/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php new file mode 100644 index 0000000..1541628 --- /dev/null +++ b/plugins/neatstudio.ai-moderation/src/Filament/Pages/AiSettings.php @@ -0,0 +1,129 @@ +form->fill([ + 'llm_base_url' => Setting::get('llm_base_url', env('LLM_BASE_URL')), + 'llm_api_key' => Setting::get('llm_api_key', env('LLM_API_KEY')), + 'llm_model' => Setting::get('llm_model', env('LLM_MODEL', 'gpt-4o-mini')), + 'ai_moderation_enabled' => (int) Setting::get('ai_moderation_enabled', 1) === 1, + 'ai_moderation_system_prompt' => Setting::get('ai_moderation_system_prompt', '你是博客评论审核员。判断评论是否包含:广告/垃圾、人身攻击、违法内容、无关灌水。只回复 JSON:{"verdict":"approved|rejected|spam","reason":"简短理由"}'), + ]); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + \Filament\Schemas\Components\Section::make('LLM 配置') + ->description('OpenAI 兼容接口:OpenAI / DeepSeek / 通义 / 自建代理') + ->schema([ + TextInput::make('llm_base_url')->label('Base URL')->placeholder('https://api.openai.com/v1')->required(), + TextInput::make('llm_api_key')->label('API Key')->password()->required(), + TextInput::make('llm_model')->label('模型')->default('gpt-4o-mini')->required(), + ]), + \Filament\Schemas\Components\Section::make('评论审核') + ->schema([ + Toggle::make('ai_moderation_enabled')->label('新评论启用 AI 自动审核'), + Textarea::make('ai_moderation_system_prompt')->label('审核提示词')->rows(4)->columnSpanFull(), + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $data = $this->form->getState(); + + foreach ($data as $key => $value) { + Setting::set($key, is_bool($value) ? (string) (int) $value : (string) $value); + } + + Notification::make()->title('AI 设置已保存')->success()->send(); + } + + public function polish(Post $post): void + { + $this->polishPost = (string) $post->id; + + if ($post->meta['ai_polished'] ?? null) { + $this->polishResult = $post->meta['ai_polished']; + + return; + } + + dispatch(new AiPolishContentJob($post->id))->onQueue('ai'); + Notification::make()->title('润色任务已提交(由 Workerman 异步执行)')->info()->send(); + } + + public function acceptPolish(Post $post): void + { + $polished = $post->meta['ai_polished'] ?? null; + + if (! $polished) { + Notification::make()->title('没有可采纳的润色结果')->warning()->send(); + + return; + } + + $post->update([ + 'content' => $polished, + 'content_format' => 'markdown', + 'meta' => array_merge($post->meta ?? [], ['ai_polished' => null]), + ]); + + Notification::make()->title('已采纳润色结果')->success()->send(); + $this->polishResult = null; + } + + public function getPendingPolishesProperty(): \Illuminate\Support\Collection + { + return Post::query() + ->where('meta->ai_polished', '!=', null) + ->latest() + ->limit(10) + ->get(['id', 'title']); + } + + protected function getFormActions(): array + { + return [ + Action::make('save') + ->label('保存') + ->submit('save'), + ]; + } +} diff --git a/plugins/neatstudio.ai-moderation/src/Jobs/AiModerateCommentJob.php b/plugins/neatstudio.ai-moderation/src/Jobs/AiModerateCommentJob.php new file mode 100644 index 0000000..83c062f --- /dev/null +++ b/plugins/neatstudio.ai-moderation/src/Jobs/AiModerateCommentJob.php @@ -0,0 +1,64 @@ +find($this->commentId); + + if (! $comment || $comment->status !== Comment::STATUS_PENDING) { + return; + } + + WorkermanBroadcaster::send('ai.moderation.started', ['comment_id' => $this->commentId]); + + $system = (string) blog_setting('ai_moderation_system_prompt', '你是博客评论审核员。判断评论是否包含:广告/垃圾、人身攻击、违法内容、无关灌水。只回复 JSON:{"verdict":"approved|rejected|spam","reason":"简短理由"}'); + + try { + $raw = $llm->chat([ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => '文章标题:'.($comment->post?->title ?? '')."\n\n评论内容:\n".$comment->content], + ], ['max_tokens' => 200]); + + $verdict = $this->parseVerdict($raw); + + $comment->update([ + 'status' => $verdict === 'approved' ? Comment::STATUS_PUBLISHED : ($verdict === 'spam' ? Comment::STATUS_SPAM : Comment::STATUS_REJECTED), + 'ai_review' => ['source' => 'llm', 'raw' => $raw, 'reviewed_at' => now()->toIso8601String()], + ]); + + if ($verdict === 'approved') { + $comment->post?->increment('comment_count'); + } + + WorkermanBroadcaster::send('ai.moderation.finished', [ + 'comment_id' => $this->commentId, + 'verdict' => $verdict, + ]); + } catch (\Throwable $e) { + Log::error('AI 评论审核失败', ['comment' => $this->commentId, 'error' => $e->getMessage()]); + WorkermanBroadcaster::send('ai.moderation.failed', ['comment_id' => $this->commentId, 'error' => $e->getMessage()]); + } + } + + private function parseVerdict(string $raw): string + { + if (preg_match('/"verdict"\s*:\s*"(approved|rejected|spam)"/', $raw, $m)) { + return $m[1]; + } + + return 'rejected'; + } +} diff --git a/plugins/neatstudio.ai-moderation/src/Jobs/AiPolishContentJob.php b/plugins/neatstudio.ai-moderation/src/Jobs/AiPolishContentJob.php new file mode 100644 index 0000000..7ef2084 --- /dev/null +++ b/plugins/neatstudio.ai-moderation/src/Jobs/AiPolishContentJob.php @@ -0,0 +1,35 @@ +postId); + + if (! $post) { + return; + } + + $source = $post->content_format === 'markdown' + ? $post->content + : (new \League\HTMLToMarkdown\HtmlConverter())->convert($post->content); + + $polished = $llm->chat([ + ['role' => 'system', 'content' => '你是中文博客编辑。润色下面的文章,保持原意、事实、Markdown 结构不变,改进表达、语法、可读性。直接输出润色后的完整 Markdown。'], + ['role' => 'user', 'content' => $source], + ], ['max_tokens' => 4000]); + + // 润色结果暂存,后台点击「采纳」时写回 + $post->update(['meta' => array_merge($post->meta ?? [], ['ai_polished' => $polished])]); + } +} diff --git a/plugins/neatstudio.ai-moderation/src/ServiceProvider.php b/plugins/neatstudio.ai-moderation/src/ServiceProvider.php new file mode 100644 index 0000000..31f5e33 --- /dev/null +++ b/plugins/neatstudio.ai-moderation/src/ServiceProvider.php @@ -0,0 +1,31 @@ +loadViews(__DIR__.'/../views', 'plugin.ai-moderation'); + + // 新评论创建后自动进入 AI 审核 + $manager->addAction('comment.created', function ($comment) { + if (! app(\App\Blog\Services\LlmClient::class)->isConfigured()) { + return; + } + + if ((int) blog_setting('ai_moderation_enabled', 1) !== 1) { + return; + } + + if ($comment->status !== \App\Models\Comment::STATUS_PENDING) { + return; + } + + dispatch(new Jobs\AiModerateCommentJob($comment->id))->onQueue('ai'); + }, 10); + } +} diff --git a/plugins/neatstudio.ai-moderation/views/ai-settings.blade.php b/plugins/neatstudio.ai-moderation/views/ai-settings.blade.php new file mode 100644 index 0000000..9c8e812 --- /dev/null +++ b/plugins/neatstudio.ai-moderation/views/ai-settings.blade.php @@ -0,0 +1,31 @@ + +
+ {{ $this->form }} +
+ 保存设置 +
+
+ + +
+ @foreach(\App\Models\Post::query()->latest()->limit(20)->get(['id','title']) as $post) +
+ {{ $post->title }} +
+ AI 润色 + @if($post->meta['ai_polished'] ?? null) + 采纳 + @endif +
+
+ @endforeach +
+ + @if($polishResult) +
+

润色预览(已暂存,点击采纳写回文章)

+
{{ Str::limit($polishResult, 3000) }}
+
+ @endif +
+
diff --git a/plugins/neatstudio.membership/database/migrations/2026_08_11_100000_create_membership_tables.php b/plugins/neatstudio.membership/database/migrations/2026_08_11_100000_create_membership_tables.php new file mode 100644 index 0000000..bdc52e6 --- /dev/null +++ b/plugins/neatstudio.membership/database/migrations/2026_08_11_100000_create_membership_tables.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/plugins/neatstudio.membership/plugin.json b/plugins/neatstudio.membership/plugin.json new file mode 100644 index 0000000..84e7bab --- /dev/null +++ b/plugins/neatstudio.membership/plugin.json @@ -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" + ] +} diff --git a/plugins/neatstudio.membership/routes/web.php b/plugins/neatstudio.membership/routes/web.php new file mode 100644 index 0000000..f78f543 --- /dev/null +++ b/plugins/neatstudio.membership/routes/web.php @@ -0,0 +1,9 @@ +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'); diff --git a/plugins/neatstudio.membership/src/Filament/Resources/MembershipPlanResource.php b/plugins/neatstudio.membership/src/Filament/Resources/MembershipPlanResource.php new file mode 100644 index 0000000..a84ee72 --- /dev/null +++ b/plugins/neatstudio.membership/src/Filament/Resources/MembershipPlanResource.php @@ -0,0 +1,74 @@ +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'), + ]; + } +} diff --git a/plugins/neatstudio.membership/src/Filament/Resources/Pages/CreateMembershipPlan.php b/plugins/neatstudio.membership/src/Filament/Resources/Pages/CreateMembershipPlan.php new file mode 100644 index 0000000..bd77774 --- /dev/null +++ b/plugins/neatstudio.membership/src/Filament/Resources/Pages/CreateMembershipPlan.php @@ -0,0 +1,11 @@ +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'), + ]; + } +} diff --git a/plugins/neatstudio.membership/src/Http/MembershipController.php b/plugins/neatstudio.membership/src/Http/MembershipController.php new file mode 100644 index 0000000..5f88136 --- /dev/null +++ b/plugins/neatstudio.membership/src/Http/MembershipController.php @@ -0,0 +1,88 @@ +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()); + } +} diff --git a/plugins/neatstudio.membership/src/Models/MembershipPlan.php b/plugins/neatstudio.membership/src/Models/MembershipPlan.php new file mode 100644 index 0000000..d0662f3 --- /dev/null +++ b/plugins/neatstudio.membership/src/Models/MembershipPlan.php @@ -0,0 +1,23 @@ + 'array', + 'active' => 'boolean', + ]; + + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } +} diff --git a/plugins/neatstudio.membership/src/Models/Subscription.php b/plugins/neatstudio.membership/src/Models/Subscription.php new file mode 100644 index 0000000..f4733f3 --- /dev/null +++ b/plugins/neatstudio.membership/src/Models/Subscription.php @@ -0,0 +1,34 @@ + '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(); + } +} diff --git a/plugins/neatstudio.membership/src/ServiceProvider.php b/plugins/neatstudio.membership/src/ServiceProvider.php new file mode 100644 index 0000000..72efc18 --- /dev/null +++ b/plugins/neatstudio.membership/src/ServiceProvider.php @@ -0,0 +1,76 @@ +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', + '', + $html + ); + } + + if (! $service->canReadPost($user, $post)) { + return ''; + } + + return $html; + }, 10); + } +} diff --git a/plugins/neatstudio.membership/src/Services/MembershipService.php b/plugins/neatstudio.membership/src/Services/MembershipService.php new file mode 100644 index 0000000..305fa7a --- /dev/null +++ b/plugins/neatstudio.membership/src/Services/MembershipService.php @@ -0,0 +1,79 @@ +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); + } +} diff --git a/plugins/neatstudio.payment/database/migrations/2026_08_11_100000_create_payments_table.php b/plugins/neatstudio.payment/database/migrations/2026_08_11_100000_create_payments_table.php new file mode 100644 index 0000000..6a5041a --- /dev/null +++ b/plugins/neatstudio.payment/database/migrations/2026_08_11_100000_create_payments_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('order_no', 40)->unique(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('subject', 255); + $table->string('description', 500)->nullable(); + $table->unsignedBigInteger('amount'); // 单位:分 + $table->string('channel', 20)->default('alipay'); // alipay / wechat / sandbox + $table->string('status', 20)->default('pending'); // pending / paid / failed / closed + $table->string('gateway_trade_no', 100)->nullable(); + $table->json('payload')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/plugins/neatstudio.payment/plugin.json b/plugins/neatstudio.payment/plugin.json new file mode 100644 index 0000000..6d53fb0 --- /dev/null +++ b/plugins/neatstudio.payment/plugin.json @@ -0,0 +1,14 @@ +{ + "title": "支付", + "version": "1.0.0", + "description": "支付宝 + 微信支付(yansongda/pay),沙箱模拟;订单回调统一走 payment.paid 钩子", + "author": "LaraLog", + "type": "core", + "provider": "Plugins\\Neatstudio\\Payment\\ServiceProvider", + "filament_pages": [ + "Plugins\\Neatstudio\\Payment\\Filament\\Pages\\PaymentSettings" + ], + "filament_resources": [ + "Plugins\\Neatstudio\\Payment\\Filament\\Resources\\PaymentResource" + ] +} diff --git a/plugins/neatstudio.payment/routes/web.php b/plugins/neatstudio.payment/routes/web.php new file mode 100644 index 0000000..15d5d45 --- /dev/null +++ b/plugins/neatstudio.payment/routes/web.php @@ -0,0 +1,11 @@ +name('pay.checkout'); +Route::get('/pay/sandbox/{orderNo}', [PaymentController::class, 'sandbox'])->name('pay.sandbox'); +Route::post('/pay/sandbox/{orderNo}/confirm', [PaymentController::class, 'sandboxConfirm'])->name('pay.sandbox.confirm'); +Route::get('/pay/result/{orderNo}', [PaymentController::class, 'result'])->name('pay.result'); +Route::match(['get', 'post'], '/pay/notify/{channel}', [PaymentController::class, 'notify'])->name('pay.notify')->withoutMiddleware('csrf'); +Route::get('/pay/return/{channel}', [PaymentController::class, 'return'])->name('pay.return'); diff --git a/plugins/neatstudio.payment/src/Filament/Pages/PaymentSettings.php b/plugins/neatstudio.payment/src/Filament/Pages/PaymentSettings.php new file mode 100644 index 0000000..1c465e1 --- /dev/null +++ b/plugins/neatstudio.payment/src/Filament/Pages/PaymentSettings.php @@ -0,0 +1,83 @@ +form->fill([ + 'pay_sandbox' => (int) Setting::get('pay_sandbox', 1) === 1, + 'pay_alipay_app_id' => Setting::get('pay_alipay_app_id', ''), + 'pay_alipay_app_secret' => Setting::get('pay_alipay_app_secret', ''), + 'pay_wechat_app_id' => Setting::get('pay_wechat_app_id', ''), + 'pay_wechat_mch_id' => Setting::get('pay_wechat_mch_id', ''), + 'pay_wechat_mch_secret' => Setting::get('pay_wechat_mch_secret', ''), + ]); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Section::make('通用') + ->schema([ + Toggle::make('pay_sandbox')->label('沙箱模式(无需真实密钥即可测试支付流程)')->default(true), + ]), + Section::make('支付宝') + ->columns(2) + ->schema([ + TextInput::make('pay_alipay_app_id')->label('App ID')->columnSpanFull(), + TextInput::make('pay_alipay_app_secret')->label('应用私钥(app_secret_cert)')->password()->columnSpanFull(), + ]), + Section::make('微信支付') + ->columns(2) + ->schema([ + TextInput::make('pay_wechat_app_id')->label('App ID'), + TextInput::make('pay_wechat_mch_id')->label('商户号 MCH ID'), + TextInput::make('pay_wechat_mch_secret')->label('API 密钥')->password()->columnSpanFull(), + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $data = $this->form->getState(); + + foreach ($data as $key => $value) { + Setting::set($key, is_bool($value) ? (string) (int) $value : (string) $value); + } + + Notification::make()->title('支付设置已保存')->success()->send(); + } + + protected function getFormActions(): array + { + return [ + Action::make('save')->label('保存')->submit('save'), + ]; + } +} diff --git a/plugins/neatstudio.payment/src/Filament/Resources/Pages/ListPayments.php b/plugins/neatstudio.payment/src/Filament/Resources/Pages/ListPayments.php new file mode 100644 index 0000000..0447136 --- /dev/null +++ b/plugins/neatstudio.payment/src/Filament/Resources/Pages/ListPayments.php @@ -0,0 +1,11 @@ +components([ + \Filament\Forms\Components\TextInput::make('order_no')->label('订单号')->disabled(), + \Filament\Forms\Components\TextInput::make('subject')->label('商品')->disabled(), + \Filament\Forms\Components\TextInput::make('amount')->label('金额(分)')->numeric()->disabled(), + \Filament\Forms\Components\TextInput::make('channel')->label('渠道')->disabled(), + \Filament\Forms\Components\TextInput::make('status')->label('状态')->disabled(), + \Filament\Forms\Components\DateTimePicker::make('paid_at')->label('支付时间')->disabled(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + \Filament\Tables\Columns\TextColumn::make('order_no')->label('订单号')->searchable(), + \Filament\Tables\Columns\TextColumn::make('user.name')->label('用户'), + \Filament\Tables\Columns\TextColumn::make('subject')->label('商品')->limit(30), + \Filament\Tables\Columns\TextColumn::make('amount')->label('金额') + ->formatStateUsing(fn ($state) => '¥'.number_format((float) $state / 100, 2)), + \Filament\Tables\Columns\TextColumn::make('channel')->label('渠道')->badge(), + \Filament\Tables\Columns\TextColumn::make('status')->label('状态') + ->badge() + ->formatStateUsing(fn ($state) => match ($state) { + 'paid' => '已支付', + 'pending' => '待支付', + 'failed' => '失败', + 'closed' => '已关闭', + default => $state, + }) + ->color(fn ($state) => match ($state) { + 'paid' => 'success', + 'pending' => 'warning', + default => 'gray', + }), + \Filament\Tables\Columns\TextColumn::make('paid_at')->label('支付时间')->dateTime('Y-m-d H:i'), + \Filament\Tables\Columns\TextColumn::make('created_at')->label('创建时间')->dateTime('Y-m-d H:i'), + ]) + ->filters([ + \Filament\Tables\Filters\SelectFilter::make('status') + ->options(['paid' => '已支付', 'pending' => '待支付', 'failed' => '失败', 'closed' => '已关闭']), + ]) + ->recordActions([]) + ->defaultSort('created_at', 'desc'); + } + + public static function getPages(): array + { + return [ + 'index' => ListPayments::route('/'), + ]; + } +} diff --git a/plugins/neatstudio.payment/src/Http/PaymentController.php b/plugins/neatstudio.payment/src/Http/PaymentController.php new file mode 100644 index 0000000..0a25b16 --- /dev/null +++ b/plugins/neatstudio.payment/src/Http/PaymentController.php @@ -0,0 +1,85 @@ +user_id !== auth()->id()) { + abort(403); + } + + $url = $this->manager->pay($payment); + + return redirect()->away($url); + } + + /** + * 沙箱模拟支付页。 + */ + public function sandbox(string $orderNo) + { + $payment = Payment::where('order_no', $orderNo)->firstOrFail(); + + return theme_view('payments.sandbox', compact('payment')); + } + + /** + * 沙箱确认支付。 + */ + public function sandboxConfirm(Request $request, string $orderNo) + { + $payment = Payment::where('order_no', $orderNo)->firstOrFail(); + + $this->manager->simulatePay($payment); + + return redirect()->route('pay.result', $payment->order_no); + } + + /** + * 支付结果页(沙箱 / 回跳共用)。 + */ + public function result(string $orderNo) + { + $payment = Payment::where('order_no', $orderNo)->firstOrFail(); + + return theme_view('payments.result', compact('payment')); + } + + /** + * 异步回调(支付宝/微信 notify)。 + */ + public function notify(Request $request, string $channel) + { + $result = $this->manager->handleNotify($channel, $request->all()); + + return response($result); + } + + /** + * 同步回跳。 + */ + public function return(Request $request, string $channel) + { + $orderNo = $request->input('out_trade_no'); + + if (! $orderNo) { + return redirect()->route('home'); + } + + return redirect()->route('pay.result', $orderNo); + } +} diff --git a/plugins/neatstudio.payment/src/Models/Payment.php b/plugins/neatstudio.payment/src/Models/Payment.php new file mode 100644 index 0000000..fd6925a --- /dev/null +++ b/plugins/neatstudio.payment/src/Models/Payment.php @@ -0,0 +1,35 @@ + 'array', + 'paid_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function markPaid(string $gatewayTradeNo = null): void + { + $this->update([ + 'status' => 'paid', + 'gateway_trade_no' => $gatewayTradeNo, + 'paid_at' => now(), + ]); + + app(\App\Blog\Support\PluginManager::class)->doAction('payment.paid', $this); + } +} diff --git a/plugins/neatstudio.payment/src/ServiceProvider.php b/plugins/neatstudio.payment/src/ServiceProvider.php new file mode 100644 index 0000000..df78af9 --- /dev/null +++ b/plugins/neatstudio.payment/src/ServiceProvider.php @@ -0,0 +1,22 @@ +loadRoutes(__DIR__.'/../routes/web.php'); + + $manager->addFilter('payment.gateway', function (array $gateways) { + $gateways['alipay'] = '支付宝'; + $gateways['wechat'] = '微信支付'; + $gateways['sandbox'] = '沙箱模拟'; + + return $gateways; + }); + } +} diff --git a/plugins/neatstudio.payment/src/Services/PaymentManager.php b/plugins/neatstudio.payment/src/Services/PaymentManager.php new file mode 100644 index 0000000..e0475b7 --- /dev/null +++ b/plugins/neatstudio.payment/src/Services/PaymentManager.php @@ -0,0 +1,141 @@ + $orderNo, + 'user_id' => $user->id, + 'subject' => $subject, + 'description' => $description, + 'amount' => $amountCents, + 'channel' => $channel, + 'status' => 'pending', + ]); + } + + /** + * 发起支付,返回跳转地址(沙箱模式返回模拟支付页)。 + */ + public function pay(Payment $payment): string + { + $this->assertPending($payment); + + if ($this->sandboxEnabled()) { + return route('pay.sandbox', $payment->order_no); + } + + try { + return match ($payment->channel) { + 'alipay' => $this->alipay()->wap([ + 'out_trade_no' => $payment->order_no, + 'subject' => $payment->subject, + 'total_amount' => number_format($payment->amount / 100, 2), + ])->toArray()['h5_url'] ?? throw new \RuntimeException('支付宝未返回支付链接'), + 'wechat' => $this->wechat()->wap([ + 'out_trade_no' => $payment->order_no, + 'description' => $payment->subject, + 'amount' => ['total' => $payment->amount], + ])->toArray()['h5_url'] ?? throw new \RuntimeException('微信未返回支付链接'), + default => throw new \InvalidArgumentException('未知支付渠道'), + }; + } catch (\Throwable $e) { + if (str_contains($e->getMessage(), '未返回支付链接')) { + throw $e; + } + + throw new \RuntimeException('支付网关调用失败,请检查渠道配置或开启沙箱模式:'.$e->getMessage()); + } + } + + /** + * 沙箱模拟支付:直接标记订单已支付。 + */ + public function simulatePay(Payment $payment): void + { + $this->assertPending($payment); + $payment->markPaid('sandbox_'.Str::random(12)); + } + + public function handleNotify(string $channel, array $params): string + { + if ($this->sandboxEnabled()) { + return 'fail'; + } + + try { + $result = match ($channel) { + 'alipay' => $this->alipay()->callback($params), + 'wechat' => $this->wechat()->callback($params), + default => throw new \InvalidArgumentException('未知回调渠道'), + }; + + $orderNo = $result['out_trade_no'] ?? null; + $tradeNo = $result['trade_no'] ?? null; + + if ($orderNo) { + $payment = Payment::where('order_no', $orderNo)->first(); + $payment?->markPaid($tradeNo); + } + + return 'success'; + } catch (\Throwable) { + return 'fail'; + } + } + + public function sandboxEnabled(): bool + { + return (int) Setting::get('pay_sandbox', 1) === 1; + } + + public function alipay(): Alipay + { + return Pay::alipay([ + 'default' => [ + 'notify_url' => route('pay.notify', ['channel' => 'alipay']), + 'return_url' => route('pay.return', ['channel' => 'alipay']), + ], + 'app_id' => Setting::get('pay_alipay_app_id', ''), + 'app_secret_cert' => Setting::get('pay_alipay_app_secret', ''), + 'app_public_cert_path' => null, + 'alipay_public_cert_path' => null, + 'alipay_root_cert_path' => null, + ]); + } + + public function wechat(): Wechat + { + return Pay::wechat([ + 'default' => [ + 'notify_url' => route('pay.notify', ['channel' => 'wechat']), + 'return_url' => route('pay.return', ['channel' => 'wechat']), + ], + 'mch_id' => Setting::get('pay_wechat_mch_id', ''), + 'mch_secret_key' => Setting::get('pay_wechat_mch_secret', ''), + 'mch_secret_cert' => null, + 'mch_public_cert_path' => null, + 'mp_app_id' => Setting::get('pay_wechat_app_id', ''), + ]); + } + + private function assertPending(Payment $payment): void + { + if ($payment->status !== 'pending') { + throw new \RuntimeException('订单状态不是待支付'); + } + } +} diff --git a/resources/views/filament/pages/plugin-settings.blade.php b/resources/views/filament/pages/plugin-settings.blade.php new file mode 100644 index 0000000..21e34bb --- /dev/null +++ b/resources/views/filament/pages/plugin-settings.blade.php @@ -0,0 +1,8 @@ + +
+ {{ $this->form }} +
+ 保存设置 +
+
+
diff --git a/resources/views/membership/index.blade.php b/resources/views/membership/index.blade.php new file mode 100644 index 0000000..6e25bad --- /dev/null +++ b/resources/views/membership/index.blade.php @@ -0,0 +1,44 @@ +@php $pageTitle = '会员中心 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

会员中心

+ @auth + @php $member = app(\Plugins\Neatstudio\Membership\Services\MembershipService::class)->isMember(auth()->user()); @endphp + @if($member) +
你是会员,可阅读全部会员专享内容。
+ @else +
你还不是会员,开通后可解锁会员专享文章。
+ @endif + @else +
请先 登录 后购买会员。
+ @endauth + +
+ @forelse($plans as $plan) +
+

{{ $plan->name }}

+

¥{{ number_format($plan->price / 100, 2) }}

+

有效期 {{ $plan->duration_days }} 天

+ @if($plan->description)

{{ $plan->description }}

@endif + @auth +
+ @csrf + +
+ @endauth +
+ @empty +
暂无上架套餐
+ @endforelse +
+ +

查看我的订阅

+
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/membership/mine.blade.php b/resources/views/membership/mine.blade.php new file mode 100644 index 0000000..6ce8a10 --- /dev/null +++ b/resources/views/membership/mine.blade.php @@ -0,0 +1,31 @@ +@php $pageTitle = '我的订阅 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

我的订阅

+ @forelse($subscriptions as $subscription) +
+

{{ $subscription->plan?->name ?? '(已下架)' }}

+ +
+ @empty +
暂无订阅记录,去开通会员
+ @endforelse +
+ @include('partials.sidebar') +
+@include('partials.footer') + + diff --git a/resources/views/payments/result.blade.php b/resources/views/payments/result.blade.php new file mode 100644 index 0000000..4ae45ea --- /dev/null +++ b/resources/views/payments/result.blade.php @@ -0,0 +1,22 @@ +@php $pageTitle = '支付结果 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

支付结果

+ @if($payment->status === 'paid') +
支付成功!订单号:{{ $payment->order_no }}
+ @elseif($payment->status === 'pending') +
支付未完成,订单号:{{ $payment->order_no }}
+ @else +
订单状态:{{ $payment->status }}
+ @endif +

商品:{{ $payment->subject }}

+

金额:¥{{ number_format($payment->amount / 100, 2) }}

+

返回首页

+
+
+@include('partials.footer') + + diff --git a/resources/views/payments/sandbox.blade.php b/resources/views/payments/sandbox.blade.php new file mode 100644 index 0000000..f3db190 --- /dev/null +++ b/resources/views/payments/sandbox.blade.php @@ -0,0 +1,24 @@ +@php $pageTitle = '模拟支付 - '.$siteName; @endphp +@include('partials.head') + +@include('partials.header') +
+
+

模拟支付(沙箱)

+
+

订单号:{{ $payment->order_no }}

+

商品:{{ $payment->subject }}

+

金额:¥{{ number_format($payment->amount / 100, 2) }}

+

渠道:{{ $payment->channel }}

+

沙箱模式:点击下方按钮模拟支付成功

+
+
+ @csrf + +
+

取消,返回首页

+
+
+@include('partials.footer') + + diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8364a84..b961ad4 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -2,18 +2,24 @@ namespace Tests\Feature; -// use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ExampleTest extends TestCase { - /** - * A basic test example. - */ - public function test_the_application_returns_a_successful_response(): void - { - $response = $this->get('/'); + use RefreshDatabase; - $response->assertStatus(200); + /** + * 首页与前台关键页面正常渲染。 + */ + public function test_home_page_returns_successful_response(): void + { + $this->seed(); + + $this->get('/')->assertStatus(200); + $this->get('/archives')->assertStatus(200); + $this->get('/rss.xml')->assertStatus(200); + $this->get('/sitemap.xml')->assertStatus(200); + $this->get('/robots.txt')->assertStatus(200); } } diff --git a/tests/Feature/MembershipFlowTest.php b/tests/Feature/MembershipFlowTest.php new file mode 100644 index 0000000..674e493 --- /dev/null +++ b/tests/Feature/MembershipFlowTest.php @@ -0,0 +1,104 @@ +seed(); + Setting::set('pay_sandbox', '1'); + + $this->user = User::create([ + 'name' => '测试会员', + 'email' => 'member@test.com', + 'password' => bcrypt('password'), + ]); + + $this->plan = MembershipPlan::create([ + 'name' => '月付会员', + 'slug' => 'monthly', + 'price' => 9900, + 'duration_days' => 30, + 'permissions' => ['read_members_only'], + 'active' => true, + ]); + } + + public function test_membership_page_lists_plans(): void + { + $this->get('/membership')->assertOk()->assertSee('月付会员'); + } + + public function test_subscribe_creates_payment_and_redirects(): void + { + $this->actingAs($this->user) + ->post('/membership/'.$this->plan->id.'/subscribe') + ->assertRedirect(); + + $payment = Payment::query()->where('user_id', $this->user->id)->first(); + $this->assertNotNull($payment); + $this->assertSame('pending', $payment->status); + $this->assertSame('MembershipPlan#'.$this->plan->id, $payment->description); + } + + public function test_sandbox_payment_activates_subscription(): void + { + $this->actingAs($this->user)->post('/membership/'.$this->plan->id.'/subscribe'); + $payment = Payment::query()->where('user_id', $this->user->id)->first(); + + $this->actingAs($this->user)->get('/pay/checkout/'.$payment->id)->assertRedirect(); + $this->actingAs($this->user)->post('/pay/sandbox/'.$payment->order_no.'/confirm')->assertRedirect(); + + $payment->refresh(); + $this->assertSame('paid', $payment->status); + + $subscription = Subscription::query()->where('user_id', $this->user->id)->first(); + $this->assertNotNull($subscription); + $this->assertTrue($subscription->isActive()); + } + + public function test_paid_content_hidden_for_guest_and_visible_for_member(): void + { + $post = Post::create([ + 'title' => '付费文章', + 'slug' => 'paid-post', + 'content' => "公开内容\n\n[paid]\n付费隐藏内容\n[/paid]", + 'content_format' => 'markdown', + 'status' => 'published', + 'published_at' => now(), + ]); + + // 游客:付费部分被替换 + $this->get('/posts/paid-post') + ->assertOk() + ->assertDontSee('付费隐藏内容') + ->assertSee('付费内容已隐藏'); + + // 会员:可见 + $this->actingAs($this->user)->post('/membership/'.$this->plan->id.'/subscribe'); + $payment = Payment::query()->where('user_id', $this->user->id)->first(); + $this->actingAs($this->user)->post('/pay/sandbox/'.$payment->order_no.'/confirm'); + + $this->actingAs($this->user)->get('/posts/paid-post') + ->assertOk() + ->assertSee('付费隐藏内容'); + } +}