request->param('token', '')); if (!hash_equals($token, $q)) { return json(['code' => -1, 'msg' => 'forbidden']); } } $raw = $this->request->getContent(); $json = json_decode($raw, true); if (!is_array($json)) { Log::warning('TRTC recording callback: invalid json', ['raw' => substr($raw, 0, 500)]); return json(['code' => 0, 'msg' => 'ok']); } $eventType = (int)($json['EventType'] ?? 0); $roomId = $this->extractRoomId($json); $taskId = trim((string)data_get($json, 'EventInfo.TaskId', '')); $payload = data_get($json, 'EventInfo.Payload'); if (!is_array($payload)) { $payload = []; } Log::info('TRTC callback recv', [ 'EventType' => $eventType, 'roomId' => $roomId, 'TaskId' => $taskId, 'PayloadKeys' => implode(',', array_keys($payload)), ]); // 0. 提前查找 call_record(COS 310 拼 URL 需要它的 diagnosis_id + id 来还原前缀) $record = $this->resolveCallRecordForNotify($roomId, $taskId); // 1. 尝试提取 HTTP URL(VOD 311 等) $urls = $this->extractRecordingUrls($json); // 2. COS 存储:EventType=310 MP4 上传完成,从文件名拼接 COS URL if ($urls === [] && $eventType === self::ET_MP4_STOP) { $cosPrefix = $this->resolveRecordingCosPrefix($record); $urls = $this->buildCosUrlsFromPayload($payload, $taskId, $cosPrefix); if ($urls === []) { Log::warning('TRTC 310 (COS MP4) 未解析到文件', [ 'Status' => $payload['Status'] ?? null, 'FileList' => json_encode($payload['FileList'] ?? null, JSON_UNESCAPED_UNICODE), 'FileMessage' => json_encode($payload['FileMessage'] ?? null, JSON_UNESCAPED_UNICODE), 'TaskId' => $taskId, 'roomId' => $roomId, 'cosPrefix' => $cosPrefix, ]); } } // 2b. COS HLS 兜底:305(上传结束) 时用同样的前缀 if ($urls === [] && $eventType === self::ET_UPLOAD_STOP) { $cosPrefix = $this->resolveRecordingCosPrefix($record); $hlsUrls = $this->buildCosHlsUrlFromPayload($payload, $taskId, $roomId, $cosPrefix); if ($hlsUrls !== []) { $urls = $hlsUrls; } } // 3. 311 但无 URL(Status!=0 或字段变更) if ($urls === [] && $eventType === self::ET_VOD_COMMIT) { Log::warning('TRTC 311 (VOD) 未解析到 VideoUrl', [ 'Status' => $payload['Status'] ?? null, 'Errmsg' => $payload['Errmsg'] ?? $payload['ErrMsg'] ?? null, 'TaskId' => $taskId, 'roomId' => $roomId, ]); } // 4. 无 URL:进度/状态事件,正常跳过 if ($urls === []) { if (!in_array($eventType, self::PROGRESS_EVENTS, true)) { Log::info('TRTC callback: no url', [ 'EventType' => $eventType, 'TaskId' => $taskId, 'roomId' => $roomId, ]); } return json(['code' => 0, 'msg' => 'ok']); } // 5. 写入 call_record if (!$record) { Log::warning('TRTC recording: no call_record', [ 'roomId' => $roomId, 'TaskId' => $taskId, 'EventType' => $eventType, 'urls' => json_encode($urls, JSON_UNESCAPED_UNICODE), ]); return json(['code' => 0, 'msg' => 'ok']); } $prev = []; if (!empty($record->recording_urls)) { $prev = json_decode((string)$record->recording_urls, true); if (!is_array($prev)) { $prev = []; } } $merged = array_values(array_unique(array_merge($prev, $urls))); $merged = $this->mirrorRecordingUrlsIfEnabled($merged); $record->save([ 'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE), 'recording_status' => 2, 'update_time' => time(), ]); Log::info('TRTC recording saved', [ 'id' => $record->id, 'EventType' => $eventType, 'roomId' => $roomId, 'newUrls' => count($urls), 'totalUrls' => count($merged), ]); return json(['code' => 0, 'msg' => 'ok']); } /* ------------------------------------------------------------------ */ /* COS 前缀还原 */ /* ------------------------------------------------------------------ */ /** * 还原 CreateCloudRecording 时使用的 FileNamePrefix。 * 录制启动时前缀为 mix_{diagnosisId}_{callRecordId},回调时需还原。 */ private function resolveRecordingCosPrefix(?CallRecord $record): string { if ($record && !empty($record->diagnosis_id) && !empty($record->id)) { return 'mix_' . (int)$record->diagnosis_id . '_' . (int)$record->id; } return $this->cosConfig('recording_cos_prefix', 'trtc-recording'); } /* ------------------------------------------------------------------ */ /* COS 配置读取(config() 优先,env() 兜底,兼容 config 文件未同步部署) */ /* ------------------------------------------------------------------ */ private function cosConfig(string $key, string $default = ''): string { $val = trim((string)config("trtc.{$key}", '')); if ($val !== '') { return $val; } return trim((string)env("trtc.{$key}", $default)); } private function cosBucket(): string { return $this->cosConfig('recording_cos_bucket'); } private function cosRegion(): string { $r = $this->cosConfig('recording_cos_region'); return $r !== '' ? $r : $this->cosConfig('recording_api_region', 'ap-guangzhou'); } private function cosPrefix(): string { return $this->cosConfig('recording_cos_prefix', 'trtc-recording'); } private function cosPrefixParts(): array { $raw = $this->cosPrefix(); return $raw !== '' ? explode('/', rtrim($raw, '/')) : []; } /* ------------------------------------------------------------------ */ /* COS 文件 URL 拼接 */ /* ------------------------------------------------------------------ */ /** * EventType=310 的 Payload 中提取文件名,拼接完整 COS 下载 URL。 * * COS 路径格式:{FileNamePrefix}/{TaskId}/{FileName} * URL:https://{Bucket}.cos.{Region}.myqcloud.com/{path} * * @return list */ private function buildCosUrlsFromPayload(array $payload, string $taskId, string $prefix): array { $status = (int)($payload['Status'] ?? -1); if ($status === 2 || $status === -1) { return []; } $fileNames = []; if (!empty($payload['FileMessage']) && is_array($payload['FileMessage'])) { foreach ($payload['FileMessage'] as $fm) { if (is_array($fm) && !empty($fm['FileName'])) { $fileNames[] = trim((string)$fm['FileName']); } } } if ($fileNames === [] && !empty($payload['FileList'])) { $fl = $payload['FileList']; if (is_array($fl)) { foreach ($fl as $f) { if (is_string($f) && trim($f) !== '') { $fileNames[] = trim($f); } } } elseif (is_string($fl) && trim($fl) !== '') { $fileNames[] = trim($fl); } } if ($fileNames === []) { return []; } $bucket = $this->cosBucket(); $region = $this->cosRegion(); if ($bucket === '') { Log::warning('TRTC 310: COS bucket 未配置,无法拼接下载 URL', ['files' => implode(', ', $fileNames)]); return []; } $prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : []; $baseUrl = "https://{$bucket}.cos.{$region}.myqcloud.com"; $urls = []; foreach ($fileNames as $fn) { $parts = array_merge($prefixParts, [$taskId, $fn]); $path = implode('/', array_filter($parts, fn($p) => $p !== '')); $urls[] = $baseUrl . '/' . $path; } return $urls; } /** * COS HLS 兜底:OutputFormat=0(hls) 时不会有 310 事件。 * 305(UPLOAD_STOP) 后用录制的 m3u8 文件名拼 COS URL。 */ private function buildCosHlsUrlFromPayload(array $payload, string $taskId, string $roomId, string $prefix): array { $status = (int)($payload['Status'] ?? -1); if ($status !== 0) { return []; } $bucket = $this->cosBucket(); if ($bucket === '') { return []; } $region = $this->cosRegion(); $prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : []; $sdkAppId = (int)(config('trtc.sdkAppId', 0) ?: env('trtc.sdk_app_id', 0)); $m3u8Name = "{$sdkAppId}_{$roomId}.m3u8"; $parts = array_merge($prefixParts, [$taskId, $m3u8Name]); $path = implode('/', array_filter($parts, fn($p) => $p !== '')); $url = "https://{$bucket}.cos.{$region}.myqcloud.com/{$path}"; Log::info('TRTC 305 HLS fallback', [ 'roomId' => $roomId, 'TaskId' => $taskId, 'url' => $url, ]); return [$url]; } /* ------------------------------------------------------------------ */ /* 字段提取 */ /* ------------------------------------------------------------------ */ private function extractRoomId(array $data): string { $candidates = [ data_get($data, 'EventInfo.RoomId'), data_get($data, 'EventInfo.RoomIdStr'), data_get($data, 'EventInfo.StrRoomId'), data_get($data, 'EventInfo.Payload.RoomId'), data_get($data, 'EventInfo.Payload.RoomIdStr'), data_get($data, 'RoomId'), data_get($data, 'room_id'), ]; foreach ($candidates as $v) { if ($v !== null && $v !== '') { $s = trim((string)$v); if ($s !== '' && $s !== '0') { return $s; } } } return ''; } private function resolveCallRecordForNotify(string $roomId, string $taskId): ?CallRecord { if ($roomId !== '') { $record = CallRecord::where('room_id', $roomId)->order('id', 'desc')->find(); if ($record) { return $record; } if (ctype_digit($roomId)) { $norm = (string)(int)$roomId; $record = CallRecord::where('room_id', $norm)->order('id', 'desc')->find(); if ($record) { return $record; } } } if ($taskId !== '') { $record = CallRecord::where('cloud_recording_task_id', $taskId)->order('id', 'desc')->find(); if ($record) { return $record; } } return null; } /** * 从回调 JSON 中递归提取 HTTP URL(适用于 VOD 311 等含 VideoUrl/MediaUrl 的事件) */ private function extractRecordingUrls(array $data): array { $urls = []; $this->walkForUrls($data, $urls); return array_values(array_unique(array_filter($urls))); } private function walkForUrls($node, array &$urls): void { if (!is_array($node)) { return; } foreach ($node as $key => $val) { $keyLower = is_string($key) ? strtolower($key) : ''; if (in_array($keyLower, ['videourl', 'fileurl', 'mediaurl', 'url', 'streamurl', 'playurl'], true) && is_string($val)) { $v = trim($val); if ($v !== '' && preg_match('#^https?://#i', $v) === 1) { $urls[] = $v; } } if (is_array($val)) { $this->walkForUrls($val, $urls); } } } /* ------------------------------------------------------------------ */ /* 可选:镜像录制文件到本服务器 */ /* ------------------------------------------------------------------ */ private function mirrorRecordingUrlsIfEnabled(array $urls): array { if (!(int)config('trtc.recording_mirror_to_storage', 0)) { return $urls; } $out = []; foreach ($urls as $u) { $u = trim((string)$u); if ($u === '' || strncmp($u, 'http', 4) !== 0) { $out[] = $u; continue; } $local = $this->downloadRecordingToPublic($u); $out[] = $local !== null ? FileService::getFileUrl($local) : $u; } return $out; } private function downloadRecordingToPublic(string $url): ?string { $dirRel = 'uploads/video/trtc_recording/' . date('Ymd'); $dirAbs = public_path() . $dirRel; if (!is_dir($dirAbs) && !mkdir($dirAbs, 0755, true) && !is_dir($dirAbs)) { Log::warning('TRTC mirror: mkdir failed', ['dir' => $dirAbs]); return null; } $ext = '.mp4'; if (preg_match('/\.([a-z0-9]+)(\?|#|$)/i', $url, $m)) { $ext = '.' . strtolower($m[1]); } $name = 'trtc_' . date('His') . '_' . bin2hex(random_bytes(4)) . $ext; $target = $dirAbs . DIRECTORY_SEPARATOR . $name; $ctx = stream_context_create([ 'http' => ['timeout' => 600], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], ]); try { $src = @fopen($url, 'rb', false, $ctx); if ($src === false) { return null; } $dst = @fopen($target, 'wb'); if ($dst === false) { fclose($src); return null; } stream_copy_to_stream($src, $dst); fclose($src); fclose($dst); } catch (\Throwable $e) { Log::warning('TRTC mirror download failed: ' . $e->getMessage()); return null; } if (!is_file($target) || filesize($target) < 1) { return null; } return str_replace('\\', '/', $dirRel . '/' . $name); } }