Files
zyt/server/app/api/controller/TrtcController.php
2026-05-11 17:49:38 +08:00

470 lines
16 KiB
PHP
Executable File
Raw Permalink 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
// +----------------------------------------------------------------------
// | 腾讯云 TRTC 云端录制 HTTP 回调(需在控制台填写本接口完整 URL)
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\api\controller;
use app\common\model\tcm\CallRecord;
use app\common\service\FileService;
use think\facade\Log;
class TrtcController extends BaseApiController
{
/** 免登录:腾讯云服务器回调 */
public array $notNeedLogin = ['recordingNotify'];
/**
* 事件类型常量(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', '');
if ($token !== '') {
$q = (string)($this->request->param('token', ''));
if (!hash_equals($token, $q)) {
return json(['code' => -1, 'msg' => 'forbidden']);
}
}
$raw = $this->request->getContent();
$json = json_decode($raw, true);
if (!is_array($json)) {
Log::warning('TRTC recording callback: invalid json', ['raw' => substr($raw, 0, 500)]);
return json(['code' => 0, 'msg' => 'ok']);
}
$eventType = (int)($json['EventType'] ?? 0);
$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_recordCOS 310 拼 URL 需要它的 diagnosis_id + id 来还原前缀)
$record = $this->resolveCallRecordForNotify($roomId, $taskId);
// 1. 尝试提取 HTTP URLVOD 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 但无 URLStatus!=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 === []) {
if (!in_array($eventType, self::PROGRESS_EVENTS, true)) {
Log::info('TRTC callback: no url', [
'EventType' => $eventType,
'TaskId' => $taskId,
'roomId' => $roomId,
]);
}
return json(['code' => 0, 'msg' => 'ok']);
}
// 5. 写入 call_record
if (!$record) {
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']);
}
$prev = [];
if (!empty($record->recording_urls)) {
$prev = json_decode((string)$record->recording_urls, true);
if (!is_array($prev)) {
$prev = [];
}
}
$merged = array_values(array_unique(array_merge($prev, $urls)));
$merged = $this->mirrorRecordingUrlsIfEnabled($merged);
$record->save([
'recording_urls' => json_encode($merged, JSON_UNESCAPED_UNICODE),
'recording_status' => 2,
'update_time' => time(),
]);
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}
* URLhttps://{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 = [
data_get($data, 'EventInfo.RoomId'),
data_get($data, 'EventInfo.RoomIdStr'),
data_get($data, 'EventInfo.StrRoomId'),
data_get($data, 'EventInfo.Payload.RoomId'),
data_get($data, 'EventInfo.Payload.RoomIdStr'),
data_get($data, 'RoomId'),
data_get($data, 'room_id'),
];
foreach ($candidates as $v) {
if ($v !== null && $v !== '') {
$s = trim((string)$v);
if ($s !== '' && $s !== '0') {
return $s;
}
}
}
return '';
}
private function resolveCallRecordForNotify(string $roomId, string $taskId): ?CallRecord
{
if ($roomId !== '') {
$record = CallRecord::where('room_id', $roomId)->order('id', 'desc')->find();
if ($record) {
return $record;
}
if (ctype_digit($roomId)) {
$norm = (string)(int)$roomId;
$record = CallRecord::where('room_id', $norm)->order('id', 'desc')->find();
if ($record) {
return $record;
}
}
}
if ($taskId !== '') {
$record = CallRecord::where('cloud_recording_task_id', $taskId)->order('id', 'desc')->find();
if ($record) {
return $record;
}
}
return null;
}
/**
* 从回调 JSON 中递归提取 HTTP URL(适用于 VOD 311 等含 VideoUrl/MediaUrl 的事件)
*/
private function extractRecordingUrls(array $data): array
{
$urls = [];
$this->walkForUrls($data, $urls);
return array_values(array_unique(array_filter($urls)));
}
private function walkForUrls($node, array &$urls): void
{
if (!is_array($node)) {
return;
}
foreach ($node as $key => $val) {
$keyLower = is_string($key) ? strtolower($key) : '';
if (in_array($keyLower, ['videourl', 'fileurl', 'mediaurl', 'url', 'streamurl', 'playurl'], true) && is_string($val)) {
$v = trim($val);
if ($v !== '' && preg_match('#^https?://#i', $v) === 1) {
$urls[] = $v;
}
}
if (is_array($val)) {
$this->walkForUrls($val, $urls);
}
}
}
/* ------------------------------------------------------------------ */
/* 可选:镜像录制文件到本服务器 */
/* ------------------------------------------------------------------ */
private function mirrorRecordingUrlsIfEnabled(array $urls): array
{
if (!(int)config('trtc.recording_mirror_to_storage', 0)) {
return $urls;
}
$out = [];
foreach ($urls as $u) {
$u = trim((string)$u);
if ($u === '' || strncmp($u, 'http', 4) !== 0) {
$out[] = $u;
continue;
}
$local = $this->downloadRecordingToPublic($u);
$out[] = $local !== null ? FileService::getFileUrl($local) : $u;
}
return $out;
}
private function downloadRecordingToPublic(string $url): ?string
{
$dirRel = 'uploads/video/trtc_recording/' . date('Ymd');
$dirAbs = public_path() . $dirRel;
if (!is_dir($dirAbs) && !mkdir($dirAbs, 0755, true) && !is_dir($dirAbs)) {
Log::warning('TRTC mirror: mkdir failed', ['dir' => $dirAbs]);
return null;
}
$ext = '.mp4';
if (preg_match('/\.([a-z0-9]+)(\?|#|$)/i', $url, $m)) {
$ext = '.' . strtolower($m[1]);
}
$name = 'trtc_' . date('His') . '_' . bin2hex(random_bytes(4)) . $ext;
$target = $dirAbs . DIRECTORY_SEPARATOR . $name;
$ctx = stream_context_create([
'http' => ['timeout' => 600],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
try {
$src = @fopen($url, 'rb', false, $ctx);
if ($src === false) {
return null;
}
$dst = @fopen($target, 'wb');
if ($dst === false) {
fclose($src);
return null;
}
stream_copy_to_stream($src, $dst);
fclose($src);
fclose($dst);
} catch (\Throwable $e) {
Log::warning('TRTC mirror download failed: ' . $e->getMessage());
return null;
}
if (!is_file($target) || filesize($target) < 1) {
return null;
}
return str_replace('\\', '/', $dirRel . '/' . $name);
}
}