SQLite 조회 기능 및 UI 추가

This commit is contained in:
김인섭 2025-11-19 23:25:21 +09:00
parent 97a0533afd
commit df8743a80b
10 changed files with 1050 additions and 26 deletions

351
SQL_VIEWER_GUIDE.md Normal file
View File

@ -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 데이터베이스를 다양한 방법으로 조회할 수 있습니다.

View File

@ -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<WebSocket, Session>;
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<boolean>('noMoreBet');
if (storedNoMoreBet !== undefined) {
this.noMoreBet = storedNoMoreBet;
@ -52,6 +62,65 @@ export class CounterDurableObject {
}
async fetch(request: Request): Promise<Response> {
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();

50
src/lib/db-schema.ts Normal file
View File

@ -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;
}

View File

@ -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 = {

View File

@ -47,8 +47,7 @@
};
let allBettings = $state<BettingInfo[]>([]);
// 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<typeof setInterval> | 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 @@
</div>
</div>
<!-- Progress Bar -->
<!-- 카운트다운 타이머 -->
<div class="mb-8 p-6 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl border-2 border-indigo-200">
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<span class="font-semibold text-gray-700">
{noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
</span>
<span class="text-lg font-bold text-indigo-600">
{remainingTime}
</span>
<div class="flex flex-col items-center justify-center gap-4">
<div class="text-xl font-semibold text-gray-700">
{noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
</div>
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div
class="h-full transition-all duration-100 {noMoreBet ? 'bg-red-500' : 'bg-green-500'}"
style="width: {progressPercent}%"
></div>
<!-- 큰 카운트다운 숫자 -->
<div class="relative">
<div class="text-8xl font-bold {noMoreBet ? 'text-red-600' : 'text-green-600'} tabular-nums">
{remainingTime}
</div>
<div class="absolute -bottom-2 left-1/2 transform -translate-x-1/2 text-2xl font-semibold text-gray-600">
</div>
</div>
<div class="text-base text-gray-600 text-center mt-2">
{noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'}
</div>
</div>
<div class="text-sm text-gray-600 text-center">
{noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'}
</div>
</div>

View File

@ -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' }
});
}
};

View File

@ -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' }
});
}
};

View File

@ -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' }
});
}
};

View File

@ -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' }
});
}
};

View File

@ -0,0 +1,189 @@
<script lang="ts">
let queryType = $state<'users' | 'bets' | 'info' | 'custom'>('info');
let customQuery = $state('SELECT * FROM user LIMIT 10');
let result = $state<any>(null);
let error = $state<string | null>(null);
let loading = $state(false);
async function executeQuery() {
loading = true;
error = null;
result = null;
try {
let url = '/sql-api';
let options: RequestInit = {
method: 'GET'
};
switch (queryType) {
case 'users':
url = '/sql-api/users';
break;
case 'bets':
url = '/sql-api/bets';
break;
case 'info':
url = '/sql-api/info';
break;
case 'custom':
url = '/sql-api/query';
options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: customQuery })
};
break;
}
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
result = await response.json();
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
// 페이지 로드 시 자동으로 info 조회
$effect(() => {
executeQuery();
});
</script>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<div class="max-w-6xl mx-auto">
<h1 class="text-4xl font-bold text-gray-800 mb-8">
🔍 Durable Object SQLite Viewer
</h1>
<!-- 쿼리 선택 -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">쿼리 선택</h2>
<div class="flex gap-4 mb-4">
<button
onclick={() => queryType = 'info'}
class="px-4 py-2 rounded-lg {queryType === 'info' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}"
>
📊 DB 정보
</button>
<button
onclick={() => queryType = 'users'}
class="px-4 py-2 rounded-lg {queryType === 'users' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}"
>
👥 사용자 목록
</button>
<button
onclick={() => queryType = 'bets'}
class="px-4 py-2 rounded-lg {queryType === 'bets' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}"
>
🎲 배팅 목록
</button>
<button
onclick={() => queryType = 'custom'}
class="px-4 py-2 rounded-lg {queryType === 'custom' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'}"
>
⚙️ 커스텀 쿼리
</button>
</div>
{#if queryType === 'custom'}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">SQL 쿼리</label>
<textarea
bind:value={customQuery}
class="w-full p-3 border rounded-lg font-mono text-sm"
rows="4"
placeholder="SELECT * FROM user LIMIT 10"
></textarea>
</div>
{/if}
<button
onclick={executeQuery}
disabled={loading}
class="px-6 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg font-semibold disabled:bg-gray-400"
>
{loading ? '⏳ 조회 중...' : '🔍 조회 실행'}
</button>
</div>
<!-- 결과 표시 -->
<div class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold mb-4">결과</h2>
{#if error}
<div class="bg-red-50 border border-red-300 text-red-700 p-4 rounded-lg">
<strong>에러:</strong> {error}
</div>
{:else if loading}
<div class="text-center py-8 text-gray-500">
⏳ 데이터를 불러오는 중...
</div>
{:else if result}
<div class="overflow-x-auto">
<pre class="bg-gray-50 p-4 rounded-lg text-sm overflow-auto max-h-[600px]">{JSON.stringify(result, null, 2)}</pre>
</div>
{#if Array.isArray(result) && result.length > 0}
<div class="mt-4">
<p class="text-sm text-gray-600 mb-2">
<strong>{result.length}</strong>개의 행
</p>
<!-- 테이블 형식으로도 표시 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 border">
<thead class="bg-gray-50">
<tr>
{#each Object.keys(result[0]) as key}
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border">
{key}
</th>
{/each}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each result as row}
<tr>
{#each Object.values(row) as value}
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900 border">
{value}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{:else}
<div class="text-center py-8 text-gray-500">
쿼리를 실행하여 결과를 확인하세요.
</div>
{/if}
</div>
<!-- 도움말 -->
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="font-semibold text-blue-900 mb-2">💡 사용 가능한 테이블</h3>
<ul class="list-disc list-inside text-sm text-blue-800 space-y-1">
<li><code class="bg-blue-100 px-1 rounded">user</code> - 사용자 정보 (id, nickname, email, joinGameCount, capital)</li>
<li><code class="bg-blue-100 px-1 rounded">current_bet</code> - 배팅 정보 (id, gameId, diceNum, userId, betType, amount, isWin, reward)</li>
</ul>
<h3 class="font-semibold text-blue-900 mt-4 mb-2">📝 예제 쿼리</h3>
<ul class="list-disc list-inside text-sm text-blue-800 space-y-1">
<li><code class="bg-blue-100 px-1 rounded">SELECT * FROM user</code></li>
<li><code class="bg-blue-100 px-1 rounded">SELECT * FROM current_bet WHERE gameId = 'xxx'</code></li>
<li><code class="bg-blue-100 px-1 rounded">SELECT userId, SUM(amount) as total FROM current_bet GROUP BY userId</code></li>
</ul>
</div>
</div>
</div>