Refactor user management and authentication system

- Removed nickname from user table and UserRecord interface.
- Updated user creation to generate UUID for user ID.
- Changed user ID type from number to string in authentication functions.
- Modified database schema to accommodate new user structure.
- Enhanced login flow to redirect users based on query parameters.
- Improved profile management with nickname update functionality.
- Added automatic nickname setting based on email input during registration.
- Implemented WebSocket connection management and duplicate login detection.
- Updated UI components for better user experience and responsiveness.
This commit is contained in:
pd0a6847 2025-11-20 15:46:17 +09:00
parent e6ed427b68
commit bdffa90305
18 changed files with 980 additions and 330 deletions

View File

@ -0,0 +1,14 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`nickname` text NOT NULL,
`created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "password_hash", "nickname", "created_at") SELECT "id", "email", "password_hash", "nickname", "created_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

View File

@ -0,0 +1,72 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2f463a59-2377-49d8-992b-f8d2a2c60a49",
"prevId": "83c12a3c-ec11-4135-b526-261771d6ede4",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s', 'now'))"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1763435039243, "when": 1763435039243,
"tag": "0000_omniscient_lady_mastermind", "tag": "0000_omniscient_lady_mastermind",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1763601535973,
"tag": "0001_wakeful_silver_fox",
"breakpoints": true
} }
] ]
} }

View File

@ -49,6 +49,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@number-flow/svelte": "^0.3.9",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"jose": "^6.1.2" "jose": "^6.1.2"
} }

21
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@number-flow/svelte':
specifier: ^0.3.9
version: 0.3.9(svelte@5.43.8)
drizzle-orm: drizzle-orm:
specifier: ^0.44.7 specifier: ^0.44.7
version: 0.44.7(@cloudflare/workers-types@4.20251117.0)(@libsql/client@0.15.15)(better-sqlite3@12.4.1) version: 0.44.7(@cloudflare/workers-types@4.20251117.0)(@libsql/client@0.15.15)(better-sqlite3@12.4.1)
@ -759,6 +762,11 @@ packages:
'@neon-rs/load@0.0.4': '@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@number-flow/svelte@0.3.9':
resolution: {integrity: sha512-CTw1+e0074GzbPX2IHcNCaK8nqxGNCOIUnQUjEjhcmBwBxOAhN3GYLQ6cJHvhQnWwplVe4eQ3z+c25Vttr2stQ==}
peerDependencies:
svelte: ^4 || ^5
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@ -1487,6 +1495,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
number-flow@0.5.8:
resolution: {integrity: sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA==}
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -2255,6 +2266,12 @@ snapshots:
'@neon-rs/load@0.0.4': {} '@neon-rs/load@0.0.4': {}
'@number-flow/svelte@0.3.9(svelte@5.43.8)':
dependencies:
esm-env: 1.2.2
number-flow: 0.5.8
svelte: 5.43.8
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@poppinss/colors@4.1.5': '@poppinss/colors@4.1.5':
@ -2849,6 +2866,10 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
number-flow@0.5.8:
dependencies:
esm-env: 1.2.2
once@1.4.0: once@1.4.0:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2

2
src/app.d.ts vendored
View File

@ -14,7 +14,7 @@ declare global {
} }
interface Locals { interface Locals {
user?: { user?: {
id: number; id: string;
email: string; email: string;
nickname: string; nickname: string;
}; };

View File

@ -1,7 +1,8 @@
import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-types'; import type { DurableObjectNamespace, DurableObjectState } from '@cloudflare/workers-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'; import { DB_SCHEMA, type UserRecord, type CurrentBetRecord } from './db-schema';
import { verifyToken } from './server/auth';
import type { BettingInfo, BetTypeKey } from './types';
// 게임 지속 시간 상수 (ms) // 게임 지속 시간 상수 (ms)
export const NO_MORE_BET_DURATION_MS = 15_000; // 15초 export const NO_MORE_BET_DURATION_MS = 15_000; // 15초
@ -11,11 +12,22 @@ export interface Env {
COUNTER: DurableObjectNamespace; COUNTER: DurableObjectNamespace;
} }
// 세션 인터페이스 (Durable Object용)
export interface Session {
id: string;
webSocket: WebSocket;
userId: string | undefined; // DB user.id (UUID)
nickname: string | undefined;
capital: number | undefined;
betInfo: BettingInfo[]; // 배팅 정보 배열
}
export class CounterDurableObject { 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 userSessions: Map<string, WebSocket>; // userId -> WebSocket 매핑
private sql: any; // SqlStorage 타입 private sql: any; // SqlStorage 타입
// 주사위 게임 상태 // 주사위 게임 상태
@ -32,6 +44,7 @@ export class CounterDurableObject {
this.ctx = ctx; this.ctx = ctx;
this.env = env; this.env = env;
this.sessions = new Map(); this.sessions = new Map();
this.userSessions = new Map();
// SQLite 초기화 // SQLite 초기화
this.sql = ctx.storage.sql; this.sql = ctx.storage.sql;
@ -121,6 +134,64 @@ export class CounterDurableObject {
} }
} }
if (url.pathname === '/sql/clear-users') {
// user 테이블 데이터 모두 삭제 (개발용)
if (request.method === 'POST') {
try {
this.sql.exec('DELETE FROM user');
return new Response(JSON.stringify({ success: true, message: 'All users deleted' }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error: any) {
return new Response(JSON.stringify({ error: error.message }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
}
}
if (url.pathname === '/sql/migrate-remove-nickname') {
// nickname 컬럼 제거 마이그레이션 (개발용)
if (request.method === 'POST') {
try {
// 1. 새 테이블 생성
this.sql.exec(`
CREATE TABLE IF NOT EXISTS user_new (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
joinGameCount INTEGER DEFAULT 0,
capital INTEGER DEFAULT 10000
)
`);
// 2. 데이터 복사 (nickname 제외)
this.sql.exec(`
INSERT INTO user_new (id, email, joinGameCount, capital)
SELECT id, email, joinGameCount, capital FROM user
`);
// 3. 기존 테이블 삭제
this.sql.exec('DROP TABLE user');
// 4. 새 테이블 이름 변경
this.sql.exec('ALTER TABLE user_new RENAME TO user');
return new Response(JSON.stringify({
success: true,
message: 'Migration completed: nickname column removed'
}), {
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') {
@ -135,17 +206,10 @@ export class CounterDurableObject {
const session: Session = { const session: Session = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
webSocket: server, webSocket: server,
userId: undefined,
nickname: undefined, nickname: undefined,
capital: undefined, capital: undefined,
oddBet: 0, betInfo: [] // 배팅 정보 배열
evenBet: 0,
bigBet: 0,
smallBet: 0,
oddResult: null,
evenResult: null,
bigResult: null,
smallResult: null,
lastWinAmount: 0
}; };
// WebSocket Hibernation API 사용 // WebSocket Hibernation API 사용
@ -153,11 +217,6 @@ 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();
@ -173,14 +232,78 @@ export class CounterDurableObject {
const data = typeof message === 'string' ? JSON.parse(message) : null; const data = typeof message === 'string' ? JSON.parse(message) : null;
const session = this.sessions.get(ws); const session = this.sessions.get(ws);
if (!session) return; if (!session) return;
if (data && data.type === 'setUser') { if (data && data.type === 'setUser') {
// 사용자 정보 설정 // JWT 토큰 검증
session.nickname = data.nickname; const token = data.token;
session.capital = data.capital; if (!token) {
this.broadcast(); console.error('Missing JWT token');
} else if (data && data.type === 'sqlQuery') { return;
}
try {
// JWT 토큰 검증 및 페이로드 추출
const payload = await verifyToken(token);
if (!payload) {
console.error('Invalid JWT token');
return;
}
const userId = payload.userId;
const email = payload.email;
// SQLite에서 사용자 정보 조회
let user = this.getUser(userId);
if (!user) {
// 사용자가 없으면 새로 생성 (초기 자본금 10000)
this.createOrUpdateUser(userId, email, 10000);
user = this.getUser(userId);
console.log(`New user created: ${email}, Capital: 10000`);
}
if (user) {
// 동일 사용자의 기존 WebSocket 연결 확인
const existingWs = this.userSessions.get(user.id);
if (existingWs && existingWs !== ws) {
// 기존 연결에 중복 로그인 알림 전송
try {
if (existingWs.readyState === WebSocket.OPEN || existingWs.readyState === 1) {
existingWs.send(JSON.stringify({
type: 'duplicateLogin',
message: '다른 브라우저에서 로그인되어 현재 연결이 종료됩니다.'
}));
}
// 기존 연결 종료
setTimeout(() => {
this.sessions.delete(existingWs);
// Cloudflare Workers에서는 close() 인자 없이 호출
try {
existingWs.close();
} catch (e) {
console.error('Error closing WebSocket:', e);
}
}, 500); // 메시지 전송 후 0.5초 대기
} catch (error) {
console.error('Error closing existing connection:', error);
}
}
// 세션에 사용자 정보 설정
session.userId = user.id;
session.nickname = user.email.split('@')[0]; // email에서 닉네임 추출
session.capital = user.capital;
// 새로운 연결을 userSessions에 등록
this.userSessions.set(user.id, ws);
console.log(`User loaded: ${user.email}, Capital: ${user.capital}`);
} this.broadcast();
} catch (error) {
console.error('Error verifying token:', error);
}
} else if (data && data.type === 'sqlQuery') {
// SQL 조회 요청 처리 // SQL 조회 요청 처리
try { try {
let result; let result;
@ -209,27 +332,57 @@ export class CounterDurableObject {
ws.send(JSON.stringify({ type: 'sqlError', error: error.message })); 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) {
// 배팅 처리 // 배팅 처리 - betInfo 배열에 추가 및 current_bet 테이블에 저장
const amount = data.amount || 1000; const amount = data.amount || 1000;
const betType = data.betType as BetTypeKey; // 'Odd', 'Even', 'Big', 'Small'
if (session.capital && session.capital >= amount) { if (!session.userId || session.capital === undefined) {
switch (data.betType) { console.error('User not set in session');
case 'odd': return;
session.oddBet += amount; }
break;
case 'even': if (!this.gameId) {
session.evenBet += amount; console.error('No active game');
break; return;
case 'big': }
session.bigBet += amount;
break; if (session.capital >= amount) {
case 'small': // 사용자 정보 조회 (userId로 직접 조회)
session.smallBet += amount; const user = this.getUser(session.userId);
break; if (!user) {
} console.error('User not found in database');
// 클라이언트 측에서 자본금 차감은 이미 되어있으므로 서버에서도 동기화 return;
} // 세션의 betInfo 배열에 추가
const bettingInfo: BettingInfo = {
gameId: this.gameId,
betType: betType,
betMoney: amount,
isWin: undefined,
winMoney: undefined,
valid: true
};
session.betInfo.push(bettingInfo);
// current_bet 테이블에 배팅 저장
const bet: CurrentBetRecord = {
gameId: this.gameId,
diceNum: 0, // 아직 주사위가 나오지 않음
userId: user.id,
betType: betType,
amount: amount,
isWin: 0,
reward: 0,
valid: 1
};
this.saveBet(bet);
// 자본금 차감
session.capital -= amount; session.capital -= amount;
this.broadcast(); // DB에도 자본금 업데이트
this.updateUserCapital(user.id, session.capital);
// 해당 사용자에게만 업데이트 전송 (배팅 정보, 자본금)
this.sendToSession(ws);
} }
} }
} catch (error) { } catch (error) {
@ -244,17 +397,76 @@ export class CounterDurableObject {
*/ */
private initializeDatabase() { private initializeDatabase() {
try { try {
this.sql.exec(DB_SCHEMA); // 기존 user 테이블에 nickname 컬럼이 있는지 확인
console.log('SQLite database initialized successfully'); const hasNickname = this.checkIfNicknameExists();
if (hasNickname) {
console.log('Migrating: Removing nickname column from user table...');
// 외래 키 체크 비활성화
this.sql.exec('PRAGMA foreign_keys = OFF');
// 기존 user_new 테이블이 있으면 삭제
try {
this.sql.exec('DROP TABLE IF EXISTS user_new');
} catch (e) {
// 무시
}
// nickname 컬럼 제거 마이그레이션
this.sql.exec(`
CREATE TABLE user_new (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
joinGameCount INTEGER DEFAULT 0,
capital INTEGER DEFAULT 10000
)
`);
this.sql.exec(`
INSERT INTO user_new (id, email, joinGameCount, capital)
SELECT id, email, joinGameCount, capital FROM user
`);
this.sql.exec('DROP TABLE user');
this.sql.exec('ALTER TABLE user_new RENAME TO user');
// 외래 키 체크 재활성화
this.sql.exec('PRAGMA foreign_keys = ON');
console.log('Migration completed: nickname column removed');
} else {
// 새 스키마로 테이블 생성
this.sql.exec(DB_SCHEMA);
console.log('SQLite database initialized successfully');
}
} catch (error) { } catch (error) {
console.error('Error initializing database:', error); console.error('Error initializing database:', error);
// 오류 발생 시 외래 키 체크 재활성화
try {
this.sql.exec('PRAGMA foreign_keys = ON');
} catch (e) {
// 무시
}
}
}
/**
* user nickname
*/
private checkIfNicknameExists(): boolean {
try {
const tableInfo = this.sql.exec("PRAGMA table_info(user)").toArray();
return tableInfo.some((col: any) => col.name === 'nickname');
} catch (error) {
return false;
} }
} }
/** /**
* *
*/ */
createOrUpdateUser(userId: string, nickname: string, email: string, capital?: number): void { createOrUpdateUser(userId: string, email: string, capital?: number): void {
try { try {
// 기존 사용자 확인 // 기존 사용자 확인
const existing = this.sql.exec( const existing = this.sql.exec(
@ -265,8 +477,7 @@ export class CounterDurableObject {
if (existing) { if (existing) {
// 업데이트 // 업데이트
this.sql.exec( this.sql.exec(
'UPDATE user SET nickname = ?, email = ?, capital = COALESCE(?, capital) WHERE id = ?', 'UPDATE user SET email = ?, capital = COALESCE(?, capital) WHERE id = ?',
nickname,
email, email,
capital, capital,
userId userId
@ -274,9 +485,8 @@ export class CounterDurableObject {
} else { } else {
// 새로운 사용자 생성 // 새로운 사용자 생성
this.sql.exec( this.sql.exec(
'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)', 'INSERT INTO user (id, email, capital) VALUES (?, ?, ?)',
userId, userId,
nickname,
email, email,
capital || 10000 capital || 10000
); );
@ -285,9 +495,8 @@ export class CounterDurableObject {
// 사용자가 없으면 생성 // 사용자가 없으면 생성
try { try {
this.sql.exec( this.sql.exec(
'INSERT INTO user (id, nickname, email, capital) VALUES (?, ?, ?, ?)', 'INSERT INTO user (id, email, capital) VALUES (?, ?, ?)',
userId, userId,
nickname,
email, email,
capital || 10000 capital || 10000
); );
@ -434,13 +643,127 @@ export class CounterDurableObject {
this.noMoreBetStartTime = Date.now(); this.noMoreBetStartTime = Date.now();
this.noMoreBetEndTime = this.noMoreBetStartTime + NO_MORE_BET_DURATION_MS; // 15초 this.noMoreBetEndTime = this.noMoreBetStartTime + NO_MORE_BET_DURATION_MS; // 15초
// 베팅 종료 로그
const now = new Date();
console.log(`[Betting Closed] GameID: ${this.gameId} | Time: ${now.toLocaleTimeString('ko-KR')} (${now.toISOString()})`);
// 3개의 주사위 랜덤 생성 (1-6) // 3개의 주사위 랜덤 생성 (1-6)
this.dice1 = Math.floor(Math.random() * 6) + 1; this.dice1 = Math.floor(Math.random() * 6) + 1;
this.dice2 = Math.floor(Math.random() * 6) + 1; this.dice2 = Math.floor(Math.random() * 6) + 1;
this.dice3 = Math.floor(Math.random() * 6) + 1; this.dice3 = Math.floor(Math.random() * 6) + 1;
// 추출된 함수로 배팅 결과 계산 및 세션 갱신 // 주사위 합계 계산
applyBetResults(this.sessions, this.dice1, this.dice2, this.dice3); const sum = this.dice1 + this.dice2 + this.dice3;
const isOdd = sum % 2 === 1;
const isBig = sum >= 10;
// 통계 변수
let totalBetAmount = 0;
let totalRewardAmount = 0;
// current_bet 테이블에서 현재 게임의 배팅 조회
if (this.gameId) {
const bets = this.getBetsByGameId(this.gameId);
// 각 배팅의 결과 계산 및 업데이트
bets.forEach((bet) => {
let isWin = 0;
let reward = 0;
// 배팅 타입에 따른 승패 판정
switch (bet.betType) {
case 'odd':
if (isOdd) {
isWin = 1;
reward = bet.amount * 2;
}
break;
case 'even':
if (!isOdd) {
isWin = 1;
reward = bet.amount * 2;
}
break;
case 'big':
if (isBig) {
isWin = 1;
reward = bet.amount * 2;
}
break;
case 'small':
if (!isBig) {
isWin = 1;
reward = bet.amount * 2;
}
break;
}
// 통계 누적
totalBetAmount += bet.amount;
totalRewardAmount += reward;
// diceNum 및 결과 업데이트
this.sql.exec(
'UPDATE current_bet SET diceNum = ?, isWin = ?, reward = ? WHERE id = ?',
sum,
isWin,
reward,
bet.id
);
// 승리 시 사용자 자본금 업데이트
if (isWin) {
const user = this.getUser(bet.userId);
if (user) {
this.updateUserCapital(bet.userId, user.capital + reward);
}
}
});
// 게임 결과 로그
const houseProfit = totalBetAmount - totalRewardAmount;
const resultType = `${isOdd ? 'ODD' : 'EVEN'}, ${isBig ? 'BIG' : 'SMALL'}`;
console.log(
`[Game Result] GameID: ${this.gameId} | Dice: [${this.dice1},${this.dice2},${this.dice3}] Sum: ${sum} (${resultType}) | ` +
`Total Bets: ${totalBetAmount.toLocaleString()}원 | Total Rewards: ${totalRewardAmount.toLocaleString()}원 | ` +
`House Profit: ${houseProfit >= 0 ? '+' : ''}${houseProfit.toLocaleString()}원 | Bet Count: ${bets.length}`
);
// 세션 betInfo 업데이트 (UI 표시용)
this.sessions.forEach((session) => {
if (!session.userId) return;
// 사용자 정보 조회
const user = this.getUser(session.userId);
if (!user) return;
// 현재 게임의 betInfo 업데이트
const userBets = bets.filter(b => b.userId === user.id);
userBets.forEach((bet) => {
const betResult = this.sql.exec(
'SELECT isWin, reward, betType FROM current_bet WHERE id = ?',
bet.id
).one();
// betInfo 배열에서 해당 배팅 찾기
const betInfoItem = session.betInfo.find(
b => b.gameId === this.gameId && b.betType === betResult.betType
);
if (betInfoItem) {
betInfoItem.isWin = betResult.isWin === 1;
betInfoItem.winMoney = betResult.reward;
}
});
// 자본금 업데이트 (DB에서 가져오기)
const updatedUser = this.getUser(user.id);
if (updatedUser) {
session.capital = updatedUser.capital;
}
});
}
// 상태 저장 // 상태 저장
this.ctx.storage.put('noMoreBet', this.noMoreBet); this.ctx.storage.put('noMoreBet', this.noMoreBet);
@ -472,17 +795,17 @@ export class CounterDurableObject {
this.dice2 = null; this.dice2 = null;
this.dice3 = null; this.dice3 = null;
// 모든 세션의 배팅 및 결과 클리어 // current_bet 테이블의 모든 데이터 삭제 (새 게임 시작)
try {
this.sql.exec('DELETE FROM current_bet');
console.log('Cleared all bets from current_bet table');
} catch (error) {
console.error('Error clearing current_bet table:', error);
}
// 모든 세션의 betInfo 클리어
this.sessions.forEach((session) => { this.sessions.forEach((session) => {
session.oddBet = 0; session.betInfo = [];
session.evenBet = 0;
session.bigBet = 0;
session.smallBet = 0;
session.oddResult = null;
session.evenResult = null;
session.bigResult = null;
session.smallResult = null;
session.lastWinAmount = 0;
}); });
// 상태 저장 // 상태 저장
@ -501,19 +824,71 @@ export class CounterDurableObject {
} }
async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean) { async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean) {
// 세션에서 userId 조회
const session = this.sessions.get(ws);
// 세션 종료 로그
if (session) {
const userInfo = session.userId
? `User: ${session.nickname || 'Unknown'} (${session.userId})`
: 'Guest (not authenticated)';
console.log(`[WebSocket Closed] ${userInfo} | Code: ${code} | Reason: ${_reason || 'No reason'} | Clean: ${_wasClean} | Remaining sessions: ${this.sessions.size - 1}`);
} else {
console.log(`[WebSocket Closed] Unknown session | Code: ${code}`);
}
if (session && session.userId) {
// userSessions에서 해당 userId의 연결이 현재 ws인 경우만 제거
if (this.userSessions.get(session.userId) === ws) {
this.userSessions.delete(session.userId);
}
}
// 세션 제거 // 세션 제거
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();
} }
/**
* (, )
*/
private sendToSession(ws: WebSocket) {
try {
if (ws.readyState !== WebSocket.OPEN && ws.readyState !== 1) {
return;
}
const session = this.sessions.get(ws);
if (!session) return;
// DB에서 최신 capital 조회
let currentCapital = session.capital;
if (session.userId) {
const user = this.getUser(session.userId);
if (user) {
currentCapital = user.capital;
session.capital = user.capital;
}
}
const message = JSON.stringify({
type: 'personalUpdate',
capital: currentCapital,
betInfo: session.betInfo || [],
});
ws.send(message);
} catch (error) {
console.error('Error sending to session:', error);
}
}
/**
* (, , )
* (capital, betInfo)
*/
private broadcast() { private broadcast() {
// WebSocket Hibernation API를 사용할 때는 getWebSockets()로 실제 연결 수를 확인 // WebSocket Hibernation API를 사용할 때는 getWebSockets()로 실제 연결 수를 확인
const connectedWebSockets = this.ctx.getWebSockets(); const connectedWebSockets = this.ctx.getWebSockets();
@ -522,7 +897,23 @@ export class CounterDurableObject {
// @ts-ignore - Cloudflare Workers types 불일치 // @ts-ignore - Cloudflare Workers types 불일치
connectedWebSockets.forEach((ws: WebSocket) => { connectedWebSockets.forEach((ws: WebSocket) => {
try { try {
// WebSocket이 OPEN 상태인지 확인
if (ws.readyState !== WebSocket.OPEN && ws.readyState !== 1) {
return;
}
const session = this.sessions.get(ws); const session = this.sessions.get(ws);
if (!session) return;
// DB에서 최신 capital 조회 (세션 메모리와 DB 동기화)
let currentCapital = session.capital;
if (session.userId) {
const user = this.getUser(session.userId);
if (user) {
currentCapital = user.capital;
session.capital = user.capital; // 세션도 동기화
}
}
const message = JSON.stringify({ const message = JSON.stringify({
noMoreBet: this.noMoreBet, noMoreBet: this.noMoreBet,
@ -531,17 +922,14 @@ export class CounterDurableObject {
dice3: this.dice3, dice3: this.dice3,
noMoreBetStartTime: this.noMoreBetStartTime, noMoreBetStartTime: this.noMoreBetStartTime,
noMoreBetEndTime: this.noMoreBetEndTime, noMoreBetEndTime: this.noMoreBetEndTime,
online: connectedWebSockets.length, online: this.sessions.size,
gameId: this.gameId,
// 세션별 사용자 정보 (DB 최신 값)
nickname: session.nickname,
capital: currentCapital,
// 세션별 배팅 정보 // 세션별 배팅 정보
capital: session?.capital, betInfo: session.betInfo || [],
oddResult: session?.oddResult,
evenResult: session?.evenResult,
bigResult: session?.bigResult,
smallResult: session?.smallResult,
lastWinAmount: session?.lastWinAmount,
}); });
ws.send(message); ws.send(message);
} catch (error) { } catch (error) {
console.error('Error broadcasting to client:', error); console.error('Error broadcasting to client:', error);

View File

@ -4,7 +4,6 @@ export const DB_SCHEMA = `
-- User 테이블: 사용자 -- User 테이블: 사용자
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
nickname TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
joinGameCount INTEGER DEFAULT 0, joinGameCount INTEGER DEFAULT 0,
capital INTEGER DEFAULT 10000 capital INTEGER DEFAULT 10000
@ -31,7 +30,6 @@ export const DB_SCHEMA = `
// 타입 정의 // 타입 정의
export interface UserRecord { export interface UserRecord {
id: string; id: string;
nickname: string;
email: string; email: string;
joinGameCount: number; joinGameCount: number;
capital: number; capital: number;
@ -42,9 +40,10 @@ export interface CurrentBetRecord {
gameId: string; gameId: string;
diceNum: number; diceNum: number;
userId: string; userId: string;
betType: string; betType: string; // BetTypeKey를 문자열로 저장
amount: number; amount: number;
isWin: number; // SQLite는 boolean이 없어서 0 또는 1 isWin: number; // SQLite는 boolean이 없어서 0 또는 1
reward: number; reward: number;
valid?: number; // 유효 배팅 여부 (0 또는 1)
} }

View File

@ -19,7 +19,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
return passwordHash === hash; return passwordHash === hash;
} }
export async function createToken(payload: { userId: number; email: string }): Promise<string> { export async function createToken(payload: { userId: string; email: string }): Promise<string> {
return await new SignJWT({ ...payload }) return await new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setIssuedAt() .setIssuedAt()
@ -27,10 +27,10 @@ export async function createToken(payload: { userId: number; email: string }): P
.sign(JWT_SECRET); .sign(JWT_SECRET);
} }
export async function verifyToken(token: string): Promise<{ userId: number; email: string } | null> { export async function verifyToken(token: string): Promise<{ userId: string; email: string } | null> {
try { try {
const { payload } = await jwtVerify(token, JWT_SECRET); const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as { userId: number; email: string }; return payload as { userId: string; email: string };
} catch { } catch {
return null; return null;
} }

View File

@ -17,7 +17,11 @@ export async function createUser(
try { try {
const drizzleDb = getDb(db); const drizzleDb = getDb(db);
// UUID 생성
const userId = crypto.randomUUID();
const newUser: NewUser = { const newUser: NewUser = {
id: userId,
email, email,
passwordHash, passwordHash,
nickname nickname
@ -56,7 +60,7 @@ export async function getUserByEmail(db: D1Database, email: string): Promise<Use
} }
} }
export async function getUserById(db: D1Database, id: number): Promise<User | null> { export async function getUserById(db: D1Database, id: string): Promise<User | null> {
try { try {
const drizzleDb = getDb(db); const drizzleDb = getDb(db);

View File

@ -2,7 +2,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
export const users = sqliteTable('users', { export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }), id: text('id').primaryKey(),
email: text('email').notNull().unique(), email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(), passwordHash: text('password_hash').notNull(),
nickname: text('nickname').notNull(), nickname: text('nickname').notNull(),

View File

@ -1,18 +1,9 @@
export interface Session { export interface Session {
id: string; id: string | null;
token?: string; token: string | null;
webSocket: WebSocket; webSocket: WebSocket;
nickname?: string; nickname: string | null;
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) betInfo?: BettingInfo[]; // 배팅 정보 (기존 호환성을 위해 optional)
} }

View File

@ -1,8 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals, cookies }) => {
// 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트
if (!locals.user) {
throw redirect(303, '/login?redirectTo=/');
}
// JWT 토큰 가져오기
const authToken = cookies.get('auth_token');
return { return {
user: locals.user user: locals.user,
authToken
}; };
}; };

View File

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import NumberFlow from '@number-flow/svelte';
let { data } = $props(); let { data } = $props();
let online = $state(0); let online = $state(0);
@ -8,12 +11,27 @@
// 사용자 정보 // 사용자 정보
let nickname = $state(''); let nickname = $state('');
let capital = $state(10000); // 초기 자본금 let capital = $state(0);
let inputNickname = $state('');
let inputCapital = $state('10000'); // 게임 ID
let gameId = $state<string | null>(null);
// 베팅 정보
let betInfo = $state<any[]>([]);
// WebSocket 수동 종료 플래그 (의도적 종료 vs 에러 종료 구분)
let intentionalClose = $state(false);
// 중복 로그인 감지 상태
let isDuplicateLogin = $state(false);
// 로그아웃 함수 // 로그아웃 함수
async function logout() { async function logout() {
intentionalClose = true;
if (ws) {
ws.close();
ws = null;
}
await fetch('/api/logout', { method: 'POST' }); await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login'; window.location.href = '/login';
} }
@ -51,9 +69,14 @@
let remainingTime = $state(0); let remainingTime = $state(0);
function connectWebSocket() { function connectWebSocket() {
// 이미 연결 중이거나 연결되어 있으면 중복 연결 방지
if (ws || isConnecting || isConnected) {
console.log('WebSocket already connecting or connected, skipping...');
return;
}
isConnecting = true; isConnecting = true;
nickname = inputNickname; console.log('Starting WebSocket connection...');
capital = parseInt(inputCapital);
// WebSocket 프로토콜 결정 (https -> wss, http -> ws) // WebSocket 프로토콜 결정 (https -> wss, http -> ws)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@ -64,14 +87,13 @@
ws.onopen = () => { ws.onopen = () => {
isConnected = true; isConnected = true;
isConnecting = false; isConnecting = false;
console.log('WebSocket connected'); console.log('WebSocket connected successfully');
// 서버에 사용자 정보 전송 // 서버에 JWT 토큰 전송
if (ws) { if (ws && data.authToken) {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'setUser', type: 'setUser',
nickname: nickname, token: data.authToken
capital: capital
})); }));
} }
}; };
@ -79,6 +101,31 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// 중복 로그인 감지 처리
if (data.type === 'duplicateLogin') {
isDuplicateLogin = true;
intentionalClose = true;
if (ws) {
ws.close();
ws = null;
}
isConnected = false;
return;
}
// 개인 업데이트 처리 (배팅 후 자본금, betInfo)
if (data.type === 'personalUpdate') {
if (data.capital !== undefined) {
capital = data.capital;
}
if (data.betInfo !== undefined) {
betInfo = data.betInfo;
}
return;
}
// 전체 브로드캐스트 (게임 상태)
noMoreBet = data.noMoreBet; noMoreBet = data.noMoreBet;
dice1 = data.dice1; dice1 = data.dice1;
dice2 = data.dice2; dice2 = data.dice2;
@ -86,11 +133,18 @@
noMoreBetStartTime = data.noMoreBetStartTime; noMoreBetStartTime = data.noMoreBetStartTime;
noMoreBetEndTime = data.noMoreBetEndTime; noMoreBetEndTime = data.noMoreBetEndTime;
online = data.online; online = data.online;
gameId = data.gameId ?? null;
// 배팅 결과 // 사용자 정보 업데이트
if (data.nickname !== undefined) {
nickname = data.nickname;
}
if (data.capital !== undefined) { if (data.capital !== undefined) {
capital = data.capital; capital = data.capital;
} }
if (data.betInfo !== undefined) {
betInfo = data.betInfo;
}
if (data.oddResult !== undefined) oddResult = data.oddResult; if (data.oddResult !== undefined) oddResult = data.oddResult;
if (data.evenResult !== undefined) evenResult = data.evenResult; if (data.evenResult !== undefined) evenResult = data.evenResult;
if (data.bigResult !== undefined) bigResult = data.bigResult; if (data.bigResult !== undefined) bigResult = data.bigResult;
@ -123,10 +177,11 @@
isConnecting = false; isConnecting = false;
}; };
ws.onclose = () => { ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
isConnected = false; isConnected = false;
isConnecting = false; isConnecting = false;
console.log('WebSocket disconnected'); ws = null;
}; };
} }
@ -206,7 +261,23 @@
return patterns[number] || patterns[1]; return patterns[number] || patterns[1];
} }
// 카운트다운 타이머 업데이트 // 페이지 로드 시 자동으로 WebSocket 연결 (한 번만)
onMount(() => {
if (data.user) {
connectWebSocket();
}
// 컴포넌트 언마운트 시 WebSocket 정리
return () => {
intentionalClose = true;
if (ws) {
ws.close();
ws = null;
}
};
});
// 카운트다운 타이머
$effect(() => { $effect(() => {
let interval: ReturnType<typeof setInterval> | null = null; let interval: ReturnType<typeof setInterval> | null = null;
@ -225,17 +296,36 @@
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
}; };
}); });
// 컴포넌트 언마운트 시 WebSocket 정리
$effect(() => {
return () => {
disconnectWebSocket();
};
});
</script> </script>
<!-- 중복 로그인 오버레이 -->
{#if isDuplicateLogin}
<div class="fixed inset-0 bg-white bg-opacity-50 backdrop-blur-sm flex items-center justify-center z-50">
<div class="bg-white rounded-2xl shadow-2xl p-8 max-w-md mx-4 border-4 border-red-500">
<div class="text-center">
<div class="mb-4 text-red-500">
<svg class="w-20 h-20 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">세션 종료</h2>
<p class="text-gray-600 mb-6">
새로운 장치에서 로그인되어<br/>
현재 세션이 종료되었습니다.
</p>
<button
onclick={() => window.location.reload()}
class="px-6 py-3 bg-white hover:bg-gray-100 text-black border-2 border-black rounded-lg font-semibold transition"
>
새로고침
</button>
</div>
</div>
</div>
{/if}
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-pink-100 flex items-center justify-center p-4"> <div class="min-h-screen bg-gradient-to-br from-purple-50 to-pink-100 flex items-center justify-center p-4">
<div class="max-w-3xl w-full bg-white rounded-2xl shadow-xl p-8"> <div class="max-w-3xl w-full bg-white rounded-2xl shadow-xl p-8" class:opacity-30={isDuplicateLogin} class:pointer-events-none={isDuplicateLogin}>
<!-- 로그인 상태 표시 --> <!-- 로그인 상태 표시 -->
{#if data.user} {#if data.user}
<div class="mb-4 flex justify-between items-center bg-blue-50 p-4 rounded-lg"> <div class="mb-4 flex justify-between items-center bg-blue-50 p-4 rounded-lg">
@ -243,12 +333,24 @@
<p class="text-sm text-gray-600">로그인됨</p> <p class="text-sm text-gray-600">로그인됨</p>
<p class="font-semibold text-gray-800">{data.user.nickname} ({data.user.email})</p> <p class="font-semibold text-gray-800">{data.user.nickname} ({data.user.email})</p>
</div> </div>
<button <div class="flex gap-2 items-center">
onclick={logout} <div class="px-4 py-2 bg-white text-black border-2 border-black rounded-lg font-semibold flex items-center gap-2">
class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition" <div class="w-2 h-2 rounded-full bg-black {isConnected ? 'animate-pulse' : 'opacity-50'}"></div>
> <span>{online}명 접속</span>
로그아웃 </div>
</button> <a
href="/profile"
class="px-4 py-2 bg-white hover:bg-gray-100 text-black border-2 border-black rounded-lg transition"
>
프로필 수정
</a>
<button
onclick={logout}
class="px-4 py-2 bg-white hover:bg-gray-100 text-black border-2 border-black rounded-lg transition"
>
로그아웃
</button>
</div>
</div> </div>
{:else} {:else}
<div class="mb-4 bg-yellow-50 p-4 rounded-lg text-center"> <div class="mb-4 bg-yellow-50 p-4 rounded-lg text-center">
@ -259,118 +361,20 @@
</div> </div>
{/if} {/if}
<h1 class="text-4xl font-bold text-center mb-8 text-gray-800"> {#if gameId}
Durable Objects TEST <div class="text-center mb-6 text-sm text-gray-600">현재 게임 ID: <span class="font-mono font-semibold">{gameId}</span></div>
</h1> {/if}
<!-- 연결 상태, 접속자 수, 자본금 (3 columns) -->
<div class="grid grid-cols-3 gap-4 mb-8">
<!-- 연결 상태 -->
<div
class="p-4 rounded-lg {isConnected ? 'bg-green-50 border-2 border-green-200' : 'bg-gray-50 border-2 border-gray-200'}">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full {isConnected ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}"></div>
<span class="font-medium text-gray-700">
{isConnected ? '연결됨' : isConnecting ? '연결 중...' : '연결 안됨'}
</span>
</div>
{#if !isConnected}
<input
type="text"
bind:value={inputNickname}
placeholder="닉네임"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="number"
bind:value={inputCapital}
placeholder="초기 자본금"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onclick={connectWebSocket}
disabled={isConnecting || !inputNickname || !inputCapital}
class="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? '연결 중...' : '연결하기'}
</button>
{:else}
<button
onclick={disconnectWebSocket}
class="w-full px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
연결 끊기
</button>
{/if}
</div>
</div>
<!-- 접속자 수 -->
<div
class="bg-gradient-to-br from-green-50 to-emerald-50 p-4 rounded-lg border-2 border-green-200 flex flex-col items-center justify-center">
<div class="text-4xl font-bold text-green-600 mb-1">
{online}
</div>
<div class="text-sm text-gray-600">실시간 접속자</div>
</div>
<!-- 현재 자본금 -->
<div
class="bg-gradient-to-br from-amber-50 to-yellow-50 p-4 rounded-lg border-2 border-amber-200 flex flex-col items-center justify-center">
{#if isConnected && nickname}
<div class="text-xs text-gray-600 mb-1">{nickname}</div>
<div class="text-3xl font-bold text-amber-700 mb-1">{capital.toLocaleString()}</div>
<div class="text-xs text-gray-600">현재 자본금</div>
{#if lastWinAmount !== 0}
<div class="text-sm font-semibold mt-1 {lastWinAmount > 0 ? 'text-green-600' : 'text-red-600'}">
{lastWinAmount > 0 ? '+' : ''}{lastWinAmount.toLocaleString()}
</div>
{/if}
{:else}
<div class="text-2xl font-bold text-gray-400">-</div>
<div class="text-xs text-gray-600">자본금</div>
{/if}
</div>
</div>
<!-- 카운트다운 타이머 -->
<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="flex flex-col items-center justify-center gap-4">
<div class="text-xl font-semibold text-gray-700">
{noMoreBet ? '🚫 베팅 마감' : '✅ 베팅 가능'}
</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>
<!-- 주사위 디스플레이 --> <!-- 주사위 디스플레이 -->
{#if dice1 !== null && dice2 !== null && dice3 !== null} {#if dice1 !== null && dice2 !== null && dice3 !== null}
<div class="mb-8 p-6 bg-gradient-to-br from-yellow-50 to-orange-50 rounded-xl border-2 border-yellow-200"> <div class="mb-6 p-4 bg-gradient-to-br from-yellow-50 to-orange-50 rounded-xl border border-yellow-200">
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">
🎲 결과
</h2>
<!-- 3개의 주사위 --> <!-- 3개의 주사위 -->
<div class="flex justify-center gap-6 mb-6"> <div class="flex justify-center gap-4 mb-4">
{#each [dice1, dice2, dice3] as dice} {#each [dice1, dice2, dice3] as dice}
<div class="relative"> <div class="relative">
<div class="w-24 h-24 bg-white rounded-2xl shadow-2xl border-4 border-gray-800 p-3"> <div class="w-12 h-12 bg-white rounded-xl shadow-md border-2 border-gray-700 p-1">
<div class="grid grid-cols-3 gap-1 h-full"> <div class="grid grid-cols-3 gap-0.5 h-full">
{#each getDiceDots(dice) as row} {#each getDiceDots(dice) as row}
{#each row as dot} {#each row as dot}
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@ -382,95 +386,51 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="absolute -top-2 -right-2 w-8 h-8 bg-red-500 text-white <div class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white
rounded-full flex items-center justify-center text-sm font-bold shadow-lg"> rounded-full flex items-center justify-center text-xs font-bold shadow-lg">
{dice} {dice}
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
<!-- 합계 -->
<div class="text-center mb-4">
<div class="text-4xl font-bold text-indigo-600 mb-2">
합계: {dice1 + dice2 + dice3}
</div>
<div class="flex justify-center gap-4 text-2xl font-semibold">
<span class="{(dice1 + dice2 + dice3) % 2 === 1 ? 'text-blue-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) % 2 === 1 ? '✓ 홀수' : '홀수'}
</span>
<span class="{(dice1 + dice2 + dice3) % 2 === 0 ? 'text-pink-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) % 2 === 0 ? '✓ 짝수' : '짝수'}
</span>
</div>
<div class="flex justify-center gap-4 text-2xl font-semibold mt-2">
<span class="{(dice1 + dice2 + dice3) >= 10 ? 'text-orange-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) >= 10 ? '✓ 대' : '대'}
</span>
<span class="{(dice1 + dice2 + dice3) <= 9 ? 'text-purple-600' : 'text-gray-400'}">
{(dice1 + dice2 + dice3) <= 9 ? '✓ 소' : '소'}
</span>
</div>
</div>
<!-- 배팅 결과 표시 -->
{#if oddBet > 0 || evenBet > 0 || bigBet > 0 || smallBet > 0}
<div class="grid grid-cols-2 gap-3 mb-4">
{#if oddBet > 0}
<div
class="p-3 rounded-lg {oddResult === 'win' ? 'bg-green-100 border-2 border-green-300' : oddResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">홀수 배팅</div>
<div class="text-lg font-bold">{oddBet.toLocaleString()}</div>
{#if oddResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{oddBet.toLocaleString()}</div>
{:else if oddResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
{#if evenBet > 0}
<div
class="p-3 rounded-lg {evenResult === 'win' ? 'bg-green-100 border-2 border-green-300' : evenResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">짝수 배팅</div>
<div class="text-lg font-bold">{evenBet.toLocaleString()}</div>
{#if evenResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{evenBet.toLocaleString()}</div>
{:else if evenResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
{#if bigBet > 0}
<div
class="p-3 rounded-lg {bigResult === 'win' ? 'bg-green-100 border-2 border-green-300' : bigResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">대 배팅</div>
<div class="text-lg font-bold">{bigBet.toLocaleString()}</div>
{#if bigResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{bigBet.toLocaleString()}</div>
{:else if bigResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
{#if smallBet > 0}
<div
class="p-3 rounded-lg {smallResult === 'win' ? 'bg-green-100 border-2 border-green-300' : smallResult === 'lose' ? 'bg-red-100 border-2 border-red-300' : 'bg-gray-100 border-2 border-gray-300'}">
<div class="text-sm text-gray-600">소 배팅</div>
<div class="text-lg font-bold">{smallBet.toLocaleString()}</div>
{#if smallResult === 'win'}
<div class="text-green-600 font-semibold">✓ 승리 +{smallBet.toLocaleString()}</div>
{:else if smallResult === 'lose'}
<div class="text-red-600 font-semibold">✗ 패배</div>
{/if}
</div>
{/if}
</div>
{/if}
</div> </div>
{/if} {/if}
<!-- 카운트다운 타이머 -->
<div class="mb-6 p-4 bg-white rounded-xl">
<div class="flex flex-col items-center justify-center gap-4">
<div class="text-sm font-semibold text-gray-700">
{noMoreBet ? '🚫 No more bet' : '✅ Place your bet'}
</div>
<!-- 카운트다운 숫자 (축소) -->
<div class="text-green-700 font-bold" style="font-size:20px;">
<NumberFlow value={remainingTime} />s
</div>
</div>
</div> <!-- 접속자 수 & 자본금 (2 columns) -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="p-4 rounded-lg bg-green-50 border border-green-200 flex flex-col items-center justify-center">
<div class="flex items-center gap-2 mb-2">
<div class="w-2.5 h-2.5 rounded-full {isConnected ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}"></div>
<span class="text-xs text-gray-600">{isConnected ? '연결됨' : '연결 대기'}</span>
</div>
<div class="text-3xl font-bold text-green-600"><NumberFlow value={online} /></div>
<div class="text-xs text-gray-600 mt-1">실시간 접속자</div>
</div>
<div class="p-4 rounded-lg bg-amber-50 border border-amber-200 flex flex-col items-center justify-center">
<div class="text-xs text-gray-600 mb-1">{nickname || '---'}</div>
<div class="text-3xl font-bold text-amber-700"><NumberFlow value={capital} /></div>
<div class="text-xs text-gray-600 mt-1">현재 자본금</div>
{#if betInfo && betInfo.length > 0}
<div class="text-xs mt-1 text-gray-500">배팅 <NumberFlow value={betInfo.length} /></div>
{/if}
</div>
</div>
<!-- 베팅 버튼 --> <!-- 베팅 버튼 -->
<div class="mb-8 p-6 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl border-2 border-blue-200"> <div class="mb-6 p-4 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl border border-blue-200">
<h2 class="text-xl font-bold text-center mb-4 text-gray-800"> <h2 class="text-xl font-bold text-center mb-4 text-gray-800">
배팅 선택 (1000원씩 배팅) 배팅 선택 (1000원씩 배팅)
</h2> </h2>
@ -486,7 +446,7 @@
> >
홀수 홀수
{#if oddBet > 0} {#if oddBet > 0}
<div class="text-sm mt-1">{oddBet.toLocaleString()}</div> <div class="text-sm mt-1"><NumberFlow value={oddBet} /></div>
{/if} {/if}
</button> </button>
<button <button
@ -498,31 +458,31 @@
> >
짝수 짝수
{#if evenBet > 0} {#if evenBet > 0}
<div class="text-sm mt-1">{evenBet.toLocaleString()}</div> <div class="text-sm mt-1"><NumberFlow value={evenBet} /></div>
{/if} {/if}
</button> </button>
<button <button
onclick={betBig} onclick={betBig}
disabled={!isConnected || noMoreBet || capital < 1000} disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-orange-500 text-white rounded-xl font-bold text-xl class="px-4 py-4 bg-white text-black border-2 border-black rounded-xl font-bold text-xl
hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105" transition-all transform hover:scale-105"
> >
대 (大) 대 (大)
{#if bigBet > 0} {#if bigBet > 0}
<div class="text-sm mt-1">{bigBet.toLocaleString()}</div> <div class="text-sm mt-1"><NumberFlow value={bigBet} /></div>
{/if} {/if}
</button> </button>
<button <button
onclick={betSmall} onclick={betSmall}
disabled={!isConnected || noMoreBet || capital < 1000} disabled={!isConnected || noMoreBet || capital < 1000}
class="px-4 py-4 bg-purple-500 text-white rounded-xl font-bold text-xl class="px-4 py-4 bg-white text-black border-2 border-black rounded-xl font-bold text-xl
hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed
transition-all transform hover:scale-105" transition-all transform hover:scale-105"
> >
소 (小) 소 (小)
{#if smallBet > 0} {#if smallBet > 0}
<div class="text-sm mt-1">{smallBet.toLocaleString()}</div> <div class="text-sm mt-1"><NumberFlow value={smallBet} /></div>
{/if} {/if}
</button> </button>
</div> </div>
@ -534,6 +494,33 @@
{/if} {/if}
</div> </div>
<!-- 나의 베팅 내역 -->
<div class="mb-6 p-4 bg-gradient-to-br from-gray-50 to-slate-100 rounded-xl border border-gray-300">
<h2 class="text-lg font-bold mb-3 text-gray-700">나의 베팅 내역</h2>
{#if betInfo.length === 0}
<p class="text-sm text-gray-500">아직 배팅 기록이 없습니다.</p>
{:else}
<ul class="space-y-2 text-sm">
{#each betInfo.slice().reverse() as b, i}
<li class="flex justify-between items-center bg-white rounded-md shadow-sm px-3 py-2 border border-gray-200">
<div class="flex flex-col">
<span class="font-mono text-xs text-gray-500">{b.gameId.slice(0,8)}...</span>
<span class="font-semibold">{b.betType}</span>
</div>
<div class="text-right">
<div class="text-gray-700"><NumberFlow value={b.betMoney} /></div>
{#if b.isWin !== undefined}
<div class="{b.isWin ? 'text-green-600' : 'text-red-600'} font-medium">
{b.isWin ? `+` : ''}{#if b.isWin}<NumberFlow value={(b.winMoney||0) - b.betMoney} />{:else}-{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ import { verifyPassword, createToken } from '$lib/server/auth';
import { getUserByEmail } from '$lib/server/db'; import { getUserByEmail } from '$lib/server/db';
export const actions = { export const actions = {
default: async ({ request, platform, cookies }) => { default: async ({ request, platform, cookies, url }) => {
const data = await request.formData(); const data = await request.formData();
const email = data.get('email'); const email = data.get('email');
const password = data.get('password'); const password = data.get('password');
@ -46,7 +46,9 @@ export const actions = {
maxAge: 60 * 60 * 24 * 7 // 7 days maxAge: 60 * 60 * 24 * 7 // 7 days
}); });
throw redirect(303, '/'); // redirectTo 쿼리 파라미터가 있으면 해당 페이지로, 없으면 메인 페이지로
const redirectTo = url.searchParams.get('redirectTo') || '/';
throw redirect(303, redirectTo);
} }
} satisfies Actions; } satisfies Actions;

View File

@ -0,0 +1,63 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getUserById } from '$lib/server/db';
export const load: PageServerLoad = async ({ locals, platform }) => {
// 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트
if (!locals.user) {
throw redirect(303, '/login?redirectTo=/profile');
}
// D1 데이터베이스에서 사용자 정보 조회
if (platform?.env?.DB) {
const user = await getUserById(platform.env.DB, locals.user.id);
if (user) {
return {
user: {
id: user.id,
email: user.email,
nickname: user.nickname
}
};
}
}
return {
user: locals.user
};
};
export const actions = {
default: async ({ request, platform, locals, cookies }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
const data = await request.formData();
const nickname = data.get('nickname');
// 입력값 검증
if (!nickname || typeof nickname !== 'string' || nickname.length < 2) {
return fail(400, { nickname: '', weak: true });
}
if (!platform?.env?.DB) {
return fail(500, { error: 'Database not available' });
}
try {
// 닉네임 업데이트
await platform.env.DB.prepare('UPDATE users SET nickname = ? WHERE id = ?')
.bind(nickname, locals.user.id)
.run();
// locals.user 업데이트
locals.user.nickname = nickname;
return { success: true, nickname };
} catch (error) {
console.error('Error updating nickname:', error);
return fail(500, { error: 'Failed to update nickname' });
}
}
} satisfies Actions;

View File

@ -0,0 +1,74 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
</script>
<svelte:head>
<title>프로필 수정 - DD Game</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-900">
<div class="bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-md">
<h1 class="text-3xl font-bold text-center text-white mb-8">프로필 수정</h1>
<form method="POST" use:enhance class="space-y-6">
{#if form?.success}
<div class="bg-green-500/20 border border-green-500 text-green-200 px-4 py-3 rounded">
닉네임이 성공적으로 변경되었습니다!
</div>
{/if}
{#if form?.error}
<div class="bg-red-500/20 border border-red-500 text-red-200 px-4 py-3 rounded">
{form.error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">
이메일
</label>
<input
type="email"
id="email"
value={data.user.email}
disabled
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-400 cursor-not-allowed"
/>
<p class="mt-1 text-xs text-gray-400">이메일은 변경할 수 없습니다.</p>
</div>
<div>
<label for="nickname" class="block text-sm font-medium text-gray-300 mb-2">
닉네임
</label>
<input
type="text"
id="nickname"
name="nickname"
required
value={form?.nickname ?? data.user.nickname}
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white"
placeholder="닉네임을 입력하세요"
/>
{#if form?.weak}
<p class="mt-1 text-sm text-red-400">닉네임은 2자 이상이어야 합니다.</p>
{/if}
</div>
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-lg transition duration-200"
>
저장
</button>
</form>
<div class="mt-6 text-center space-y-2">
<a href="/" class="block text-blue-400 hover:text-blue-300 text-sm">
메인 페이지로 돌아가기
</a>
</div>
</div>
</div>

View File

@ -1,7 +1,22 @@
<script> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
let { form } = $props(); let { form } = $props();
let email = $state(form?.email ?? '');
let nickname = $state('');
// 이메일 변경 시 닉네임 자동 설정
function handleEmailChange(event: Event) {
const target = event.target as HTMLInputElement;
email = target.value;
// @ 앞부분만 추출하여 닉네임 설정
const atIndex = email.indexOf('@');
if (atIndex > 0 && !nickname) {
nickname = email.substring(0, atIndex);
}
}
</script> </script>
<svelte:head> <svelte:head>
@ -34,7 +49,8 @@
id="email" id="email"
name="email" name="email"
required required
value={form?.email ?? ''} value={email}
oninput={handleEmailChange}
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white" class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white"
placeholder="your@email.com" placeholder="your@email.com"
/> />
@ -52,13 +68,14 @@
id="nickname" id="nickname"
name="nickname" name="nickname"
required required
value={form?.nickname ?? ''} bind:value={nickname}
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white" class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-white"
placeholder="닉네임을 입력하세요" placeholder="닉네임을 입력하세요"
/> />
{#if form?.weak && form?.field === 'nickname'} {#if form?.weak && form?.field === 'nickname'}
<p class="mt-1 text-sm text-red-400">닉네임은 2자 이상이어야 합니다.</p> <p class="mt-1 text-sm text-red-400">닉네임은 2자 이상이어야 합니다.</p>
{/if} {/if}
<p class="mt-1 text-xs text-gray-400">이메일 입력 시 자동으로 설정됩니다.</p>
</div> </div>
<div> <div>