fix(page+bot): 리뷰 지적사항 반영 - 서버 멤버십·같은음성채널 인가(1,2), SSE 공유구독 fan-out(3), botRpc 즉시폴링(4), 큐삭제 encoded 검증(6), 진행바 anchor 타이머(7), 캐시 파싱 가드(8), key 안정화(9), 볼륨 롤백(10), SSE 자동재연결(11), body userId 정리(12)

This commit is contained in:
tkrmagid
2026-09-12 22:49:56 +09:00
parent ff391cc1c3
commit 1efa55c3ec
7 changed files with 165 additions and 116 deletions

View File

@@ -72,11 +72,14 @@ class RedisClientClass {
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
@@ -88,11 +91,14 @@ class RedisClientClass {
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
@@ -102,8 +108,10 @@ class RedisClientClass {
const resultKey = `player:now:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const nowGuild = await getGuildById(data.serverId);
if (!nowGuild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(nowGuild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.pub.setex(resultKey, 60, JSON.stringify({
success: true,
botPlayer: !!player,
@@ -118,8 +126,10 @@ class RedisClientClass {
const resultKey = `queue:list:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const qlGuild = await getGuildById(data.serverId);
if (!qlGuild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(qlGuild, data.userId))) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
}
if (data.action === "queue_set") {
@@ -152,6 +162,12 @@ class RedisClientClass {
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
// 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인.
// SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지.
const removeTarget = context.player.queue[numIndex + 1];
if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) {
return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." }));
}
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg();
@@ -232,6 +248,16 @@ class RedisClientClass {
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
}
/**
* 요청한 userId가 해당 guild의 멤버인지 확인(캐시 우선, 없으면 단건 fetch).
* 대시보드가 보낸 serverId를 그대로 신뢰하지 않기 위한 인가 검증.
*/
private async isMember(guild: Guild, userId: string): Promise<boolean> {
if (guild.members.cache.has(userId)) return true;
const fetched = await guild.members.fetch(userId).catch(() => null);
return !!fetched;
}
private async getContext(guildId: string, resultKey: string, userId: string): Promise<{
ok: true;
guild: Guild;
@@ -243,6 +269,11 @@ class RedisClientClass {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
return { ok: false };
}
// 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단)
if (!(await this.isMember(guild, userId))) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
return { ok: false };
}
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, userId);
if (!player) {
@@ -251,6 +282,10 @@ class RedisClientClass {
return { ok: false };
}
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
// 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
return { ok: false };
}
if (!player) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));