From df8743a80b5f238bd9a501abd25d1a636d502b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9D=B8=EC=84=AD?= Date: Wed, 19 Nov 2025 23:25:21 +0900 Subject: [PATCH] =?UTF-8?q?SQLite=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SQL_VIEWER_GUIDE.md | 351 ++++++++++++++++++++++++++++ src/lib/counter-do.ts | 295 +++++++++++++++++++++++ src/lib/db-schema.ts | 50 ++++ src/lib/types.ts | 18 +- src/routes/+page.svelte | 42 ++-- src/routes/sql-api/bets/+server.ts | 34 +++ src/routes/sql-api/info/+server.ts | 31 +++ src/routes/sql-api/query/+server.ts | 37 +++ src/routes/sql-api/users/+server.ts | 29 +++ src/routes/sql-viewer/+page.svelte | 189 +++++++++++++++ 10 files changed, 1050 insertions(+), 26 deletions(-) create mode 100644 SQL_VIEWER_GUIDE.md create mode 100644 src/lib/db-schema.ts create mode 100644 src/routes/sql-api/bets/+server.ts create mode 100644 src/routes/sql-api/info/+server.ts create mode 100644 src/routes/sql-api/query/+server.ts create mode 100644 src/routes/sql-api/users/+server.ts create mode 100644 src/routes/sql-viewer/+page.svelte diff --git a/SQL_VIEWER_GUIDE.md b/SQL_VIEWER_GUIDE.md new file mode 100644 index 0000000..b69f589 --- /dev/null +++ b/SQL_VIEWER_GUIDE.md @@ -0,0 +1,351 @@ +# Durable Object SQLite 조회 가이드 + +Durable Object의 내장 SQLite 데이터베이스를 조회하는 여러 가지 방법을 제공합니다. + +## 📋 목차 + +1. [웹 인터페이스로 조회](#1-웹-인터페이스로-조회) +2. [API 엔드포인트로 조회](#2-api-엔드포인트로-조회) +3. [WebSocket으로 조회](#3-websocket으로-조회) +4. [사용 가능한 테이블](#4-사용-가능한-테이블) + +--- + +## 1. 웹 인터페이스로 조회 + +가장 간편한 방법입니다. 브라우저에서 바로 SQLite 데이터를 확인할 수 있습니다. + +### 접속 방법 +``` +http://localhost:8788/sql-viewer +``` + +### 기능 +- **📊 DB 정보**: 데이터베이스 크기, 테이블 목록, 레코드 수 등 +- **👥 사용자 목록**: 모든 사용자 조회 +- **🎲 배팅 목록**: 최근 배팅 내역 조회 +- **⚙️ 커스텀 쿼리**: 원하는 SQL 쿼리 직접 실행 + +### 예시 화면 +``` +┌─────────────────────────────────────┐ +│ 🔍 Durable Object SQLite Viewer │ +├─────────────────────────────────────┤ +│ [📊 DB 정보] [👥 사용자] [🎲 배팅] │ +│ │ +│ 결과: │ +│ { │ +│ "databaseSize": 12345, │ +│ "userCount": 10, │ +│ "betCount": 50 │ +│ } │ +└─────────────────────────────────────┘ +``` + +--- + +## 2. API 엔드포인트로 조회 + +프로그래밍 방식으로 데이터를 조회할 수 있습니다. + +### 2.1 데이터베이스 정보 조회 + +```bash +# HTTP GET +curl http://localhost:8788/sql-api/info +``` + +**응답 예시:** +```json +{ + "databaseSize": 24576, + "userCount": 3, + "betCount": 15, + "currentGameId": "550e8400-e29b-41d4-a716-446655440000", + "tables": [ + { "name": "user" }, + { "name": "current_bet" } + ] +} +``` + +### 2.2 모든 사용자 조회 + +```bash +curl http://localhost:8788/sql-api/users +``` + +**응답 예시:** +```json +[ + { + "id": "user-123", + "nickname": "홍길동", + "email": "hong@example.com", + "joinGameCount": 5, + "capital": 15000 + } +] +``` + +### 2.3 배팅 내역 조회 + +```bash +# 모든 배팅 (최근 100개) +curl http://localhost:8788/sql-api/bets + +# 특정 게임의 배팅 +curl "http://localhost:8788/sql-api/bets?gameId=550e8400-e29b-41d4-a716-446655440000" +``` + +**응답 예시:** +```json +[ + { + "id": 1, + "gameId": "550e8400-e29b-41d4-a716-446655440000", + "diceNum": 12, + "userId": "user-123", + "betType": "odd", + "amount": 1000, + "isWin": 1, + "reward": 2000 + } +] +``` + +### 2.4 커스텀 SQL 쿼리 실행 + +```bash +curl -X POST http://localhost:8788/sql-api/query \ + -H "Content-Type: application/json" \ + -d '{"query": "SELECT * FROM user WHERE capital > 10000"}' +``` + +--- + +## 3. WebSocket으로 조회 + +실시간 게임 중에 WebSocket 연결을 통해 SQL을 조회할 수 있습니다. + +### JavaScript 예시 + +```javascript +// WebSocket 연결 +const ws = new WebSocket('ws://localhost:8788/api/counter'); + +// DB 정보 조회 +ws.send(JSON.stringify({ + type: 'sqlQuery', + query: 'info' +})); + +// 모든 사용자 조회 +ws.send(JSON.stringify({ + type: 'sqlQuery', + query: 'users' +})); + +// 커스텀 쿼리 +ws.send(JSON.stringify({ + type: 'sqlQuery', + query: 'SELECT * FROM current_bet WHERE amount > 5000' +})); + +// 결과 수신 +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'sqlResult') { + console.log('SQL 결과:', data.data); + } else if (data.type === 'sqlError') { + console.error('SQL 에러:', data.error); + } +}; +``` + +--- + +## 4. 사용 가능한 테이블 + +### 4.1 `user` 테이블 + +사용자 정보를 저장합니다. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | TEXT | 사용자 고유 ID (PRIMARY KEY) | +| `nickname` | TEXT | 닉네임 | +| `email` | TEXT | 이메일 (UNIQUE) | +| `joinGameCount` | INTEGER | 참여한 게임 횟수 | +| `capital` | INTEGER | 현재 자본금 | + +**예제 쿼리:** +```sql +-- 모든 사용자 조회 +SELECT * FROM user; + +-- 자본금이 10000 이상인 사용자 +SELECT * FROM user WHERE capital >= 10000; + +-- 가장 많이 참여한 사용자 +SELECT * FROM user ORDER BY joinGameCount DESC LIMIT 10; +``` + +### 4.2 `current_bet` 테이블 + +배팅 정보를 저장합니다. + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | INTEGER | 배팅 고유 ID (PRIMARY KEY, AUTO INCREMENT) | +| `gameId` | TEXT | 게임 고유 ID | +| `diceNum` | INTEGER | 주사위 합계 | +| `userId` | TEXT | 사용자 ID (FOREIGN KEY) | +| `betType` | TEXT | 배팅 타입 (odd/even/big/small) | +| `amount` | INTEGER | 배팅 금액 | +| `isWin` | INTEGER | 승리 여부 (0=패배, 1=승리) | +| `reward` | INTEGER | 보상 금액 | + +**예제 쿼리:** +```sql +-- 모든 배팅 조회 +SELECT * FROM current_bet; + +-- 특정 게임의 배팅 +SELECT * FROM current_bet WHERE gameId = 'xxx'; + +-- 사용자별 총 배팅액 +SELECT userId, SUM(amount) as totalBet +FROM current_bet +GROUP BY userId; + +-- 승리한 배팅만 조회 +SELECT * FROM current_bet WHERE isWin = 1; + +-- 게임별 통계 +SELECT + gameId, + COUNT(*) as betCount, + SUM(amount) as totalAmount, + SUM(CASE WHEN isWin = 1 THEN 1 ELSE 0 END) as winCount +FROM current_bet +GROUP BY gameId; +``` + +--- + +## 5. 고급 쿼리 예제 + +### 5.1 사용자별 승률 계산 + +```sql +SELECT + u.nickname, + COUNT(cb.id) as totalBets, + SUM(CASE WHEN cb.isWin = 1 THEN 1 ELSE 0 END) as wins, + CAST(SUM(CASE WHEN cb.isWin = 1 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(cb.id) * 100 as winRate +FROM user u +LEFT JOIN current_bet cb ON u.id = cb.userId +GROUP BY u.id, u.nickname +HAVING COUNT(cb.id) > 0; +``` + +### 5.2 배팅 타입별 통계 + +```sql +SELECT + betType, + COUNT(*) as count, + SUM(amount) as totalAmount, + AVG(amount) as avgAmount, + SUM(CASE WHEN isWin = 1 THEN 1 ELSE 0 END) as winCount +FROM current_bet +GROUP BY betType; +``` + +### 5.3 최근 게임 분석 + +```sql +SELECT + gameId, + diceNum, + COUNT(*) as betCount, + SUM(amount) as totalBet, + SUM(reward) as totalReward +FROM current_bet +WHERE gameId = (SELECT gameId FROM current_bet ORDER BY id DESC LIMIT 1) +GROUP BY gameId, diceNum; +``` + +--- + +## 6. 주의사항 + +### 보안 +- **프로덕션 환경**: `/sql/query` 엔드포인트는 개발용입니다. 프로덕션에서는 제거하거나 인증을 추가하세요. +- **SQL Injection**: 사용자 입력을 직접 쿼리에 넣지 마세요. 항상 파라미터 바인딩을 사용하세요. + +### 성능 +- **인덱스 활용**: `gameId`와 `userId`에 인덱스가 설정되어 있습니다. +- **LIMIT 사용**: 큰 테이블 조회 시 항상 `LIMIT`를 사용하세요. + +### 비용 +- Cloudflare Durable Objects의 SQLite 사용 시 [요금 정책](https://developers.cloudflare.com/durable-objects/platform/pricing/#sqlite-storage-backend)을 확인하세요. +- Row read/write에 따라 비용이 발생합니다. + +--- + +## 7. 문제 해결 + +### "COUNTER binding not found" 에러 +- `wrangler.jsonc`에 Durable Object 바인딩이 올바르게 설정되어 있는지 확인하세요. + +### 빈 결과가 반환됨 +- 아직 사용자나 배팅이 생성되지 않았을 수 있습니다. +- 게임에 접속하여 배팅을 해보세요. + +### WebSocket 연결 실패 +- 개발 서버가 실행 중인지 확인하세요: `pnpm run dev` + +--- + +## 8. 실전 예제 + +### 게임 시작 시 사용자 생성 +```javascript +// counter-do.ts의 fetch 메서드에서 +this.createOrUpdateUser( + session.id, + 'Player1', + 'player1@example.com', + 10000 +); +``` + +### 배팅 저장 +```javascript +// startNoMoreBetPeriod 메서드에서 주사위를 굴린 후 +const diceSum = this.dice1! + this.dice2! + this.dice3!; + +this.sessions.forEach((session) => { + if (session.oddBet > 0) { + this.saveBet({ + gameId: this.gameId!, + diceNum: diceSum, + userId: session.id, + betType: 'odd', + amount: session.oddBet, + isWin: diceSum % 2 === 1 ? 1 : 0, + reward: diceSum % 2 === 1 ? session.oddBet * 2 : 0 + }); + } +}); +``` + +--- + +완성! 🎉 + +이제 Durable Object의 SQLite 데이터베이스를 다양한 방법으로 조회할 수 있습니다. + diff --git a/src/lib/counter-do.ts b/src/lib/counter-do.ts index af1ce24..179c5df 100644 --- a/src/lib/counter-do.ts +++ b/src/lib/counter-do.ts @@ -1,6 +1,7 @@ import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types'; import type { Session } from './types'; import { applyBetResults } from './game-results'; +import { DB_SCHEMA, type UserRecord, type CurrentBetRecord } from './db-schema'; // 게임 지속 시간 상수 (ms) export const NO_MORE_BET_DURATION_MS = 15_000; // 15초 @@ -15,8 +16,10 @@ export class CounterDurableObject { private ctx: DurableObjectState; private env: Env; private sessions: Map; + private sql: any; // SqlStorage 타입 // 주사위 게임 상태 + private gameId: string | null; private noMoreBet: boolean; private dice1: number | null; private dice2: number | null; @@ -30,7 +33,11 @@ export class CounterDurableObject { this.env = env; this.sessions = new Map(); + // SQLite 초기화 + this.sql = ctx.storage.sql; + // 주사위 게임 초기화 + this.gameId = null; this.noMoreBet = false; this.dice1 = null; this.dice2 = null; @@ -41,6 +48,9 @@ export class CounterDurableObject { // Durable Objects에서 영구 저장소로부터 상태 복원 this.ctx.blockConcurrencyWhile(async () => { + // SQLite 테이블 생성 + this.initializeDatabase(); + const storedNoMoreBet = await this.ctx.storage.get('noMoreBet'); if (storedNoMoreBet !== undefined) { this.noMoreBet = storedNoMoreBet; @@ -52,6 +62,65 @@ export class CounterDurableObject { } async fetch(request: Request): Promise { + const url = new URL(request.url); + + // SQL 조회 API 엔드포인트 + if (url.pathname === '/sql/users') { + // 모든 사용자 조회 + const users = this.getAllUsers(); + return new Response(JSON.stringify(users, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } + + if (url.pathname === '/sql/bets') { + // 특정 게임 또는 모든 배팅 조회 + const gameId = url.searchParams.get('gameId'); + let bets; + if (gameId) { + bets = this.getBetsByGameId(gameId); + } else { + // 최근 배팅 조회 + const cursor = this.sql.exec('SELECT * FROM current_bet ORDER BY id DESC LIMIT 100'); + bets = cursor.toArray(); + } + return new Response(JSON.stringify(bets, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } + + if (url.pathname === '/sql/info') { + // 데이터베이스 정보 조회 + const info = { + databaseSize: this.getDatabaseSize(), + userCount: this.sql.exec('SELECT COUNT(*) as count FROM user').one().count, + betCount: this.sql.exec('SELECT COUNT(*) as count FROM current_bet').one().count, + currentGameId: this.gameId, + tables: this.sql.exec("SELECT name FROM sqlite_master WHERE type='table'").toArray() + }; + return new Response(JSON.stringify(info, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } + + if (url.pathname === '/sql/query') { + // 커스텀 SQL 쿼리 실행 (개발용 - 프로덕션에서는 제거 권장) + if (request.method === 'POST') { + try { + const body = await request.json() as { query: string }; + const result = this.sql.exec(body.query).toArray(); + return new Response(JSON.stringify(result, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + } + } + // WebSocket 업그레이드 요청 처리 const upgradeHeader = request.headers.get('Upgrade'); if (!upgradeHeader || upgradeHeader !== 'websocket') { @@ -84,6 +153,11 @@ export class CounterDurableObject { this.ctx.acceptWebSocket(server); this.sessions.set(server, session); + // 첫 접속자가 들어왔을 때 게임 루프 시작 + if (this.sessions.size === 1) { + this.startGameLoop(); + } + // 현재 상태 전송 this.broadcast(); @@ -106,6 +180,34 @@ export class CounterDurableObject { session.nickname = data.nickname; session.capital = data.capital; this.broadcast(); + } else if (data && data.type === 'sqlQuery') { + // SQL 조회 요청 처리 + try { + let result; + switch (data.query) { + case 'users': + result = this.getAllUsers(); + break; + case 'bets': + result = this.sql.exec('SELECT * FROM current_bet ORDER BY id DESC LIMIT 50').toArray(); + break; + case 'info': + result = { + databaseSize: this.getDatabaseSize(), + userCount: this.sql.exec('SELECT COUNT(*) as count FROM user').one().count, + betCount: this.sql.exec('SELECT COUNT(*) as count FROM current_bet').one().count, + currentGameId: this.gameId, + tables: this.sql.exec("SELECT name FROM sqlite_master WHERE type='table'").toArray() + }; + break; + default: + // 커스텀 쿼리 실행 + result = this.sql.exec(data.query).toArray(); + } + ws.send(JSON.stringify({ type: 'sqlResult', data: result })); + } catch (error: any) { + ws.send(JSON.stringify({ type: 'sqlError', error: error.message })); + } } else if (data && data.type === 'bet' && !this.noMoreBet) { // 배팅 처리 const amount = data.amount || 1000; @@ -135,12 +237,197 @@ export class CounterDurableObject { } } + // ===== SQLite 데이터베이스 메서드 ===== + + /** + * SQLite 데이터베이스 초기화 - 테이블 생성 + */ + private initializeDatabase() { + try { + this.sql.exec(DB_SCHEMA); + console.log('SQLite database initialized successfully'); + } catch (error) { + console.error('Error initializing database:', error); + } + } + + /** + * 사용자 생성 또는 업데이트 + */ + createOrUpdateUser(userId: string, nickname: string, email: string, capital?: number): void { + try { + // 기존 사용자 확인 + const existing = this.sql.exec( + 'SELECT id FROM user WHERE id = ?', + userId + ).one(); + + if (existing) { + // 업데이트 + this.sql.exec( + 'UPDATE user SET nickname = ?, email = ?, capital = COALESCE(?, capital) WHERE id = ?', + nickname, + email, + capital, + userId + ); + } else { + // 새로운 사용자 생성 + this.sql.exec( + 'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)', + userId, + nickname, + email, + capital || 10000 + ); + } + } catch (error) { + // 사용자가 없으면 생성 + try { + this.sql.exec( + 'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)', + userId, + nickname, + email, + capital || 10000 + ); + } catch (insertError) { + console.error('Error creating user:', insertError); + } + } + } + + /** + * 사용자 정보 조회 + */ + getUser(userId: string): UserRecord | null { + try { + const result = this.sql.exec( + 'SELECT * FROM user WHERE id = ?', + userId + ).one(); + return result; + } catch (error) { + return null; + } + } + + /** + * 사용자 자본금 업데이트 + */ + updateUserCapital(userId: string, newCapital: number): void { + this.sql.exec( + 'UPDATE user SET capital = ? WHERE id = ?', + newCapital, + userId + ); + } + + /** + * 사용자 참여 게임 수 증가 + */ + incrementJoinGameCount(userId: string): void { + this.sql.exec( + 'UPDATE user SET joinGameCount = joinGameCount + 1 WHERE id = ?', + userId + ); + } + + /** + * 배팅 정보 저장 + */ + saveBet(bet: CurrentBetRecord): void { + this.sql.exec( + `INSERT INTO current_bet (gameId, diceNum, userId, betType, amount, isWin, reward) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + bet.gameId, + bet.diceNum, + bet.userId, + bet.betType, + bet.amount, + bet.isWin, + bet.reward + ); + } + + /** + * 특정 게임의 모든 배팅 조회 + */ + getBetsByGameId(gameId: string): CurrentBetRecord[] { + const cursor = this.sql.exec( + 'SELECT * FROM current_bet WHERE gameId = ?', + gameId + ); + return cursor.toArray(); + } + + /** + * 특정 사용자의 배팅 조회 + */ + getBetsByUserId(userId: string): CurrentBetRecord[] { + const cursor = this.sql.exec( + 'SELECT * FROM current_bet WHERE userId = ? ORDER BY id DESC LIMIT 10', + userId + ); + return cursor.toArray(); + } + + /** + * 배팅 결과 업데이트 + */ + updateBetResult(betId: number, isWin: number, reward: number): void { + this.sql.exec( + 'UPDATE current_bet SET isWin = ?, reward = ? WHERE id = ?', + isWin, + reward, + betId + ); + } + + /** + * 특정 게임의 배팅 삭제 (게임 종료 후 정리용) + */ + deleteBetsByGameId(gameId: string): void { + this.sql.exec( + 'DELETE FROM current_bet WHERE gameId = ?', + gameId + ); + } + + /** + * 모든 사용자 조회 + */ + getAllUsers(): UserRecord[] { + const cursor = this.sql.exec('SELECT * FROM user'); + return cursor.toArray(); + } + + /** + * 데이터베이스 크기 조회 + */ + getDatabaseSize(): number { + return this.sql.databaseSize; + } + + // ===== 게임 로직 메서드 ===== + // 게임 루프 시작 private startGameLoop() { + // 이미 실행 중이면 중복 실행 방지 + if (this.gameTimer !== null) return; + // 처음 시작 시 베팅 기간으로 시작 (45초) this.startBettingPeriod(); } + // 게임 루프 중지 + private stopGameLoop() { + if (this.gameTimer) { + clearTimeout(this.gameTimer); + this.gameTimer = null; + } + } + // 1번 로직: noMoreBet = true, 15초간 유지 private startNoMoreBetPeriod() { this.noMoreBet = true; @@ -172,6 +459,10 @@ export class CounterDurableObject { // 2번 로직: noMoreBet = false, 45초간 유지 private startBettingPeriod() { + // 새로운 게임 ID 생성 (베팅 기간 시작 시) + this.gameId = crypto.randomUUID(); + console.log(`[New Game] GameID: ${this.gameId} - 베팅 기간 시작`); + this.noMoreBet = false; this.noMoreBetStartTime = Date.now(); this.noMoreBetEndTime = this.noMoreBetStartTime + BETTING_DURATION_MS; // 45초 @@ -214,6 +505,10 @@ export class CounterDurableObject { this.sessions.delete(ws); ws.close(code, 'Durable Object is closing WebSocket'); + // 마지막 접속자가 나갔을 때 게임 루프 중지 + if (this.sessions.size === 0) { + this.stopGameLoop(); + } // 남은 클라이언트들에게 업데이트 전송 this.broadcast(); diff --git a/src/lib/db-schema.ts b/src/lib/db-schema.ts new file mode 100644 index 0000000..d9be09d --- /dev/null +++ b/src/lib/db-schema.ts @@ -0,0 +1,50 @@ +// Durable Object SQLite 스키마 정의 + +export const DB_SCHEMA = ` + -- User 테이블: 사용자 정보 저장 + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + nickname TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + joinGameCount INTEGER DEFAULT 0, + capital INTEGER DEFAULT 10000 + ); + + -- CurrentBet 테이블: 현재 배팅 정보 저장 + CREATE TABLE IF NOT EXISTS current_bet ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gameId TEXT NOT NULL, + diceNum INTEGER NOT NULL, + userId TEXT NOT NULL, + betType TEXT NOT NULL, + amount INTEGER NOT NULL, + isWin INTEGER DEFAULT 0, + reward INTEGER DEFAULT 0, + FOREIGN KEY (userId) REFERENCES user(id) + ); + + -- gameId와 userId로 빠른 조회를 위한 인덱스 + CREATE INDEX IF NOT EXISTS idx_current_bet_gameId ON current_bet(gameId); + CREATE INDEX IF NOT EXISTS idx_current_bet_userId ON current_bet(userId); +`; + +// 타입 정의 +export interface UserRecord { + id: string; + nickname: string; + email: string; + joinGameCount: number; + capital: number; +} + +export interface CurrentBetRecord { + id?: number; + gameId: string; + diceNum: number; + userId: string; + betType: string; + amount: number; + isWin: number; // SQLite는 boolean이 없어서 0 또는 1 + reward: number; +} + diff --git a/src/lib/types.ts b/src/lib/types.ts index 98e37f5..de3d7f3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,8 +1,19 @@ export interface Session { - token: string; + id: string; + token?: string; webSocket: WebSocket; - betInfo: BettingInfo[]; // 배팅 정보 - capital?: number; // 현재 자본금 + nickname?: string; + capital?: number; + oddBet: number; + evenBet: number; + bigBet: number; + smallBet: number; + oddResult: 'win' | 'lose' | null; + evenResult: 'win' | 'lose' | null; + bigResult: 'win' | 'lose' | null; + smallResult: 'win' | 'lose' | null; + lastWinAmount: number; + betInfo?: BettingInfo[]; // 배팅 정보 (기존 호환성을 위해 optional) } export interface BettingInfo { @@ -11,6 +22,7 @@ export interface BettingInfo { betMoney: number; //배팅금액 isWin?: boolean; //승리여부 winMoney?: number; //승리금액 + valid?: boolean; //유효배팅여부 } export const BetType = { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index bd55d1e..841a21e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -47,8 +47,7 @@ }; let allBettings = $state([]); - // Progress bar 관련 - let progressPercent = $state(0); + // 카운트다운 타이머 관련 let remainingTime = $state(0); function connectWebSocket() { @@ -207,7 +206,7 @@ return patterns[number] || patterns[1]; } - // Progress bar 업데이트 + // 카운트다운 타이머 업데이트 $effect(() => { let interval: ReturnType | null = null; @@ -216,11 +215,8 @@ if (noMoreBetStartTime === null || noMoreBetEndTime === null) return; const now = Date.now(); - const total = noMoreBetEndTime - noMoreBetStartTime; - const elapsed = now - noMoreBetStartTime; const remaining = Math.max(0, noMoreBetEndTime - now); - progressPercent = Math.min(100, (elapsed / total) * 100); remainingTime = Math.ceil(remaining / 1000); }, 100); } @@ -338,26 +334,26 @@ - +
-
-
- - {noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'} - - - {remainingTime}초 - +
+
+ {noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
-
-
+ + +
+
+ {remainingTime} +
+
+ 초 +
+
+ +
+ {noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'}
-
-
- {noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'}
diff --git a/src/routes/sql-api/bets/+server.ts b/src/routes/sql-api/bets/+server.ts new file mode 100644 index 0000000..7549bc9 --- /dev/null +++ b/src/routes/sql-api/bets/+server.ts @@ -0,0 +1,34 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ platform, url }) => { + try { + const env = platform?.env; + if (!env?.COUNTER) { + return new Response(JSON.stringify({ error: 'COUNTER binding not found' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const id = env.COUNTER.idFromName('global-counter'); + const stub = env.COUNTER.get(id); + + const gameId = url.searchParams.get('gameId'); + const doUrl = gameId + ? `https://fake-host/sql/bets?gameId=${gameId}` + : 'https://fake-host/sql/bets'; + + const response = await stub.fetch(doUrl); + const data = await response.json(); + + return new Response(JSON.stringify(data, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; + diff --git a/src/routes/sql-api/info/+server.ts b/src/routes/sql-api/info/+server.ts new file mode 100644 index 0000000..bc83fc0 --- /dev/null +++ b/src/routes/sql-api/info/+server.ts @@ -0,0 +1,31 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ platform }) => { + try { + const env = platform?.env; + if (!env?.COUNTER) { + return new Response(JSON.stringify({ error: 'COUNTER binding not found' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Durable Object ID 가져오기 + const id = env.COUNTER.idFromName('global-counter'); + const stub = env.COUNTER.get(id); + + // Durable Object의 fetch 메서드 호출 + const response = await stub.fetch('https://fake-host/sql/info'); + const data = await response.json(); + + return new Response(JSON.stringify(data, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; + diff --git a/src/routes/sql-api/query/+server.ts b/src/routes/sql-api/query/+server.ts new file mode 100644 index 0000000..3064609 --- /dev/null +++ b/src/routes/sql-api/query/+server.ts @@ -0,0 +1,37 @@ +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ platform, request }) => { + try { + const env = platform?.env; + if (!env?.COUNTER) { + return new Response(JSON.stringify({ error: 'COUNTER binding not found' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const body = await request.json(); + + const id = env.COUNTER.idFromName('global-counter'); + const stub = env.COUNTER.get(id); + + const response = await stub.fetch('https://fake-host/sql/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + + return new Response(JSON.stringify(data, null, 2), { + status: response.status, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; + diff --git a/src/routes/sql-api/users/+server.ts b/src/routes/sql-api/users/+server.ts new file mode 100644 index 0000000..a8c6fff --- /dev/null +++ b/src/routes/sql-api/users/+server.ts @@ -0,0 +1,29 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ platform }) => { + try { + const env = platform?.env; + if (!env?.COUNTER) { + return new Response(JSON.stringify({ error: 'COUNTER binding not found' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const id = env.COUNTER.idFromName('global-counter'); + const stub = env.COUNTER.get(id); + + const response = await stub.fetch('https://fake-host/sql/users'); + const data = await response.json(); + + return new Response(JSON.stringify(data, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; + diff --git a/src/routes/sql-viewer/+page.svelte b/src/routes/sql-viewer/+page.svelte new file mode 100644 index 0000000..07a53e0 --- /dev/null +++ b/src/routes/sql-viewer/+page.svelte @@ -0,0 +1,189 @@ + + +
+
+

+ 🔍 Durable Object SQLite Viewer +

+ + +
+

쿼리 선택

+ +
+ + + + +
+ + {#if queryType === 'custom'} +
+ + +
+ {/if} + + +
+ + +
+

결과

+ + {#if error} +
+ 에러: {error} +
+ {:else if loading} +
+ ⏳ 데이터를 불러오는 중... +
+ {:else if result} +
+
{JSON.stringify(result, null, 2)}
+
+ + {#if Array.isArray(result) && result.length > 0} +
+

+ 총 {result.length}개의 행 +

+ + +
+ + + + {#each Object.keys(result[0]) as key} + + {/each} + + + + {#each result as row} + + {#each Object.values(row) as value} + + {/each} + + {/each} + +
+ {key} +
+ {value} +
+
+
+ {/if} + {:else} +
+ 쿼리를 실행하여 결과를 확인하세요. +
+ {/if} +
+ + +
+

💡 사용 가능한 테이블

+
    +
  • user - 사용자 정보 (id, nickname, email, joinGameCount, capital)
  • +
  • current_bet - 배팅 정보 (id, gameId, diceNum, userId, betType, amount, isWin, reward)
  • +
+ +

📝 예제 쿼리

+
    +
  • SELECT * FROM user
  • +
  • SELECT * FROM current_bet WHERE gameId = 'xxx'
  • +
  • SELECT userId, SUM(amount) as total FROM current_bet GROUP BY userId
  • +
+
+
+
+