352 lines
15 KiB
PHP
Executable File
352 lines
15 KiB
PHP
Executable File
<?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_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());
|
||
}
|
||
}
|
||
}
|