Files
EchoChat/docs/verify/meeting_t8_verify.mjs
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

319 lines
13 KiB
JavaScript
Raw 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.

#!/usr/bin/env node
// Task 8 端到端验证脚本:会议生命周期状态机
// 覆盖:
// 1. host 掉线 → 宽限期过期自动转让(其他成员收 host.changedDB host_id 更新)
// 2. host 掉线 → 宽限期内重连 → host 身份保留
// 3. 全员 leave → empty_ttl 存在 → 新用户 join 复活empty_ttl key 被 DEL
// 4. 全员 leave → 等待 TTL 过期 → Node Router 被关闭CloseRouter 日志佐证)+ DB 状态 Ended
// 5. JoinRoom 不再重复调 CreateRouterNode 侧仅 1 次 router created 日志)
//
// 运行前置:
// - 必须设置短时长环境变量加速触发,推荐:
// ECHOCHAT_MEETING_HOST_GRACE_SECONDS=3
// ECHOCHAT_MEETING_EMPTY_ROOM_TTL_SECONDS=3
// ECHOCHAT_MEETING_CLEANUP_INTERVAL_SECONDS=1
// 然后启动 go-service
// - media-server 必须已启动(端口 3300
import WebSocket from 'ws';
import { createClient } from 'redis';
const GO = 'http://localhost:8085/api/v1';
const NODE = 'http://localhost:3300';
const NODE_TOKEN = 'dev-token-abcdef1234567890';
// 兼容上游通过环境变量传入缩短后的时长(默认与 E2E 推荐一致)
const HOST_GRACE_SECONDS = Number(process.env.ECHOCHAT_MEETING_HOST_GRACE_SECONDS || 3);
const EMPTY_TTL_SECONDS = Number(process.env.ECHOCHAT_MEETING_EMPTY_ROOM_TTL_SECONDS || 3);
const CLEANUP_SECONDS = Number(process.env.ECHOCHAT_MEETING_CLEANUP_INTERVAL_SECONDS || 1);
let pass = 0;
let fail = 0;
const assert = (cond, name, detail) => {
if (cond) {
pass++;
console.log(`PASS: ${name}`);
} else {
fail++;
console.log(`FAIL: ${name}`, detail ?? '');
}
};
// ============ HTTP utils ============
async function httpJSON(method, url, body, headers = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json', ...headers } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let data = null;
try { data = JSON.parse(text); } catch (_) { /* non-json */ }
return { status: r.status, data, text };
}
async function register(suffix) {
const email = `t8user${suffix}@test.local`;
const password = 'TestPassword123!';
const username = `t8user${suffix}`;
await httpJSON('POST', `${GO}/auth/register`, { email, username, password });
const login = await httpJSON('POST', `${GO}/auth/login`, { account: email, password });
if (!login.data?.data?.token) throw new Error(`login failed ${JSON.stringify(login)}`);
return { token: login.data.data.token, userID: login.data.data.user?.id };
}
// ============ WS utils ============
function connectWS(token) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:8085/ws?token=${token}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
setTimeout(() => reject(new Error('ws open timeout')), 5000);
});
}
let _seqCounter = 1;
function wsSend(ws, event, data) {
return new Promise((resolve) => {
const seq = _seqCounter++;
const ackEvent = `${event}.ack`;
const onMsg = (raw) => {
try {
const m = JSON.parse(raw.toString());
if (m.event === ackEvent && m.seq === seq) {
ws.off('message', onMsg);
resolve(m);
}
} catch {}
};
ws.on('message', onMsg);
ws.send(JSON.stringify({ event, seq, data, time: new Date().toISOString() }));
setTimeout(() => {
ws.off('message', onMsg);
resolve(null);
}, 6000);
});
}
// 收集指定 event 的推送消息到内存 buffer用于非 ACK 广播事件断言)
function collectPush(ws, eventName, buffer) {
const listener = (raw) => {
try {
const m = JSON.parse(raw.toString());
if (m.event === eventName) buffer.push(m);
} catch {}
};
ws.on('message', listener);
return () => ws.off('message', listener);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ============ Redis utils直接断言 key 状态,避免依赖服务日志)============
async function connectRedis() {
const c = createClient({ url: 'redis://localhost:6379/0' });
await c.connect();
return c;
}
// ============ Main ============
(async () => {
console.log('==== Task 8 Meeting Lifecycle E2E Verify ====');
console.log(`Config: HOST_GRACE=${HOST_GRACE_SECONDS}s EMPTY_TTL=${EMPTY_TTL_SECONDS}s CLEANUP=${CLEANUP_SECONDS}s`);
const redis = await connectRedis();
// 预检media-server 活着
const mHealth = await httpJSON('GET', `${NODE}/healthz`);
assert(mHealth.status === 200 && mHealth.data?.ok === true, 'media-server healthz');
const ts = Date.now().toString().slice(-6);
// ===== 场景 1host 宽限期过期自动转让 =====
console.log('\n-- Scenario 1: host grace expired → auto transfer --');
{
const a = await register(`a1${ts}`);
const b = await register(`b1${ts}`);
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
title: 'T8 Scene1', type: 1,
}, { Authorization: `Bearer ${a.token}` });
assert(createRes.status === 201, 'S1: create room ok');
const code = createRes.data?.data?.room?.room_code;
const roomID = createRes.data?.data?.room?.id;
await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
const wsA = await connectWS(a.token);
const wsB = await connectWS(b.token);
await wsSend(wsA, 'meeting.room.join', { room_code: code });
await wsSend(wsB, 'meeting.room.join', { room_code: code });
// 监听 B 上的 host.changed 广播
const hostChangedBuf = [];
const stopCollect = collectPush(wsB, 'meeting.host.changed', hostChangedBuf);
// Ahost断开 WS → 写入 host_grace key
wsA.close();
await sleep(500);
const graceExists = await redis.exists(`echo:meeting:host_grace:${code}`);
assert(graceExists === 1, 'S1: host_grace key written after host disconnect');
// 等宽限期 + cleanup 一轮
await sleep((HOST_GRACE_SECONDS + CLEANUP_SECONDS + 1) * 1000);
const graceGone = await redis.exists(`echo:meeting:host_grace:${code}`);
assert(graceGone === 0, 'S1: host_grace key cleared after expiry');
assert(hostChangedBuf.length >= 1, 'S1: meeting.host.changed broadcasted', hostChangedBuf);
if (hostChangedBuf.length > 0) {
const payload = hostChangedBuf[0]?.data;
assert(payload?.new_host_id === b.userID, 'S1: new_host_id = B', payload);
assert(payload?.auto_reason === 'host_grace_expired', 'S1: auto_reason = host_grace_expired', payload);
}
// DB 状态:通过 REST /rooms/:code 查看 host_idB 的视角)
const roomInfo = await httpJSON('GET', `${GO}/meeting/rooms/${code}`, undefined, { Authorization: `Bearer ${b.token}` });
assert(roomInfo.data?.data?.room?.host_id === b.userID, 'S1: DB host_id updated to B', roomInfo.data);
stopCollect();
wsB.close();
// 清理会议(通过新 host B
await httpJSON('POST', `${GO}/meeting/rooms/${code}/end`, {}, { Authorization: `Bearer ${b.token}` });
await sleep(300);
}
// ===== 场景 2host 宽限期内重连保留身份 =====
console.log('\n-- Scenario 2: host reconnect within grace period --');
{
const a = await register(`a2${ts}`);
const b = await register(`b2${ts}`);
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
title: 'T8 Scene2', type: 1,
}, { Authorization: `Bearer ${a.token}` });
const code = createRes.data?.data?.room?.room_code;
await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
let wsA = await connectWS(a.token);
const wsB = await connectWS(b.token);
await wsSend(wsA, 'meeting.room.join', { room_code: code });
await wsSend(wsB, 'meeting.room.join', { room_code: code });
// A 掉线 → 立刻重连(远小于 HOST_GRACE_SECONDS
wsA.close();
await sleep(500);
const graceExists = await redis.exists(`echo:meeting:host_grace:${code}`);
assert(graceExists === 1, 'S2: host_grace key written');
// 重连 + room.join 触发 OnHostReconnect
wsA = await connectWS(a.token);
await wsSend(wsA, 'meeting.room.join', { room_code: code });
await sleep(500);
const graceGone = await redis.exists(`echo:meeting:host_grace:${code}`);
assert(graceGone === 0, 'S2: host_grace key DEL on reconnect');
// 继续等过完原本的宽限期 + cleanup无 host 变更即通过
await sleep((HOST_GRACE_SECONDS + CLEANUP_SECONDS + 1) * 1000);
const roomInfo = await httpJSON('GET', `${GO}/meeting/rooms/${code}`, undefined, { Authorization: `Bearer ${a.token}` });
assert(roomInfo.data?.data?.room?.host_id === a.userID, 'S2: host identity preserved', roomInfo.data);
wsA.close();
wsB.close();
await httpJSON('POST', `${GO}/meeting/rooms/${code}/end`, {}, { Authorization: `Bearer ${a.token}` });
await sleep(300);
}
// ===== 场景 3空房 TTL 复活 =====
console.log('\n-- Scenario 3: empty_ttl revival on new join --');
{
const a = await register(`a3${ts}`);
const c = await register(`c3${ts}`);
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
title: 'T8 Scene3', type: 1,
}, { Authorization: `Bearer ${a.token}` });
const code = createRes.data?.data?.room?.room_code;
// host 自己 leave → 空房 TTL 启动
await httpJSON('POST', `${GO}/meeting/rooms/${code}/leave`, {}, { Authorization: `Bearer ${a.token}` });
await sleep(300);
const ttlExists = await redis.exists(`echo:meeting:empty_ttl:${code}`);
assert(ttlExists === 1, 'S3: empty_ttl key written on empty room');
// TTL 内 C 新加入 → 撤销 TTL房间保持 Active
const joinRes = await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${c.token}` });
assert(joinRes.status === 200, 'S3: new user join succeeds within TTL', joinRes.data);
await sleep(300);
const ttlGone = await redis.exists(`echo:meeting:empty_ttl:${code}`);
assert(ttlGone === 0, 'S3: empty_ttl key DEL on join');
const roomInfo = await httpJSON('GET', `${GO}/meeting/rooms/${code}`, undefined, { Authorization: `Bearer ${c.token}` });
assert(roomInfo.data?.data?.room?.status === 1, 'S3: room remains Active after revival', roomInfo.data);
// 清理C 结束或 leaveC 非 host走 leave→空房→TTL等其过期兜底
await httpJSON('POST', `${GO}/meeting/rooms/${code}/leave`, {}, { Authorization: `Bearer ${c.token}` });
await sleep((EMPTY_TTL_SECONDS + CLEANUP_SECONDS + 1) * 1000);
}
// ===== 场景 4空房 TTL 过期 → 自动销毁 =====
console.log('\n-- Scenario 4: empty_ttl expiry → room Ended --');
{
const a = await register(`a4${ts}`);
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
title: 'T8 Scene4', type: 1,
}, { Authorization: `Bearer ${a.token}` });
const code = createRes.data?.data?.room?.room_code;
await httpJSON('POST', `${GO}/meeting/rooms/${code}/leave`, {}, { Authorization: `Bearer ${a.token}` });
await sleep(300);
assert((await redis.exists(`echo:meeting:empty_ttl:${code}`)) === 1, 'S4: empty_ttl set');
await sleep((EMPTY_TTL_SECONDS + CLEANUP_SECONDS + 1) * 1000);
assert((await redis.exists(`echo:meeting:empty_ttl:${code}`)) === 0, 'S4: empty_ttl cleared after expiry');
// 会议已结束 → 再 join 报已结束
const b = await register(`b4${ts}`);
const joinAgain = await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
assert(joinAgain.status === 400 || joinAgain.status === 404 || joinAgain.status === 410,
'S4: join Ended room is rejected', joinAgain.data);
}
// ===== 场景 5JoinRoom 不再重复调 CreateRouter =====
console.log('\n-- Scenario 5: JoinRoom should NOT create duplicate Router --');
{
// 记录 Node 当前 Router 数(通过 /internal/info
const info0 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
const routers0 = info0.data?.stats?.routers ?? info0.data?.routers ?? 0;
const a = await register(`a5${ts}`);
const b = await register(`b5${ts}`);
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
title: 'T8 Scene5', type: 1,
}, { Authorization: `Bearer ${a.token}` });
const code = createRes.data?.data?.room?.room_code;
// 创建后应 +1 个 Router
const info1 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
const routers1 = info1.data?.stats?.routers ?? info1.data?.routers ?? 0;
assert(routers1 - routers0 === 1, 'S5: Router +1 after CreateRoom', { routers0, routers1 });
// B 加入不应再增加 Router
await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
const info2 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
const routers2 = info2.data?.stats?.routers ?? info2.data?.routers ?? 0;
assert(routers2 === routers1, 'S5: Router count unchanged after JoinRoom (no duplicate)', { routers1, routers2 });
// 清理
await httpJSON('POST', `${GO}/meeting/rooms/${code}/end`, {}, { Authorization: `Bearer ${a.token}` });
await sleep(500);
}
await redis.quit();
console.log('-----');
console.log(`PASS=${pass} FAIL=${fail}`);
process.exit(fail > 0 ? 1 : 0);
})().catch((err) => {
console.error('script error:', err);
process.exit(2);
});