M5: Workerman 常驻服务 + AI 审核/润色插件 + 支付插件(支付宝/微信/沙箱)+ 会员插件(套餐/订阅/付费文章)
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\AiModeration\Filament\Pages;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Schema;
|
||||
use Plugins\Neatstudio\AiModeration\Jobs\AiPolishContentJob;
|
||||
|
||||
class AiSettings extends Page
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected static ?string $navigationLabel = 'AI 设置';
|
||||
|
||||
protected string $view = 'plugin.ai-moderation::ai-settings';
|
||||
|
||||
public array $data = [];
|
||||
|
||||
public ?string $polishPost = null;
|
||||
|
||||
public ?string $polishResult = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\AiModeration;
|
||||
|
||||
use App\Blog\Jobs\AiJob;
|
||||
use App\Blog\Services\LlmClient;
|
||||
use App\Blog\Support\WorkermanBroadcaster;
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiModerateCommentJob implements AiJob
|
||||
{
|
||||
public function __construct(public int $commentId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(LlmClient $llm): void
|
||||
{
|
||||
$comment = Comment::with('post')->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\AiModeration;
|
||||
|
||||
use App\Blog\Jobs\AiJob;
|
||||
use App\Blog\Services\LlmClient;
|
||||
use App\Models\Post;
|
||||
|
||||
class AiPolishContentJob implements AiJob
|
||||
{
|
||||
public function __construct(public int $postId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(LlmClient $llm): void
|
||||
{
|
||||
$post = Post::find($this->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])]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\AiModeration;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-filament-panels::page>
|
||||
<form wire:submit="save">
|
||||
{{ $this->form }}
|
||||
<div class="mt-6">
|
||||
<x-filament::button type="submit" color="primary">保存设置</x-filament::button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-filament::section heading="AI 润色文章">
|
||||
<div class="space-y-2">
|
||||
@foreach(\App\Models\Post::query()->latest()->limit(20)->get(['id','title']) as $post)
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-white/10">
|
||||
<span class="text-sm">{{ $post->title }}</span>
|
||||
<div class="flex gap-2">
|
||||
<x-filament::button size="sm" color="primary" wire:click="polish({{ $post->id }})">AI 润色</x-filament::button>
|
||||
@if($post->meta['ai_polished'] ?? null)
|
||||
<x-filament::button size="sm" color="success" wire:click="acceptPolish({{ $post->id }})">采纳</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($polishResult)
|
||||
<div class="mt-4 rounded-lg border border-gray-300 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<h3 class="mb-2 text-sm font-semibold">润色预览(已暂存,点击采纳写回文章)</h3>
|
||||
<pre class="whitespace-pre-wrap text-sm">{{ Str::limit($polishResult, 3000) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
Reference in New Issue
Block a user