249 lines
8.8 KiB
PHP
249 lines
8.8 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'];
|
||
|
||
/**
|
||
* POST /api/trtc/recording-notify 或 /api/trtc/recordingNotify(与控制台回调 URL 一致即可)
|
||
* 可选安全:环境变量 TRTC_RECORDING_CALLBACK_TOKEN 非空时,Query 需带 ?token=xxx
|
||
*/
|
||
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', ''));
|
||
$urls = $this->extractRecordingUrls($json);
|
||
|
||
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,
|
||
]);
|
||
}
|
||
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);
|
||
if (!$record) {
|
||
Log::warning('TRTC recording: no call_record (try room_id + cloud_recording_task_id)', [
|
||
'roomId' => $roomId,
|
||
'TaskId' => $taskId,
|
||
'EventType' => $eventType,
|
||
]);
|
||
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, 'urls' => count($merged)]);
|
||
|
||
return json(['code' => 0, 'msg' => 'ok']);
|
||
}
|
||
|
||
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 '';
|
||
}
|
||
|
||
/**
|
||
* 先按 room_id,再按 CreateCloudRecording 返回的 TaskId(须库中仍保留 cloud_recording_task_id)
|
||
*/
|
||
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 中提取录制文件地址(混流/单路字段名在不同版本可能不同)
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将腾讯云录制地址拉取到本服务器 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)) {
|
||
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);
|
||
}
|
||
}
|