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', '')); $urls = $this->extractRecordingUrls($json); if ($urls === []) { // 301–308 等为进度事件,无播放地址;311=VOD 上传完成带 VideoUrl(见文档 81113) $mayHaveUrl = in_array($eventType, [309, 310, 311], true); $payload = data_get($json, 'EventInfo.Payload'); if ($urls === [] && $eventType === 311) { Log::warning('TRTC recording callback: 311 但未解析到 VideoUrl(可能 Status!=0 或字段名变更)', [ 'PayloadStatus' => is_array($payload) ? ($payload['Status'] ?? null) : null, 'Errmsg' => is_array($payload) ? ($payload['Errmsg'] ?? $payload['ErrMsg'] ?? null) : null, 'TaskId' => $taskId !== '' ? $taskId : null, 'roomId' => $roomId !== '' ? $roomId : null, ]); } Log::info( 'TRTC recording callback: skip (no playback url in payload) ' . 'EventType=' . $eventType . ' EventGroupId=' . (string)($json['EventGroupId'] ?? '') . ' TaskId=' . ($taskId !== '' ? $taskId : '-') . ' roomId=' . ($roomId !== '' ? $roomId : '-') . ' ' . ($mayHaveUrl ? 'expect_url' : 'progress_ok') ); return json(['code' => 0, 'msg' => 'ok']); } $record = $this->resolveCallRecordForNotify($roomId, $taskId); if (!$record) { Log::warning('TRTC recording: no call_record (try room_id + cloud_recording_task_id)', [ 'roomId' => $roomId, 'TaskId' => $taskId, 'EventType' => $eventType, ]); 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, 'urls' => count($merged)]); return json(['code' => 0, 'msg' => 'ok']); } 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 ''; } /** * 先按 room_id,再按 CreateCloudRecording 返回的 TaskId(须库中仍保留 cloud_recording_task_id) */ 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 中提取录制文件地址(混流/单路字段名在不同版本可能不同) */ 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); } } } /** * 将腾讯云录制地址拉取到本服务器 public/uploads(需在 .env 开启 trtc.recording_mirror_to_storage=1) * @param array $urls * @return array */ 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); } }