Files
EchoChat/media-server/src/app.ts
bujinyuan 3b83c79036 feat(phase2e-2): 落地会议生命周期状态机 + Router 幂等双层防御(Task 8)
核心交付:
- 新建 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
2026-04-21 18:21:04 +08:00

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();
}