| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716 |
- <?php
- namespace App\Services\AIGeneration;
- use App\Facade\Site;
- use App\Consts\BaseConst;
- use App\Libs\ScriptAnchor;
- use App\Models\MpGeneratePicTask;
- use App\Models\MpAsset;
- use App\Models\MpUserPointsDetail;
- use App\Services\PointsService;
- use App\Services\VolcEngineService;
- use GuzzleHttp\Client;
- use GuzzleHttp\Exception\GuzzleException;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Redis;
- class AIImageGenerationService
- {
- private $volcEngineService;
- private $httpClient;
- /** @var PointsService */
- private $pointsService;
- // 模型常量
- const MODEL_JIMENG_4 = 'jimeng_4.0';
- const MODEL_NANO_BANANA_2 = 'NanoBanana2';
- const MODEL_NANO_BANANA_PRO = 'NanoBananaPro';
- const MODEL_GPT_IMAGE_2 = 'GptImage2';
- // 队列派发去重相关常量
- /** 派发锁在 Redis 中的 key 前缀 */
- const DISPATCH_LOCK_PREFIX = 'ai_pic_dispatch:';
- /** 派发锁使用的 Redis 连接,与队列保持一致 */
- const DISPATCH_LOCK_CONNECTION = 'default';
- /** 派发锁有效期(秒):worker 长时间未消费时的兜底重派时间 */
- const DISPATCH_LOCK_TTL = 600;
- /** 每轮最多派发的任务数,避免一次性推送过多任务 */
- const DISPATCH_BATCH_LIMIT = 50;
- /** 每轮扫描的候选任务数,需大于 DISPATCH_BATCH_LIMIT,避免已入队的任务挡住后面的任务 */
- const DISPATCH_CANDIDATE_LIMIT = 500;
- /** 派发锁分组:火山API */
- const DISPATCH_GROUP_VOLC = 'volc_pics';
- /** 派发锁分组:GPT-Image2 */
- const DISPATCH_GROUP_GPT_IMAGE2 = 'gpt_image2_pics';
- /** 图片任务最大尝试次数:达到上限仍失败则直接判失败,并写入最后一次的错误原因 */
- const MAX_TASK_ATTEMPTS = 10;
- /**
- * 单次上游请求超时(秒)
- *
- * 必须小于队列 job 的 timeout(540 秒),保证请求先超时、由业务层记录错误并重新排队,
- * 而不是被 worker 强杀导致任务长期停留在 processing。
- * 取值依据:4K 编辑(3840x2160 / 2160x3840)平均 148~152s、长尾超过 300s;
- * 批量提交时上游排队会把单请求推到 300s 以上,故取 480s 给长尾留出余量,
- * 并给 job 留 60s 收尾时间。
- */
- const REQUEST_TIMEOUT_SECONDS = 480;
- /** 连接超时(秒) */
- const REQUEST_CONNECT_TIMEOUT_SECONDS = 30;
- /** extra_params 中记录重试信息的键名 */
- const EXTRA_ATTEMPTS = 'attempts';
- const EXTRA_LAST_ERROR = 'last_error';
- const EXTRA_NEXT_RETRY_AT = 'next_retry_at';
- /**
- * 百度图片上游响应头中携带的任务/请求 ID(实测 gpt-image-2 系列生成与编辑接口均返回)。
- * 这些 ID 一并写入 result_json,用于后续与百度侧月度消耗数据比对。
- */
- const BAIDU_UPSTREAM_TASK_ID_HEADER = 'x-vod-task-id';
- const BAIDU_UPSTREAM_REQUEST_ID_HEADER = 'x-vod-request-id';
- const BAIDU_UPSTREAM_REQID_HEADER = 'http_x_reqid';
- /** result_json 中记录上游 ID 的键名 */
- const RESULT_UPSTREAM_TASK_ID = 'upstream_task_id';
- const RESULT_UPSTREAM_REQUEST_ID = 'upstream_request_id';
- const RESULT_UPSTREAM_REQID = 'upstream_reqid';
- /** 图片任务失败/重试时 token 明细的兜底归属用户(uid 缺失时使用,对账口径) */
- const IMAGE_TOKEN_FALLBACK_UID = 142857;
- public function __construct(VolcEngineService $volcEngineService, PointsService $pointsService)
- {
- $this->volcEngineService = $volcEngineService;
- $this->pointsService = $pointsService;
- $this->httpClient = new Client([
- 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
- 'connect_timeout' => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
- ]);
- }
- /**
- * 构建图片任务计费信息
- *
- * @param array $params
- * @param string $model
- * @return array
- */
- private function buildImageChargeInfo(array $params, string $model): array
- {
- $uid = 0;
- try {
- $uid = (int)Site::getUid();
- } catch (\Throwable $e) {
- // 非请求上下文(如命令行)下可能无法获取用户ID,置为0
- }
- $width = (int)getProp($params, 'width', 1600);
- $height = (int)getProp($params, 'height', 2848);
- $chargeInfo = [
- 'user_id' => $uid,
- 'model' => $model,
- 'width' => $width,
- 'height' => $height,
- 'resolution' => $this->pointsService->normalizeImageResolutionKey($width, $height),
- 'image_num' => (int)getProp($params, 'image_num', 1),
- 'source' => !empty($params['alias_segment_id']) ? 'segment' : (!empty($params['alias_act_id']) ? 'act' : ''),
- 'alias_segment_id' => $params['alias_segment_id'] ?? '',
- 'created_at' => date('Y-m-d H:i:s'),
- ];
- // 前端传入的锚点与业务来源标记(0 默认 / 1 资产 / 2 画布 → charge_info.source_from)
- $chargeInfo = array_merge($chargeInfo, ScriptAnchor::chargeContextFromRequest($params));
- // 锚点数据(anime_id / episode_id / episode_number / script_id,以及 alias_act_id / alias_segment_id),上下文没有的不记录
- return ScriptAnchor::mergeIntoChargeInfo($chargeInfo, $params);
- }
- /**
- * 图片生成余额预检
- *
- * 有用户上下文时校验本次生成所需积分(单价 × 生成张数),不足直接抛错;
- * 后台命令/队列等无用户上下文的场景跳过预检。
- *
- * @param array $chargeInfo
- * @return void
- */
- private function preCheckImageBalance(array $chargeInfo): void
- {
- $uid = (int)($chargeInfo['user_id'] ?? 0);
- if (!$uid) {
- return;
- }
- $imageNum = (int)($chargeInfo['image_num'] ?? 1);
- if ($imageNum <= 0) {
- $imageNum = 1;
- }
- $needPoints = $this->pointsService->getImageChargePoints($chargeInfo) * $imageNum;
- if ($needPoints <= 0) {
- return;
- }
- $this->pointsService->checkUserPointsEnough($needPoints, $uid);
- }
- /**
- * 图片生成成功统一处理(事务保证原子性)
- *
- * 在同一事务内完成:任务状态更新 + 积分计费 + 分镜表同步;
- * 任一步失败整体回滚,任务保持原状态,由后续定时任务/超时重置重试。
- *
- * @param MpGeneratePicTask $task
- * @param array $resultUrls
- * @param array $resultJson
- * @return void
- */
- private function handleImageTaskSuccess(MpGeneratePicTask $task, array $resultUrls, array $resultJson): void
- {
- try {
- DB::beginTransaction();
- // 更新任务状态为成功
- $task->updateStatus(MpGeneratePicTask::STATUS_SUCCESS, [
- 'result_url' => $resultUrls,
- 'result_json' => $resultJson,
- ]);
- // 记录计费明细并扣减用户积分(真实失败则抛错回滚,避免“成功但未计费”)
- $chargeResult = $this->pointsService->recordImageTaskCharge($task);
- if (empty($chargeResult['charged'])
- && !in_array((string)($chargeResult['reason'] ?? ''), ['already_charged', 'no_charge_info'], true)) {
- throw new \Exception('图片计费失败: ' . (string)($chargeResult['reason'] ?? 'unknown'));
- }
- // 同步调整分镜图片状态和结果
- $segmentId = getProp($task, 'alias_segment_id');
- if ($segmentId) {
- $updateSegmentResult = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->update([
- 'img_url' => $resultUrls[0],
- 'pic_task_status' => '已完成',
- ]);
- // 本应更新却未更新到(分镜被删除等),抛错触发整体回滚重试
- if ($updateSegmentResult === false || $updateSegmentResult === 0) {
- throw new \Exception('更新分镜图片状态失败: 未找到对应的分镜记录');
- }
- // 如果是首帧图ID则更新anime表
- if (Redis::sismember('anime_first_frame_urls', $segmentId)) {
- $animeId = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->value('anime_id');
- DB::table('mp_animes')->where('id', $animeId)->update(['first_frame_url' => $resultUrls[0], 'updated_at' => date('Y-m-d H:i:s')]);
- Redis::srem('anime_first_frame_urls', $segmentId);
- }
- }
- DB::commit();
- } catch (\Exception $e) {
- DB::rollBack();
- dLog('generate')->error('图片任务成功处理失败,已回滚: ' . $e->getMessage(), ['task_id' => $task->id]);
- logDB('generate', 'error', '图片任务成功处理失败,已回滚', [
- 'task_id' => $task->id,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- /**
- * 异步创建图片生成任务
- *
- * @param array $params
- * @return MpGeneratePicTask
- */
- public function createImageGenerationTask(array $params): MpGeneratePicTask
- {
- // // 获取模型类型,默认使用即梦AI 4.0
- // $model = $params['model'] ?? self::MODEL_JIMENG_4;
- // 获取模型类型,默认使用即梦seedream5.0-lite
- $model = $params['model'] ?? defaultImageModel();
- // 构建计费信息并余额预检(不足直接报错)
- $chargeInfo = $this->buildImageChargeInfo($params, $model);
- $this->preCheckImageBalance($chargeInfo);
-
- // 检查是否有正在处理的任务(仅即梦AI需要排队)
- if ($model === self::MODEL_JIMENG_4 && $this->hasProcessingTask()) {
- // 如果有正在处理的任务,创建排队任务
- return $this->createQueuedTask($params);
- }
- // 生成唯一的任务ID
- $taskId = 'img_gen_' . time() . '_' . uniqid();
- // 额外参数:显式传入 quality 时记录,提交上游时按需携带(未传则不带该参数)
- $extraParams = $params['extra_params'] ?? [];
- if (!is_array($extraParams)) {
- $extraParams = [];
- }
- $quality = trim((string)($params['quality'] ?? ''));
- if ($quality !== '') {
- $extraParams['quality'] = $quality;
- }
- // 创建任务记录
- $task = MpGeneratePicTask::create([
- 'task_id' => $taskId,
- 'alias_segment_id' => $params['alias_segment_id'] ?? 0,
- 'prompt' => $params['prompt'] ?? '',
- 'width' => $params['width'] ?? 1600,
- 'height' => $params['height'] ?? 2848,
- 'image_num' => $params['image_num'] ?? 1,
- 'scale' => $params['scale'] ?? 50,
- 'ref_img_url' => $params['ref_img_urls'] ?? null,
- 'mask_img_url' => $params['mask_img_url'] ?? null,
- 'extra_params' => $extraParams ?: null,
- 'status' => MpGeneratePicTask::STATUS_PENDING,
- 'model' => $model,
- 'charge_info' => $chargeInfo,
- ]);
- // 即梦AI和NanoBanana系列立即提交任务,火山API在定时任务中提交
- if ($model === self::MODEL_JIMENG_4) {
- $this->submitTaskToJimengApi($task);
- } elseif (in_array($model, BaseConst::NANO_BANANA_MODELS)) {
- $this->submitTaskToNanoBananaApi($task);
- }
- return $task;
- }
- /**
- * 检查是否有正在处理的任务
- *
- * @return bool
- */
- private function hasProcessingTask(): bool
- {
- return MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)
- ->exists();
- }
- /**
- * 创建排队任务
- *
- * @param array $params
- * @return MpGeneratePicTask
- */
- private function createQueuedTask(array $params): MpGeneratePicTask
- {
- // 生成唯一的任务ID
- $taskId = 'img_gen_' . time() . '_' . uniqid();
-
- // 获取模型类型
- $model = $params['model'] ?? self::MODEL_JIMENG_4;
- // 构建计费信息并余额预检(不足直接报错)
- $chargeInfo = $this->buildImageChargeInfo($params, $model);
- $this->preCheckImageBalance($chargeInfo);
- // 创建排队状态的任务记录
- return MpGeneratePicTask::create([
- 'task_id' => $taskId,
- 'alias_segment_id' => $params['alias_segment_id'] ?? 0,
- 'prompt' => $params['prompt'] ?? '',
- 'width' => $params['width'] ?? 1600,
- 'height' => $params['height'] ?? 2848,
- 'image_num' => $params['image_num'] ?? 1,
- 'scale' => $params['scale'] ?? 50,
- 'ref_img_url' => $params['ref_img_urls'] ?? null,
- 'mask_img_url' => $params['mask_img_url'] ?? null,
- 'extra_params' => $params['extra_params'] ?? null,
- 'status' => MpGeneratePicTask::STATUS_PENDING,
- 'model' => $model,
- 'charge_info' => $chargeInfo,
- ]);
- }
- /**
- * 提交任务到即梦AI API
- *
- * @param MpGeneratePicTask $task
- * @return void
- */
- private function submitTaskToJimengApi(MpGeneratePicTask $task): void
- {
- try {
- // 验证环境变量配置
- $accessKey = env('VOLC_AI_AK');
- $secretKey = env('VOLC_AI_SK');
-
- if (empty($accessKey) || empty($secretKey)) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK'
- ]);
- return;
- }
-
- //构建即梦AI 4.0 API请求参数
- $apiParams = [
- 'req_key' => 'jimeng_t2i_v40',
- 'prompt' => $task->prompt,
- 'scale' => $task->scale / 100, //为0-1范围
- ];
-
- // 添加可选参数
- if ($task->image_num > 1) {
- $apiParams['force_single'] = false;
- } else {
- $apiParams['force_single'] = true;
- }
-
- // 参考图片
- if ($task->ref_img_url) {
- $apiParams['image_urls'] = $task->ref_img_url;
- }
-
- // if ($task->mask_img_url) {
- // $apiParams['image_urls'] = $task->mask_img_url;
- // }
-
- // 尺寸参数
- $area = $task->width * $task->height; //计算面积
- if ($area >= 1024*1024 && $area <= 4096*4096) {
- $apiParams['size'] = (int)$area;
- }
- dLog('generate')->info('即梦AI文生图参数: ', $apiParams);
- logDB('generate', 'info', '即梦AI文生图任务提交', ['task_id' => $task->task_id, 'params' => $apiParams]);
-
- //调用即梦AI 4.0 API提交任务
- $response = $this->volcEngineService->request(
- 'POST',
- 'visual.volcengineapi.com', //即梦AI API域名
- '/', // 使用根路径
- [], // Query参数
- json_encode($apiParams),
- $accessKey,
- $secretKey,
- 'cv', // 服务标识
- 'cn-north-1', //区域
- 'CVSync2AsyncSubmitTask',
- '2022-08-31',
- 'application/json'
- );
-
- $responseData = json_decode($response['body'], true);
- dLog('generate')->info('即梦AI 4.0 API提交任务响应: ', $responseData);
- logDB('generate', 'info', '即梦AI 4.0 API提交任务响应', ['task_id' => $task->task_id, 'response' => $responseData]);
-
- if ($responseData['code'] !== 10000) {
- // API返回错误
- logDB('generate', 'error', '即梦AI任务提交失败', ['task_id' => $task->task_id, 'error' => $responseData['message'] ?? 'API Error']);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $responseData['message'] ?? 'API Error',
- 'extra_params' => $responseData['data'] ?? []
- ]);
- } else {
- // 任务提交成功,更新状态为处理中
- $updateData = [];
- //存储API返回的任务ID
- if (isset($responseData['data']['task_id'])) {
- $updateData['task_id'] = $responseData['data']['task_id'];
- $updateData['extra_params'] = $responseData ?? [];
- }
-
- $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING, $updateData);
- }
- } catch (\Exception $e) {
- // 记录错误
- logDB('generate', 'error', '即梦AI任务提交异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => 'API请求失败: ' . $e->getMessage()
- ]);
- }
- }
- /**
- * 提交任务到GPT-Image2 API
- *
- * @param MpGeneratePicTask $task
- * @return void
- */
- public function submitTaskToGptImage2Api(MpGeneratePicTask $task): void
- {
- // 上游任务/请求 ID:百度渠道在响应头中返回,成功或失败都尽量记录到 result_json
- $upstreamIds = [];
- // 本次上游返回的响应体(失败/重试路径用于保留原始结果并补记 token)
- $lastResponseData = null;
- // 本次上游返回的响应头(编辑模式 cURL / 文生图 Guzzle 均采集),
- // 超时或异常时写入 result_json,用于判断该次是否已拿到上游任务/请求 ID
- $rawResponseHeaders = [];
- try {
- // 渠道判定:baidu- 前缀走「百度生成模型服务V2」(gpt-image-2 / 2.5 系),其余走现有快快渠道
- $isBaiduChannel = strpos((string)$task->model, 'baidu-') === 0;
- // 上游模型名不带渠道前缀:表里存 baidu-gpt-image-2,调用上游用 gpt-image-2
- $upstreamModel = $isBaiduChannel ? substr((string)$task->model, strlen('baidu-')) : (string)$task->model;
- // 验证环境变量配置
- $apiKey = $isBaiduChannel ? env('BAIDU_GPT_IMAGE_AK') : env('GPT_IMAGE2_API_KEY');
- if (empty($apiKey)) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $isBaiduChannel
- ? '百度GPT-Image接口KEY未配置,请检查环境变量BAIDU_GPT_IMAGE_AK'
- : 'GPT-Image2 API KEY未配置,请检查环境变量GPT_IMAGE2_API_KEY'
- ]);
- return;
- }
-
- // 重试上限控制:已达上限直接判失败(error_message 内含最后一次失败原因)
- if (!$this->beginTaskAttempt($task)) {
- return;
- }
- // 更新状态为处理中
- $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
-
- // 渠道 base url:百度生成模型服务V2(默认新加坡节点)/ 快快中转
- $apiBase = $isBaiduChannel
- ? rtrim((string)env('BAIDU_GPT_IMAGE_BASE_URL', 'https://eos-sg.aigc.exp.bcevod.com/v1'), '/')
- : 'https://ai-api.kkidc.com/v1';
- $api_url = $apiBase . '/images/generations';
- $isEditMode = false;
-
- // 参考图片 - 如果有参考图则使用 /edits 接口
- if ($task->ref_img_url) {
- $api_url = $apiBase . '/images/edits';
- $isEditMode = true;
- }
-
- // 图像质量(OpenAI gpt-image-2 的 quality 参数):仅当创建任务时显式传入才携带
- $taskExtraParams = $task->extra_params;
- $taskQuality = is_array($taskExtraParams) ? trim((string)getProp($taskExtraParams, 'quality', '')) : '';
- $channelLog = ['task_id' => $task->task_id, 'mode' => $isEditMode ? 'edit' : 'generation', 'channel' => $isBaiduChannel ? 'baidu' : 'kkidc', 'model' => $upstreamModel, 'quality' => $taskQuality];
- dLog('generate')->info('开始调用GPT-Image2 API', $channelLog);
- logDB('generate', 'info', '开始调用GPT-Image2 API', $channelLog);
-
- // 创建专用客户端,优化配置
- $gptClient = new Client([
- // 超时必须小于队列 job 的 timeout(540s):请求先超时并抛出异常,
- // 由业务层记录错误并重新排队,避免 worker 强杀导致任务长期悬挂
- 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
- 'connect_timeout' => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
- 'http_errors' => false, // 不自动抛出HTTP错误
- 'verify' => false, // 禁用SSL验证(如果有证书问题)
- 'allow_redirects' => true,
- // 响应头到达即采集,body 卡住超时时也能记录到上游任务/请求 ID
- 'on_headers' => function (\Psr\Http\Message\ResponseInterface $response) use (&$rawResponseHeaders) {
- $rawResponseHeaders = $response->getHeaders();
- },
- ]);
-
- // 根据是否有参考图选择不同的请求方式
- if ($isEditMode) {
- // 使用原生 cURL 处理 multipart/form-data 格式
- $imageUrls = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
-
- // 多图上限:暂不限制张数(如需恢复限制,放开下面这行)
- // $imageUrls = array_slice($imageUrls, 0, 4);
-
- dLog('generate')->info('下载参考图片', ['image_urls' => $imageUrls, 'count' => count($imageUrls)]);
-
- // 单张参考图超过该大小时压缩,避免请求体过大被网关拒绝
- $maxRefBytes = 5 * 1024 * 1024;
- // 下载所有参考图片并保存为临时文件
- $tempFiles = [];
- foreach ($imageUrls as $index => $imageUrl) {
- $imageContent = @file_get_contents($imageUrl);
- if ($imageContent === false) {
- dLog('generate')->warning('下载参考图片失败,跳过', ['image_url' => $imageUrl]);
- continue;
- }
-
- dLog('generate')->info('参考图片下载完成', ['index' => $index, 'size' => strlen($imageContent)]);
-
- $extension = pathinfo(parse_url($imageUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'png';
- // 超过 5MB 时压缩(公共方法保持原图比例与原格式)
- if (strlen($imageContent) > $maxRefBytes) {
- $rawSize = strlen($imageContent);
- $compressedData = compressRemoteImageUrlToSize($imageUrl, $maxRefBytes);
- if (!empty($compressedData)) {
- $imageContent = $compressedData;
- $imgInfo = @getimagesizefromstring($imageContent);
- $mime = strtolower($imgInfo['mime'] ?? '');
- $extension = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/webp' ? 'webp' : 'png');
- } else {
- // 公共方法对 JPEG 不做有损压缩,回退到本地缩放
- $fallbackData = $this->compressReferenceImageForEdit($imageContent, 2048, 90);
- if ($fallbackData !== null) {
- $imageContent = $fallbackData;
- $extension = 'jpg';
- }
- }
- dLog('generate')->info('参考图片压缩完成', [
- 'index' => $index,
- 'before' => $rawSize,
- 'after' => strlen($imageContent),
- 'extension' => $extension,
- ]);
- }
- // 保存为临时文件(cURL 需要真实文件路径)
- $tempFile = tempnam(sys_get_temp_dir(), 'gpt_img_') . '.' . $extension;
- file_put_contents($tempFile, $imageContent);
-
- $tempFiles[] = [
- 'path' => $tempFile,
- 'extension' => $extension
- ];
-
- dLog('generate')->info('临时文件创建成功', ['file' => $tempFile, 'size' => filesize($tempFile)]);
- }
-
- if (empty($tempFiles)) {
- throw new \Exception('所有参考图片下载失败');
- }
-
- // 构建 POST 字段(文本字段)
- $textFields = [
- 'prompt' => $task->prompt,
- 'model' => $upstreamModel,
- 'n' => (string)$task->image_num, // 新接口要求字符串类型
- ];
- if (!$isBaiduChannel) {
- // 快快渠道要求显式指定返回格式;百度渠道默认返回 b64_json
- $textFields['response_format'] = 'b64_json';
- }
- // 图像质量:显式传入 quality 才携带(百度 / 快快渠道一致)
- if ($taskQuality !== '') {
- $textFields['quality'] = $taskQuality;
- }
-
- // 尺寸参数(必填)
- if ($task->width && $task->height) {
- $textFields['size'] = $task->width . 'x' . $task->height;
- } else {
- $textFields['size'] = '1024x1024'; // 默认尺寸
- }
-
- // 多张参考图需要在 multipart 中出现重复的 image[] 字段;
- // PHP 数组无法承载重复 key(同名会覆盖),因此手动拼装请求体。
- // 单张参考图沿用 image 字段,避免影响已有单图任务。
- $fileFieldName = count($tempFiles) > 1 ? 'image[]' : 'image';
- $boundary = '----MpGptImage' . md5(uniqid('', true));
- $postFields = $this->buildMultipartBody($textFields, $tempFiles, $boundary, $fileFieldName);
-
- dLog('generate')->info('GPT-Image2 API参数(编辑模式-cURL)', [
- 'api_url' => $api_url,
- 'prompt' => $task->prompt,
- 'size' => $textFields['size'],
- 'n' => $textFields['n'],
- 'response_format' => getProp($textFields, 'response_format', ''),
- 'file_field' => $fileFieldName,
- 'body_size' => strlen($postFields),
- 'image_count' => count($tempFiles)
- ]);
- logDB('generate', 'info', 'GPT-Image2任务提交(编辑模式)', [
- 'task_id' => $task->task_id,
- 'api_url' => $api_url,
- 'image_urls' => $imageUrls,
- 'prompt' => $task->prompt
- ]);
-
- $startTime = microtime(true);
-
- // 使用原生 cURL;响应头由方法上方统一声明的 $rawResponseHeaders 采集
- $ch = curl_init();
- curl_setopt_array($ch, [
- CURLOPT_URL => $api_url,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => $postFields,
- CURLOPT_HTTPHEADER => [
- 'Authorization: Bearer ' . $apiKey,
- 'Accept: application/json',
- 'Content-Type: multipart/form-data; boundary=' . $boundary,
- 'Content-Length: ' . strlen($postFields),
- 'Expect:', // 移除 Expect: 100-continue,避免等待
- ],
- CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT_SECONDS, // 与 job timeout(540s) 对齐,先超时再落状态
- CURLOPT_CONNECTTIMEOUT => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
- CURLOPT_SSL_VERIFYPEER => false,
- CURLOPT_SSL_VERIFYHOST => false,
- CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, // 强制使用 HTTP/1.1
- CURLOPT_HEADERFUNCTION => function ($ch, $headerLine) use (&$rawResponseHeaders) {
- $separatorPos = strpos($headerLine, ':');
- if ($separatorPos !== false) {
- $headerName = strtolower(trim(substr($headerLine, 0, $separatorPos)));
- $rawResponseHeaders[$headerName][] = trim(substr($headerLine, $separatorPos + 1));
- }
- return strlen($headerLine);
- },
- ]);
-
- dLog('generate')->info('开始发送cURL请求');
-
- $responseBody = curl_exec($ch);
- $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $curlError = curl_error($ch);
- $curlErrno = curl_errno($ch);
- curl_close($ch);
- // 解析上游任务/请求 ID(失败响应同样可能携带)
- $upstreamIds = $this->extractBaiduUpstreamIds($rawResponseHeaders);
- // 解析响应体(含非 JSON 的原始片段),供失败/重试路径记录
- $lastResponseData = $this->decodeUpstreamBody($responseBody);
-
- // 删除所有临时文件
- foreach ($tempFiles as $tempFile) {
- @unlink($tempFile['path']);
- }
-
- $elapsed = round(microtime(true) - $startTime, 2);
- dLog('generate')->info('cURL请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode, 'curl_errno' => $curlErrno]);
-
- if ($curlErrno !== 0) {
- throw new \Exception('cURL错误: ' . $curlError . ' (错误码: ' . $curlErrno . ')');
- }
-
- if ($statusCode >= 500) {
- // 上游 5xx:按可重试错误处理(放回队列,稍后再试)
- throw new \RuntimeException('上游服务异常,HTTP状态码: ' . $statusCode);
- }
-
- if (empty($responseBody)) {
- throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
- }
- } else {
- // 使用 JSON 格式 (原有的文生图模式)
- $apiParams = [
- 'model' => $upstreamModel,
- 'prompt' => $task->prompt,
- 'n' => (int)$task->image_num,
- ];
- // 图像质量:显式传入 quality 才携带(百度 / 快快渠道一致),未传则不带该参数、由上游按默认处理
- if ($taskQuality !== '') {
- $apiParams['quality'] = $taskQuality;
- }
- if (!$isBaiduChannel) {
- // 快快渠道要求显式指定返回格式;百度渠道默认返回 b64_json
- $apiParams['response_format'] = 'b64_json';
- }
-
- // 尺寸参数
- if ($task->width && $task->height) {
- $apiParams['size'] = $task->width . 'x' . $task->height;
- } else {
- $apiParams['size'] = 'auto'; // 新接口必填参数
- }
-
- dLog('generate')->info('GPT-Image2 API参数', $apiParams);
- logDB('generate', 'info', 'GPT-Image2任务提交', ['task_id' => $task->task_id, 'api_url' => $api_url, 'params' => $apiParams]);
-
- $startTime = microtime(true);
-
- // 调用GPT-Image2 API
- $response = $gptClient->post($api_url, [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $apiKey,
- 'Content-Type' => 'application/json',
- ],
- 'json' => $apiParams,
- ]);
-
- $statusCode = $response->getStatusCode();
- $responseBody = $response->getBody()->getContents();
- $upstreamIds = $this->extractBaiduUpstreamIds($response->getHeaders());
- // 解析响应体(含非 JSON 的原始片段),供失败/重试路径记录
- $lastResponseData = $this->decodeUpstreamBody($responseBody);
-
- $elapsed = round(microtime(true) - $startTime, 2);
- dLog('generate')->info('Guzzle请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode]);
-
- if ($statusCode >= 500) {
- // 上游 5xx:按可重试错误处理(放回队列,稍后再试)
- throw new \RuntimeException('上游服务异常,HTTP状态码: ' . $statusCode . ',响应: ' . mb_substr((string)$responseBody, 0, 200));
- }
-
- if (empty($responseBody)) {
- throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
- }
- }
-
- // 解析响应
- dLog('generate')->info('响应数据长度', ['length' => strlen($responseBody)]);
-
- $responseData = json_decode($responseBody, true);
-
- if (json_last_error() !== JSON_ERROR_NONE) {
- dLog('generate')->error('JSON解析失败', ['error' => json_last_error_msg(), 'body' => substr($responseBody, 0, 500)]);
- throw new \Exception('JSON解析失败: ' . json_last_error_msg());
- }
-
- // 移除 b64_json 字段用于日志记录,避免生成超大日志
- $logResponseData = $this->removeB64JsonFromResponse($responseData);
- dLog('generate')->info('GPT-Image2 API响应', $logResponseData);
- logDB('generate', 'info', 'GPT-Image2 API响应', ['task_id' => $task->task_id, 'response' => $logResponseData]);
-
- if (isset($responseData['error'])) {
- // API返回错误
- logDB('generate', 'error', 'GPT-Image2任务失败', ['task_id' => $task->task_id, 'error' => $responseData['error']['message'] ?? 'API Error']);
- // 准确记录上游返回结果;若含 usage 则补记 token 明细(失败不扣积分)
- $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, 'GPT-Image2任务失败,仅记录token不扣积分');
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $responseData['error']['message'] ?? 'API Error',
- 'result_json' => $failResultJson
- ]);
- } else {
- // 任务提交成功,直接处理结果
- dLog('generate')->info('开始处理GPT-Image2响应结果');
- $this->processGptImage2ApiResponse($task, $responseData, $upstreamIds);
- }
- } catch (\Exception $e) {
- // 记录错误
- dLog('generate')->error('GPT-Image2 API请求失败: ' . $e->getMessage(), [
- 'task_id' => $task->task_id,
- 'trace' => $e->getTraceAsString()
- ]);
- logDB('generate', 'error', 'GPT-Image2任务异常', [
- 'task_id' => $task->task_id,
- 'error' => $e->getMessage(),
- 'file' => $e->getFile(),
- 'line' => $e->getLine()
- ]);
- $errorMessage = 'API请求失败: ' . $e->getMessage();
- if ($this->isRetryableApiError($e)) {
- // 超时/连接失败等可重试错误:先记录上游返回结果/token,再放回队列尾部重试
- $failResultJson = $this->captureFailedAttempt($task, $lastResponseData, $upstreamIds, '图片任务失败重试,仅记录token不扣积分', $rawResponseHeaders);
- $this->requeueTaskForRetry($task, $errorMessage, $failResultJson);
- return;
- }
- // 最终失败:同样记录上游返回结果与 token(失败不扣积分)
- $failResultJson = $this->captureFailedAttempt($task, $lastResponseData, $upstreamIds, '图片任务最终失败,仅记录token不扣积分', $rawResponseHeaders);
- $failData = ['error_message' => $errorMessage];
- if (!empty($failResultJson)) {
- $failData['result_json'] = $failResultJson;
- }
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, $failData);
- }
- }
- /**
- * 压缩参考图片(兜底方案,用于公共压缩方法不处理的场景,如大尺寸 JPEG)
- *
- * 缩放到长边不超过 $maxSide,并统一输出 JPEG(透明背景填白)。
- *
- * @param string $imageStream 图片二进制数据
- * @param int $maxSide 长边像素上限
- * @param int $quality JPEG 质量
- * @return string|null 压缩后的二进制数据,失败返回 null
- */
- private function compressReferenceImageForEdit(string $imageStream, int $maxSide = 1536, int $quality = 90): ?string
- {
- try {
- $imgInfo = @getimagesizefromstring($imageStream);
- if (!$imgInfo) {
- return null;
- }
- $srcImg = @imagecreatefromstring($imageStream);
- if (!$srcImg) {
- return null;
- }
- $origW = imagesx($srcImg);
- $origH = imagesy($srcImg);
- $scale = $maxSide / max($origW, $origH);
- if ($scale < 1) {
- $newW = max(1, (int)round($origW * $scale));
- $newH = max(1, (int)round($origH * $scale));
- $resizedImg = imagecreatetruecolor($newW, $newH);
- // JPEG 不支持透明通道,用白底填充
- $white = imagecolorallocate($resizedImg, 255, 255, 255);
- imagefill($resizedImg, 0, 0, $white);
- imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
- imagedestroy($srcImg);
- $srcImg = $resizedImg;
- } elseif ($imgInfo['mime'] !== 'image/jpeg') {
- // 未缩放但非 JPEG:铺白底再输出,避免透明区域变黑
- $flatImg = imagecreatetruecolor($origW, $origH);
- $white = imagecolorallocate($flatImg, 255, 255, 255);
- imagefill($flatImg, 0, 0, $white);
- imagecopy($flatImg, $srcImg, 0, 0, 0, 0, $origW, $origH);
- imagedestroy($srcImg);
- $srcImg = $flatImg;
- }
- ob_start();
- imagejpeg($srcImg, null, $quality);
- $compressed = ob_get_clean();
- imagedestroy($srcImg);
- return ($compressed === false || $compressed === '') ? null : $compressed;
- } catch (\Throwable $e) {
- dLog('generate')->warning('参考图片压缩失败,使用原图', ['error' => $e->getMessage()]);
- return null;
- }
- }
- /**
- * 构建 multipart/form-data 请求体
- *
- * 参考图字段需要在请求体中出现多次(image[]),而 PHP 数组同一个 key 只能保留最后一个值,
- * 用 CURLOPT_POSTFIELDS 传数组会导致多张参考图互相覆盖,所以这里手动拼装请求体字符串。
- *
- * @param array $textFields 普通文本字段 [name => value]
- * @param array $files 文件字段,每项含 path、extension
- * @param string $boundary multipart 分隔符
- * @param string $fileFieldName 文件字段名,多图时传 image[],单图传 image
- * @return string
- */
- private function buildMultipartBody(array $textFields, array $files, string $boundary, string $fileFieldName = 'image[]'): string
- {
- $eol = "\r\n";
- $body = '';
- foreach ($textFields as $name => $value) {
- $body .= '--' . $boundary . $eol;
- $body .= 'Content-Disposition: form-data; name="' . $name . '"' . $eol . $eol;
- $body .= (string)$value . $eol;
- }
- foreach ($files as $index => $file) {
- $extension = $file['extension'];
- $filename = 'image' . ($index + 1) . '.' . $extension;
- $mime = strtolower($extension) === 'jpg' ? 'image/jpeg' : 'image/' . $extension;
- $content = @file_get_contents($file['path']);
- if ($content === false) {
- throw new \Exception('读取参考图片临时文件失败: ' . $file['path']);
- }
- $body .= '--' . $boundary . $eol;
- $body .= 'Content-Disposition: form-data; name="' . $fileFieldName . '"; filename="' . $filename . '"' . $eol;
- $body .= 'Content-Type: ' . $mime . $eol . $eol;
- $body .= $content . $eol;
- unset($content);
- }
- $body .= '--' . $boundary . '--' . $eol;
- return $body;
- }
- /**
- * 处理GPT-Image2 API响应结果
- *
- * @param MpGeneratePicTask $task
- * @param array $responseData
- * @param array $upstreamIds 上游响应头中解析出的任务/请求 ID
- * @return void
- */
- private function processGptImage2ApiResponse(MpGeneratePicTask $task, array $responseData, array $upstreamIds = []): void
- {
- try {
- if (!isset($responseData['data']) || empty($responseData['data'])) {
- $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, '图片任务未返回图片数据,仅记录token不扣积分');
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '未返回图片数据',
- 'result_json' => $failResultJson
- ]);
- return;
- }
- $result_urls = [];
- foreach ($responseData['data'] as $index => $imageData) {
- // 优先处理 b64_json 格式(新接口返回格式)
- if (isset($imageData['b64_json']) && !empty($imageData['b64_json'])) {
- $base64Data = $imageData['b64_json'];
- $imageStream = base64_decode($base64Data);
-
- if ($imageStream === false) {
- dLog('generate')->error('Base64解码失败', ['task_id' => $task->task_id, 'index' => $index]);
- continue;
- }
-
- $pic_name = 'ai_generation_gpt_' . time() . '_' . uniqid() . '.png';
-
- // 压缩PNG图片流
- $compressedData = $this->compressImageStream($imageStream);
- $url = uploadStreamByTos('image', $compressedData, $pic_name);
-
- $result_urls[] = $url;
-
- // 记录优化后的提示词(如果有)
- if (isset($imageData['revised_prompt'])) {
- dLog('generate')->info('图片生成优化提示词', [
- 'task_id' => $task->task_id,
- 'index' => $index,
- 'revised_prompt' => $imageData['revised_prompt']
- ]);
- }
- } elseif (isset($imageData['url']) && !empty($imageData['url'])) {
- // 兼容旧的 url 格式(如果接口仍返回)
- $url = $imageData['url'];
- $pic_name = 'ai_generation_gpt_' . time() . '_' . uniqid();
- $pic_ext = getImgExtFromUrl($url);
- $pic_name = $pic_name . $pic_ext;
-
- if ($pic_ext === '.png') {
- $comporessed_data = compressRemoteImageUrlToSize($url);
- $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
- } else {
- $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
- }
-
- $result_urls[] = $url;
- }
- }
- if (empty($result_urls)) {
- $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, '图片任务所有图片生成失败,仅记录token不扣积分');
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '所有图片生成失败',
- 'result_json' => $failResultJson
- ]);
- return;
- }
- // 移除 b64_json 字段以避免存储大量 Base64 数据
- $cleanedResponseData = $responseData;
- if (isset($cleanedResponseData['data'])) {
- foreach ($cleanedResponseData['data'] as &$imageData) {
- unset($imageData['b64_json']); // 移除 Base64 数据
- }
- unset($imageData); // 解除引用
- }
- // 上游任务/请求 ID 随 result_json 一并落库,供月度消耗比对
- $cleanedResponseData = $this->mergeUpstreamIds($cleanedResponseData, $upstreamIds);
- // 保留此前重试失败时留下的超时响应头,任务最终成功也能回溯该次是否拿到上游 ID
- if (is_array($task->result_json) && !empty($task->result_json['raw_response_headers'])) {
- $cleanedResponseData['raw_response_headers'] = $task->result_json['raw_response_headers'];
- }
- // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
- $this->handleImageTaskSuccess($task, $result_urls, $cleanedResponseData);
- } catch (\Exception $e) {
- $failResultJson = $this->captureFailedAttempt($task, $responseData, $upstreamIds, '图片任务处理响应失败,仅记录token不扣积分');
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '处理响应失败: ' . $e->getMessage(),
- 'result_json' => $failResultJson
- ]);
- }
- }
- /**
- * 提交任务到火山图片生成API
- *
- * @param MpGeneratePicTask $task
- * @return void
- */
- public function submitTaskToVolcApi(MpGeneratePicTask $task): void
- {
- try {
- // 验证环境变量配置
- $apiKey = env('VOLC_AI_API_KEY');
-
- if (empty($apiKey)) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '火山引擎API KEY未配置,请检查环境变量VOLC_AI_API_KEY'
- ]);
- return;
- }
-
- // 重试上限控制:已达上限直接判失败(error_message 内含最后一次失败原因)
- if (!$this->beginTaskAttempt($task)) {
- return;
- }
- // 更新状态为处理中
- $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
-
- // 构建火山图片生成API请求参数
- $apiParams = [
- 'model' => isset(BaseConst::MODELS_MAP[$task->model]) ? BaseConst::MODELS_MAP[$task->model] : $task->model,
- 'prompt' => $task->prompt,
- 'watermark' => false,
- ];
-
- // 尺寸参数
- if ($task->width && $task->height) {
- $apiParams['size'] = $task->width . 'x' . $task->height;
- }
-
- // 参考图片
- if ($task->ref_img_url) {
- if (is_array($task->ref_img_url)) {
- // $apiParams['image'] = count($task->ref_img_url) === 1 ? $task->ref_img_url[0] : $task->ref_img_url;
- $apiParams['image'] = $task->ref_img_url;
- } else {
- $apiParams['image'] = $task->ref_img_url;
- }
- }
- // 生成多图或者单图
- $imageNum = (int)$task->image_num;
- if ($imageNum === 1) {
- $apiParams['sequential_image_generation'] = 'disabled';
- }else if ($imageNum > 1) {
- // 组图模式下由模型决定实际生成张数,这里在提示词末尾明确要求生成对应数量,并将组图上限设置为目标张数
- $apiParams['prompt'] = rtrim((string)$task->prompt) . "\n生成{$imageNum}张图片";
- $apiParams['sequential_image_generation'] = 'auto';
- $apiParams['sequential_image_generation_options']['max_images'] = $imageNum;
- }
- dLog('generate')->info('火山图片生成API参数: ', $apiParams);
- logDB('generate', 'info', '火山图片生成任务提交', ['task_id' => $task->task_id, 'params' => $apiParams]);
-
- // 调用火山图片生成API
- $response = $this->httpClient->post('https://ark.cn-beijing.volces.com/api/v3/images/generations', [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $apiKey,
- 'Content-Type' => 'application/json',
- ],
- 'json' => $apiParams,
- ]);
-
- $responseData = json_decode($response->getBody()->getContents(), true);
- dLog('generate')->info('火山图片生成API响应: ', $responseData);
- logDB('generate', 'info', '火山图片生成API响应', ['task_id' => $task->task_id, 'response' => $responseData]);
-
- if (isset($responseData['error'])) {
- // API返回错误
- logDB('generate', 'error', '火山图片生成任务失败', ['task_id' => $task->task_id, 'error' => $responseData['error']['message'] ?? 'API Error']);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $responseData['error']['message'] ?? 'API Error',
- 'result_json' => $responseData
- ]);
- // 上游报错时若已返回 usage,仅补记 token(失败不扣积分)
- $this->recordFailedTaskTokens($task, $responseData, '火山图片生成任务失败,仅记录token不扣积分');
- } else {
- // 任务提交成功,直接处理结果
- $this->processVolcApiResponse($task, $responseData);
- }
- } catch (\Exception $e) {
- // 记录错误
- dLog('generate')->error('火山API请求失败: ' . $e->getMessage());
- logDB('generate', 'error', '火山图片生成任务异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
- $errorMessage = 'API请求失败: ' . $e->getMessage();
- if ($this->isRetryableApiError($e)) {
- // 超时/连接失败等可重试错误:放回队列尾部重试,先让其它任务执行
- $this->requeueTaskForRetry($task, $errorMessage);
- return;
- }
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $errorMessage
- ]);
- }
- }
- /**
- * 图片任务失败/重试时,把上游返回的 usage 补记成 token 明细(失败不扣积分)
- *
- * 失败/重试任务不走 recordImageTaskCharge(那是成功计费),这里只补 token 记录;
- * 明细以 task_id=NULL 落库((type, task_id) 唯一索引下多个 NULL 互不冲突),
- * 既不占用成功计费行,也不会阻断后续成功扣费。
- *
- * @param MpGeneratePicTask $task
- * @param mixed $responseData 上游响应(含 usage 时才会落库)
- * @param string $remark 备注
- * @param array $upstreamIds 上游响应头中解析出的任务/请求 ID
- * @return void
- */
- private function recordFailedTaskTokens(MpGeneratePicTask $task, $responseData, string $remark, array $upstreamIds = []): void
- {
- try {
- if (!is_array($responseData)) {
- return;
- }
- $usage = isset($responseData['usage']) && is_array($responseData['usage']) ? $responseData['usage'] : [];
- $tokens = (int)($usage['total_tokens'] ?? $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0);
- if ($tokens <= 0) {
- dLog('generate')->info('图片任务失败响应未含usage,跳过token记账', ['task_id' => $task->task_id, 'remark' => $remark]);
- return;
- }
- $chargeInfo = $task->charge_info;
- if (is_string($chargeInfo)) {
- $chargeInfo = json_decode($chargeInfo, true);
- }
- if (!is_array($chargeInfo)) {
- $chargeInfo = [];
- }
- // 归属用户:优先任务自带 user_id,缺失时用图片兜底 uid(对账口径)
- $uid = (int)getProp($chargeInfo, 'user_id', 0);
- if ($uid <= 0) {
- $uid = self::IMAGE_TOKEN_FALLBACK_UID;
- $chargeInfo['fallback_uid'] = true;
- dLog('generate')->warning('图片失败尝试token明细归属用户缺失,使用兜底uid记账', [
- 'fallback_uid' => $uid,
- 'task_id' => $task->task_id,
- ]);
- }
- // 记录任务关联信息,便于月度审查把 token 明细回溯到具体图片任务与上游任务
- $chargeInfo['image_task_id'] = (int)$task->id;
- $chargeInfo['external_task_id'] = (string)$task->task_id;
- if (!empty($upstreamIds)) {
- $chargeInfo['upstream_ids'] = $upstreamIds;
- }
- $this->pointsService->recordUnchargedAttemptTokens(
- $uid,
- $tokens,
- MpUserPointsDetail::TYPE_IMAGE,
- (string)getProp($task, 'model', ''),
- $chargeInfo,
- $remark
- );
- dLog('generate')->info('图片任务失败尝试token明细已记录', [
- 'task_id' => $task->task_id,
- 'tokens' => $tokens,
- 'uid' => $uid,
- 'remark' => $remark,
- ]);
- } catch (\Throwable $e) {
- dLog('generate')->warning('图片任务失败尝试token明细记录异常(不影响主流程): ' . $e->getMessage(), [
- 'task_id' => $task->task_id ?? null,
- ]);
- }
- }
- /**
- * 处理火山API响应结果
- *
- * @param MpGeneratePicTask $task
- * @param array $responseData
- * @return void
- */
- private function processVolcApiResponse(MpGeneratePicTask $task, array $responseData): void
- {
- try {
- if (!isset($responseData['data']) || empty($responseData['data'])) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '未返回图片数据',
- 'result_json' => $responseData
- ]);
- $this->recordFailedTaskTokens($task, $responseData, '未返回图片数据,仅记录token不扣积分');
- return;
- }
- $result_urls = [];
- foreach ($responseData['data'] as $imageData) {
- if (isset($imageData['error'])) {
- // 单张图片生成失败
- dLog('generate')->warning('单张图片生成失败: ', $imageData['error']);
- continue;
- }
- if (isset($imageData['url'])) {
- $url = $imageData['url'];
- $pic_name = 'ai_generation_' . time() . '_' . uniqid();
- $pic_ext = getImgExtFromUrl($url);
- $pic_name = $pic_name . $pic_ext;
-
- if ($pic_ext === '.png') {
- $comporessed_data = compressRemoteImageUrlToSize($url);
- $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
- } else {
- // 将图片另存到tos
- $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
- }
-
- $result_urls[] = $url;
- }
- }
- if (empty($result_urls)) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '所有图片生成失败',
- 'result_json' => $responseData
- ]);
- $this->recordFailedTaskTokens($task, $responseData, '所有图片生成失败,仅记录token不扣积分');
- return;
- }
- // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
- $this->handleImageTaskSuccess($task, $result_urls, $responseData);
- } catch (\Exception $e) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '处理响应失败: ' . $e->getMessage()
- ]);
- }
- }
- public function getTaskList($data)
- {
- $task_id = getProp($data, 'task_id');
- $status = getProp($data, 'status');
- $query = MpGeneratePicTask::select('*');
- if ($task_id) {
- $query->where('id', $task_id);
- }
- if ($status) {
- $query->where('status', $status);
- }
- return $query->orderBy('created_at', 'desc')->paginate();
- }
- /**
- * 查询任务状态
- *
- * @param MpGeneratePicTask $task
- * @return array
- */
- public function queryTaskStatus(MpGeneratePicTask $task): array
- {
- // 根据模型类型调用不同的查询方法
- if ($task->model === self::MODEL_JIMENG_4) {
- return $this->queryJimengTaskStatus($task);
- } elseif (in_array($task->model, BaseConst::NANO_BANANA_MODELS)) {
- return $this->queryNanoBananaTaskStatus($task);
- } else {
- // 火山API是同步返回结果,不需要查询
- return [
- 'status' => $task->status === MpGeneratePicTask::STATUS_SUCCESS ? 'success' : 'failed',
- 'error_message' => $task->error_message,
- 'result_url' => $task->result_url,
- 'result_json' => $task->result_json ?? []
- ];
- }
- }
- /**
- * 查询即梦AI任务状态
- *
- * @param MpGeneratePicTask $task
- * @return array
- */
- private function queryJimengTaskStatus(MpGeneratePicTask $task): array
- {
- try {
- // 获取API任务ID
- $apiTaskId = $task->task_id ?? null;
-
- if (!$apiTaskId) {
- return [
- 'status' => 'failed',
- 'error_message' => 'API任务ID不存在',
- 'result_url' => null
- ];
- }
-
- //构建查询参数
- $apiParams = [
- 'req_key' => 'jimeng_t2i_v40',
- 'task_id' => $apiTaskId,
- 'req_json' => '{"return_url":true}' // 返回URL格式
- ];
-
- //调用即梦AI 4.0 API查询任务状态
- $response = $this->volcEngineService->request(
- 'POST',
- 'visual.volcengineapi.com',
- '/', // 使用根路径
- [], // Query参数
- json_encode($apiParams),
- env('VOLC_AI_AK'),
- env('VOLC_AI_SK'),
- 'cv',
- 'cn-north-1',
- 'CVSync2AsyncGetResult',
- '2022-08-31',
- 'application/json'
- );
-
- $responseData = json_decode($response['body'], true);
- dLog('generate')->info('即梦AI 4.0 API查询任务状态响应: ', $responseData);
- logDB('generate', 'info', '即梦AI任务状态查询', ['task_id' => $apiTaskId, 'response' => $responseData]);
-
- if ($responseData['code'] !== 10000) {
- // API返回错误
- logDB('generate', 'error', '即梦AI任务状态查询失败', ['task_id' => $apiTaskId, 'error' => $responseData['message'] ?? 'API Error']);
- return [
- 'status' => 'failed',
- 'error_message' => $responseData['message'] ?? 'API Error',
- 'result_url' => null,
- 'result_json' => $responseData ?? []
- ];
- }
-
- // 解析API响应
- $result = $responseData['data'] ?? [];
- $taskStatus = $result['status'] ?? 'failed';
-
- $returnData = [
- 'status' => $taskStatus,
- 'error_message' => null,
- 'result_url' => null,
- 'result_json' => $responseData ?? []
- ];
-
- // 如果任务成功,获取结果URL
- if ($taskStatus === 'done' && isset($result['image_urls']) && count($result['image_urls']) > 0) {
- $result_urls = [];
- foreach ($result['image_urls'] as $url) {
- $pic_name = 'ai_generation_' . time() . '_' . uniqid();
- $pic_ext = getImgExtFromUrl($url);
- $pic_name = $pic_name . $pic_ext;
-
- if ($pic_ext === '.png') {
- $comporessed_data = compressRemoteImageUrlToSize($url);
- $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
- }else {
- // 将图片另存到tos
- $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
- }
-
- $result_urls[] = $url;
- }
- $returnData['result_url'] = $result_urls;
- $returnData['status'] = 'success';
- } elseif (in_array($taskStatus, ['not_found', 'expired'])) {
- $returnData['status'] = 'failed';
- $returnData['error_message'] = '任务未找到或已过期';
- } elseif ($taskStatus === 'failed') {
- $returnData['status'] = 'failed';
- $returnData['error_message'] = '任务执行失败';
- }
- // in_queue 或 generating状态保持原status
-
- return $returnData;
- } catch (\Exception $e) {
- return [
- 'status' => 'failed',
- 'error_message' => $e->getMessage(),
- 'result_url' => null
- ];
- }
- }
- /**
- * 提交任务到NanoBanana API
- *
- * @param MpGeneratePicTask $task
- * @return void
- */
- private function submitTaskToNanoBananaApi(MpGeneratePicTask $task): void
- {
- try {
- // 验证环境变量配置
- $apiKey = env('NANO_BANANA_API_KEY');
-
- if (empty($apiKey)) {
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => 'NanoBanana API KEY未配置,请检查环境变量NANO_BANANA_API_KEY'
- ]);
- return;
- }
-
- // 根据模型选择API地址
- switch ($task->model) {
- case self::MODEL_NANO_BANANA_2:
- $apiUrl = 'https://api.wuyinkeji.com/api/async/image_nanoBanana2';
- break;
- case self::MODEL_NANO_BANANA_PRO:
- $apiUrl = 'https://api.wuyinkeji.com/api/async/image_nanoBanana_pro';
- break;
- case self::MODEL_GPT_IMAGE_2:
- $apiUrl = 'https://api.wuyinkeji.com/api/async/image_gpt';
- break;
- default:
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '不支持的模型类型: ' . $task->model
- ]);
- logDB('generate', 'error', '模型类型错误', ['task_id' => $task->task_id, 'model' => $task->model]);
- return;
- }
-
- // 构建API请求参数
- $apiParams = [
- 'key' => $apiKey,
- 'prompt' => $task->prompt,
- ];
- if ($task->model == self::MODEL_GPT_IMAGE_2) {
- // 该模型下,size需设置为9:16类似的数值,请根据如下比例反推
- $apiParams['size'] = $this->getRatioFromSize($task->width, $task->height);
- }else {
- // 尺寸参数(根据宽高判断)
- $area = $task->width * $task->height;
- if ($area >= 1024*1024 && $area < 2048*2048) {
- $apiParams['size'] = '1k';
- } elseif ($area >= 2048*2048) {
- $apiParams['size'] = '2k';
- } else {
- $apiParams['size'] = '1k'; // 默认1k
- }
-
- // 画面比例
- if ($task->width && $task->height) {
- $ratio = $task->width . ':' . $task->height;
- // 简化比例
- $gcd = $this->gcd($task->width, $task->height);
- $ratio = ($task->width / $gcd) . ':' . ($task->height / $gcd);
- $apiParams['aspectRatio'] = $ratio;
- }
- }
-
- // 参考图片
- if ($task->ref_img_url) {
- $apiParams['urls'] = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
- }
- dLog('generate')->info('NanoBanana API参数: ', $apiParams);
- logDB('generate', 'info', 'NanoBanana任务提交', ['task_id' => $task->task_id, 'model' => $task->model, 'params' => $apiParams]);
-
- // 调用NanoBanana API
- $response = $this->httpClient->post($apiUrl, [
- 'query' => ['key' => $apiKey],
- 'json' => $apiParams,
- ]);
-
- $responseData = json_decode($response->getBody()->getContents(), true);
- dLog('generate')->info('NanoBanana API响应: ', $responseData);
- logDB('generate', 'info', 'NanoBanana API响应', ['task_id' => $task->task_id, 'response' => $responseData]);
-
- if ($responseData['code'] !== 200) {
- // API返回错误
- logDB('generate', 'error', 'NanoBanana任务提交失败', ['task_id' => $task->task_id, 'error' => $responseData['msg'] ?? 'API Error']);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $responseData['msg'] ?? 'API Error',
- 'extra_params' => $responseData
- ]);
- } else {
- // 任务提交成功,更新状态为处理中
- $updateData = [];
- // 存储API返回的任务ID
- if (isset($responseData['data']['id'])) {
- $updateData['task_id'] = $responseData['data']['id'];
- $updateData['extra_params'] = $responseData;
- }
-
- $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING, $updateData);
- }
- } catch (\Exception $e) {
- // 记录错误
- logDB('generate', 'error', 'NanoBanana任务提交异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => 'API请求失败: ' . $e->getMessage()
- ]);
- }
- }
- /**
- * 查询NanoBanana任务状态
- *
- * @param MpGeneratePicTask $task
- * @return array
- */
- private function queryNanoBananaTaskStatus(MpGeneratePicTask $task): array
- {
- try {
- // 获取API任务ID
- $apiTaskId = $task->task_id ?? null;
- $apiKey = env('NANO_BANANA_API_KEY');
-
- if (!$apiTaskId) {
- return [
- 'status' => 'failed',
- 'error_message' => 'API任务ID不存在',
- 'result_url' => null
- ];
- }
-
- if (empty($apiKey)) {
- return [
- 'status' => 'failed',
- 'error_message' => 'NanoBanana API KEY未配置',
- 'result_url' => null
- ];
- }
-
- // 调用统一查询接口
- $response = $this->httpClient->get('https://api.wuyinkeji.com/api/async/detail', [
- 'query' => [
- 'key' => $apiKey,
- 'id' => $apiTaskId
- ]
- ]);
-
- $responseData = json_decode($response->getBody()->getContents(), true);
- dLog('generate')->info('NanoBanana任务状态查询响应: ', $responseData);
- logDB('generate', 'info', 'NanoBanana任务状态查询', ['task_id' => $apiTaskId, 'response' => $responseData]);
-
- if ($responseData['code'] !== 200) {
- // API返回错误
- logDB('generate', 'error', 'NanoBanana任务状态查询失败', ['task_id' => $apiTaskId, 'error' => $responseData['msg'] ?? 'API Error']);
- return [
- 'status' => 'failed',
- 'error_message' => $responseData['msg'] ?? 'API Error',
- 'result_url' => null,
- 'result_json' => $responseData
- ];
- }
-
- // 解析API响应
- $data = $responseData['data'] ?? [];
- $taskStatus = $data['status'] ?? 0;
-
- $returnData = [
- 'status' => 'processing',
- 'error_message' => null,
- 'result_url' => null,
- 'result_json' => $responseData
- ];
-
- // status: 2表示成功
- if ($taskStatus === 2 && isset($data['result']) && !empty($data['result'])) {
- $result_urls = [];
- foreach ($data['result'] as $url) {
- $pic_name = 'ai_generation_nanobanana_' . time() . '_' . uniqid();
- $pic_ext = getImgExtFromUrl($url);
- $pic_name = $pic_name . $pic_ext;
-
- if ($pic_ext === '.png') {
- $comporessed_data = compressRemoteImageUrlToSize($url);
- $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
- } else {
- // 将图片另存到tos
- $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
- }
-
- $result_urls[] = $url;
- }
- $returnData['result_url'] = $result_urls;
- $returnData['status'] = 'success';
- } elseif ($taskStatus === 3) {
- // status: 3表示失败
- $returnData['status'] = 'failed';
- $returnData['error_message'] = $data['message'] ?? '任务执行失败';
- }
- // status: 0或1表示处理中,保持processing状态
-
- return $returnData;
- } catch (\Exception $e) {
- return [
- 'status' => 'failed',
- 'error_message' => $e->getMessage(),
- 'result_url' => null
- ];
- }
- }
- /**
- * 计算最大公约数(用于简化比例)
- *
- * @param int $a
- * @param int $b
- * @return int
- */
- private function gcd(int $a, int $b): int
- {
- while ($b != 0) {
- $temp = $b;
- $b = $a % $b;
- $a = $temp;
- }
- return $a;
- }
- /**
- * 创建素材
- *
- * @param array $params
- * @return array
- */
- public function createAsset(array $params): array
- {
- try {
- // 验证必填参数
- if (empty($params['url'])) {
- return [
- 'code' => 1,
- 'msg' => '素材URL不能为空',
- 'data' => null
- ];
- }
- // 先获取表中是否已有该url(仅限智帧AI来源的素材)
- $asset = DB::table('mp_assets')->where('url', $params['url'])->where('source_type', 'zhizhen')->first();
- if ($asset) {
- return [
- 'code' => 0,
- 'msg' => '创建成功',
- 'data' => [
- 'id' => $asset->id,
- 'asset_id' => $asset->asset_id,
- 'group_id' => $asset->group_id,
- 'group_name' => $asset->group_name,
- ]
- ];
- }
- // 获取API配置
- $apiKey = env('ZHIZHEN_AI_SK');
- $apiUrl = 'https://model-relay.zzengine.net';
-
- if (empty($apiKey)) {
- return [
- 'code' => 1002,
- 'msg' => '智帧AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
- 'data' => null
- ];
- }
- // 构建请求参数
- $requestData = [
- 'url' => $params['url'],
- 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
- ];
- // 可选参数
- if (!empty($params['name'])) {
- $requestData['name'] = $params['name'];
- }
- if (!empty($params['group_id'])) {
- $requestData['group_id'] = $params['group_id'];
- }
- dLog('generate')->info('智帧AI创建素材请求参数: ', $requestData);
- // 调用智帧AI API
- $response = $this->httpClient->post($apiUrl . '/api/v1/asset/create', [
- 'headers' => [
- 'X-Api-Key' => $apiKey,
- 'Content-Type' => 'application/json',
- ],
- 'json' => $requestData,
- ]);
- $responseData = json_decode($response->getBody()->getContents(), true);
- dLog('generate')->info('智帧AI创建素材响应: ', $responseData);
- logDB('generate', 'info', '智帧AI创建素材', ['params' => $requestData, 'response' => $responseData]);
- if ($responseData['code'] !== 0) {
- return [
- 'code' => $responseData['code'],
- 'msg' => $responseData['msg'] ?? '创建素材失败',
- 'data' => null
- ];
- }
- // 保存素材记录到数据库
- $assetData = $responseData['data'] ?? [];
- $asset = MpAsset::create([
- 'asset_id' => $assetData['asset_id'] ?? '',
- 'group_id' => $assetData['group_id'] ?? null,
- 'group_name' => $assetData['group_name'] ?? null,
- 'name' => $params['name'] ?? null,
- 'url' => $params['url'],
- 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
- 'source_type' => 'zhizhen',
- 'status' => MpAsset::STATUS_PROCESSING,
- 'extra_params' => $responseData,
- ]);
- return [
- 'code' => 0,
- 'msg' => '创建成功',
- 'data' => [
- 'id' => $asset->id,
- 'asset_id' => $asset->asset_id,
- 'group_id' => $asset->group_id,
- 'group_name' => $asset->group_name,
- ]
- ];
- } catch (\Exception $e) {
- dLog('generate')->error('创建素材异常: ' . $e->getMessage());
- logDB('generate', 'error', '创建素材异常', ['params' => $params, 'error' => $e->getMessage()]);
-
- return [
- 'code' => 20003,
- 'msg' => '创建素材失败: ' . $e->getMessage(),
- 'data' => null
- ];
- }
- }
- /**
- * 查询素材
- *
- * @param string $assetId
- * @return array
- */
- public function getAsset(string $assetId): array
- {
- try {
- // 验证参数
- if (empty($assetId)) {
- return [
- 'code' => 20003,
- 'msg' => '素材ID不能为空',
- 'data' => null
- ];
- }
- // 先获取表中是否已有该素材(仅限智帧AI来源的素材)
- $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'zhizhen')->first();
- if ($assetInfo) {
- $assetInfo = (array)$assetInfo;
- $status = getProp($assetInfo, 'status');
- if (in_array($status, ['Active', 'Failed'])) {
- $assetInfo['id'] = $assetInfo['asset_id'];
- return [
- 'code' => 0,
- 'msg' => '查询成功',
- 'data' => [
- 'asset' => $assetInfo
- ]
- ];
- }
- }
- // 获取API配置
- $apiKey = env('ZHIZHEN_AI_SK');
- $apiUrl = 'https://model-relay.zzengine.net';
-
- if (empty($apiKey)) {
- return [
- 'code' => 1002,
- 'msg' => '智帧AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
- 'data' => null
- ];
- }
- dLog('generate')->info('智帧AI查询素材: ' . $assetId);
- // 调用智帧AI API
- $response = $this->httpClient->post($apiUrl . '/api/v1/asset/get', [
- 'headers' => [
- 'X-Api-Key' => $apiKey,
- 'Content-Type' => 'application/json',
- ],
- 'json' => [
- 'asset_id' => $assetId,
- ],
- ]);
- $responseData = json_decode($response->getBody()->getContents(), true);
- dLog('generate')->info('智帧AI查询素材响应: ', $responseData);
- logDB('generate', 'info', '智帧AI查询素材', ['asset_id' => $assetId, 'response' => $responseData]);
- if ($responseData['code'] !== 0) {
- return [
- 'code' => $responseData['code'],
- 'msg' => $responseData['msg'] ?? '查询素材失败',
- 'data' => null
- ];
- }
- $assetInfo = $responseData['data']['asset'] ?? [];
- // 更新本地数据库记录
- $localAsset = MpAsset::where('asset_id', $assetId)->first();
- if ($localAsset) {
- $localAsset->update([
- 'status' => $assetInfo['status'] ?? MpAsset::STATUS_PROCESSING,
- 'cover_url' => $assetInfo['url'] ?? null,
- 'extra_params' => $responseData,
- ]);
- }
- return [
- 'code' => 0,
- 'msg' => '查询成功',
- 'data' => [
- 'asset' => $assetInfo
- ]
- ];
- } catch (\Exception $e) {
- dLog('generate')->error('查询素材异常: ' . $e->getMessage());
- logDB('generate', 'error', '查询素材异常', ['asset_id' => $assetId, 'error' => $e->getMessage()]);
-
- return [
- 'code' => 20003,
- 'msg' => '查询素材失败: ' . $e->getMessage(),
- 'data' => null
- ];
- }
- }
- /**
- * 更新所有待处理任务的状态
- *
- * @return void
- */
- public function updatePendingTasks(): void
- {
- dLog('generate')->info('更新图片状态ing');
-
- // 1. 处理火山API的pending任务(批量提交到队列)
- $volcPendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
- ->whereIn('model', BaseConst::VOLC_PIC_MODELS)
- ->orderByRaw($this->dispatchOrderByRaw())
- ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
- ->get();
- $volcDispatched = 0;
- foreach ($volcPendingTasks as $task) {
- if ($volcDispatched >= self::DISPATCH_BATCH_LIMIT) {
- break;
- }
- // 同一任务已在队列中或正在执行时跳过,避免重复入队
- if (!$this->dispatchTaskOnce(self::DISPATCH_GROUP_VOLC, (int)$task->id, \App\Jobs\ProcessVolcImageGenerationJob::class)) {
- continue;
- }
- dLog('generate')->info('开始分发火山API任务到队列: ' . $task->task_id);
- $volcDispatched++;
- }
-
- // 2. 处理GPT-Image2的pending任务(批量提交到队列)
- $gptImage2PendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
- ->whereIn('model', BaseConst::GPT_IMAGE2_MODELS)
- ->orderByRaw($this->dispatchOrderByRaw())
- ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
- ->get();
- $gptImage2Dispatched = 0;
- foreach ($gptImage2PendingTasks as $task) {
- if ($gptImage2Dispatched >= self::DISPATCH_BATCH_LIMIT) {
- break;
- }
- // 同一任务已在队列中或正在执行时跳过,避免重复入队
- if (!$this->dispatchTaskOnce(self::DISPATCH_GROUP_GPT_IMAGE2, (int)$task->id, \App\Jobs\ProcessGptImage2GenerationJob::class)) {
- continue;
- }
- dLog('generate')->info('开始分发GPT-Image2任务到队列: ' . $task->task_id);
- $gptImage2Dispatched++;
- }
-
- // 3. 获取所有处理中的任务(即梦AI和NanoBanana系列需要查询状态)
- $processingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)
- ->whereIn('model', array_merge([self::MODEL_JIMENG_4], BaseConst::NANO_BANANA_MODELS))
- ->get();
- foreach ($processingTasks as $task) {
- $statusInfo = $this->queryTaskStatus($task);
- if ($statusInfo['status'] === 'success') {
- logDB('generate', 'info', '即梦AI图片生成任务成功', ['task_id' => $task->task_id, 'result_url' => $statusInfo['result_url']]);
- // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
- $this->handleImageTaskSuccess($task, $statusInfo['result_url'], $statusInfo['result_json'] ?? []);
- } elseif ($statusInfo['status'] === 'failed') {
- logDB('generate', 'error', '即梦AI图片生成任务失败', ['task_id' => $task->task_id, 'error' => $statusInfo['error_message']]);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => $statusInfo['error_message'],
- 'result_json' => $statusInfo['result_json'] ?? []
- ]);
- if (getProp($task, 'alias_segment_id')) {
- DB::table('mp_episode_segments')->where('segment_id', getProp($task, 'alias_segment_id'))->update([
- 'pic_task_status' => '失败',
- ]);
- }
- }
- // 如果仍然是处理中状态,不做任何操作
-
- //处理:如果任务处理超过12小时,标记为失败
- $processingTime = now()->diffInHours($task->created_at);
- if ($processingTime > 12) {
- logDB('generate', 'warning', '即梦AI图片生成任务超时', ['task_id' => $task->task_id, 'processing_hours' => $processingTime]);
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
- 'error_message' => '任务处理超时(超过12小时)'
- ]);
- }
- }
- // 4. 检查是否有任务完成,如果有则处理下一个排队任务(仅即梦AI)
- $this->processNextQueuedTask();
- }
- /**
- * 派发单个任务到队列(带派发去重)
- *
- * 同一任务在「已入队等待执行」或「正在执行」期间不会重复入队,
- * 避免每轮扫描(约3秒一次)把同一条任务重复推送进队列。
- *
- * @param string $group 派发分组,用于区分同一任务ID在不同业务下的锁
- * @param int $taskId 图片任务ID
- * @param string $jobClass 队列任务类
- * @return bool 本次是否成功入队;false 表示已有同任务的 job 在队列中或执行中
- */
- private function dispatchTaskOnce(string $group, int $taskId, string $jobClass): bool
- {
- if (!self::acquireTaskDispatchLock($group, $taskId)) {
- return false;
- }
- try {
- $jobClass::dispatch($taskId)->onConnection('redis');
- } catch (\Throwable $e) {
- // 入队失败必须释放锁,否则该任务在锁过期前无法再次派发
- self::releaseTaskDispatchLock($group, $taskId);
- throw $e;
- }
- return true;
- }
- /**
- * 记录一次真实提交尝试(在真正调用上游接口前调用),并做重试上限控制
- *
- * - 每次实际提交前 attempts + 1(记录在 extra_params.attempts)
- * - attempts 达到 MAX_TASK_ATTEMPTS 后直接判失败,
- * 并把最后一次的错误原因写进 error_message,供前端展示失败原因;
- * - 计数放在“真正提交”这一步而不是“派发”这一步:队列积压时同一任务可能被重复派发,
- * 按派发计数会凭空消耗重试次数,导致任务提前被判失败。
- *
- * @param MpGeneratePicTask $task
- * @return bool 是否继续提交本次请求(false 表示已达重试上限,任务已判失败)
- */
- private function beginTaskAttempt(MpGeneratePicTask $task): bool
- {
- $fresh = MpGeneratePicTask::find($task->id);
- $extra = ($fresh && is_array($fresh->extra_params)) ? $fresh->extra_params : [];
- $attempts = (int)getProp($extra, self::EXTRA_ATTEMPTS, 0);
- // 重试次数已达上限:直接判失败,避免任务无限重试并长期占用队列
- if ($attempts >= self::MAX_TASK_ATTEMPTS) {
- $lastError = (string)getProp($extra, self::EXTRA_LAST_ERROR, '');
- $message = '图片生成失败:已重试' . self::MAX_TASK_ATTEMPTS . '次仍未成功';
- if ($lastError !== '') {
- $message .= ';最后错误:' . $lastError;
- }
- $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, ['error_message' => $message]);
- logDB('generate', 'error', '图片任务重试次数已达上限,标记失败', [
- 'task_id' => $task->task_id,
- 'model' => $task->model,
- 'attempts' => $attempts,
- 'last_error' => $lastError,
- ]);
- return false;
- }
- // 记录本次尝试次数(重试上限以此计数为准)
- $extra[self::EXTRA_ATTEMPTS] = $attempts + 1;
- ($fresh ?: $task)->update(['extra_params' => $extra]);
- return true;
- }
- /**
- * 图片任务派发顺序:优先按 extra_params.next_retry_at(失败后重排时间)升序,
- * 没有重排时间的按创建时间升序。这样失败重试的任务会排到当前队列尾部,
- * 先让其它任务各过一遍,单个任务卡住不会一直堵住后面的任务。
- *
- * @return string
- */
- private function dispatchOrderByRaw(): string
- {
- return "COALESCE(STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(extra_params, '$.next_retry_at')), '%Y-%m-%d %H:%i:%s'), created_at) ASC";
- }
- /**
- * 判断异常是否属于可重试错误(上游超时、连接失败、5xx 等)
- *
- * @param \Throwable $e
- * @return bool
- */
- private function isRetryableApiError(\Throwable $e): bool
- {
- // Guzzle:连接失败/请求超时
- if ($e instanceof \GuzzleHttp\Exception\ConnectException) {
- return true;
- }
- // Guzzle:上游 5xx
- if ($e instanceof \GuzzleHttp\Exception\ServerException) {
- return true;
- }
- // 原生 cURL 分支的超时、空响应、网关 5xx 统一按可重试处理
- // 注意:这里只做语义匹配,不能用裸数字(如 504)匹配,否则会被尺寸文本(3040x5504)误命中
- $message = mb_strtolower($e->getMessage());
- foreach (['timed out', 'timeout', 'curl错误: 28', '空响应', 'empty response', '上游服务异常'] as $keyword) {
- if (mb_strpos($message, $keyword) !== false) {
- return true;
- }
- }
- return false;
- }
- /**
- * 可重试失败:把任务放回 pending,记录最后一次错误与重排时间,
- * 使其排到队列尾部等待下一次尝试;尝试次数在真正提交前统一累加并在达到上限时判失败。
- *
- * @param MpGeneratePicTask $task
- * @param string $lastError
- * @param array|null $resultJson 本次失败尝试的上游返回结果(含上游 ID),为空则不覆盖已有值
- * @return void
- */
- private function requeueTaskForRetry(MpGeneratePicTask $task, string $lastError, ?array $resultJson = null): void
- {
- $fresh = MpGeneratePicTask::find($task->id);
- if (!$fresh) {
- return;
- }
- $extra = is_array($fresh->extra_params) ? $fresh->extra_params : [];
- $extra[self::EXTRA_LAST_ERROR] = mb_substr($lastError, 0, 200);
- $extra[self::EXTRA_NEXT_RETRY_AT] = date('Y-m-d H:i:s');
- $updateData = [
- 'status' => MpGeneratePicTask::STATUS_PENDING,
- 'started_at' => null,
- 'completed_at' => null,
- 'extra_params' => $extra,
- ];
- // 保留本次失败尝试的上游返回结果与上游 ID,便于月度审查
- if (!empty($resultJson)) {
- $updateData['result_json'] = $resultJson;
- }
- $fresh->update($updateData);
- dLog('generate')->info('图片任务失败已放回队列尾部等待重试', [
- 'task_id' => $task->task_id,
- 'model' => $task->model,
- 'attempts' => (int)getProp($extra, self::EXTRA_ATTEMPTS, 0),
- 'last_error' => $extra[self::EXTRA_LAST_ERROR],
- ]);
- }
- /**
- * 获取任务派发锁
- *
- * 在队列所在的 Redis 连接上执行原生 `SET key value EX ttl NX` 原子命令,
- * 不依赖 Lua 脚本(部分 Redis 会禁用 EVAL)也不需要额外的缓存 store 配置。
- *
- * @param string $group
- * @param int $taskId
- * @return bool true 表示获取成功(可以入队)
- */
- public static function acquireTaskDispatchLock(string $group, int $taskId): bool
- {
- try {
- $result = Redis::connection(self::DISPATCH_LOCK_CONNECTION)->set(
- self::buildTaskDispatchLockKey($group, $taskId),
- 1,
- 'EX',
- self::DISPATCH_LOCK_TTL,
- 'NX'
- );
- // NX 未命中时:predis 返回 null,phpredis 返回 false
- return $result !== null && $result !== false;
- } catch (\Throwable $e) {
- // 锁不可用时降级为不去重(与旧逻辑一致),避免任务因此完全无法派发
- dLog('generate')->warning('获取图片任务派发锁失败,本次不拦截重复派发', [
- 'group' => $group,
- 'task_id' => $taskId,
- 'error' => $e->getMessage(),
- ]);
- return true;
- }
- }
- /**
- * 释放任务派发锁
- *
- * 在任务执行结束、任务已由其它流程处理、任务最终失败时调用;
- * 未释放的锁会在 DISPATCH_LOCK_TTL 后自动过期,保证任务仍能被重新派发。
- *
- * @param string $group
- * @param int $taskId
- * @return void
- */
- public static function releaseTaskDispatchLock(string $group, int $taskId): void
- {
- try {
- Redis::connection(self::DISPATCH_LOCK_CONNECTION)->del(
- self::buildTaskDispatchLockKey($group, $taskId)
- );
- } catch (\Throwable $e) {
- // 释放失败不影响主流程,锁会由 TTL 自动过期
- dLog('generate')->warning('释放图片任务派发锁失败', [
- 'group' => $group,
- 'task_id' => $taskId,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- /**
- * 构建任务派发锁 key
- *
- * @param string $group
- * @param int $taskId
- * @return string
- */
- public static function buildTaskDispatchLockKey(string $group, int $taskId): string
- {
- return self::DISPATCH_LOCK_PREFIX . $group . ':' . $taskId;
- }
- /**
- * 处理下一个排队的任务
- *
- * @return void
- */
- public function processNextQueuedTask(): void
- {
- // 检查是否还有正在处理的任务(仅即梦AI)
- if ($this->hasProcessingTask()) {
- return; // 如果还有处理中的任务,不处理新任务
- }
- // 获取最早的待处理任务(仅即梦AI)
- $nextTask = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
- ->where('model', self::MODEL_JIMENG_4)
- ->orderBy('created_at', 'asc')
- ->first();
- if ($nextTask) {
- dLog('generate')->info('开始处理排队任务: ' . $nextTask->task_id);
- logDB('generate', 'info', '开始处理即梦AI排队任务', ['task_id' => $nextTask->task_id]);
- $this->submitTaskToJimengApi($nextTask);
- }
- }
- /**
- * 获取任务队列状态
- *
- * @return array
- */
- public function getQueueStatus(): array
- {
- $processingCount = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)->count();
- $pendingCount = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)->count();
-
- return [
- 'processing_count' => $processingCount,
- 'pending_count' => $pendingCount,
- 'can_submit_new_task' => $processingCount === 0
- ];
- }
- /**
- * 处理智帧AI的参考图片转素材
- *
- * @param array $referenceImages 参考图片URL数组
- * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记)
- * @return array 返回素材ID数组
- */
- public function processReferenceImagesToAssets(array $referenceImages, string &$fullPrompt): array
- {
- return $this->processReferenceAssetsToZhizhenAssets($referenceImages, MpAsset::TYPE_IMAGE, '图片', '图片', $fullPrompt);
- }
- /**
- * 处理智帧AI的参考视频转素材
- *
- * @param array $referenceVideos 参考视频URL数组
- * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记)
- * @return array 返回素材ID数组
- */
- public function processReferenceVideosToAssets(array $referenceVideos, string &$fullPrompt): array
- {
- return $this->processReferenceAssetsToZhizhenAssets($referenceVideos, MpAsset::TYPE_VIDEO, '视频', '视频', $fullPrompt);
- }
- /**
- * 处理智帧AI的参考音频转素材
- *
- * @param array $referenceAudios 参考音频URL数组
- * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记)
- * @return array 返回素材ID数组
- */
- public function processReferenceAudiosToAssets(array $referenceAudios, string &$fullPrompt): array
- {
- return $this->processReferenceAssetsToZhizhenAssets($referenceAudios, MpAsset::TYPE_AUDIO, '音频', '音频', $fullPrompt);
- }
- /**
- * 处理智帧AI的参考素材转素材(图片/视频/音频通用方法)
- *
- * @param array $urls 参考素材URL数组
- * @param string $assetType 素材类型(Image/Video/Audio)
- * @param string $label 素材中文名(图片/视频/音频),用于日志与素材命名
- * @param string $tagName 提示词标记名(图片/视频/音频),用于 <{tagName}N> 标记的清理
- * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败素材的标记)
- * @return array 返回素材ID数组(asset://格式)
- */
- private function processReferenceAssetsToZhizhenAssets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array
- {
- $assetIds = [];
- $failedIndexes = []; // 记录失败的素材序号
-
- foreach ($urls as $index => $url) {
- $mediaIndex = $index + 1; // 素材序号从1开始
-
- try {
- dLog('generate')->info("开始处理参考{$label} {$mediaIndex}: {$url}");
-
- // 1. 创建素材
- $createResult = $this->createAsset([
- 'url' => $url,
- 'asset_type' => $assetType,
- 'name' => "参考{$label}_{$mediaIndex}_" . time(),
- ]);
-
- if ($createResult['code'] !== 0) {
- dLog('generate')->warning("{$label} {$mediaIndex} 创建素材失败: " . $createResult['msg']);
- logDB('generate', 'warning', '创建素材失败', [
- 'url' => $url,
- 'asset_type' => $assetType,
- 'index' => $mediaIndex,
- 'error' => $createResult['msg']
- ]);
- $failedIndexes[] = $mediaIndex;
- continue;
- }
-
- $assetId = $createResult['data']['asset_id'];
- dLog('generate')->info("{$label} {$mediaIndex} 素材创建成功,asset_id: {$assetId},开始轮询查询状态");
-
- // 2. 轮询查询素材状态(每3秒查询一次,最多查询100次即5分钟)
- $maxAttempts = 100;
- $attempt = 0;
- $assetStatus = null;
-
- while ($attempt < $maxAttempts) {
- sleep(3); // 等待3秒
- $attempt++;
-
- $getResult = $this->getAsset($assetId);
-
- if ($getResult['code'] !== 0) {
- dLog('generate')->warning("{$label} {$mediaIndex} 查询素材状态失败: " . $getResult['msg']);
- continue;
- }
-
- $assetStatus = $getResult['data']['asset']['status'] ?? null;
- dLog('generate')->info("{$label} {$mediaIndex} 素材状态查询第 {$attempt} 次,状态: {$assetStatus}");
-
- if ($assetStatus === 'Active') {
- // 素材可用
- $assetIds[] = $assetId;
- dLog('generate')->info("{$label} {$mediaIndex} 素材处理成功,asset_id: {$assetId}");
- logDB('generate', 'info', '素材转换成功', [
- 'url' => $url,
- 'asset_type' => $assetType,
- 'index' => $mediaIndex,
- 'asset_id' => $assetId
- ]);
- break;
- } elseif ($assetStatus === 'Failed') {
- // 素材处理失败
- dLog('generate')->warning("{$label} {$mediaIndex} 素材处理失败");
- logDB('generate', 'warning', '素材处理失败', [
- 'url' => $url,
- 'asset_type' => $assetType,
- 'index' => $mediaIndex,
- 'asset_id' => $assetId
- ]);
- $failedIndexes[] = $mediaIndex;
- break;
- }
- // 其他状态(Processing等)继续等待
- }
-
- // 如果超时还未成功
- if ($attempt >= $maxAttempts && $assetStatus !== 'Active') {
- dLog('generate')->warning("{$label} {$mediaIndex} 素材处理超时");
- logDB('generate', 'warning', '素材处理超时', [
- 'url' => $url,
- 'asset_type' => $assetType,
- 'index' => $mediaIndex,
- 'asset_id' => $assetId,
- 'last_status' => $assetStatus
- ]);
- $failedIndexes[] = $mediaIndex;
- }
-
- } catch (\Exception $e) {
- dLog('generate')->error("{$label} {$mediaIndex} 处理异常: " . $e->getMessage());
- logDB('generate', 'error', '素材转换异常', [
- 'url' => $url,
- 'asset_type' => $assetType,
- 'index' => $mediaIndex,
- 'error' => $e->getMessage()
- ]);
- $failedIndexes[] = $mediaIndex;
- }
- }
-
- // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号
- if (!empty($failedIndexes)) {
- dLog('generate')->info("开始处理失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes));
-
- // 按序号从大到小排序,避免删除时影响后续序号
- rsort($failedIndexes);
-
- foreach ($failedIndexes as $failedIndex) {
- // 删除失败素材的标记,例如 <图片1>、<视频2>、<音频3> 等
- $pattern = "/<{$tagName}{$failedIndex}>/u";
- $fullPrompt = preg_replace($pattern, '', $fullPrompt);
- dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>");
- }
-
- // 重新调整剩余素材的序号
- // 例如:如果素材2失败,素材3、素材4需要改为素材2、素材3
- $needAdjust = false;
- foreach ($failedIndexes as $failedIndex) {
- $needAdjust = true;
- // 将所有大于失败序号的素材标记序号减1
- for ($i = $failedIndex + 1; $i <= count($urls); $i++) {
- $oldPattern = "/<{$tagName}{$i}>/u";
- $newTag = "<{$tagName}" . ($i - 1) . ">";
- $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt);
- }
- }
-
- if ($needAdjust) {
- dLog('generate')->info("提示词{$label}序号调整完成");
- logDB('generate', 'info', '提示词调整', [
- 'failed_indexes' => $failedIndexes,
- 'adjusted_prompt' => $fullPrompt
- ]);
- }
- }
-
- dLog('generate')->info("参考{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes));
- // 格式化为可直接在API接口中使用的数组
- foreach ($assetIds as &$assetId) {
- $assetId = "asset://{$assetId}";
- }
- unset($assetId);
-
- return $assetIds;
- }
- /**
- * 根据宽高反推图片比例
- * @param int $width 宽度
- * @param int $height 高度
- * @return string 比例字符串,如 "9:16",匹配不到则返回 "auto"
- */
- private function getRatioFromSize($width, $height)
- {
- if (!$width || !$height) {
- return 'auto';
- }
- // 遍历所有预定义的比例,查找匹配的
- foreach (BaseConst::IMAGE_RATIOS as $ratio => $size) {
- if ($size['width'] == $width && $size['height'] == $height) {
- return $ratio;
- }
- }
- // 没有精确匹配,尝试通过计算比例匹配
- $gcd = $this->gcd($width, $height);
- $simplifiedRatio = ($width / $gcd) . ':' . ($height / $gcd);
- // 检查简化后的比例是否在预定义列表中
- if (isset(BaseConst::IMAGE_RATIOS[$simplifiedRatio])) {
- return $simplifiedRatio;
- }
- return 'auto';
- }
- /**
- * 压缩图片流(针对PNG格式)
- *
- * @param string $imageStream 图片二进制数据
- * @param int $maxBytes 最大字节数(默认3MB)
- * @return string|null 压缩后的图片二进制数据,失败时返回原数据
- */
- private function compressImageStream(string $imageStream, int $maxBytes = 3 * 1024 * 1024): ?string
- {
- try {
- // 检查图片类型
- $imgInfo = @getimagesizefromstring($imageStream);
- if (!$imgInfo) {
- dLog('generate')->warning('无法识别图片类型,返回原始数据');
- return $imageStream;
- }
- $mime = $imgInfo['mime'] ?? '';
-
- // 只处理PNG,JPEG不做有损压缩
- if ($mime === 'image/jpeg') {
- return $imageStream;
- }
- // 如果已经小于最大限制,直接返回
- if (strlen($imageStream) <= $maxBytes) {
- return $imageStream;
- }
- // 载入图像对象
- $srcImg = @imagecreatefromstring($imageStream);
- if (!$srcImg) {
- dLog('generate')->warning('无法创建图像资源,返回原始数据');
- return $imageStream;
- }
- $origW = imagesx($srcImg);
- $origH = imagesy($srcImg);
- // 逐步降低质量直到满足大小要求
- $quality = 9; // PNG压缩等级 0-9
- $scaleFactor = 1.0;
- $maxAttempts = 10;
- $attempt = 0;
- $compressedData = null;
- while ($attempt < $maxAttempts) {
- $attempt++;
- // 如果需要缩放
- if ($scaleFactor < 1.0) {
- $newW = (int)($origW * $scaleFactor);
- $newH = (int)($origH * $scaleFactor);
- $resizedImg = imagecreatetruecolor($newW, $newH);
-
- // 保持透明度
- imagealphablending($resizedImg, false);
- imagesavealpha($resizedImg, true);
-
- imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
- $targetImg = $resizedImg;
- } else {
- $targetImg = $srcImg;
- }
- // 输出到内存
- ob_start();
- imagepng($targetImg, null, $quality);
- $compressedData = ob_get_clean();
- if ($scaleFactor < 1.0) {
- imagedestroy($targetImg);
- }
- // 检查大小
- $size = strlen($compressedData);
- dLog('generate')->info('图片压缩尝试', [
- 'attempt' => $attempt,
- 'quality' => $quality,
- 'scale' => $scaleFactor,
- 'size' => $size,
- 'target' => $maxBytes
- ]);
- if ($size <= $maxBytes) {
- break;
- }
- // 调整参数
- if ($quality > 0) {
- $quality--;
- } else {
- $scaleFactor *= 0.9; // 每次缩小10%
- }
- }
- imagedestroy($srcImg);
-
- return $compressedData ?: $imageStream;
- } catch (\Exception $e) {
- dLog('generate')->error('图片压缩失败', ['error' => $e->getMessage()]);
- return $imageStream;
- }
- }
- /**
- * 从上游响应头中解析百度图片任务/请求 ID。
- *
- * 兼容两种响应头结构(键均为小写、值为字符串数组):
- * - Guzzle 返回的响应头
- * - CURLOPT_HEADERFUNCTION 累积的响应头
- * 同一响应头出现多次时取最后一个非空值(覆盖重定向 / 100-continue 场景)。
- *
- * @param array $headers
- * @return array
- */
- private function extractBaiduUpstreamIds(array $headers): array
- {
- $pick = function (string $name) use ($headers): string {
- $value = $headers[strtolower($name)] ?? null;
- if (is_array($value)) {
- $value = end($value);
- }
- return is_string($value) ? trim($value) : '';
- };
- $ids = [];
- $taskId = $pick(self::BAIDU_UPSTREAM_TASK_ID_HEADER);
- $requestId = $pick(self::BAIDU_UPSTREAM_REQUEST_ID_HEADER);
- $reqid = $pick(self::BAIDU_UPSTREAM_REQID_HEADER);
- if ($taskId !== '') {
- $ids[self::RESULT_UPSTREAM_TASK_ID] = $taskId;
- }
- if ($requestId !== '') {
- $ids[self::RESULT_UPSTREAM_REQUEST_ID] = $requestId;
- }
- if ($reqid !== '') {
- $ids[self::RESULT_UPSTREAM_REQID] = $reqid;
- }
- return $ids;
- }
- /**
- * 把上游任务/请求 ID 合并进 result_json,仅在有值时才写入,
- * 避免重试或失败路径用空值覆盖已记录的信息。
- *
- * @param array $resultJson
- * @param array $upstreamIds
- * @return array
- */
- private function mergeUpstreamIds(array $resultJson, array $upstreamIds): array
- {
- foreach ($upstreamIds as $key => $value) {
- if ($value !== '' && $value !== null) {
- $resultJson[$key] = $value;
- }
- }
- return $resultJson;
- }
- /**
- * 解析上游响应体:JSON 数组/对象返回数组,非 JSON 返回原文片段(截断 2000 字),空响应返回 null。
- * 失败/重试路径用它尽量保留上游返回的原始结果。
- *
- * @param mixed $responseBody
- * @return array|null
- */
- private function decodeUpstreamBody($responseBody): ?array
- {
- if (!is_string($responseBody) || $responseBody === '') {
- return null;
- }
- $decoded = json_decode($responseBody, true);
- if (is_array($decoded)) {
- return $decoded;
- }
- return ['_raw_response' => mb_substr($responseBody, 0, 2000)];
- }
- /**
- * 记录一次失败/重试尝试:返回要写入 result_json 的内容,并按需补记 token 明细(失败不扣积分)。
- *
- * - 有上游响应体时:保存去掉 b64_json 后的响应(含 error/usage 等),并合并本次上游 ID;
- * - 只有响应头、没有响应体时(如超时/5xx 无 body):保留已有 result_json,仅补写本次拿到的上游 ID;
- * - 只要上游返回 usage,就补记 token 明细(uid 缺失时用兜底 uid)。
- *
- * @param MpGeneratePicTask $task
- * @param mixed $responseData 上游响应体(数组或 null)
- * @param array $upstreamIds 上游响应头中解析出的任务/请求 ID
- * @param string $remark 备注
- * @param array $rawResponseHeaders 本次收到的原始响应头(超时/请求中断时留痕)
- * @return array 写入 result_json 的内容
- */
- private function captureFailedAttempt(MpGeneratePicTask $task, $responseData, array $upstreamIds, string $remark, array $rawResponseHeaders = []): array
- {
- $resultJson = [];
- try {
- $resultJson = is_array($responseData) ? $this->removeB64JsonFromResponse($responseData) : [];
- // 本次没有响应体时,保留此前已记录的 result_json,避免用空值覆盖
- if (empty($resultJson)) {
- $resultJson = is_array($task->result_json) ? $task->result_json : [];
- }
- $resultJson = $this->mergeUpstreamIds($resultJson, $upstreamIds);
- // 超时/请求中断时也留下本次收到的响应头;本次没拿到时沿用此前记录的,
- // 便于事后判断超时那次到底有没有拿到上游 ID
- $headersToKeep = !empty($rawResponseHeaders)
- ? $rawResponseHeaders
- : (is_array($task->result_json) && !empty($task->result_json['raw_response_headers'])
- ? $task->result_json['raw_response_headers']
- : []);
- if (!empty($headersToKeep)) {
- $resultJson['raw_response_headers'] = $headersToKeep;
- }
- } catch (\Throwable $e) {
- dLog('generate')->warning('记录失败尝试上游结果异常(不影响token补记): ' . $e->getMessage(), [
- 'task_id' => $task->task_id ?? null,
- ]);
- $resultJson = is_array($task->result_json) ? $task->result_json : [];
- }
- // 上游返回 usage 时补记 token 明细(失败/重试不扣积分)
- $this->recordFailedTaskTokens($task, $responseData, $remark, $upstreamIds);
- return $resultJson;
- }
- /**
- * 从响应数据中移除 b64_json 字段
- * 避免在日志和数据库中存储大量 Base64 数据
- *
- * @param array $responseData
- * @return array
- */
- private function removeB64JsonFromResponse(array $responseData): array
- {
- $cleanedData = $responseData;
-
- if (isset($cleanedData['data']) && is_array($cleanedData['data'])) {
- foreach ($cleanedData['data'] as &$imageData) {
- if (isset($imageData['b64_json'])) {
- // 移除 b64_json 字段,可选择性保留数据长度信息
- $b64Length = strlen($imageData['b64_json']);
- unset($imageData['b64_json']);
- $imageData['b64_json_removed'] = true;
- $imageData['b64_json_length'] = $b64Length;
- }
- }
- unset($imageData); // 解除引用
- }
-
- return $cleanedData;
- }
- }
|