470 lines
16 KiB
PHP
470 lines
16 KiB
PHP
<?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_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 === []) {
|
||
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}
|
||
* 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 = [
|
||
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);
|
||
}
|
||
}
|