WorkOrder.php 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056
  1. <?php
  2. namespace app\api\controller;
  3. use app\common\controller\Api;
  4. use app\job\ImageJob;
  5. use app\service\AIGatewayService;
  6. use app\service\ImageService;
  7. use think\App;
  8. use think\Db;
  9. use think\Exception;
  10. use think\Log;
  11. use think\Queue;
  12. use think\queue\job\Redis;
  13. use think\Request;
  14. use app\api\controller\Common;
  15. class WorkOrder extends Api{
  16. protected $noNeedLogin = ['*'];
  17. protected $noNeedRight = ['*'];
  18. public function index(){echo '访问成功';}
  19. /**
  20. * AI队列入口处理 出图接口
  21. * 此方法处理图像转换为文本的请求,将图像信息存入队列以供后续处理。
  22. */
  23. public function imageToText()
  24. {
  25. $params = $this->request->param();
  26. $service = new ImageService();
  27. $service->handleImage($params);
  28. $this->success('任务成功提交至队列');
  29. }
  30. /**
  31. * task_id:查询获取任务图片
  32. */
  33. public function GetImageStatus(){
  34. $params = $this->request->param();
  35. $taskId = $params['task_id'];
  36. if (empty($taskId)) {
  37. $res = [
  38. 'code' => 1,
  39. 'msg' => '任务ID不能为空'
  40. ];
  41. return json($res);
  42. }
  43. //从Redis中获取任务状态
  44. $redis = getTaskRedis();
  45. $taskData = $redis->get("img_to_img_task:{$taskId}");
  46. if (!$taskData) {
  47. $taskData = $redis->get("text_to_image_task:{$taskId}");
  48. }
  49. if (!$taskData) {
  50. $res = [
  51. 'code' => 1,
  52. 'msg' => '任务不存在或已过期',
  53. ];
  54. return json($res);
  55. }
  56. $taskInfo = json_decode($taskData, true);
  57. if (!empty($taskInfo['status'])) {
  58. $taskInfo['status'] = ImageService::normalizeTaskStatus((string)$taskInfo['status']);
  59. }
  60. if (!empty($taskInfo['image'])) {
  61. $taskInfo['image'] = Common::ossFullUrl((string)$taskInfo['image']);
  62. }
  63. if (!empty($taskInfo['image_url'])) {
  64. $taskInfo['image_url'] = Common::ossFullUrl((string)$taskInfo['image_url']);
  65. }
  66. $res = [
  67. 'code' => 0,
  68. 'msg' => '查询成功',
  69. 'data' => $taskInfo
  70. ];
  71. return json($res);
  72. }
  73. /**
  74. * 支持的AI任务类型枚举(前端传入的status_val需在该列表内)
  75. * 键:前端传入值,值:方法后缀( '前端传入值' => '方法后缀')
  76. */
  77. private static $AI_TASK_TYPES = [
  78. '图生文' => 'ImgToText',
  79. '文生文' => 'TextToText',
  80. '文生图' => 'TextToImg',
  81. '图生图' => 'ImgToImg'
  82. ];
  83. /**
  84. * AI模型接口统一调用入口
  85. * @description 接收前端AI请求,校验任务类型合法性,分发至对应处理方法,统一异常捕获
  86. * @return \think\response\Json 标准化JSON响应
  87. */
  88. public function callAIModelApi()
  89. {
  90. try {
  91. // 1. 获取并校验入参
  92. $params = $this->request->param();
  93. $statusVal = $this->validateAndGetStatusVal($params);
  94. // 2. 映射并校验处理方法
  95. $method = $this->getHandleMethod($statusVal);
  96. // 3. 执行对应处理逻辑并返回响应
  97. return $this->$method($params);
  98. } catch (\InvalidArgumentException $e) {
  99. // 参数/方法异常(用户侧错误)
  100. return $this->jsonResponse(1, $e->getMessage());
  101. } catch (\Throwable $e) {
  102. // 系统异常(服务侧错误)
  103. \think\Log::error('AI接口处理异常:' . $e->getMessage() . ' | 任务类型:' . ($params['status_val'] ?? '未知') . ' | 异常行:' . $e->getLine());
  104. return $this->jsonResponse(1, '服务异常,请稍后重试');
  105. }
  106. }
  107. // -------------------------- 私有核心方法(通用逻辑) --------------------------
  108. /**
  109. * 校验并获取合法的任务类型
  110. * @param array $params 前端入参
  111. * @return string 合法的status_val
  112. * @throws \InvalidArgumentException 任务类型不合法时抛出
  113. */
  114. private function validateAndGetStatusVal(array $params): string
  115. {
  116. $statusVal = trim($params['status_val'] ?? '');
  117. // 空值校验
  118. if (empty($statusVal)) {
  119. throw new \InvalidArgumentException('任务类型不能为空');
  120. }
  121. // 合法性校验
  122. if (!array_key_exists($statusVal, self::$AI_TASK_TYPES)) {
  123. throw new \InvalidArgumentException('不支持的任务类型:' . $statusVal);
  124. }
  125. return $statusVal;
  126. }
  127. /**
  128. * 获取任务对应的处理方法名
  129. * @param string $statusVal 合法的任务类型
  130. * @return string 处理方法名(如handleAiImgToText)
  131. * @throws \InvalidArgumentException 方法未实现时抛出
  132. */
  133. private function getHandleMethod(string $statusVal): string
  134. {
  135. $methodSuffix = self::$AI_TASK_TYPES[$statusVal];
  136. $method = 'handleAi' . $methodSuffix;
  137. if (!method_exists($this, $method)) {
  138. throw new \InvalidArgumentException('任务类型暂未实现:' . $statusVal);
  139. }
  140. return $method;
  141. }
  142. /**
  143. * 通用JSON响应封装
  144. * @param int $code 响应码(0=成功,1=失败)
  145. * @param string $msg 响应信息
  146. * @param array $data 响应数据(可选)
  147. * @return \think\response\Json
  148. */
  149. private function jsonResponse(int $code, string $msg, array $data = []): \think\response\Json
  150. {
  151. $response = [
  152. 'code' => $code,
  153. 'msg' => $msg,
  154. 'time' => date('Y-m-d H:i:s')
  155. ];
  156. if (!empty($data)) {
  157. $response['data'] = $data;
  158. }
  159. return json($response);
  160. }
  161. /**
  162. * 任务类接口统一响应(带task_id的场景)
  163. * @param array $result 业务处理结果(需包含success字段)
  164. * @param string $failMsg 失败提示语
  165. * @return \think\response\Json
  166. */
  167. private function jsonTaskResponse(array $result, string $failMsg = '任务提交失败'): \think\response\Json
  168. {
  169. $isSuccess = isset($result['success']) && $result['success'];
  170. $data = $isSuccess ? ['task_id' => $result['task_id'] ?? ''] : [];
  171. $msg = $isSuccess ? ($result['message'] ?? '提交成功') : ($result['message'] ?? $failMsg);
  172. return $this->jsonResponse($isSuccess ? 0 : 1, $msg, $data);
  173. }
  174. // -------------------------- 业务处理方法(按任务类型拆分) --------------------------
  175. /**
  176. * 图生文任务处理:提交队列并返回提示
  177. * @param array $params 前端入参
  178. * @return \think\response\Json
  179. */
  180. private function handleAiImgToText(array $params): \think\response\Json
  181. {
  182. (new ImageService())->handleImgToText($params);
  183. return $this->jsonResponse(0, '正在优化提示词,请稍等.....');
  184. }
  185. /**
  186. * 文生文任务处理:生成话术并返回内容(支持产品内容更新)
  187. * @param array $params 前端入参
  188. * @return \think\response\Json
  189. */
  190. private function handleAiTextToText(array $params): \think\response\Json
  191. {
  192. //构造生成提示词
  193. $promptTemplate = "\n请根据上述内容生成一段完整的话术,要求:\n"
  194. . "1. 内容必须是连贯的一段话,不要使用列表、分隔线或其他结构化格式\n"
  195. . "2. 不要包含非文本元素的描述\n"
  196. . "3. 不要添加任何额外的引导语、解释或开场白\n"
  197. . "4. 禁忌:不添加无关形容词,不修改产品核心信息,语言流畅自然";
  198. $prompt = ($params['prompt'] ?? '') . $promptTemplate;
  199. // 调用服务层生成内容
  200. $result = (new ImageService())->handleTextToText(
  201. $params['status_val'],
  202. $prompt,
  203. $params['model']
  204. );
  205. if (empty($result['success'])) {
  206. return $this->jsonResponse(1, $result['message'] ?? '生成失败');
  207. }
  208. $content = $result['data'] ?? '';
  209. //区分业务场景处理
  210. $isProductImageGeneration = ($params['status_type'] ?? '') === 'ProductImageGeneration';
  211. $isProductTemplateReplace = ($params['status_type'] ?? '') === 'ProductTemplateReplace';
  212. if (!$isProductImageGeneration && !$isProductTemplateReplace) {
  213. Db::name('product')->where('id', $params['id'])->update(['content' => $content]);
  214. }
  215. return $this->jsonResponse(0, '优化成功', ['content' => $content]);
  216. }
  217. /**
  218. * 文生图任务处理:提交任务并返回task_id
  219. * @param array $params 前端入参
  220. * @return \think\response\Json
  221. */
  222. private function handleAiTextToImg(array $params): \think\response\Json
  223. {
  224. $serviceResult = (new ImageService())->handleTextToImg($params);
  225. return $this->jsonTaskResponse($serviceResult, '文生图任务提交失败');
  226. }
  227. /**
  228. * 图生图任务处理:提交任务并返回task_id
  229. * @param array $params 前端入参
  230. * @return \think\response\Json
  231. */
  232. private function handleAiImgToImg(array $params): \think\response\Json
  233. {
  234. $serviceResult = (new ImageService())->handleImgToImg($params);
  235. return $this->jsonTaskResponse($serviceResult, '图生图任务提交失败');
  236. }
  237. /**
  238. * 即梦AI--创建视频任务接口
  239. * 支持:单张首帧图 / 首尾双帧图
  240. * 图片入参:JSON/base64(first_image/last_image)、form-data 文件、或 http(s) URL
  241. */
  242. public function Create_ImgToVideo()
  243. {
  244. $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
  245. $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339';
  246. $params = $this->mergeRequestParams();
  247. $prompt = trim((string)($params['prompt'] ?? ''));
  248. if ($prompt === '') {
  249. return json(['code' => 0, 'msg' => 'prompt 不能为空']);
  250. }
  251. $firstError = '';
  252. $firstFrame = $this->resolveFrameImagePayload($params, 'first', $firstError);
  253. if (($firstFrame['api_url'] ?? '') === '') {
  254. $hint = Common::isOssEnabled()
  255. ? '请传 first_image(JSON 内 data:image/...;base64,... 或 form-data 文件),并查看 runtime/log'
  256. : '请在 application/config.php 配置 oss(accessKeyId、endpoint、bucket、host)';
  257. $detail = $firstError !== '' ? '(' . $firstError . ')' : '';
  258. return json([
  259. 'code' => 0,
  260. 'msg' => '首帧图片无效或上传失败。' . $hint . $detail,
  261. 'data' => [
  262. 'has_first_image' => isset($params['first_image']) && $params['first_image'] !== '',
  263. 'content_type' => $this->request->contentType(),
  264. 'upload_env' => $this->getPhpUploadEnvInfo(),
  265. ],
  266. ]);
  267. }
  268. $lastFrame = $this->resolveFrameImagePayload($params, 'last');
  269. $firstImageUrl = $firstFrame['api_url'];
  270. $lastImageUrl = $lastFrame['api_url'];
  271. $content = $this->buildImgToVideoContent($prompt, $firstImageUrl, $lastImageUrl);
  272. $data = [
  273. 'model' => 'doubao-seedance-1-5-pro-251215',
  274. 'content' => $content,
  275. 'generate_audio' => filter_var($params['generate_audio'] ?? true, FILTER_VALIDATE_BOOLEAN),
  276. 'ratio' => $params['ratio'] ?? $params['aspect_ratio'] ?? $params['size'] ?? 'adaptive',
  277. 'duration' => (int)($params['duration'] ?? $params['seconds'] ?? 5),
  278. 'watermark' => filter_var($params['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN),
  279. ];
  280. $ch = curl_init();
  281. curl_setopt($ch, CURLOPT_URL, $apiUrl);
  282. curl_setopt($ch, CURLOPT_POST, true);
  283. curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
  284. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  285. 'Content-Type: application/json',
  286. 'Authorization: Bearer ' . $apiKey
  287. ]);
  288. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  289. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  290. curl_setopt($ch, CURLOPT_TIMEOUT, 60);
  291. $response = curl_exec($ch);
  292. if (curl_errno($ch)) {
  293. $error = curl_error($ch);
  294. curl_close($ch);
  295. return json(['code' => 0, 'msg' => 'Curl 错误: ' . $error]);
  296. }
  297. curl_close($ch);
  298. $responseData = json_decode($response, true);
  299. if (isset($responseData['error'])) {
  300. $msg = $responseData['error']['message'] ?? 'API 请求失败';
  301. return json(['code' => 0, 'msg' => 'API 错误: ' . $msg]);
  302. }
  303. $taskId = $responseData['id'] ?? '';
  304. if ($taskId === '') {
  305. return json(['code' => 0, 'msg' => '获取任务 ID 失败']);
  306. }
  307. $firstImageUrl = $this->finalizeFrameImageToOss($firstFrame, $taskId);
  308. $lastImageUrl = $this->finalizeFrameImageToOss($lastFrame, $taskId);
  309. $firstImageDb = $this->toDbUploadPath($firstImageUrl);
  310. $lastImageDb = $this->toDbUploadPath($lastImageUrl);
  311. $videoData = [
  312. 'video_id' => $taskId,
  313. 'prompt' => $prompt,
  314. 'first_image_url' => $firstImageDb,
  315. 'last_image_url' => $lastImageDb,
  316. 'model' => $data['model'],
  317. 'seconds' => (string)$data['duration'],
  318. 'size' => (string)$data['ratio'],
  319. 'sys_id' => $data['sys_id'],
  320. 'sys_rq' => date('Y-m-d H:i:s'),
  321. ];
  322. try {
  323. Db::name('video')->insert($videoData);
  324. } catch (Exception $e) {
  325. return json([
  326. 'code' => 0,
  327. 'msg' => '任务已创建,但数据库保存失败',
  328. 'data' => [
  329. 'task_id' => $taskId,
  330. 'error_message' => $e->getMessage(),
  331. ],
  332. ]);
  333. }
  334. return json([
  335. 'code' => 1,
  336. 'msg' => '任务创建成功',
  337. 'data' => [
  338. 'task_id' => $taskId,
  339. 'video_id' => $taskId,
  340. 'status' => $responseData['status'] ?? '',
  341. 'created_at' => $responseData['created_at'] ?? '',
  342. 'mode' => $lastImageUrl !== '' ? 'first_last_frame' : 'single_frame',
  343. 'first_image_url' => $this->toPublicMediaUrl($firstImageDb),
  344. 'last_image_url' => $this->toPublicMediaUrl($lastImageDb),
  345. ]
  346. ]);
  347. }
  348. /**
  349. * 即梦AI--获取视频接口(先 GET 查询任务状态,succeeded 后再落盘/OSS/入库)
  350. */
  351. public function Get_ImgToVideo()
  352. {
  353. $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
  354. $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339';
  355. $params = $this->request->param();
  356. $taskId = trim((string)($params['task_id'] ?? $params['video_id'] ?? ''));
  357. if ($taskId === '') {
  358. return json(['code' => 0, 'msg' => '任务 ID 不能为空']);
  359. }
  360. // 1. 先根据任务 ID 查询即梦任务状态(GET /tasks/{id})
  361. $queryResult = $this->fetchVolcImgToVideoTask($taskId, $apiUrl, $apiKey);
  362. if (!$queryResult['ok']) {
  363. return json([
  364. 'code' => 0,
  365. 'msg' => '查询任务失败: ' . $queryResult['error'],
  366. 'data' => [
  367. 'task_id' => $taskId,
  368. 'http_code' => $queryResult['http_code'],
  369. 'task' => $queryResult['data'],
  370. ],
  371. ]);
  372. }
  373. $taskData = $queryResult['data'];
  374. $taskStatus = (string)($taskData['status'] ?? '');
  375. // 2. 终态(failed / cancelled / expired)直接返回,不进入下载/入库
  376. $terminalMsg = $this->resolveVolcTaskTerminalFailure($taskStatus);
  377. if ($terminalMsg !== null) {
  378. return json([
  379. 'code' => 0,
  380. 'msg' => $terminalMsg,
  381. 'data' => $this->formatVolcTaskQueryPayload($taskData),
  382. ]);
  383. }
  384. // 3. queued / running 等未完成态则轮询(最多 30 次,间隔 5 秒)
  385. if (!$this->isVolcVideoTaskSucceeded($taskStatus)) {
  386. $pollResult = $this->pollVolcImgToVideoTaskUntilDone($taskId, $apiUrl, $apiKey, 30, 5);
  387. if (!$pollResult['ok']) {
  388. return json([
  389. 'code' => 0,
  390. 'msg' => $pollResult['error'],
  391. 'data' => $this->formatVolcTaskQueryPayload($pollResult['data']),
  392. ]);
  393. }
  394. $taskData = $pollResult['data'];
  395. $taskStatus = (string)($taskData['status'] ?? '');
  396. }
  397. if (!$this->isVolcVideoTaskSucceeded($taskStatus)) {
  398. return json([
  399. 'code' => 0,
  400. 'msg' => '轮询超时,当前状态:' . $this->getVolcTaskStatusMessage($taskStatus),
  401. 'data' => $this->formatVolcTaskQueryPayload($taskData),
  402. ]);
  403. }
  404. // 4. 事务处理:下载视频 → 本地/OSS → 更新数据库
  405. $videoUrl = $this->extractVideoUrlFromVolcTask($taskData);
  406. if ($videoUrl === '') {
  407. return json([
  408. 'code' => 0,
  409. 'msg' => '获取视频 URL 失败',
  410. 'data' => $this->formatVolcTaskQueryPayload($taskData),
  411. ]);
  412. }
  413. $fileName = $this->sanitizeTaskIdSegment($taskId) . '.mp4';
  414. $saveDir = $this->buildTaskMediaLocalDir($taskId);
  415. if (!is_dir($saveDir) && !@mkdir($saveDir, 0755, true) && !is_dir($saveDir)) {
  416. return json(['code' => 0, 'msg' => '创建视频目录失败', 'data' => ['saveDir' => $saveDir]]);
  417. }
  418. $savePath = $saveDir . $fileName;
  419. $videoContent = $this->downloadRemoteFile($videoUrl);
  420. if ($videoContent === false) {
  421. return json(['code' => 0, 'msg' => '下载视频失败', 'data' => ['videoUrl' => $videoUrl]]);
  422. }
  423. if (file_put_contents($savePath, $videoContent) === false) {
  424. return json(['code' => 0, 'msg' => '保存视频失败', 'data' => ['savePath' => $savePath]]);
  425. }
  426. $objectKey = $this->buildTaskMediaObjectKey($taskId, $fileName);
  427. $upload = $this->uploadToOSS($savePath, $objectKey);
  428. $ossObjectKey = (string)($upload['object_key'] ?? $objectKey);
  429. $webUrlDb = $this->toDbUploadPath($ossObjectKey);
  430. if ($webUrlDb === '' || stripos($webUrlDb, 'uploads/') !== 0) {
  431. $fullUrl = $upload['success']
  432. ? ((string)($upload['url'] ?? '') !== '' ? $upload['url'] : Common::ossFullUrl($objectKey))
  433. : ($this->buildPublicUploadUrl($savePath) ?: Common::ossFullUrl($objectKey));
  434. $webUrlDb = $this->toDbUploadPath((string)$fullUrl);
  435. if (!$upload['success']) {
  436. Log::write('[Get_ImgToVideo] OSS上传失败,入库路径=' . $webUrlDb, 'error');
  437. }
  438. }
  439. try {
  440. Db::name('video')->where('video_id', $taskId)->update(['web_url' => $webUrlDb]);
  441. } catch (Exception $e) {
  442. return json([
  443. 'code' => 0,
  444. 'msg' => '视频已生成,但数据库更新失败',
  445. 'data' => array_merge($this->formatVolcTaskQueryPayload($taskData), [
  446. 'web_url' => $this->toPublicMediaUrl($webUrlDb),
  447. 'error_message' => $e->getMessage(),
  448. ]),
  449. ]);
  450. }
  451. return json([
  452. 'code' => 1,
  453. 'msg' => '视频获取成功',
  454. 'data' => array_merge($this->formatVolcTaskQueryPayload($taskData), [
  455. 'task_id' => $taskId,
  456. 'video_id' => $taskId,
  457. 'web_url' => $this->toPublicMediaUrl($webUrlDb),
  458. 'oss_object_key' => $ossObjectKey,
  459. 'oss_uploaded' => $upload['success'] ?? false,
  460. 'local_path' => $webUrlDb,
  461. 'source_video_url' => $videoUrl,
  462. ]),
  463. ]);
  464. }
  465. /**
  466. * GET 查询即梦图生视频任务:/api/v3/contents/generations/tasks/{task_id}
  467. * @return array{ok:bool,data:array,error:string,http_code:int}
  468. */
  469. private function fetchVolcImgToVideoTask(string $taskId, string $apiUrl, string $apiKey): array
  470. {
  471. $empty = ['ok' => false, 'data' => [], 'error' => '', 'http_code' => 0];
  472. $queryUrl = rtrim($apiUrl, '/') . '/' . str_replace('%2F', '/', rawurlencode($taskId));
  473. $ch = curl_init();
  474. curl_setopt($ch, CURLOPT_URL, $queryUrl);
  475. curl_setopt($ch, CURLOPT_HTTPGET, true);
  476. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  477. 'Content-Type: application/json',
  478. 'Authorization: Bearer ' . $apiKey,
  479. ]);
  480. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  481. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  482. curl_setopt($ch, CURLOPT_TIMEOUT, 60);
  483. $response = curl_exec($ch);
  484. if (curl_errno($ch)) {
  485. $empty['error'] = curl_error($ch);
  486. curl_close($ch);
  487. return $empty;
  488. }
  489. $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
  490. curl_close($ch);
  491. $data = json_decode((string)$response, true);
  492. if (!is_array($data)) {
  493. return ['ok' => false, 'data' => [], 'error' => '任务响应解析失败', 'http_code' => $httpCode];
  494. }
  495. if (isset($data['error'])) {
  496. $errMsg = is_array($data['error'])
  497. ? (string)($data['error']['message'] ?? json_encode($data['error'], JSON_UNESCAPED_UNICODE))
  498. : (string)$data['error'];
  499. return ['ok' => false, 'data' => $data, 'error' => $errMsg, 'http_code' => $httpCode];
  500. }
  501. if ($httpCode < 200 || $httpCode >= 300) {
  502. return ['ok' => false, 'data' => $data, 'error' => 'HTTP ' . $httpCode, 'http_code' => $httpCode];
  503. }
  504. return ['ok' => true, 'data' => $data, 'error' => '', 'http_code' => $httpCode];
  505. }
  506. /**
  507. * 轮询任务直至 succeeded,或遇到 failed/cancelled/expired
  508. * @return array{ok:bool,data:array,error:string}
  509. */
  510. private function pollVolcImgToVideoTaskUntilDone(
  511. string $taskId,
  512. string $apiUrl,
  513. string $apiKey,
  514. int $maxPolls = 30,
  515. int $intervalSeconds = 5
  516. ): array {
  517. $pollCount = 0;
  518. $lastData = [];
  519. while ($pollCount < $maxPolls) {
  520. sleep($intervalSeconds);
  521. $pollCount++;
  522. $result = $this->fetchVolcImgToVideoTask($taskId, $apiUrl, $apiKey);
  523. if (!$result['ok']) {
  524. return ['ok' => false, 'data' => $result['data'], 'error' => $result['error']];
  525. }
  526. $lastData = $result['data'];
  527. $status = (string)($lastData['status'] ?? '');
  528. $terminalMsg = $this->resolveVolcTaskTerminalFailure($status);
  529. if ($terminalMsg !== null) {
  530. return ['ok' => false, 'data' => $lastData, 'error' => $terminalMsg];
  531. }
  532. if ($this->isVolcVideoTaskSucceeded($status)) {
  533. return ['ok' => true, 'data' => $lastData, 'error' => ''];
  534. }
  535. }
  536. $lastStatus = (string)($lastData['status'] ?? '');
  537. return [
  538. 'ok' => false,
  539. 'data' => $lastData,
  540. 'error' => '轮询超时,当前状态:' . $this->getVolcTaskStatusMessage($lastStatus),
  541. ];
  542. }
  543. /**
  544. * 即梦任务 status 中文说明(官方:queued/running/cancelled/succeeded/failed/expired)
  545. */
  546. private function getVolcTaskStatusMessage(string $status): string
  547. {
  548. $map = [
  549. 'queued' => '排队中',
  550. 'running' => '任务运行中',
  551. 'cancelled' => '任务已取消',
  552. 'succeeded' => '任务成功',
  553. 'failed' => '任务失败',
  554. 'expired' => '任务超时',
  555. 'completed' => '任务成功',
  556. ];
  557. return $map[$status] ?? ('未知状态(' . $status . ')');
  558. }
  559. /**
  560. * 是否为可继续轮询的进行中状态
  561. */
  562. private function isVolcVideoTaskPending(string $status): bool
  563. {
  564. return in_array($status, ['queued', 'running'], true);
  565. }
  566. /**
  567. * 终态且不可下载:failed / cancelled / expired
  568. */
  569. private function resolveVolcTaskTerminalFailure(string $status): ?string
  570. {
  571. $map = [
  572. 'failed' => '任务执行失败',
  573. 'cancelled' => '任务已取消',
  574. 'expired' => '任务超时',
  575. ];
  576. return $map[$status] ?? null;
  577. }
  578. private function isVolcVideoTaskSucceeded(string $status): bool
  579. {
  580. return in_array($status, ['succeeded', 'completed'], true);
  581. }
  582. /**
  583. * 从即梦任务响应中取视频地址(优先 content.video_url)
  584. */
  585. private function extractVideoUrlFromVolcTask(array $taskData): string
  586. {
  587. $content = $taskData['content'] ?? null;
  588. if (is_array($content) && !empty($content['video_url'])) {
  589. return trim((string)$content['video_url']);
  590. }
  591. return trim((string)(
  592. $taskData['output']['video_url']
  593. ?? $taskData['video_url']
  594. ?? ''
  595. ));
  596. }
  597. /**
  598. * 格式化任务查询结果(与即梦 GET 响应字段对齐)
  599. */
  600. private function formatVolcTaskQueryPayload(array $taskData): array
  601. {
  602. $status = (string)($taskData['status'] ?? '');
  603. return [
  604. 'id' => $taskData['id'] ?? '',
  605. 'model' => $taskData['model'] ?? '',
  606. 'status' => $status,
  607. 'status_text' => $this->getVolcTaskStatusMessage($status),
  608. 'is_pending' => $this->isVolcVideoTaskPending($status),
  609. 'is_succeeded' => $this->isVolcVideoTaskSucceeded($status),
  610. 'content' => $taskData['content'] ?? null,
  611. 'usage' => $taskData['usage'] ?? null,
  612. 'created_at' => $taskData['created_at'] ?? null,
  613. 'updated_at' => $taskData['updated_at'] ?? null,
  614. 'resolution' => $taskData['resolution'] ?? '',
  615. 'ratio' => $taskData['ratio'] ?? '',
  616. 'duration' => $taskData['duration'] ?? null,
  617. 'framespersecond' => $taskData['framespersecond'] ?? null,
  618. 'generate_audio' => $taskData['generate_audio'] ?? null,
  619. ];
  620. }
  621. /**
  622. * 下载远程文件(视频等)
  623. * @return string|false
  624. */
  625. private function downloadRemoteFile(string $url)
  626. {
  627. $ch = curl_init();
  628. curl_setopt($ch, CURLOPT_URL, $url);
  629. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  630. curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
  631. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  632. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
  633. curl_setopt($ch, CURLOPT_TIMEOUT, 300);
  634. $body = curl_exec($ch);
  635. if (curl_errno($ch)) {
  636. Log::write('[downloadRemoteFile] ' . curl_error($ch) . ' | url=' . $url, 'error');
  637. curl_close($ch);
  638. return false;
  639. }
  640. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  641. curl_close($ch);
  642. if ($httpCode < 200 || $httpCode >= 300 || $body === false) {
  643. return false;
  644. }
  645. return $body;
  646. }
  647. //获取视频列表
  648. public function Getvideolist(){
  649. if (!$this->request->isGet()) {
  650. $this->error('请求方式错误');
  651. }
  652. $params = $this->request->param();
  653. $search = input('search', '');
  654. $page = isset($params['page']) ? (int)$params['page'] : 1;
  655. $limit = isset($params['limit']) ? (int)$params['limit'] : 50;
  656. $where = [];
  657. if (!empty($search)) {
  658. $where['prompt'] = ['like', '%' . $search . '%'];
  659. }
  660. //除超级管理员以外都查看当前账号信息数据
  661. if (!empty($params['sys_id']) && $params['sys_id'] != '超级管理员') {
  662. $where['sys_id'] = $params['sys_id'];
  663. }
  664. $list = Db::name('video')->where('mod_rq', null)
  665. ->where($where)
  666. ->order('id desc')
  667. ->limit(($page - 1) * $limit, $limit)
  668. ->select();
  669. foreach ($list as &$row) {
  670. if (!empty($row['web_url'])) {
  671. $row['web_url'] = $this->toPublicMediaUrl((string)$row['web_url']);
  672. }
  673. if (!empty($row['first_image_url'])) {
  674. $row['first_image_url'] = $this->toPublicMediaUrl((string)$row['first_image_url']);
  675. }
  676. if (!empty($row['last_image_url'])) {
  677. $row['last_image_url'] = $this->toPublicMediaUrl((string)$row['last_image_url']);
  678. }
  679. }
  680. unset($row);
  681. $total = Db::name('video')->where('mod_rq', null)
  682. ->where($where)
  683. ->count();
  684. $res['code'] = 0;
  685. $res['msg'] = '成功';
  686. $res['count'] = $total;
  687. $res['data'] = $list;
  688. return json($res);
  689. }
  690. //获取图片列表
  691. public function Gettexttoimagelist(){
  692. if (!$this->request->isGet()) {
  693. $this->error('请求方式错误');
  694. }
  695. $params = $this->request->param();
  696. $search = input('search', '');
  697. $page = isset($params['page']) ? (int)$params['page'] : 1;
  698. $limit = isset($params['limit']) ? (int)$params['limit'] : 50;
  699. $where = [];
  700. if (!empty($search)) {
  701. $where['prompt'] = ['like', '%' . $search . '%'];
  702. }
  703. $list = Db::name('ai_text_image')->where('mod_rq', null)
  704. ->where($where)
  705. ->order('id desc')
  706. ->limit(($page - 1) * $limit, $limit)
  707. ->select();
  708. foreach ($list as &$row) {
  709. if (!empty($row['old_img'])) {
  710. $row['old_img'] = $this->toPublicMediaUrl((string)$row['old_img']);
  711. }
  712. if (!empty($row['ref_img'])) {
  713. $row['ref_img'] = $this->toPublicMediaUrl((string)$row['ref_img']);
  714. }
  715. if (!empty($row['new_img'])) {
  716. $row['new_img'] = $this->toPublicMediaUrl((string)$row['new_img']);
  717. }
  718. }
  719. unset($row);
  720. $total = Db::name('ai_text_image')->where('mod_rq', null)
  721. ->where($where)
  722. ->count();
  723. $res['code'] = 0;
  724. $res['msg'] = '成功';
  725. $res['count'] = $total;
  726. $res['data'] = $list;
  727. return json($res);
  728. }
  729. /**
  730. * 文生视频/图生视频接口
  731. */
  732. //文生视频
  733. public function video(){
  734. $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
  735. $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339';
  736. $params = $this->request->param();
  737. $postData = [
  738. 'prompt' => $params['prompt'],
  739. 'model' => 'doubao-seedance-1-5-pro-251215',
  740. 'seconds' => $params['seconds'],
  741. 'size' => $params['size'],
  742. ];
  743. // 初始化CURL
  744. $ch = curl_init();
  745. curl_setopt($ch, CURLOPT_URL, $apiUrl);
  746. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  747. curl_setopt($ch, CURLOPT_POST, true);
  748. curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
  749. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  750. 'Authorization: Bearer ' . $apiKey
  751. ]);
  752. curl_setopt($ch, CURLOPT_TIMEOUT, 300);
  753. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  754. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
  755. curl_setopt($ch, CURLOPT_HEADER, true); // 获取响应头
  756. curl_setopt($ch, CURLOPT_VERBOSE, true); // 启用详细输出以进行调试
  757. // 创建临时文件来捕获详细的cURL输出
  758. $verbose = fopen('php://temp', 'w+');
  759. curl_setopt($ch, CURLOPT_STDERR, $verbose);
  760. // 执行请求
  761. $response = curl_exec($ch);
  762. //HTTP状态码
  763. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  764. // 获取详细的cURL调试信息
  765. rewind($verbose);
  766. //CURL调试信息
  767. $verboseLog = stream_get_contents($verbose);
  768. fclose($verbose);
  769. // 分离头部和主体
  770. $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
  771. //响应头部
  772. $header = substr($response, 0, $header_size);
  773. //响应主体
  774. $body = substr($response, $header_size);
  775. // 检查CURL错误
  776. $curlError = curl_error($ch);
  777. curl_close($ch);
  778. $responseData = json_decode($body, true);
  779. echo "<pre>";
  780. print_r($responseData);
  781. echo "<pre>";die;
  782. // 检查API是否返回了错误信息
  783. if (isset($responseData['error'])) {
  784. $errorMessage = isset($responseData['error']['message']) ? $responseData['error']['message'] : 'API请求失败';
  785. return json([
  786. 'code' => 1,
  787. 'msg' => '视频生成请求失败',
  788. 'data' => [
  789. 'error_type' => isset($responseData['error']['type']) ? $responseData['error']['type'] : 'unknown',
  790. 'error_code' => isset($responseData['error']['code']) ? $responseData['error']['code'] : 'unknown',
  791. 'error_message' => $errorMessage
  792. ]
  793. ]);
  794. }
  795. // 检查是否有自定义错误格式
  796. if (isset($responseData['code']) && $responseData['code'] === 'fail_to_fetch_task' && isset($responseData['message'])) {
  797. return json([
  798. 'code' => 1,
  799. 'msg' => '视频生成请求失败',
  800. 'data' => [
  801. 'error_code' => $responseData['code'],
  802. 'error_message' => $responseData['message']
  803. ]
  804. ]);
  805. }
  806. // 检查是否存在id字段
  807. if (!isset($responseData['id'])) {
  808. return json([
  809. 'code' => 1,
  810. 'msg' => '无法获取视频ID',
  811. 'data' => [
  812. 'response_data' => $responseData,
  813. 'http_code' => $httpCode
  814. ]
  815. ]);
  816. }
  817. $videoData = [
  818. 'video_id' => $responseData['id'],
  819. 'prompt' => $postData['prompt'],
  820. 'model' => $postData['model'],
  821. 'seconds' => $postData['seconds'],
  822. 'size' => $postData['size'],
  823. 'sys_rq' => date("Y-m-d H:i:s")
  824. ];
  825. // 尝试插入数据
  826. try {
  827. $res = Db::name('video')->insert($videoData);
  828. return json([
  829. 'code' => 0,
  830. 'msg' => '视频正在生成中',
  831. 'data ' => [
  832. 'video_id' => $responseData['id'],
  833. 'insert_result' => $res
  834. ]
  835. ]);
  836. } catch (Exception $e) {
  837. return json([
  838. 'code' => 1,
  839. 'msg' => '数据库操作失败',
  840. 'data' => [
  841. 'error_message' => $e->getMessage()
  842. ]
  843. ]);
  844. }
  845. }
  846. /**
  847. * 获取视频内容
  848. * 下载已完成的视频内容
  849. */
  850. public function videoContent(){
  851. // 从请求参数获取video_id,如果没有则使用默认值
  852. $video_id = input('get.video_id');
  853. $apiKey = '';
  854. // 1. 先检查视频状态
  855. $statusUrl = 'https://chatapi.onechats.ai/v1/videos/' . $video_id;
  856. $statusData = $this->fetchVideoStatus($statusUrl, $apiKey);
  857. // 检查视频状态
  858. if ($statusData['status'] !== 'completed') {
  859. return json([
  860. 'code' => 202,
  861. 'msg' => '视频尚未生成完成',
  862. 'data' => [
  863. 'video_id' => $video_id,
  864. 'status' => $statusData['status'],
  865. 'progress' => $statusData['progress'],
  866. 'created_at' => $statusData['created_at'],
  867. 'message' => '请稍后再试,视频仍在' . ($statusData['status'] === 'queued' ? '排队中' : '处理中')
  868. ]
  869. ]);
  870. }
  871. // 2. 视频生成完成,准备下载
  872. $apiUrl = 'https://chatapi.onechats.ai/v1/videos/' . $video_id . '/content';
  873. // 获取可选的variant参数
  874. $variant = $this->request->get('variant', '');
  875. if (!empty($variant)) {
  876. $apiUrl .= '?variant=' . urlencode($variant);
  877. }
  878. // 创建保存目录
  879. $saveDir = ROOT_PATH . 'public' . DS . 'uploads' . DS . 'videos' . DS . date('Ymd');
  880. if (!is_dir($saveDir)) {
  881. mkdir($saveDir, 0755, true);
  882. }
  883. // 生成唯一文件名
  884. $filename = $video_id . '.mp4';
  885. $localPath = DS . 'uploads' . DS . 'videos' . DS . date('Ymd') . DS . $filename;
  886. $fullPath = $saveDir . DS . $filename;
  887. // 3. 下载视频
  888. $videoData = $this->downloadVideo($apiUrl, $apiKey);
  889. // 4. 保存视频文件
  890. if (file_put_contents($fullPath, $videoData) === false) {
  891. throw new Exception('视频保存失败');
  892. }
  893. // 确保路径使用正斜杠,并只保存相对路径部分
  894. $localPath = str_replace('\\', '/', $localPath);
  895. // 移除开头的斜杠,确保路径格式为uploads/videos/...
  896. $savePath = ltrim($localPath, '/');
  897. // 将正确格式的文件路径存入数据库
  898. Db::name('video')->where('video_id', $video_id)->update([
  899. 'web_url' => $savePath
  900. ]);
  901. // 返回成功响应
  902. return json([
  903. 'code' => 0,
  904. 'msg' => '视频下载成功',
  905. 'data' => [
  906. 'video_id' => $video_id,
  907. 'local_path' => $localPath,
  908. 'web_url' => $savePath,
  909. 'file_size' => filesize($fullPath)
  910. ]
  911. ]);
  912. }
  913. /**
  914. * 获取视频状态
  915. */
  916. private function fetchVideoStatus($url, $apiKey) {
  917. $ch = curl_init();
  918. curl_setopt($ch, CURLOPT_URL, $url);
  919. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  920. curl_setopt($ch, CURLOPT_HTTPGET, true);
  921. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  922. 'Authorization: Bearer ' . $apiKey,
  923. 'Accept: application/json'
  924. ]);
  925. curl_setopt($ch, CURLOPT_TIMEOUT, 30);
  926. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  927. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
  928. $response = curl_exec($ch);
  929. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  930. $error = curl_error($ch);
  931. curl_close($ch);
  932. if ($error) {
  933. throw new Exception('获取视频状态失败: ' . $error);
  934. }
  935. if ($httpCode < 200 || $httpCode >= 300) {
  936. throw new Exception('获取视频状态失败,HTTP状态码: ' . $httpCode);
  937. }
  938. $data = json_decode($response, true);
  939. if (!is_array($data)) {
  940. throw new Exception('视频状态数据格式错误');
  941. }
  942. return $data;
  943. }
  944. /**
  945. * 下载视频文件
  946. */
  947. private function downloadVideo($url, $apiKey) {
  948. $ch = curl_init();
  949. curl_setopt($ch, CURLOPT_URL, $url);
  950. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  951. curl_setopt($ch, CURLOPT_HTTPGET, true);
  952. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  953. 'Authorization: Bearer ' . $apiKey
  954. ]);
  955. curl_setopt($ch, CURLOPT_TIMEOUT, 300);
  956. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  957. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
  958. $response = curl_exec($ch);
  959. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  960. $error = curl_error($ch);
  961. curl_close($ch);
  962. if ($error) {
  963. throw new Exception('视频下载失败: ' . $error);
  964. }
  965. if ($httpCode < 200 || $httpCode >= 300) {
  966. throw new Exception('视频下载失败,HTTP状态码: ' . $httpCode);
  967. }
  968. return $response;
  969. }
  970. private function sendPostRequest($url, $data, $apiKey)
  971. {
  972. $ch = curl_init();
  973. curl_setopt($ch, CURLOPT_URL, $url);
  974. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  975. curl_setopt($ch, CURLOPT_POST, true);
  976. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  977. 'Authorization: Bearer ' . $apiKey,
  978. 'Accept: application/json',
  979. 'Content-Type: application/json'
  980. ]);
  981. curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
  982. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  983. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  984. curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 延长超时时间
  985. $response = curl_exec($ch);
  986. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  987. $error = curl_error($ch);
  988. curl_close($ch);
  989. return [
  990. 'response' => $response,
  991. 'http_code' => $httpCode,
  992. 'error' => $error
  993. ];
  994. }
  995. /**
  996. * 查询模版
  997. */
  998. public function product_template()
  999. {
  1000. $params = $this->request->param();
  1001. if (!$this->request->isGet()) {
  1002. $this->error('请求方法错误');
  1003. }
  1004. $page = isset($params['page']) ? (int)$params['page'] : 1;
  1005. $limit = isset($params['limit']) ? (int)$params['limit'] : 30;
  1006. $where = [];
  1007. if (!empty($params['search'])) {
  1008. $where['id|chinese_description|template_name|style'] = ['like', '%' . $params['search'] . '%'];
  1009. }
  1010. // toexamine / release:用 isset + 非空串,避免 empty(0) 导致「未发布」无法筛选;release 建议库中为 0/1
  1011. if (isset($params['toexamine']) && $params['toexamine'] !== '') {
  1012. $where['toexamine'] = $params['toexamine'];
  1013. }
  1014. if (isset($params['release']) && $params['release'] !== '') {
  1015. $where['release'] = is_numeric($params['release']) ? (int) $params['release'] : $params['release'];
  1016. }
  1017. $isSuperAdmin = false;
  1018. if (!empty($params['sys_id']) && $params['sys_id'] == '超级管理员') {
  1019. $isSuperAdmin = true;
  1020. }
  1021. if (!$isSuperAdmin && !empty($params['sys_id'])) {
  1022. $where['sys_id'] = ['like', '%' . $params['sys_id'] . '%'];
  1023. $products = Db::name('product_template')->order('id desc')->where($where)
  1024. ->whereNull('mod_rq')
  1025. ->limit(($page - 1) * $limit, $limit)
  1026. ->select();
  1027. }else{
  1028. $products = Db::name('product_template')->order('id desc')->where($where)
  1029. ->whereNull('mod_rq')
  1030. ->limit(($page - 1) * $limit, $limit)
  1031. ->select();
  1032. }
  1033. $total = Db::name('product_template')->where($where)
  1034. ->whereNull('mod_rq')
  1035. ->count();
  1036. foreach ($products as &$item) {
  1037. if (!empty($item['template_image_url'])) {
  1038. $item['template_image_url'] = Common::ossFullUrl((string)$item['template_image_url']);
  1039. }
  1040. if (!empty($item['thumbnail_image'])) {
  1041. $item['thumbnail_image'] = Common::ossFullUrl((string)$item['thumbnail_image']);
  1042. }
  1043. }
  1044. unset($item);
  1045. return json([
  1046. 'code' => 0,
  1047. 'msg' => '请求成功',
  1048. 'count' => $total,
  1049. 'data' => $products
  1050. ]);
  1051. }
  1052. /**
  1053. * 获取 AI 模型配置
  1054. * status 1 = 启用 0 = 禁用(同一模型内的优先级,数值越小越优先)
  1055. * model_type 支持多能力逗号间隔(如 文生图,图生图),传参精确匹配某一能力
  1056. * 可选参数:manage=1 时返回全部(含禁用),用于管理端
  1057. */
  1058. public function GetAIModel(){
  1059. $params = $this->request->param();
  1060. $query = Db::name('ai_model');
  1061. if (empty($params['manage'])) {
  1062. $query->where('status', '1');
  1063. }
  1064. if (!empty($params['model_type'])) {
  1065. $query->whereRaw('FIND_IN_SET(:mt, model_type) > 0', ['mt' => trim($params['model_type'])]);
  1066. }
  1067. if (!empty($params['supplier'])) {
  1068. $query->where('supplier', 'like', '%' . $params['supplier'] . '%');
  1069. }
  1070. if (!empty($params['model_name'])) {
  1071. $query->where('model_name|model_alias', 'like', '%' . $params['model_name'] . '%');
  1072. }
  1073. $list = $query
  1074. ->field('id,model_alias,model_group,model_name,model_type,sort,status,supplier')
  1075. ->order('sort ASC, id ASC')->select();
  1076. return json([
  1077. 'code' => 0,
  1078. 'msg' => '成功',
  1079. 'data' => $list
  1080. ]);
  1081. }
  1082. /**
  1083. * 新增 AI 模型配置
  1084. * POST: status, supplier, api_key, api_url, model_group, model_name, model_alias, model_type, sort
  1085. * model_type 多能力用逗号间隔,如:文生图,图生图
  1086. */
  1087. public function AddAIModel(){
  1088. $params = $this->request->param();
  1089. $required = ['api_url', 'api_key', 'model_name', 'model_type'];
  1090. foreach ($required as $k) {
  1091. if (empty(trim($params[$k] ?? ''))) {
  1092. return json(['code' => 1, 'msg' => $k . ' 不能为空']);
  1093. }
  1094. }
  1095. $insert = [
  1096. 'status' => $params['status'],
  1097. 'supplier' => trim($params['supplier']),
  1098. 'api_key' => trim($params['api_key']),
  1099. 'api_url' => trim($params['api_url']),
  1100. 'model_group' => trim($params['model_group']),
  1101. 'model_name' => trim($params['model_name']),
  1102. 'model_alias' => trim($params['model_alias']),
  1103. 'model_type' => trim($params['model_type']),
  1104. 'sort' => isset($params['sort']) ? intval($params['sort']) : 0,
  1105. ];
  1106. try {
  1107. Db::name('ai_model')->insert($insert);
  1108. return json(['code' => 0, 'msg' => '新增成功']);
  1109. } catch (\Exception $e) {
  1110. return json(['code' => 1, 'msg' => '新增失败: ' . $e->getMessage()]);
  1111. }
  1112. }
  1113. /**
  1114. * 修改 AI 模型配置
  1115. * POST: id(必填), 其余字段同新增
  1116. */
  1117. public function UpdateAIModel(){
  1118. $params = $this->request->param();
  1119. if (empty($params['id'])) {
  1120. return json(['code' => 1, 'msg' => 'id 不能为空']);
  1121. }
  1122. $id = intval($params['id']);
  1123. $exists = Db::name('ai_model')->where('id', $id)->find();
  1124. if (!$exists) {
  1125. return json(['code' => 1, 'msg' => '记录不存在']);
  1126. }
  1127. $update = [];
  1128. $fields = ['status', 'supplier', 'api_key', 'api_url', 'model_group', 'model_name', 'model_alias', 'model_type', 'sort'];
  1129. foreach ($fields as $f) {
  1130. if (array_key_exists($f, $params)) {
  1131. $update[$f] = $f === 'sort' ? intval($params[$f]) : trim($params[$f] ?? '');
  1132. }
  1133. }
  1134. if (empty($update)) {
  1135. return json(['code' => 1, 'msg' => '无有效修改字段']);
  1136. }
  1137. try {
  1138. Db::name('ai_model')->where('id', $id)->update($update);
  1139. return json(['code' => 0, 'msg' => '修改成功']);
  1140. } catch (\Exception $e) {
  1141. return json(['code' => 1, 'msg' => '修改失败: ' . $e->getMessage()]);
  1142. }
  1143. }
  1144. /**
  1145. * 用于获取所有产品记录
  1146. **/
  1147. public function GetProductList(){
  1148. $params = $this->request->param();
  1149. $page = max(1, intval($params['page'] ?? 1));
  1150. $pageSize = min(100, max(1, intval($params['limit'] ?? 30)));
  1151. // 构建查询条件
  1152. $where = [];
  1153. if (!empty($params['search'])) {
  1154. $where['prompt|model'] = ['like', '%' . $params['search'] . '%'];
  1155. }
  1156. if (!empty($params['sys_id'])) {
  1157. $where['sys_id'] = ['like', '%' . $params['sys_id'] . '%'];
  1158. }
  1159. $prompt = Db::name('product_image_generate')->field('prompt')->where($where)->group('prompt')->order('id desc')->select();
  1160. $data = Db::name('product_image_generate')->where($where)->order('id desc')->page($page, $pageSize)->select();
  1161. $count = Db::name('product_image_generate')->field('prompt')->where($where)->order('id desc')->count();
  1162. foreach ($data as &$item) {
  1163. if (!empty($item['generated_image']) || !empty($item['product_img']) || !empty($item['reference_image'])) {
  1164. $item['generated_image'] = Common::ossFullUrl((string)$item['generated_image']);
  1165. $item['product_img'] = Common::ossFullUrl((string)$item['product_img']);
  1166. $item['reference_image'] = Common::ossFullUrl((string)$item['reference_image']);
  1167. }
  1168. }
  1169. unset($item);
  1170. return json([
  1171. 'code' => 0,
  1172. 'msg' => '成功',
  1173. 'count' => $count,
  1174. 'prompt' => $prompt,
  1175. 'data' => $data
  1176. ]);
  1177. }
  1178. /**
  1179. * 查询队列列表
  1180. * 统计文件对应的队列情况
  1181. */
  1182. public function get_queue_logs()
  1183. {
  1184. $params = $this->request->param('old_image_file', '');
  1185. $queue_logs = Db::name('queue_logs')
  1186. ->where('old_image_file', $params)
  1187. ->order('id desc')
  1188. ->select();
  1189. $result = []; //初始化变量,避免未定义错误
  1190. foreach ($queue_logs as &$log) {
  1191. $taskId = $log['id'];
  1192. $statusCount = Db::name('image_task_log')
  1193. ->field('status, COUNT(*) as count')
  1194. ->where('task_id', $taskId)
  1195. ->where('mod_rq', null)
  1196. ->group('status')
  1197. ->select();
  1198. $log['已完成数量'] = 0;
  1199. $log['处理中数量'] = 0;
  1200. $log['排队中的数量'] = 0;
  1201. $log['失败数量'] = 0;
  1202. foreach ($statusCount as $item) {
  1203. switch ($item['status']) {
  1204. case 0:
  1205. $log['排队中的数量'] = $item['count'];
  1206. break;
  1207. case 1:
  1208. $log['处理中数量'] = $item['count'];
  1209. break;
  1210. case 2:
  1211. $log['已完成数量'] = $item['count'];
  1212. break;
  1213. case -1:
  1214. $log['失败数量'] = $item['count'];
  1215. break;
  1216. }
  1217. }
  1218. // if ($log['排队中的数量'] >$log['已完成数量']) {
  1219. // $result[] = $log;
  1220. // }
  1221. if ($log['排队中的数量']) {
  1222. $result[] = $log;
  1223. }
  1224. // if ($log['处理中数量'] >= 0) {
  1225. // $result[] = $log;
  1226. // }
  1227. }
  1228. return json([
  1229. 'code' => 0,
  1230. 'msg' => '查询成功',
  1231. 'data' => $result,
  1232. 'count' => count($result)
  1233. ]);
  1234. }
  1235. /**
  1236. * 查询总队列状态(统计当前处理的数据量)
  1237. */
  1238. public function queueStats()
  1239. {
  1240. $statusList = Db::name('image_task_log')
  1241. ->field('status, COUNT(*) as total')
  1242. ->where('mod_rq', null)
  1243. ->where('create_time', '>=', date('Y-m-d 00:00:00'))
  1244. ->group('status')
  1245. ->select();
  1246. $statusCount = [];
  1247. foreach ($statusList as $item) {
  1248. $statusCount[$item['status']] = $item['total'];
  1249. }
  1250. // 总数为所有状态和
  1251. $total = array_sum($statusCount);
  1252. //获取队列当前状态
  1253. $statusText = Db::name('queue_logs')->order('id desc')->value('status');
  1254. return json([
  1255. 'code' => 0,
  1256. 'msg' => '获取成功',
  1257. 'data' => [
  1258. '总任务数' => $total,
  1259. '待处理' => $statusCount[0] ?? 0,
  1260. '处理中' => $statusCount[1] ?? 0,
  1261. '成功' => $statusCount[2] ?? 0,
  1262. '失败' => $statusCount[-1] ?? 0,
  1263. '当前状态' => $statusText
  1264. ]
  1265. ]);
  1266. }
  1267. /**
  1268. * 获取 Redis 连接实例
  1269. * @return \Redis|null Redis 实例或 null(如果连接失败)
  1270. */
  1271. private function getRedisConnection()
  1272. {
  1273. if (!class_exists('\Redis')) {
  1274. return null;
  1275. }
  1276. return getTaskRedis();
  1277. }
  1278. /**
  1279. * 显示当前运行中的队列监听进程
  1280. */
  1281. public function viewQueueStatus()
  1282. {
  1283. $redis = $this->getRedisConnection();
  1284. if (!$redis) {
  1285. return json([
  1286. 'code' => 1,
  1287. 'msg' => 'Redis扩展未安装或未启用',
  1288. 'data' => null
  1289. ]);
  1290. }
  1291. $key = 'queues:imgtotxt';
  1292. // 判断 key 是否存在,避免报错
  1293. if (!$redis->exists($key)) {
  1294. return json([
  1295. 'code' => 0,
  1296. 'msg' => '查询成功,队列为空',
  1297. 'count' => 0,
  1298. 'tasks_preview' => []
  1299. ]);
  1300. }
  1301. $count = $redis->lLen($key);
  1302. $list = $redis->lRange($key, 0, 9);
  1303. // 解码 JSON 内容,确保每一项都有效
  1304. $parsed = array_filter(array_map(function ($item) {
  1305. return json_decode($item, true);
  1306. }, $list), function ($item) {
  1307. return !is_null($item);
  1308. });
  1309. return json([
  1310. 'code' => 0,
  1311. 'msg' => '查询成功',
  1312. 'count' => $count,
  1313. 'tasks_preview' => $parsed
  1314. ]);
  1315. }
  1316. /**
  1317. * 清空队列并删除队列日志记录
  1318. */
  1319. public function stopQueueProcesses()
  1320. {
  1321. Db::name('image_task_log')
  1322. ->where('log', '队列中')
  1323. ->whereOr('status', 1)
  1324. ->where('create_time', '>=', date('Y-m-d 00:00:00'))
  1325. ->update([
  1326. 'status' => "-1",
  1327. 'log' => '清空取消队列',
  1328. 'mod_rq' => date('Y-m-d H:i:s')
  1329. ]);
  1330. Db::name('image_task_log')
  1331. ->whereLike('log', '%处理中%')
  1332. ->where('create_time', '>=', date('Y-m-d 00:00:00'))
  1333. ->update([
  1334. 'status' => "-1",
  1335. 'log' => '清空取消队列',
  1336. 'mod_rq' => date('Y-m-d H:i:s')
  1337. ]);
  1338. $redis = $this->getRedisConnection();
  1339. if (!$redis) {
  1340. return json([
  1341. 'code' => 1,
  1342. 'msg' => 'Redis扩展未安装或未启用',
  1343. 'data' => null
  1344. ]);
  1345. }
  1346. $key_txttoimg = 'queues:txttoimg:reserved';
  1347. $key_txttotxt = 'queues:txttotxt:reserved';
  1348. $key_imgtotxt = 'queues:imgtotxt:reserved';
  1349. $key_imgtoimg = 'queues:imgtoimg:reserved';
  1350. // 清空 Redis 队列
  1351. $redis->del($key_txttoimg);
  1352. $redis->del($key_txttotxt);
  1353. $redis->del($key_imgtotxt);
  1354. $redis->del($key_imgtoimg);
  1355. $count = $redis->lLen($key_txttoimg) + $redis->lLen($key_txttotxt) + $redis->lLen($key_imgtotxt) + $redis->lLen($key_imgtoimg);
  1356. return json([
  1357. 'code' => 0,
  1358. 'msg' => '成功停止队列任务'
  1359. ]);
  1360. }
  1361. /**
  1362. *获取服务器URL地址和端口 IP地址:端口
  1363. * 用于获取图片路径拼接时
  1364. **/
  1365. public function GetHttpUrl(){
  1366. $data = Db::name('http_url')->find();
  1367. $fullUrl = "http://" . $data['baseUrl'] . ":" . $data['port'];
  1368. $res = [
  1369. 'code' => 0,
  1370. 'msg' => '成功',
  1371. 'data' => [
  1372. 'id' => $data['id'],
  1373. 'full_url' => $fullUrl,
  1374. 'baseUrl' => $data['baseUrl'],
  1375. 'port' => $data['port']
  1376. ]
  1377. ];
  1378. return json($res);
  1379. }
  1380. /**
  1381. * 构建图生视频 content:单帧无 role,双帧带 first_frame / last_frame
  1382. */
  1383. private function buildImgToVideoContent(string $prompt, string $firstImageUrl, string $lastImageUrl): array
  1384. {
  1385. $content = [
  1386. ['type' => 'text', 'text' => $prompt],
  1387. ];
  1388. if ($lastImageUrl !== '') {
  1389. $content[] = [
  1390. 'type' => 'image_url',
  1391. 'image_url' => ['url' => $firstImageUrl],
  1392. 'role' => 'first_frame',
  1393. ];
  1394. $content[] = [
  1395. 'type' => 'image_url',
  1396. 'image_url' => ['url' => $lastImageUrl],
  1397. 'role' => 'last_frame',
  1398. ];
  1399. return $content;
  1400. }
  1401. $content[] = [
  1402. 'type' => 'image_url',
  1403. 'image_url' => ['url' => $firstImageUrl],
  1404. ];
  1405. return $content;
  1406. }
  1407. /** 图生视频创建前,帧图暂存 OSS 子目录(AI 返回 id 后会迁移到正式目录) */
  1408. private const FRAME_OSS_STAGING_ID = '_staging';
  1409. /**
  1410. * 任务 id 用于路径的安全片段
  1411. */
  1412. private function sanitizeTaskIdSegment(string $taskId): string
  1413. {
  1414. $segment = preg_replace('/[\\\\\/:*?"<>|]/u', '_', trim($taskId));
  1415. return $segment !== '' ? $segment : '_unknown';
  1416. }
  1417. /**
  1418. * 图生视频任务媒体本地目录:public/uploads/videos/{日期}/{taskId}/
  1419. */
  1420. private function buildTaskMediaLocalDir(string $taskId): string
  1421. {
  1422. return str_replace('\\', '/', ROOT_PATH . 'public/uploads/videos/' . date('Ymd') . '/' . $this->sanitizeTaskIdSegment($taskId) . '/');
  1423. }
  1424. /**
  1425. * 图生视频任务媒体 OSS 对象键:uploads/videos/{日期}/{taskId}/{文件名}(帧图与生成视频同目录)
  1426. */
  1427. private function buildTaskMediaObjectKey(string $taskId, string $fileName): string
  1428. {
  1429. return 'uploads/videos/' . date('Ymd') . '/' . $this->sanitizeTaskIdSegment($taskId) . '/' . ltrim($fileName, '/');
  1430. }
  1431. /**
  1432. * 解析帧图片(含本地路径与暂存 OSS 信息,供创建任务后按 AI id 归档)
  1433. * @return array{type:string,api_url:string,local_path:string,staging_object_key:string}
  1434. */
  1435. private function resolveFrameImagePayload(array $params, string $role, string &$error = ''): array
  1436. {
  1437. $empty = ['type' => 'empty', 'api_url' => '', 'local_path' => '', 'staging_object_key' => ''];
  1438. $prefix = $role === 'last' ? 'last' : 'first';
  1439. $error = '';
  1440. $taskId = trim((string)($params['task_id'] ?? $params['video_id'] ?? ''));
  1441. $ossTaskId = $taskId !== '' ? $taskId : self::FRAME_OSS_STAGING_ID;
  1442. $uploadedFile = $this->request->file("{$prefix}_image");
  1443. if (!empty($uploadedFile)) {
  1444. $fileList = is_array($uploadedFile) ? $uploadedFile : [$uploadedFile];
  1445. foreach ($fileList as $file) {
  1446. if (!$file) {
  1447. continue;
  1448. }
  1449. $fileError = '';
  1450. $result = $this->uploadFrameFileToOss($file, $prefix, $fileError, $ossTaskId);
  1451. if (($result['url'] ?? '') !== '') {
  1452. return [
  1453. 'type' => 'local',
  1454. 'api_url' => $result['url'],
  1455. 'local_path' => $result['local_path'] ?? '',
  1456. 'staging_object_key' => $result['object_key'] ?? '',
  1457. ];
  1458. }
  1459. if ($fileError !== '') {
  1460. $error = $fileError;
  1461. }
  1462. }
  1463. }
  1464. $base64Keys = ["{$prefix}_image", "{$prefix}_image_base64"];
  1465. foreach ($base64Keys as $key) {
  1466. if (empty($params[$key]) || !is_string($params[$key])) {
  1467. continue;
  1468. }
  1469. $raw = trim($params[$key]);
  1470. if ($raw === '' || strlen($raw) < 50) {
  1471. continue;
  1472. }
  1473. $result = $this->uploadBase64ImageToOss($raw, $prefix, $ossTaskId, $error);
  1474. if (($result['url'] ?? '') !== '') {
  1475. return [
  1476. 'type' => 'local',
  1477. 'api_url' => $result['url'],
  1478. 'local_path' => $result['local_path'] ?? '',
  1479. 'staging_object_key' => $result['object_key'] ?? '',
  1480. ];
  1481. }
  1482. }
  1483. $urlKey = "{$prefix}_image_url";
  1484. if (!empty($params[$urlKey])) {
  1485. $url = trim((string)$params[$urlKey]);
  1486. if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
  1487. return ['type' => 'external', 'api_url' => $url, 'local_path' => '', 'staging_object_key' => ''];
  1488. }
  1489. }
  1490. return $empty;
  1491. }
  1492. /**
  1493. * AI 返回任务 id 后,将本地帧图上传到 uploads/videos/{日期}/{taskId}/(与生成视频同目录)
  1494. */
  1495. private function finalizeFrameImageToOss(array $payload, string $taskId): string
  1496. {
  1497. if (($payload['type'] ?? '') === 'external') {
  1498. return (string)($payload['api_url'] ?? '');
  1499. }
  1500. if (($payload['type'] ?? '') !== 'local') {
  1501. return '';
  1502. }
  1503. $localPath = (string)($payload['local_path'] ?? '');
  1504. if ($localPath === '' || !is_file($localPath)) {
  1505. return (string)($payload['api_url'] ?? '');
  1506. }
  1507. $objectKey = $this->buildTaskMediaObjectKey($taskId, basename($localPath));
  1508. $stagingKey = (string)($payload['staging_object_key'] ?? '');
  1509. if ($stagingKey !== '' && $stagingKey === $objectKey) {
  1510. return (string)($payload['api_url'] ?? '') ?: Common::ossFullUrl($objectKey);
  1511. }
  1512. $upload = $this->uploadToOSS($localPath, $objectKey);
  1513. if (!$upload['success']) {
  1514. Log::write('[finalizeFrameImageToOss] OSS上传失败: ' . $objectKey, 'error');
  1515. return (string)($payload['api_url'] ?? '');
  1516. }
  1517. if ($stagingKey !== '' && $stagingKey !== $objectKey) {
  1518. Common::deleteOssObject($stagingKey);
  1519. }
  1520. return $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
  1521. }
  1522. /**
  1523. * 解析帧图片:form-data 文件 > base64 > http(s) URL
  1524. */
  1525. private function resolveFrameImageUrl(array $params, string $role, string &$error = ''): string
  1526. {
  1527. $payload = $this->resolveFrameImagePayload($params, $role, $error);
  1528. return (string)($payload['api_url'] ?? '');
  1529. }
  1530. /**
  1531. * form-data 上传的图片落盘并同步 OSS
  1532. * @return array{url:string,local_path:string,object_key:string}
  1533. */
  1534. private function uploadFrameFileToOss($file, string $roleLabel, string &$error = '', string $taskId = ''): array
  1535. {
  1536. $empty = ['url' => '', 'local_path' => '', 'object_key' => ''];
  1537. if ($taskId === '') {
  1538. $taskId = self::FRAME_OSS_STAGING_ID;
  1539. }
  1540. $error = '';
  1541. $ext = $this->resolveUploadedImageExt($file);
  1542. if ($ext === '') {
  1543. $error = '不支持的图片格式';
  1544. Log::write('[uploadFrameFileToOss] ' . $error, 'error');
  1545. return $empty;
  1546. }
  1547. $uploadInfo = $file->getInfo();
  1548. $tmpPath = isset($uploadInfo['tmp_name']) ? (string)$uploadInfo['tmp_name'] : '';
  1549. if ($tmpPath === '' || !is_file($tmpPath)) {
  1550. $tmpPath = (string)($file->getRealPath() ?: '');
  1551. }
  1552. if ($tmpPath === '' || !is_file($tmpPath)) {
  1553. $uploadErr = $uploadInfo['error'] ?? UPLOAD_ERR_NO_FILE;
  1554. $error = '未接收到上传文件(错误码' . $uploadErr . ')';
  1555. Log::write('[uploadFrameFileToOss] ' . $error, 'error');
  1556. return $empty;
  1557. }
  1558. $saveDir = $this->buildTaskMediaLocalDir($taskId);
  1559. if (!is_dir($saveDir)) {
  1560. mkdir($saveDir, 0755, true);
  1561. }
  1562. $saveFileName = $roleLabel . '_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
  1563. $localFullPath = $saveDir . $saveFileName;
  1564. $saved = false;
  1565. if (method_exists($file, 'isValid') && $file->isValid()) {
  1566. $info = $file->move($saveDir, $saveFileName);
  1567. if ($info) {
  1568. $localFullPath = $saveDir . $info->getFilename();
  1569. $saved = true;
  1570. }
  1571. }
  1572. if (!$saved && !@copy($tmpPath, $localFullPath)) {
  1573. $moveErr = method_exists($file, 'getError') ? (string)$file->getError() : 'copy失败';
  1574. $error = '图片保存失败: ' . $moveErr;
  1575. Log::write('[uploadFrameFileToOss] ' . $error, 'error');
  1576. return $empty;
  1577. }
  1578. $objectKey = $this->buildTaskMediaObjectKey($taskId, basename($localFullPath));
  1579. $upload = $this->uploadToOSS($localFullPath, $objectKey);
  1580. if (!$upload['success']) {
  1581. $error = 'OSS上传失败';
  1582. Log::write('[uploadFrameFileToOss] ' . $error . ' | ' . $objectKey, 'error');
  1583. return $empty;
  1584. }
  1585. $url = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
  1586. return [
  1587. 'url' => $url,
  1588. 'local_path' => $localFullPath,
  1589. 'object_key' => $upload['object_key'] ?? $objectKey,
  1590. ];
  1591. }
  1592. /**
  1593. * 解析上传图片扩展名
  1594. */
  1595. private function resolveUploadedImageExt($file): string
  1596. {
  1597. $name = $file->getInfo('name') ?? '';
  1598. $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
  1599. if ($ext === 'jpeg') {
  1600. $ext = 'jpg';
  1601. }
  1602. $allowed = ['jpg', 'png', 'gif', 'webp', 'bmp'];
  1603. if ($ext && in_array($ext, $allowed, true)) {
  1604. return $ext;
  1605. }
  1606. $mime = method_exists($file, 'getMime') ? strtolower((string)$file->getMime()) : '';
  1607. $map = [
  1608. 'image/jpeg' => 'jpg',
  1609. 'image/png' => 'png',
  1610. 'image/gif' => 'gif',
  1611. 'image/webp' => 'webp',
  1612. 'image/bmp' => 'bmp',
  1613. ];
  1614. return $map[$mime] ?? '';
  1615. }
  1616. /**
  1617. * base64 图片落盘并上传 OSS(前端传 data:image/...;base64,...)
  1618. * @return array{url:string,local_path:string,object_key:string}
  1619. */
  1620. private function uploadBase64ImageToOss(string $base64Input, string $roleLabel, string $taskId = '', string &$error = ''): array
  1621. {
  1622. $empty = ['url' => '', 'local_path' => '', 'object_key' => ''];
  1623. if ($taskId === '') {
  1624. $taskId = self::FRAME_OSS_STAGING_ID;
  1625. }
  1626. $error = '';
  1627. $parsed = $this->parseBase64Image($base64Input);
  1628. if ($parsed === null) {
  1629. $error = '图片Base64解析失败';
  1630. Log::write('[uploadBase64ImageToOss] ' . $error, 'error');
  1631. return $empty;
  1632. }
  1633. [$ext, $imageData] = $parsed;
  1634. $saveDir = $this->buildTaskMediaLocalDir($taskId);
  1635. if (!is_dir($saveDir)) {
  1636. mkdir($saveDir, 0755, true);
  1637. }
  1638. $fileName = $roleLabel . '_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
  1639. $localFullPath = $saveDir . $fileName;
  1640. if (file_put_contents($localFullPath, $imageData) === false) {
  1641. Log::write('[uploadBase64ImageToOss] 本地保存失败: ' . $localFullPath, 'error');
  1642. return $empty;
  1643. }
  1644. $objectKey = $this->buildTaskMediaObjectKey($taskId, $fileName);
  1645. $upload = $this->uploadToOSS($localFullPath, $objectKey);
  1646. if (!$upload['success']) {
  1647. $fallbackUrl = $this->buildPublicUploadUrl($localFullPath);
  1648. if ($fallbackUrl !== '') {
  1649. Log::write('[uploadBase64ImageToOss] OSS失败,回退本站URL: ' . $fallbackUrl, 'warning');
  1650. return [
  1651. 'url' => $fallbackUrl,
  1652. 'local_path' => $localFullPath,
  1653. 'object_key' => $objectKey,
  1654. ];
  1655. }
  1656. Log::write('[uploadBase64ImageToOss] OSS上传失败: ' . $objectKey, 'error');
  1657. return $empty;
  1658. }
  1659. $url = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
  1660. return [
  1661. 'url' => $url,
  1662. 'local_path' => $localFullPath,
  1663. 'object_key' => $upload['object_key'] ?? $objectKey,
  1664. ];
  1665. }
  1666. /**
  1667. * 解析 base64 图片(避免对大字符串整段正则,防止服务器 PCRE 超限)
  1668. * @return array{0:string,1:string}|null [扩展名, 二进制内容]
  1669. */
  1670. private function parseBase64Image(string $base64Input): ?array
  1671. {
  1672. $base64Input = trim($base64Input);
  1673. if ($base64Input === '') {
  1674. return null;
  1675. }
  1676. $ext = 'jpg';
  1677. $rawBase64 = $base64Input;
  1678. $prefix = 'data:image/';
  1679. if (stripos($base64Input, $prefix) === 0) {
  1680. $semi = stripos($base64Input, ';base64,');
  1681. if ($semi === false) {
  1682. return null;
  1683. }
  1684. $mimePart = strtolower(substr($base64Input, strlen($prefix), $semi - strlen($prefix)));
  1685. if ($mimePart === 'jpeg') {
  1686. $mimePart = 'jpg';
  1687. }
  1688. if (in_array($mimePart, ['jpg', 'png', 'gif', 'webp', 'bmp'], true)) {
  1689. $ext = $mimePart;
  1690. }
  1691. $rawBase64 = substr($base64Input, $semi + 8);
  1692. }
  1693. $rawBase64 = preg_replace('/\s+/', '', $rawBase64);
  1694. if ($rawBase64 === '') {
  1695. return null;
  1696. }
  1697. $imageData = base64_decode($rawBase64, true);
  1698. if ($imageData === false || strlen($imageData) < 100) {
  1699. return null;
  1700. }
  1701. return [$ext, $imageData];
  1702. }
  1703. /**
  1704. * 合并请求参数(兼容浏览器 application/json 提交 first_image/last_image base64)
  1705. */
  1706. private function mergeRequestParams(): array
  1707. {
  1708. $params = $this->request->param();
  1709. if (!empty($params['first_image']) || !empty($params['last_image']) || !empty($params['prompt'])) {
  1710. return $params;
  1711. }
  1712. $content = $this->request->getInput();
  1713. if (!is_string($content) || $content === '') {
  1714. return $params;
  1715. }
  1716. $json = json_decode($content, true);
  1717. if (!is_array($json)) {
  1718. return $params;
  1719. }
  1720. return array_merge($params, $json);
  1721. }
  1722. /**
  1723. * 将本地文件上传到阿里云 OSS
  1724. *
  1725. * @param string $file 本地完整路径,或 public 下相对路径(如 uploads/videos/20260604/xxx.mp4)
  1726. * @param string $objectKey OSS 对象键;传 uploads/ 开头或带扩展名则视为完整键,否则作为子目录标识(如 task_id)
  1727. * @return array{success:bool,object_key:string,url:string}
  1728. */
  1729. private function uploadToOSS(string $file, string $objectKey = ''): array
  1730. {
  1731. $localFullPath = $this->resolvePublicLocalPath($file);
  1732. if ($localFullPath === '') {
  1733. Log::write('[uploadToOSS] 本地文件不存在: ' . $file, 'error');
  1734. return ['success' => false, 'object_key' => '', 'url' => ''];
  1735. }
  1736. $fileName = basename($localFullPath);
  1737. if ($objectKey !== '' && (strpos($objectKey, 'uploads/') === 0 || preg_match('/\.[a-z0-9]{1,8}$/i', $objectKey))) {
  1738. $ossObjectKey = Common::normalizeOssObjectKey($objectKey);
  1739. } else {
  1740. $segments = ['uploads', 'videos', date('Ymd')];
  1741. if ($objectKey !== '') {
  1742. $segments[] = preg_replace('/[\\\\\/:*?"<>|]/u', '_', $objectKey);
  1743. }
  1744. $segments[] = $fileName;
  1745. $ossObjectKey = implode('/', $segments);
  1746. }
  1747. $success = Common::uploadLocalFileToOss($localFullPath, $ossObjectKey);
  1748. return [
  1749. 'success' => $success,
  1750. 'object_key' => $ossObjectKey,
  1751. 'url' => $success ? Common::ossFullUrl($ossObjectKey) : '',
  1752. ];
  1753. }
  1754. /**
  1755. * 入库路径:仅保留 uploads/ 及后面部分
  1756. */
  1757. private function toDbUploadPath(string $path): string
  1758. {
  1759. $path = trim(str_replace('\\', '/', $path));
  1760. if ($path === '') {
  1761. return '';
  1762. }
  1763. if (stripos($path, 'http://') === 0 || stripos($path, 'https://') === 0) {
  1764. $mark = '/uploads/';
  1765. $pos = stripos($path, $mark);
  1766. if ($pos !== false) {
  1767. return ltrim(substr($path, $pos + 1), '/');
  1768. }
  1769. return $path;
  1770. }
  1771. $path = ltrim($path, '/');
  1772. if (stripos($path, 'uploads/') === 0) {
  1773. return $path;
  1774. }
  1775. $pos = stripos($path, 'uploads/');
  1776. if ($pos !== false) {
  1777. return substr($path, $pos);
  1778. }
  1779. return $path;
  1780. }
  1781. /**
  1782. * 接口返回:库内相对路径转完整 URL
  1783. */
  1784. private function toPublicMediaUrl(string $dbPath): string
  1785. {
  1786. $dbPath = trim($dbPath);
  1787. if ($dbPath === '') {
  1788. return '';
  1789. }
  1790. if (stripos($dbPath, 'http://') === 0 || stripos($dbPath, 'https://') === 0) {
  1791. return $dbPath;
  1792. }
  1793. return Common::ossFullUrl($this->toDbUploadPath($dbPath));
  1794. }
  1795. /**
  1796. * 将 public 下本地文件转为可公网访问的站点 URL(OSS 不可用时的回退)
  1797. */
  1798. private function buildPublicUploadUrl(string $localFullPath): string
  1799. {
  1800. $publicRoot = str_replace('\\', '/', ROOT_PATH . 'public/');
  1801. $path = str_replace('\\', '/', $localFullPath);
  1802. if (strpos($path, $publicRoot) !== 0) {
  1803. return '';
  1804. }
  1805. $relative = ltrim(substr($path, strlen($publicRoot)), '/');
  1806. return $relative === '' ? '' : rtrim($this->request->domain(), '/') . '/' . $relative;
  1807. }
  1808. /**
  1809. * @return array{summary:string,content_length:int,post_max_size:string,upload_max_filesize:string,has_first_image_file:bool}
  1810. */
  1811. private function getPhpUploadEnvInfo(): array
  1812. {
  1813. $contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
  1814. $postMax = (string)ini_get('post_max_size');
  1815. $uploadMax = (string)ini_get('upload_max_filesize');
  1816. $hasFile = !empty($this->request->file('first_image'));
  1817. $summary = 'CONTENT_LENGTH=' . $contentLength
  1818. . ';post_max_size=' . $postMax
  1819. . ';upload_max_filesize=' . $uploadMax
  1820. . ';has_first_image_file=' . ($hasFile ? 'yes' : 'no');
  1821. return [
  1822. 'summary' => $summary,
  1823. 'content_length' => $contentLength,
  1824. 'post_max_size' => $postMax,
  1825. 'upload_max_filesize' => $uploadMax,
  1826. 'has_first_image_file' => $hasFile,
  1827. ];
  1828. }
  1829. /**
  1830. * 解析为 public 目录下的本地绝对路径
  1831. */
  1832. private function resolvePublicLocalPath(string $file): string
  1833. {
  1834. $file = str_replace('\\', '/', trim($file));
  1835. if ($file === '') {
  1836. return '';
  1837. }
  1838. if (is_file($file)) {
  1839. return $file;
  1840. }
  1841. $publicPath = str_replace('\\', '/', ROOT_PATH . 'public/' . ltrim($file, '/'));
  1842. return is_file($publicPath) ? $publicPath : '';
  1843. }
  1844. }