WorkOrder.php 74 KB

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