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,6 +68,7 @@ class PluginManager
|
||||
|
||||
public function isEnabled(string $plugin): bool
|
||||
{
|
||||
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();
|
||||
@@ -75,6 +76,7 @@ class PluginManager
|
||||
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([
|
||||
|
||||
@@ -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>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('membership_plans', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 60);
|
||||
$table->string('slug', 60)->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedBigInteger('price'); // 单位:分
|
||||
$table->unsignedInteger('duration_days')->default(30);
|
||||
$table->json('permissions')->nullable(); // 会员权限位
|
||||
$table->boolean('active')->default(true);
|
||||
$table->unsignedInteger('sort')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('membership_plan_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('status', 20)->default('active'); // active / expired / cancelled
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('subscriptions');
|
||||
Schema::dropIfExists('membership_plans');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"title": "会员",
|
||||
"version": "1.0.0",
|
||||
"description": "会员套餐订阅 + 付费文章([paid] 短代码),依赖支付插件闭环",
|
||||
"author": "LaraLog",
|
||||
"type": "core",
|
||||
"provider": "Plugins\\Neatstudio\\Membership\\ServiceProvider",
|
||||
"requires": ["neatstudio.payment"],
|
||||
"filament_resources": [
|
||||
"Plugins\\Neatstudio\\Membership\\Filament\\Resources\\MembershipPlanResource",
|
||||
"Plugins\\Neatstudio\\Membership\\Filament\\Resources\\SubscriptionResource"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Neatstudio\Membership\Http\MembershipController;
|
||||
|
||||
Route::get('/membership', [MembershipController::class, 'index'])->name('membership.index');
|
||||
Route::get('/membership/mine', [MembershipController::class, 'mine'])->middleware('auth')->name('membership.mine');
|
||||
Route::post('/membership/{plan}/subscribe', [MembershipController::class, 'subscribe'])->middleware('auth')->name('membership.subscribe');
|
||||
Route::post('/posts/{post}/unlock', [MembershipController::class, 'unlockPost'])->middleware('auth')->name('posts.unlock');
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\ListMembershipPlans;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\EditMembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\CreateMembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
|
||||
class MembershipPlanResource extends Resource
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static ?string $model = MembershipPlan::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedStar;
|
||||
|
||||
protected static ?string $navigationLabel = '会员套餐';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')->label('套餐名')->required(),
|
||||
TextInput::make('slug')->label('Slug')->required()->unique(ignoreRecord: true),
|
||||
Textarea::make('description')->label('描述')->rows(2)->columnSpanFull(),
|
||||
TextInput::make('price')->label('价格(分)')->numeric()->required()->default(0)->helperText('例:9900 = ¥99'),
|
||||
TextInput::make('duration_days')->label('时长(天)')->numeric()->required()->default(30),
|
||||
KeyValue::make('permissions')->label('权限位')->columnSpanFull(),
|
||||
Toggle::make('active')->label('上架')->default(true),
|
||||
TextInput::make('sort')->label('排序')->numeric()->default(0),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
\Filament\Tables\Columns\TextColumn::make('name')->label('套餐'),
|
||||
\Filament\Tables\Columns\TextColumn::make('price')->label('价格')
|
||||
->formatStateUsing(fn ($state) => '¥'.number_format((float) $state / 100, 2)),
|
||||
\Filament\Tables\Columns\TextColumn::make('duration_days')->label('时长')->suffix(' 天'),
|
||||
\Filament\Tables\Columns\IconColumn::make('active')->label('上架')->boolean(),
|
||||
\Filament\Tables\Columns\TextColumn::make('sort')->label('排序')->sortable(),
|
||||
])
|
||||
->filters([])
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
\Filament\Actions\BulkActionGroup::make([
|
||||
\Filament\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListMembershipPlans::route('/'),
|
||||
'create' => CreateMembershipPlan::route('/create'),
|
||||
'edit' => EditMembershipPlan::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class CreateMembershipPlan extends CreateRecord
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class EditMembershipPlan extends EditRecord
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\SubscriptionResource;
|
||||
|
||||
class EditSubscription extends EditRecord
|
||||
{
|
||||
protected static string $resource = SubscriptionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\MembershipPlanResource;
|
||||
|
||||
class ListMembershipPlans extends ListRecords
|
||||
{
|
||||
protected static string $resource = MembershipPlanResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\SubscriptionResource;
|
||||
|
||||
class ListSubscriptions extends ListRecords
|
||||
{
|
||||
protected static string $resource = SubscriptionResource::class;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Neatstudio\Membership\Filament\Resources\Pages\ListSubscriptions;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
|
||||
class SubscriptionResource extends Resource
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static ?string $model = Subscription::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
|
||||
|
||||
protected static ?string $navigationLabel = '订阅记录';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('user_id')->label('用户')->relationship('user', 'name')->disabled(),
|
||||
Select::make('membership_plan_id')->label('套餐')->relationship('plan', 'name')->disabled(),
|
||||
Select::make('status')->label('状态')->options([
|
||||
'active' => '生效中',
|
||||
'expired' => '已过期',
|
||||
'cancelled' => '已取消',
|
||||
])->required(),
|
||||
DateTimePicker::make('starts_at')->label('开始'),
|
||||
DateTimePicker::make('ends_at')->label('结束'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
\Filament\Tables\Columns\TextColumn::make('user.name')->label('用户'),
|
||||
\Filament\Tables\Columns\TextColumn::make('plan.name')->label('套餐'),
|
||||
\Filament\Tables\Columns\TextColumn::make('status')->label('状态')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => match ($state) {
|
||||
'active' => '生效中',
|
||||
'expired' => '已过期',
|
||||
'cancelled' => '已取消',
|
||||
default => $state,
|
||||
})
|
||||
->color(fn ($state) => $state === 'active' ? 'success' : 'gray'),
|
||||
\Filament\Tables\Columns\TextColumn::make('ends_at')->label('到期')->dateTime('Y-m-d'),
|
||||
\Filament\Tables\Columns\TextColumn::make('created_at')->label('购买时间')->dateTime('Y-m-d H:i'),
|
||||
])
|
||||
->filters([])
|
||||
->recordActions([
|
||||
\Filament\Actions\EditAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListSubscriptions::route('/'),
|
||||
'edit' => \Plugins\Neatstudio\Membership\Filament\Resources\Pages\EditSubscription::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Http;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
use Plugins\Neatstudio\Membership\Services\MembershipService;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
use Plugins\Neatstudio\Payment\Services\PaymentManager;
|
||||
|
||||
class MembershipController
|
||||
{
|
||||
public function __construct(private MembershipService $service, private PaymentManager $payment)
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$plans = MembershipPlan::query()->where('active', true)->orderBy('sort')->get();
|
||||
|
||||
return theme_view('membership.index', compact('plans'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 购买套餐:创建支付订单并跳转支付。
|
||||
*/
|
||||
public function subscribe(Request $request, MembershipPlan $plan)
|
||||
{
|
||||
if (! $plan->active) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$channel = $request->input('channel', 'alipay');
|
||||
|
||||
$payment = $this->payment->createOrder(
|
||||
$user,
|
||||
'会员套餐:'.$plan->name,
|
||||
$plan->price,
|
||||
$channel,
|
||||
'MembershipPlan#'.$plan->id
|
||||
);
|
||||
|
||||
return redirect()->route('pay.checkout', $payment);
|
||||
}
|
||||
|
||||
public function mine(Request $request)
|
||||
{
|
||||
$subscriptions = Subscription::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->with('plan')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return theme_view('membership.mine', compact('subscriptions'));
|
||||
}
|
||||
|
||||
public function unlockPost(Request $request, Post $post)
|
||||
{
|
||||
// 单篇付费解锁:创建一笔定向支付
|
||||
$price = (int) ($post->meta['price'] ?? 0);
|
||||
|
||||
if ($price <= 0) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$channel = $request->input('channel', 'alipay');
|
||||
|
||||
$payment = $this->payment->createOrder(
|
||||
$user,
|
||||
'解锁文章:'.$post->title,
|
||||
$price,
|
||||
$channel,
|
||||
'PostUnlock#'.$post->id
|
||||
);
|
||||
|
||||
return redirect()->route('pay.checkout', $payment);
|
||||
}
|
||||
|
||||
public function userIsMember(?User $user = null): bool
|
||||
{
|
||||
return $this->service->isMember($user ?? auth()->user());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MembershipPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name', 'slug', 'description', 'price', 'duration_days', 'permissions', 'active', 'sort',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subscription::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id', 'membership_plan_id', 'status', 'starts_at', 'ends_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MembershipPlan::class, 'membership_plan_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active' && $this->ends_at && $this->ends_at->isFuture();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||
|
||||
// 支付成功:激活会员订阅 / 解锁单篇付费文章
|
||||
$manager->addAction('payment.paid', function (Payment $payment) {
|
||||
$description = (string) $payment->description;
|
||||
$user = $payment->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($description, 'MembershipPlan#')) {
|
||||
$planId = (int) substr($description, strlen('MembershipPlan#'));
|
||||
$plan = MembershipPlan::find($planId);
|
||||
|
||||
if ($plan) {
|
||||
$now = now();
|
||||
Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'membership_plan_id' => $plan->id,
|
||||
'status' => 'active',
|
||||
'starts_at' => $now,
|
||||
'ends_at' => $now->copy()->addDays($plan->duration_days),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($description, 'PostUnlock#')) {
|
||||
$postId = (int) substr($description, strlen('PostUnlock#'));
|
||||
$post = \App\Models\Post::find($postId);
|
||||
|
||||
if ($post) {
|
||||
$meta = $post->meta ?? [];
|
||||
$unlocked = $meta['unlocked_user_ids'] ?? [];
|
||||
$unlocked[] = $user->id;
|
||||
$post->update(['meta' => array_merge($meta, ['unlocked_user_ids' => array_values(array_unique($unlocked))])]);
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// 付费文章:[paid]...[/paid] 对无权限访客隐藏
|
||||
$manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) {
|
||||
$service = app(Services\MembershipService::class);
|
||||
$user = auth()->user();
|
||||
|
||||
if (preg_match('/\[paid\](.*?)\[\/paid\]/s', $html) && ! $service->canReadPaidContent($user, $post)) {
|
||||
$html = preg_replace(
|
||||
'/\[paid\](.*?)\[\/paid\]/s',
|
||||
'<div class="paid-teaser">(付费内容已隐藏,开通会员或单篇解锁后可见)</div>',
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
if (! $service->canReadPost($user, $post)) {
|
||||
return '<div class="paid-teaser">(本文章为会员专享,开通会员后可见)</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Membership\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
|
||||
class MembershipService
|
||||
{
|
||||
public function isMember(?User $user): bool
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Subscription::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->where('ends_at', '>', now())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权阅读某篇文章(会员 或 作者 或 已单篇解锁)。
|
||||
*/
|
||||
public function canReadPost(?User $user, \App\Models\Post $post): bool
|
||||
{
|
||||
if ($user && $post->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$meta = $post->meta ?? [];
|
||||
|
||||
// 单篇解锁:meta.unlocked_user_ids
|
||||
if ($user && in_array($user->id, $meta['unlocked_user_ids'] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 会员专享文章
|
||||
if (! empty($meta['members_only'])) {
|
||||
return $user !== null && $this->isMember($user);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有权阅读 [paid] 付费块:会员 或 作者 或 已单篇解锁。
|
||||
*/
|
||||
public function canReadPaidContent(?User $user, \App\Models\Post $post): bool
|
||||
{
|
||||
if ($user && $post->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($user && in_array($user->id, $post->meta['unlocked_user_ids'] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user !== null && $this->isMember($user);
|
||||
}
|
||||
|
||||
public function hasPermission(User $user, string $permission): bool
|
||||
{
|
||||
if (! $this->isMember($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscription = Subscription::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->where('ends_at', '>', now())
|
||||
->with('plan')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
return $subscription && in_array($permission, $subscription->plan?->permissions ?? [], true);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?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('payments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Neatstudio\Payment\Http\PaymentController;
|
||||
|
||||
Route::get('/pay/checkout/{payment}', [PaymentController::class, 'checkout'])->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');
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment\Filament\Pages;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Section;
|
||||
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;
|
||||
|
||||
class PaymentSettings extends Page
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
|
||||
|
||||
protected static ?string $navigationLabel = '支付设置';
|
||||
|
||||
protected string $view = 'filament.pages.plugin-settings';
|
||||
|
||||
public array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment\Filament\Resources\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Plugins\Neatstudio\Payment\Filament\Resources\PaymentResource;
|
||||
|
||||
class ListPayments extends ListRecords
|
||||
{
|
||||
protected static string $resource = PaymentResource::class;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Plugins\Neatstudio\Payment\Filament\Resources\Pages\ListPayments;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
|
||||
class PaymentResource extends Resource
|
||||
{
|
||||
protected static \UnitEnum|string|null $navigationGroup = '管理';
|
||||
|
||||
protected static ?string $model = Payment::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBanknotes;
|
||||
|
||||
protected static ?string $navigationLabel = '订单';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment\Http;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
use Plugins\Neatstudio\Payment\Services\PaymentManager;
|
||||
|
||||
class PaymentController
|
||||
{
|
||||
public function __construct(private PaymentManager $manager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起支付:创建订单并跳转。
|
||||
*/
|
||||
public function checkout(Request $request, Payment $payment)
|
||||
{
|
||||
if ($payment->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Payment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'order_no', 'user_id', 'subject', 'description', 'amount', 'channel', 'status', 'gateway_trade_no', 'payload', 'paid_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
|
||||
class ServiceProvider extends PluginServiceProvider
|
||||
{
|
||||
protected function boot(PluginManager $manager): void
|
||||
{
|
||||
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||
|
||||
$manager->addFilter('payment.gateway', function (array $gateways) {
|
||||
$gateways['alipay'] = '支付宝';
|
||||
$gateways['wechat'] = '微信支付';
|
||||
$gateways['sandbox'] = '沙箱模拟';
|
||||
|
||||
return $gateways;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Neatstudio\Payment\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
use Yansongda\Pay\Pay;
|
||||
use Yansongda\Pay\Provider\Alipay;
|
||||
use Yansongda\Pay\Provider\Wechat;
|
||||
|
||||
class PaymentManager
|
||||
{
|
||||
public function createOrder(User $user, string $subject, int $amountCents, string $channel = 'alipay', string $description = null): Payment
|
||||
{
|
||||
$orderNo = date('YmdHis').Str::random(8);
|
||||
|
||||
return Payment::create([
|
||||
'order_no' => $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('订单状态不是待支付');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<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-panels::page>
|
||||
@@ -0,0 +1,44 @@
|
||||
@php $pageTitle = '会员中心 - '.$siteName; @endphp
|
||||
@include('partials.head')
|
||||
<body>
|
||||
@include('partials.header')
|
||||
<div class="container main-layout">
|
||||
<main class="content">
|
||||
<h1 class="list-title">会员中心</h1>
|
||||
@auth
|
||||
@php $member = app(\Plugins\Neatstudio\Membership\Services\MembershipService::class)->isMember(auth()->user()); @endphp
|
||||
@if($member)
|
||||
<div class="alert alert-success">你是会员,可阅读全部会员专享内容。</div>
|
||||
@else
|
||||
<div class="alert alert-error">你还不是会员,开通后可解锁会员专享文章。</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="alert alert-error">请先 <a href="{{ route('login') }}">登录</a> 后购买会员。</div>
|
||||
@endauth
|
||||
|
||||
<div class="plans-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;">
|
||||
@forelse($plans as $plan)
|
||||
<div class="post-card plan-card">
|
||||
<h2 class="post-title">{{ $plan->name }}</h2>
|
||||
<p class="plan-price" style="font-size:26px;font-weight:800;color:#2d6cdf;">¥{{ number_format($plan->price / 100, 2) }}</p>
|
||||
<p style="color:#888;font-size:14px;">有效期 {{ $plan->duration_days }} 天</p>
|
||||
@if($plan->description)<p style="color:#555;font-size:14px;">{{ $plan->description }}</p>@endif
|
||||
@auth
|
||||
<form action="{{ route('membership.subscribe', $plan) }}" method="post" style="margin-top:12px;">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">立即购买</button>
|
||||
</form>
|
||||
@endauth
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">暂无上架套餐</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<p style="margin-top:16px;"><a href="{{ route('membership.mine') }}">查看我的订阅</a></p>
|
||||
</main>
|
||||
@include('partials.sidebar')
|
||||
</div>
|
||||
@include('partials.footer')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
@php $pageTitle = '我的订阅 - '.$siteName; @endphp
|
||||
@include('partials.head')
|
||||
<body>
|
||||
@include('partials.header')
|
||||
<div class="container main-layout">
|
||||
<main class="content">
|
||||
<h1 class="list-title">我的订阅</h1>
|
||||
@forelse($subscriptions as $subscription)
|
||||
<div class="post-card">
|
||||
<h2 class="post-title">{{ $subscription->plan?->name ?? '(已下架)' }}</h2>
|
||||
<div class="post-meta">
|
||||
<span>状态:
|
||||
@if($subscription->isActive())
|
||||
<span class="badge">生效中</span>
|
||||
@else
|
||||
<span style="color:#c00;">{{ $subscription->status === 'expired' ? '已过期' : '已取消' }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<span>· 开始:{{ $subscription->starts_at?->format('Y-m-d') }}</span>
|
||||
<span>· 到期:{{ $subscription->ends_at?->format('Y-m-d') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty">暂无订阅记录,<a href="{{ route('membership.index') }}">去开通会员</a></div>
|
||||
@endforelse
|
||||
</main>
|
||||
@include('partials.sidebar')
|
||||
</div>
|
||||
@include('partials.footer')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
@php $pageTitle = '支付结果 - '.$siteName; @endphp
|
||||
@include('partials.head')
|
||||
<body>
|
||||
@include('partials.header')
|
||||
<div class="container auth-layout">
|
||||
<main class="auth-box">
|
||||
<h1 class="list-title">支付结果</h1>
|
||||
@if($payment->status === 'paid')
|
||||
<div class="alert alert-success">支付成功!订单号:{{ $payment->order_no }}</div>
|
||||
@elseif($payment->status === 'pending')
|
||||
<div class="alert alert-error">支付未完成,订单号:{{ $payment->order_no }}</div>
|
||||
@else
|
||||
<div class="alert alert-error">订单状态:{{ $payment->status }}</div>
|
||||
@endif
|
||||
<p><strong>商品:</strong>{{ $payment->subject }}</p>
|
||||
<p><strong>金额:</strong>¥{{ number_format($payment->amount / 100, 2) }}</p>
|
||||
<p class="auth-alt"><a href="{{ route('home') }}">返回首页</a></p>
|
||||
</main>
|
||||
</div>
|
||||
@include('partials.footer')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
@php $pageTitle = '模拟支付 - '.$siteName; @endphp
|
||||
@include('partials.head')
|
||||
<body>
|
||||
@include('partials.header')
|
||||
<div class="container auth-layout">
|
||||
<main class="auth-box">
|
||||
<h1 class="list-title">模拟支付(沙箱)</h1>
|
||||
<div class="profile-box" style="border:none;padding:0;">
|
||||
<p><strong>订单号:</strong>{{ $payment->order_no }}</p>
|
||||
<p><strong>商品:</strong>{{ $payment->subject }}</p>
|
||||
<p><strong>金额:</strong>¥{{ number_format($payment->amount / 100, 2) }}</p>
|
||||
<p><strong>渠道:</strong>{{ $payment->channel }}</p>
|
||||
<p class="text-muted" style="color:#999;font-size:13px;">沙箱模式:点击下方按钮模拟支付成功</p>
|
||||
</div>
|
||||
<form action="{{ route('pay.sandbox.confirm', $payment->order_no) }}" method="post">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">确认支付(模拟)</button>
|
||||
</form>
|
||||
<p class="auth-alt"><a href="{{ route('home') }}">取消,返回首页</a></p>
|
||||
</main>
|
||||
</div>
|
||||
@include('partials.footer')
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
use Plugins\Neatstudio\Payment\Models\Payment;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MembershipFlowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
|
||||
private MembershipPlan $plan;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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('付费隐藏内容');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user