Files
2026-04-01 09:46:25 +08:00

249 lines
8.8 KiB
PHP
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'];
/**
* 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);
}
}