视频保存

This commit is contained in:
duoaohui
2026-05-19 12:12:11 +08:00
parent 472495af02
commit aac8ec642a
2 changed files with 82 additions and 13 deletions

View File

@@ -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 会拒绝所有 webhookForbidden录制失败状态将无法及时落 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-servicehost 进程)可直接读取 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"

View File

@@ -145,10 +145,15 @@ export function setRecordingFailureCallback(cb: RecordingFailureCallback | null)
async function defaultWebhookCallback(info: Parameters<RecordingFailureCallback>[0]): Promise<void> {
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<string, string> = { '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<RecordingFailureCallback>
}
/**
* 内部触发失败回调:异步、错误吞掉
* 同时尝试用户注册的回调和默认 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<RecordingFailureCallback>[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 可能还没入 recordingMapstartRecording 在末尾才插入),
* 依旧需要把失败上报给 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<string, RecordingEntry>();
// 默认输出目录;由 RECORDING_OUTPUT_DIR 环境变量覆盖,便于 docker volume 挂载
@@ -492,6 +529,20 @@ export async function startRecording(input: StartRecordingInput): Promise<StartR
// Phase B watchdog异步触发失败回调webhook + 用户回调),不阻塞 exit 处理
fireFailureCallback(entry);
}
} else if (code !== 0) {
// entry 还没入 mapstartRecording 末尾才插入)→ 走 spawn 失败兜底,避免 webhook 漏报
// startRecording 同步路径会自己 throw + rollback这里上报只为防止上层 DB 行已存在的边缘场景
log.error(
{ recordingId: id, code, signal },
'ffmpeg exited before recording entry registered',
);
fireSpawnFailure({
recordingId: id,
roomCode: input.roomCode,
routerId: input.routerId,
exitCode: code,
failureReason: `ffmpeg exited during spawn code=${code} signal=${signal ?? ''}`,
});
}
resolve();
});
@@ -504,7 +555,17 @@ export async function startRecording(input: StartRecordingInput): Promise<StartR
});
ffmpegProc.once('error', (err) => {
// 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 + 绑定端口