SeedreamImageGenerationJob.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. <?php
  2. namespace app\job;
  3. use OSS\OssClient;
  4. use think\Config;
  5. use think\Db;
  6. use think\Log;
  7. use think\queue\Job;
  8. /**
  9. * 豆包 Seedream 组图生成队列(独立于 txttoimg / imgtoimg / arrimage)
  10. *
  11. * 启动消费者:
  12. * php think queue:work --queue seedreamimage --timeout 900 --sleep 3 --tries 1
  13. */
  14. class SeedreamImageGenerationJob
  15. {
  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. const REDIS_KEY_PREFIX = 'seedream_image_task:';
  19. const QUEUE_NAME = 'seedreamimage';
  20. const TASK_TTL = 1800;
  21. /**
  22. * 队列入口
  23. */
  24. public function fire(Job $job, $data)
  25. {
  26. $taskId = trim((string)($data['task_id'] ?? ''));
  27. if ($taskId === '') {
  28. $job->delete();
  29. return;
  30. }
  31. $redisKey = self::REDIS_KEY_PREFIX . $taskId;
  32. $redis = getTaskRedis();
  33. echo "\n" . date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 开始 task_id={$taskId}\n";
  34. try {
  35. $existing = $redis->get($redisKey);
  36. if ($existing) {
  37. $info = json_decode($existing, true);
  38. if (is_array($info) && ($info['status'] ?? '') === 'completed') {
  39. echo "任务 {$taskId} 已完成,跳过\n";
  40. $job->delete();
  41. return;
  42. }
  43. }
  44. $redis->set($redisKey, json_encode([
  45. 'status' => 'processing',
  46. 'task_id' => $taskId,
  47. 'started_at' => date('Y-m-d H:i:s'),
  48. ], JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]);
  49. set_time_limit(900);
  50. ini_set('max_execution_time', '900');
  51. $payload = self::execute(is_array($data) ? $data : []);
  52. $redis->set($redisKey, json_encode(array_merge($payload, [
  53. 'status' => 'completed',
  54. 'task_id' => $taskId,
  55. 'completed_at' => date('Y-m-d H:i:s'),
  56. ]), JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]);
  57. echo date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 完成 task_id={$taskId}\n";
  58. $job->delete();
  59. } catch (\Exception $e) {
  60. echo date('Y-m-d H:i:s') . " [SeedreamImageGenerationJob] 失败: " . $e->getMessage() . "\n";
  61. Log::write('[SeedreamImageGenerationJob] ' . $e->getMessage(), 'error');
  62. $redis->set($redisKey, json_encode([
  63. 'status' => 'failed',
  64. 'task_id' => $taskId,
  65. 'error' => $e->getMessage(),
  66. 'completed_at' => date('Y-m-d H:i:s'),
  67. ], JSON_UNESCAPED_UNICODE), ['EX' => self::TASK_TTL]);
  68. $job->delete();
  69. }
  70. }
  71. /**
  72. * 执行 Seedream 生图(组图/单图均可)
  73. */
  74. public static function execute(array $data): array
  75. {
  76. $prompt = trim((string)($data['prompt'] ?? ''));
  77. $model = trim((string)($data['model'] ?? self::DEFAULT_MODEL));
  78. $size = trim((string)($data['size'] ?? '2K'));
  79. $stream = filter_var($data['stream'] ?? false, FILTER_VALIDATE_BOOLEAN);
  80. $referenceImages = is_array($data['reference_images'] ?? null) ? $data['reference_images'] : [];
  81. $genConfig = is_array($data['gen_config'] ?? null) ? $data['gen_config'] : [];
  82. $requestBody = is_array($data['request_body'] ?? null) ? $data['request_body'] : [];
  83. if (empty($requestBody)) {
  84. $referenceCount = count($referenceImages);
  85. $maxImages = max(1, min(15, (int)($genConfig['max_images'] ?? 1)));
  86. $sequentialMode = trim((string)($genConfig['sequential_image_generation'] ?? ''));
  87. if ($sequentialMode === '') {
  88. $sequentialMode = $maxImages > 1 ? 'auto' : 'disabled';
  89. }
  90. $requestBody = [
  91. 'model' => $model,
  92. 'prompt' => $prompt,
  93. 'sequential_image_generation' => $sequentialMode,
  94. 'response_format' => 'url',
  95. 'size' => $size,
  96. 'stream' => $stream,
  97. 'watermark' => filter_var($data['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN),
  98. ];
  99. if ($sequentialMode !== 'disabled') {
  100. $requestBody['sequential_image_generation_options'] = [
  101. 'max_images' => $maxImages,
  102. ];
  103. }
  104. if ($referenceCount > 0) {
  105. $requestBody['image'] = $referenceCount === 1 ? $referenceImages[0] : $referenceImages;
  106. }
  107. $genConfig['max_images'] = $maxImages;
  108. $genConfig['sequential_image_generation'] = $sequentialMode;
  109. $genConfig['mode'] = self::detectGenerationMode($referenceCount, $maxImages);
  110. }
  111. $result = self::callSeedreamApi($requestBody, $model, $stream);
  112. $images = self::normalizeImageResult($result);
  113. $imgId = self::extractApiImageId($result);
  114. $taskId = trim((string)($data['task_id'] ?? ''));
  115. $stored = self::persistGeneratedImagesToOss($images, $imgId, $referenceImages, $taskId);
  116. $images = $stored['images'];
  117. $generateImgUrl = implode(',', $stored['urls']);
  118. $payload = [
  119. 'img_id' => $imgId,
  120. 'mode' => $genConfig['mode'] ?? '',
  121. 'reference_count' => count($referenceImages),
  122. 'reference_images' => $referenceImages,
  123. 'generate_images' => $stored['urls'],
  124. 'max_images' => (int)($genConfig['max_images'] ?? count($images)),
  125. 'images' => $images,
  126. 'model' => $result['model'] ?? $model,
  127. 'usage' => $result['usage'] ?? null,
  128. 'created' => $result['created'] ?? null,
  129. ];
  130. try {
  131. $payload['storage_id'] = self::savePictureStorage([
  132. 'img_id' => $imgId,
  133. 'prompt' => $prompt,
  134. 'reference_img_url' => implode(',', $referenceImages),
  135. 'generate_img_url' => $generateImgUrl,
  136. 'model' => $result['model'] ?? $model,
  137. 'img_size' => $size,
  138. ]);
  139. } catch (\Exception $e) {
  140. $payload['db_save_error'] = $e->getMessage();
  141. Log::write('[SeedreamImageGenerationJob] 数据库保存失败: ' . $e->getMessage(), 'error');
  142. }
  143. return $payload;
  144. }
  145. private static function detectGenerationMode(int $referenceCount, int $maxImages): string
  146. {
  147. if ($referenceCount === 0) {
  148. return $maxImages > 1 ? 'text_to_images' : 'text_to_image';
  149. }
  150. if ($referenceCount === 1) {
  151. return $maxImages > 1 ? 'image_to_images' : 'image_to_image';
  152. }
  153. return $maxImages > 1 ? 'images_to_images' : 'images_to_image';
  154. }
  155. private static function callSeedreamApi(array $requestBody, string $model, bool $stream = false): array
  156. {
  157. $configs = self::resolveModelApiConfigs($model);
  158. if (empty($configs)) {
  159. throw new \Exception("模型配置不存在: {$model}");
  160. }
  161. $postData = json_encode($requestBody, JSON_UNESCAPED_UNICODE);
  162. if (json_last_error() !== JSON_ERROR_NONE) {
  163. throw new \Exception('请求数据 JSON 编码失败: ' . json_last_error_msg());
  164. }
  165. $errors = [];
  166. foreach ($configs as $index => $config) {
  167. $apiUrl = self::sanitizeApiUrl((string)($config['api_url'] ?? ''));
  168. $apiKey = trim((string)($config['api_key'] ?? ''));
  169. if ($apiUrl === '' || $apiKey === '') {
  170. $errors[] = '第' . ($index + 1) . '个接口配置无效(URL/Key为空)';
  171. continue;
  172. }
  173. $headers = [
  174. 'Content-Type: application/json',
  175. 'Authorization: Bearer ' . $apiKey,
  176. ];
  177. if ($stream) {
  178. $headers[] = 'Accept: text/event-stream';
  179. }
  180. $ch = curl_init();
  181. curl_setopt_array($ch, [
  182. CURLOPT_URL => $apiUrl,
  183. CURLOPT_POST => true,
  184. CURLOPT_POSTFIELDS => $postData,
  185. CURLOPT_HTTPHEADER => $headers,
  186. CURLOPT_RETURNTRANSFER => true,
  187. CURLOPT_TIMEOUT => 900,
  188. CURLOPT_CONNECTTIMEOUT => 30,
  189. CURLOPT_SSL_VERIFYPEER => false,
  190. CURLOPT_SSL_VERIFYHOST => 0,
  191. ]);
  192. $response = curl_exec($ch);
  193. $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
  194. $curlErrno = curl_errno($ch);
  195. $curlError = curl_error($ch);
  196. curl_close($ch);
  197. if ($response === false) {
  198. $errors[] = '第' . ($index + 1) . '个接口请求失败 [curl:' . $curlErrno . '] '
  199. . ($curlError !== '' ? $curlError : '请求超时或网络异常');
  200. continue;
  201. }
  202. if ($httpCode !== 200) {
  203. $decoded = json_decode($response, true);
  204. $msg = is_array($decoded)
  205. ? ($decoded['error']['message'] ?? json_encode($decoded, JSON_UNESCAPED_UNICODE))
  206. : self::truncateText((string)$response, 300);
  207. $errors[] = '第' . ($index + 1) . '个接口 HTTP ' . $httpCode . ': ' . $msg;
  208. continue;
  209. }
  210. try {
  211. return $stream ? self::parseStreamResponse($response) : self::parseJsonResponse($response);
  212. } catch (\Exception $e) {
  213. $errors[] = '第' . ($index + 1) . '个接口响应解析失败: ' . $e->getMessage();
  214. }
  215. }
  216. throw new \Exception(($stream ? '流式' : '') . '生成失败: ' . implode('; ', $errors));
  217. }
  218. private static function resolveModelApiConfigs(string $model): array
  219. {
  220. $rows = Db::name('ai_model')
  221. ->where('model_name', $model)
  222. ->where('status', '1')
  223. ->order('sort ASC')
  224. ->select();
  225. $configs = [];
  226. $seenUrls = [];
  227. $fallbackKey = '';
  228. foreach ($rows as $row) {
  229. $apiUrl = self::sanitizeApiUrl((string)($row['api_url'] ?? ''));
  230. $apiKey = trim((string)($row['api_key'] ?? ''));
  231. if ($apiKey !== '' && $fallbackKey === '') {
  232. $fallbackKey = $apiKey;
  233. }
  234. if ($apiUrl === '' || $apiKey === '') {
  235. continue;
  236. }
  237. $urlKey = strtolower(rtrim($apiUrl, '/'));
  238. if (isset($seenUrls[$urlKey])) {
  239. continue;
  240. }
  241. $seenUrls[$urlKey] = true;
  242. $configs[] = ['api_url' => $apiUrl, 'api_key' => $apiKey];
  243. }
  244. $defaultUrl = rtrim(self::DEFAULT_API_URL, '/');
  245. if ($fallbackKey !== '' && !isset($seenUrls[strtolower($defaultUrl)])) {
  246. $configs[] = ['api_url' => self::DEFAULT_API_URL, 'api_key' => $fallbackKey];
  247. }
  248. return $configs;
  249. }
  250. private static function sanitizeApiUrl(string $url): string
  251. {
  252. $url = trim(str_replace(["\r\n", "\r", "\n", "\t"], ' ', $url));
  253. if ($url === '') {
  254. return '';
  255. }
  256. foreach (preg_split('/\s+/', $url) as $part) {
  257. $part = trim($part);
  258. if ($part !== '' && preg_match('/^https?:\/\//i', $part)) {
  259. return $part;
  260. }
  261. }
  262. return '';
  263. }
  264. private static function parseJsonResponse(string $raw): array
  265. {
  266. $result = json_decode($raw, true);
  267. if (!is_array($result)) {
  268. throw new \Exception('API 响应解析失败');
  269. }
  270. if (isset($result['error'])) {
  271. throw new \Exception($result['error']['message'] ?? 'API 返回错误');
  272. }
  273. if (empty($result['data']) || !is_array($result['data'])) {
  274. throw new \Exception('未解析到有效图片结果');
  275. }
  276. return $result;
  277. }
  278. private static function parseStreamResponse(string $raw): array
  279. {
  280. $images = [];
  281. $usage = null;
  282. $model = '';
  283. $created = null;
  284. $apiId = '';
  285. foreach (preg_split('/\r?\n/', $raw) as $line) {
  286. $line = trim($line);
  287. if ($line === '' || $line === 'data: [DONE]' || stripos($line, 'data:') !== 0) {
  288. continue;
  289. }
  290. $event = json_decode(trim(substr($line, 5)), true);
  291. if (!is_array($event)) {
  292. continue;
  293. }
  294. if (isset($event['error'])) {
  295. throw new \Exception($event['error']['message'] ?? 'API 返回错误');
  296. }
  297. if (!empty($event['model'])) {
  298. $model = (string)$event['model'];
  299. }
  300. if (!empty($event['id'])) {
  301. $apiId = (string)$event['id'];
  302. }
  303. if (isset($event['created'])) {
  304. $created = $event['created'];
  305. }
  306. if (isset($event['usage'])) {
  307. $usage = $event['usage'];
  308. }
  309. $type = (string)($event['type'] ?? '');
  310. if ($type === 'image_generation.partial_succeeded' && !empty($event['url'])) {
  311. $images[] = [
  312. 'url' => $event['url'],
  313. 'size' => $event['size'] ?? '',
  314. 'image_index' => $event['image_index'] ?? count($images),
  315. ];
  316. }
  317. if ($type === 'image_generation.completed' && !empty($event['data']) && is_array($event['data'])) {
  318. foreach ($event['data'] as $item) {
  319. if (!empty($item['url'])) {
  320. $images[] = $item;
  321. }
  322. }
  323. }
  324. }
  325. if (empty($images)) {
  326. $decoded = json_decode($raw, true);
  327. if (is_array($decoded) && !empty($decoded['data'])) {
  328. return $decoded;
  329. }
  330. throw new \Exception('未解析到有效图片结果');
  331. }
  332. return [
  333. 'id' => $apiId,
  334. 'model' => $model,
  335. 'created' => $created,
  336. 'data' => $images,
  337. 'usage' => $usage,
  338. ];
  339. }
  340. private static function extractApiImageId(array $result): string
  341. {
  342. foreach (['id', 'request_id', 'task_id'] as $key) {
  343. if (!empty($result[$key])) {
  344. return substr((string)$result[$key], 0, 50);
  345. }
  346. }
  347. return substr(str_replace('.', '', uniqid('img', true)), 0, 50);
  348. }
  349. private static function savePictureStorage(array $data): int
  350. {
  351. $insert = [
  352. 'img_id' => substr((string)($data['img_id'] ?? ''), 0, 50),
  353. 'prompt' => (string)($data['prompt'] ?? ''),
  354. 'reference_img_url' => (string)($data['reference_img_url'] ?? ''),
  355. 'generate_img_url' => (string)($data['generate_img_url'] ?? ''),
  356. 'model' => substr((string)($data['model'] ?? ''), 0, 255),
  357. 'img_size' => substr((string)($data['img_size'] ?? ''), 0, 255),
  358. 'sys_rq' => date('Y-m-d H:i:s'),
  359. ];
  360. if ($insert['img_id'] === '' || $insert['model'] === '') {
  361. throw new \Exception('picture_storage 入库参数不完整');
  362. }
  363. $storageId = Db::name('picture_storage')->insertGetId($insert);
  364. if (!$storageId) {
  365. throw new \Exception('picture_storage 入库失败');
  366. }
  367. return (int)$storageId;
  368. }
  369. private static function normalizeImageResult(array $result): array
  370. {
  371. $items = $result['data'] ?? [];
  372. if (!is_array($items)) {
  373. return [];
  374. }
  375. $images = [];
  376. foreach ($items as $item) {
  377. if (!is_array($item) || empty($item['url'])) {
  378. continue;
  379. }
  380. $images[] = [
  381. 'url' => $item['url'],
  382. 'size' => $item['size'] ?? '',
  383. 'image_index' => $item['image_index'] ?? null,
  384. ];
  385. }
  386. return $images;
  387. }
  388. /**
  389. * 生成图落盘并上传 OSS(目录/命名规则与参考图一致:uploads/imagegen/{日期}/)
  390. * @return array{urls:string[],images:array}
  391. */
  392. private static function persistGeneratedImagesToOss(
  393. array $images,
  394. string $imgId,
  395. array $referenceImages,
  396. string $taskId = ''
  397. ): array {
  398. if (empty($images)) {
  399. throw new \Exception('没有可保存的生成图');
  400. }
  401. if (!self::isOssEnabled()) {
  402. throw new \Exception('OSS 未配置,无法保存生成图');
  403. }
  404. $defaultExt = self::resolveOutputExtFromReference($referenceImages);
  405. $dateDir = date('Ymd');
  406. $saveDir = ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR
  407. . 'imagegen' . DIRECTORY_SEPARATOR . $dateDir . DIRECTORY_SEPARATOR;
  408. if (!is_dir($saveDir)) {
  409. mkdir($saveDir, 0755, true);
  410. }
  411. $safeId = preg_replace('/[^a-zA-Z0-9_]/', '_', $imgId);
  412. $safeTask = $taskId !== '' ? preg_replace('/[^a-zA-Z0-9_]/', '_', $taskId) : '';
  413. $ossUrls = [];
  414. $storedImages = [];
  415. foreach ($images as $index => $image) {
  416. $sourceUrl = trim((string)($image['url'] ?? ''));
  417. if ($sourceUrl === '') {
  418. continue;
  419. }
  420. $binary = self::downloadRemoteImage($sourceUrl);
  421. $ext = self::detectImageExt($binary, $defaultExt);
  422. $suffix = $safeTask !== '' ? $safeTask . '_' . $index : $safeId . '_' . $index;
  423. $fileName = 'gen_' . $suffix . '.' . $ext;
  424. $localFullPath = $saveDir . $fileName;
  425. if (file_put_contents($localFullPath, $binary) === false) {
  426. throw new \Exception('生成图本地保存失败');
  427. }
  428. $objectKey = 'uploads/imagegen/' . $dateDir . '/' . $fileName;
  429. if (!self::putFileToOss($localFullPath, $objectKey)) {
  430. throw new \Exception('生成图上传 OSS 失败: ' . $objectKey);
  431. }
  432. $ossUrl = self::buildOssPublicUrl($objectKey);
  433. if (stripos($ossUrl, 'http://') !== 0 && stripos($ossUrl, 'https://') !== 0) {
  434. throw new \Exception('无法获取生成图 OSS 访问地址');
  435. }
  436. $ossUrls[] = $ossUrl;
  437. $storedImages[] = array_merge($image, [
  438. 'url' => $ossUrl,
  439. 'oss_url' => $ossUrl,
  440. 'object_key' => $objectKey,
  441. 'source_url' => $sourceUrl,
  442. ]);
  443. }
  444. if (empty($ossUrls)) {
  445. throw new \Exception('生成图 OSS 保存结果为空');
  446. }
  447. return ['urls' => $ossUrls, 'images' => $storedImages];
  448. }
  449. /**
  450. * 从参考图推断输出扩展名(无参考图时默认 jpg)
  451. */
  452. private static function resolveOutputExtFromReference(array $referenceImages): string
  453. {
  454. foreach ($referenceImages as $url) {
  455. $url = trim((string)$url);
  456. if ($url === '') {
  457. continue;
  458. }
  459. $path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
  460. $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
  461. if ($ext === 'jpeg') {
  462. $ext = 'jpg';
  463. }
  464. if (in_array($ext, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) {
  465. return $ext;
  466. }
  467. }
  468. return 'jpg';
  469. }
  470. private static function detectImageExt(string $binary, string $fallback = 'jpg'): string
  471. {
  472. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  473. $mime = finfo_buffer($finfo, $binary);
  474. finfo_close($finfo);
  475. $map = [
  476. 'image/jpeg' => 'jpg',
  477. 'image/png' => 'png',
  478. 'image/gif' => 'gif',
  479. 'image/webp' => 'webp',
  480. 'image/bmp' => 'bmp',
  481. ];
  482. if ($mime && isset($map[$mime])) {
  483. return $map[$mime];
  484. }
  485. $fallback = strtolower($fallback);
  486. return in_array($fallback, ['jpg', 'png', 'gif', 'webp', 'bmp'], true) ? $fallback : 'jpg';
  487. }
  488. private static function downloadRemoteImage(string $url): string
  489. {
  490. $ch = curl_init();
  491. curl_setopt_array($ch, [
  492. CURLOPT_URL => $url,
  493. CURLOPT_RETURNTRANSFER => true,
  494. CURLOPT_FOLLOWLOCATION => true,
  495. CURLOPT_TIMEOUT => 120,
  496. CURLOPT_CONNECTTIMEOUT => 15,
  497. CURLOPT_SSL_VERIFYPEER => false,
  498. CURLOPT_SSL_VERIFYHOST => 0,
  499. ]);
  500. $binary = curl_exec($ch);
  501. $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
  502. $curlError = curl_error($ch);
  503. curl_close($ch);
  504. if ($binary === false || $binary === '') {
  505. throw new \Exception('生成图下载失败: ' . ($curlError ?: $url));
  506. }
  507. if ($httpCode !== 200) {
  508. throw new \Exception('生成图下载失败 HTTP ' . $httpCode);
  509. }
  510. if (strlen($binary) < 100) {
  511. throw new \Exception('生成图内容无效');
  512. }
  513. return $binary;
  514. }
  515. private static function getOssConfig(): array
  516. {
  517. $config = Config::get('oss');
  518. return is_array($config) ? $config : [];
  519. }
  520. private static function isOssEnabled(): bool
  521. {
  522. $config = self::getOssConfig();
  523. return !empty($config['accessKeyId'])
  524. && !empty($config['accessKeySecret'])
  525. && !empty($config['endpoint'])
  526. && !empty($config['bucket']);
  527. }
  528. private static function normalizeOssObjectKey(string $objectKey): string
  529. {
  530. return ltrim(str_replace('\\', '/', trim($objectKey)), '/');
  531. }
  532. private static function putFileToOss(string $localFullPath, string $objectKey): bool
  533. {
  534. if (!self::isOssEnabled() || !is_file($localFullPath)) {
  535. return false;
  536. }
  537. $config = self::getOssConfig();
  538. $objectKey = self::normalizeOssObjectKey($objectKey);
  539. if ($objectKey === '') {
  540. return false;
  541. }
  542. try {
  543. $ossClient = new OssClient(
  544. $config['accessKeyId'],
  545. $config['accessKeySecret'],
  546. $config['endpoint']
  547. );
  548. $ossClient->uploadFile($config['bucket'], $objectKey, $localFullPath);
  549. return true;
  550. } catch (\Throwable $e) {
  551. Log::write('[SeedreamImageGenerationJob/OSS] ' . $e->getMessage() . ' | objectKey=' . $objectKey, 'error');
  552. return false;
  553. }
  554. }
  555. private static function buildOssPublicUrl(string $path): string
  556. {
  557. $path = trim($path);
  558. if ($path === '' || stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) {
  559. return $path;
  560. }
  561. $config = self::getOssConfig();
  562. $host = trim((string)($config['host'] ?? ''));
  563. if ($host === '') {
  564. return $path;
  565. }
  566. if (stripos($host, 'http://') !== 0 && stripos($host, 'https://') !== 0) {
  567. $host = 'https://' . $host;
  568. }
  569. return rtrim($host, '/') . '/' . ltrim($path, '/');
  570. }
  571. private static function truncateText(string $text, int $maxLen): string
  572. {
  573. $text = trim(preg_replace('/\s+/', ' ', $text));
  574. if ($text === '' || strlen($text) <= $maxLen) {
  575. return $text;
  576. }
  577. return substr($text, 0, $maxLen) . '...';
  578. }
  579. }