치지직 채팅창 인식 추가 및 주사위 횟수 증가 추가.

This commit is contained in:
kimyu
2026-09-16 02:21:39 +09:00
parent 171a4e19f6
commit 7c402dfd8f
22 changed files with 2133 additions and 90 deletions

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import {
consumeDice,
getRoom,
notifyRoom,
toPublicRoom,
} from '@/lib/rooms/store';
export const runtime = 'nodejs';
type Params = { params: Promise<{ roomId: string }> };
export async function POST(request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
const body = (await request.json().catch(() => ({}))) as { count?: number };
const count = Math.max(1, Math.trunc(body.count ?? 1));
if (!room.settings.chzzkEnabled) {
return NextResponse.json({ room: toPublicRoom(room), consumed: 0 });
}
const updated = consumeDice(roomId, count);
if (!updated) {
return NextResponse.json(
{ error: '남은 주사위가 없습니다.', room: toPublicRoom(room) },
{ status: 409 },
);
}
notifyRoom(roomId);
return NextResponse.json({ room: toPublicRoom(updated), consumed: count });
}

View File

@@ -0,0 +1,83 @@
import {
cancelScheduledDisconnectRoomChat,
scheduleDisconnectRoomChat,
} from '@/lib/chzzk/chat-session';
import {
addRoomViewer,
getRoom,
removeRoomViewer,
subscribeRoom,
toPublicRoom,
type PublicRoomState,
} from '@/lib/rooms/store';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ roomId: string }> };
export async function GET(request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return new Response(JSON.stringify({ error: '방을 찾을 수 없습니다.' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const clientId =
new URL(request.url).searchParams.get('clientId')?.trim() ||
crypto.randomUUID();
const encoder = new TextEncoder();
let unsubscribe: (() => void) | null = null;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let presenceHeld = false;
const release = () => {
if (!presenceHeld) return;
presenceHeld = false;
const { emptied } = removeRoomViewer(roomId, clientId);
if (emptied) {
// 즉시 끊지 않음 — 빠른 재입장과 경쟁하지 않도록 유예
scheduleDisconnectRoomChat(roomId);
}
};
const stream = new ReadableStream({
start(controller) {
// 재입장 시 예약된 disconnect 취소
cancelScheduledDisconnectRoomChat(roomId);
addRoomViewer(roomId, clientId);
presenceHeld = true;
const send = (event: string, data: unknown) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
};
send('room', toPublicRoom(room));
unsubscribe = subscribeRoom(roomId, (state: PublicRoomState) => {
send('room', state);
});
heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`: ping\n\n`));
}, 15000);
},
cancel() {
if (unsubscribe) unsubscribe();
if (heartbeat) clearInterval(heartbeat);
release();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { scheduleDisconnectRoomChat } from '@/lib/chzzk/chat-session';
import {
getRoom,
getRoomViewerCount,
removeRoomViewer,
toPublicRoom,
} from '@/lib/rooms/store';
export const runtime = 'nodejs';
type Params = { params: Promise<{ roomId: string }> };
/**
* 탭/창 종료·페이지 이탈 시 호출.
* 마지막 시청자면 치지직 채팅 웹소켓을 유예 후 끊는다 (빠른 재입장 대비).
*/
export async function POST(request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return NextResponse.json({ ok: true, emptied: true });
}
let clientId = '';
const contentType = request.headers.get('content-type') || '';
try {
if (contentType.includes('application/json')) {
const body = (await request.json()) as { clientId?: string };
clientId = body.clientId?.trim() || '';
} else {
const text = await request.text();
if (text.startsWith('{')) {
const body = JSON.parse(text) as { clientId?: string };
clientId = body.clientId?.trim() || '';
} else {
clientId = new URLSearchParams(text).get('clientId')?.trim() || '';
}
}
} catch {
clientId = '';
}
if (!clientId) {
return NextResponse.json({ ok: true, viewers: getRoomViewerCount(roomId) });
}
const { viewers, emptied } = removeRoomViewer(roomId, clientId);
if (emptied) {
scheduleDisconnectRoomChat(roomId);
}
return NextResponse.json({
ok: true,
viewers,
emptied,
room: toPublicRoom(getRoom(roomId) ?? room),
});
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getRoom, toPublicRoom } from '@/lib/rooms/store';
export const runtime = 'nodejs';
type Params = { params: Promise<{ roomId: string }> };
export async function GET(_request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
return NextResponse.json({ room: toPublicRoom(room) });
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import {
addDiceFromDonation,
getRoom,
notifyRoom,
toPublicRoom,
} from '@/lib/rooms/store';
export const runtime = 'nodejs';
type Params = { params: Promise<{ roomId: string }> };
export async function POST(request: Request, { params }: Params) {
const { roomId } = await params;
const room = getRoom(roomId);
if (!room) {
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
}
const body = (await request.json().catch(() => ({}))) as {
payAmount?: number;
donatorNickname?: string;
};
const payAmount = Number(body.payAmount ?? 0);
if (!Number.isFinite(payAmount) || payAmount <= 0) {
return NextResponse.json({ error: 'payAmount가 필요합니다.' }, { status: 400 });
}
if (!room.settings.chzzkEnabled) {
return NextResponse.json(
{ error: '연동 없는 룸에서는 후원 시뮬레이션을 사용할 수 없습니다.' },
{ status: 400 },
);
}
const result = addDiceFromDonation(roomId, {
payAmount,
donatorNickname: body.donatorNickname || '테스트후원',
donationText: 'simulate',
simulated: true,
});
if (!result) {
return NextResponse.json({ error: '처리 실패' }, { status: 500 });
}
notifyRoom(roomId);
return NextResponse.json({
room: toPublicRoom(result.room),
diceAdded: result.diceAdded,
});
}