From aac8ec642a95982a3065d918e533cb1fc769db7d Mon Sep 17 00:00:00 2001 From: duoaohui <928970622@qq.com> Date: Tue, 19 May 2026 12:12:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.dev.yml | 8 ++ .../src/services/recording.service.ts | 87 ++++++++++++++++--- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index e653f5a..bde607e 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -67,6 +67,9 @@ services: # 透传关键环境变量,config.docker.yaml 内部引用(可选覆盖) ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-} ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-} + # media-server 失败回调共享密钥:与 media-server 服务的 INTERNAL_WEBHOOK_SECRET 保持一致 + # 未配置时 go-service 会拒绝所有 webhook(Forbidden),录制失败状态将无法及时落 failed + INTERNAL_WEBHOOK_SECRET: ${INTERNAL_WEBHOOK_SECRET:-internal-webhook-secret-change-me} ports: - "${GO_SERVICE_PORT:-8085}:8085" depends_on: @@ -105,6 +108,11 @@ services: # 录制输出目录:与 host 路径保持一致,使 go-service(host 进程)可直接读取 ffmpeg 写出的 mp4 # 容器内路径 = host 路径,ffmpeg 写出的 outputPath 字符串可在两侧通用 RECORDING_OUTPUT_DIR: ${RECORDING_OUTPUT_DIR:-/home/ykf/jenkins/yuyin/data} + # ffmpeg 异常退出回调地址:media-server 容器使用 network_mode=host,可直连 host 上 go-service 的 8085 端口 + # 路径与 backend/go-service/app/meeting/router.go 注册的 /internal/meeting/recordings/failure 一致 + RECORDING_FAILURE_WEBHOOK_URL: ${RECORDING_FAILURE_WEBHOOK_URL:-http://127.0.0.1:8085/internal/meeting/recordings/failure} + # webhook 共享密钥:必须与 go-service 端 INTERNAL_WEBHOOK_SECRET 一致,否则 go-service 直接 403 + INTERNAL_WEBHOOK_SECRET: ${INTERNAL_WEBHOOK_SECRET:-internal-webhook-secret-change-me} volumes: # 录制文件 host 共享卷:media-server 容器内 ffmpeg 写入此目录,go-service host 进程直接读 # 没有这条挂载时 go-service 会报 "stat ...: no such file or directory" diff --git a/media-server/src/services/recording.service.ts b/media-server/src/services/recording.service.ts index 0d69f69..e506aad 100644 --- a/media-server/src/services/recording.service.ts +++ b/media-server/src/services/recording.service.ts @@ -145,10 +145,15 @@ export function setRecordingFailureCallback(cb: RecordingFailureCallback | null) async function defaultWebhookCallback(info: Parameters[0]): Promise { const url = process.env.RECORDING_FAILURE_WEBHOOK_URL; if (!url) return; + // go-service 端校验 X-Internal-Secret 与其 INTERNAL_WEBHOOK_SECRET 是否一致, + // 缺失/不匹配会直接 403。生产部署务必两侧使用相同值 + const secret = process.env.INTERNAL_WEBHOOK_SECRET ?? ''; + const headers: Record = { 'Content-Type': 'application/json' }; + if (secret) headers['X-Internal-Secret'] = secret; try { const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify(info), }); if (!res.ok) { @@ -166,19 +171,10 @@ async function defaultWebhookCallback(info: Parameters } /** - * 内部触发失败回调:异步、错误吞掉 - * 同时尝试用户注册的回调和默认 webhook(两者独立) + * 实际异步派发失败回调:同时跑 default webhook + 用户注册的 callback + * 抽出此函数是为了让 spawn 阶段(entry 还没进 recordingMap)也能复用 */ -function fireFailureCallback(entry: RecordingEntry): void { - if (entry.unexpectedExit) return; // 幂等:每条录制最多触发一次 - entry.unexpectedExit = true; - const info = { - recordingId: entry.id, - roomCode: entry.roomCode, - routerId: entry.routerId, - exitCode: entry.exitCode, - failureReason: entry.failureReason ?? 'ffmpeg exited unexpectedly', - }; +function dispatchFailureInfo(info: Parameters[0]): void { setImmediate(() => { void defaultWebhookCallback(info); if (failureCallback) { @@ -202,6 +198,47 @@ function fireFailureCallback(entry: RecordingEntry): void { }); } +/** + * 内部触发失败回调:异步、错误吞掉 + * 入口语义"已 active 的 ffmpeg 进程异常退出",依赖已落到 recordingMap 的 entry + */ +function fireFailureCallback(entry: RecordingEntry): void { + if (entry.unexpectedExit) return; // 幂等:每条录制最多触发一次 + entry.unexpectedExit = true; + dispatchFailureInfo({ + recordingId: entry.id, + roomCode: entry.roomCode, + routerId: entry.routerId, + exitCode: entry.exitCode, + failureReason: entry.failureReason ?? 'ffmpeg exited unexpectedly', + }); +} + +/** + * spawn 阶段失败兜底:ffmpeg 二进制找不到 / 无权限 / 启动后立刻退出 + * 此时 entry 可能还没入 recordingMap(startRecording 在末尾才插入), + * 依旧需要把失败上报给 go-service,否则 startRecording 同步 throw 之后 + * 万一上层 DB 行已经存在(异常时序、重试场景)就会卡在 recording 状态。 + * + * 与 fireFailureCallback 互斥:只要其中一个跑过就标记 entry.unexpectedExit=true(如果有 entry) + */ +function fireSpawnFailure(args: { + recordingId: string; + roomCode: string; + routerId: string; + exitCode: number | null; + failureReason: string; +}): void { + const entry = recordingMap.get(args.recordingId); + if (entry) { + if (entry.unexpectedExit) return; + entry.unexpectedExit = true; + if (!entry.failureReason) entry.failureReason = args.failureReason; + if (entry.exitCode === null) entry.exitCode = args.exitCode; + } + dispatchFailureInfo(args); +} + const recordingMap = new Map(); // 默认输出目录;由 RECORDING_OUTPUT_DIR 环境变量覆盖,便于 docker volume 挂载 @@ -492,6 +529,20 @@ export async function startRecording(input: StartRecordingInput): Promise { + // spawn 阶段错误:ffmpeg 二进制找不到 / 无权限 / OS 资源耗尽等 + // 此前仅记日志,go-service 完全感知不到。这里立刻触发 failure webhook 兜底, + // 让上层(即使 DB 已写入 recording 行)能把状态推进到 failed log.error({ recordingId: id, err: err.message }, 'ffmpeg spawn error'); + fireSpawnFailure({ + recordingId: id, + roomCode: input.roomCode, + routerId: input.routerId, + exitCode: null, + failureReason: `ffmpeg spawn error: ${err.message}`, + }); }); // 等 ffmpeg 进入监听就绪:实践经验 200ms 足够 ffmpeg 解析 SDP + 绑定端口