false, 'message' => '未安装 tencentcloud/trtc,请在 server 目录执行 composer update']; } $secretId = trim((string)config('trtc.api_secret_id', '')); $secretKey = trim((string)config('trtc.api_secret_key', '')); if ($secretId === '' || $secretKey === '') { return ['ok' => false, 'message' => '未配置 trtc.api_secret_id / trtc.api_secret_key(CAM 密钥)']; } if ($sdkAppId <= 0) { return ['ok' => false, 'message' => 'SdkAppId 无效']; } if (self::trtcUserSigSecretKey() === '') { return ['ok' => false, 'message' => '未配置 TRTC UserSig 密钥(project.trtc.secretKey)']; } $roomIdType = self::resolveRecordingRoomIdType((string)$roomId); $region = (string)config('trtc.recording_api_region', 'ap-guangzhou'); try { self::applyRecordingSslCaBundleEarly(); $cred = new \TencentCloud\Common\Credential($secretId, $secretKey); $httpProfile = new \TencentCloud\Common\Profile\HttpProfile(); $httpProfile->setEndpoint('trtc.tencentcloudapi.com'); $clientProfile = new \TencentCloud\Common\Profile\ClientProfile(); $clientProfile->setHttpProfile($httpProfile); $client = new \TencentCloud\Trtc\V20190722\TrtcClient($cred, $region, $clientProfile); $req = new \TencentCloud\Trtc\V20190722\Models\CreateCloudRecordingRequest(); $req->SdkAppId = $sdkAppId; $req->RoomId = (string)$roomId; $req->RoomIdType = $roomIdType; $req->UserId = $botUserId; $req->UserSig = $botUserSig; $recordParams = new \TencentCloud\Trtc\V20190722\Models\RecordParams(); $recordParams->RecordMode = self::RECORD_MODE_MIX; $recordParams->StreamType = 0; $recordParams->MaxIdleTime = (int)config('trtc.recording_max_idle_time', 300); // COS 存储时 OutputFormat 决定输出格式:0=hls(默认)、1=hls+mp4、3=mp4。 // 310 回调仅在产出 MP4 时触发;默认 hls 不会生成 MP4 → 永远收不到 310。 $recordParams->OutputFormat = (int)config('trtc.recording_output_format', 3); // 不订阅录制机器人自身流,避免占混流画面;医患流仍默认全订阅 $subscribe = new \TencentCloud\Trtc\V20190722\Models\SubscribeStreamUserIds(); $subscribe->UnSubscribeAudioUserIds = [$botUserId]; $subscribe->UnSubscribeVideoUserIds = [$botUserId]; $recordParams->SubscribeStreamUserIds = $subscribe; $req->RecordParams = $recordParams; // 使用 COS 对象存储 $cloudStorage = new \TencentCloud\Trtc\V20190722\Models\CloudStorage(); $cloudStorage->Vendor = (int)config('trtc.recording_cos_vendor', 0); $cosRegion = trim((string)config('trtc.recording_cos_region', '')); $cloudStorage->Region = $cosRegion !== '' ? $cosRegion : $region; $cloudStorage->Bucket = trim((string)config('trtc.recording_cos_bucket', '')); $cosAk = trim((string)config('trtc.recording_cos_access_key', '')); $cosSk = trim((string)config('trtc.recording_cos_secret_key', '')); $cloudStorage->AccessKey = $cosAk !== '' ? $cosAk : $secretId; $cloudStorage->SecretKey = $cosSk !== '' ? $cosSk : $secretKey; $prefix = self::sanitizeVodUserDefineRecordId($vodUserDefineRecordId); if ($prefix === '') { $prefix = rtrim((string)config('trtc.recording_cos_prefix', 'trtc-recording'), '/'); } $cloudStorage->FileNamePrefix = $prefix !== '' ? explode('/', $prefix) : []; $storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams(); $storage->CloudStorage = $cloudStorage; $req->StorageParams = $storage; // MixTranscodeParams:SDK 说明「若设置该参数则内部字段须填全」。仅填 VideoParams 未填 AudioParams 可能导致合流异常或退化为非预期行为 $videoParams = new \TencentCloud\Trtc\V20190722\Models\VideoParams(); $videoParams->Width = (int)config('trtc.recording_mix_width', 1280); $videoParams->Height = (int)config('trtc.recording_mix_height', 720); $videoParams->Fps = (int)config('trtc.recording_mix_fps', 15); $videoParams->BitRate = (int)config('trtc.recording_mix_video_bitrate', 1500000); $videoParams->Gop = (int)config('trtc.recording_mix_gop', 2); $audioParams = new \TencentCloud\Trtc\V20190722\Models\AudioParams(); $audioParams->SampleRate = (int)config('trtc.recording_mix_audio_sample_rate', 1); $audioParams->Channel = (int)config('trtc.recording_mix_audio_channel', 2); $audioParams->BitRate = (int)config('trtc.recording_mix_audio_bitrate', 64000); $mixTc = new \TencentCloud\Trtc\V20190722\Models\MixTranscodeParams(); $mixTc->VideoParams = $videoParams; $mixTc->AudioParams = $audioParams; $req->MixTranscodeParams = $mixTc; $mixLayout = new \TencentCloud\Trtc\V20190722\Models\MixLayoutParams(); $layoutMode = (int)config('trtc.recording_mix_layout_mode', 3); if ($layoutMode < 1 || $layoutMode > 4) { $layoutMode = 3; } $mixLayout->MixLayoutMode = $layoutMode; $req->MixLayoutParams = $mixLayout; $usedAk = (string)$cloudStorage->AccessKey; Log::info('CreateCloudRecording mix (COS)', [ 'sdkAppId' => $sdkAppId, 'roomId' => $roomId, 'roomIdType' => $roomIdType, 'recordMode' => self::RECORD_MODE_MIX, 'outputFormat' => $recordParams->OutputFormat, 'mixLayoutMode' => $layoutMode, 'bucket' => $cloudStorage->Bucket, 'region' => $cloudStorage->Region, 'fileNamePrefix' => implode('/', $cloudStorage->FileNamePrefix ?: []), 'cosAkSource' => $cosAk !== '' ? 'cos_config' : 'api_secret_id', 'cosAkPrefix' => substr($usedAk, 0, 8) . '***', ]); $resp = $client->CreateCloudRecording($req); $taskId = $resp->TaskId ?? ''; if ($taskId === '') { return ['ok' => false, 'message' => 'CreateCloudRecording 未返回 TaskId']; } self::logDescribeCloudRecordingHint($client, $sdkAppId, $taskId, (string)$roomId, $roomIdType); return ['ok' => true, 'task_id' => $taskId]; } catch (\Throwable $e) { $msg = $e->getMessage(); if (str_contains($msg, 'SSL certificate problem') || str_contains($msg, 'error 60')) { $msg .= ';请下载 https://curl.se/ca/cacert.pem 保存到本机,在 .env [trtc] 设置 RECORDING_SSL_CAFILE=绝对路径,或在 php.ini 配置 openssl.cafile / curl.cainfo'; } Log::error('CreateCloudRecording failed: ' . $msg); return ['ok' => false, 'message' => $msg]; } } /** * @return array{ok:bool,message?:string} */ public static function stopRecording(int $sdkAppId, string $taskId): array { if ($taskId === '') { return ['ok' => true]; } if (!self::sdkAvailable()) { return ['ok' => false, 'message' => '未安装 tencentcloud/trtc']; } $secretId = trim((string)config('trtc.api_secret_id', '')); $secretKey = trim((string)config('trtc.api_secret_key', '')); if ($secretId === '' || $secretKey === '') { return ['ok' => false, 'message' => '未配置云 API 密钥']; } $region = (string)config('trtc.recording_api_region', 'ap-guangzhou'); try { self::applyRecordingSslCaBundleEarly(); $cred = new \TencentCloud\Common\Credential($secretId, $secretKey); $httpProfile = new \TencentCloud\Common\Profile\HttpProfile(); $httpProfile->setEndpoint('trtc.tencentcloudapi.com'); $clientProfile = new \TencentCloud\Common\Profile\ClientProfile(); $clientProfile->setHttpProfile($httpProfile); $client = new \TencentCloud\Trtc\V20190722\TrtcClient($cred, $region, $clientProfile); $req = new \TencentCloud\Trtc\V20190722\Models\DeleteCloudRecordingRequest(); $req->SdkAppId = $sdkAppId; $req->TaskId = $taskId; $client->DeleteCloudRecording($req); return ['ok' => true]; } catch (\Throwable $e) { Log::warning('DeleteCloudRecording: ' . $e->getMessage()); return ['ok' => false, 'message' => $e->getMessage()]; } } public static function trtcSdkAppId(): int { $p = config('project.trtc'); return (int)(is_array($p) ? ($p['sdkAppId'] ?? 0) : 0) ?: (int)config('trtc.sdkAppId', 0); } public static function trtcUserSigSecretKey(): string { $p = config('project.trtc'); $fromProject = is_array($p) ? (string)($p['secretKey'] ?? '') : ''; return $fromProject !== '' ? $fromProject : (string)config('trtc.secretKey', ''); } public static function makeBotUserSig(string $botUserId): string { $sdkAppId = self::trtcSdkAppId(); $key = self::trtcUserSigSecretKey(); $api = new TLSSigAPIv2($sdkAppId, $key); $expire = (int)(config('project.trtc.expireTime') ?? config('trtc.expireTime', 86400)); return $api->genUserSig($botUserId, $expire > 0 ? $expire : 86400); } /** * RoomIdType 必须与通话实际房间类型一致,否则录制进错房或只能录到单路(见 CreateCloudRecording RoomIdType 说明) */ public static function resolveRecordingRoomIdType(string $roomId): int { $cfg = trim((string)config('trtc.recording_room_id_type', '')); if ($cfg !== '' && strtolower($cfg) !== 'auto') { return ((int)$cfg) === 1 ? 1 : 0; } return ctype_digit($roomId) ? 1 : 0; } /** * COS 存储路径前缀:仅 a-zA-Z0-9_-/ */ private static function sanitizeVodUserDefineRecordId(?string $raw): string { if ($raw === null || $raw === '') { return ''; } $s = preg_replace('/[^a-zA-Z0-9_\/-]/', '', $raw) ?? ''; return strlen($s) > 64 ? substr($s, 0, 64) : $s; } /** * COS 存储时 StorageFile 结构不同于 VOD。 * CreateCloudRecording 后立刻查询常为 Idle,约 2s 后再查一次再下结论。 */ private static function logDescribeCloudRecordingHint( \TencentCloud\Trtc\V20190722\TrtcClient $client, int $sdkAppId, string $taskId, string $roomId, int $roomIdType ): void { try { $snap = function () use ($client, $sdkAppId, $taskId, $roomId, $roomIdType): array { $dreq = new \TencentCloud\Trtc\V20190722\Models\DescribeCloudRecordingRequest(); $dreq->SdkAppId = $sdkAppId; $dreq->TaskId = $taskId; $dresp = $client->DescribeCloudRecording($dreq); $list = $dresp->StorageFileList ?? []; $firstUid = ''; if (is_array($list) && isset($list[0]) && $list[0] instanceof \TencentCloud\Trtc\V20190722\Models\StorageFile) { $firstUid = (string)($list[0]->UserId ?? ''); } return [ 'taskId' => $taskId, 'roomId' => $roomId, 'roomIdType' => $roomIdType, 'status' => (string)($dresp->Status ?? ''), 'storageFileCount' => is_array($list) ? count($list) : 0, 'firstFileUserId' => $firstUid, ]; }; $first = $snap(); if (strcasecmp($first['status'], 'Idle') === 0) { sleep(2); $second = $snap(); if (strcasecmp($second['status'], 'Idle') !== 0) { Log::info('DescribeCloudRecording(合流校验-COS): 首次 Idle,约2s 后已非 Idle', $second + [ 'hint' => '启动瞬间 Idle 属常见;COS 文件以实际上传为准', ]); return; } Log::warning( 'DescribeCloudRecording: 约2s 后仍为 Idle(未拉到流:多因 RoomIdType 与客户端房间类型不一致,或房内无上推)', $second ); return; } Log::info('DescribeCloudRecording(合流校验-COS)', $first + [ 'hint' => 'COS 存储场景,文件将直接上传到对象存储桶', ]); } catch (\Throwable $e) { Log::info('DescribeCloudRecording 跳过: ' . $e->getMessage()); } } }