Files
laralog/app/Console/Commands/GenerateCovers.php
T
gouki 58ebaf7f90
Build and Deploy / verify (push) Successful in 11s
Build and Deploy / deploy (push) Successful in 19s
fix(covers): generate card conversion inline during covers:generate
站点无常驻 queue worker,media conversion 走 database 队列会永远不被消费,
导致封面 card 缩略图 404/403。批量生成期间关闭 queue_conversions_by_default。
2026-10-14 00:01:30 +00:00

152 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Services\LlmClient;
use App\Models\Post;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 用 AI 图像接口(OpenAI 兼容 /image_generation,如 MiniMax image-01)批量生成文章封面。
* 复用「AI 设置」里的 llm_base_url / llm_api_key,模型取 llm_image_model(默认 image-01)。
*/
class GenerateCovers extends Command
{
protected $signature = 'covers:generate
{--all : 全部重新生成(默认只处理没有任何封面图的文章)}
{--limit= : 最多处理多少篇}
{--post= : 只处理指定文章 ID,逗号分隔}
{--model= : 图片模型,覆盖 llm_image_model 设置}
{--style= : 附加到提示词的风格描述}
{--dry-run : 只列出将处理的文章,不调用接口}';
protected $description = '用 AI 为文章批量生成封面图(存入 media cover 集合)';
public function handle(LlmClient $llm): int
{
if (! $llm->isConfigured()) {
$this->error('LLM 未配置:请先在后台「AI 设置」或 .env 配置 LLM_BASE_URL / LLM_API_KEY');
return self::FAILURE;
}
$model = (string) ($this->option('model') ?: blog_setting('llm_image_model', 'image-01'));
// 站点无常驻 queue worker:批量生成期间同步产出 card 转换,避免永远卡在队列里。
config(['media-library.queue_conversions_by_default' => false]);
$query = Post::query()->published()->orderedForList();
if ($ids = trim((string) $this->option('post'))) {
$query->whereIn('id', array_filter(array_map('intval', explode(',', $ids))));
}
$todo = $query->get()
->filter(fn (Post $post) => $this->option('all') || $post->coverOrFirstImageUrl() === null)
->values();
if ($limit = (int) $this->option('limit')) {
$todo = $todo->take($limit)->values();
}
if ($todo->isEmpty()) {
$this->info('没有需要生成封面的文章。');
return self::SUCCESS;
}
$this->info("待处理 {$todo->count()} 篇,模型 {$model}");
if ($this->option('dry-run')) {
foreach ($todo as $post) {
$this->line("[{$post->id}] {$post->title}");
}
return self::SUCCESS;
}
$ok = 0;
$fail = 0;
foreach ($todo as $post) {
try {
$this->generateFor($post, $llm, $model);
$ok++;
$this->info("✔ [{$post->id}] {$post->title}");
} catch (\Throwable $e) {
$fail++;
$this->warn("✘ [{$post->id}] {$post->title}:{$e->getMessage()}");
Log::warning('covers:generate 失败', ['post_id' => $post->id, 'error' => $e->getMessage()]);
}
}
$this->info("完成:成功 {$ok},失败 {$fail}");
return $ok === 0 && $fail > 0 ? self::FAILURE : self::SUCCESS;
}
private function generateFor(Post $post, LlmClient $llm, string $model): void
{
$response = Http::timeout(120)
->withToken($llm->apiKey())
->post($llm->baseUrl().'/image_generation', [
'model' => $model,
'prompt' => $this->buildPrompt($post),
'aspect_ratio' => '16:9',
'response_format' => 'url',
'n' => 1,
'prompt_optimizer' => true,
]);
if ($response->failed()) {
throw new \RuntimeException('HTTP '.$response->status().':'.Str::limit($response->body(), 200));
}
$url = $response->json('data.image_urls.0');
if (! is_string($url) || $url === '') {
$reason = $response->json('base_resp.status_msg') ?: $response->body();
throw new \RuntimeException('接口未返回图片:'.Str::limit((string) $reason, 200));
}
$image = Http::timeout(60)->get($url)->throw();
$ext = match (strtolower(explode(';', (string) $image->header('Content-Type'))[0])) {
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg',
};
$tmp = tempnam(sys_get_temp_dir(), 'cover_');
file_put_contents($tmp, $image->body());
try {
$post->addMedia($tmp)
->usingFileName('cover-'.$post->id.'.'.$ext)
->toMediaCollection('cover');
} finally {
@unlink($tmp);
}
}
private function buildPrompt(Post $post): string
{
$style = trim((string) ($this->option('style')
?: blog_setting('cover_prompt_style', '扁平插画风格,色调柔和,构图简洁')));
$excerpt = Str::limit(trim(strip_tags((string) $post->excerpt)), 60, '');
$parts = ["为博客文章《{$post->title}》生成一张封面配图"];
if ($excerpt !== '') {
$parts[] = "内容大意:{$excerpt}";
}
$parts[] = $style;
$parts[] = '画面中不要出现任何文字';
return implode('。', $parts);
}
}