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); } }