核心交付:
- 新建 MeetingLifecycleService(6 钩子 + sync.Map 本地 timer + Redis key 双保险 + RescheduleFromRedis)
- 新建 MeetingCleanupTask(启动重建 timer + 每 N 秒扫 host_grace/empty_ttl 兜底 + 4h stale active 回收)
- MediaOrchestrator 新增 ResolveRouterID;HTTPMediaOrchestrator.CreateRouter 入口 sync.Map 幂等防御
- 业务层 JoinRoom 移除 CreateRouter 调用改走 CancelEmptyTTL + ResolveRouterID;LeaveRoom 空房分支改调 OnAllMembersLeft 不再立即销毁
- MeetingSignalService 新增 OnWSDisconnect 实现 ws.MeetingDisconnectHook;OnRoomJoin 追加 host 重连钩子
- ws.handler 定义 MeetingDisconnectHook 接口 + SetMeetingDisconnectHook,解耦 ws→meeting 反向依赖
- config 新增 MeetingConfig{HostGrace=120, EmptyRoomTTL=300, CleanupInterval=30, StaleRoomHours=4}
关键设计决策:
- Redis key TTL = 业务时长 + max(CleanupIntervalSeconds*2, 30s) buffer:避免本地 timer 与
Redis 自动过期同步到期导致 DEL 返回 0 被误判为"已被其他路径处理"而跳过业务逻辑
- Router 幂等双层防御(决策 q2_router_dedup=a2_both):业务层不重复调 + HTTP 层 sync.Map 命中直接返回
- 普通成员 WS 断开仅清 media 资源不动 participant 表(决策 q1_nonhost_disconnect=a1_keep_current)
E2E 验证:docs/verify/meeting_t8_verify.mjs PASS=20 FAIL=0,覆盖 5 场景:
- S1 host 宽限期过期自动转让(meeting.host.changed + DB host_id 更新)
- S2 宽限期内重连保留身份
- S3 empty_ttl 期内新成员加入复活房间
- S4 empty_ttl 过期 → 房间 Ended + 新 join 被拒
- S5 CreateRoom +1 Router / JoinRoom 不再创建新 Router(通过 media-server /internal/info stats.routers 断言)
media-server:/internal/info 响应追加 stats.routers + routers[] 供 E2E 断言 Router 幂等
文档同步:
- docs/progress/CURRENT_STATUS.md 头部 + 新增 Task 8 交付条目
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md Task 8 标记完成 + 实际产出/决策/验证
- docs/api/frontend/meeting.md 补充 host.changed.auto_reason / room.ended.reason=system_error / 空房 TTL 复活语义 + Task 8 验证记录
- docs/architecture/system-architecture.md meeting 模块职责补充"会议生命周期状态机"
- .cursor/rules/project-context.mdc 追加 Task 8 条目并更新 Phase 2e-2 进度(Task 0-8 ✅)
Made-with: Cursor
152 lines
4.1 KiB
TypeScript
152 lines
4.1 KiB
TypeScript
import * as mediasoup from 'mediasoup';
|
|
import sensible from '@fastify/sensible';
|
|
import Fastify from 'fastify';
|
|
|
|
import { config } from './config.js';
|
|
import { registerErrorHandler } from './middlewares/error-handler.js';
|
|
import { internalAuthPlugin } from './middlewares/internal-auth.js';
|
|
import { closeWorker, getWorkerSnapshot, startWorker } from './mediasoup/worker.js';
|
|
import { consumerRoutes } from './routes/consumer.route.js';
|
|
import { producerRoutes } from './routes/producer.route.js';
|
|
import { routerRoutes } from './routes/router.route.js';
|
|
import { transportRoutes } from './routes/transport.route.js';
|
|
import { getRouterStats } from './services/router.service.js';
|
|
import { logger } from './utils/logger.js';
|
|
|
|
export async function buildApp() {
|
|
const app = Fastify({
|
|
loggerInstance: logger,
|
|
disableRequestLogging: false,
|
|
trustProxy: false,
|
|
bodyLimit: 256 * 1024,
|
|
});
|
|
|
|
await app.register(sensible);
|
|
registerErrorHandler(app);
|
|
|
|
app.get('/healthz', async () => {
|
|
const snapshot = getWorkerSnapshot();
|
|
return {
|
|
ok: snapshot.ready,
|
|
service: 'media-server',
|
|
mediasoupVersion: mediasoup.version,
|
|
workerPid: snapshot.pid,
|
|
workerRestartAttempts: snapshot.restartAttempts,
|
|
uptimeSec: Math.round(process.uptime()),
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
});
|
|
|
|
app.get('/readyz', async (_request, reply) => {
|
|
const snapshot = getWorkerSnapshot();
|
|
if (!snapshot.ready) {
|
|
reply.code(503);
|
|
return {
|
|
ready: false,
|
|
reason: 'mediasoup worker not ready',
|
|
};
|
|
}
|
|
return { ready: true };
|
|
});
|
|
|
|
await app.register(internalAuthPlugin);
|
|
|
|
app.get('/internal/info', async () => {
|
|
const snapshot = getWorkerSnapshot();
|
|
const routerStats = getRouterStats();
|
|
return {
|
|
service: 'media-server',
|
|
version: '0.1.0',
|
|
mediasoupVersion: mediasoup.version,
|
|
worker: snapshot,
|
|
listen: {
|
|
ip: config.mediasoup.listenIp,
|
|
announcedIp: config.mediasoup.announcedIp ?? null,
|
|
rtcMinPort: config.mediasoup.rtcMinPort,
|
|
rtcMaxPort: config.mediasoup.rtcMaxPort,
|
|
},
|
|
stats: {
|
|
routers: routerStats.total,
|
|
},
|
|
routers: routerStats.rooms,
|
|
};
|
|
});
|
|
|
|
await app.register(
|
|
async (scope) => {
|
|
await scope.register(routerRoutes);
|
|
await scope.register(transportRoutes);
|
|
await scope.register(producerRoutes);
|
|
await scope.register(consumerRoutes);
|
|
},
|
|
{ prefix: '/internal/v1' },
|
|
);
|
|
|
|
return app;
|
|
}
|
|
|
|
async function bootstrap(): Promise<void> {
|
|
const app = await buildApp();
|
|
|
|
try {
|
|
await startWorker();
|
|
} catch (err) {
|
|
logger.fatal(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
'failed to start mediasoup worker, exiting',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
await app.listen({ host: config.http.host, port: config.http.port });
|
|
logger.info(
|
|
{
|
|
host: config.http.host,
|
|
port: config.http.port,
|
|
env: config.nodeEnv,
|
|
},
|
|
'media-server listening',
|
|
);
|
|
} catch (err) {
|
|
logger.fatal(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
'failed to start HTTP server',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const shutdown = async (signal: string): Promise<void> => {
|
|
logger.info({ signal }, 'received shutdown signal');
|
|
try {
|
|
await app.close();
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
'error while closing fastify',
|
|
);
|
|
}
|
|
await closeWorker();
|
|
process.exit(0);
|
|
};
|
|
|
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
process.on('unhandledRejection', (reason) => {
|
|
logger.error({ reason }, 'unhandled promise rejection');
|
|
});
|
|
process.on('uncaughtException', (err) => {
|
|
logger.fatal({ err: err.message, stack: err.stack }, 'uncaught exception');
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
const isEntryPoint =
|
|
import.meta.url === `file://${process.argv[1]}` ||
|
|
process.argv[1]?.endsWith('app.ts') ||
|
|
process.argv[1]?.endsWith('app.js');
|
|
|
|
if (isEntryPoint) {
|
|
void bootstrap();
|
|
}
|