更新
This commit is contained in:
@@ -14,12 +14,33 @@ use think\facade\Log;
|
||||
class TrtcController extends BaseApiController
|
||||
{
|
||||
/** 免登录:腾讯云服务器回调 */
|
||||
public array $notNeedLogin = ['recordingNotify'];
|
||||
public array $notNeedLogin = ['recordingNotify'];
|
||||
|
||||
/**
|
||||
* POST /api/trtc/recording-notify 或 /api/trtc/recordingNotify(与控制台回调 URL 一致即可)
|
||||
* 可选安全:环境变量 TRTC_RECORDING_CALLBACK_TOKEN 非空时,Query 需带 ?token=xxx
|
||||
* 事件类型常量(EventGroupId=3 云端录制)
|
||||
* @see https://cloud.tencent.com/document/product/647/81113
|
||||
*/
|
||||
private const ET_RECORDER_START = 301;
|
||||
private const ET_RECORDER_STOP = 302;
|
||||
private const ET_UPLOAD_START = 303;
|
||||
private const ET_FILE_INFO = 304;
|
||||
private const ET_UPLOAD_STOP = 305;
|
||||
private const ET_FILE_SLICE = 307;
|
||||
private const ET_MP4_STOP = 310; // COS MP4 上传完成
|
||||
private const ET_VOD_COMMIT = 311; // VOD 上传完成
|
||||
private const ET_VOD_STOP = 312;
|
||||
|
||||
private const PROGRESS_EVENTS = [
|
||||
self::ET_RECORDER_START,
|
||||
self::ET_RECORDER_STOP,
|
||||
self::ET_UPLOAD_START,
|
||||
self::ET_FILE_INFO,
|
||||
self::ET_UPLOAD_STOP,
|
||||
self::ET_FILE_SLICE,
|
||||
self::ET_VOD_STOP,
|
||||
306, 308, 309,
|
||||
];
|
||||
|
||||
public function recordingNotify()
|
||||
{
|
||||
$token = (string)config('trtc.recording_callback_token', '');
|
||||
@@ -38,39 +59,80 @@ class TrtcController extends BaseApiController
|
||||
}
|
||||
|
||||
$eventType = (int)($json['EventType'] ?? 0);
|
||||
$roomId = $this->extractRoomId($json);
|
||||
$taskId = trim((string)data_get($json, 'EventInfo.TaskId', ''));
|
||||
$roomId = $this->extractRoomId($json);
|
||||
$taskId = trim((string)data_get($json, 'EventInfo.TaskId', ''));
|
||||
$payload = data_get($json, 'EventInfo.Payload');
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
|
||||
Log::info('TRTC callback recv', [
|
||||
'EventType' => $eventType,
|
||||
'roomId' => $roomId,
|
||||
'TaskId' => $taskId,
|
||||
'PayloadKeys' => implode(',', array_keys($payload)),
|
||||
]);
|
||||
|
||||
// 0. 提前查找 call_record(COS 310 拼 URL 需要它的 diagnosis_id + id 来还原前缀)
|
||||
$record = $this->resolveCallRecordForNotify($roomId, $taskId);
|
||||
|
||||
// 1. 尝试提取 HTTP URL(VOD 311 等)
|
||||
$urls = $this->extractRecordingUrls($json);
|
||||
|
||||
// 2. COS 存储:EventType=310 MP4 上传完成,从文件名拼接 COS URL
|
||||
if ($urls === [] && $eventType === self::ET_MP4_STOP) {
|
||||
$cosPrefix = $this->resolveRecordingCosPrefix($record);
|
||||
$urls = $this->buildCosUrlsFromPayload($payload, $taskId, $cosPrefix);
|
||||
if ($urls === []) {
|
||||
Log::warning('TRTC 310 (COS MP4) 未解析到文件', [
|
||||
'Status' => $payload['Status'] ?? null,
|
||||
'FileList' => json_encode($payload['FileList'] ?? null, JSON_UNESCAPED_UNICODE),
|
||||
'FileMessage' => json_encode($payload['FileMessage'] ?? null, JSON_UNESCAPED_UNICODE),
|
||||
'TaskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
'cosPrefix' => $cosPrefix,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. COS HLS 兜底:305(上传结束) 时用同样的前缀
|
||||
if ($urls === [] && $eventType === self::ET_UPLOAD_STOP) {
|
||||
$cosPrefix = $this->resolveRecordingCosPrefix($record);
|
||||
$hlsUrls = $this->buildCosHlsUrlFromPayload($payload, $taskId, $roomId, $cosPrefix);
|
||||
if ($hlsUrls !== []) {
|
||||
$urls = $hlsUrls;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 311 但无 URL(Status!=0 或字段变更)
|
||||
if ($urls === [] && $eventType === self::ET_VOD_COMMIT) {
|
||||
Log::warning('TRTC 311 (VOD) 未解析到 VideoUrl', [
|
||||
'Status' => $payload['Status'] ?? null,
|
||||
'Errmsg' => $payload['Errmsg'] ?? $payload['ErrMsg'] ?? null,
|
||||
'TaskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. 无 URL:进度/状态事件,正常跳过
|
||||
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,
|
||||
if (!in_array($eventType, self::PROGRESS_EVENTS, true)) {
|
||||
Log::info('TRTC callback: no url', [
|
||||
'EventType' => $eventType,
|
||||
'TaskId' => $taskId,
|
||||
'roomId' => $roomId,
|
||||
]);
|
||||
}
|
||||
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);
|
||||
// 5. 写入 call_record
|
||||
if (!$record) {
|
||||
Log::warning('TRTC recording: no call_record (try room_id + cloud_recording_task_id)', [
|
||||
Log::warning('TRTC recording: no call_record', [
|
||||
'roomId' => $roomId,
|
||||
'TaskId' => $taskId,
|
||||
'EventType' => $eventType,
|
||||
'urls' => json_encode($urls, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
@@ -91,11 +153,174 @@ class TrtcController extends BaseApiController
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
Log::info('TRTC recording saved', ['id' => $record->id, 'urls' => count($merged)]);
|
||||
Log::info('TRTC recording saved', [
|
||||
'id' => $record->id,
|
||||
'EventType' => $eventType,
|
||||
'roomId' => $roomId,
|
||||
'newUrls' => count($urls),
|
||||
'totalUrls' => count($merged),
|
||||
]);
|
||||
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COS 前缀还原 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 还原 CreateCloudRecording 时使用的 FileNamePrefix。
|
||||
* 录制启动时前缀为 mix_{diagnosisId}_{callRecordId},回调时需还原。
|
||||
*/
|
||||
private function resolveRecordingCosPrefix(?CallRecord $record): string
|
||||
{
|
||||
if ($record && !empty($record->diagnosis_id) && !empty($record->id)) {
|
||||
return 'mix_' . (int)$record->diagnosis_id . '_' . (int)$record->id;
|
||||
}
|
||||
return $this->cosConfig('recording_cos_prefix', 'trtc-recording');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COS 配置读取(config() 优先,env() 兜底,兼容 config 文件未同步部署) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private function cosConfig(string $key, string $default = ''): string
|
||||
{
|
||||
$val = trim((string)config("trtc.{$key}", ''));
|
||||
if ($val !== '') {
|
||||
return $val;
|
||||
}
|
||||
return trim((string)env("trtc.{$key}", $default));
|
||||
}
|
||||
|
||||
private function cosBucket(): string
|
||||
{
|
||||
return $this->cosConfig('recording_cos_bucket');
|
||||
}
|
||||
|
||||
private function cosRegion(): string
|
||||
{
|
||||
$r = $this->cosConfig('recording_cos_region');
|
||||
return $r !== '' ? $r : $this->cosConfig('recording_api_region', 'ap-guangzhou');
|
||||
}
|
||||
|
||||
private function cosPrefix(): string
|
||||
{
|
||||
return $this->cosConfig('recording_cos_prefix', 'trtc-recording');
|
||||
}
|
||||
|
||||
private function cosPrefixParts(): array
|
||||
{
|
||||
$raw = $this->cosPrefix();
|
||||
return $raw !== '' ? explode('/', rtrim($raw, '/')) : [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* COS 文件 URL 拼接 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* EventType=310 的 Payload 中提取文件名,拼接完整 COS 下载 URL。
|
||||
*
|
||||
* COS 路径格式:{FileNamePrefix}/{TaskId}/{FileName}
|
||||
* URL:https://{Bucket}.cos.{Region}.myqcloud.com/{path}
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildCosUrlsFromPayload(array $payload, string $taskId, string $prefix): array
|
||||
{
|
||||
$status = (int)($payload['Status'] ?? -1);
|
||||
if ($status === 2 || $status === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fileNames = [];
|
||||
|
||||
if (!empty($payload['FileMessage']) && is_array($payload['FileMessage'])) {
|
||||
foreach ($payload['FileMessage'] as $fm) {
|
||||
if (is_array($fm) && !empty($fm['FileName'])) {
|
||||
$fileNames[] = trim((string)$fm['FileName']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileNames === [] && !empty($payload['FileList'])) {
|
||||
$fl = $payload['FileList'];
|
||||
if (is_array($fl)) {
|
||||
foreach ($fl as $f) {
|
||||
if (is_string($f) && trim($f) !== '') {
|
||||
$fileNames[] = trim($f);
|
||||
}
|
||||
}
|
||||
} elseif (is_string($fl) && trim($fl) !== '') {
|
||||
$fileNames[] = trim($fl);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileNames === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$bucket = $this->cosBucket();
|
||||
$region = $this->cosRegion();
|
||||
|
||||
if ($bucket === '') {
|
||||
Log::warning('TRTC 310: COS bucket 未配置,无法拼接下载 URL', ['files' => implode(', ', $fileNames)]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : [];
|
||||
$baseUrl = "https://{$bucket}.cos.{$region}.myqcloud.com";
|
||||
|
||||
$urls = [];
|
||||
foreach ($fileNames as $fn) {
|
||||
$parts = array_merge($prefixParts, [$taskId, $fn]);
|
||||
$path = implode('/', array_filter($parts, fn($p) => $p !== ''));
|
||||
$urls[] = $baseUrl . '/' . $path;
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* COS HLS 兜底:OutputFormat=0(hls) 时不会有 310 事件。
|
||||
* 305(UPLOAD_STOP) 后用录制的 m3u8 文件名拼 COS URL。
|
||||
*/
|
||||
private function buildCosHlsUrlFromPayload(array $payload, string $taskId, string $roomId, string $prefix): array
|
||||
{
|
||||
$status = (int)($payload['Status'] ?? -1);
|
||||
if ($status !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$bucket = $this->cosBucket();
|
||||
if ($bucket === '') {
|
||||
return [];
|
||||
}
|
||||
$region = $this->cosRegion();
|
||||
|
||||
$prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : [];
|
||||
|
||||
$sdkAppId = (int)(config('trtc.sdkAppId', 0) ?: env('trtc.sdk_app_id', 0));
|
||||
$m3u8Name = "{$sdkAppId}_{$roomId}.m3u8";
|
||||
|
||||
$parts = array_merge($prefixParts, [$taskId, $m3u8Name]);
|
||||
$path = implode('/', array_filter($parts, fn($p) => $p !== ''));
|
||||
$url = "https://{$bucket}.cos.{$region}.myqcloud.com/{$path}";
|
||||
|
||||
Log::info('TRTC 305 HLS fallback', [
|
||||
'roomId' => $roomId,
|
||||
'TaskId' => $taskId,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return [$url];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 字段提取 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private function extractRoomId(array $data): string
|
||||
{
|
||||
$candidates = [
|
||||
@@ -119,9 +344,6 @@ class TrtcController extends BaseApiController
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 先按 room_id,再按 CreateCloudRecording 返回的 TaskId(须库中仍保留 cloud_recording_task_id)
|
||||
*/
|
||||
private function resolveCallRecordForNotify(string $roomId, string $taskId): ?CallRecord
|
||||
{
|
||||
if ($roomId !== '') {
|
||||
@@ -148,7 +370,7 @@ class TrtcController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* 从回调 JSON 中提取录制文件地址(混流/单路字段名在不同版本可能不同)
|
||||
* 从回调 JSON 中递归提取 HTTP URL(适用于 VOD 311 等含 VideoUrl/MediaUrl 的事件)
|
||||
*/
|
||||
private function extractRecordingUrls(array $data): array
|
||||
{
|
||||
@@ -177,11 +399,10 @@ class TrtcController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将腾讯云录制地址拉取到本服务器 public/uploads(需在 .env 开启 trtc.recording_mirror_to_storage=1)
|
||||
* @param array<int, string> $urls
|
||||
* @return array<int, string>
|
||||
*/
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 可选:镜像录制文件到本服务器 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
private function mirrorRecordingUrlsIfEnabled(array $urls): array
|
||||
{
|
||||
if (!(int)config('trtc.recording_mirror_to_storage', 0)) {
|
||||
|
||||
Reference in New Issue
Block a user