page 전체 코드 품질/보안 개선 및 봇 RPC 검증 정합
[보안/인증] - 모든 player/queue API 라우트에 세션 가드 추가 (이전: /api/servers 만 보호) - NextAuth 환경변수 부팅 시점 검증, NEXTAUTH_SECRET 명시 - next.config.ts CSP/보안 헤더 추가, 잘못된 allowedDevOrigins 제거 - Redis 호스트 하드코딩 IP 제거(필수 env 로 강제) [안정성] - 봇 RPC 패턴(@/lib/api) 공용화: crypto.randomUUID requestId, JSON.parse 안전, EXPIRE 자동, 폴링 백오프 - SSE(@/lib/sse) 공용화: subscriber error 처리, JSON.parse 안전, 30초 keep-alive, abort/에러 정리 - pause API 양 끝(boolean) 정상화: 프론트 String() 캐스트 + 백엔드 .trim().toLowerCase() 비교 제거 - 봇 RedisClient: isPaused/index/seek/volume falsy 거부 → typeof 검사로 교체(0/false 정상 허용) [타입/품질] - next-auth 모듈 보강 → session.user.id, session.accessToken 타입 안전 - DiscordServer/Track/SearchTrack 공용 타입 도입, 컴포넌트 any 제거 - BigInt permissions 안전 검증(타입 가드) - Logger: NODE_ENV 게이트, error → stderr, ISO 기반 안전 timestamp - tsconfig target → ES2020 (BigInt 리터럴) [취약점] - next 16.2.2 → 16.2.4 (DoS/postcss XSS 패치) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -142,7 +142,8 @@ class RedisClientClass {
|
|||||||
const resultKey = `queue:remove:${data.requestId}`;
|
const resultKey = `queue:remove:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
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를 찾을수 없습니다." }));
|
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." }));
|
// index 는 number(0 도 유효) — typeof 검증으로 변경.
|
||||||
|
if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
|
||||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||||
if (!context.ok) return;
|
if (!context.ok) return;
|
||||||
const numIndex = Number(data.index);
|
const numIndex = Number(data.index);
|
||||||
@@ -159,7 +160,8 @@ class RedisClientClass {
|
|||||||
const resultKey = `player:paused:${data.requestId}`;
|
const resultKey = `player:paused:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
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를 찾을수 없습니다." }));
|
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." }));
|
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
|
||||||
|
if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
|
||||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||||
if (!context.ok) return;
|
if (!context.ok) return;
|
||||||
await context.player.setPause();
|
await context.player.setPause();
|
||||||
@@ -178,7 +180,8 @@ class RedisClientClass {
|
|||||||
const resultKey = `player:seek:${data.requestId}`;
|
const resultKey = `player:seek:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
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를 찾을수 없습니다." }));
|
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." }));
|
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
|
||||||
|
if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
|
||||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||||
if (!context.ok) return;
|
if (!context.ok) return;
|
||||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||||
@@ -194,7 +197,8 @@ class RedisClientClass {
|
|||||||
const resultKey = `player:volume:${data.requestId}`;
|
const resultKey = `player:volume:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
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를 찾을수 없습니다." }));
|
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." }));
|
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
|
||||||
|
if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
|
||||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||||
if (!context.ok) return;
|
if (!context.ok) return;
|
||||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// 보안 헤더
|
||||||
allowedDevOrigins: [
|
async headers() {
|
||||||
"192.168.10.13",
|
return [
|
||||||
"localhost",
|
{
|
||||||
"music.tkrmagid.kr"
|
source: "/(.*)",
|
||||||
]
|
headers: [
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{
|
||||||
|
key: "Permissions-Policy",
|
||||||
|
value: "camera=(), microphone=(), geolocation=()",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
// 디스코드 CDN(이미지)과 자기 자신만 신뢰
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"img-src 'self' data: https://cdn.discordapp.com https://i.scdn.co https://i.ytimg.com https://lh3.googleusercontent.com",
|
||||||
|
"script-src 'self' 'unsafe-inline'" + (process.env.NODE_ENV === "production" ? "" : " 'unsafe-eval'"),
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"connect-src 'self' https://discord.com",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self' https://discord.com",
|
||||||
|
].join("; "),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
204
page/package-lock.json
generated
204
page/package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "^16.2.4",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.2",
|
"eslint-config-next": "^16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -611,9 +611,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -630,9 +627,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -649,9 +643,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -668,9 +659,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -687,9 +675,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -706,9 +691,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -725,9 +707,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -744,9 +723,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -763,9 +739,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -788,9 +761,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -813,9 +783,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -838,9 +805,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -863,9 +827,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -888,9 +849,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -913,9 +871,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -938,9 +893,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1102,15 +1054,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
|
||||||
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
|
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
|
||||||
"integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==",
|
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1118,9 +1070,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
|
||||||
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
|
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1134,9 +1086,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
|
||||||
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
|
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1150,15 +1102,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
|
||||||
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
|
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1169,15 +1118,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
|
||||||
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
|
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1188,15 +1134,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
|
||||||
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
|
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1207,15 +1150,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
|
||||||
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
|
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1226,9 +1166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
|
||||||
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
|
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1242,9 +1182,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||||
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
|
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1463,9 +1403,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1483,9 +1420,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1503,9 +1437,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1523,9 +1454,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2076,9 +2004,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2093,9 +2018,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2110,9 +2032,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2127,9 +2046,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2144,9 +2060,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2161,9 +2074,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2178,9 +2088,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2195,9 +2102,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3270,13 +3174,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
|
||||||
"integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==",
|
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "16.2.2",
|
"@next/eslint-plugin-next": "16.2.4",
|
||||||
"eslint-import-resolver-node": "^0.3.6",
|
"eslint-import-resolver-node": "^0.3.6",
|
||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
@@ -4873,9 +4777,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4897,9 +4798,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4921,9 +4819,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4945,9 +4840,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5185,12 +5077,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.2",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
|
||||||
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
|
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.2",
|
"@next/env": "16.2.4",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -5204,14 +5096,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.2",
|
"@next/swc-darwin-arm64": "16.2.4",
|
||||||
"@next/swc-darwin-x64": "16.2.2",
|
"@next/swc-darwin-x64": "16.2.4",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.2",
|
"@next/swc-linux-arm64-gnu": "16.2.4",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.2",
|
"@next/swc-linux-arm64-musl": "16.2.4",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.2",
|
"@next/swc-linux-x64-gnu": "16.2.4",
|
||||||
"@next/swc-linux-x64-musl": "16.2.2",
|
"@next/swc-linux-x64-musl": "16.2.4",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.2",
|
"@next/swc-win32-arm64-msvc": "16.2.4",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.2",
|
"@next/swc-win32-x64-msvc": "16.2.4",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "^16.2.4",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.2",
|
"eslint-config-next": "^16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||||
import DiscordProvider from "next-auth/providers/discord";
|
import DiscordProvider from "next-auth/providers/discord";
|
||||||
|
|
||||||
|
// 환경변수 부팅 시점 검증 — 누락 시 즉시 실패
|
||||||
|
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID?.trim();
|
||||||
|
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET?.trim();
|
||||||
|
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET?.trim();
|
||||||
|
|
||||||
|
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
|
||||||
|
throw new Error("[NextAuth] DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET 환경변수가 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
if (!NEXTAUTH_SECRET) {
|
||||||
|
throw new Error("[NextAuth] NEXTAUTH_SECRET 환경변수가 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordProfile {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
|
secret: NEXTAUTH_SECRET,
|
||||||
providers: [
|
providers: [
|
||||||
DiscordProvider({
|
DiscordProvider({
|
||||||
clientId: process.env.DISCORD_CLIENT_ID as string,
|
clientId: DISCORD_CLIENT_ID,
|
||||||
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
|
clientSecret: DISCORD_CLIENT_SECRET,
|
||||||
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
|
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
|
||||||
authorization: { params: { scope: "identify email guilds" } },
|
authorization: { params: { scope: "identify email guilds" } },
|
||||||
}),
|
}),
|
||||||
@@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile }) {
|
||||||
if (account && (profile as any)?.id) {
|
const discordProfile = profile as DiscordProfile | undefined;
|
||||||
token.id = (profile as any).id;
|
if (account && discordProfile?.id) {
|
||||||
|
token.id = discordProfile.id;
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }: any) {
|
async session({ session, token }) {
|
||||||
session.user.id = token.id;
|
if (token.id) session.user.id = token.id;
|
||||||
session.accessToken = token.accessToken;
|
if (token.accessToken) session.accessToken = token.accessToken;
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,54 +1,10 @@
|
|||||||
// src/app/api/queue/events/route.ts
|
// src/app/api/player/events/route.ts
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
|
import { botEventStream } from "@/lib/sse";
|
||||||
|
|
||||||
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
// 프론트엔드에서 보낸 serverId 가져오기
|
return botEventStream(req, { botEventName: "player_update" });
|
||||||
const serverId = req.nextUrl.searchParams.get("serverId");
|
|
||||||
|
|
||||||
if (!serverId) {
|
|
||||||
return new Response("Missing serverId", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE(Server-Sent Events) 스트림 생성
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
async start(controller) {
|
|
||||||
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
|
|
||||||
const subscriber = Redis.duplicate();
|
|
||||||
|
|
||||||
// 'bot-site' 채널 구독
|
|
||||||
await subscriber.subscribe("bot-site");
|
|
||||||
|
|
||||||
// 메세지가 들어올 때마다 실행
|
|
||||||
subscriber.on("message", (channel, message) => {
|
|
||||||
if (channel !== "bot-site") return;
|
|
||||||
const data = JSON.parse(message);
|
|
||||||
if (data.guildId !== serverId) return;
|
|
||||||
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
|
|
||||||
if (data.event === "player_update") {
|
|
||||||
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
|
|
||||||
controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
|
|
||||||
req.signal.addEventListener("abort", () => {
|
|
||||||
subscriber.unsubscribe("bot-site");
|
|
||||||
subscriber.quit();
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,34 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface NowBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<NowBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_now' 명령 전송
|
const { status, body } = await botRpc({
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
channel: "player:now",
|
||||||
action: "player_now",
|
payload: {
|
||||||
requestId: requestId,
|
action: "player_now",
|
||||||
serverId: serverId,
|
serverId: serverIdResult.value,
|
||||||
userId: userId,
|
userId,
|
||||||
}));
|
},
|
||||||
|
});
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
return NextResponse.json(body, { status });
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
|
||||||
const botReply = await Redis.get(resultKey);
|
|
||||||
if (botReply) {
|
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
|
||||||
await Redis.del(resultKey);
|
|
||||||
const replyData = JSON.parse(botReply);
|
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Play API Error:", error);
|
Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,46 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import {
|
||||||
|
botRpc,
|
||||||
|
errorResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireBoolean,
|
||||||
|
requireSession,
|
||||||
|
requireString,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
interface PauseBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
isPaused?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId, isPaused } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const userId = sessionResult.session.user.id;
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
|
||||||
if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const bodyResult = await readJsonBody<PauseBody>(request);
|
||||||
const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_pause' 명령 전송
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
action: "player_paused",
|
|
||||||
requestId: requestId,
|
|
||||||
serverId: serverId,
|
|
||||||
userId: userId,
|
|
||||||
isPaused: isPaused,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused");
|
||||||
for (let i = 0; i < 15; i++) {
|
if (!isPausedResult.ok) return isPausedResult.response;
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
|
||||||
const botReply = await Redis.get(resultKey);
|
|
||||||
if (botReply) {
|
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
|
||||||
await Redis.del(resultKey);
|
|
||||||
const replyData = JSON.parse(botReply);
|
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
const { status, body } = await botRpc({
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
channel: "player:paused",
|
||||||
|
payload: {
|
||||||
|
action: "player_paused",
|
||||||
|
serverId: serverIdResult.value,
|
||||||
|
userId,
|
||||||
|
isPaused: isPausedResult.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Play API Error:", error);
|
Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,39 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface PlayBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
track?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId, track } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<PlayBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_play' 명령 전송
|
const track = bodyResult.data.track;
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다.");
|
||||||
action: "player_play",
|
|
||||||
requestId: requestId,
|
|
||||||
serverId: serverId,
|
|
||||||
userId: userId,
|
|
||||||
track: track,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
const { status, body } = await botRpc({
|
||||||
for (let i = 0; i < 15; i++) {
|
channel: "player:play",
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
payload: {
|
||||||
const botReply = await Redis.get(resultKey);
|
action: "player_play",
|
||||||
if (botReply) {
|
serverId: serverIdResult.value,
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
userId,
|
||||||
await Redis.del(resultKey);
|
track,
|
||||||
const replyData = JSON.parse(botReply);
|
},
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
});
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
return NextResponse.json(body, { status });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Play API Error:", error);
|
Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,39 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface PlaylistBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
playlistUrl?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId, playlistUrl } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<PlaylistBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_playlist' 명령 전송
|
const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl");
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
if (!urlResult.ok) return urlResult.response;
|
||||||
action: "player_playlist",
|
|
||||||
requestId: requestId,
|
|
||||||
serverId: serverId,
|
|
||||||
userId: userId,
|
|
||||||
playlistUrl: playlistUrl,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
const { status, body } = await botRpc({
|
||||||
for (let i = 0; i < 15; i++) {
|
channel: "player:playlist",
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
payload: {
|
||||||
const botReply = await Redis.get(resultKey);
|
action: "player_playlist",
|
||||||
if (botReply) {
|
serverId: serverIdResult.value,
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
userId,
|
||||||
await Redis.del(resultKey);
|
playlistUrl: urlResult.value,
|
||||||
const replyData = JSON.parse(botReply);
|
},
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
});
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
return NextResponse.json(body, { status });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Queue Adds API Error:", error);
|
Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,47 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import {
|
||||||
|
botRpc,
|
||||||
|
errorResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireNumber,
|
||||||
|
requireSession,
|
||||||
|
requireString,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
interface SeekBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
seek?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId, seek } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<SeekBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_seek' 명령 전송
|
// seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용.
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true });
|
||||||
action: "player_seek",
|
if (!seekResult.ok) return seekResult.response;
|
||||||
requestId: requestId,
|
|
||||||
serverId: serverId,
|
|
||||||
userId: userId,
|
|
||||||
seek: seek,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
const { status, body } = await botRpc({
|
||||||
for (let i = 0; i < 15; i++) {
|
channel: "player:seek",
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
payload: {
|
||||||
const botReply = await Redis.get(resultKey);
|
action: "player_seek",
|
||||||
if (botReply) {
|
serverId: serverIdResult.value,
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
userId,
|
||||||
await Redis.del(resultKey);
|
seek: seekResult.value,
|
||||||
const replyData = JSON.parse(botReply);
|
},
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
});
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
return NextResponse.json(body, { status });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Play API Error:", error);
|
Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,34 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface SkipBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<SkipBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_skip' 명령 전송
|
const { status, body } = await botRpc({
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
channel: "player:skip",
|
||||||
action: "player_skip",
|
payload: {
|
||||||
requestId: requestId,
|
action: "player_skip",
|
||||||
serverId: serverId,
|
serverId: serverIdResult.value,
|
||||||
userId: userId,
|
userId,
|
||||||
}));
|
},
|
||||||
|
});
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
return NextResponse.json(body, { status });
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
|
||||||
const botReply = await Redis.get(resultKey);
|
|
||||||
if (botReply) {
|
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
|
||||||
await Redis.del(resultKey);
|
|
||||||
const replyData = JSON.parse(botReply);
|
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Play API Error:", error);
|
Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,50 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import {
|
||||||
|
botRpc,
|
||||||
|
errorResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireNumber,
|
||||||
|
requireSession,
|
||||||
|
requireString,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
interface VolumeBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
volume?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId, volume } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<VolumeBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'player_volume' 명령 전송
|
const volumeResult = requireNumber(bodyResult.data.volume, "volume", {
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
min: 0,
|
||||||
action: "player_volume",
|
max: 100,
|
||||||
requestId: requestId,
|
integer: true,
|
||||||
serverId: serverId,
|
});
|
||||||
userId: userId,
|
if (!volumeResult.ok) return volumeResult.response;
|
||||||
volume: volume,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
const { status, body } = await botRpc({
|
||||||
for (let i = 0; i < 15; i++) {
|
channel: "player:volume",
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
payload: {
|
||||||
const botReply = await Redis.get(resultKey);
|
action: "player_volume",
|
||||||
if (botReply) {
|
serverId: serverIdResult.value,
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
userId,
|
||||||
await Redis.del(resultKey);
|
volume: volumeResult.value,
|
||||||
const replyData = JSON.parse(botReply);
|
},
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
});
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
return NextResponse.json(body, { status });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Play API Error:", error);
|
Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,54 +1,10 @@
|
|||||||
// src/app/api/queue/events/route.ts
|
// src/app/api/queue/events/route.ts
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
|
import { botEventStream } from "@/lib/sse";
|
||||||
|
|
||||||
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
// 프론트엔드에서 보낸 serverId 가져오기
|
return botEventStream(req, { botEventName: "queue_update" });
|
||||||
const serverId = req.nextUrl.searchParams.get("serverId");
|
|
||||||
|
|
||||||
if (!serverId) {
|
|
||||||
return new Response("Missing serverId", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE(Server-Sent Events) 스트림 생성
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
async start(controller) {
|
|
||||||
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
|
|
||||||
const subscriber = Redis.duplicate();
|
|
||||||
|
|
||||||
// 'bot-site' 채널 구독
|
|
||||||
await subscriber.subscribe("bot-site");
|
|
||||||
|
|
||||||
// 메세지가 들어올 때마다 실행
|
|
||||||
subscriber.on("message", (channel, message) => {
|
|
||||||
if (channel !== "bot-site") return;
|
|
||||||
const data = JSON.parse(message);
|
|
||||||
if (data.guildId !== serverId) return;
|
|
||||||
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
|
|
||||||
if (data.event === "queue_update") {
|
|
||||||
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
|
|
||||||
controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
|
|
||||||
req.signal.addEventListener("abort", () => {
|
|
||||||
subscriber.unsubscribe("bot-site");
|
|
||||||
subscriber.quit();
|
|
||||||
controller.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,34 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface QueueListBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, userId } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<QueueListBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
|
|
||||||
// 1. 고유한 요청 ID(진동벨) 생성
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
const resultKey = `queue:list:${requestId}`;
|
|
||||||
|
|
||||||
// 2. 봇에게 'queue_list' 명령 발송
|
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
|
||||||
action: "queue_list",
|
|
||||||
serverId: serverId,
|
|
||||||
userId: userId,
|
|
||||||
requestId: requestId, // 🌟 봇이 대답을 남길 키
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
|
||||||
const botReply = await Redis.get(resultKey);
|
|
||||||
if (botReply) {
|
|
||||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
|
||||||
await Redis.del(resultKey);
|
|
||||||
const replyData = JSON.parse(botReply);
|
|
||||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
|
||||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3초가 지나도 봇이 묵묵부답일 때
|
|
||||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
|
||||||
|
|
||||||
|
const { status, body } = await botRpc({
|
||||||
|
channel: "queue:list",
|
||||||
|
payload: {
|
||||||
|
action: "queue_list",
|
||||||
|
serverId: serverIdResult.value,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Queue List API Error:", error);
|
Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,48 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import {
|
||||||
|
botRpc,
|
||||||
|
errorResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireNumber,
|
||||||
|
requireSession,
|
||||||
|
requireString,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
interface QueueRemoveBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
index?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, index, userId } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<QueueRemoveBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `queue:remove:${requestId}`;
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라)
|
// index 0 도 정상값
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true });
|
||||||
action: "queue_remove",
|
if (!indexResult.ok) return indexResult.response;
|
||||||
serverId: serverId,
|
|
||||||
requestId: requestId,
|
|
||||||
userId: userId,
|
|
||||||
index: index,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
const { status, body } = await botRpc({
|
||||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
channel: "queue:remove",
|
||||||
for (let i = 0; i < 10; i++) {
|
payload: {
|
||||||
// 0.5초 대기
|
action: "queue_remove",
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
serverId: serverIdResult.value,
|
||||||
|
userId,
|
||||||
// Redis 게시판 확인
|
index: indexResult.value,
|
||||||
const resultData = await Redis.get(resultKey);
|
},
|
||||||
|
timeoutMs: 5000,
|
||||||
if (resultData) {
|
});
|
||||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
return NextResponse.json(body, { status });
|
||||||
return NextResponse.json(JSON.parse(resultData));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5초가 지나도 응답이 없으면 타임아웃
|
|
||||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,40 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface QueueSetBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
newQueue?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const sessionResult = await requireSession();
|
||||||
const { serverId, newQueue, userId } = body;
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
const bodyResult = await readJsonBody<QueueSetBody>(request);
|
||||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 });
|
|
||||||
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
const resultKey = `queue:set:${requestId}`;
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
// 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!)
|
const newQueue = bodyResult.data.newQueue;
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다.");
|
||||||
action: "queue_set",
|
|
||||||
serverId: serverId,
|
|
||||||
requestId: requestId,
|
|
||||||
userId: userId,
|
|
||||||
newQueue: newQueue,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
const { status, body } = await botRpc({
|
||||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
channel: "queue:set",
|
||||||
for (let i = 0; i < 10; i++) {
|
payload: {
|
||||||
// 0.5초 대기
|
action: "queue_set",
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
serverId: serverIdResult.value,
|
||||||
|
userId,
|
||||||
// Redis 게시판 확인
|
newQueue,
|
||||||
const resultData = await Redis.get(resultKey);
|
},
|
||||||
|
timeoutMs: 5000,
|
||||||
if (resultData) {
|
});
|
||||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
return NextResponse.json(body, { status });
|
||||||
return NextResponse.json(JSON.parse(resultData));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5초가 지나도 응답이 없으면 타임아웃
|
|
||||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Queue Reorder API Error:", error);
|
Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,30 @@
|
|||||||
// src/app/api/search/route.ts
|
// src/app/api/search/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, requireSession } from "@/lib/api";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
// 1. 검색어(query) 가져오기
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const sessionResult = await requireSession();
|
||||||
const query = searchParams.get("q");
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
|
||||||
if (!query) {
|
const { searchParams } = new URL(request.url);
|
||||||
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 });
|
const query = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
|
if (!query) return errorResponse("검색어가 없습니다.", 400);
|
||||||
|
|
||||||
|
const { status, body } = await botRpc({
|
||||||
|
channel: "search",
|
||||||
|
payload: {
|
||||||
|
action: "search",
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
timeoutMs: 10000,
|
||||||
|
pollIntervalMs: 250,
|
||||||
|
});
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`search API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
||||||
const resultKey = `search:${requestId}`;
|
|
||||||
|
|
||||||
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
|
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
|
||||||
action: "search",
|
|
||||||
query: query,
|
|
||||||
requestId: requestId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
|
||||||
// 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다.
|
|
||||||
for (let i=0; i<10; i++) {
|
|
||||||
// 1.0초 대기
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Redis 게시판 확인
|
|
||||||
const resultData = await Redis.get(resultKey);
|
|
||||||
|
|
||||||
if (resultData) {
|
|
||||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
|
||||||
return NextResponse.json(JSON.parse(resultData));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5초가 지나도 응답이 없으면 타임아웃
|
|
||||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,58 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { Redis } from "@/lib/Redis";
|
import { Redis } from "@/lib/Redis";
|
||||||
import { authOptions } from "../auth/[...nextauth]/route";
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { requireSession } from "@/lib/api";
|
||||||
|
|
||||||
|
interface DiscordGuild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
owner: boolean;
|
||||||
|
permissions: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await getServerSession(authOptions) as any;
|
const sessionResult = await requireSession();
|
||||||
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
|
||||||
if (!session || !session.accessToken) {
|
const accessToken = sessionResult.session.accessToken;
|
||||||
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
|
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
|
||||||
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
|
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
const userGuilds = await userGuildsRes.json() ?? [];
|
if (!userGuildsRes.ok) {
|
||||||
|
Logger.warn(`Discord guilds API ${userGuildsRes.status} ${userGuildsRes.statusText}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Discord 서버 목록을 가져오지 못했습니다." },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const userGuildsRaw: unknown = await userGuildsRes.json();
|
||||||
|
const userGuilds: DiscordGuild[] = Array.isArray(userGuildsRaw) ? (userGuildsRaw as DiscordGuild[]) : [];
|
||||||
|
|
||||||
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
|
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
|
||||||
const botGuildsData = await Redis.get("bot-guilds");
|
const botGuildsData = await Redis.get("bot-guilds");
|
||||||
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : [];
|
let botGuildIds: string[] = [];
|
||||||
|
if (botGuildsData) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(botGuildsData);
|
||||||
|
if (Array.isArray(parsed)) botGuildIds = parsed.filter((v): v is string => typeof v === "string");
|
||||||
|
} catch {
|
||||||
|
Logger.warn("Redis bot-guilds JSON 파싱 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
|
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
|
||||||
const filteredGuilds = userGuilds.filter((guild: any) =>
|
const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id));
|
||||||
botGuildIds.includes(guild.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(filteredGuilds);
|
return NextResponse.json(filteredGuilds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("서버 필터링 에러:", error);
|
Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar";
|
|||||||
import MainContent from "@/components/player/MainContent";
|
import MainContent from "@/components/player/MainContent";
|
||||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
import QueueSidebar from "@/components/player/QueueSidebar";
|
||||||
import PlayerBar from "@/components/player/PlayerBar";
|
import PlayerBar from "@/components/player/PlayerBar";
|
||||||
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
|
||||||
// 화면 모드 타입 정의
|
// 화면 모드 타입 정의
|
||||||
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||||
|
|
||||||
export default function MusicPlayerLayout() {
|
export default function MusicPlayerLayout() {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||||
const [selectedServer, setSelectedServer] = useState<any>(null);
|
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
||||||
@@ -33,7 +34,7 @@ export default function MusicPlayerLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 서버 선택 시
|
// 서버 선택 시
|
||||||
const handleSelectServer = (server: any) => {
|
const handleSelectServer = (server: DiscordServer) => {
|
||||||
setSelectedServer(server);
|
setSelectedServer(server);
|
||||||
setViewMode("SERVER_DETAIL");
|
setViewMode("SERVER_DETAIL");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ListMusic, Library } from "lucide-react";
|
import { ListMusic } from "lucide-react";
|
||||||
|
|
||||||
export default function LeftSidebar() {
|
export default function LeftSidebar() {
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||||
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
|
<div className="flex items-center gap-2 text-neutral-400 font-medium">
|
||||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
<ListMusic size={20} />
|
||||||
<Library size={20} /> 내 플레이리스트
|
<span>음악 봇 컨트롤</span>
|
||||||
</button>
|
</div>
|
||||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
|
||||||
<ListMusic size={20} /> 좋아요 표시한 곡
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<hr className="border-neutral-800" />
|
<hr className="border-neutral-800" />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
|
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||||
<p className="hover:text-white cursor-pointer truncate">출근길 노동요 모음</p>
|
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
|
||||||
<p className="hover:text-white cursor-pointer truncate">2024 빌보드 탑 100</p>
|
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
|
||||||
<p className="hover:text-white cursor-pointer truncate">비 오는 날 듣기 좋은 재즈</p>
|
</p>
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
||||||
import { signIn, signOut, useSession } from "next-auth/react";
|
import { signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
selectedServer: any; // 🌟 추가: 선택된 서버 정보
|
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||||
|
|||||||
@@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc }
|
|||||||
import { ViewMode } from "@/app/page";
|
import { ViewMode } from "@/app/page";
|
||||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
||||||
import { useToast } from "@/components/ToastProvider";
|
import { useToast } from "@/components/ToastProvider";
|
||||||
|
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: (mode: ViewMode) => void;
|
setViewMode: (mode: ViewMode) => void;
|
||||||
selectedServer: any;
|
selectedServer: DiscordServer | null;
|
||||||
setSelectedServer: (server: any) => void;
|
setSelectedServer: (server: DiscordServer | null) => void;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
onSelectServer: (server: any) => void;
|
onSelectServer: (server: DiscordServer) => void;
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchResultsType {
|
|
||||||
spotify: any[];
|
|
||||||
youtubeMusic: any[];
|
|
||||||
youtubeVideo: any[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MainContent({
|
export default function MainContent({
|
||||||
@@ -36,27 +31,31 @@ export default function MainContent({
|
|||||||
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const [servers, setServers] = useState<any[]>([]);
|
const [servers, setServers] = useState<DiscordServer[]>([]);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
|
||||||
const [searchResults, setSearchResults] = useState<SearchResultsType>({
|
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||||
spotify: [],
|
spotify: [],
|
||||||
youtubeMusic: [],
|
youtubeMusic: [],
|
||||||
youtubeVideo: []
|
youtubeVideo: []
|
||||||
});
|
});
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
|
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
||||||
if (!selectedServer) {
|
if (!selectedServer) {
|
||||||
// 🌟 alert -> showToast 교체
|
// 🌟 alert -> showToast 교체
|
||||||
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
showToast("로그인이 필요합니다.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
let bodyData: any = { serverId: selectedServer.id, userId: userId };
|
const bodyData: Record<string, unknown> = { serverId: selectedServer.id, userId };
|
||||||
if (actionType === 'player_play') {
|
if (actionType === 'player_play') {
|
||||||
endpoint = "/api/player/play";
|
endpoint = "/api/player/play";
|
||||||
bodyData.track = track;
|
bodyData.track = track;
|
||||||
@@ -88,13 +87,16 @@ export default function MainContent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionLabel = (server: any) => {
|
const getPermissionLabel = (server: DiscordServer | null) => {
|
||||||
if (!server) return "알 수 없음";
|
if (!server) return "알 수 없음";
|
||||||
if (server.owner) return "👑 서버 주인";
|
if (server.owner) return "👑 서버 주인";
|
||||||
|
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
|
||||||
|
const raw: unknown = server.permissions;
|
||||||
|
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
|
||||||
try {
|
try {
|
||||||
const perms = BigInt(server.permissions);
|
const perms = BigInt(raw);
|
||||||
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
|
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
|
||||||
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
|
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
|
||||||
return "👤 일반 멤버";
|
return "👤 일반 멤버";
|
||||||
} catch {
|
} catch {
|
||||||
return "👤 일반 멤버";
|
return "👤 일반 멤버";
|
||||||
@@ -159,7 +161,7 @@ export default function MainContent({
|
|||||||
|
|
||||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||||
|
|
||||||
const renderTrackCard = (track: any) => (
|
const renderTrackCard = (track: SearchTrack) => (
|
||||||
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||||
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
||||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||||
@@ -258,7 +260,7 @@ export default function MainContent({
|
|||||||
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
||||||
{viewMode === "SEARCH_RESULT" && (
|
{viewMode === "SEARCH_RESULT" && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" 검색 결과</h2>
|
<h2 className="text-2xl font-bold mb-2">“{searchQuery}” 검색 결과</h2>
|
||||||
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
|
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
|
||||||
|
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
|
|||||||
@@ -2,16 +2,17 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import type { DiscordServer, Track } from "@/types/music";
|
||||||
|
|
||||||
interface PlayerBarProps {
|
interface PlayerBarProps {
|
||||||
selectedServer: any;
|
selectedServer: DiscordServer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
// 재생 상태 관리
|
// 재생 상태 관리
|
||||||
const [track, setTrack] = useState<any>(null);
|
const [track, setTrack] = useState<Track | null>(null);
|
||||||
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [isPaused, setIsPaused] = useState<boolean>(false);
|
const [isPaused, setIsPaused] = useState<boolean>(false);
|
||||||
@@ -28,7 +29,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
const fetchNowPlaying = useCallback(async () => {
|
const fetchNowPlaying = useCallback(async () => {
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
|
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -43,12 +44,12 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok && data.success && data.track) {
|
if (res.ok && data.success && data.track) {
|
||||||
setBotPlayer(data.botPlayer);
|
setBotPlayer(Boolean(data.botPlayer));
|
||||||
setIsPlaying(data.isPlaying);
|
setIsPlaying(Boolean(data.isPlaying));
|
||||||
setIsPaused(data.isPaused);
|
setIsPaused(Boolean(data.isPaused));
|
||||||
setTrack(data.track);
|
setTrack(data.track as Track);
|
||||||
setDuration(data.track.info.length || 0);
|
setDuration(Number(data.track?.info?.length ?? 0) || 0);
|
||||||
setVolume(data.volume ?? 50);
|
setVolume(typeof data.volume === "number" ? data.volume : 50);
|
||||||
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
||||||
if (!isDragging.current) {
|
if (!isDragging.current) {
|
||||||
setPosition(data.position || 0);
|
setPosition(data.position || 0);
|
||||||
@@ -67,41 +68,53 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
|
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
|
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
|
|
||||||
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
|
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
|
||||||
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
|
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
try {
|
||||||
if (data.type === "player_update") {
|
const data = JSON.parse(event.data);
|
||||||
fetchNowPlaying();
|
if (data?.type === "player_update") {
|
||||||
|
fetchNowPlaying();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("SSE JSON 파싱 실패:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error("Player SSE 연결 오류:", error);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
return () => eventSource.close();
|
return () => eventSource.close();
|
||||||
}, [selectedServer, fetchNowPlaying]);
|
}, [selectedServer, fetchNowPlaying]);
|
||||||
|
|
||||||
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||||
|
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
|
||||||
|
const isPausedRef = useRef(isPaused);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let localInterval: NodeJS.Timeout;
|
isPausedRef.current = isPaused;
|
||||||
let syncInterval: NodeJS.Timeout;
|
}, [isPaused]);
|
||||||
|
|
||||||
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
|
useEffect(() => {
|
||||||
if (isPlaying && !isDragging.current) {
|
if (!isPlaying) return;
|
||||||
|
|
||||||
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||||
localInterval = setInterval(() => {
|
const localInterval = setInterval(() => {
|
||||||
if (!isPaused) setPosition((prev) => {
|
if (isPausedRef.current || isDragging.current) return;
|
||||||
if (prev >= duration) return duration;
|
setPosition((prev) => {
|
||||||
return prev + 1000;
|
if (prev >= duration) return duration;
|
||||||
});
|
return prev + 1000;
|
||||||
}, 1000);
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||||
syncInterval = setInterval(() => {
|
const syncInterval = setInterval(() => {
|
||||||
if (!isPaused) fetchNowPlaying();
|
if (isPausedRef.current || isDragging.current) return;
|
||||||
}, 10000);
|
fetchNowPlaying();
|
||||||
|
}, 10000);
|
||||||
}
|
|
||||||
|
|
||||||
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||||
return () => {
|
return () => {
|
||||||
@@ -117,9 +130,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
const handleTogglePause = async () => {
|
const handleTogglePause = async () => {
|
||||||
if (!selectedServer || !track) return;
|
if (!selectedServer || !track) return;
|
||||||
if (!isPlaying) return;
|
if (!isPlaying) return;
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
const nextPaused = !isPaused;
|
||||||
// UI 즉각 반영 (Optimistic UI)
|
// UI 즉각 반영 (Optimistic UI)
|
||||||
setIsPaused(!isPaused);
|
setIsPaused(nextPaused);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/player/pause', {
|
const res = await fetch('/api/player/pause', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -127,27 +142,28 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
serverId: selectedServer.id,
|
serverId: selectedServer.id,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
isPaused: String(isPaused),
|
isPaused: nextPaused, // boolean 그대로 전송
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
|
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
|
||||||
setIsPaused(true);
|
if (typeof data.paused === "boolean") setIsPaused(data.paused);
|
||||||
} else {
|
} else {
|
||||||
setIsPaused(false);
|
// 실패 시 롤백
|
||||||
}
|
setIsPaused(!nextPaused);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("일시정지 에러:", error);
|
console.error("일시정지 에러:", error);
|
||||||
setIsPaused(!isPaused); // 실패 시 롤백
|
setIsPaused(!nextPaused); // 실패 시 롤백
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다음 곡 스킵 (player_skip)
|
// 다음 곡 스킵 (player_skip)
|
||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
if (!selectedServer || !track) return;
|
if (!selectedServer || !track) return;
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/player/skip', {
|
await fetch('/api/player/skip', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -167,7 +183,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
|
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
|
||||||
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||||
if (!selectedServer || !track) return;
|
if (!selectedServer || !track) return;
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
|
|
||||||
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
||||||
@@ -198,7 +215,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
|||||||
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||||
setIsVolumeDragging(false);
|
setIsVolumeDragging(false);
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return;
|
||||||
const finalVolume = Number(e.currentTarget.value);
|
const finalVolume = Number(e.currentTarget.value);
|
||||||
setVolume(finalVolume); // UI 즉시 반영
|
setVolume(finalVolume); // UI 즉시 반영
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import { Trash2, GripVertical, Music } from "lucide-react";
|
import { Trash2, GripVertical, Music } from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useToast } from "@/components/ToastProvider";
|
import { useToast } from "@/components/ToastProvider";
|
||||||
|
import type { DiscordServer, Track } from "@/types/music";
|
||||||
|
|
||||||
interface QueueSidebarProps {
|
interface QueueSidebarProps {
|
||||||
selectedServer: any;
|
selectedServer: DiscordServer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||||
@@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
// 👇 [추가] 토스트 사용 준비 완료!
|
// 👇 [추가] 토스트 사용 준비 완료!
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const [queue, setQueue] = useState<any[]>([]);
|
const [queue, setQueue] = useState<Track[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
@@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
const fetchQueue = useCallback(async () => {
|
const fetchQueue = useCallback(async () => {
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
|
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/queue/list', {
|
const res = await fetch('/api/queue/list', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -41,29 +40,33 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok && data.success && Array.isArray(data.queue)) {
|
if (res.ok && data.success && Array.isArray(data.queue)) {
|
||||||
setQueue(data.queue);
|
setQueue(data.queue as Track[]);
|
||||||
} else {
|
} else {
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("큐 불러오기 실패:", error);
|
console.error("큐 불러오기 실패:", error);
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [selectedServer, session]);
|
}, [selectedServer, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "loading" || !selectedServer) return;
|
if (status === "loading" || !selectedServer) return;
|
||||||
|
|
||||||
|
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
fetchQueue();
|
fetchQueue();
|
||||||
|
|
||||||
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
|
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
try {
|
||||||
if (data.type === "queue_update") {
|
const data = JSON.parse(event.data);
|
||||||
fetchQueue();
|
if (data?.type === "queue_update") {
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("SSE JSON 파싱 실패:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
|
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
// 3. 화면 즉시 업데이트
|
// 3. 화면 즉시 업데이트
|
||||||
const newQueue = [...queue];
|
const newQueue = [...queue];
|
||||||
@@ -139,7 +142,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
|||||||
|
|
||||||
const handleDelete = async (indexToRemove: number) => {
|
const handleDelete = async (indexToRemove: number) => {
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
||||||
setQueue(newQueue);
|
setQueue(newQueue);
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
import colors from "colors/safe";
|
import colors from "colors/safe";
|
||||||
|
|
||||||
|
// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출.
|
||||||
export const Timestamp = () => {
|
export const Timestamp = () => {
|
||||||
const Now = new Date();
|
const now = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||||
Now.setHours(Now.getHours() + 9);
|
// YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss
|
||||||
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
|
const iso = now.toISOString();
|
||||||
}
|
const date = iso.slice(2, 10); // YY-MM-DD
|
||||||
|
const time = iso.slice(11, 19); // HH:mm:ss
|
||||||
|
return `${date} ${time}`;
|
||||||
|
};
|
||||||
|
|
||||||
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
type LogType = "log" | "info" | "warn" | "error" | "debug" | "ready";
|
||||||
|
|
||||||
const log = (content: string, type: logType) => {
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
const write = (label: string, content: string, useStderr: boolean) => {
|
||||||
const timestamp = colors.white(`[${Timestamp()}]`);
|
const timestamp = colors.white(`[${Timestamp()}]`);
|
||||||
|
const line = `${label} ${timestamp} ${content}`;
|
||||||
|
if (useStderr) console.error(line);
|
||||||
|
else console.log(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = (content: string, type: LogType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "log":
|
case "log":
|
||||||
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
|
// 일반 디버그성 로그는 프로덕션에서 숨김
|
||||||
|
if (isProd) return;
|
||||||
|
return write(colors.gray("[LOG]"), content, false);
|
||||||
case "info":
|
case "info":
|
||||||
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
|
return write(colors.cyan("[INFO]"), content, false);
|
||||||
case "warn":
|
case "warn":
|
||||||
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
|
return write(colors.yellow("[WARN]"), content, true);
|
||||||
case "error":
|
case "error":
|
||||||
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
|
return write(colors.red("[ERROR]"), content, true);
|
||||||
case "debug":
|
case "debug":
|
||||||
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
|
if (isProd) return;
|
||||||
|
return write(colors.magenta("[DEBUG]"), content, false);
|
||||||
case "ready":
|
case "ready":
|
||||||
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
|
return write(colors.green("[READY]"), content, false);
|
||||||
default:
|
default:
|
||||||
throw new TypeError("Logger 타입이 올바르지 않습니다.");
|
throw new TypeError("Logger 타입이 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
@@ -34,5 +49,5 @@ export const Logger = {
|
|||||||
error: (content: string) => log(content, "error"),
|
error: (content: string) => log(content, "error"),
|
||||||
debug: (content: string) => log(content, "debug"),
|
debug: (content: string) => log(content, "debug"),
|
||||||
info: (content: string) => log(content, "info"),
|
info: (content: string) => log(content, "info"),
|
||||||
ready: (content: string) => log(content, "ready")
|
ready: (content: string) => log(content, "ready"),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Redis as RedisClass } from "ioredis";
|
import { Redis as RedisClass } from "ioredis";
|
||||||
|
import { Logger } from "@/lib/Logger";
|
||||||
|
|
||||||
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅)
|
// Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다.
|
||||||
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7";
|
const REDIS_HOST = process.env.REDIS_HOST?.trim();
|
||||||
|
const REDIS_PORT = Number(process.env.REDIS_PORT?.trim() ?? "6379");
|
||||||
|
|
||||||
|
if (!REDIS_HOST) {
|
||||||
|
throw new Error("[Redis] REDIS_HOST 환경변수가 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(REDIS_PORT) || REDIS_PORT <= 0) {
|
||||||
|
throw new Error(`[Redis] REDIS_PORT 값이 올바르지 않습니다: ${process.env.REDIS_PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
|
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
|
||||||
const globalForRedis = global as unknown as {
|
const globalForRedis = global as unknown as {
|
||||||
@@ -9,16 +18,24 @@ const globalForRedis = global as unknown as {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
|
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
|
||||||
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 });
|
export const Redis = globalForRedis.redisClient ?? new RedisClass({
|
||||||
|
host: REDIS_HOST,
|
||||||
|
port: REDIS_PORT,
|
||||||
|
lazyConnect: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
});
|
||||||
|
|
||||||
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
|
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
|
||||||
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
|
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
|
||||||
|
|
||||||
// 연결 성공 시 로그 한 번만 찍기
|
// 연결 성공 시 로그 한 번만 찍기
|
||||||
|
let connectLogged = false;
|
||||||
Redis.on("connect", () => {
|
Redis.on("connect", () => {
|
||||||
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!");
|
if (connectLogged) return;
|
||||||
|
connectLogged = true;
|
||||||
|
Logger.ready(`Redis 연결 완료 (${REDIS_HOST}:${REDIS_PORT})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
Redis.on("error", (err) => {
|
Redis.on("error", (err) => {
|
||||||
console.error("❌ [Next.js] Redis 연결 에러:", err);
|
Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
});
|
});
|
||||||
163
page/src/lib/api.ts
Normal file
163
page/src/lib/api.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession, type Session } from "next-auth";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Redis } from "@/lib/Redis";
|
||||||
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||||
|
|
||||||
|
// ========== 공용 응답 스키마 ==========
|
||||||
|
// 모든 API는 { success: boolean, ... } 형태로 응답한다.
|
||||||
|
// 실패시 { success: false, error: string } 보장.
|
||||||
|
|
||||||
|
export const errorResponse = (message: string, status = 400) =>
|
||||||
|
NextResponse.json({ success: false, error: message }, { status });
|
||||||
|
|
||||||
|
// ========== 세션 가드 ==========
|
||||||
|
// 세션이 없으면 401 NextResponse를 던지고, 있으면 세션을 돌려준다.
|
||||||
|
export async function requireSession(): Promise<
|
||||||
|
{ ok: true; session: Session } | { ok: false; response: NextResponse }
|
||||||
|
> {
|
||||||
|
const session = (await getServerSession(authOptions)) as Session | null;
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { ok: false, response: errorResponse("인증되지 않았습니다.", 401) };
|
||||||
|
}
|
||||||
|
return { ok: true, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 봇 RPC 헬퍼 ==========
|
||||||
|
// site → bot: Redis Pub/Sub 으로 명령 전송
|
||||||
|
// bot → site: Redis SET 으로 결과 저장 (resultKey)
|
||||||
|
// 사이트는 resultKey 를 short polling 으로 확인.
|
||||||
|
|
||||||
|
export interface BotRpcOptions {
|
||||||
|
/** Redis 결과 키 prefix (e.g. "player:now") */
|
||||||
|
channel: string;
|
||||||
|
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
/** 폴링 총 타임아웃 (ms). 기본 3000. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
|
||||||
|
resultTtlSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export async function botRpc(
|
||||||
|
opts: BotRpcOptions,
|
||||||
|
): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||||
|
const {
|
||||||
|
channel,
|
||||||
|
payload,
|
||||||
|
timeoutMs = 3000,
|
||||||
|
pollIntervalMs = 100,
|
||||||
|
resultTtlSec = 5,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
|
||||||
|
const requestId = `req:${randomUUID()}`;
|
||||||
|
const resultKey = `${channel}:${requestId}`;
|
||||||
|
|
||||||
|
// 봇에게 명령 전송 (action 필드는 호출부에서 payload 에 포함)
|
||||||
|
await Redis.publish(
|
||||||
|
"site-bot",
|
||||||
|
JSON.stringify({ ...payload, requestId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let interval = pollIntervalMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await sleep(interval);
|
||||||
|
interval = Math.min(interval * 2, 400);
|
||||||
|
|
||||||
|
const reply = await Redis.get(resultKey);
|
||||||
|
if (!reply) continue;
|
||||||
|
|
||||||
|
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
|
||||||
|
await Redis.del(resultKey);
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(reply);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`[botRpc:${channel}] 봇 응답 파싱 실패: ${String(err)}`);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
body: { success: false, error: "봇 응답을 해석할 수 없습니다." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 봇이 success 필드를 명시한 경우만 false로 평가. 없으면 데이터 응답으로 간주(200).
|
||||||
|
const hasSuccessField = "success" in parsed;
|
||||||
|
const ok = !hasSuccessField || parsed.success === true;
|
||||||
|
return { status: ok ? 200 : 400, body: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
|
||||||
|
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
|
||||||
|
try {
|
||||||
|
await Redis.expire(resultKey, resultTtlSec);
|
||||||
|
} catch {
|
||||||
|
// 무시: 정리 실패는 치명적 아님
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 504,
|
||||||
|
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== POST 본문 파싱 ==========
|
||||||
|
export async function readJsonBody<T = unknown>(
|
||||||
|
request: Request,
|
||||||
|
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
|
||||||
|
try {
|
||||||
|
const data = (await request.json()) as T;
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, response: errorResponse("요청 본문이 올바른 JSON이 아닙니다.", 400) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 필수 필드 검증 ==========
|
||||||
|
export function requireString(
|
||||||
|
value: unknown,
|
||||||
|
field: string,
|
||||||
|
): { ok: true; value: string } | { ok: false; response: NextResponse } {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
return { ok: false, response: errorResponse(`${field} 정보가 필요합니다.`, 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value: value.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireNumber(
|
||||||
|
value: unknown,
|
||||||
|
field: string,
|
||||||
|
opts?: { min?: number; max?: number; integer?: boolean },
|
||||||
|
): { ok: true; value: number } | { ok: false; response: NextResponse } {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return { ok: false, response: errorResponse(`${field} 값이 올바르지 않습니다.`, 400) };
|
||||||
|
}
|
||||||
|
if (opts?.integer && !Number.isInteger(n)) {
|
||||||
|
return { ok: false, response: errorResponse(`${field} 는 정수여야 합니다.`, 400) };
|
||||||
|
}
|
||||||
|
if (opts?.min !== undefined && n < opts.min) {
|
||||||
|
return { ok: false, response: errorResponse(`${field} 가 너무 작습니다.`, 400) };
|
||||||
|
}
|
||||||
|
if (opts?.max !== undefined && n > opts.max) {
|
||||||
|
return { ok: false, response: errorResponse(`${field} 가 너무 큽니다.`, 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value: n };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBoolean(
|
||||||
|
value: unknown,
|
||||||
|
field: string,
|
||||||
|
): { ok: true; value: boolean } | { ok: false; response: NextResponse } {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
return { ok: false, response: errorResponse(`${field} 는 boolean 이어야 합니다.`, 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
129
page/src/lib/sse.ts
Normal file
129
page/src/lib/sse.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { Redis } from "@/lib/Redis";
|
||||||
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||||
|
|
||||||
|
interface BotEvent {
|
||||||
|
event?: unknown;
|
||||||
|
guildId?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BotEventStreamOptions {
|
||||||
|
/** bot 이 발행하는 event 이름 (e.g. "player_update", "queue_update"). */
|
||||||
|
botEventName: string;
|
||||||
|
/** 클라이언트로 보낼 SSE type. 기본: botEventName 과 동일. */
|
||||||
|
clientEventType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봇이 publish 하는 "bot-site" 채널을 구독해서 SSE 로 흘려보내는 공용 핸들러.
|
||||||
|
* - 인증 가드: 세션 없으면 401
|
||||||
|
* - serverId 검증
|
||||||
|
* - JSON.parse 안전 처리
|
||||||
|
* - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리
|
||||||
|
* - keepalive ping (30초)
|
||||||
|
*/
|
||||||
|
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
|
||||||
|
const clientEventType = opts.clientEventType ?? opts.botEventName;
|
||||||
|
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return new Response("인증되지 않았습니다.", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = req.nextUrl.searchParams.get("serverId")?.trim();
|
||||||
|
if (!serverId) {
|
||||||
|
return new Response("Missing serverId", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const subscriber = Redis.duplicate();
|
||||||
|
let closed = false;
|
||||||
|
const timers: NodeJS.Timeout[] = [];
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
for (const t of timers) clearInterval(t);
|
||||||
|
try {
|
||||||
|
await subscriber.unsubscribe("bot-site");
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await subscriber.quit();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* 이미 닫혔을 수 있음 */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
subscriber.on("error", async (err) => {
|
||||||
|
Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`);
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await subscriber.subscribe("bot-site");
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`);
|
||||||
|
await cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriber.on("message", (channel, message) => {
|
||||||
|
if (channel !== "bot-site" || closed) return;
|
||||||
|
let data: BotEvent;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(message) as BotEvent;
|
||||||
|
} catch (err) {
|
||||||
|
Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.guildId !== serverId) return;
|
||||||
|
if (data.event !== opts.botEventName) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`);
|
||||||
|
void cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지)
|
||||||
|
timers.push(
|
||||||
|
setInterval(() => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`));
|
||||||
|
} catch {
|
||||||
|
void cleanup();
|
||||||
|
}
|
||||||
|
}, 30000),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 클라이언트가 연결을 끊으면 정리
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
void cleanup();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
42
page/src/types/music.ts
Normal file
42
page/src/types/music.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// 공용 도메인 타입 — 봇/Discord/Lavalink 데이터 표현.
|
||||||
|
|
||||||
|
export interface DiscordServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
owner: boolean;
|
||||||
|
permissions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackInfo {
|
||||||
|
identifier?: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
length?: number;
|
||||||
|
artworkUrl?: string;
|
||||||
|
uri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
encoded?: string;
|
||||||
|
info?: TrackInfo;
|
||||||
|
// 큐 항목에는 종종 추가 메타데이터(요청자 등)가 붙음.
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 결과(곡 카드) 공용 타입 — Spotify/YT Music/YT Video 모두 매핑됨.
|
||||||
|
export interface SearchTrack {
|
||||||
|
videoId?: string;
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResults {
|
||||||
|
spotify: SearchTrack[];
|
||||||
|
youtubeMusic: SearchTrack[];
|
||||||
|
youtubeVideo: SearchTrack[];
|
||||||
|
}
|
||||||
21
page/src/types/next-auth.d.ts
vendored
Normal file
21
page/src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import "next-auth";
|
||||||
|
import "next-auth/jwt";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
accessToken?: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2020",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user