ImageGeneration.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. <?php
  2. namespace app\api\controller;
  3. use app\common\controller\Api;
  4. use app\job\SeedreamImageGenerationJob;
  5. use OSS\OssClient;
  6. use think\Config;
  7. use think\Log;
  8. use think\Queue;
  9. /**
  10. * 文生图 / 图生图 / 组图生组图(豆包 Seedream)
  11. */
  12. class ImageGeneration extends Api
  13. {
  14. protected $noNeedLogin = ['*'];
  15. protected $noNeedRight = ['*'];
  16. const DEFAULT_MODEL = 'doubao-seedream-5-0-260128';
  17. const DEFAULT_API_URL = 'https://ark.cn-beijing.volces.com/api/v3/images/generations';
  18. /**
  19. * 文生图 / 图生图 / 组图生组图
  20. * POST /api/image_generation/generate
  21. *
  22. * 参数:
  23. * - prompt(必填)提示词
  24. * - image / images / image_url / image_urls / reference_images(可选)参考图,支持字符串或数组,最多 14 张
  25. * - 上传 image / images 文件(可选,支持多文件;先上传 OSS,再以 OSS URL 作为参考图)
  26. * - max_images / generate_count / output_count 生成张数,默认 1;大于 1 时走异步队列(seedreamimage)
  27. * - model 模型,默认 doubao-seedream-5-0-260128
  28. * - size 尺寸,默认 2K
  29. * - sequential_image_generation 组图模式,不传时:生成 1 张为 disabled,多张为 auto
  30. * - stream 是否流式,默认 false(组图队列任务固定非流式)
  31. * - watermark 是否加水印,默认 false
  32. *
  33. * 组图(max_images>1)返回 task_id,请轮询 getStatus 接口获取结果
  34. *
  35. * 模式说明(由参考图数量 + 生成数量自动判定):
  36. * - 0 张参考 + 1 张输出 → text_to_image 文生单图
  37. * - 0 张参考 + N 张输出 → text_to_images 文生组图
  38. * - 1 张参考 + 1 张输出 → image_to_image 单图生单图
  39. * - 1 张参考 + N 张输出 → image_to_images 单图生组图
  40. * - M 张参考 + N 张输出 → images_to_images 组图生组图
  41. *
  42. * AI 调用成功后会写入 picture_storage(img_id、prompt、reference_img_url 等;generate_img_url 暂不写入)
  43. */
  44. public function generate()
  45. {
  46. set_time_limit(600);
  47. ini_set('max_execution_time', '600');
  48. try {
  49. $params = $this->mergeRequestParams();
  50. $prompt = trim((string)($params['prompt'] ?? ''));
  51. if ($prompt === '') {
  52. return json(['code' => 0, 'msg' => 'prompt 不能为空']);
  53. }
  54. $referenceImages = $this->resolveReferenceImages($params);
  55. $referenceCount = count($referenceImages);
  56. $genConfig = $this->buildGenerationConfig($params, $referenceCount);
  57. $model = trim((string)($params['model'] ?? self::DEFAULT_MODEL));
  58. $stream = filter_var($params['stream'] ?? false, FILTER_VALIDATE_BOOLEAN);
  59. $watermark = filter_var($params['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN);
  60. $size = trim((string)($params['size'] ?? '2K'));
  61. $requestBody = [
  62. 'model' => $model,
  63. 'prompt' => $prompt,
  64. 'sequential_image_generation' => $genConfig['sequential_image_generation'],
  65. 'response_format' => 'url',
  66. 'size' => $size,
  67. 'stream' => $stream,
  68. 'watermark' => $watermark,
  69. ];
  70. if ($genConfig['sequential_image_generation'] !== 'disabled') {
  71. $requestBody['sequential_image_generation_options'] = [
  72. 'max_images' => $genConfig['max_images'],
  73. ];
  74. }
  75. if ($referenceCount > 0) {
  76. $requestBody['image'] = $referenceCount === 1 ? $referenceImages[0] : $referenceImages;
  77. }
  78. $taskPayload = [
  79. 'prompt' => $prompt,
  80. 'model' => $model,
  81. 'size' => $size,
  82. 'watermark' => $watermark,
  83. 'stream' => $stream,
  84. 'reference_images' => $referenceImages,
  85. 'gen_config' => $genConfig,
  86. 'request_body' => $requestBody,
  87. ];
  88. if ($genConfig['max_images'] > 1) {
  89. return $this->submitBatchTask($taskPayload, $genConfig, $referenceImages, $referenceCount);
  90. }
  91. $payload = SeedreamImageGenerationJob::execute($taskPayload);
  92. $dbError = (string)($payload['db_save_error'] ?? '');
  93. unset($payload['db_save_error']);
  94. return $this->jsonGenerateSuccess($payload, $dbError);
  95. } catch (\Exception $e) {
  96. $this->logError('[ImageGeneration/generate] ' . $e->getMessage());
  97. return json(['code' => 0, 'msg' => $e->getMessage()]);
  98. }
  99. }
  100. /**
  101. * 查询组图异步任务状态
  102. * GET/POST /api/image_generation/getStatus
  103. * 参数:task_id
  104. */
  105. public function getStatus()
  106. {
  107. $taskId = trim((string)$this->request->param('task_id', ''));
  108. if ($taskId === '') {
  109. return json(['code' => 0, 'msg' => 'task_id 不能为空']);
  110. }
  111. $redis = getTaskRedis();
  112. $raw = $redis->get(SeedreamImageGenerationJob::REDIS_KEY_PREFIX . $taskId);
  113. if (!$raw) {
  114. return json(['code' => 0, 'msg' => '任务不存在或已过期']);
  115. }
  116. $info = json_decode($raw, true);
  117. if (!is_array($info)) {
  118. return json(['code' => 0, 'msg' => '任务数据异常']);
  119. }
  120. return json([
  121. 'code' => 1,
  122. 'msg' => '查询成功',
  123. 'data' => $info,
  124. ]);
  125. }
  126. /**
  127. * 组图任务提交到独立队列 seedreamimage
  128. */
  129. private function submitBatchTask(
  130. array $taskPayload,
  131. array $genConfig,
  132. array $referenceImages,
  133. int $referenceCount
  134. ): \think\response\Json {
  135. $taskId = 'sd' . date('YmdHis') . mt_rand(1000, 9999);
  136. $taskPayload['task_id'] = $taskId;
  137. $taskPayload['stream'] = false;
  138. $redis = getTaskRedis();
  139. $redis->set(
  140. SeedreamImageGenerationJob::REDIS_KEY_PREFIX . $taskId,
  141. json_encode([
  142. 'status' => 'pending',
  143. 'task_id' => $taskId,
  144. 'mode' => $genConfig['mode'],
  145. 'reference_count' => $referenceCount,
  146. 'reference_images' => $referenceImages,
  147. 'max_images' => $genConfig['max_images'],
  148. 'created_at' => date('Y-m-d H:i:s'),
  149. ], JSON_UNESCAPED_UNICODE),
  150. ['EX' => SeedreamImageGenerationJob::TASK_TTL]
  151. );
  152. Queue::push('app\job\SeedreamImageGenerationJob', $taskPayload, SeedreamImageGenerationJob::QUEUE_NAME);
  153. return json([
  154. 'code' => 1,
  155. 'msg' => '组图任务已提交,请稍后查询结果',
  156. 'data' => [
  157. 'async' => true,
  158. 'task_id' => $taskId,
  159. 'status' => 'pending',
  160. 'mode' => $genConfig['mode'],
  161. 'reference_count' => $referenceCount,
  162. 'reference_images' => $referenceImages,
  163. 'max_images' => $genConfig['max_images'],
  164. 'poll_url' => '/api/image_generation/getStatus',
  165. ],
  166. ]);
  167. }
  168. /**
  169. * AI 成功但数据库失败时,仍返回生成结果并附带警告
  170. */
  171. private function jsonGenerateSuccess(array $payload, string $dbError = ''): \think\response\Json
  172. {
  173. if ($dbError !== '') {
  174. $payload['db_save_error'] = $dbError;
  175. }
  176. return json([
  177. 'code' => 1,
  178. 'msg' => $dbError !== '' ? '生成成功,但数据库保存失败' : '生成成功',
  179. 'data' => $payload,
  180. ]);
  181. }
  182. /**
  183. * 根据前端参数构建组图配置
  184. */
  185. private function buildGenerationConfig(array $params, int $referenceCount): array
  186. {
  187. $maxImages = (int)($params['max_images'] ?? $params['generate_count'] ?? $params['output_count'] ?? 1);
  188. $maxImages = max(1, min(15, $maxImages));
  189. $sequentialMode = trim((string)($params['sequential_image_generation'] ?? ''));
  190. if ($sequentialMode === '') {
  191. $sequentialMode = $maxImages > 1 ? 'auto' : 'disabled';
  192. }
  193. return [
  194. 'max_images' => $maxImages,
  195. 'sequential_image_generation' => $sequentialMode,
  196. 'mode' => $this->detectGenerationMode($referenceCount, $maxImages),
  197. ];
  198. }
  199. /**
  200. * 判定当前生成模式
  201. */
  202. private function detectGenerationMode(int $referenceCount, int $maxImages): string
  203. {
  204. if ($referenceCount === 0) {
  205. return $maxImages > 1 ? 'text_to_images' : 'text_to_image';
  206. }
  207. if ($referenceCount === 1) {
  208. return $maxImages > 1 ? 'image_to_images' : 'image_to_image';
  209. }
  210. return $maxImages > 1 ? 'images_to_images' : 'images_to_image';
  211. }
  212. /**
  213. * 解析参考图列表:上传文件/base64 先落 OSS,再以公网 URL 传给 AI
  214. * @return string[]
  215. */
  216. private function resolveReferenceImages(array $params): array
  217. {
  218. $resolved = [];
  219. foreach (['image', 'images'] as $field) {
  220. $uploaded = $this->request->file($field);
  221. if (empty($uploaded)) {
  222. continue;
  223. }
  224. $fileList = is_array($uploaded) ? $uploaded : [$uploaded];
  225. foreach ($fileList as $file) {
  226. if (!$file) {
  227. continue;
  228. }
  229. $ossUrl = $this->uploadUploadedFileToOss($file);
  230. if ($ossUrl !== '') {
  231. $resolved[] = $ossUrl;
  232. }
  233. }
  234. }
  235. $rawList = [];
  236. foreach (['images', 'reference_images', 'image_urls'] as $key) {
  237. if (empty($params[$key]) || !is_array($params[$key])) {
  238. continue;
  239. }
  240. foreach ($params[$key] as $item) {
  241. if ($item !== null && $item !== '') {
  242. $rawList[] = $item;
  243. }
  244. }
  245. }
  246. foreach (['image', 'image_url'] as $key) {
  247. if (empty($params[$key])) {
  248. continue;
  249. }
  250. if (is_array($params[$key])) {
  251. foreach ($params[$key] as $item) {
  252. if ($item !== null && $item !== '') {
  253. $rawList[] = $item;
  254. }
  255. }
  256. } else {
  257. $rawList[] = $params[$key];
  258. }
  259. }
  260. foreach ($rawList as $raw) {
  261. $item = $this->resolveSingleReferenceImage((string)$raw);
  262. if ($item !== '') {
  263. $resolved[] = $item;
  264. }
  265. }
  266. return array_slice($resolved, 0, 14);
  267. }
  268. /**
  269. * 解析单张参考图(URL/路径直接使用;base64 先上传 OSS)
  270. */
  271. private function resolveSingleReferenceImage(string $raw): string
  272. {
  273. $raw = trim($raw);
  274. if ($raw === '') {
  275. return '';
  276. }
  277. if (stripos($raw, 'data:image/') === 0) {
  278. return $this->uploadBase64ImageToOss($raw);
  279. }
  280. if (stripos($raw, 'http://') === 0 || stripos($raw, 'https://') === 0) {
  281. return $raw;
  282. }
  283. if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', $raw) && strlen($raw) > 100) {
  284. $ossUrl = $this->uploadBase64ImageToOss($raw);
  285. if ($ossUrl !== '') {
  286. return $ossUrl;
  287. }
  288. }
  289. $normalizedPath = ltrim(str_replace('\\', '/', $raw), '/');
  290. $publicUrl = $this->buildOssPublicUrl($normalizedPath);
  291. if (stripos($publicUrl, 'http://') === 0 || stripos($publicUrl, 'https://') === 0) {
  292. return $publicUrl;
  293. }
  294. $localPath = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalizedPath);
  295. if (is_file($localPath)) {
  296. return $this->uploadLocalFileToOss($localPath, $normalizedPath);
  297. }
  298. try {
  299. $loaded = $this->readImageBinary($raw);
  300. $dataUrl = 'data:' . $loaded['mimeType'] . ';base64,' . $loaded['base64Data'];
  301. return $this->uploadBase64ImageToOss($dataUrl);
  302. } catch (\Exception $e) {
  303. $this->logWarning('[ImageGeneration] 读取参考图失败: ' . $e->getMessage());
  304. return '';
  305. }
  306. }
  307. /**
  308. * form-data 上传文件:落盘 → OSS → 返回公网 URL
  309. */
  310. private function uploadUploadedFileToOss($file): string
  311. {
  312. if (!$this->isOssEnabled()) {
  313. throw new \Exception('OSS 未配置,无法上传参考图');
  314. }
  315. $ext = $this->resolveUploadedImageExt($file);
  316. if ($ext === '') {
  317. throw new \Exception('不支持的图片格式');
  318. }
  319. $dateDir = date('Ymd');
  320. $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR;
  321. if (!is_dir($saveDir)) {
  322. mkdir($saveDir, 0755, true);
  323. }
  324. $fileName = 'ref_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
  325. $localFullPath = $saveDir . $fileName;
  326. $saved = false;
  327. if (method_exists($file, 'isValid') && $file->isValid()) {
  328. $info = $file->move($saveDir, $fileName);
  329. if ($info) {
  330. $localFullPath = $saveDir . $info->getFilename();
  331. $saved = true;
  332. }
  333. }
  334. if (!$saved) {
  335. $uploadInfo = $file->getInfo();
  336. $tmpPath = isset($uploadInfo['tmp_name']) ? (string)$uploadInfo['tmp_name'] : '';
  337. if ($tmpPath === '' || !is_file($tmpPath)) {
  338. $tmpPath = (string)($file->getRealPath() ?: '');
  339. }
  340. if ($tmpPath === '' || !is_file($tmpPath) || !@copy($tmpPath, $localFullPath)) {
  341. throw new \Exception('参考图保存失败');
  342. }
  343. }
  344. $objectKey = 'uploads/imagegen/' . $dateDir . '/' . basename($localFullPath);
  345. return $this->uploadLocalFileToOss($localFullPath, $objectKey);
  346. }
  347. /**
  348. * base64 图片:落盘 → OSS → 返回公网 URL
  349. */
  350. private function uploadBase64ImageToOss(string $base64Input): string
  351. {
  352. if (!$this->isOssEnabled()) {
  353. throw new \Exception('OSS 未配置,无法上传参考图');
  354. }
  355. $parsed = $this->parseBase64Image($base64Input);
  356. if ($parsed === null) {
  357. return '';
  358. }
  359. [$ext, $imageData] = $parsed;
  360. $dateDir = date('Ymd');
  361. $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR;
  362. if (!is_dir($saveDir)) {
  363. mkdir($saveDir, 0755, true);
  364. }
  365. $fileName = 'ref_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
  366. $localFullPath = $saveDir . $fileName;
  367. if (file_put_contents($localFullPath, $imageData) === false) {
  368. throw new \Exception('参考图本地保存失败');
  369. }
  370. $objectKey = 'uploads/imagegen/' . $dateDir . '/' . $fileName;
  371. return $this->uploadLocalFileToOss($localFullPath, $objectKey);
  372. }
  373. /**
  374. * 本地文件上传 OSS 并返回公网 URL
  375. */
  376. private function uploadLocalFileToOss(string $localFullPath, string $objectKey): string
  377. {
  378. $objectKey = $this->normalizeOssObjectKey($objectKey);
  379. if (!$this->putFileToOss($localFullPath, $objectKey)) {
  380. throw new \Exception('参考图上传 OSS 失败');
  381. }
  382. $url = $this->buildOssPublicUrl($objectKey);
  383. if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
  384. throw new \Exception('无法获取参考图 OSS 访问地址,请检查 oss.host 配置');
  385. }
  386. return $url;
  387. }
  388. /**
  389. * 解析 base64 图片
  390. * @return array{0:string,1:string}|null [扩展名, 二进制内容]
  391. */
  392. private function parseBase64Image(string $base64Input): ?array
  393. {
  394. $base64Input = trim($base64Input);
  395. if ($base64Input === '') {
  396. return null;
  397. }
  398. $ext = 'jpg';
  399. $rawBase64 = $base64Input;
  400. $prefix = 'data:image/';
  401. if (stripos($base64Input, $prefix) === 0) {
  402. $semi = stripos($base64Input, ';base64,');
  403. if ($semi === false) {
  404. return null;
  405. }
  406. $mimePart = strtolower(substr($base64Input, strlen($prefix), $semi - strlen($prefix)));
  407. if ($mimePart === 'jpeg') {
  408. $mimePart = 'jpg';
  409. }
  410. if (in_array($mimePart, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) {
  411. $ext = $mimePart;
  412. }
  413. $rawBase64 = substr($base64Input, $semi + 8);
  414. }
  415. $rawBase64 = preg_replace('/\s+/', '', $rawBase64);
  416. if ($rawBase64 === '') {
  417. return null;
  418. }
  419. $imageData = base64_decode($rawBase64, true);
  420. if ($imageData === false || strlen($imageData) < 100) {
  421. return null;
  422. }
  423. return [$ext, $imageData];
  424. }
  425. /**
  426. * 解析上传图片扩展名
  427. */
  428. private function resolveUploadedImageExt($file): string
  429. {
  430. $name = $file->getInfo('name') ?? '';
  431. $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
  432. if ($ext === 'jpeg') {
  433. $ext = 'jpg';
  434. }
  435. $allowed = ['jpg', 'png', 'gif', 'webp', 'bmp'];
  436. if ($ext && in_array($ext, $allowed, true)) {
  437. return $ext;
  438. }
  439. $mime = method_exists($file, 'getMime') ? strtolower((string)$file->getMime()) : '';
  440. $map = [
  441. 'image/jpeg' => 'jpg',
  442. 'image/png' => 'png',
  443. 'image/gif' => 'gif',
  444. 'image/webp' => 'webp',
  445. 'image/bmp' => 'bmp',
  446. ];
  447. return $map[$mime] ?? '';
  448. }
  449. /**
  450. * 读取图片二进制(本地路径 / 完整 URL / OSS 相对路径)
  451. * @return array{base64Data:string,mimeType:string}
  452. */
  453. private function readImageBinary(string $imageUrl): array
  454. {
  455. $imageUrl = trim($imageUrl);
  456. if ($imageUrl === '') {
  457. throw new \Exception('图片路径不能为空');
  458. }
  459. $rootPath = str_replace('\\', '/', ROOT_PATH);
  460. $relativePath = ltrim($imageUrl, '/');
  461. $localPath = rtrim($rootPath, '/') . '/public/' . $relativePath;
  462. $localPathDecoded = rtrim($rootPath, '/') . '/public/' . urldecode($relativePath);
  463. $imageContent = false;
  464. $mimeType = '';
  465. if (preg_match('/^https?:\/\//i', $imageUrl)) {
  466. $imageContent = @file_get_contents($imageUrl);
  467. } elseif (file_exists($localPath)) {
  468. $imageContent = @file_get_contents($localPath);
  469. if ($imageContent !== false) {
  470. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  471. $mimeType = finfo_file($finfo, $localPath);
  472. finfo_close($finfo);
  473. }
  474. } elseif (file_exists($localPathDecoded)) {
  475. $imageContent = @file_get_contents($localPathDecoded);
  476. if ($imageContent !== false) {
  477. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  478. $mimeType = finfo_file($finfo, $localPathDecoded);
  479. finfo_close($finfo);
  480. }
  481. } else {
  482. $ossConfig = $this->getOssConfig();
  483. $ossHost = trim((string)($ossConfig['host'] ?? ''));
  484. if ($ossHost !== '') {
  485. if (stripos($ossHost, 'http://') !== 0 && stripos($ossHost, 'https://') !== 0) {
  486. $ossHost = 'https://' . $ossHost;
  487. }
  488. $remoteUrl = rtrim($ossHost, '/') . '/' . ltrim($relativePath, '/');
  489. $imageContent = @file_get_contents($remoteUrl);
  490. }
  491. }
  492. if ($imageContent === false || $imageContent === '') {
  493. throw new \Exception('图片内容读取失败(本地/OSS均未读取到)');
  494. }
  495. if ($mimeType === '') {
  496. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  497. $mimeType = finfo_buffer($finfo, $imageContent);
  498. finfo_close($finfo);
  499. }
  500. if (!$mimeType) {
  501. $mimeType = 'image/png';
  502. }
  503. return [
  504. 'base64Data' => base64_encode($imageContent),
  505. 'mimeType' => $mimeType,
  506. ];
  507. }
  508. /**
  509. * 获取 OSS 配置
  510. */
  511. private function getOssConfig(): array
  512. {
  513. $config = Config::get('oss');
  514. return is_array($config) ? $config : [];
  515. }
  516. /**
  517. * OSS 是否可用
  518. */
  519. private function isOssEnabled(): bool
  520. {
  521. $config = $this->getOssConfig();
  522. return !empty($config['accessKeyId'])
  523. && !empty($config['accessKeySecret'])
  524. && !empty($config['endpoint'])
  525. && !empty($config['bucket']);
  526. }
  527. /**
  528. * 归一化 OSS 对象键
  529. */
  530. private function normalizeOssObjectKey(string $objectKey): string
  531. {
  532. return ltrim(str_replace('\\', '/', trim($objectKey)), '/');
  533. }
  534. /**
  535. * 上传本地文件到 OSS
  536. */
  537. private function putFileToOss(string $localFullPath, string $objectKey): bool
  538. {
  539. if (!$this->isOssEnabled() || !is_file($localFullPath)) {
  540. return false;
  541. }
  542. $config = $this->getOssConfig();
  543. $objectKey = $this->normalizeOssObjectKey($objectKey);
  544. if ($objectKey === '') {
  545. return false;
  546. }
  547. try {
  548. $ossClient = new OssClient(
  549. $config['accessKeyId'],
  550. $config['accessKeySecret'],
  551. $config['endpoint']
  552. );
  553. $ossClient->uploadFile($config['bucket'], $objectKey, $localFullPath);
  554. return true;
  555. } catch (\Throwable $e) {
  556. $this->logError('[ImageGeneration/OSS] ' . $e->getMessage() . ' | objectKey=' . $objectKey);
  557. return false;
  558. }
  559. }
  560. /**
  561. * 相对路径转 OSS 公网 URL
  562. */
  563. private function buildOssPublicUrl(string $path): string
  564. {
  565. $path = trim($path);
  566. if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) {
  567. return $path;
  568. }
  569. $config = $this->getOssConfig();
  570. $host = trim((string)($config['host'] ?? ''));
  571. if ($host === '') {
  572. return $path;
  573. }
  574. if (stripos($host, 'http://') !== 0 && stripos($host, 'https://') !== 0) {
  575. $host = 'https://' . $host;
  576. }
  577. return rtrim($host, '/') . '/' . ltrim($path, '/');
  578. }
  579. private function logError(string $message): void
  580. {
  581. Log::write($message, 'error');
  582. }
  583. private function logWarning(string $message): void
  584. {
  585. Log::write($message, 'warning');
  586. }
  587. /**
  588. * 合并 JSON Body 与 form 参数
  589. */
  590. private function mergeRequestParams(): array
  591. {
  592. $params = $this->request->param();
  593. if (!empty($params['prompt']) || !empty($params['image']) || !empty($params['images'])) {
  594. return $params;
  595. }
  596. $content = $this->request->getInput();
  597. if (!is_string($content) || $content === '') {
  598. return $params;
  599. }
  600. $json = json_decode($content, true);
  601. if (!is_array($json)) {
  602. return $params;
  603. }
  604. return array_merge($params, $json);
  605. }
  606. }