Files
zyt/server/app/common/service/TrtcCloudRecordingService.php
T
2026-04-01 09:46:25 +08:00

340 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use think\facade\Log;
/**
* 腾讯云 TRTC 云端录制(CreateCloudRecording / DeleteCloudRecording
* 混流(合流)录制:在 RecordParams 中将 RecordMode 设为 2,并配合 MixLayoutParams / MixTranscodeParams。
* 依赖:composer require tencentcloud/trtc
*/
class TrtcCloudRecordingService
{
/** RecordParams.RecordMode:混流录制(多路合成一个文件) */
public const RECORD_MODE_MIX = 2;
/**
* 在应用启动时调用,避免 Guzzle 首次 defaultCaBundle() 缓存错误路径(Windows 常见 cURL error 60)。
* 也可在发起请求前再次调用(重复 ini_set 无害)。
*/
public static function applyRecordingSslCaBundleEarly(): void
{
$path = self::resolveRecordingCaBundlePath();
if ($path === '') {
if (\function_exists('putenv')) {
@putenv('TRTC_RECORDING_SSL_CAFILE');
}
return;
}
@ini_set('openssl.cafile', $path);
@ini_set('curl.cainfo', $path);
// 腾讯云 SDK 内 Guzzle 会忽略已缓存的 defaultCaBundle;通过环境变量让 HttpConnection 显式 verify
if (\function_exists('putenv')) {
@putenv('TRTC_RECORDING_SSL_CAFILE=' . $path);
}
}
private static function resolveRecordingCaBundlePath(): string
{
$raw = trim((string)config('trtc.recording_ssl_cafile', ''));
$path = trim($raw, " \t\"'");
if ($path !== '' && is_readable($path)) {
return $path;
}
$root = rtrim((string)root_path(), '/\\');
$candidate = $root . DIRECTORY_SEPARATOR . 'cacert.pem';
return is_readable($candidate) ? $candidate : '';
}
public static function sdkAvailable(): bool
{
return class_exists(\TencentCloud\Trtc\V20190722\TrtcClient::class);
}
/**
* @param string|null $vodUserDefineRecordId 云点播文件名前缀(仅字母数字下划线连字符,≤64),便于与控制台单流区分;混流文件会带此前缀
* @return array{ok:bool,task_id?:string,message?:string}
*/
public static function startMixRecording(
int $sdkAppId,
string $roomId,
string $botUserId,
string $botUserSig,
?string $vodUserDefineRecordId = null
): array {
if (!self::sdkAvailable()) {
return ['ok' => 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_keyCAM 密钥)'];
}
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();
// 合流录制:RecordMode=2(见 https://cloud.tencent.com/document/product/647/76497 方案二 API 手动录制)
$recordParams->RecordMode = self::RECORD_MODE_MIX;
$recordParams->StreamType = 0;
$recordParams->MaxIdleTime = (int)config('trtc.recording_max_idle_time', 300);
// 不订阅录制机器人自身流,避免占混流画面;医患流仍默认全订阅
$subscribe = new \TencentCloud\Trtc\V20190722\Models\SubscribeStreamUserIds();
$subscribe->UnSubscribeAudioUserIds = [$botUserId];
$subscribe->UnSubscribeVideoUserIds = [$botUserId];
$recordParams->SubscribeStreamUserIds = $subscribe;
$req->RecordParams = $recordParams;
$tencentVod = new \TencentCloud\Trtc\V20190722\Models\TencentVod();
$tencentVod->ExpireTime = 0;
$tencentVod->MediaType = 0;
$prefix = self::sanitizeVodUserDefineRecordId($vodUserDefineRecordId);
if ($prefix !== '') {
$tencentVod->UserDefineRecordId = $prefix;
}
$vodSubApp = (int)config('trtc.recording_vod_sub_app_id', 0);
if ($vodSubApp > 0) {
$tencentVod->SubAppId = $vodSubApp;
}
$cloudVod = new \TencentCloud\Trtc\V20190722\Models\CloudVod();
$cloudVod->TencentVod = $tencentVod;
$storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams();
$storage->CloudVod = $cloudVod;
$req->StorageParams = $storage;
// MixTranscodeParamsSDK 说明「若设置该参数则内部字段须填全」。仅填 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;
Log::info('CreateCloudRecording mix', [
'sdkAppId' => $sdkAppId,
'roomId' => $roomId,
'roomIdType' => $roomIdType,
'recordMode' => self::RECORD_MODE_MIX,
'mixLayoutMode' => $layoutMode,
'vodUserDefineRecordId' => $prefix !== '' ? $prefix : null,
]);
$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;
}
/**
* TencentVod.UserDefineRecordId:仅 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;
}
/**
* 混流时 StorageFile.UserId 应为空串;非空则多为单流。用于区分控制台全局单流与 API 合流。
* 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(合流校验): 首次 Idle,约2s 后已非 Idle', $second + [
'hint' => '启动瞬间 Idle 属常见;VOD 成片仍以回调311与点播为准',
]);
return;
}
Log::warning(
'DescribeCloudRecording: 约2s 后仍为 Idle(未拉到流:多因 RoomIdType 与客户端房间类型不一致,或房内无上推)',
$second
);
return;
}
Log::info('DescribeCloudRecording(合流校验)', $first + [
'hint' => $first['firstFileUserId'] === '' ? 'VOD 场景 StorageFileList 常为空,以点播媒资+311 回调为准' : 'firstFileUserId 非空更像单流',
]);
} catch (\Throwable $e) {
Log::info('DescribeCloudRecording 跳过: ' . $e->getMessage());
}
}
}