Merge branch 'master' of https://git.frovide.com/kegorii/dd
This commit is contained in:
commit
e6ed427b68
351
SQL_VIEWER_GUIDE.md
Normal file
351
SQL_VIEWER_GUIDE.md
Normal 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 데이터베이스를 다양한 방법으로 조회할 수 있습니다.
|
||||||
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types';
|
import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types';
|
||||||
import type { Session } from './types';
|
import type { Session } from './types';
|
||||||
import { applyBetResults } from './game-results';
|
import { applyBetResults } from './game-results';
|
||||||
|
import { DB_SCHEMA, type UserRecord, type CurrentBetRecord } from './db-schema';
|
||||||
|
|
||||||
// 게임 지속 시간 상수 (ms)
|
// 게임 지속 시간 상수 (ms)
|
||||||
export const NO_MORE_BET_DURATION_MS = 15_000; // 15초
|
export const NO_MORE_BET_DURATION_MS = 15_000; // 15초
|
||||||
@ -15,8 +16,10 @@ export class CounterDurableObject {
|
|||||||
private ctx: DurableObjectState;
|
private ctx: DurableObjectState;
|
||||||
private env: Env;
|
private env: Env;
|
||||||
private sessions: Map<WebSocket, Session>;
|
private sessions: Map<WebSocket, Session>;
|
||||||
|
private sql: any; // SqlStorage 타입
|
||||||
|
|
||||||
// 주사위 게임 상태
|
// 주사위 게임 상태
|
||||||
|
private gameId: string | null;
|
||||||
private noMoreBet: boolean;
|
private noMoreBet: boolean;
|
||||||
private dice1: number | null;
|
private dice1: number | null;
|
||||||
private dice2: number | null;
|
private dice2: number | null;
|
||||||
@ -30,7 +33,11 @@ export class CounterDurableObject {
|
|||||||
this.env = env;
|
this.env = env;
|
||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
|
|
||||||
|
// SQLite 초기화
|
||||||
|
this.sql = ctx.storage.sql;
|
||||||
|
|
||||||
// 주사위 게임 초기화
|
// 주사위 게임 초기화
|
||||||
|
this.gameId = null;
|
||||||
this.noMoreBet = false;
|
this.noMoreBet = false;
|
||||||
this.dice1 = null;
|
this.dice1 = null;
|
||||||
this.dice2 = null;
|
this.dice2 = null;
|
||||||
@ -41,6 +48,9 @@ export class CounterDurableObject {
|
|||||||
|
|
||||||
// Durable Objects에서 영구 저장소로부터 상태 복원
|
// Durable Objects에서 영구 저장소로부터 상태 복원
|
||||||
this.ctx.blockConcurrencyWhile(async () => {
|
this.ctx.blockConcurrencyWhile(async () => {
|
||||||
|
// SQLite 테이블 생성
|
||||||
|
this.initializeDatabase();
|
||||||
|
|
||||||
const storedNoMoreBet = await this.ctx.storage.get<boolean>('noMoreBet');
|
const storedNoMoreBet = await this.ctx.storage.get<boolean>('noMoreBet');
|
||||||
if (storedNoMoreBet !== undefined) {
|
if (storedNoMoreBet !== undefined) {
|
||||||
this.noMoreBet = storedNoMoreBet;
|
this.noMoreBet = storedNoMoreBet;
|
||||||
@ -52,6 +62,65 @@ export class CounterDurableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
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 업그레이드 요청 처리
|
// WebSocket 업그레이드 요청 처리
|
||||||
const upgradeHeader = request.headers.get('Upgrade');
|
const upgradeHeader = request.headers.get('Upgrade');
|
||||||
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
||||||
@ -84,6 +153,11 @@ export class CounterDurableObject {
|
|||||||
this.ctx.acceptWebSocket(server);
|
this.ctx.acceptWebSocket(server);
|
||||||
this.sessions.set(server, session);
|
this.sessions.set(server, session);
|
||||||
|
|
||||||
|
// 첫 접속자가 들어왔을 때 게임 루프 시작
|
||||||
|
if (this.sessions.size === 1) {
|
||||||
|
this.startGameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 상태 전송
|
// 현재 상태 전송
|
||||||
this.broadcast();
|
this.broadcast();
|
||||||
|
|
||||||
@ -106,6 +180,34 @@ export class CounterDurableObject {
|
|||||||
session.nickname = data.nickname;
|
session.nickname = data.nickname;
|
||||||
session.capital = data.capital;
|
session.capital = data.capital;
|
||||||
this.broadcast();
|
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) {
|
} else if (data && data.type === 'bet' && !this.noMoreBet) {
|
||||||
// 배팅 처리
|
// 배팅 처리
|
||||||
const amount = data.amount || 1000;
|
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() {
|
private startGameLoop() {
|
||||||
|
// 이미 실행 중이면 중복 실행 방지
|
||||||
|
if (this.gameTimer !== null) return;
|
||||||
|
|
||||||
// 처음 시작 시 베팅 기간으로 시작 (45초)
|
// 처음 시작 시 베팅 기간으로 시작 (45초)
|
||||||
this.startBettingPeriod();
|
this.startBettingPeriod();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 게임 루프 중지
|
||||||
|
private stopGameLoop() {
|
||||||
|
if (this.gameTimer) {
|
||||||
|
clearTimeout(this.gameTimer);
|
||||||
|
this.gameTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1번 로직: noMoreBet = true, 15초간 유지
|
// 1번 로직: noMoreBet = true, 15초간 유지
|
||||||
private startNoMoreBetPeriod() {
|
private startNoMoreBetPeriod() {
|
||||||
this.noMoreBet = true;
|
this.noMoreBet = true;
|
||||||
@ -172,6 +459,10 @@ export class CounterDurableObject {
|
|||||||
|
|
||||||
// 2번 로직: noMoreBet = false, 45초간 유지
|
// 2번 로직: noMoreBet = false, 45초간 유지
|
||||||
private startBettingPeriod() {
|
private startBettingPeriod() {
|
||||||
|
// 새로운 게임 ID 생성 (베팅 기간 시작 시)
|
||||||
|
this.gameId = crypto.randomUUID();
|
||||||
|
console.log(`[New Game] GameID: ${this.gameId} - 베팅 기간 시작`);
|
||||||
|
|
||||||
this.noMoreBet = false;
|
this.noMoreBet = false;
|
||||||
this.noMoreBetStartTime = Date.now();
|
this.noMoreBetStartTime = Date.now();
|
||||||
this.noMoreBetEndTime = this.noMoreBetStartTime + BETTING_DURATION_MS; // 45초
|
this.noMoreBetEndTime = this.noMoreBetStartTime + BETTING_DURATION_MS; // 45초
|
||||||
@ -214,6 +505,10 @@ export class CounterDurableObject {
|
|||||||
this.sessions.delete(ws);
|
this.sessions.delete(ws);
|
||||||
ws.close(code, 'Durable Object is closing WebSocket');
|
ws.close(code, 'Durable Object is closing WebSocket');
|
||||||
|
|
||||||
|
// 마지막 접속자가 나갔을 때 게임 루프 중지
|
||||||
|
if (this.sessions.size === 0) {
|
||||||
|
this.stopGameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
// 남은 클라이언트들에게 업데이트 전송
|
// 남은 클라이언트들에게 업데이트 전송
|
||||||
this.broadcast();
|
this.broadcast();
|
||||||
|
|||||||
50
src/lib/db-schema.ts
Normal file
50
src/lib/db-schema.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,8 +1,19 @@
|
|||||||
export interface Session {
|
export interface Session {
|
||||||
token: string;
|
id: string;
|
||||||
|
token?: string;
|
||||||
webSocket: WebSocket;
|
webSocket: WebSocket;
|
||||||
betInfo: BettingInfo[]; // 배팅 정보
|
nickname?: string;
|
||||||
capital?: number; // 현재 자본금
|
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 {
|
export interface BettingInfo {
|
||||||
@ -11,6 +22,7 @@ export interface BettingInfo {
|
|||||||
betMoney: number; //배팅금액
|
betMoney: number; //배팅금액
|
||||||
isWin?: boolean; //승리여부
|
isWin?: boolean; //승리여부
|
||||||
winMoney?: number; //승리금액
|
winMoney?: number; //승리금액
|
||||||
|
valid?: boolean; //유효배팅여부
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BetType = {
|
export const BetType = {
|
||||||
|
|||||||
@ -47,8 +47,7 @@
|
|||||||
};
|
};
|
||||||
let allBettings = $state<BettingInfo[]>([]);
|
let allBettings = $state<BettingInfo[]>([]);
|
||||||
|
|
||||||
// Progress bar 관련
|
// 카운트다운 타이머 관련
|
||||||
let progressPercent = $state(0);
|
|
||||||
let remainingTime = $state(0);
|
let remainingTime = $state(0);
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
@ -207,7 +206,7 @@
|
|||||||
return patterns[number] || patterns[1];
|
return patterns[number] || patterns[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress bar 업데이트
|
// 카운트다운 타이머 업데이트
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@ -216,11 +215,8 @@
|
|||||||
if (noMoreBetStartTime === null || noMoreBetEndTime === null) return;
|
if (noMoreBetStartTime === null || noMoreBetEndTime === null) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const total = noMoreBetEndTime - noMoreBetStartTime;
|
|
||||||
const elapsed = now - noMoreBetStartTime;
|
|
||||||
const remaining = Math.max(0, noMoreBetEndTime - now);
|
const remaining = Math.max(0, noMoreBetEndTime - now);
|
||||||
|
|
||||||
progressPercent = Math.min(100, (elapsed / total) * 100);
|
|
||||||
remainingTime = Math.ceil(remaining / 1000);
|
remainingTime = Math.ceil(remaining / 1000);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@ -338,26 +334,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-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 flex-col items-center justify-center gap-4">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="text-xl font-semibold text-gray-700">
|
||||||
<span class="font-semibold text-gray-700">
|
{noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
|
||||||
{noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
|
|
||||||
</span>
|
|
||||||
<span class="text-lg font-bold text-indigo-600">
|
|
||||||
{remainingTime}초
|
|
||||||
</span>
|
|
||||||
</div>
|
</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'}"
|
<div class="relative">
|
||||||
style="width: {progressPercent}%"
|
<div class="text-8xl font-bold {noMoreBet ? 'text-red-600' : 'text-green-600'} tabular-nums">
|
||||||
></div>
|
{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>
|
|
||||||
<div class="text-sm text-gray-600 text-center">
|
|
||||||
{noMoreBet ? '주사위를 굴리는 중... 다음 라운드를 기다려주세요' : '홀/짝, 대/소를 선택하세요!'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
34
src/routes/sql-api/bets/+server.ts
Normal file
34
src/routes/sql-api/bets/+server.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
31
src/routes/sql-api/info/+server.ts
Normal file
31
src/routes/sql-api/info/+server.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
37
src/routes/sql-api/query/+server.ts
Normal file
37
src/routes/sql-api/query/+server.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
29
src/routes/sql-api/users/+server.ts
Normal file
29
src/routes/sql-api/users/+server.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
189
src/routes/sql-viewer/+page.svelte
Normal file
189
src/routes/sql-viewer/+page.svelte
Normal 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>
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user