AIImageGenerationService.php 114 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716
  1. <?php
  2. namespace App\Services\AIGeneration;
  3. use App\Facade\Site;
  4. use App\Consts\BaseConst;
  5. use App\Libs\ScriptAnchor;
  6. use App\Models\MpGeneratePicTask;
  7. use App\Models\MpAsset;
  8. use App\Models\MpUserPointsDetail;
  9. use App\Services\PointsService;
  10. use App\Services\VolcEngineService;
  11. use GuzzleHttp\Client;
  12. use GuzzleHttp\Exception\GuzzleException;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Log;
  15. use Illuminate\Support\Facades\Redis;
  16. class AIImageGenerationService
  17. {
  18. private $volcEngineService;
  19. private $httpClient;
  20. /** @var PointsService */
  21. private $pointsService;
  22. // 模型常量
  23. const MODEL_JIMENG_4 = 'jimeng_4.0';
  24. const MODEL_NANO_BANANA_2 = 'NanoBanana2';
  25. const MODEL_NANO_BANANA_PRO = 'NanoBananaPro';
  26. const MODEL_GPT_IMAGE_2 = 'GptImage2';
  27. // 队列派发去重相关常量
  28. /** 派发锁在 Redis 中的 key 前缀 */
  29. const DISPATCH_LOCK_PREFIX = 'ai_pic_dispatch:';
  30. /** 派发锁使用的 Redis 连接,与队列保持一致 */
  31. const DISPATCH_LOCK_CONNECTION = 'default';
  32. /** 派发锁有效期(秒):worker 长时间未消费时的兜底重派时间 */
  33. const DISPATCH_LOCK_TTL = 600;
  34. /** 每轮最多派发的任务数,避免一次性推送过多任务 */
  35. const DISPATCH_BATCH_LIMIT = 50;
  36. /** 每轮扫描的候选任务数,需大于 DISPATCH_BATCH_LIMIT,避免已入队的任务挡住后面的任务 */
  37. const DISPATCH_CANDIDATE_LIMIT = 500;
  38. /** 派发锁分组:火山API */
  39. const DISPATCH_GROUP_VOLC = 'volc_pics';
  40. /** 派发锁分组:GPT-Image2 */
  41. const DISPATCH_GROUP_GPT_IMAGE2 = 'gpt_image2_pics';
  42. /** 图片任务最大尝试次数:达到上限仍失败则直接判失败,并写入最后一次的错误原因 */
  43. const MAX_TASK_ATTEMPTS = 10;
  44. /**
  45. * 单次上游请求超时(秒)
  46. *
  47. * 必须小于队列 job 的 timeout(540 秒),保证请求先超时、由业务层记录错误并重新排队,
  48. * 而不是被 worker 强杀导致任务长期停留在 processing。
  49. * 取值依据:4K 编辑(3840x2160 / 2160x3840)平均 148~152s、长尾超过 300s;
  50. * 批量提交时上游排队会把单请求推到 300s 以上,故取 480s 给长尾留出余量,
  51. * 并给 job 留 60s 收尾时间。
  52. */
  53. const REQUEST_TIMEOUT_SECONDS = 480;
  54. /** 连接超时(秒) */
  55. const REQUEST_CONNECT_TIMEOUT_SECONDS = 30;
  56. /** extra_params 中记录重试信息的键名 */
  57. const EXTRA_ATTEMPTS = 'attempts';
  58. const EXTRA_LAST_ERROR = 'last_error';
  59. const EXTRA_NEXT_RETRY_AT = 'next_retry_at';
  60. /**
  61. * 百度图片上游响应头中携带的任务/请求 ID(实测 gpt-image-2 系列生成与编辑接口均返回)。
  62. * 这些 ID 一并写入 result_json,用于后续与百度侧月度消耗数据比对。
  63. */
  64. const BAIDU_UPSTREAM_TASK_ID_HEADER = 'x-vod-task-id';
  65. const BAIDU_UPSTREAM_REQUEST_ID_HEADER = 'x-vod-request-id';
  66. const BAIDU_UPSTREAM_REQID_HEADER = 'http_x_reqid';
  67. /** result_json 中记录上游 ID 的键名 */
  68. const RESULT_UPSTREAM_TASK_ID = 'upstream_task_id';
  69. const RESULT_UPSTREAM_REQUEST_ID = 'upstream_request_id';
  70. const RESULT_UPSTREAM_REQID = 'upstream_reqid';
  71. /** 图片任务失败/重试时 token 明细的兜底归属用户(uid 缺失时使用,对账口径) */
  72. const IMAGE_TOKEN_FALLBACK_UID = 142857;
  73. public function __construct(VolcEngineService $volcEngineService, PointsService $pointsService)
  74. {
  75. $this->volcEngineService = $volcEngineService;
  76. $this->pointsService = $pointsService;
  77. $this->httpClient = new Client([
  78. 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
  79. 'connect_timeout' => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
  80. ]);
  81. }
  82. /**
  83. * 构建图片任务计费信息
  84. *
  85. * @param array $params
  86. * @param string $model
  87. * @return array
  88. */
  89. private function buildImageChargeInfo(array $params, string $model): array
  90. {
  91. $uid = 0;
  92. try {
  93. $uid = (int)Site::getUid();
  94. } catch (\Throwable $e) {
  95. // 非请求上下文(如命令行)下可能无法获取用户ID,置为0
  96. }
  97. $width = (int)getProp($params, 'width', 1600);
  98. $height = (int)getProp($params, 'height', 2848);
  99. $chargeInfo = [
  100. 'user_id' => $uid,
  101. 'model' => $model,
  102. 'width' => $width,
  103. 'height' => $height,
  104. 'resolution' => $this->pointsService->normalizeImageResolutionKey($width, $height),
  105. 'image_num' => (int)getProp($params, 'image_num', 1),
  106. 'source' => !empty($params['alias_segment_id']) ? 'segment' : (!empty($params['alias_act_id']) ? 'act' : ''),
  107. 'alias_segment_id' => $params['alias_segment_id'] ?? '',
  108. 'created_at' => date('Y-m-d H:i:s'),
  109. ];
  110. // 前端传入的锚点与业务来源标记(0 默认 / 1 资产 / 2 画布 → charge_info.source_from)
  111. $chargeInfo = array_merge($chargeInfo, ScriptAnchor::chargeContextFromRequest($params));
  112. // 锚点数据(anime_id / episode_id / episode_number / script_id,以及 alias_act_id / alias_segment_id),上下文没有的不记录
  113. return ScriptAnchor::mergeIntoChargeInfo($chargeInfo, $params);
  114. }
  115. /**
  116. * 图片生成余额预检
  117. *
  118. * 有用户上下文时校验本次生成所需积分(单价 × 生成张数),不足直接抛错;
  119. * 后台命令/队列等无用户上下文的场景跳过预检。
  120. *
  121. * @param array $chargeInfo
  122. * @return void
  123. */
  124. private function preCheckImageBalance(array $chargeInfo): void
  125. {
  126. $uid = (int)($chargeInfo['user_id'] ?? 0);
  127. if (!$uid) {
  128. return;
  129. }
  130. $imageNum = (int)($chargeInfo['image_num'] ?? 1);
  131. if ($imageNum <= 0) {
  132. $imageNum = 1;
  133. }
  134. $needPoints = $this->pointsService->getImageChargePoints($chargeInfo) * $imageNum;
  135. if ($needPoints <= 0) {
  136. return;
  137. }
  138. $this->pointsService->checkUserPointsEnough($needPoints, $uid);
  139. }
  140. /**
  141. * 图片生成成功统一处理(事务保证原子性)
  142. *
  143. * 在同一事务内完成:任务状态更新 + 积分计费 + 分镜表同步;
  144. * 任一步失败整体回滚,任务保持原状态,由后续定时任务/超时重置重试。
  145. *
  146. * @param MpGeneratePicTask $task
  147. * @param array $resultUrls
  148. * @param array $resultJson
  149. * @return void
  150. */
  151. private function handleImageTaskSuccess(MpGeneratePicTask $task, array $resultUrls, array $resultJson): void
  152. {
  153. try {
  154. DB::beginTransaction();
  155. // 更新任务状态为成功
  156. $task->updateStatus(MpGeneratePicTask::STATUS_SUCCESS, [
  157. 'result_url' => $resultUrls,
  158. 'result_json' => $resultJson,
  159. ]);
  160. // 记录计费明细并扣减用户积分(真实失败则抛错回滚,避免“成功但未计费”)
  161. $chargeResult = $this->pointsService->recordImageTaskCharge($task);
  162. if (empty($chargeResult['charged'])
  163. && !in_array((string)($chargeResult['reason'] ?? ''), ['already_charged', 'no_charge_info'], true)) {
  164. throw new \Exception('图片计费失败: ' . (string)($chargeResult['reason'] ?? 'unknown'));
  165. }
  166. // 同步调整分镜图片状态和结果
  167. $segmentId = getProp($task, 'alias_segment_id');
  168. if ($segmentId) {
  169. $updateSegmentResult = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->update([
  170. 'img_url' => $resultUrls[0],
  171. 'pic_task_status' => '已完成',
  172. ]);
  173. // 本应更新却未更新到(分镜被删除等),抛错触发整体回滚重试
  174. if ($updateSegmentResult === false || $updateSegmentResult === 0) {
  175. throw new \Exception('更新分镜图片状态失败: 未找到对应的分镜记录');
  176. }
  177. // 如果是首帧图ID则更新anime表
  178. if (Redis::sismember('anime_first_frame_urls', $segmentId)) {
  179. $animeId = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->value('anime_id');
  180. DB::table('mp_animes')->where('id', $animeId)->update(['first_frame_url' => $resultUrls[0], 'updated_at' => date('Y-m-d H:i:s')]);
  181. Redis::srem('anime_first_frame_urls', $segmentId);
  182. }
  183. }
  184. DB::commit();
  185. } catch (\Exception $e) {
  186. DB::rollBack();
  187. dLog('generate')->error('图片任务成功处理失败,已回滚: ' . $e->getMessage(), ['task_id' => $task->id]);
  188. logDB('generate', 'error', '图片任务成功处理失败,已回滚', [
  189. 'task_id' => $task->id,
  190. 'error' => $e->getMessage(),
  191. ]);
  192. }
  193. }
  194. /**
  195. * 异步创建图片生成任务
  196. *
  197. * @param array $params
  198. * @return MpGeneratePicTask
  199. */
  200. public function createImageGenerationTask(array $params): MpGeneratePicTask
  201. {
  202. // // 获取模型类型,默认使用即梦AI 4.0
  203. // $model = $params['model'] ?? self::MODEL_JIMENG_4;
  204. // 获取模型类型,默认使用即梦seedream5.0-lite
  205. $model = $params['model'] ?? defaultImageModel();
  206. // 构建计费信息并余额预检(不足直接报错)
  207. $chargeInfo = $this->buildImageChargeInfo($params, $model);
  208. $this->preCheckImageBalance($chargeInfo);
  209. // 检查是否有正在处理的任务(仅即梦AI需要排队)
  210. if ($model === self::MODEL_JIMENG_4 && $this->hasProcessingTask()) {
  211. // 如果有正在处理的任务,创建排队任务
  212. return $this->createQueuedTask($params);
  213. }
  214. // 生成唯一的任务ID
  215. $taskId = 'img_gen_' . time() . '_' . uniqid();
  216. // 额外参数:显式传入 quality 时记录,提交上游时按需携带(未传则不带该参数)
  217. $extraParams = $params['extra_params'] ?? [];
  218. if (!is_array($extraParams)) {
  219. $extraParams = [];
  220. }
  221. $quality = trim((string)($params['quality'] ?? ''));
  222. if ($quality !== '') {
  223. $extraParams['quality'] = $quality;
  224. }
  225. // 创建任务记录
  226. $task = MpGeneratePicTask::create([
  227. 'task_id' => $taskId,
  228. 'alias_segment_id' => $params['alias_segment_id'] ?? 0,
  229. 'prompt' => $params['prompt'] ?? '',
  230. 'width' => $params['width'] ?? 1600,
  231. 'height' => $params['height'] ?? 2848,
  232. 'image_num' => $params['image_num'] ?? 1,
  233. 'scale' => $params['scale'] ?? 50,
  234. 'ref_img_url' => $params['ref_img_urls'] ?? null,
  235. 'mask_img_url' => $params['mask_img_url'] ?? null,
  236. 'extra_params' => $extraParams ?: null,
  237. 'status' => MpGeneratePicTask::STATUS_PENDING,
  238. 'model' => $model,
  239. 'charge_info' => $chargeInfo,
  240. ]);
  241. // 即梦AI和NanoBanana系列立即提交任务,火山API在定时任务中提交
  242. if ($model === self::MODEL_JIMENG_4) {
  243. $this->submitTaskToJimengApi($task);
  244. } elseif (in_array($model, BaseConst::NANO_BANANA_MODELS)) {
  245. $this->submitTaskToNanoBananaApi($task);
  246. }
  247. return $task;
  248. }
  249. /**
  250. * 检查是否有正在处理的任务
  251. *
  252. * @return bool
  253. */
  254. private function hasProcessingTask(): bool
  255. {
  256. return MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)
  257. ->exists();
  258. }
  259. /**
  260. * 创建排队任务
  261. *
  262. * @param array $params
  263. * @return MpGeneratePicTask
  264. */
  265. private function createQueuedTask(array $params): MpGeneratePicTask
  266. {
  267. // 生成唯一的任务ID
  268. $taskId = 'img_gen_' . time() . '_' . uniqid();
  269. // 获取模型类型
  270. $model = $params['model'] ?? self::MODEL_JIMENG_4;
  271. // 构建计费信息并余额预检(不足直接报错)
  272. $chargeInfo = $this->buildImageChargeInfo($params, $model);
  273. $this->preCheckImageBalance($chargeInfo);
  274. // 创建排队状态的任务记录
  275. return MpGeneratePicTask::create([
  276. 'task_id' => $taskId,
  277. 'alias_segment_id' => $params['alias_segment_id'] ?? 0,
  278. 'prompt' => $params['prompt'] ?? '',
  279. 'width' => $params['width'] ?? 1600,
  280. 'height' => $params['height'] ?? 2848,
  281. 'image_num' => $params['image_num'] ?? 1,
  282. 'scale' => $params['scale'] ?? 50,
  283. 'ref_img_url' => $params['ref_img_urls'] ?? null,
  284. 'mask_img_url' => $params['mask_img_url'] ?? null,
  285. 'extra_params' => $params['extra_params'] ?? null,
  286. 'status' => MpGeneratePicTask::STATUS_PENDING,
  287. 'model' => $model,
  288. 'charge_info' => $chargeInfo,
  289. ]);
  290. }
  291. /**
  292. * 提交任务到即梦AI API
  293. *
  294. * @param MpGeneratePicTask $task
  295. * @return void
  296. */
  297. private function submitTaskToJimengApi(MpGeneratePicTask $task): void
  298. {
  299. try {
  300. // 验证环境变量配置
  301. $accessKey = env('VOLC_AI_AK');
  302. $secretKey = env('VOLC_AI_SK');
  303. if (empty($accessKey) || empty($secretKey)) {
  304. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  305. 'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK'
  306. ]);
  307. return;
  308. }
  309. //构建即梦AI 4.0 API请求参数
  310. $apiParams = [
  311. 'req_key' => 'jimeng_t2i_v40',
  312. 'prompt' => $task->prompt,
  313. 'scale' => $task->scale / 100, //为0-1范围
  314. ];
  315. // 添加可选参数
  316. if ($task->image_num > 1) {
  317. $apiParams['force_single'] = false;
  318. } else {
  319. $apiParams['force_single'] = true;
  320. }
  321. // 参考图片
  322. if ($task->ref_img_url) {
  323. $apiParams['image_urls'] = $task->ref_img_url;
  324. }
  325. // if ($task->mask_img_url) {
  326. // $apiParams['image_urls'] = $task->mask_img_url;
  327. // }
  328. // 尺寸参数
  329. $area = $task->width * $task->height; //计算面积
  330. if ($area >= 1024*1024 && $area <= 4096*4096) {
  331. $apiParams['size'] = (int)$area;
  332. }
  333. dLog('generate')->info('即梦AI文生图参数: ', $apiParams);
  334. logDB('generate', 'info', '即梦AI文生图任务提交', ['task_id' => $task->task_id, 'params' => $apiParams]);
  335. //调用即梦AI 4.0 API提交任务
  336. $response = $this->volcEngineService->request(
  337. 'POST',
  338. 'visual.volcengineapi.com', //即梦AI API域名
  339. '/', // 使用根路径
  340. [], // Query参数
  341. json_encode($apiParams),
  342. $accessKey,
  343. $secretKey,
  344. 'cv', // 服务标识
  345. 'cn-north-1', //区域
  346. 'CVSync2AsyncSubmitTask',
  347. '2022-08-31',
  348. 'application/json'
  349. );
  350. $responseData = json_decode($response['body'], true);
  351. dLog('generate')->info('即梦AI 4.0 API提交任务响应: ', $responseData);
  352. logDB('generate', 'info', '即梦AI 4.0 API提交任务响应', ['task_id' => $task->task_id, 'response' => $responseData]);
  353. if ($responseData['code'] !== 10000) {
  354. // API返回错误
  355. logDB('generate', 'error', '即梦AI任务提交失败', ['task_id' => $task->task_id, 'error' => $responseData['message'] ?? 'API Error']);
  356. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  357. 'error_message' => $responseData['message'] ?? 'API Error',
  358. 'extra_params' => $responseData['data'] ?? []
  359. ]);
  360. } else {
  361. // 任务提交成功,更新状态为处理中
  362. $updateData = [];
  363. //存储API返回的任务ID
  364. if (isset($responseData['data']['task_id'])) {
  365. $updateData['task_id'] = $responseData['data']['task_id'];
  366. $updateData['extra_params'] = $responseData ?? [];
  367. }
  368. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING, $updateData);
  369. }
  370. } catch (\Exception $e) {
  371. // 记录错误
  372. logDB('generate', 'error', '即梦AI任务提交异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
  373. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  374. 'error_message' => 'API请求失败: ' . $e->getMessage()
  375. ]);
  376. }
  377. }
  378. /**
  379. * 提交任务到GPT-Image2 API
  380. *
  381. * @param MpGeneratePicTask $task
  382. * @return void
  383. */
  384. public function submitTaskToGptImage2Api(MpGeneratePicTask $task): void
  385. {
  386. // 上游任务/请求 ID:百度渠道在响应头中返回,成功或失败都尽量记录到 result_json
  387. $upstreamIds = [];
  388. // 本次上游返回的响应体(失败/重试路径用于保留原始结果并补记 token)
  389. $lastResponseData = null;
  390. // 本次上游返回的响应头(编辑模式 cURL / 文生图 Guzzle 均采集),
  391. // 超时或异常时写入 result_json,用于判断该次是否已拿到上游任务/请求 ID
  392. $rawResponseHeaders = [];
  393. try {
  394. // 渠道判定:baidu- 前缀走「百度生成模型服务V2」(gpt-image-2 / 2.5 系),其余走现有快快渠道
  395. $isBaiduChannel = strpos((string)$task->model, 'baidu-') === 0;
  396. // 上游模型名不带渠道前缀:表里存 baidu-gpt-image-2,调用上游用 gpt-image-2
  397. $upstreamModel = $isBaiduChannel ? substr((string)$task->model, strlen('baidu-')) : (string)$task->model;
  398. // 验证环境变量配置
  399. $apiKey = $isBaiduChannel ? env('BAIDU_GPT_IMAGE_AK') : env('GPT_IMAGE2_API_KEY');
  400. if (empty($apiKey)) {
  401. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  402. 'error_message' => $isBaiduChannel
  403. ? '百度GPT-Image接口KEY未配置,请检查环境变量BAIDU_GPT_IMAGE_AK'
  404. : 'GPT-Image2 API KEY未配置,请检查环境变量GPT_IMAGE2_API_KEY'
  405. ]);
  406. return;
  407. }
  408. // 重试上限控制:已达上限直接判失败(error_message 内含最后一次失败原因)
  409. if (!$this->beginTaskAttempt($task)) {
  410. return;
  411. }
  412. // 更新状态为处理中
  413. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
  414. // 渠道 base url:百度生成模型服务V2(默认新加坡节点)/ 快快中转
  415. $apiBase = $isBaiduChannel
  416. ? rtrim((string)env('BAIDU_GPT_IMAGE_BASE_URL', 'https://eos-sg.aigc.exp.bcevod.com/v1'), '/')
  417. : 'https://ai-api.kkidc.com/v1';
  418. $api_url = $apiBase . '/images/generations';
  419. $isEditMode = false;
  420. // 参考图片 - 如果有参考图则使用 /edits 接口
  421. if ($task->ref_img_url) {
  422. $api_url = $apiBase . '/images/edits';
  423. $isEditMode = true;
  424. }
  425. // 图像质量(OpenAI gpt-image-2 的 quality 参数):仅当创建任务时显式传入才携带
  426. $taskExtraParams = $task->extra_params;
  427. $taskQuality = is_array($taskExtraParams) ? trim((string)getProp($taskExtraParams, 'quality', '')) : '';
  428. $channelLog = ['task_id' => $task->task_id, 'mode' => $isEditMode ? 'edit' : 'generation', 'channel' => $isBaiduChannel ? 'baidu' : 'kkidc', 'model' => $upstreamModel, 'quality' => $taskQuality];
  429. dLog('generate')->info('开始调用GPT-Image2 API', $channelLog);
  430. logDB('generate', 'info', '开始调用GPT-Image2 API', $channelLog);
  431. // 创建专用客户端,优化配置
  432. $gptClient = new Client([
  433. // 超时必须小于队列 job 的 timeout(540s):请求先超时并抛出异常,
  434. // 由业务层记录错误并重新排队,避免 worker 强杀导致任务长期悬挂
  435. 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
  436. 'connect_timeout' => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
  437. 'http_errors' => false, // 不自动抛出HTTP错误
  438. 'verify' => false, // 禁用SSL验证(如果有证书问题)
  439. 'allow_redirects' => true,
  440. // 响应头到达即采集,body 卡住超时时也能记录到上游任务/请求 ID
  441. 'on_headers' => function (\Psr\Http\Message\ResponseInterface $response) use (&$rawResponseHeaders) {
  442. $rawResponseHeaders = $response->getHeaders();
  443. },
  444. ]);
  445. // 根据是否有参考图选择不同的请求方式
  446. if ($isEditMode) {
  447. // 使用原生 cURL 处理 multipart/form-data 格式
  448. $imageUrls = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
  449. // 多图上限:暂不限制张数(如需恢复限制,放开下面这行)
  450. // $imageUrls = array_slice($imageUrls, 0, 4);
  451. dLog('generate')->info('下载参考图片', ['image_urls' => $imageUrls, 'count' => count($imageUrls)]);
  452. // 单张参考图超过该大小时压缩,避免请求体过大被网关拒绝
  453. $maxRefBytes = 5 * 1024 * 1024;
  454. // 下载所有参考图片并保存为临时文件
  455. $tempFiles = [];
  456. foreach ($imageUrls as $index => $imageUrl) {
  457. $imageContent = @file_get_contents($imageUrl);
  458. if ($imageContent === false) {
  459. dLog('generate')->warning('下载参考图片失败,跳过', ['image_url' => $imageUrl]);
  460. continue;
  461. }
  462. dLog('generate')->info('参考图片下载完成', ['index' => $index, 'size' => strlen($imageContent)]);
  463. $extension = pathinfo(parse_url($imageUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'png';
  464. // 超过 5MB 时压缩(公共方法保持原图比例与原格式)
  465. if (strlen($imageContent) > $maxRefBytes) {
  466. $rawSize = strlen($imageContent);
  467. $compressedData = compressRemoteImageUrlToSize($imageUrl, $maxRefBytes);
  468. if (!empty($compressedData)) {
  469. $imageContent = $compressedData;
  470. $imgInfo = @getimagesizefromstring($imageContent);
  471. $mime = strtolower($imgInfo['mime'] ?? '');
  472. $extension = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/webp' ? 'webp' : 'png');
  473. } else {
  474. // 公共方法对 JPEG 不做有损压缩,回退到本地缩放
  475. $fallbackData = $this->compressReferenceImageForEdit($imageContent, 2048, 90);
  476. if ($fallbackData !== null) {
  477. $imageContent = $fallbackData;
  478. $extension = 'jpg';
  479. }
  480. }
  481. dLog('generate')->info('参考图片压缩完成', [
  482. 'index' => $index,
  483. 'before' => $rawSize,
  484. 'after' => strlen($imageContent),
  485. 'extension' => $extension,
  486. ]);
  487. }
  488. // 保存为临时文件(cURL 需要真实文件路径)
  489. $tempFile = tempnam(sys_get_temp_dir(), 'gpt_img_') . '.' . $extension;
  490. file_put_contents($tempFile, $imageContent);
  491. $tempFiles[] = [
  492. 'path' => $tempFile,
  493. 'extension' => $extension
  494. ];
  495. dLog('generate')->info('临时文件创建成功', ['file' => $tempFile, 'size' => filesize($tempFile)]);
  496. }
  497. if (empty($tempFiles)) {
  498. throw new \Exception('所有参考图片下载失败');
  499. }
  500. // 构建 POST 字段(文本字段)
  501. $textFields = [
  502. 'prompt' => $task->prompt,
  503. 'model' => $upstreamModel,
  504. 'n' => (string)$task->image_num, // 新接口要求字符串类型
  505. ];
  506. if (!$isBaiduChannel) {
  507. // 快快渠道要求显式指定返回格式;百度渠道默认返回 b64_json
  508. $textFields['response_format'] = 'b64_json';
  509. }
  510. // 图像质量:显式传入 quality 才携带(百度 / 快快渠道一致)
  511. if ($taskQuality !== '') {
  512. $textFields['quality'] = $taskQuality;
  513. }
  514. // 尺寸参数(必填)
  515. if ($task->width && $task->height) {
  516. $textFields['size'] = $task->width . 'x' . $task->height;
  517. } else {
  518. $textFields['size'] = '1024x1024'; // 默认尺寸
  519. }
  520. // 多张参考图需要在 multipart 中出现重复的 image[] 字段;
  521. // PHP 数组无法承载重复 key(同名会覆盖),因此手动拼装请求体。
  522. // 单张参考图沿用 image 字段,避免影响已有单图任务。
  523. $fileFieldName = count($tempFiles) > 1 ? 'image[]' : 'image';
  524. $boundary = '----MpGptImage' . md5(uniqid('', true));
  525. $postFields = $this->buildMultipartBody($textFields, $tempFiles, $boundary, $fileFieldName);
  526. dLog('generate')->info('GPT-Image2 API参数(编辑模式-cURL)', [
  527. 'api_url' => $api_url,
  528. 'prompt' => $task->prompt,
  529. 'size' => $textFields['size'],
  530. 'n' => $textFields['n'],
  531. 'response_format' => getProp($textFields, 'response_format', ''),
  532. 'file_field' => $fileFieldName,
  533. 'body_size' => strlen($postFields),
  534. 'image_count' => count($tempFiles)
  535. ]);
  536. logDB('generate', 'info', 'GPT-Image2任务提交(编辑模式)', [
  537. 'task_id' => $task->task_id,
  538. 'api_url' => $api_url,
  539. 'image_urls' => $imageUrls,
  540. 'prompt' => $task->prompt
  541. ]);
  542. $startTime = microtime(true);
  543. // 使用原生 cURL;响应头由方法上方统一声明的 $rawResponseHeaders 采集
  544. $ch = curl_init();
  545. curl_setopt_array($ch, [
  546. CURLOPT_URL => $api_url,
  547. CURLOPT_RETURNTRANSFER => true,
  548. CURLOPT_POST => true,
  549. CURLOPT_POSTFIELDS => $postFields,
  550. CURLOPT_HTTPHEADER => [
  551. 'Authorization: Bearer ' . $apiKey,
  552. 'Accept: application/json',
  553. 'Content-Type: multipart/form-data; boundary=' . $boundary,
  554. 'Content-Length: ' . strlen($postFields),
  555. 'Expect:', // 移除 Expect: 100-continue,避免等待
  556. ],
  557. CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT_SECONDS, // 与 job timeout(540s) 对齐,先超时再落状态
  558. CURLOPT_CONNECTTIMEOUT => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
  559. CURLOPT_SSL_VERIFYPEER => false,
  560. CURLOPT_SSL_VERIFYHOST => false,
  561. CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, // 强制使用 HTTP/1.1
  562. CURLOPT_HEADERFUNCTION => function ($ch, $headerLine) use (&$rawResponseHeaders) {
  563. $separatorPos = strpos($headerLine, ':');
  564. if ($separatorPos !== false) {
  565. $headerName = strtolower(trim(substr($headerLine, 0, $separatorPos)));
  566. $rawResponseHeaders[$headerName][] = trim(substr($headerLine, $separatorPos + 1));
  567. }
  568. return strlen($headerLine);
  569. },
  570. ]);
  571. dLog('generate')->info('开始发送cURL请求');
  572. $responseBody = curl_exec($ch);
  573. $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  574. $curlError = curl_error($ch);
  575. $curlErrno = curl_errno($ch);
  576. curl_close($ch);
  577. // 解析上游任务/请求 ID(失败响应同样可能携带)
  578. $upstreamIds = $this->extractBaiduUpstreamIds($rawResponseHeaders);
  579. // 解析响应体(含非 JSON 的原始片段),供失败/重试路径记录
  580. $lastResponseData = $this->decodeUpstreamBody($responseBody);
  581. // 删除所有临时文件
  582. foreach ($tempFiles as $tempFile) {
  583. @unlink($tempFile['path']);
  584. }
  585. $elapsed = round(microtime(true) - $startTime, 2);
  586. dLog('generate')->info('cURL请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode, 'curl_errno' => $curlErrno]);
  587. if ($curlErrno !== 0) {
  588. throw new \Exception('cURL错误: ' . $curlError . ' (错误码: ' . $curlErrno . ')');
  589. }
  590. if ($statusCode >= 500) {
  591. // 上游 5xx:按可重试错误处理(放回队列,稍后再试)
  592. throw new \RuntimeException('上游服务异常,HTTP状态码: ' . $statusCode);
  593. }
  594. if (empty($responseBody)) {
  595. throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
  596. }
  597. } else {
  598. // 使用 JSON 格式 (原有的文生图模式)
  599. $apiParams = [
  600. 'model' => $upstreamModel,
  601. 'prompt' => $task->prompt,
  602. 'n' => (int)$task->image_num,
  603. ];
  604. // 图像质量:显式传入 quality 才携带(百度 / 快快渠道一致),未传则不带该参数、由上游按默认处理
  605. if ($taskQuality !== '') {
  606. $apiParams['quality'] = $taskQuality;
  607. }
  608. if (!$isBaiduChannel) {
  609. // 快快渠道要求显式指定返回格式;百度渠道默认返回 b64_json
  610. $apiParams['response_format'] = 'b64_json';
  611. }
  612. // 尺寸参数
  613. if ($task->width && $task->height) {
  614. $apiParams['size'] = $task->width . 'x' . $task->height;
  615. } else {
  616. $apiParams['size'] = 'auto'; // 新接口必填参数
  617. }
  618. dLog('generate')->info('GPT-Image2 API参数', $apiParams);
  619. logDB('generate', 'info', 'GPT-Image2任务提交', ['task_id' => $task->task_id, 'api_url' => $api_url, 'params' => $apiParams]);
  620. $startTime = microtime(true);
  621. // 调用GPT-Image2 API
  622. $response = $gptClient->post($api_url, [
  623. 'headers' => [
  624. 'Authorization' => 'Bearer ' . $apiKey,
  625. 'Content-Type' => 'application/json',
  626. ],
  627. 'json' => $apiParams,
  628. ]);
  629. $statusCode = $response->getStatusCode();
  630. $responseBody = $response->getBody()->getContents();
  631. $upstreamIds = $this->extractBaiduUpstreamIds($response->getHeaders());
  632. // 解析响应体(含非 JSON 的原始片段),供失败/重试路径记录
  633. $lastResponseData = $this->decodeUpstreamBody($responseBody);
  634. $elapsed = round(microtime(true) - $startTime, 2);
  635. dLog('generate')->info('Guzzle请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode]);
  636. if ($statusCode >= 500) {
  637. // 上游 5xx:按可重试错误处理(放回队列,稍后再试)
  638. throw new \RuntimeException('上游服务异常,HTTP状态码: ' . $statusCode . ',响应: ' . mb_substr((string)$responseBody, 0, 200));
  639. }
  640. if (empty($responseBody)) {
  641. throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
  642. }
  643. }
  644. // 解析响应
  645. dLog('generate')->info('响应数据长度', ['length' => strlen($responseBody)]);
  646. $responseData = json_decode($responseBody, true);
  647. if (json_last_error() !== JSON_ERROR_NONE) {
  648. dLog('generate')->error('JSON解析失败', ['error' => json_last_error_msg(), 'body' => substr($responseBody, 0, 500)]);
  649. throw new \Exception('JSON解析失败: ' . json_last_error_msg());
  650. }
  651. // 移除 b64_json 字段用于日志记录,避免生成超大日志
  652. $logResponseData = $this->removeB64JsonFromResponse($responseData);
  653. dLog('generate')->info('GPT-Image2 API响应', $logResponseData);
  654. logDB('generate', 'info', 'GPT-Image2 API响应', ['task_id' => $task->task_id, 'response' => $logResponseData]);
  655. if (isset($responseData['error'])) {
  656. // API返回错误
  657. logDB('generate', 'error', 'GPT-Image2任务失败', ['task_id' => $task->task_id, 'error' => $responseData['error']['message'] ?? 'API Error']);
  658. // 准确记录上游返回结果;若含 usage 则补记 token 明细(失败不扣积分)
  659. $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, 'GPT-Image2任务失败,仅记录token不扣积分');
  660. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  661. 'error_message' => $responseData['error']['message'] ?? 'API Error',
  662. 'result_json' => $failResultJson
  663. ]);
  664. } else {
  665. // 任务提交成功,直接处理结果
  666. dLog('generate')->info('开始处理GPT-Image2响应结果');
  667. $this->processGptImage2ApiResponse($task, $responseData, $upstreamIds);
  668. }
  669. } catch (\Exception $e) {
  670. // 记录错误
  671. dLog('generate')->error('GPT-Image2 API请求失败: ' . $e->getMessage(), [
  672. 'task_id' => $task->task_id,
  673. 'trace' => $e->getTraceAsString()
  674. ]);
  675. logDB('generate', 'error', 'GPT-Image2任务异常', [
  676. 'task_id' => $task->task_id,
  677. 'error' => $e->getMessage(),
  678. 'file' => $e->getFile(),
  679. 'line' => $e->getLine()
  680. ]);
  681. $errorMessage = 'API请求失败: ' . $e->getMessage();
  682. if ($this->isRetryableApiError($e)) {
  683. // 超时/连接失败等可重试错误:先记录上游返回结果/token,再放回队列尾部重试
  684. $failResultJson = $this->captureFailedAttempt($task, $lastResponseData, $upstreamIds, '图片任务失败重试,仅记录token不扣积分', $rawResponseHeaders);
  685. $this->requeueTaskForRetry($task, $errorMessage, $failResultJson);
  686. return;
  687. }
  688. // 最终失败:同样记录上游返回结果与 token(失败不扣积分)
  689. $failResultJson = $this->captureFailedAttempt($task, $lastResponseData, $upstreamIds, '图片任务最终失败,仅记录token不扣积分', $rawResponseHeaders);
  690. $failData = ['error_message' => $errorMessage];
  691. if (!empty($failResultJson)) {
  692. $failData['result_json'] = $failResultJson;
  693. }
  694. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, $failData);
  695. }
  696. }
  697. /**
  698. * 压缩参考图片(兜底方案,用于公共压缩方法不处理的场景,如大尺寸 JPEG)
  699. *
  700. * 缩放到长边不超过 $maxSide,并统一输出 JPEG(透明背景填白)。
  701. *
  702. * @param string $imageStream 图片二进制数据
  703. * @param int $maxSide 长边像素上限
  704. * @param int $quality JPEG 质量
  705. * @return string|null 压缩后的二进制数据,失败返回 null
  706. */
  707. private function compressReferenceImageForEdit(string $imageStream, int $maxSide = 1536, int $quality = 90): ?string
  708. {
  709. try {
  710. $imgInfo = @getimagesizefromstring($imageStream);
  711. if (!$imgInfo) {
  712. return null;
  713. }
  714. $srcImg = @imagecreatefromstring($imageStream);
  715. if (!$srcImg) {
  716. return null;
  717. }
  718. $origW = imagesx($srcImg);
  719. $origH = imagesy($srcImg);
  720. $scale = $maxSide / max($origW, $origH);
  721. if ($scale < 1) {
  722. $newW = max(1, (int)round($origW * $scale));
  723. $newH = max(1, (int)round($origH * $scale));
  724. $resizedImg = imagecreatetruecolor($newW, $newH);
  725. // JPEG 不支持透明通道,用白底填充
  726. $white = imagecolorallocate($resizedImg, 255, 255, 255);
  727. imagefill($resizedImg, 0, 0, $white);
  728. imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
  729. imagedestroy($srcImg);
  730. $srcImg = $resizedImg;
  731. } elseif ($imgInfo['mime'] !== 'image/jpeg') {
  732. // 未缩放但非 JPEG:铺白底再输出,避免透明区域变黑
  733. $flatImg = imagecreatetruecolor($origW, $origH);
  734. $white = imagecolorallocate($flatImg, 255, 255, 255);
  735. imagefill($flatImg, 0, 0, $white);
  736. imagecopy($flatImg, $srcImg, 0, 0, 0, 0, $origW, $origH);
  737. imagedestroy($srcImg);
  738. $srcImg = $flatImg;
  739. }
  740. ob_start();
  741. imagejpeg($srcImg, null, $quality);
  742. $compressed = ob_get_clean();
  743. imagedestroy($srcImg);
  744. return ($compressed === false || $compressed === '') ? null : $compressed;
  745. } catch (\Throwable $e) {
  746. dLog('generate')->warning('参考图片压缩失败,使用原图', ['error' => $e->getMessage()]);
  747. return null;
  748. }
  749. }
  750. /**
  751. * 构建 multipart/form-data 请求体
  752. *
  753. * 参考图字段需要在请求体中出现多次(image[]),而 PHP 数组同一个 key 只能保留最后一个值,
  754. * 用 CURLOPT_POSTFIELDS 传数组会导致多张参考图互相覆盖,所以这里手动拼装请求体字符串。
  755. *
  756. * @param array $textFields 普通文本字段 [name => value]
  757. * @param array $files 文件字段,每项含 path、extension
  758. * @param string $boundary multipart 分隔符
  759. * @param string $fileFieldName 文件字段名,多图时传 image[],单图传 image
  760. * @return string
  761. */
  762. private function buildMultipartBody(array $textFields, array $files, string $boundary, string $fileFieldName = 'image[]'): string
  763. {
  764. $eol = "\r\n";
  765. $body = '';
  766. foreach ($textFields as $name => $value) {
  767. $body .= '--' . $boundary . $eol;
  768. $body .= 'Content-Disposition: form-data; name="' . $name . '"' . $eol . $eol;
  769. $body .= (string)$value . $eol;
  770. }
  771. foreach ($files as $index => $file) {
  772. $extension = $file['extension'];
  773. $filename = 'image' . ($index + 1) . '.' . $extension;
  774. $mime = strtolower($extension) === 'jpg' ? 'image/jpeg' : 'image/' . $extension;
  775. $content = @file_get_contents($file['path']);
  776. if ($content === false) {
  777. throw new \Exception('读取参考图片临时文件失败: ' . $file['path']);
  778. }
  779. $body .= '--' . $boundary . $eol;
  780. $body .= 'Content-Disposition: form-data; name="' . $fileFieldName . '"; filename="' . $filename . '"' . $eol;
  781. $body .= 'Content-Type: ' . $mime . $eol . $eol;
  782. $body .= $content . $eol;
  783. unset($content);
  784. }
  785. $body .= '--' . $boundary . '--' . $eol;
  786. return $body;
  787. }
  788. /**
  789. * 处理GPT-Image2 API响应结果
  790. *
  791. * @param MpGeneratePicTask $task
  792. * @param array $responseData
  793. * @param array $upstreamIds 上游响应头中解析出的任务/请求 ID
  794. * @return void
  795. */
  796. private function processGptImage2ApiResponse(MpGeneratePicTask $task, array $responseData, array $upstreamIds = []): void
  797. {
  798. try {
  799. if (!isset($responseData['data']) || empty($responseData['data'])) {
  800. $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, '图片任务未返回图片数据,仅记录token不扣积分');
  801. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  802. 'error_message' => '未返回图片数据',
  803. 'result_json' => $failResultJson
  804. ]);
  805. return;
  806. }
  807. $result_urls = [];
  808. foreach ($responseData['data'] as $index => $imageData) {
  809. // 优先处理 b64_json 格式(新接口返回格式)
  810. if (isset($imageData['b64_json']) && !empty($imageData['b64_json'])) {
  811. $base64Data = $imageData['b64_json'];
  812. $imageStream = base64_decode($base64Data);
  813. if ($imageStream === false) {
  814. dLog('generate')->error('Base64解码失败', ['task_id' => $task->task_id, 'index' => $index]);
  815. continue;
  816. }
  817. $pic_name = 'ai_generation_gpt_' . time() . '_' . uniqid() . '.png';
  818. // 压缩PNG图片流
  819. $compressedData = $this->compressImageStream($imageStream);
  820. $url = uploadStreamByTos('image', $compressedData, $pic_name);
  821. $result_urls[] = $url;
  822. // 记录优化后的提示词(如果有)
  823. if (isset($imageData['revised_prompt'])) {
  824. dLog('generate')->info('图片生成优化提示词', [
  825. 'task_id' => $task->task_id,
  826. 'index' => $index,
  827. 'revised_prompt' => $imageData['revised_prompt']
  828. ]);
  829. }
  830. } elseif (isset($imageData['url']) && !empty($imageData['url'])) {
  831. // 兼容旧的 url 格式(如果接口仍返回)
  832. $url = $imageData['url'];
  833. $pic_name = 'ai_generation_gpt_' . time() . '_' . uniqid();
  834. $pic_ext = getImgExtFromUrl($url);
  835. $pic_name = $pic_name . $pic_ext;
  836. if ($pic_ext === '.png') {
  837. $comporessed_data = compressRemoteImageUrlToSize($url);
  838. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  839. } else {
  840. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  841. }
  842. $result_urls[] = $url;
  843. }
  844. }
  845. if (empty($result_urls)) {
  846. $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, '图片任务所有图片生成失败,仅记录token不扣积分');
  847. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  848. 'error_message' => '所有图片生成失败',
  849. 'result_json' => $failResultJson
  850. ]);
  851. return;
  852. }
  853. // 移除 b64_json 字段以避免存储大量 Base64 数据
  854. $cleanedResponseData = $responseData;
  855. if (isset($cleanedResponseData['data'])) {
  856. foreach ($cleanedResponseData['data'] as &$imageData) {
  857. unset($imageData['b64_json']); // 移除 Base64 数据
  858. }
  859. unset($imageData); // 解除引用
  860. }
  861. // 上游任务/请求 ID 随 result_json 一并落库,供月度消耗比对
  862. $cleanedResponseData = $this->mergeUpstreamIds($cleanedResponseData, $upstreamIds);
  863. // 保留此前重试失败时留下的超时响应头,任务最终成功也能回溯该次是否拿到上游 ID
  864. if (is_array($task->result_json) && !empty($task->result_json['raw_response_headers'])) {
  865. $cleanedResponseData['raw_response_headers'] = $task->result_json['raw_response_headers'];
  866. }
  867. // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
  868. $this->handleImageTaskSuccess($task, $result_urls, $cleanedResponseData);
  869. } catch (\Exception $e) {
  870. $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, '图片任务处理响应失败,仅记录token不扣积分');
  871. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  872. 'error_message' => '处理响应失败: ' . $e->getMessage(),
  873. 'result_json' => $failResultJson
  874. ]);
  875. }
  876. }
  877. /**
  878. * 提交任务到火山图片生成API
  879. *
  880. * @param MpGeneratePicTask $task
  881. * @return void
  882. */
  883. public function submitTaskToVolcApi(MpGeneratePicTask $task): void
  884. {
  885. try {
  886. // 验证环境变量配置
  887. $apiKey = env('VOLC_AI_API_KEY');
  888. if (empty($apiKey)) {
  889. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  890. 'error_message' => '火山引擎API KEY未配置,请检查环境变量VOLC_AI_API_KEY'
  891. ]);
  892. return;
  893. }
  894. // 重试上限控制:已达上限直接判失败(error_message 内含最后一次失败原因)
  895. if (!$this->beginTaskAttempt($task)) {
  896. return;
  897. }
  898. // 更新状态为处理中
  899. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
  900. // 构建火山图片生成API请求参数
  901. $apiParams = [
  902. 'model' => isset(BaseConst::MODELS_MAP[$task->model]) ? BaseConst::MODELS_MAP[$task->model] : $task->model,
  903. 'prompt' => $task->prompt,
  904. 'watermark' => false,
  905. ];
  906. // 尺寸参数
  907. if ($task->width && $task->height) {
  908. $apiParams['size'] = $task->width . 'x' . $task->height;
  909. }
  910. // 参考图片
  911. if ($task->ref_img_url) {
  912. if (is_array($task->ref_img_url)) {
  913. // $apiParams['image'] = count($task->ref_img_url) === 1 ? $task->ref_img_url[0] : $task->ref_img_url;
  914. $apiParams['image'] = $task->ref_img_url;
  915. } else {
  916. $apiParams['image'] = $task->ref_img_url;
  917. }
  918. }
  919. // 生成多图或者单图
  920. $imageNum = (int)$task->image_num;
  921. if ($imageNum === 1) {
  922. $apiParams['sequential_image_generation'] = 'disabled';
  923. }else if ($imageNum > 1) {
  924. // 组图模式下由模型决定实际生成张数,这里在提示词末尾明确要求生成对应数量,并将组图上限设置为目标张数
  925. $apiParams['prompt'] = rtrim((string)$task->prompt) . "\n生成{$imageNum}张图片";
  926. $apiParams['sequential_image_generation'] = 'auto';
  927. $apiParams['sequential_image_generation_options']['max_images'] = $imageNum;
  928. }
  929. dLog('generate')->info('火山图片生成API参数: ', $apiParams);
  930. logDB('generate', 'info', '火山图片生成任务提交', ['task_id' => $task->task_id, 'params' => $apiParams]);
  931. // 调用火山图片生成API
  932. $response = $this->httpClient->post('https://ark.cn-beijing.volces.com/api/v3/images/generations', [
  933. 'headers' => [
  934. 'Authorization' => 'Bearer ' . $apiKey,
  935. 'Content-Type' => 'application/json',
  936. ],
  937. 'json' => $apiParams,
  938. ]);
  939. $responseData = json_decode($response->getBody()->getContents(), true);
  940. dLog('generate')->info('火山图片生成API响应: ', $responseData);
  941. logDB('generate', 'info', '火山图片生成API响应', ['task_id' => $task->task_id, 'response' => $responseData]);
  942. if (isset($responseData['error'])) {
  943. // API返回错误
  944. logDB('generate', 'error', '火山图片生成任务失败', ['task_id' => $task->task_id, 'error' => $responseData['error']['message'] ?? 'API Error']);
  945. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  946. 'error_message' => $responseData['error']['message'] ?? 'API Error',
  947. 'result_json' => $responseData
  948. ]);
  949. // 上游报错时若已返回 usage,仅补记 token(失败不扣积分)
  950. $this->recordFailedTaskTokens($task, $responseData, '火山图片生成任务失败,仅记录token不扣积分');
  951. } else {
  952. // 任务提交成功,直接处理结果
  953. $this->processVolcApiResponse($task, $responseData);
  954. }
  955. } catch (\Exception $e) {
  956. // 记录错误
  957. dLog('generate')->error('火山API请求失败: ' . $e->getMessage());
  958. logDB('generate', 'error', '火山图片生成任务异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
  959. $errorMessage = 'API请求失败: ' . $e->getMessage();
  960. if ($this->isRetryableApiError($e)) {
  961. // 超时/连接失败等可重试错误:放回队列尾部重试,先让其它任务执行
  962. $this->requeueTaskForRetry($task, $errorMessage);
  963. return;
  964. }
  965. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  966. 'error_message' => $errorMessage
  967. ]);
  968. }
  969. }
  970. /**
  971. * 图片任务失败/重试时,把上游返回的 usage 补记成 token 明细(失败不扣积分)
  972. *
  973. * 失败/重试任务不走 recordImageTaskCharge(那是成功计费),这里只补 token 记录;
  974. * 明细以 task_id=NULL 落库((type, task_id) 唯一索引下多个 NULL 互不冲突),
  975. * 既不占用成功计费行,也不会阻断后续成功扣费。
  976. *
  977. * @param MpGeneratePicTask $task
  978. * @param mixed $responseData 上游响应(含 usage 时才会落库)
  979. * @param string $remark 备注
  980. * @param array $upstreamIds 上游响应头中解析出的任务/请求 ID
  981. * @return void
  982. */
  983. private function recordFailedTaskTokens(MpGeneratePicTask $task, $responseData, string $remark, array $upstreamIds = []): void
  984. {
  985. try {
  986. if (!is_array($responseData)) {
  987. return;
  988. }
  989. $usage = isset($responseData['usage']) && is_array($responseData['usage']) ? $responseData['usage'] : [];
  990. $tokens = (int)($usage['total_tokens'] ?? $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0);
  991. if ($tokens <= 0) {
  992. dLog('generate')->info('图片任务失败响应未含usage,跳过token记账', ['task_id' => $task->task_id, 'remark' => $remark]);
  993. return;
  994. }
  995. $chargeInfo = $task->charge_info;
  996. if (is_string($chargeInfo)) {
  997. $chargeInfo = json_decode($chargeInfo, true);
  998. }
  999. if (!is_array($chargeInfo)) {
  1000. $chargeInfo = [];
  1001. }
  1002. // 归属用户:优先任务自带 user_id,缺失时用图片兜底 uid(对账口径)
  1003. $uid = (int)getProp($chargeInfo, 'user_id', 0);
  1004. if ($uid <= 0) {
  1005. $uid = self::IMAGE_TOKEN_FALLBACK_UID;
  1006. $chargeInfo['fallback_uid'] = true;
  1007. dLog('generate')->warning('图片失败尝试token明细归属用户缺失,使用兜底uid记账', [
  1008. 'fallback_uid' => $uid,
  1009. 'task_id' => $task->task_id,
  1010. ]);
  1011. }
  1012. // 记录任务关联信息,便于月度审查把 token 明细回溯到具体图片任务与上游任务
  1013. $chargeInfo['image_task_id'] = (int)$task->id;
  1014. $chargeInfo['external_task_id'] = (string)$task->task_id;
  1015. if (!empty($upstreamIds)) {
  1016. $chargeInfo['upstream_ids'] = $upstreamIds;
  1017. }
  1018. $this->pointsService->recordUnchargedAttemptTokens(
  1019. $uid,
  1020. $tokens,
  1021. MpUserPointsDetail::TYPE_IMAGE,
  1022. (string)getProp($task, 'model', ''),
  1023. $chargeInfo,
  1024. $remark
  1025. );
  1026. dLog('generate')->info('图片任务失败尝试token明细已记录', [
  1027. 'task_id' => $task->task_id,
  1028. 'tokens' => $tokens,
  1029. 'uid' => $uid,
  1030. 'remark' => $remark,
  1031. ]);
  1032. } catch (\Throwable $e) {
  1033. dLog('generate')->warning('图片任务失败尝试token明细记录异常(不影响主流程): ' . $e->getMessage(), [
  1034. 'task_id' => $task->task_id ?? null,
  1035. ]);
  1036. }
  1037. }
  1038. /**
  1039. * 处理火山API响应结果
  1040. *
  1041. * @param MpGeneratePicTask $task
  1042. * @param array $responseData
  1043. * @return void
  1044. */
  1045. private function processVolcApiResponse(MpGeneratePicTask $task, array $responseData): void
  1046. {
  1047. try {
  1048. if (!isset($responseData['data']) || empty($responseData['data'])) {
  1049. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1050. 'error_message' => '未返回图片数据',
  1051. 'result_json' => $responseData
  1052. ]);
  1053. $this->recordFailedTaskTokens($task, $responseData, '未返回图片数据,仅记录token不扣积分');
  1054. return;
  1055. }
  1056. $result_urls = [];
  1057. foreach ($responseData['data'] as $imageData) {
  1058. if (isset($imageData['error'])) {
  1059. // 单张图片生成失败
  1060. dLog('generate')->warning('单张图片生成失败: ', $imageData['error']);
  1061. continue;
  1062. }
  1063. if (isset($imageData['url'])) {
  1064. $url = $imageData['url'];
  1065. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  1066. $pic_ext = getImgExtFromUrl($url);
  1067. $pic_name = $pic_name . $pic_ext;
  1068. if ($pic_ext === '.png') {
  1069. $comporessed_data = compressRemoteImageUrlToSize($url);
  1070. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  1071. } else {
  1072. // 将图片另存到tos
  1073. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  1074. }
  1075. $result_urls[] = $url;
  1076. }
  1077. }
  1078. if (empty($result_urls)) {
  1079. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1080. 'error_message' => '所有图片生成失败',
  1081. 'result_json' => $responseData
  1082. ]);
  1083. $this->recordFailedTaskTokens($task, $responseData, '所有图片生成失败,仅记录token不扣积分');
  1084. return;
  1085. }
  1086. // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
  1087. $this->handleImageTaskSuccess($task, $result_urls, $responseData);
  1088. } catch (\Exception $e) {
  1089. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1090. 'error_message' => '处理响应失败: ' . $e->getMessage()
  1091. ]);
  1092. }
  1093. }
  1094. public function getTaskList($data)
  1095. {
  1096. $task_id = getProp($data, 'task_id');
  1097. $status = getProp($data, 'status');
  1098. $query = MpGeneratePicTask::select('*');
  1099. if ($task_id) {
  1100. $query->where('id', $task_id);
  1101. }
  1102. if ($status) {
  1103. $query->where('status', $status);
  1104. }
  1105. return $query->orderBy('created_at', 'desc')->paginate();
  1106. }
  1107. /**
  1108. * 查询任务状态
  1109. *
  1110. * @param MpGeneratePicTask $task
  1111. * @return array
  1112. */
  1113. public function queryTaskStatus(MpGeneratePicTask $task): array
  1114. {
  1115. // 根据模型类型调用不同的查询方法
  1116. if ($task->model === self::MODEL_JIMENG_4) {
  1117. return $this->queryJimengTaskStatus($task);
  1118. } elseif (in_array($task->model, BaseConst::NANO_BANANA_MODELS)) {
  1119. return $this->queryNanoBananaTaskStatus($task);
  1120. } else {
  1121. // 火山API是同步返回结果,不需要查询
  1122. return [
  1123. 'status' => $task->status === MpGeneratePicTask::STATUS_SUCCESS ? 'success' : 'failed',
  1124. 'error_message' => $task->error_message,
  1125. 'result_url' => $task->result_url,
  1126. 'result_json' => $task->result_json ?? []
  1127. ];
  1128. }
  1129. }
  1130. /**
  1131. * 查询即梦AI任务状态
  1132. *
  1133. * @param MpGeneratePicTask $task
  1134. * @return array
  1135. */
  1136. private function queryJimengTaskStatus(MpGeneratePicTask $task): array
  1137. {
  1138. try {
  1139. // 获取API任务ID
  1140. $apiTaskId = $task->task_id ?? null;
  1141. if (!$apiTaskId) {
  1142. return [
  1143. 'status' => 'failed',
  1144. 'error_message' => 'API任务ID不存在',
  1145. 'result_url' => null
  1146. ];
  1147. }
  1148. //构建查询参数
  1149. $apiParams = [
  1150. 'req_key' => 'jimeng_t2i_v40',
  1151. 'task_id' => $apiTaskId,
  1152. 'req_json' => '{"return_url":true}' // 返回URL格式
  1153. ];
  1154. //调用即梦AI 4.0 API查询任务状态
  1155. $response = $this->volcEngineService->request(
  1156. 'POST',
  1157. 'visual.volcengineapi.com',
  1158. '/', // 使用根路径
  1159. [], // Query参数
  1160. json_encode($apiParams),
  1161. env('VOLC_AI_AK'),
  1162. env('VOLC_AI_SK'),
  1163. 'cv',
  1164. 'cn-north-1',
  1165. 'CVSync2AsyncGetResult',
  1166. '2022-08-31',
  1167. 'application/json'
  1168. );
  1169. $responseData = json_decode($response['body'], true);
  1170. dLog('generate')->info('即梦AI 4.0 API查询任务状态响应: ', $responseData);
  1171. logDB('generate', 'info', '即梦AI任务状态查询', ['task_id' => $apiTaskId, 'response' => $responseData]);
  1172. if ($responseData['code'] !== 10000) {
  1173. // API返回错误
  1174. logDB('generate', 'error', '即梦AI任务状态查询失败', ['task_id' => $apiTaskId, 'error' => $responseData['message'] ?? 'API Error']);
  1175. return [
  1176. 'status' => 'failed',
  1177. 'error_message' => $responseData['message'] ?? 'API Error',
  1178. 'result_url' => null,
  1179. 'result_json' => $responseData ?? []
  1180. ];
  1181. }
  1182. // 解析API响应
  1183. $result = $responseData['data'] ?? [];
  1184. $taskStatus = $result['status'] ?? 'failed';
  1185. $returnData = [
  1186. 'status' => $taskStatus,
  1187. 'error_message' => null,
  1188. 'result_url' => null,
  1189. 'result_json' => $responseData ?? []
  1190. ];
  1191. // 如果任务成功,获取结果URL
  1192. if ($taskStatus === 'done' && isset($result['image_urls']) && count($result['image_urls']) > 0) {
  1193. $result_urls = [];
  1194. foreach ($result['image_urls'] as $url) {
  1195. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  1196. $pic_ext = getImgExtFromUrl($url);
  1197. $pic_name = $pic_name . $pic_ext;
  1198. if ($pic_ext === '.png') {
  1199. $comporessed_data = compressRemoteImageUrlToSize($url);
  1200. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  1201. }else {
  1202. // 将图片另存到tos
  1203. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  1204. }
  1205. $result_urls[] = $url;
  1206. }
  1207. $returnData['result_url'] = $result_urls;
  1208. $returnData['status'] = 'success';
  1209. } elseif (in_array($taskStatus, ['not_found', 'expired'])) {
  1210. $returnData['status'] = 'failed';
  1211. $returnData['error_message'] = '任务未找到或已过期';
  1212. } elseif ($taskStatus === 'failed') {
  1213. $returnData['status'] = 'failed';
  1214. $returnData['error_message'] = '任务执行失败';
  1215. }
  1216. // in_queue 或 generating状态保持原status
  1217. return $returnData;
  1218. } catch (\Exception $e) {
  1219. return [
  1220. 'status' => 'failed',
  1221. 'error_message' => $e->getMessage(),
  1222. 'result_url' => null
  1223. ];
  1224. }
  1225. }
  1226. /**
  1227. * 提交任务到NanoBanana API
  1228. *
  1229. * @param MpGeneratePicTask $task
  1230. * @return void
  1231. */
  1232. private function submitTaskToNanoBananaApi(MpGeneratePicTask $task): void
  1233. {
  1234. try {
  1235. // 验证环境变量配置
  1236. $apiKey = env('NANO_BANANA_API_KEY');
  1237. if (empty($apiKey)) {
  1238. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1239. 'error_message' => 'NanoBanana API KEY未配置,请检查环境变量NANO_BANANA_API_KEY'
  1240. ]);
  1241. return;
  1242. }
  1243. // 根据模型选择API地址
  1244. switch ($task->model) {
  1245. case self::MODEL_NANO_BANANA_2:
  1246. $apiUrl = 'https://api.wuyinkeji.com/api/async/image_nanoBanana2';
  1247. break;
  1248. case self::MODEL_NANO_BANANA_PRO:
  1249. $apiUrl = 'https://api.wuyinkeji.com/api/async/image_nanoBanana_pro';
  1250. break;
  1251. case self::MODEL_GPT_IMAGE_2:
  1252. $apiUrl = 'https://api.wuyinkeji.com/api/async/image_gpt';
  1253. break;
  1254. default:
  1255. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1256. 'error_message' => '不支持的模型类型: ' . $task->model
  1257. ]);
  1258. logDB('generate', 'error', '模型类型错误', ['task_id' => $task->task_id, 'model' => $task->model]);
  1259. return;
  1260. }
  1261. // 构建API请求参数
  1262. $apiParams = [
  1263. 'key' => $apiKey,
  1264. 'prompt' => $task->prompt,
  1265. ];
  1266. if ($task->model == self::MODEL_GPT_IMAGE_2) {
  1267. // 该模型下,size需设置为9:16类似的数值,请根据如下比例反推
  1268. $apiParams['size'] = $this->getRatioFromSize($task->width, $task->height);
  1269. }else {
  1270. // 尺寸参数(根据宽高判断)
  1271. $area = $task->width * $task->height;
  1272. if ($area >= 1024*1024 && $area < 2048*2048) {
  1273. $apiParams['size'] = '1k';
  1274. } elseif ($area >= 2048*2048) {
  1275. $apiParams['size'] = '2k';
  1276. } else {
  1277. $apiParams['size'] = '1k'; // 默认1k
  1278. }
  1279. // 画面比例
  1280. if ($task->width && $task->height) {
  1281. $ratio = $task->width . ':' . $task->height;
  1282. // 简化比例
  1283. $gcd = $this->gcd($task->width, $task->height);
  1284. $ratio = ($task->width / $gcd) . ':' . ($task->height / $gcd);
  1285. $apiParams['aspectRatio'] = $ratio;
  1286. }
  1287. }
  1288. // 参考图片
  1289. if ($task->ref_img_url) {
  1290. $apiParams['urls'] = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
  1291. }
  1292. dLog('generate')->info('NanoBanana API参数: ', $apiParams);
  1293. logDB('generate', 'info', 'NanoBanana任务提交', ['task_id' => $task->task_id, 'model' => $task->model, 'params' => $apiParams]);
  1294. // 调用NanoBanana API
  1295. $response = $this->httpClient->post($apiUrl, [
  1296. 'query' => ['key' => $apiKey],
  1297. 'json' => $apiParams,
  1298. ]);
  1299. $responseData = json_decode($response->getBody()->getContents(), true);
  1300. dLog('generate')->info('NanoBanana API响应: ', $responseData);
  1301. logDB('generate', 'info', 'NanoBanana API响应', ['task_id' => $task->task_id, 'response' => $responseData]);
  1302. if ($responseData['code'] !== 200) {
  1303. // API返回错误
  1304. logDB('generate', 'error', 'NanoBanana任务提交失败', ['task_id' => $task->task_id, 'error' => $responseData['msg'] ?? 'API Error']);
  1305. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1306. 'error_message' => $responseData['msg'] ?? 'API Error',
  1307. 'extra_params' => $responseData
  1308. ]);
  1309. } else {
  1310. // 任务提交成功,更新状态为处理中
  1311. $updateData = [];
  1312. // 存储API返回的任务ID
  1313. if (isset($responseData['data']['id'])) {
  1314. $updateData['task_id'] = $responseData['data']['id'];
  1315. $updateData['extra_params'] = $responseData;
  1316. }
  1317. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING, $updateData);
  1318. }
  1319. } catch (\Exception $e) {
  1320. // 记录错误
  1321. logDB('generate', 'error', 'NanoBanana任务提交异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
  1322. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1323. 'error_message' => 'API请求失败: ' . $e->getMessage()
  1324. ]);
  1325. }
  1326. }
  1327. /**
  1328. * 查询NanoBanana任务状态
  1329. *
  1330. * @param MpGeneratePicTask $task
  1331. * @return array
  1332. */
  1333. private function queryNanoBananaTaskStatus(MpGeneratePicTask $task): array
  1334. {
  1335. try {
  1336. // 获取API任务ID
  1337. $apiTaskId = $task->task_id ?? null;
  1338. $apiKey = env('NANO_BANANA_API_KEY');
  1339. if (!$apiTaskId) {
  1340. return [
  1341. 'status' => 'failed',
  1342. 'error_message' => 'API任务ID不存在',
  1343. 'result_url' => null
  1344. ];
  1345. }
  1346. if (empty($apiKey)) {
  1347. return [
  1348. 'status' => 'failed',
  1349. 'error_message' => 'NanoBanana API KEY未配置',
  1350. 'result_url' => null
  1351. ];
  1352. }
  1353. // 调用统一查询接口
  1354. $response = $this->httpClient->get('https://api.wuyinkeji.com/api/async/detail', [
  1355. 'query' => [
  1356. 'key' => $apiKey,
  1357. 'id' => $apiTaskId
  1358. ]
  1359. ]);
  1360. $responseData = json_decode($response->getBody()->getContents(), true);
  1361. dLog('generate')->info('NanoBanana任务状态查询响应: ', $responseData);
  1362. logDB('generate', 'info', 'NanoBanana任务状态查询', ['task_id' => $apiTaskId, 'response' => $responseData]);
  1363. if ($responseData['code'] !== 200) {
  1364. // API返回错误
  1365. logDB('generate', 'error', 'NanoBanana任务状态查询失败', ['task_id' => $apiTaskId, 'error' => $responseData['msg'] ?? 'API Error']);
  1366. return [
  1367. 'status' => 'failed',
  1368. 'error_message' => $responseData['msg'] ?? 'API Error',
  1369. 'result_url' => null,
  1370. 'result_json' => $responseData
  1371. ];
  1372. }
  1373. // 解析API响应
  1374. $data = $responseData['data'] ?? [];
  1375. $taskStatus = $data['status'] ?? 0;
  1376. $returnData = [
  1377. 'status' => 'processing',
  1378. 'error_message' => null,
  1379. 'result_url' => null,
  1380. 'result_json' => $responseData
  1381. ];
  1382. // status: 2表示成功
  1383. if ($taskStatus === 2 && isset($data['result']) && !empty($data['result'])) {
  1384. $result_urls = [];
  1385. foreach ($data['result'] as $url) {
  1386. $pic_name = 'ai_generation_nanobanana_' . time() . '_' . uniqid();
  1387. $pic_ext = getImgExtFromUrl($url);
  1388. $pic_name = $pic_name . $pic_ext;
  1389. if ($pic_ext === '.png') {
  1390. $comporessed_data = compressRemoteImageUrlToSize($url);
  1391. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  1392. } else {
  1393. // 将图片另存到tos
  1394. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  1395. }
  1396. $result_urls[] = $url;
  1397. }
  1398. $returnData['result_url'] = $result_urls;
  1399. $returnData['status'] = 'success';
  1400. } elseif ($taskStatus === 3) {
  1401. // status: 3表示失败
  1402. $returnData['status'] = 'failed';
  1403. $returnData['error_message'] = $data['message'] ?? '任务执行失败';
  1404. }
  1405. // status: 0或1表示处理中,保持processing状态
  1406. return $returnData;
  1407. } catch (\Exception $e) {
  1408. return [
  1409. 'status' => 'failed',
  1410. 'error_message' => $e->getMessage(),
  1411. 'result_url' => null
  1412. ];
  1413. }
  1414. }
  1415. /**
  1416. * 计算最大公约数(用于简化比例)
  1417. *
  1418. * @param int $a
  1419. * @param int $b
  1420. * @return int
  1421. */
  1422. private function gcd(int $a, int $b): int
  1423. {
  1424. while ($b != 0) {
  1425. $temp = $b;
  1426. $b = $a % $b;
  1427. $a = $temp;
  1428. }
  1429. return $a;
  1430. }
  1431. /**
  1432. * 创建素材
  1433. *
  1434. * @param array $params
  1435. * @return array
  1436. */
  1437. public function createAsset(array $params): array
  1438. {
  1439. try {
  1440. // 验证必填参数
  1441. if (empty($params['url'])) {
  1442. return [
  1443. 'code' => 1,
  1444. 'msg' => '素材URL不能为空',
  1445. 'data' => null
  1446. ];
  1447. }
  1448. // 先获取表中是否已有该url(仅限智帧AI来源的素材)
  1449. $asset = DB::table('mp_assets')->where('url', $params['url'])->where('source_type', 'zhizhen')->first();
  1450. if ($asset) {
  1451. return [
  1452. 'code' => 0,
  1453. 'msg' => '创建成功',
  1454. 'data' => [
  1455. 'id' => $asset->id,
  1456. 'asset_id' => $asset->asset_id,
  1457. 'group_id' => $asset->group_id,
  1458. 'group_name' => $asset->group_name,
  1459. ]
  1460. ];
  1461. }
  1462. // 获取API配置
  1463. $apiKey = env('ZHIZHEN_AI_SK');
  1464. $apiUrl = 'https://model-relay.zzengine.net';
  1465. if (empty($apiKey)) {
  1466. return [
  1467. 'code' => 1002,
  1468. 'msg' => '智帧AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
  1469. 'data' => null
  1470. ];
  1471. }
  1472. // 构建请求参数
  1473. $requestData = [
  1474. 'url' => $params['url'],
  1475. 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
  1476. ];
  1477. // 可选参数
  1478. if (!empty($params['name'])) {
  1479. $requestData['name'] = $params['name'];
  1480. }
  1481. if (!empty($params['group_id'])) {
  1482. $requestData['group_id'] = $params['group_id'];
  1483. }
  1484. dLog('generate')->info('智帧AI创建素材请求参数: ', $requestData);
  1485. // 调用智帧AI API
  1486. $response = $this->httpClient->post($apiUrl . '/api/v1/asset/create', [
  1487. 'headers' => [
  1488. 'X-Api-Key' => $apiKey,
  1489. 'Content-Type' => 'application/json',
  1490. ],
  1491. 'json' => $requestData,
  1492. ]);
  1493. $responseData = json_decode($response->getBody()->getContents(), true);
  1494. dLog('generate')->info('智帧AI创建素材响应: ', $responseData);
  1495. logDB('generate', 'info', '智帧AI创建素材', ['params' => $requestData, 'response' => $responseData]);
  1496. if ($responseData['code'] !== 0) {
  1497. return [
  1498. 'code' => $responseData['code'],
  1499. 'msg' => $responseData['msg'] ?? '创建素材失败',
  1500. 'data' => null
  1501. ];
  1502. }
  1503. // 保存素材记录到数据库
  1504. $assetData = $responseData['data'] ?? [];
  1505. $asset = MpAsset::create([
  1506. 'asset_id' => $assetData['asset_id'] ?? '',
  1507. 'group_id' => $assetData['group_id'] ?? null,
  1508. 'group_name' => $assetData['group_name'] ?? null,
  1509. 'name' => $params['name'] ?? null,
  1510. 'url' => $params['url'],
  1511. 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
  1512. 'source_type' => 'zhizhen',
  1513. 'status' => MpAsset::STATUS_PROCESSING,
  1514. 'extra_params' => $responseData,
  1515. ]);
  1516. return [
  1517. 'code' => 0,
  1518. 'msg' => '创建成功',
  1519. 'data' => [
  1520. 'id' => $asset->id,
  1521. 'asset_id' => $asset->asset_id,
  1522. 'group_id' => $asset->group_id,
  1523. 'group_name' => $asset->group_name,
  1524. ]
  1525. ];
  1526. } catch (\Exception $e) {
  1527. dLog('generate')->error('创建素材异常: ' . $e->getMessage());
  1528. logDB('generate', 'error', '创建素材异常', ['params' => $params, 'error' => $e->getMessage()]);
  1529. return [
  1530. 'code' => 20003,
  1531. 'msg' => '创建素材失败: ' . $e->getMessage(),
  1532. 'data' => null
  1533. ];
  1534. }
  1535. }
  1536. /**
  1537. * 查询素材
  1538. *
  1539. * @param string $assetId
  1540. * @return array
  1541. */
  1542. public function getAsset(string $assetId): array
  1543. {
  1544. try {
  1545. // 验证参数
  1546. if (empty($assetId)) {
  1547. return [
  1548. 'code' => 20003,
  1549. 'msg' => '素材ID不能为空',
  1550. 'data' => null
  1551. ];
  1552. }
  1553. // 先获取表中是否已有该素材(仅限智帧AI来源的素材)
  1554. $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'zhizhen')->first();
  1555. if ($assetInfo) {
  1556. $assetInfo = (array)$assetInfo;
  1557. $status = getProp($assetInfo, 'status');
  1558. if (in_array($status, ['Active', 'Failed'])) {
  1559. $assetInfo['id'] = $assetInfo['asset_id'];
  1560. return [
  1561. 'code' => 0,
  1562. 'msg' => '查询成功',
  1563. 'data' => [
  1564. 'asset' => $assetInfo
  1565. ]
  1566. ];
  1567. }
  1568. }
  1569. // 获取API配置
  1570. $apiKey = env('ZHIZHEN_AI_SK');
  1571. $apiUrl = 'https://model-relay.zzengine.net';
  1572. if (empty($apiKey)) {
  1573. return [
  1574. 'code' => 1002,
  1575. 'msg' => '智帧AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
  1576. 'data' => null
  1577. ];
  1578. }
  1579. dLog('generate')->info('智帧AI查询素材: ' . $assetId);
  1580. // 调用智帧AI API
  1581. $response = $this->httpClient->post($apiUrl . '/api/v1/asset/get', [
  1582. 'headers' => [
  1583. 'X-Api-Key' => $apiKey,
  1584. 'Content-Type' => 'application/json',
  1585. ],
  1586. 'json' => [
  1587. 'asset_id' => $assetId,
  1588. ],
  1589. ]);
  1590. $responseData = json_decode($response->getBody()->getContents(), true);
  1591. dLog('generate')->info('智帧AI查询素材响应: ', $responseData);
  1592. logDB('generate', 'info', '智帧AI查询素材', ['asset_id' => $assetId, 'response' => $responseData]);
  1593. if ($responseData['code'] !== 0) {
  1594. return [
  1595. 'code' => $responseData['code'],
  1596. 'msg' => $responseData['msg'] ?? '查询素材失败',
  1597. 'data' => null
  1598. ];
  1599. }
  1600. $assetInfo = $responseData['data']['asset'] ?? [];
  1601. // 更新本地数据库记录
  1602. $localAsset = MpAsset::where('asset_id', $assetId)->first();
  1603. if ($localAsset) {
  1604. $localAsset->update([
  1605. 'status' => $assetInfo['status'] ?? MpAsset::STATUS_PROCESSING,
  1606. 'cover_url' => $assetInfo['url'] ?? null,
  1607. 'extra_params' => $responseData,
  1608. ]);
  1609. }
  1610. return [
  1611. 'code' => 0,
  1612. 'msg' => '查询成功',
  1613. 'data' => [
  1614. 'asset' => $assetInfo
  1615. ]
  1616. ];
  1617. } catch (\Exception $e) {
  1618. dLog('generate')->error('查询素材异常: ' . $e->getMessage());
  1619. logDB('generate', 'error', '查询素材异常', ['asset_id' => $assetId, 'error' => $e->getMessage()]);
  1620. return [
  1621. 'code' => 20003,
  1622. 'msg' => '查询素材失败: ' . $e->getMessage(),
  1623. 'data' => null
  1624. ];
  1625. }
  1626. }
  1627. /**
  1628. * 更新所有待处理任务的状态
  1629. *
  1630. * @return void
  1631. */
  1632. public function updatePendingTasks(): void
  1633. {
  1634. dLog('generate')->info('更新图片状态ing');
  1635. // 1. 处理火山API的pending任务(批量提交到队列)
  1636. $volcPendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
  1637. ->whereIn('model', BaseConst::VOLC_PIC_MODELS)
  1638. ->orderByRaw($this->dispatchOrderByRaw())
  1639. ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
  1640. ->get();
  1641. $volcDispatched = 0;
  1642. foreach ($volcPendingTasks as $task) {
  1643. if ($volcDispatched >= self::DISPATCH_BATCH_LIMIT) {
  1644. break;
  1645. }
  1646. // 同一任务已在队列中或正在执行时跳过,避免重复入队
  1647. if (!$this->dispatchTaskOnce(self::DISPATCH_GROUP_VOLC, (int)$task->id, \App\Jobs\ProcessVolcImageGenerationJob::class)) {
  1648. continue;
  1649. }
  1650. dLog('generate')->info('开始分发火山API任务到队列: ' . $task->task_id);
  1651. $volcDispatched++;
  1652. }
  1653. // 2. 处理GPT-Image2的pending任务(批量提交到队列)
  1654. $gptImage2PendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
  1655. ->whereIn('model', BaseConst::GPT_IMAGE2_MODELS)
  1656. ->orderByRaw($this->dispatchOrderByRaw())
  1657. ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
  1658. ->get();
  1659. $gptImage2Dispatched = 0;
  1660. foreach ($gptImage2PendingTasks as $task) {
  1661. if ($gptImage2Dispatched >= self::DISPATCH_BATCH_LIMIT) {
  1662. break;
  1663. }
  1664. // 同一任务已在队列中或正在执行时跳过,避免重复入队
  1665. if (!$this->dispatchTaskOnce(self::DISPATCH_GROUP_GPT_IMAGE2, (int)$task->id, \App\Jobs\ProcessGptImage2GenerationJob::class)) {
  1666. continue;
  1667. }
  1668. dLog('generate')->info('开始分发GPT-Image2任务到队列: ' . $task->task_id);
  1669. $gptImage2Dispatched++;
  1670. }
  1671. // 3. 获取所有处理中的任务(即梦AI和NanoBanana系列需要查询状态)
  1672. $processingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)
  1673. ->whereIn('model', array_merge([self::MODEL_JIMENG_4], BaseConst::NANO_BANANA_MODELS))
  1674. ->get();
  1675. foreach ($processingTasks as $task) {
  1676. $statusInfo = $this->queryTaskStatus($task);
  1677. if ($statusInfo['status'] === 'success') {
  1678. logDB('generate', 'info', '即梦AI图片生成任务成功', ['task_id' => $task->task_id, 'result_url' => $statusInfo['result_url']]);
  1679. // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
  1680. $this->handleImageTaskSuccess($task, $statusInfo['result_url'], $statusInfo['result_json'] ?? []);
  1681. } elseif ($statusInfo['status'] === 'failed') {
  1682. logDB('generate', 'error', '即梦AI图片生成任务失败', ['task_id' => $task->task_id, 'error' => $statusInfo['error_message']]);
  1683. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1684. 'error_message' => $statusInfo['error_message'],
  1685. 'result_json' => $statusInfo['result_json'] ?? []
  1686. ]);
  1687. if (getProp($task, 'alias_segment_id')) {
  1688. DB::table('mp_episode_segments')->where('segment_id', getProp($task, 'alias_segment_id'))->update([
  1689. 'pic_task_status' => '失败',
  1690. ]);
  1691. }
  1692. }
  1693. // 如果仍然是处理中状态,不做任何操作
  1694. //处理:如果任务处理超过12小时,标记为失败
  1695. $processingTime = now()->diffInHours($task->created_at);
  1696. if ($processingTime > 12) {
  1697. logDB('generate', 'warning', '即梦AI图片生成任务超时', ['task_id' => $task->task_id, 'processing_hours' => $processingTime]);
  1698. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1699. 'error_message' => '任务处理超时(超过12小时)'
  1700. ]);
  1701. }
  1702. }
  1703. // 4. 检查是否有任务完成,如果有则处理下一个排队任务(仅即梦AI)
  1704. $this->processNextQueuedTask();
  1705. }
  1706. /**
  1707. * 派发单个任务到队列(带派发去重)
  1708. *
  1709. * 同一任务在「已入队等待执行」或「正在执行」期间不会重复入队,
  1710. * 避免每轮扫描(约3秒一次)把同一条任务重复推送进队列。
  1711. *
  1712. * @param string $group 派发分组,用于区分同一任务ID在不同业务下的锁
  1713. * @param int $taskId 图片任务ID
  1714. * @param string $jobClass 队列任务类
  1715. * @return bool 本次是否成功入队;false 表示已有同任务的 job 在队列中或执行中
  1716. */
  1717. private function dispatchTaskOnce(string $group, int $taskId, string $jobClass): bool
  1718. {
  1719. if (!self::acquireTaskDispatchLock($group, $taskId)) {
  1720. return false;
  1721. }
  1722. try {
  1723. $jobClass::dispatch($taskId)->onConnection('redis');
  1724. } catch (\Throwable $e) {
  1725. // 入队失败必须释放锁,否则该任务在锁过期前无法再次派发
  1726. self::releaseTaskDispatchLock($group, $taskId);
  1727. throw $e;
  1728. }
  1729. return true;
  1730. }
  1731. /**
  1732. * 记录一次真实提交尝试(在真正调用上游接口前调用),并做重试上限控制
  1733. *
  1734. * - 每次实际提交前 attempts + 1(记录在 extra_params.attempts)
  1735. * - attempts 达到 MAX_TASK_ATTEMPTS 后直接判失败,
  1736. * 并把最后一次的错误原因写进 error_message,供前端展示失败原因;
  1737. * - 计数放在“真正提交”这一步而不是“派发”这一步:队列积压时同一任务可能被重复派发,
  1738. * 按派发计数会凭空消耗重试次数,导致任务提前被判失败。
  1739. *
  1740. * @param MpGeneratePicTask $task
  1741. * @return bool 是否继续提交本次请求(false 表示已达重试上限,任务已判失败)
  1742. */
  1743. private function beginTaskAttempt(MpGeneratePicTask $task): bool
  1744. {
  1745. $fresh = MpGeneratePicTask::find($task->id);
  1746. $extra = ($fresh && is_array($fresh->extra_params)) ? $fresh->extra_params : [];
  1747. $attempts = (int)getProp($extra, self::EXTRA_ATTEMPTS, 0);
  1748. // 重试次数已达上限:直接判失败,避免任务无限重试并长期占用队列
  1749. if ($attempts >= self::MAX_TASK_ATTEMPTS) {
  1750. $lastError = (string)getProp($extra, self::EXTRA_LAST_ERROR, '');
  1751. $message = '图片生成失败:已重试' . self::MAX_TASK_ATTEMPTS . '次仍未成功';
  1752. if ($lastError !== '') {
  1753. $message .= ';最后错误:' . $lastError;
  1754. }
  1755. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, ['error_message' => $message]);
  1756. logDB('generate', 'error', '图片任务重试次数已达上限,标记失败', [
  1757. 'task_id' => $task->task_id,
  1758. 'model' => $task->model,
  1759. 'attempts' => $attempts,
  1760. 'last_error' => $lastError,
  1761. ]);
  1762. return false;
  1763. }
  1764. // 记录本次尝试次数(重试上限以此计数为准)
  1765. $extra[self::EXTRA_ATTEMPTS] = $attempts + 1;
  1766. ($fresh ?: $task)->update(['extra_params' => $extra]);
  1767. return true;
  1768. }
  1769. /**
  1770. * 图片任务派发顺序:优先按 extra_params.next_retry_at(失败后重排时间)升序,
  1771. * 没有重排时间的按创建时间升序。这样失败重试的任务会排到当前队列尾部,
  1772. * 先让其它任务各过一遍,单个任务卡住不会一直堵住后面的任务。
  1773. *
  1774. * @return string
  1775. */
  1776. private function dispatchOrderByRaw(): string
  1777. {
  1778. return "COALESCE(STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(extra_params, '$.next_retry_at')), '%Y-%m-%d %H:%i:%s'), created_at) ASC";
  1779. }
  1780. /**
  1781. * 判断异常是否属于可重试错误(上游超时、连接失败、5xx 等)
  1782. *
  1783. * @param \Throwable $e
  1784. * @return bool
  1785. */
  1786. private function isRetryableApiError(\Throwable $e): bool
  1787. {
  1788. // Guzzle:连接失败/请求超时
  1789. if ($e instanceof \GuzzleHttp\Exception\ConnectException) {
  1790. return true;
  1791. }
  1792. // Guzzle:上游 5xx
  1793. if ($e instanceof \GuzzleHttp\Exception\ServerException) {
  1794. return true;
  1795. }
  1796. // 原生 cURL 分支的超时、空响应、网关 5xx 统一按可重试处理
  1797. // 注意:这里只做语义匹配,不能用裸数字(如 504)匹配,否则会被尺寸文本(3040x5504)误命中
  1798. $message = mb_strtolower($e->getMessage());
  1799. foreach (['timed out', 'timeout', 'curl错误: 28', '空响应', 'empty response', '上游服务异常'] as $keyword) {
  1800. if (mb_strpos($message, $keyword) !== false) {
  1801. return true;
  1802. }
  1803. }
  1804. return false;
  1805. }
  1806. /**
  1807. * 可重试失败:把任务放回 pending,记录最后一次错误与重排时间,
  1808. * 使其排到队列尾部等待下一次尝试;尝试次数在真正提交前统一累加并在达到上限时判失败。
  1809. *
  1810. * @param MpGeneratePicTask $task
  1811. * @param string $lastError
  1812. * @param array|null $resultJson 本次失败尝试的上游返回结果(含上游 ID),为空则不覆盖已有值
  1813. * @return void
  1814. */
  1815. private function requeueTaskForRetry(MpGeneratePicTask $task, string $lastError, ?array $resultJson = null): void
  1816. {
  1817. $fresh = MpGeneratePicTask::find($task->id);
  1818. if (!$fresh) {
  1819. return;
  1820. }
  1821. $extra = is_array($fresh->extra_params) ? $fresh->extra_params : [];
  1822. $extra[self::EXTRA_LAST_ERROR] = mb_substr($lastError, 0, 200);
  1823. $extra[self::EXTRA_NEXT_RETRY_AT] = date('Y-m-d H:i:s');
  1824. $updateData = [
  1825. 'status' => MpGeneratePicTask::STATUS_PENDING,
  1826. 'started_at' => null,
  1827. 'completed_at' => null,
  1828. 'extra_params' => $extra,
  1829. ];
  1830. // 保留本次失败尝试的上游返回结果与上游 ID,便于月度审查
  1831. if (!empty($resultJson)) {
  1832. $updateData['result_json'] = $resultJson;
  1833. }
  1834. $fresh->update($updateData);
  1835. dLog('generate')->info('图片任务失败已放回队列尾部等待重试', [
  1836. 'task_id' => $task->task_id,
  1837. 'model' => $task->model,
  1838. 'attempts' => (int)getProp($extra, self::EXTRA_ATTEMPTS, 0),
  1839. 'last_error' => $extra[self::EXTRA_LAST_ERROR],
  1840. ]);
  1841. }
  1842. /**
  1843. * 获取任务派发锁
  1844. *
  1845. * 在队列所在的 Redis 连接上执行原生 `SET key value EX ttl NX` 原子命令,
  1846. * 不依赖 Lua 脚本(部分 Redis 会禁用 EVAL)也不需要额外的缓存 store 配置。
  1847. *
  1848. * @param string $group
  1849. * @param int $taskId
  1850. * @return bool true 表示获取成功(可以入队)
  1851. */
  1852. public static function acquireTaskDispatchLock(string $group, int $taskId): bool
  1853. {
  1854. try {
  1855. $result = Redis::connection(self::DISPATCH_LOCK_CONNECTION)->set(
  1856. self::buildTaskDispatchLockKey($group, $taskId),
  1857. 1,
  1858. 'EX',
  1859. self::DISPATCH_LOCK_TTL,
  1860. 'NX'
  1861. );
  1862. // NX 未命中时:predis 返回 null,phpredis 返回 false
  1863. return $result !== null && $result !== false;
  1864. } catch (\Throwable $e) {
  1865. // 锁不可用时降级为不去重(与旧逻辑一致),避免任务因此完全无法派发
  1866. dLog('generate')->warning('获取图片任务派发锁失败,本次不拦截重复派发', [
  1867. 'group' => $group,
  1868. 'task_id' => $taskId,
  1869. 'error' => $e->getMessage(),
  1870. ]);
  1871. return true;
  1872. }
  1873. }
  1874. /**
  1875. * 释放任务派发锁
  1876. *
  1877. * 在任务执行结束、任务已由其它流程处理、任务最终失败时调用;
  1878. * 未释放的锁会在 DISPATCH_LOCK_TTL 后自动过期,保证任务仍能被重新派发。
  1879. *
  1880. * @param string $group
  1881. * @param int $taskId
  1882. * @return void
  1883. */
  1884. public static function releaseTaskDispatchLock(string $group, int $taskId): void
  1885. {
  1886. try {
  1887. Redis::connection(self::DISPATCH_LOCK_CONNECTION)->del(
  1888. self::buildTaskDispatchLockKey($group, $taskId)
  1889. );
  1890. } catch (\Throwable $e) {
  1891. // 释放失败不影响主流程,锁会由 TTL 自动过期
  1892. dLog('generate')->warning('释放图片任务派发锁失败', [
  1893. 'group' => $group,
  1894. 'task_id' => $taskId,
  1895. 'error' => $e->getMessage(),
  1896. ]);
  1897. }
  1898. }
  1899. /**
  1900. * 构建任务派发锁 key
  1901. *
  1902. * @param string $group
  1903. * @param int $taskId
  1904. * @return string
  1905. */
  1906. public static function buildTaskDispatchLockKey(string $group, int $taskId): string
  1907. {
  1908. return self::DISPATCH_LOCK_PREFIX . $group . ':' . $taskId;
  1909. }
  1910. /**
  1911. * 处理下一个排队的任务
  1912. *
  1913. * @return void
  1914. */
  1915. public function processNextQueuedTask(): void
  1916. {
  1917. // 检查是否还有正在处理的任务(仅即梦AI)
  1918. if ($this->hasProcessingTask()) {
  1919. return; // 如果还有处理中的任务,不处理新任务
  1920. }
  1921. // 获取最早的待处理任务(仅即梦AI)
  1922. $nextTask = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
  1923. ->where('model', self::MODEL_JIMENG_4)
  1924. ->orderBy('created_at', 'asc')
  1925. ->first();
  1926. if ($nextTask) {
  1927. dLog('generate')->info('开始处理排队任务: ' . $nextTask->task_id);
  1928. logDB('generate', 'info', '开始处理即梦AI排队任务', ['task_id' => $nextTask->task_id]);
  1929. $this->submitTaskToJimengApi($nextTask);
  1930. }
  1931. }
  1932. /**
  1933. * 获取任务队列状态
  1934. *
  1935. * @return array
  1936. */
  1937. public function getQueueStatus(): array
  1938. {
  1939. $processingCount = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)->count();
  1940. $pendingCount = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)->count();
  1941. return [
  1942. 'processing_count' => $processingCount,
  1943. 'pending_count' => $pendingCount,
  1944. 'can_submit_new_task' => $processingCount === 0
  1945. ];
  1946. }
  1947. /**
  1948. * 处理智帧AI的参考图片转素材
  1949. *
  1950. * @param array $referenceImages 参考图片URL数组
  1951. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记)
  1952. * @return array 返回素材ID数组
  1953. */
  1954. public function processReferenceImagesToAssets(array $referenceImages, string &$fullPrompt): array
  1955. {
  1956. return $this->processReferenceAssetsToZhizhenAssets($referenceImages, MpAsset::TYPE_IMAGE, '图片', '图片', $fullPrompt);
  1957. }
  1958. /**
  1959. * 处理智帧AI的参考视频转素材
  1960. *
  1961. * @param array $referenceVideos 参考视频URL数组
  1962. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记)
  1963. * @return array 返回素材ID数组
  1964. */
  1965. public function processReferenceVideosToAssets(array $referenceVideos, string &$fullPrompt): array
  1966. {
  1967. return $this->processReferenceAssetsToZhizhenAssets($referenceVideos, MpAsset::TYPE_VIDEO, '视频', '视频', $fullPrompt);
  1968. }
  1969. /**
  1970. * 处理智帧AI的参考音频转素材
  1971. *
  1972. * @param array $referenceAudios 参考音频URL数组
  1973. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记)
  1974. * @return array 返回素材ID数组
  1975. */
  1976. public function processReferenceAudiosToAssets(array $referenceAudios, string &$fullPrompt): array
  1977. {
  1978. return $this->processReferenceAssetsToZhizhenAssets($referenceAudios, MpAsset::TYPE_AUDIO, '音频', '音频', $fullPrompt);
  1979. }
  1980. /**
  1981. * 处理智帧AI的参考素材转素材(图片/视频/音频通用方法)
  1982. *
  1983. * @param array $urls 参考素材URL数组
  1984. * @param string $assetType 素材类型(Image/Video/Audio)
  1985. * @param string $label 素材中文名(图片/视频/音频),用于日志与素材命名
  1986. * @param string $tagName 提示词标记名(图片/视频/音频),用于 <{tagName}N> 标记的清理
  1987. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败素材的标记)
  1988. * @return array 返回素材ID数组(asset://格式)
  1989. */
  1990. private function processReferenceAssetsToZhizhenAssets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array
  1991. {
  1992. $assetIds = [];
  1993. $failedIndexes = []; // 记录失败的素材序号
  1994. foreach ($urls as $index => $url) {
  1995. $mediaIndex = $index + 1; // 素材序号从1开始
  1996. try {
  1997. dLog('generate')->info("开始处理参考{$label} {$mediaIndex}: {$url}");
  1998. // 1. 创建素材
  1999. $createResult = $this->createAsset([
  2000. 'url' => $url,
  2001. 'asset_type' => $assetType,
  2002. 'name' => "参考{$label}_{$mediaIndex}_" . time(),
  2003. ]);
  2004. if ($createResult['code'] !== 0) {
  2005. dLog('generate')->warning("{$label} {$mediaIndex} 创建素材失败: " . $createResult['msg']);
  2006. logDB('generate', 'warning', '创建素材失败', [
  2007. 'url' => $url,
  2008. 'asset_type' => $assetType,
  2009. 'index' => $mediaIndex,
  2010. 'error' => $createResult['msg']
  2011. ]);
  2012. $failedIndexes[] = $mediaIndex;
  2013. continue;
  2014. }
  2015. $assetId = $createResult['data']['asset_id'];
  2016. dLog('generate')->info("{$label} {$mediaIndex} 素材创建成功,asset_id: {$assetId},开始轮询查询状态");
  2017. // 2. 轮询查询素材状态(每3秒查询一次,最多查询100次即5分钟)
  2018. $maxAttempts = 100;
  2019. $attempt = 0;
  2020. $assetStatus = null;
  2021. while ($attempt < $maxAttempts) {
  2022. sleep(3); // 等待3秒
  2023. $attempt++;
  2024. $getResult = $this->getAsset($assetId);
  2025. if ($getResult['code'] !== 0) {
  2026. dLog('generate')->warning("{$label} {$mediaIndex} 查询素材状态失败: " . $getResult['msg']);
  2027. continue;
  2028. }
  2029. $assetStatus = $getResult['data']['asset']['status'] ?? null;
  2030. dLog('generate')->info("{$label} {$mediaIndex} 素材状态查询第 {$attempt} 次,状态: {$assetStatus}");
  2031. if ($assetStatus === 'Active') {
  2032. // 素材可用
  2033. $assetIds[] = $assetId;
  2034. dLog('generate')->info("{$label} {$mediaIndex} 素材处理成功,asset_id: {$assetId}");
  2035. logDB('generate', 'info', '素材转换成功', [
  2036. 'url' => $url,
  2037. 'asset_type' => $assetType,
  2038. 'index' => $mediaIndex,
  2039. 'asset_id' => $assetId
  2040. ]);
  2041. break;
  2042. } elseif ($assetStatus === 'Failed') {
  2043. // 素材处理失败
  2044. dLog('generate')->warning("{$label} {$mediaIndex} 素材处理失败");
  2045. logDB('generate', 'warning', '素材处理失败', [
  2046. 'url' => $url,
  2047. 'asset_type' => $assetType,
  2048. 'index' => $mediaIndex,
  2049. 'asset_id' => $assetId
  2050. ]);
  2051. $failedIndexes[] = $mediaIndex;
  2052. break;
  2053. }
  2054. // 其他状态(Processing等)继续等待
  2055. }
  2056. // 如果超时还未成功
  2057. if ($attempt >= $maxAttempts && $assetStatus !== 'Active') {
  2058. dLog('generate')->warning("{$label} {$mediaIndex} 素材处理超时");
  2059. logDB('generate', 'warning', '素材处理超时', [
  2060. 'url' => $url,
  2061. 'asset_type' => $assetType,
  2062. 'index' => $mediaIndex,
  2063. 'asset_id' => $assetId,
  2064. 'last_status' => $assetStatus
  2065. ]);
  2066. $failedIndexes[] = $mediaIndex;
  2067. }
  2068. } catch (\Exception $e) {
  2069. dLog('generate')->error("{$label} {$mediaIndex} 处理异常: " . $e->getMessage());
  2070. logDB('generate', 'error', '素材转换异常', [
  2071. 'url' => $url,
  2072. 'asset_type' => $assetType,
  2073. 'index' => $mediaIndex,
  2074. 'error' => $e->getMessage()
  2075. ]);
  2076. $failedIndexes[] = $mediaIndex;
  2077. }
  2078. }
  2079. // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号
  2080. if (!empty($failedIndexes)) {
  2081. dLog('generate')->info("开始处理失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes));
  2082. // 按序号从大到小排序,避免删除时影响后续序号
  2083. rsort($failedIndexes);
  2084. foreach ($failedIndexes as $failedIndex) {
  2085. // 删除失败素材的标记,例如 <图片1>、<视频2>、<音频3> 等
  2086. $pattern = "/<{$tagName}{$failedIndex}>/u";
  2087. $fullPrompt = preg_replace($pattern, '', $fullPrompt);
  2088. dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>");
  2089. }
  2090. // 重新调整剩余素材的序号
  2091. // 例如:如果素材2失败,素材3、素材4需要改为素材2、素材3
  2092. $needAdjust = false;
  2093. foreach ($failedIndexes as $failedIndex) {
  2094. $needAdjust = true;
  2095. // 将所有大于失败序号的素材标记序号减1
  2096. for ($i = $failedIndex + 1; $i <= count($urls); $i++) {
  2097. $oldPattern = "/<{$tagName}{$i}>/u";
  2098. $newTag = "<{$tagName}" . ($i - 1) . ">";
  2099. $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt);
  2100. }
  2101. }
  2102. if ($needAdjust) {
  2103. dLog('generate')->info("提示词{$label}序号调整完成");
  2104. logDB('generate', 'info', '提示词调整', [
  2105. 'failed_indexes' => $failedIndexes,
  2106. 'adjusted_prompt' => $fullPrompt
  2107. ]);
  2108. }
  2109. }
  2110. dLog('generate')->info("参考{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes));
  2111. // 格式化为可直接在API接口中使用的数组
  2112. foreach ($assetIds as &$assetId) {
  2113. $assetId = "asset://{$assetId}";
  2114. }
  2115. unset($assetId);
  2116. return $assetIds;
  2117. }
  2118. /**
  2119. * 根据宽高反推图片比例
  2120. * @param int $width 宽度
  2121. * @param int $height 高度
  2122. * @return string 比例字符串,如 "9:16",匹配不到则返回 "auto"
  2123. */
  2124. private function getRatioFromSize($width, $height)
  2125. {
  2126. if (!$width || !$height) {
  2127. return 'auto';
  2128. }
  2129. // 遍历所有预定义的比例,查找匹配的
  2130. foreach (BaseConst::IMAGE_RATIOS as $ratio => $size) {
  2131. if ($size['width'] == $width && $size['height'] == $height) {
  2132. return $ratio;
  2133. }
  2134. }
  2135. // 没有精确匹配,尝试通过计算比例匹配
  2136. $gcd = $this->gcd($width, $height);
  2137. $simplifiedRatio = ($width / $gcd) . ':' . ($height / $gcd);
  2138. // 检查简化后的比例是否在预定义列表中
  2139. if (isset(BaseConst::IMAGE_RATIOS[$simplifiedRatio])) {
  2140. return $simplifiedRatio;
  2141. }
  2142. return 'auto';
  2143. }
  2144. /**
  2145. * 压缩图片流(针对PNG格式)
  2146. *
  2147. * @param string $imageStream 图片二进制数据
  2148. * @param int $maxBytes 最大字节数(默认3MB)
  2149. * @return string|null 压缩后的图片二进制数据,失败时返回原数据
  2150. */
  2151. private function compressImageStream(string $imageStream, int $maxBytes = 3 * 1024 * 1024): ?string
  2152. {
  2153. try {
  2154. // 检查图片类型
  2155. $imgInfo = @getimagesizefromstring($imageStream);
  2156. if (!$imgInfo) {
  2157. dLog('generate')->warning('无法识别图片类型,返回原始数据');
  2158. return $imageStream;
  2159. }
  2160. $mime = $imgInfo['mime'] ?? '';
  2161. // 只处理PNG,JPEG不做有损压缩
  2162. if ($mime === 'image/jpeg') {
  2163. return $imageStream;
  2164. }
  2165. // 如果已经小于最大限制,直接返回
  2166. if (strlen($imageStream) <= $maxBytes) {
  2167. return $imageStream;
  2168. }
  2169. // 载入图像对象
  2170. $srcImg = @imagecreatefromstring($imageStream);
  2171. if (!$srcImg) {
  2172. dLog('generate')->warning('无法创建图像资源,返回原始数据');
  2173. return $imageStream;
  2174. }
  2175. $origW = imagesx($srcImg);
  2176. $origH = imagesy($srcImg);
  2177. // 逐步降低质量直到满足大小要求
  2178. $quality = 9; // PNG压缩等级 0-9
  2179. $scaleFactor = 1.0;
  2180. $maxAttempts = 10;
  2181. $attempt = 0;
  2182. $compressedData = null;
  2183. while ($attempt < $maxAttempts) {
  2184. $attempt++;
  2185. // 如果需要缩放
  2186. if ($scaleFactor < 1.0) {
  2187. $newW = (int)($origW * $scaleFactor);
  2188. $newH = (int)($origH * $scaleFactor);
  2189. $resizedImg = imagecreatetruecolor($newW, $newH);
  2190. // 保持透明度
  2191. imagealphablending($resizedImg, false);
  2192. imagesavealpha($resizedImg, true);
  2193. imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
  2194. $targetImg = $resizedImg;
  2195. } else {
  2196. $targetImg = $srcImg;
  2197. }
  2198. // 输出到内存
  2199. ob_start();
  2200. imagepng($targetImg, null, $quality);
  2201. $compressedData = ob_get_clean();
  2202. if ($scaleFactor < 1.0) {
  2203. imagedestroy($targetImg);
  2204. }
  2205. // 检查大小
  2206. $size = strlen($compressedData);
  2207. dLog('generate')->info('图片压缩尝试', [
  2208. 'attempt' => $attempt,
  2209. 'quality' => $quality,
  2210. 'scale' => $scaleFactor,
  2211. 'size' => $size,
  2212. 'target' => $maxBytes
  2213. ]);
  2214. if ($size <= $maxBytes) {
  2215. break;
  2216. }
  2217. // 调整参数
  2218. if ($quality > 0) {
  2219. $quality--;
  2220. } else {
  2221. $scaleFactor *= 0.9; // 每次缩小10%
  2222. }
  2223. }
  2224. imagedestroy($srcImg);
  2225. return $compressedData ?: $imageStream;
  2226. } catch (\Exception $e) {
  2227. dLog('generate')->error('图片压缩失败', ['error' => $e->getMessage()]);
  2228. return $imageStream;
  2229. }
  2230. }
  2231. /**
  2232. * 从上游响应头中解析百度图片任务/请求 ID。
  2233. *
  2234. * 兼容两种响应头结构(键均为小写、值为字符串数组):
  2235. * - Guzzle 返回的响应头
  2236. * - CURLOPT_HEADERFUNCTION 累积的响应头
  2237. * 同一响应头出现多次时取最后一个非空值(覆盖重定向 / 100-continue 场景)。
  2238. *
  2239. * @param array $headers
  2240. * @return array
  2241. */
  2242. private function extractBaiduUpstreamIds(array $headers): array
  2243. {
  2244. $pick = function (string $name) use ($headers): string {
  2245. $value = $headers[strtolower($name)] ?? null;
  2246. if (is_array($value)) {
  2247. $value = end($value);
  2248. }
  2249. return is_string($value) ? trim($value) : '';
  2250. };
  2251. $ids = [];
  2252. $taskId = $pick(self::BAIDU_UPSTREAM_TASK_ID_HEADER);
  2253. $requestId = $pick(self::BAIDU_UPSTREAM_REQUEST_ID_HEADER);
  2254. $reqid = $pick(self::BAIDU_UPSTREAM_REQID_HEADER);
  2255. if ($taskId !== '') {
  2256. $ids[self::RESULT_UPSTREAM_TASK_ID] = $taskId;
  2257. }
  2258. if ($requestId !== '') {
  2259. $ids[self::RESULT_UPSTREAM_REQUEST_ID] = $requestId;
  2260. }
  2261. if ($reqid !== '') {
  2262. $ids[self::RESULT_UPSTREAM_REQID] = $reqid;
  2263. }
  2264. return $ids;
  2265. }
  2266. /**
  2267. * 把上游任务/请求 ID 合并进 result_json,仅在有值时才写入,
  2268. * 避免重试或失败路径用空值覆盖已记录的信息。
  2269. *
  2270. * @param array $resultJson
  2271. * @param array $upstreamIds
  2272. * @return array
  2273. */
  2274. private function mergeUpstreamIds(array $resultJson, array $upstreamIds): array
  2275. {
  2276. foreach ($upstreamIds as $key => $value) {
  2277. if ($value !== '' && $value !== null) {
  2278. $resultJson[$key] = $value;
  2279. }
  2280. }
  2281. return $resultJson;
  2282. }
  2283. /**
  2284. * 解析上游响应体:JSON 数组/对象返回数组,非 JSON 返回原文片段(截断 2000 字),空响应返回 null。
  2285. * 失败/重试路径用它尽量保留上游返回的原始结果。
  2286. *
  2287. * @param mixed $responseBody
  2288. * @return array|null
  2289. */
  2290. private function decodeUpstreamBody($responseBody): ?array
  2291. {
  2292. if (!is_string($responseBody) || $responseBody === '') {
  2293. return null;
  2294. }
  2295. $decoded = json_decode($responseBody, true);
  2296. if (is_array($decoded)) {
  2297. return $decoded;
  2298. }
  2299. return ['_raw_response' => mb_substr($responseBody, 0, 2000)];
  2300. }
  2301. /**
  2302. * 记录一次失败/重试尝试:返回要写入 result_json 的内容,并按需补记 token 明细(失败不扣积分)。
  2303. *
  2304. * - 有上游响应体时:保存去掉 b64_json 后的响应(含 error/usage 等),并合并本次上游 ID;
  2305. * - 只有响应头、没有响应体时(如超时/5xx 无 body):保留已有 result_json,仅补写本次拿到的上游 ID;
  2306. * - 只要上游返回 usage,就补记 token 明细(uid 缺失时用兜底 uid)。
  2307. *
  2308. * @param MpGeneratePicTask $task
  2309. * @param mixed $responseData 上游响应体(数组或 null)
  2310. * @param array $upstreamIds 上游响应头中解析出的任务/请求 ID
  2311. * @param string $remark 备注
  2312. * @param array $rawResponseHeaders 本次收到的原始响应头(超时/请求中断时留痕)
  2313. * @return array 写入 result_json 的内容
  2314. */
  2315. private function captureFailedAttempt(MpGeneratePicTask $task, $responseData, array $upstreamIds, string $remark, array $rawResponseHeaders = []): array
  2316. {
  2317. $resultJson = [];
  2318. try {
  2319. $resultJson = is_array($responseData) ? $this->removeB64JsonFromResponse($responseData) : [];
  2320. // 本次没有响应体时,保留此前已记录的 result_json,避免用空值覆盖
  2321. if (empty($resultJson)) {
  2322. $resultJson = is_array($task->result_json) ? $task->result_json : [];
  2323. }
  2324. $resultJson = $this->mergeUpstreamIds($resultJson, $upstreamIds);
  2325. // 超时/请求中断时也留下本次收到的响应头;本次没拿到时沿用此前记录的,
  2326. // 便于事后判断超时那次到底有没有拿到上游 ID
  2327. $headersToKeep = !empty($rawResponseHeaders)
  2328. ? $rawResponseHeaders
  2329. : (is_array($task->result_json) && !empty($task->result_json['raw_response_headers'])
  2330. ? $task->result_json['raw_response_headers']
  2331. : []);
  2332. if (!empty($headersToKeep)) {
  2333. $resultJson['raw_response_headers'] = $headersToKeep;
  2334. }
  2335. } catch (\Throwable $e) {
  2336. dLog('generate')->warning('记录失败尝试上游结果异常(不影响token补记): ' . $e->getMessage(), [
  2337. 'task_id' => $task->task_id ?? null,
  2338. ]);
  2339. $resultJson = is_array($task->result_json) ? $task->result_json : [];
  2340. }
  2341. // 上游返回 usage 时补记 token 明细(失败/重试不扣积分)
  2342. $this->recordFailedTaskTokens($task, $responseData, $remark, $upstreamIds);
  2343. return $resultJson;
  2344. }
  2345. /**
  2346. * 从响应数据中移除 b64_json 字段
  2347. * 避免在日志和数据库中存储大量 Base64 数据
  2348. *
  2349. * @param array $responseData
  2350. * @return array
  2351. */
  2352. private function removeB64JsonFromResponse(array $responseData): array
  2353. {
  2354. $cleanedData = $responseData;
  2355. if (isset($cleanedData['data']) && is_array($cleanedData['data'])) {
  2356. foreach ($cleanedData['data'] as &$imageData) {
  2357. if (isset($imageData['b64_json'])) {
  2358. // 移除 b64_json 字段,可选择性保留数据长度信息
  2359. $b64Length = strlen($imageData['b64_json']);
  2360. unset($imageData['b64_json']);
  2361. $imageData['b64_json_removed'] = true;
  2362. $imageData['b64_json_length'] = $b64Length;
  2363. }
  2364. }
  2365. unset($imageData); // 解除引用
  2366. }
  2367. return $cleanedData;
  2368. }
  2369. }