Files
zyt/server/app/common/service/TrtcCloudRecordingService.php
T
2026-04-17 09:47:17 +08:00

352 lines
15 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();
$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;
// 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;
$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());
}
}
}