request->param(); $service = new ImageService(); $service->handleImage($params); $this->success('任务成功提交至队列'); } /** * task_id:查询获取任务图片 */ public function GetImageStatus(){ $params = $this->request->param(); $taskId = $params['task_id']; if (empty($taskId)) { $res = [ 'code' => 1, 'msg' => '任务ID不能为空' ]; return json($res); } //从Redis中获取任务状态 $redis = getTaskRedis(); $taskData = $redis->get("img_to_img_task:{$taskId}"); if (!$taskData) { $taskData = $redis->get("text_to_image_task:{$taskId}"); } if (!$taskData) { $res = [ 'code' => 1, 'msg' => '任务不存在或已过期', ]; return json($res); } $taskInfo = json_decode($taskData, true); if (!empty($taskInfo['image'])) { $taskInfo['image'] = Common::ossFullUrl((string)$taskInfo['image']); } if (!empty($taskInfo['image_url'])) { $taskInfo['image_url'] = Common::ossFullUrl((string)$taskInfo['image_url']); } $res = [ 'code' => 0, 'msg' => '查询成功', 'data' => $taskInfo ]; return json($res); } /** * 支持的AI任务类型枚举(前端传入的status_val需在该列表内) * 键:前端传入值,值:方法后缀( '前端传入值' => '方法后缀') */ private static $AI_TASK_TYPES = [ '图生文' => 'ImgToText', '文生文' => 'TextToText', '文生图' => 'TextToImg', '图生图' => 'ImgToImg' ]; /** * AI模型接口统一调用入口 * @description 接收前端AI请求,校验任务类型合法性,分发至对应处理方法,统一异常捕获 * @return \think\response\Json 标准化JSON响应 */ public function callAIModelApi() { try { // 1. 获取并校验入参 $params = $this->request->param(); $statusVal = $this->validateAndGetStatusVal($params); // 2. 映射并校验处理方法 $method = $this->getHandleMethod($statusVal); // 3. 执行对应处理逻辑并返回响应 return $this->$method($params); } catch (\InvalidArgumentException $e) { // 参数/方法异常(用户侧错误) return $this->jsonResponse(1, $e->getMessage()); } catch (\Throwable $e) { // 系统异常(服务侧错误) \think\Log::error('AI接口处理异常:' . $e->getMessage() . ' | 任务类型:' . ($params['status_val'] ?? '未知') . ' | 异常行:' . $e->getLine()); return $this->jsonResponse(1, '服务异常,请稍后重试'); } } // -------------------------- 私有核心方法(通用逻辑) -------------------------- /** * 校验并获取合法的任务类型 * @param array $params 前端入参 * @return string 合法的status_val * @throws \InvalidArgumentException 任务类型不合法时抛出 */ private function validateAndGetStatusVal(array $params): string { $statusVal = trim($params['status_val'] ?? ''); // 空值校验 if (empty($statusVal)) { throw new \InvalidArgumentException('任务类型不能为空'); } // 合法性校验 if (!array_key_exists($statusVal, self::$AI_TASK_TYPES)) { throw new \InvalidArgumentException('不支持的任务类型:' . $statusVal); } return $statusVal; } /** * 获取任务对应的处理方法名 * @param string $statusVal 合法的任务类型 * @return string 处理方法名(如handleAiImgToText) * @throws \InvalidArgumentException 方法未实现时抛出 */ private function getHandleMethod(string $statusVal): string { $methodSuffix = self::$AI_TASK_TYPES[$statusVal]; $method = 'handleAi' . $methodSuffix; if (!method_exists($this, $method)) { throw new \InvalidArgumentException('任务类型暂未实现:' . $statusVal); } return $method; } /** * 通用JSON响应封装 * @param int $code 响应码(0=成功,1=失败) * @param string $msg 响应信息 * @param array $data 响应数据(可选) * @return \think\response\Json */ private function jsonResponse(int $code, string $msg, array $data = []): \think\response\Json { $response = [ 'code' => $code, 'msg' => $msg, 'time' => date('Y-m-d H:i:s') ]; if (!empty($data)) { $response['data'] = $data; } return json($response); } /** * 任务类接口统一响应(带task_id的场景) * @param array $result 业务处理结果(需包含success字段) * @param string $failMsg 失败提示语 * @return \think\response\Json */ private function jsonTaskResponse(array $result, string $failMsg = '任务提交失败'): \think\response\Json { $isSuccess = isset($result['success']) && $result['success']; $data = $isSuccess ? ['task_id' => $result['task_id'] ?? ''] : []; $msg = $isSuccess ? ($result['message'] ?? '提交成功') : ($result['message'] ?? $failMsg); return $this->jsonResponse($isSuccess ? 0 : 1, $msg, $data); } // -------------------------- 业务处理方法(按任务类型拆分) -------------------------- /** * 图生文任务处理:提交队列并返回提示 * @param array $params 前端入参 * @return \think\response\Json */ private function handleAiImgToText(array $params): \think\response\Json { (new ImageService())->handleImgToText($params); return $this->jsonResponse(0, '正在优化提示词,请稍等.....'); } /** * 文生文任务处理:生成话术并返回内容(支持产品内容更新) * @param array $params 前端入参 * @return \think\response\Json */ private function handleAiTextToText(array $params): \think\response\Json { //构造生成提示词 $promptTemplate = "\n请根据上述内容生成一段完整的话术,要求:\n" . "1. 内容必须是连贯的一段话,不要使用列表、分隔线或其他结构化格式\n" . "2. 不要包含非文本元素的描述\n" . "3. 不要添加任何额外的引导语、解释或开场白\n" . "4. 禁忌:不添加无关形容词,不修改产品核心信息,语言流畅自然"; $prompt = ($params['prompt'] ?? '') . $promptTemplate; // 调用服务层生成内容 $result = (new ImageService())->handleTextToText( $params['status_val'], $prompt, $params['model'] ); if (empty($result['success'])) { return $this->jsonResponse(1, $result['message'] ?? '生成失败'); } $content = $result['data'] ?? ''; //区分业务场景处理 $isProductImageGeneration = ($params['status_type'] ?? '') === 'ProductImageGeneration'; $isProductTemplateReplace = ($params['status_type'] ?? '') === 'ProductTemplateReplace'; if (!$isProductImageGeneration && !$isProductTemplateReplace) { Db::name('product')->where('id', $params['id'])->update(['content' => $content]); } return $this->jsonResponse(0, '优化成功', ['content' => $content]); } /** * 文生图任务处理:提交任务并返回task_id * @param array $params 前端入参 * @return \think\response\Json */ private function handleAiTextToImg(array $params): \think\response\Json { $serviceResult = (new ImageService())->handleTextToImg($params); return $this->jsonTaskResponse($serviceResult, '文生图任务提交失败'); } /** * 图生图任务处理:提交任务并返回task_id * @param array $params 前端入参 * @return \think\response\Json */ private function handleAiImgToImg(array $params): \think\response\Json { $serviceResult = (new ImageService())->handleImgToImg($params); return $this->jsonTaskResponse($serviceResult, '图生图任务提交失败'); } /** * 即梦AI--创建视频任务接口 * 支持:单张首帧图 / 首尾双帧图 * 图片入参:form-data 文件(first_image/last_image)、base64、或 http(s) URL */ public function Create_ImgToVideo() { $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks'; $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339'; $params = $this->request->param(); $prompt = trim((string)($params['prompt'] ?? '')); if ($prompt === '') { return json(['code' => 0, 'msg' => 'prompt 不能为空']); } $firstError = ''; $firstFrame = $this->resolveFrameImagePayload($params, 'first', $firstError); if (($firstFrame['api_url'] ?? '') === '') { $hint = Common::isOssEnabled() ? '请用 form-data 上传 first_image(类型选文件),并查看 runtime/log' : '请在 application/config.php 配置 oss(accessKeyId、endpoint、bucket、host)'; $detail = $firstError !== '' ? '(' . $firstError . ')' : ''; return json(['code' => 0, 'msg' => '首帧图片无效或上传 OSS 失败。' . $hint . $detail]); } $lastFrame = $this->resolveFrameImagePayload($params, 'last'); $firstImageUrl = $firstFrame['api_url']; $lastImageUrl = $lastFrame['api_url']; $content = $this->buildImgToVideoContent($prompt, $firstImageUrl, $lastImageUrl); $data = [ 'model' => 'doubao-seedance-1-5-pro-251215', 'content' => $content, 'generate_audio' => filter_var($params['generate_audio'] ?? true, FILTER_VALIDATE_BOOLEAN), 'ratio' => $params['ratio'] ?? $params['aspect_ratio'] ?? 'adaptive', 'duration' => (int)($params['duration'] ?? 5), 'watermark' => filter_var($params['watermark'] ?? false, FILTER_VALIDATE_BOOLEAN), ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $apiUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_TIMEOUT, 60); $response = curl_exec($ch); if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); return json(['code' => 0, 'msg' => 'Curl 错误: ' . $error]); } curl_close($ch); $responseData = json_decode($response, true); if (isset($responseData['error'])) { $msg = $responseData['error']['message'] ?? 'API 请求失败'; return json(['code' => 0, 'msg' => 'API 错误: ' . $msg]); } $taskId = $responseData['id'] ?? ''; if ($taskId === '') { return json(['code' => 0, 'msg' => '获取任务 ID 失败']); } $firstImageUrl = $this->finalizeFrameImageToOss($firstFrame, $taskId); $lastImageUrl = $this->finalizeFrameImageToOss($lastFrame, $taskId); $videoData = [ 'video_id' => $taskId, 'prompt' => $prompt, 'first_image_url' => $firstImageUrl, 'last_image_url' => $lastImageUrl, 'model' => $data['model'], 'seconds' => (string)$data['duration'], 'size' => (string)$data['ratio'], 'sys_rq' => date('Y-m-d H:i:s'), ]; try { Db::name('video')->insert($videoData); } catch (Exception $e) { return json([ 'code' => 0, 'msg' => '任务已创建,但数据库保存失败', 'data' => [ 'task_id' => $taskId, 'error_message' => $e->getMessage(), ], ]); } return json([ 'code' => 1, 'msg' => '任务创建成功', 'data' => [ 'task_id' => $taskId, 'video_id' => $taskId, 'status' => $responseData['status'] ?? '', 'created_at' => $responseData['created_at'] ?? '', 'mode' => $lastImageUrl !== '' ? 'first_last_frame' : 'single_frame', 'first_image_url' => $firstImageUrl, 'last_image_url' => $lastImageUrl, ] ]); } /** * 即梦AI--获取视频接口 * 首帧图 + 尾帧图 = 新效果视频 */ public function Get_ImgToVideo() { $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks'; $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339'; $params = $this->request->param(); $taskId = $params['task_id'] ?? $params['video_id'] ?? ''; if ($taskId === '') { return json(['code' => 0, 'msg' => '任务 ID 不能为空']); } // 查询任务状态 $queryUrl = $apiUrl . '/' . $taskId; $ch2 = curl_init(); curl_setopt($ch2, CURLOPT_URL, $queryUrl); curl_setopt($ch2, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey ]); curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch2, CURLOPT_SSL_VERIFYPEER, false); // 开发环境临时关闭SSL验证 curl_setopt($ch2, CURLOPT_TIMEOUT, 60); // 超时时间 $queryResponse = curl_exec($ch2); // 检查 cURL 错误 if (curl_errno($ch2)) { $error = curl_error($ch2); curl_close($ch2); return json(['code' => 0, 'msg' => 'Curl 错误: ' . $error]); } curl_close($ch2); // 解析查询响应 $queryData = json_decode($queryResponse, true); // print_r($queryData);die; // 轮询任务状态,直到完成 $maxPolls = 30; $pollCount = 0; $pollData = is_array($queryData) ? $queryData : []; $taskStatus = $pollData['status'] ?? ''; while (!in_array($taskStatus, ['completed', 'succeeded']) && $pollCount < $maxPolls) { sleep(5); // 每5秒轮询一次 $pollCount++; // 再次查询任务状态 $ch3 = curl_init(); curl_setopt($ch3, CURLOPT_URL, $queryUrl); curl_setopt($ch3, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey ]); curl_setopt($ch3, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch3, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch3, CURLOPT_TIMEOUT, 60); $pollResponse = curl_exec($ch3); curl_close($ch3); $pollData = json_decode($pollResponse, true); $taskStatus = $pollData['status'] ?? ''; // 检查任务是否失败 if ($taskStatus === 'failed') { return json(['code' => 0, 'msg' => '任务执行失败']); } } // 如果任务已经成功,直接使用 $queryData if (empty($pollData) && isset($queryData['status']) && $queryData['status'] === 'succeeded') { $pollData = $queryData; } // 检查轮询是否超时 if (!in_array($taskStatus, ['completed', 'succeeded'])) { return json(['code' => 0, 'msg' => '任务执行超时']); } // 获取视频 URL(兼容不同响应结构) $videoUrl = $pollData['content']['video_url'] ?? $pollData['output']['video_url'] ?? $pollData['video_url'] ?? ''; if ($videoUrl === '') { return json(['code' => 0, 'msg' => '获取视频 URL 失败', 'data' => ['pollData' => $pollData]]); } $fileName = $this->sanitizeTaskIdSegment($taskId) . '.mp4'; $saveDir = $this->buildTaskMediaLocalDir($taskId); if (!is_dir($saveDir)) { mkdir($saveDir, 0755, true); } $savePath = $saveDir . $fileName; $videoContent = $this->downloadRemoteFile($videoUrl); if ($videoContent === false) { return json(['code' => 0, 'msg' => '下载视频失败', 'data' => ['videoUrl' => $videoUrl]]); } if (file_put_contents($savePath, $videoContent) === false) { return json(['code' => 0, 'msg' => '保存视频失败', 'data' => ['savePath' => $savePath]]); } $objectKey = $this->buildTaskMediaObjectKey($taskId, $fileName); $upload = $this->uploadToOSS($savePath, $objectKey); if ($upload['success']) { $webUrl = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey); } else { $webUrl = $objectKey; Log::write('[Get_ImgToVideo] OSS上传失败,使用本地相对路径: ' . $objectKey, 'error'); } try { Db::name('video')->where('video_id', $taskId)->update(['web_url' => $webUrl]); } catch (Exception $e) { return json([ 'code' => 0, 'msg' => '视频已生成,但数据库更新失败', 'data' => [ 'task_id' => $taskId, 'web_url' => $webUrl, 'error_message' => $e->getMessage(), ], ]); } return json([ 'code' => 1, 'msg' => '视频获取成功', 'data' => [ 'task_id' => $taskId, 'video_id' => $taskId, 'status' => $taskStatus, 'web_url' => $webUrl, 'oss_object_key' => $upload['object_key'] ?? $objectKey, 'oss_uploaded' => $upload['success'] ?? false, 'local_path' => $objectKey, ], ]); } /** * 下载远程文件(视频等) * @return string|false */ private function downloadRemoteFile(string $url) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); curl_setopt($ch, CURLOPT_TIMEOUT, 300); $body = curl_exec($ch); if (curl_errno($ch)) { Log::write('[downloadRemoteFile] ' . curl_error($ch) . ' | url=' . $url, 'error'); curl_close($ch); return false; } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300 || $body === false) { return false; } return $body; } //获取视频列表 public function Getvideolist(){ if (!$this->request->isGet()) { $this->error('请求方式错误'); } $params = $this->request->param(); $search = input('search', ''); $page = isset($params['page']) ? (int)$params['page'] : 1; $limit = isset($params['limit']) ? (int)$params['limit'] : 50; $where = []; if (!empty($search)) { $where['prompt'] = ['like', '%' . $search . '%']; } $list = Db::name('video')->where('mod_rq', null) ->where($where) ->order('id desc') ->limit(($page - 1) * $limit, $limit) ->select(); $total = Db::name('video')->where('mod_rq', null) ->where($where) ->count(); $res['code'] = 0; $res['msg'] = '成功'; $res['count'] = $total; $res['data'] = $list; return json($res); } /** * 文生视频/图生视频接口 */ //文生视频 public function video(){ $apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks'; $apiKey = 'ark-1ca8aa97-3663-4bc7-8c53-d4ab516883f1-d2339'; $params = $this->request->param(); $postData = [ 'prompt' => $params['prompt'], 'model' => 'doubao-seedance-1-5-pro-251215', 'seconds' => $params['seconds'], 'size' => $params['size'], ]; // 初始化CURL $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $apiUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $postData); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $apiKey ]); curl_setopt($ch, CURLOPT_TIMEOUT, 300); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); curl_setopt($ch, CURLOPT_HEADER, true); // 获取响应头 curl_setopt($ch, CURLOPT_VERBOSE, true); // 启用详细输出以进行调试 // 创建临时文件来捕获详细的cURL输出 $verbose = fopen('php://temp', 'w+'); curl_setopt($ch, CURLOPT_STDERR, $verbose); // 执行请求 $response = curl_exec($ch); //HTTP状态码 $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); // 获取详细的cURL调试信息 rewind($verbose); //CURL调试信息 $verboseLog = stream_get_contents($verbose); fclose($verbose); // 分离头部和主体 $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); //响应头部 $header = substr($response, 0, $header_size); //响应主体 $body = substr($response, $header_size); // 检查CURL错误 $curlError = curl_error($ch); curl_close($ch); $responseData = json_decode($body, true); echo "
";
print_r($responseData);
echo "";die;
// 检查API是否返回了错误信息
if (isset($responseData['error'])) {
$errorMessage = isset($responseData['error']['message']) ? $responseData['error']['message'] : 'API请求失败';
return json([
'code' => 1,
'msg' => '视频生成请求失败',
'data' => [
'error_type' => isset($responseData['error']['type']) ? $responseData['error']['type'] : 'unknown',
'error_code' => isset($responseData['error']['code']) ? $responseData['error']['code'] : 'unknown',
'error_message' => $errorMessage
]
]);
}
// 检查是否有自定义错误格式
if (isset($responseData['code']) && $responseData['code'] === 'fail_to_fetch_task' && isset($responseData['message'])) {
return json([
'code' => 1,
'msg' => '视频生成请求失败',
'data' => [
'error_code' => $responseData['code'],
'error_message' => $responseData['message']
]
]);
}
// 检查是否存在id字段
if (!isset($responseData['id'])) {
return json([
'code' => 1,
'msg' => '无法获取视频ID',
'data' => [
'response_data' => $responseData,
'http_code' => $httpCode
]
]);
}
$videoData = [
'video_id' => $responseData['id'],
'prompt' => $postData['prompt'],
'model' => $postData['model'],
'seconds' => $postData['seconds'],
'size' => $postData['size'],
'sys_rq' => date("Y-m-d H:i:s")
];
// 尝试插入数据
try {
$res = Db::name('video')->insert($videoData);
return json([
'code' => 0,
'msg' => '视频正在生成中',
'data ' => [
'video_id' => $responseData['id'],
'insert_result' => $res
]
]);
} catch (Exception $e) {
return json([
'code' => 1,
'msg' => '数据库操作失败',
'data' => [
'error_message' => $e->getMessage()
]
]);
}
}
/**
* 获取视频内容
* 下载已完成的视频内容
*/
public function videoContent(){
// 从请求参数获取video_id,如果没有则使用默认值
$video_id = input('get.video_id');
$apiKey = '';
// 1. 先检查视频状态
$statusUrl = 'https://chatapi.onechats.ai/v1/videos/' . $video_id;
$statusData = $this->fetchVideoStatus($statusUrl, $apiKey);
// 检查视频状态
if ($statusData['status'] !== 'completed') {
return json([
'code' => 202,
'msg' => '视频尚未生成完成',
'data' => [
'video_id' => $video_id,
'status' => $statusData['status'],
'progress' => $statusData['progress'],
'created_at' => $statusData['created_at'],
'message' => '请稍后再试,视频仍在' . ($statusData['status'] === 'queued' ? '排队中' : '处理中')
]
]);
}
// 2. 视频生成完成,准备下载
$apiUrl = 'https://chatapi.onechats.ai/v1/videos/' . $video_id . '/content';
// 获取可选的variant参数
$variant = $this->request->get('variant', '');
if (!empty($variant)) {
$apiUrl .= '?variant=' . urlencode($variant);
}
// 创建保存目录
$saveDir = ROOT_PATH . 'public' . DS . 'uploads' . DS . 'videos' . DS . date('Ymd');
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
// 生成唯一文件名
$filename = $video_id . '.mp4';
$localPath = DS . 'uploads' . DS . 'videos' . DS . date('Ymd') . DS . $filename;
$fullPath = $saveDir . DS . $filename;
// 3. 下载视频
$videoData = $this->downloadVideo($apiUrl, $apiKey);
// 4. 保存视频文件
if (file_put_contents($fullPath, $videoData) === false) {
throw new Exception('视频保存失败');
}
// 确保路径使用正斜杠,并只保存相对路径部分
$localPath = str_replace('\\', '/', $localPath);
// 移除开头的斜杠,确保路径格式为uploads/videos/...
$savePath = ltrim($localPath, '/');
// 将正确格式的文件路径存入数据库
Db::name('video')->where('video_id', $video_id)->update([
'web_url' => $savePath
]);
// 返回成功响应
return json([
'code' => 0,
'msg' => '视频下载成功',
'data' => [
'video_id' => $video_id,
'local_path' => $localPath,
'web_url' => $savePath,
'file_size' => filesize($fullPath)
]
]);
}
/**
* 获取视频状态
*/
private function fetchVideoStatus($url, $apiKey) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new Exception('获取视频状态失败: ' . $error);
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new Exception('获取视频状态失败,HTTP状态码: ' . $httpCode);
}
$data = json_decode($response, true);
if (!is_array($data)) {
throw new Exception('视频状态数据格式错误');
}
return $data;
}
/**
* 下载视频文件
*/
private function downloadVideo($url, $apiKey) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new Exception('视频下载失败: ' . $error);
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new Exception('视频下载失败,HTTP状态码: ' . $httpCode);
}
return $response;
}
private function sendPostRequest($url, $data, $apiKey)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 60); // 延长超时时间
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return [
'response' => $response,
'http_code' => $httpCode,
'error' => $error
];
}
/**
* 查询模版
*/
public function product_template()
{
$params = $this->request->param();
if (!$this->request->isGet()) {
$this->error('请求方法错误');
}
$page = isset($params['page']) ? (int)$params['page'] : 1;
$limit = isset($params['limit']) ? (int)$params['limit'] : 30;
$where = [];
if (!empty($params['search'])) {
$where['id|chinese_description|template_name|style'] = ['like', '%' . $params['search'] . '%'];
}
// toexamine / release:用 isset + 非空串,避免 empty(0) 导致「未发布」无法筛选;release 建议库中为 0/1
if (isset($params['toexamine']) && $params['toexamine'] !== '') {
$where['toexamine'] = $params['toexamine'];
}
if (isset($params['release']) && $params['release'] !== '') {
$where['release'] = is_numeric($params['release']) ? (int) $params['release'] : $params['release'];
}
$isSuperAdmin = false;
if (!empty($params['sys_id']) && $params['sys_id'] == '超级管理员') {
$isSuperAdmin = true;
}
if (!$isSuperAdmin && !empty($params['sys_id'])) {
$where['sys_id'] = ['like', '%' . $params['sys_id'] . '%'];
$products = Db::name('product_template')->order('id desc')->where($where)
->whereNull('mod_rq')
->limit(($page - 1) * $limit, $limit)
->select();
}else{
$products = Db::name('product_template')->order('id desc')->where($where)
->whereNull('mod_rq')
->limit(($page - 1) * $limit, $limit)
->select();
}
$total = Db::name('product_template')->where($where)
->whereNull('mod_rq')
->count();
foreach ($products as &$item) {
if (!empty($item['template_image_url'])) {
$item['template_image_url'] = Common::ossFullUrl((string)$item['template_image_url']);
}
if (!empty($item['thumbnail_image'])) {
$item['thumbnail_image'] = Common::ossFullUrl((string)$item['thumbnail_image']);
}
}
unset($item);
return json([
'code' => 0,
'msg' => '请求成功',
'count' => $total,
'data' => $products
]);
}
/**
* 获取 AI 模型配置
* status 1 = 启用 0 = 禁用(同一模型内的优先级,数值越小越优先)
* model_type 支持多能力逗号间隔(如 文生图,图生图),传参精确匹配某一能力
* 可选参数:manage=1 时返回全部(含禁用),用于管理端
*/
public function GetAIModel(){
$params = $this->request->param();
$query = Db::name('ai_model');
if (empty($params['manage'])) {
$query->where('status', '1');
}
if (!empty($params['model_type'])) {
$query->whereRaw('FIND_IN_SET(:mt, model_type) > 0', ['mt' => trim($params['model_type'])]);
}
if (!empty($params['supplier'])) {
$query->where('supplier', 'like', '%' . $params['supplier'] . '%');
}
if (!empty($params['model_name'])) {
$query->where('model_name|model_alias', 'like', '%' . $params['model_name'] . '%');
}
$list = $query
->field('id,model_alias,model_group,model_name,model_type,sort,status,supplier')
->order('sort ASC, id ASC')->select();
return json([
'code' => 0,
'msg' => '成功',
'data' => $list
]);
}
/**
* 新增 AI 模型配置
* POST: status, supplier, api_key, api_url, model_group, model_name, model_alias, model_type, sort
* model_type 多能力用逗号间隔,如:文生图,图生图
*/
public function AddAIModel(){
$params = $this->request->param();
$required = ['api_url', 'api_key', 'model_name', 'model_type'];
foreach ($required as $k) {
if (empty(trim($params[$k] ?? ''))) {
return json(['code' => 1, 'msg' => $k . ' 不能为空']);
}
}
$insert = [
'status' => $params['status'],
'supplier' => trim($params['supplier']),
'api_key' => trim($params['api_key']),
'api_url' => trim($params['api_url']),
'model_group' => trim($params['model_group']),
'model_name' => trim($params['model_name']),
'model_alias' => trim($params['model_alias']),
'model_type' => trim($params['model_type']),
'sort' => isset($params['sort']) ? intval($params['sort']) : 0,
];
try {
Db::name('ai_model')->insert($insert);
return json(['code' => 0, 'msg' => '新增成功']);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => '新增失败: ' . $e->getMessage()]);
}
}
/**
* 修改 AI 模型配置
* POST: id(必填), 其余字段同新增
*/
public function UpdateAIModel(){
$params = $this->request->param();
if (empty($params['id'])) {
return json(['code' => 1, 'msg' => 'id 不能为空']);
}
$id = intval($params['id']);
$exists = Db::name('ai_model')->where('id', $id)->find();
if (!$exists) {
return json(['code' => 1, 'msg' => '记录不存在']);
}
$update = [];
$fields = ['status', 'supplier', 'api_key', 'api_url', 'model_group', 'model_name', 'model_alias', 'model_type', 'sort'];
foreach ($fields as $f) {
if (array_key_exists($f, $params)) {
$update[$f] = $f === 'sort' ? intval($params[$f]) : trim($params[$f] ?? '');
}
}
if (empty($update)) {
return json(['code' => 1, 'msg' => '无有效修改字段']);
}
try {
Db::name('ai_model')->where('id', $id)->update($update);
return json(['code' => 0, 'msg' => '修改成功']);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => '修改失败: ' . $e->getMessage()]);
}
}
/**
* 用于获取所有产品记录
**/
public function GetProductList(){
$params = $this->request->param();
$page = max(1, intval($params['page'] ?? 1));
$pageSize = min(100, max(1, intval($params['limit'] ?? 30)));
// 构建查询条件
$where = [];
if (!empty($params['search'])) {
$where['prompt|model'] = ['like', '%' . $params['search'] . '%'];
}
if (!empty($params['sys_id'])) {
$where['sys_id'] = ['like', '%' . $params['sys_id'] . '%'];
}
$prompt = Db::name('product_image_generate')->field('prompt')->where($where)->group('prompt')->order('id desc')->select();
$data = Db::name('product_image_generate')->where($where)->order('id desc')->page($page, $pageSize)->select();
$count = Db::name('product_image_generate')->field('prompt')->where($where)->order('id desc')->count();
foreach ($data as &$item) {
if (!empty($item['generated_image']) || !empty($item['product_img']) || !empty($item['reference_image'])) {
$item['generated_image'] = Common::ossFullUrl((string)$item['generated_image']);
$item['product_img'] = Common::ossFullUrl((string)$item['product_img']);
$item['reference_image'] = Common::ossFullUrl((string)$item['reference_image']);
}
}
unset($item);
return json([
'code' => 0,
'msg' => '成功',
'count' => $count,
'prompt' => $prompt,
'data' => $data
]);
}
/**
* 查询队列列表
* 统计文件对应的队列情况
*/
public function get_queue_logs()
{
$params = $this->request->param('old_image_file', '');
$queue_logs = Db::name('queue_logs')
->where('old_image_file', $params)
->order('id desc')
->select();
$result = []; //初始化变量,避免未定义错误
foreach ($queue_logs as &$log) {
$taskId = $log['id'];
$statusCount = Db::name('image_task_log')
->field('status, COUNT(*) as count')
->where('task_id', $taskId)
->where('mod_rq', null)
->group('status')
->select();
$log['已完成数量'] = 0;
$log['处理中数量'] = 0;
$log['排队中的数量'] = 0;
$log['失败数量'] = 0;
foreach ($statusCount as $item) {
switch ($item['status']) {
case 0:
$log['排队中的数量'] = $item['count'];
break;
case 1:
$log['处理中数量'] = $item['count'];
break;
case 2:
$log['已完成数量'] = $item['count'];
break;
case -1:
$log['失败数量'] = $item['count'];
break;
}
}
// if ($log['排队中的数量'] >$log['已完成数量']) {
// $result[] = $log;
// }
if ($log['排队中的数量']) {
$result[] = $log;
}
// if ($log['处理中数量'] >= 0) {
// $result[] = $log;
// }
}
return json([
'code' => 0,
'msg' => '查询成功',
'data' => $result,
'count' => count($result)
]);
}
/**
* 查询总队列状态(统计当前处理的数据量)
*/
public function queueStats()
{
$statusList = Db::name('image_task_log')
->field('status, COUNT(*) as total')
->where('mod_rq', null)
->where('create_time', '>=', date('Y-m-d 00:00:00'))
->group('status')
->select();
$statusCount = [];
foreach ($statusList as $item) {
$statusCount[$item['status']] = $item['total'];
}
// 总数为所有状态和
$total = array_sum($statusCount);
//获取队列当前状态
$statusText = Db::name('queue_logs')->order('id desc')->value('status');
return json([
'code' => 0,
'msg' => '获取成功',
'data' => [
'总任务数' => $total,
'待处理' => $statusCount[0] ?? 0,
'处理中' => $statusCount[1] ?? 0,
'成功' => $statusCount[2] ?? 0,
'失败' => $statusCount[-1] ?? 0,
'当前状态' => $statusText
]
]);
}
/**
* 获取 Redis 连接实例
* @return \Redis|null Redis 实例或 null(如果连接失败)
*/
private function getRedisConnection()
{
if (!class_exists('\Redis')) {
return null;
}
return getTaskRedis();
}
/**
* 显示当前运行中的队列监听进程
*/
public function viewQueueStatus()
{
$redis = $this->getRedisConnection();
if (!$redis) {
return json([
'code' => 1,
'msg' => 'Redis扩展未安装或未启用',
'data' => null
]);
}
$key = 'queues:imgtotxt';
// 判断 key 是否存在,避免报错
if (!$redis->exists($key)) {
return json([
'code' => 0,
'msg' => '查询成功,队列为空',
'count' => 0,
'tasks_preview' => []
]);
}
$count = $redis->lLen($key);
$list = $redis->lRange($key, 0, 9);
// 解码 JSON 内容,确保每一项都有效
$parsed = array_filter(array_map(function ($item) {
return json_decode($item, true);
}, $list), function ($item) {
return !is_null($item);
});
return json([
'code' => 0,
'msg' => '查询成功',
'count' => $count,
'tasks_preview' => $parsed
]);
}
/**
* 清空队列并删除队列日志记录
*/
public function stopQueueProcesses()
{
Db::name('image_task_log')
->where('log', '队列中')
->whereOr('status', 1)
->where('create_time', '>=', date('Y-m-d 00:00:00'))
->update([
'status' => "-1",
'log' => '清空取消队列',
'mod_rq' => date('Y-m-d H:i:s')
]);
Db::name('image_task_log')
->whereLike('log', '%处理中%')
->where('create_time', '>=', date('Y-m-d 00:00:00'))
->update([
'status' => "-1",
'log' => '清空取消队列',
'mod_rq' => date('Y-m-d H:i:s')
]);
$redis = $this->getRedisConnection();
if (!$redis) {
return json([
'code' => 1,
'msg' => 'Redis扩展未安装或未启用',
'data' => null
]);
}
$key_txttoimg = 'queues:txttoimg:reserved';
$key_txttotxt = 'queues:txttotxt:reserved';
$key_imgtotxt = 'queues:imgtotxt:reserved';
$key_imgtoimg = 'queues:imgtoimg:reserved';
// 清空 Redis 队列
$redis->del($key_txttoimg);
$redis->del($key_txttotxt);
$redis->del($key_imgtotxt);
$redis->del($key_imgtoimg);
$count = $redis->lLen($key_txttoimg) + $redis->lLen($key_txttotxt) + $redis->lLen($key_imgtotxt) + $redis->lLen($key_imgtoimg);
return json([
'code' => 0,
'msg' => '成功停止队列任务'
]);
}
/**
*获取服务器URL地址和端口 IP地址:端口
* 用于获取图片路径拼接时
**/
public function GetHttpUrl(){
$data = Db::name('http_url')->find();
$fullUrl = "http://" . $data['baseUrl'] . ":" . $data['port'];
$res = [
'code' => 0,
'msg' => '成功',
'data' => [
'id' => $data['id'],
'full_url' => $fullUrl,
'baseUrl' => $data['baseUrl'],
'port' => $data['port']
]
];
return json($res);
}
/**
* 构建图生视频 content:单帧无 role,双帧带 first_frame / last_frame
*/
private function buildImgToVideoContent(string $prompt, string $firstImageUrl, string $lastImageUrl): array
{
$content = [
['type' => 'text', 'text' => $prompt],
];
if ($lastImageUrl !== '') {
$content[] = [
'type' => 'image_url',
'image_url' => ['url' => $firstImageUrl],
'role' => 'first_frame',
];
$content[] = [
'type' => 'image_url',
'image_url' => ['url' => $lastImageUrl],
'role' => 'last_frame',
];
return $content;
}
$content[] = [
'type' => 'image_url',
'image_url' => ['url' => $firstImageUrl],
];
return $content;
}
/** 图生视频创建前,帧图暂存 OSS 子目录(AI 返回 id 后会迁移到正式目录) */
private const FRAME_OSS_STAGING_ID = '_staging';
/**
* 任务 id 用于路径的安全片段
*/
private function sanitizeTaskIdSegment(string $taskId): string
{
$segment = preg_replace('/[\\\\\/:*?"<>|]/u', '_', trim($taskId));
return $segment !== '' ? $segment : '_unknown';
}
/**
* 图生视频任务媒体本地目录:public/uploads/videos/{日期}/{taskId}/
*/
private function buildTaskMediaLocalDir(string $taskId): string
{
return str_replace('\\', '/', ROOT_PATH . 'public/uploads/videos/' . date('Ymd') . '/' . $this->sanitizeTaskIdSegment($taskId) . '/');
}
/**
* 图生视频任务媒体 OSS 对象键:uploads/videos/{日期}/{taskId}/{文件名}(帧图与生成视频同目录)
*/
private function buildTaskMediaObjectKey(string $taskId, string $fileName): string
{
return 'uploads/videos/' . date('Ymd') . '/' . $this->sanitizeTaskIdSegment($taskId) . '/' . ltrim($fileName, '/');
}
/**
* 解析帧图片(含本地路径与暂存 OSS 信息,供创建任务后按 AI id 归档)
* @return array{type:string,api_url:string,local_path:string,staging_object_key:string}
*/
private function resolveFrameImagePayload(array $params, string $role, string &$error = ''): array
{
$empty = ['type' => 'empty', 'api_url' => '', 'local_path' => '', 'staging_object_key' => ''];
$prefix = $role === 'last' ? 'last' : 'first';
$error = '';
$taskId = trim((string)($params['task_id'] ?? $params['video_id'] ?? ''));
$ossTaskId = $taskId !== '' ? $taskId : self::FRAME_OSS_STAGING_ID;
$uploadedFile = $this->request->file("{$prefix}_image");
if (!empty($uploadedFile)) {
$fileList = is_array($uploadedFile) ? $uploadedFile : [$uploadedFile];
foreach ($fileList as $file) {
if (!$file) {
continue;
}
$fileError = '';
$result = $this->uploadFrameFileToOss($file, $prefix, $fileError, $ossTaskId);
if (($result['url'] ?? '') !== '') {
return [
'type' => 'local',
'api_url' => $result['url'],
'local_path' => $result['local_path'] ?? '',
'staging_object_key' => $result['object_key'] ?? '',
];
}
if ($fileError !== '') {
$error = $fileError;
}
}
}
$base64Keys = ["{$prefix}_image", "{$prefix}_image_base64"];
foreach ($base64Keys as $key) {
if (empty($params[$key]) || !is_string($params[$key])) {
continue;
}
$raw = trim($params[$key]);
if ($raw === '' || strlen($raw) < 50) {
continue;
}
$result = $this->uploadBase64ImageToOss($raw, $prefix, $ossTaskId);
if (($result['url'] ?? '') !== '') {
return [
'type' => 'local',
'api_url' => $result['url'],
'local_path' => $result['local_path'] ?? '',
'staging_object_key' => $result['object_key'] ?? '',
];
}
}
$urlKey = "{$prefix}_image_url";
if (!empty($params[$urlKey])) {
$url = trim((string)$params[$urlKey]);
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
return ['type' => 'external', 'api_url' => $url, 'local_path' => '', 'staging_object_key' => ''];
}
}
return $empty;
}
/**
* AI 返回任务 id 后,将本地帧图上传到 uploads/videos/{日期}/{taskId}/(与生成视频同目录)
*/
private function finalizeFrameImageToOss(array $payload, string $taskId): string
{
if (($payload['type'] ?? '') === 'external') {
return (string)($payload['api_url'] ?? '');
}
if (($payload['type'] ?? '') !== 'local') {
return '';
}
$localPath = (string)($payload['local_path'] ?? '');
if ($localPath === '' || !is_file($localPath)) {
return (string)($payload['api_url'] ?? '');
}
$objectKey = $this->buildTaskMediaObjectKey($taskId, basename($localPath));
$stagingKey = (string)($payload['staging_object_key'] ?? '');
if ($stagingKey !== '' && $stagingKey === $objectKey) {
return (string)($payload['api_url'] ?? '') ?: Common::ossFullUrl($objectKey);
}
$upload = $this->uploadToOSS($localPath, $objectKey);
if (!$upload['success']) {
Log::write('[finalizeFrameImageToOss] OSS上传失败: ' . $objectKey, 'error');
return (string)($payload['api_url'] ?? '');
}
if ($stagingKey !== '' && $stagingKey !== $objectKey) {
Common::deleteOssObject($stagingKey);
}
return $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
}
/**
* 解析帧图片:form-data 文件 > base64 > http(s) URL
*/
private function resolveFrameImageUrl(array $params, string $role, string &$error = ''): string
{
$payload = $this->resolveFrameImagePayload($params, $role, $error);
return (string)($payload['api_url'] ?? '');
}
/**
* form-data 上传的图片落盘并同步 OSS
* @return array{url:string,local_path:string,object_key:string}
*/
private function uploadFrameFileToOss($file, string $roleLabel, string &$error = '', string $taskId = ''): array
{
$empty = ['url' => '', 'local_path' => '', 'object_key' => ''];
if ($taskId === '') {
$taskId = self::FRAME_OSS_STAGING_ID;
}
$error = '';
$ext = $this->resolveUploadedImageExt($file);
if ($ext === '') {
$error = '不支持的图片格式';
Log::write('[uploadFrameFileToOss] ' . $error, 'error');
return $empty;
}
$uploadInfo = $file->getInfo();
$tmpPath = isset($uploadInfo['tmp_name']) ? (string)$uploadInfo['tmp_name'] : '';
if ($tmpPath === '' || !is_file($tmpPath)) {
$tmpPath = (string)($file->getRealPath() ?: '');
}
if ($tmpPath === '' || !is_file($tmpPath)) {
$uploadErr = $uploadInfo['error'] ?? UPLOAD_ERR_NO_FILE;
$error = '未接收到上传文件(错误码' . $uploadErr . ')';
Log::write('[uploadFrameFileToOss] ' . $error, 'error');
return $empty;
}
$saveDir = $this->buildTaskMediaLocalDir($taskId);
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$saveFileName = $roleLabel . '_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
$localFullPath = $saveDir . $saveFileName;
$saved = false;
if (method_exists($file, 'isValid') && $file->isValid()) {
$info = $file->move($saveDir, $saveFileName);
if ($info) {
$localFullPath = $saveDir . $info->getFilename();
$saved = true;
}
}
if (!$saved && !@copy($tmpPath, $localFullPath)) {
$moveErr = method_exists($file, 'getError') ? (string)$file->getError() : 'copy失败';
$error = '图片保存失败: ' . $moveErr;
Log::write('[uploadFrameFileToOss] ' . $error, 'error');
return $empty;
}
$objectKey = $this->buildTaskMediaObjectKey($taskId, basename($localFullPath));
$upload = $this->uploadToOSS($localFullPath, $objectKey);
if (!$upload['success']) {
$error = 'OSS上传失败';
Log::write('[uploadFrameFileToOss] ' . $error . ' | ' . $objectKey, 'error');
return $empty;
}
$url = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
return [
'url' => $url,
'local_path' => $localFullPath,
'object_key' => $upload['object_key'] ?? $objectKey,
];
}
/**
* 解析上传图片扩展名
*/
private function resolveUploadedImageExt($file): string
{
$name = $file->getInfo('name') ?? '';
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($ext === 'jpeg') {
$ext = 'jpg';
}
$allowed = ['jpg', 'png', 'gif', 'webp', 'bmp'];
if ($ext && in_array($ext, $allowed, true)) {
return $ext;
}
$mime = method_exists($file, 'getMime') ? strtolower((string)$file->getMime()) : '';
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp' => 'bmp',
];
return $map[$mime] ?? '';
}
/**
* base64 图片落盘并上传 OSS
* @return array{url:string,local_path:string,object_key:string}
*/
private function uploadBase64ImageToOss(string $base64Input, string $roleLabel, string $taskId = ''): array
{
$empty = ['url' => '', 'local_path' => '', 'object_key' => ''];
if ($taskId === '') {
$taskId = self::FRAME_OSS_STAGING_ID;
}
$parsed = $this->parseBase64Image($base64Input);
if ($parsed === null) {
return $empty;
}
[$ext, $imageData] = $parsed;
$saveDir = $this->buildTaskMediaLocalDir($taskId);
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$fileName = $roleLabel . '_' . str_replace('.', '', uniqid('', true)) . '.' . $ext;
$localFullPath = $saveDir . $fileName;
if (file_put_contents($localFullPath, $imageData) === false) {
Log::write('[uploadBase64ImageToOss] 本地保存失败: ' . $localFullPath, 'error');
return $empty;
}
$objectKey = $this->buildTaskMediaObjectKey($taskId, $fileName);
$upload = $this->uploadToOSS($localFullPath, $objectKey);
if (!$upload['success']) {
Log::write('[uploadBase64ImageToOss] OSS上传失败: ' . $objectKey, 'error');
return $empty;
}
$url = $upload['url'] !== '' ? $upload['url'] : Common::ossFullUrl($objectKey);
return [
'url' => $url,
'local_path' => $localFullPath,
'object_key' => $upload['object_key'] ?? $objectKey,
];
}
/**
* 解析 base64 图片(支持 data URI 与纯 base64)
* @return array{0:string,1:string}|null [扩展名, 二进制内容]
*/
private function parseBase64Image(string $base64Input): ?array
{
$base64Input = trim($base64Input);
if ($base64Input === '') {
return null;
}
if (preg_match('/^data:image\/(png|jpe?g|webp);base64,(.+)$/is', $base64Input, $m)) {
$ext = strtolower($m[1]) === 'jpeg' ? 'jpg' : strtolower($m[1]);
$raw = preg_replace('/\s+/', '', $m[2]);
$imageData = base64_decode($raw, true);
} else {
$raw = preg_replace('/\s+/', '', $base64Input);
$imageData = base64_decode($raw, true);
$ext = $imageData !== false ? $this->detectImageExtension($imageData) : '';
}
if ($imageData === false || strlen($imageData) < 100 || $ext === '') {
return null;
}
return [$ext, $imageData];
}
private function detectImageExtension(string $imageData): string
{
if (strncmp($imageData, "\x89PNG\r\n\x1a\n", 8) === 0) {
return 'png';
}
if (strncmp($imageData, "\xFF\xD8\xFF", 3) === 0) {
return 'jpg';
}
if (strlen($imageData) >= 12 && substr($imageData, 0, 4) === 'RIFF' && substr($imageData, 8, 4) === 'WEBP') {
return 'webp';
}
return 'jpg';
}
/**
* 将本地文件上传到阿里云 OSS
*
* @param string $file 本地完整路径,或 public 下相对路径(如 uploads/videos/20260604/xxx.mp4)
* @param string $objectKey OSS 对象键;传 uploads/ 开头或带扩展名则视为完整键,否则作为子目录标识(如 task_id)
* @return array{success:bool,object_key:string,url:string}
*/
private function uploadToOSS(string $file, string $objectKey = ''): array
{
$localFullPath = $this->resolvePublicLocalPath($file);
if ($localFullPath === '') {
Log::write('[uploadToOSS] 本地文件不存在: ' . $file, 'error');
return ['success' => false, 'object_key' => '', 'url' => ''];
}
$fileName = basename($localFullPath);
if ($objectKey !== '' && (strpos($objectKey, 'uploads/') === 0 || preg_match('/\.[a-z0-9]{1,8}$/i', $objectKey))) {
$ossObjectKey = Common::normalizeOssObjectKey($objectKey);
} else {
$segments = ['uploads', 'videos', date('Ymd')];
if ($objectKey !== '') {
$segments[] = preg_replace('/[\\\\\/:*?"<>|]/u', '_', $objectKey);
}
$segments[] = $fileName;
$ossObjectKey = implode('/', $segments);
}
$success = $this->uploadLocalFileToAliyunOss($localFullPath, $ossObjectKey);
return [
'success' => $success,
'object_key' => $ossObjectKey,
'url' => $success ? Common::ossFullUrl($ossObjectKey) : '',
];
}
/**
* 上传本地文件到阿里云 OSS(仅 WorkOrder 内实现,不修改 Common)
*/
private function uploadLocalFileToAliyunOss(string $localFullPath, string $objectKey): bool
{
if (!Common::isOssEnabled() || !is_file($localFullPath)) {
return false;
}
$objectKey = Common::normalizeOssObjectKey($objectKey);
if ($objectKey === '') {
return false;
}
if (class_exists(\OSS\OssClient::class, true)) {
try {
$config = Common::getOssConfig();
$client = new \OSS\OssClient(
$config['accessKeyId'],
$config['accessKeySecret'],
$config['endpoint']
);
$client->uploadFile($config['bucket'], $objectKey, $localFullPath);
return true;
} catch (\Throwable $e) {
Log::write('[uploadLocalFileToAliyunOss SDK] ' . $e->getMessage() . ' | ' . $objectKey, 'error');
}
}
return $this->putLocalFileToAliyunOssByCurl($localFullPath, $objectKey);
}
/**
* 无 OSS SDK 时通过 REST PUT 上传
*/
private function putLocalFileToAliyunOssByCurl(string $localFullPath, string $objectKey): bool
{
$config = Common::getOssConfig();
$content = file_get_contents($localFullPath);
if ($content === false) {
return false;
}
$mime = function_exists('mime_content_type') ? (mime_content_type($localFullPath) ?: '') : '';
if ($mime === '') {
$ext = strtolower(pathinfo($localFullPath, PATHINFO_EXTENSION));
$mimeMap = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'mp4' => 'video/mp4',
];
$mime = $mimeMap[$ext] ?? 'application/octet-stream';
}
$date = gmdate('D, d M Y H:i:s \G\M\T');
$bucket = $config['bucket'];
$endpoint = ltrim((string)$config['endpoint'], 'https://');
$endpoint = ltrim($endpoint, 'http://');
$canonicalizedResource = '/' . $bucket . '/' . $objectKey;
$stringToSign = "PUT\n\n{$mime}\n{$date}\n{$canonicalizedResource}";
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $config['accessKeySecret'], true));
$urlPath = implode('/', array_map('rawurlencode', explode('/', $objectKey)));
$url = 'https://' . $bucket . '.' . $endpoint . '/' . $urlPath;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Date: ' . $date,
'Content-Type: ' . $mime,
'Authorization: OSS ' . $config['accessKeyId'] . ':' . $signature,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
return true;
}
Log::write(
'[putLocalFileToAliyunOssByCurl] http=' . $httpCode
. ' err=' . $curlError
. ' resp=' . substr((string)$response, 0, 500)
. ' | objectKey=' . $objectKey,
'error'
);
return false;
}
/**
* 解析为 public 目录下的本地绝对路径
*/
private function resolvePublicLocalPath(string $file): string
{
$file = str_replace('\\', '/', trim($file));
if ($file === '') {
return '';
}
if (is_file($file)) {
return $file;
}
$publicPath = str_replace('\\', '/', ROOT_PATH . 'public/' . ltrim($file, '/'));
return is_file($publicPath) ? $publicPath : '';
}
}