M5: Workerman 常驻服务 + AI 审核/润色插件 + 支付插件(支付宝/微信/沙箱)+ 会员插件(套餐/订阅/付费文章)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Jobs;
|
||||
|
||||
use App\Blog\Services\LlmClient;
|
||||
|
||||
/**
|
||||
* 由 Workerman 消费者执行的 AI 任务统一接口。
|
||||
*/
|
||||
interface AiJob
|
||||
{
|
||||
public function handle(LlmClient $llm): void;
|
||||
}
|
||||
@@ -16,11 +16,6 @@ class PluginManagerServiceProvider extends ServiceProvider
|
||||
{
|
||||
$manager = $this->app->make(PluginManager::class);
|
||||
|
||||
// 迁移尚未执行(migrate:fresh 首轮)时跳过,避免查询不存在的表
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('plugin_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_dir(config('plugins.path'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* OpenAI 兼容 LLM 客户端(支持 OpenAI / DeepSeek / 通义 / 自建代理)。
|
||||
*/
|
||||
class LlmClient
|
||||
{
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) blog_setting('llm_base_url', config('services.llm.base_url', env('LLM_BASE_URL'))), '/');
|
||||
}
|
||||
|
||||
public function apiKey(): string
|
||||
{
|
||||
return (string) blog_setting('llm_api_key', config('services.llm.api_key', env('LLM_API_KEY')));
|
||||
}
|
||||
|
||||
public function model(): string
|
||||
{
|
||||
return (string) blog_setting('llm_model', config('services.llm.model', env('LLM_MODEL', 'gpt-4o-mini')));
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->baseUrl() !== '' && $this->apiKey() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的聊天补全。
|
||||
*
|
||||
* @param array<int, array{role: string, content: string}> $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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
/**
|
||||
* 从已启用插件的 manifest 中收集 Filament 页面/资源。
|
||||
*/
|
||||
class PluginPages
|
||||
{
|
||||
public static function pages(): array
|
||||
{
|
||||
return self::collect('filament_pages');
|
||||
}
|
||||
|
||||
public static function resources(): array
|
||||
{
|
||||
return self::collect('filament_resources');
|
||||
}
|
||||
|
||||
private static function collect(string $key): array
|
||||
{
|
||||
$manager = app(PluginManager::class);
|
||||
$classes = [];
|
||||
|
||||
foreach ($manager->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Blog\Support;
|
||||
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
/**
|
||||
* Workerman WebSocket 广播器:任务执行中向后台推送进度。
|
||||
*/
|
||||
class WorkermanBroadcaster
|
||||
{
|
||||
/** @var array<int, TcpConnection> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Blog\Services\LlmClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
class WorkermanServe extends Command
|
||||
{
|
||||
protected $signature = 'workerman:serve {action=start : start/stop/restart/reload}';
|
||||
|
||||
protected $description = '启动 Workerman 常驻服务:队列消费者(LLM 异步任务)+ WebSocket 进度推送';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
Worker::$pidFile = storage_path('framework/workerman.pid');
|
||||
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user