wip: article AI polish, category SEO fields, cover generator, membership plan seeder
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped

This commit is contained in:
2026-09-07 18:48:37 +00:00
parent 263b98b218
commit 3cec4c5e18
121 changed files with 4701 additions and 313 deletions
+22 -15
View File
@@ -4,41 +4,48 @@ declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
use App\Domain\Media\ArticleCoverService;
use App\Models\Article;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
/**
* Phase-2 stub: auto-pick or generate a cover image for an article.
* Dispatch later from import scripts / artisan batch; process via Workerman/queue.
* Auto-pick a cover from article body embeds / attachments.
* `generate` strategy is reserved for future text-to-image.
*/
class GenerateArticleCoverJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Queueable;
public function __construct(
public int $articleId,
public string $strategy = 'auto', // auto|from_content|generate
public string $strategy = 'auto', // auto|from_content|attachment|generate
) {
$this->onQueue('ai-content');
}
public function handle(): void
public function handle(ArticleCoverService $covers): void
{
$article = Article::query()->find($this->articleId);
if ($article === null) {
return;
}
// Intentionally unimplemented in phase 1.
Log::info('GenerateArticleCoverJob stub skipped', [
'article_id' => $article->id,
'strategy' => $this->strategy,
'cover_status' => $article->cover_status,
]);
try {
$covers->apply($article, $this->strategy);
} catch (\Throwable $exception) {
Log::warning('GenerateArticleCoverJob failed.', [
'article_id' => $this->articleId,
'strategy' => $this->strategy,
'message' => $exception->getMessage(),
]);
$article->forceFill([
'cover_status' => ArticleCoverService::STATUS_FAILED,
'cover_generated_at' => now(),
])->save();
}
}
}
@@ -34,15 +34,27 @@ class OptimizeArticleContentJob implements ShouldQueue
}
try {
$result = $llm->complete($article->content, [
$result = $llm->complete((string) $article->content, [
'title' => $article->title,
'article_id' => $article->id,
'content_format' => $article->content_format,
]);
$article->forceFill([
'ai_summary' => $result['summary'] ?? null,
'ai_suggestions' => $result['suggestions'] ?? [],
])->save();
$updates = [
'ai_summary' => filled($result['summary'] ?? null) ? (string) $result['summary'] : $article->ai_summary,
'ai_suggestions' => array_values($result['suggestions'] ?? []),
];
if (filled($result['polished_content'] ?? null)) {
$updates['ai_polished_content'] = (string) $result['polished_content'];
}
// Fill empty SEO description from the model when the author left it blank.
if (blank($article->description) && filled($result['description'] ?? null)) {
$updates['description'] = (string) $result['description'];
}
$article->forceFill($updates)->save();
} catch (\Throwable $exception) {
Log::warning('Article content optimization failed.', [
'article_id' => $this->articleId,
+90 -37
View File
@@ -17,58 +17,64 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
public function complete(string $prompt, array $context = []): array
{
$response = $this->request([
'model' => $this->settings->model ?? 'gpt-4o-mini',
'messages' => [
[
'role' => 'system',
'content' => 'You optimize blog article content. Respond with JSON containing summary (string) and suggestions (array of strings).',
],
[
'role' => 'user',
'content' => $prompt,
],
$title = (string) ($context['title'] ?? '');
$format = (string) ($context['content_format'] ?? 'markdown');
$formatHint = $format === 'html'
? 'Keep valid HTML (no markdown). Preserve existing tags where useful.'
: 'Keep Markdown (no raw HTML unless already present). Preserve headings/lists/code fences.';
$response = $this->chat([
[
'role' => 'system',
'content' => implode("\n", [
'You are an editor polishing blog posts for LaraBlog (Chinese and English OK).',
'Improve clarity, flow, and grammar without inventing facts or changing the author\'s intent.',
$formatHint,
'Respond with JSON only (no markdown fences):',
'{"summary":"short overview","description":"SEO description <=160 chars","polished_content":"full polished body","suggestions":["tip1","tip2"]}',
]),
],
'response_format' => ['type' => 'json_object'],
]);
[
'role' => 'user',
'content' => "Title: {$title}\nFormat: {$format}\n\n---\n{$prompt}",
],
], preferJsonObject: true);
$content = data_get($response, 'choices.0.message.content');
if (! is_string($content)) {
throw new RuntimeException('LLM completion response missing content.');
}
$decoded = json_decode($content, true);
$decoded = is_string($content) ? $this->decodeJsonObject($content) : null;
if (! is_array($decoded)) {
throw new RuntimeException('LLM completion response is not valid JSON.');
}
$suggestions = $decoded['suggestions'] ?? [];
if (! is_array($suggestions)) {
$suggestions = [];
}
return [
'summary' => (string) ($decoded['summary'] ?? ''),
'suggestions' => array_values($decoded['suggestions'] ?? []),
'description' => (string) ($decoded['description'] ?? ''),
'polished_content' => (string) ($decoded['polished_content'] ?? $decoded['content'] ?? ''),
'suggestions' => array_values(array_map('strval', $suggestions)),
];
}
public function moderate(string $content): array
{
$response = $this->request([
'model' => $this->settings->model ?? 'gpt-4o-mini',
'messages' => [
[
'role' => 'system',
'content' => 'Moderate blog comments. Respond with JSON: {"status":"approved|rejected|needs_human","reason":"..."}',
],
[
'role' => 'user',
'content' => $content,
],
$response = $this->chat([
[
'role' => 'system',
'content' => 'You moderate Chinese and English blog comments for spam, ads, scams, abuse, and irrelevant promo. Respond with JSON only (no markdown): {"status":"approved|rejected|needs_human","reason":"..."}. Reject clear spam/ads; approve normal discussion; use needs_human when unsure.',
],
'response_format' => ['type' => 'json_object'],
]);
[
'role' => 'user',
'content' => $content,
],
], preferJsonObject: true);
$payload = data_get($response, 'choices.0.message.content');
$decoded = is_string($payload) ? json_decode($payload, true) : null;
$decoded = is_string($payload) ? $this->decodeJsonObject($payload) : null;
if (! is_array($decoded) || ! isset($decoded['status'])) {
return [
@@ -77,12 +83,41 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
];
}
$status = (string) $decoded['status'];
if (! in_array($status, ['approved', 'rejected', 'needs_human'], true)) {
$status = 'needs_human';
}
return [
'status' => (string) $decoded['status'],
'status' => $status,
'reason' => isset($decoded['reason']) ? (string) $decoded['reason'] : null,
];
}
/**
* @param list<array{role: string, content: string}> $messages
* @return array<string, mixed>
*/
protected function chat(array $messages, bool $preferJsonObject = false): array
{
$payload = [
'model' => $this->settings->model ?? 'gpt-4o-mini',
'messages' => $messages,
];
if ($preferJsonObject) {
try {
return $this->request($payload + [
'response_format' => ['type' => 'json_object'],
]);
} catch (\Throwable) {
// Some OpenAI-compatible gateways (e.g. Agnes) reject response_format.
}
}
return $this->request($payload);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
@@ -91,9 +126,9 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
{
$baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/');
$response = Http::withToken($this->settings->api_key ?? '')
$response = Http::withToken((string) ($this->settings->api_key ?? ''))
->acceptJson()
->timeout(60)
->timeout(90)
->post("{$baseUrl}/chat/completions", $payload)
->throw()
->json();
@@ -104,4 +139,22 @@ class OpenAiCompatibleLlmProvider implements LlmProvider
return $response;
}
/**
* @return array<string, mixed>|null
*/
protected function decodeJsonObject(string $content): ?array
{
$trimmed = trim($content);
if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/s', $trimmed, $matches) === 1) {
$trimmed = $matches[1];
} elseif (preg_match('/\{.*\}/s', $trimmed, $matches) === 1) {
$trimmed = $matches[0];
}
$decoded = json_decode($trimmed, true);
return is_array($decoded) ? $decoded : null;
}
}
+5 -1
View File
@@ -10,8 +10,12 @@ class StubLlmProvider implements LlmProvider
{
public function complete(string $prompt, array $context = []): array
{
$title = trim((string) ($context['title'] ?? 'Untitled'));
return [
'summary' => 'Stub summary for content optimization.',
'summary' => 'Stub summary for «'.$title..',
'description' => 'Stub SEO description for '.$title.'.',
'polished_content' => trim($prompt)."\n\n<!-- stub polished -->",
'suggestions' => [
'Review headings for clarity.',
'Add a concise meta description.',
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Models\Article;
use App\Models\Attachment;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
use RuntimeException;
/**
* Template-rendered OG covers (1200×630) written to the attachments disk.
* Uses title + summary; does not call an external text-to-image API.
*/
class ArticleCoverGenerator
{
public function generate(Article $article): array
{
$disk = (string) config('larablog.attachments_disk', 'attachments');
$relative = sprintf(
'covers/%s/article-%d-%s.jpg',
now()->format('Y/m'),
$article->id,
substr(sha1($article->title.'|'.microtime(true)), 0, 10),
);
$binary = $this->renderJpeg($article);
Storage::disk($disk)->put($relative, $binary, ['visibility' => 'public']);
Attachment::query()->create([
'article_id' => $article->id,
'disk' => $disk,
'path' => $relative,
'filename' => basename($relative),
'mime' => 'image/jpeg',
'size' => strlen($binary),
'checksum' => hash('sha256', $binary),
'visibility' => Attachment::VISIBILITY_PUBLIC,
'synced_at' => now(),
]);
return [
'disk' => $disk,
'path' => $relative,
'source' => ArticleCoverService::SOURCE_GENERATED,
];
}
protected function renderJpeg(Article $article): string
{
$manager = new ImageManager(new Driver);
$image = $manager->create(1200, 630)->fill('#0f3d4c');
// Soft bands for atmosphere (avoid a single flat fill).
$image->drawRectangle(0, 0, function ($rect): void {
$rect->size(1200, 210)->background('#123f4f');
});
$image->drawRectangle(0, 420, function ($rect): void {
$rect->size(1200, 210)->background('#16384a');
});
$image->drawRectangle(0, 0, function ($rect): void {
$rect->size(1200, 12)->background('#2bb0a6');
});
$site = 'LaraBlog';
try {
$site = (string) (app(GeneralSettings::class)->site_name ?: $site);
} catch (\Throwable) {
//
}
$title = $this->wrapText((string) $article->title, 18, 3);
$summary = trim((string) ($article->ai_summary ?: $article->description ?: ''));
if ($summary !== '') {
$summary = $this->wrapText($summary, 32, 2);
}
$font = $this->fontFile();
if ($font !== null) {
$image->text($site, 72, 110, function ($f) use ($font): void {
$f->filename($font);
$f->size(28);
$f->color('#8fd9d2');
});
$image->text($title, 72, 220, function ($f) use ($font): void {
$f->filename($font);
$f->size(56);
$f->color('#f4f7f8');
$f->lineHeight(1.25);
});
if ($summary !== '') {
$image->text($summary, 72, 430, function ($f) use ($font): void {
$f->filename($font);
$f->size(26);
$f->color('#c5d4db');
$f->lineHeight(1.35);
});
}
}
$encoded = $image->toJpeg(85)->toString();
if (! is_string($encoded) || $encoded === '') {
throw new RuntimeException('Failed to encode generated cover JPEG.');
}
return $encoded;
}
protected function wrapText(string $text, int $maxChars, int $maxLines): string
{
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
if ($text === '') {
return '';
}
$lines = [];
while ($text !== '' && count($lines) < $maxLines) {
if (mb_strlen($text) <= $maxChars) {
$lines[] = $text;
break;
}
$chunk = mb_substr($text, 0, $maxChars);
$lines[] = $chunk;
$text = ltrim(mb_substr($text, $maxChars));
}
if ($text !== '' && $lines !== []) {
$last = $lines[array_key_last($lines)];
$lines[array_key_last($lines)] = mb_substr($last, 0, max(1, $maxChars - 1)).'…';
}
return implode("\n", $lines);
}
protected function fontFile(): ?string
{
$configured = config('larablog.cover_font');
$candidates = array_filter([
is_string($configured) && $configured !== '' ? $configured : null,
resource_path('fonts/Cover.ttf'),
resource_path('fonts/NotoSansSC-Regular.otf'),
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
'/Library/Fonts/Arial Unicode.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
]);
foreach ($candidates as $path) {
if (is_string($path) && is_file($path)) {
return $path;
}
}
return null;
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Models\Article;
use App\Models\Attachment;
use Illuminate\Support\Facades\Storage;
/**
* Resolve / assign article covers from content, attachments, or template generation.
*/
class ArticleCoverService
{
public const SOURCE_NONE = 'none';
public const SOURCE_MANUAL = 'manual';
public const SOURCE_ATTACHMENT = 'attachment';
public const SOURCE_CONTENT_IMAGE = 'content_image';
public const SOURCE_GENERATED = 'generated';
public const STATUS_NONE = 'none';
public const STATUS_PENDING = 'pending';
public const STATUS_READY = 'ready';
public const STATUS_FAILED = 'failed';
public function __construct(
protected ArticleCoverGenerator $generator,
) {}
/**
* @return array{disk: ?string, path: string, source: string}|null
*/
public function resolve(Article $article, string $strategy = 'auto'): ?array
{
return match ($strategy) {
'attachment' => $this->fromAttachments($article),
'from_content', 'content_image' => $this->fromContent($article),
'generate' => $this->generator->generate($article),
default => $this->fromContent($article) ?? $this->fromAttachments($article),
};
}
public function apply(Article $article, string $strategy = 'auto'): Article
{
$article->forceFill([
'cover_status' => self::STATUS_PENDING,
])->save();
try {
$resolved = $this->resolve($article, $strategy);
} catch (\Throwable $exception) {
$article->forceFill([
'cover_status' => self::STATUS_FAILED,
'cover_source' => $strategy === 'generate' ? self::SOURCE_GENERATED : self::SOURCE_NONE,
'cover_generated_at' => now(),
])->save();
throw $exception;
}
if ($resolved === null) {
$article->forceFill([
'cover_disk' => null,
'cover_path' => null,
'cover_source' => self::SOURCE_NONE,
'cover_status' => self::STATUS_FAILED,
'cover_generated_at' => now(),
])->save();
return $article->refresh();
}
$article->forceFill([
'cover_disk' => $resolved['disk'],
'cover_path' => $resolved['path'],
'cover_source' => $resolved['source'],
'cover_status' => self::STATUS_READY,
'cover_generated_at' => now(),
])->save();
return $article->refresh();
}
public function publicUrl(Article $article): ?string
{
if (! $article->hasCover()) {
return null;
}
$path = (string) $article->cover_path;
if ($article->cover_disk === 'url' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
$disk = (string) ($article->cover_disk ?: config('larablog.attachments_disk', 'attachments'));
try {
return Storage::disk($disk)->url($path);
} catch (\Throwable) {
return url('/'.$path);
}
}
/**
* @return array{disk: ?string, path: string, source: string}|null
*/
protected function fromContent(Article $article): ?array
{
$content = (string) $article->content;
if (preg_match('/!\[[^\]]*\]\(attach:(\d+)\)/i', $content, $matches) === 1
|| preg_match('/\[attach=(\d+)\]/i', $content, $matches) === 1) {
$attachment = Attachment::query()->find((int) $matches[1]);
if ($attachment !== null && $this->isImage($attachment)) {
return [
'disk' => $attachment->disk,
'path' => $attachment->path,
'source' => self::SOURCE_CONTENT_IMAGE,
];
}
}
if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i', $content, $matches) === 1) {
return [
'disk' => 'url',
'path' => $matches[1],
'source' => self::SOURCE_CONTENT_IMAGE,
];
}
if (preg_match('/<img[^>]+src=["\'](https?:\/\/[^"\']+)["\']/i', $content, $matches) === 1) {
return [
'disk' => 'url',
'path' => $matches[1],
'source' => self::SOURCE_CONTENT_IMAGE,
];
}
return null;
}
/**
* @return array{disk: ?string, path: string, source: string}|null
*/
protected function fromAttachments(Article $article): ?array
{
$attachment = $article->attachments()
->orderBy('id')
->get()
->first(fn (Attachment $item): bool => $this->isImage($item));
if ($attachment === null) {
return null;
}
return [
'disk' => $attachment->disk,
'path' => $attachment->path,
'source' => self::SOURCE_ATTACHMENT,
];
}
protected function isImage(Attachment $attachment): bool
{
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
return true;
}
$ext = strtolower(pathinfo((string) $attachment->filename, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
}
}
+79 -11
View File
@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Domain\Plugin;
use App\Domain\Blog\ContentRenderer;
use App\Models\Plugin;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use Mews\Purifier\Facades\Purifier;
use RuntimeException;
class PluginManager
@@ -168,7 +170,7 @@ class PluginManager
return $dependents;
}
public function docsPath(string $name): ?string
public function docsPath(string $name, ?string $locale = null): ?string
{
$manifest = $this->discover()->get($name);
if ($manifest === null) {
@@ -181,25 +183,91 @@ class PluginManager
}
$root = realpath((string) $manifest['path']);
$path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs);
if ($root === false || $path === false || ! is_file($path)) {
if ($root === false) {
return null;
}
// Never read outside the plugin directory, even via symlinks.
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
return null;
foreach ($this->docsCandidates($docs, $locale ?? app()->getLocale()) as $relative) {
if (str_starts_with($relative, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $relative) === 1) {
continue;
}
$path = realpath($root.DIRECTORY_SEPARATOR.str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative));
if ($path === false || ! is_file($path)) {
continue;
}
// Never read outside the plugin directory, even via symlinks.
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
continue;
}
return $path;
}
return $path;
return null;
}
public function readDocs(string $name): ?string
public function readDocs(string $name, ?string $locale = null): ?string
{
$path = $this->docsPath($name);
$path = $this->docsPath($name, $locale);
if ($path === null) {
return null;
}
return $path !== null ? (string) file_get_contents($path) : null;
$contents = (string) file_get_contents($path);
return trim($contents) === '' ? null : $contents;
}
public function readDocsHtml(string $name, ?string $locale = null): ?string
{
$markdown = $this->readDocs($name, $locale);
if ($markdown === null) {
return null;
}
$html = app(ContentRenderer::class)->markdownToHtml($markdown);
return Purifier::clean($html, 'article');
}
/**
* Prefer README.{locale}.md, then README.{lang}.md, then the manifest docs file.
*
* @return list<string>
*/
protected function docsCandidates(string $docs, string $locale): array
{
$docs = str_replace('\\', '/', $docs);
$directory = dirname($docs);
$basename = basename($docs);
$extension = pathinfo($basename, PATHINFO_EXTENSION);
$stem = $extension !== ''
? substr($basename, 0, -strlen($extension) - 1)
: $basename;
$suffix = $extension !== '' ? '.'.$extension : '';
$prefix = ($directory === '.' || $directory === '') ? '' : $directory.'/';
$names = [];
$normalized = str_replace('-', '_', trim($locale));
if ($normalized !== '' && preg_match('/^[A-Za-z0-9_]+$/', $normalized) === 1) {
$names[] = $stem.'.'.$normalized.$suffix;
if (str_contains($normalized, '_')) {
$names[] = $stem.'.'.explode('_', $normalized, 2)[0].$suffix;
}
}
$names[] = $basename;
$candidates = [];
foreach ($names as $name) {
$relative = $prefix.$name;
if (! in_array($relative, $candidates, true)) {
$candidates[] = $relative;
}
}
return $candidates;
}
public function registerEnabledProviders(): void
+59
View File
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Domain\Seo;
use App\Models\Article;
use App\Models\Category;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
use Illuminate\Support\Str;
class SeoPresenter
{
@@ -76,6 +78,62 @@ class SeoPresenter
];
}
/**
* @return array{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
*/
public function forCategory(Category $category): array
{
$canonical = $category->publicUrl();
$rawDescription = $category->description ?: Str::limit(trim(preg_replace('/\s+/', ' ', (string) $category->intro) ?: ''), 160);
$description = $this->description($rawDescription !== '' ? $rawDescription : null);
return [
'title' => $this->title($category->name),
'description' => $description,
'keywords' => $this->keywords($category->keywords),
'canonical' => $canonical,
'og' => array_filter([
'og:title' => $this->title($category->name),
'og:description' => $description,
'og:url' => $canonical,
'og:type' => 'website',
'og:site_name' => $this->generalSettings->site_name,
'og:locale' => 'zh_CN',
'og:image' => $category->coverUrl(),
]),
'twitter' => array_filter([
'twitter:card' => $category->coverUrl() ? 'summary_large_image' : 'summary',
'twitter:title' => $this->title($category->name),
'twitter:description' => $description,
'twitter:image' => $category->coverUrl(),
]),
'jsonld' => $this->jsonLdForCategory($category, $canonical, $description),
];
}
/**
* @return array<string, mixed>
*/
protected function jsonLdForCategory(Category $category, string $canonical, string $description): array
{
if (! $this->seoSettings->json_ld_enabled) {
return [];
}
return [
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $category->name,
'description' => $description,
'url' => $canonical,
'isPartOf' => [
'@type' => 'WebSite',
'name' => $this->generalSettings->site_name,
'url' => $this->generalSettings->site_url ?: url('/'),
],
];
}
/**
* @return array<string, string>
*/
@@ -94,6 +152,7 @@ class SeoPresenter
'og:type' => $article ? 'article' : 'website',
'og:site_name' => $this->generalSettings->site_name,
'og:locale' => 'zh_CN',
'og:image' => $article?->coverUrl(),
]);
}