unknown пре 1 месец
родитељ
комит
caac0623ca

+ 689 - 0
application/api/controller/ImageGeneration.php

@@ -0,0 +1,689 @@
+<?php
+
+namespace app\api\controller;
+
+use app\common\controller\Api;
+use app\job\SeedreamImageGenerationJob;
+use OSS\OssClient;
+use think\Config;
+use think\Log;
+use think\Queue;
+
+/**
+ * 文生图 / 图生图 / 组图生组图(豆包 Seedream)
+ */
+class ImageGeneration extends Api
+{
+    protected $noNeedLogin = ['*'];
+    protected $noNeedRight = ['*'];
+
+    const DEFAULT_MODEL = 'doubao-seedream-5-0-260128';
+    const DEFAULT_API_URL = 'https://ark.cn-beijing.volces.com/api/v3/images/generations';
+
+    /**
+     * 文生图 / 图生图 / 组图生组图
+     * POST /api/image_generation/generate
+     *
+     * 参数:
+     * - prompt(必填)提示词
+     * - image / images / image_url / image_urls / reference_images(可选)参考图,支持字符串或数组,最多 14 张
+     * - 上传 image / images 文件(可选,支持多文件;先上传 OSS,再以 OSS URL 作为参考图)
+     * - max_images / generate_count / output_count 生成张数,默认 1;大于 1 时走异步队列(seedreamimage)
+     * - model 模型,默认 doubao-seedream-5-0-260128
+     * - size 尺寸,默认 2K
+     * - sequential_image_generation 组图模式,不传时:生成 1 张为 disabled,多张为 auto
+     * - stream 是否流式,默认 false(组图队列任务固定非流式)
+     * - watermark 是否加水印,默认 false
+     *
+     * 组图(max_images>1)返回 task_id,请轮询 getStatus 接口获取结果
+     *
+     * 模式说明(由参考图数量 + 生成数量自动判定):
+     * - 0 张参考 + 1 张输出 → text_to_image 文生单图
+     * - 0 张参考 + N 张输出 → text_to_images 文生组图
+     * - 1 张参考 + 1 张输出 → image_to_image 单图生单图
+     * - 1 张参考 + N 张输出 → image_to_images 单图生组图
+     * - M 张参考 + N 张输出 → images_to_images 组图生组图
+     *
+     * AI 调用成功后会写入 picture_storage(img_id、prompt、reference_img_url 等;generate_img_url 暂不写入)
+     */
+    public function generate()
+    {
+        set_time_limit(600);
+        ini_set('max_execution_time', '600');
+
+        try {
+            $params = $this->mergeRequestParams();
+
+            $prompt = trim((string)($params['prompt'] ?? ''));
+            if ($prompt === '') {
+                return json(['code' => 0, 'msg' => 'prompt 不能为空']);
+            }
+
+            $referenceImages = $this->resolveReferenceImages($params);
+            $referenceCount = count($referenceImages);
+            $genConfig = $this->buildGenerationConfig($params, $referenceCount);
+
+            $model = trim((string)($params['model'] ?? self::DEFAULT_MODEL));
+            $stream = filter_var($params['stream'] ?? false, FILTER_VALIDATE_BOOLEAN);
+            $watermark = filter_var($params['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN);
+            $size = trim((string)($params['size'] ?? '2K'));
+
+            $requestBody = [
+                'model' => $model,
+                'prompt' => $prompt,
+                'sequential_image_generation' => $genConfig['sequential_image_generation'],
+                'response_format' => 'url',
+                'size' => $size,
+                'stream' => $stream,
+                'watermark' => $watermark,
+            ];
+            if ($genConfig['sequential_image_generation'] !== 'disabled') {
+                $requestBody['sequential_image_generation_options'] = [
+                    'max_images' => $genConfig['max_images'],
+                ];
+            }
+            if ($referenceCount > 0) {
+                $requestBody['image'] = $referenceCount === 1 ? $referenceImages[0] : $referenceImages;
+            }
+
+            $taskPayload = [
+                'prompt' => $prompt,
+                'model' => $model,
+                'size' => $size,
+                'watermark' => $watermark,
+                'stream' => $stream,
+                'reference_images' => $referenceImages,
+                'gen_config' => $genConfig,
+                'request_body' => $requestBody,
+            ];
+
+            if ($genConfig['max_images'] > 1) {
+                return $this->submitBatchTask($taskPayload, $genConfig, $referenceImages, $referenceCount);
+            }
+
+            $payload = SeedreamImageGenerationJob::execute($taskPayload);
+            $dbError = (string)($payload['db_save_error'] ?? '');
+            unset($payload['db_save_error']);
+
+            return $this->jsonGenerateSuccess($payload, $dbError);
+        } catch (\Exception $e) {
+            $this->logError('[ImageGeneration/generate] ' . $e->getMessage());
+            return json(['code' => 0, 'msg' => $e->getMessage()]);
+        }
+    }
+
+    /**
+     * 查询组图异步任务状态
+     * GET/POST /api/image_generation/getStatus
+     * 参数:task_id
+     */
+    public function getStatus()
+    {
+        $taskId = trim((string)$this->request->param('task_id', ''));
+        if ($taskId === '') {
+            return json(['code' => 0, 'msg' => 'task_id 不能为空']);
+        }
+
+        $redis = getTaskRedis();
+        $raw = $redis->get(SeedreamImageGenerationJob::REDIS_KEY_PREFIX . $taskId);
+        if (!$raw) {
+            return json(['code' => 0, 'msg' => '任务不存在或已过期']);
+        }
+
+        $info = json_decode($raw, true);
+        if (!is_array($info)) {
+            return json(['code' => 0, 'msg' => '任务数据异常']);
+        }
+
+        return json([
+            'code' => 1,
+            'msg' => '查询成功',
+            'data' => $info,
+        ]);
+    }
+
+    /**
+     * 组图任务提交到独立队列 seedreamimage
+     */
+    private function submitBatchTask(
+        array $taskPayload,
+        array $genConfig,
+        array $referenceImages,
+        int $referenceCount
+    ): \think\response\Json {
+        $taskId = 'sd' . date('YmdHis') . mt_rand(1000, 9999);
+        $taskPayload['task_id'] = $taskId;
+        $taskPayload['stream'] = false;
+
+        $redis = getTaskRedis();
+        $redis->set(
+            SeedreamImageGenerationJob::REDIS_KEY_PREFIX . $taskId,
+            json_encode([
+                'status' => 'pending',
+                'task_id' => $taskId,
+                'mode' => $genConfig['mode'],
+                'reference_count' => $referenceCount,
+                'reference_images' => $referenceImages,
+                'max_images' => $genConfig['max_images'],
+                'created_at' => date('Y-m-d H:i:s'),
+            ], JSON_UNESCAPED_UNICODE),
+            ['EX' => SeedreamImageGenerationJob::TASK_TTL]
+        );
+
+        Queue::push('app\job\SeedreamImageGenerationJob', $taskPayload, SeedreamImageGenerationJob::QUEUE_NAME);
+
+        return json([
+            'code' => 1,
+            'msg' => '组图任务已提交,请稍后查询结果',
+            'data' => [
+                'async' => true,
+                'task_id' => $taskId,
+                'status' => 'pending',
+                'mode' => $genConfig['mode'],
+                'reference_count' => $referenceCount,
+                'reference_images' => $referenceImages,
+                'max_images' => $genConfig['max_images'],
+                'poll_url' => '/api/image_generation/getStatus',
+            ],
+        ]);
+    }
+
+    /**
+     * AI 成功但数据库失败时,仍返回生成结果并附带警告
+     */
+    private function jsonGenerateSuccess(array $payload, string $dbError = ''): \think\response\Json
+    {
+        if ($dbError !== '') {
+            $payload['db_save_error'] = $dbError;
+        }
+        return json([
+            'code' => 1,
+            'msg' => $dbError !== '' ? '生成成功,但数据库保存失败' : '生成成功',
+            'data' => $payload,
+        ]);
+    }
+
+    /**
+     * 根据前端参数构建组图配置
+     */
+    private function buildGenerationConfig(array $params, int $referenceCount): array
+    {
+        $maxImages = (int)($params['max_images'] ?? $params['generate_count'] ?? $params['output_count'] ?? 1);
+        $maxImages = max(1, min(15, $maxImages));
+
+        $sequentialMode = trim((string)($params['sequential_image_generation'] ?? ''));
+        if ($sequentialMode === '') {
+            $sequentialMode = $maxImages > 1 ? 'auto' : 'disabled';
+        }
+
+        return [
+            'max_images' => $maxImages,
+            'sequential_image_generation' => $sequentialMode,
+            'mode' => $this->detectGenerationMode($referenceCount, $maxImages),
+        ];
+    }
+
+    /**
+     * 判定当前生成模式
+     */
+    private function detectGenerationMode(int $referenceCount, int $maxImages): string
+    {
+        if ($referenceCount === 0) {
+            return $maxImages > 1 ? 'text_to_images' : 'text_to_image';
+        }
+        if ($referenceCount === 1) {
+            return $maxImages > 1 ? 'image_to_images' : 'image_to_image';
+        }
+        return $maxImages > 1 ? 'images_to_images' : 'images_to_image';
+    }
+
+    /**
+     * 解析参考图列表:上传文件/base64 先落 OSS,再以公网 URL 传给 AI
+     * @return string[]
+     */
+    private function resolveReferenceImages(array $params): array
+    {
+        $resolved = [];
+
+        foreach (['image', 'images'] as $field) {
+            $uploaded = $this->request->file($field);
+            if (empty($uploaded)) {
+                continue;
+            }
+            $fileList = is_array($uploaded) ? $uploaded : [$uploaded];
+            foreach ($fileList as $file) {
+                if (!$file) {
+                    continue;
+                }
+                $ossUrl = $this->uploadUploadedFileToOss($file);
+                if ($ossUrl !== '') {
+                    $resolved[] = $ossUrl;
+                }
+            }
+        }
+
+        $rawList = [];
+        foreach (['images', 'reference_images', 'image_urls'] as $key) {
+            if (empty($params[$key]) || !is_array($params[$key])) {
+                continue;
+            }
+            foreach ($params[$key] as $item) {
+                if ($item !== null && $item !== '') {
+                    $rawList[] = $item;
+                }
+            }
+        }
+        foreach (['image', 'image_url'] as $key) {
+            if (empty($params[$key])) {
+                continue;
+            }
+            if (is_array($params[$key])) {
+                foreach ($params[$key] as $item) {
+                    if ($item !== null && $item !== '') {
+                        $rawList[] = $item;
+                    }
+                }
+            } else {
+                $rawList[] = $params[$key];
+            }
+        }
+
+        foreach ($rawList as $raw) {
+            $item = $this->resolveSingleReferenceImage((string)$raw);
+            if ($item !== '') {
+                $resolved[] = $item;
+            }
+        }
+
+        return array_slice($resolved, 0, 14);
+    }
+
+    /**
+     * 解析单张参考图(URL/路径直接使用;base64 先上传 OSS)
+     */
+    private function resolveSingleReferenceImage(string $raw): string
+    {
+        $raw = trim($raw);
+        if ($raw === '') {
+            return '';
+        }
+
+        if (stripos($raw, 'data:image/') === 0) {
+            return $this->uploadBase64ImageToOss($raw);
+        }
+
+        if (stripos($raw, 'http://') === 0 || stripos($raw, 'https://') === 0) {
+            return $raw;
+        }
+
+        if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', $raw) && strlen($raw) > 100) {
+            $ossUrl = $this->uploadBase64ImageToOss($raw);
+            if ($ossUrl !== '') {
+                return $ossUrl;
+            }
+        }
+
+        $normalizedPath = ltrim(str_replace('\\', '/', $raw), '/');
+        $publicUrl = $this->buildOssPublicUrl($normalizedPath);
+        if (stripos($publicUrl, 'http://') === 0 || stripos($publicUrl, 'https://') === 0) {
+            return $publicUrl;
+        }
+
+        $localPath = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalizedPath);
+        if (is_file($localPath)) {
+            return $this->uploadLocalFileToOss($localPath, $normalizedPath);
+        }
+
+        try {
+            $loaded = $this->readImageBinary($raw);
+            $dataUrl = 'data:' . $loaded['mimeType'] . ';base64,' . $loaded['base64Data'];
+            return $this->uploadBase64ImageToOss($dataUrl);
+        } catch (\Exception $e) {
+            $this->logWarning('[ImageGeneration] 读取参考图失败: ' . $e->getMessage());
+            return '';
+        }
+    }
+
+    /**
+     * form-data 上传文件:落盘 → OSS → 返回公网 URL
+     */
+    private function uploadUploadedFileToOss($file): string
+    {
+        if (!$this->isOssEnabled()) {
+            throw new \Exception('OSS 未配置,无法上传参考图');
+        }
+
+        $ext = $this->resolveUploadedImageExt($file);
+        if ($ext === '') {
+            throw new \Exception('不支持的图片格式');
+        }
+
+        $dateDir = date('Ymd');
+        $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR;
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        $fileName = 'ref_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
+        $localFullPath = $saveDir . $fileName;
+        $saved = false;
+
+        if (method_exists($file, 'isValid') && $file->isValid()) {
+            $info = $file->move($saveDir, $fileName);
+            if ($info) {
+                $localFullPath = $saveDir . $info->getFilename();
+                $saved = true;
+            }
+        }
+        if (!$saved) {
+            $uploadInfo = $file->getInfo();
+            $tmpPath = isset($uploadInfo['tmp_name']) ? (string)$uploadInfo['tmp_name'] : '';
+            if ($tmpPath === '' || !is_file($tmpPath)) {
+                $tmpPath = (string)($file->getRealPath() ?: '');
+            }
+            if ($tmpPath === '' || !is_file($tmpPath) || !@copy($tmpPath, $localFullPath)) {
+                throw new \Exception('参考图保存失败');
+            }
+        }
+
+        $objectKey = 'uploads/imagegen/' . $dateDir . '/' . basename($localFullPath);
+        return $this->uploadLocalFileToOss($localFullPath, $objectKey);
+    }
+
+    /**
+     * base64 图片:落盘 → OSS → 返回公网 URL
+     */
+    private function uploadBase64ImageToOss(string $base64Input): string
+    {
+        if (!$this->isOssEnabled()) {
+            throw new \Exception('OSS 未配置,无法上传参考图');
+        }
+
+        $parsed = $this->parseBase64Image($base64Input);
+        if ($parsed === null) {
+            return '';
+        }
+
+        [$ext, $imageData] = $parsed;
+        $dateDir = date('Ymd');
+        $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR;
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        $fileName = 'ref_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
+        $localFullPath = $saveDir . $fileName;
+        if (file_put_contents($localFullPath, $imageData) === false) {
+            throw new \Exception('参考图本地保存失败');
+        }
+
+        $objectKey = 'uploads/imagegen/' . $dateDir . '/' . $fileName;
+        return $this->uploadLocalFileToOss($localFullPath, $objectKey);
+    }
+
+    /**
+     * 本地文件上传 OSS 并返回公网 URL
+     */
+    private function uploadLocalFileToOss(string $localFullPath, string $objectKey): string
+    {
+        $objectKey = $this->normalizeOssObjectKey($objectKey);
+        if (!$this->putFileToOss($localFullPath, $objectKey)) {
+            throw new \Exception('参考图上传 OSS 失败');
+        }
+
+        $url = $this->buildOssPublicUrl($objectKey);
+        if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
+            throw new \Exception('无法获取参考图 OSS 访问地址,请检查 oss.host 配置');
+        }
+
+        return $url;
+    }
+
+    /**
+     * 解析 base64 图片
+     * @return array{0:string,1:string}|null [扩展名, 二进制内容]
+     */
+    private function parseBase64Image(string $base64Input): ?array
+    {
+        $base64Input = trim($base64Input);
+        if ($base64Input === '') {
+            return null;
+        }
+
+        $ext = 'jpg';
+        $rawBase64 = $base64Input;
+        $prefix = 'data:image/';
+        if (stripos($base64Input, $prefix) === 0) {
+            $semi = stripos($base64Input, ';base64,');
+            if ($semi === false) {
+                return null;
+            }
+            $mimePart = strtolower(substr($base64Input, strlen($prefix), $semi - strlen($prefix)));
+            if ($mimePart === 'jpeg') {
+                $mimePart = 'jpg';
+            }
+            if (in_array($mimePart, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) {
+                $ext = $mimePart;
+            }
+            $rawBase64 = substr($base64Input, $semi + 8);
+        }
+
+        $rawBase64 = preg_replace('/\s+/', '', $rawBase64);
+        if ($rawBase64 === '') {
+            return null;
+        }
+
+        $imageData = base64_decode($rawBase64, true);
+        if ($imageData === false || strlen($imageData) < 100) {
+            return null;
+        }
+
+        return [$ext, $imageData];
+    }
+
+    /**
+     * 解析上传图片扩展名
+     */
+    private function resolveUploadedImageExt($file): string
+    {
+        $name = $file->getInfo('name') ?? '';
+        $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
+        if ($ext === 'jpeg') {
+            $ext = 'jpg';
+        }
+        $allowed = ['jpg', 'png', 'gif', 'webp', 'bmp'];
+        if ($ext && in_array($ext, $allowed, true)) {
+            return $ext;
+        }
+
+        $mime = method_exists($file, 'getMime') ? strtolower((string)$file->getMime()) : '';
+        $map = [
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/gif' => 'gif',
+            'image/webp' => 'webp',
+            'image/bmp' => 'bmp',
+        ];
+
+        return $map[$mime] ?? '';
+    }
+
+    /**
+     * 读取图片二进制(本地路径 / 完整 URL / OSS 相对路径)
+     * @return array{base64Data:string,mimeType:string}
+     */
+    private function readImageBinary(string $imageUrl): array
+    {
+        $imageUrl = trim($imageUrl);
+        if ($imageUrl === '') {
+            throw new \Exception('图片路径不能为空');
+        }
+
+        $rootPath = str_replace('\\', '/', ROOT_PATH);
+        $relativePath = ltrim($imageUrl, '/');
+        $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath;
+        $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath);
+
+        $imageContent = false;
+        $mimeType = '';
+
+        if (preg_match('/^https?:\/\//i', $imageUrl)) {
+            $imageContent = @file_get_contents($imageUrl);
+        } elseif (file_exists($localPath)) {
+            $imageContent = @file_get_contents($localPath);
+            if ($imageContent !== false) {
+                $finfo = finfo_open(FILEINFO_MIME_TYPE);
+                $mimeType = finfo_file($finfo, $localPath);
+                finfo_close($finfo);
+            }
+        } elseif (file_exists($localPathDecoded)) {
+            $imageContent = @file_get_contents($localPathDecoded);
+            if ($imageContent !== false) {
+                $finfo = finfo_open(FILEINFO_MIME_TYPE);
+                $mimeType = finfo_file($finfo, $localPathDecoded);
+                finfo_close($finfo);
+            }
+        } else {
+            $ossConfig = $this->getOssConfig();
+            $ossHost = trim((string)($ossConfig['host'] ?? ''));
+            if ($ossHost !== '') {
+                if (stripos($ossHost, 'http://') !== 0 && stripos($ossHost, 'https://') !== 0) {
+                    $ossHost = 'https://' . $ossHost;
+                }
+                $remoteUrl = rtrim($ossHost, '/') . '/' . ltrim($relativePath, '/');
+                $imageContent = @file_get_contents($remoteUrl);
+            }
+        }
+
+        if ($imageContent === false || $imageContent === '') {
+            throw new \Exception('图片内容读取失败(本地/OSS均未读取到)');
+        }
+
+        if ($mimeType === '') {
+            $finfo = finfo_open(FILEINFO_MIME_TYPE);
+            $mimeType = finfo_buffer($finfo, $imageContent);
+            finfo_close($finfo);
+        }
+        if (!$mimeType) {
+            $mimeType = 'image/png';
+        }
+
+        return [
+            'base64Data' => base64_encode($imageContent),
+            'mimeType' => $mimeType,
+        ];
+    }
+
+    /**
+     * 获取 OSS 配置
+     */
+    private function getOssConfig(): array
+    {
+        $config = Config::get('oss');
+        return is_array($config) ? $config : [];
+    }
+
+    /**
+     * OSS 是否可用
+     */
+    private function isOssEnabled(): bool
+    {
+        $config = $this->getOssConfig();
+        return !empty($config['accessKeyId'])
+            && !empty($config['accessKeySecret'])
+            && !empty($config['endpoint'])
+            && !empty($config['bucket']);
+    }
+
+    /**
+     * 归一化 OSS 对象键
+     */
+    private function normalizeOssObjectKey(string $objectKey): string
+    {
+        return ltrim(str_replace('\\', '/', trim($objectKey)), '/');
+    }
+
+    /**
+     * 上传本地文件到 OSS
+     */
+    private function putFileToOss(string $localFullPath, string $objectKey): bool
+    {
+        if (!$this->isOssEnabled() || !is_file($localFullPath)) {
+            return false;
+        }
+
+        $config = $this->getOssConfig();
+        $objectKey = $this->normalizeOssObjectKey($objectKey);
+        if ($objectKey === '') {
+            return false;
+        }
+
+        try {
+            $ossClient = new OssClient(
+                $config['accessKeyId'],
+                $config['accessKeySecret'],
+                $config['endpoint']
+            );
+            $ossClient->uploadFile($config['bucket'], $objectKey, $localFullPath);
+            return true;
+        } catch (\Throwable $e) {
+            $this->logError('[ImageGeneration/OSS] ' . $e->getMessage() . ' | objectKey=' . $objectKey);
+            return false;
+        }
+    }
+
+    /**
+     * 相对路径转 OSS 公网 URL
+     */
+    private function buildOssPublicUrl(string $path): string
+    {
+        $path = trim($path);
+        if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) {
+            return $path;
+        }
+
+        $config = $this->getOssConfig();
+        $host = trim((string)($config['host'] ?? ''));
+        if ($host === '') {
+            return $path;
+        }
+        if (stripos($host, 'http://') !== 0 && stripos($host, 'https://') !== 0) {
+            $host = 'https://' . $host;
+        }
+
+        return rtrim($host, '/') . '/' . ltrim($path, '/');
+    }
+
+    private function logError(string $message): void
+    {
+        Log::write($message, 'error');
+    }
+
+    private function logWarning(string $message): void
+    {
+        Log::write($message, 'warning');
+    }
+
+    /**
+     * 合并 JSON Body 与 form 参数
+     */
+    private function mergeRequestParams(): array
+    {
+        $params = $this->request->param();
+        if (!empty($params['prompt']) || !empty($params['image']) || !empty($params['images'])) {
+            return $params;
+        }
+
+        $content = $this->request->getInput();
+        if (!is_string($content) || $content === '') {
+            return $params;
+        }
+
+        $json = json_decode($content, true);
+        if (!is_array($json)) {
+            return $params;
+        }
+
+        return array_merge($params, $json);
+    }
+}

+ 2 - 1
application/extra/queue.php

@@ -1,8 +1,9 @@
 <?php
 return [
     'connector'  => 'Redis',          // Redis 驱动
-    'expire'     => 300,          // 任务 reserve 超时秒数(需>=图生图耗时);0会导致任务立即被重放回队列造成重复执行
+    'expire'     => 900,          // 任务 reserve 超时秒数;组图生成较久,需>=900
     'default'    => 'default',    // 默认的队列名称
+    'seedream_queue' => 'seedreamimage', // Seedream 组图专用队列(与 txttoimg/imgtoimg 分离)
     'host'       => '127.0.0.1',       // redis 主机ip
     'port'       => 6379,        // redis 端口
     'password'   => '123456',             // redis 密码(改密码时统一在此修改)

+ 656 - 0
application/job/SeedreamImageGenerationJob.php

@@ -0,0 +1,656 @@
+<?php
+
+namespace app\job;
+
+use OSS\OssClient;
+use think\Config;
+use think\Db;
+use think\Log;
+use think\queue\Job;
+
+/**
+ * 豆包 Seedream 组图生成队列(独立于 txttoimg / imgtoimg / arrimage)
+ *
+ * 启动消费者:
+ * php think queue:work --queue seedreamimage --timeout 900 --sleep 3 --tries 1
+ */
+class SeedreamImageGenerationJob
+{
+    const DEFAULT_MODEL = 'doubao-seedream-5-0-260128';
+    const DEFAULT_API_URL = 'https://ark.cn-beijing.volces.com/api/v3/images/generations';
+    const REDIS_KEY_PREFIX = 'seedream_image_task:';
+    const QUEUE_NAME = 'seedreamimage';
+    const TASK_TTL = 1800;
+
+    /**
+     * 队列入口
+     */
+    public function fire(Job $job, $data)
+    {
+        $taskId = trim((string)($data['task_id'] ?? ''));
+        if ($taskId === '') {
+            $job->delete();
+            return;
+        }
+
+        $redisKey = self::REDIS_KEY_PREFIX . $taskId;
+        $redis = getTaskRedis();
+
+        echo "\n" . date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 开始 task_id={$taskId}\n";
+
+        try {
+            $existing = $redis->get($redisKey);
+            if ($existing) {
+                $info = json_decode($existing, true);
+                if (is_array($info) && ($info['status'] ?? '') === 'completed') {
+                    echo "任务 {$taskId} 已完成,跳过\n";
+                    $job->delete();
+                    return;
+                }
+            }
+
+            $redis->set($redisKey, json_encode([
+                'status' => 'processing',
+                'task_id' => $taskId,
+                'started_at' => date('Y-m-d H:i:s'),
+            ], JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]);
+
+            set_time_limit(900);
+            ini_set('max_execution_time', '900');
+
+            $payload = self::execute(is_array($data) ? $data : []);
+
+            $redis->set($redisKey, json_encode(array_merge($payload, [
+                'status' => 'completed',
+                'task_id' => $taskId,
+                'completed_at' => date('Y-m-d H:i:s'),
+            ]), JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]);
+
+            echo date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 完成 task_id={$taskId}\n";
+            $job->delete();
+        } catch (\Exception $e) {
+            echo date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 失败: " . $e->getMessage() . "\n";
+            Log::write('[SeedreamImageGenerationJob] ' . $e->getMessage(), 'error');
+
+            $redis->set($redisKey, json_encode([
+                'status' => 'failed',
+                'task_id' => $taskId,
+                'error' => $e->getMessage(),
+                'completed_at' => date('Y-m-d H:i:s'),
+            ], JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]);
+
+            $job->delete();
+        }
+    }
+
+    /**
+     * 执行 Seedream 生图(组图/单图均可)
+     */
+    public static function execute(array $data): array
+    {
+        $prompt = trim((string)($data['prompt'] ?? ''));
+        $model = trim((string)($data['model'] ?? self::DEFAULT_MODEL));
+        $size = trim((string)($data['size'] ?? '2K'));
+        $stream = filter_var($data['stream'] ?? false, FILTER_VALIDATE_BOOLEAN);
+        $referenceImages = is_array($data['reference_images'] ?? null) ? $data['reference_images'] : [];
+        $genConfig = is_array($data['gen_config'] ?? null) ? $data['gen_config'] : [];
+
+        $requestBody = is_array($data['request_body'] ?? null) ? $data['request_body'] : [];
+        if (empty($requestBody)) {
+            $referenceCount = count($referenceImages);
+            $maxImages = max(1, min(15, (int)($genConfig['max_images'] ?? 1)));
+            $sequentialMode = trim((string)($genConfig['sequential_image_generation'] ?? ''));
+            if ($sequentialMode === '') {
+                $sequentialMode = $maxImages > 1 ? 'auto' : 'disabled';
+            }
+
+            $requestBody = [
+                'model' => $model,
+                'prompt' => $prompt,
+                'sequential_image_generation' => $sequentialMode,
+                'response_format' => 'url',
+                'size' => $size,
+                'stream' => $stream,
+                'watermark' => filter_var($data['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN),
+            ];
+            if ($sequentialMode !== 'disabled') {
+                $requestBody['sequential_image_generation_options'] = [
+                    'max_images' => $maxImages,
+                ];
+            }
+            if ($referenceCount > 0) {
+                $requestBody['image'] = $referenceCount === 1 ? $referenceImages[0] : $referenceImages;
+            }
+
+            $genConfig['max_images'] = $maxImages;
+            $genConfig['sequential_image_generation'] = $sequentialMode;
+            $genConfig['mode'] = self::detectGenerationMode($referenceCount, $maxImages);
+        }
+
+        $result = self::callSeedreamApi($requestBody, $model, $stream);
+        $images = self::normalizeImageResult($result);
+        $imgId = self::extractApiImageId($result);
+        $taskId = trim((string)($data['task_id'] ?? ''));
+
+        $stored = self::persistGeneratedImagesToOss($images, $imgId, $referenceImages, $taskId);
+        $images = $stored['images'];
+        $generateImgUrl = implode(',', $stored['urls']);
+
+        $payload = [
+            'img_id' => $imgId,
+            'mode' => $genConfig['mode'] ?? '',
+            'reference_count' => count($referenceImages),
+            'reference_images' => $referenceImages,
+            'generate_images' => $stored['urls'],
+            'max_images' => (int)($genConfig['max_images'] ?? count($images)),
+            'images' => $images,
+            'model' => $result['model'] ?? $model,
+            'usage' => $result['usage'] ?? null,
+            'created' => $result['created'] ?? null,
+        ];
+
+        try {
+            $payload['storage_id'] = self::savePictureStorage([
+                'img_id' => $imgId,
+                'prompt' => $prompt,
+                'reference_img_url' => implode(',', $referenceImages),
+                'generate_img_url' => $generateImgUrl,
+                'model' => $result['model'] ?? $model,
+                'img_size' => $size,
+            ]);
+        } catch (\Exception $e) {
+            $payload['db_save_error'] = $e->getMessage();
+            Log::write('[SeedreamImageGenerationJob] 数据库保存失败: ' . $e->getMessage(), 'error');
+        }
+
+        return $payload;
+    }
+
+    private static function detectGenerationMode(int $referenceCount, int $maxImages): string
+    {
+        if ($referenceCount === 0) {
+            return $maxImages > 1 ? 'text_to_images' : 'text_to_image';
+        }
+        if ($referenceCount === 1) {
+            return $maxImages > 1 ? 'image_to_images' : 'image_to_image';
+        }
+        return $maxImages > 1 ? 'images_to_images' : 'images_to_image';
+    }
+
+    private static function callSeedreamApi(array $requestBody, string $model, bool $stream = false): array
+    {
+        $configs = self::resolveModelApiConfigs($model);
+        if (empty($configs)) {
+            throw new \Exception("模型配置不存在: {$model}");
+        }
+
+        $postData = json_encode($requestBody, JSON_UNESCAPED_UNICODE);
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            throw new \Exception('请求数据 JSON 编码失败: ' . json_last_error_msg());
+        }
+
+        $errors = [];
+        foreach ($configs as $index => $config) {
+            $apiUrl = self::sanitizeApiUrl((string)($config['api_url'] ?? ''));
+            $apiKey = trim((string)($config['api_key'] ?? ''));
+            if ($apiUrl === '' || $apiKey === '') {
+                $errors[] = '第' . ($index + 1) . '个接口配置无效(URL/Key为空)';
+                continue;
+            }
+
+            $headers = [
+                'Content-Type: application/json',
+                'Authorization: Bearer ' . $apiKey,
+            ];
+            if ($stream) {
+                $headers[] = 'Accept: text/event-stream';
+            }
+
+            $ch = curl_init();
+            curl_setopt_array($ch, [
+                CURLOPT_URL => $apiUrl,
+                CURLOPT_POST => true,
+                CURLOPT_POSTFIELDS => $postData,
+                CURLOPT_HTTPHEADER => $headers,
+                CURLOPT_RETURNTRANSFER => true,
+                CURLOPT_TIMEOUT => 900,
+                CURLOPT_CONNECTTIMEOUT => 30,
+                CURLOPT_SSL_VERIFYPEER => false,
+                CURLOPT_SSL_VERIFYHOST => 0,
+            ]);
+
+            $response = curl_exec($ch);
+            $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+            $curlErrno = curl_errno($ch);
+            $curlError = curl_error($ch);
+            curl_close($ch);
+
+            if ($response === false) {
+                $errors[] = '第' . ($index + 1) . '个接口请求失败 [curl:' . $curlErrno . '] '
+                    . ($curlError !== '' ? $curlError : '请求超时或网络异常');
+                continue;
+            }
+
+            if ($httpCode !== 200) {
+                $decoded = json_decode($response, true);
+                $msg = is_array($decoded)
+                    ? ($decoded['error']['message'] ?? json_encode($decoded, JSON_UNESCAPED_UNICODE))
+                    : self::truncateText((string)$response, 300);
+                $errors[] = '第' . ($index + 1) . '个接口 HTTP ' . $httpCode . ': ' . $msg;
+                continue;
+            }
+
+            try {
+                return $stream ? self::parseStreamResponse($response) : self::parseJsonResponse($response);
+            } catch (\Exception $e) {
+                $errors[] = '第' . ($index + 1) . '个接口响应解析失败: ' . $e->getMessage();
+            }
+        }
+
+        throw new \Exception(($stream ? '流式' : '') . '生成失败: ' . implode('; ', $errors));
+    }
+
+    private static function resolveModelApiConfigs(string $model): array
+    {
+        $rows = Db::name('ai_model')
+            ->where('model_name', $model)
+            ->where('status', '1')
+            ->order('sort ASC')
+            ->select();
+
+        $configs = [];
+        $seenUrls = [];
+        $fallbackKey = '';
+
+        foreach ($rows as $row) {
+            $apiUrl = self::sanitizeApiUrl((string)($row['api_url'] ?? ''));
+            $apiKey = trim((string)($row['api_key'] ?? ''));
+            if ($apiKey !== '' && $fallbackKey === '') {
+                $fallbackKey = $apiKey;
+            }
+            if ($apiUrl === '' || $apiKey === '') {
+                continue;
+            }
+            $urlKey = strtolower(rtrim($apiUrl, '/'));
+            if (isset($seenUrls[$urlKey])) {
+                continue;
+            }
+            $seenUrls[$urlKey] = true;
+            $configs[] = ['api_url' => $apiUrl, 'api_key' => $apiKey];
+        }
+
+        $defaultUrl = rtrim(self::DEFAULT_API_URL, '/');
+        if ($fallbackKey !== '' && !isset($seenUrls[strtolower($defaultUrl)])) {
+            $configs[] = ['api_url' => self::DEFAULT_API_URL, 'api_key' => $fallbackKey];
+        }
+
+        return $configs;
+    }
+
+    private static function sanitizeApiUrl(string $url): string
+    {
+        $url = trim(str_replace(["\r\n", "\r", "\n", "\t"], ' ', $url));
+        if ($url === '') {
+            return '';
+        }
+        foreach (preg_split('/\s+/', $url) as $part) {
+            $part = trim($part);
+            if ($part !== '' && preg_match('/^https?:\/\//i', $part)) {
+                return $part;
+            }
+        }
+        return '';
+    }
+
+    private static function parseJsonResponse(string $raw): array
+    {
+        $result = json_decode($raw, true);
+        if (!is_array($result)) {
+            throw new \Exception('API 响应解析失败');
+        }
+        if (isset($result['error'])) {
+            throw new \Exception($result['error']['message'] ?? 'API 返回错误');
+        }
+        if (empty($result['data']) || !is_array($result['data'])) {
+            throw new \Exception('未解析到有效图片结果');
+        }
+        return $result;
+    }
+
+    private static function parseStreamResponse(string $raw): array
+    {
+        $images = [];
+        $usage = null;
+        $model = '';
+        $created = null;
+        $apiId = '';
+
+        foreach (preg_split('/\r?\n/', $raw) as $line) {
+            $line = trim($line);
+            if ($line === '' || $line === 'data: [DONE]' || stripos($line, 'data:') !== 0) {
+                continue;
+            }
+            $event = json_decode(trim(substr($line, 5)), true);
+            if (!is_array($event)) {
+                continue;
+            }
+            if (isset($event['error'])) {
+                throw new \Exception($event['error']['message'] ?? 'API 返回错误');
+            }
+            if (!empty($event['model'])) {
+                $model = (string)$event['model'];
+            }
+            if (!empty($event['id'])) {
+                $apiId = (string)$event['id'];
+            }
+            if (isset($event['created'])) {
+                $created = $event['created'];
+            }
+            if (isset($event['usage'])) {
+                $usage = $event['usage'];
+            }
+            $type = (string)($event['type'] ?? '');
+            if ($type === 'image_generation.partial_succeeded' && !empty($event['url'])) {
+                $images[] = [
+                    'url' => $event['url'],
+                    'size' => $event['size'] ?? '',
+                    'image_index' => $event['image_index'] ?? count($images),
+                ];
+            }
+            if ($type === 'image_generation.completed' && !empty($event['data']) && is_array($event['data'])) {
+                foreach ($event['data'] as $item) {
+                    if (!empty($item['url'])) {
+                        $images[] = $item;
+                    }
+                }
+            }
+        }
+
+        if (empty($images)) {
+            $decoded = json_decode($raw, true);
+            if (is_array($decoded) && !empty($decoded['data'])) {
+                return $decoded;
+            }
+            throw new \Exception('未解析到有效图片结果');
+        }
+
+        return [
+            'id' => $apiId,
+            'model' => $model,
+            'created' => $created,
+            'data' => $images,
+            'usage' => $usage,
+        ];
+    }
+
+    private static function extractApiImageId(array $result): string
+    {
+        foreach (['id', 'request_id', 'task_id'] as $key) {
+            if (!empty($result[$key])) {
+                return substr((string)$result[$key], 0, 50);
+            }
+        }
+        return substr(str_replace('.', '', uniqid('img', true)), 0, 50);
+    }
+
+    private static function savePictureStorage(array $data): int
+    {
+        $insert = [
+            'img_id' => substr((string)($data['img_id'] ?? ''), 0, 50),
+            'prompt' => (string)($data['prompt'] ?? ''),
+            'reference_img_url' => (string)($data['reference_img_url'] ?? ''),
+            'generate_img_url' => (string)($data['generate_img_url'] ?? ''),
+            'model' => substr((string)($data['model'] ?? ''), 0, 255),
+            'img_size' => substr((string)($data['img_size'] ?? ''), 0, 255),
+            'sys_rq' => date('Y-m-d H:i:s'),
+        ];
+        if ($insert['img_id'] === '' || $insert['model'] === '') {
+            throw new \Exception('picture_storage 入库参数不完整');
+        }
+        $storageId = Db::name('picture_storage')->insertGetId($insert);
+        if (!$storageId) {
+            throw new \Exception('picture_storage 入库失败');
+        }
+        return (int)$storageId;
+    }
+
+    private static function normalizeImageResult(array $result): array
+    {
+        $items = $result['data'] ?? [];
+        if (!is_array($items)) {
+            return [];
+        }
+        $images = [];
+        foreach ($items as $item) {
+            if (!is_array($item) || empty($item['url'])) {
+                continue;
+            }
+            $images[] = [
+                'url' => $item['url'],
+                'size' => $item['size'] ?? '',
+                'image_index' => $item['image_index'] ?? null,
+            ];
+        }
+        return $images;
+    }
+
+    /**
+     * 生成图落盘并上传 OSS(目录/命名规则与参考图一致:uploads/imagegen/{日期}/)
+     * @return array{urls:string[],images:array}
+     */
+    private static function persistGeneratedImagesToOss(
+        array $images,
+        string $imgId,
+        array $referenceImages,
+        string $taskId = ''
+    ): array {
+        if (empty($images)) {
+            throw new \Exception('没有可保存的生成图');
+        }
+        if (!self::isOssEnabled()) {
+            throw new \Exception('OSS 未配置,无法保存生成图');
+        }
+
+        $defaultExt = self::resolveOutputExtFromReference($referenceImages);
+        $dateDir = date('Ymd');
+        $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR
+            . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR;
+        if (!is_dir($saveDir)) {
+            mkdir($saveDir, 0755, true);
+        }
+
+        $safeId = preg_replace('/[^a-zA-Z0-9_]/', '_', $imgId);
+        $safeTask = $taskId !== '' ? preg_replace('/[^a-zA-Z0-9_]/', '_', $taskId) : '';
+        $ossUrls = [];
+        $storedImages = [];
+
+        foreach ($images as $index => $image) {
+            $sourceUrl = trim((string)($image['url'] ?? ''));
+            if ($sourceUrl === '') {
+                continue;
+            }
+
+            $binary = self::downloadRemoteImage($sourceUrl);
+            $ext = self::detectImageExt($binary, $defaultExt);
+            $suffix = $safeTask !== '' ? $safeTask . '_' . $index : $safeId . '_' . $index;
+            $fileName = 'gen_' . $suffix . '.' . $ext;
+            $localFullPath = $saveDir . $fileName;
+
+            if (file_put_contents($localFullPath, $binary) === false) {
+                throw new \Exception('生成图本地保存失败');
+            }
+
+            $objectKey = 'uploads/imagegen/' . $dateDir . '/' . $fileName;
+            if (!self::putFileToOss($localFullPath, $objectKey)) {
+                throw new \Exception('生成图上传 OSS 失败: ' . $objectKey);
+            }
+
+            $ossUrl = self::buildOssPublicUrl($objectKey);
+            if (stripos($ossUrl, 'http://') !== 0 && stripos($ossUrl, 'https://') !== 0) {
+                throw new \Exception('无法获取生成图 OSS 访问地址');
+            }
+
+            $ossUrls[] = $ossUrl;
+            $storedImages[] = array_merge($image, [
+                'url' => $ossUrl,
+                'oss_url' => $ossUrl,
+                'object_key' => $objectKey,
+                'source_url' => $sourceUrl,
+            ]);
+        }
+
+        if (empty($ossUrls)) {
+            throw new \Exception('生成图 OSS 保存结果为空');
+        }
+
+        return ['urls' => $ossUrls, 'images' => $storedImages];
+    }
+
+    /**
+     * 从参考图推断输出扩展名(无参考图时默认 jpg)
+     */
+    private static function resolveOutputExtFromReference(array $referenceImages): string
+    {
+        foreach ($referenceImages as $url) {
+            $url = trim((string)$url);
+            if ($url === '') {
+                continue;
+            }
+            $path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
+            $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+            if ($ext === 'jpeg') {
+                $ext = 'jpg';
+            }
+            if (in_array($ext, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) {
+                return $ext;
+            }
+        }
+        return 'jpg';
+    }
+
+    private static function detectImageExt(string $binary, string $fallback = 'jpg'): string
+    {
+        $finfo = finfo_open(FILEINFO_MIME_TYPE);
+        $mime = finfo_buffer($finfo, $binary);
+        finfo_close($finfo);
+
+        $map = [
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/gif' => 'gif',
+            'image/webp' => 'webp',
+            'image/bmp' => 'bmp',
+        ];
+
+        if ($mime && isset($map[$mime])) {
+            return $map[$mime];
+        }
+
+        $fallback = strtolower($fallback);
+        return in_array($fallback, ['jpg', 'png', 'gif', 'webp', 'bmp'], true) ? $fallback : 'jpg';
+    }
+
+    private static function downloadRemoteImage(string $url): string
+    {
+        $ch = curl_init();
+        curl_setopt_array($ch, [
+            CURLOPT_URL => $url,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_FOLLOWLOCATION => true,
+            CURLOPT_TIMEOUT => 120,
+            CURLOPT_CONNECTTIMEOUT => 15,
+            CURLOPT_SSL_VERIFYPEER => false,
+            CURLOPT_SSL_VERIFYHOST => 0,
+        ]);
+        $binary = curl_exec($ch);
+        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $curlError = curl_error($ch);
+        curl_close($ch);
+
+        if ($binary === false || $binary === '') {
+            throw new \Exception('生成图下载失败: ' . ($curlError ?: $url));
+        }
+        if ($httpCode !== 200) {
+            throw new \Exception('生成图下载失败 HTTP ' . $httpCode);
+        }
+        if (strlen($binary) < 100) {
+            throw new \Exception('生成图内容无效');
+        }
+
+        return $binary;
+    }
+
+    private static function getOssConfig(): array
+    {
+        $config = Config::get('oss');
+        return is_array($config) ? $config : [];
+    }
+
+    private static function isOssEnabled(): bool
+    {
+        $config = self::getOssConfig();
+        return !empty($config['accessKeyId'])
+            && !empty($config['accessKeySecret'])
+            && !empty($config['endpoint'])
+            && !empty($config['bucket']);
+    }
+
+    private static function normalizeOssObjectKey(string $objectKey): string
+    {
+        return ltrim(str_replace('\\', '/', trim($objectKey)), '/');
+    }
+
+    private static function putFileToOss(string $localFullPath, string $objectKey): bool
+    {
+        if (!self::isOssEnabled() || !is_file($localFullPath)) {
+            return false;
+        }
+
+        $config = self::getOssConfig();
+        $objectKey = self::normalizeOssObjectKey($objectKey);
+        if ($objectKey === '') {
+            return false;
+        }
+
+        try {
+            $ossClient = new OssClient(
+                $config['accessKeyId'],
+                $config['accessKeySecret'],
+                $config['endpoint']
+            );
+            $ossClient->uploadFile($config['bucket'], $objectKey, $localFullPath);
+            return true;
+        } catch (\Throwable $e) {
+            Log::write('[SeedreamImageGenerationJob/OSS] ' . $e->getMessage() . ' | objectKey=' . $objectKey, 'error');
+            return false;
+        }
+    }
+
+    private static function buildOssPublicUrl(string $path): string
+    {
+        $path = trim($path);
+        if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) {
+            return $path;
+        }
+
+        $config = self::getOssConfig();
+        $host = trim((string)($config['host'] ?? ''));
+        if ($host === '') {
+            return $path;
+        }
+        if (stripos($host, 'http://') !== 0 && stripos($host, 'https://') !== 0) {
+            $host = 'https://' . $host;
+        }
+
+        return rtrim($host, '/') . '/' . ltrim($path, '/');
+    }
+
+    private static function truncateText(string $text, int $maxLen): string
+    {
+        $text = trim(preg_replace('/\s+/', ' ', $text));
+        if ($text === '' || strlen($text) <= $maxLen) {
+            return $text;
+        }
+        return substr($text, 0, $maxLen) . '...';
+    }
+}

+ 4 - 0
application/route.php

@@ -23,6 +23,10 @@ return [
     // 图生图接口 - 支持多种访问路径(与 error 控制器提示的 urls 一致)
     'img2img' => 'api/Index/img_to_img',
     'api/img2img' => 'api/Index/img_to_img',
+    // 豆包 Seedream 文生图/图生图
+    'api/imagegen' => 'api/ImageGeneration/generate',
+    'api/image_generation/generate' => 'api/ImageGeneration/generate',
+    'api/image_generation/getStatus' => 'api/ImageGeneration/getStatus',
     // 当请求被误解析为 error 时,返回友好提示
     'error/500' => 'error/Index/index',
     'error/:code' => 'error/Index/index',

+ 1 - 1
public/.htaccess

@@ -4,4 +4,4 @@ RewriteEngine on
 RewriteCond %{REQUEST_FILENAME} !-d
 RewriteCond %{REQUEST_FILENAME} !-f
 RewriteRule ^(.*)$ index.php [L,E=PATH_INFO:$1]
-</IfModule>
+</IfModule>